Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type { ChartPunchcardCell, ChartPunchcardData } from "./chart-punchcard.contract"

/**
 * Which channel carries the quantity.
 * - "area"  — dot area (the punchcard proper)
 * - "color" — one full-size dot per bucket on a single-hue ramp
 * - "both"  — area and ramp together, so colour is never the only channel
 */
export type ChartPunchcardEncoding = "area" | "color" | "both"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-punchcard.json

Prompt

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 max is 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.

On This Page