inkplot-scratch 0.0.0-stage → 1.0.0-beta.1
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/LICENSE +21 -0
- package/README.md +60 -2
- package/bin.mjs +312 -0
- package/fonts/Inter-Bold.otf +0 -0
- package/fonts/Inter-Regular.otf +0 -0
- package/fonts/LICENSE.txt +133 -0
- package/package.json +14 -4
- package/skill/SKILL.md +61 -0
- package/skill/inkplot/SKILL.md +309 -0
- package/skill/inkplot/reference/exports.md +104 -0
- package/skill/inkplot/topics/axes.md +125 -0
- package/skill/inkplot/topics/data.md +141 -0
- package/skill/inkplot/topics/furniture.md +137 -0
- package/skill/inkplot/topics/interaction.md +169 -0
- package/skill/inkplot/topics/lines-bars.md +201 -0
- package/skill/inkplot/topics/markers-color.md +274 -0
- package/skill/inkplot/topics/planes.md +139 -0
- package/skill/inkplot/topics/statistics.md +365 -0
- package/skill/inkplot/topics/styling-output.md +75 -0
- package/skill/inkplot/topics/troubleshooting.md +24 -0
- package/src/app.ts +100 -0
- package/src/figures.ts +105 -0
- package/src/interact.ts +39 -0
- package/src/template.html +38 -0
package/src/figures.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a source module hands over, checked and described: shared by the command (in node) and the app (in a browser),
|
|
3
|
+
* so what the agent is told about a figure is what the person sees.
|
|
4
|
+
*
|
|
5
|
+
* export default [chart1, { name: 'Histogram', caption: 'Bins of 0.5', chart: chart2 }];
|
|
6
|
+
*/
|
|
7
|
+
import { type Axis, type Chart, Plane, SeriesRenderer, StringPlatform, toSvgString } from 'inkplot';
|
|
8
|
+
|
|
9
|
+
export interface Figure {
|
|
10
|
+
/** A file-safe name, numbered in order: `01-histogram`. */
|
|
11
|
+
readonly id: string;
|
|
12
|
+
/** What the figure is called: the `name` given, else the chart's `meta.title`, else `Figure 3`. */
|
|
13
|
+
readonly title: string;
|
|
14
|
+
readonly caption?: string;
|
|
15
|
+
/** Whether the page adds pan/zoom, a crosshair and a tooltip. Default true; `interactive: false` on an item turns it off. */
|
|
16
|
+
readonly interactive: boolean;
|
|
17
|
+
readonly chart: Chart;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `orientation` is the axis's place: bottom, left, top, right on a Cartesian plane, angle or radius on a polar one. */
|
|
21
|
+
export interface AxisFacts { title?: string; orientation: string; min: number; max: number; ticks: string[] }
|
|
22
|
+
export interface SeriesFacts { name: string; points: number; shown: boolean }
|
|
23
|
+
export interface Facts { id: string; title: string; caption?: string; width: number; height: number; series: SeriesFacts[]; axes: AxisFacts[]; problems: string[] }
|
|
24
|
+
|
|
25
|
+
export class FigureError extends Error {}
|
|
26
|
+
|
|
27
|
+
// oxlint-disable-next-line typescript/consistent-type-assertions -- a type guard: the members are probed before use, on a value of unknown type
|
|
28
|
+
const looksLikeChart = (x: unknown): x is Chart => typeof x === 'object' && x !== null && typeof (x as Chart).attach === 'function' && typeof (x as Chart).flush === 'function' && 'attrs' in x;
|
|
29
|
+
const describe = (x: unknown) => (x === null ? 'null' : Array.isArray(x) ? 'an array' : typeof x === 'object' ? `an object${x.constructor?.name && x.constructor.name !== 'Object' ? ` (${x.constructor.name})` : ` with keys ${Object.keys(x).slice(0, 5).join(', ') || '(none)'}`}` : `${typeof x} ${String(x).slice(0, 30)}`);
|
|
30
|
+
const slug = (s: string) => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
|
31
|
+
|
|
32
|
+
/** The figures in a module's default export: an array of charts or `{ chart, name?, caption?, interactive? }`. A lone chart is one figure. */
|
|
33
|
+
export function figuresOf(module: Record<string, unknown>): Figure[] {
|
|
34
|
+
if (!('default' in module)) throw new FigureError('The module has no default export. End the file with: export default [chart1, chart2];');
|
|
35
|
+
const given = module.default;
|
|
36
|
+
const list = Array.isArray(given) ? given : [given];
|
|
37
|
+
if (list.length === 0) throw new FigureError('The default export is an empty array: put at least one chart in it.');
|
|
38
|
+
const width = String(list.length).length < 2 ? 2 : String(list.length).length;
|
|
39
|
+
const taken = new Set<string>(), charts = new Map<unknown, number>();
|
|
40
|
+
return list.map((item, i): Figure => {
|
|
41
|
+
const at = `export default [...] item ${i + 1}`;
|
|
42
|
+
const wrapped = typeof item === 'object' && item !== null && !looksLikeChart(item) && 'chart' in item;
|
|
43
|
+
// oxlint-disable-next-line typescript/consistent-type-assertions -- `wrapped` was checked just above: the item is a `{ chart }` wrapper
|
|
44
|
+
const chart = wrapped ? (item as { chart: unknown }).chart : item;
|
|
45
|
+
if (!looksLikeChart(chart)) {
|
|
46
|
+
throw new FigureError(`${at} is ${describe(chart)}, not a Chart. Each item is a <Chart> / new Chart(...), or { chart, name?, caption? }.`);
|
|
47
|
+
}
|
|
48
|
+
const earlier = charts.get(chart);
|
|
49
|
+
if (earlier !== undefined) throw new FigureError(`${at} is the same chart object as item ${earlier + 1}. One chart can be one figure: build a second one (a function that returns a new chart works well).`);
|
|
50
|
+
charts.set(chart, i);
|
|
51
|
+
// oxlint-disable-next-line typescript/consistent-type-assertions -- the wrapper's fields are read as `unknown` and checked one by one below
|
|
52
|
+
const o = (wrapped ? item : {}) as { name?: unknown; caption?: unknown; interactive?: unknown };
|
|
53
|
+
const title = typeof o.name === 'string' && o.name ? o.name : chart.attrs.meta?.title ?? `Figure ${i + 1}`;
|
|
54
|
+
let id = `${String(i + 1).padStart(width, '0')}-${slug(title) || 'figure'}`;
|
|
55
|
+
while (taken.has(id)) id += '-';
|
|
56
|
+
taken.add(id);
|
|
57
|
+
return { id, title, ...(typeof o.caption === 'string' && { caption: o.caption }), interactive: o.interactive !== false, chart };
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface SizeOptions { width?: number; height?: number }
|
|
62
|
+
|
|
63
|
+
/** The chart as an SVG document, at its own size (the options only fill in what it leaves out). */
|
|
64
|
+
export const svgOf = (figure: Figure, size: SizeOptions = {}): string => toSvgString(figure.chart, size);
|
|
65
|
+
|
|
66
|
+
const round = (n: number) => Number(n.toPrecision(12));
|
|
67
|
+
|
|
68
|
+
/** What the chart holds once laid out. The chart is attached (to `platform`, or a string one) for the look and detached again. */
|
|
69
|
+
export function factsOf(figure: Figure, size: SizeOptions = {}): Facts {
|
|
70
|
+
const { chart } = figure, a = chart.attrs;
|
|
71
|
+
const platform = new StringPlatform({ width: a.width ?? size.width ?? 640, height: a.height ?? size.height ?? 400 });
|
|
72
|
+
chart.attach(platform).flush();
|
|
73
|
+
const series: SeriesFacts[] = [], axes: AxisFacts[] = [], problems: string[] = [], seen = new Set<Axis>();
|
|
74
|
+
let n = 0;
|
|
75
|
+
for (const plane of chart) {
|
|
76
|
+
if (!(plane instanceof Plane)) continue;
|
|
77
|
+
for (const s of plane.allSeries) {
|
|
78
|
+
n++;
|
|
79
|
+
const f = { name: s.attrs.name ?? s.attrs.meta?.title ?? `series ${n}`, points: s.points.count, shown: s.shown };
|
|
80
|
+
series.push(f);
|
|
81
|
+
if (![...s].some((c) => c instanceof SeriesRenderer)) problems.push(`${f.name} has no renderer, so nothing is drawn for it: add one after its data (LinePath, BarPath, BoxPath, a marker, …)`);
|
|
82
|
+
else if (f.shown && f.points === 0) problems.push(`${f.name} has no points: the data is empty, or its domain misses the axis range`);
|
|
83
|
+
}
|
|
84
|
+
for (const axis of plane.axes) {
|
|
85
|
+
if (seen.has(axis)) continue;
|
|
86
|
+
seen.add(axis);
|
|
87
|
+
const ticks = axis.getTicks(axis.ruler.length);
|
|
88
|
+
if (ticks.length < 2) { problems.push(`the ${axis.orientation} axis${axis.attrs.title ? ` "${axis.attrs.title}"` : ''} has fewer than two ticks`); continue; }
|
|
89
|
+
axes.push({ ...(axis.attrs.title && { title: axis.attrs.title }), orientation: axis.orientation, min: round(axis.range.min), max: round(axis.range.max), ticks: ticks.map((t) => t.label) });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const { width, height } = platform.size(), svg = platform.toString();
|
|
93
|
+
chart.detach();
|
|
94
|
+
if (/NaN|Infinity/.test(svg)) problems.push('the SVG contains NaN or Infinity: a value, scale or size is not a finite number');
|
|
95
|
+
return { id: figure.id, title: figure.title, ...(figure.caption !== undefined && { caption: figure.caption }), width, height, series, axes, problems };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The facts as the lines `--check` prints. */
|
|
99
|
+
export function checkText(f: Facts): string {
|
|
100
|
+
const lines = [`${f.id} "${f.title}" ${f.width}x${f.height}`];
|
|
101
|
+
for (const s of f.series) lines.push(` series ${JSON.stringify(s.name)}: ${s.points} points${s.shown ? '' : ' (hidden)'}`);
|
|
102
|
+
for (const a of f.axes) lines.push(` ${a.orientation} axis${a.title ? ` ${JSON.stringify(a.title)}` : ''}: range ${a.min} to ${a.max}; ticks ${a.ticks.length > 12 ? [...a.ticks.slice(0, 6), '…', ...a.ticks.slice(-3)].join(' ') : a.ticks.join(' ')}`);
|
|
103
|
+
for (const p of f.problems) lines.push(` WARNING ${p}`);
|
|
104
|
+
return lines.join('\n');
|
|
105
|
+
}
|
package/src/interact.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The standard interactive elements, added to a chart that is about to go on the page: pan and zoom, drag-a-box zoom, a
|
|
3
|
+
* crosshair and a tooltip. It adds only what the chart does not already have, so a module that sets these up itself is left alone.
|
|
4
|
+
* It runs after the chart has been rendered for export, so no SVG file, PNG or fact ever includes them.
|
|
5
|
+
*/
|
|
6
|
+
import { type AnyElement, BoxZoom, CartesianPlane, type Chart, ChartTooltip, Crosshair, Cursor, PanZoom, Plane } from 'inkplot';
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
/** What was added, by name: for the page's hint line and for tests. */
|
|
10
|
+
export function makeInteractive(chart: Chart): string[] {
|
|
11
|
+
const added = new Set<string>();
|
|
12
|
+
const children = (parent: AnyElement): AnyElement[] => [...parent];
|
|
13
|
+
const planes = children(chart).filter((c): c is Plane => c instanceof Plane);
|
|
14
|
+
for (const plane of planes) {
|
|
15
|
+
const kids = children(plane);
|
|
16
|
+
if (plane instanceof CartesianPlane && !kids.some((k) => k instanceof BoxZoom)) {
|
|
17
|
+
// A box takes a press, so it goes before whatever else does (PanZoom, a Brush, a PointerListener).
|
|
18
|
+
const first = kids.find((k) => k.claimsPress) ?? null;
|
|
19
|
+
// `first` is one of the plane's own children, which the iteration types more loosely than `insertBefore` takes.
|
|
20
|
+
// oxlint-disable-next-line typescript/consistent-type-assertions -- `first` is any child of the plane; `insertBefore` takes the plane's own child union, and the wildcard child cannot be narrowed to it
|
|
21
|
+
plane.insertBefore(new BoxZoom(), first as Parameters<Plane['insertBefore']>[1]);
|
|
22
|
+
added.add('boxzoom');
|
|
23
|
+
}
|
|
24
|
+
if (!kids.some((k) => k instanceof PanZoom)) { plane.append(new PanZoom()); added.add('panzoom'); }
|
|
25
|
+
}
|
|
26
|
+
if (planes.length > 0 && !children(chart).some((c) => c instanceof Cursor)) {
|
|
27
|
+
chart.append(new Cursor({}, new Crosshair(), new ChartTooltip()));
|
|
28
|
+
added.add('cursor');
|
|
29
|
+
}
|
|
30
|
+
return [...added];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The hint under a figure's title for what is now on it. */
|
|
34
|
+
export function hintFor(added: readonly string[]): string {
|
|
35
|
+
const parts: string[] = [];
|
|
36
|
+
if (added.includes('panzoom')) parts.push('drag to pan', 'scroll to zoom', 'double-click to reset');
|
|
37
|
+
if (added.includes('boxzoom')) parts.push('Shift-drag for a zoom box');
|
|
38
|
+
return parts.length ? parts.join(' · ') : '';
|
|
39
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Charts</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: light dark; --bg: #f6f7f9; --card: #fff; --ink: #1d2733; --muted: #5d6b7a; --line: #d9dee5; --accent: #2a6df4; --warn: #9a5b00; }
|
|
9
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #14181d; --card: #1d232b; --ink: #e6ebf1; --muted: #9aa7b5; --line: #323b46; --accent: #7aa7ff; --warn: #f0b45a; } }
|
|
10
|
+
* { box-sizing: border-box; }
|
|
11
|
+
body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 system-ui, sans-serif; }
|
|
12
|
+
main { max-width: 1040px; margin: 0 auto; padding: 16px; }
|
|
13
|
+
.top { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; justify-content: space-between; margin: 8px 0 16px; }
|
|
14
|
+
h1 { font-size: 22px; margin: 0; } h2 { font-size: 17px; margin: 0; }
|
|
15
|
+
.figure { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px 16px; margin: 0 0 18px; }
|
|
16
|
+
.figure header { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
|
17
|
+
.buttons { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
18
|
+
button { font: inherit; font-size: 13px; padding: 4px 10px; border-radius: 6px; border: 1px solid var(--line); background: transparent; color: var(--ink); cursor: pointer; }
|
|
19
|
+
button:hover { border-color: var(--accent); color: var(--accent); }
|
|
20
|
+
.plot { width: 100%; background: #fff; border-radius: 6px; }
|
|
21
|
+
.caption { color: var(--muted); margin: 0 0 8px; }
|
|
22
|
+
.hint { color: var(--muted); font-size: 12.5px; margin: 0 0 6px; }
|
|
23
|
+
.stats { margin: 10px 0 0; padding: 10px; background: var(--bg); border-radius: 6px; font-size: 12.5px; overflow: auto; }
|
|
24
|
+
.warning { color: var(--warn); margin: 8px 0 0; }
|
|
25
|
+
.error { color: #b3261e; white-space: pre-wrap; }
|
|
26
|
+
#note { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); background: var(--ink); color: var(--bg); padding: 8px 14px; border-radius: 8px; opacity: 0; transition: opacity .2s; pointer-events: none; max-width: 90vw; }
|
|
27
|
+
#note.on { opacity: 1; }
|
|
28
|
+
</style>
|
|
29
|
+
</head>
|
|
30
|
+
<body>
|
|
31
|
+
<main>
|
|
32
|
+
<div class="top"><h1 id="title">Charts</h1><div class="buttons"><button id="all-svg" type="button">All SVG ↓</button><button id="all-png" type="button">All PNG ↓</button></div></div>
|
|
33
|
+
<div id="figures"></div>
|
|
34
|
+
</main>
|
|
35
|
+
<div id="note" role="status" aria-live="polite"></div>
|
|
36
|
+
<script>/*SCRIPT*/</script>
|
|
37
|
+
</body>
|
|
38
|
+
</html>
|