Skip to content

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.

Pressure kPa 90.3 90 Precip mm/h 0.5 0 Cloud % 100 0 H M L Layers % w* m/s 3 0 CAPE J/kg 1500 0 900m 2953ft 1668m 5472ft 2436m 7991ft 3203m 10510ft 3971m 13029ft 4739m 15548ft 10 11 12 13 14 15 16 17 18 19 11° 16° 23° 26° 24° 17° 13° launch 1050 m G7 G9 G11 G14 G22 G29 G32 G22 G16 G13 10° 20°
  1. 1Time–height plotscene.fields · scene.series · scene.barbs · scene.markers · scene.gusts: interpolated iso-band fields, derived-height lines, wind barbs, marker trains
  2. 2Altitude axesscene.axes.altitude · scene.axes.pressureAltitude: each tick carries y, labelMetres, and labelFeet; pressure ticks carry median level heights
  3. 3Hour labels and surface rowscene.axes.hours · scene.surfaceTemperatures: local hour ticks (hourLabel option) and the per-hour rounded temperature readout
  4. 4Metric stripsscene.strips[] — pressure · precipitation · cloudCover · cloudLayers · thermalStrength · cape: one MetricStrip per row with top, height, min/max, and line/area/band paths
  5. 5Launch linescene.launch: y, altitude, and label for the surveyed launch (launch 1050 m)
  6. 6Selected hourscene.selectedHourIndex: the day's peak-W* column, highlighted by the selectedHour overlay
Region boxes are positioned from this scene's own scales and strip geometry at build time — scene.scales places the plot, each MetricStrip carries its top and height, and the launch line and selected hour are scene fields, not renderer guesses.Units scene px · altitude m and ft · time UTC
build-scene.ts
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 };
}

Options accept one of three equivalent forms:

  • hourIndices: indices into profile.hours; this wins when both forms exist;
  • hours: hour objects matched back by validAt; or
  • hours: { 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.

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.

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.

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.

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.

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.

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.

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) and nearestDrawnBarb(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. Each BarbPlacement carries its hourIndex, data altitudeM, and a surface flag — the surface barb draws at scales.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 by validAt and re-ask after every rebuild: hour windows renumber, and an index-keyed pin silently moves.

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.