@noaa-gsl/wizard-charts
v1.2.1
Published
An extension of D3 for visualizing weather data in a variety of charts.
Readme
WIZARD Charts
WIZARD Charts is a React charting library built on top of D3 for weather and forecast-oriented visualizations.
Table of Contents
- Installation
- Release Notes
- Quick Start
- ChartContainer Props
- Dynamic Margins
- Data Model
- Options Overview
- Zoom
- Hover Readout
- Series Configuration
- Legend
- Combining Multiple Plot Types
- Per-Plot Defaults
- Axis Configuration
- Secondary Axes
- Utility Exports
- Notes and Gotchas
Installation
d3 is a peer dependency and must be installed alongside WIZARD Charts.
npm install @noaa-gsl/wizard-charts d3Release Notes
See CHANGELOG.md for user-facing updates by release.
Quick Start
Import the stylesheet once in your app entrypoint or global styles location:
import '@noaa-gsl/wizard-charts/styles.css';Render a ChartContainer with data and options:
import { ChartContainer } from '@noaa-gsl/wizard-charts';
const data = [
{
date: new Date('2026-01-01'),
temp: { mean: 30, p90: 38 },
},
{
date: new Date('2026-01-02'),
temp: { mean: 27, p90: 35 },
},
];
const options = {
series: [
{
type: 'line',
xKey: 'date',
yKey: 'temp.mean',
stroke: '#147AF3',
},
],
axes: {
x: { type: 'time' },
y: { type: 'linear', nice: true },
},
};
<ChartContainer
margin={{ left: 40, top: 'auto', right: 'auto', bottom: 'auto' }}
data={data}
options={options}
sx={{ border: '1px solid #737373' }}
/>;ChartContainer Props
type ChartContainerProps = {
height?: number | 'auto';
width?: number | 'auto';
margin?: {
top?: number | 'auto';
right?: number | 'auto';
bottom?: number | 'auto';
left?: number | 'auto';
};
data: unknown[] | Record<string, unknown[]>;
options: ChartOptions;
controller?: ReturnType<typeof useChartController>['controller'];
ReadoutComponent?: React.ComponentType<{
readout: ReadoutState;
options: ChartOptions['readout'];
}>;
children?: React.ReactNode;
className?: string;
sx?: React.CSSProperties;
};margin defaults to { top: 'auto', right: 'auto', bottom: 'auto', left: 'auto' }.
width and height default to 'auto' (100% of the parent container on that dimension). If you provide one or both as numbers, only those dimensions are fixed.
width and height define the chart's outer SVG box. Internal chart layout (scales, axes, and plot area) is measured from the SVG content box, so box-sizing: border-box with border and/or padding in sx is accounted for automatically.
Series rendering inside ChartContainer is clipped to the computed inner plot area (inside margins). This prevents plot layers from visually spilling into axis/legend space.
Dynamic Margins
Each margin side can be either:
- a number: fixed pixel margin for that side
'auto': measured from axis rendering requirements
Auto margins are computed independently per side (top, right, bottom, left) using the axis mapped to that side:
- axis line width (when
hasAxisLineis enabled) - outward tick length (negative tick length is treated as inward and does not add outside space)
- tick label padding
- measured tick label text bounds
- measured axis label text bounds from
axes.*.label
When options.legend.enabled is true and at least one visible series is eligible for the legend, bottom auto-margin also reserves space for the legend block below the primary x-axis.
Axis labels are sourced from axes.x.label, axes.x2.label, axes.y.label, and axes.y2.label.
Default behavior (all auto):
<ChartContainer data={data} options={options} />Mixed overrides (fixed left margin, other sides auto):
<ChartContainer
width={800}
margin={{ left: 48, top: 'auto', right: 'auto', bottom: 'auto' }}
data={data}
options={options}
/>Data Model
WIZARD Charts supports two input data shapes:
- Row-based arrays (default)
- Columnar object-of-arrays (auto-normalized)
Row-Based Data (Default)
Each array element maps to one position on the x-axis.
[
{
date: 1775075088409,
series1: {
mean: 31.45,
p10: 27.66,
p25: 30.6,
p50: 31.4,
p75: 31.82,
p90: 32.63,
},
series2: {
mean: 81.28,
p10: 81.18,
p25: 81.17,
p50: 81.5,
p75: 82.0,
p90: 81.69,
},
},
];Accessors and Dot Notation
Series values are read with keys like xKey, yKey, minYKey, etc.
Dot notation is supported:
xKey: 'date'yKey: 'series2.p50'
Columnar Data (Per-Series Override)
You can provide data directly on a series as an object-of-arrays. This overrides root-level data for that series.
{
type: 'line',
xKey: 'date',
yKey: 'mean',
data: {
date: [
new Date('2024-01-01'),
new Date('2024-01-02'),
new Date('2024-01-03'),
],
mean: [10, 20, 15],
},
}All column arrays must be the same length.
Options Overview
options drives how series and axes are rendered:
{
series: [],
axes: {
x: {
// optional reference lines for this axis
lineMarkers: [],
},
y: {
// optional reference lines for this axis
lineMarkers: [],
},
// optional secondary axes
// x2: {},
// y2: {},
},
legend: {
enabled: true,
gap: 8, // space above the legend
rowGap: 8, // space between rows when the legend wraps
itemGap: 16, // space between items
markerSize: 12, // size of shaded line, square or circle identifier
fontFamily: 'inherit',
fontSize: 12,
fontWeight: 500,
fontColor: 'currentColor',
colorbar: {
width: 120,
height: 10,
tickGap: 4,
tickFontSize: 10,
tickFontWeight: 400,
},
className: '',
sx: {},
},
zoom: {
enabled: true,
wheelEnabled: true,
dragEnabled: true,
panEnabled: true,
rightClickResetEnabled: true,
modifierKey: 'ctrl', // 'ctrl' | 'shift' | 'alt' | 'meta'
panCursor: 'move',
wheelZoomSpeed: 0.1,
minWindow: 0, // minimum x-domain span; 0 disables clamping
minDragPixels: 4,
dragBox: {
fill: '#147AF333',
stroke: '#147AF3',
strokeWidth: 1,
},
},
readout: {
hoverMode: 'local',
showVerticalLine: true,
showTooltip: true,
className: '',
sx: {},
tooltip: {
fill: '#111827',
fillOpacity: 0.95,
stroke: '#374151',
strokeWidth: 1,
cornerRadius: 6,
className: '',
sx: {},
},
displayUnits: true,
rowOrder: 'seriesIndex', // 'seriesIndex' | 'distance'
xEligibility: 'withinBounds', // 'withinBounds' | 'withinTolerance' | 'anyDistance'
xTolerance: undefined, // data-domain units; Date/time axes use milliseconds
missingSeries: 'placeholder', // 'placeholder' | 'omit'
missingText: '---',
boxPlotFields: 'auto', // 'auto' | key | key[]
areaFields: 'auto', // 'auto' | key | key[]
titleFormatter: null, // (value, context?) => string
valueFormatter: null, // (numericValue, context?) => string
padding: { x: 8, y: 8 }, // number | { x, y }
rowGap: 4,
title: {
fontSize: 12,
fontWeight: 700,
fontFamily: 'inherit',
fontColor: 'currentColor',
},
row: {
fontSize: 12,
fontWeight: 400,
fontFamily: 'inherit',
fontColor: 'currentColor',
},
showPointMarkers: true,
tooltipOffset: 12,
markerRadius: 4,
markerStroke: '#ffffff',
markerStrokeWidth: 1.25,
markerFill: 'none',
debug: false,
},
animationDuration: 1000, // ms (set 0 to disable animation)
}Zoom
Zoom currently supports wheel zoom, drag-select zoom-in, and modifier-drag panning, and updates x-domains only.
- Zoom is enabled by default through
options.zoom.enabled: true. - Wheel zoom is controlled by
options.zoom.wheelEnabled(trueby default). - Drag-select zoom is controlled by
options.zoom.dragEnabled(trueby default). - Modifier-drag panning is controlled by
options.zoom.panEnabled(trueby default). - Y-axis domains remain fixed while zooming; only
x/x2domains change. - Zoom focus is anchored to the pointer x-position in the plot area.
- Wheel zoom requires a modifier key by default (
modifierKey: 'ctrl'). - Panning uses the same
modifierKeyas wheel zoom and left-drag in the plot area. - While modifier is held over the inner plot area, cursor switches to
panCursor('move'by default). - Drag-select zoom uses plain left-click drag in the plot area by default.
- Right-click inside the inner plot area resets zoom back to the starting x-domain extent.
- Supported zoom axis scale types are
linearandtimeonly.
Zoom options:
enabled(boolean, defaulttrue): master toggle for zoom features.wheelEnabled(boolean, defaulttrue): enables/disables wheel zoom.dragEnabled(boolean, defaulttrue): enables/disables drag-select zoom.panEnabled(boolean, defaulttrue): enables/disables modifier-drag panning.rightClickResetEnabled(boolean, defaulttrue): enables/disables right-click zoom reset in the inner plot area.modifierKey('ctrl' | 'shift' | 'alt' | 'meta', default'ctrl'): required key while scrolling and modifier-drag panning.panCursor(string, default'move'): cursor shown while modifier is held over the inner plot area.wheelZoomSpeed(number, default0.1): zoom sensitivity. Larger values zoom faster per wheel step.minWindow(number, default0): minimum x-domain span. Use0to disable minimum-span clamping.minDragPixels(number, default4): minimum horizontal drag distance in pixels before drag zoom is applied.dragBox.fill(string, default'#147AF333'): fill color for the drag selection rectangle.dragBox.stroke(string, default'#147AF3'): stroke color for the drag selection rectangle.dragBox.strokeWidth(number, default1): stroke width in pixels for the drag selection rectangle.
Examples:
<ChartContainer
data={data}
options={{
...options,
zoom: {
enabled: true,
wheelEnabled: true,
dragEnabled: true,
panEnabled: true,
rightClickResetEnabled: true,
modifierKey: 'ctrl',
panCursor: 'move',
wheelZoomSpeed: 0.08,
minWindow: 0,
minDragPixels: 4,
dragBox: {
fill: '#147AF333',
stroke: '#147AF3',
strokeWidth: 1,
},
},
}}
/>Disable zoom:
<ChartContainer
data={data}
options={{
...options,
zoom: {
enabled: false,
},
}}
/>Programmatic Zoom Control
Use useChartController when controls outside the SVG need to read or change
zoom state.
import { ChartContainer, useChartController } from '@noaa-gsl/wizard-charts';
function ForecastChart({ data, options }) {
const { controller, zoomState } = useChartController({
onZoomStateChange: (nextZoomState, context) => {
console.log('zoom changed', context.source, nextZoomState);
},
});
return (
<>
<button type="button" onClick={() => controller.resetZoom()}>
Reset Zoom
</button>
<button
type="button"
onClick={() =>
controller.setZoomWindow({
center: new Date('2026-01-01T12:00:00Z'),
windowSize: 6 * 60 * 60 * 1000,
})
}
>
Center Six-Hour Window
</button>
<ChartContainer controller={controller} data={data} options={options} />
<output>{zoomState.center?.toString() || 'Full extent'}</output>
</>
);
}Controller commands:
controller.resetZoom(): resetx/x2zoom domains to the starting extent.controller.setZoomWindow({ center, windowSize }): center the zoom window on a data value. For time axes,centercan be aDateor timestamp andwindowSizeis milliseconds. For linear axes, both values are numeric domain units.controller.setZoomCenter(center): move the current zoom window while keeping the currentwindowSize.controller.setZoomStart(start): move the current zoom window so it starts atstartwhile keeping the currentwindowSize.controller.setZoomEnd(end): move the current zoom window so it ends atendwhile keeping the currentwindowSize.controller.getZoomState(): read the latest zoom state outside render.
Reactive zoom state is available from the hook return value and from
controller.zoomState. Use the hook return value when rendering UI so React
updates when zoom changes.
const { controller, zoomState } = useChartController();
<input
type="range"
min={zoomState.bounds.x?.[0]?.valueOf() ?? 0}
max={zoomState.bounds.x?.[1]?.valueOf() ?? 0}
value={zoomState.centerValue ?? 0}
onChange={(event) => controller.setZoomCenter(Number(event.target.value))}
/>;For zoom state fields, values without the Value suffix use the chart domain
type: Date objects for time axes and numbers for linear axes. Fields ending
in Value are always numeric, so they are usually the best fit for sliders,
math, comparisons, and telemetry. For time axes, numeric values are JavaScript
timestamps in milliseconds.
| Property | Meaning | Linear-axis example | Time-axis example | Recommended use |
| ------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| domain | Active zoom domains by axis. x and/or x2 contain [start, end]; axes without an active override are null. | { x: [20, 80], x2: null } | { x: [Date('2026-01-01T00:00Z'), Date('2026-01-01T06:00Z')], x2: null } | Low-level access to exact active domains. |
| start | Typed start of the primary active zoom domain (x, then x2). | 20 | Date('2026-01-01T00:00Z') | Display, formatting, and passing a domain value back to controller methods. |
| startValue | Numeric start of the primary active zoom domain. | 20 | 1767225600000 | Sliders, range math, comparisons, and telemetry. |
| center | Typed center of the primary active zoom domain. | 50 | Date('2026-01-01T03:00Z') | Display, formatting, and controller.setZoomCenter(center). |
| centerValue | Numeric center of the primary active zoom domain. | 50 | 1767236400000 | Slider values and numeric positioning controls. |
| end | Typed end of the primary active zoom domain. | 80 | Date('2026-01-01T06:00Z') | Display, formatting, and passing a domain value back to controller methods. |
| endValue | Numeric end of the primary active zoom domain. | 80 | 1767247200000 | Sliders, range math, comparisons, and telemetry. |
| windowSize | Numeric span of the primary active zoom domain. | 60 | 21600000 | Preserving, displaying, or calculating zoom window size. For time axes this is milliseconds. |
| bounds | Starting zoomable domain bounds by axis. These are the clamp limits for programmatic zoom, wheel zoom, drag zoom, and pan. | { x: [0, 100], x2: null } | { x: [Date('2026-01-01T00:00Z'), Date('2026-01-02T00:00Z')], x2: null } | Setting control min/max values and understanding clamp limits. |
| isZoomed | Whether any x-axis zoom domain override is active. | true | true | Enabling reset controls or showing zoomed state. |
| source | Last zoom update source: 'wheel', 'drag', 'pan', 'reset', 'programmatic', or null. | 'programmatic' | 'wheel' | Telemetry, debugging, and UI labels. |
When no zoom window is active, start, startValue, center, centerValue,
end, endValue, and windowSize are null.
Programmatic zoom control applies to mapped zoomable x-axes automatically (x
and/or x2). Supported scale types are linear and time.
Hover Readout
Tooltips render as HTML/CSS through a React portal into document.body.
The default tooltip and a custom ReadoutComponent share the same measured-box
positioning and containment. Vertical guide lines and point markers remain SVG.
Import the package stylesheet as shown in Quick Start; it includes the default
HTML layout and styling hooks.
options.readout.hoverMode supports two modes:
'local'(default): hover tracking is local to each chart and does not require any wrapper.'global': charts synchronize hover readout events throughHoverPointProvider.
options.readout.showVerticalLine controls whether a dashed vertical guide line renders at the hovered x position.
true(default): show vertical guide line.false: hide vertical guide line.
options.readout.showTooltip controls whether a tooltip box renders with readout values.
true(default): show tooltip.false: hide the default or custom hosted tooltip. Sampling, external readout subscriptions, vertical guides, and point markers remain independent.
options.readout.className and options.readout.sx apply to the readout overlay root <g> element.
- Use
classNamefor CSS-based overrides. - Use
sxfor inline style overrides.
options.readout.tooltip controls the HTML content surface for both default and
custom tooltips. The library owns a separate positioning/clipping container.
fill: background color.fillOpacity: background-only opacity (0..1), without fading text.stroke: border color.strokeWidth: border width in pixels.cornerRadius: border radius in pixels.className: class applied to the HTML tooltip surface.sx: inline CSS style object applied to the HTML tooltip surface.
Default appearance uses low-specificity CSS so consumer classes can override it;
sx takes precedence over ordinary stylesheet rules. Use backgroundColor to
override the background directly, or --readout-background and
--readout-background-opacity to reuse the default background-only alpha blending
(CSS color-mix(), supported by current browsers). Use CSS color for text, not SVG fill.
Default title/row font options still apply; custom components own their internal
typography and can use the supplied options to reuse those settings.
The portal copies the SVG's computed font family and color, but it does not
inherit ancestor-specific CSS selectors or other custom properties from the
chart's DOM ancestors. Put theme rules on the tooltip class or pass sx.
Position, clipping, and pointer transparency remain library-controlled.
options.readout.displayUnits controls whether series units are appended to readout values.
true(default): append units when available.false: suppress unit suffixes in readout rows.- Readout units use series
unitsfirst. - If a series
unitsis empty, axis-linked series (line,bar,boxPlot,area,areaStacked,circle) fall back to their mapped y-axisaxes.y.unitsoraxes.y2.units. matrix,heatmap, andcontourGridvalues do not fall back to axis units; provideseries.unitswhen you want value units in those readouts.- Units only render when both
readout.displayUnitsandseries.displayUnitsare true. - If no series units are provided, nothing is appended.
options.readout.rowOrder controls tooltip row ordering.
'seriesIndex'(default): order rows by series index inoptions.series.'distance': order rows by distance from the hover pointer.
options.readout.rowGap controls vertical spacing in pixels between tooltip rows and between the title and first row.
4(default).
options.readout.xEligibility controls whether a nearest-style series is eligible to show a real value at the hovered x-value.
'withinBounds'(default): show a real value only when the hovered x-value is within that series' own x extent.'withinTolerance': show a real value when the hovered x-value is within the series x extent or the nearest sample is withinxTolerance.'anyDistance': always show the nearest point, matching the previous behavior.series.readoutXEligibilityoverrides the readout-level setting for one series.
options.readout.xTolerance sets the tolerance for 'withinTolerance'.
- The value uses data-domain units. For linear forecast-hour axes,
2means two hours when your x values are hours. - For
Date/time axes, use milliseconds, for example2 * 60 * 60 * 1000for two hours. series.readoutXToleranceoverrides the readout-level setting for one series.
options.readout.missingSeries controls unavailable rows when a series is outside its x eligibility.
'placeholder'(default): keep the row and rendermissingText, preserving tooltip height as series become unavailable.'omit': remove unavailable rows from the readout.
options.readout.missingText controls placeholder text for unavailable rows.
'---'(default).
Unavailable placeholder rows do not render SVG point markers/circles, because no value is being shown for that series at the hovered x-value.
options.readout.boxPlotFields controls which box-plot values render in the readout row.
'auto'(default): uses median when available, then falls back to box midpoint.stringorstring[]: choose from'median','q1','q3','min','max'.- aliases:
'lower'->'q1','upper'->'q3'.
options.readout.areaFields controls which area values render in the readout row.
'auto'(default): usesywhen available, then falls back to band midpoint.stringorstring[]: accepts field ids.- built-in aliases:
'median'->'y','q1'->'lower','q3'->'upper'. - for plain
area, you can keep using legacy ids ('y','lower','upper','min','max') or define custom ids/labels on the series vialowerField,lowerLabel,medianField,medianLabel,upperField,upperLabel,minField,minLabel,maxField,maxLabel. - for custom area ids derived from data keys, full accessor keys are accepted as aliases (for example
series1.p25).
For areaStacked, areaFields accepts field ids derived from each band key plus optional medianField.
Band field ids are inferred from key suffixes:
series1.p05->p05,series1.p95->p95.Full keys are also accepted as aliases in
areaFields(for exampleseries1.p05).'auto'(default): orders fields from high to low: upper bounds in configured band order, then median, then lower bounds in reverse order.stringorstring[]: explicit field id order (for example['p05', 'p10', 'p25', 'p50', 'p75', 'p90', 'p95']).
When multiple fields are configured for boxPlot/area/areaStacked, the tooltip renders labeled values on indented sub-lines with an aligned value column.
The first valid configured field also drives marker y-position and distance ranking.
options.readout.titleFormatter optionally formats the x-value shown in the tooltip title.
null(default): uses built-in formatting.(value, context) => string: custom formatter.context:{ axisKey: 'x' | 'x2', isDate: boolean }.
Series readout controls:
series.readoutPrecision: optional fixed decimal precision used by default readout formatting.series.readoutXEligibility: optional per-series override forreadout.xEligibility.series.readoutXTolerance: optional per-series override forreadout.xTolerance.series.units: optional unit suffix for readout values.series.displayUnits: per-series unit toggle in readout (trueby default).
options.readout.valueFormatter optionally formats numeric values shown in tooltip rows.
null(default): uses built-in numeric formatting.(value, context) => string: custom formatter.value: numeric value after parsing.context:{ seriesType, fieldKey, fieldLabel, variant, units, displayUnits, readoutDisplayUnits, seriesDisplayUnits, readoutPrecision, defaultText, summary }wherevariantis'row'or'detail'.- If a formatter throws or returns
null/undefined, readout falls back to default formatting.
Default readout formatting uses series.readoutPrecision when provided; otherwise it uses the built-in adaptive formatter.
Example formatter usage:
{
readout: {
titleFormatter: timeFormatter('%m-%d %Hz'),
valueFormatter: (value, { fieldKey, defaultText }) => {
if (fieldKey === 'value') {
return `${value.toFixed(0)}%`;
}
return defaultText;
},
},
}options.readout.padding controls outer padding around the tooltip title and rows.
number: applies same padding to x and y.{ x, y }: sets horizontal and vertical padding independently.- default:
{ x: 8, y: 8 }.
options.readout.title and options.readout.row controls tooltip title and row text styling.
fontSizefontWeightfontFamilyfontColor
Row labels and values share the same row font settings.
options.readout.showPointMarkers controls point marker circles at readout points.
true(default): show marker circles.false: hide marker circles.- For
boxPlot,area, andareaStacked, markers follow configured readout fields: when multiple fields are selected (for example['q1', 'q3']), one marker is rendered per field. - For
areaandlineseries on continuous x-scales, marker x-position follows the raw x-scale value. - For
bar/boxPlotseries, marker x-position follows the rendered rectangle center (including alignment and width). - Unavailable placeholder rows do not render markers.
options.readout.tooltipOffset sets the horizontal pixel distance from pointer to tooltip anchor.
Marker style options:
markerRadiusmarkerStrokemarkerStrokeWidthmarkerFill
Readout overlays are constrained to chart/SVG bounds:
- Readout rendering only occurs while the pointer is inside the plot area.
- Tooltip containment uses the total SVG rectangle, including chart margins, intersected with the visible viewport when the chart is partially offscreen.
- Tooltip prefers the right of the pointer, flips left when appropriate, and clamps to all four edges. Near an edge, the box stops following the pointer while its sampled values continue updating.
- HTML/CSS lays out the content; only the final box is measured. Bounds and placement update on hover, chart/content resize, zoom, and scrolling.
- Long text wraps. Content larger than the available chart area is clipped; hosted tooltips are non-interactive and do not have scrollbars. Use an external readout for full-size or interactive content.
- Custom content is subject to the same limits. Separate portals created by a custom component are outside this containment contract.
- The body portal uses fixed positioning. Arbitrarily rotated charts, transformed
html/bodycontaining blocks, and top-layer dialogs require application-level handling (for example an external readout); there is no portal-target prop.
Custom HTML Tooltip
Pass a component, not its rendered element, to ChartContainer.ReadoutComponent.
It receives { readout, options } and may use React hooks. Define the component
outside the chart's render function to preserve its identity between updates.
Returning null renders no tooltip content. The component is mounted only while
there is an active sample and showTooltip is true; no portal is rendered during SSR.
function ForecastReadout({ readout }) {
return (
<section>
<strong>{readout.title}</strong>
{readout.rows.map((row) => (
<div key={row.seriesIndex}>
<span style={{ color: row.color }}>{row.label}</span>: {row.text}
</div>
))}
</section>
);
}
<ChartContainer
data={data}
options={{
...options,
readout: {
...options.readout,
tooltip: {
className: 'forecast-readout',
sx: { color: '#fff', borderRadius: 4, padding: 12 },
},
},
}}
ReadoutComponent={ForecastReadout}
/>;External Readout
useChartReadoutState(controller) subscribes to the same model without requiring
an SVG descendant or a portal. Keep the subscription in the component that renders
the readout, rather than the component that owns the chart. Hover updates do not
notify the controller's zoom subscribers.
import {
ChartContainer,
useChartController,
useChartReadoutState,
} from '@noaa-gsl/wizard-charts';
function ForecastPanel({ controller }) {
const readout = useChartReadoutState(controller);
if (!readout) return null;
return (
<aside>
<h3>{readout.title}</h3>
{readout.rows.map((row) => (
<p key={row.seriesIndex}>
{row.label}: {row.text}
</p>
))}
</aside>
);
}
function ForecastChart({ data, options }) {
const { controller } = useChartController();
return (
<>
<ChartContainer
controller={controller}
data={data}
options={{
...options,
readout: { ...options.readout, showTooltip: false },
}}
/>
<ForecastPanel controller={controller} />
</>
);
}Use one controller per chart. controller.getReadoutState() reads the latest
snapshot outside render. controller.subscribeReadout(listener) subscribes to
changes and returns an unsubscribe function. Readout and zoom subscriptions are
separate; useChartController() does not implicitly subscribe to readout updates.
Readout Model
ReadoutComponent receives this non-null model as readout. The hook returns the
model or null when inactive, outside the receiving chart's plot, without samples,
or after the chart unmounts. In global mode each chart samples its own series and
uses its own mapped coordinates. Treat snapshots and referenced data as read-only.
| Property | Meaning |
| -------------------------- | -------------------------------------------------------------------------------------------- |
| chartId, sourceChartId | Receiving chart and originating hover chart identifiers. |
| mode | Effective 'local' or 'global' mode. |
| xValue, axisKey | Hovered domain value and primary readout axis ('x' or 'x2'). Dates retain their type. |
| title | Title formatted using readout.titleFormatter. |
| local | { x, y } in the receiving SVG coordinate system, not tooltip position. |
| sourceClient | { x, y } browser coordinates of the originating pointer; not a target-chart portal anchor. |
| rows | Per-series samples ordered by readout.rowOrder. |
Each row contains:
| Property | Meaning |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| id, seriesIndex, seriesType, axisKeys | Configured series id (index fallback), index, plot type, and mapped axes. |
| label, color, units | Resolved series name, readout color, and units. |
| values | Plot-specific sampled values, such as y, area bounds, value, or wind speed/direction. These are not necessarily original raw data; existing wind precision rounding is preserved. |
| status | 'available' for rows with a real sampled value, or 'outOfRange' when the series is outside its x eligibility and rendered as a placeholder. |
| sampling | 'nearest' or 'interpolate'. Matrix uses nearest cell selection. |
| datum, dataIndex | Selected normalized source row/index where available; null for interpolated values and grid samples without a source index. A heatmap's nearest supporting sample is not claimed as its interpolated datum. |
| entries | Selected fields as { key, label, value }, before display formatting. |
| text, detailLines | Formatted summary and { key, label, text } detail lines, honoring existing formatters, units, precision and field selection. |
| sampleX, xDistanceValue, xExtent | Domain-space sampling metadata for x eligibility and custom out-of-range displays. |
| distancePx, xPixel, yPixel, markerPoints | Sample distance and SVG marker geometry; not HTML positioning coordinates. Placeholder rows have no marker geometry. |
Consumers may ignore all formatted fields and use values, entries, or datum
to build their own presentation. The public model is separate from debug payloads.
SVG Tooltip Migration
The default tooltip is now HTML; there is no legacy SVG tooltip mode.
readout.className/sx still target the SVG annotation group, while
readout.tooltip.className/sx target the HTML surface. Update selectors that
previously targeted tooltip g, rect, or text elements and replace SVG-only
style properties with their CSS equivalents. Practical appearance options and
formatters remain supported, but browser text layout is not pixel-identical to
the previous SVG layout. Serializing the chart SVG no longer includes its tooltip.
options.readout.debug controls console debug payload logging.
false(default): no readout debug logging.true: emit throttledconsole.debugpayloads (roughly every 100ms while hovering).
Local Mode
<ChartContainer
data={data}
options={{ ...options, readout: { hoverMode: 'local' } }}
/>Global Mode
Wrap charts that should share hover state in one HoverPointProvider group:
import { ChartContainer, HoverPointProvider } from '@noaa-gsl/wizard-charts';
<HoverPointProvider>
<ChartContainer
data={dataA}
options={{ ...optionsA, readout: { hoverMode: 'global' } }}
/>
<ChartContainer
data={dataB}
options={{ ...optionsB, readout: { hoverMode: 'global' } }}
/>
</HoverPointProvider>;If hoverMode is 'global' but no provider is present, charts fall back to local mode.
When readout.debug is enabled, debug payloads include hover coordinates plus nearest values per chart. Supported series for nearest-value debug output are line, bar, circle, area, areaStacked, boxPlot, matrix, heatmap, contourGrid, and windBarbs.
Series Configuration
Each entry in options.series renders one plot layer.
Common Series Options
{
type: 'line', // 'line' | 'bar' | 'boxPlot' | 'circle' | 'area' | 'areaStacked' | 'matrix' | 'heatmap' | 'contourGrid' | 'windBarbs'
name: undefined, // legend label; falls back to yKey
xKey: 'x',
yKey: 'y',
units: '', // optional readout unit suffix for this series
displayUnits: true, // toggle unit suffix in readout for this series
readoutPrecision: undefined, // optional fixed decimal precision for readout values
data: undefined, // optional per-series dataset
// set true to map this series to x2 / y2 instead of x / y
isSecondaryYAxis: false,
isSecondaryXAxis: false,
isVisible: true,
showInLegend: true,
stroke: null,
fill: null,
className: '',
sx: {},
}Legend
Legend behavior is enabled by default.
- The legend renders beneath the primary x-axis when at least one series is visible and
showInLegendis not false. - Set
options.legend.enabled: falseto hide legend rendering and skip legend auto-margin reservation. - Set
series.showInLegend: falseto hide a single series from legend output. - Series labels use
namefirst, then fall back toyKey. - Matrix, heatmap, and contourGrid series render a per-series colorbar legend entry instead of a marker.
Example:
{
legend: {
enabled: true,
},
series: [
{
type: 'line',
name: 'Mean Temperature',
xKey: 'date',
yKey: 'temp.mean',
},
],
}Matrix Data Shape (Long Form)
Use one row per cell for matrix charts.
[
{ date: new Date('2026-01-01'), category: 'model1', value: 62.3 },
{ date: new Date('2026-01-01'), category: 'model2', value: 58.1 },
{ date: new Date('2026-01-02'), category: 'model1', value: 64.8 },
];Suggested matrix series config:
{
type: 'matrix',
xKey: 'date',
yKey: 'category',
valueKey: 'value',
thresholds: [50, 58, 66, 74],
colors: ['#1f3b66', '#245f8f', '#2c8f9f', '#5ac18e', '#d4e77a'],
}Threshold bins are applied in ascending order using value <= threshold.
The color array should usually contain thresholds.length + 1 colors.
Stacked Bar Behavior
For bar series:
stacked: trueenables stacking with compatible bar series.isCumulative: truesums stacked values on the y-domain.- Stacking is computed by matching x positions and compatible bar-series settings.
{
type: 'bar',
xKey: 'date',
yKey: 'precip',
stacked: true,
isCumulative: true,
}Combining Multiple Plot Types
You can render multiple plot types in one chart by adding multiple entries to options.series.
- Each series entry renders one layer.
- You can mix
line,bar,boxPlot,area,areaStacked,circle,matrix, andheatmapin the same chart. contourGridis currently validated for mixing withline,area,areaStacked,circle, andwindBarbsin v1.- Render order follows array order: later series draw on top of earlier series.
Example:
const options = {
series: [
{
type: 'bar',
xKey: 'date',
yKey: 'hourlyPrecip.mean',
fill: '#72E06A88',
alignment: 'center',
},
{
type: 'line',
xKey: 'date',
yKey: 'accumulatedPrecip.mean',
fill: '#71da6e',
strokeWidth: 4,
},
{
type: 'circle',
xKey: 'date',
yKey: 'windDir.mean',
stroke: '#147AF3',
isSecondaryYAxis: true,
},
],
axes: {
x: { type: 'time' },
y: { type: 'linear' },
y2: { type: 'linear' },
},
};Tip: if mixed series use very different units, map one group to y2 using isSecondaryYAxis: true.
Per-Plot Defaults
Use these as references when building options.
Area
{
xKey: 'date',
minYKey: 'series1.p10',
q1YKey: 'series1.p25',
medianYKey: 'series1.p50',
q3YKey: 'series1.p75',
maxYKey: 'series1.p90',
lowerField: 'p25',
lowerLabel: '25th',
medianField: 'p50',
medianLabel: '50th',
upperField: 'p75',
upperLabel: '75th',
minField: 'p10',
minLabel: '10th',
maxField: 'p90',
maxLabel: '90th',
className: '',
fill: `${dataVizColors.tropicalIndigo}88`,
isVisible: true,
stroke: 'none',
strokeWhisker: dataVizColors.tropicalIndigo,
strokeWidth: 2,
sx: {},
}AreaStacked
areaStacked is designed for layered probabilistic bands in a single series.
{
xKey: 'date',
bands: [
{
lowerKey: 'series1.p05',
upperKey: 'series1.p95',
lowerLabel: '5th',
upperLabel: '95th',
fill: '#F6851122',
},
{
lowerKey: 'series1.p10',
upperKey: 'series1.p90',
lowerLabel: '10th',
upperLabel: '90th',
fill: '#F6851133',
},
{
lowerKey: 'series1.p25',
upperKey: 'series1.p75',
lowerLabel: '25th',
upperLabel: '75th',
fill: '#F6851155',
},
],
medianKey: 'series1.p50',
medianField: 'p50',
medianLabel: '50th',
medianStroke: dataVizColors.palatinateBlue,
medianStrokeWidth: 2,
medianIsVisible: true,
className: '',
isVisible: true,
fill: `${dataVizColors.tropicalIndigo}33`, // can also be per-band array
stroke: 'none', // can also be per-band array
strokeWidth: 1,
sx: {},
}Recommended band order is outer-to-inner (for example 5-95, 10-90, 25-75) so the smallest interval renders on top.
Bar
{
alignment: 'center',
cornerRadius: 2,
className: '',
fill: dataVizColors.tropicalIndigo,
isVisible: true,
paddingFactor: 0.8, // 0-1
stacked: false,
isCumulative: false,
stroke: 'none',
strokeWidth: 2,
sx: {},
}BoxPlot
{
xKey: 'date',
minYKey: 'series1.p10',
q1YKey: 'series1.p25',
medianYKey: 'series1.p50',
q3YKey: 'series1.p75',
maxYKey: 'series1.p90',
alignment: 'center',
cornerRadius: 2,
className: '',
fill: dataVizColors.tropicalIndigo,
isVisible: true,
paddingFactor: 0.8, // 0-1
stroke: 'none',
strokeMedian: '#ffffff88',
strokeWhisker: dataVizColors.tropicalIndigo,
strokeWidth: 2,
sx: {},
}Circle
{
className: '',
fill: dataVizColors.tropicalIndigo,
isVisible: true,
stroke: 'none',
radius: 4,
sx: {},
}Matrix
{
xKey: 'x',
yKey: 'y',
valueKey: 'value',
labelKey: undefined,
// optional; auto-generated from data range and colors.length when omitted
thresholds: undefined,
colors: ['#edf8fb', '#b2e2e2', '#66c2a4', '#2ca25f', '#006d2c'],
timeAnchor: 'center', // 'start' | 'center' | 'end'
cellPadding: 1,
cellWidthFactor: 1,
fill: dataVizColors.tropicalIndigo,
stroke: '#20202055',
strokeWidth: 0,
showLabels: false,
labelFormatter: null,
labelColor: '#f2f2f2',
labelFontSize: 10,
labelFontWeight: 600,
missingCellMode: 'sparse',
isVisible: true,
sx: {},
}Matrix placement is inferred from the resolved x-scale:
axes.x.type: 'band'=> band cells- non-band x scales (
time,linear) => continuous placement
Matrix option details:
| Property | Type | Default | Description |
| ----------------- | ------------------------------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| xKey | string \| (row) => any | 'x' | Accessor for x position. Supports dot notation (for example forecast.validTime). |
| yKey | string \| (row) => any | 'y' | Accessor for matrix row/category. Use categorical values when axes.y.type is band. |
| valueKey | string \| (row) => number | 'value' | Numeric value used for threshold binning and color selection. |
| labelKey | string \| (row) => any | undefined | Optional accessor for label text. Falls back to valueKey when omitted. |
| thresholds | number[] \| undefined | undefined | Optional threshold breakpoints. If omitted, thresholds are auto-generated from the matrix value extent using colors.length - 1 evenly spaced breaks. |
| colors | string[] | ['#edf8fb', '#b2e2e2', '#66c2a4', '#2ca25f', '#006d2c'] | Fill colors for threshold bins. Recommended length is thresholds.length + 1. |
| fill | string | dataVizColors.tropicalIndigo | Fallback fill color when value is non-numeric or colors/thresholds are not usable. |
| timeAnchor | 'start' \| 'center' \| 'end' | 'center' | Anchor for non-band x scales. start: tick at left edge of cell. center: tick at center. end: tick at right edge. Aliases left/right are also accepted. |
| cellPadding | number | 1 | Inner pixel padding on each side of each cell. Larger values create visible gaps between cells. |
| cellWidthFactor | number | 1 | Width multiplier for non-band x cells. Effective range is clamped to 0.05..1. |
| stroke | string | '#20202055' | Cell border color. |
| strokeWidth | number | 0 | Cell border width in pixels. |
| showLabels | boolean | false | Whether to render text labels centered in each cell. |
| labelFormatter | (labelValue, row) => string | null | Optional formatter for label text. Ignored when showLabels is false. |
| labelColor | string | '#f2f2f2' | Label text color. |
| labelFontSize | number | 10 | Label text size in pixels. |
| labelFontWeight | number \| string | 600 | Label text weight. |
| className | string | '' | Class applied to the matrix <g> container. |
| sx | object | {} | Inline style object applied to the matrix <g> container. |
| isVisible | boolean | true | Toggles matrix visibility while preserving layout/scales. |
| missingCellMode | string | 'sparse' | Reserved for future behavior. Current implementation renders only cells present in data. |
Additional notes:
- Matrix currently requires a band y-axis (
axes.y.type: 'band') for uniform row heights. - For non-band x scales, cell widths are based on local spacing between neighboring x-values and then adjusted by
timeAnchorandcellWidthFactor. - Matrix x-axis ticks for
timeandlinearscales use matrix data x-values by default unlessaxes.x.ticks.valuesis explicitly provided. - When
thresholdsis omitted, matrix computes evenly spaced thresholds across the data value range based oncolors.length - 1.
Heatmap
Heatmap renders continuous contour bands plus optional contour lines from scattered x/y/value points.
{
xKey: 'x',
yKey: 'y',
valueKey: 'value',
// optional; auto-generated from data range and colors.length when omitted
thresholds: undefined,
colors: ['#edf8fb', '#b2e2e2', '#66c2a4', '#2ca25f', '#006d2c'],
fill: '#d6e6f2',
resolution: 64,
interpolationMethod: 'idw',
idwPower: 2,
idwNeighbors: 16,
showContourFill: true,
fillOpacity: 0.85,
showContourLines: true,
contourLineColor: null,
contourLineWidth: 1,
contourLineOpacity: 0.85,
isVisible: true,
sx: {},
}Heatmap option details:
| Property | Type | Default | Description |
| --------------------- | --------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| xKey | string \| (row) => any | 'x' | Accessor for x coordinates. Supports dot notation. |
| yKey | string \| (row) => any | 'y' | Accessor for y coordinates. Supports dot notation. |
| valueKey | string \| (row) => number | 'value' | Numeric value used for interpolation and contour thresholds. |
| thresholds | number[] \| undefined | undefined | Optional contour levels. If omitted, thresholds are auto-generated from the value extent using colors.length - 1 evenly spaced breaks. |
| colors | string[] | ['#edf8fb', '#b2e2e2', '#66c2a4', '#2ca25f', '#006d2c'] | Color bins for threshold bands. Recommended length is thresholds.length + 1. |
| fill | string | '#d6e6f2' | Fallback base fill color when bins/colors are insufficient. |
| resolution | number | 16 | Interpolation grid resolution. Higher values produce smoother contours with higher cost. |
| interpolationMethod | 'idw' | 'idw' | Scattered-point interpolation method used before contour extraction. |
| idwPower | number | 2 | IDW distance exponent. Larger values emphasize nearby points. |
| idwNeighbors | number | 8 | Number of nearest points sampled for each interpolated grid node. |
| showContourFill | boolean | true | Render filled contour bands. |
| fillOpacity | number | 0.85 | Opacity applied to filled contour bands. |
| showContourLines | boolean | true | Render contour line overlays on top of fills. |
| contourLineColor | string \| null | null | Line color override. When null, each line uses its threshold-bin color. |
| contourLineWidth | number | 1 | Contour line width in pixels. |
| contourLineOpacity | number | 0.85 | Contour line opacity. |
| className | string | '' | Class applied to the heatmap container <g>. |
| sx | object | {} | Inline style object applied to the heatmap container <g>. |
| isVisible | boolean | true | Toggles heatmap visibility while preserving layout/scales. |
Heatmap notes:
- Heatmap requires continuous x/y scales. Use
axes.x.typeoflinearortime, andaxes.y.typeoflinear(log may work if your data domain is strictly positive). - Threshold color semantics match matrix: bins are interpreted in ascending order with
value <= thresholdfor boundary inclusion. - When
thresholdsis omitted, heatmap computes evenly spaced thresholds across the data value range based oncolors.length - 1. - For large datasets, start with moderate resolution values (for example
16) and increase only when you need smoother contours.
ContourGrid
ContourGrid renders contour fills/lines from structured gridded x/y/value data. Unlike heatmap, it does not run scattered-point IDW interpolation and is intended for fast, spatially consistent contouring on regular grids.
{
xKey: 'x',
yKey: 'y',
valueKey: 'value',
thresholds: undefined,
colors: ['#edf8fb', '#b2e2e2', '#66c2a4', '#2ca25f', '#006d2c'],
fill: '#d6e6f2',
showContourFill: true,
fillOpacity: 0.85,
showContourLines: true,
contourLineColor: null,
contourLineWidth: 1,
contourLineOpacity: 0.85,
readoutSamplingMode: 'interpolate', // 'interpolate' | 'nearest'
isVisible: true,
sx: {},
}ContourGrid option details:
| Property | Type | Default | Description |
| --------------------- | ---------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| xKey | string \| (row) => any | 'x' | Accessor for x coordinates. Supports dot notation. |
| yKey | string \| (row) => any | 'y' | Accessor for y coordinates. Supports dot notation. |
| valueKey | string \| (row) => number | 'value' | Numeric field used for contour thresholds and readout values. |
| thresholds | number[] \| undefined | undefined | Optional contour levels. If omitted, thresholds are auto-generated from the value extent using colors.length - 1 evenly spaced breaks. |
| colors | string[] | ['#edf8fb', '#b2e2e2', '#66c2a4', '#2ca25f', '#006d2c'] | Color bins for threshold bands. Recommended length is thresholds.length + 1. |
| fill | string | '#d6e6f2' | Fallback base fill color when bins/colors are insufficient. |
| showContourFill | boolean | true | Render filled contour bands. |
| fillOpacity | number | 0.85 | Opacity applied to filled contour bands. |
| showContourLines | boolean | true | Render contour line overlays on top of fills. |
| contourLineColor | string \| null | null | Line color override. When null, each line uses its threshold-bin color. |
| contourLineWidth | number | 1 | Contour line width in pixels. |
| contourLineOpacity | number | 0.85 | Contour line opacity. |
| readoutSamplingMode | 'interpolate' \| 'nearest' | 'interpolate' | Hover sampling mode for readout value lookup at pointer x/y. |
| className | string | '' | Class applied to the contourGrid container <g>. |
| sx | object | {} | Inline style object applied to the contourGrid container <g>. |
| isVisible | boolean | true | Toggles contourGrid visibility while preserving layout/scales. |
ContourGrid notes:
- ContourGrid assumes structured gridded data and currently supports continuous axes only (
linear,log,time). - Band scales are not supported for contourGrid.
- In mixed charts, contourGrid is currently validated for
line,area,circle, andwindBarbsoverlays in v1.
WindBarbs
WindBarbs renders meteorological wind barbs at each data point. It works both as a standalone scatter-style overlay (like circle) and as a gridded overlay paired with contourGrid.
Each datum requires an x position, a y position, a speed value, and a direction value. Speed maps to the barb shape in 5-unit buckets (unit-agnostic — the chart does not interpret or convert values; provide series.units for readout display).
Barb shapes are drawn as inline SVG paths. The color option controls the stroke and fill of all barb elements, so any valid CSS color value produces correctly colored barbs.
{
xKey: 'x',
yKey: 'y',
speedKey: 'speed',
directionKey: 'direction',
color: '#404040',
size: 20, // staff length in pixels
strokeWidth: 1.5,
isVisible: true,
className: '',
sx: {},
}WindBarbs option details:
| Property | Type | Default | Description
