# Zyplot > High-performance, natively rendered graphs for web, iOS, and Android, designed for developers and coding agents. One npm package, one serializable props contract, four renderers: ECharts and uPlot in the DOM, Swift Charts on iOS, and a Jetpack Compose `Canvas` on Android. There is no WebView on native. Twenty-one chart forms exist on all three platforms, and each native platform adds two of its own. - Documentation: https://zyplot.janblazej.dev/docs - Page index for models: https://zyplot.janblazej.dev/llms.txt - Repository: https://github.com/hzblj/zyplot - npm: `@hzblj/zyplot` This file is the whole documentation in one place. It is maintained by hand alongside the site, so it stays prose rather than a scrape. ## Installation ```bash npm install @hzblj/zyplot # or: yarn add @hzblj/zyplot · pnpm add @hzblj/zyplot · bun add @hzblj/zyplot ``` One package covers every platform. There is nothing else to add for iOS or Android — the native modules ship inside it. **No stylesheet import.** Zyplot includes its compiled styles through the JavaScript entry point. Your application does not need Tailwind CSS. For iOS and Android, autolinking picks the native code up; rebuild the native project after adding it. These are native modules, so Expo Go cannot load them — use a development build. ```bash yarn add @hzblj/zyplot npx expo prebuild npx expo run:ios npx expo run:android ``` **iOS 17 and newer.** Swift Charts features used by the renderer require a deployment target of 17.0. Set it with `expo-build-properties` if your app targets something lower. ### Entry points A plain `@hzblj/zyplot` import resolves to the renderer the current target needs, and gives you the chart forms that exist everywhere. The three subpaths name a platform outright, and are how you reach forms only one renderer has. | Entry | Target | What it is | | --- | --- | --- | | `@hzblj/zyplot` | any | Resolves per target: ECharts and uPlot on the web, the native module under React Native. Exposes the twenty-one shared forms. | | `@hzblj/zyplot/web` | web | The DOM renderer, and the only entry with `Chart.Frame`, `Chart.Legend` and a `.Skeleton` on every form. | | `@hzblj/zyplot/ios` | iOS | Shared forms plus `Chart.Range` and `Chart.Rule`, and the Swift Charts scrolling options on `xAxis`. | | `@hzblj/zyplot/android` | Android | Shared forms plus `Chart.Lollipop` and `Chart.Waterfall`, and Compose label overflow on both axes. | All four carry the builders and `useLastReading`, so a helper that assembles chart props is not tied to a platform. `useChartScrub` is on all four too: a finger on the native entries, a pointer on the web one, reported as the same phases. ### Quick start ```tsx import { Chart } from '@hzblj/zyplot' const series = [ { id: 'revenue', label: 'Revenue', values: [42, 56, 51, 72] }, { id: 'costs', label: 'Costs', values: [28, 34, 38, 41] }, ] export function Example() { return ( ) } ``` ## Data Chart data is plain serializable objects — no formatter callbacks and no render props, which is what lets a server component render a chart. Labels are already translated: this package never resolves an i18n key. **One contract, and every list is read-only.** These types live in the shared contract and are re-exported by all four entry points, so a value typed once can be handed to a web chart and a native one. Every prop that takes a list — `series`, `categories`, `data`, `nodes`, `cells`, `groups`, `rows`, `values` — accepts a `readonly` array, so an `as const` value or a `readonly`-returning selector needs no cast. The shapes below are written as plain arrays for legibility. ### ChartSeries Used by line, area, bar, stacked bar, radar and every other multi-series form. Each `values` entry aligns with the category at the same index, so the array is as long as `categories`. ```ts type ChartSeries = { /** Stable identity: the React key, and how hover correlates across charts. */ id: string /** Already-translated display name, used by the legend and the tooltip. */ label: string /** One value per category. null is a genuine gap, drawn as one, never as zero. */ values: (number | null)[] /** Palette slot, 1-based. Pin it when the caller can hide series. */ slot?: number /** Overrides the active palette for this series. */ color?: string } ``` An explicit `color` wins over the provider palette and the CSS variables. Omit `slot` and a series takes its index — correct for a fixed list, wrong the moment the list can be filtered, because the survivors get repainted and the reader has to re-learn the chart. ### ChartDatum A labelled scalar — the shape part-to-whole and ranked forms consume: pie, funnel, gauge segments, diverging bars. ```ts type ChartDatum = { id: string label: string value: number slot?: number color?: string } ``` ### Axes and number formatting `axis` is the on/off switch both cartesian axes share; `format` is one description of a number, applied to axis ticks, tooltips and direct labels alike, so they can never disagree. ```ts type ChartAxes = { x?: boolean y?: boolean } type ChartNumberFormat = { /** Fraction digits. Defaults to 0. */ decimals?: number /** BCP 47 tag for grouping and decimal separators. Defaults to the runtime locale. */ locale?: string /** Rendered before the number — a currency symbol, typically. */ prefix?: string /** Rendered after the number — a unit or a percent sign. */ suffix?: string } ``` ### ChartLegendItem What `Chart.Legend` takes when you place identity yourself. The color is already resolved — a swatch is a color, not a slot to look up. ```ts type ChartLegendItem = { id: string label: string color: string } ``` ### Specialized chart data Some forms take a shape-specific contract instead of `ChartSeries`, because their encoding is not "one value per category". ```ts /** Chart.Radar — one axis per row. Axes are scaled independently. */ type ChartRadarAxis = { label: string max: number } /** Chart.Heatmap — addressed by axis index; null renders empty, not as the ramp's low end. */ type ChartHeatmapCell = { columnIndex: number rowIndex: number value: number | null } /** Chart.Dumbbell — a before → after pair per row. */ type ChartDumbbellRow = { id: string label: string before: number after: number } /** Chart.Boxplot — the five-number summary, plus outliers you have already picked. */ type ChartBoxplotGroup = { id: string label: string min: number q1: number median: number q3: number max: number outliers?: number[] } /** Required, because "Q1" is not a word every reader of your product knows. */ type BoxplotLabels = { min: string q1: string median: string q3: string max: string } /** Chart.Sankey — nodes, and the weighted edges that address them by id. */ type ChartFlowNode = { id: string label: string slot?: number color?: string } type ChartFlowLink = { source: string target: string value: number } /** Chart.Treemap and Chart.Sunburst — leaves carry a value, parents sum their children. */ type ChartHierarchyNode = { id: string label: string value?: number children?: ChartHierarchyNode[] slot?: number color?: string } /** Chart.Scatter — an unordered two-measure space. size turns points into bubbles. */ type ChartScatterSeries = { id: string label: string points: Array<{ x: number y: number size?: number label?: string }> slot?: number color?: string } /** Chart.TimeSeries — parallel arrays, because that is what uPlot consumes. */ type ChartTimePoints = { /** Unix seconds, strictly ascending. */ timestamps: number[] /** One entry per series, each as long as timestamps. */ values: (number | null)[][] } ``` ### Candlestick data One entry per session. `volume` is what `showVolume` draws, and `timestamp` is only needed when something outside the chart has to line up with the session. ```ts type ChartCandlestickDatum = { id: string category: string open: number high: number low: number close: number volume?: number /** Unix seconds. */ timestamp?: number } type ChartCandlestickStyle = { upColor?: string downColor?: string neutralColor?: string /** Draws rising candles as outlines — the convention on most trading desks. */ hollowUp?: boolean /** Corner radius on the candle body. Rounds the wick's caps with it. */ candleRadius?: number /** Body width as a share of the slot a candle sits in. */ candleWidth?: number wickWidth?: number volumeUpColor?: string volumeDownColor?: string /** Share of the plot height the volume histogram takes. */ volumeHeightRatio?: number } ``` ### Axis options `axis` switches an axis off; `xAxis` and `yAxis` describe one. Both are read by line, area, bar, stacked bar and candlestick — the forms whose readers pin a domain, change the scale or annotate a value. The other forms take the visibility switch only. ```ts type ChartAxisOptions = { visible?: boolean label?: string /** "auto" | "category" | "linear" | "log" | "time" */ scale?: ChartAxisScale domain?: ChartAxisDomain format?: ChartNumberFormat grid?: boolean gridDash?: number[] labelRotation?: number /** Which side the axis is drawn on: "start" | "end". */ position?: ChartAxisPosition reversed?: boolean /** A hint, not a guarantee — the engine still picks readable ticks. */ tickCount?: number /** Exact ticks, when the reader is looking for specific ones. */ tickValues?: (number | string)[] } type ChartAxisDomain = { /** Pins the extent. Omit either end to keep it computed from the data. */ min?: number max?: number /** * Headroom kept beyond the data, as a fraction of its extent — 0.08 leaves 8% * clear at each end. Applies only to an end that is computed, so it combines * with a single pinned one. */ padding?: number } ``` **Reach for `padding` before pinning a domain.** Without it a line runs into the top and bottom of its own plot, which reads as clipped rather than as the highest and lowest reading. A pinned `min`/`max` fixes that too, but it has to be recomputed every time the data changes; a fraction does not. ### Annotations A union discriminated on `type`. Coordinates are `number | string`: a category name on a category axis, a value on a linear or time one. ```ts type ChartAnnotation = | ChartLineAnnotation | ChartRangeAnnotation | ChartPointAnnotation | ChartTextAnnotation /** A target, a threshold, a launch date. */ type ChartLineAnnotation = { type: 'line' id: string axis: 'x' | 'y' value: number | string label?: string color?: string /** Dash and gap lengths. Omit for a solid rule. */ dash?: number[] /** Rule thickness. Default 1. */ width?: number } /** A shaded span — a quarter, an incident window, a tolerance band. */ type ChartRangeAnnotation = { type: 'range' id: string axis: 'x' | 'y' start: number | string end: number | string label?: string color?: string opacity?: number } type ChartPointAnnotation = { type: 'point' id: string x: number | string y: number label?: string color?: string symbol?: ChartSymbol } type ChartTextAnnotation = { type: 'text' id: string text: string x?: number | string y?: number color?: string } ``` ### Interaction `interaction` is what the chart does on its own; `onInteraction` is how your code hears about it. The event is one flat serializable shape for every form, so a handler written for a bar chart works on a line chart. ```ts type ChartInteraction = { /** "axis" | "nearest" | "series" | "none" */ hover?: ChartHoverMode /** "both" | "x" | "y" | "none" */ crosshair?: ChartCrosshairMode tooltip?: boolean /** "single" | "multiple" | "none" */ selection?: ChartSelectionMode pan?: boolean zoom?: boolean /** How far a hovered mark grows. */ highlightScale?: number /** How far the rest fades while one mark is hovered. */ dimOpacity?: number /** A colour the read mark is lifted towards, so it reads as lit rather than as undimmed. */ highlightColor?: string /** How far towards it, 0–1. Below 1 the mark's own colour still reads. Default 1. */ highlightBlend?: number /** Native only — the web has no honest equivalent. */ haptics?: boolean } type ChartInteractionEvent = { seriesId?: string category?: string value?: number x?: number y?: number /** Unix seconds, on the time-based forms. */ timestamp?: number /** Pointer position in the chart's own coordinate space. */ nativeX?: number nativeY?: number /** Position of the read mark in the chart's own data order. */ index?: number /** "began" | "changed" | "ended" | "layout" */ phase?: ChartInteractionPhase /** Where the plot and its annotations sit, on the "layout" phase. */ geometry?: ChartGeometry } ``` **A handler is a client boundary.** Everything else on a chart is serializable data, so a server component can render it — `onInteraction` is the one prop that cannot cross, and the file that passes it needs `"use client"`. ### Plot, series style and animation `surface` is the box the chart sits in; `plot` is the drawing area inside it. `seriesStyles` is keyed by `ChartSeries.id`, so a style survives reordering and filtering the way `slot` does for color. **These are the floor, not the ceiling.** The `animation`, `seriesStyles`, `interaction`, `annotations`, `xAxis` and `yAxis` props are all declared with the wider `Native*` shapes described under "The presentation vocabulary" — on every entry point, the web one included. ```ts type ChartPlotStyle = { backgroundColor?: string borderColor?: string borderWidth?: number borderRadius?: number /** Clips marks to the plot area — the honest choice when a domain is pinned. */ clip?: boolean padding?: number | { top?: number; right?: number; bottom?: number; left?: number } } type ChartSeriesStyle = { color?: string strokeWidth?: number strokeDash?: number[] fillOpacity?: number opacity?: number /** "circle" | "diamond" | "square" | "triangle" | "none" */ symbol?: ChartSymbol symbolSize?: number } type ChartAnimation = { enabled?: boolean /** The entrance. Turn it off for a chart that re-renders on every keystroke. */ initial?: boolean /** The transition when data changes under a mounted chart. */ updates?: boolean duration?: number /** How long the entrance waits before it starts. */ delay?: number /** "linear" | "ease-in" | "ease-out" | "ease-in-out" | "spring" */ easing?: ChartAnimationEasing } ``` ## Theming `Chart.Provider` scopes a palette to a subtree. It writes what you pass as `--zyplot-*` custom properties on its own wrapper, and the charts inside read the resolved values back off the DOM. A key you leave out keeps the stylesheet's value, including that value's dark variant. **A theme key holds in both modes.** The provider writes inline custom properties, which outrank the stylesheet's light and dark rules alike — so a color passed here is the color in both. When a palette has to change with the mode, set it in CSS instead. ```tsx ``` ### The theme contract Every key is optional and takes any color the browser can resolve. There is no `background` here — the box a chart sits in is `surface`. ```ts type ChartProviderTheme = { colors?: { /** Slots 1…7, in order. A series takes one by index or by its own slot. */ categorical?: readonly string[] /** Low → high, five steps. Heatmap, treemap, sunburst. */ sequential?: readonly string[] /** Signed scales: diverging bars, and any positive/negative encoding. */ diverging?: { negative?: string negativeSoft?: string neutral?: string positive?: string positiveSoft?: string } /** The de-emphasis grey — every series that is context rather than subject. */ muted?: string axis?: string grid?: string /** Axis and data labels. */ label?: string negative?: string positive?: string /** Tooltip fill. */ surface?: string /** Tooltip hairline. */ border?: string /** The unfilled part of a gauge or a meter. */ track?: string } typography?: { /** A resolved family name. A canvas cannot read var(--font-sans). */ fontFamily?: string } } ``` **The provider's shape is `ChartProviderTheme`.** It is the full palette, because the provider writes CSS variables and the stylesheet has one for each of these. It is also a superset of the `ChartTheme` a single chart takes, so one object can be passed to both. **Seven and five.** Only the first seven `categorical` entries and the first five `sequential` steps are ever read. An eighth series color is one no color-blind reader can separate from a slot that already exists, so the eighth series is an "other" bucket, a small multiple, or a second encoding through `texture` — not a longer palette. ### A theme on one chart Every chart also takes a `theme` of its own, on all four entry points. This one is `ChartTheme`, the portable subset: every key on it is a key each of the four renderers draws with, so a theme written against it works unchanged on the web, on iOS and on Android. **How it meets the provider's is not the same on both.** On the web the provider's keys are CSS variables the chart has already resolved, so a chart's own `theme` lands over them key by key — one colour here does not drop the rest. On native there are no variables to resolve against: a chart that passes a `theme` replaces the provider's outright, so restate the keys you still want, or leave the theme to the provider and style the chart with `seriesStyles` and `surface` instead. `surface` does merge key by key on both. ```ts import type { ChartTheme } from '@hzblj/zyplot' type ChartTheme = { colors?: ChartThemeColors typography?: ChartTypography } type ChartThemeColors = { axis?: string /** Series colours, in the order series take them. */ categorical?: readonly string[] grid?: string label?: string /** The negative half of a signed scale: a losing bar, a falling candle. */ negative?: string positive?: string /** Tooltip fill. */ surface?: string /** The unfilled part of a gauge or a meter. */ track?: string } /** The one colour only a native chart paints for itself. */ type NativeChartTheme = { colors?: ChartThemeColors & { background?: string } typography?: ChartTypography } ``` The two wider shapes build on that subset rather than beside it. `ChartProviderTheme` adds the palettes and greys only a CSS variable can carry; `NativeChartTheme`, which the native charts and the native provider take, adds the chart's own `background`. Both are supersets, so one `ChartTheme` value satisfies all three props. **A chart background is a surface, not a theme, on the web.** There is no `--zyplot-color-background`: in the DOM the box a chart sits on is `surface.background`, which exists on native too. Reach for that when one object has to dress a chart on every platform. **`typography.fontFamily` takes a resolved family name.** Each platform looks it up the way its own text does: the DOM through `--zyplot-font-family`, iOS through the registered-font lookup behind `UIFont(name:)`, Android through React Native's font manager — which covers `assets/fonts`, `res/font` and anything `expo-font` loaded at runtime. A family that works in a `` works in a chart; one the app never shipped falls back to the platform font on all three. ### The chart surface `theme` answers "what colour is this series"; `surface` answers "what does the container look like". A chart's own `surface` merges over the provider's key by key. ```tsx ``` | Prop | Type | Default | Description | | --- | --- | --- | --- | | `background` | `string` | — | Fill behind the plot. Any CSS or hex colour. | | `border` | `{ color?: string; width?: number }` | — | Outline around the container. | | `cornerRadius` | `number` | `0` | Corner rounding, in px on web and points on native. | | `padding` | `number \| ChartSurfacePadding` | — | A number applies to all four sides; the object form takes horizontal, vertical and the individual sides, most specific winning. | **The same four keys everywhere.** Only properties that mean the same thing to a `div`, a SwiftUI view and a Compose `Canvas` live here. ### Provider props | Prop | Type | Default | Description | | --- | --- | --- | --- | | `children` * | `ReactNode` | — | Charts rendered inside the scope. | | `theme` | `ChartProviderTheme` | — | Scoped color and typography overrides. A superset of a chart's own `ChartTheme`. | | `surface` | `ChartSurface` | — | Container treatment every chart in the subtree inherits, merged key by key with the chart's own. | | `colorMode` | `"inherit" \| "light" \| "dark" \| "system"` | `"inherit"` | How the light/dark palette is resolved for the subtree. | | `className` | `string` | — | CSS class on the wrapper element the provider renders. | **The provider renders an element.** The custom properties have to land somewhere, so the scope is a real `div` in your layout rather than context alone. ## Light and dark mode Both palettes ship in the stylesheet. The dark one is keyed off `.dark` or `data-theme="dark"` on the document root — the two conventions Tailwind and next-themes already write — and a root that pins neither falls back to `prefers-color-scheme`. A project doing either needs no chart-specific wiring. ### Resolution `Chart.Provider` pins a subtree instead. Its `colorMode` lands as `data-zyplot-color-mode` on the wrapper, and the stylesheet resolves the palette from there. | Value | Resolves from | Description | | --- | --- | --- | | `"inherit"` (default) | document root | Takes whatever the document root resolved to, including the OS fallback. Charts outside a provider behave this way too. | | `"light"` | pinned | The light palette regardless of the root, plus `color-scheme: light`. | | `"dark"` | pinned | The dark palette regardless of the root, plus `color-scheme: dark`. | | `"system"` | media query | Follows the OS through `prefers-color-scheme`, ignoring the document root. | On native, `colorMode` is per chart: there is no cascade to inherit through, so `Chart.Provider` scopes `surface` and `theme` but not the color mode. Its default there is `"system"`. **Switching repaints in place.** Because canvas colors are read off the DOM, every mounted chart watches `class`, `data-theme`, `data-zyplot-color-mode` and `style` on the root — plus the `prefers-color-scheme` query — and repaints from the new values. No remount. ### The CSS contract These names are the public API; the Tailwind tokens behind them are not. The values below are the light defaults, and every color among them has a dark counterpart in the stylesheet. ```css :root { /* Categorical: slots 1…7, in the order series take them. */ --zyplot-color-categorical-1: #4400fc; --zyplot-color-categorical-2: #0092de; --zyplot-color-categorical-3: #ff5700; --zyplot-color-categorical-4: #9c74ff; --zyplot-color-categorical-5: #00a546; --zyplot-color-categorical-6: #006fac; --zyplot-color-categorical-7: #ff133c; /* Sequential: low → high. Heatmap, treemap, sunburst. */ --zyplot-color-sequential-1: #b89bff; --zyplot-color-sequential-2: #9c74ff; --zyplot-color-sequential-3: #7135ff; --zyplot-color-sequential-4: #4400fc; --zyplot-color-sequential-5: #2f00ae; /* Diverging: signed scales. */ --zyplot-color-diverging-negative: #d23100; --zyplot-color-diverging-negative-soft: #ff7d4f; --zyplot-color-diverging-neutral: #d9d9d9; --zyplot-color-diverging-positive-soft: #59c4fd; --zyplot-color-diverging-positive: #006fac; /* Chrome. */ --zyplot-color-axis: #a6a6a6; --zyplot-color-grid: #f5f5f5; --zyplot-color-label: #666666; --zyplot-color-muted: #808080; --zyplot-color-surface: #fcfcfc; --zyplot-color-border: #f5f5f5; --zyplot-color-track: #ebebeb; --zyplot-font-family: inherit; } /* Only the keys you actually change need restating per mode. */ [data-theme='dark'] { --zyplot-color-categorical-1: #7135ff; --zyplot-color-grid: #212121; } ``` The font is inherited from the page. Set `--zyplot-font-family` only when charts should use a different stack, and give it a resolved family name — a canvas cannot read another variable. **A page with no font of its own gets a system stack.** Inheritance is the mechanism, so when nothing up the tree declares a font the browser answers with its own serif, and a chart has no business painting Times beside text that is not. Zyplot compares what it inherited against that untouched default and falls back to `system-ui` and the platform stack behind it. React Native Web is the case this exists for: its reset puts no font on the document and styles each `` on its own, so a chart has nothing to inherit however deeply it looks. **Wide-gamut values are safe.** On a P3 display these resolve to `color(display-p3 …)`, which ECharts' own parser rejects. Every color is normalized to sRGB on the way to the canvas. ## Builders Every prop is a plain object, and writing one by hand stays supported. The builders exist for the handful of shapes where hand-writing is genuinely error-prone: a union you have to remember the discriminant for, a record whose key repeats an id declared elsewhere, a type that permits two fields only one variant reads. They return the same plain objects, so they compose, spread and serialize. ```ts import { // Builders with variants, one function per variant. annotation, axis, marker, reveal, // A series and its styling, declared together. series, seriesProps, // Typed passthroughs for the groups with nothing to get wrong. animation, format, glow, halo, interaction, plot, seriesStyle, surface, theme, } from '@hzblj/zyplot' ``` They are on every entry point, and every renderer draws what they describe: `glow`, `halo`, `badge`, `pulse` and `scrubOpacity` all land in the DOM as well as on a native canvas. That includes the `axis` builders — `NativeChartAxisOptions` is what `xAxis` and `yAxis` take everywhere, so an overlaid axis works on a web chart too. ### series and seriesProps `seriesStyles` is a record keyed by `ChartSeries.id`, which means styling a series normally spells its id twice, with nothing checking the two agree. A typo does not fail: it silently drops the styling. `series` declares both in one place and `seriesProps` splits the list into the two props the chart takes. ```tsx const lines = useMemo( () => seriesProps([ series({ color: '#ff3b4a', id: 'price', label: 'Price', style: { glow: glow({ opacity: 0.16, radius: 7 }), strokeWidth: 2.3 }, values: range.values, }), ]), [range] ) ``` **Hold the result across renders.** `seriesProps` rebuilds both props on every call, and a chart whose props change identity re-serializes its whole dataset — over the bridge, on native. Wrap it in `useMemo` keyed on the data. ### annotation | Builder | Returns | Description | | --- | --- | --- | | `annotation.line` | `NativeChartLineAnnotation` | A reference line: a target, a threshold, a launch date. Takes `axis` and `value`, plus `dash` and `width`. | | `annotation.range` | `ChartRangeAnnotation` | A shaded span. Takes `start` and `end`. | | `annotation.point` | `NativeChartPointAnnotation` | A single marked point, placed by coordinate. Takes `x` and `y`. | | `annotation.text` | `ChartTextAnnotation` | Free text on the plot. Omit `x` and `y` and the renderer places it. | ```tsx const baseline = (value: number, label: string) => annotation.line({ axis: 'y', dash: [1, 4], id: 'baseline', label, labelBackground: '#000000', labelPosition: 'auto', scrubOpacity: 0, value, width: 1.5, }) const marketOpen = (category: string) => annotation.line({ axis: 'x', badge: '↑', dash: [2, 4], id: 'open', size: 18, value: category }) ``` ### axis One builder per axis position. `start` and `end` put the labels in a gutter on that side; `overlay` puts them inside the plot against its trailing edge and reserves no gutter, which keeps the full width for the marks. `labelInset` is `overlay`'s alone. | Builder | Description | | --- | --- | | `axis.start` | Labels in a gutter on the leading side — the left of a y axis, the bottom of an x axis. | | `axis.end` | Labels in a gutter on the trailing side. | | `axis.overlay` | Labels inside the plot, no gutter reserved. The only position that takes `labelInset`. | ```tsx const priceAxis = (domain: { max: number; min: number }) => axis.overlay({ domain, format: { decimals: 2 }, grid: false, labelInset: 22, labelSize: 13, ticks: false, tickValues: [domain.min, domain.max], }) ``` **They describe the wider axis, and it is not native-only.** What these return is a `NativeChartAxisOptions`, which is what `xAxis` and `yAxis` take on every entry point — so the overlaid position, `labelInset`, `labelSize` and `ticks` all work on a web chart too. Plain `ChartAxisOptions` objects stay valid everywhere, because the wider type is a superset. ### reveal The first-render entrance, passed as `animation.reveal`. Only a traced entrance has a frontier to light, so `flashColor`, `trackColor` and `startOpacity` are `draw`'s alone. | Builder | Description | | --- | --- | | `reveal.draw` | Traces the marks along the x axis. Takes the flash, track and easing fields. Forms with no direction fall back to a fade. | | `reveal.fade` | Fades the marks in. Takes duration and easing only. | | `reveal.none` | No entrance. Takes nothing. | ```tsx const arrival = animation({ reveal: reveal.draw({ duration: 420, flashColor: '#ffe3e8', flashDuration: 480, flashEasing: 'ease-in-out', flashHold: 220, trackColor: '#8a8a8a', trackOpacity: 0.4, }), updates: false, }) ``` ### marker How the mark under the finger is picked out, passed as `interaction.marker`. `span` is how far a segment reaches along the line and means nothing to a dot; `size` is a dot's diameter and means nothing to a segment. | Builder | Description | | --- | --- | | `marker.point` | A dot on the mark. Takes `size`; rejects `span`. | | `marker.segment` | Brightens the stretch of line around the mark and blooms behind it. Takes `span`; rejects `size`. | ```tsx const scrubbing = interaction({ crosshair: 'x', crosshairStyle: { color: '#ffffff', width: 1 }, dimOpacity: 0.38, haptics: true, hover: 'nearest', marker: marker.segment({ color: '#ffffff', glow: glow({ color: '#ff3b4a', opacity: 0.32, radius: 42 }), span: 2, }), }) ``` **A marker is not a tooltip.** It says only which mark is being read, never what it says — so use it when the value is shown outside the plot, and pair it with `useChartScrub`. ### The passthroughs Identity functions for the option groups with no variants to get wrong. What they add is a name to import and hold, so a preset can be declared away from the chart that consumes it. ```ts // chart-style.ts — declared once, imported by both the line and the candlestick chart. export const chartTheme = theme({ colors: { label: '#8a8a8a' } }) export const priceFormat = format({ decimals: 2, locale: 'en-US' }) export const card = surface({ background: '#0b0b0b', cornerRadius: 16, padding: 12 }) export const priceLine = seriesStyle({ glow: glow({ opacity: 0.16, radius: 7 }), strokeWidth: 2.3 }) ``` `animation`, `interaction`, `seriesStyle`, `plot`, `surface`, `theme` and `format` all take their own option group. `glow` is a bloom behind a mark, in the mark's own colour unless told otherwise; `halo` is a hard disc behind a point, so a small bright dot can sit in a larger ring. Both are drawn on every platform. **There is no builder for a pulse.** It is the one nested object that also takes a `boolean`: `pulse: true` is the whole of what most live points want, and `annotation.point` already types the object form for the ones that tune the rhythm. ## The web renderer `@hzblj/zyplot/web` draws in the DOM. A plain `@hzblj/zyplot` import already resolves here on every target except React Native. It is the only entry point that carries `Chart.Frame`, `Chart.Legend` and a `.Skeleton` on every form. `Chart.Provider` exists on every entry point, but only here does it take `colorMode` and a `className`. ### What draws what Component names are the form, not the engine. `Chart.Line` and `Chart.TimeSeries` both draw a line — you pick between them on point count, never on renderer. | Engine | Kind | Covers | | --- | --- | --- | | ECharts | canvas | Eighteen of the twenty-one forms, Line through Treemap. Series types are registered per chart file, so a page with one line chart does not ship the sankey, sunburst and boxplot code. | | uPlot | canvas | `Chart.TimeSeries` and `Chart.Sparkline`. Tens of thousands of points, or forty sparklines in a table. | | Plain DOM | no engine | `Chart.Meter` is a filled track — two elements, `role="meter"` and no engine, so it is the only form whose final markup is painted on the server instead of after a first read off the document. | ### Text stays in the DOM Only marks and axis ticks are painted into the canvas. The legend is React: every chart with two or more series renders one on its own, a single series never does, and the labels stay selectable, translatable and reachable by a screen reader. ### Server components Chart props stay serializable — no formatter callbacks, no render props — so a server component can render a chart without a client boundary of its own. Anything that would have been a callback is expressed as data instead, which is what `ChartNumberFormat` is for. **Colors are read off the document.** A canvas takes color as a string, so each chart reads the resolved `--zyplot-*` values from the DOM after mounting and repaints when they change. Until that first read there is nothing honest to paint, so a chart shows its skeleton for a frame even with `isLoading` false. ## Frame and legend `Chart.Frame` is the card a chart can sit in: a title, a description, one row for filters, and a caption underneath for the source or the caveat. Web only. ```tsx ``` The header only exists when at least one of `title`, `description` and `actions` is set. **Frame is not `surface`.** The frame is a card with type in it, rendered around the chart; `surface` is the box the plot itself is painted on, and it exists on native too. | Frame prop | Type | Description | | --- | --- | --- | | `children` * | `ReactNode` | Chart or composed visualization content. | | `title` | `string` | Heading rendered above the chart. | | `description` | `string` | Supporting text below the title. | | `actions` | `ReactNode` | Filters and controls aligned with the heading. | | `caption` | `string` | Source, method or caveat below the chart. | A chart renders its own legend from two series up, and none for a single one, so `Chart.Legend` is for the surface that places identity itself — one legend above a row of small multiples, or a legend that doubles as a series filter. It takes `items` (`ChartLegendItem[]`, required) and `className`. ## iOS and Android Zyplot ships an Expo module that draws with the platform's own graphics stack — Swift Charts on iOS, Jetpack Compose `Canvas` on Android — behind the same `Chart` namespace the web renderer exposes. There is no WebView. ### Platform-specific files A few forms exist on one platform only, because the underlying renderer has a mark the other has no honest equivalent for. They are not on the shared namespace. Instead, give the component one file per platform and let Metro choose. ```text forecast.ios.tsx imports @hzblj/zyplot/ios forecast.android.tsx imports @hzblj/zyplot/android forecast.tsx optional web or fallback version ``` ```tsx // forecast.ios.tsx import { Chart } from '@hzblj/zyplot/ios' export const Forecast = ({ bands }: ForecastProps) => ( ) ``` **Keep a base file for TypeScript.** `tsc` does not know about platform extensions, so `./forecast` needs a plain `forecast.tsx` to resolve to. It doubles as the web version, or returns `null` if the component is native-only. ### Chart coverage | Group | Platforms | Forms | | --- | --- | --- | | Cartesian | web · iOS · Android | Line, Area, Bar, StackedBar, TimeSeries, Sparkline, Scatter, Histogram | | Radial and flow | web · iOS · Android | Pie, Gauge, Meter, Radar, Sunburst, Treemap, Funnel, Sankey | | Statistical and finance | web · iOS · Android | Candlestick, Boxplot, DivergingBar, Dumbbell, Heatmap | | iOS extensions | iOS only | `Chart.Range`, `Chart.Rule` | | Android extensions | Android only | `Chart.Waterfall`, `Chart.Lollipop` | Most forms take exactly the props documented under Charts. Eleven differ, and the compiler says so — these are separate prop types, not one type with fields the native renderer drops. | Form | On native | Note | | --- | --- | --- | | `Chart.Funnel` | `stages` → `data` | Same `ChartDatum[]`, same order. | | `Chart.Sunburst` · `Chart.Treemap` | `nodes` → `data` | The hierarchy is `data` on both forms natively. | | `Chart.Pie` | `innerRadius` | Instead of the web `isSolid`, `maxSlices` and `otherLabel`. Fold the tail yourself before passing the data. | | `Chart.Gauge` | adds `label`, `max` optional | `max` defaults to 100 there. | | `Chart.Meter` | takes the gauge props | `value`, `max`, `min`, `label` — so no `showValue`, and `label` is optional. | | `Chart.Dumbbell` | no `beforeLabel` / `afterLabel` | Label the two ends in your own view. | | `Chart.Histogram` | no `valueFormat` | Use `format`, which is on every native chart. | | `Chart.DivergingBar` | no `orientation` | Bars always run horizontally. | | `Chart.Sparkline` | no `slot` | Takes an explicit `color` instead of a palette slot. | | `Chart.Candlestick` | adds `labels` | Your five words for the readings; the tooltip then lists all of them. | ### The presentation vocabulary Five of the option groups take a wider shape than the plain contract: `seriesStyles`, `animation`, `interaction`, `annotations` and the two axes. The wider shapes are the `Native*` types, they are supersets, and they are what *every* renderer takes — the web one included. A glow, a traced entrance, a selection marker and a pulsing point are all drawn in the DOM too. | Prop | Type | What it adds | | --- | --- | --- | | `seriesStyles` | `NativeChartSeriesStyle` | `glow` — a bloom behind the stroke, in the series colour unless told otherwise. | | `animation` | `NativeChartAnimation` | `reveal`, the first-render entrance, and `transition`, how marks move when data changes. | | `interaction` | `NativeChartInteraction` | `crosshairStyle` and `marker` — how the reading under the finger is picked out. | | `annotations` | `NativeChartAnnotation[]` | `badge`, `glow`, `halo`, `pulse`, `hidden`, `labelBackground`, `labelPosition` and `scrubOpacity`. | | `xAxis` · `yAxis` | `NativeChartAxisOptions` | The `overlay` position, `labelInset`, `labelSize`, `ticks` and the two plot-dimension paddings. | **The names are historical.** The vocabulary was built for the platform graphics stacks and landed on the web renderer afterwards, so the types still read `Native*`. Four fields are still native alone: `interaction.haptics`, which the web has no honest equivalent for, `style.candleRadius`, which the web candlestick engine cannot round, and `style.neutralColor` and `style.volumeHeightRatio` — the web candlestick colours a flat session as a rising one and gives the volume histogram a fixed share of the plot. **A decorated point is drawn by the layer that reads the pointer.** So `glow`, `halo`, `pulse`, `badge` and `size` on an annotation land on `Chart.Line` and `Chart.Candlestick` on every platform, and on the rest of the cartesian forms on iOS and Android. On a web `Chart.Area`, `Chart.Bar` or `Chart.StackedBar` a point annotation is a plain mark and a rule carries no badge — the rules, ranges, labels and `labelBackground` those forms do draw are the engine's own, and `annotationViews` works on all five, so your own node is the way to put a head on a mark there. ```ts type ChartGlow = { /** Defaults to the mark's own colour. */ color?: string opacity?: number radius?: number } /** A hard disc behind a point. Unlike a glow it has an edge. */ type ChartHalo = { color?: string opacity?: number size?: number } type ChartPulse = { /** Defaults to the glow's colour, then to the point's own. */ color?: string /** One bloom, in ms. Default 450. */ duration?: number /** The rest between blooms, in ms. Default 1550. */ interval?: number /** How opaque the ring starts, before fading out over the bloom. Default 0.9. */ opacity?: number /** How far it blooms, as a multiple of the point's resting ring. Default 2.2. */ scale?: number } type ChartRevealAnimation = { /** "draw" | "fade" | "none" */ style?: ChartRevealStyle duration?: number /** "ease-in" | "ease-in-out" | "ease-out" | "linear". Defaults to "linear" for a trace. */ easing?: ChartRevealEasing /** A brief brightening as the trace lands. "draw" only. */ flashColor?: string flashDuration?: number /** The curve the flash decays along once it has held. Defaults to "ease-out". */ flashEasing?: ChartRevealEasing /** How far the glow blooms at the peak, as a multiple of its resting radius. */ flashGlow?: number /** How long the flash holds at full strength before decaying. */ flashHold?: number flashOpacity?: number /** How dim the stroke starts while being traced, as a fraction of its final opacity. */ startOpacity?: number /** Draws the whole path in this colour under the trace, so the shape reads from frame one. */ trackColor?: string trackOpacity?: number } /** How marks move when the data changes under a mounted chart. */ type ChartTransition = 'crossfade' | 'morph' ``` **`crossfade` is the honest one when the axis changed.** `morph` interpolates between the two datasets, which reads as the data moving. If the categories underneath are not the same categories, nothing moved — dissolve instead. ### Scrubbing Three fields carry a scrub: `index`, the mark's position in the data you passed, `phase`, where the gesture is in its lifetime, and `geometry`. One phase is not a gesture at all: `'layout'` fires once the chart has measured itself and carries the geometry, which is what you position your own views over the plot with. All three are reported on the web too, from the pointer — `NativeChartInteractionEvent` is an alias of `ChartInteractionEvent`, kept as a name because that is what the native props are declared with. Every coordinate in an event — `geometry` and the pointer's `nativeX`/`nativeY` — is in the unit the platform lays views out in: points on iOS, dp on Android, px on the web. So a view positioned from one lands where the chart drew, with no density arithmetic in between. ```ts type ChartInteractionPhase = 'began' | 'changed' | 'ended' | 'layout' type ChartInteractionEvent = { /** Position of the selected mark in the chart's own data order. */ index?: number phase?: ChartInteractionPhase /** Where the plot and its annotations sit, on the "layout" phase. */ geometry?: ChartGeometry // … plus category, value, seriesId and the pointer position } type ChartGeometry = { annotations: readonly { id: string; x: number; y: number }[] plot: { height: number; width: number; x: number; y: number } } /** How the mark under the finger is picked out. */ type ChartSelectionMarker = { /** "point" | "segment" */ style?: ChartMarkerStyle color?: string glow?: ChartGlow /** A dot's diameter. "point" only. */ size?: number /** How many data steps either side of the touch a segment covers. "segment" only. */ span?: number } type ChartCrosshairStyle = { color?: string dash?: number[] width?: number } ``` | Prop | Type | Default | Description | | --- | --- | --- | --- | | `marker.style` | `"point" \| "segment"` | `"point"` | A dot on the mark, or a lit stretch of the line around it. | | `marker.size` | `number` | `9` | Dot diameter, in points. | | `marker.span` | `number` | `2` | Data steps either side of the touch. | | `glow.opacity` | `number` | `0.55` | Glow opacity at rest. | | `glow.radius` | `number` | `6` | Glow radius, in points. | | `halo.size` | `number` | `12` | Halo diameter, in points. | | `crosshairStyle.width` | `number` | `1` | Crosshair line width. | | `annotation.pulse` | `boolean \| ChartPulse` | `false` | A ring blooming out of a point annotation and resting before it does it again. `true` takes 450 ms out, 1550 ms at rest, 2.2× the point's ring. | | `annotation.labelBackground` | `string` | — | Painted behind an annotation's label, so its value stays legible where the marks run through it. | | `annotation.labelPosition` | `"auto" \| "bottom" \| "leading" \| "top" \| "trailing"` | — | Which side of the rule its label sits on. `"auto"` keeps it inside the plot: above a rule sitting low, below one sitting high. Omit it and each renderer keeps its own default side, so name one when the three have to agree. | | `annotation.scrubOpacity` | `number` | `1` | What an annotation fades to while a point is being read. Set `0` to hide it entirely. | | `annotation.badge` | `string` | — | A single glyph in a filled circle capping a rule at the plot edge; the rule starts below it. Leave it off and the chart draws only the rule. | | `annotation.size` | `number` | — | A point annotation's dot diameter, or a badge circle's. Each renderer rests at a slightly different dot size, so name it when the three have to agree; a badge takes it on iOS and the web, and Android draws a fixed one. | | `annotation.hidden` | `boolean` | `false` | Measured and reported in `geometry` as usual, but drawn by nobody — for an annotation that is only an anchor for a view of your own. | | `annotationViews` | `Record` | — | Your own node where an annotation lands, keyed by its `id`. The chart centres it on the spot and leaves out the mark it would have drawn there. | | `interaction.highlightColor` | `string` | — | A colour the mark under the finger is lifted towards. | | `interaction.highlightBlend` | `number` | `1` | How far towards it. Below 1 the mark's own colour still reads through the lift. | | `style.candleRadius` | `number` | `0` | Corner radius on a candle body. Rounds the wick's caps with it. | ### Native axis options `xAxis` and `yAxis` take everything `ChartAxisOptions` defines, plus these — on every entry point, the web one included. | Prop | Type | Default | Description | | --- | --- | --- | --- | | `position` | `"start" \| "end" \| "overlay"` | `"start"` | `overlay` puts the labels inside the plot against its trailing edge and reserves no gutter. Build it with `axis.overlay`. | | `labelInset` | `number` | `2` | How far an overlaid label sits from the plot's trailing edge. Only read when `position` is `overlay`. | | `labelSize` | `number` | `11` | Point size of the tick labels. | | `ticks` | `boolean` | `true` | Draws the short marks beside each label. Independent of `grid`, and coloured by `theme.colors.axis` — the only thing any renderer draws with it, since none of them draws a domain line. | | `plotDimensionStartPadding` | `number` | `0` | Free space, in points, kept before the first mark. | | `plotDimensionEndPadding` | `number` | `0` | Free space, in points, kept after the last mark. | ### Differences from web | Prop | Where | Note | | --- | --- | --- | | `className` | web only | Native charts have no DOM node to style. Use `height` and `plot` instead. | | `texture` | web only | Pattern fills answer forced-colors and print, which a native chart never meets. Not part of the native props — the compiler rejects it. | | `skeleton` | web only | Both native renderers draw a shimmering placeholder matched to the form — a ring, a column row or a curve — but there is no slot to put your own in, and no `.Skeleton` component to mount on its own. | | `annotationViews` | wider on native | On every native form's props, and it lands on the ones that anchor an annotation to a plot — the cartesian forms. On the web it is `Chart.Line`, `Chart.Area`, `Chart.Bar`, `Chart.StackedBar` and `Chart.Candlestick`, the forms that report where their annotations landed. | | `colorMode` | native chart · web provider | Per chart on native, defaulting to `"system"`: there is no cascade to inherit through. On the web `Chart.Provider` takes it, and it has an `"inherit"` the native prop does not. | | `accessibilityLabel` | native only | The accessibility name of the chart view, since there is no DOM node to label. | | `format` | every native form | On the base props natively. On the web only the forms that write numbers take it, and a few take a second one — `valueFormat` on the histogram, `xFormat`/`yFormat` on the scatter. | | `height` | different default | 320 points on native, 240 px on the web — and 200 for the gauge, 300 for the sankey, 32 for the sparkline. | | `labels` | wider on native | Boxplot terminology is honoured natively too. `Chart.Candlestick` takes `labels` only here — pass your five words and the tooltip lists every reading instead of just the close. | | `interaction.haptics` | native only | The web has no honest equivalent. | | `style.candleRadius` | native only | The web candlestick engine cannot round a candle body. | | `style.neutralColor` | native only | The web candlestick colours a session that closed where it opened as a rising one. | | `style.volumeHeightRatio` | native only | The web volume histogram takes a fixed share of the plot height. | Nothing else in the presentation vocabulary is platform-specific: `glow`, `reveal`, `marker`, `badge`, `pulse`, `halo`, `annotationViews` and the overlaid axis all render in the DOM as well. Eleven forms do take different data props, though — see the table under "Chart coverage". ```ts type ChartCandlestickLabels = { open: string high: string low: string close: string change: string } ``` ### iOS `@hzblj/zyplot/ios` renders with SwiftUI and Swift Charts. Chart configuration crosses the bridge as JSON and is decoded into native models, so every prop stays serializable. Importing it commits a file to iOS, so name that file `*.ios.tsx`. ```tsx // price.ios.tsx import { Chart } from '@hzblj/zyplot/ios' export function Price() { return ( console.log(event.category, event.value)} showVolume /> ) } ``` iOS-only forms — two marks Swift Charts has that neither other renderer does: - `Chart.Range` (`ChartRangePropsIos`) — shaded band between a low and a high per category: forecast ranges, confidence intervals. - `Chart.Rule` (`ChartRulePropsIos`) — reference rules at a value, optionally spanning a start and end. iOS axis extras, on the x axis alone: - `xAxis.visibleDomain` (`number`) — length of the visible x domain. Setting it makes the plot horizontally scrollable. - `xAxis.scrollPosition` (`number | string`) — initial scroll offset along the x axis. ### Android `@hzblj/zyplot/android` draws on a Jetpack Compose `Canvas`. Marks, axis text, grid, annotations and the tooltip are all drawn by the module, so a chart is one view rather than a tree of them. Importing it commits a file to Android, so name that file `*.android.tsx`. ```tsx // spend.android.tsx import { Chart } from '@hzblj/zyplot/android' export function Spend() { return ( ) } ``` Android-only forms: - `Chart.Waterfall` (`ChartWaterfallPropsAndroid`) — running total across signed movements, colored by direction. - `Chart.Lollipop` (`ChartLollipopPropsAndroid`) — a stem and a dot per category: a bar chart with the ink of a dot plot. Android axis extras — overflow handling for long tick labels, the one control Compose needs that Swift Charts resolves on its own: - `xAxis.labelOverflow` (`"clip" | "ellipsis" | "visible"`, default `"ellipsis"`) — how a tick label that exceeds its band is cut. - `yAxis.labelOverflow` (`"clip" | "ellipsis" | "visible"`, default `"ellipsis"`) — how a tick label that exceeds the gutter is cut. ## Hooks ### useChartScrub Tracks which datum is being read, for a readout that lives outside the plot. The same hook on all three platforms: a finger on iOS and Android, a pointer on the web. The selection returns to `null` when the finger lifts or the pointer leaves the plot, which is the signal a readout needs to go back to its resting value. **A scrub is a continuous reading, so not every form has one.** On the web it is `Chart.Line` and `Chart.Candlestick` that turn the pointer into one; the other forms report the mark the pointer landed on instead, which carries no `index` and so leaves the selection alone. On native a finger is read by its position along the category axis, so the forms that have one — the line, area, bar and candlestick families — are the forms a scrub means something on. The radial and flow ones have no ordered axis to walk, and nothing usable comes back from them. ```tsx 'use client' import { Chart, marker, useChartScrub } from '@hzblj/zyplot' export const Price = ({ prices, categories }: PriceProps) => { const { onInteraction, selection } = useChartScrub() const shown = selection ? prices[selection.index] : prices.at(-1) return ( <> {shown} {selection ? categories[selection.index] : 'Today'} ) } ``` | Returns | Type | Description | | --- | --- | --- | | `selection` | `ChartScrubSelection \| null` | What is being read, or `null` when nothing is being scrubbed. | | `onInteraction` | `(event) => void` | Hand this straight to the chart's `onInteraction`. | | `reset` | `() => void` | Drops the selection, for a caller that has its own reason to. | | `geometry` | `ChartGeometry \| null` | Where the plot and its annotations sit in the chart view, for drawing your own views over them. | ```ts type ChartScrubSelection = { /** Position of the mark in the chart's own data order. */ index: number category?: string /** Where the finger is, in the chart view's own coordinate space. */ nativeX?: number nativeY?: number value?: number } ``` **For a view on an annotation, reach for `annotationViews` first.** Key your own node by the annotation's `id` and the chart centres it on the spot, keeps its own mark for it out of the drawing, and moves it with the data — no geometry to hold, no positioning to write. A logo at the live reading, a letter on a rule, a price that stays with the last point. Every native form takes it, and on the web it is the five that report where an annotation landed — `Chart.Line`, `Chart.Area`, `Chart.Bar`, `Chart.StackedBar` and `Chart.Candlestick`. ```tsx import { annotation, Chart, useLastReading } from '@hzblj/zyplot' export const Price = ({ prices, categories }: PriceProps) => { const live = useLastReading(categories, prices) return ( }} categories={categories} series={[{ id: 'price', label: 'Price', values: prices }]} /> ) } ``` **`geometry` is the way down when a view is not on an annotation.** It reports the plot's box and every annotation's `x`/`y` in the chart's own coordinates, so a card can follow the finger with `selection.nativeX` — which is the one thing `annotationViews` cannot place for you. An annotation that is only an anchor for such a view takes `hidden: true`: measured and reported as usual, drawn by nobody. ```tsx 'use client' import { annotation, Chart, useChartScrub } from '@hzblj/zyplot' export const Price = ({ prices, categories }: PriceProps) => { const { geometry, onInteraction, selection } = useChartScrub() const dividend = geometry?.annotations.find((mark) => mark.id === 'dividend') return ( {dividend ? ( D ) : null} {selection ? ( ) : null} ) } ``` **The identity is stable.** `onInteraction` and `reset` keep the same identity across renders, and the returned object only changes when the selection does — so a `memo`'d chart is not re-rendered by the readout updating above it. ### useLastReading A series is often shorter than its axis on purpose — a trading session still in progress, a forecast that has not started — so its last *slot* is not its last reading. This walks back to where the data really ends, which is where a "now" marker belongs. It returns `null` when the series has no readings at all, and it reads nothing but the arrays you pass it, so it is on every entry point. ```tsx const live = useLastReading(range.categories, range.values) ``` | Argument | Type | Description | | --- | --- | --- | | `categories` * | `readonly string[]` | The categories the series is plotted against. | | `values` * | `readonly (number \| null)[]` | The series values. Trailing `null` entries are skipped, as are gaps. | ```ts type ChartReading = { category: string index: number value: number } ``` **The native renderers already agree with it.** A scrub stops at the same point this returns, so a "now" marker placed here is exactly where the finger can no longer go. ## Loading states Hold `isLoading` true while the data is in flight. The chart shows the shape it is about to be, at the height it will occupy, and cross-fades into the plot when the flag drops — same grid cell, same size, so nothing on the page moves when the marks land. ```tsx ``` The placeholder is derived from the props the chart already has: one legend row per series, and axis rows only where an axis is visible. **The first frame is a loading state too.** A chart has to read its colors off the document before it can paint, so the built-in placeholder also covers that frame — with `isLoading` false and data in hand. The wrapper carries `aria-busy` while either is true, and the placeholder itself is `aria-hidden`. **Native draws one too, in its own way.** `isLoading` means the same thing on iOS and Android, and both draw a shimmering placeholder shaped like the form they stand in for — a ring for the radial ones, a row of columns for the bar family, a curve for everything else. What they do not have is a `skeleton` slot or a `.Skeleton` component to mount on its own: those are DOM composition, and the placeholder there is one view the renderer draws. ### Skeleton props Every form also exposes its placeholder on its own, as `Chart.Line.Skeleton` — for when the chart is not mounted yet at all. Web only. ```tsx }> ``` | Prop | Type | Default | Description | | --- | --- | --- | --- | | `height` | `number` | `240` | Reserved height. Match the chart it stands in for. | | `legendCount` | `number` | `0` | Legend rows to reserve. Drawn from two up — a single series gets no legend. | | `xAxis` | `boolean` | `true` | Reserves the horizontal-axis label row. | | `yAxis` | `boolean` | `true` | Reserves the vertical-axis label column. | | `className` | `string` | — | CSS class applied to the skeleton root. | ### Custom skeleton `skeleton` takes a rendered element, not a component, and replaces the built-in one while `isLoading` is true. Keep its height equal to the chart's so the swap still costs no layout shift. It covers `isLoading` only — the frame before the first paint still uses the built-in placeholder. ## Charts ### Shared props Every form takes these, so they are listed once rather than twenty-one times. | Prop | Type | Default | Description | | --- | --- | --- | --- | | `className` | `string` | — | CSS class applied to the chart root. Web only — a native chart has no DOM node. | | `height` | `number` | `240` | Plot height. A chart never measures its own content, so this is what reserves the space. Px on the web (240), points on native (320). | | `isLoading` | `boolean` | `false` | Held true while the data is in flight. Shows the matching skeleton, then cross-fades into the plot. | | `skeleton` | `ReactNode` | — | Replaces the built-in placeholder while `isLoading` is true. Web only. | | `surface` | `ChartSurface` | — | The container the plot sits in. Merges over `Chart.Provider`, key by key. | | `theme` | `ChartTheme` | — | Colours and fonts for this chart alone. The portable subset; native charts take `NativeChartTheme`, which adds `background`. Merges over `Chart.Provider` key by key on the web; replaces it outright on native. | | `texture` | `boolean` | `false` | Draws decal patterns over fills — a second encoding on top of hue, for full colour-vision deficiency, print and forced-colors. Web only. | | `accessibilityLabel` | `string` | — | Native only: the accessibility name of the chart view. | The cartesian forms — line, area, bar, stacked bar and candlestick — additionally take the **plot controls**. The `Native*` types here are what every renderer takes, web included. | Prop | Type | Default | Description | | --- | --- | --- | --- | | `axis` | `ChartAxes` | `{ x: true, y: true }` | Horizontal and vertical axis visibility. | | `animation` | `NativeChartAnimation` | — | Mark entrance, traced reveal and data-update animation. | | `annotations` | `NativeChartAnnotation[]` | — | Reference lines, highlighted ranges, points and text anchored to the plot. | | `annotationViews` | `Record` | — | Your own node where an annotation lands, keyed by its `id`. The chart centres it on the spot and draws no mark of its own there. | | `interaction` | `NativeChartInteraction` | — | Hover, crosshair, marker, tooltip, selection, pan and zoom behaviour. | | `onInteraction` | `(event: ChartInteractionEvent) => void` | — | Receives normalized pointer and selection data. Needs a client component. | | `plot` | `ChartPlotStyle` | — | The plot area alone — its own background, border, clipping and padding, inside the surface. | | `xAxis` | `NativeChartAxisOptions` | — | Scale, domain, ticks, grid, position and label for the horizontal axis. | | `yAxis` | `NativeChartAxisOptions` | — | The same for the vertical axis. | Forms that read `series` also take `emphasisId` (`string`) to keep one series colored and mute the others, and `seriesStyles` (`Record`) for per-series stroke, fill, dash, symbol and glow keyed by `ChartSeries.id`. A line draws no symbol per reading unless one is named there — a dot per datum is a mark the reader did not ask for, and the native renderers draw none. Forms that show numbers take `format` (`ChartNumberFormat`) — on native every form does. The props below are the web ones. Eleven forms take different data props on native; those are listed under "Chart coverage". ### Line Compare continuous trends across an ordered category or time axis. Use for trends; do not smooth data when intermediate values are unknown. web · iOS · Android. ```tsx ``` Own props: `categories` (`string[]`, required), `series` (`ChartSeries[]`, required), `isSmooth` (`boolean`, default `false`) draws rounded interpolation between observations. Plus `format`, `emphasisId`, `seriesStyles`, the plot controls and the shared props. ### Area Show a trend while emphasizing magnitude or composition over time. Use when magnitude matters in addition to direction. web · iOS · Android. ```tsx ``` Own props: `categories` (required), `series` (required), `isSmooth` (`boolean`, `false`), `isStacked` (`boolean`, `false`) stacks series to show their combined composition. Plus `format`, `emphasisId`, `seriesStyles`, the plot controls and the shared props. ### Bar Compare discrete values across a small set of categories. Switch to horizontal for long labels. web · iOS · Android. ```tsx ``` Own props: `categories` (required), `series` (required), `orientation` (`"horizontal" | "vertical"`, default `"vertical"`). Plus `format`, `emphasisId`, `seriesStyles`, the plot controls and the shared props. ### Stacked bar Compare category totals and the composition inside each total. Normalize when composition matters more than the absolute total. web · iOS · Android. ```tsx ``` Own props: `categories` (required), `series` (required), `isNormalized` (`boolean`, `false`) normalizes every stack to 100 percent, `orientation` (`"horizontal" | "vertical"`, `"vertical"`). Plus `format`, `emphasisId`, `seriesStyles`, the plot controls and the shared props. ### Histogram Reveal the distribution of raw numeric observations. Use when shape, spread and outliers matter more than individual values. web · iOS · Android. ```tsx ``` Own props: `values` (`number[]`, required — binning is handled by the component), `binCount` (`number`, default `10`), `valueFormat` (`ChartNumberFormat`) for bin boundaries, `axis`. Plus the shared props. Native has no `valueFormat` — use `format`. ### Scatter Reveal relationships, clusters and outliers between two measures. web · iOS · Android. ```tsx ``` Own props: `series` (`ChartScatterSeries[]`, required), `xLabel`, `yLabel` (`string`), `xFormat`, `yFormat` (`ChartNumberFormat`), `axis`. Plus the shared props. ### Time series Render tens of thousands of ordered time points efficiently. Prefer Line for small human-scale datasets. web · iOS · Android. ```tsx ``` Own props: `points` (`ChartTimePoints`, required — parallel timestamp and value arrays), `series` (`Omit[]`, required — identity and colour only), `format`, `axis`. Plus the shared props. ### Sparkline A tiny trend shape without axes, tooltip or legend. Use inside a table row or card as context, never for exact lookup. web · iOS · Android. ```tsx ``` Own props: `values` (`number[]`, required), `slot` (`number`) palette slot for the stroke, `color` (`string`) explicit stroke color overriding the palette, `height` (default `32`), `className`, `isLoading`, `surface`. Native has no `slot` — pass `color`. ### Pie Show a simple part-to-whole relationship with a short tail. Use for two to five parts; prefer bars when precise comparison matters. web · iOS · Android. ```tsx ``` Own props: `data` (`ChartDatum[]`, required, ordered by importance), `format`, `isSolid` (`boolean`, `false`) fills the center to render a full pie, `maxSlices` (`number`, default `3`) slices retained before folding the tail, `otherLabel` (`string`) translated label for the folded tail. Plus the shared props. Native takes `innerRadius` instead of these three. ### Gauge Show one current value against a fixed bounded range. Use for capacity and progress with a meaningful maximum. web · iOS · Android. ```tsx ``` Own props: `value` (`number`, required), `max` (`number`, required), `min` (`number`, default `0`), `format`. Plus the shared props. Height defaults to `200` here. On native `max` is optional and defaults to 100, and there is an extra `label`. ### Meter A compact accessible scalar for rows, settings and summaries. Use instead of a gauge when vertical space is limited. Two elements, `role="meter"` and no charting engine, so its final markup is painted on the server. web · iOS · Android. ```tsx ``` Own props: `label` (`string`, required — rendered above the meter), `value` (required), `max` (required), `format`, `showValue` (`boolean`, default `true`), `className`, `surface`. Native takes the gauge props instead: `value`, `max`, `min`, `label`, with no `showValue`. ### Funnel Show ordered attrition through a sequence of stages. Use only when each stage is a subset of the previous stage. web · iOS · Android. ```tsx ``` Own props: `stages` (`ChartDatum[]`, required, ordered widest to narrowest), `format`. Plus the shared props. Natively the prop is called `data`. ### Radar Compare multivariate profiles on a shared set of bounded axes. Use for profile shape, not precise lookup; keep dimensions limited. web · iOS · Android. ```tsx ``` Own props: `axes` (`ChartRadarAxis[]`, required — labels and upper bounds per dimension), `series` (`ChartSeries[]`, required), `format`. Plus the shared props. ### Sankey Trace weighted flow between named nodes and stages. Use when flow volume between states is the primary story. web · iOS · Android. ```tsx ``` Own props: `nodes` (`ChartFlowNode[]`, required), `links` (`ChartFlowLink[]`, required), `format`. Plus the shared props. Height defaults to `300` here. ### Sunburst Show hierarchical part-to-whole relationships in concentric rings. Use when both hierarchy depth and part-to-whole structure matter. web · iOS · Android. ```tsx ``` Own props: `nodes` (`ChartHierarchyNode[]`, required — leaf nodes carry values), `format`. Plus the shared props. Natively the prop is called `data`. ### Treemap Fit hierarchical part-to-whole data into a compact rectangle. Use when screen efficiency matters more than reading hierarchy depth. web · iOS · Android. ```tsx ``` Own props: `nodes` (`ChartHierarchyNode[]`, required), `format`. Plus the shared props. Natively the prop is called `data`. ### Candlestick Show open, high, low and close for each session, with optional volume. For a single measure over time reach for Line or Time series instead — a candlestick spends four values of ink on one. web · iOS · Android. ```tsx ``` Own props: `data` (`ChartCandlestickDatum[]`, required, chronological), `format`, `showVolume` (`boolean`, `false`) adds a volume histogram beneath the price plot and needs `volume` on each datum, `style` (`ChartCandlestickStyle`) candle body, wick and volume colors plus hollow-up rendering. Plus the plot controls and the shared props. On native it also takes `labels` (`ChartCandlestickLabels`). ### Boxplot Compare five-number summaries and outliers across groups. Use to compare distributions when raw observations are not required. web · iOS · Android. ```tsx ``` Own props: `groups` (`ChartBoxplotGroup[]`, required), `labels` (`BoxplotLabels`, required — translated labels for the summary statistics), `format`, `orientation` (`"horizontal" | "vertical"`, `"vertical"`), `axis`. Plus the shared props. ### Diverging bar Compare positive and negative values around a shared zero. Use for variance, sentiment, gain/loss and change from baseline. web · iOS · Android. ```tsx ``` Own props: `data` (`ChartDatum[]`, required, signed values), `format`, `orientation` (`"horizontal" | "vertical"`, default `"horizontal"`), `axis`. Plus the shared props. Native has no `orientation`; the bars always run horizontally. ### Dumbbell Show movement between exactly two measurements per item. Use when the story is change between two known states. web · iOS · Android. ```tsx ``` Own props: `rows` (`ChartDumbbellRow[]`, required), `beforeLabel` (`string`, required), `afterLabel` (`string`, required), `format`, `axis`. Plus the shared props. Native takes `rows` alone — label the two ends in your own view. ### Heatmap Display magnitude across two categorical dimensions. Use to expose clusters and patterns in a dense matrix. web · iOS · Android. ```tsx ``` Own props: `cells` (`ChartHeatmapCell[]`, required, addressed by row and column index), `columns` (`string[]`, required), `rows` (`string[]`, required), `format`, `axis`. Plus the shared props. ## Example app `apps/example` in the repository runs every chart on a device, plus a full Revolut-style stock quote screen: a headline price that follows the finger, a smoothed intraday line that stops where the session does, a range selector, a candlestick toggle, and native tabs and headers above it all. It is the reference for what the native renderers are for — everything on the screen except the plot itself is ordinary React Native. - `revolut-line-chart.tsx` — the intraday price: smoothed, glowing, with a dashed baseline rule, an event badge, and a pulsing point at the last reading that `useLastReading` finds. - `revolut-candlestick-chart.tsx` — the same screen switched to OHLC, with the candle width and radius measured off the design. - `use-quote-readout.ts` — turns the scrub into the price, the delta and the date above the plot. No tooltip is drawn by either chart. - `quote-chart-overlay.tsx` — the reading card and the event badge are React Native views placed over the plot from the `geometry` the `'layout'` phase reports. - `revolut.ios.tsx` / `revolut.android.tsx` — one screen per platform: SwiftUI through `@expo/ui` on iOS, Compose on Android, sharing the data, the theme and both charts. Source: https://github.com/hzblj/zyplot/tree/main/apps/example/src/revolut It is a design study on generated data, not affiliated with or endorsed by Revolut. ## Releases Versions are cut with changesets and published from CI with npm provenance, so the git tag, the GitHub release and the version on npm are always the same number. - Releases: https://github.com/hzblj/zyplot/releases - Changelog: https://zyplot.janblazej.dev/docs/changelog