@colixsystems/widget-sdk 0.246.0 → 0.247.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -84,7 +84,26 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
84
84
 
85
85
  ## Status
86
86
 
87
- `v0.246.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
87
+ `v0.247.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
88
+
89
+ ### What's new in 0.247.0 (contract 1.201.0)
90
+
91
+ **`<Grid>` lays out card grids, tile rows and calendar-style cells for you.** It measures its own unpadded box and balances the lines, so there is no lone card under a full row, no fifth stat tile running off the edge, and no 200px calendar day. `<Grid minCellWidth={240}>{items.map((item) => <Card key={item.id} style={{ flex: 1 }}>…</Card>)}</Grid>` fills every line but the last and pads that one so its cards line up; `<Grid balance="tiles">` spreads tiles evenly; `<Grid columns={7} cell={{ min: 32, max: 64 }} square gap="xs">` draws a month of square days capped at 64px and centred (a block wider than the space starts at the leading edge). Every cell stays mounted as lines change, so a cell keeps its state across a resize. Layouts it cannot express (masonry, spanning cells) can still be laid out by hand with `useContainerWidth()`.
92
+
93
+ **A themed chart: `<Chart>` and Design > Components > Charts.** `<Chart type data />` draws a bar (default), horizontal bar (`hbar`), line, area, donut or sparkline chart whose series colours, axis and gridline colours and bar corners come from the new `chart` theme scope; colours left unset follow the theme, primary first. It measures its own width, holds its place with a `Skeleton` while `loading`, shows an `EmptyState` when there is nothing to plot, adds a legend for a donut or several `series`, thins crowded labels, groups numbers for the app's language, and reads to a screen reader as one image with a summary and the values (`accessibilityLabel` replaces the summary, `moreText` translates its tail). `valueFormat="percent"` expects values already in percent. A chart type it does not draw (a scatter plot, a timeline) is still yours to draw with `react-native-svg`, in the same colours through `chartColors(theme)`.
94
+
95
+ ```jsx
96
+ const { groups, loading } = useDatastoreAggregate(tableId, { groupBy: "city", sumColumn: "visits", order: "sum:desc", limit: 10 });
97
+ <Chart type="bar" loading={loading} valueFormat="compact"
98
+ data={groups.map((g) => ({ label: String(g.group), value: Number(g.sum) || 0 }))} />
99
+ ```
100
+
101
+ **Searchable pickers: `<Select searchable>` and `<PeoplePicker>`.** A long `Select` can be searched: pass `searchable` to filter the options you hold, `onSearch` to fetch each query yourself (called with "" when the sheet opens and again once typing pauses), or an async `options` function and Select fetches, keeping only the latest answer. `<PeoplePicker source="users" | "groups" | "records">` does this for the workspace's people, groups or a table's records with avatar rows, server-side search and `excludeIds`, and names a saved choice that is not on the first page instead of showing its id (a malformed saved id reads "Unknown" without failing the rest). `useGroups({ ids })` names groups by id.
102
+
103
+ ```jsx
104
+ <PeoplePicker label="Assignee" value={assignee} onValueChange={setAssignee} />
105
+ <PeoplePicker source="groups" multiple label="Teams" values={teams} onValuesChange={setTeams} />
106
+ ```
88
107
 
89
108
  ### What's new in 0.246.0 (contract 1.200.0)
90
109
 
@@ -3067,14 +3086,17 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
3067
3086
  - `isSafeMarkdownImageSrc`, `isHttpImageSrc`, `normaliseMarkdownImageSize`, `readMarkdownAlt` — the image half of that grammar, for a widget rendering images itself via `renderImage`. `isSafeMarkdownImageSrc` is the same allowlist the parser applies (an `http(s)` URL or a filestore id; a `javascript:` scheme is refused), so the widget and the parser cannot disagree about which src is renderable.
3068
3087
  - `List`, `ListRow`, `ListHeader`, `EmptyState` — the themed list building blocks. `<ListRow title subtitle leading trailing onPress selected divider>` draws one record row from the theme's `list` scope (padding, height, gap, radius, background, divider); `<List>` spaces its rows and hands overrides down; `<ListHeader>` labels a table's columns on the rows' own inset; `<EmptyState icon title message>` is the "nothing here" panel. Never hand-roll a row or a header row from `View` + `Pressable`.
3069
3088
  - `Badge`, `Avatar`, `Progress`, `Stat`, `Divider` — the themed status pill, person picture, completion bar or ring, headline figure and rule the built-in widgets of the same names render through. Reach for them inside a record row instead of hand-rolling a pill or a circle from `View`.
3089
+ - `Grid` — balanced lines of cells sized from the grid's own measured width, on both hosts. `<Grid minCellWidth gap balance>`: `balance` `"cards"` (default) fits as many cells to a line as `minCellWidth` allows (240 when unset), fills every line but the last and pads that one with empty cells; `"tiles"` spreads the remainder and stretches each line (5 at 4 per line are 3 + 2), with `maxColumns` or the width tier (4 / 3 / 2) as the ceiling. `columns` fixes the count per line; `cell={{ min, max }}` with `square` makes definite-size cells (calendar days, seats, swatches) in a centred block. It measures an inner box with no padding, and `gap` is a theme spacing name or a number. Each child keeps its own look, and a child with `flex: 1` fills its cell's height. Use it by default for card grids, tile rows and capped cells; masonry or spanning cells are yours to lay out with `useContainerWidth()`.
3070
3090
  - `Card`, `Button`, `TextField` — the themed card surface, button and text field the built-in widgets render through. With no props each takes the app's look from Design > Components (Cards, Buttons, Inputs); never hand-roll one from `View` / `Pressable` / a bare `TextInput`.
3071
3091
  - `Banner` — the themed inline message. `<Banner tone message>` shows an error, warning, success or notice in place, styled by the theme's `alert` scope; `action` adds one button and `onDismiss` a close control. Use `useToast()` for a brief confirmation instead. Never hand-roll a tinted error box.
3072
3092
  - `Accordion`, `AccordionItem` — themed collapsible sections. `<Accordion><AccordionItem title subtitle trailing>…</AccordionItem></Accordion>` draws its surface from the theme's `card` scope and its headers and dividers from the `list` scope; one section open at a time, or `multiple`. Never hand-roll a collapsible from `Pressable` + a chevron.
3073
3093
  - `Spinner`, `Skeleton` — the themed loading states. `<Spinner size label>` for a pending action; `<Skeleton shape="text" | "block" | "circle" | "listRow">` (or a group of bones) for content whose layout you know. The spinner wears `colors.loader`, the skeleton follows the theme's `loading` scope; both stop under reduced motion and mark one busy region. Never use `ActivityIndicator` or hand-build placeholder bars.
3074
3094
  - `Checkbox`, `Switch`, `Select`, `SearchField` — the themed tick box, on/off toggle, picker and search box. Like `TextField` they follow the theme's `muted` scope, Design > Components > Inputs (and `popover`, Dropdown menus, for the Select's option sheet; `SearchField` also reads `search`, Search fields, for its glyph position, corner radius, background, border colour and height, each falling back to Inputs) and take `label`, `helper`, `error` and `disabled`. `Select` opens its options at screen level; with `multiple` it takes `values` / `onValuesChange`, ticks options in the same sheet and commits them on Done. Never hand-roll one from `Pressable` and `View`.
3075
3095
  - `RadioGroup` — the themed pick-ONE of a short list that stays on screen. `<RadioGroup options value onValueChange variant>` with `variant` `"list"` (default) or `"card"`, both following `muted` (Design > Components > Inputs), or `"segmented"`, the `tabs` scope's segmented strip; options are `{ value, label, description, icon, disabled, testID }`, and it takes `label`, `helper`, `error` and `disabled`. Radio role and checked state on both hosts; on the web one tab stop, arrows move the choice. Use it by default for two to about six visible options; a long list is a `Select`, and a choice it cannot draw (a picture or a swatch per option) is yours to build, with the radio role.
3096
+ - `PeoplePicker` — the themed pick of a workspace user, a group or a table's record. `<PeoplePicker source value onValueChange>` (`source` `"users"` (default), `"groups"` or `"records"` with `table`) is a searchable `Select`: it reads `useDirectory`, `useGroups` or `useDatastoreQuery`, searches on the server once typing pauses, leads each row with an `Avatar`, leaves out `excludeIds`, and looks up a saved value outside the first page once by id so the field names it, never showing the id. A `Select` of your own options turns searchable with `searchable` (filters what you pass), `onSearch(query)` with `loading`, or an async `options(query)`; `selectedOptions` names saved values the page does not hold.
3076
3097
  - `Tabs` — the themed tab strip. `<Tabs items value onChange>` draws a segmented control, pills or an underline as the theme's `tabs` scope says; render the selected panel yourself. Never hand-roll tabs from `Pressable` chips.
3077
3098
  - `Chip`, `ChipGroup` — the themed filter chip, tag or pill. `<Chip label selected onPress onRemove disabled>` follows the theme's `chip` scope; `<ChipGroup>` spaces and wraps its chips and, with `selection="single"`, announces them as one choice. Never hand-roll a chip from `Pressable` + a pill radius.
3099
+ - `Chart` — the themed chart, drawn with `react-native-svg` on both hosts. `<Chart type data series valueFormat height loading>` with `type` `"bar"` (default), `"hbar"`, `"line"`, `"area"`, `"donut"` or `"sparkline"`; `data` is `[{ label, value }]`, or one number per `series` key. It measures its own width, shows a `Skeleton` while `loading` and an `EmptyState` with no data, draws a legend for a donut or several series, and is one image to a screen reader, labelled with a summary and the values. Its series colours, axis and gridline colours and bar corners follow the theme's `chart` scope (Design > Components > Charts), falling back to the theme's colours. Use it by default, fed by `useDatastoreAggregate`; a chart it does not draw (a scatter plot, a timeline) is yours to draw with `react-native-svg`.
3078
3100
  - `textRoleStyle(role, typography, colors)`, `TEXT_ROLES` — the style of a semantic text tier, shared with the built-in widgets.
3079
3101
  - `WidgetContextProvider` — React context provider that the host (Studio, Player, exported app) wraps widgets with.
3080
3102
 
@@ -3104,7 +3126,7 @@ A widget that works but looks unfinished is only half done. `useTheme()` is the
3104
3126
  - **Tint the supporting cast.** `colors.primarySoft` is a tint of the workspace accent over the surface and `colors.onPrimarySoft` is guaranteed readable on it (WCAG AA, on light and dark themes alike). Use the pair for chips, secondary buttons, progress tracks, icon badges and selected rows. One saturated accent moment surrounded by several pale echoes of the same hue is what reads as designed — a row of grey-outlined buttons reads as a form. Never hand-mix a tint with `rgba(...)` or a translucent overlay.
3105
3127
  - **Spend one gradient.** `<Gradient colors={[theme.colors.primary, theme.colors.primaryStrong]} angle={160} style={…}>` is a `View` that paints a gradient behind its children, so it replaces the `View` you'd otherwise give a flat `backgroundColor`. `angle` is CSS degrees (0 = to top, 90 = to right, default 180); text on it uses `colors.onPrimary`. Exactly **one** per widget — on the focal element — and never behind body text. Both hosts render it identically (web paints CSS, native uses `expo-linear-gradient`), so there is no per-platform branching to write; don't import `expo-linear-gradient` yourself and don't write a `backgroundImage` string.
3106
3128
  - **Answer the touch.** Every tappable card, row and list entry lifts while the pointer is over it (web) or it is pressed (touch). One declaration does both: give the Pressable a style FUNCTION and spread `pressableLift` — `<Pressable onPress={open} style={(state) => [styles.card, ...pressableLift(state)]}>`. The lift is a -2px nudge plus one elevation step from `theme.interaction`, with the web transition built in. Never hand-write hover logic or your own pressed shadows, and never fake feedback with `opacity` — a dimmed surface reads as disabling itself.
3107
- - **Size to your container — measure it, don't stretch into it.** The same widget sits in a full-width desktop section (~1400px), a half-width grid cell (~700px) and a phone (~360px), so layout built only from `flex: 1` stretches to fill whatever it is handed — a month calendar ends up with 200px day cells and swallows the page. Measure your own width with `const [width, onLayout] = useContainerWidth();`, spreading `onLayout` on an unpadded root `View` (a React Native callback, so it behaves identically on both hosts). A width of `0` means "not yet measured" and the SDK helpers answer it with the wide form, so do not special-case it — except a count or size computed from the width (a calendar day, a chart, a card grid's cards per line), which renders nothing size-dependent until the width arrives: a widget gets no declared breakpoint prop, but it can always measure. **Every form, list, table and filter bar lays out in three tiers** (sc-8508): `const [width, onLayout] = useContainerWidth();` then branch on `widthTier(width)` — a form is one column narrow, paired short fields at medium, a 2–3 column grid capped near 1000px wide; a list is stacked cards, one-line cards, then real columns under a header; a filter bar is one toolbar row (search, a dropdown per facet, sort) with the active filters as removable chips beneath, and a single "Filters" button opening a panel when narrow. **Cap a repeating cell** rather than giving a grid `flex: 1` — `const cell = Math.max(32, Math.min(Math.floor((usable - gap * (columns - 1)) / columns), 64));`, with a calendar day cell topping out at 56–72px on `aspectRatio: 1`, and the grid given its exact computed width plus `alignSelf: 'center'` when the cap leaves slack. **Split two co-equal surfaces in the wide tier** (`flexDirection: widthTier(width) === 'wide' ? 'row' : 'column'`, each half `{ flex: 1, minWidth: 0 }`) — a picker beside the form it feeds on a wide canvas, stacked in reading order below it. Never hardcode a width, never put `flex: 1` / `height: '100%'` on a content widget's root, and don't read the screen with `Dimensions` — the screen is not the widget. **Whatever you narrow, you centre** — a block that caps its own measure carries all three of `{ width: '100%', maxWidth: FORM_MAX, alignSelf: 'center' }`, because `alignSelf` alone stops the block stretching and sizes it to its content instead, and `maxWidth` alone leaves the widget flush left with dead space beside it; a capped wrap row lays its cells out along the MAIN axis, so it centres with `justifyContent: 'center'` on the row instead — but a card grid or a tile row is never capped. **A card grid fills its lines, it never wraps freely** — a free `flexWrap` strands the remainder (six cards that fit five render 5 + 1). Count the cards that fit the measured width (`const max = Math.max(1, Math.floor((width + gap) / (CARD_MIN + gap)));`), take `const lines = Math.ceil(count / max); const cols = Math.ceil(count / lines);`, and render each slice of `cols` cards as a row of `{ flex: 1, minWidth: 0 }` cells, padding the last line with empty `{ flex: 1 }` spacers so its cards keep the width of the cards above (six that fit five are 3 + 3, ten that fit four are 4 + 4 + 2). **A short value never breaks across lines** — a time, date, price, count, percentage or code takes `numberOfLines={1}` and `flexShrink: 0`, never a fixed width narrower than it, and a narrow slot re-arranges or steps the font down instead. **A metric / stat-tile row FILLS ITS ROW** — when you know the count, balance the lines (`const lines = Math.ceil(count / max); const base = Math.floor(count / lines); const extra = count % lines; const rows = Array.from({ length: lines }, (_, i) => tiles.slice(i * base + Math.min(i, extra), (i + 1) * base + Math.min(i + 1, extra)));`, with `max` 4 / 3 / 2 by tier, each tile `{ flex: 1, minWidth: 0 }`), and when the count comes from data wrap it (`{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }`, each tile `{ flexGrow: 1, flexBasis: TILE_MIN }`, never a `maxWidth` — capped tiles cluster to one side), because a bare `flexDirection: 'row'` has no way to break and a React Native flex child defaults to `flexShrink: 0` (unlike CSS), so the tiles cannot give ground either: the row runs past its container, spilling out of the card natively and cut off by the scrolling page column on web, with nothing thrown and nothing logged, once there are enough of them (four fit where five do not). The basis is also the per-line ceiling — `Math.floor((usable + gap) / (TILE_MIN + gap))` tiles fit a line, so 240 tops out at five across a full-width ~1400px section and 320 at four; past four or five a line the tiles stop reading as cards.
3129
+ - **Size to your container — measure it, don't stretch into it.** The same widget sits in a full-width desktop section (~1400px), a half-width grid cell (~700px) and a phone (~360px), so layout built only from `flex: 1` stretches to fill whatever it is handed — a month calendar ends up with 200px day cells and swallows the page. Measure your own width with `const [width, onLayout] = useContainerWidth();`, spreading `onLayout` on an unpadded root `View` (a React Native callback, so it behaves identically on both hosts). A width of `0` means "not yet measured" and the SDK helpers answer it with the wide form, so do not special-case it — except a count or size computed from the width (a calendar day, a chart, a card grid's cards per line), which renders nothing size-dependent until the width arrives: a widget gets no declared breakpoint prop, but it can always measure. **Every form, list, table and filter bar lays out in three tiers** (sc-8508): `const [width, onLayout] = useContainerWidth();` then branch on `widthTier(width)` — a form is one column narrow, paired short fields at medium, a 2–3 column grid capped near 1000px wide; a list is stacked cards, one-line cards, then real columns under a header; a filter bar is one toolbar row (search, a dropdown per facet, sort) with the active filters as removable chips beneath, and a single "Filters" button opening a panel when narrow. **Cap a repeating cell** rather than giving a grid `flex: 1` — `const cell = Math.max(32, Math.min(Math.floor((usable - gap * (columns - 1)) / columns), 64));`, with a calendar day cell topping out at 56–72px on `aspectRatio: 1`, and the grid given its exact computed width plus `alignSelf: 'center'` when the cap leaves slack. **Split two co-equal surfaces in the wide tier** (`flexDirection: widthTier(width) === 'wide' ? 'row' : 'column'`, each half `{ flex: 1, minWidth: 0 }`) — a picker beside the form it feeds on a wide canvas, stacked in reading order below it. Never hardcode a width, never put `flex: 1` / `height: '100%'` on a content widget's root, and don't read the screen with `Dimensions` — the screen is not the widget. **Whatever you narrow, you centre** — a block that caps its own measure carries all three of `{ width: '100%', maxWidth: FORM_MAX, alignSelf: 'center' }`, because `alignSelf` alone stops the block stretching and sizes it to its content instead, and `maxWidth` alone leaves the widget flush left with dead space beside it; a capped wrap row lays its cells out along the MAIN axis, so it centres with `justifyContent: 'center'` on the row instead — but a card grid or a tile row is never capped. **Lay out a card grid, a tile row or a grid of capped cells with `<Grid>` by default** — it measures its own unpadded box and applies the rules below; lay one out yourself when it needs what `<Grid>` cannot express, such as masonry or cells that span lines. **A card grid fills its lines, it never wraps freely** — a free `flexWrap` strands the remainder (six cards that fit five render 5 + 1). Count the cards that fit the measured width (`const max = Math.max(1, Math.floor((width + gap) / (CARD_MIN + gap)));`), take `const lines = Math.ceil(count / max); const cols = Math.ceil(count / lines);`, and render each slice of `cols` cards as a row of `{ flex: 1, minWidth: 0 }` cells, padding the last line with empty `{ flex: 1 }` spacers so its cards keep the width of the cards above (six that fit five are 3 + 3, ten that fit four are 4 + 4 + 2). **A short value never breaks across lines** — a time, date, price, count, percentage or code takes `numberOfLines={1}` and `flexShrink: 0`, never a fixed width narrower than it, and a narrow slot re-arranges or steps the font down instead. **A metric / stat-tile row FILLS ITS ROW** — when you know the count, balance the lines (`const lines = Math.ceil(count / max); const base = Math.floor(count / lines); const extra = count % lines; const rows = Array.from({ length: lines }, (_, i) => tiles.slice(i * base + Math.min(i, extra), (i + 1) * base + Math.min(i + 1, extra)));`, with `max` 4 / 3 / 2 by tier, each tile `{ flex: 1, minWidth: 0 }`), and when the count comes from data wrap it (`{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }`, each tile `{ flexGrow: 1, flexBasis: TILE_MIN }`, never a `maxWidth` — capped tiles cluster to one side), because a bare `flexDirection: 'row'` has no way to break and a React Native flex child defaults to `flexShrink: 0` (unlike CSS), so the tiles cannot give ground either: the row runs past its container, spilling out of the card natively and cut off by the scrolling page column on web, with nothing thrown and nothing logged, once there are enough of them (four fit where five do not). The basis is also the per-line ceiling — `Math.floor((usable + gap) / (TILE_MIN + gap))` tiles fit a line, so 240 tops out at five across a full-width ~1400px section and 320 at four; past four or five a line the tiles stop reading as cards.
3108
3130
  - **Compose forms — pair fields into rows, don't stack one per row.** Lay the form out in three tiers and cap it near 1000px, centred with the three declarations above — never a 560–720px column alone in a wide box, which leaves a form that IS the page as a narrow strip in empty space. Put short, related fields side by side (first + last name, city + postal code, expiry + CVC): a row of `{ flexDirection: 'row', flexWrap: 'wrap', gap: theme.spacing.md }` with each field cell `{ flexGrow: 1, flexBasis: 160 }` splits the width on a wide card and wraps to stacked on a narrow phone — the right recipe for field pairs because it needs no measurement (a fixed-width column overflows a phone; when a layout needs a real column count instead of wrapping, measure your width as above). Keep wide fields (email, address, notes) full-width with `width: '100%'` — NEVER `flexBasis: '100%'`, which sizes the main axis and therefore claims the parent's whole HEIGHT in a column (sc-7274) — cap it at two–three per row, group a long form into labelled sections, and label every input above it (not placeholder-only). Give a `multiline` field BOTH a floor and a ceiling (`{ minHeight: 150, maxHeight: 260 }`) so a long value scrolls inside the box instead of growing past its card, and keep the Save / Cancel row in normal flow below the fields, never positioned over them. A search box is `<SearchField>`, which already carries its glyph and clear control. Put any other icon or unit beside the field as a sibling in a row (`flex: 1, minWidth: 0` on the field, so a long value shrinks instead of pushing the row past its container) rather than rebuilding the field to hold it: a hand-built bordered wrapper around a bare `TextInput` loses the app's input look and its label, focus and error handling.
3109
3131
  - **Respond to touch.** Give every `Pressable` the lift via the function-style `style={(state) => [base, ...pressableLift(state)]}` — see "Answer the touch" above. Never dim with `opacity`, which reads as the surface disabling itself.
3110
3132
  - **Drag and drop — show what is being dragged.** A drag where the item stays put reads as broken. Three things change the moment a drag starts: the **drag proxy** (the item lifts and follows the finger — `...theme.elevation.lg`, `{ scale: 1.03 }`, `opacity: 0.9`; for a tall or full-width item drag a compact `primarySoft` pill with its icon + one line of label instead), the **source placeholder** (the vacated slot keeps its height as a quiet `colors.surfaceMuted` block so the list doesn't collapse), and the **drop target** (one slot at a time highlighted with `primarySoft` or a 2px `colors.primary` border). Always animate the release — settle into the new slot, or `Animated.spring(pan, { toValue: { x: 0, y: 0 }, useNativeDriver: false })` back to the origin on cancel. Build it with `Animated` + `PanResponder` from `react-native` (the only mechanism that behaves identically on both hosts) — never HTML5 drag events (`draggable` / `onDragStart` / `dataTransfer` are web-only, and `document` / `window` are banned) — and start the drag from a `GripVertical` grip handle whenever the row is also tappable or sits in a `ScrollView`. **A board (kanban, pipeline) drops onto ANOTHER column**, which needs four more things: the board's root renders the one proxy (a column clips whatever leaves it); you hit-test the drop yourself — `measureInWindow` every column and the board when the drag starts and compare the gesture's window `moveX` (never `onLayout` frames, which are relative to a padded or scrolled parent) — highlighting exactly one target; the drop WRITES (`update(id, { [statusField]: value })` through `useDatastoreMutation`, optimistic until `refetch()`, a toast and a spring-back on failure); and three or more columns go in a `horizontal` `ScrollView` when `widthTier(width) === "narrow"`, with the grip claiming the gesture (`onStartShouldSetPanResponder`, `onPanResponderTerminationRequest: () => false`) and carrying `touchAction: 'none'` on web so a finger drag moves the card instead of scrolling the page. And because `useRef(PanResponder.create(...))` runs ONCE, everything its callbacks read — the `useCanWrite` flag included, which starts `false` — goes through a ref refreshed every render (`latest.current.canDrag`); the publish gate `widget.staleResponder` rejects a value read directly.
@@ -0,0 +1,575 @@
1
+ // The geometry `<Chart>` draws, as plain numbers and SVG path strings. No
2
+ // React and no renderer, so both hosts draw the same marks and node tests
3
+ // can pin them.
4
+
5
+ export const CHART_TYPES = Object.freeze(["bar", "hbar", "line", "area", "donut", "sparkline"]);
6
+ export const DEFAULT_CHART_TYPE = "bar";
7
+ export const DEFAULT_CHART_HEIGHT = 220;
8
+ export const DEFAULT_SPARKLINE_HEIGHT = 48;
9
+ export const VALUE_FORMATS = Object.freeze(["number", "compact", "percent"]);
10
+
11
+ // The single series a chart draws when the caller names none.
12
+ export const DEFAULT_SERIES_KEY = "value";
13
+ // A screen reader hears at most this many values, over all series.
14
+ export const SPOKEN_VALUES_MAX = 24;
15
+ /** The tail of a cut-short spoken list, until a widget passes `moreText`. */
16
+ export const DEFAULT_MORE_TEXT = (count) => `and ${count} more`;
17
+
18
+ const TYPE_NAMES = Object.freeze({
19
+ bar: "Bar chart",
20
+ hbar: "Horizontal bar chart",
21
+ line: "Line chart",
22
+ area: "Area chart",
23
+ donut: "Donut chart",
24
+ sparkline: "Sparkline",
25
+ });
26
+
27
+ // Average glyph width as a share of the font size, for fitting labels.
28
+ const GLYPH_WIDTH = 0.58;
29
+ const TICK_COUNT = 4;
30
+ // A crowded axis keeps every n-th label once labels would shrink below this.
31
+ const LABEL_MIN_CHARS = 6;
32
+ const TICK_GAP = 6;
33
+ const GROUP_SHARE = 0.72;
34
+ const BAR_MAX_WIDTH = 48;
35
+ const BAR_MIN_WIDTH = 2;
36
+ const DONUT_HOLE = 0.62;
37
+ const DOTS_MAX_POINTS = 24;
38
+ const HBAR_LABEL_SHARE = 0.38;
39
+ const HBAR_ROW_SHARE = 0.7;
40
+ // A horizontal bar's label needs its line height; crowded rows label every n-th.
41
+ const HBAR_LABEL_GAP = 4;
42
+
43
+ export function normaliseChartType(type) {
44
+ return CHART_TYPES.includes(type) ? type : DEFAULT_CHART_TYPE;
45
+ }
46
+
47
+ /** The plot's height: a positive number, else the type's own default. */
48
+ export function chartHeight(type, height) {
49
+ if (typeof height === "number" && Number.isFinite(height) && height > 0) return height;
50
+ return type === "sparkline" ? DEFAULT_SPARKLINE_HEIGHT : DEFAULT_CHART_HEIGHT;
51
+ }
52
+
53
+ function labelText(value) {
54
+ if (value === null || value === undefined) return "";
55
+ if (typeof value === "object" && typeof value.label === "string") return value.label;
56
+ return String(value);
57
+ }
58
+
59
+ function numberOrNull(value) {
60
+ if (value === null || value === undefined || value === "") return null;
61
+ const n = typeof value === "number" ? value : Number(value);
62
+ return Number.isFinite(n) ? n : null;
63
+ }
64
+
65
+ /**
66
+ * Turn `data` (and the optional `series`) into the labels and the value rows
67
+ * every chart type reads. Without `series` each item is `{ label, value }`;
68
+ * with it, each item carries one number per series key.
69
+ *
70
+ * @returns {{ labels: string[], series: Array<{ key: string, label: string, color?: string, values: Array<number|null> }> }}
71
+ */
72
+ export function normaliseChartData(data, series) {
73
+ const rows = Array.isArray(data) ? data.filter((row) => row && typeof row === "object") : [];
74
+ const declared = Array.isArray(series)
75
+ ? series.filter((s) => s && typeof s === "object" && typeof s.key === "string" && s.key)
76
+ : [];
77
+ const lines = declared.length
78
+ ? declared.map((s) => ({
79
+ key: s.key,
80
+ label: labelText(s.label ?? s.key),
81
+ color: typeof s.color === "string" && s.color ? s.color : undefined,
82
+ }))
83
+ : [{ key: DEFAULT_SERIES_KEY, label: "", color: undefined }];
84
+ return {
85
+ labels: rows.map((row) => labelText(row.label)),
86
+ series: lines.map((line) => ({ ...line, values: rows.map((row) => numberOrNull(row[line.key])) })),
87
+ };
88
+ }
89
+
90
+ /** The values a donut draws: the first series, positive values only. */
91
+ function donutValues(model) {
92
+ const first = model.series[0];
93
+ return first ? first.values.map((v) => (v !== null && v > 0 ? v : 0)) : [];
94
+ }
95
+
96
+ /** True when there is nothing to plot, so the chart shows its empty state. */
97
+ export function isChartEmpty(model, type) {
98
+ if (!model || model.labels.length === 0) return true;
99
+ if (type === "donut") return donutValues(model).reduce((sum, v) => sum + v, 0) <= 0;
100
+ return !model.series.some((s) => s.values.some((v) => v !== null));
101
+ }
102
+
103
+ // `locale` is the app's language; an unknown one falls back to the device's.
104
+ function localNumber(value, digits, locale) {
105
+ try {
106
+ return value.toLocaleString(locale, { maximumFractionDigits: digits });
107
+ } catch {
108
+ return String(Number(value.toFixed(digits)));
109
+ }
110
+ }
111
+
112
+ function compactNumber(value, locale) {
113
+ const abs = Math.abs(value);
114
+ for (const [size, suffix] of [
115
+ [1e12, "T"],
116
+ [1e9, "B"],
117
+ [1e6, "M"],
118
+ [1e3, "k"],
119
+ ]) {
120
+ if (abs >= size) return `${localNumber(value / size, abs / size < 10 ? 1 : 0, locale)}${suffix}`;
121
+ }
122
+ return localNumber(value, abs < 10 ? 2 : 1, locale);
123
+ }
124
+
125
+ /**
126
+ * One value as text. `format` is a function of the number, or `"number"`
127
+ * (default, grouped), `"compact"` (1.2k, 3M) or `"percent"`, which appends a
128
+ * percent sign to a value that is already a percentage (42 reads "42%"; it
129
+ * never multiplies by 100). Digits are grouped for `locale`, the app's language.
130
+ */
131
+ export function formatChartValue(value, format, locale) {
132
+ if (typeof value !== "number" || !Number.isFinite(value)) return "";
133
+ if (typeof format === "function") {
134
+ try {
135
+ return String(format(value));
136
+ } catch {
137
+ return localNumber(value, 2, locale);
138
+ }
139
+ }
140
+ if (format === "compact") return compactNumber(value, locale);
141
+ if (format === "percent") return `${localNumber(value, 2, locale)}%`;
142
+ return localNumber(value, 2, locale);
143
+ }
144
+
145
+ /** An axis tick as text: compact unless the caller formats values itself. */
146
+ export function formatChartTick(value, format, locale) {
147
+ if (typeof format === "function" || format === "percent") return formatChartValue(value, format, locale);
148
+ return compactNumber(value, locale);
149
+ }
150
+
151
+ function niceStep(raw) {
152
+ const power = 10 ** Math.floor(Math.log10(raw));
153
+ const fraction = raw / power;
154
+ const nice = fraction < 1.5 ? 1 : fraction < 3 ? 2 : fraction < 7 ? 5 : 10;
155
+ return nice * power;
156
+ }
157
+
158
+ function cleanFloat(value) {
159
+ return Number(value.toPrecision(12));
160
+ }
161
+
162
+ /**
163
+ * Round a value range out to "nice" tick values (0, 25, 50 …).
164
+ *
165
+ * @returns {{ min: number, max: number, ticks: number[] }}
166
+ */
167
+ export function niceTicks(min, max, count = TICK_COUNT) {
168
+ let low = Number.isFinite(min) ? min : 0;
169
+ let high = Number.isFinite(max) ? max : 0;
170
+ if (low > high) [low, high] = [high, low];
171
+ if (low === high) {
172
+ if (low === 0) high = 1;
173
+ else if (low > 0) low = 0;
174
+ else high = 0;
175
+ }
176
+ const step = niceStep((high - low) / Math.max(1, count));
177
+ const niceMin = cleanFloat(Math.floor(low / step) * step);
178
+ const niceMax = cleanFloat(Math.ceil(high / step) * step);
179
+ const ticks = [];
180
+ for (let tick = niceMin; tick <= niceMax + step / 2; tick += step) ticks.push(cleanFloat(tick));
181
+ return { min: niceMin, max: niceMax, ticks };
182
+ }
183
+
184
+ /** Map `[d0, d1]` onto `[r0, r1]`; a zero-width domain maps to the middle. */
185
+ export function linearScale(d0, d1, r0, r1) {
186
+ const span = d1 - d0;
187
+ if (!span) return () => (r0 + r1) / 2;
188
+ return (value) => r0 + ((value - d0) / span) * (r1 - r0);
189
+ }
190
+
191
+ function round(value) {
192
+ return Math.round(value * 100) / 100;
193
+ }
194
+
195
+ /**
196
+ * A line through the points, broken where a point is null (a missing value).
197
+ *
198
+ * @param {Array<{x:number,y:number}|null>} points
199
+ */
200
+ export function linePath(points) {
201
+ let d = "";
202
+ let pen = false;
203
+ for (const point of points) {
204
+ if (!point) {
205
+ pen = false;
206
+ continue;
207
+ }
208
+ d += `${d ? " " : ""}${pen ? "L" : "M"} ${round(point.x)} ${round(point.y)}`;
209
+ pen = true;
210
+ }
211
+ return d;
212
+ }
213
+
214
+ /** The area under each unbroken run of points, closed down to `baseY`. */
215
+ export function areaPath(points, baseY) {
216
+ const runs = [];
217
+ let run = [];
218
+ for (const point of points) {
219
+ if (point) run.push(point);
220
+ else if (run.length) {
221
+ runs.push(run);
222
+ run = [];
223
+ }
224
+ }
225
+ if (run.length) runs.push(run);
226
+ return runs
227
+ .map((part) => {
228
+ const first = part[0];
229
+ const last = part[part.length - 1];
230
+ const top = part.map((p) => `L ${round(p.x)} ${round(p.y)}`).join(" ");
231
+ return `M ${round(first.x)} ${round(baseY)} ${top} L ${round(last.x)} ${round(baseY)} Z`;
232
+ })
233
+ .join(" ");
234
+ }
235
+
236
+ /**
237
+ * A bar rounded only at its far end (`edge`: "top" | "bottom" | "right" |
238
+ * "left"), so it stands square on its baseline.
239
+ */
240
+ export function barPath({ x, y, width, height }, radius, edge = "top") {
241
+ const across = edge === "top" || edge === "bottom" ? width : height;
242
+ const along = edge === "top" || edge === "bottom" ? height : width;
243
+ const r = Math.max(0, Math.min(radius || 0, across / 2, along));
244
+ const [x0, y0, x1, y1] = [x, y, x + width, y + height].map(round);
245
+ if (r <= 0) return `M ${x0} ${y0} L ${x1} ${y0} L ${x1} ${y1} L ${x0} ${y1} Z`;
246
+ const q = round(r);
247
+ const arc = (tx, ty) => `A ${q} ${q} 0 0 1 ${round(tx)} ${round(ty)}`;
248
+ if (edge === "top") {
249
+ return `M ${x0} ${y1} L ${x0} ${round(y + r)} ${arc(x + r, y)} L ${round(x + width - r)} ${y0} ${arc(x + width, y + r)} L ${x1} ${y1} Z`;
250
+ }
251
+ if (edge === "bottom") {
252
+ return `M ${x1} ${y0} L ${x1} ${round(y + height - r)} ${arc(x + width - r, y + height)} L ${round(x + r)} ${y1} ${arc(x, y + height - r)} L ${x0} ${y0} Z`;
253
+ }
254
+ if (edge === "right") {
255
+ return `M ${x0} ${y0} L ${round(x + width - r)} ${y0} ${arc(x + width, y + r)} L ${x1} ${round(y + height - r)} ${arc(x + width - r, y + height)} L ${x0} ${y1} Z`;
256
+ }
257
+ return `M ${x1} ${y1} L ${round(x + r)} ${y1} ${arc(x, y + height - r)} L ${x0} ${round(y + r)} ${arc(x + r, y)} L ${x1} ${y0} Z`;
258
+ }
259
+
260
+ function polar(cx, cy, r, angle) {
261
+ return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) };
262
+ }
263
+
264
+ function circlePath(cx, cy, r, sweep) {
265
+ const top = `${round(cx)} ${round(cy - r)}`;
266
+ const bottom = `${round(cx)} ${round(cy + r)}`;
267
+ const q = round(r);
268
+ return `M ${top} A ${q} ${q} 0 1 ${sweep} ${bottom} A ${q} ${q} 0 1 ${sweep} ${top} Z`;
269
+ }
270
+
271
+ /** One ring segment from `start` to `end` (radians, 0 = 3 o'clock, clockwise). */
272
+ export function ringSlicePath(cx, cy, outer, inner, start, end) {
273
+ if (end - start >= Math.PI * 2 - 1e-6) {
274
+ // A whole ring: the hole is cut out by the opposite winding.
275
+ return `${circlePath(cx, cy, outer, 1)} ${inner > 0 ? circlePath(cx, cy, inner, 0) : ""}`.trim();
276
+ }
277
+ const large = end - start > Math.PI ? 1 : 0;
278
+ const a = polar(cx, cy, outer, start);
279
+ const b = polar(cx, cy, outer, end);
280
+ const c = polar(cx, cy, inner, end);
281
+ const d = polar(cx, cy, inner, start);
282
+ const o = round(outer);
283
+ const i = round(inner);
284
+ return (
285
+ `M ${round(a.x)} ${round(a.y)} A ${o} ${o} 0 ${large} 1 ${round(b.x)} ${round(b.y)} ` +
286
+ `L ${round(c.x)} ${round(c.y)} A ${i} ${i} 0 ${large} 0 ${round(d.x)} ${round(d.y)} Z`
287
+ );
288
+ }
289
+
290
+ function estimateWidth(text, fontSize) {
291
+ return String(text).length * fontSize * GLYPH_WIDTH;
292
+ }
293
+
294
+ /** Shorten a label to fit `maxWidth`, ending it with an ellipsis. */
295
+ export function fitLabel(text, maxWidth, fontSize) {
296
+ const value = String(text);
297
+ if (estimateWidth(value, fontSize) <= maxWidth) return value;
298
+ const chars = Math.max(1, Math.floor(maxWidth / (fontSize * GLYPH_WIDTH)) - 1);
299
+ return `${value.slice(0, chars)}…`;
300
+ }
301
+
302
+ function valueExtent(model) {
303
+ let min = Infinity;
304
+ let max = -Infinity;
305
+ for (const s of model.series) {
306
+ for (const v of s.values) {
307
+ if (v === null) continue;
308
+ if (v < min) min = v;
309
+ if (v > max) max = v;
310
+ }
311
+ }
312
+ return Number.isFinite(min) ? { min, max } : { min: 0, max: 0 };
313
+ }
314
+
315
+ // Bars and areas stand on zero; a line or sparkline shows its own range.
316
+ function valueDomain(model, type) {
317
+ const { min, max } = valueExtent(model);
318
+ if (type === "line" || type === "sparkline") return { min, max };
319
+ return { min: Math.min(0, min), max: Math.max(0, max) };
320
+ }
321
+
322
+ // A bar's thickness: its share of the group, at least a hairline, never past its slot.
323
+ function barThickness(groupSize, groups, slot) {
324
+ return Math.min(Math.max(BAR_MIN_WIDTH, groupSize / groups), slot / groups);
325
+ }
326
+
327
+ // Every n-th category label, so neighbouring labels never overlap.
328
+ function labelStride(labels, slot, fontSize) {
329
+ const longest = labels.reduce((m, l) => Math.max(m, Math.min(String(l).length, LABEL_MIN_CHARS)), 1);
330
+ const needed = longest * fontSize * GLYPH_WIDTH + TICK_GAP;
331
+ return Math.max(1, Math.ceil(needed / Math.max(1, slot)));
332
+ }
333
+
334
+ function pointsFor(values, xAt, scale) {
335
+ return values.map((v, i) => (v === null ? null : { x: xAt(i), y: scale(v) }));
336
+ }
337
+
338
+ function verticalLayout({ type, width, height, model, fontSize, valueFormat, locale }) {
339
+ const sparkline = type === "sparkline";
340
+ const domain = valueDomain(model, type);
341
+ const nice = sparkline ? { ...domain, ticks: [] } : niceTicks(domain.min, domain.max);
342
+ const tickTexts = nice.ticks.map((t) => formatChartTick(t, valueFormat, locale));
343
+ const stroke = sparkline ? 2 : 0;
344
+ const left = sparkline ? stroke : Math.ceil(tickTexts.reduce((m, t) => Math.max(m, estimateWidth(t, fontSize)), 0)) + TICK_GAP;
345
+ const right = sparkline ? stroke : Math.ceil(fontSize / 2);
346
+ const top = sparkline ? stroke : Math.ceil(fontSize / 2) + 1;
347
+ const bottom = sparkline ? stroke : fontSize + TICK_GAP + 2;
348
+ const plotW = Math.max(1, width - left - right);
349
+ const plotH = Math.max(1, height - top - bottom);
350
+ const scale = linearScale(nice.min, nice.max, top + plotH, top);
351
+ const baseY = scale(Math.min(Math.max(0, nice.min), nice.max));
352
+ const count = model.labels.length;
353
+
354
+ const out = {
355
+ plot: { left, top, width: plotW, height: plotH },
356
+ gridLines: nice.ticks.map((t) => ({ x1: left, x2: left + plotW, y: round(scale(t)) })),
357
+ tickLabels: nice.ticks.map((t, i) => ({ x: left - TICK_GAP, y: round(scale(t) + fontSize / 3), text: tickTexts[i], anchor: "end" })),
358
+ baseline: sparkline ? null : { x1: left, x2: left + plotW, y: round(baseY) },
359
+ categoryLabels: [],
360
+ bars: [],
361
+ lines: [],
362
+ areas: [],
363
+ dots: [],
364
+ };
365
+
366
+ if (type === "bar") {
367
+ const slot = plotW / Math.max(1, count);
368
+ const groups = model.series.length;
369
+ const groupW = Math.min(slot * GROUP_SHARE, BAR_MAX_WIDTH * groups);
370
+ const barW = barThickness(groupW, groups, slot);
371
+ model.series.forEach((s, si) => {
372
+ s.values.forEach((v, i) => {
373
+ if (v === null) return;
374
+ const x = left + i * slot + (slot - barW * groups) / 2 + si * barW;
375
+ const y = scale(v);
376
+ const up = y <= baseY;
377
+ out.bars.push({
378
+ seriesIndex: si,
379
+ index: i,
380
+ rect: { x, y: up ? y : baseY, width: barW, height: Math.abs(baseY - y) },
381
+ edge: up ? "top" : "bottom",
382
+ });
383
+ });
384
+ });
385
+ const stride = labelStride(model.labels, slot, fontSize);
386
+ model.labels.forEach((label, i) => {
387
+ if (i % stride) return;
388
+ out.categoryLabels.push({
389
+ x: round(left + i * slot + slot / 2),
390
+ y: round(top + plotH + TICK_GAP + fontSize * 0.8),
391
+ text: fitLabel(label, slot * stride - TICK_GAP, fontSize),
392
+ anchor: "middle",
393
+ });
394
+ });
395
+ return out;
396
+ }
397
+
398
+ const xAt = count > 1 ? (i) => left + (i * plotW) / (count - 1) : () => left + plotW / 2;
399
+ model.series.forEach((s, si) => {
400
+ const points = pointsFor(s.values, xAt, scale);
401
+ out.lines.push({ seriesIndex: si, d: linePath(points) });
402
+ if (type === "area") out.areas.push({ seriesIndex: si, d: areaPath(points, baseY) });
403
+ if (sparkline) {
404
+ const last = [...points].reverse().find(Boolean);
405
+ if (last) out.dots.push({ seriesIndex: si, cx: round(last.x), cy: round(last.y) });
406
+ } else if (count <= DOTS_MAX_POINTS) {
407
+ points.forEach((p) => p && out.dots.push({ seriesIndex: si, cx: round(p.x), cy: round(p.y) }));
408
+ }
409
+ });
410
+ if (!sparkline) {
411
+ const slot = count > 1 ? plotW / (count - 1) : plotW;
412
+ const stride = labelStride(model.labels, slot, fontSize);
413
+ model.labels.forEach((label, i) => {
414
+ if (i % stride) return;
415
+ const first = i === 0 && count > 1;
416
+ const last = i === count - 1 && count > 1;
417
+ out.categoryLabels.push({
418
+ x: round(xAt(i)),
419
+ y: round(top + plotH + TICK_GAP + fontSize * 0.8),
420
+ text: fitLabel(label, Math.max(slot * stride - TICK_GAP, fontSize * 3), fontSize),
421
+ anchor: first ? "start" : last ? "end" : "middle",
422
+ });
423
+ });
424
+ }
425
+ return out;
426
+ }
427
+
428
+ function horizontalLayout({ width, height, model, fontSize, valueFormat, locale }) {
429
+ const domain = valueDomain(model, "hbar");
430
+ const nice = niceTicks(domain.min, domain.max);
431
+ const count = model.labels.length;
432
+ const longest = model.labels.reduce((m, l) => Math.max(m, estimateWidth(l, fontSize)), 0);
433
+ const left = Math.ceil(Math.min(longest, width * HBAR_LABEL_SHARE)) + TICK_GAP;
434
+ const lastTick = formatChartTick(nice.ticks[nice.ticks.length - 1], valueFormat, locale);
435
+ const right = Math.ceil(estimateWidth(lastTick, fontSize) / 2) + 2;
436
+ const top = 2;
437
+ const bottom = fontSize + TICK_GAP + 2;
438
+ const plotW = Math.max(1, width - left - right);
439
+ const plotH = Math.max(1, height - top - bottom);
440
+ const scale = linearScale(nice.min, nice.max, left, left + plotW);
441
+ const baseX = scale(Math.min(Math.max(0, nice.min), nice.max));
442
+ const slot = plotH / Math.max(1, count);
443
+ const groups = model.series.length;
444
+ const groupH = Math.min(slot * HBAR_ROW_SHARE, BAR_MAX_WIDTH * groups);
445
+ const barH = barThickness(groupH, groups, slot);
446
+ // The chart keeps its height, so rows thinner than a line of text label every n-th.
447
+ const stride = Math.max(1, Math.ceil((fontSize + HBAR_LABEL_GAP) / Math.max(slot, 1e-6)));
448
+ const out = {
449
+ plot: { left, top, width: plotW, height: plotH },
450
+ gridLines: nice.ticks.map((t) => ({ y1: top, y2: top + plotH, x: round(scale(t)) })),
451
+ tickLabels: nice.ticks.map((t) => ({ x: round(scale(t)), y: round(top + plotH + TICK_GAP + fontSize * 0.8), text: formatChartTick(t, valueFormat, locale), anchor: "middle" })),
452
+ baseline: { y1: top, y2: top + plotH, x: round(baseX) },
453
+ categoryLabels: model.labels.flatMap((label, i) =>
454
+ i % stride
455
+ ? []
456
+ : [{ x: left - TICK_GAP, y: round(top + i * slot + slot / 2 + fontSize / 3), text: fitLabel(label, left - TICK_GAP, fontSize), anchor: "end" }],
457
+ ),
458
+ bars: [],
459
+ lines: [],
460
+ areas: [],
461
+ dots: [],
462
+ };
463
+ model.series.forEach((s, si) => {
464
+ s.values.forEach((v, i) => {
465
+ if (v === null) return;
466
+ const x = scale(v);
467
+ const forward = x >= baseX;
468
+ out.bars.push({
469
+ seriesIndex: si,
470
+ index: i,
471
+ rect: { x: forward ? baseX : x, y: top + i * slot + (slot - barH * groups) / 2 + si * barH, width: Math.abs(x - baseX), height: barH },
472
+ edge: forward ? "right" : "left",
473
+ });
474
+ });
475
+ });
476
+ return out;
477
+ }
478
+
479
+ /**
480
+ * Everything a bar, horizontal bar, line, area or sparkline chart draws, in
481
+ * the pixel space of a `width` x `height` SVG.
482
+ */
483
+ export function cartesianLayout({ type, width, height, model, fontSize = 12, valueFormat, locale }) {
484
+ const kind = normaliseChartType(type);
485
+ if (kind === "hbar") return horizontalLayout({ width, height, model, fontSize, valueFormat, locale });
486
+ return verticalLayout({ type: kind, width, height, model, fontSize, valueFormat, locale });
487
+ }
488
+
489
+ /** The ring segments of a donut, one per positive value of the first series. */
490
+ export function donutLayout({ width, height, model }) {
491
+ const values = donutValues(model);
492
+ const total = values.reduce((sum, v) => sum + v, 0);
493
+ const size = Math.max(0, Math.min(width, height));
494
+ const cx = width / 2;
495
+ const cy = height / 2;
496
+ const outer = size / 2;
497
+ const inner = outer * DONUT_HOLE;
498
+ const slices = [];
499
+ let angle = -Math.PI / 2;
500
+ values.forEach((v, index) => {
501
+ if (v <= 0 || total <= 0) return;
502
+ const sweep = (v / total) * Math.PI * 2;
503
+ slices.push({ index, d: ringSlicePath(cx, cy, outer, inner, angle, angle + sweep), share: v / total });
504
+ angle += sweep;
505
+ });
506
+ return { cx, cy, outer, inner, total, slices };
507
+ }
508
+
509
+ function spokenValues(labels, values, { format, locale, moreText, budget }) {
510
+ const said = [];
511
+ values.forEach((v, i) => {
512
+ if (v === null || said.length >= budget) return;
513
+ said.push(`${labels[i]} ${formatChartValue(v, format, locale)}`.trim());
514
+ });
515
+ const shown = values.filter((v) => v !== null).length;
516
+ const rest = shown - said.length;
517
+ return rest > 0 ? `${said.join(", ")}, ${moreText(rest)}` : said.join(", ");
518
+ }
519
+
520
+ function highest(labels, values) {
521
+ let best = -1;
522
+ values.forEach((v, i) => {
523
+ if (v !== null && (best === -1 || v > values[best])) best = i;
524
+ });
525
+ return best === -1 ? null : { label: labels[best], value: values[best] };
526
+ }
527
+
528
+ function plural(count, one, many) {
529
+ return `${count} ${count === 1 ? one : many}`;
530
+ }
531
+
532
+ /**
533
+ * What a screen reader hears: a one-line summary, then the values, at most
534
+ * `SPOKEN_VALUES_MAX` of them over all series. `lead` replaces the summary (a
535
+ * widget passes its translated title there) and `moreText(count)` the tail
536
+ * of a list cut short, so a widget can say both in the app's language.
537
+ */
538
+ export function chartSummary({ type, model, valueFormat, lead, locale, moreText }) {
539
+ const kind = normaliseChartType(type);
540
+ const name = TYPE_NAMES[kind];
541
+ const labels = model.labels;
542
+ const tail = typeof moreText === "function" ? moreText : DEFAULT_MORE_TEXT;
543
+ const say = (values, budget = SPOKEN_VALUES_MAX) =>
544
+ spokenValues(labels, values, { format: valueFormat, locale, moreText: tail, budget });
545
+ const value = (v) => formatChartValue(v, valueFormat, locale);
546
+ if (kind === "donut") {
547
+ const values = donutValues(model).map((v) => (v > 0 ? v : null));
548
+ const total = values.reduce((sum, v) => sum + (v || 0), 0);
549
+ const top = highest(labels, values);
550
+ const share = top && total > 0 ? Math.round((top.value / total) * 100) : 0;
551
+ const summary = top
552
+ ? `${name}, ${plural(values.filter((v) => v !== null).length, "slice", "slices")}, largest: ${top.label} ${value(top.value)} (${share}%)`
553
+ : name;
554
+ return `${lead || summary}. ${say(values)}`;
555
+ }
556
+ if (model.series.length > 1) {
557
+ let best = null;
558
+ for (const s of model.series) {
559
+ const top = highest(labels, s.values);
560
+ if (top && (!best || top.value > best.value)) best = { ...top, series: s.label };
561
+ }
562
+ const summary = best
563
+ ? `${name}, ${plural(model.series.length, "series", "series")} of ${plural(labels.length, "value", "values")}, highest: ${best.series}, ${best.label} ${value(best.value)}`
564
+ : name;
565
+ const each = Math.max(1, Math.floor(SPOKEN_VALUES_MAX / model.series.length));
566
+ const spoken = model.series.map((s) => `${s.label}: ${say(s.values, each)}`).join("; ");
567
+ return `${lead || summary}. ${spoken}`;
568
+ }
569
+ const values = model.series[0] ? model.series[0].values : [];
570
+ const top = highest(labels, values);
571
+ const summary = top
572
+ ? `${name}, ${plural(values.filter((v) => v !== null).length, "value", "values")}, highest: ${top.label} ${value(top.value)}`
573
+ : name;
574
+ return `${lead || summary}. ${say(values)}`;
575
+ }