@tangle-network/charts 0.7.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/charts",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Static SVG and HTML figures for benchmarks and operational timing. Draws from producer data and refuses what it cannot draw honestly.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,491 +0,0 @@
1
- //#region src/text.ts
2
- /**
3
- * Text: escaping, measurement and wrapping.
4
- *
5
- * Layout runs with no DOM (a Worker, a build step), so text width comes from
6
- * Inter's advance widths, the face tangle.tools and the reports load. Numbers
7
- * render in a monospace face at 0.6 em per glyph. Widths carry a 4% margin so a
8
- * fallback face does not collide with its neighbour.
9
- */
10
- /** Every dynamic string in markup passes through here. */
11
- function esc(value) {
12
- return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
13
- }
14
- const REGULAR = [
15
- 281,
16
- 288,
17
- 466,
18
- 633,
19
- 642,
20
- 982,
21
- 644,
22
- 300,
23
- 365,
24
- 365,
25
- 501,
26
- 662,
27
- 288,
28
- 460,
29
- 288,
30
- 360,
31
- 631,
32
- 407,
33
- 610,
34
- 618,
35
- 646,
36
- 608,
37
- 620,
38
- 566,
39
- 619,
40
- 620,
41
- 288,
42
- 302,
43
- 662,
44
- 662,
45
- 662,
46
- 511,
47
- 966,
48
- 690,
49
- 654,
50
- 730,
51
- 722,
52
- 601,
53
- 590,
54
- 746,
55
- 743,
56
- 269,
57
- 571,
58
- 672,
59
- 565,
60
- 903,
61
- 753,
62
- 765,
63
- 639,
64
- 765,
65
- 644,
66
- 642,
67
- 646,
68
- 744,
69
- 690,
70
- 985,
71
- 682,
72
- 679,
73
- 629,
74
- 365,
75
- 360,
76
- 365,
77
- 471,
78
- 456,
79
- 323,
80
- 562,
81
- 612,
82
- 571,
83
- 612,
84
- 583,
85
- 370,
86
- 613,
87
- 591,
88
- 242,
89
- 242,
90
- 549,
91
- 242,
92
- 876,
93
- 591,
94
- 600,
95
- 612,
96
- 612,
97
- 376,
98
- 528,
99
- 327,
100
- 591,
101
- 562,
102
- 818,
103
- 546,
104
- 562,
105
- 552,
106
- 426,
107
- 333,
108
- 426,
109
- 662
110
- ];
111
- const SEMIBOLD = [
112
- 252,
113
- 321,
114
- 523,
115
- 644,
116
- 650,
117
- 1004,
118
- 663,
119
- 326,
120
- 373,
121
- 373,
122
- 540,
123
- 673,
124
- 319,
125
- 465,
126
- 319,
127
- 379,
128
- 660,
129
- 423,
130
- 623,
131
- 636,
132
- 666,
133
- 629,
134
- 640,
135
- 576,
136
- 640,
137
- 640,
138
- 319,
139
- 329,
140
- 673,
141
- 673,
142
- 673,
143
- 543,
144
- 999,
145
- 728,
146
- 659,
147
- 737,
148
- 722,
149
- 605,
150
- 588,
151
- 749,
152
- 746,
153
- 277,
154
- 580,
155
- 703,
156
- 565,
157
- 922,
158
- 759,
159
- 769,
160
- 645,
161
- 773,
162
- 652,
163
- 650,
164
- 660,
165
- 736,
166
- 728,
167
- 1020,
168
- 720,
169
- 713,
170
- 652,
171
- 373,
172
- 379,
173
- 373,
174
- 481,
175
- 469,
176
- 351,
177
- 574,
178
- 624,
179
- 583,
180
- 624,
181
- 591,
182
- 389,
183
- 625,
184
- 612,
185
- 262,
186
- 262,
187
- 569,
188
- 262,
189
- 900,
190
- 612,
191
- 609,
192
- 624,
193
- 624,
194
- 397,
195
- 549,
196
- 353,
197
- 612,
198
- 587,
199
- 839,
200
- 569,
201
- 588,
202
- 566,
203
- 455,
204
- 359,
205
- 455,
206
- 673
207
- ];
208
- const EXTRA = {
209
- "·": 300,
210
- "×": 670,
211
- "—": 1e3,
212
- "–": 500,
213
- "…": 900,
214
- "≥": 670,
215
- "≤": 670,
216
- "±": 670,
217
- "→": 954,
218
- "−": 662
219
- };
220
- const MARGIN = 1.04;
221
- function textWidth(text, size, face = "sans") {
222
- if (face === "mono") return text.length * .6 * size * MARGIN;
223
- const table = face === "sans-semibold" ? SEMIBOLD : REGULAR;
224
- let units = 0;
225
- for (const ch of text) {
226
- const code = ch.codePointAt(0) ?? 0;
227
- units += code >= 32 && code <= 126 ? table[code - 32] : EXTRA[ch] ?? 620;
228
- }
229
- return units / 1e3 * size * MARGIN;
230
- }
231
- /**
232
- * Breaks `text` into at most `maxLines` lines no wider than `width`. Breaks at
233
- * spaces, and after `-`, `/`, `_` or `·` when a word alone is too wide. A line
234
- * that still overflows the last allowed line ends with an ellipsis; callers put
235
- * the full text in a `<title>` and in the table.
236
- */
237
- function wrap(text, width, size, face = "sans", maxLines = 2) {
238
- const fits = (s) => textWidth(s, size, face) <= width;
239
- const lines = [];
240
- const glue = [];
241
- let line = "";
242
- const breakLine = (next, joiner) => {
243
- lines.push(line);
244
- glue.push(joiner);
245
- line = next;
246
- };
247
- for (const word of text.split(/\s+/).filter(Boolean)) {
248
- const joined = line ? `${line} ${word}` : word;
249
- if (fits(joined)) line = joined;
250
- else if (fits(word)) {
251
- if (line) breakLine(word, " ");
252
- else line = word;
253
- } else {
254
- const pieces = word.match(/[^\-/_·]+[\-/_·]?|[\-/_·]/g) ?? [word];
255
- for (const [index, piece] of pieces.entries()) {
256
- const joiner = index === 0 ? " " : "";
257
- const candidate = line ? `${line}${joiner}${piece}` : piece;
258
- if (fits(candidate) || !line) line = candidate;
259
- else breakLine(piece, joiner);
260
- }
261
- }
262
- }
263
- if (line) lines.push(line);
264
- if (lines.length <= maxLines && lines.every(fits)) return {
265
- lines,
266
- truncated: false
267
- };
268
- const kept = lines.slice(0, maxLines);
269
- let last = lines.slice(maxLines - 1).reduce((acc, l, i) => i === 0 ? l : `${acc}${glue[maxLines - 2 + i]}${l}`, "");
270
- while (last.length > 1 && !fits(`${last}…`)) last = last.slice(0, -1);
271
- kept[maxLines - 1] = `${last.trimEnd()}…`;
272
- return {
273
- lines: kept,
274
- truncated: true
275
- };
276
- }
277
- /** One `<text>` element with a line per `<tspan>`. */
278
- function textBlock(lines, x, y, lineHeight, attrs) {
279
- if (lines.length === 1) return `<text x="${x}" y="${y}" ${attrs}>${esc(lines[0])}</text>`;
280
- return `<text x="${x}" y="${y}" ${attrs}>${lines.map((line, i) => `<tspan x="${x}" dy="${i === 0 ? 0 : lineHeight}">${esc(line)}</tspan>`).join("")}</text>`;
281
- }
282
- /** Joins `parts` with " · ", starting a new line wherever the next part would pass `width`. */
283
- function fitLines(parts, width, size, face = "sans") {
284
- const lines = [];
285
- for (const part of parts) {
286
- const last = lines[lines.length - 1];
287
- if (last !== void 0 && textWidth(`${last} · ${part}`, size, face) <= width) lines[lines.length - 1] = `${last} · ${part}`;
288
- else lines.push(part);
289
- }
290
- return lines;
291
- }
292
- function plural(count, one, many = `${one}s`) {
293
- return count === 1 ? one : many;
294
- }
295
- /** "a, b and c" */
296
- function listJoin(items) {
297
- if (items.length <= 1) return items.join("");
298
- return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
299
- }
300
- function capitalize(text) {
301
- return text.charAt(0).toUpperCase() + text.slice(1);
302
- }
303
- //#endregion
304
- //#region src/svg.ts
305
- /**
306
- * SVG marks. Every mark carries a class and `figure.css` maps classes to the
307
- * `--chart-*` tokens, so a render holds no colour and follows the page theme.
308
- * Geometry stays in attributes, so an SVG shown without the stylesheet keeps
309
- * its layout.
310
- */
311
- const r2 = (n) => Math.round(n * 100) / 100;
312
- function svgRoot(width, height, inner) {
313
- const h = Math.ceil(height);
314
- return `<svg class="tgc-svg" xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}" aria-hidden="true" focusable="false">${inner}</svg>`;
315
- }
316
- /** A horizontal bar anchored at `x`, square at the baseline, 4 px round at the data end. */
317
- function hbar(x, y, w, h, cls) {
318
- if (w <= 0) return "";
319
- const r = Math.min(4, h / 2, w);
320
- return `<path class="${cls}" d="M${r2(x)} ${r2(y)}h${r2(w - r)}a${r} ${r} 0 0 1 ${r} ${r}v${r2(h - 2 * r)}a${r} ${r} 0 0 1 -${r} ${r}h-${r2(w - r)}z"/>`;
321
- }
322
- function rect(x, y, w, h, cls, extra = "") {
323
- if (w <= 0 || h <= 0) return "";
324
- return `<rect class="${cls}" x="${r2(x)}" y="${r2(y)}" width="${r2(w)}" height="${r2(h)}"${extra ? ` ${extra}` : ""}/>`;
325
- }
326
- /**
327
- * 45° hatch lines clipped to a rectangle, as one path `d`. Pure geometry with
328
- * no pattern id, so a hatch survives duplicate SVGs on one page and hidden
329
- * wide/narrow renders. Shared with the React renderers.
330
- */
331
- function hatchPath(x, y, w, h, step = 6) {
332
- if (!(w > 0) || !(h > 0)) return "";
333
- let d = "";
334
- for (let k = step / 2; k < w + h; k += step) {
335
- const x0 = Math.max(0, k - h), x1 = Math.min(w, k);
336
- d += `M${r2(x + x0)} ${r2(y + k - x0)}L${r2(x + x1)} ${r2(y + k - x1)}`;
337
- }
338
- return d;
339
- }
340
- /** A hatched rectangle: a neutral wash plus hatch lines. Missing data is hatched, never drawn as zero. */
341
- function hatch(x, y, w, h, cls) {
342
- if (w <= 0 || h <= 0) return "";
343
- return `<g class="${cls}" data-hatched="true">${rect(x, y, w, h, "tgc-hatch-bg")}<path class="tgc-hatch" d="${hatchPath(x, y, w, h)}"/></g>`;
344
- }
345
- function line(x1, y1, x2, y2, cls) {
346
- return `<line class="${cls}" x1="${r2(x1)}" y1="${r2(y1)}" x2="${r2(x2)}" y2="${r2(y2)}"/>`;
347
- }
348
- function circle(cx, cy, r, cls) {
349
- return `<circle class="${cls}" cx="${r2(cx)}" cy="${r2(cy)}" r="${r}"/>`;
350
- }
351
- const MARKER_SHAPES = [
352
- "circle",
353
- "square",
354
- "triangle",
355
- "diamond"
356
- ];
357
- /** A marker outline as a path `d`, centred on (cx, cy) with radius-like size `r`. Shared with the React renderers. */
358
- function markerPath(shape, cx, cy, r) {
359
- const [x, y] = [r2(cx), r2(cy)];
360
- if (shape === "square") {
361
- const s = r2(r * .9);
362
- return `M${r2(x - s)} ${r2(y - s)}h${r2(2 * s)}v${r2(2 * s)}h${r2(-2 * s)}z`;
363
- }
364
- if (shape === "triangle") {
365
- const s = r2(r * 1.2);
366
- return `M${x} ${r2(y - s)}L${r2(x + s)} ${r2(y + s * .8)}L${r2(x - s)} ${r2(y + s * .8)}z`;
367
- }
368
- if (shape === "diamond") {
369
- const s = r2(r * 1.3);
370
- return `M${x} ${r2(y - s)}L${r2(x + s)} ${y}L${x} ${r2(y + s)}L${r2(x - s)} ${y}z`;
371
- }
372
- return `M${r2(x - r)} ${y}a${r} ${r} 0 1 0 ${r2(2 * r)} 0a${r} ${r} 0 1 0 ${r2(-2 * r)} 0`;
373
- }
374
- function polyline(points, cls) {
375
- return `<polyline class="${cls}" points="${points.map(([x, y]) => `${r2(x)},${r2(y)}`).join(" ")}"/>`;
376
- }
377
- function text(x, y, value, o = {}) {
378
- const anchor = o.anchor && o.anchor !== "start" ? ` text-anchor="${o.anchor}"` : "";
379
- const weight = o.weight === 600 ? ` font-weight="600"` : "";
380
- return `<text class="${o.cls ?? "tgc-ink"}" x="${r2(x)}" y="${r2(y)}" font-size="${o.size ?? 12}"${anchor}${weight}>${esc(value)}</text>`;
381
- }
382
- /** A group whose `<title>` is the hover text for everything inside it. */
383
- function titled(title, inner) {
384
- return `<g><title>${esc(title)}</title>${inner}</g>`;
385
- }
386
- //#endregion
387
- //#region src/table.ts
388
- /** A plain HTML table: the accessible form of a figure and its phone view. */
389
- function table(caption, columns, rows) {
390
- const head = columns.map((c) => `<th scope="col"${c.numeric ? " class=\"tgc-n\"" : ""}>${esc(c.label)}</th>`).join("");
391
- const body = rows.map((row) => `<tr>${row.map((cell, i) => i === 0 ? `<th scope="row">${esc(cell)}</th>` : `<td${columns[i]?.numeric ? " class=\"tgc-n\"" : ""}>${esc(cell)}</td>`).join("")}</tr>`).join("");
392
- return `<table class="tgc-table"><caption>${esc(caption)}</caption><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
393
- }
394
- //#endregion
395
- //#region src/types.ts
396
- function isRefusal(value) {
397
- return "refused" in value;
398
- }
399
- //#endregion
400
- //#region src/events.ts
401
- /**
402
- * Recorded events and spans drawn over a continuous x axis: vertical marks
403
- * (first pass, deadline, death) and shaded bands (time after the first pass,
404
- * idle time, no data). The producer records every position; the library never
405
- * places an event.
406
- */
407
- const MARK_KINDS = [
408
- "pass",
409
- "deadline",
410
- "died",
411
- "event"
412
- ];
413
- const BAND_KINDS = [
414
- "after-pass",
415
- "idle",
416
- "missing"
417
- ];
418
- /** Why the marks or bands cannot be drawn on `domain` (null when they can). Omit `domain` to check shape only. */
419
- function eventsGap(marks = [], bands = [], domain) {
420
- const inside = (value) => !domain || value >= domain[0] && value <= domain[1];
421
- for (const mark of marks) {
422
- if (!mark || typeof mark.label !== "string" || !mark.label.trim()) return "Every mark needs a label.";
423
- if (!MARK_KINDS.includes(mark.kind)) return `${mark.label}: unknown mark kind.`;
424
- if (!Number.isFinite(mark.at) || !inside(mark.at)) return `${mark.label}: mark position must be finite and inside the axis domain.`;
425
- }
426
- for (const band of bands) {
427
- if (!band || typeof band.label !== "string" || !band.label.trim()) return "Every band needs a label.";
428
- if (!BAND_KINDS.includes(band.kind)) return `${band.label}: unknown band kind.`;
429
- if (!Number.isFinite(band.from) || !Number.isFinite(band.to) || band.from >= band.to || !inside(band.from) || !inside(band.to)) return `${band.label}: a band needs finite, increasing bounds inside the axis domain.`;
430
- }
431
- return null;
432
- }
433
- /** Bands sit under the data; a missing band is hatched over a neutral wash. */
434
- function drawBands(bands, px, top, bottom, format) {
435
- return bands.map((band) => {
436
- const x = px(band.from), w = px(band.to) - x, cls = `tgc-band tgc-band-${band.kind}`;
437
- const mark = band.kind === "missing" ? hatch(x, top, w, bottom - top, cls) : rect(x, top, w, bottom - top, cls);
438
- return titled(`${band.label}: ${format(band.from)} to ${format(band.to)}`, `<g data-band="${band.kind}">${mark}</g>`);
439
- }).join("");
440
- }
441
- /** Marks sit over the data; labels stagger in three rows so adjacent events stay readable. */
442
- function drawMarks(marks, px, top, bottom, left, right, format) {
443
- return [...marks].sort((a, b) => a.at - b.at).map((mark, i) => {
444
- const x = px(mark.at), y = top + 12 + i % 3 * 14;
445
- const flip = x > left + (right - left) * .62;
446
- let glyph = line(x, top, x, bottom, `tgc-mark tgc-mark-${mark.kind}`);
447
- if (mark.kind === "died") glyph += line(x - 4, bottom - 8, x + 4, bottom, "tgc-mark tgc-mark-died") + line(x - 4, bottom, x + 4, bottom - 8, "tgc-mark tgc-mark-died");
448
- glyph += text(flip ? x - 4 : x + 4, y, mark.label, {
449
- size: 11,
450
- anchor: flip ? "end" : "start",
451
- cls: "tgc-mark-label"
452
- });
453
- return titled(`${mark.label}: ${format(mark.at)}`, `<g data-mark="${mark.kind}">${glyph}</g>`);
454
- }).join("");
455
- }
456
- /** The table form of marks and bands, in axis order. Empty when there are none. */
457
- function eventsTable(marks, bands, axisLabel, format) {
458
- if (!marks.length && !bands.length) return "";
459
- const rows = [...marks.map((mark) => ({
460
- at: mark.at,
461
- cells: [
462
- mark.label,
463
- mark.kind,
464
- format(mark.at),
465
- ""
466
- ]
467
- })), ...bands.map((band) => ({
468
- at: band.from,
469
- cells: [
470
- band.label,
471
- band.kind === "missing" ? "no data" : band.kind,
472
- format(band.from),
473
- format(band.to)
474
- ]
475
- }))].sort((a, b) => a.at - b.at);
476
- return table(`Recorded events by ${axisLabel}`, [
477
- { label: "Event" },
478
- { label: "Kind" },
479
- {
480
- label: `${axisLabel} from`,
481
- numeric: true
482
- },
483
- {
484
- label: `${axisLabel} to`,
485
- numeric: true
486
- }
487
- ], rows.map((row) => row.cells));
488
- }
489
- const EVENTS_READ = "Vertical rules mark recorded events (solid: pass, dashed: deadline, cross: died, dotted: other); tinted spans are recorded periods and hatched spans have no data.";
490
- //#endregion
491
- export { listJoin as C, wrap as D, textWidth as E, fitLines as S, textBlock as T, svgRoot as _, eventsTable as a, capitalize as b, MARKER_SHAPES as c, hatchPath as d, hbar as f, rect as g, polyline as h, eventsGap as i, circle as l, markerPath as m, drawBands as n, isRefusal as o, line as p, drawMarks as r, table as s, EVENTS_READ as t, hatch as u, text as v, plural as w, esc as x, titled as y };