@bezda/rhp-mcp 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bezda/rhp-mcp",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Make charts with rhp from any AI agent: an MCP server with rhp's guide, references and recipes, and a checker that renders a chart in a browser and reports what is wrong, with screenshots",
5
5
  "keywords": [
6
6
  "rhp",
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@babel/core": "^7.29.7",
54
- "@bezda/rhp": "^2.1.0",
54
+ "@bezda/rhp": "^2.2.0",
55
55
  "@modelcontextprotocol/sdk": "^1.32.0",
56
56
  "babel-preset-solid": "^1.9.15",
57
57
  "esbuild": "^0.28.2",
package/skill/SKILL.md CHANGED
@@ -269,7 +269,7 @@ Download saves the full poster, including the headline and note, even when the v
269
269
  - A request about the user's own data with none given ("chart my spending") gets illustrative numbers in one obvious data block at the top of the script, with the note's text in the same block, so the user changes both in one place.
270
270
  The headline and the dek compute their numbers from that data, so they stay true for the user's own.
271
271
  The handoff says exactly how to put their own numbers in, or offers to read a file they point to.
272
- - Compute the scale from the data: `nice(Math.min(0, ...values), Math.max(0, ...values))` (rule 2 below).
272
+ - Compute the scale from the data: `nice(...extent([0, ...values]))`, which skips missing values (rule 2 below).
273
273
  - Compute the ranks, the leader and every number in the headline and the dek from a sorted copy of the data, never from its input order: a Plot's `order` sorts only what it draws.
274
274
  - Text computed from the data (the headline, the dek, a readout, an `aria-label`) must stay true for any data it may get:
275
275
  - A change from a zero or missing base prints "new" or "up from none", never ∞%, NaN% or "as many as"; a change in a rate is in percentage points ("down 0.8 points").
@@ -375,12 +375,12 @@ A complete chart in the plain HTML format (the format of every recipe), with a p
375
375
  <body>
376
376
  <div id="chart"></div>
377
377
  <script type="module">
378
- import { Chart, Plot, Bar, Label, Poster, slat, nice, html, render, createSignal } from "@bezda/rhp/standalone";
378
+ import { Chart, Plot, Bar, Label, Poster, slat, nice, extent, html, render, createSignal } from "@bezda/rhp/standalone";
379
379
 
380
380
  // Data, and what depends on it
381
381
  const fruit = ["Bananas", "Apples", "Cherries", "Pears", "Plums"];
382
382
  const sold = [18, 12, 7, 5, 3];
383
- const scale = nice(Math.min(0, ...sold), Math.max(0, ...sold)); // 0 to 20, ticks every 5
383
+ const scale = nice(...extent([0, ...sold])); // 0 to 20, ticks every 5
384
384
  const lead = sold.indexOf(Math.max(...sold)); // the fruit the headline names: lit until the reader picks another
385
385
 
386
386
  // Slat type: one slat per row; its CSS reaches only these slats
@@ -449,7 +449,7 @@ Data:
449
449
 
450
450
  1. **Data names must match.** A slat reads `d.sold` only if the Plot has a prop `sold`. A list of objects goes in `rows={[...]}`, never `data={...}`: `data` would just be a column named "data". Any prop a Plot does not know (`id`, `title`, `onClick`) is a column too, and an array is always a column, item i for row i: to give every slat the same list, pass a function of the row (`shown=${(d) => shown()}`).
451
451
  2. **The scale must hold the data.** `scale={[min, max]}` with min below max covers every value: a Bar past it is cut off, and Dots, Ticks, Places and the points of an Area or a Line past it land outside or squeeze the outline.
452
- Build it from the data, `const s = nice(Math.min(0, ...values), Math.max(0, ...values))`, which keeps 0 and holds negative values too, and give the Chart `scale=${[s.min, s.max]} ticks=${s.ticks}`.
452
+ Build it from the data, `const s = nice(...extent([0, ...values]))`, which keeps 0, holds negative values and skips missing ones, and give the Chart `scale=${[s.min, s.max]} ticks=${s.ticks}`.
453
453
  3. **Rows that come and go need a `key`** on the Plot (`key="city"`), or slats swap data instead of leaving. `d.index` can change then, so read it in a function: `data-row=${() => d.index}`.
454
454
  4. **Format numbers** with `Intl.NumberFormat` (thousands, units, percentages, currency) and use `font-variant-numeric: tabular-nums` where numbers line up. In the JS version (`animate`), values in between are fractional: round what you print.
455
455
 
@@ -659,6 +659,31 @@ const ChangeChart = () => html`
659
659
  render(ChangeChart, document.getElementById("chart"));
660
660
  ```
661
661
 
662
+ ### Missing data
663
+
664
+ `null`, `undefined`, `NaN` and infinite numbers are missing values, and rhp never draws one as 0.
665
+
666
+ - A block given a missing value for its place draws nothing: a Bar's `from` or `to`, the `at` or `cross` of a Dot, Tick, Label or Place, a Cell's `value`.
667
+ Its slat and the slat's other blocks stay.
668
+ - Only given values count: a Bar with no `from` starts at 0, and a Label at an `edge` is always drawn.
669
+ - A Line or an Area breaks at a point that is `null` or has a missing x or y, and draws each run of two or more points.
670
+ To join the line across a gap, leave the point out: `points.filter(([, y]) => y != null)`.
671
+ - The helpers leave missing values out, and `sortBy` puts them last in either direction (section 9).
672
+ - A value that comes back is drawn where it is, without moving from where it was.
673
+ - Read a CSV's empty cells as `null`, never 0: `(s) => (s.trim() === "" ? null : +s)`.
674
+
675
+ Say "no data" where the value would be, in the slat and in any readout:
676
+
677
+ ```js
678
+ const shown = (v) => (Number.isFinite(v) ? v.toLocaleString() : "no data");
679
+ const Country = slat({ room: { start: 90, end: 60 } }, (d) => html`
680
+ <div>
681
+ <${Label} edge="start">${() => d.country}<//>
682
+ <${Bar} to=${() => d.rate} />
683
+ <${Label} edge="end">${() => shown(d.rate)}<//>
684
+ </div>`);
685
+ ```
686
+
662
687
  ### ManyDots (experimental point collection)
663
688
 
664
689
  `ManyDots` draws a bulk collection of points directly inside a `Chart` with `scale` and `cross`.
@@ -947,15 +972,15 @@ When their input changes, call them inside `createMemo` so they run once per cha
947
972
  | Helper | Returns | Example (real output) |
948
973
  |---|---|---|
949
974
  | `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 |
950
- | `extent(values)` | `[smallest, largest]` (`[Infinity, -Infinity]` when empty) | `extent([5, -2, 9])` → `[-2, 9]` |
975
+ | `extent(values)` | `[smallest, largest]` (`[Infinity, -Infinity]` when empty) | `extent([5, null, -2, 9])` → `[-2, 9]` |
951
976
  | `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]` |
952
- | `stackUp(values)` | `{ from, to }`: positives stack up from 0, negatives down | `stackUp([5, -3, 4])` → from `[0, 0, 5]`, to `[5, -3, 9]` |
977
+ | `stackUp(values)` | `{ from, to }`: positives stack up from 0, negatives down; a missing value's are `null` | `stackUp([5, -3, 4])` → from `[0, 0, 5]`, to `[5, -3, 9]` |
953
978
  | `shares(values, total = 100)` | each value's share of the sum of absolute values, scaled to `total` | `shares([1, 1, 2])` → `[25, 25, 50]` |
954
979
  | `running(changes)` | `{ from, to }`: running totals (waterfall) | `running([100, -30, 45])` → from `[0, 100, 70]`, to `[100, 70, 115]` |
955
980
  | `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]` |
956
981
  | `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]` |
957
982
  | `density(samples, { domain, points = 40, bandwidth })` | `[[x, density], …]` for an Area (Gaussian kernel, Silverman's bandwidth) | keep `domain` inside the Chart's scale |
958
- | `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")}` |
983
+ | `sortBy(key, "asc" or "desc")` | an `order` function; `key` is a group name or `(d) => value`; ties keep their places on screen, missing values go last | `order=${sortBy("sold", "desc")}` |
959
984
  | `series(n = 6)` | a data group: `"series-1"`… by row index | `color=${series(4)}` |
960
985
  | `cycle(list)` | a data group repeating `list` over the rows (does not set the number of rows) | `color=${cycle(["#111", "#999"])}` |
961
986
  | `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` |
@@ -967,6 +992,8 @@ When their input changes, call them inside `createMemo` so they run once per cha
967
992
  | `restyle(slatType, css)` | gives a slat type made with `css` new CSS; its slats restyle in place | for style editors |
968
993
  | `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 |
969
994
 
995
+ `extent`, `summary`, `bins` and `density` leave missing values out (section 6, Missing data); `stackUp` and `running` give one `null` for `from` and `to`, and `shares` gives it no share (`null`).
996
+
970
997
  A shape must start with `M`.
971
998
  Further `M` commands start separate subpaths, allowing disconnected pieces or holes with the opposite winding direction.
972
999
  These work with native CSS `shape()` and the polygon fallback; curves in the fallback retain the existing approximation.
@@ -1364,7 +1391,8 @@ const VoteChart = () => {
1364
1391
  render(VoteChart, document.getElementById("chart"));
1365
1392
  ```
1366
1393
 
1367
- **A top-N race whose rows leave and come in.**
1394
+ ### A top-N race whose rows leave and come in
1395
+
1368
1396
  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.
1369
1397
  Rows a source leaves out (a table of the top four each year) are `null` in the data.
1370
1398
  Three parts do it:
@@ -1635,8 +1663,8 @@ Every one of these was found by running rhp 2.0.1.
1635
1663
  15. **`scale` needs `min < max`.**
1636
1664
  An equal or reversed pair draws Bars with no length and no axis.
1637
1665
  To run bars right to left, use negative values and `format=${(v) => Math.abs(v)}`.
1638
- 16. **NaN, `undefined` and `null` draw at the scale's start without an error**, and a Label prints "NaN".
1639
- Clean the data first.
1666
+ 16. **A missing value draws nothing, and a Label prints it as "NaN" or "null".**
1667
+ Print "no data" for it (section 6, Missing data).
1640
1668
  17. **A numeric Dot `size` makes an ellipse** (a share of the slat's width and height).
1641
1669
  Give a length: `size="12px"`.
1642
1670
  18. **`series()` counts 6 colors whatever the theme holds.**
@@ -619,6 +619,9 @@ const Reference = slat({
619
619
  - **Units** once, where the reader looks first: in the dek, the Chart's `label` and the axis `format`.
620
620
  A short symbol ($, %, °C) may repeat on every value; a unit word ("megawatts", "TWh") goes in the dek or a column head, or after the first value only.
621
621
  - **Numbers** rounded to what matters (two or three significant figures), with the same decimals down a column.
622
+ - **Missing data said, never drawn as 0.** A row with no value keeps its name and says "no data" where its value would be (api.md, "Missing data"), and a line breaks at a gap.
623
+ Join a line across a gap only when the dek or note says the points are joined.
624
+ When many values are missing, the note says how many and why ("no survey in 2020").
622
625
 
623
626
  ## 8. Inside an existing app
624
627
 
@@ -708,12 +711,12 @@ Whatever they did not mention keeps the defaults: text at 4.5:1 and marks at 3:1
708
711
  - A size that fits the content (about 480 to 640px wide for a few bars), centered with room around it (`#chart { max-width: 480px; margin: 0 auto; }` and `body { padding: 64px 16px; }`), not pinned to the top of an empty page.
709
712
 
710
713
  ```js
711
- import { Chart, Plot, Bar, Label, Tick, slat, nice, html, render } from "@bezda/rhp/standalone";
714
+ import { Chart, Plot, Bar, Label, Tick, slat, nice, extent, html, render } from "@bezda/rhp/standalone";
712
715
 
713
716
  // Data
714
717
  const NAME = ["A", "B", "C"];
715
718
  const VALUE = [3, 7, 5];
716
- const scale = nice(Math.min(0, ...VALUE), Math.max(0, ...VALUE));
719
+ const scale = nice(...extent([0, ...VALUE]));
717
720
 
718
721
  // Slat types: columns with their numbers (no axis numbers) and square feet, and a baseline, a Tick at 0 across the plot
719
722
  const Column = slat({
@@ -38,17 +38,17 @@ Start from a recipe, never from what another library does.
38
38
  3. **`render` got the result instead of a function.** Write ``render(() => html`<${MyChart} />`, element)``.
39
39
  4. **The slat returns two elements, text, or nothing** [30]. Return one root element (`<div>`) holding the blocks.
40
40
  5. **The Plot is outside a Chart** [29], or the Plot got the bare function instead of what `slat()` returned (the slat then has no CSS or layout).
41
- 6. **Data groups are empty** or the Plot has none of the names the slat reads: every slat reads `undefined` and draws at the start of the scale [16]. Check the names: `d.sold` needs a Plot prop `sold`; a list of objects goes in `rows`, not `data` [7].
41
+ 6. **Data groups are empty** or the Plot has none of the names the slat reads: every slat reads `undefined`, a missing value, and its blocks draw nothing [16]. Check the names: `d.sold` needs a Plot prop `sold`; a list of objects goes in `rows`, not `data` [7].
42
42
 
43
43
  ## Bars, dots or labels in the wrong place
44
44
 
45
45
  1. **The scale does not hold the data** [13]: bars are cut at the end, dots and ticks land outside the plot.
46
- Build it from the data: `const s = nice(Math.min(0, ...values), Math.max(0, ...values))`, then `scale=${[s.min, s.max]} ticks=${s.ticks}`.
46
+ Build it from the data: `const s = nice(...extent([0, ...values]))`, then `scale=${[s.min, s.max]} ticks=${s.ticks}`.
47
47
  2. **Negative values on a scale that starts at 0**: a value below the scale's min draws nothing; the scale above takes its min below 0.
48
48
  A Bar whose `to` is below its `from` runs backward.
49
49
  3. **Area or Line points past the scale squeeze the whole outline** [14]: keep every x inside the scale; give `density()` a `domain` inside it.
50
50
  4. **A reversed or equal scale** (`[100, 0]`, `[5, 5]`) draws nothing [15]. For bars that run the other way, use negative values and print them with `format=${(v) => Math.abs(v)}`.
51
- 5. **NaN, undefined or null** draw at the scale's start and print "NaN" [16]: clean or filter the data first.
51
+ 5. **A Label prints "NaN", "null" or "undefined"**: its value is missing, and the blocks it places draw nothing [16]. Print "no data" for it (api.md, "Missing data"), and check the data when the value should be there.
52
52
  6. **Every slat got one item of a list meant for all of them**: an array given to a Plot is a column, item i for row i. To give every slat the same list (the series still shown, a set of picked names), pass a function of the row: `shown=${(d) => shown()}`.
53
53
  7. **Lists of different lengths** wrap around without an error [8]: the third slat of a 3-item list shown beside a 5-item list repeats the first values. Give every data group the same length.
54
54
  8. **A Dot is an oval**: its `size` was a number [17].
@@ -114,7 +114,8 @@ The tests read this table: every code in it must have a chart in `test/broken/`
114
114
  | `plot-typo` | warning | A Plot prop that is a typo of a Plot setting, with a value of that setting's kind (`overlpa=${true}`): rhp takes it as a data group. | The setting it means. |
115
115
  | `group-lengths` | warning | A Plot's data groups (lists of 3 or more, not palettes) have different lengths, and rhp repeats the shorter ones. | One item per row in every group. |
116
116
  | `no-component` | error | A Solid, React or module file exports several components and no default one. | Export the chart as the default. |
117
- | `nan-value` | error | A block's number is NaN; a block's `at` or `to` is given but null or undefined (a row with no value, drawn at the start of the scale); a `--rhp-*` value of a block or the Chart came out as NaN, undefined or Infinity; or a text in a chart prints NaN, undefined or Infinity. | Check the data the value is worked out from; leave rows with no value out, or draw the block in a `Show`. |
117
+ | `missing-value` | info | A block given a missing value (null, undefined, NaN or an infinite number) for its place (`from`, `to`, `at`, `cross`, `value`), which rhp doesn't draw; or a Line or Area point that is missing, where the outline breaks. | Say "no data" in the slat for a row with none; join a line over a gap by leaving the point out; otherwise check the data the value is worked out from. |
118
+ | `nan-value` | error | A block's number that is not a place (`across`, `peak`, `base`) is NaN; a `--rhp-*` value of a block rhp draws, or of the Chart, came out as NaN, undefined or Infinity; every value a chart would draw is missing, so it draws no data; or a text in a chart prints NaN, undefined or Infinity. | Check the data the value is worked out from (a misspelled name, numbers read as text, a division by zero), and format numbers before printing them. |
118
119
  | `stale-read` | error | A slat read `d.x` once, while it was drawn and outside any function (`${d.x}` in an html template), and the interaction pass changed it: the slat still shows the old value. | `${() => d.x}`, and never destructure `d`. |
119
120
  | `static-chart` | error | A static Chart or Plot whose per-row function (`on=${(d) => on() === d.index}`) changed during the interaction pass: static draws each row once, so the interaction changes nothing. | Take `static` off. |
120
121
 
@@ -27,6 +27,7 @@ export const SEE = {
27
27
  "value-past-scale": PITFALLS("Bars, dots or labels in the wrong place"),
28
28
  "points-past-scale": PITFALLS("Bars, dots or labels in the wrong place"),
29
29
  "nan-value": PITFALLS("Bars, dots or labels in the wrong place"),
30
+ "missing-value": 'api.md, "Missing data"',
30
31
  "dot-size": PITFALLS("Bars, dots or labels in the wrong place"),
31
32
  "sticks-out": PITFALLS("Text sticks out, is cut off or overlaps"),
32
33
  "text-cut-off": PITFALLS("Text sticks out, is cut off or overlaps"),
@@ -95,6 +96,7 @@ export function fromProbe(f, context) {
95
96
  fix: f.vertical ? "Give the Chart a height (height={320}) or an aspect (aspect={16 / 9}), and its parent the room for it." : "Give the chart more width: its parent is too narrow, or the room for labels (room start and end) takes it all.",
96
97
  };
97
98
  case "nan-value":
99
+ if (f.everything) return { level: "error", code: f.code, message: `Every value ${f.chart} would draw is missing (${f.count} blocks given null, undefined, NaN or an infinite number), so it draws no data.`, fix: "Check the data the values come from: a misspelled data group or key, numbers read as text and not converted, or a division by zero." };
98
100
  if (f.printed) return { level: "error", code: f.code, message: `${f.what} in the chart shows ${q(f.text)}: a value it prints is missing or not a number.`, fix: "Check the data the label prints (a missing value, a misspelled name, a division by zero), and format numbers before printing them." };
99
101
  return { level: "error", code: f.code, message: `${f.block}'s ${f.prop} is ${f.value}.`, fix: `Give ${f.block} a number for ${f.prop}: check the data it is computed from (a missing value, a name that is misspelled, a division by zero).` };
100
102
  case "plot-outside-chart":
@@ -391,6 +391,12 @@ export function install() {
391
391
  // or a Plot of slats with none, draws no data
392
392
  const empty = (p) => ![...p.children].some((s) => !s.hidden);
393
393
  if (plots.length && (plots.every(empty) || plots.some((p) => empty(p) && !p.hasAttribute("data-rhp-overlap")))) add({ code: "empty-plot", chart: describe(c) });
394
+ // every block that could show a value has a missing one: the chart draws no data (a misspelled name, values read
395
+ // as text), though each block alone is a note
396
+ const gone = c.querySelectorAll(".rhp-body [data-rhp-missing]").length;
397
+ if (gone && !c.querySelector(".rhp-body :is(.rhp-bar, .rhp-dot, .rhp-tick, .rhp-cell, .rhp-place, .rhp-label[data-rhp-at], .rhp-line, .rhp-area):not([data-rhp-missing])")) {
398
+ add({ code: "nan-value", everything: true, count: gone, chart: describe(c) });
399
+ }
394
400
  for (const p of plots) {
395
401
  const shown = [...p.children].filter((s) => !s.hidden);
396
402
  if (!shown.length) continue;
@@ -409,10 +415,11 @@ export function install() {
409
415
  }
410
416
  }
411
417
 
412
- // CSS variables that came out as NaN or undefined, and values past the Chart's scale
418
+ // CSS variables that came out as NaN or undefined (not a block rhp hides for a missing value), and values past the
419
+ // Chart's scale
413
420
  const VALUE = { "--rhp-from": "from", "--rhp-to": "to", "--rhp-at": "at", "--rhp-value": "value", "--rhp-size": "size", "--rhp-across": "across", "--rhp-cross": "cross", "--rhp-min": "scale", "--rhp-max": "scale", "--rhp-thick": "thick" };
414
421
  for (const c of all) {
415
- for (const el of [c, ...c.querySelectorAll("[style]")]) {
422
+ for (const el of [c, ...c.querySelectorAll("[style]:not([data-rhp-missing])")]) {
416
423
  for (let i = 0; i < el.style.length; i++) {
417
424
  const name = el.style[i];
418
425
  if (!name.startsWith("--rhp-")) continue;
package/src/guard/rhp.js CHANGED
@@ -42,6 +42,11 @@ const show = (v) => {
42
42
  const isColor = (c) => typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("color", c);
43
43
  const SPAN = new Set(["from", "to", "value"]);
44
44
 
45
+ // A missing value (null, undefined, NaN or infinite), as rhp sees one: a block given one for its place draws nothing,
46
+ // and a Line or an Area breaks at a point with one
47
+ const missing = (v) => v == null || (typeof v === "number" && !Number.isFinite(v));
48
+ const PLACED = new Set(["from", "to", "at", "cross", "value"]);
49
+
45
50
  // A number a block draws at: finite, or left out
46
51
  function checkNumber(name, prop, v) {
47
52
 
@@ -321,12 +326,16 @@ function inspectBlock(name, props) {
321
326
 
322
327
  for (const prop of spec.numbers) {
323
328
  const v = read(props, prop);
324
- checkNumber(name, prop, v);
325
- // a place given with no value (a row with no data) is drawn at the start of the scale, with no error
326
- if (v == null && (prop === "at" || prop === "to") && given.includes(prop)) {
327
- note("error", "nan-value", `${name}'s ${prop} is ${v} (a row with no value), so rhp draws it at the start of the scale.`,
328
- `Leave rows with no value out of the data, or draw the ${name} only when there is one: <\${Show} when=\${() => d.value != null}>...<//>.`);
329
+ // a place given a missing value (a row with no data) is not drawn, which is right, so it is a note: the slat should
330
+ // say there is no data (a Label at an edge is drawn there and doesn't use its at)
331
+ if (PLACED.has(prop) && given.includes(prop) && missing(v)) {
332
+ if (!(name === "Label" && read(props, "edge") != null)) {
333
+ note("info", "missing-value", `${name}'s ${prop} is ${v}, a missing value, so rhp draws no ${name} there.`,
334
+ `For a row with no data that is right: say so in the slat (a Label at its edge: "no data"). If the row should have a value, check the data it is worked out from (a misspelled name, an empty cell, a division by zero).`);
335
+ }
336
+ continue;
329
337
  }
338
+ checkNumber(name, prop, v);
330
339
  }
331
340
 
332
341
  for (const prop of LENGTHS[name] ?? []) {
@@ -358,22 +367,33 @@ function checkPoints(name, points) {
358
367
 
359
368
  if (points == null) return;
360
369
 
361
- const pairs = isList(points) && Array.from(points).every((p) => isList(p) && p.length >= 2);
370
+ // (a point may be null: a missing one)
371
+ const list = isList(points) ? Array.from(points) : null;
372
+ const pairs = list && list.every((p) => p == null || (isList(p) && p.length >= 2));
362
373
  if (!pairs) {
363
374
  note("error", "bad-prop", `${name}'s points is ${show(points)}, not a list of [x, y] pairs.`, `Give points as [[x, y], [x, y], ...]: x on the value axis, y across the band.`);
364
375
  return;
365
376
  }
366
377
 
367
- const bad = Array.from(points).findIndex((p) => !Number.isFinite(p[0]) || !Number.isFinite(p[1]));
378
+ const shownPoint = (p) => (p == null ? String(p) : `[${p[0]}, ${p[1]}]`);
379
+ const bad = list.findIndex((p) => p != null && [p[0], p[1]].some((v) => !missing(v) && !Number.isFinite(v)));
368
380
  if (bad >= 0) {
369
- note("error", Number.isNaN(points[bad][0]) || Number.isNaN(points[bad][1]) ? "nan-value" : "bad-prop", `${name}'s point ${bad} is [${points[bad][0]}, ${points[bad][1]}].`, "Give every point two finite numbers.");
381
+ note("error", "bad-prop", `${name}'s point ${bad} is ${shownPoint(list[bad])}, not two numbers.`, "Give every point two numbers (null for a value that is missing).");
370
382
  return;
371
383
  }
372
384
 
385
+ const gap = list.findIndex((p) => p == null || missing(p[0]) || missing(p[1]));
386
+ if (gap >= 0) {
387
+ note("info", "missing-value", `${name}'s point ${gap} is ${shownPoint(list[gap])}, a missing value, so the ${name === "Line" ? "line" : "shape"} breaks there.`,
388
+ `For a gap in the data that is right: a break shows the reader where it is. To join the points around it, leave the missing point out of the list. If it should have a value, check the data it is worked out from.`);
389
+ }
390
+
373
391
  if (name === "Area") {
374
- const unsorted = points.findIndex((p, i) => i > 0 && p[0] < points[i - 1][0]);
375
- if (unsorted > 0) note("error", "bad-prop", `Area's points are not sorted by x (point ${unsorted} comes before point ${unsorted - 1}).`, "Sort an Area's points by x.");
376
- if (points.some((p) => p[1] < 0)) note("error", "bad-prop", "Area has a point with y below 0.", "An Area's y is 0 or more; for values on both sides use a Line on a cross scale.");
392
+ // the points that are there, with their places in the list
393
+ const kept = list.map((p, i) => [i, p]).filter(([, p]) => p != null && !missing(p[0]) && !missing(p[1]));
394
+ const unsorted = kept.findIndex(([, p], k) => k > 0 && p[0] < kept[k - 1][1][0]);
395
+ if (unsorted > 0) note("error", "bad-prop", `Area's points are not sorted by x (point ${kept[unsorted][0]} comes before point ${kept[unsorted - 1][0]}).`, "Sort an Area's points by x.");
396
+ if (kept.some(([, p]) => p[1] < 0)) note("error", "bad-prop", "Area has a point with y below 0.", "An Area's y is 0 or more; for values on both sides use a Line on a cross scale.");
377
397
  }
378
398
  }
379
399