Build a scene graph
buildScene(profile, options) converts a validated document into pure data:
scales, ticks, strips, sampled fields, line and band paths, wind barbs, labels,
markers, and interaction readings. It touches no DOM and contains no functions,
so it can cross a worker boundary or be serialized for inspection.
One scene graph, mapped onto its own pixels
buildScene returns pure data; every region of the rendered chart is a named collection on that data.
A rendered teaching windgram with outlined regions naming the SceneGraph collections that draw them: the time–height plot (fields, series, barbs, markers), the altitude axes, the hour-label and surface-temperature row, the metric strips, the launch line, and the selected-hour column.
- 1Time–height plot —
scene.fields · scene.series · scene.barbs · scene.markers · scene.gusts: interpolated iso-band fields, derived-height lines, wind barbs, marker trains - 2Altitude axes —
scene.axes.altitude · scene.axes.pressureAltitude: each tick carries y, labelMetres, and labelFeet; pressure ticks carry median level heights - 3Hour labels and surface row —
scene.axes.hours · scene.surfaceTemperatures: local hour ticks (hourLabel option) and the per-hour rounded temperature readout - 4Metric strips —
scene.strips[] — pressure · precipitation · cloudCover · cloudLayers · thermalStrength · cape: one MetricStrip per row with top, height, min/max, and line/area/band paths - 5Launch line —
scene.launch: y, altitude, and label for the surveyed launch (launch 1050 m) - 6Selected hour —
scene.selectedHourIndex: the day's peak-W* column, highlighted by theselectedHouroverlay
import type { WindgramProfile } from "windgram/contract";import { buildScene, cursorReading } from "windgram/scene";
export function sceneAndFirstReading(profile: WindgramProfile, olderProfileTimeZone?: string) { const timeZone = profile.site.timeZone ?? olderProfileTimeZone; if (!timeZone) throw new Error("older profile needs an explicit IANA timezone"); const scene = buildScene(profile, { timeZone, hours: profile.hours.slice(0, 8), overlays: { thermalIndex: true, windShear: true }, widthPx: 900, plotHeightPx: 380, hourLabel: "12h", barbStride: "auto", markerStride: { cloudBase: { every: 2 }, usableLiftTop: { every: 2 }, }, stripLabels: { thermalStrength: "LIFT" }, });
const reading = cursorReading( scene, scene.scales.plotLeft + 5, scene.scales.plotTop + 5, ); return { scene, reading };}Select hours explicitly
Section titled “Select hours explicitly”Options accept one of three equivalent forms:
hourIndices: indices intoprofile.hours; this wins when both forms exist;hours: hour objects matched back byvalidAt; orhours: { timeZone, dateKey }: one local calendar day.
Absent a selection, the scene includes every profile hour. buildScene
requires an explicit timezone for labels and accessible descriptions. Pass
profile.site.timeZone when present or a caller-owned fallback for an older
profile; the package does not infer a zone from a site name or coordinate.
Configure presentation
Section titled “Configure presentation”SceneOptions controls overlays, 1-2-1 display smoothing, CAPE class
thresholds (capeClasses, defaulting to the exported
DEFAULT_CAPE_CLASSES), a parameterized sink rate, chart geometry, hour
labels, barbs, line markers, and strip labels. Every drawn data layer except the axes and
frame has an overlay toggle. The surfaceTemperature overlay defaults on and
adds one rounded <n>° readout per hour below the hour labels. Unavailable
fields add no marks; the scene never pads them with zero.
Draw smoke, and the adjusted view
Section titled “Draw smoke, and the adjusted view”Pass a site’s smoke document as options.smoke and the smoke strip
draws wherever the profile itself publishes no smoke — one source per
strip, never a blend, with scene.smokeSource naming the model and run
that fed the pixels. Set options.smokeAdjusted: true to build the
smoke-adjusted alternate view: every hour’s w* derated by the
slant-path transmittance and the usable-lift envelope re-derived, one
coherent scene. The graph then carries scene.smokeAdjustment (smoke
model + run) — render that label; the reference key does it for
you via KeySpec.smokeAdjusted. The option quietly no-ops, and
smokeAdjustment stays null, when there is no smoke data or the
profile’s own fluxes are already smoke-aware
(semantics.smoke: "radiativelyCoupled"). Pointer packets from
cursorReading include the drawn hour’s smokeSurfaceUgm3 and
smokeAot, so tooltips and pixels cannot disagree.
Draw measurements beside the forecast
Section titled “Draw measurements beside the forecast”Pass a site’s observation document as options.observations and the
Sun strip draws: satellite-measured W/m² joined to each rendered
hour by nearest instant, with a shadow behind the line that deepens as
the measured sky under-delivers against the clear-sky expectation
(tint = 1 − observed transmittance). scene.observationSource names the
dataset and its newest measured instant — the strip is another source
with its own cadence, and renderers must be able to label it; the
reference key explains the shadow via KeySpec.measuredDimming.
Pointer packets carry observedIrradianceWm2 and
observedTransmittance, so an inspector reads the measurement where
the pixels drew it.
Pass an AOD observation document as options.aotObservations and the
AOT strip draws beside it: satellite-measured aerosol optical
thickness at 550 nm — the same quantity, wavelength, and field name the
smoke document forecasts as aot — joined to each rendered hour by
nearest instant, with scene.aotObservationSource naming the dataset
and its newest measured instant. The haze behind the line is
deliberately the forecast smoke strip’s own cell encoding, same class
and same scale (full tint at AOT 3), so forecast smoke and measured
smoke compare at a glance and one key chip — KeySpec.smokeHaze —
explains both tints. The observedAot overlay defaults on, a document
whose entries are not AOT-shaped contributes nothing, and pointer
packets carry observedAot.
Provenance is structural. Every strip declares whose data it draws
(provenance: "model" | "crossModel" | "measurement"), and the stack
splits spatially: the viewed model’s own strips render as one group,
and anything foreign — another model’s smoke, the Sun and AOT
measurement strips — renders below a labeled divider (“beside this model — not in its
physics”, scene.stripDivider) with its source and instant written
inside the strip itself (sourceLabel), so even a cropped screenshot
answers “did the model account for this?”. The reference renderer
always draws the divider when any foreign strip exists. The one subtle
case is a model’s own passive smoke: its data, so it stays above the
line, but the strip says “this model’s forecast · not in its
physics” — position answers whose data, the label answers whether the
physics felt it. Radiatively coupled smoke (HRRR) carries no statement
at all: it is ordinary model data.
Render continuous field bands
Section titled “Render continuous field bands”Sampled stability, thermal-index, shear, humidity, vertical-velocity, and dew-point-depression fields use interpolated iso-bands. Class boundaries cross each grid cell at the underlying threshold instead of following rectangular sample runs.
sampledFieldPaths({ banding, nodesByHour, ...geometry }) accepts ascending
breakpoints and one classNames entry per interval. A null class remains
unpainted. Each returned band path contains its outer and inner threshold
outlines, so consumers fill FieldLayer.paths with fill-rule="evenodd".
renderSvg applies that rule.
The lower-level geometry helpers behind the scene — windBarbParts,
curvedPath, interpolateVertical, and friends — are exported for renderers
that compose their own layers; their contracts live in the shipped type
declarations.
The optional buoyancyShear strip draws zero-shear, nonzero-buoyancy hours as
wg-bs-unopposed. A blank cell remains reserved for a ratio that cannot be
computed.
Fit and label the consuming surface
Section titled “Fit and label the consuming surface”| Option | Use it when | Behaviour |
|---|---|---|
widthPx |
The chart must fill a known panel | Sets total scene width after hour windowing and wins over columnWidthPx |
columnWidthPx |
The chart should scroll by a chosen hour pitch | Sets pixels per hour when widthPx is absent |
minColumnWidthPx / maxColumnWidthPx |
A density policy bounds the pitch | Clamps the resolved pitch; a moved fit narrows the chart or lets it scroll. The minimum wins a conflict |
fitMinColumns |
Short windows must not stretch | The widthPx fit divides by at least this many columns; inert with explicit columnWidthPx |
hourLabel |
A surface needs 24-hour, 12-hour, or custom labels | Changes ticks and the scene aria label together |
stripLabels |
A publisher has its own display voice | Changes visible labels only; strip keys and CSS classes remain stable |
plotHeightPx |
The time-height panel needs a different vertical scale | Changes the panel height; strips retain fixed heights |
Use widthPx instead of probe-building to discover package gutters. The
package owns those gutters and derives scene.scales.columnWidth. A pitch
policy belongs in the same build: pass the bounds and the short-window
floor as options rather than building once to read the fitted pitch and
again to correct it.
Control barb and marker density
Section titled “Control barb and marker density”barbStride: "auto" is geometry-aware and is the default. An explicit number
forces an hour stride. barbMinGapPx controls vertical clearance between
level barbs, while barbScale pins glyph scale; absent those overrides, both
follow the resolved column pitch. Gust labels use the same resolved hour
stride. scene.scales.surfaceWindY exposes the surface row’s placement; use it
for hit-testing instead of assuming the plot floor.
markerStride can draw cloud and wing marker trains along cloudBase and
usableLiftTop. A number draws every n hours from the selected-hour anchor;
{ every } is the object form. Each train follows its own overlay, and where
usable lift reaches cloud base the coincident cloud and wing render as one
stacked symbol — trains never need phasing apart. With no stride, each line
keeps one marker at the selected hour.
The scene reads pipeline-authored derived.* values by default. sinkRateMs
can recompute only the usable-lift series from published inputs for a
deterministic document. Palette is not scene data; apply --wg-* tokens when
rendering.
Answer pointer positions
Section titled “Answer pointer positions”The hit-testing queries beside buildScene share the plotted scales, keeping
tooltips and geometry aligned. Read them in the order a pointer event needs
them:
clientPointToScene(scene, rect, clientX, clientY)maps a client-pixel position through the mount’s bounding rect into scene coordinates, scaling x and y independently. It returns null for a zero-area rect — the measurement a hidden tab produces.hourIndexForX(scene, x)names the hour column under an x, null outside the plot;hourIndexForX(scene, x, { clamp: true })resolves to the nearest edge column instead, so strips and margins still select.cursorReading(scene, x, y)interpolates the continuous column — temperature, wind, lapse, stability class — at any altitude.drawnBarbsForHour(scene, hourIndex)andnearestDrawnBarb(scene, hourIndex, y)answer the discrete question instead: which barbs did this column actually draw (stride and min-gap thinning applied), and which is nearest the pointer. EachBarbPlacementcarries itshourIndex, dataaltitudeM, and asurfaceflag — the surface barb draws atscales.surfaceWindY, above its data altitude, and the flag is its identity.xForTime(scene, validAt)positions an instant with sub-hour precision — time cursors, sunrise and sunset ticks — interpolating between hour centres;{ clamp: true }pins out-of-window instants to the frame edges.hourIndexForValidAt(scene, validAt)finds the rendered index for an instant, comparing timestamps rather than strings. Key stored selections byvalidAtand re-ask after every rebuild: hour windows renumber, and an index-keyed pin silently moves.
Express a selection
Section titled “Express a selection”selection: { hourIndex, altitudeM? } passes the consumer’s selection — the
hour an inspector is reading, and optionally an altitude — into the build.
The scene resolves it against what it actually drew and reports the geometry
as scene.selection: the column, its centre line, and the nearest drawn barb
for the ring. renderSvg draws all three (wg-selection-* classes, themed
by --wg-selection), so the marker and the readout cannot disagree. It is
distinct from selectedHourIndex, which the scene computes itself (the
peak-W* column).
The resolver is also exported: resolveSelection(scene, { hourIndex, altitudeM? }) returns the same SceneSelection geometry from an
already-built scene — it is the very function buildScene calls — so a
consumer overlay that must not pay for a rebuild (a hover preview) draws
from geometry that cannot differ from the serializer-drawn pin.
The pointer wiring that feeds these queries — preview, pin, touch policy, and carrying a pin across model switches — is a consumer state machine, not scene data; Wire an inspector is the worked recipe.