Skip to content

Wire an inspector

An inspector — the readout that follows a pointer, pins on a click, and steps with the arrow keys — is two different kinds of code. The geometry questions (“which hour is under this pixel”, “which drawn barb is nearest”, “where does this instant fall”) are pure functions of the scene, and the package answers every one of them. The state between events — preview versus pin, what a touch does, what survives a model switch — is a small machine whose shape belongs to the consumer and its framework. The first production consumer’s machine is two-dimensional, never empty, and keyboard-driven; a second consumer’s will differ. This page is the recipe for wiring one, not a module to import.

A selection the scene resolved and the serializer drew

Pass selection to buildScene and the reference render marks it — the inspector and the pixels share one authority.

A rendered teaching windgram whose build received selection: { hourIndex: 3, altitudeM: 1750 }. The serializer drew the tinted selection column with its centre hairline, and a ring on the drawn wind barb the requested altitude snapped to. The scene's own computed best-hour highlight is visible on a different 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. 1Selection columnscene.selection.x · width · top · bottom: the tinted column and its span, strips to plot floor (wg-selection-column)
  2. 2Hairlinescene.selection.centerX: the column-centre time line (wg-selection-line)
  3. 3Barb ringscene.selection.barb: the requested altitude snapped to the nearest drawn barb, ringed at its drawn position (wg-selection-ring, themed by --wg-selection)
  4. 4Not the selectionscene.selectedHourIndex: the scene's own computed peak-W* highlight, a different fact with its own toggle
scene.selection resolved the ring to the drawn barb at 1750 m — the nearest DRAWN barb to the request, the same answer nearestDrawnBarb gives. The paler highlight at hour 5 is scene.selectedHourIndex, the computed peak-W* column: the two marks are different facts.Units scene px · altitude m

Every consumer needs the same three-step pipeline: client pixels into scene coordinates, an hour column, and a snap to something actually drawn. All three are package queries, so the whole resolver is a dozen lines with no renderer facts in it:

selection-at-point.ts
import type { MountRect, SceneGraph } from "windgram/scene";
import { clientPointToScene, hourIndexForX, nearestDrawnBarb } from "windgram/scene";
/** Keyed by validAt, not index — see "Carry or reset" below. */
export interface InspectorSelection {
validAt: string;
altitudeM: number | null;
}
export function selectionAtPoint(
scene: SceneGraph,
rect: MountRect,
clientX: number,
clientY: number,
): InspectorSelection | null {
const point = clientPointToScene(scene, rect, clientX, clientY);
if (point === null) return null; // zero-area rect: a hidden tab
const hourIndex = hourIndexForX(scene, point.x, { clamp: true });
if (hourIndex === null) return null; // empty scene
const { plotTop, plotHeight } = scene.scales;
const inPlot = point.y >= plotTop && point.y <= plotTop + plotHeight;
const barb = inPlot ? nearestDrawnBarb(scene, hourIndex, point.y) : null;
return {
validAt: scene.hourValidAts[hourIndex],
altitudeM: barb === null ? null : barb.altitudeM,
};
}

Three decisions in that code are worth making deliberately. The clamp means the strips and margins still select an hour — a pointer over the pressure strip is asking about that hour, not about nothing. The snap goes to drawn barbs: nearestDrawnBarb already knows about the barb stride, the min-gap thinning, and the surface row’s lifted position (scales.surfaceWindY), so the selection ring can never circle a glyph that is not there. And above or below the plot the selection degrades to the hour alone rather than inventing an altitude.

For continuous readouts — temperature, wind, lapse rate at the exact cursor altitude — call cursorReading(scene, point.x, point.y) with the same converted point. The interpolated reading and the discrete snap answer different questions; inspectors usually want both.

Preview, pin, and the touch policy

The state machine is small and consumer-owned; the package supplies the pure queries its transitions call.

A three-state diagram: Resting, Previewing, and Pinned. Pointer movement with a non-touch pointer previews; leaving the chart clears the preview; a click or tap pins from any state; clicking the pinned target again, or Escape, unpins; a model or day swap exits the machine entirely, where the consumer chooses reset or carry.

Resting, Previewing, and Pinned states with their transitionsRestingselection shown is thestored (or initial) onePreviewingconsumer overlay tracksthe pointer, nothing storedPinnedselection stored; rebuildwith the selection optionpointermove · not touchpointerleaveclick / tapclick / tap — a touch pointer pins without previewingclick the pinned target again · Escapere-pinmodel / day swapreset, or carry by validAt — consumer decides
Touch pointers skip the Previewing state — a finger cannot hover, so a tap pins directly. The swap edge is the carry-or-reset decision: key the stored selection by validAt and re-resolve it with hourIndexForValidAt, or reset, as the measured first consumer does.

The machine has three states and a policy per edge. Hover previews only for pointers that can hover: pointerType === "touch" skips straight to the pin, because a finger that must touch the chart to point at it should not fight a phantom hover state. Leaving the chart clears a preview but never a pin. Clicking the already-pinned target unpins; clicking anywhere else re-pins. Escape unpins. Written as a reducer it is a handful of cases over { selection, preview, pinned } — small enough that owning it outright costs less than adapting a shipped one to your framework’s rendering model, which is why it ships here as a recipe and not as code.

The worked example behind this page makes three further choices a second consumer might make differently, all consumer policy, none scene facts: its selection is never empty (it initializes to the first hour at the site’s altitude, so the inspector always reads a real place in the forecast); unpinning requires clicking the same hour and level, so a click at a different altitude re-pins instead; and the arrow keys form a second input axis — left and right step hours, up and down walk the drawn ladder from drawnBarbsForHour, with the readout’s aria-live enabled only while pinned so hover motion never spams a screen reader.

A pinned selection is worth a rebuild: pass it as the selection option and the reference serializer draws the column, hairline, and barb ring from the same scales as everything else — the figure above is exactly that output. Pixels and readout cannot disagree, and the marks retheme with one token (--wg-selection).

render-pinned.ts
import type { WindgramProfile } from "windgram/contract";
import { buildScene } from "windgram/scene";
import { renderSvg } from "windgram/svg";
export function renderPinned(
profile: WindgramProfile,
timeZone: string,
selection: { hourIndex: number; altitudeM?: number | null },
): string {
const scene = buildScene(profile, { timeZone, selection });
return renderSvg(scene, { idPrefix: "club-main" });
}

Pins change on clicks and key presses, so rebuilding on each is cheap. Hover previews fire per pointer event; if a rebuild per move measures too hot on your target hardware, draw the preview as a consumer overlay and reserve the scene option for the pin. Position the overlay with resolveSelection(scene, { hourIndex, altitudeM }) — the same function buildScene runs for its selection option — so the preview and the serializer-drawn pin resolve through one implementation and cannot disagree about where the selection is.

Hour windows renumber. The same afternoon hour is index 9 on one model’s window and index 3 on another’s, so an index-keyed pin silently moves when the consumer swaps models or days. Key stored selections by validAt and re-resolve against every freshly built scene:

carry-selection.ts
import type { SceneGraph } from "windgram/scene";
import { hourIndexForValidAt } from "windgram/scene";
export function carrySelection(
scene: SceneGraph,
stored: { validAt: string; altitudeM: number | null },
): { hourIndex: number; altitudeM: number | null } | null {
const hourIndex = hourIndexForValidAt(scene, stored.validAt);
if (hourIndex === null) return null; // the hour left the window
return { hourIndex, altitudeM: stored.altitudeM };
}

Whether to carry at all is a product decision, not a correctness one. The worked example deliberately resets its pin on every model and day switch and carries only overlay toggles — a pilot who turned on the thermal index is asking a question of the day, and the answer should survive the switch; a pin on 2 p.m. may not deserve to. If you do carry, carry by validAt as above, and decide explicitly what a null answer means for your inspector: fall back to the initial selection, or the nearest rendered hour.

For marks that live between columns — a “now” line, sunrise and sunset ticks — xForTime(scene, instant) interpolates between hour centres and is null outside the rendered window; xForTime(scene, instant, { clamp: true }) pins it to the frame edge instead, which is what a shading band that starts before the window wants. xForHour stays the right call for anything that names a whole column.