@liggi/figkit 0.5.0 → 0.6.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 +61 -6
- package/THIRD_PARTY_NOTICES.md +48 -1
- package/bin/fig.mjs +5 -3
- package/dist/viz.iife.js +47 -42
- package/examples/charts/map.json +269 -0
- package/package.json +2 -1
- package/runtime/fig.css +17 -0
- package/runtime/fig.js +146 -18
- package/viz/draw.js +13 -0
- package/viz/map-spec.js +121 -0
- package/viz/map.js +513 -0
- package/viz/spec.js +8 -3
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "map",
|
|
3
|
+
"title": "Every grocery order passes through the order service before stores pick or vans deliver",
|
|
4
|
+
"subsystems": [
|
|
5
|
+
{
|
|
6
|
+
"id": "shop",
|
|
7
|
+
"name": "Storefront",
|
|
8
|
+
"kind": "web and mobile"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"id": "orders",
|
|
12
|
+
"name": "Orders",
|
|
13
|
+
"kind": "orders team"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "stores",
|
|
17
|
+
"name": "Store fulfilment",
|
|
18
|
+
"kind": "stores team"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "delivery",
|
|
22
|
+
"name": "Delivery",
|
|
23
|
+
"kind": "logistics team"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "outside",
|
|
27
|
+
"name": "Outside services"
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"parts": [
|
|
31
|
+
{
|
|
32
|
+
"id": "web",
|
|
33
|
+
"name": "Shop website",
|
|
34
|
+
"kind": "Next.js app",
|
|
35
|
+
"in": "shop"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": "mobile",
|
|
39
|
+
"name": "Shop app",
|
|
40
|
+
"kind": "iOS and Android",
|
|
41
|
+
"in": "shop"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"id": "gateway",
|
|
45
|
+
"name": "API gateway",
|
|
46
|
+
"kind": "Envoy",
|
|
47
|
+
"in": "shop"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"id": "search",
|
|
51
|
+
"name": "Product search",
|
|
52
|
+
"kind": "OpenSearch",
|
|
53
|
+
"in": "shop"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"id": "basket",
|
|
57
|
+
"name": "Basket service",
|
|
58
|
+
"kind": "Go service",
|
|
59
|
+
"in": "shop"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"id": "order",
|
|
63
|
+
"name": "Order service",
|
|
64
|
+
"kind": "Go service",
|
|
65
|
+
"in": "orders",
|
|
66
|
+
"tone": "accent"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"id": "orderdb",
|
|
70
|
+
"name": "Orders database",
|
|
71
|
+
"kind": "Postgres",
|
|
72
|
+
"in": "orders"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "payment",
|
|
76
|
+
"name": "Payment service",
|
|
77
|
+
"kind": "Go service",
|
|
78
|
+
"in": "orders"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"id": "fraud",
|
|
82
|
+
"name": "Fraud check",
|
|
83
|
+
"kind": "rules engine",
|
|
84
|
+
"in": "orders"
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"id": "events",
|
|
88
|
+
"name": "Order events",
|
|
89
|
+
"kind": "Kafka topic",
|
|
90
|
+
"in": "orders"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"id": "planner",
|
|
94
|
+
"name": "Picking planner",
|
|
95
|
+
"kind": "nightly batch",
|
|
96
|
+
"in": "stores"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"id": "stock",
|
|
100
|
+
"name": "Store stock",
|
|
101
|
+
"kind": "per-store counts",
|
|
102
|
+
"in": "stores"
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"id": "picker",
|
|
106
|
+
"name": "Picker app",
|
|
107
|
+
"kind": "handheld scanner",
|
|
108
|
+
"in": "stores"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"id": "subs",
|
|
112
|
+
"name": "Substitution rules",
|
|
113
|
+
"kind": "YAML in repo",
|
|
114
|
+
"in": "stores"
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"id": "slots",
|
|
118
|
+
"name": "Van slots",
|
|
119
|
+
"kind": "1-hour windows",
|
|
120
|
+
"in": "delivery"
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"id": "routes",
|
|
124
|
+
"name": "Route planner",
|
|
125
|
+
"kind": "OR-Tools job",
|
|
126
|
+
"in": "delivery"
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"id": "driver",
|
|
130
|
+
"name": "Driver app",
|
|
131
|
+
"kind": "Android",
|
|
132
|
+
"in": "delivery"
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"id": "tracking",
|
|
136
|
+
"name": "Live tracking",
|
|
137
|
+
"kind": "WebSocket service",
|
|
138
|
+
"in": "delivery"
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"id": "cards",
|
|
142
|
+
"name": "Card processor",
|
|
143
|
+
"kind": "Stripe",
|
|
144
|
+
"in": "outside"
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"id": "sms",
|
|
148
|
+
"name": "Text messages",
|
|
149
|
+
"kind": "Twilio",
|
|
150
|
+
"in": "outside"
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"id": "maps",
|
|
154
|
+
"name": "Road map data",
|
|
155
|
+
"kind": "Mapbox",
|
|
156
|
+
"in": "outside"
|
|
157
|
+
}
|
|
158
|
+
],
|
|
159
|
+
"links": [
|
|
160
|
+
{
|
|
161
|
+
"from": "web",
|
|
162
|
+
"to": "gateway",
|
|
163
|
+
"tone": "accent"
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"from": "mobile",
|
|
167
|
+
"to": "gateway",
|
|
168
|
+
"tone": "accent"
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
"from": "gateway",
|
|
172
|
+
"to": "search",
|
|
173
|
+
"label": "queries"
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
"from": "gateway",
|
|
177
|
+
"to": "basket",
|
|
178
|
+
"tone": "accent"
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"from": "search",
|
|
182
|
+
"to": "stock"
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
"from": "basket",
|
|
186
|
+
"to": "slots",
|
|
187
|
+
"label": "books a slot"
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
"from": "basket",
|
|
191
|
+
"to": "order",
|
|
192
|
+
"label": "checkout",
|
|
193
|
+
"tone": "accent"
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
"from": "order",
|
|
197
|
+
"to": "payment",
|
|
198
|
+
"label": "charge"
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
"from": "payment",
|
|
202
|
+
"to": "fraud"
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
"from": "payment",
|
|
206
|
+
"to": "cards"
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
"from": "order",
|
|
210
|
+
"to": "orderdb",
|
|
211
|
+
"label": "writes"
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
"from": "order",
|
|
215
|
+
"to": "events",
|
|
216
|
+
"label": "publishes",
|
|
217
|
+
"tone": "accent"
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
"from": "events",
|
|
221
|
+
"to": "planner",
|
|
222
|
+
"tone": "accent",
|
|
223
|
+
"label": "new order"
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
"from": "events",
|
|
227
|
+
"to": "routes",
|
|
228
|
+
"tone": "accent",
|
|
229
|
+
"label": "new order"
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
"from": "planner",
|
|
233
|
+
"to": "picker",
|
|
234
|
+
"label": "pick lists"
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
"from": "picker",
|
|
238
|
+
"to": "stock",
|
|
239
|
+
"label": "takes off stock"
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
"from": "picker",
|
|
243
|
+
"to": "subs"
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
"from": "routes",
|
|
247
|
+
"to": "maps"
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
"from": "routes",
|
|
251
|
+
"to": "driver",
|
|
252
|
+
"label": "route stops"
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
"from": "driver",
|
|
256
|
+
"to": "tracking",
|
|
257
|
+
"label": "location"
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
"from": "tracking",
|
|
261
|
+
"to": "sms",
|
|
262
|
+
"label": "ETA texts"
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
"from": "slots",
|
|
266
|
+
"to": "routes"
|
|
267
|
+
}
|
|
268
|
+
]
|
|
269
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liggi/figkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"agents"
|
|
35
35
|
],
|
|
36
36
|
"dependencies": {
|
|
37
|
+
"@dagrejs/dagre": "^3.1.3",
|
|
37
38
|
"@fontsource/geist-mono": "^5.3.0",
|
|
38
39
|
"@fontsource/geist-sans": "^5.3.0",
|
|
39
40
|
"@fontsource/newsreader": "^5.3.0",
|
package/runtime/fig.css
CHANGED
|
@@ -313,3 +313,20 @@ fig-bars.has-ref .fb-range.accent { --tone: var(--ink); }
|
|
|
313
313
|
/* the bracket beside the states that share one move, which leads to it with one arrow */
|
|
314
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
315
|
.fig.phone .node.bare.st-bracket { margin-right: -24px; width: 10px; }
|
|
316
|
+
|
|
317
|
+
/* ---------- map: a whole system, parts in subsystem regions (a spec of type "map"), laid out by dagre and packed by figkit ---------- */
|
|
318
|
+
.fig .map { position: relative; }
|
|
319
|
+
.fig .map > .map-part, .fig .map-region > .map-part { position: absolute; max-width: 260px; min-width: 120px; padding: 10px 14px; gap: 2px; }
|
|
320
|
+
.fig .map-part > h3 { font-size: var(--body); line-height: 1.2; }
|
|
321
|
+
.fig .map-part > p { font-size: var(--small); white-space: nowrap; }
|
|
322
|
+
.fig .map-region { position: absolute; display: block; padding: 0; border: 0; border-radius: 14px; background: color-mix(in srgb, var(--ink) 4%, var(--paper)); box-shadow: inset 0 0 0 1px var(--hair); }
|
|
323
|
+
.fig .map-region > .mp-head { position: absolute; top: 14px; left: 20px; display: flex; flex-direction: column; gap: 2px; white-space: nowrap; }
|
|
324
|
+
.fig .map-region > .mp-head > h2 { font-family: var(--serif); font-size: calc(var(--h) * 1.35); font-weight: 400; line-height: 1.1; color: var(--ink); }
|
|
325
|
+
.fig .map > svg.map-wires { position: absolute; inset: 0; pointer-events: none; overflow: visible; }
|
|
326
|
+
.fig .map .map-label { padding: 0 4px; line-height: 1.25; }
|
|
327
|
+
.fig .map-overview > .map-sub { max-width: 230px; min-width: 150px; }
|
|
328
|
+
.fig .map-sub > ul { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 1px; }
|
|
329
|
+
.fig .map-sub > ul li { font-size: var(--small); color: var(--muted); }
|
|
330
|
+
.fig .map-sub > .mp-count { color: var(--soft); }
|
|
331
|
+
/* A map's link label outlined, so a line visibly runs into it rather than stopping short of a floating word. */
|
|
332
|
+
.fig:has(.map) .fig-wire-label { box-shadow: inset 0 0 0 1px var(--hair); }
|
package/runtime/fig.js
CHANGED
|
@@ -107,6 +107,11 @@
|
|
|
107
107
|
function routeOrtho(A, B, obs, arrow, used, W, H) {
|
|
108
108
|
const hard = obs.map((o) => grow(o.r, 3)), soft = obs.map((o) => grow(o.r, M - 1));
|
|
109
109
|
const others = obs.filter((o) => o.el !== A.el && o.el !== B.el).map((o) => grow(o.r, 3));
|
|
110
|
+
// On a map, a line running along a region's border reads as part of the border, so a step beside one costs more.
|
|
111
|
+
const borders = [...root().querySelectorAll('.map-region')].map(rectOf);
|
|
112
|
+
const hugs = (x1, y1, x2, y2) => borders.some((r) => (y1 === y2
|
|
113
|
+
? Math.min(Math.abs(y1 - r.y), Math.abs(y1 - r.y - r.h)) < 16 && Math.max(x1, x2) > r.x && Math.min(x1, x2) < r.x + r.w
|
|
114
|
+
: Math.min(Math.abs(x1 - r.x), Math.abs(x1 - r.x - r.w)) < 16 && Math.max(y1, y2) > r.y && Math.min(y1, y2) < r.y + r.h));
|
|
110
115
|
const clear = (x1, y1, x2, y2) => {
|
|
111
116
|
const seg = { x: Math.min(x1, x2) - 0.5, y: Math.min(y1, y2) - 0.5, w: Math.abs(x2 - x1) + 1, h: Math.abs(y2 - y1) + 1 };
|
|
112
117
|
return !others.some((r) => Math.min(seg.x + seg.w, r.x + r.w) > Math.max(seg.x, r.x) && Math.min(seg.y + seg.h, r.y + r.h) > Math.max(seg.y, r.y));
|
|
@@ -144,7 +149,8 @@
|
|
|
144
149
|
}
|
|
145
150
|
add(xs, 14, 'edge'); add(xs, W - 14, 'edge'); add(ys, 14, 'edge'); add(ys, H - 14, 'edge');
|
|
146
151
|
const pad = parseFloat(getComputedStyle(root()).paddingLeft) || 0;
|
|
147
|
-
|
|
152
|
+
// A map's regions start at the padding, so a line there would run along their edges.
|
|
153
|
+
if (pad > 14 && !root().querySelector('.map')) { add(xs, pad, 'mid'); add(xs, W - pad, 'mid'); }
|
|
148
154
|
for (const p of [...pa, ...pb]) { add(xs, p.sx, 'mid'); add(ys, p.sy, 'mid'); add(xs, p.x, 'mid'); add(ys, p.y, 'mid'); }
|
|
149
155
|
const X = [...xs.keys()].sort((a, b) => a - b), Y = [...ys.keys()].sort((a, b) => a - b);
|
|
150
156
|
const NX = X.length, NY = Y.length;
|
|
@@ -156,6 +162,8 @@
|
|
|
156
162
|
for (const r of soft) if (inside(r, x, y)) return 5;
|
|
157
163
|
return 1;
|
|
158
164
|
};
|
|
165
|
+
// On a map, lines run a long way side by side between regions; closer than 24px apart they cannot be told apart.
|
|
166
|
+
const near = borders.length ? 24 : 10;
|
|
159
167
|
const usedCost = (x1, y1, x2, y2) => {
|
|
160
168
|
let k = 1, plus = 0;
|
|
161
169
|
const horiz = y1 === y2;
|
|
@@ -163,17 +171,19 @@
|
|
|
163
171
|
const uh = u.y1 === u.y2;
|
|
164
172
|
if (uh === horiz) {
|
|
165
173
|
const off = horiz ? Math.abs(u.y1 - y1) : Math.abs(u.x1 - x1);
|
|
166
|
-
if (off >=
|
|
174
|
+
if (off >= near) continue;
|
|
167
175
|
const lo = horiz ? Math.max(Math.min(x1, x2), Math.min(u.x1, u.x2)) : Math.max(Math.min(y1, y2), Math.min(u.y1, u.y2));
|
|
168
176
|
const hi = horiz ? Math.min(Math.max(x1, x2), Math.max(u.x1, u.x2)) : Math.min(Math.max(y1, y2), Math.max(u.y1, u.y2));
|
|
169
|
-
|
|
170
|
-
if (
|
|
177
|
+
// Carrying straight on where another line stops reads as that line going on; for the highlighted path, as the path going somewhere it doesn't.
|
|
178
|
+
if (hi - lo <= 0.5) { if ((arrow.ref || (borders.length && u.tone === 'accent' && arrow.tone !== 'accent')) && off < 0.5 && hi - lo > -160 && !bus(u)) plus += 400; continue; }
|
|
179
|
+
if (off < 0.5 && bus(u)) k = Math.min(k, 0.85); else k = Math.max(k, arrow.ref || (borders.length && u.tone === 'accent' && arrow.tone !== 'accent') ? 12 : 4);
|
|
171
180
|
} else if (!bus(u)) {
|
|
172
181
|
const cx = horiz ? u.x1 : x1, cy = horiz ? y1 : u.y1;
|
|
173
182
|
// Strictly inside this step, or at its far end: two lines that meet on a grid point cross there too.
|
|
174
183
|
const onMe = horiz ? (cx > Math.min(x1, x2) && cx < Math.max(x1, x2)) || cx === x2 : (cy > Math.min(y1, y2) && cy < Math.max(y1, y2)) || cy === y2;
|
|
175
184
|
const onU = horiz ? cy > Math.min(u.y1, u.y2) && cy < Math.max(u.y1, u.y2) : cx > Math.min(u.x1, u.x2) && cx < Math.max(u.x1, u.x2);
|
|
176
|
-
|
|
185
|
+
// On a map, a grey line crossing the highlighted path breaks the run the reader follows, so it goes round.
|
|
186
|
+
if (onMe && onU) plus += arrow.ref ? 1500 : borders.length && u.tone === 'accent' && arrow.tone !== 'accent' ? 900 : 70;
|
|
177
187
|
}
|
|
178
188
|
}
|
|
179
189
|
return [k, plus];
|
|
@@ -210,8 +220,9 @@
|
|
|
210
220
|
const jx = ix + VEC[nd][0], jy = iy + VEC[nd][1];
|
|
211
221
|
if (jx < 0 || jy < 0 || jx >= NX || jy >= NY) continue;
|
|
212
222
|
const x1 = X[ix], y1 = Y[iy], x2 = X[jx], y2 = Y[jy];
|
|
213
|
-
|
|
223
|
+
let m = mult((x1 + x2) / 2, (y1 + y2) / 2);
|
|
214
224
|
if (m === Infinity) continue;
|
|
225
|
+
if (borders.length && hugs(x1, y1, x2, y2)) m *= 3;
|
|
215
226
|
const [k, plus] = usedCost(x1, y1, x2, y2);
|
|
216
227
|
const len = Math.abs(x2 - x1) + Math.abs(y2 - y1);
|
|
217
228
|
const cost = c + len * m * k * (VEC[nd][0] === 0 ? kx[ix] : ky[iy]) + plus + (nd === d ? 0 : BEND);
|
|
@@ -367,7 +378,7 @@
|
|
|
367
378
|
const attrs = { d: pathD, fill: 'none', stroke: color, 'stroke-width': weight, 'stroke-linecap': 'round' };
|
|
368
379
|
if (dash) attrs['stroke-dasharray'] = dash;
|
|
369
380
|
svgEl('path', attrs, svg);
|
|
370
|
-
fig.wires.push({ from, to, pts, mode, ref });
|
|
381
|
+
fig.wires.push({ from, to, pts, mode, ref, tone });
|
|
371
382
|
|
|
372
383
|
const text = ref ? '' : tag.getAttribute('label') ?? tag.textContent.trim();
|
|
373
384
|
if (text) labels.push({ text, sub: tag.getAttribute('sub'), tone, color, pts, at: labelAt, want: tag.getAttribute('label-at'), from, to });
|
|
@@ -376,6 +387,49 @@
|
|
|
376
387
|
}
|
|
377
388
|
|
|
378
389
|
/** Pairs of drawn lines that cross. Arrows from one box, or into one box, share a trunk, so meeting is not crossing. */
|
|
390
|
+
/** Pairs of lines that run along each other on the same track, so the eye cannot tell which goes where. */
|
|
391
|
+
function overlapPairs() {
|
|
392
|
+
const segs = (fig.wires ?? []).filter((w) => w.mode !== 'curve').flatMap((w) => w.pts.slice(1).map((b, i) => ({ w, a: w.pts[i], b })));
|
|
393
|
+
const out = [];
|
|
394
|
+
for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) {
|
|
395
|
+
const s = segs[i], t = segs[j];
|
|
396
|
+
if (s.w === t.w || s.w.from === t.w.from || s.w.to === t.w.to) continue;
|
|
397
|
+
const sh = s.a[1] === s.b[1], th = t.a[1] === t.b[1];
|
|
398
|
+
if (sh !== th) continue;
|
|
399
|
+
const k = sh ? 1 : 0, q = 1 - k;
|
|
400
|
+
if (Math.abs(s.a[k] - t.a[k]) >= 3) continue;
|
|
401
|
+
const lo = Math.max(Math.min(s.a[q], s.b[q]), Math.min(t.a[q], t.b[q])), hi = Math.min(Math.max(s.a[q], s.b[q]), Math.max(t.a[q], t.b[q]));
|
|
402
|
+
if (hi - lo > 4 && !out.some(([u, w]) => u === s.w && w === t.w)) out.push([s.w, t.w]);
|
|
403
|
+
}
|
|
404
|
+
return out;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* On a map, how the highlighted path reads: grey links crossing it, and each run along it (from a highlighted link
|
|
409
|
+
* no other highlighted link feeds, to one that feeds none) that doubles back on itself, left then right or down then up.
|
|
410
|
+
*/
|
|
411
|
+
function accentStats() {
|
|
412
|
+
const ws = (fig.wires ?? []).filter((w) => w.mode !== 'curve'), hot = ws.filter((w) => w.tone === 'accent');
|
|
413
|
+
const crossed = crossingPairs().filter(([u, w]) => (u.tone === 'accent') !== (w.tone === 'accent'));
|
|
414
|
+
const runs = [];
|
|
415
|
+
const walk = (w, path) => {
|
|
416
|
+
const next = hot.filter((n) => n.from === w.to && !path.includes(n));
|
|
417
|
+
if (!next.length) runs.push([...path, w]);
|
|
418
|
+
for (const n of next) walk(n, [...path, w]);
|
|
419
|
+
};
|
|
420
|
+
for (const w of hot) if (!hot.some((u) => u.to === w.from)) walk(w, []);
|
|
421
|
+
const back = runs.filter((run) => {
|
|
422
|
+
const seen = new Set();
|
|
423
|
+
for (const w of run) for (let i = 1; i < w.pts.length; i++) {
|
|
424
|
+
const dx = w.pts[i][0] - w.pts[i - 1][0], dy = w.pts[i][1] - w.pts[i - 1][1];
|
|
425
|
+
if (Math.abs(dx) >= 40) seen.add(dx > 0 ? 'r' : 'l');
|
|
426
|
+
if (Math.abs(dy) >= 40) seen.add(dy > 0 ? 'd' : 'u');
|
|
427
|
+
}
|
|
428
|
+
return (seen.has('r') && seen.has('l')) || (seen.has('d') && seen.has('u'));
|
|
429
|
+
});
|
|
430
|
+
return { crossed, back, runs, any: hot.length > 0 };
|
|
431
|
+
}
|
|
432
|
+
|
|
379
433
|
function crossingPairs() {
|
|
380
434
|
const segs = (fig.wires ?? []).filter((w) => w.mode !== 'curve').flatMap((w) => w.pts.slice(1).map((b, i) => ({ w, a: w.pts[i], b })));
|
|
381
435
|
const out = [];
|
|
@@ -460,8 +514,10 @@
|
|
|
460
514
|
function placeLabels(labels, obs) {
|
|
461
515
|
const R = root(), W = R.offsetWidth, H = R.offsetHeight;
|
|
462
516
|
const placed = [], fails = [];
|
|
517
|
+
const onLine = !!R.querySelector('.map-region');
|
|
518
|
+
const heads = [...R.querySelectorAll('.mp-head')].map(rectOf);
|
|
463
519
|
const segsOf = (pts) => pts.slice(1).map((b, i) => ({ a: pts[i], b, len: Math.hypot(b[0] - pts[i][0], b[1] - pts[i][1]) }));
|
|
464
|
-
const wireHit = (r, skip) => (fig.wires ?? []).some((w) => segsOf(w.pts).some((s) => s !== skip && !(skip && s.a === skip.a && s.b === skip.b)
|
|
520
|
+
const wireHit = (r, skip, own) => (fig.wires ?? []).some((w) => w.pts !== own && segsOf(w.pts).some((s) => s !== skip && !(skip && s.a === skip.a && s.b === skip.b)
|
|
465
521
|
&& Math.min(Math.max(s.a[0], s.b[0]), r.x + r.w) >= Math.max(Math.min(s.a[0], s.b[0]), r.x) && Math.min(Math.max(s.a[1], s.b[1]), r.y + r.h) >= Math.max(Math.min(s.a[1], s.b[1]), r.y)));
|
|
466
522
|
for (const L of labels) {
|
|
467
523
|
const lab = document.createElement('div');
|
|
@@ -492,9 +548,15 @@
|
|
|
492
548
|
for (const seg of segs) {
|
|
493
549
|
const horiz = seg.a[1] === seg.b[1], mid = [(seg.a[0] + seg.b[0]) / 2, (seg.a[1] + seg.b[1]) / 2];
|
|
494
550
|
const real = segsOf(mine ? mine.pts : L.pts).find((s) => s.a[0] === seg.a[0] && s.a[1] === seg.a[1] && s.b[0] === seg.b[0] && s.b[1] === seg.b[1]);
|
|
495
|
-
|
|
551
|
+
// On a map, many lines run side by side between regions, so a label beside a line reads as naming its
|
|
552
|
+
// neighbour: there a label sits only on its own line, moved along it until it is clear.
|
|
553
|
+
// Clear of the line's ends: a little at a bend or the start, more before the arrowhead on the last segment.
|
|
554
|
+
const half = (horiz ? w : h) / 2, lastSeg = seg.b === L.pts[L.pts.length - 1], lo = half + 8, hi = seg.len - half - (lastSeg ? 26 : 8), slide = [];
|
|
555
|
+
if (onLine && hi >= lo) for (let d = 0, mid = (lo + hi) / 2; d <= (hi - lo) / 2; d += 4) slide.push((mid - d) / seg.len, (mid + d) / seg.len);
|
|
556
|
+
for (const t of onLine ? slide : [0.5, 0.3, 0.7]) {
|
|
496
557
|
const c = [seg.a[0] + (seg.b[0] - seg.a[0]) * t, seg.a[1] + (seg.b[1] - seg.a[1]) * t];
|
|
497
|
-
if (seg.len >= (horiz ? w : h) + 36) cands.push({ c, on: real });
|
|
558
|
+
if (onLine || seg.len >= (horiz ? w : h) + 36) cands.push({ c, on: real });
|
|
559
|
+
if (onLine) continue;
|
|
498
560
|
if (horiz) cands.push({ c: [c[0], c[1] - h / 2 - 7] }, { c: [c[0], c[1] + h / 2 + 7] });
|
|
499
561
|
else cands.push({ c: [c[0] + w / 2 + 9, c[1]] }, { c: [c[0] - w / 2 - 9, c[1]] });
|
|
500
562
|
}
|
|
@@ -506,11 +568,19 @@
|
|
|
506
568
|
const r = rectAt(cand.c);
|
|
507
569
|
if (r.x < 8 || r.y < 8 || r.x + r.w > W - 8 || r.y + r.h > H - 8) return false;
|
|
508
570
|
const within = (g) => r.x > g.x + 4 && r.y > g.y + 4 && r.x + r.w < g.x + g.w - 4 && r.y + r.h < g.y + g.h - 4;
|
|
509
|
-
|
|
510
|
-
if (
|
|
511
|
-
|
|
571
|
+
// A map's region is a tinted area: a label sits wholly inside it or wholly outside, never across its edge.
|
|
572
|
+
if (obs.some((o) => (o.el.classList.contains('map-region') ? overlap(grow(r, 8), o.r) && !(r.x > o.r.x + 12 && r.y > o.r.y + 12 && r.x + r.w < o.r.x + o.r.w - 12 && r.y + r.h < o.r.y + o.r.h - 12) : o.el.classList.contains('group') ? overlap(grow(r, 3), o.r) && !within(o.r) : hits({ r: grow(r, 3) }, o)))) return false;
|
|
573
|
+
if (placed.some((p) => overlap(grow(r, 4), p)) || heads.some((g) => overlap(grow(r, 8), g))) return false;
|
|
574
|
+
return onLine ? !wireHit(grow(r, 8), null, L.pts) : !wireHit(grow(r, 2), cand.on);
|
|
512
575
|
};
|
|
513
|
-
|
|
576
|
+
let pick = cands.find(free);
|
|
577
|
+
if (!pick) {
|
|
578
|
+
fails.push({ from: L.from, to: L.to, w, h });
|
|
579
|
+
lab.dataset.stuck = '1';
|
|
580
|
+
// With no clear place, the label still goes on its own line, where check() reports it.
|
|
581
|
+
const long = segsOf(L.pts).sort((p, q) => q.len - p.len)[0];
|
|
582
|
+
pick = cands[0] ?? { c: [(long.a[0] + long.b[0]) / 2, (long.a[1] + long.b[1]) / 2] };
|
|
583
|
+
}
|
|
514
584
|
lab.style.left = pick.c[0] + 'px';
|
|
515
585
|
lab.style.top = pick.c[1] + 'px';
|
|
516
586
|
placed.push(rectAt(pick.c));
|
|
@@ -666,6 +736,15 @@
|
|
|
666
736
|
if (reach > 1.02) P.push(`The text of decision ${name(d)} does not fit inside its diamond: shorten it or give the diamond a min-width.`);
|
|
667
737
|
}
|
|
668
738
|
|
|
739
|
+
// On a map, a label names the line it sits on: its centre must lie on a segment of its own arrow, clear of the rest.
|
|
740
|
+
if (R.querySelector('.map-region')) for (const lab of R.querySelectorAll('.fig-wire-label')) {
|
|
741
|
+
const [from, to] = lab.dataset.arrow.split(' → '), w = (fig.wires ?? []).find((x) => x.from === from && x.to === to);
|
|
742
|
+
const r = rectOf(lab), cx = r.x + r.w / 2, cy = r.y + r.h / 2;
|
|
743
|
+
const on = w && w.pts.slice(1).some((b, i) => { const a = w.pts[i]; return Math.abs(a[0] - b[0]) < 0.5 ? Math.abs(cx - a[0]) <= 3 && cy >= Math.min(a[1], b[1]) && cy <= Math.max(a[1], b[1]) : Math.abs(cy - a[1]) <= 3 && cx >= Math.min(a[0], b[0]) && cx <= Math.max(a[0], b[0]); });
|
|
744
|
+
if (!on) P.push(`The label "${lab.firstChild.textContent}" of arrow ${lab.dataset.arrow} is not on its own line.`);
|
|
745
|
+
else if (lab.dataset.stuck) P.push(`The label "${lab.firstChild.textContent}" of arrow ${lab.dataset.arrow} has no clear place on its own line: other lines or boxes cover every spot. Drop the label, or shorten it.`);
|
|
746
|
+
}
|
|
747
|
+
|
|
669
748
|
// Boxes and labels that collide.
|
|
670
749
|
for (let i = 0; i < obs.length; i++) for (let j = i + 1; j < obs.length; j++) {
|
|
671
750
|
const a = obs[i], b = obs[j];
|
|
@@ -690,8 +769,24 @@
|
|
|
690
769
|
if (hit) P.push(`Arrow ${w.from} → ${w.to} runs through ${name(o.el)}.`);
|
|
691
770
|
}
|
|
692
771
|
}
|
|
772
|
+
if (R.querySelector('.map')) {
|
|
773
|
+
const along = overlapPairs().map(([u, w]) => `${u.from} → ${u.to} and ${w.from} → ${w.to}`);
|
|
774
|
+
if (along.length) P.push(`Links run on top of each other: ${along.join('; ')}. figkit found no arrangement that keeps them apart; leave out a link the question does not need.`);
|
|
775
|
+
}
|
|
693
776
|
const crossings = new Set(crossingPairs().map(([u, w]) => `${u.from} → ${u.to} and ${w.from} → ${w.to}`));
|
|
694
|
-
|
|
777
|
+
// A map of many links crosses some at right angles, which still reads full screen; up to one crossing per 2 links is
|
|
778
|
+
// allowed and named. Real systems of 24 parts and 30 links cross about 10 times; past 30 parts, 28 times or more.
|
|
779
|
+
const allowed = R.querySelector('.map') ? Math.floor((fig.wires ?? []).length / 2) : 0;
|
|
780
|
+
if (R.querySelector('.map')) {
|
|
781
|
+
// The highlighted path is what the map is about: it reads as one run, and grey links crossing it are named.
|
|
782
|
+
const st = accentStats(), arrow = (w) => `${w.from} → ${w.to}`;
|
|
783
|
+
if (st.back.length) P.push(`The highlighted path turns back on itself: ${st.back.map((run) => [run[0].from, ...run.map((w) => w.to)].join(' → ')).join('; ')}. figkit could not lay it out as one run; highlight only the links the question follows, in one direction.`);
|
|
784
|
+
const grey = [...new Set(st.crossed.map(([u, w]) => (u.tone === 'accent' ? arrow(w) : arrow(u))))];
|
|
785
|
+
if (grey.length) fig.notes = [...(fig.notes ?? []), `${grey.length} grey link${grey.length === 1 ? ' crosses' : 's cross'} the highlighted path: ${grey.join('; ')}.`];
|
|
786
|
+
}
|
|
787
|
+
if (crossings.size && crossings.size <= allowed) fig.notes = [...(fig.notes ?? []), `${crossings.size} crossing${crossings.size === 1 ? '' : 's'} of links (a map of ${(fig.wires ?? []).length} links is allowed ${allowed}): ${[...crossings].join('; ')}.`];
|
|
788
|
+
else if (crossings.size && R.querySelector('.map')) P.push(`Links cross ${crossings.size} times; a map of ${(fig.wires ?? []).length} links holds at most ${allowed}: ${[...crossings].join('; ')}. Leave out links the question does not need, or move a part to the subsystem it talks to most.`);
|
|
789
|
+
else if (crossings.size) P.push(R.querySelector('.entities')
|
|
695
790
|
? `References cross: ${[...crossings].join('; ')}. Split the records over two figures; do not leave a reference out.`
|
|
696
791
|
: R.querySelector('.states') ? `Moves cross: ${[...crossings].join('; ')}. figkit could not lay them out apart; draw the side branch as a second figure.`
|
|
697
792
|
: `Arrows cross: ${[...crossings].join('; ')}. Reorder the boxes so they do not, or drop an arrow.`);
|
|
@@ -704,6 +799,7 @@
|
|
|
704
799
|
if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
|
|
705
800
|
// Records share a top edge in their row; their heights follow their fields, which figkit placed, not the author.
|
|
706
801
|
if (a.el.classList.contains('record') && b.el.classList.contains('record')) continue;
|
|
802
|
+
if (a.el.closest('.map') && b.el.closest('.map')) continue;
|
|
707
803
|
const edges = [['left edges', a.r.x, b.r.x], ['right edges', a.r.x + a.r.w, b.r.x + b.r.w], ['top edges', a.r.y, b.r.y], ['bottom edges', a.r.y + a.r.h, b.r.y + b.r.h]];
|
|
708
804
|
for (const [what, u, v] of edges) { const d = Math.abs(u - v); if (d > 0.75 && d <= 6) near.push(`${name(a.el)} and ${name(b.el)} (${what}, ${d.toFixed(0)}px apart)`); }
|
|
709
805
|
}
|
|
@@ -717,7 +813,9 @@
|
|
|
717
813
|
const nodes = R.querySelectorAll('.node:not(.bare), .decision').length, arrows = (fig.wires ?? []).length;
|
|
718
814
|
// A states picture reads down one path of short names, so it holds up to 7 states (see the states spec).
|
|
719
815
|
const states = !!R.querySelector('.states');
|
|
720
|
-
|
|
816
|
+
// A map is many parts laid out by figkit, so it holds what the map spec allows (map-spec.js), not a detail figure's 18.
|
|
817
|
+
const map = !!R.querySelector('.map');
|
|
818
|
+
const budget = map ? { words: 320, nodes: 24, arrows: 30 } : detail ? { words: 190, nodes: 18, arrows: 18 } : states ? { words: 90, nodes: 7, arrows: 12 } : { words: 70, nodes: 5, arrows: 8 };
|
|
721
819
|
if (words > budget.words) P.push(`${words} words; a ${detail ? 'detail' : 'glance'} figure holds about ${budget.words}. Cut sentences that explain, and keep labels.`);
|
|
722
820
|
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.'}`);
|
|
723
821
|
if (arrows > budget.arrows) P.push(`${arrows} arrows; more than ${budget.arrows} tangles. Show the main path and drop the rest.`);
|
|
@@ -753,10 +851,40 @@
|
|
|
753
851
|
await Promise.all([...document.images].map((img) => (img.complete ? null : new Promise((res) => { img.onload = img.onerror = res; }))));
|
|
754
852
|
for (const d of root().querySelectorAll('.decision')) { const h = Math.max(...[...d.children].map((k) => k.offsetHeight), 20); d.style.paddingBlock = Math.max(30, Math.round(h * 0.7)) + 'px'; }
|
|
755
853
|
const spec = document.getElementById('figkit-spec');
|
|
756
|
-
|
|
757
|
-
|
|
854
|
+
const drawn = spec ? window.figkitViz.drawSpec(JSON.parse(spec.textContent), root()) : (buildSplits(), buildBars(), null);
|
|
855
|
+
if (drawn?.ready) await drawn.ready;
|
|
758
856
|
for (const job of fig.queue) job();
|
|
759
857
|
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
858
|
+
// A map offers a few arrangements of its regions: each is routed, and the one whose arrows cross least is kept.
|
|
859
|
+
if (fig.mapTry?.length > 1) {
|
|
860
|
+
let best = null;
|
|
861
|
+
for (const [i, apply] of fig.mapTry.entries()) {
|
|
862
|
+
apply();
|
|
863
|
+
await new Promise((r) => requestAnimationFrame(r));
|
|
864
|
+
const before = fig.problems.length;
|
|
865
|
+
const fails = drawArrows().length, crossings = crossingPairs().length + 2 * overlapPairs().length, lost = fig.problems.length - before;
|
|
866
|
+
// The checks themselves, so an arrangement whose arrows run through labels or boxes loses.
|
|
867
|
+
const notes = fig.notes;
|
|
868
|
+
check();
|
|
869
|
+
const flagged = fig.problems.length - before - lost;
|
|
870
|
+
fig.problems.length = before;
|
|
871
|
+
fig.notes = notes;
|
|
872
|
+
// The highlighted path should read as one run: grey links crossing it, and a run that doubles back, cost most.
|
|
873
|
+
const st = accentStats();
|
|
874
|
+
// And, as when the regions were packed, a map read larger on a full screen is better.
|
|
875
|
+
// With a highlighted path the arrangements differ a lot in size: one read larger on a full screen is better.
|
|
876
|
+
const read = st.any ? Math.min(1440 / Math.max(root().offsetWidth, 960), 860 / root().offsetHeight) : 1;
|
|
877
|
+
// Those arrangements are also built for the path rather than packed, so they vary more in what the checks refuse,
|
|
878
|
+
// and a refused figure is worse than any crossing: there, each problem counts double.
|
|
879
|
+
const k = st.any ? 2 : 1;
|
|
880
|
+
// And long detours, which those arrangements make more of, are lines the reader must trace further.
|
|
881
|
+
const far = st.any ? (fig.wires ?? []).reduce((t, w) => t + w.pts.slice(1).reduce((u, b, j) => u + Math.abs(b[0] - w.pts[j][0]) + Math.abs(b[1] - w.pts[j][1]), 0), 0) / 1000 : 0;
|
|
882
|
+
const score = crossings + k * (2 * fails + 3 * lost + 4 * flagged) + 3 * st.crossed.length + 8 * st.back.length - 8 * Math.log(read) + far + i * 0.1;
|
|
883
|
+
if (!best || score < best.score) best = { score, apply };
|
|
884
|
+
}
|
|
885
|
+
best.apply();
|
|
886
|
+
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
887
|
+
}
|
|
760
888
|
// A label with no free place opens the gap it needs, then everything is drawn again.
|
|
761
889
|
const tried = new Map();
|
|
762
890
|
for (let i = 0; ; i++) {
|
package/viz/draw.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import * as Plot from '@observablehq/plot';
|
|
3
3
|
import { drawSequence } from './sequence.js';
|
|
4
4
|
import { drawStates } from './states.js';
|
|
5
|
+
import { drawMap } from './map.js';
|
|
5
6
|
const NS = 'http://www.w3.org/2000/svg';
|
|
6
7
|
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
7
8
|
|
|
@@ -363,6 +364,18 @@ export function drawSpec(spec, main) {
|
|
|
363
364
|
if (spec.source) { const footer = document.createElement('footer'); footer.innerHTML = `<p>${esc(spec.source)}</p>`; main.append(footer); }
|
|
364
365
|
return { chart, draw: () => {} };
|
|
365
366
|
}
|
|
367
|
+
if (spec.type === 'map') {
|
|
368
|
+
if (main.classList.contains('phone')) {
|
|
369
|
+
const key = document.createElement('p');
|
|
370
|
+
key.className = 'read mp-key';
|
|
371
|
+
key.textContent = 'Each box is a subsystem; numbers count the links between their parts.';
|
|
372
|
+
header.appendChild(key);
|
|
373
|
+
}
|
|
374
|
+
main.append(header);
|
|
375
|
+
const footer = spec.source ? Object.assign(document.createElement('footer'), { innerHTML: `<p>${esc(spec.source)}</p>` }) : null;
|
|
376
|
+
const ready = drawMap(spec, main).then((chart) => { if (footer) main.append(footer); return chart; });
|
|
377
|
+
return { chart: null, draw: () => {}, ready };
|
|
378
|
+
}
|
|
366
379
|
if (spec.type === 'sequence') {
|
|
367
380
|
main.append(header);
|
|
368
381
|
const chart = drawSequence(spec, main);
|