Punchcard
A weekday × hour dot grid — every Monday 09:00 folded into one bucket, dot area on a square-root scale, switchable size / colour / both encodings and the aggregation timezone stated on the card.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-punchcard.jsonPrompt
Build a React + TypeScript + Tailwind "ChartPunchcard" component with zod — a
weekday × hour punchcard: 7 rows by 24 columns of dots whose AREA encodes how
much happened in that bucket.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
title: string; caption?: string;
timezone: string; // the zone the buckets were aggregated in
weeks?: number; // how many weeks were folded into one grid
weekStart?: "sun" | "mon"; // row order only, default "mon"
unit?: string; // plural noun, default "events"
max?: number; // fixed ceiling; omit to fit this data
cells: { day: 0-6; hour: 0-23; value: number >= 0 }[] }.
- `day` is always the JS getDay() index (0 = Sunday), never a row number, so
flipping weekStart reorders rows without renumbering data.
- `cells` is sparse: an omitted bucket is a real zero, so a quiet night costs
no rows. Duplicate day/hour pairs are a contract error (the upstream GROUP BY
is wrong) but the component still sums them — that is what a bucket means.
- Component props = z.infer of the schema plus defaultEncoding?: "area" |
"color" | "both", encodingToggle?: boolean, locale?: string (default
"en-US" — Intl.*(undefined) desyncs SSR from the visitor), onRetry and the
rest of the div props. No parallel hand-written interface.
Behavior
- Fold `cells` into a dense 7 x 24 matrix once per data change, clamping as you
go: non-integer or out-of-range day/hour are dropped, non-finite or negative
values become 0. Derive the total, the peak bucket and the ceiling in the
same pass. When `max` is given and positive the scale is FIXED (two cards
stay comparable); otherwise it is fitted to this data. Say which one is in
force on the card — a reader cannot tell a fitted scale from a fixed one by
looking.
- Size is sqrt, and this is the whole chart. Perceived quantity is the dot's
AREA, so radius = maxRadius * sqrt(value / ceiling). Feeding the value
straight into the diameter squares every ratio: a bucket with 4x the traffic
would draw 16x the ink, and the chart would overstate by the ratio itself.
Verify it from the DOM rather than by reading the code — measured diameter
squared divided by value must be constant across every dot (a linear-diameter
bug shows up as a spread of vmax/vmin).
- Two floors, both deliberate breaks in proportionality, both worth stating:
a positive bucket never renders below ~20% of the cell (one commit stays
visible next to a 400-alert incident), and no dot goes sub-pixel. Make the
pixel floor per kind, not shared — 2px for a positive bucket, 1px for a
measured zero. Measured at a 246px container the cells are 8.8px, and one
shared 2px floor pinned a zero and a one-event bucket to exactly the same
size: the size channel quietly stopped separating them while every
colour-channel assertion stayed green.
- A measured zero is not missing data: draw it as a small neutral dot in a
different token from the value ramp, strictly smaller than the smallest
positive dot at every container width. Two channels separate them, size and
colour.
- Encoding is switchable: "area" (one constant fill, size carries everything),
"color" (every dot at full size on a single-hue ramp) and "both" (size and
ramp together, so colour is never the only channel). A three-way segmented
control with aria-pressed owns it; encodingToggle={false} pins it for a
report that ships as an image.
- Reading a value: one delegated pointermove listener on the grid maps the
event target to a bucket and feeds a readout line under the chart; the state
only changes when the bucket under the pointer changes, so 168 cells are not
re-rendered on every mouse move. With nothing hovered the readout names the
busiest bucket, so the chart's main question is answered before anyone
touches it. Every cell also carries a native `title`.
- Keyboard: the grid is ONE tab stop. Arrow keys walk a cursor that drives the
same readout and a ring on the active cell, Home/End jump to 00:00 / 23:00,
Escape clears. Movement clamps and never wraps — rolling 23:00 into the next
row's 00:00 would imply a continuous week, which is exactly the fold this
chart removes.
- Four first-class branches in one card shell: loading (a 7 x 24 skeleton of
the same shape so nothing shifts), empty, error (a "Try again" button only
when onRetry exists), ready. The header — title, caption, timezone line — is
rendered in every state.
Rendering & styling
- One CSS grid, [gutter] repeat(24, minmax(0, 1fr)). Each cell is
aspect-square, so the row height follows the column width and the whole chart
reflows from 320px to 1440px with no measurement, no ResizeObserver and no
fixed height. The dot inside is sized in PERCENT of the cell, which is what
makes an area encoding survive a responsive container — a chart library that
sizes symbols in px^2 cannot.
- Place every grid item explicitly (gridColumn / gridRow). Thinned hour ticks
are display:none, and auto-placement will happily pull the next row's items
up into the slots they vacate: measured at a 246px container, the whole grid
sheared five columns sideways while still reporting 168 cells and zero
overflow.
- Hour ticks thin by CONTAINER width, not viewport: every 6h at the narrowest,
every 2h from ~16rem, every hour from ~32rem. The same card is a full page on
one site and a 380px sidebar on another. Measured minimum gap between
adjacent visible ticks at those three steps: 40.1px / 9.4px / 8.7px — never
negative.
- Semantic tokens only. The value ramp is
color-mix(in oklab, var(--chart-1) X%, var(--card)) with X from 14% to 88% —
mixing into the surface makes the ramp travel away from it in both themes. A
measured zero is var(--border), or var(--muted) in colour mode. No hex, no
literal oklch() anywhere in the component.
- Accessibility: the card is role="group" with an aria-label summarising the
peak and the total; the dot grid is role="img" and focusable; every label is
aria-hidden and the real data lives in a full sr-only 7 x 24 table with row
and column headers. Wrap that table in a plain div — sr-only's width:1px is
only a lower bound on a table box, and an unwrapped one pushes the page
sideways. The readout line is aria-live="polite" so arrow-key movement is
announced.
- The only animation is the skeleton pulse, and it carries
motion-reduce:animate-none.
Customization levers
- Density: the whole chart is driven by the --pc-gutter custom property (the
weekday label column, default 2rem) and the cell's aspect-square. Widening
the gutter buys room for full weekday names; dropping aspect-square for a
fixed row height turns the grid into a wide band.
- Dot scale: MAX_DOT (0.92 of the cell) sets how close neighbouring dots come
to touching; MIN_DOT is the visibility floor. Raise MIN_DOT for long-tailed
data where the small buckets matter, drop it to 0 for a strictly
area-proportional chart and accept that near-zero buckets vanish.
- Palette: swap var(--chart-1) for any single token; the mix percentages
(14 -> 88) are the contrast knob. Keep the ramp on one hue — a two-hue ramp
reads as a diverging scale, and this measure has only one interesting end.
- Buckets: the same layout takes any cyclic pair. 12 columns of months x 7
rows, or 24 x 4 for quarter-hours, only needs the column count and the tick
formatter changed.
- Readout: swap the readout line for a floating tooltip if the audience is
pointer-only, or add row and column totals as an extra grid track for a
marginal profile.
- Timezone: deliberately a printed label, not a select. Offering a conversion
would be a lie — see the concept note below.Concepts
- Cyclic aggregation, not a calendar — a punchcard answers "when in a week", so every Monday 09:00 in the window collapses into one number. Time stops advancing: there is no first or last week on this chart and no streak to read. A grid where time still moves forward is a contribution calendar, not this.
- Area, not diameter — quantity is judged by how much ink a mark carries, so the radius rides
sqrt(value). Mapping the value onto the diameter squares every ratio; the error is not cosmetic, it is the ratio, and it grows exactly where the chart is meant to be loudest. - Two honest floors — a positive bucket never shrinks below a visible size, and no dot goes sub-pixel. Both break proportionality on purpose at the bottom of the scale, where the alternative is a bucket that exists but cannot be seen. That belongs in the docs rather than left for a reader to assume the small end is exact.
- Measured zero vs no data — an omitted bucket and a zero bucket are the same fact here (nothing happened), and both draw a small neutral dot in a different token from the value ramp. The empty night reads as measured, not as missing.
- Timezone belongs to the aggregation — once events are folded into weekday × hour the originals are gone, so no client-side conversion can move the grid to another zone. The zone is printed on the card and never offered as a control: a select that silently relabels 09:00 as 10:00 would be a lie. Re-bucket upstream to change it.
- Fitted vs fixed ceiling — fitted fills the scale with whatever this dataset happens to contain and is right for a card read alone; a fixed
maxis what makes two teams' punchcards comparable. The card always states which one is in force. - Percent-sized marks — the dot's diameter is a percentage of its cell, and the cell is a square grid track. That is why the area encoding survives from a 320px sidebar to a 1440px dashboard with no measurement at all; a symbol sized in px² needs a ResizeObserver to stay correct, and overlaps its neighbours the moment the container shrinks.
Horizon
A four-state horizon chart that folds each series into stacked bands and encodes magnitude as depth of colour, so a dozen dense time series fit in a screenful — one shared band scale, mirrored or split negatives, and a column cursor that reads every lane at the same instant.
Alluvial Diagram
A four-state alluvial that re-draws the same categories at every reading — band width is how many members moved, one global category order chosen by measured crossing cost, and a retained / arrived / left readout for whatever you point at.