@liggi/figkit 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/GUIDE.md CHANGED
@@ -5,7 +5,7 @@ work out coordinates or text widths: boxes size to their text, arrows route them
5
5
  measure the real result.
6
6
 
7
7
  ```
8
- npx figkit figure.html [--out figure.png]
8
+ npx @liggi/figkit figure.html [--out figure.png]
9
9
  ```
10
10
 
11
11
  The file is just the `<main>` below; no doctype or `<head>` needed. Without `--out` the PNG goes to a temporary folder; the command prints where.
@@ -28,6 +28,8 @@ the images and look, then embed the PNG: `![what it shows](/abs/path.png)`.
28
28
  A question about structure ("what are the entities?", "where does X live?", "how do these relate?", "what loads
29
29
  what?", "walk me through it") is answered with a picture first, not with numbered steps.
30
30
 
31
+ - "What happens when…?", "walk me through a run" and "who calls whom?" are a `sequence` spec (below): the steps in
32
+ order between the parts, not a numbered list or a table of steps.
31
33
  - "What are the entities and how do they relate?" is an `entities` spec (below), not boxes and arrows in HTML. It
32
34
  draws references: which record holds a pointer to which. The order work happens in (approve, then label, then
33
35
  train) is not a reference; it goes in prose, a list or a separate flow. A record that creates others still points
@@ -46,12 +48,12 @@ what?", "walk me through it") is answered with a picture first, not with numbere
46
48
 
47
49
  ## Charts and entities from a spec
48
50
 
49
- When the whole figure is one chart or one entities picture of a type below, write a JSON spec instead of HTML. You give the data; figkit
51
+ When the whole figure is one chart, one entities picture or one sequence of a type below, write a JSON spec instead of HTML. You give the data; figkit
50
52
  does the layout, colours and labels. Use HTML (the rest of this guide) for anything else, including a chart that sits
51
53
  beside boxes or arrows.
52
54
 
53
55
  ```
54
- npx figkit chart.json [--out chart.png]
56
+ npx @liggi/figkit chart.json [--out chart.png]
55
57
  ```
56
58
 
57
59
  A spec that breaks a rule is refused with one line per problem; fix them and run it again. Then look at the images
@@ -159,6 +161,74 @@ when there is room. Columns label themselves at even steps, so give hours as "12
159
161
  "mark": { "row": "Wed", "column": "12pm", "label": "Busiest: Wed noon" } }
160
162
  ```
161
163
 
164
+ ### How sure a number is
165
+
166
+ Most numbers are counted from a sample: 71% of 75 days is not 71% of 7,500. When the reader will compare such
167
+ numbers, draw how sure each one is.
168
+
169
+ Ranges on `bars`, `series` and `lines`: give each bar or point a `low` and a `high` around its value, and say once
170
+ in `range` what they are, in the reader's words ("likely range (95%)", "lowest to highest week"); it is printed under
171
+ the reading line beside the mark it describes. A bar steps back to a pale fill, its range is drawn across its end in
172
+ the bar's colour and the range's ends are printed after the value; a series or line gets a band. Accent the bar whose
173
+ range the title is about. Give every item a
174
+ range or none, since one without reads as certain. figkit refuses a range that does not contain its value, a range
175
+ with no `range` text, and a bar range below zero. `n` on a bar prints its sample size under its label, as "14 runs" when `each` names
176
+ the things counted (`"each": "runs"`), else "n = 14"; give every bar one or none. `ref` on a `series`,
177
+ `{ "y": 67, "label": "Jul to mid-Oct: 67%" }`, draws a dashed level to compare the line with, named in the key line. A percentage of fewer than 10 things is refused: show it as a count (`"show": "3 of 6"`) or draw `units`.
178
+
179
+ ```json
180
+ { "type": "bars", "title": "The new reply check catches more, but the ranges still overlap",
181
+ "note": "Share of unsafe replies caught, on the same test set", "unit": "%", "max": 100, "range": "likely range (95%)", "each": "replies",
182
+ "bars": [ { "label": "Current check", "value": 64, "low": 53, "high": 74, "n": 72 },
183
+ { "label": "New check", "value": 78, "low": 67, "high": 86, "n": 72, "tone": "accent" } ] }
184
+ ```
185
+
186
+ The title claims only what the ranges allow: when they overlap, "catches more" needs a "but".
187
+
188
+ `dots`: every value as a dot, for a sample small enough to look at one by one (up to 300 a group, 600 in all), in 1
189
+ to 6 groups on one axis. Use it instead of a bar of the average when the spread or a few odd values are the point.
190
+ Dots that would overlap stack upward and downward, so none hides another, and each group's count is printed. `note`
191
+ (required) says what one dot is. `each` names what a dot is, so counts read "50 runs". `summary` draws the `"median"` or `"mean"` as a line across each group, labelled;
192
+ `mark` rings one value and labels it; `zero` starts the axis at zero; `tone` on a group colours its dots.
193
+
194
+ ```json
195
+ { "type": "dots", "title": "Most builds finish in four minutes; a few take over ten",
196
+ "note": "One dot per build: minutes from push to result", "unit": " min", "summary": "median", "zero": true, "each": "builds",
197
+ "groups": [ { "label": "Main branch", "values": [3.8, 4.1, 3.6, 11.8, 4.0, 12.6, 4.2] },
198
+ { "label": "Pull requests", "values": [4.9, 5.2, 4.6, 5.8, 5.1], "tone": "accent" } ],
199
+ "mark": { "group": "Main branch", "value": 12.6, "label": "12.6 min, cache miss" } }
200
+ ```
201
+
202
+ `scatter`: two measures against each other, one dot per thing measured, up to 1000. It is its own type, not a mode
203
+ of `dots`, because it takes pairs and answers a different question: whether one measure moves with the other. `x`
204
+ and `y` each need a `label` saying what they measure, and take `unit`, `"log": true` for measures that span several
205
+ powers of ten (every value above zero; the axis runs to whole powers of ten), `zero`, and `time` (`"seconds"`,
206
+ `"minutes"`, `"hours"` or `"days"`, the unit the values are in) so ticks read 1 min, 1 h, 1 day, 1 wk.
207
+ `"medians": true` draws the median y of each band of x (each power of ten on a log axis, else fifths) in the accent
208
+ colour, with the middle half of the band's dots shaded behind it, for bands with 5 or more dots: the relation, or its
209
+ absence, drawn without fitting a model. `label` names a point beside it, at most 5; `tone` colours it.
210
+ figkit draws no fitted trend line: it claims more than dots do. Say the relation in the title only when the dots or
211
+ the medians show it, and "barely" when they mostly do not.
212
+
213
+ ```json
214
+ { "type": "scatter", "title": "Bigger pull requests wait longer for review",
215
+ "note": "One dot per merged pull request",
216
+ "x": { "label": "Lines changed", "log": true }, "y": { "label": "Time to first review", "time": "hours", "zero": true },
217
+ "points": [ { "x": 12, "y": 1.5 }, { "x": 120, "y": 5.5 }, { "x": 540, "y": 22, "label": "Search rewrite", "tone": "accent" } ] }
218
+ ```
219
+
220
+ `units`: a small count made concrete, "11 of 29" as 29 squares with 11 filled. `total` is at most 100: beyond that
221
+ the squares cannot be counted by eye, and a `split` bar of the shares reads better. Squares sit ten to a row and fill
222
+ column by column, so each part is one block from the left. The blocks show counts, not order: if when things happened
223
+ matters, draw it over time instead. Up to 3 `parts` fill squares in order (the first in the accent colour
224
+ unless it has a `tone`); `rest` (required) names the empty squares, so at least one is left. Counts are whole things.
225
+
226
+ ```json
227
+ { "type": "units", "title": "11 of the 29 flagged days were real problems",
228
+ "note": "One square per day the monitor flagged in October", "total": 29,
229
+ "parts": [ { "label": "real problems", "count": 11 } ], "rest": "false alarms" }
230
+ ```
231
+
162
232
  `entities`: what the things are and which points at which. 2 to 5 `records`, each with an `id` you use in `refs`,
163
233
  a `name` in the reader's words, and up to 4 `fields` that say what it holds (put a table name here only if the reader
164
234
  needs it). Each of up to 8 `refs` is a pointer the `from` record holds to the `to` record: `count` is how many `from`
@@ -183,7 +253,35 @@ in plain words, not in-house terms or button labels.
183
253
 
184
254
  More than 5 records is two figures: the core records, then one corner of it with the core record it hangs off.
185
255
 
186
- Flows (boxes and arrows) have no spec: write them in HTML, below.
256
+ `sequence`: who calls whom, in order. Use it when the question is "what happens when…", "walk me through a run",
257
+ "who starts what": the order is the point and more than one part takes part. A table of steps (step, who starts it,
258
+ who is involved) is this picture without the shape; draw it instead. A flow of boxes and arrows shows what connects
259
+ to what, not when; use it for structure. 2 to 5 `participants`, left to right in the order the reader meets them, each
260
+ an `id`, a `name` and an optional `kind` (where it runs: "GitHub Actions", "Cloud Run job"). 2 to 12 `messages` in
261
+ the order they happen, each from one participant to another with a `label` saying what is asked for or sent. figkit
262
+ numbers the steps, so your prose can say "step 4". `"reply": true` is the answer to an earlier call, drawn dashed;
263
+ leave replies out when nothing comes back that the reader needs. A message from a participant to itself is work it
264
+ does on its own. `note` adds one short line under a step: a condition ("only if the tests pass"), a wait ("up to an
265
+ hour"). `repeats` frames steps `from` to `to` (numbered from 1) that run more than once, with a `label` such as "For
266
+ each model". Accent one participant, or the steps the title is about, not both. The title claims only what the
267
+ steps show.
268
+
269
+ ```json
270
+ { "type": "sequence", "title": "A failed card charge is retried before the shopper hears anything",
271
+ "participants": [ { "id": "app", "name": "Shop app", "kind": "browser" }, { "id": "api", "name": "Checkout API" },
272
+ { "id": "pay", "name": "Card processor" } ],
273
+ "messages": [ { "from": "app", "to": "api", "label": "Place order" },
274
+ { "from": "api", "to": "pay", "label": "Charge the card", "tone": "accent" },
275
+ { "from": "pay", "to": "api", "label": "Approved or declined", "reply": true },
276
+ { "from": "api", "to": "app", "label": "Order number", "reply": true } ],
277
+ "repeats": [ { "from": 2, "to": 3, "label": "Up to 3 tries" } ] }
278
+ ```
279
+
280
+ More than 5 participants or 12 steps is two figures: the whole run with minor parts merged into one participant, then
281
+ the stage that needs detail. Leave out steps that only log or record what happened unless the question is about them.
282
+
283
+ Flows (boxes and arrows) have no spec: write them in HTML, below. A flow says what connects to what; when the order
284
+ things happen in is the point, use `sequence`.
187
285
 
188
286
  - `title` (required) is the point, 14 words at most. `note` is the reading line under it; `source` a footer.
189
287
  - `"style": "report"` draws a light chart in one sans font, for a chart that sits in a page: a research report, a doc.
@@ -320,7 +418,8 @@ A few numbers compared:
320
418
  ```
321
419
 
322
420
  Grouped bars, several lines, small multiples and heat maps have specs (`groups`, `lines`, `panels`, `heatmap`); use
323
- them when the chart is the whole figure. Anything else (dots, intervals, distributions, a chart beside boxes) is
421
+ them when the chart is the whole figure. Dots, ranges, scatters and small counts have specs too
422
+ (`dots`, `scatter`, ranges on `bars` and `series`, `units`). Anything else (a distribution of thousands, a chart beside boxes) is
324
423
  Observable Plot, already loaded with d3:
325
424
 
326
425
  ```html
package/README.md CHANGED
@@ -16,7 +16,7 @@ Node 20 or later, and a Chromium-based browser: figkit uses Chrome or Edge if on
16
16
 
17
17
  ```
18
18
  npm install @liggi/figkit
19
- npx figkit guide # prints GUIDE.md, the rules for writing figures
19
+ npx @liggi/figkit guide # prints GUIDE.md, the rules for writing figures
20
20
  ```
21
21
 
22
22
  ## A figure from HTML
@@ -39,7 +39,7 @@ Save this as `order.html`:
39
39
  ```
40
40
 
41
41
  ```
42
- npx figkit order.html --out order.png
42
+ npx @liggi/figkit order.html --out order.png
43
43
  ```
44
44
 
45
45
  ![An order is checked before it is charged: Basket, then Fraud check, then Charge the card](docs/readme-flow.png)
@@ -47,7 +47,7 @@ npx figkit order.html --out order.png
47
47
  ## A chart from a spec
48
48
 
49
49
  For one chart of a known type (`split`, `bars`, `before-after`, `dumbbell`, `series`, `groups`, `lines`, `panels`,
50
- `heatmap`), or an `entities` picture of records and the references between them, give the data and figkit does the
50
+ `heatmap`, `dots`, `scatter`, `units`), or an `entities` picture of records and the references between them, give the data and figkit does the
51
51
  rest, including the layout. Add `"style": "report"` for a light chart that sits in a page. Save this as `checkout.json`:
52
52
 
53
53
  ```json
@@ -59,7 +59,7 @@ rest, including the layout. Add `"style": "report"` for a light chart that sits
59
59
  ```
60
60
 
61
61
  ```
62
- npx figkit checkout.json --out checkout.png
62
+ npx @liggi/figkit checkout.json --out checkout.png
63
63
  ```
64
64
 
65
65
  ![Bar chart: the current checkout takes 86 seconds, the new one 41](docs/readme-bars.png)
package/bin/fig.mjs CHANGED
@@ -46,7 +46,7 @@ if (/\.json$/i.test(src)) {
46
46
  console.log(`This chart spec has ${spec.problems.length} problem${spec.problems.length === 1 ? '' : 's'}:\n${spec.problems.map((p) => ` - ${p}`).join('\n')}`);
47
47
  process.exit(1);
48
48
  }
49
- const cls = [spec.spec.type === 'entities' ? 'entities-fig' : 'chart', spec.spec.type === 'panels' ? 'panels-fig' : '', spec.spec.style ?? ''].filter(Boolean).join(' ');
49
+ const cls = [spec.spec.type === 'entities' ? 'entities-fig' : spec.spec.type === 'sequence' ? 'sequence-fig' : 'chart', spec.spec.type === 'panels' ? 'panels-fig' : '', spec.spec.style ?? ''].filter(Boolean).join(' ');
50
50
  body = `<main class="fig ${cls}"><script type="application/json" id="figkit-spec">${JSON.stringify(spec.spec).replace(/</g, '\\u003c')}</script></main>`;
51
51
  } else if (!/class="[^"]*\bfig\b/.test(body)) body = `<main class="fig">\n${body}\n</main>`;
52
52
  await ensureBuilt();
@@ -101,7 +101,7 @@ try {
101
101
  if (phone) await page.addInitScript(() => { window.__figPhone = true; });
102
102
  await page.goto(pathToFileURL(wrapped).href);
103
103
  await page.waitForFunction('window.__figDone === true', null, { timeout: 30000 });
104
- const r = await page.evaluate(() => ({ problems: window.fig.problems, info: window.fig.info, sides: !!document.querySelector('.fig .sides, .fig .entities, .fig .panels, .fig.report'), entities: !!document.querySelector('.fig .entities'), panels: !!document.querySelector('.fig .panels'), stack: !!document.querySelector('.fig .sides') }));
104
+ const r = await page.evaluate(() => ({ problems: window.fig.problems, info: window.fig.info, sides: !!document.querySelector('.fig .sides, .fig .entities, .fig .sequence, .fig .panels, .fig.report'), entities: !!document.querySelector('.fig .entities'), sequence: !!document.querySelector('.fig .sequence'), panels: !!document.querySelector('.fig .panels'), stack: !!document.querySelector('.fig .sides') }));
105
105
  await page.locator('.fig').first().screenshot({ path: shot });
106
106
  await page.close();
107
107
  return { ...r, problems: [...errors.map((e) => `Script error: ${e}`), ...r.problems] };
@@ -116,7 +116,7 @@ try {
116
116
 
117
117
  const { info } = desk;
118
118
  const lines = [`Figure: ${png} (${info.width * 2}×${info.height * 2})`];
119
- if (mob) lines.push(`Phone layout: ${phonePng} (${mob.info.width * 2}×${mob.info.height * 2}), ${desk.entities ? 'the records in two columns' : desk.panels ? 'the panels stacked' : desk.stack ? 'the sides stacked' : 'at phone width'}, for a viewer to show on a phone`);
119
+ if (mob) lines.push(`Phone layout: ${phonePng} (${mob.info.width * 2}×${mob.info.height * 2}), ${desk.entities ? 'the records in two columns' : desk.sequence ? 'the participants narrower' : desk.panels ? 'the panels stacked' : desk.stack ? 'the sides stacked' : 'at phone width'}, for a viewer to show on a phone`);
120
120
  lines.push(`Phone inline: ${inline} (preview only)`);
121
121
  const ph = (mob ?? desk).info.phone;
122
122
  const sizes = [['the title', ph?.title], ['headings', ph?.heading], ['body', ph?.body]].filter(([, v]) => v != null).map(([k, v]) => `${k} ${v}px`);