I wanted to setup my own Pomodoro app. This was not too hard; I am using Nicegui for such stuff. The thing that was hard for me was a visual representation; particularly since Nicegui does not provide something usable out of the box.
After searching around a bit, I first found their example creating a visual clock and updating it every second. With that in mind, I decided to understand the SVG they were drawing and to update it according to my needs. And I can say, it worked out! My result looks something like this:
Countdown running | Empty clock |
---|---|
For the left image, the concept is that about three quarters of the time are still remaining.
I would like to write down what I learned on the way.
The SVG elements
The image I drew is based on this file from Wikipedia, available via the Creative Common License.
I will discuss multiple aspects of the file here.
Image Size
I kept most of it as it was there, like the size: My image is $800 \times 800$ pixels. This is important to keep fixed, as the defined coordinates build on this one.
I had to do a bit of a redefinition from their setup: They used the width
and height
part, but I changed that and used the viewBox
instead. That way, I could scale in my application as required but keep their coordinates.
Background: A white circle
This one was easy. Start with a white circle that will be the background of the clock. In case you want to see this background here in my blog, use the dark mode - as my background is white in the light theme…
In SVG, a circle is really easy:
<circle cx="400" cy="400" r="400" fill="#fff"></circle>
We define the coordinates of the center (cx
and cy
), we define a radius r
and we define a color (fill
). As easy as that.
Markers
These were the most elaborate part to be drawn. Thankfully, I did not have to learn that myself; the template provided these for me. But I understood what happened and I want to write that down.
The code is a set of nested <g>
elements. Note the id
s of each element, as I will reference these.
<!-- #c: All bars from 12:00 up to 12:30 -->
<g id="c">
<!-- #d: All bars from 12:00 up to 12:15 -->
<g id="d">
<!-- 12:00 bar -->
<path d="m400 40v107" stroke="#000" stroke-width="26.7" />
<!-- #a: All bars from 12:01 up to 12:05 -->
<g id="a">
<!-- 12:05 bar -->
<path d="m580 88.233-42.5 73.612" stroke="#000" stroke-width="26.7"/>
<!-- #e: Two small bars for 12:01 and 12:02 -->
<g id="e">
<!-- 12:01 bar -->
<path id="b" d="m437.63 41.974-3.6585 34.808" stroke="#000" stroke-width="13.6" />
<!-- Copy the 12:01 bar to 12:02 -->
<use transform="rotate(6 400 400)" xlink:href="#b" />
</g>
<!-- Copy the 12:01 and 12:02 bars to 12:03 and 12:04 -->
<use transform="rotate(12 400 400)" xlink:href="#e" />
</g>
<!-- Add 12:06 up to 12:10 bars -->
<use transform="rotate(30 400 400)" xlink:href="#a" />
<!-- Add 12:11 up to 12:15 bars -->
<use transform="rotate(60 400 400)" xlink:href="#a" />
</g>
<use transform="rotate(90 400 400)" xlink:href="#d" />
</g>
<use transform="matrix(-1,0,0,1,800,0)" xlink:href="#c"/>
I will discuss each element. It makes more sense to start from the inside:
- Start with
id="e"
. We have got two things here:- A path with
id="b"
. This path exactly describes the marker for 12:01. - Then, there is a
<use transform="...">
element. This one rotates the 12:01 bar for $6$ degrees, giving us the 12:02 bar. - The resulting image contains not too much:
- A path with
- Below the
id="e"
, we have another transform that rotatesb
for $12$ degrees. This one gives us the bars for 12:03 and 12:04. - Now consider
id="a"
. This one contains multiple things:- A
path
. This one represents the 12:05 bar, which is larger than the other bars created until here. - The
id="e"
element, containing all small bars from 12:01 up to 12:04. - We now get four small and one big marker:
- A
- Next out is
id="d"
. In this one we have:- A new bar that will represent the 12:00 bar.
- The bars from 12:01 up to 12:05, as
a
is a child ofd
. - Two transformation of
a
, giving us he 12:06-12:10 and 12:11-12:15 range. - So, in total,
d
gives us all bars from 12:00 up to 12:15. - Our clock starts to get a shape:
- Now there are two ways how to continue here:
- Rotate
d
once to get the right half of the clock (this happens inc
) and then mirror this new thing to the other side (this is done in the last line).
I kept with the original and chose this approach. Note that with that, every quarter-hour marker is drawn twice! We don’t really care about that, but I found it important for understanding what’s going on. - Rotate
d
four times to get the same for all four quarters of the clock.
I would have preferred this way, as here I can skip the 12:00 bar ind
and don’t have to draw them duplicated.
Still, I decided to keep the original at the moment. - With this, we are done!
- Rotate
Red countdown filling
I learned two interesting things here:
- I had to learn about SVG’s
<path>
element, and the possibility to draw arcs here - I had to do a bit of maths for finding out the end points of the arcs.
In general, the red filling in my drawing does the following:
- Start on top
- Draw an arc for the given fraction of the circle
- Draw a straight line to the center
- Go back to the beginning
In general, I did this with a path in SVG which I defined to be filled. For the image shown on top, my path looks like this1:
<path d="M400,100
A300,300 0,0,0 101,373
L400,400
" fill="#f00"></path>
Most of this looks easy! In general, we only have three things to consider:
- Use a
<path>
element. - Define where the path goes via
d
. - Add the red color via
fill
.
The arc
Now the obvious complicated part is the d
attribute. I want to discuss that in a bit more detail.
Thankfull, multi-line attributes work, so I was able to split this up a bit. Let’s consider each line:
M400,100
: This defines where the path should start: At coordinates400,100
. This is the top corner of the red element.
Without this, the path starts at the top left point at0, 0
, which I definitely don’t want here.A...
: This defines the rounded part of the path.
The general setup of this element is:A rx,ry rotation,large-arc-flag,sweep-flag x,y
. Commas are optional and can be replaced with spaces, but this is the version I find most readable. Let’s discuss what this means:- Technically, we are not drawing a circle here, but an ellipse. This is important to remember!
rx,ry
: For an ellipse, these are the lengths of the two axes. For a circle, both should be the same number.rotation
: In the default case, the major semi-axis of the ellipsis is horizontal; this argument defines how much to rotate it. For a circle, you may use any number here; it does not matter and returns the same thing always. So let’s just keep it at zero.large-arc-flag
: If the ellipsis is defined and we are given two points on the border of the ellipsis, there are two ways to get from one point to the other one; one of them typically is shorter than the other one. Set this flag to1
if your path shold take the longer way, and0
otherwise.sweep-flag
: We will have defined all dimensions of the ellipse/circle. Now given two points, there are two ellipses that have these dimensions and that have both points on their boundary. Thesweep-flag
essentially defines which of the two ellipse to choose.
I had to play around with these arguments a bit to understand what they mean, so please do the same if you want a better intuition.x,y
: These are the end points where the arc should stop.
- Now my arc definition looks like this:
A300,300 0,0,0 101,373
.
This means that we will look for a circle with radius300px
with the following two points on their boundary:400,100
(when starting the arc, the path is currently in this position) and101,373
(the end point is defined in the arc configuration). Thesweep-flag
andlarge-arg-flag
might change, but are correct for this case. - Finally, from the end point, go to the center of the clock.
L400,400
does exactly that: Draw a straight line from the previous point (which was101,373
) to400,400
. - Now for the filling, the curve is closed by adding a straight line from the last point to the starting point. And we are done!
End point of the arc
Obviously, I did not choose 101,373
accidentially. Finding these coordinates was more of a mathematical challenge. The idea here is simple: Start at the top point of the circle, and rotate the point by a given number of degrees.
For computing them, I created a function that receives the ratio
of the circle that I want to fill as an input. Let’s start with a bit of theory:
First, find out which point we want to rotate. This is actually simple: The part of the circle is starting at the top, so at 400,100
. Note that the coordinate system of SVG is a bit counterintuitive - the y
axis is kind of reversed. This will play a role again later on.
This was a merely theoretical concept. Here’s the next one: How do we rotate?
In maths, a rotation is done with a matrix. Given a point $(x_0, y_0)$, we get a rotation for angle $\alpha$ in clockwise direction by doing a matrix multiplication. $$ \left[\begin{matrix} x_1 \newline y_1 \end{matrix}\right] = \left[\begin{matrix} \cos(\alpha) & -\sin(\alpha) \\ \sin(\alpha) & \cos(\alpha) \end{matrix}\right] \cdot \left[\begin{matrix} x_0 \newline y_0 \end{matrix}\right] $$
This rotation happens around the origin, so we need to transform our circle to that. Thankfully we know that our circle center is $(400, 400)$, so it is sufficient to compute $x_{0, new} = x_0 - 400$ and $y_{0, new} = y_0 - 400$.
As the final theoretical concept, we need to find the angle $\alpha$, based on the ratio we want to fill. In general, this is easy: Since $2 \pi$ is a full circle, we need $ratio \cdot 2 \pi$ as our angle. But here comes SVG in again with their weird coordinate system: Due to that, we need the angle $(1 - ratio) \cdot 2 \pi$…
So, summing this all up, I just created a Python function that does all this stuff:
def rotate_point(ratio, x_0=400, y_0=100):
# Compute the ratio
alpha = 2 * math.pi * (1 - ratio)
# Transfer the circle center into the origin
x_prime = x_0 - 400
y_prime = y_0 - 400
# Do the matrix multiplication
x_new = x_prime * math.cos(alpha) - y_prime * math.sin(alpha)
y_new = x_prime * math.sin(alpha) + y_prime * math.cos(alpha)
# Don't forget to transfer the circle back to
# the original range!
x_final = x_new + 400
y_final = y_new + 400
return x_final, y_final
Conclusion
Drawing stuff on the computer is quite a bit of math. Also, SVG is a fun thing to work with!
Note that I have rounded the coordinates here. These are not totally correct, but they make the code more readable. ↩︎