@liggi/figkit 0.1.0 → 0.3.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
@@ -28,6 +28,11 @@ 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 are the entities and how do they relate?" is an `entities` spec (below), not boxes and arrows in HTML. It
32
+ draws references: which record holds a pointer to which. The order work happens in (approve, then label, then
33
+ train) is not a reference; it goes in prose, a list or a separate flow. A record that creates others still points
34
+ at them: draw the pointers it holds, not the steps it runs. Draw every pointer a record holds, even when another
35
+ line reaches the same record by a longer way; leaving one out says the column is not there.
31
36
  - Each figure is a plain `fig` with at most 5 boxes and no `group`. More than that is two or three figures in a row,
32
37
  not one map: what exists, then one case through it, then what changes. A process or machine the code runs in is a
33
38
  `tag` on the box, not a box around it. Use `fig detail` only when the reader asked for the whole map.
@@ -39,9 +44,9 @@ what?", "walk me through it") is answered with a picture first, not with numbere
39
44
  three arrows; one shared copy is one box fanning out to the three. "How many" must be visible without reading.
40
45
  - Name each thing once, and use that name in the title, the boxes and your prose.
41
46
 
42
- ## Charts from a spec
47
+ ## Charts and entities from a spec
43
48
 
44
- When the whole figure is one chart of a type below, write a JSON spec instead of HTML. You give the data; figkit
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
45
50
  does the layout, colours and labels. Use HTML (the rest of this guide) for anything else, including a chart that sits
46
51
  beside boxes or arrows.
47
52
 
@@ -100,9 +105,90 @@ mark's `x` must be one of them. The y axis starts at zero unless `"zero": false`
100
105
  "mark": { "x": "2026-10-14", "label": "212, launch day" } }
101
106
  ```
102
107
 
108
+ `groups`: a few things measured for each of 2 to 6 groups, 2 to 4 series, one bar per series in each group, all
109
+ from zero. Use it when the point is a difference that shows in some groups and not others (before and after, one
110
+ segment against another); for one number per thing, use `bars`. Each group gets a heading and each bar its series
111
+ name and number, so there is no legend and no axis. `accent` is required: `{ "series" }` colours that series in every
112
+ group, `{ "series", "group" }` colours one bar; `"tone": "bad"` or `"good"` when the bar is a result.
113
+
114
+ ```json
115
+ { "type": "groups", "title": "Only the new carrier got slower in December",
116
+ "note": "Median days from dispatch to doorstep", "unit": " days",
117
+ "series": ["Old carrier", "New carrier"],
118
+ "groups": [ { "label": "November", "values": [2.2, 2.1] }, { "label": "December", "values": [2.4, 3.6] } ],
119
+ "accent": { "series": "New carrier", "group": "December", "tone": "bad" } }
120
+ ```
121
+
122
+ `lines`: 2 or 3 lines on one axis, each named at its right end in its own colour. `accent` (required) names the
123
+ line the title is about; the others are grey. `mark` labels one point on the accent line. `xTicks` sets the axis
124
+ labels, e.g. hours as 12am, 6am, 12pm, 6pm, rather than 0 to 23. More than 3 lines is `panels`.
125
+
126
+ ```json
127
+ { "type": "lines", "title": "Weekday orders peak at noon", "note": "Orders per hour",
128
+ "series": [ { "label": "Weekdays", "points": [ { "x": 0, "y": 3 }, { "x": 12, "y": 30 }, { "x": 23, "y": 4 } ] },
129
+ { "label": "Weekends", "points": [ { "x": 0, "y": 4 }, { "x": 12, "y": 22 }, { "x": 23, "y": 6 } ] } ],
130
+ "accent": "Weekdays", "mark": { "x": 12, "label": "30 at noon" },
131
+ "xTicks": [ { "x": 0, "label": "12am" }, { "x": 12, "label": "12pm" } ] }
132
+ ```
133
+
134
+ `panels`: small multiples, the same `groups` or `lines` chart drawn for 2 to 4 parts side by side, on one shared
135
+ scale so the panels compare; stacked on a phone. `chart` says which. For `groups`, `series` and `accent` are given once
136
+ at the top and each panel has its `groups`; for `lines`, `accent` is the line's label and every panel has the same
137
+ lines in the same order. Use it when one chart would need more than 3 lines or 4 series, or the parts measure
138
+ different things on the same scale.
139
+
140
+ ```json
141
+ { "type": "panels", "chart": "groups", "title": "The cheaper plan answers almost as fast as the premium one",
142
+ "note": "Share of tickets answered within an hour", "unit": "%", "max": 100,
143
+ "series": ["Basic", "Standard", "Premium"], "accent": { "series": "Standard" },
144
+ "panels": [ { "label": "Email", "groups": [ { "label": "Weekdays", "values": [61, 84, 89] } ] },
145
+ { "label": "Chat", "groups": [ { "label": "Weekdays", "values": [72, 91, 94] } ] } ] }
146
+ ```
147
+
148
+ `heatmap`: a grid of values over two dimensions (day by hour, team by week), coloured from pale to dark blue: darker
149
+ is always more. Up to 12 `rows` and 31 `columns`; `values` is one array per row. `note` (required) says what the
150
+ colour counts, and the scale is drawn above the grid. Here the colour is the measure, so the one-accent rule moves:
151
+ `mark` outlines the one cell to notice in the accent colour and names it beside the scale. Cells show their numbers
152
+ when there is room. Columns label themselves at even steps, so give hours as "12am", "1am", … and they read 12am,
153
+ 6am, 12pm, 6pm.
154
+
155
+ ```json
156
+ { "type": "heatmap", "title": "The help desk is busiest midweek mornings", "note": "Tickets opened per hour",
157
+ "rows": ["Mon", "Tue", "Wed"], "columns": ["9am", "10am", "11am", "12pm"],
158
+ "values": [ [22, 25, 27, 28], [23, 26, 29, 30], [24, 27, 30, 32] ],
159
+ "mark": { "row": "Wed", "column": "12pm", "label": "Busiest: Wed noon" } }
160
+ ```
161
+
162
+ `entities`: what the things are and which points at which. 2 to 5 `records`, each with an `id` you use in `refs`,
163
+ 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
+ needs it). Each of up to 8 `refs` is a pointer the `from` record holds to the `to` record: `count` is how many `from`
165
+ records can point at one `to` (`"many"`, the default, or `"one"`), and `"optional": true` when the pointer may be
166
+ empty. figkit places the records (what is pointed at sits above what points at it) and draws each reference as a line
167
+ joined at the records' names: a dot on the record named, and on the holder a crow's foot when many can point at one or
168
+ a short bar when only one can; dashed when it may be empty; never an arrow, never a key. It draws a two-column layout
169
+ for a phone. Every record must be in a reference. Accent at most one record: the one the title is about. The title
170
+ claims only what the lines show; if it says "everything names X", every record has a line to X. Names and fields are
171
+ in plain words, not in-house terms or button labels.
172
+
173
+ ```json
174
+ { "type": "entities", "title": "Every order line names one product and one order",
175
+ "records": [ { "id": "order", "name": "Order", "fields": ["placed at"] },
176
+ { "id": "line", "name": "Order line", "fields": ["quantity"], "tone": "accent" },
177
+ { "id": "product", "name": "Product", "fields": ["sku"] },
178
+ { "id": "payment", "name": "Payment" } ],
179
+ "refs": [ { "from": "line", "to": "order" },
180
+ { "from": "line", "to": "product" },
181
+ { "from": "payment", "to": "order", "count": "one", "optional": true } ] }
182
+ ```
183
+
184
+ More than 5 records is two figures: the core records, then one corner of it with the core record it hangs off.
185
+
103
186
  Flows (boxes and arrows) have no spec: write them in HTML, below.
104
187
 
105
188
  - `title` (required) is the point, 14 words at most. `note` is the reading line under it; `source` a footer.
189
+ - `"style": "report"` draws a light chart in one sans font, for a chart that sits in a page: a research report, a doc.
190
+ It also draws a phone layout, `chart.phone.png`. Leave it out for the default dark figure for a dark chat.
191
+ - Keep `note` to what is measured, and "lower is better" where it applies; dates and method go in `source`.
106
192
  - `unit` is printed straight after each number. `show` on an item replaces its printed value, e.g. `"11 of 29"`.
107
193
  - `tone` is `accent` for what to notice, `good` or `bad` when it is a result; at most two tones in a chart. Leave the rest grey.
108
194
  - `bars` takes `max` to fix the end of the axis (e.g. 100 for percentages) and a short `note` per bar.
@@ -112,7 +198,7 @@ Flows (boxes and arrows) have no spec: write them in HTML, below.
112
198
  ## The page
113
199
 
114
200
  ```html
115
- <main class="fig"> <!-- add "detail" for a map to open and read; "paper" for a light figure for docs -->
201
+ <main class="fig"> <!-- add "detail" for a map to open and read; "paper" for a light figure for docs; "report" for a light chart in a page, in one sans font -->
116
202
  <header>
117
203
  <h1>The point, stated. <em>The part to notice</em></h1>
118
204
  <p class="read">How to read it. <b class="t-accent">Orange</b> means code.</p>
@@ -123,7 +209,7 @@ Flows (boxes and arrows) have no spec: write them in HTML, below.
123
209
  ```
124
210
 
125
211
  - In the `<h1>`, `<em>` turns the words accent-coloured italic and `<br>` sets a line break.
126
- - `fig` is a glance figure, 960 wide: one point, big type, reads inline on a phone. At most 8 boxes, 8 arrows and 70 words.
212
+ - `fig` is a glance figure, 960 wide: one point, big type, reads inline on a phone. At most 5 boxes, 8 arrows and 70 words.
127
213
  - `fig detail` is 1360 wide, for a flow or map the reader asked for and opens full screen; to explain a system, use
128
214
  several `fig` figures instead (see Explaining a system). At most 18 boxes, 18 arrows and 190 words.
129
215
  Widen it with `style="--w: 1560px"` only when a row will not fit.
@@ -196,7 +282,8 @@ never nudge with margins.
196
282
 
197
283
  Put them anywhere inside the figure; `from` and `to` are element ids. Any element can be an end: a node, a group, a
198
284
  `mark` on a screenshot, a `<span>` of text. Arrows keep clear of boxes and loose text, and arrows sharing an end and
199
- a colour share a trunk.
285
+ a colour share a trunk. When a label has no free place, figkit opens the gap between the two boxes (or widens the
286
+ figure a little) and draws again; keep labels to a few words so it rarely has to.
200
287
 
201
288
  - colour: `accent`, `second`, `good`, `bad`; `dashed`; `weight="3"`; `both` for two heads; `head="none"`
202
289
  - `blocked`: a route that does not exist. The line stops short of the target at a cross, with no arrowhead
@@ -232,7 +319,9 @@ A few numbers compared:
232
319
  </fig-bars>
233
320
  ```
234
321
 
235
- Anything else (lines, dots, intervals, distributions, small multiples) is Observable Plot, already loaded with d3:
322
+ 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
324
+ Observable Plot, already loaded with d3:
236
325
 
237
326
  ```html
238
327
  <div id="chart"></div>
@@ -249,7 +338,7 @@ Anything else (lines, dots, intervals, distributions, small multiples) is Observ
249
338
  </script>
250
339
  ```
251
340
 
252
- `theme` has `ink`, `muted`, `soft`, `line`, `accent`, `second`, `good`, `bad`. Draw the series in `soft`, the thing to
341
+ `theme` has `ink`, `muted`, `soft`, `fill`, `fill2`, `line`, `accent`, `second`, `good`, `bad`. Draw the series in `fill`, the thing to
253
342
  notice in `accent`, and label it directly on the chart; no legends. Start bar axes at zero.
254
343
 
255
344
  ## Screenshots
package/README.md CHANGED
@@ -46,8 +46,9 @@ npx figkit order.html --out order.png
46
46
 
47
47
  ## A chart from a spec
48
48
 
49
- For one chart of a known type (`split`, `bars`, `before-after`, `dumbbell`, `series`) give the data and figkit does
50
- the rest. Save this as `checkout.json`:
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
51
+ rest, including the layout. Add `"style": "report"` for a light chart that sits in a page. Save this as `checkout.json`:
51
52
 
52
53
  ```json
53
54
  { "type": "bars", "title": "The new checkout halves the time to pay",
package/bin/fig.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  import fs from 'node:fs';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
+ import { createRequire } from 'node:module';
10
11
  import { fileURLToPath, pathToFileURL } from 'node:url';
11
12
  import { chromium } from 'playwright-core';
12
13
  import { ensureBuilt } from '../viz/build.mjs';
@@ -14,6 +15,12 @@ import { parseSpec } from '../viz/spec.js';
14
15
 
15
16
  const HOME = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
16
17
  const url = (...p) => pathToFileURL(path.join(HOME, ...p)).href;
18
+ /** A dependency's file, wherever npm put the package: figkit's own node_modules, or one hoisted above it. */
19
+ const dep = (name, ...p) => {
20
+ const dir = createRequire(import.meta.url).resolve.paths(name).map((d) => path.join(d, name)).find((d) => fs.existsSync(d));
21
+ if (!dir) { console.error(`figkit: cannot find ${name}; run npm install in the figkit folder.`); process.exit(1); }
22
+ return pathToFileURL(path.join(dir, ...p)).href;
23
+ };
17
24
  const args = process.argv.slice(2);
18
25
 
19
26
  if (args.length === 0 || args[0] === '-h' || args[0] === '--help') {
@@ -39,18 +46,19 @@ if (/\.json$/i.test(src)) {
39
46
  console.log(`This chart spec has ${spec.problems.length} problem${spec.problems.length === 1 ? '' : 's'}:\n${spec.problems.map((p) => ` - ${p}`).join('\n')}`);
40
47
  process.exit(1);
41
48
  }
42
- body = `<main class="fig chart"><script type="application/json" id="figkit-spec">${JSON.stringify(spec.spec).replace(/</g, '\\u003c')}</script></main>`;
49
+ const cls = [spec.spec.type === 'entities' ? 'entities-fig' : 'chart', spec.spec.type === 'panels' ? 'panels-fig' : '', spec.spec.style ?? ''].filter(Boolean).join(' ');
50
+ body = `<main class="fig ${cls}"><script type="application/json" id="figkit-spec">${JSON.stringify(spec.spec).replace(/</g, '\\u003c')}</script></main>`;
43
51
  } else if (!/class="[^"]*\bfig\b/.test(body)) body = `<main class="fig">\n${body}\n</main>`;
44
52
  await ensureBuilt();
45
53
 
46
54
  const fonts = ['newsreader/400.css', 'newsreader/400-italic.css', 'geist-sans/400.css', 'geist-sans/500.css', 'geist-sans/600.css', 'geist-mono/400.css', 'geist-mono/500.css']
47
- .map((f) => `<link rel="stylesheet" href="${url('node_modules/@fontsource', f)}">`).join('\n');
55
+ .map((f) => `<link rel="stylesheet" href="${dep('@fontsource/' + f.split('/')[0], f.split('/')[1])}">`).join('\n');
48
56
  const page_html = `<!doctype html><html><head><meta charset="utf-8">
49
57
  <base href="${pathToFileURL(path.dirname(src) + path.sep).href}">
50
58
  ${fonts}
51
59
  <link rel="stylesheet" href="${url('runtime/fig.css')}">
52
- <script src="${url('node_modules/d3/dist/d3.min.js')}"></script>
53
- <script src="${url('node_modules/@observablehq/plot/dist/plot.umd.min.js')}"></script>
60
+ <script src="${dep('d3', 'dist/d3.min.js')}"></script>
61
+ <script src="${dep('@observablehq/plot', 'dist/plot.umd.min.js')}"></script>
54
62
  <script src="${url('dist/viz.iife.js')}"></script>
55
63
  <script src="${url('runtime/fig.js')}"></script>
56
64
  <style>body { display: inline-block; }</style>
@@ -93,7 +101,7 @@ try {
93
101
  if (phone) await page.addInitScript(() => { window.__figPhone = true; });
94
102
  await page.goto(pathToFileURL(wrapped).href);
95
103
  await page.waitForFunction('window.__figDone === true', null, { timeout: 30000 });
96
- const r = await page.evaluate(() => ({ problems: window.fig.problems, info: window.fig.info, sides: !!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 .panels, .fig.report'), entities: !!document.querySelector('.fig .entities'), panels: !!document.querySelector('.fig .panels'), stack: !!document.querySelector('.fig .sides') }));
97
105
  await page.locator('.fig').first().screenshot({ path: shot });
98
106
  await page.close();
99
107
  return { ...r, problems: [...errors.map((e) => `Script error: ${e}`), ...r.problems] };
@@ -108,7 +116,7 @@ try {
108
116
 
109
117
  const { info } = desk;
110
118
  const lines = [`Figure: ${png} (${info.width * 2}×${info.height * 2})`];
111
- if (mob) lines.push(`Phone layout: ${phonePng} (${mob.info.width * 2}×${mob.info.height * 2}), the sides stacked, 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.panels ? 'the panels stacked' : desk.stack ? 'the sides stacked' : 'at phone width'}, for a viewer to show on a phone`);
112
120
  lines.push(`Phone inline: ${inline} (preview only)`);
113
121
  const ph = (mob ?? desk).info.phone;
114
122
  const sizes = [['the title', ph?.title], ['headings', ph?.heading], ['body', ph?.body]].filter(([, v]) => v != null).map(([k, v]) => `${k} ${v}px`);