@liggi/figkit 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/runtime/fig.css CHANGED
@@ -8,6 +8,8 @@ html, body { margin: 0; padding: 0; background: transparent; }
8
8
  --paper: #2d3142; --panel: #252938; --ink: #f5f5f5; --muted: #c3c6cf; --soft: #949eb2; --line: #8d95ad; --hair: rgba(245,245,245,.16);
9
9
  --accent: #f08a59; --second: #739fdf; --good: #6fc392; --bad: #ef6b73;
10
10
  --on-fill: #1d2030;
11
+ /* grey marks: the ones not to notice; fill2 a darker grey for a second series */
12
+ --fill: var(--soft); --fill2: var(--line);
11
13
  --sans: 'Geist Sans', 'Geist', system-ui, sans-serif;
12
14
  --serif: 'Newsreader', Georgia, serif;
13
15
  --mono: 'Geist Mono', ui-monospace, monospace;
@@ -135,7 +137,7 @@ fig-arrow { display: none; }
135
137
  /* ---------- split bar: parts of a whole ---------- */
136
138
  fig-split { display: flex; flex-direction: column; gap: 10px; }
137
139
  fig-split .fs-bar { display: flex; gap: 3px; height: 58px; }
138
- fig-split .fs-seg { --tone: var(--soft); background: var(--tone); color: var(--on-fill); min-width: 4px; display: flex; align-items: center; gap: 10px; padding: 0 14px; overflow: hidden; white-space: nowrap; }
140
+ fig-split .fs-seg { --tone: var(--fill); background: var(--tone); color: var(--on-fill); min-width: 4px; display: flex; align-items: center; gap: 10px; padding: 0 14px; overflow: hidden; white-space: nowrap; }
139
141
  fig-split .fs-seg:first-child { border-radius: 8px 0 0 8px; } fig-split .fs-seg:last-child { border-radius: 0 8px 8px 0; }
140
142
  fig-split .fs-seg.fs-out { padding: 0; }
141
143
  fig-split .fs-seg.fs-out > * { display: none; }
@@ -151,7 +153,7 @@ fig-change { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0,
151
153
  fig-change .fc-side { display: flex; flex-direction: column; gap: 10px; }
152
154
  fig-change .fc-when { font-size: var(--small); font-weight: 600; color: var(--muted); }
153
155
  fig-change .fc-num { font-family: var(--serif); font-weight: 400; font-size: calc(var(--num) * 1.5); line-height: 1; letter-spacing: -.01em; color: var(--ink); }
154
- fig-change .fc-bar { height: 14px; min-width: 3px; background: var(--soft); border-radius: 3px; }
156
+ fig-change .fc-bar { height: 14px; min-width: 3px; background: var(--fill); border-radius: 3px; }
155
157
  fig-change .before .fc-num { color: var(--muted); }
156
158
  fig-change .after .fc-num { color: var(--tone); }
157
159
  fig-change .after .fc-bar { background: var(--tone); }
@@ -161,7 +163,7 @@ fig-change .fc-arrow { color: var(--soft); display: flex; }
161
163
  fig-bars { display: grid; grid-template-columns: max-content minmax(0, 1fr); column-gap: 16px; row-gap: 10px; align-items: center; }
162
164
  fig-bars .fb-label { font-size: var(--body); }
163
165
  fig-bars .fb-track { display: flex; align-items: center; gap: 10px; min-width: 0; }
164
- fig-bars .fb-bar { --tone: var(--soft); height: 26px; background: var(--tone); border-radius: 3px; min-width: 3px; flex: 0 0 auto; }
166
+ fig-bars .fb-bar { --tone: var(--fill); height: 26px; background: var(--tone); border-radius: 3px; min-width: 3px; flex: 0 0 auto; }
165
167
  fig-bars .fb-val { font-family: var(--serif); font-size: 1.3em; font-weight: 400; font-variant-numeric: tabular-nums; white-space: nowrap; }
166
168
  fig-bars .fb-val.lead { color: var(--tone); }
167
169
  fig-bars .fb-sub { font-weight: 400; color: var(--soft); font-size: var(--small); margin-left: 6px; }
@@ -170,7 +172,7 @@ fig-bars .fb-sub { font-weight: 400; color: var(--soft); font-size: var(--small)
170
172
  .fig .chart { min-width: 0; }
171
173
  .fig .fd-key { display: flex; gap: 28px; margin: 0 0 18px; font-size: var(--small); font-weight: 600; color: var(--soft); }
172
174
  .fig .fd-key span { display: inline-flex; align-items: center; gap: 8px; }
173
- .fig .fd-key i { display: inline-block; width: 13px; height: 13px; border-radius: 50%; background: var(--soft); }
175
+ .fig .fd-key i { display: inline-block; width: 13px; height: 13px; border-radius: 50%; background: var(--fill); }
174
176
  .fig .fd-key .fd-to i { width: 0; height: 0; border-radius: 0; background: none; border-left: 13px solid currentColor; border-top: 7.5px solid transparent; border-bottom: 7.5px solid transparent; }
175
177
  .fig .fd-key .fd-to.back i { transform: scaleX(-1); }
176
178
  .fig.chart > footer { border-top: 0; padding-top: 0; }
@@ -186,3 +188,89 @@ fig-bars .fb-sub { font-weight: 400; color: var(--soft); font-size: var(--small)
186
188
  .fig .t-second.t-second { color: var(--second); }
187
189
  .fig .t-good.t-good { color: var(--good); } .fig .t-bad.t-bad { color: var(--bad); }
188
190
  .fig .decision:not(.accent):not(.second):not(.good):not(.bad) { --tone: var(--line); }
191
+
192
+ /* ---------- report: a light chart that sits in a page (a research report, a doc), set in one sans ---------- */
193
+ .fig.report {
194
+ --paper: #ffffff; --panel: #ffffff; --ink: #1d2130; --muted: #5b6170; --soft: #7a8092; --line: #9aa0ad; --hair: rgba(29,33,48,.12);
195
+ --fill: #b4b8c2; --fill2: #7d8391;
196
+ --accent: #cf5a23; --second: #2f6bc4; --good: #2f8f5b; --bad: #c9414a; --on-fill: #ffffff;
197
+ --serif: var(--sans);
198
+ --w: 720px; --pad: 32px; --gap: 14px;
199
+ --title: 26px; --read: 18px; --h: 18px; --body: 17px; --small: 16px; --num: 30px;
200
+ border: 1px solid #e6e2d8; border-radius: 10px; gap: 20px;
201
+ }
202
+ .fig.panels-fig { --w: 1000px; }
203
+ .fig.panels-fig.phone { --w: 560px; }
204
+ .fig.report h1 { font-weight: 600; letter-spacing: -.01em; line-height: 1.2; }
205
+ .fig.report > header { gap: 6px; }
206
+ .fig.report > footer { border-top: 0; padding-top: 0; }
207
+ .fig.report.phone { --w: 400px; --pad: 20px; --title: 22px; --read: 16px; --body: 16px; --small: 15px; }
208
+ .fig.report fig-bars .fb-val, .fig.report fig-split .fs-seg b, .fig.report fig-change .fc-num { font-weight: 500; letter-spacing: 0; }
209
+ .fig.report fig-bars .fb-val { font-size: 1.05em; }
210
+ .fig.report fig-bars .fb-val.lead { font-weight: 600; }
211
+
212
+ /* panels: the same chart drawn for each part, side by side, one above the other on a phone */
213
+ .fig .panels { display: grid; grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr)); column-gap: 36px; row-gap: 28px; }
214
+ .fig.phone .panels { grid-template-columns: minmax(0, 1fr); }
215
+ .fig .panel > h2 { font-size: calc(var(--h) * 1.15); margin: 0 0 4px; }
216
+
217
+ /* heat map scale: what the colour means, at readable size */
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
+ .fig .hm-key i { display: inline-block; width: 160px; height: 14px; border-radius: 3px; }
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; }
package/runtime/fig.js CHANGED
@@ -544,7 +544,7 @@
544
544
  if (!el) { fig.problems.push(`fig.plot: nothing matches ${target}.`); return; }
545
545
  el.classList.add('chart');
546
546
  const width = Math.floor(el.clientWidth) || 640;
547
- const t = { ink: css('--ink'), muted: css('--muted'), soft: css('--soft'), line: css('--line'), hair: css('--hair'), accent: css('--accent'), second: css('--second'), good: css('--good'), bad: css('--bad'), paper: css('--paper'), panel: css('--panel') };
547
+ const t = { ink: css('--ink'), muted: css('--muted'), soft: css('--soft'), fill: css('--fill'), fill2: css('--fill2'), line: css('--line'), hair: css('--hair'), accent: css('--accent'), second: css('--second'), good: css('--good'), bad: css('--bad'), paper: css('--paper'), panel: css('--panel') };
548
548
  try {
549
549
  const opts = build({ Plot: window.Plot, d3: window.d3, width, theme: t });
550
550
  const size = parseFloat(css('--small')) || 15;
@@ -567,6 +567,13 @@
567
567
  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
568
  }
569
569
 
570
+ // Chart labels over dots: a label drawn across a dot hides the value it sits on.
571
+ const dots = [...R.querySelectorAll('.chart svg circle')].filter(visible).map(rectOf).map((r) => grow(r, -1.5));
572
+ for (const a of marks) {
573
+ const hit = dots.filter((d) => overlap(grow(a.r, -1), d)).length;
574
+ if (hit) P.push(`Chart label covers ${hit === 1 ? 'a dot' : hit + ' dots'}: "${a.e.textContent.trim()}".`);
575
+ }
576
+
570
577
  // Text: too small, cut off, or past its box.
571
578
  const small = [];
572
579
  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()))))) {
@@ -635,7 +642,10 @@
635
642
  if (near.length) P.push(`Almost aligned but not quite: ${near.slice(0, 6).join('; ')}${near.length > 6 ? `; and ${near.length - 6} more` : ''}. Put them in one row, column or grid so they share the edge.`);
636
643
 
637
644
  // Too much on the page.
638
- const words = R.innerText.split(/\s+/).filter(Boolean).length;
645
+ // Panels repeat the same labels in each panel; a label read once counts once.
646
+ const panelText = R.querySelector('.panels') ? [...new Set([...R.querySelectorAll('.panels text, .panels h2')].map((e) => e.textContent.trim()))].join(' ') : null;
647
+ 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
+ const words = (panelText !== null ? outside + ' ' + panelText : R.innerText).split(/\s+/).filter(Boolean).length;
639
649
  const nodes = R.querySelectorAll('.node:not(.bare), .decision').length, arrows = (fig.wires ?? []).length;
640
650
  const budget = detail ? { words: 190, nodes: 18, arrows: 18 } : { words: 70, nodes: 5, arrows: 8 };
641
651
  if (words > budget.words) P.push(`${words} words; a ${detail ? 'detail' : 'glance'} figure holds about ${budget.words}. Cut sentences that explain, and keep labels.`);
@@ -644,6 +654,11 @@
644
654
  const tones = TONES.filter((t) => R.querySelector(`.${t}, [${t}], .t-${t}`));
645
655
  if (tones.length > 2) P.push(`${tones.length} colours carry meaning (${tones.join(', ')}); use one for what the reader must notice and at most one more.`);
646
656
  for (const o of obs) if (o.text || o.el.matches('.node')) { const t = o.el.innerText ?? ''; if ((t.match(/ [·|•] /g) ?? []).length >= 1 && !o.el.querySelector('*')) P.push(`${name(o.el)} strings facts together with separators; give each fact its own line or cut one.`); }
657
+ // A face that never loaded draws in whatever the machine has, and every measurement above is of that fallback.
658
+ const faces = [...document.fonts];
659
+ const families = [...new Set(['--sans', '--serif'].map((v) => css(v).split(',')[0].replace(/["']/g, '').trim()))];
660
+ const missing = families.filter((f) => !faces.some((x) => x.family.replace(/["']/g, '') === f && x.status === 'loaded'));
661
+ if (missing.length) P.push(`figkit's fonts did not load (${missing.join(', ')}); the figure is drawn in fallback fonts. Reinstall figkit with its dependencies.`);
647
662
  const h1 = R.querySelector('h1');
648
663
  if (!h1) P.push('No <h1>: give the figure a title that states its point.');
649
664
  else if (h1.innerText.split(/\s+/).length > 14) P.push('The title is over 14 words; state the point more shortly.');
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/draw.js', 'package.json'].map((f) => path.join(HOME, f));
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);