@liggi/figkit 0.4.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 +94 -7
- package/bin/fig.mjs +3 -3
- package/dist/viz.iife.js +81 -18
- package/examples/charts/bars-ref.json +6 -0
- package/examples/charts/estimates.json +7 -0
- package/examples/charts/heatmap-signed.json +5 -0
- package/examples/charts/states.json +71 -0
- package/package.json +1 -1
- package/runtime/fig.css +39 -0
- package/runtime/fig.js +73 -3
- package/viz/draw.js +155 -14
- package/viz/spec.js +59 -6
- package/viz/states-spec.js +161 -0
- package/viz/states.js +203 -0
|
@@ -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,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,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
|
+
}
|
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
|
@@ -274,3 +274,42 @@ fig-bars .fb-label .fb-n { display: block; font-size: var(--small); color: var(-
|
|
|
274
274
|
.fig.phone fig-bars { grid-template-columns: minmax(0, 1fr); row-gap: 4px; }
|
|
275
275
|
.fig.phone fig-bars .fb-label { margin-top: 10px; }
|
|
276
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
|
|
|
@@ -574,6 +632,15 @@
|
|
|
574
632
|
if (hit) P.push(`Chart label covers ${hit === 1 ? 'a dot' : hit + ' dots'}: "${a.e.textContent.trim()}".`);
|
|
575
633
|
}
|
|
576
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
|
+
|
|
577
644
|
// Text: too small, cut off, or past its box.
|
|
578
645
|
const small = [];
|
|
579
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()))))) {
|
|
@@ -626,6 +693,7 @@
|
|
|
626
693
|
const crossings = new Set(crossingPairs().map(([u, w]) => `${u.from} → ${u.to} and ${w.from} → ${w.to}`));
|
|
627
694
|
if (crossings.size) P.push(R.querySelector('.entities')
|
|
628
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.`
|
|
629
697
|
: `Arrows cross: ${[...crossings].join('; ')}. Reorder the boxes so they do not, or drop an arrow.`);
|
|
630
698
|
|
|
631
699
|
// Neighbours that almost line up.
|
|
@@ -647,7 +715,9 @@
|
|
|
647
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;
|
|
648
716
|
const words = (panelText !== null ? outside + ' ' + panelText : R.innerText).split(/\s+/).filter(Boolean).length;
|
|
649
717
|
const nodes = R.querySelectorAll('.node:not(.bare), .decision').length, arrows = (fig.wires ?? []).length;
|
|
650
|
-
|
|
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 };
|
|
651
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.`);
|
|
652
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.'}`);
|
|
653
723
|
if (arrows > budget.arrows) P.push(`${arrows} arrows; more than ${budget.arrows} tangles. Show the main path and drop the rest.`);
|
package/viz/draw.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* The chart types, drawn into a host element styled by runtime/fig.css. Figures use them through runtime/fig.js. */
|
|
2
2
|
import * as Plot from '@observablehq/plot';
|
|
3
3
|
import { drawSequence } from './sequence.js';
|
|
4
|
+
import { drawStates } from './states.js';
|
|
4
5
|
const NS = 'http://www.w3.org/2000/svg';
|
|
5
6
|
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
|
|
6
7
|
|
|
@@ -84,11 +85,15 @@ export function drawSplit(host, parts, unit) {
|
|
|
84
85
|
* A few numbers compared into a <fig-bars>, on an axis from zero to max (default: the largest value or high). A bar
|
|
85
86
|
* with `low` and `high` gets a range drawn across its end, and its number moves past the range; `n` goes under its label.
|
|
86
87
|
*/
|
|
87
|
-
export function drawBars(host, bars, unit, max, each) {
|
|
88
|
-
const top = max ?? Math.max(...bars.map((b) => Math.max(b.value, b.high ?? 0)));
|
|
88
|
+
export function drawBars(host, bars, unit, max, each, ref) {
|
|
89
|
+
const top = max ?? Math.max(...bars.map((b) => Math.max(b.value, b.high ?? 0)), ref?.value ?? 0);
|
|
89
90
|
host.innerHTML = '';
|
|
91
|
+
host.classList.toggle('has-ref', !!ref);
|
|
92
|
+
// A reference is its own first row, in the second colour; a bar above it (its whole range, when it has one) is
|
|
93
|
+
// accented and every other bar is grey.
|
|
94
|
+
if (ref) bars = [{ ...ref, text: String(ref.value), tone: 'second', isRef: true }, ...bars.map((b) => ({ ...b, tone: (b.low ?? b.value) > ref.value ? 'accent' : undefined }))];
|
|
90
95
|
const fills = bars.map((b) => {
|
|
91
|
-
const label = Object.assign(document.createElement('div'), { className: 'fb-label', innerHTML: b.label + (b.n !== undefined ? `<span class="fb-n">${each ? `${esc(b.n.toLocaleString('en-GB'))} ${esc(each)}` : `n = ${esc(b.n.toLocaleString('en-GB'))}`}</span>` : '') });
|
|
96
|
+
const label = Object.assign(document.createElement('div'), { className: 'fb-label' + (b.isRef ? ' ref' : ''), innerHTML: b.label + (b.n !== undefined ? `<span class="fb-n">${each ? `${esc(b.n.toLocaleString('en-GB'))} ${esc(each)}` : `n = ${esc(b.n.toLocaleString('en-GB'))}`}</span>` : '') });
|
|
92
97
|
const track = Object.assign(document.createElement('div'), { className: 'fb-track' });
|
|
93
98
|
const tone = b.tone ? ' ' + b.tone : '';
|
|
94
99
|
const ranged = b.low !== undefined;
|
|
@@ -109,6 +114,20 @@ export function drawBars(host, bars, unit, max, each) {
|
|
|
109
114
|
f.range.style.width = at(f.hi - f.lo);
|
|
110
115
|
f.val.style.marginLeft = `max(0px, calc((100% - ${room}px) * ${f.hi - f.ratio} - 3px))`;
|
|
111
116
|
}
|
|
117
|
+
if (!ref) return;
|
|
118
|
+
// The reference level as a dashed line from the end of its bar down through every other row; a value that would
|
|
119
|
+
// sit on the line is moved past it.
|
|
120
|
+
const line = document.createElement('div');
|
|
121
|
+
line.className = 'fb-refline';
|
|
122
|
+
host.appendChild(line);
|
|
123
|
+
const hostBox = host.getBoundingClientRect();
|
|
124
|
+
const refBar = fills[0].bar.getBoundingClientRect(), last = fills[fills.length - 1].bar.parentElement.getBoundingClientRect();
|
|
125
|
+
const x = refBar.right - hostBox.left;
|
|
126
|
+
Object.assign(line.style, { left: `${x - 1}px`, top: `${refBar.bottom - hostBox.top}px`, height: `${last.bottom - refBar.bottom}px` });
|
|
127
|
+
for (const f of fills.slice(1)) {
|
|
128
|
+
const v = f.val.getBoundingClientRect();
|
|
129
|
+
if (v.left - hostBox.left < x + 8 && v.right - hostBox.left > x - 8) f.val.style.marginLeft = `${parseFloat(getComputedStyle(f.val).marginLeft) + (x + 10 - (v.left - hostBox.left))}px`;
|
|
130
|
+
}
|
|
112
131
|
}
|
|
113
132
|
|
|
114
133
|
/** What the ranges are, as a key line under the reading line: the mark as drawn, then the words. */
|
|
@@ -116,7 +135,8 @@ function rangeKey(spec) {
|
|
|
116
135
|
const p = document.createElement('p');
|
|
117
136
|
p.className = 'range-key';
|
|
118
137
|
p.innerHTML = (spec.range ? `<span><i class="${spec.type === 'bars' ? 'whisker' : 'band'}"></i>${esc(spec.range)}</span>` : '')
|
|
119
|
-
+ (spec.ref ? `<span><i class="dash"></i>${esc(spec.ref.label)}</span>` : '')
|
|
138
|
+
+ (spec.ref && spec.type !== 'bars' ? `<span><i class="dash"></i>${esc(spec.ref.label)}</span>` : '')
|
|
139
|
+
+ (spec.ref && spec.type === 'bars' && spec.bars.some((b) => (b.low ?? b.value) > spec.ref.value) ? `<span><i class="swatch"></i>${spec.range ? 'whole range ' : ''}above ${esc(spec.ref.label)}</span>` : '');
|
|
120
140
|
return p;
|
|
121
141
|
}
|
|
122
142
|
|
|
@@ -316,20 +336,33 @@ function drawEntities(spec, main) {
|
|
|
316
336
|
}
|
|
317
337
|
}
|
|
318
338
|
|
|
319
|
-
const HOSTS = { split: 'fig-split', bars: 'fig-bars', 'before-after': 'fig-change', dumbbell: 'div', series: 'div', groups: 'div', lines: 'div', panels: 'div', heatmap: 'div', dots: 'div', scatter: 'div', units: 'div' };
|
|
339
|
+
const HOSTS = { split: 'fig-split', bars: 'fig-bars', estimates: 'div', 'before-after': 'fig-change', dumbbell: 'div', series: 'div', groups: 'div', lines: 'div', panels: 'div', heatmap: 'div', dots: 'div', scatter: 'div', units: 'div' };
|
|
320
340
|
|
|
321
341
|
/** A checked spec drawn into a figure: header, chart and footer, as figkit's own markup. */
|
|
322
342
|
export function drawSpec(spec, main) {
|
|
323
343
|
const header = document.createElement('header');
|
|
324
344
|
header.innerHTML = `<h1>${esc(spec.title)}</h1>${spec.note ? `<p class="read">${esc(spec.note)}</p>` : ''}`;
|
|
325
345
|
// What the ranges and a reference line are, said once under the reading line: there they never sit on the data.
|
|
326
|
-
if (spec.
|
|
346
|
+
if (spec.type === 'estimates') header.appendChild(estimatesKey(spec));
|
|
347
|
+
else if (spec.range || spec.ref) header.appendChild(rangeKey(spec));
|
|
327
348
|
if (spec.type === 'entities') {
|
|
328
349
|
main.append(header);
|
|
329
350
|
drawEntities(spec, main);
|
|
330
351
|
if (spec.source) { const footer = document.createElement('footer'); footer.innerHTML = `<p>${esc(spec.source)}</p>`; main.append(footer); }
|
|
331
352
|
return { chart: main.querySelector('.entities'), draw: () => {} };
|
|
332
353
|
}
|
|
354
|
+
if (spec.type === 'states') {
|
|
355
|
+
if (spec.moves.some((m) => m.count != null)) {
|
|
356
|
+
const key = document.createElement('p');
|
|
357
|
+
key.className = 'read st-key';
|
|
358
|
+
key.textContent = `Counts are ${spec.each} who made each move at least once${spec.shares ? '; % of those who reached the state it leaves' : ''}.`;
|
|
359
|
+
header.appendChild(key);
|
|
360
|
+
}
|
|
361
|
+
main.append(header);
|
|
362
|
+
const chart = drawStates(spec, main);
|
|
363
|
+
if (spec.source) { const footer = document.createElement('footer'); footer.innerHTML = `<p>${esc(spec.source)}</p>`; main.append(footer); }
|
|
364
|
+
return { chart, draw: () => {} };
|
|
365
|
+
}
|
|
333
366
|
if (spec.type === 'sequence') {
|
|
334
367
|
main.append(header);
|
|
335
368
|
const chart = drawSequence(spec, main);
|
|
@@ -346,6 +379,7 @@ export function drawSpec(spec, main) {
|
|
|
346
379
|
const draw = () => {
|
|
347
380
|
if (spec.type === 'before-after') return drawChange(chart, spec);
|
|
348
381
|
if (spec.type === 'dumbbell') return drawDumbbell(chart, spec);
|
|
382
|
+
if (spec.type === 'estimates') return drawEstimates(chart, spec);
|
|
349
383
|
if (spec.type === 'series') return drawSeries(chart, spec);
|
|
350
384
|
if (spec.type === 'groups') return drawGroups(chart, spec, { unit: spec.unit, max: spec.max });
|
|
351
385
|
if (spec.type === 'lines') return drawLines(chart, spec, { unit: spec.unit, xTicks: spec.xTicks });
|
|
@@ -356,7 +390,7 @@ export function drawSpec(spec, main) {
|
|
|
356
390
|
if (spec.type === 'units') return drawUnits(chart, spec);
|
|
357
391
|
const items = (spec.parts ?? spec.bars).map((x) => ({ ...x, text: String(x.value), label: esc(x.label) }));
|
|
358
392
|
if (spec.type === 'split') drawSplit(chart, items, spec.unit);
|
|
359
|
-
else drawBars(chart, items, spec.unit, spec.max, spec.each);
|
|
393
|
+
else drawBars(chart, items, spec.unit, spec.max, spec.each, spec.ref && { ...spec.ref, label: esc(spec.ref.label) });
|
|
360
394
|
};
|
|
361
395
|
draw();
|
|
362
396
|
return { chart, draw };
|
|
@@ -484,14 +518,26 @@ export function drawPanels(host, spec) {
|
|
|
484
518
|
*/
|
|
485
519
|
export function drawHeatmap(host, spec) {
|
|
486
520
|
const t = themeOf(host);
|
|
521
|
+
host.classList.add('heatmap');
|
|
487
522
|
const width = Math.floor(host.clientWidth) || 640, phone = phoneOf(host) || width < 420;
|
|
488
523
|
const cells = spec.rows.flatMap((r, i) => spec.columns.map((c, j) => ({ r: String(r), c: String(c), v: spec.values[i][j] })));
|
|
489
524
|
const vs = cells.map((d) => d.v), lo = Math.min(...vs), hi = Math.max(...vs);
|
|
490
|
-
//
|
|
491
|
-
|
|
492
|
-
const
|
|
493
|
-
const
|
|
494
|
-
const fmt = (v) =>
|
|
525
|
+
// Any value below zero makes the grid signed: a diverging scale around zero, orange below and blue above, equal
|
|
526
|
+
// distances from zero equally strong. Otherwise one hue, near white to dark: darker is always more.
|
|
527
|
+
const diverging = lo < 0;
|
|
528
|
+
const places = placesOf(vs);
|
|
529
|
+
const fmt = (v) => (diverging ? signed(v, places) : Number(v.toFixed(places)).toLocaleString('en-GB', { minimumFractionDigits: places })) + (spec.unit ?? '');
|
|
530
|
+
const scale = diverging ? null : Plot.scale({ color: { type: 'linear', scheme: 'blues', domain: [lo - (hi - lo) * 0.08, hi] } });
|
|
531
|
+
const MID = '#f2f2f0', NEG = '#a63603', POS = '#08519c', far = Math.max(-lo, hi) || 1;
|
|
532
|
+
const colour = (v) => (!diverging ? scale.apply(v) : v < 0 ? mix(MID, NEG, -v / far) : mix(MID, POS, v / far));
|
|
533
|
+
// Cell text in dark or white, whichever reads against the cell, whatever the theme's ink.
|
|
534
|
+
const lum = (c) => { const m = /rgb\((\d+), (\d+), (\d+)\)/.exec(c); const x = m ? m.slice(1).map(Number) : hex(c) ?? [0, 0, 0]; const [r, g, b] = x.map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
|
|
535
|
+
const textOn = (v) => { const L = lum(colour(v)); return (L + 0.05) / (lum('#1d2130') + 0.05) >= 1.05 / (L + 0.05) ? '#1d2130' : '#ffffff'; };
|
|
536
|
+
const K = phone ? 120 : 200, z = Math.round((100 * -lo) / (hi - lo || 1));
|
|
537
|
+
// Signed: one bar through zero, with 0 marked where it falls on the scale.
|
|
538
|
+
const ramp = diverging
|
|
539
|
+
? `<span>${esc(fmt(lo))}</span><i class="hm-signed" style="width:${K}px;background:linear-gradient(90deg, ${colour(lo)}, ${MID} ${z}%, ${colour(Math.max(hi, 0))})"><b style="left:${z}%">0</b></i><span>${esc(fmt(hi))}</span>`
|
|
540
|
+
: `<span>${esc(fmt(lo))}</span><i style="background:linear-gradient(90deg, ${[0, 0.25, 0.5, 0.75, 1].map((p) => colour(lo + (hi - lo) * p)).join(', ')})"></i><span>${esc(fmt(hi))}</span>`;
|
|
495
541
|
const cols = spec.columns.map(String), rows = spec.rows.map(String);
|
|
496
542
|
const labelW = Math.ceil(widest(host, rows, t.small)) + 12;
|
|
497
543
|
const cellW = (width - labelW - 8) / cols.length;
|
|
@@ -502,7 +548,7 @@ export function drawHeatmap(host, spec) {
|
|
|
502
548
|
const rowH = phone ? 30 : 36;
|
|
503
549
|
const key = document.createElement('p');
|
|
504
550
|
key.className = 'hm-key';
|
|
505
|
-
key.innerHTML =
|
|
551
|
+
key.innerHTML = ramp
|
|
506
552
|
+ (spec.mark ? `<span style="margin-left:14px;white-space:nowrap;display:inline-flex;align-items:center;gap:8px;color:${t.ink};font-weight:600"><i style="width:16px;height:16px;border:2.5px solid ${t.accent};border-radius:2px;background:none"></i>${esc(spec.mark.label)}</span>` : '');
|
|
507
553
|
plotInto(host, t, {
|
|
508
554
|
width, height: rows.length * rowH + 36, marginLeft: labelW, marginRight: 8, marginTop: 0, marginBottom: 36,
|
|
@@ -510,7 +556,7 @@ export function drawHeatmap(host, spec) {
|
|
|
510
556
|
y: { domain: rows, label: null, tickSize: 0, padding: 0.08 },
|
|
511
557
|
marks: [
|
|
512
558
|
Plot.cell(cells, { x: 'c', y: 'r', fill: (d) => colour(d.v), inset: 0.5 }),
|
|
513
|
-
show ? Plot.text(cells, { x: 'c', y: 'r', text: (d) => fmt(d.v), fill: (d) => (
|
|
559
|
+
show ? Plot.text(cells, { x: 'c', y: 'r', text: (d) => fmt(d.v), fill: (d) => textOn(d.v), fontSize: t.small }) : null,
|
|
514
560
|
// The marked cell: an accent ring, the one thing in the chart that is not the scale.
|
|
515
561
|
spec.mark ? Plot.cell([{ r: String(spec.mark.row), c: String(spec.mark.column) }], { x: 'c', y: 'r', fill: 'none', stroke: t.accent, strokeWidth: 2.5 }) : null,
|
|
516
562
|
],
|
|
@@ -519,6 +565,101 @@ export function drawHeatmap(host, spec) {
|
|
|
519
565
|
host.prepend(key);
|
|
520
566
|
}
|
|
521
567
|
|
|
568
|
+
// ---------- signed estimates with ranges around zero (a forest plot) ----------
|
|
569
|
+
|
|
570
|
+
/** Decimal places the author wrote, at most 3, so -0.18 is not printed as -0.2. */
|
|
571
|
+
const placesOf = (vs) => Math.min(3, Math.max(0, ...vs.map((v) => { const m = /\.(\d+)/.exec(String(v)); return m ? m[1].length : 0; })));
|
|
572
|
+
/** A signed number with a true minus sign: +7.4, −0.7, 0. */
|
|
573
|
+
const signed = (v, places) => { const r = Math.abs(v).toFixed(places); return (+r === 0 ? '' : v > 0 ? '+' : '−') + r; };
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* One row per estimate into a .chart: a dot at the value, a line across its range and a zero line through all of
|
|
577
|
+
* them. A row whose range clears zero is coloured; one whose range includes zero is grey with a hollow dot. Each row's
|
|
578
|
+
* value and range are printed in a column at the right; on a phone they go on a line above the row with its label.
|
|
579
|
+
*/
|
|
580
|
+
export function drawEstimates(host, spec) {
|
|
581
|
+
const t = themeOf(host);
|
|
582
|
+
host.classList.add('estimates');
|
|
583
|
+
const width = Math.floor(host.clientWidth) || 640;
|
|
584
|
+
const places = placesOf(spec.rows.flatMap((r) => [r.value, r.low, r.high]));
|
|
585
|
+
const u = spec.unit ?? '';
|
|
586
|
+
const rows = spec.rows.map((r, i) => {
|
|
587
|
+
const clear = r.low > 0 || r.high < 0;
|
|
588
|
+
const good = spec.better ? (r.value > 0) === (spec.better === 'higher') : null;
|
|
589
|
+
const color = !clear ? t.soft : good === null ? t.accent : good ? t.good : t.bad;
|
|
590
|
+
const n = r.n === undefined ? '' : spec.each ? `${r.n.toLocaleString('en-GB')} ${spec.each}` : `n = ${r.n.toLocaleString('en-GB')}`;
|
|
591
|
+
return { ...r, i, clear, color, n, val: signed(r.value, places) + u, span: `${signed(r.low, places)} to ${signed(r.high, places)}` };
|
|
592
|
+
});
|
|
593
|
+
// Zero always shows, with room on its empty side, so the line it is measured from is never the frame's edge.
|
|
594
|
+
const lo0 = Math.min(0, ...rows.map((r) => r.low)), hi0 = Math.max(0, ...rows.map((r) => r.high));
|
|
595
|
+
const room = (hi0 - lo0 || 1) * 0.1;
|
|
596
|
+
let lo = Math.min(lo0 - room * 0.5, -room), hi = Math.max(hi0 + room * 0.5, room);
|
|
597
|
+
const spanW = Math.ceil(widest(host, rows.map((r) => r.span), t.small));
|
|
598
|
+
const labelW = Math.ceil(widest(host, rows.map((r) => r.label), t.body));
|
|
599
|
+
const side = 12, plotW = width - 2 * side, ticks = Math.max(3, Math.min(6, Math.floor(plotW / 120)));
|
|
600
|
+
// Room each side of zero for the words saying what that side means, so they meet at the zero line.
|
|
601
|
+
if (spec.sides) {
|
|
602
|
+
const need = (txt) => Math.min(plotW * 0.35, widest(host, [txt], t.small) + 24);
|
|
603
|
+
const nb = need('← ' + spec.sides.below), na = need(spec.sides.above + ' →');
|
|
604
|
+
if (-lo / (hi - lo) * plotW < nb) lo = -(nb * hi) / (plotW - nb);
|
|
605
|
+
if (hi / (hi - lo) * plotW < na) hi = (na * -lo) / (plotW - na);
|
|
606
|
+
}
|
|
607
|
+
// Each row is a line of text (its label at the left, its value and range at the right) over its range. y runs in
|
|
608
|
+
// row units from the top: the text sits at a third of the row, the range at 0.72 of it.
|
|
609
|
+
const rowH = 84, T = 0.32, M = 0.72;
|
|
610
|
+
const halo = { stroke: t.paper, strokeWidth: 8, paintOrder: 'stroke' };
|
|
611
|
+
const tick = (d) => signed(d, Math.max(0, places - 1)) + (u.length <= 2 ? u : '');
|
|
612
|
+
const raw = (hi - lo) / ticks, mag = 10 ** Math.floor(Math.log10(raw)), step = [1, 2, 2.5, 5, 10].map((k) => k * mag).find((k) => (hi - lo) / k <= ticks + 1);
|
|
613
|
+
const tickVals = []; for (let v = Math.ceil(lo / step) * step; v <= hi + 1e-9; v += step) tickVals.push(+v.toFixed(10));
|
|
614
|
+
// The grid and the zero line run from just under each row's text to the next row, never through text.
|
|
615
|
+
const spans = rows.map((r) => ({ y1: r.i + 0.5, y2: r.i + 1 }));
|
|
616
|
+
const nW = rows[0].n ? Math.ceil(widest(host, rows.map((r) => r.n), t.small)) : 0;
|
|
617
|
+
plotInto(host, t, {
|
|
618
|
+
width, height: rows.length * rowH + 40, marginLeft: side, marginRight: side, marginTop: 0, marginBottom: 40,
|
|
619
|
+
x: { domain: [lo, hi], ticks: tickVals, label: null, tickFormat: tick, tickSize: 0 },
|
|
620
|
+
y: { domain: [0, rows.length], range: [0, rows.length * rowH], axis: null },
|
|
621
|
+
marks: [
|
|
622
|
+
...tickVals.filter((v) => v !== 0).map((v) => Plot.ruleX(spans, { x: () => v, y1: 'y1', y2: 'y2', stroke: t.hair })),
|
|
623
|
+
Plot.ruleX(spans, { x: () => 0, y1: 'y1', y2: 'y2', stroke: t.ink, strokeWidth: 1.5 }),
|
|
624
|
+
Plot.link(rows, { x1: 'low', x2: 'high', y1: (r) => r.i + M, y2: (r) => r.i + M, stroke: 'color', strokeWidth: 3 }),
|
|
625
|
+
Plot.ruleX(rows.flatMap((r) => [{ ...r, x: r.low }, { ...r, x: r.high }]), { x: 'x', y1: (r) => r.i + M - 9 / rowH, y2: (r) => r.i + M + 9 / rowH, stroke: 'color', strokeWidth: 3 }),
|
|
626
|
+
Plot.dot(rows, { x: 'value', y: (r) => r.i + M, r: 7.5, fill: (r) => (r.clear ? r.color : t.paper), stroke: 'color', strokeWidth: 2.5 }),
|
|
627
|
+
Plot.text(rows, { y: (r) => r.i + T, frameAnchor: 'left', dx: -side, text: 'label', textAnchor: 'start', fill: t.ink, fontSize: t.body, ...halo }),
|
|
628
|
+
nW ? Plot.text(rows, { y: (r) => r.i + T, frameAnchor: 'left', dx: -side + labelW + 12, text: 'n', textAnchor: 'start', fill: t.soft, fontSize: t.small, ...halo }) : null,
|
|
629
|
+
Plot.text(rows, { y: (r) => r.i + T, frameAnchor: 'right', dx: side - spanW - 12, text: 'val', textAnchor: 'end', fill: (r) => (r.clear ? r.color : t.muted), fontWeight: (r) => (r.clear ? 600 : 400), fontSize: t.body, ...halo }),
|
|
630
|
+
Plot.text(rows, { y: (r) => r.i + T, frameAnchor: 'right', dx: side, text: 'span', textAnchor: 'end', fill: t.muted, fontSize: t.small, ...halo }),
|
|
631
|
+
],
|
|
632
|
+
});
|
|
633
|
+
// What each side of zero means, under the axis, each reading away from the zero line.
|
|
634
|
+
if (spec.sides) {
|
|
635
|
+
const x0 = host.querySelector('svg').scale('x').apply(0);
|
|
636
|
+
const p = document.createElement('div');
|
|
637
|
+
p.className = 'est-sides';
|
|
638
|
+
p.innerHTML = `<span>← ${esc(spec.sides.below)}</span><span>${esc(spec.sides.above)} →</span>`;
|
|
639
|
+
host.appendChild(p);
|
|
640
|
+
const [below, above] = p.children;
|
|
641
|
+
const bw = below.offsetWidth, aw = above.offsetWidth;
|
|
642
|
+
if (x0 - 12 - bw < 0) window.fig?.problems.push(`sides.below: "${spec.sides.below}" is too long to end at the zero line; shorten it`);
|
|
643
|
+
if (x0 + 12 + aw > width) window.fig?.problems.push(`sides.above: "${spec.sides.above}" is too long to start at the zero line; shorten it`);
|
|
644
|
+
const bLeft = Math.max(0, x0 - 12 - bw);
|
|
645
|
+
const aLeft = Math.min(width - aw, Math.max(x0 + 12, bLeft + bw + 24));
|
|
646
|
+
below.style.left = `${bLeft}px`;
|
|
647
|
+
above.style.left = `${aLeft}px`;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** The estimates key, under the reading line: what the ranges are, and what a filled or hollow dot says. */
|
|
652
|
+
function estimatesKey(spec) {
|
|
653
|
+
const clear = spec.rows.map((r) => r.low > 0 || r.high < 0);
|
|
654
|
+
const p = document.createElement('p');
|
|
655
|
+
p.className = 'range-key';
|
|
656
|
+
const ink = spec.better ? 'var(--ink)' : 'var(--accent)';
|
|
657
|
+
p.innerHTML = `<span><i class="whisker"></i>${esc(spec.range)}</span>`
|
|
658
|
+
+ (clear.some(Boolean) ? `<span><i class="est-dot" style="background:${ink};border-color:${ink}"></i>clear of 0</span>` : '')
|
|
659
|
+
+ (clear.some((c) => !c) ? '<span><i class="est-dot" style="border-color:var(--soft)"></i>includes 0</span>' : '');
|
|
660
|
+
return p;
|
|
661
|
+
}
|
|
662
|
+
|
|
522
663
|
// ---------- every value as a dot, two measures against each other, small counts as units ----------
|
|
523
664
|
|
|
524
665
|
/**
|