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.
| Step | What runs |
|---|---|
| fit | TimeScaleModel 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. |
| cull | computeFrame asks scale.ticks() for each header band across the visible window plus horizontal overscan (default 128 px). |
| label | dropRepeatedGranularity 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. |
| paint | render/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.
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 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.
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.
| Rule | Job |
|---|---|
.fg-header | position: sticky; top: 0. Band count × --fg-band-height sets height. Rows scroll under the header. |
.fg-tick | box-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-line | 1 px wide, --fg-date-line-color, pointer-events: none. The today line is this element with data-flag='today'. |
.fg-timeline-pane | Native 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.