Sunday, 22 March 2020

A tale with two angles

I picked up a link to a twitter post from @Vector_GL featuring what was described as “Concentrichron: A clock and calendar made of concentric rings”. You can find it here and very nice it is too. Reminded me that when I last built a substantial web based application I was much taken with SVG as the technology for creating arbitrary graphical output from code. Not quite how it was done here but still it was nice to see it in action.

As I have spent a lot of time over the last few months working on my new book “Learn to Program, with games and JavaScript” I have been rather focused on Canvas based graphics. So I rather wondered if the “Concentrichron” could be reproduced in the browser using just JavaScript and some canvas elements. Turned out to be a lot of fun. I particularly enjoyed rendering curved text which turned out to be much simpler than I had anticipated.

The two angles mentioned in the title allude to the fact that angles (and thus rotations) are described in radians when working with canvas objects but the CSS rotations used to manage the animated display are invoked in degrees. Fortunately, the only real maths is in one function and that is just a bit or trigonometry while everything else is just fractions of circles. There are some nice little bits of the code that look like they could be useful in other projects.

The code can be downloaded from GitHub ready to play with and you can see a running demo at https://adit.co.uk/clock.html


Sizing the concentric rings looked like a good start point and rather than trial and error I decided to base the inner ring on the font size. It needs to incorporate 60 numbers (0 to 59) with some reasonable spacing. The first function written therefore measured the size of those numbers and stored them in an array for later use. The function returned the width and height of the largest as well as populating the array.

function measureDigits(){ let max = {width: 0, height: 0}; let cvs = document.createElement("canvas"); let ctx = cvs.getContext("2d"); ctx.font = clock.font; for(let i = 0; i < 60; i++){ let tm = ctx.measureText(''+i); sizes.push(tm.width); if(tm.width > max.width){max.width = tm.width;} let height = tm.actualBoundingBoxAscent + tm.actualBoundingBoxDescent; if(height > max.height){max.height = height;} } return max; }











Off hand, I could not think of a common font with descenders on numbers but the code allows for one.

There is a similar function to measure the font descenders for lower case letters to help position text on the dials – plus a function to measure the character widths of individual words before rendering in a curve. It turns out that the canvas measureText() method returns a width value for a word which is the same as the sum of the widths of the individual characters in that word. This is not true for (say) the .NET MeasureString() method. I am sure that all three of my text measuring functions could be merged but maybe I am still in tutorial mode.

The sizeInnerWheel() function takes the maximum number sizes and uses them to set the radius of the inner and outer circumferences of the smallest dial. The circumferences of all of the other dials are just incremented by the difference between this first pair.

function sizeInnerWheel(secRad){ let maxNum = measureDigits(); // decides dial size based upon font let circum = maxNum.width * 90; // the 90 and 1.7 are arbitrary secRad.inner = Math.ceil(circum / (twoPi)); secRad.outer = Math.ceil(secRad.inner + maxNum.height * 1.7); }






The number of days in a month varies by month and could even change (for the currently displayed month) if the script was running at midnight on the last day of a month. Otherwise, all of the dials have a pretty constant content. I started the outer (year) dial from the year before the current year as that would look better in January of any new year.

Otherwise all of the set-up is to be found in a longish function that draws the dial edges and the tick marks between sections of each dial. This function culminates in drawing numbers or rotated text in the middle of each dial section. I could see no practical purpose in rotating the two individual digits of (say) 33 but made the effort for the likes of “January” and “Thursday”.

The technique is to size each word and to then rotate each dial canvas to the midpoint of each section. The canvas is then rotated back by half the angle calculated to match the width of the whole word bent around the circumference of the circle the word will be notionally drawn around. The first character of the word is then drawn and the canvas rotated by the angle related to the width of that character ready for the next to be drawn. And so on. The angle calculation has to take into account the radius from the canvas centre of rotation to the base of each character.

function setWheel(cvs, secRad, innerFill, nums, text, outside){ let ctx = cvs.getContext("2d"); let half = cvs.width / 2; if(outside){ // colour the dials and draw outside rim ctx.beginPath(); ctx.arc(half, half, secRad.outer, 0, twoPi); ctx.fillStyle = clock.dialColour; ctx.fill(); ctx.fillStyle = "black"; ctx.stroke(); ctx.closePath(); } ctx.beginPath(); ctx.arc(half, half, secRad.inner, 0, twoPi); if(innerFill){ // fill the center ctx.fillStyle = innerFill; ctx.fill(); ctx.fillStyle = "black"; } ctx.stroke(); ctx.closePath(); let step = twoPi / nums; let tick = nums === 60 ? 5 : 10; // longer for outer dials for(let i = 0, j = nums; i < j; i++){ // mark the tick marks around dial let angle = step * i - Math.PI / 2; // start at top not zero radians let pt = findPointOnCircle(half, half, secRad.inner, angle); ctx.beginPath(); ctx.moveTo(pt.x, pt.y); pt = findPointOnCircle(half, half, secRad.inner + tick, angle); ctx.lineTo(pt.x, pt.y); ctx.stroke(); ctx.closePath(); } ctx.font = clock.font; ctx.save(); ctx.translate(half, half); // set the coordinate system to mid point if(text.length > 0){ // draw curved text around dial in each slot ctx.rotate(step/2); for(let i = 0; i < nums; i++){ let clens = measureText(text[i]); let textArc = clens[clens.length-1] / (secRad.inner + clock.maxDrop); ctx.rotate(-textArc/2); // move back half the word length in radians for(let j = 0, k = text[i].length; j < k; j++){ ctx.fillText(text[i].charAt(j), 0, -(secRad.inner + clock.maxDrop)); ctx.rotate(clens[j] / (secRad.inner + clock.maxDrop)); // advance one char } ctx.rotate(step - textArc/2); } } else { ctx.rotate(step/2); // just write numbers in slots for(let i = 0, j = nums; i < j; i++){ ctx.fillText('' + i, -sizes[i]/2, -(secRad.inner + clock.maxDrop)); ctx.rotate(step); } } ctx.restore(); // restore coordinates and rotation }





















































Once the dials are drawn and positioned relative to each other, then a timer can be started to rotate each canvas to correctly display the time. This uses CSS rotations which are in degrees. The rotations are updated at a rate equivalent to 20 frames a second which looks reasonably smooth for the seconds dial. The rotations are calculated based upon values extracted from a javaScript date object holding the current time.

function runTime() { let now = new Date(); let secs = now.getSeconds() + now.getMilliseconds() / 1000; let mins = now.getMinutes() + secs / 60; let hrs = now.getHours() + mins/60; let day = now.getDay() + hrs / 24; let monthDay = (now.getDate() - 1) + hrs / 24; let month = now.getMonth(); if(month !== clock.lastMonth){ setMonth(clock.monthRad, 0); } month += monthDay / clock.monthLen; let year = (now.getFullYear() - parseInt(years[0])) + month / 12; cvSec.style.transform = "rotate(" + (secs/60 * fullTurn) + "deg)"; cvMins.style.transform = "rotate(" + (mins / 60 * fullTurn) + "deg)"; cvHours.style.transform = "rotate(" + (hrs / 24 * fullTurn) + "deg)"; cvDay.style.transform = "rotate(" + (day / 7 * fullTurn) + "deg)"; cvDays.style.transform = "rotate(" + (monthDay / clock.monthLen * fullTurn) + "deg)"; cvMonths.style.transform = "rotate(" + (month / 12 * fullTurn) + "deg)"; cvYears.style.transform = "rotate(" + (year / 10 * fullTurn) + "deg)"; }
















The value fullTurn is -360 as the dials rotate counter clockwise which makes the virtual clock hand (half transparent red line) rotate clockwise in at least a relative way.

Once again, the code can be downloaded from GitHub ready to play with and you can see a running demo at https://adit.co.uk/clock.html


No comments:

Post a comment