Skip to main content

How the timeline paints

The timeline is a bound TimeScale plus a header of absolutely positioned ticks. On top of that sit five behaviours worth their own explanation: a density floor, sticky coarse labels, dropped repeated granularity across bands, unpadded hour labels, and a today line that only draws inside the scale range.

Source: time/scale.ts, time/presets.ts, render/dom/. Open zoom.html while you read this.

Paint path​

GanttShell.render() reads the bound viewport, runs one pure computeFrame, then the DOM backend patches ticks with translateX and width. Geometry stays in layout/. Painting stays in render/ and view/styles.ts.

Derived from view/gantt-shell.ts render(), layout/frame.ts, layout/viewport/time-scale-model.ts, render/dom/index.ts, view/styles.ts.

Timeline header paint path GanttShell.render reads Viewport and TimeScale, computeFrame drops repeated granularity and clamps tick x, then the DOM backend writes .fg-tick transform and width. GanttShell.render view/gantt-shell.ts Viewport visible · preset · scale TimeScale ticks() · pxPerMs computeFrame headers dropRepeatedGranularity clamp tick.x to pane left DOM backend.sync syncKeyed .fg-tick .fg-tick CSS ellipsis · padding · sticky header LEGEND layout / time (DOM-free) view / render header layout and label clamping
One frame. The shell never formats dates. The backend never computes tick x. CSS never moves a tick; it only clips a label that is still too wide.
StepWhat runs
fitTimeScaleModel resolves pxPerMs from 'pane', 'preset', or an explicit number. It then floors that density at minTickWidthPx and caps content width at 16,777,216 px.
cullcomputeFrame asks scale.ticks() for each header band across the visible window plus horizontal overscan (default 128 px).
labeldropRepeatedGranularity strips year/month from a finer band when a coarser band already states them. Then each tick's x clamps to the pane's left edge so a coarse label stays in view.
paintrender/dom sets transform: translateX(tick.x) and width. The header is position: sticky; top: 0 so rows scroll under it.

Density floor​

Zoom-out no longer squashes a preset until labels overlap. Each shipped preset states minTickWidthPx. The model raises pxPerMs until one finest-band tick is at least that wide. The pane then scrolls.

Derived from time/presets.ts, time/scale.ts minPxPerMsForPreset, layout/viewport/time-scale-model.ts #resolvePxPerMs.

Shipped minTickWidthPx by preset Bar chart of the density floor in pixels for the nine zoom-ladder presets. The day preset is the tallest at 96 pixels because its lone band shows a full date. 100 80 60 40 20 0 minTickWidthPx 48 hour 48 h+d+w 96 day 32 d+w 28 d+w+m 40 w+m 40 w+m+y 50 m+y 40 year LEGEND lone day band — full “Sep 21, 2026” other shipped floors
The floor measures the finest band of each zoom-ladder preset (ZOOM_PRESETS). Multi-band presets sit lower because dropRepeatedGranularity leaves the day band as a bare number. Standalone week and month still ship (96 px / 72 px) but are not zoom steps.

Header bands and dropped repeated granularity​

A preset lists bands coarsest first. Left alone, every band carries its own full date format, so weekAndMonth would write "Sep 2026" on the month row and "Sep 1, 2026" on the week row — the month named twice.

dropRepeatedGranularity(headers) walks that list once. When an earlier band already states year or month, it strips that field from later Intl.DateTimeFormatOptions. A callback format (formatWeekNumber, formatHour) passes through. Set repeatCoarserUnits: true on one band to opt that band out.

Derived from time/format.ts, layout/frame.ts band loop.

weekAndMonth labels before and after dropping repeated granularity Two stacked headers. The left pair repeats September and 2026 on both bands. The right pair keeps the month band as Sep 2026 and the week band as the day number only. before — same format on both bands Sep 2026 Sep 1, 2026 Sep 8, 2026 Sep 15, 2026 Sep 22, 2026 after — what ships Sep 2026 1 8 15 22 — month above hour bands use formatHour, not Intl en-US still zero-pads { hour: 'numeric', hour12: false }. formatHour prints 9:00. Memo: WeakMap on the headers array so the stripped options object keeps one identity across frames.
Try weekAndMonth or weekMonthYear on zoom.html. The coarse band still carries the year. The fine band no longer repeats it.

Sticky labels inside a cell​

A tick's true x is the calendar boundary in content pixels. For a year that started before the dataset, that x sits left of the pane. The old paint put the label at that x, so overflow: hidden on the header clipped it even while most of the cell was visible.

The frame now sets painted x = max(tick.x, visible.x) and shrinks width by the same delta. The instant that drives the text does not change. Only where the label sits changes.

Derived from layout/frame.ts labelLeftClamp.

Coarse tick label clamped to the pane left edge Two rows. The year cell starts off-screen. The unclamped label sits in the clipped region. The clamped label sits at the visible left edge of the same cell. unclamped — label at true boundary visible pane 2025 true x is left of the clip · the reader never sees the year clamped — what ships visible pane 2025 painted x = max(tick.x, visible.x) · width shrinks · instant (the year) is unchanged
Switch zoom.html to the multi-year dataset and a three-band preset. Scroll horizontally. The year stays at the left of its cell until the cell leaves the pane.

Today line​

The today line is a Date line, not a type of its own. resolveDateLines (layout/date-line.ts) emits one DateLineDecoration carrying today: true, alongside any line a consumer authored in dateLines, and only when the instant falls inside scale.range. Paint keys the wrapper off that flag: render/dom/date-line.ts writes data-flag="today" on the .fg-date-line stroke and on its .fg-date-line-label chip, so the stroke a consumer authored and the today stroke are one element type with one token, --fg-date-line-color.

todayLine defaults to true, which reads the clock. false omits the line, and an Instant pins it there with no clock read at all — which is how a test freezes it.

The line stays current on its own. A page left open past a tick boundary used to show a stale reading, because nothing asked for a frame at the boundary. GanttShell now arms one setTimeout for the finest header band's next tick boundary after every frame painted with todayLine: true, and asks FrameScheduler to repaint when it fires. It re-arms every frame, so a preset change moves the boundary with it, and it is cleared when todayLine leaves true and on destroy(). view/ may not read the clock or do Instant arithmetic (I1, I10), so the delay arrives through the GanttShellWiring.nextTickBoundaryDelayMs port, composed in api/gantt.ts from time/'s nextTickBoundary() and now(). The delay is clamped to setTimeout's 32-bit ceiling (~24.8 days), because a year preset's boundary sits past it and an unclamped delay fires almost immediately instead.

The sample fixture starts on 2026-09-01 so unit tests stay deterministic. If "today" is before that start (or after the last entry under range: 'fitDataset'), the line is correctly missing. That is not a paint bug. Use the multi-year dataset on zoom.html when you want the line inside the range.

Derived from layout/date-line.ts, render/dom/date-line.ts, view/gantt-shell.ts, view/frame-settings.ts, fixtures/sample-dataset.ts.

CSS the library owns​

The base stylesheet in view/styles.ts now owns tick overflow. The harness pages keep only font-size on .fg-tick.

RuleJob
.fg-headerposition: sticky; top: 0. Band count × --fg-band-height sets height. Rows scroll under the header.
.fg-tickbox-sizing: border-box, padding: 0 4px, overflow: hidden, text-overflow: ellipsis, left hairline. A label that is still too wide clips instead of covering its neighbour.
.fg-date-line1 px wide, --fg-date-line-color, pointer-events: none. The today line is this element with data-flag='today'.
.fg-timeline-paneNative scroller. min-width: 0 so a zoom-out that hits the density floor can shrink the pane and scroll content instead of stretching the shell.

The module and class map still covers construction and the notification machine. This page covers only what the timeline shows after those passes.