Command Palette

Search for a command to run...

Hectal
PHASE 6Intermediate ~22 min· topic 3 of 7

Topic 6.3

Time Scales

In one line

A time scale maps dates to positions and picks calendar-aware ticks (months, weeks, days, hours). The hard part is time itself: scaleTime follows the viewer's clock and scaleUtc follows UTC, and a day is not always 24 hours.

Think of it like this

A wall calendar. Months have different lengths, but each month gets one page. A calendar does not put a mark every 30 days, because people think in months, weeks and days. A time axis works the same way: its ticks sit on calendar boundaries, not on equal numbers of milliseconds.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

UTC
The world's reference time: no summer time, the same everywhere. Written with a Z at the end: 2025-03-30T00:00:00Z.
time zone
A region's rule for local clock time, such as Atlantic/Azores (UTC−1 in winter, UTC+0 in summer).
summer time (DST)
Moving clocks forward in spring and back in autumn. Days then last 23 or 25 hours.
instant
One exact moment, the same for everyone, stored as milliseconds since 1970.
interval
A calendar unit D3 can step by: d3.utcDay, d3.utcWeek, d3.utcMonth.

Step by step

01Dates in, pixels out

The first example builds scaleUtc over the year's first and last dates. 18 July lands at 272 pixels of 500, and invert(250) gives back the middle of the year as a Date: 2 July.

02The span picks the step

Asked for 10 ticks, a year gives 12 monthly ticks (the first gap, 744 hours, is January's 31 days). Asked for 5, it gives four 3-month ticks. A fortnight gives daily ticks, one day gives 3-hour ticks. The wanted count is only a hint, as in Topic 6.1.

03The clock that skips an hour

The second example asks the Azores clock directly. At 00:59 UTC on 30 March it reads 23:59 (UTC−1). One minute later it reads 01:00 (UTC+0). It then measures each local day: 23 hours for 30 March, 25 hours for 26 October, 24 for the rest.

The clock that skips an hourdiagram
Rendering diagram…

04The fix for daily data

scaleUtc ticks are always 24 hours apart: the example prints 24 24 24 24 24 for the same five days. Because daily rows are UTC midnights, UTC ticks and rows line up in every time zone.

Try it yourself

  1. 1

    Ask for fewer ticks

    In the first example change show("year, count 10", ...) to count 3. The target gap is about 4 months, so the nearest step is 3 months and you get the same four ticks as with count 5. The list has only 18 steps, so many counts give the same axis.

  2. 2

    Pick the interval yourself

    Replace x.ticks(d3.utcMonth.every(3)) with x.ticks(d3.utcMonth.every(2)). You get January, March, May, July, September and November: 2025-01 2025-03 2025-05 2025-07 2025-09 2025-11. Passing an interval skips the automatic choice.

Code & diagrams

scaleUtc: ticks, nice and padding New tab

Everything here uses UTC, so the printed values are the same on every computer.

Console

(nothing was printed)

Expected console output

domain: [ 2025-01-01T00:00:00.000Z, 2025-12-31T00:00:00.000Z ]
18 July -> 272.0 px   invert(250) -> 2025-07-02T00:00:00.000Z
year, count 10   12 ticks, first gap 744 h, labels: 2025 | February | March
year, count 5     4 ticks, first gap 2160 h, labels: 2025 | April | July
two weeks        15 ticks, first gap 24 h, labels: June | Mon 02 | Tue 03
one day           9 ticks, first gap 3 h, labels: June | 03 AM | 06 AM
every 3 months: 2025-01 2025-04 2025-07 2025-10
nice(): 2025-03-01 to 2025-11-01
nice(week): 2025-03-09 to 2025-10-12
padded 3 days: 2024-12-29 to 2026-01-03
domain from text: [ Invalid Date, Invalid Date ] -> NaN
text given to a Date domain: undefined
A day is not always 24 hours New tab

Every line names its time zone, so this prints the same everywhere. scaleTime would print what your own clock does, so the course never logs it.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

the instant: 2025-03-30T00:00:00.000Z
UTC                writes it as 30 Mar
Asia/Kolkata       writes it as 30 Mar
Pacific/Auckland   writes it as 30 Mar
America/New_York   writes it as 29 Mar
Atlantic/Azores    writes it as 29 Mar
clock at 00:59Z on 30 March: 23:59 GMT-1 | at 01:00Z: 01:00 GMT+0
clock at 00:59Z on 26 October: 00:59 GMT+0 | at 01:00Z: 00:00 GMT-1
28 Mar: 24 h | 29 Mar: 24 h | 30 Mar: 23 h | 31 Mar: 24 h | 24 Oct: 24 h | 25 Oct: 24 h | 26 Oct: 25 h
scaleUtc day ticks, hours apart: 24 24 24 24 24

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Build the domain from date text

In the first example replace .domain(d3.extent(rows, (d) => d.date)) with .domain(["2025-01-01", "2025-12-31"]) and run it.

terminal
$ Console
── what you'll see ──
domain: [ Invalid Date, Invalid Date ]
18 July -> NaN px invert(250) -> Invalid Date

Myth vs fact

Myth

A day always has 24 hours.

Fact

Not in a zone with summer time: 30 March 2025 lasted 23 hours in Ardenvale and 26 October lasted 25.

Myth

scaleTime and scaleUtc differ only in speed.

Fact

They place the ticks on different midnights. For data whose rows are UTC days, scaleTime puts ticks hours away from the rows in most zones.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Local-time functions (d3.timeDay, scaleTime) use the browser's zone, so the same code draws different ticks in Lisbon and New York, and tests that log them fail on another machine. This is why the course logs only UTC values or values with an explicit timeZone.

  • ▸

    D3 ships only UTC and viewer-local intervals, no named-zone ones such as "Atlantic/Azores midnight". You can build one with d3.timeInterval(floor, offset) (the same helper D3 uses for its own) and pass it to scale.ticks(interval); the floor function must find the zone's midnight, which is the hard part (the example's startOfDay is a slow way to do it).

Remember this

  1. 1

    The problem this solves: a date is a number of milliseconds, but a good axis must say "1 March", not "1740787200000", and must place its marks on the first of each month even though months are 28 to 31 days long. A time scale does both. The trouble is that "first of the month" and "midnight" depend on which time zone you mean.

  2. 2

    **Two scales: scaleTime and scaleUtc.** (Recall from Topic 6.1: a scale has a domain, a range, ticks(), nice() and invert(); the time scales keep all of them, with Date objects in the domain.) They have the same methods. scaleTime rounds to the viewer's local midnights. scaleUtc rounds to UTC midnights. d3.autoType turns 2025-06-01 into midnight UTC, so scaleUtc puts every daily mark exactly on a tick, in every viewer's time zone. With scaleTime, a viewer in New York gets ticks at 04:00 UTC in summer and 05:00 UTC in winter, so a label made with a local formatter can show the day before the row's own date (the example in this topic shows 29 Mar for a row dated 30 March).

  3. 3

    How time ticks are chosen. D3 has a fixed list of 18 steps: 1, 5, 15 and 30 seconds, 1, 5, 15 and 30 minutes, 1, 3, 6 and 12 hours, 1 and 2 days, 1 week, 1 and 3 months, 1 year. It divides the span by the wanted count and picks the step nearest to the result by ratio. A year with count 10 gives monthly ticks (12), and with count 5 gives three-month ticks (4). Beyond a year it falls back to the 1-2-5 rule on years. Weeks start on Sunday by default. Pass an interval to choose yourself: d3.utcMonday gives every Monday, and d3.utcMonth.every(2) gives every second month.

  4. 4

    Tick labels depend on the tick's level. The default tickFormat looks at each tick on its own: a tick at midnight on 1 January prints 2025, a first of a month prints the month name (February), a Sunday prints Jun 08, other days Mon 02, and an hour prints 03 AM. This is why a year axis starts with 2025 and then reads February, March. Pass a specifier (tickFormat(10, "%b")) to use one format for all ticks.

  5. 5

    A day is not always 24 hours. In Ardenvale's zone, Atlantic/Azores, clocks went forward on Sunday 30 March 2025: at 01:00 UTC the clock jumped from 23:59 (UTC−1) to 01:00 (UTC+0), so local midnight of 30 March never happened and the day lasted 23 hours. On 26 October clocks went back at 01:00 UTC, and the day lasted 25 hours. Local-time ticks across these days are 23 or 25 hours apart, and a chart that assumes 24 hours is off by an hour.

Explain it without notes

01

What is the difference between scaleTime and scaleUtc, and which fits daily.csv?

02

How does D3 choose between monthly and 3-monthly ticks?

03

Why was 30 March 2025 only 23 hours long in Ardenvale?

Practice

01

Make a scale for June 2025 (UTC) over 480 pixels and print the pixel for 15 June at noon. Then print the ticks for every Monday as ISO dates.

02

Write dayLabel(date) that prints a daily row's date as 30 Mar in every viewer's time zone.

Trade-offs

  • ↔

    UTC scales line daily data up exactly, but their ticks fall on UTC midnights, which are not the viewer's midnights. For a chart of instants (trip starts) the viewer's local time is what readers expect.

Done when you can

  • I can create a scaleUtc from Dates and say what it returns for text.

  • I can say how D3 picks a time tick step.

  • I can use nice(), ticks(interval) and utcDay.offset.

  • I can explain why a local day can last 23 or 25 hours.