@liggi/figkit 0.3.0 → 0.5.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 +194 -8
- package/README.md +4 -4
- package/bin/fig.mjs +3 -3
- package/dist/viz.iife.js +81 -18
- package/examples/charts/bars-range.json +6 -0
- package/examples/charts/bars-ref.json +6 -0
- package/examples/charts/dots.json +6 -0
- package/examples/charts/estimates.json +7 -0
- package/examples/charts/heatmap-signed.json +5 -0
- package/examples/charts/scatter.json +6 -0
- package/examples/charts/sequence.json +67 -0
- package/examples/charts/series-range.json +6 -0
- package/examples/charts/states.json +71 -0
- package/examples/charts/units.json +4 -0
- package/package.json +1 -1
- package/runtime/fig.css +95 -0
- package/runtime/fig.js +80 -3
- package/viz/build.mjs +1 -1
- package/viz/draw.js +437 -25
- package/viz/sequence-spec.js +60 -0
- package/viz/sequence.js +171 -0
- package/viz/spec.js +201 -10
- package/viz/states-spec.js +161 -0
- package/viz/states.js +203 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{ "type": "bars", "style": "report", "title": "The new reply check catches more, but the ranges still overlap",
|
|
2
|
+
"note": "Share of unsafe replies caught, on the same test set", "unit": "%", "max": 100,
|
|
3
|
+
"range": "likely range (95%)", "each": "replies",
|
|
4
|
+
"bars": [ { "label": "Current check", "value": 64, "low": 53, "high": 74, "n": 72 },
|
|
5
|
+
{ "label": "New check", "value": 78, "low": 67, "high": 86, "n": 72, "tone": "accent" } ],
|
|
6
|
+
"source": "Invented example data" }
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{ "type": "bars", "title": "Two of the four new layouts beat the current page",
|
|
2
|
+
"note": "Share of visitors who finished checkout", "unit": "%",
|
|
3
|
+
"ref": { "label": "Current page", "value": 61 },
|
|
4
|
+
"bars": [ { "label": "One-page form", "value": 68 }, { "label": "Saved cards first", "value": 64 },
|
|
5
|
+
{ "label": "Address lookup", "value": 60 }, { "label": "Guest by default", "value": 57 } ],
|
|
6
|
+
"source": "Invented data, for illustration." }
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{ "type": "dots", "style": "report", "title": "Most builds finish in four minutes; a few take over ten",
|
|
2
|
+
"note": "One dot per build: minutes from push to result", "unit": " min", "summary": "median", "zero": true, "each": "builds",
|
|
3
|
+
"groups": [ { "label": "Main branch", "values": [3.8,4.1,3.6,4.4,3.9,4.0,4.2,3.7,4.6,3.9,4.3,11.8,4.0,3.5,4.1,4.8,3.9,4.2,12.6,4.0,3.8,4.4,4.1,3.7,4.5,3.9,10.4,4.2] },
|
|
4
|
+
{ "label": "Pull requests", "values": [4.9,5.2,4.6,5.8,5.1,4.8,6.2,5.0,5.5,4.7,5.3,5.9,5.0,4.9,6.4,5.2,5.6,4.8,5.1,5.4], "tone": "accent" } ],
|
|
5
|
+
"mark": { "group": "Main branch", "value": 12.6, "label": "12.6 min, cache miss" },
|
|
6
|
+
"source": "Invented example data" }
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{ "type": "estimates", "title": "Free delivery lifts orders in two regions; the north's range includes zero",
|
|
2
|
+
"note": "Change in orders per 100 visitors, free delivery minus paid", "range": "95% interval",
|
|
3
|
+
"sides": { "below": "Paid better", "above": "Free better" },
|
|
4
|
+
"rows": [ { "label": "South", "value": 2.4, "low": 1.1, "high": 3.7 },
|
|
5
|
+
{ "label": "Midlands", "value": 1.8, "low": 0.6, "high": 3.0 },
|
|
6
|
+
{ "label": "North", "value": 0.7, "low": -0.9, "high": 2.3 } ],
|
|
7
|
+
"source": "Invented data, for illustration." }
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{ "type": "heatmap", "title": "The new menu helped every region except the north",
|
|
2
|
+
"note": "Change in weekly orders per store after the new menu", "unit": "%",
|
|
3
|
+
"rows": ["South", "Midlands", "North"], "columns": ["Wk 1", "Wk 2", "Wk 3", "Wk 4"],
|
|
4
|
+
"values": [ [3.1, 4.4, 5.0, 4.8], [1.2, 2.6, 3.3, 3.9], [-2.4, -3.8, -1.9, -2.7] ],
|
|
5
|
+
"source": "Invented data, for illustration." }
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{ "type": "scatter", "style": "report", "title": "Bigger pull requests wait longer for review",
|
|
2
|
+
"note": "One dot per merged pull request", "x": { "label": "Lines changed", "log": true }, "y": { "label": "Time to first review", "time": "hours", "zero": true },
|
|
3
|
+
"points": [ {"x":12,"y":1.5},{"x":30,"y":2.1},{"x":8,"y":0.6},{"x":45,"y":3.2},{"x":120,"y":5.5},{"x":260,"y":9.1},{"x":75,"y":2.8},
|
|
4
|
+
{"x":18,"y":1.1},{"x":540,"y":22.0,"label":"Search rewrite","tone":"accent"},{"x":95,"y":6.0},{"x":160,"y":4.2},{"x":22,"y":3.9},
|
|
5
|
+
{"x":64,"y":2.2},{"x":310,"y":12.5},{"x":40,"y":1.8},{"x":5,"y":0.4},{"x":210,"y":7.7},{"x":880,"y":30.5},{"x":15,"y":0.9},{"x":130,"y":3.6} ],
|
|
6
|
+
"source": "Invented example data" }
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "sequence",
|
|
3
|
+
"title": "The shopper has an order number before the receipt is sent",
|
|
4
|
+
"participants": [
|
|
5
|
+
{
|
|
6
|
+
"id": "app",
|
|
7
|
+
"name": "Shop app",
|
|
8
|
+
"kind": "browser"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"id": "api",
|
|
12
|
+
"name": "Checkout API"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "pay",
|
|
16
|
+
"name": "Card processor"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "mail",
|
|
20
|
+
"name": "Email queue"
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"messages": [
|
|
24
|
+
{
|
|
25
|
+
"from": "app",
|
|
26
|
+
"to": "api",
|
|
27
|
+
"label": "Place order"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"from": "api",
|
|
31
|
+
"to": "api",
|
|
32
|
+
"label": "Check stock and price"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"from": "api",
|
|
36
|
+
"to": "pay",
|
|
37
|
+
"label": "Charge the card",
|
|
38
|
+
"tone": "accent"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"from": "pay",
|
|
42
|
+
"to": "api",
|
|
43
|
+
"label": "Approved or declined",
|
|
44
|
+
"reply": true,
|
|
45
|
+
"tone": "accent"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"from": "api",
|
|
49
|
+
"to": "app",
|
|
50
|
+
"label": "Order number",
|
|
51
|
+
"reply": true
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"from": "api",
|
|
55
|
+
"to": "mail",
|
|
56
|
+
"label": "Send the receipt",
|
|
57
|
+
"note": "within a minute"
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
"repeats": [
|
|
61
|
+
{
|
|
62
|
+
"from": 3,
|
|
63
|
+
"to": 4,
|
|
64
|
+
"label": "Up to 3 tries"
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{ "type": "series", "style": "report", "title": "The weekly pass rate dipped in week 6, beyond its usual range",
|
|
2
|
+
"note": "Share of test runs that passed, each week", "unit": "%", "zero": false, "range": "likely range (95%)",
|
|
3
|
+
"points": [ {"x":1,"y":91,"low":86,"high":94}, {"x":2,"y":93,"low":88,"high":96}, {"x":3,"y":90,"low":85,"high":94},
|
|
4
|
+
{"x":4,"y":92,"low":87,"high":95}, {"x":5,"y":91,"low":86,"high":95}, {"x":6,"y":79,"low":72,"high":85},
|
|
5
|
+
{"x":7,"y":89,"low":84,"high":93}, {"x":8,"y":92,"low":87,"high":95} ],
|
|
6
|
+
"mark": { "x": 6, "label": "79%, week 6" }, "source": "Invented example data" }
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "states",
|
|
3
|
+
"title": "An order waits after a declined card until a new card is added",
|
|
4
|
+
"entry": "Shopper checks out",
|
|
5
|
+
"states": [
|
|
6
|
+
{
|
|
7
|
+
"id": "waiting",
|
|
8
|
+
"name": "Awaiting payment",
|
|
9
|
+
"start": true
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"id": "declined",
|
|
13
|
+
"name": "Card declined",
|
|
14
|
+
"note": "Shopper emailed",
|
|
15
|
+
"tone": "accent"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "paid",
|
|
19
|
+
"name": "Paid"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"id": "packed",
|
|
23
|
+
"name": "Packed"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "shipped",
|
|
27
|
+
"name": "Shipped",
|
|
28
|
+
"end": true
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "cancelled",
|
|
32
|
+
"name": "Cancelled",
|
|
33
|
+
"end": true
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"moves": [
|
|
37
|
+
{
|
|
38
|
+
"from": "waiting",
|
|
39
|
+
"to": "paid",
|
|
40
|
+
"label": "Charge succeeds"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"from": "waiting",
|
|
44
|
+
"to": "declined",
|
|
45
|
+
"label": "Charge fails"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"from": "declined",
|
|
49
|
+
"to": "waiting",
|
|
50
|
+
"label": "New card added"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"from": "paid",
|
|
54
|
+
"to": "packed",
|
|
55
|
+
"label": "Warehouse picks"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"from": "packed",
|
|
59
|
+
"to": "shipped",
|
|
60
|
+
"label": "Carrier collects"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"from": [
|
|
64
|
+
"paid",
|
|
65
|
+
"packed"
|
|
66
|
+
],
|
|
67
|
+
"to": "cancelled",
|
|
68
|
+
"label": "Shopper cancels"
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
{ "type": "units", "style": "report", "title": "11 of the 29 flagged days were real problems",
|
|
2
|
+
"note": "One square per day the monitor flagged in October", "total": 29,
|
|
3
|
+
"parts": [ { "label": "real problems", "count": 11 } ], "rest": "false alarms",
|
|
4
|
+
"source": "Invented example data" }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liggi/figkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Explanatory figures for agents and people: write a little HTML or a chart spec, get a laid-out PNG and a list of what is wrong with it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/runtime/fig.css
CHANGED
|
@@ -218,3 +218,98 @@ fig-bars .fb-sub { font-weight: 400; color: var(--soft); font-size: var(--small)
|
|
|
218
218
|
.fig .hm-key { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; font-size: var(--small); color: var(--muted); margin: 0 0 12px; }
|
|
219
219
|
.fig .hm-key i { display: inline-block; width: 160px; height: 14px; border-radius: 3px; }
|
|
220
220
|
.fig.phone .hm-key i { width: 84px; }
|
|
221
|
+
|
|
222
|
+
/* ---------- sequence: who calls whom, in order (a spec of type "sequence") ---------- */
|
|
223
|
+
.fig .sequence { position: relative; }
|
|
224
|
+
.fig .sequence > svg { position: absolute; left: 0; top: 0; overflow: visible; }
|
|
225
|
+
.fig .seq-lanes { position: relative; }
|
|
226
|
+
.fig .seq-who { position: absolute; }
|
|
227
|
+
.fig .seq-who { text-align: center; align-items: center; justify-content: center; }
|
|
228
|
+
.fig.phone .seq-who { padding: 10px 8px; }
|
|
229
|
+
.fig.phone .seq-who > h3 { font-size: var(--body); }
|
|
230
|
+
/* The paper shows only behind the words themselves, line by line, so a line passing a label stops just at its text. */
|
|
231
|
+
.fig .seq-msg { position: absolute; box-sizing: border-box; width: max-content; display: flex; gap: 10px; align-items: baseline; padding: 1px 6px; }
|
|
232
|
+
.fig .seq-ink { background: var(--paper); box-decoration-break: clone; -webkit-box-decoration-break: clone; padding: 1px 0; }
|
|
233
|
+
.fig .seq-n { font-size: var(--small); font-variant-numeric: tabular-nums; color: var(--soft); flex: 0 0 auto; min-width: 2ch; text-align: right; }
|
|
234
|
+
.fig .seq-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
235
|
+
.fig .seq-note { font-size: var(--small); color: var(--muted); }
|
|
236
|
+
.fig .seq-msg.reply .seq-text { color: var(--muted); }
|
|
237
|
+
.fig .seq-msg.accent .seq-text, .fig .seq-msg.accent .seq-n { color: var(--accent); }
|
|
238
|
+
.fig .seq-msg.accent .seq-text { font-weight: 600; }
|
|
239
|
+
.fig .seq-msg.accent .seq-note { font-weight: 400; }
|
|
240
|
+
.fig .seq-frame-label { position: absolute; white-space: nowrap; background: var(--paper); padding: 0 8px; font-size: var(--small); font-weight: 600; color: var(--muted); }
|
|
241
|
+
.fig .seq-lanes.tight .seq-who { padding: 8px 4px; }
|
|
242
|
+
.fig .seq-lanes.tight .seq-who > h3 { font-size: var(--small); }
|
|
243
|
+
|
|
244
|
+
/* ranges: what they are, said once under the reading line; on bars a line with end caps across the bar's end */
|
|
245
|
+
.fig .range-key { display: flex; flex-wrap: wrap; column-gap: 28px; row-gap: 6px; margin: 2px 0 0; font-size: var(--small); color: var(--muted); }
|
|
246
|
+
.fig .range-key > span { display: inline-flex; align-items: center; gap: 10px; }
|
|
247
|
+
.fig .range-key i { flex: 0 0 auto; }
|
|
248
|
+
.fig .range-key i.dash { width: 34px; height: 0; border-top: 2px dashed var(--ink); }
|
|
249
|
+
.fig .range-key i.whisker { position: relative; width: 34px; height: 14px; border-left: 2px solid var(--ink); border-right: 2px solid var(--ink); }
|
|
250
|
+
.fig .range-key i.whisker::after { content: ''; position: absolute; left: 0; right: 0; top: 6px; border-top: 2px solid var(--ink); }
|
|
251
|
+
.fig .range-key i.median { width: 34px; height: 4px; border-radius: 2px; }
|
|
252
|
+
.fig .range-key i.band { width: 34px; height: 14px; border-radius: 2px; background: var(--fill); opacity: .45; }
|
|
253
|
+
fig-bars .fb-track { position: relative; }
|
|
254
|
+
fig-bars .fb-range { --tone: var(--ink); position: absolute; top: 50%; height: 12px; margin-top: -6px; border-left: 2px solid var(--tone); border-right: 2px solid var(--tone); pointer-events: none; }
|
|
255
|
+
fig-bars .fb-range::after { content: ''; position: absolute; left: 0; right: 0; top: 4.5px; border-top: 3px solid var(--tone); }
|
|
256
|
+
fig-bars .fb-bar.dim { opacity: .4; }
|
|
257
|
+
fig-bars .fb-val .fb-sub + .fb-sub { margin-left: 10px; }
|
|
258
|
+
fig-bars .fb-label .fb-n { display: block; font-size: var(--small); color: var(--soft); }
|
|
259
|
+
|
|
260
|
+
/* dots: the count of a single group, above its dots */
|
|
261
|
+
.fig .dots-n { margin: 0 0 4px; font-size: var(--small); color: var(--muted); }
|
|
262
|
+
|
|
263
|
+
/* units: one square per thing counted */
|
|
264
|
+
.fig .units { display: flex; flex-direction: column; gap: 22px; }
|
|
265
|
+
.fig .un-grid { display: grid; }
|
|
266
|
+
.fig .un-grid i { width: var(--un); height: var(--un); border-radius: 4px; }
|
|
267
|
+
.fig .un-grid i.empty, .fig .un-key i.empty { background: var(--hair); }
|
|
268
|
+
.fig .un-key { display: flex; flex-wrap: wrap; column-gap: 28px; row-gap: 8px; font-size: var(--body); color: var(--muted); }
|
|
269
|
+
.fig .un-key span { display: inline-flex; align-items: center; gap: 8px; }
|
|
270
|
+
.fig .un-key i { width: 18px; height: 18px; border-radius: 3px; display: inline-block; }
|
|
271
|
+
.fig .un-key b { color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
272
|
+
|
|
273
|
+
/* bars on a phone: the name and count above each bar, so the bar and its range get the full width */
|
|
274
|
+
.fig.phone fig-bars { grid-template-columns: minmax(0, 1fr); row-gap: 4px; }
|
|
275
|
+
.fig.phone fig-bars .fb-label { margin-top: 10px; }
|
|
276
|
+
.fig.phone fig-bars .fb-label .fb-n { display: inline; margin-left: 8px; }
|
|
277
|
+
|
|
278
|
+
/* estimates: signed values with ranges around a zero line; what each side of zero means under the axis */
|
|
279
|
+
.fig .estimates { position: relative; }
|
|
280
|
+
|
|
281
|
+
.fig .range-key i.est-dot { width: 14px; height: 14px; border-radius: 50%; border: 2.5px solid; box-sizing: border-box; }
|
|
282
|
+
.fig .est-sides { position: relative; height: calc(var(--small) * 1.4); margin-top: -6px; font-size: var(--small); color: var(--muted); }
|
|
283
|
+
.fig .est-sides span { position: absolute; top: 0; white-space: nowrap; }
|
|
284
|
+
|
|
285
|
+
/* bars with a reference: its own first row in the second colour, and a dashed line from its end through the rest */
|
|
286
|
+
fig-bars.has-ref { position: relative; }
|
|
287
|
+
fig-bars .fb-label.ref { color: var(--second); }
|
|
288
|
+
fig-bars .fb-refline { position: absolute; width: 0; border-left: 2px dashed var(--second); pointer-events: none; }
|
|
289
|
+
.fig .range-key i.swatch { width: 18px; height: 14px; border-radius: 2px; background: var(--accent); }
|
|
290
|
+
fig-bars.has-ref .fb-label.ref + .fb-track .fb-bar { opacity: .55; }
|
|
291
|
+
/* on a phone the names sit above the bars, so the reference line stops at each name rather than crossing it */
|
|
292
|
+
.fig.phone fig-bars.has-ref .fb-label { position: relative; z-index: 1; justify-self: start; background: var(--paper); padding-right: 8px; }
|
|
293
|
+
.fig .hm-key i.hm-signed { position: relative; margin-bottom: calc(var(--small) * 1.3); }
|
|
294
|
+
.fig .hm-key i.hm-signed b { font-style: normal; position: absolute; top: 100%; transform: translateX(-50%); font-weight: 400; color: var(--muted); padding-top: 2px; line-height: 1; }
|
|
295
|
+
.fig .hm-key i.hm-signed b::before { content: ''; position: absolute; left: 50%; top: -14px; height: 14px; border-left: 2px solid var(--paper); }
|
|
296
|
+
fig-bars.has-ref .fb-val:not(.lead) { color: var(--muted); }
|
|
297
|
+
/* Against a reference, a bar that clears it is solid accent (a faded orange reads as brown on dark); its range is drawn in ink on top. */
|
|
298
|
+
fig-bars.has-ref .fb-bar.dim.accent { opacity: 1; }
|
|
299
|
+
fig-bars.has-ref .fb-range.accent { --tone: var(--ink); }
|
|
300
|
+
|
|
301
|
+
/* ---------- states: what one thing moves through (a spec of type "states") ---------- */
|
|
302
|
+
.fig .states { display: grid; column-gap: 120px; row-gap: 76px; align-items: stretch; justify-content: start; }
|
|
303
|
+
.fig.phone .states { column-gap: 64px; row-gap: 68px; }
|
|
304
|
+
.fig.phone .state { padding: 12px 10px; }
|
|
305
|
+
.fig.phone .state > h3 { font-size: var(--body); }
|
|
306
|
+
.fig .state { text-align: center; align-items: center; justify-content: center; justify-self: stretch; }
|
|
307
|
+
.fig .state > p { margin-top: 4px; }
|
|
308
|
+
.fig .state > .st-stayed { color: var(--ink); }
|
|
309
|
+
.fig .node.bare.st-dot { width: 22px; height: 22px; padding: 0; border-radius: 50%; background: var(--line); justify-self: center; align-self: center; }
|
|
310
|
+
.fig .fig-wire-label .fig-wire-sub { display: block; font-weight: 400; font-variant-numeric: tabular-nums; }
|
|
311
|
+
.fig .st-key { font-size: var(--small); }
|
|
312
|
+
.fig .state.mid { align-self: center; }
|
|
313
|
+
/* the bracket beside the states that share one move, which leads to it with one arrow */
|
|
314
|
+
.fig .node.bare.st-bracket { justify-self: end; width: 14px; margin-right: -30px; padding: 0; border: 1.5px solid var(--line); border-left: 0; border-radius: 0 4px 4px 0; background: none; }
|
|
315
|
+
.fig.phone .node.bare.st-bracket { margin-right: -24px; width: 10px; }
|
package/runtime/fig.js
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
function obstacles() {
|
|
43
43
|
const out = [];
|
|
44
44
|
const solid = [...root().querySelectorAll(SOLID)].filter(visible);
|
|
45
|
-
for (const el of solid) if (!el.classList.contains('bare')) out.push({ el, r: rectOf(el) });
|
|
45
|
+
for (const el of solid) if (!el.classList.contains('bare') || el.classList.contains('st-bracket')) out.push({ el, r: rectOf(el) });
|
|
46
46
|
const walker = document.createTreeWalker(root(), NodeFilter.SHOW_TEXT);
|
|
47
47
|
const seen = new Set();
|
|
48
48
|
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
@@ -370,7 +370,7 @@
|
|
|
370
370
|
fig.wires.push({ from, to, pts, mode, ref });
|
|
371
371
|
|
|
372
372
|
const text = ref ? '' : tag.getAttribute('label') ?? tag.textContent.trim();
|
|
373
|
-
if (text) labels.push({ text, tone, color, pts, at: labelAt, want: tag.getAttribute('label-at'), from, to });
|
|
373
|
+
if (text) labels.push({ text, sub: tag.getAttribute('sub'), tone, color, pts, at: labelAt, want: tag.getAttribute('label-at'), from, to });
|
|
374
374
|
}
|
|
375
375
|
return placeLabels(labels, obs);
|
|
376
376
|
}
|
|
@@ -467,6 +467,8 @@
|
|
|
467
467
|
const lab = document.createElement('div');
|
|
468
468
|
lab.className = 'fig-wire-label';
|
|
469
469
|
lab.textContent = L.text;
|
|
470
|
+
// Further lines under the label, such as a count: one span each.
|
|
471
|
+
for (const t of (L.sub ?? '').split('\n').filter(Boolean)) { const e = document.createElement('span'); e.className = 'fig-wire-sub'; e.textContent = t; lab.appendChild(e); }
|
|
470
472
|
if (L.tone !== 'line') lab.style.color = L.color;
|
|
471
473
|
lab.dataset.arrow = `${L.from} → ${L.to}`;
|
|
472
474
|
R.appendChild(lab);
|
|
@@ -553,10 +555,66 @@
|
|
|
553
555
|
});
|
|
554
556
|
|
|
555
557
|
// ---------- checks ----------
|
|
558
|
+
/** The colour as [r, g, b, a] (0–255, alpha 0–1), or null when it is not a plain colour (none, a gradient, a pattern). */
|
|
559
|
+
function rgba(c) {
|
|
560
|
+
const m = /^rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)$/.exec((c || '').trim());
|
|
561
|
+
if (m) return [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : +m[4]];
|
|
562
|
+
const s = /^color\(srgb ([\d.e-]+) ([\d.e-]+) ([\d.e-]+)(?: \/ ([\d.]+))?\)$/.exec((c || '').trim());
|
|
563
|
+
return s ? [+s[1] * 255, +s[2] * 255, +s[3] * 255, s[4] === undefined ? 1 : +s[4]] : null;
|
|
564
|
+
}
|
|
565
|
+
const lumOf = ([r, g, b]) => [r, g, b].map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }).reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i], 0);
|
|
566
|
+
const over = (top, under) => [0, 1, 2].map((i) => top[i] * top[3] + under[i] * (1 - top[3])).concat(1);
|
|
567
|
+
function opacityOf(el) { let o = 1; for (let x = el; x && x !== document.documentElement; x = x.parentElement) o *= parseFloat(getComputedStyle(x).opacity); return o; }
|
|
568
|
+
/** The colour an element paints over its own area, with its opacity, or undefined when it paints nothing there. */
|
|
569
|
+
function paintOf(el) {
|
|
570
|
+
const cs = getComputedStyle(el);
|
|
571
|
+
if (el instanceof SVGElement) {
|
|
572
|
+
if (!/^(rect|path|circle|ellipse|polygon)$/.test(el.tagName)) return undefined;
|
|
573
|
+
if (cs.fill === 'none') return undefined;
|
|
574
|
+
const c = rgba(cs.fill);
|
|
575
|
+
return c ? [c[0], c[1], c[2], c[3] * parseFloat(cs.fillOpacity) * opacityOf(el)] : null;
|
|
576
|
+
}
|
|
577
|
+
if (cs.backgroundImage !== 'none') return null;
|
|
578
|
+
const c = rgba(cs.backgroundColor);
|
|
579
|
+
if (!c) return null;
|
|
580
|
+
return c[3] === 0 ? undefined : [c[0], c[1], c[2], c[3] * opacityOf(el)];
|
|
581
|
+
}
|
|
582
|
+
/** Contrast between a text element and what lies under its middle, as a WCAG ratio; null when it cannot be told. */
|
|
583
|
+
function contrastOf(el) {
|
|
584
|
+
const b = el.getBoundingClientRect();
|
|
585
|
+
if (b.top < 0 || b.bottom > innerHeight || b.left < 0 || b.right > innerWidth) el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
586
|
+
const r = el.tagName === 'text' ? el.getBoundingClientRect() : (() => { const t = textRect(el), o = R0(); return { x: t.x + o.x, y: t.y + o.y, width: t.w, height: t.h }; })();
|
|
587
|
+
const stack = document.elementsFromPoint(r.x + r.width / 2, r.y + r.height / 2);
|
|
588
|
+
let i = stack.findIndex((x) => x === el || x.contains(el));
|
|
589
|
+
if (i < 0) return null;
|
|
590
|
+
if (el.tagName === 'text') i++;
|
|
591
|
+
const layers = [];
|
|
592
|
+
for (; i < stack.length; i++) {
|
|
593
|
+
const p = paintOf(stack[i]);
|
|
594
|
+
if (p === null) return null;
|
|
595
|
+
if (p === undefined) continue;
|
|
596
|
+
layers.push(p);
|
|
597
|
+
if (p[3] >= 0.99) break;
|
|
598
|
+
}
|
|
599
|
+
let bg = [255, 255, 255, 1];
|
|
600
|
+
for (const l of layers.reverse()) bg = over(l, bg);
|
|
601
|
+
const cs = getComputedStyle(el);
|
|
602
|
+
const fg = rgba(el.tagName === 'text' ? cs.fill : cs.color);
|
|
603
|
+
if (!fg) return null;
|
|
604
|
+
const ink = over([fg[0], fg[1], fg[2], fg[3] * opacityOf(el)], bg);
|
|
605
|
+
const la = lumOf(ink), lb = lumOf(bg);
|
|
606
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
607
|
+
}
|
|
608
|
+
const R0 = () => { const b = root().getBoundingClientRect(); return { x: b.x, y: b.y }; };
|
|
609
|
+
|
|
556
610
|
function check() {
|
|
557
611
|
const R = root(), P = fig.problems;
|
|
558
612
|
const W = R.offsetWidth, H = R.offsetHeight;
|
|
559
613
|
const detail = R.classList.contains('detail');
|
|
614
|
+
// A phone layout is 560px wide (fig.css); opening gaps for labels can widen it past that.
|
|
615
|
+
const phoneW = R.classList.contains('phone') ? 560 : 0;
|
|
616
|
+
const wide = Math.max(W, R.scrollWidth, R.getBoundingClientRect().width);
|
|
617
|
+
if (phoneW && wide > phoneW + 1) P.push(`The figure is ${Math.round(wide)}px wide on a phone, wider than ${phoneW}px; shorten the widest labels.`);
|
|
560
618
|
const minText = detail ? 13 : 15;
|
|
561
619
|
const obs = obstacles();
|
|
562
620
|
|
|
@@ -567,6 +625,22 @@
|
|
|
567
625
|
for (const b of marks.slice(i + 1)) if (overlap(a.r, b.r) && !a.e.contains(b.e) && !b.e.contains(a.e)) P.push(`Chart labels overlap: "${a.e.textContent.trim()}" and "${b.e.textContent.trim()}".`);
|
|
568
626
|
}
|
|
569
627
|
|
|
628
|
+
// Chart labels over dots: a label drawn across a dot hides the value it sits on.
|
|
629
|
+
const dots = [...R.querySelectorAll('.chart svg circle')].filter(visible).map(rectOf).map((r) => grow(r, -1.5));
|
|
630
|
+
for (const a of marks) {
|
|
631
|
+
const hit = dots.filter((d) => overlap(grow(a.r, -1), d)).length;
|
|
632
|
+
if (hit) P.push(`Chart label covers ${hit === 1 ? 'a dot' : hit + ' dots'}: "${a.e.textContent.trim()}".`);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Text that cannot be read against what is drawn behind it, such as white numbers on a pale cell.
|
|
636
|
+
for (const e of [...R.querySelectorAll('*')].filter((x) => !x.closest('svg') || x.tagName === 'text')) {
|
|
637
|
+
if (e.tagName !== 'text' && ![...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) continue;
|
|
638
|
+
if (!e.textContent.trim() || !visible(e) || e.matches('script, style')) continue;
|
|
639
|
+
const ratio = contrastOf(e);
|
|
640
|
+
if (ratio !== null && ratio < 3) P.push(`Text cannot be read against what is behind it: "${e.textContent.trim().replace(/\s+/g, ' ')}" (contrast ${ratio.toFixed(1)} to 1; it needs 3).`);
|
|
641
|
+
}
|
|
642
|
+
window.scrollTo(0, 0);
|
|
643
|
+
|
|
570
644
|
// Text: too small, cut off, or past its box.
|
|
571
645
|
const small = [];
|
|
572
646
|
for (const o of obs.flatMap((b) => (b.text ? [b.el] : [...b.el.querySelectorAll('*')].filter((e) => [...e.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()))))) {
|
|
@@ -619,6 +693,7 @@
|
|
|
619
693
|
const crossings = new Set(crossingPairs().map(([u, w]) => `${u.from} → ${u.to} and ${w.from} → ${w.to}`));
|
|
620
694
|
if (crossings.size) P.push(R.querySelector('.entities')
|
|
621
695
|
? `References cross: ${[...crossings].join('; ')}. Split the records over two figures; do not leave a reference out.`
|
|
696
|
+
: R.querySelector('.states') ? `Moves cross: ${[...crossings].join('; ')}. figkit could not lay them out apart; draw the side branch as a second figure.`
|
|
622
697
|
: `Arrows cross: ${[...crossings].join('; ')}. Reorder the boxes so they do not, or drop an arrow.`);
|
|
623
698
|
|
|
624
699
|
// Neighbours that almost line up.
|
|
@@ -640,7 +715,9 @@
|
|
|
640
715
|
const outside = R.querySelector('.panels') ? [...R.children].filter((c) => !c.matches('script, style') && !c.querySelector('.panels') && !c.matches('.panels')).map((c) => c.innerText).join(' ') : null;
|
|
641
716
|
const words = (panelText !== null ? outside + ' ' + panelText : R.innerText).split(/\s+/).filter(Boolean).length;
|
|
642
717
|
const nodes = R.querySelectorAll('.node:not(.bare), .decision').length, arrows = (fig.wires ?? []).length;
|
|
643
|
-
|
|
718
|
+
// A states picture reads down one path of short names, so it holds up to 7 states (see the states spec).
|
|
719
|
+
const states = !!R.querySelector('.states');
|
|
720
|
+
const budget = detail ? { words: 190, nodes: 18, arrows: 18 } : states ? { words: 90, nodes: 7, arrows: 12 } : { words: 70, nodes: 5, arrows: 8 };
|
|
644
721
|
if (words > budget.words) P.push(`${words} words; a ${detail ? 'detail' : 'glance'} figure holds about ${budget.words}. Cut sentences that explain, and keep labels.`);
|
|
645
722
|
if (nodes > budget.nodes) P.push(`${nodes} boxes; more than ${budget.nodes} stops reading as one picture. ${detail ? 'Split it into two figures or merge boxes.' : 'Split it into two or three figures in a row (what exists, one case through it, what changes), or merge boxes.'}`);
|
|
646
723
|
if (arrows > budget.arrows) P.push(`${arrows} arrows; more than ${budget.arrows} tangles. Show the main path and drop the rest.`);
|
package/viz/build.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
|
|
6
6
|
const HOME = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
7
|
-
const SOURCES = ['viz
|
|
7
|
+
const SOURCES = [...fs.readdirSync(path.join(HOME, 'viz')).filter((f) => f.endsWith('.js')).map((f) => 'viz/' + f), 'package.json'].map((f) => path.join(HOME, f));
|
|
8
8
|
const OUT = path.join(HOME, 'dist/viz.iife.js');
|
|
9
9
|
|
|
10
10
|
const mtime = (f) => (fs.existsSync(f) ? fs.statSync(f).mtimeMs : 0);
|