@bezda/rhp-mcp 0.0.0-stage → 0.1.1

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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -2
  3. package/bin/rhp-mcp.js +129 -0
  4. package/package.json +62 -4
  5. package/skill/SKILL.md +450 -0
  6. package/skill/recipes/area.html +156 -0
  7. package/skill/recipes/bar.html +145 -0
  8. package/skill/recipes/box-plot.html +164 -0
  9. package/skill/recipes/bubble.html +188 -0
  10. package/skill/recipes/bullet.html +138 -0
  11. package/skill/recipes/candlestick.html +179 -0
  12. package/skill/recipes/column.html +138 -0
  13. package/skill/recipes/dial.html +349 -0
  14. package/skill/recipes/diverging-bars.html +150 -0
  15. package/skill/recipes/donut.html +231 -0
  16. package/skill/recipes/dumbbell.html +137 -0
  17. package/skill/recipes/gantt.html +196 -0
  18. package/skill/recipes/grouped-bars.html +142 -0
  19. package/skill/recipes/heatmap.html +183 -0
  20. package/skill/recipes/histogram.html +199 -0
  21. package/skill/recipes/line.html +170 -0
  22. package/skill/recipes/live.html +255 -0
  23. package/skill/recipes/log-line.html +493 -0
  24. package/skill/recipes/lollipop.html +152 -0
  25. package/skill/recipes/multi-line.html +228 -0
  26. package/skill/recipes/pyramid.html +162 -0
  27. package/skill/recipes/race.html +278 -0
  28. package/skill/recipes/radial-bars.html +157 -0
  29. package/skill/recipes/scatter.html +199 -0
  30. package/skill/recipes/silhouettes.html +352 -0
  31. package/skill/recipes/slope.html +199 -0
  32. package/skill/recipes/sparklines.html +175 -0
  33. package/skill/recipes/stacked-100.html +158 -0
  34. package/skill/recipes/stacked-bars.html +150 -0
  35. package/skill/recipes/strip.html +221 -0
  36. package/skill/recipes/unit-stack.html +276 -0
  37. package/skill/recipes/violin.html +169 -0
  38. package/skill/recipes/waffle.html +165 -0
  39. package/skill/recipes/waterfall.html +127 -0
  40. package/skill/references/api.md +1684 -0
  41. package/skill/references/design.md +740 -0
  42. package/skill/references/environments.md +1577 -0
  43. package/skill/references/forms.md +197 -0
  44. package/skill/references/interaction.md +794 -0
  45. package/skill/references/looks.md +267 -0
  46. package/skill/references/pitfalls.md +202 -0
  47. package/src/check/README.md +176 -0
  48. package/src/check/browser.js +92 -0
  49. package/src/check/build.js +439 -0
  50. package/src/check/colors.js +324 -0
  51. package/src/check/findings.js +247 -0
  52. package/src/check/index.js +508 -0
  53. package/src/check/inpage.js +1332 -0
  54. package/src/check/interact.js +266 -0
  55. package/src/check/png.js +98 -0
  56. package/src/check/report.js +106 -0
  57. package/src/check/source.js +387 -0
  58. package/src/check/sourcemap.js +75 -0
  59. package/src/guard/note.js +30 -0
  60. package/src/guard/render.js +17 -0
  61. package/src/guard/rhp.js +612 -0
  62. package/src/guard/rules.js +125 -0
  63. package/src/server.js +266 -0
  64. package/src/skill.js +140 -0
@@ -0,0 +1,1684 @@
1
+ # rhp API reference
2
+
3
+ rhp 2 (`@bezda/rhp@2`, built on SolidJS 1.9).
4
+ Every statement here was checked against rhp's source and by running it in a browser, and every code block is a complete example that runs.
5
+ The `js` examples are the module script of [the page skeleton](#a-page-with-a-chart): they draw into `<div id="chart">`.
6
+
7
+ Contents: [1 The model](#1-the-model) · [2 Imports and the html template](#2-imports-and-the-html-template) · [3 Chart](#3-chart) · [4 Plot](#4-plot) · [5 slat()](#5-slat) · [6 Blocks](#6-blocks) · [ManyDots](#manydots-experimental-point-collection) · [7 Scales and axes](#7-scales-and-axes) · [8 Theme](#8-theme) · [9 Helpers](#9-helpers) · [10 Poster](#10-poster) · [11 CSS](#11-css) · [12 Motion](#12-motion) · [13 Accessibility and interaction](#13-accessibility-and-interaction) · [14 Limits](#14-limits) · [15 Gotchas](#15-gotchas)
8
+
9
+ ## 1. The model
10
+
11
+ - A **Chart** is the frame: the value scale `[min, max]`, the orientation, the axis, the theme and the size.
12
+ It holds one or more Plots, drawn over each other on the same scale.
13
+ - A **Plot** is a stack of slats, one per row of data.
14
+ Each Plot prop that is not a setting is a **data group**: one column of the table (`fruit` = `["Apples", "Pears"]`, `sold` = `[12, 7]`).
15
+ - A **slat** is a function of one row, `d`, that returns one element.
16
+ It reads the row as `d.fruit` and `d.sold`, plus `d.index` (the row's number) and `d.position` (its place on screen).
17
+ rhp places that element in its band and moves it when the order changes.
18
+ - **Blocks** inside the slat (Bar, Dot, Tick, Label, Cell, Place, Area, Line) sit at their numbers on the Chart's scale.
19
+ - **ManyDots** is an experimental bulk point collection, placed directly inside a Chart with `cross`.
20
+ It draws one plain HTML element per valid row without a Plot, slat, or Solid computation per point.
21
+ - **Orientation** is the direction bars run.
22
+ `"horizontal"` (the default): bars run left to right and slats stack top to bottom.
23
+ `"vertical"`: bars run bottom to top and slats stand side by side.
24
+ One slat draws both ways.
25
+ - The **value axis** runs along the bars, and the **band** is the strip one slat takes across it (its thickness).
26
+ Names follow the value axis: **start** is the scale's start side (left, or bottom when vertical) and **end** its far side (right, or top).
27
+ Along the stack, **before** is the first slat's side (top, or left) and **after** the last slat's (bottom, or right).
28
+ - The **scale** is given, never computed: every block of every Plot in the Chart uses `scale`, so it must cover the data (`nice()` rounds it).
29
+ - **One slat per row**: the longest list among the data groups and `rows` sets how many slats there are (or `slats` does).
30
+
31
+ ## 2. Imports and the html template
32
+
33
+ | Import | What it is | Where |
34
+ |---|---|---|
35
+ | `@bezda/rhp` | rhp for Solid apps (peer dependency `solid-js` ^1.9) | Solid (Vite, SolidStart), Astro islands: JSX |
36
+ | `@bezda/rhp/standalone` | rhp and Solid in one ES module | plain HTML (CDN and import map), React, Next.js, Vue, Svelte, Angular: html template |
37
+ | `@bezda/rhp/rhp.css` | rhp's core CSS as a file | only with `linkedCss()` (server rendering, many islands) |
38
+ | `@bezda/rhp/posters.css` | the gallery's Poster looks | optional; the kit's recipes write their own |
39
+ | `@bezda/rhp-react` | `toReact()` and everything in standalone | not on npm yet: in React apps, use `@bezda/rhp/standalone` with the small wrapper in environments.md |
40
+
41
+ Both `@bezda/rhp` and `@bezda/rhp/standalone` export rhp's whole API:
42
+
43
+ - components: `Chart`, `Plot`, `Scale`, `Axis`, `Theme`, `Poster`
44
+ - blocks: `Bar`, `Dot`, `Tick`, `Label`, `Cell`, `Place`, `Area`, `Line`
45
+ - experimental point collection: [`ManyDots`](#manydots-experimental-point-collection), in builds of this repository that export it
46
+ - functions: `slat`, `restyle`, `linkedCss`, `shape`, `at`, `useOrientation`, `series`, `cycle`, `sortBy`, `extent`, `every`, `nice`, `stackUp`, `shares`, `running`, `summary`, `bins`, `density`, `animated`, `curve`, `drawing`
47
+ - the object `THEME` (the default theme)
48
+
49
+ `@bezda/rhp/standalone` adds these from Solid, and nothing else: `render`, `html`, `createSignal`, `createMemo`, `createEffect`, `createRoot`, `createSelector`, `createComputed`, `on`, `onMount`, `onCleanup`, `batch`, `untrack`, `mergeProps`, `splitProps`, `For`, `Index`, `Show`, `Switch`, `Match`, `createStore`, `reconcile`, `produce`, `unwrap`.
50
+ `createSelector`, `createComputed`, `on`, `mergeProps`, `splitProps`, `Switch` and `Match` are there from rhp 2.0.2; 2.0.1 lacks them.
51
+ It has no `Dynamic`, `Portal`, `createResource` or `ErrorBoundary`.
52
+ Never import `solid-js` next to it: the module carries its own copy of Solid, and a signal from another copy is not tracked.
53
+
54
+ rhp adds its own CSS to the page when the first chart is drawn, so there is no stylesheet to link.
55
+
56
+ ### The html template
57
+
58
+ Without a Solid build there is no JSX: charts are written with Solid's `html` tagged template.
59
+
60
+ | JSX (Solid app) | html template | Why |
61
+ |---|---|---|
62
+ | `<Bar to={d.sold} />` | `<${Bar} to=${() => d.sold} />` | a component is `<${Name}>`; a value that can change is a function |
63
+ | `<Plot …>{Fruit}</Plot>` | `<${Plot} …>${Fruit}<//>` | `<//>` closes a component (or self-close with `/>`) |
64
+ | `<Label>{d.name}</Label>` | `<${Label}>${() => d.name}<//>` | `${d.name}` is read once and never updates |
65
+ | `<Plot overlap>` | `<${Plot} overlap=${true}>` | a bare attribute passes `""`, which is false |
66
+ | `<Bar onClick={() => go()} />` | `<${Bar} onClick=${(e) => go()} />` | on a component, a function with no parameter is read as a value: it runs at render and is never attached |
67
+ | `scale={[0, 30]}` | `scale=${[0, 30]}` | numbers, arrays and objects go in `${}` |
68
+ | `style={{ "pointer-events": "none" }}` | `style=${{ "pointer-events": "none" }}` | Chart and Plot take a style object only (a string is dropped) |
69
+
70
+ The rules, in full:
71
+
72
+ 1. On a component (`<${Chart}>`, `<${Plot}>`, a block, `<${Poster}>`, `<${Show}>`), every prop that is a function with no parameter is turned into a getter: `to=${() => d.sold}` is a value that follows `d.sold`.
73
+ A signal or memo can be passed as it is: `sold=${sold}` follows the signal.
74
+ 2. So a function that must stay a function needs a parameter: event handlers (`onClick=${(e) => …}`), per-row data groups (`warm=${(d) => d.temp > 15}`), `format=${(v) => v + "%"}`, `key=${(d) => d.id}`.
75
+ The helpers `sortBy()`, `series()`, `cycle()` and `every()` already return such functions.
76
+ 3. On a plain element (`<div>`, `<button>`), handlers attach normally, with or without a parameter.
77
+ 4. Booleans are written out: `overlap=${true}`, `keyboard=${true}`, `static=${true}`, `animate=${true}`, `mirror=${true}`, `fill=${true}`, `smooth=${true}`.
78
+ 5. A slat returns one element; whitespace around it is fine, and two elements side by side throw.
79
+ Use a wrapper for an ordinary Plot or several blocks.
80
+ In an `overlap` Plot or a Scale, a single block can be the root itself, with no extra wrapper.
81
+ 6. Never destructure `d`: `({ sold }) => …` reads each value once.
82
+ Read `d.sold` inside a function.
83
+ 7. Use `class`, never `className`: on a block, `className` replaces rhp's class and the block is no longer drawn; on a plain element it does nothing.
84
+ 8. A space between two tags is dropped: `<b>${() => d.name}</b> <em>12</em>` renders "Name12". Write it as `${" "}`, or keep it next to other text (`<b>Name</b>: <em>`).
85
+
86
+ ### A page with a chart
87
+
88
+ ```html
89
+ <!doctype html>
90
+ <html lang="en">
91
+ <head>
92
+ <meta charset="utf-8">
93
+ <meta name="viewport" content="width=device-width, initial-scale=1">
94
+ <title>Fruit sold today</title>
95
+ <script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
96
+ <style>
97
+ body { margin: 0; padding: 24px 16px; background: #fff; font-family: system-ui, sans-serif; }
98
+ #chart { max-width: 760px; margin: 0 auto; }
99
+ </style>
100
+ </head>
101
+ <body>
102
+ <div id="chart"></div>
103
+ <script type="module">
104
+ import { Chart, Plot, Bar, Label, slat, html, render } from "@bezda/rhp/standalone";
105
+
106
+ // Data
107
+ const fruit = ["Apples", "Bananas", "Cherries"];
108
+ const sold = [12, 18, 7];
109
+
110
+ // Slat types
111
+ const Fruit = slat({ thickness: 36 }, (d) => html`
112
+ <div>
113
+ <${Label} edge="start">${() => d.fruit}<//>
114
+ <${Bar} to=${() => d.sold} />
115
+ <${Label} at=${() => d.sold}>${() => d.sold}<//>
116
+ </div>`);
117
+
118
+ // Chart component
119
+ const FruitChart = () => html`
120
+ <${Chart} scale=${[0, 20]} label="Fruit sold today">
121
+ <${Plot} fruit=${fruit} sold=${sold}>${Fruit}<//>
122
+ <//>`;
123
+
124
+ render(FruitChart, document.getElementById("chart"));
125
+ </script>
126
+ </body>
127
+ </html>
128
+ ```
129
+
130
+ The same chart in a Solid app.
131
+ In JSX, values need no `() =>`, bare booleans are true, handlers need no parameter, and Solid's own imports (`createSelector` included) come from `solid-js`.
132
+
133
+ ```jsx
134
+ import { createSignal } from "solid-js";
135
+ import { Chart, Plot, Bar, Label, slat } from "@bezda/rhp";
136
+
137
+ const Fruit = slat({ thickness: 36 }, (d) => (
138
+ <div>
139
+ <Label edge="start">{d.fruit}</Label>
140
+ <Bar to={d.sold} />
141
+ <Label at={d.sold}>{d.sold}</Label>
142
+ </div>
143
+ ));
144
+
145
+ export default function FruitChart() {
146
+ const [sold, setSold] = createSignal([12, 18, 7]);
147
+ return (
148
+ <>
149
+ <button onClick={() => setSold([15, 9, 11])}>New day</button>
150
+ <Chart scale={[0, 20]} label="Fruit sold today">
151
+ <Plot keyboard fruit={["Apples", "Bananas", "Cherries"]} sold={sold()}>{Fruit}</Plot>
152
+ </Chart>
153
+ </>
154
+ );
155
+ }
156
+ ```
157
+
158
+ ## 3. Chart
159
+
160
+ | Prop | Type | Default | What it does |
161
+ |---|---|---|---|
162
+ | `scale` | `[min, max]` | `[0, 100]` | the value axis. `min` must be below `max`: an equal or reversed pair draws Bars with no length and no axis. |
163
+ | `orientation` | `"horizontal"` or `"vertical"` | `"horizontal"` | the direction bars run |
164
+ | `height` | px | 240 when vertical or with `cross` | the **plot's** height (room and axis come on top). Horizontal: slats without `thickness` share it; slats with one keep theirs and `height` does nothing. |
165
+ | `aspect` | number above 0 | none | the **whole** chart's width over its height (`16 / 9`), room and axis included, at any width. `height` is then ignored (with a warning). Horizontal slats without `thickness` share the height; slats with one keep it (with a warning). |
166
+ | `ticks` | `number[]`, a count, `([min, max]) => number[]`, or `false` | about 5 round values | the axis: grid lines and numbers. A list keeps only values inside the scale. `false` draws no axis and leaves no room for it. |
167
+ | `grid` | boolean | `true` | `false` hides the value-axis grid lines while keeping the numbers and axis room. |
168
+ | `format` | `(value) => text or element` | the number as is | the text of each axis number |
169
+ | `animate` | `true` or `{ duration, ease, slide }` | off | the JS version of motion (section 12); the Plots inside take it |
170
+ | `theme` | theme object (section 8) | `THEME` | colors and font, over those of a `<Theme>` around it |
171
+ | `static` | boolean | `false` | for data that never changes: slats are drawn once and keep no signals; a data change draws them all again, without motion |
172
+ | `cross` | `[min, max]` | none | a second axis across the band (scatter plots, line charts; section 7) |
173
+ | `crossTicks`, `crossFormat` | like `ticks`, `format` | about 5, the number | the second axis |
174
+ | `crossGrid` | boolean | `true` | `false` hides only the second axis' grid lines, keeping its numbers. |
175
+ | `label` | string | none | names the chart for screen readers: the chart gets `role="figure"` and `aria-label` |
176
+ | `aria-labelledby`, `aria-describedby`, any `aria-*` | string | none | set on the chart's element; `aria-labelledby` also makes it a figure |
177
+ | `id`, `class`, `role` | string | none | set on the chart's element |
178
+ | `style` | object | none | inline style of the chart's element (a string is dropped) |
179
+ | `ref` | `(el) => …` | none | the chart's element |
180
+
181
+ A Chart passes nothing else to its element: `data-*`, `title`, `tabindex` and event handlers are dropped.
182
+ Put them on an element around the chart, or on the [Poster](#10-poster).
183
+
184
+ Children: one or more Plots (later ones drawn on top), a `Scale`, a `ManyDots` collection with a cross scale, and Solid control flow (`Show`) around them.
185
+ A Plot outside a Chart draws nothing usable: no CSS, no scale, no message.
186
+
187
+ **Size and room.** The chart's element (`.rhp-chart`) is a block as wide as its container.
188
+ Its padding is the room for names, values and axis numbers, so page CSS cannot change its padding.
189
+ What rhp gives by default (measured):
190
+
191
+ | | horizontal | vertical |
192
+ |---|---|---|
193
+ | slat thickness without `thickness` | 32px each (the chart grows with its slats); with `height` or `aspect`, they share it | the chart's width shared |
194
+ | plot height | slats × thickness | 240px, or `height` |
195
+ | room when the slat type sets no `room` | start 104px (names, left), end 44px (values, right) | start 28px (names, below), end 20px (values, above) |
196
+ | room for the axis numbers | 24px below; 14px past each end | 42px on the left; 8px past each end |
197
+ | room on any side otherwise | 2px | 2px |
198
+ | a Bar's thickness | 64% of the band (`inset` 18% on each side) | same |
199
+ | text | Labels 12px, axis numbers 11px with tabular figures, in the theme's font | same |
200
+
201
+ **aspect or height.** `aspect` keeps the chart's shape at every width (a vertical chart that should not get tall and thin on a phone).
202
+ `height` fixes the plot's height in px whatever the width.
203
+ A horizontal chart needs neither: with no `height` its slats are 32px each (or their `thickness`).
204
+
205
+ ```js
206
+ import { Chart, Plot, Bar, Label, html, render } from "@bezda/rhp/standalone";
207
+
208
+ // Data: rain in mm
209
+ const month = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"];
210
+ const rain = [84, 61, 58, 43, 49, 52];
211
+
212
+ // Slat types: a plain function is a slat too, with the default room
213
+ const Month = (d) => html`
214
+ <div>
215
+ <${Label} edge="start">${() => d.month}<//>
216
+ <${Bar} to=${() => d.rain} />
217
+ <${Label} at=${() => d.rain}>${() => d.rain}<//>
218
+ </div>`;
219
+
220
+ // Chart component: vertical, 16:9 at any width
221
+ const RainChart = () => html`
222
+ <${Chart} orientation="vertical" aspect=${16 / 9} scale=${[0, 100]} ticks=${[0, 25, 50, 75, 100]}
223
+ label="Rain per month, in mm">
224
+ <${Plot} month=${month} rain=${rain}>${Month}<//>
225
+ <//>`;
226
+
227
+ render(RainChart, document.getElementById("chart"));
228
+ ```
229
+
230
+ ## 4. Plot
231
+
232
+ A Plot's child is the slat: a slat type from `slat()` or a plain function `(d) => element`.
233
+ Its settings:
234
+
235
+ | Setting | Type | Default | What it does |
236
+ |---|---|---|---|
237
+ | `rows` | array of objects | none | each object is a row; its fields read as `d.field`. A data group with the same name wins. |
238
+ | `key` | group name, or `(d) => id` | none | names each row, so a slat follows its row when rows are added or removed |
239
+ | `order` | `(number or null)[]`, or `(rows, current) => positions` | data order | each row's place on screen. `null` hides a row at once; ties and fractions are allowed. `sortBy()` makes the function. A function of your own gets the rows and their places on screen now (`null` for a hidden row), and runs again whenever what it reads changes (section 12 has one). |
240
+ | `reorder` | `"slide"`, `"move"`, `"refill"` | `"slide"` | slide: slats keep their place in the page and slide on screen (`aria-owns` gives the reading order). move: slats move in the page. refill: the elements stay in place and show other rows. |
241
+ | `overlap` | boolean | `false` | every slat shares one band: stacked segments, layers, strips of dots, scatter plots |
242
+ | `orientation` | `"horizontal"`, `"vertical"`, `"across"` | the Chart's or the Plot's around it | `"across"`: the other way from the Plot around it (heatmap cells) |
243
+ | `slats` | number | the longest list | the number of slats; past the data, rows repeat (lists wrap around) |
244
+ | `animate` | `true`, group names, or `{ groups, duration, ease, slide }` | the Chart's | the JS version for these data groups |
245
+ | `thick` | share of the band (`0.5`) or CSS length | `1` | inside a slat: how much of the band the Plot uses |
246
+ | `static` | boolean | the Chart's | draw the slats once (see Chart) |
247
+ | `keyboard` | boolean | `false` | the slats take focus (section 13) |
248
+ | `class` | string | none | added to `rhp-plot` on the Plot's element |
249
+ | `style` | object | none | the Plot's element (`{ "pointer-events": "none" }` for an overlay); a string is dropped |
250
+ | `ref` | `(el) => …` | none | the Plot's element |
251
+
252
+ **Data groups.** Every other prop is a data group, read in the slat as `d.<name>`:
253
+
254
+ - a list (an array or a typed array) gives item `i` to slat `i`, and a shorter list wraps around (item `i % length`);
255
+ - a single value is shared by every slat;
256
+ - a function of `d` (with a parameter) is worked out per row, can read other groups and signals, and is cached per row.
257
+
258
+ So `id`, `title`, `aria-*`, `data-*` and `onClick` on a Plot are data groups too, not attributes: put attributes on the slat's root element.
259
+ A list of objects goes in `rows`; `data=${…}` is only a group named `data`.
260
+ Data that changes is a signal, a memo or a function with no parameter: `sold=${sold}`, `rows=${() => cities()}`.
261
+ For a big list that changes one item at a time, a store (`createStore`) updates only that row's slat.
262
+ `d.index` is the row's number in the data (from 0) and `d.position` its place on screen (`null` when hidden).
263
+ A slat's `d.index` can change (with `key` when rows leave, and with `reorder="refill"`), so read it in a function: `data-row=${() => d.index}`.
264
+ Reading a name that is neither a group nor a field of `rows` gives `undefined`, without an error.
265
+
266
+ **Several Plots in one Chart** share the plot area and the scale, and later ones are drawn on top.
267
+ A Plot drawn over another takes the pointer from it: give it `style=${{ "pointer-events": "none" }}`.
268
+ A top-level Plot whose slat type sets no `room` asks for the default room (104px and 44px when horizontal), so give an overlay's slat `room: {}` when the chart's room is smaller.
269
+
270
+ **A Plot inside a slat** draws in that slat's band, on the same scale.
271
+ Without `overlap` its slats divide the band (grouped bars); with `overlap` they share it (stacked segments).
272
+ `orientation="across"` turns it, so its slats run along the value axis (one Cell per hour).
273
+ `thick` uses a part of the band.
274
+ A Plot inside a slat asks for no room, is not a list for screen readers, and does not take the Chart's `animate`.
275
+ A slat of an `overlap` Plot or a Scale may return a block itself (the stacked bars in section 9 do), including that block's own children.
276
+ Use that direct root for a single mark to avoid an extra wrapper; several blocks still need a common root.
277
+ In a Plot without `overlap`, keep the wrapper: a block as the slat's root loses part of its placing and may warn.
278
+
279
+ ```js
280
+ import { Chart, Plot, Bar, Label, Tick, slat, sortBy, html, render, createSignal } from "@bezda/rhp/standalone";
281
+
282
+ // Data: rows as objects
283
+ const START = [
284
+ { city: "City A", temp: 6 },
285
+ { city: "City B", temp: 12 },
286
+ { city: "City C", temp: 27 },
287
+ { city: "City D", temp: 23 },
288
+ ];
289
+
290
+ // Slat types
291
+ const City = slat({ thickness: 34, room: { start: 64, end: 48 } }, (d) => html`
292
+ <div>
293
+ <${Label} edge="start">${() => d.city}<//>
294
+ <${Bar} to=${() => d.temp} color=${() => (d.warm ? "negative" : "series-1")} />
295
+ <${Label} at=${() => d.temp}>${() => d.temp} ${() => d.unit}<//>
296
+ </div>`);
297
+ // One line across the whole plot: an overlay Plot with one slat, asking for no room
298
+ const Mean = slat({ room: {} }, (d) => html`<${Tick} at=${() => d.mean} thick=${1} color="muted" />`);
299
+
300
+ // Chart component: rows with a key, a shared value, a per-row function, a sort that changes
301
+ const CityChart = () => {
302
+ const [cities, setCities] = createSignal(START);
303
+ const [dir, setDir] = createSignal("desc");
304
+ const mean = () => cities().reduce((s, c) => s + c.temp, 0) / cities().length;
305
+ return html`
306
+ <div>
307
+ <button onClick=${() => setDir(dir() === "desc" ? "asc" : "desc")}>Sort</button>
308
+ <button onClick=${() => setCities(cities().slice(1))}>Remove the first city</button>
309
+ <${Chart} scale=${[0, 30]}>
310
+ <${Plot} rows=${() => cities()} key="city" unit="°C" warm=${(d) => d.temp > 15}
311
+ order=${() => sortBy("temp", dir())}>${City}<//>
312
+ <${Plot} overlap=${true} slats=${1} mean=${mean} style=${{ "pointer-events": "none" }}>${Mean}<//>
313
+ <//>
314
+ </div>`;
315
+ };
316
+
317
+ render(CityChart, document.getElementById("chart"));
318
+ ```
319
+
320
+ Grouped bars, a Plot inside each slat:
321
+
322
+ ```js
323
+ import { Chart, Plot, Bar, Label, slat, html, render } from "@bezda/rhp/standalone";
324
+
325
+ // Data: medals per team (gold, silver, bronze)
326
+ const team = ["North", "East", "South"];
327
+ const medals = [[12, 9, 14], [8, 15, 6], [17, 11, 9]];
328
+ const METAL = ["#c9a227", "#98a1ab", "#b8733b"];
329
+
330
+ // Slat types: a medal count is a slat of the Plot inside a team's slat
331
+ const Medal = (m) => html`<div><${Bar} to=${() => m.count} color=${() => m.color} /></div>`;
332
+ const Team = slat({ thickness: 66, room: { start: 56, end: 24 } }, (d) => html`
333
+ <div>
334
+ <${Label} edge="start">${() => d.team}<//>
335
+ <${Plot} thick=${0.84} count=${() => d.medals} color=${METAL}>${Medal}<//>
336
+ </div>`);
337
+
338
+ // Chart component
339
+ const MedalChart = () => html`
340
+ <${Chart} scale=${[0, 20]} ticks=${[0, 5, 10, 15, 20]}>
341
+ <${Plot} team=${team} medals=${medals}>${Team}<//>
342
+ <//>`;
343
+
344
+ render(MedalChart, document.getElementById("chart"));
345
+ ```
346
+
347
+ ## 5. slat()
348
+
349
+ `slat(fn)` returns `fn` as it is.
350
+ `slat(layout, fn)` returns a slat type: a new function with the layout and the CSS attached (and `fn` left as it was).
351
+ Give the Plot what `slat()` returns.
352
+
353
+ | Layout | Type | Default | What it does |
354
+ |---|---|---|---|
355
+ | `css` | string, or a list of strings (joined in order) | none | CSS scoped to this type's slats (section 11) |
356
+ | `thickness` | px, or a CSS length (`"2.5rem"`, `"var(--pitch)"`) | 32px when horizontal without `height`; otherwise a share of the chart | a slat's size along the stack: its height in a horizontal chart, its width in a vertical one |
357
+ | `inset` | share of the band (`0.25`), or a CSS length (`"1px"`) | `0.18` | the empty part of the band on each side of a Bar or Tick (an Area or Line keeps half of it on each side) |
358
+ | `room` | `{ start, end, before, after }` in px, `"auto"` for start or end, or `"auto"` for both | the default room (section 3) | space outside the plot for what the slat draws there |
359
+
360
+ Every setting can differ by orientation: `thickness: { horizontal: 40, vertical: 60 }`, `room: { horizontal: { start: 120, end: 48 }, vertical: { start: 32 } }`.
361
+
362
+ **room.**
363
+ `start` holds the names (`<Label edge="start">`), `end` the values past the bars or `<Label edge="end">`, `before` and `after` the space before the first slat and after the last.
364
+ The chart takes the largest room any of its Plots ask for on each side, and at least the axis' room.
365
+ A `room` given replaces the default room entirely: a side left out gets 0, then the axis' minimum (14px at a horizontal chart's ends).
366
+ So `room: { start: 120 }` leaves 14px for numbers past the bars.
367
+ `"auto"` sizes start or end to the widest **edge** Label there, in CSS, on every change:
368
+
369
+ - it measures only Labels with `edge` that are direct children of the slat's root (one wrapped in another element gets no room, and rhp warns);
370
+ - a Label with `at` is never measured, so numbers past the bars need `end` in px: `room: { start: "auto", end: 48 }`;
371
+ - a name gets at most 40% of the chart's width (`--rhp-gutter-max`, settable in the slat's CSS) and ends in "…" past it;
372
+ - with hundreds of slats, or names that change often, give px: every change lays out all slats again.
373
+
374
+ **What the slat function gets.**
375
+ `d`, a live view of its row: `d.<group>`, `d.index`, `d.position`.
376
+ Read it inside functions (`${() => d.sold}`) and never destructure it.
377
+
378
+ **What it returns.**
379
+ One element, the slat's root, usually a `<div>` holding blocks and any other elements.
380
+ For a single block in an `overlap` Plot or a Scale, return that block directly instead.
381
+ rhp sets on the root: `data-rhp-slat` (the type's CSS scope), `data-rhp-o` (`h` or `v`), `role="listitem"` in a top-level Plot without `overlap` (unless the root has a role), an `id` when the slats are sorted (unless it has one), `hidden` when its position is `null`, `tabindex` with `keyboard`, and the style `--rhp-position`.
382
+ rhp places and sizes an ordinary Plot's root, absolutely, in its band: the slat's CSS can style it (background, border, radius, hover) but cannot move or resize it.
383
+ An `overlap` Plot or a Scale leaves a direct block's own geometry in control.
384
+ Elements of your own inside a wrapper are positioned by your CSS against the full slat's band.
385
+ Children inside a direct block use that block's box instead; keep a wrapper for independently positioned sibling marks or labels.
386
+ Keep a wrapper when a single mark needs a full-band hover or pointer target.
387
+
388
+ ```js
389
+ import { Chart, Plot, Bar, Label, slat, series, html, render, createSignal } from "@bezda/rhp/standalone";
390
+
391
+ // Data: moons known in March 2024 (NASA); more have been found since
392
+ const planet = ["Jupiter", "Saturn", "Uranus", "Neptune"];
393
+ const moons = [95, 146, 28, 16];
394
+
395
+ // Slat types: layout per orientation, and CSS that only reaches these slats
396
+ const Planet = slat({
397
+ thickness: { horizontal: 42 },
398
+ inset: 0.24,
399
+ room: { horizontal: { start: 76, end: 40 }, vertical: { start: 28, end: 22 } },
400
+ css: `
401
+ .slat:hover { background: color-mix(in srgb, var(--rhp-ink) 7%, transparent); border-radius: 8px; }
402
+ .bar { --rhp-end-radius: 6px; }
403
+ .value { font-weight: 700; }
404
+ .name:vertical { font-size: 11px; }
405
+ `,
406
+ }, (d) => html`
407
+ <div class="slat">
408
+ <${Label} edge="start" class="name">${() => d.planet}<//>
409
+ <${Bar} class="bar" to=${() => d.moons} color=${() => d.color} />
410
+ <${Label} at=${() => d.moons} class="value">${() => d.moons}<//>
411
+ </div>`);
412
+
413
+ // Chart component: one button turns the chart
414
+ const MoonChart = () => {
415
+ const [o, setO] = createSignal("horizontal");
416
+ return html`
417
+ <div>
418
+ <button onClick=${() => setO(o() === "horizontal" ? "vertical" : "horizontal")}>Turn</button>
419
+ <${Chart} orientation=${o} scale=${[0, 150]}>
420
+ <${Plot} planet=${planet} moons=${moons} color=${series()}>${Planet}<//>
421
+ <//>
422
+ </div>`;
423
+ };
424
+
425
+ render(MoonChart, document.getElementById("chart"));
426
+ ```
427
+
428
+ ## 6. Blocks
429
+
430
+ Every block also takes `class`, `style` (an object or a string; published rhp 2.0.2 needs objects for changing styles, gotcha 4), `ref`, children, event handlers and any HTML attribute (`title`, `data-*`, `aria-*`).
431
+ `color` takes a theme key or any CSS color (section 8).
432
+ Bar, Dot, Tick, Label and Cell take `shape` (an outline from `shape()`, section 9).
433
+ Values are on the Chart's scale.
434
+
435
+ | Block | Draws | Props (default) | Past the scale |
436
+ |---|---|---|---|
437
+ | `Bar` | a span from `from` to `to`, across the band | `from` (0), `to` (0), `thick` (64% of the band: a number is a share, a string a CSS length), `color` (`series-1`), `shape`. With `to` below `from` the bar runs backward. Corners 2px. | cut at the scale's ends |
438
+ | `Dot` | a disc centered at `at` | `at`, `size` (`"10px"`), `across` (0.5: its place across the band, 0 to 1), `cross` (a value on the second axis, instead of `across`), `color` (`series-1`), `shape` | keeps its place, outside the plot |
439
+ | `Tick` | a 2px line across the band at `at` | `at`, `thick` (64% of the band), `color` (`ink`), `shape` | keeps its place, outside the plot |
440
+ | `Label` | text at a value, or beside the plot | `at`, `side` (`"after"`; `"before"` ends the text at the value), `edge` (`"start"` or `"end"`: in the room before or after the plot), `cross`, `shape`. No `color` prop: color it in CSS. | horizontal: one past the max stays at the end, one below the min sticks out; vertical: one below the min stays at the bottom, one past the max rises above the plot |
441
+ | `Cell` | the whole band (less 1px all round), colored by `value` from the theme's `low` (at min) to `high` (at max) | `value`, `color` (a fixed color instead), `shape`. Corners 2px. | colored as min or max |
442
+ | `Place` | nothing: a point at `at`, of no size, for elements of your own | `at`, `across` (0.5), `cross` | keeps its place |
443
+ | `Area` | a filled outline from `points`, grown from the band's edge (ridgeline) or both ways from its middle (`mirror`, violin) | `points` (`[[x, y], …]` sorted by x, y ≥ 0), `peak` (the largest y: the y that fills the band), `mirror` (false), `smooth` (false), `color` (`series-1`) | the outline is squeezed into the scale: keep every x inside it |
444
+ | `Line` | a 2px line through `points`; in a slat a sparkline, on a cross scale a line chart | `points` (`[[x, y], …]`; out of x order, a connected scatter), `peak` (largest y; in a slat only), `fill` (false: fills under the line), `base` (0: where the fill ends on a cross scale), `smooth` (false), `color` (`series-1`) | squeezed like Area |
445
+
446
+ Where things sit:
447
+
448
+ - A Dot whose `size` is a number takes that share of the slat's box in each direction, which makes an ellipse.
449
+ Give a length (`"14px"`).
450
+ - A Label is 12px, on one line, with tabular figures.
451
+ With `at` it starts 5px past the value (3px above it when vertical); with `side="before"` it ends there.
452
+ With `edge="start"` it fills the start room, right-aligned with an 8px gap (horizontal), or sits centered under its column (vertical).
453
+ With `edge="end"` it sits after the plot (6px gap) or above the column.
454
+ An edge Label wider than its room ends in "…".
455
+ - A Label without `at` or `edge` has no place on the scale: it sits at the slat's left edge (horizontal) or top (vertical).
456
+ - An Area spans from its first x to its last, a Line from its smallest x to its largest; across, both take the band less the inset (82% of it by default, with 9% free on each side).
457
+ An Area is filled with its color at 35% and stroked 1.5px; a Line is stroked 2px, and with `fill` filled at 22%.
458
+ - A Place's children are placed by your CSS from its point: `position: absolute; translate: -50% -50%` centers one.
459
+ - `color` sets `--rhp-color` on that block only, so a Label next to a Bar does not see the Bar's color.
460
+ Set `--rhp-color` on the slat's root to share one, in a style object (`style=${() => ({ "--rhp-color": … })}`).
461
+
462
+ A Bar from one value to another, Dots at its ends, and a Tick:
463
+
464
+ ```js
465
+ import { Chart, Plot, Bar, Dot, Tick, Label, slat, html, render } from "@bezda/rhp/standalone";
466
+
467
+ // Data: a day's low, mean and high in °C
468
+ const city = ["City A", "City B", "City C"];
469
+ const low = [-4, 3, 9], mean = [0, 7.5, 14], high = [4, 12, 19];
470
+
471
+ // Slat types
472
+ const Range = slat({ thickness: 40 }, (d) => html`
473
+ <div>
474
+ <${Label} edge="start">${() => d.city}<//>
475
+ <${Bar} from=${() => d.low} to=${() => d.high} thick="4px" color="grid" />
476
+ <${Dot} at=${() => d.low} size="12px" color="series-1" />
477
+ <${Dot} at=${() => d.high} size="12px" color="series-2" />
478
+ <${Tick} at=${() => d.mean} thick=${0.6} />
479
+ </div>`);
480
+
481
+ // Chart component
482
+ const RangeChart = () => html`
483
+ <${Chart} scale=${[-10, 25]} ticks=${[-10, 0, 10, 20]} format=${(v) => v + "°"}>
484
+ <${Plot} city=${city} low=${low} mean=${mean} high=${high}>${Range}<//>
485
+ <//>`;
486
+
487
+ render(RangeChart, document.getElementById("chart"));
488
+ ```
489
+
490
+ Labels at values and at the edges, with values below zero:
491
+
492
+ ```js
493
+ import { Chart, Plot, Bar, Label, slat, html, render } from "@bezda/rhp/standalone";
494
+
495
+ // Data: profit per month, in k€
496
+ const month = ["Jan", "Feb", "Mar", "Apr"];
497
+ const profit = [25, -12, 8, -30];
498
+
499
+ // Slat types: a negative bar runs backward, and its number ends just before its value
500
+ const Month = slat({ thickness: 36, room: { start: 44, end: 44 } }, (d) => html`
501
+ <div>
502
+ <${Label} edge="start">${() => d.month}<//>
503
+ <${Bar} to=${() => d.profit} color=${() => (d.profit < 0 ? "negative" : "positive")} />
504
+ <${Label} at=${() => d.profit} side=${() => (d.profit < 0 ? "before" : "after")}>${() => d.profit}<//>
505
+ <${Label} edge="end">${() => (d.profit < 0 ? "loss" : "gain")}<//>
506
+ </div>`);
507
+
508
+ // Chart component
509
+ const ProfitChart = () => html`
510
+ <${Chart} scale=${[-40, 40]} ticks=${[-40, -20, 0, 20, 40]}>
511
+ <${Plot} month=${month} profit=${profit}>${Month}<//>
512
+ <//>`;
513
+
514
+ render(ProfitChart, document.getElementById("chart"));
515
+ ```
516
+
517
+ Cells: a heatmap, one Plot of hours across each day's band:
518
+
519
+ ```js
520
+ import { Chart, Plot, Cell, Label, slat, html, render } from "@bezda/rhp/standalone";
521
+
522
+ // Data: visitors per hour, from 9:00
523
+ const day = ["Fri", "Sat", "Sun"];
524
+ const visitors = [[2, 8, 21, 35, 30, 12], [4, 11, 26, 40, 34, 15], [9, 24, 38, 29, 18, 6]];
525
+
526
+ // Slat types: an hour is a Cell; a day holds a Plot of hours turned across its band
527
+ const Hour = slat({ css: `.cell { --rhp-radius: 3px; --rhp-cell-gap: 2px; }` }, (h) => html`
528
+ <div><${Cell} class="cell" value=${() => h.count} title=${() => h.count + " visitors"} /></div>`);
529
+ const Day = slat({ thickness: 34, room: { start: 40 } }, (d) => html`
530
+ <div>
531
+ <${Label} edge="start">${() => d.day}<//>
532
+ <${Plot} orientation="across" count=${() => d.visitors}>${Hour}<//>
533
+ </div>`);
534
+
535
+ // Chart component: the scale colors the cells, from the theme's low to its high
536
+ const VisitorChart = () => html`
537
+ <${Chart} scale=${[0, 40]} ticks=${false} theme=${{ low: "#f3ede4", high: "#9c3d10" }}>
538
+ <${Plot} day=${day} visitors=${visitors}>${Day}<//>
539
+ <//>`;
540
+
541
+ render(VisitorChart, document.getElementById("chart"));
542
+ ```
543
+
544
+ A Place, holding an element of your own at a value:
545
+
546
+ ```js
547
+ import { Chart, Plot, Bar, Label, Place, slat, html, render } from "@bezda/rhp/standalone";
548
+
549
+ // Data: heights in m
550
+ const peak = ["Everest", "K2", "Denali", "Mont Blanc"];
551
+ const metres = [8849, 8611, 6190, 4808];
552
+
553
+ // Slat types: the badge is centered on the Place's point
554
+ const Climb = slat({
555
+ thickness: 44,
556
+ room: { start: 84, end: 36 },
557
+ css: `.badge { position: absolute; translate: -50% -50%; padding: 2px 7px; border-radius: 99px;
558
+ background: var(--rhp-ink); color: var(--rhp-surface); font-size: 11px; font-weight: 700; white-space: nowrap; }`,
559
+ }, (d) => html`
560
+ <div>
561
+ <${Label} edge="start">${() => d.peak}<//>
562
+ <${Bar} to=${() => d.metres} thick="6px" />
563
+ <${Place} at=${() => d.metres}><span class="badge">${() => (d.metres / 1000).toFixed(1)} km</span><//>
564
+ </div>`);
565
+
566
+ // Chart component
567
+ const PeakChart = () => html`
568
+ <${Chart} scale=${[0, 9000]} ticks=${[0, 3000, 6000, 9000]} format=${(v) => v / 1000 + " km"}>
569
+ <${Plot} peak=${peak} metres=${metres}>${Climb}<//>
570
+ <//>`;
571
+
572
+ render(PeakChart, document.getElementById("chart"));
573
+ ```
574
+
575
+ An Area per slat (violins from `density`) and a Tick at each median:
576
+
577
+ ```js
578
+ import { Chart, Plot, Area, Tick, Label, slat, density, summary, html, render, createMemo } from "@bezda/rhp/standalone";
579
+
580
+ // Data: minutes to get to work, per town
581
+ const town = ["Ash", "Birch", "Cedar"];
582
+ const minutes = [
583
+ [12, 15, 18, 20, 21, 22, 24, 25, 25, 27, 30, 34, 38, 45],
584
+ [8, 9, 10, 12, 12, 13, 14, 15, 15, 16, 18, 20, 22, 26],
585
+ [20, 25, 28, 30, 32, 33, 35, 36, 38, 40, 42, 45, 50, 55],
586
+ ];
587
+
588
+ // Slat types: the outline over the whole scale, worked out once per change
589
+ const Town = slat({ thickness: 56, room: { start: 52, end: 24 } }, (d) => {
590
+ const outline = createMemo(() => density(d.minutes, { domain: [0, 60], points: 49 }));
591
+ const median = createMemo(() => summary(d.minutes).median);
592
+ return html`
593
+ <div>
594
+ <${Label} edge="start">${() => d.town}<//>
595
+ <${Area} points=${outline} mirror=${true} smooth=${true} />
596
+ <${Tick} at=${median} thick=${0.5} />
597
+ </div>`;
598
+ });
599
+
600
+ // Chart component
601
+ const CommuteChart = () => html`
602
+ <${Chart} scale=${[0, 60]} ticks=${[0, 15, 30, 45, 60]} format=${(m) => m + " min"}>
603
+ <${Plot} town=${town} minutes=${minutes}>${Town}<//>
604
+ <//>`;
605
+
606
+ render(CommuteChart, document.getElementById("chart"));
607
+ ```
608
+
609
+ A Line per slat (sparklines); one `peak` for all keeps them comparable:
610
+
611
+ ```js
612
+ import { Chart, Plot, Line, Label, slat, html, render } from "@bezda/rhp/standalone";
613
+
614
+ // Data: visits per day of the week
615
+ const page = ["Home", "Pricing", "Docs"];
616
+ const visits = [[320, 280, 350, 300, 390, 180, 150], [90, 120, 110, 160, 150, 60, 40], [210, 240, 260, 230, 250, 120, 140]];
617
+
618
+ // Slat types
619
+ const Page = slat({ thickness: 44, room: { start: 64, end: 40 } }, (d) => html`
620
+ <div>
621
+ <${Label} edge="start">${() => d.page}<//>
622
+ <${Line} points=${() => d.visits.map((v, i) => [i + 1, v])} peak=${400} fill=${true} />
623
+ <${Label} edge="end">${() => d.visits[6]}<//>
624
+ </div>`);
625
+
626
+ // Chart component
627
+ const VisitChart = () => html`
628
+ <${Chart} scale=${[1, 7]} ticks=${[1, 2, 3, 4, 5, 6, 7]} format=${(day) => "MTWTFSS"[day - 1]}>
629
+ <${Plot} page=${page} visits=${visits}>${Page}<//>
630
+ <//>`;
631
+
632
+ render(VisitChart, document.getElementById("chart"));
633
+ ```
634
+
635
+ `shape()`: an outline a block wears instead of its rectangle, written once for both orientations and both directions:
636
+
637
+ ```js
638
+ import { Chart, Plot, Bar, Label, slat, shape, html, render } from "@bezda/rhp/standalone";
639
+
640
+ // Data: change in points
641
+ const team = ["Lions", "Hawks", "Bears", "Wolves"];
642
+ const change = [14, -6, 9, -11];
643
+
644
+ // Slat types: an arrow, 14px from its end to its point; a bar that runs backward points the other way
645
+ const arrow = shape(["M", 0, 0], ["L", "-14px", 0], ["L", 1, 0.5], ["L", "-14px", 1], ["L", 0, 1], ["Z"]);
646
+ const Team = slat({ thickness: 36, room: { start: 60, end: 20 } }, (d) => html`
647
+ <div>
648
+ <${Label} edge="start">${() => d.team}<//>
649
+ <${Bar} to=${() => d.change} shape=${arrow} color=${() => (d.change < 0 ? "negative" : "positive")} />
650
+ </div>`);
651
+
652
+ // Chart component
653
+ const ChangeChart = () => html`
654
+ <${Chart} scale=${[-15, 15]} ticks=${[-15, -10, -5, 0, 5, 10, 15]}>
655
+ <${Plot} team=${team} change=${change}>${Team}<//>
656
+ <//>`;
657
+
658
+ render(ChangeChart, document.getElementById("chart"));
659
+ ```
660
+
661
+ ### ManyDots (experimental point collection)
662
+
663
+ `ManyDots` draws a bulk collection of points directly inside a `Chart` with `scale` and `cross`.
664
+ It is part of the upcoming npm 2.0.3 release.
665
+ It is a collection rather than a block in a slat, so do not wrap it in a `Plot` or supply a slat function.
666
+ Use a repository build that exports `ManyDots`; the version-2 CDN URL in the page skeleton does not guarantee this experimental API is available.
667
+ Both package entry points export it in that build, and Solid users can import `ManyDotsProps<T>` and `ManyDotsValue<T, V>` for its types.
668
+
669
+ Choose it explicitly for bulk HTML points, or keep `Dot` for individual blocks, rich point content, and Plot behavior at any point count.
670
+ Neither component switches into the other automatically, including at 500 points.
671
+
672
+ | Prop | Type | Default | What it does |
673
+ |---|---|---|---|
674
+ | `rows` | readonly array of rows | required | one point per row with finite coordinates |
675
+ | `at`, `cross` | number or `(row, index) => number` | required | coordinates on the Chart's value and cross scales |
676
+ | `key` | `(row, index) => identity` | source row index | stable identity for keyed updates; rendered keys must be unique |
677
+ | `size` | number, CSS length, or accessor | `"4px"` | diameter; a number means pixels, unlike Dot's band fraction |
678
+ | `color` | CSS color, theme key, or accessor | `"series-1"` | shared or individual point colors |
679
+ | `shape` | `shape()` outline or accessor | circular points | shared or individual point outlines |
680
+ | `pointClass` | string or accessor | none | space-separated application classes on each point |
681
+ | `pointStyle` | CSS properties object or accessor | none | individual appearance overrides |
682
+ | `class`, `classList`, `style`, `ref`, HTML attributes and events | normal div props | none | apply to the collection host; `style` accepts an object or string |
683
+
684
+ The appearance accessors above receive `(row, index)`.
685
+ Both domains must have finite, distinct endpoints, and nonfinite point coordinates are skipped.
686
+ Finite coordinates outside a domain keep their outside positions, so choose scales that cover the data.
687
+ Every rendered point retains its original row index in `data-rhp-index`, even when preceding rows are skipped.
688
+
689
+ **Appearance and DOM.**
690
+ The light-DOM host is `.rhp-manydots`, its point container is `.rhp-manydots-points`, and each point is a `.rhp-manydot` div.
691
+ There is no shadow root, so document queries, Testing Library queries, native events, and page-level event delegation see the points.
692
+ Use `pointClass` with ordinary application CSS for background color, radius, opacity, outlines, filters, transforms, and hover styles.
693
+ Shared defaults use one scoped stylesheet per collection, with a fallback for browsers without `@scope` support.
694
+ Per-point color and shape accessors write inline styles, which take precedence over ordinary class rules.
695
+ Size, color, and shape accessors also override the same properties supplied through `pointStyle`.
696
+ The collection owns coordinates and centering and guards position, dimensions, margins, padding, borders, and min/max dimensions.
697
+ Explicit width and height in `pointStyle` can override a shared size; appearance remains customizable without the slat's general text and font reset.
698
+
699
+ **Interaction and accessibility.**
700
+ CSS hover and native click, touch, and pointer events work on the plain HTML points.
701
+ Attach handlers to the host and recover the row with `data-rhp-index`; there is no per-point handler prop or built-in drag behavior.
702
+ Implement dragging with pointer events and pointer capture when needed, and use `getBoundingClientRect()` for overlays against the visible point box.
703
+ `ManyDots` does not implement Plot's automatic keyboard navigation, selection, or readout behavior and accepts no point children, `innerHTML`, or `textContent`.
704
+ Provide a Chart `label` and a textual summary, data table, or keyboard-accessible controls when readers need individual values.
705
+ The following complete module example includes a table whose buttons offer the same selection as clicking a point.
706
+ Validate the experimental collection's point count, geometry, appearance, native interactions, and accessible controls in the browser rather than treating a standard-chart checker report as complete bulk-point coverage.
707
+
708
+ ```js
709
+ import { Chart, ManyDots, html, render, createSignal } from "@bezda/rhp/standalone";
710
+
711
+ const samples = [
712
+ { id: "a", name: "Sample A", x: 20, y: 35, diameter: 8, color: "#a73157" },
713
+ { id: "b", name: "Sample B", x: 60, y: 80, diameter: 10, color: "#18749a" },
714
+ { id: "c", name: "Sample C", x: 85, y: 50, diameter: 6, color: "#6b4da1" },
715
+ ];
716
+
717
+ function Measurements() {
718
+ const [selected, setSelected] = createSignal(null);
719
+ const chosen = () => samples.find(row => row.id === selected());
720
+ const pick = event => {
721
+ const point = event.target.closest?.(".rhp-manydot");
722
+ if (point && event.currentTarget.contains(point)) {
723
+ setSelected(samples[Number(point.dataset.rhpIndex)].id);
724
+ }
725
+ };
726
+ return html`
727
+ <section>
728
+ <style>
729
+ .measurement-point { cursor: pointer; }
730
+ .measurement-point:hover { outline: 2px solid currentColor; outline-offset: 2px; }
731
+ </style>
732
+ <p>Click or tap a point, or choose a sample in the table.</p>
733
+ <${Chart} scale=${[0, 100]} cross=${[0, 100]} height=${280} label="Illustrative sample measurements">
734
+ <${ManyDots} rows=${samples} at=${row => row.x} cross=${row => row.y} key=${row => row.id}
735
+ size=${row => row.diameter} color=${row => row.color} pointClass="measurement-point"
736
+ pointStyle=${row => ({ opacity: selected() == null || selected() === row.id ? 1 : 0.35 })}
737
+ onClick=${pick} />
738
+ <//>
739
+ <p aria-live="polite">${() => chosen() ? `${chosen().name}: x ${chosen().x}, y ${chosen().y}` : "No sample selected"}</p>
740
+ <table>
741
+ <caption>Illustrative sample values</caption>
742
+ <thead><tr><th scope="col">Sample</th><th scope="col">X</th><th scope="col">Y</th></tr></thead>
743
+ <tbody>${samples.map(row => html`
744
+ <tr><th scope="row"><button type="button" onClick=${event => setSelected(row.id)}>${row.name}</button></th>
745
+ <td>${row.x}</td><td>${row.y}</td></tr>`)}
746
+ </tbody>
747
+ </table>
748
+ </section>`;
749
+ }
750
+
751
+ render(Measurements, document.getElementById("chart"));
752
+ ```
753
+
754
+ **Updates and server rendering.**
755
+ Stable keys preserve leaf elements through replacement arrays, reordering, and removals.
756
+ One collection computation visits every row when a dependency changes, while unchanged points receive no managed style writes.
757
+ Dragging one point through reactive data therefore still requires a bulk row pass; update and drag performance have not been measured.
758
+ Data updates move points immediately, and JavaScript-driven Chart scale animation updates their resolved coordinates.
759
+ Server rendering emits the complete HTML collection, and hydration reuses its host, container, and points.
760
+ Initial leaves are adopted by row order because server keys are not serialized; keep server and client row order aligned when point DOM identity matters.
761
+ Managed classes, coordinates, and appearance declarations can be rewritten by the collection, while native listeners and metadata outside its managed fields survive ordinary updates.
762
+ See the [prototype guide](../../../examples/manydots/README.md) for centering and custom-size animation details and the [capabilities explanation](../../../examples/manydots/TRADEOFFS.md) for the measured initial-render tradeoffs.
763
+
764
+ ## 7. Scales and axes
765
+
766
+ **The axis.** The Chart draws grid lines and numbers from `ticks` and `format`:
767
+
768
+ - `ticks` as a list: those values (values outside the scale are dropped);
769
+ - as a number: about that many round values (`5` by default; `[0, 30]` gives 0, 10, 20, 30);
770
+ - as a function of `[min, max]`: `every(10)`, or `every(5, { ends: true })` to add both ends;
771
+ - `false`: no axis, and no room for it.
772
+
773
+ `format` returns text or elements (`(v) => v + "%"`, or an element with a class that page CSS can style).
774
+ The numbers sit under a horizontal chart, centered on their lines, and left of a vertical one.
775
+ Their room is fixed (24px under, 14px past each end; 42px left of a vertical chart): long numbers stick out of the chart, so keep them short.
776
+
777
+ **A Scale of your own.** For a scale that looks different (dashed lines, numbers in pills, bands between ticks), put a `Scale` in the Chart; the Chart then draws no axis.
778
+ A Scale is an `overlap` Plot with one slat per tick, keyed by value, and takes `ticks` and `class` (other props are data groups).
779
+ A tick's slat gets `d.at` (its value), `d.next` (the next tick, or the scale's max), `d.first`, `d.last`, and `d.toEnd` (px from the tick to the scale's end, `Infinity` until measured).
780
+ It is hidden from screen readers.
781
+
782
+ ```js
783
+ import { Chart, Scale, Plot, Bar, Tick, Label, slat, every, html, render } from "@bezda/rhp/standalone";
784
+
785
+ // Data: hours of sun per day in July
786
+ const city = ["City A", "City B", "City C", "City D"];
787
+ const sun = [12.4, 11.2, 8.4, 5.8];
788
+
789
+ // Slat types: a tick's slat draws a dashed line and its number under the plot
790
+ const Mark = slat({
791
+ room: { after: 22 },
792
+ css: `
793
+ .line { --rhp-tick-width: 1px; background: none; border-left: 1px dashed var(--rhp-grid); }
794
+ .num { top: calc(100% + 6px); translate: -50% 0; --rhp-label-gap: 0px; font-size: 11px; color: var(--rhp-muted); }
795
+ `,
796
+ }, (t) => html`
797
+ <div>
798
+ <${Tick} class="line" at=${() => t.at} thick=${1} />
799
+ <${Label} class="num" at=${() => t.at}>${() => t.at} h<//>
800
+ </div>`);
801
+ const City = slat({ thickness: 34, room: { start: 64, end: 24 } }, (d) => html`
802
+ <div>
803
+ <${Label} edge="start">${() => d.city}<//>
804
+ <${Bar} to=${() => d.sun} />
805
+ </div>`);
806
+
807
+ // Chart component
808
+ const SunChart = () => html`
809
+ <${Chart} scale=${[0, 14]}>
810
+ <${Scale} ticks=${every(2)}>${Mark}<//>
811
+ <${Plot} city=${city} sun=${sun}>${City}<//>
812
+ <//>`;
813
+
814
+ render(SunChart, document.getElementById("chart"));
815
+ ```
816
+
817
+ **A second axis: `cross`.** `cross={[min, max]}` adds a scale across the band.
818
+ In a horizontal chart it runs bottom to top (the value scale is x, `cross` is y); in a vertical chart it runs left to right (the value scale is y, `cross` is x).
819
+ An `overlap` Plot's slats then share the whole plot, and a Dot, Label or Place with `cross` sits at `at` on the value scale and at `cross` on the second one: a scatter plot.
820
+ A Line's y values go on the second axis: a line chart (`fill` fills down to `base`).
821
+ The second axis takes `crossTicks` and `crossFormat`, and its numbers go where the other orientation's would (left of a horizontal chart).
822
+ A chart with `cross` is 240px tall unless `height` or `aspect` says otherwise, and an overlap Plot on it gets no default room (names need room or sit at their points).
823
+ Dots, Places and Lines past the cross scale are drawn outside the plot.
824
+
825
+ ```js
826
+ import { Chart, Plot, Dot, Label, html, render } from "@bezda/rhp/standalone";
827
+
828
+ // Data: cafés, their price and their rating
829
+ const cafe = ["Corner", "Roastery", "Kiosk", "Book & Bean", "Mill St"];
830
+ const price = [3.2, 4.5, 2.4, 3.8, 2.9];
831
+ const rating = [4.1, 4.8, 3.2, 4.4, 3.8];
832
+
833
+ // Slat types: a point and its name
834
+ const Cafe = (d) => html`
835
+ <div>
836
+ <${Dot} at=${() => d.price} cross=${() => d.rating} size="10px" />
837
+ <${Label} at=${() => d.price} cross=${() => d.rating} style="--rhp-label-gap: 9px">${() => d.cafe}<//>
838
+ </div>`;
839
+
840
+ // Chart component: price across, rating up
841
+ const CafeChart = () => html`
842
+ <${Chart} scale=${[2, 6]} ticks=${[2, 3, 4, 5, 6]} format=${(p) => "€" + p}
843
+ cross=${[3, 5]} crossTicks=${[3, 4, 5]} crossFormat=${(r) => r + "★"} height=${220}>
844
+ <${Plot} overlap=${true} cafe=${cafe} price=${price} rating=${rating}>${Cafe}<//>
845
+ <//>`;
846
+
847
+ render(CafeChart, document.getElementById("chart"));
848
+ ```
849
+
850
+ A line chart: one Line in an overlap Plot (one slat), and a Dot per point in another:
851
+
852
+ ```js
853
+ import { Chart, Plot, Line, Dot, html, render } from "@bezda/rhp/standalone";
854
+
855
+ // Data: average high in °C per month
856
+ const month = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
857
+ const high = [15, 16, 19, 20, 23, 26, 28, 29, 27, 23, 18, 15];
858
+
859
+ // Chart component: the slats are plain functions
860
+ const HighChart = () => html`
861
+ <${Chart} scale=${[0.5, 12.5]} ticks=${month} format=${(m) => "JFMAMJJASOND"[m - 1]}
862
+ cross=${[10, 30]} crossTicks=${[10, 20, 30]} crossFormat=${(t) => t + "°"} height=${200}>
863
+ <${Plot} overlap=${true} points=${[month.map((m, i) => [m, high[i]])]}>${(d) => html`
864
+ <${Line} points=${() => d.points} fill=${true} base=${10} />`}<//>
865
+ <${Plot} overlap=${true} month=${month} high=${high}>${(d) => html`
866
+ <${Dot} at=${() => d.month} cross=${() => d.high} size="8px" />`}<//>
867
+ <//>`;
868
+
869
+ render(HighChart, document.getElementById("chart"));
870
+ ```
871
+
872
+ `Axis` (exported) is the component the Chart draws its axis with, from `ticks` (a list) and `format`; use the Chart's props or a Scale instead.
873
+
874
+ ## 8. Theme
875
+
876
+ A theme is an object of colors and a font.
877
+ The Chart writes it on its element as CSS variables (`--rhp-ink`, `--rhp-series-1`…), together with rhp's own knobs.
878
+ So a page's `--rhp-*` variables, its font and its text color do not reach inside a chart: the theme is how they get in.
879
+
880
+ | Key | Default (`THEME`) | Used for |
881
+ |---|---|---|
882
+ | `series` | `["#2a78d6", "#eb6834", "#1baf7a", "#c2419a", "#7b5cd6", "#d39a12"]` | data colors `series-1`… (Bar, Dot, Area, Line default to `series-1`) |
883
+ | `ink` | `#1d232b` | all text in the chart, and the default Tick |
884
+ | `muted` | `#6b7280` | the axis numbers |
885
+ | `grid` | `rgba(128, 128, 128, .22)` | the grid lines |
886
+ | `surface` | `#ffffff` | nothing by default: a color for slat CSS (text on a bar, a badge); the chart's background is transparent |
887
+ | `positive`, `negative` | `#13894f`, `#c62828` | only where a block asks for them (`color="positive"`) |
888
+ | `low`, `high` | `#e8eef8`, `#1f4fa8` | a Cell's color at the scale's min and max (mixed in oklab between) |
889
+ | `font` | `system-ui, sans-serif` | every text in the chart |
890
+
891
+ A theme given replaces only the keys it names.
892
+ `Theme` gives a theme to every Chart inside it (`<${Theme} value=${{ font: "Georgia, serif" }}>…<//>`).
893
+ Themes nest (an inner one changes only its keys), and a Chart's own `theme` wins key by key.
894
+
895
+ **How a color resolves.** A block's `color` (and a data group holding colors) is:
896
+
897
+ - a theme key (`"series-3"`, `"positive"`, `"negative"`, `"ink"`, `"muted"`, `"grid"`, `"surface"`, `"low"`, `"high"`): it follows the theme, also when the theme changes;
898
+ - any other CSS color (`"#d4a72c"`, `"rgb(…)"`, `"oklch(…)"`): used as it is;
899
+ - `"var(--page-color)"` as a block's `color`: works, but rhp warns once per color ("reads a page variable; use a theme key"), because the chart would then look different in every page.
900
+ A theme value may read a page variable without a warning (`theme=${{ ink: "var(--ink)", series: ["var(--accent)", "var(--quiet)"] }}`): it resolves at the chart, so a page's tokens (and their dark-mode values) drive the chart (design.md builds every look this way).
901
+
902
+ In slat CSS, the theme is `var(--rhp-ink)`, `var(--rhp-series-2)`… and a block's own color is `var(--rhp-color)`.
903
+ `series(n)` gives each row a series key by its index (`"series-1"` to `"series-n"`, then again).
904
+ `n` is 6 by default whatever the theme holds, so a theme of 4 colors needs `series(4)` (a missing series color falls back to `series-1`).
905
+
906
+ **Dark mode.** rhp has no dark theme of its own: give the Chart another theme when the page turns dark.
907
+
908
+ ```js
909
+ import { Chart, Plot, Bar, Label, series, html, render, createSignal } from "@bezda/rhp/standalone";
910
+
911
+ // Data: each drink's share of a café's orders, in %
912
+ const drink = ["Latte", "Flat white", "Mocha", "Tea"];
913
+ const share = [31, 27, 12, 9];
914
+
915
+ // Themes: the same keys for light and dark
916
+ const LIGHT = { series: ["#1f5fbf", "#c4512b", "#13865c", "#8a3fb5"], ink: "#1d232b", muted: "#5f6773", grid: "rgba(0, 0, 0, .1)" };
917
+ const DARK = { series: ["#7fb0ff", "#ff9a73", "#5fd3a5", "#d39bff"], ink: "#e9ecf1", muted: "#a3abb7", grid: "rgba(255, 255, 255, .14)" };
918
+ const media = matchMedia("(prefers-color-scheme: dark)");
919
+ const [dark, setDark] = createSignal(media.matches);
920
+ media.addEventListener("change", (e) => setDark(e.matches));
921
+
922
+ // Slat types
923
+ const Drink = (d) => html`
924
+ <div>
925
+ <${Label} edge="start">${() => d.drink}<//>
926
+ <${Bar} to=${() => d.share} color=${() => d.color} />
927
+ <${Label} at=${() => d.share}>${() => d.share}%<//>
928
+ </div>`;
929
+
930
+ // Chart component: the theme follows the signal, and the page paints its own background
931
+ const DrinkChart = () => html`
932
+ <div style=${() => ({ background: dark() ? "#16181d" : "#ffffff", padding: "12px" })}>
933
+ <${Chart} scale=${[0, 40]} theme=${() => (dark() ? DARK : LIGHT)}>
934
+ <${Plot} drink=${drink} share=${share} color=${series(4)}>${Drink}<//>
935
+ <//>
936
+ </div>`;
937
+
938
+ render(DrinkChart, document.getElementById("chart"));
939
+ ```
940
+
941
+ ## 9. Helpers
942
+
943
+ Plain functions (no signals).
944
+ When their input changes, call them inside `createMemo` so they run once per change.
945
+
946
+ | Helper | Returns | Example (real output) |
947
+ |---|---|---|
948
+ | `nice(lo, hi, count = 5)` | a round scale `{ min, max, step, ticks }` | `nice(0, 87)` → `{ min: 0, max: 100, step: 20, ticks: [0, 20, 40, 60, 80, 100] }`; `nice(-23, 41)` → min -40, max 60, step 20 |
949
+ | `extent(values)` | `[smallest, largest]` (`[Infinity, -Infinity]` when empty) | `extent([5, -2, 9])` → `[-2, 9]` |
950
+ | `every(step, { ends })` | a ticks function of `[min, max]` (a step not above 0 throws) | `every(25)([0, 100])` → `[0, 25, 50, 75, 100]`; `every(5, { ends: true })([0, 27])` → `[0, 5, …, 25, 27]` |
951
+ | `stackUp(values)` | `{ from, to }`: positives stack up from 0, negatives down | `stackUp([5, -3, 4])` → from `[0, 0, 5]`, to `[5, -3, 9]` |
952
+ | `shares(values, total = 100)` | each value's share of the sum of absolute values, scaled to `total` | `shares([1, 1, 2])` → `[25, 25, 50]` |
953
+ | `running(changes)` | `{ from, to }`: running totals (waterfall) | `running([100, -30, 45])` → from `[0, 100, 70]`, to `[100, 70, 115]` |
954
+ | `summary(samples)` | `{ min, q1, median, q3, max, low, high, mean, outliers }`; low and high are the whisker ends (within 1.5 × IQR) | `summary([1, …, 9, 40])` → median 5.5, high 9, outliers `[40]` |
955
+ | `bins(samples, { domain, count = 10 })` | `{ x0, x1, tally }`: bin k covers x0[k] to x1[k] | `bins([1, 2, 2, 3, 9, 10], { count: 3 })` → x0 `[1, 4, 7]`, tally `[4, 0, 2]` |
956
+ | `density(samples, { domain, points = 40, bandwidth })` | `[[x, density], …]` for an Area (Gaussian kernel, Silverman's bandwidth) | keep `domain` inside the Chart's scale |
957
+ | `sortBy(key, "asc" or "desc")` | an `order` function; `key` is a group name or `(d) => value`; ties keep their places on screen | `order=${sortBy("sold", "desc")}` |
958
+ | `series(n = 6)` | a data group: `"series-1"`… by row index | `color=${series(4)}` |
959
+ | `cycle(list)` | a data group repeating `list` over the rows (does not set the number of rows) | `color=${cycle(["#111", "#999"])}` |
960
+ | `at(group, i)` | item i of a data group (a list wraps around, a single value is every row's) | `at([1, 2, 3], 4)` → `2` |
961
+ | `shape(...commands)` | an outline for a block's `shape`: SVG commands M, L, Q, C, Z | coordinates: a number is a share of the block's box (0 to 1), a string a CSS length, `"-14px"` measured back from the end; the first runs along the value axis, the second across the band |
962
+ | `curve(ease)` | a timing function of 0..1, from a CSS name, `[x1, y1, x2, y2]` or a function | `curve("ease-in-out")(0.5)` → `0.5` |
963
+ | `animated(read, settings)` | a reader `(ahead = 0) => value` that moves to each new value of `read()` on rhp's clock; `settings()` gives `{ duration, ease }` | `ease` must be a function: `{ duration: 400, ease: curve("ease-out") }` (a name throws) |
964
+ | `drawing(fn)` | runs `fn` with rhp's style writes made at once, not in the next frame | for data changed inside your own `requestAnimationFrame` |
965
+ | `useOrientation()` | the orientation of the Plot around, as an accessor | call it in a slat: `const o = useOrientation();` then `o()` |
966
+ | `restyle(slatType, css)` | gives a slat type made with `css` new CSS; its slats restyle in place | for style editors |
967
+ | `linkedCss()` | tells rhp the page links `@bezda/rhp/rhp.css` itself | call before the first chart; without the link rhp warns and adds its CSS anyway |
968
+
969
+ A shape must start with `M`.
970
+ Further `M` commands start separate subpaths, allowing disconnected pieces or holes with the opposite winding direction.
971
+ These work with native CSS `shape()` and the polygon fallback; curves in the fallback retain the existing approximation.
972
+
973
+ Stacked bars: `stackUp` per slat, and an overlap Plot whose slats are Bars:
974
+
975
+ ```js
976
+ import { Chart, Plot, Bar, Label, slat, stackUp, html, render, createMemo } from "@bezda/rhp/standalone";
977
+
978
+ // Data: hours of the day spent on sleep, work and the rest
979
+ const day = ["Mon", "Tue", "Wed"];
980
+ const hours = [[8, 8, 8], [7, 9, 8], [8, 6, 10]];
981
+
982
+ // Slat types: a part is a Bar (an overlap Plot's slat may be a block); a day stacks its parts
983
+ const Part = (p) => html`<${Bar} from=${() => p.from} to=${() => p.to} color=${() => p.color} />`;
984
+ const Day = slat({ thickness: 40, room: { start: 44, end: 16 }, css: `.rhp-bar { --rhp-gap: 2px; }` }, (d) => {
985
+ const stack = createMemo(() => stackUp(d.hours));
986
+ return html`
987
+ <div>
988
+ <${Label} edge="start">${() => d.day}<//>
989
+ <${Plot} overlap=${true} from=${() => stack().from} to=${() => stack().to} color=${["series-1", "series-2", "series-3"]}>${Part}<//>
990
+ </div>`;
991
+ });
992
+
993
+ // Chart component
994
+ const DayChart = () => html`
995
+ <${Chart} scale=${[0, 24]} ticks=${[0, 6, 12, 18, 24]} format=${(h) => h + " h"}>
996
+ <${Plot} day=${day} hours=${hours}>${Day}<//>
997
+ <//>`;
998
+
999
+ render(DayChart, document.getElementById("chart"));
1000
+ ```
1001
+
1002
+ A histogram: `bins` for the counts, `nice` for the scale, a vertical chart with one slat per bin:
1003
+
1004
+ ```js
1005
+ import { Chart, Plot, Bar, Label, slat, bins, nice, html, render } from "@bezda/rhp/standalone";
1006
+
1007
+ // Data: minutes per call
1008
+ const calls = [2, 3, 3, 4, 4, 5, 5, 5, 6, 6, 6, 6, 7, 7, 7, 8, 8, 9, 9, 10, 11, 12, 12, 14, 15, 17, 19, 22, 26, 29];
1009
+ const b = bins(calls, { domain: [0, 30], count: 10 });
1010
+ const top = nice(0, Math.max(...b.tally)).max;
1011
+
1012
+ // Slat types: bins touch (inset 1px); each bin's lower edge is written at its left side
1013
+ const Bin = slat({
1014
+ inset: "1px",
1015
+ css: `
1016
+ .bar { --rhp-radius: 0px; }
1017
+ .edge { left: -50%; right: 50%; font-size: 11px; color: var(--rhp-muted); }
1018
+ `,
1019
+ }, (d) => html`
1020
+ <div>
1021
+ <${Bar} class="bar" to=${() => d.tally} />
1022
+ <${Label} edge="start" class="edge">${() => d.x0}<//>
1023
+ </div>`);
1024
+
1025
+ // Chart component
1026
+ const CallChart = () => html`
1027
+ <${Chart} orientation="vertical" height=${200} scale=${[0, top]} label="Calls by length, in minutes">
1028
+ <${Plot} x0=${b.x0} tally=${b.tally}>${Bin}<//>
1029
+ <//>`;
1030
+
1031
+ render(CallChart, document.getElementById("chart"));
1032
+ ```
1033
+
1034
+ A box plot from `summary`, and a waterfall from `running`:
1035
+
1036
+ ```js
1037
+ import { Chart, Plot, Bar, Tick, Label, slat, summary, running, html, render, createMemo } from "@bezda/rhp/standalone";
1038
+
1039
+ // Data: delivery days per carrier, and a budget's steps
1040
+ const carrier = ["Post", "Swift", "Cargo"];
1041
+ const days = [[2, 3, 3, 4, 4, 4, 5, 5, 6, 9], [1, 1, 2, 2, 2, 3, 3, 3, 4, 4], [3, 4, 5, 5, 6, 6, 7, 8, 8, 12]];
1042
+ const step = ["Sales", "Services", "Costs", "Tax"];
1043
+ const r = running([60, 25, -40, -15]);
1044
+
1045
+ // Slat types: whiskers from low to high, the box from q1 to q3, a tick at the median
1046
+ const Box = slat({ thickness: 40, room: { start: 56, end: 16 } }, (d) => {
1047
+ const s = createMemo(() => summary(d.days));
1048
+ return html`
1049
+ <div>
1050
+ <${Label} edge="start">${() => d.carrier}<//>
1051
+ <${Bar} from=${() => s().low} to=${() => s().high} thick="2px" color="muted" />
1052
+ <${Bar} from=${() => s().q1} to=${() => s().q3} />
1053
+ <${Tick} at=${() => s().median} color="surface" />
1054
+ </div>`;
1055
+ });
1056
+ // A step runs from the total before it to the total after it
1057
+ const Step = slat({ thickness: 34, room: { start: 72, end: 40 } }, (d) => html`
1058
+ <div>
1059
+ <${Label} edge="start">${() => d.step}<//>
1060
+ <${Bar} from=${() => d.from} to=${() => d.to} color=${() => (d.to < d.from ? "negative" : "positive")} />
1061
+ <${Label} at=${() => Math.max(d.from, d.to)}>${() => d.to - d.from}<//>
1062
+ </div>`);
1063
+
1064
+ // Chart components
1065
+ const BoxChart = () => html`
1066
+ <${Chart} scale=${[0, 12]} ticks=${[0, 3, 6, 9, 12]} format=${(v) => v + " d"}>
1067
+ <${Plot} carrier=${carrier} days=${days}>${Box}<//>
1068
+ <//>`;
1069
+ const StepChart = () => html`
1070
+ <${Chart} scale=${[0, 100]}>
1071
+ <${Plot} step=${step} from=${r.from} to=${r.to}>${Step}<//>
1072
+ <//>`;
1073
+
1074
+ render(() => html`<div><${BoxChart} /><br /><${StepChart} /></div>`, document.getElementById("chart"));
1075
+ ```
1076
+
1077
+ ## 10. Poster
1078
+
1079
+ `Poster` is markup for a magazine-style panel around a chart, and nothing else: it brings no CSS.
1080
+
1081
+ | Prop | What it does |
1082
+ |---|---|
1083
+ | `kicker` | the small line over the headline (text or elements) |
1084
+ | `title` | the headline (not the HTML `title` attribute) |
1085
+ | `dek` | the line or two under the headline |
1086
+ | `note` | the small print under the chart (source, year); left out when not given |
1087
+ | `look` | a class added to `poster` (a look of `posters.css`, or your own) |
1088
+ | `class` | more classes |
1089
+ | children | the chart, and anything else (a key, buttons) |
1090
+ | anything else | set on the `<figure>`: `id`, `style`, `data-*`, `aria-*`, handlers (in the html template, `(e) => …`) |
1091
+
1092
+ It renders:
1093
+
1094
+ - `<figure class="poster">`, with the `look` and `class` added
1095
+ - `<figcaption>` holding `<span class="kicker">`, `<span class="headline">` and `<span class="dek">`
1096
+ - the children
1097
+ - `<span class="note">`, only when `note` is given
1098
+
1099
+ The three caption spans are always there (empty when not given) and inline: style them (`figcaption { display: grid }`).
1100
+ A `<figure>` has the browser's margins (40px each side): set `.poster { margin: 0 }`.
1101
+ The Poster is page markup, outside rhp's layers, so page CSS styles all of it.
1102
+ The chart's own text takes the theme's `font`, not the poster's.
1103
+
1104
+ ```html
1105
+ <!doctype html>
1106
+ <html lang="en">
1107
+ <head>
1108
+ <meta charset="utf-8">
1109
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1110
+ <title>Bananas sell best</title>
1111
+ <script type="importmap">{ "imports": { "@bezda/rhp/standalone": "https://cdn.jsdelivr.net/npm/@bezda/rhp@2/dist/standalone.js" } }</script>
1112
+ <style>
1113
+ body { margin: 0; padding: 32px 16px; background: #e9e6e0; }
1114
+ .poster { max-width: 680px; margin: 0 auto; padding: 24px 24px 16px; background: #fbfaf7; color: #1d232b; font-family: Georgia, serif; }
1115
+ .poster figcaption { display: grid; gap: 6px; margin-bottom: 16px; }
1116
+ .poster .kicker { font: 600 11px/1.3 system-ui, sans-serif; letter-spacing: .12em; text-transform: uppercase; color: #8a5a12; }
1117
+ .poster .headline { font-size: 30px; font-weight: 700; line-height: 1.05; }
1118
+ .poster .dek { font-size: 15px; line-height: 1.45; color: #4d5560; }
1119
+ .poster .note { display: block; margin-top: 12px; font: 11px/1.4 system-ui, sans-serif; color: #6b7280; }
1120
+ @media (max-width: 480px) { .poster { padding: 18px 14px 12px; } .poster .headline { font-size: 25px; } }
1121
+ </style>
1122
+ </head>
1123
+ <body>
1124
+ <div id="chart"></div>
1125
+ <script type="module">
1126
+ import { Chart, Plot, Bar, Label, Poster, slat, html, render } from "@bezda/rhp/standalone";
1127
+
1128
+ // Data
1129
+ const fruit = ["Bananas", "Apples", "Cherries"];
1130
+ const sold = [18, 12, 7];
1131
+
1132
+ // Slat types
1133
+ const Fruit = slat({ thickness: 38, room: { start: 76, end: 36 } }, (d) => html`
1134
+ <div>
1135
+ <${Label} edge="start">${() => d.fruit}<//>
1136
+ <${Bar} to=${() => d.sold} color=${() => (d.index === 0 ? "#c2410c" : "#d9c3a5")} />
1137
+ <${Label} at=${() => d.sold}>${() => d.sold}<//>
1138
+ </div>`);
1139
+
1140
+ // Chart component: the chart's text uses the theme's font
1141
+ const FruitPoster = () => html`
1142
+ <${Poster} kicker="Market stall" title="Bananas sell best" dek="Crates sold by midday on Saturday." note="Illustrative data.">
1143
+ <${Chart} scale=${[0, 20]} ticks=${[0, 5, 10, 15, 20]} label="Crates sold by midday"
1144
+ theme=${{ font: "system-ui, sans-serif", ink: "#1d232b", grid: "#e4ded3" }}>
1145
+ <${Plot} fruit=${fruit} sold=${sold}>${Fruit}<//>
1146
+ <//>
1147
+ <//>`;
1148
+
1149
+ render(FruitPoster, document.getElementById("chart"));
1150
+ </script>
1151
+ </body>
1152
+ </html>
1153
+ ```
1154
+
1155
+ ## 11. CSS
1156
+
1157
+ Where each kind of style goes:
1158
+
1159
+ | What | Where |
1160
+ |---|---|
1161
+ | slat blocks: bars, labels, hover, fonts per label | the slat type's `css` |
1162
+ | ManyDots point appearance | `pointClass` with ordinary application CSS, or `pointStyle`, `color`, `size`, and `shape` |
1163
+ | colors and the font of the whole chart | the Chart's `theme` (or `Theme`) |
1164
+ | the chart's box: width, margin, background, border, radius | page CSS on the chart's element, or the Chart's `class` and `style` |
1165
+ | the poster around it, and elements of your own outside the chart | page CSS |
1166
+
1167
+ **Slat CSS scope.** Every selector applies only inside this slat type's slats, the root included.
1168
+ It never reaches the page, other slat types, or a slat of another type nested inside; it does reach a nested slat that is a plain function.
1169
+ `:horizontal` and `:vertical` match a slat, block or Plot drawn that way (`.name:vertical { font-size: 11px }`).
1170
+ For an element inside a block, put them on the block: `.bar:vertical .icon`.
1171
+ A selector may start with `.rhp-chart` to depend on the chart (`.rhp-chart[data-rhp-o="v"] .bar`); it still applies only inside this type's slats.
1172
+ `@keyframes` names are made private to the type: use them in the same CSS.
1173
+ `::before` and `::after` work on the slat's root and on any element.
1174
+
1175
+ **Layers for slat blocks.** rhp makes its regular block and slat declarations `!important`, inside cascade layers declared first: `rhp.place`, then `rhp.slat`, then `rhp.core`.
1176
+ For `!important` the first layer wins, and any layered `!important` beats every page rule (`!important` or not).
1177
+ So a slat's CSS beats rhp's core (colors, corners, fonts, even a block's position), placement beats a slat's CSS (a slat's root, its motion), and the page reaches none of them.
1178
+
1179
+ **What page CSS can and cannot change on regular slat blocks** (measured):
1180
+
1181
+ | Page CSS on rhp's elements | Result |
1182
+ |---|---|
1183
+ | any property rhp sets: `background`, `color`, `font-*`, `border`, `border-radius`, `box-shadow`, `outline`, `padding`, `margin`, `display`, `transition`, `width`, `height`, `top`, `left` | ignored, even with `!important` |
1184
+ | `opacity`, `cursor`, `filter`, `transform`, `rotate`, `scale`, `z-index`, `mix-blend-mode`, `pointer-events` (not on Area or Line) | applies (rhp leaves them to the page) |
1185
+ | rhp's custom properties on an element inside the chart (`.rhp-bar { --rhp-radius: 8px }`) | applies, unless rhp sets that one inline there (a block's numbers, its `color`); on the chart's element only with `!important` (`--rhp-height: 300px !important`) |
1186
+ | elements that are not rhp's (children you put in blocks or slats, elements from `format`) | applies, like anywhere in the page |
1187
+ | the chart's element (`.rhp-chart`): `width`, `max-width`, `margin`, `background`, `border`, `border-radius`, `display` | applies; its padding is rhp's room |
1188
+ | inherited text styles (`font-family`, `color`, `line-height` on body) | stop at the chart: everything inside starts from the theme |
1189
+
1190
+ Even where page CSS reaches rhp's custom properties, set them in the slat's `css`, or on the Chart's `style` object for the whole chart: page CSS never sets `--rhp-*` variables, so a chart keeps its whole look in its slat types and its theme.
1191
+
1192
+ **Classes and attributes rhp writes** (to aim a shared look at any chart):
1193
+
1194
+ | Selector | What it is |
1195
+ |---|---|
1196
+ | `.rhp-chart` | the chart's element; `data-rhp-o` (`h` or `v`), `data-rhp-animate="js"`, `data-rhp-cross`, `data-rhp-aspect` |
1197
+ | `.rhp-body` | the grid inside it |
1198
+ | `.rhp-axis`, `.rhp-gridline`, `.rhp-gridline > span` | the axis, a grid line, its number (`.rhp-axis[data-rhp-cross]`: the second axis) |
1199
+ | `.rhp-plot` | a Plot; `data-rhp-overlap`, `data-rhp-reorder`; `.rhp-plot.rhp-scale` is a Scale |
1200
+ | `.rhp-manydots`, `.rhp-manydots-points`, `.rhp-manydot[data-rhp-index]` | ManyDots host, point container, and point with its original row index |
1201
+ | `[data-rhp-slat]` | a slat's root (the value is the type's scope) |
1202
+ | `.rhp-bar` (`[data-rhp-back]` when it runs backward), `.rhp-dot`, `.rhp-tick`, `.rhp-cell`, `.rhp-place` | blocks |
1203
+ | `.rhp-label[data-rhp-at]`, `.rhp-label[data-rhp-edge="start"]`, `.rhp-label[data-rhp-edge="end"]`, `[data-rhp-side="before"]` | Labels at a value, in the start room, in the end room |
1204
+ | `svg.rhp-area > path`, `svg.rhp-line > path.rhp-under`, `path.rhp-stroke` | an Area's outline; a Line's fill and stroke |
1205
+
1206
+ **Variables a slat's CSS sets** (on a block, or on the slat's root to reach all its blocks):
1207
+
1208
+ | Variable | Default | What it does |
1209
+ |---|---|---|
1210
+ | `--rhp-radius` | `2px` | a Bar's and a Cell's corners; one length only (rhp warns otherwise) |
1211
+ | `--rhp-start-radius`, `--rhp-end-radius` | `--rhp-radius` | a Bar's corners at its start, at its end (where `to` is) |
1212
+ | `--rhp-gap` | `0` | empty space at a Bar's start (between stacked segments) |
1213
+ | `--rhp-label-gap` | after a value 5px (3px vertical); start edge 8px (6px); end edge 6px (3px) | the space between a Label and its value or the plot |
1214
+ | `--rhp-label-size` | `12px` | a Label's font size |
1215
+ | `--rhp-tick-width` | `2px` | a Tick's width |
1216
+ | `--rhp-cell-gap` | `1px` | the space around a Cell |
1217
+ | `--rhp-color` | the block's `color` | a block's color; set on the slat's root, every block there without its own `color` takes it |
1218
+ | `--rhp-length-time`, `--rhp-length-ease` | `.15s`, `ease-out` | how values move (CSS version) |
1219
+ | `--rhp-slide-time`, `--rhp-slide-ease` | `.3s`, `ease-in-out` | how slats slide to new places |
1220
+ | `--rhp-gutter-max` | `40cqw` | with `room: "auto"`, the widest a name (and the room) can be |
1221
+
1222
+ **Variables a slat's CSS reads**: the theme (`--rhp-ink`, `--rhp-muted`, `--rhp-grid`, `--rhp-surface`, `--rhp-series-1`…, `--rhp-positive`, `--rhp-negative`, `--rhp-low`, `--rhp-high`, `--rhp-font`), `--rhp-color` (the block's color), `--rhp-toward-end` (the direction from a Bar's start to its end: `linear-gradient(var(--rhp-toward-end), …)`), `--rhp-p` (where a Dot, Tick, Label or Place sits on the scale, 0 to 1: a Label's room to the scale's end is `calc((1 - var(--rhp-p)) * 100%)`), `--rhp-lo` and `--rhp-hi` (where a Bar, an Area or a Line starts and ends on the scale, 0 to 1, clipped to it: the donut and radial-bars recipes turn a Bar's into angles), `--rhp-min`, `--rhp-max`, a block's `--rhp-from`, `--rhp-to`, `--rhp-at`, `--rhp-value`, a slat's `--rhp-position`, and `--rhp-n` (how many places the slat's Plot has on screen).
1223
+
1224
+ **Styling the axis.** Page CSS and slat CSS cannot reach rhp's grid lines and numbers.
1225
+ Their colors are the theme's `grid` and `muted`, their font the theme's `font`; `ticks` and `format` choose them (`format` may return an element with a class, which page CSS styles).
1226
+ For anything else (dashes, bands, pills, numbers on top), draw a `Scale` (section 7).
1227
+
1228
+ **Transitions.** rhp moves blocks with transitions of its own, so a `transition` on a block replaces them and the block jumps to new values.
1229
+ Put transitions (hover fades, growth) on elements inside blocks, or change properties without one.
1230
+ With reduced motion rhp turns its own off; turn yours off too (`@media (prefers-reduced-motion: reduce)` in the slat's CSS).
1231
+
1232
+ **Animations.** A CSS animation works on a slat's root and on anything inside (a row that fades in, section 12).
1233
+ A slat's CSS is made `!important`, and an `!important` declaration beats an animation, so an animation holds its own end state: `.out { animation: fade .3s forwards; }` with `@keyframes fade { to { opacity: 0; } }`; with `opacity: 0` declared beside it, the row is hidden at once and the fade never shows.
1234
+
1235
+ ```js
1236
+ import { Chart, Plot, Bar, Label, slat, html, render } from "@bezda/rhp/standalone";
1237
+
1238
+ // Data: moons known in March 2024 (NASA); more have been found since
1239
+ const planet = ["Jupiter", "Saturn", "Uranus", "Neptune"];
1240
+ const moons = [95, 146, 28, 16];
1241
+
1242
+ // Slat types: corners, a gradient along the bar, a value that darkens on hover with a fade inside the Label
1243
+ const Planet = slat({
1244
+ thickness: 42,
1245
+ room: { start: 72, end: 44 },
1246
+ css: `
1247
+ .slat:hover { background: color-mix(in srgb, var(--rhp-ink) 6%, transparent); border-radius: 8px; }
1248
+ .bar {
1249
+ --rhp-start-radius: 0px;
1250
+ --rhp-end-radius: 12px;
1251
+ background: linear-gradient(var(--rhp-toward-end), var(--rhp-series-1), var(--rhp-series-5));
1252
+ }
1253
+ .name { font-weight: 600; }
1254
+ .value { --rhp-label-gap: 8px; --rhp-label-size: 14px; font-weight: 800; }
1255
+ .value span { opacity: .7; transition: opacity .15s; }
1256
+ .slat:hover .value span { opacity: 1; }
1257
+ @media (prefers-reduced-motion: reduce) { .value span { transition: none; } }
1258
+ `,
1259
+ }, (d) => html`
1260
+ <div class="slat">
1261
+ <${Label} edge="start" class="name">${() => d.planet}<//>
1262
+ <${Bar} class="bar" to=${() => d.moons} />
1263
+ <${Label} at=${() => d.moons} class="value"><span>${() => d.moons}</span><//>
1264
+ </div>`);
1265
+
1266
+ // Chart component
1267
+ const MoonChart = () => html`
1268
+ <${Chart} scale=${[0, 150]}>
1269
+ <${Plot} planet=${planet} moons=${moons}>${Planet}<//>
1270
+ <//>`;
1271
+
1272
+ render(MoonChart, document.getElementById("chart"));
1273
+ ```
1274
+
1275
+ ## 12. Motion
1276
+
1277
+ When data changes, the chart follows by itself: a new value moves its block, a new order slides the slats.
1278
+
1279
+ **The CSS version** (the default): CSS transitions move `left`, `width`, `bottom`, `height` and `background-color` of Bars, Dots, Ticks, value Labels, Places, grid lines, Areas and Lines over 0.15s `ease-out` (a Cell's color too); an Area's or Line's outline moves the same way.
1280
+ Slats slide over 0.3s `ease-in-out`.
1281
+ Time them with `--rhp-length-time`, `--rhp-length-ease`, `--rhp-slide-time`, `--rhp-slide-ease` in a slat's CSS, or on the Chart's `style` object for the whole chart; `0s` makes values jump.
1282
+
1283
+ **The JS version** (`animate` on the Chart or a Plot): rhp counts each number from its old value to its new one on one clock for the page, over 150ms `ease-out` by default, and slats slide over 175ms, passing each other where their values are equal.
1284
+ Everything drawn from a number moves with it, and `d.sold` itself returns the in-between values, so text that prints it shows fractions while it moves: round it (`Math.round(d.sold)`).
1285
+ `animate=${true}`, or `{ duration, ease, slide }` (ms; `ease` a CSS name, `[x1, y1, x2, y2]` or a function).
1286
+ A Plot's `animate` can name its groups: `["sold"]`, or `{ groups: ["sold"], duration: 400 }`.
1287
+
1288
+ What moves in the CSS version: whatever changes a block's `left`, `width`, `bottom`, `height` or background color (values, cross values, sizes, colors), an outline, the slats' places, and the axis when the scale changes.
1289
+ What moves in the JS version: the numbers of the data groups it animates, the order, and the scale; nothing is transitioned, so colors jump.
1290
+ What always jumps: text, a Dot's or Place's `across` in a horizontal chart, a turn of `orientation`, a change of room "auto", and everything when the reader asked for reduced motion.
1291
+
1292
+ **Which version to use.**
1293
+ Data that changes now and then (a click that switches a year, a sort) keeps the CSS version: it costs almost nothing.
1294
+ Data that changes rapidly or continuously (a live feed, a play button that moves through the data, a slider or a drag that drives the data, a bar chart race) uses the JS version for the elements that react to it: give the Plot `animate` with the groups that change (`animate=${["sold"]}`), or the Chart when the scale follows the data, so the axis moves with the marks.
1295
+ In the CSS version each new value restarts a transition from where the mark is, so under steady change marks trail the data (and Safari can stall transitions that input events retarget); the JS version carries each move into the next value, and labels stay exactly on their bars.
1296
+ Interaction effects (a hover highlight) stay CSS transitions on elements inside blocks in both versions.
1297
+
1298
+ How to time the JS version depends on whether the next value is known:
1299
+ - **Playing back data you have** (a bar chart race, a year slider with Play): move a playhead `t`, a step with a fraction, on every frame (`requestAnimationFrame`), give the Plot the values at `t` (each on the straight line between the steps around it), and keep the default 150 ms. The marks glide through the steps without stopping at each one, and Pause leaves them between two steps (the example below; interaction.md section 10 adds the slider). Never step through the data with a timer: each mark would ease into a step and wait there.
1300
+ - **A live feed**, whose next value is not known yet: ease linear into each new reading over a little more than one interval (`ease: "linear"`, `duration` 1.5 intervals in the `live` recipe). With the default ease-out, or a shorter duration, the marks stop between readings.
1301
+
1302
+ Measured in Chromium, for the JS version under steady change:
1303
+ - Give the changing numbers as lists (`gdp=${() => gdpAt(t())}`), a single value or a field of `rows`: a group given as a function of the row (`(d) => ...`) is never moved by the JS version, it jumps.
1304
+ - Played back by a playhead, the `race` recipe's bars changed width on every frame and ran about 66 ms (2 px) behind the playhead.
1305
+ - For moves driven by a slider or a drag, keep the default 150 ms: with 300 ms the JS version trailed a drag as much as the CSS version (10.4 against 10.1 points of 100); with 150 ms it trailed 6.2.
1306
+ - When the scale follows the data, `animate` goes on the Chart: on the Plot alone the scale jumps every step and the bars with it.
1307
+ - For a history that scrolls (a live sparkline), animate one time value (`now`) and cut the window from it (`x = t - now`, kept inside the scale); animating the point arrays makes each vertex take its neighbor's value, so the line morphs in place instead of scrolling.
1308
+ - A race that shows the top N of more rows hides the others with its order: `order=${(rows, now) => sortBy("gdp", "desc")(rows, now).map((p) => (p < N ? p : null))}`. A row hidden this way vanishes at once; the second example below lets rows leave and come in.
1309
+ - In the JS version an order function reads each value half a slide ahead of what the marks show (measured: 75 of 100 read 253 ms into a linear 1 s move), so two rows pass each other where their values cross.
1310
+ The `race` and `live` recipes put all of this together.
1311
+ rhp writes style changes in the next animation frame; `drawing(fn)` writes those made inside your own `requestAnimationFrame` at once.
1312
+
1313
+ ```js
1314
+ import { Chart, Plot, Bar, Label, sortBy, html, render, createSignal, onCleanup } from "@bezda/rhp/standalone";
1315
+
1316
+ // Data: a vote counted in three rounds
1317
+ const name = ["Maya", "Leo", "Ivy", "Omar"];
1318
+ const ROUNDS = [[34, 21, 45, 12], [38, 30, 41, 22], [40, 44, 39, 25]];
1319
+ const LAST = ROUNDS.length - 1;
1320
+ const ROUND_MS = 1000; // how long a round takes while the count plays
1321
+ // The votes at t, a round with a fraction: each on the straight line between the rounds around t
1322
+ const votesAt = (t) => {
1323
+ const r = Math.min(Math.floor(t), LAST - 1);
1324
+ const f = t - r;
1325
+ return ROUNDS[r].map((v, i) => v * (1 - f) + ROUNDS[r + 1][i] * f);
1326
+ };
1327
+
1328
+ // Slat types: the votes are fractional between rounds, so the label rounds them
1329
+ const Candidate = (d) => html`
1330
+ <div>
1331
+ <${Label} edge="start">${() => d.name}<//>
1332
+ <${Bar} to=${() => d.votes} />
1333
+ <${Label} at=${() => d.votes}>${() => Math.round(d.votes)}<//>
1334
+ </div>`;
1335
+
1336
+ // Chart component: while the count plays, each frame moves the playhead t on by the time since the last frame (at most
1337
+ // 250 ms, so a hidden tab resumes where it was). The votes change on every frame, so the bars move by the JS version
1338
+ // with its default 150 ms. The frame loop stops at the last round, and when the chart is removed.
1339
+ const VoteChart = () => {
1340
+ const [t, setT] = createSignal(0);
1341
+ let frame;
1342
+ const play = () => {
1343
+ cancelAnimationFrame(frame);
1344
+ setT(0);
1345
+ let last = null;
1346
+ const move = (now) => {
1347
+ setT(Math.min(LAST, t() + Math.min(now - (last ?? now), 250) / ROUND_MS));
1348
+ last = now;
1349
+ if (t() < LAST) frame = requestAnimationFrame(move);
1350
+ };
1351
+ frame = requestAnimationFrame(move);
1352
+ };
1353
+ onCleanup(() => cancelAnimationFrame(frame));
1354
+ return html`
1355
+ <div>
1356
+ <button onClick=${play}>Play the count</button>
1357
+ <${Chart} scale=${[0, 50]} animate=${{ slide: 400 }}>
1358
+ <${Plot} name=${name} votes=${() => votesAt(t())} order=${sortBy("votes", "desc")}>${Candidate}<//>
1359
+ <//>
1360
+ </div>`;
1361
+ };
1362
+
1363
+ render(VoteChart, document.getElementById("chart"));
1364
+ ```
1365
+
1366
+ **A top-N race whose rows leave and come in.**
1367
+ A row that leaves the top N slides to place N, just below the last place, fades there, and is hidden at the next step; a row that comes in fades in at its place.
1368
+ Rows a source leaves out (a table of the top four each year) are `null` in the data.
1369
+ Three parts do it:
1370
+
1371
+ - The order function: the rows in the race of the step nearest to `t`, by their value at `t`; a row that dropped out at that step takes place N until `t` reaches the step; every other row is `null`.
1372
+ - A hidden Plot of N + 1 empty slats, as thick as the data's slats, under the data's Plot. While a row sits below the last place the data's Plot has N + 1 places, and the hidden Plot keeps the chart at that height all the time; without it the chart grows by one slat at every exit.
1373
+ - Animations on the slat's root fade a row out at place N and in where it comes in (rhp keeps the root's transition for its slide). A slat's CSS is made `!important`, which beats an animation, so each animation holds its own end state (`forwards`) instead of a declaration beside it.
1374
+
1375
+ ```js
1376
+ import { Chart, Plot, Bar, Label, slat, html, render, createSignal, onCleanup } from "@bezda/rhp/standalone";
1377
+
1378
+ // Data: market shares in %, from a source that lists only the four largest makers each year: null where a maker is
1379
+ // not among that year's four (illustrative; the makers are invented)
1380
+ const N = 4;
1381
+ const MAKER = ["Aster", "Birch", "Cedar", "Dune", "Ember", "Fjord"];
1382
+ const SHARE = [
1383
+ [22, 18, 14, 11, null, null], // 2021
1384
+ [21, 19, 12, null, 13, null], // 2022: Dune drops out, Ember comes in
1385
+ [19, 21, null, null, 15, 12], // 2023: Cedar drops out, Fjord comes in
1386
+ [17, 22, null, null, 18, 13], // 2024
1387
+ ];
1388
+ const LAST = SHARE.length - 1;
1389
+ const YEAR_MS = 1500;
1390
+ // The makers in each year's race: its N largest known shares
1391
+ const RACE = SHARE.map((year) => new Set(MAKER.map((_, i) => i).filter((i) => year[i] != null).sort((a, b) => year[b] - year[a]).slice(0, N)));
1392
+ // The shares at t, a year with a fraction: on the straight line between the years around t, or the known one when the
1393
+ // other year has none (a maker that drops out keeps its last share while it slides out)
1394
+ const sharesAt = (t) => {
1395
+ const k = Math.min(Math.floor(t), LAST - 1);
1396
+ const f = t - k;
1397
+ return SHARE[k].map((a, i) => {
1398
+ const b = SHARE[k + 1][i];
1399
+ return a == null ? b ?? 0 : b == null ? a : a * (1 - f) + b * f;
1400
+ });
1401
+ };
1402
+
1403
+ // Slat types: a maker fades out at place N and fades in where it comes in; the animations keep their end state
1404
+ const Maker = slat({
1405
+ thickness: 40,
1406
+ room: { start: 64, end: 52 },
1407
+ css: `
1408
+ .maker { animation: maker-in .3s; }
1409
+ .maker.out { animation: maker-out .3s forwards; }
1410
+ @keyframes maker-in { from { opacity: 0; } }
1411
+ @keyframes maker-out { to { opacity: 0; visibility: hidden; } }
1412
+ @media (prefers-reduced-motion: reduce) { .maker, .maker.out { animation: none; } .maker.out { visibility: hidden; } }
1413
+ `,
1414
+ }, (d) => html`
1415
+ <div class=${() => (d.position === N ? "maker out" : "maker")}>
1416
+ <${Label} edge="start">${() => d.maker}<//>
1417
+ <${Bar} to=${() => d.share} />
1418
+ <${Label} at=${() => d.share}>${() => d.share.toFixed(1)}%<//>
1419
+ </div>`);
1420
+ // N + 1 empty slats, drawn hidden under the data, keep the chart as tall as N + 1 places
1421
+ const Band = slat({ thickness: 40, room: {} }, () => html`<div></div>`);
1422
+
1423
+ // Chart component: the playhead t moves on every frame while it plays, as in the example above
1424
+ const ShareRace = () => {
1425
+ const [t, setT] = createSignal(0);
1426
+ // The makers in the race of the year nearest to t, by their share at t; a maker that has just dropped out takes
1427
+ // place N, below the last place, until t reaches its first year out; the others are hidden
1428
+ const places = (rows) => {
1429
+ const race = RACE[Math.round(t())];
1430
+ const ranked = [...race].sort((a, b) => rows[b].share - rows[a].share);
1431
+ return rows.map((_, i) => (race.has(i) ? ranked.indexOf(i) : RACE[Math.floor(t())].has(i) ? N : null));
1432
+ };
1433
+ let frame;
1434
+ const play = () => {
1435
+ cancelAnimationFrame(frame);
1436
+ setT(0);
1437
+ let last = null;
1438
+ const move = (now) => {
1439
+ setT(Math.min(LAST, t() + Math.min(now - (last ?? now), 250) / YEAR_MS));
1440
+ last = now;
1441
+ if (t() < LAST) frame = requestAnimationFrame(move);
1442
+ };
1443
+ frame = requestAnimationFrame(move);
1444
+ };
1445
+ onCleanup(() => cancelAnimationFrame(frame));
1446
+ return html`
1447
+ <div>
1448
+ <p><button onClick=${play}>Play</button>${" "}<b>${() => 2021 + Math.round(t())}</b></p>
1449
+ <${Chart} scale=${[0, 25]} ticks=${[0, 5, 10, 15, 20, 25]} animate=${{ slide: 300 }} label="Market shares of the four largest makers">
1450
+ <${Plot} slats=${N + 1} style=${{ visibility: "hidden" }}>${Band}<//>
1451
+ <${Plot} maker=${MAKER} share=${() => sharesAt(t())} order=${places}>${Maker}<//>
1452
+ <//>
1453
+ </div>`;
1454
+ };
1455
+
1456
+ render(ShareRace, document.getElementById("chart"));
1457
+ ```
1458
+
1459
+ Measured in Chromium while it played, at 1280px and 390px: the chart kept its height in every frame (without the hidden Plot it changed between 186px and 226px), Dune was drawn at 15 heights between its place and place 4 while it faded and was then hidden, Ember faded in, and 2024 ended with its exact shares in order; under reduced motion the rows moved and appeared at once.
1460
+
1461
+ ## 13. Accessibility and interaction
1462
+
1463
+ - **Name the chart**: `label="…"` (or `aria-labelledby`) makes it a figure with that name; `aria-describedby` and other `aria-*` props go on it too.
1464
+ - **Slats are a list**: a top-level Plot is `role="list"` and each slat's root `role="listitem"`.
1465
+ A root with its own role keeps it (`role="group" aria-label=…`).
1466
+ Plots inside slats, overlap Plots (scatter points) and Scales are not lists.
1467
+ When `order` moves slats with `reorder="slide"`, `aria-owns` gives the reading order shown.
1468
+ - **The axis is hidden** from screen readers (`aria-hidden`), and so is a Scale: each slat's own text must say its value.
1469
+ - **A slat that shows only shapes reads nothing**: add a Label, or text only a screen reader reads.
1470
+ - **Keyboard**: `keyboard=${true}` on a Plot makes its slats focusable.
1471
+ Tab stops at one slat (the one focused last, or the first shown); the arrow keys go to the slat shown before or after it, and Home and End to the first and last, in the order shown.
1472
+ A slat keeps focus when it moves.
1473
+ Keys with a modifier, and keys a handler inside the slat prevented, are left alone.
1474
+ Style `:focus-visible` next to `:hover`, in the slat's `css` (a page rule does not reach a slat).
1475
+ - **Forced colors** (Windows contrast themes): Bars, Dots, Ticks, Cells, Areas and Lines keep their colors, and Bars, Dots, Ticks and Cells get a 1px outline in the system text color.
1476
+ - **Reduced motion**: rhp's transitions and the JS version stop; values jump.
1477
+
1478
+ ```js
1479
+ import { Chart, Plot, Bar, Label, slat, html, render } from "@bezda/rhp/standalone";
1480
+
1481
+ // Data
1482
+ const city = ["City A", "City B", "City C", "City D"];
1483
+ const rain = [184, 124, 81, 56];
1484
+
1485
+ // Slat types: a focused slat shows what a hovered one shows; the hidden text says it in words
1486
+ const City = slat({
1487
+ thickness: 36,
1488
+ room: { start: 72, end: 56 },
1489
+ css: `
1490
+ .slat:is(:hover, :focus-visible) { background: color-mix(in srgb, var(--rhp-ink) 8%, transparent); outline: none; }
1491
+ .tip { opacity: 0; transition: opacity .15s; }
1492
+ .slat:is(:hover, :focus-visible) .tip { opacity: 1; }
1493
+ .say { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
1494
+ `,
1495
+ }, (d) => html`
1496
+ <div class="slat">
1497
+ <${Label} edge="start">${() => d.city}<//>
1498
+ <${Bar} to=${() => d.rain} />
1499
+ <${Label} at=${() => d.rain} aria-hidden="true"><span class="tip">${() => d.rain} mm</span><//>
1500
+ <span class="say">${() => d.rain} mm of rain</span>
1501
+ </div>`);
1502
+
1503
+ // Chart component
1504
+ const RainChart = () => html`
1505
+ <${Chart} scale=${[0, 200]} label="Rain in October, in mm">
1506
+ <${Plot} keyboard=${true} city=${city} rain=${rain}>${City}<//>
1507
+ <//>`;
1508
+
1509
+ render(RainChart, document.getElementById("chart"));
1510
+ ```
1511
+
1512
+ **Interaction state is the app's.** rhp keeps none: hold what the reader points at in a signal, feed it back to the Plot as data, and let the slat show it (interaction.md has the patterns).
1513
+ Listen on an element around the chart (the Chart takes no handlers) and find the slat with `data-row` and `closest()`.
1514
+ A mouse or a pen picks on `pointermove`, a tap or a click on `click` (a scroll never fires one, so never pick on `pointerdown`), the keyboard on `focusin`; an event on no slat keeps the pick, and leaving clears it:
1515
+
1516
+ ```js
1517
+ import { Chart, Plot, Bar, Label, Show, slat, html, render, createSignal } from "@bezda/rhp/standalone";
1518
+
1519
+ // Data
1520
+ const city = ["City A", "City B", "City C", "City D", "City E"];
1521
+ const rain = [184, 124, 81, 56, 37];
1522
+ const days = [19, 17, 13, 11, 6];
1523
+
1524
+ // Slat types: the card exists only in the slat the reader is on
1525
+ const City = slat({
1526
+ thickness: 34,
1527
+ room: { start: 72, end: 120 },
1528
+ css: `
1529
+ .slat.on .bar { filter: brightness(1.15); }
1530
+ .card { padding: 2px 8px; border-radius: 4px; background: var(--rhp-ink); color: var(--rhp-surface); }
1531
+ `,
1532
+ }, (d) => html`
1533
+ <div class=${() => (d.on ? "slat on" : "slat")} data-row=${() => d.index}>
1534
+ <${Label} edge="start">${() => d.city}<//>
1535
+ <${Bar} class="bar" to=${() => d.rain} />
1536
+ <${Show} when=${() => d.on}>
1537
+ <${Label} at=${() => d.rain} class="card">${() => d.rain} mm, ${() => d.days} wet days<//>
1538
+ <//>
1539
+ </div>`);
1540
+
1541
+ // Chart component: the row the reader picked is a signal; on=${(d) => …} is worked out per row
1542
+ const RainChart = () => {
1543
+ const [on, setOn] = createSignal(null);
1544
+ // The row of the slat an element is in, or null (between slats)
1545
+ const rowOf = (el) => {
1546
+ const root = el.closest("[data-row]");
1547
+ return root ? +root.dataset.row : null;
1548
+ };
1549
+ const pick = (e) => {
1550
+ const row = rowOf(e.target);
1551
+ if (row != null) setOn(row); // on no slat, the pick stays
1552
+ };
1553
+ // Leaving: back to the slat that has focus, or none
1554
+ const leave = (e) => {
1555
+ if (e.pointerType === "touch") return; // a finger sends pointerleave as it lifts after a tap
1556
+ setOn(e.currentTarget.contains(document.activeElement) ? rowOf(document.activeElement) : null);
1557
+ };
1558
+ return html`
1559
+ <div onPointerMove=${(e) => e.pointerType !== "touch" && pick(e)} onClick=${pick} onFocusIn=${pick}
1560
+ onPointerLeave=${leave} onFocusOut=${(e) => !e.currentTarget.contains(e.relatedTarget) && setOn(null)}>
1561
+ <${Chart} scale=${[0, 200]} label="Rain in October, in mm">
1562
+ <${Plot} keyboard=${true} city=${city} rain=${rain} days=${days} on=${(d) => on() === d.index}>${City}<//>
1563
+ <//>
1564
+ </div>`;
1565
+ };
1566
+
1567
+ render(RainChart, document.getElementById("chart"));
1568
+ ```
1569
+
1570
+ ## 14. Limits
1571
+
1572
+ rhp draws marks on one value scale (and an optional second one), as HTML elements.
1573
+ It has no projections, layouts or 3D, and no chart types to configure: everything is blocks in slats.
1574
+
1575
+ | Asked for | rhp has no | The closest rhp answer |
1576
+ |---|---|---|
1577
+ | a map (choropleth, points on a map) | geography or projections | a sorted bar chart or dot plot of the regions; a tile grid map (one Cell per region at its row and column); a map image the user supplies, with Places over it, positioned by your CSS |
1578
+ | a network or tree graph, a Sankey diagram, a chord diagram | node layouts or links | an adjacency matrix (a heatmap of Cells); stacked bars per stage for flows |
1579
+ | 3D, a surface | depth | small multiples, or a heatmap (two categories and a color) |
1580
+ | a treemap, a sunburst | nested rectangles | 100% segmented bars (`shares` and `stackUp`), or unit bars (a waffle) |
1581
+ | a radar chart | a polar scale | grouped bars, a dot plot, or radial bars (the radial-bars recipe draws each bar as a ring arc in the slat's CSS, from `--rhp-hi` and `--rhp-position`) |
1582
+ | a pie or donut | a wedge block | the donut recipe: an SVG path drawn in a Bar, sized by the shares |
1583
+ | free-form drawing (arrows between points, shaded regions, curves) | a canvas | `shape()` on a block, an Area or Line, an inline SVG inside a Place or a Bar, or elements of your own in a slat |
1584
+ | a log or time scale | scale types | turn values into numbers first (`Math.log10(v)` with `format` writing the real values; timestamps or day numbers with a date `format`) |
1585
+ | a second value axis on the same side (two y axes) | it | two Charts side by side, or one value scaled into the other with its own Labels |
1586
+
1587
+ ## 15. Gotchas
1588
+
1589
+ Every one of these was found by running rhp 2.0.1.
1590
+
1591
+ 1. **Bare booleans are false in the html template.**
1592
+ `<${Plot} overlap>` passes `""`: no overlap, and the same for `keyboard`, `static`, `animate`, `mirror`, `fill` and `smooth`.
1593
+ Write `overlap=${true}`.
1594
+ 2. **A handler without a parameter on a component runs at render.**
1595
+ `<${Bar} onClick=${() => pick(d)}>` is read as a value: `pick` runs once per slat when the chart is drawn, and the click does nothing (or throws `c.call is not a function` when `pick` returned a value).
1596
+ Write `onClick=${(e) => pick(d)}`.
1597
+ The same holds for a handler on a `<${Poster}>` and for any function prop of a component; plain elements are not affected.
1598
+ 3. **`${d.sold}` never updates.**
1599
+ A value read without a function is read once.
1600
+ Wrap it, `${() => d.sold}`, and never destructure `d`.
1601
+ 4. **Use style objects on Chart, Plot and a slat's root.**
1602
+ `style="pointer-events: none"` on a Plot or Chart is dropped without a message; write `style=${{ "pointer-events": "none" }}`.
1603
+ On a slat's root, a string that changes or a style that becomes `undefined` replaces the whole style attribute and erases the stack position, so keep it an object.
1604
+ Published rhp 2.0.2 has the same problem on Blocks: a Bar can disappear and SVG geometry can be lost, so use `style=${() => ({ "--n": d.n })}` there too.
1605
+ Current source fixes the Block replacement bug for the upcoming 2.0.3 release; Blocks then retain their geometry when strings change or style is removed.
1606
+ Plain children that hold no rhp geometry can use changing strings.
1607
+ 5. **`className` breaks a block.**
1608
+ It replaces rhp's class, so the block is no longer drawn.
1609
+ Use `class`.
1610
+ 6. **The Chart drops `data-*`, `title`, `tabindex` and handlers.**
1611
+ Listen on an element around it, or on the Poster.
1612
+ 7. **A Plot's unknown props are data, not attributes.**
1613
+ `id`, `title`, `onClick`, `aria-label` on a Plot become `d.id`, `d.title`…
1614
+ Put attributes on the slat's root.
1615
+ 8. **A shorter list wraps around.**
1616
+ Data groups of different lengths do not fail: the longest sets the number of slats and the others repeat from the start.
1617
+ `slats` larger than the data repeats rows the same way.
1618
+ 9. **`d.index` can change for a slat** (with `key` when rows leave, and with `reorder="refill"`).
1619
+ Read it in a function: `data-row=${() => d.index}`.
1620
+ 10. **Giving `room` drops the default room.**
1621
+ `room: { start: 120 }` leaves 14px at the end, so value Labels past long bars stick out of the chart.
1622
+ Give every side you use.
1623
+ 11. **`room: "auto"` measures only edge Labels**, and only direct children of the slat's root.
1624
+ A Label with `at` at the end of a long bar sticks out: add `end` in px.
1625
+ 12. **The axis numbers have fixed room** (24px under, 14px past each end, 42px left of a vertical chart).
1626
+ Long `format` text sticks out of the chart: keep it short.
1627
+ 13. **The scale is not stretched to the data.**
1628
+ A Bar past the scale is cut at its end; a Dot, Tick or Place past it is drawn outside the plot.
1629
+ A value Label past a horizontal chart's max stays at the end, but one past a vertical chart's max rises above the plot, and one below a horizontal chart's min sticks out on the left.
1630
+ Build the scale from the data (`nice()`).
1631
+ 14. **Area and Line points past the scale squeeze the whole outline** into it instead of being cut.
1632
+ One point at 60 on a scale to 30 puts a peak at 15 at a quarter of the width, not half.
1633
+ Keep every x inside the scale, and give `density` a `domain` inside it.
1634
+ 15. **`scale` needs `min < max`.**
1635
+ An equal or reversed pair draws Bars with no length and no axis.
1636
+ To run bars right to left, use negative values and `format=${(v) => Math.abs(v)}`.
1637
+ 16. **NaN, `undefined` and `null` draw at the scale's start without an error**, and a Label prints "NaN".
1638
+ Clean the data first.
1639
+ 17. **A numeric Dot `size` makes an ellipse** (a share of the slat's width and height).
1640
+ Give a length: `size="12px"`.
1641
+ 18. **`series()` counts 6 colors whatever the theme holds.**
1642
+ With a theme of 4 series colors use `series(4)`, or rows 5 and 6 take `series-1`.
1643
+ 19. **A Label does not see its Bar's color.**
1644
+ `color` sets `--rhp-color` on that block only, so `.value { color: var(--rhp-color) }` on a sibling Label stays ink.
1645
+ Set `--rhp-color` on the slat's root in a style object (and leave `color` off the Bar), or color the Label itself.
1646
+ 20. **The page's font, colors and `--rhp-*` variables stop at the chart.**
1647
+ Give the font with `theme.font` and the colors with the theme; theme values may read the page's own variables (`ink: "var(--ink)"`), which do reach the chart.
1648
+ `color="var(--brand)"` on a block works but warns.
1649
+ 21. **Page CSS cannot restyle bars, labels, slats or the axis**, even with `!important`.
1650
+ It can set `opacity`, `cursor`, `filter`, `transform`, `z-index` and `pointer-events`; it also reaches rhp's `--rhp-*` variables, but those belong in the slat's `css` or the theme, never in page CSS.
1651
+ Style the inside of a chart in the slat's `css`.
1652
+ 22. **A `transition` on a block replaces rhp's own**, and the block jumps to new values.
1653
+ A transition on an ordinary Plot's slat root is ignored (rhp keeps the root's slide).
1654
+ Transition an element inside a block.
1655
+ Focus styles for slats go in the slat's `css`: rhp's core CSS resets `outline` on slat roots, so a page `:focus-visible` rule never reaches them.
1656
+ 23. **Slat CSS cannot move or resize an ordinary Plot's slat root** (`top`, `left`, `width`, `height` are rhp's).
1657
+ It can move blocks, including a direct block root in an `overlap` Plot or a Scale (a pie's wedge takes over its Bar's box).
1658
+ 24. **A chart collapses in a shrink-to-fit box.**
1659
+ As a plain flex item, in an `inline-block`, a float or a `width: fit-content` box, it is only as wide as its room (148px by default) with a plot 0px wide.
1660
+ Give it `flex: 1; min-width: 0`, or a width.
1661
+ 25. **A vertical chart's names are as wide as their columns** and end in "…" past that; with a fixed `thickness` the columns do not fill the chart.
1662
+ Keep names short, let them wrap (`.name:vertical { white-space: normal }`), or turn the chart.
1663
+ 26. **In the JS version `d.sold` is fractional while it moves**: round what you print.
1664
+ 27. **`animated()` takes `ease` only as a function**: `{ ease: curve("ease-in-out") }`.
1665
+ A name throws `d.ease is not a function`; `animate` on a Chart or Plot takes names.
1666
+ 28. **`standalone` has `createSelector` only from rhp 2.0.2** (with `createComputed`, `on`, `mergeProps`, `splitProps`, `Switch` and `Match`).
1667
+ For the slat the reader is on, a per-row data group works with every rhp 2: `on=${(d) => on() === d.index}`; `createSelector` saves work when a chart has hundreds of slats.
1668
+ 29. **A Plot outside a Chart draws nothing usable**, and a Scale outside one throws.
1669
+ 30. **A slat must return one element.**
1670
+ Two top-level elements or text throw "rhp: a slat must return one element".
1671
+ Return a single block directly only in an `overlap` Plot or a Scale.
1672
+ An ordinary Plot needs a wrapper to preserve the block's placing.
1673
+ 31. **The Poster brings no CSS**: a bare `<figure>` keeps 40px side margins and its caption spans run inline.
1674
+ Style `.poster`, `figcaption`, `.kicker`, `.headline`, `.dek` and `.note`.
1675
+ 32. **An overlay Plot with a plain slat function asks for the default room** (104px and 44px).
1676
+ Give its slat `room: {}` when the chart's room is smaller, and `style=${{ "pointer-events": "none" }}`.
1677
+ 33. **`static` charts ignore other signals.**
1678
+ A per-row function that reads a signal (a hovered row) runs once on a static chart, so keep interactive charts off `static`.
1679
+ 34. **Warnings rhp prints** (each once per page), all worth fixing: a block as an ordinary Plot's slat root; an edge Label inside another element with room "auto"; `aspect` with `height`, with slats that have a `thickness`, or not above 0; a page variable as a color; `--rhp-radius` with several lengths; an unknown ease; `linkedCss()` without the stylesheet.
1680
+ 35. **A space that stands alone between two tags is dropped** in the html template.
1681
+ `<b>${() => d.name}</b> <em>12</em>` renders "Name12".
1682
+ Write the space as `${" "}`, or keep it next to other text (`<b>Name</b>: <em>12</em>`).
1683
+ 36. **A Plot sits at `z-index: 1`.**
1684
+ A page element that overlaps a chart (a readout pinned with `position: sticky`, a menu that opens over the chart) is drawn under the slats unless its own `z-index` is 2 or more.