inkplot 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.
Files changed (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -2
  3. package/dist/adapt.d.ts +10 -0
  4. package/dist/adapt.js +34 -0
  5. package/dist/annotation.d.ts +120 -0
  6. package/dist/annotation.js +267 -0
  7. package/dist/aria.d.ts +24 -0
  8. package/dist/aria.js +31 -0
  9. package/dist/attrs.d.ts +81 -0
  10. package/dist/attrs.js +84 -0
  11. package/dist/axis.d.ts +137 -0
  12. package/dist/axis.js +185 -0
  13. package/dist/bars.d.ts +80 -0
  14. package/dist/bars.js +255 -0
  15. package/dist/boxplot.d.ts +57 -0
  16. package/dist/boxplot.js +177 -0
  17. package/dist/boxzoom.d.ts +39 -0
  18. package/dist/boxzoom.js +138 -0
  19. package/dist/brush.d.ts +60 -0
  20. package/dist/brush.js +185 -0
  21. package/dist/bucket.d.ts +22 -0
  22. package/dist/bucket.js +62 -0
  23. package/dist/callout.d.ts +30 -0
  24. package/dist/callout.js +98 -0
  25. package/dist/chart.d.ts +126 -0
  26. package/dist/chart.js +257 -0
  27. package/dist/crosshair.d.ts +28 -0
  28. package/dist/crosshair.js +78 -0
  29. package/dist/cursor.d.ts +76 -0
  30. package/dist/cursor.js +344 -0
  31. package/dist/curve.d.ts +50 -0
  32. package/dist/curve.js +87 -0
  33. package/dist/curves.d.ts +104 -0
  34. package/dist/curves.js +375 -0
  35. package/dist/data.d.ts +209 -0
  36. package/dist/data.js +276 -0
  37. package/dist/decimation.d.ts +86 -0
  38. package/dist/decimation.js +410 -0
  39. package/dist/density.d.ts +55 -0
  40. package/dist/density.js +252 -0
  41. package/dist/dimension.d.ts +103 -0
  42. package/dist/dimension.js +205 -0
  43. package/dist/dom-platform.d.ts +53 -0
  44. package/dist/dom-platform.js +190 -0
  45. package/dist/element.d.ts +232 -0
  46. package/dist/element.js +414 -0
  47. package/dist/errors.d.ts +85 -0
  48. package/dist/errors.js +237 -0
  49. package/dist/events.d.ts +144 -0
  50. package/dist/events.js +19 -0
  51. package/dist/focus.d.ts +22 -0
  52. package/dist/focus.js +51 -0
  53. package/dist/furniture.d.ts +70 -0
  54. package/dist/furniture.js +166 -0
  55. package/dist/geometry.d.ts +40 -0
  56. package/dist/geometry.js +7 -0
  57. package/dist/heatmap.d.ts +57 -0
  58. package/dist/heatmap.js +207 -0
  59. package/dist/hit.d.ts +103 -0
  60. package/dist/hit.js +428 -0
  61. package/dist/index.d.ts +73 -0
  62. package/dist/index.js +73 -0
  63. package/dist/input.d.ts +42 -0
  64. package/dist/input.js +166 -0
  65. package/dist/jsx-dev-runtime.d.ts +9 -0
  66. package/dist/jsx-dev-runtime.js +13 -0
  67. package/dist/jsx-runtime.d.ts +59 -0
  68. package/dist/jsx-runtime.js +81 -0
  69. package/dist/key.d.ts +118 -0
  70. package/dist/key.js +199 -0
  71. package/dist/legend.d.ts +36 -0
  72. package/dist/legend.js +220 -0
  73. package/dist/mapped.d.ts +80 -0
  74. package/dist/mapped.js +106 -0
  75. package/dist/marker.d.ts +143 -0
  76. package/dist/marker.js +213 -0
  77. package/dist/panzoom.d.ts +119 -0
  78. package/dist/panzoom.js +467 -0
  79. package/dist/plane.d.ts +173 -0
  80. package/dist/plane.js +370 -0
  81. package/dist/platform.d.ts +113 -0
  82. package/dist/platform.js +80 -0
  83. package/dist/png.d.ts +44 -0
  84. package/dist/png.js +71 -0
  85. package/dist/pointer.d.ts +54 -0
  86. package/dist/pointer.js +132 -0
  87. package/dist/quantile.d.ts +3 -0
  88. package/dist/quantile.js +11 -0
  89. package/dist/radial.d.ts +116 -0
  90. package/dist/radial.js +336 -0
  91. package/dist/react.d.ts +441 -0
  92. package/dist/react.js +252 -0
  93. package/dist/registry.d.ts +2 -0
  94. package/dist/registry.js +51 -0
  95. package/dist/renderer.d.ts +191 -0
  96. package/dist/renderer.js +164 -0
  97. package/dist/scale.d.ts +37 -0
  98. package/dist/scale.js +19 -0
  99. package/dist/scales.d.ts +69 -0
  100. package/dist/scales.js +134 -0
  101. package/dist/serialize.d.ts +77 -0
  102. package/dist/serialize.js +321 -0
  103. package/dist/series.d.ts +304 -0
  104. package/dist/series.js +513 -0
  105. package/dist/simple.d.ts +35 -0
  106. package/dist/simple.js +20 -0
  107. package/dist/sources.d.ts +116 -0
  108. package/dist/sources.js +237 -0
  109. package/dist/stack.d.ts +61 -0
  110. package/dist/stack.js +172 -0
  111. package/dist/stats.d.ts +38 -0
  112. package/dist/stats.js +159 -0
  113. package/dist/steps.d.ts +50 -0
  114. package/dist/steps.js +154 -0
  115. package/dist/stub.d.ts +3 -0
  116. package/dist/stub.js +5 -0
  117. package/dist/style-names.d.ts +1 -0
  118. package/dist/style-names.js +6 -0
  119. package/dist/summary.d.ts +44 -0
  120. package/dist/summary.js +58 -0
  121. package/dist/surface.d.ts +94 -0
  122. package/dist/surface.js +145 -0
  123. package/dist/table.d.ts +39 -0
  124. package/dist/table.js +37 -0
  125. package/dist/text.d.ts +23 -0
  126. package/dist/text.js +44 -0
  127. package/dist/theme.d.ts +174 -0
  128. package/dist/theme.js +73 -0
  129. package/dist/themes.d.ts +9 -0
  130. package/dist/themes.js +56 -0
  131. package/dist/time-intl.d.ts +194 -0
  132. package/dist/time-intl.js +144 -0
  133. package/dist/time-temporal.d.ts +236 -0
  134. package/dist/time-temporal.js +58 -0
  135. package/dist/time.d.ts +216 -0
  136. package/dist/time.js +154 -0
  137. package/dist/trend.d.ts +151 -0
  138. package/dist/trend.js +500 -0
  139. package/dist/web-components.d.ts +62 -0
  140. package/dist/web-components.js +264 -0
  141. package/package.json +53 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Neofox Solutions LLP and Hamish Milne
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,47 @@
1
- # Temporary Holding Version
1
+ # inkplot
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A small chart library for the web and for servers. A chart is a **tree of elements you keep** (`Chart` → plane → axes and series → data and renderers) that draws SVG through a platform. It is tree-shakeable, has no dependencies, and a minimal chart is about 15 KB gzipped, growing only with the features you import.
4
+
5
+ > **Beta.** `1.0.0-beta.1` is feature-complete and is out for user testing. Install it with the `beta` tag.
6
+
7
+ ```sh
8
+ npm install inkplot@beta
9
+ ```
10
+
11
+ ```tsx
12
+ import { Axis, CartesianPlane, Chart, DomainSeries, FlatDataSource, LinePath, LinearCurve, LinearScale, lightTheme } from 'inkplot';
13
+
14
+ const chart = (
15
+ <Chart theme={lightTheme}>
16
+ <CartesianPlane>
17
+ <Axis orientation="bottom"><LinearScale /></Axis>
18
+ <Axis orientation="left"><LinearScale /></Axis>
19
+ <DomainSeries name="Load">
20
+ <FlatDataSource data={[3, 5, 4, 8, 6, 9]} regularDomain-start={0} regularDomain-step={1} />
21
+ <LinePath><LinearCurve /></LinePath>
22
+ </DomainSeries>
23
+ </CartesianPlane>
24
+ </Chart>
25
+ );
26
+ ```
27
+
28
+ The same chart can be written without JSX (`new Chart(attrs, ...children)`), and there are adapters for React (`inkplot/react`) and custom elements (`inkplot/web-components`).
29
+
30
+ ## Documentation
31
+
32
+ - [Quick start](https://github.com/neofox-solutions/inkplot/blob/main/docs/start/quick-start.md): install, your first chart, and the three ways to write one.
33
+ - [Concepts](https://github.com/neofox-solutions/inkplot/blob/main/docs/start/concepts.md) and [recipes](https://github.com/neofox-solutions/inkplot/blob/main/docs/start/recipes.md).
34
+ - [All the docs](https://github.com/neofox-solutions/inkplot/blob/main/docs/README.md): guide, extending and reference.
35
+ - [SKILL.md](https://github.com/neofox-solutions/inkplot/blob/main/docs/SKILL.md): a brief for an LLM that writes inkplot code.
36
+
37
+ ## inkplot-scratch
38
+
39
+ `inkplot-scratch` is a command-line tool for turning a module that exports inkplot charts into SVG, PNG, JSON facts or a single-file interactive HTML page, with a skill for agents doing data work. To use it in Claude, download `inkplot-data-charts-<version>.zip` from the [latest release](https://github.com/neofox-solutions/inkplot/releases) and upload it under Customize → Skills. See [scratch/](https://github.com/neofox-solutions/inkplot/tree/main/scratch).
40
+
41
+ ## Developing
42
+
43
+ Node 26 or later. `npm ci`, then `npm run all` runs the type check, lint, tests, docs checks, and the browser, PNG and packaging tests that CI runs.
44
+
45
+ ## Licence
46
+
47
+ MIT
@@ -0,0 +1,10 @@
1
+ /** What the adapters (React, Web Components) share: writing only the attributes that differ. */
2
+ import { expandAttributes } from './attrs.ts';
3
+ type Plain = Record<string, unknown>;
4
+ /** Same value, or same content for plain objects and arrays (not typed arrays, elements or anything else with an identity). */
5
+ export declare function same(a: unknown, b: unknown): boolean;
6
+ /** What to change to get from `prev` to `next`: changed values, and `undefined` for what is gone. Null when nothing differs. */
7
+ export declare function difference(prev: Plain, next: Plain): Plain | null;
8
+ /** The element's attributes from props: nested names expanded, and what is `undefined` left out. */
9
+ export declare function attributesOf(props: Plain, options?: Parameters<typeof expandAttributes>[1]): Plain;
10
+ export {};
package/dist/adapt.js ADDED
@@ -0,0 +1,34 @@
1
+ /** What the adapters (React, Web Components) share: writing only the attributes that differ. */
2
+ import { expandAttributes } from './attrs.js';
3
+ const isPlain = (v) => typeof v === 'object' && v !== null && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
4
+ /** Same value, or same content for plain objects and arrays (not typed arrays, elements or anything else with an identity). */
5
+ export function same(a, b) {
6
+ if (Object.is(a, b))
7
+ return true;
8
+ if (Array.isArray(a) && Array.isArray(b))
9
+ return a.length === b.length && a.every((v, i) => same(v, b[i]));
10
+ if (isPlain(a) && isPlain(b)) {
11
+ const keys = Object.keys(a);
12
+ return keys.length === Object.keys(b).length && keys.every((k) => Object.hasOwn(b, k) && same(a[k], b[k]));
13
+ }
14
+ return false;
15
+ }
16
+ /** What to change to get from `prev` to `next`: changed values, and `undefined` for what is gone. Null when nothing differs. */
17
+ export function difference(prev, next) {
18
+ let patch = null;
19
+ for (const k of Object.keys(next))
20
+ if (!same(prev[k], next[k]))
21
+ (patch ??= {})[k] = next[k];
22
+ for (const k of Object.keys(prev))
23
+ if (!(k in next))
24
+ (patch ??= {})[k] = undefined;
25
+ return patch;
26
+ }
27
+ /** The element's attributes from props: nested names expanded, and what is `undefined` left out. */
28
+ export function attributesOf(props, options) {
29
+ const attrs = expandAttributes(props, options);
30
+ for (const k of Object.keys(attrs))
31
+ if (attrs[k] === undefined)
32
+ delete attrs[k];
33
+ return attrs;
34
+ }
@@ -0,0 +1,120 @@
1
+ import { Axis } from './axis.ts';
2
+ import { type BaseAttributes, ChartElement, type ElementRef } from './element.ts';
3
+ import { type ChartEvent } from './events.ts';
4
+ import { type Rect } from './geometry.ts';
5
+ import type { Plane } from './plane.ts';
6
+ export interface LineAnnotationCommon extends BaseAttributes {
7
+ /** Default: the plane's first axis, as for a series. */
8
+ domainAxis?: ElementRef<Axis>;
9
+ /** Default: the plane's second axis. */
10
+ valueAxis?: ElementRef<Axis>;
11
+ /** Text beside the line. Where along the line it sits is the style property `align` of `annotationLabel`. */
12
+ label?: string;
13
+ /** Line colour: the same as `style: { annotationLine: { color } }`, set directly. */
14
+ stroke?: string;
15
+ /** Line width in pixels: the same as `style: { annotationLine: { width } }`, set directly. */
16
+ strokeWidth?: number;
17
+ /** A horizontal line at this value (give one of `value`, `domain`, or `from` and `to`). */
18
+ value?: number;
19
+ /** A vertical line at this domain (give one of `value`, `domain`, or `from` and `to`). */
20
+ domain?: number;
21
+ /** One end of a diagonal line, as `[domain, value]` (with `to`). */
22
+ from?: readonly [domain: number, value: number];
23
+ /** The other end of a diagonal line, as `[domain, value]` (with `from`). */
24
+ to?: readonly [domain: number, value: number];
25
+ }
26
+ /**
27
+ * Which line: give one of
28
+ * - `value`: constant value across the whole domain (horizontal on a rectangle);
29
+ * - `domain`: constant domain across the whole value range (vertical on a rectangle, a spoke on a radial plane);
30
+ * - `from` and `to`: straight between two `[domain, value]` points in data space (diagonal).
31
+ */
32
+ export type LineAnnotationAttributes = LineAnnotationCommon & ({
33
+ value: number;
34
+ domain?: undefined;
35
+ from?: undefined;
36
+ to?: undefined;
37
+ } | {
38
+ domain: number;
39
+ value?: undefined;
40
+ from?: undefined;
41
+ to?: undefined;
42
+ } | {
43
+ from: readonly [domain: number, value: number];
44
+ to: readonly [domain: number, value: number];
45
+ value?: undefined;
46
+ domain?: undefined;
47
+ });
48
+ /**
49
+ * A reference line in data space: a target, a threshold, a trend. It is traced as a polyline through the plane, with as
50
+ * few points as the plane allows: two when the line stays straight (anything axis-aligned on a rectangle, a diagonal
51
+ * between linear scales), enough to follow the curve otherwise, and for a radar just the corners, at its spokes. The line is clipped to the plot; its label is not.
52
+ */
53
+ export declare class LineAnnotation extends ChartElement<Plane, [], LineAnnotationAttributes> {
54
+ #private;
55
+ static attrTypes: {
56
+ domainAxis: 1;
57
+ label: 1;
58
+ stroke: 1;
59
+ strokeWidth: 2;
60
+ valueAxis: 1;
61
+ id: 1;
62
+ meta: {
63
+ title: 1;
64
+ description: 1;
65
+ role: 1;
66
+ };
67
+ };
68
+ /** The line as placed points in plot pixels, `[x, y]` per row, in order along the line; NaN where there is none. */
69
+ private trace;
70
+ onEvent(ev: ChartEvent): void;
71
+ }
72
+ export interface CalloutAnnotationAttributes extends BaseAttributes {
73
+ /** Default: the plane's first axis. */
74
+ domainAxis?: ElementRef<Axis>;
75
+ /** Default: the plane's second axis. */
76
+ valueAxis?: ElementRef<Axis>;
77
+ /** The point being called out, in data space. */
78
+ domain: number;
79
+ /** The value of the point being called out. */
80
+ value: number;
81
+ /** What it says. A '\n' starts another line; `align` (style) says how the lines sit in the box. */
82
+ text: string;
83
+ /**
84
+ * Where the middle of the box is, in pixels from the point. Leave both out to have it placed automatically, in the
85
+ * emptiest nearby spot; give either (the other is 0) to place it yourself.
86
+ */
87
+ dx?: number;
88
+ /** Pixels down from the point to the text box (negative is up). */
89
+ dy?: number;
90
+ }
91
+ /**
92
+ * A note attached to a data point by an arm. Its look is four selectors (the text, the box, the arm and the callout's
93
+ * own metrics), and `outline` (in `callout`) chooses between a full border, only the edge the arm meets, or none.
94
+ *
95
+ * Placement: with no offset given, the box is tried in the eight directions round the point and the spot with the least
96
+ * trouble wins: parts of the box outside the plot, series points under it, and earlier callouts it would cover, with a
97
+ * slight preference for the upper right. It is a heuristic, which is why `dx` and `dy` exist.
98
+ */
99
+ export declare class CalloutAnnotation extends ChartElement<Plane, [], CalloutAnnotationAttributes> {
100
+ #private;
101
+ static attrTypes: {
102
+ domainAxis: 1;
103
+ text: 1;
104
+ valueAxis: 1;
105
+ id: 1;
106
+ meta: {
107
+ title: 1;
108
+ description: 1;
109
+ role: 1;
110
+ };
111
+ };
112
+ /** Where the box was drawn, in plot pixels; null while it is not (point out of view). Later callouts keep clear of it. */
113
+ get box(): Readonly<Rect> | null;
114
+ /** The data point in plot pixels, or null when it is off the plot. */
115
+ private locate;
116
+ /** How bad a box is as a place for a callout: lower is better. */
117
+ private trouble;
118
+ onEvent(ev: ChartEvent): void;
119
+ private paint;
120
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Annotations: marks that sit in a plane and are positioned in *data* space.
3
+ *
4
+ * Why data space: an annotation says "the target is 40" or "this point is interesting", not "pixel 212". So both kinds
5
+ * here take values on the plane's axes and ask the plane where they are: the axes turn values into fractions, and the
6
+ * plane's `place` turns fractions into pixels, exactly as for a series. Nothing here knows what shape the plane is, so a
7
+ * line at a constant value is a horizontal line on a rectangle, a circle on a polar plane, and a polygon on a radar.
8
+ *
9
+ * Both are children of the plane, drawn after the series (so over them) and redrawn every flush, which is what keeps them
10
+ * on their data while the view pans and zooms.
11
+ */
12
+ /* oxlint-disable typescript/no-non-null-assertion -- flat [x, y] buffers walked in steps of two; every index is bounded by the buffer length */
13
+ import { STRING, NUMBER } from './attrs.js';
14
+ import { Axis } from './axis.js';
15
+ import { ChartElement, fail, resolveRef } from './element.js';
16
+ import { EVENT_ATTACH, EVENT_PAINT } from './events.js';
17
+ import { f2 } from './geometry.js';
18
+ import { calloutSize, clearCallout, paintCallout } from './callout.js';
19
+ import { applyText } from './text.js';
20
+ /** Points used to trace a line that has to bend with the plane (a circle, a diagonal on a log axis), per sweep. */
21
+ const CURVE_STEPS = 96;
22
+ /** Space between a line and its label, in pixels. */
23
+ const LABEL_GAP = 4;
24
+ /** Where a pair of axes comes from: named by the annotation, or the plane's own first and second. */
25
+ function axesOf(element, plane, domainAxis, valueAxis) {
26
+ return [
27
+ resolveRef(domainAxis, element, Axis) ?? plane.defaultAxis('first') ?? fail('plane has no axis for the domain'),
28
+ resolveRef(valueAxis, element, Axis) ?? plane.defaultAxis('second') ?? fail('plane has no axis for the value'),
29
+ ];
30
+ }
31
+ /**
32
+ * A reference line in data space: a target, a threshold, a trend. It is traced as a polyline through the plane, with as
33
+ * few points as the plane allows: two when the line stays straight (anything axis-aligned on a rectangle, a diagonal
34
+ * between linear scales), enough to follow the curve otherwise, and for a radar just the corners, at its spokes. The line is clipped to the plot; its label is not.
35
+ */
36
+ export class LineAnnotation extends ChartElement {
37
+ static attrTypes = { ...ChartElement.attrTypes, domainAxis: STRING, label: STRING, stroke: STRING, strokeWidth: NUMBER, valueAxis: STRING };
38
+ #label = false;
39
+ /** The line as placed points in plot pixels, `[x, y]` per row, in order along the line; NaN where there is none. */
40
+ trace(plane, first, second) {
41
+ const a = this.attrs;
42
+ let fractions, buf;
43
+ if (a.value !== undefined) {
44
+ // Constant value: run along the first axis. Where the plane bends, sample it, and put a point on every corner.
45
+ const f = second.fractionOf(a.value);
46
+ if (!(Number.isFinite(f)))
47
+ return null;
48
+ fractions = plane.curved ? along(first, plane) : [0, 1];
49
+ buf = new Float64Array(fractions.length * 2);
50
+ fractions.forEach((t, i) => { buf[2 * i] = t; buf[2 * i + 1] = f; });
51
+ }
52
+ else if (a.domain !== undefined) {
53
+ const f = first.fractionOf(a.domain);
54
+ if (!(Number.isFinite(f)))
55
+ return null;
56
+ buf = new Float64Array([f, 0, f, 1]);
57
+ }
58
+ else if (a.from && a.to) {
59
+ // Straight in data space, so interpolate there; it only stays straight on the page if nothing bends it.
60
+ const [d0, v0] = a.from, [d1, v1] = a.to;
61
+ const n = plane.curved || !first.scale.isLinear || !second.scale.isLinear ? CURVE_STEPS + 1 : 2;
62
+ buf = new Float64Array(n * 2);
63
+ for (let i = 0; i < n; i++) {
64
+ const t = i / (n - 1);
65
+ buf[2 * i] = d0 + t * (d1 - d0);
66
+ buf[2 * i + 1] = v0 + t * (v1 - v0);
67
+ }
68
+ first.normalize(buf, n, 2, 0);
69
+ second.normalize(buf, n, 2, 1);
70
+ }
71
+ else {
72
+ return null; // the attribute type allows nothing else; a hand-built JSON spec with none of them draws nothing
73
+ }
74
+ plane.place({ data: buf, stride: 2, count: buf.length / 2 }, first, second);
75
+ return buf;
76
+ }
77
+ onEvent(ev) {
78
+ if (ev.type !== EVENT_PAINT)
79
+ return;
80
+ const plane = this.requireParent(), s = this.surface, a = this.attrs;
81
+ const [first, second] = axesOf(this, plane, a.domainAxis, a.valueAxis);
82
+ const pts = this.trace(plane, first, second);
83
+ const look = this.styleFor(['annotationLine', 'line'], { color: a.stroke, width: a.strokeWidth });
84
+ let d = '', pen = false;
85
+ if (pts)
86
+ for (let i = 0; i < pts.length; i += 2) {
87
+ const x = pts[i], y = pts[i + 1];
88
+ if (!(Number.isFinite(x) && Number.isFinite(y))) {
89
+ pen = false;
90
+ continue;
91
+ }
92
+ d += (pen ? 'L' : 'M') + f2(x) + ' ' + f2(y);
93
+ pen = true;
94
+ }
95
+ const line = s.node('line', 'path');
96
+ s.set(line, 'd', d);
97
+ s.set(line, 'fill', 'none');
98
+ s.set(line, 'stroke', look.color);
99
+ s.set(line, 'stroke-width', look.width);
100
+ s.set(line, 'stroke-dasharray', look.dash);
101
+ s.set(line, 'opacity', look.opacity);
102
+ s.set(line, 'clip-path', plane.clipUrl ?? 'none');
103
+ if (!a.label || !pts) {
104
+ if (this.#label) {
105
+ s.remove('label');
106
+ this.#label = false;
107
+ }
108
+ return;
109
+ }
110
+ const text = this.styleFor(['annotationLabel', 'annotationLine', 'text'], { color: a.stroke });
111
+ const at = pointAlong(pts, text.align === 'start' ? 0 : text.align === 'middle' ? 0.5 : 1);
112
+ if (!at)
113
+ return;
114
+ // The label sits on the side of the line that faces up (or, for a vertical line, right), which on a circle is outwards.
115
+ let nx = at.ty, ny = -at.tx;
116
+ if (ny > 1e-6 || (Math.abs(ny) <= 1e-6 && nx < 0)) {
117
+ nx = -nx;
118
+ ny = -ny;
119
+ }
120
+ const n = s.node('label', 'text');
121
+ this.#label = true;
122
+ applyText(s, n, text, Math.abs(nx) > 0.5 ? (nx > 0 ? 'start' : 'end') : text.align);
123
+ s.set(n, 'x', f2(at.x + nx * LABEL_GAP));
124
+ s.set(n, 'y', f2(at.y + ny * LABEL_GAP + (ny > 0.3 ? text.size * 0.8 : ny < -0.3 ? 0 : text.size * 0.35)));
125
+ s.text(n, a.label);
126
+ }
127
+ }
128
+ /**
129
+ * Fractions along an axis to trace a line that runs along it at. A plane that says where it bends (a radar) is straight
130
+ * between those places, so they are all that is needed and the line is exact; any other curved plane is sampled evenly.
131
+ */
132
+ function along(axis, plane) {
133
+ const out = new Set([0, 1]);
134
+ const corners = plane.breaks(axis);
135
+ if (corners.length > 0)
136
+ for (const b of corners) {
137
+ if (b >= 0 && b <= 1)
138
+ out.add(Math.round(b * 1e9) / 1e9);
139
+ }
140
+ else
141
+ for (let i = 1; i < CURVE_STEPS; i++)
142
+ out.add(i / CURVE_STEPS);
143
+ return [...out].sort((p, q) => p - q);
144
+ }
145
+ /** The point a fraction of the way along a polyline's length, and the direction it is heading there. Null without two real points. */
146
+ function pointAlong(pts, fraction) {
147
+ const n = pts.length / 2, real = (i) => Number.isFinite(pts[2 * i]) && Number.isFinite(pts[2 * i + 1]);
148
+ let total = 0;
149
+ for (let i = 1; i < n; i++)
150
+ if (real(i) && real(i - 1))
151
+ total += Math.hypot(pts[2 * i] - pts[2 * i - 2], pts[2 * i + 1] - pts[2 * i - 1]);
152
+ if (!(total > 0))
153
+ return null;
154
+ let target = fraction * total;
155
+ for (let i = 1; i < n; i++) {
156
+ if (!real(i) || !real(i - 1))
157
+ continue;
158
+ const x0 = pts[2 * i - 2], y0 = pts[2 * i - 1], dx = pts[2 * i] - x0, dy = pts[2 * i + 1] - y0, len = Math.hypot(dx, dy);
159
+ if (target <= len || i === n - 1) {
160
+ const t = len > 0 ? Math.min(1, target / len) : 0;
161
+ return { x: x0 + t * dx, y: y0 + t * dy, tx: len > 0 ? dx / len : 1, ty: len > 0 ? dy / len : 0 };
162
+ }
163
+ target -= len;
164
+ }
165
+ return null;
166
+ }
167
+ /** The eight places an automatically placed box may go, as the direction from the point, in order of preference. */
168
+ const DIRECTIONS = [[1, -1], [-1, -1], [1, 0], [-1, 0], [1, 1], [-1, 1], [0, -1], [0, 1]];
169
+ /** The most series points looked at when judging a spot: more are skipped evenly. */
170
+ const MAX_PROBES = 2000;
171
+ /**
172
+ * A note attached to a data point by an arm. Its look is four selectors (the text, the box, the arm and the callout's
173
+ * own metrics), and `outline` (in `callout`) chooses between a full border, only the edge the arm meets, or none.
174
+ *
175
+ * Placement: with no offset given, the box is tried in the eight directions round the point and the spot with the least
176
+ * trouble wins: parts of the box outside the plot, series points under it, and earlier callouts it would cover, with a
177
+ * slight preference for the upper right. It is a heuristic, which is why `dx` and `dy` exist.
178
+ */
179
+ export class CalloutAnnotation extends ChartElement {
180
+ static attrTypes = { ...ChartElement.attrTypes, domainAxis: STRING, text: STRING, valueAxis: STRING };
181
+ #box = null;
182
+ #lines = 0;
183
+ /** Where the box was drawn, in plot pixels; null while it is not (point out of view). Later callouts keep clear of it. */
184
+ get box() { return this.#box; }
185
+ /** The data point in plot pixels, or null when it is off the plot. */
186
+ locate(plane, first, second) {
187
+ const a = this.attrs, buf = new Float64Array([a.domain, a.value]);
188
+ first.normalize(buf, 1, 2, 0);
189
+ second.normalize(buf, 1, 2, 1);
190
+ plane.place({ data: buf, stride: 2, count: 1 }, first, second);
191
+ const [x, y] = [buf[0], buf[1]], r = plane.rect;
192
+ return x >= 0 && x <= r.width && y >= 0 && y <= r.height ? [x, y] : null;
193
+ }
194
+ /** How bad a box is as a place for a callout: lower is better. */
195
+ trouble(plane, box, order) {
196
+ const r = plane.rect;
197
+ // Outside the plot: by how much of its area.
198
+ const inX = Math.max(0, Math.min(box.x + box.width, r.width) - Math.max(box.x, 0));
199
+ const inY = Math.max(0, Math.min(box.y + box.height, r.height) - Math.max(box.y, 0));
200
+ let score = (1 - (inX * inY) / (box.width * box.height)) * 1000;
201
+ // Series points under it.
202
+ for (const s of plane.series) {
203
+ const { data, count, stride } = s.points, step = Math.max(1, Math.ceil(count / MAX_PROBES));
204
+ for (let i = 0; i < count; i += step) {
205
+ const x = data[i * stride], y = data[i * stride + 1];
206
+ if (x >= box.x - 2 && x <= box.x + box.width + 2 && y >= box.y - 2 && y <= box.y + box.height + 2)
207
+ score += step;
208
+ }
209
+ }
210
+ // Earlier callouts: much worse than a point.
211
+ for (const c of plane) {
212
+ if (c === this)
213
+ break;
214
+ const o = c instanceof CalloutAnnotation ? c.box : null;
215
+ if (o && box.x < o.x + o.width && box.x + box.width > o.x && box.y < o.y + o.height && box.y + box.height > o.y)
216
+ score += 500;
217
+ }
218
+ return score + order * 0.01;
219
+ }
220
+ onEvent(ev) {
221
+ switch (ev.type) {
222
+ case EVENT_ATTACH:
223
+ this.surface.wrapper(); // carries the text style, which every line inherits
224
+ break;
225
+ case EVENT_PAINT:
226
+ this.paint();
227
+ break;
228
+ }
229
+ }
230
+ paint() {
231
+ const plane = this.requireParent(), s = this.surface, a = this.attrs;
232
+ const [first, second] = axesOf(this, plane, a.domainAxis, a.valueAxis);
233
+ const at = this.locate(plane, first, second);
234
+ if (!at) {
235
+ // Off the plot (panned away): draw nothing, and take up no room.
236
+ clearCallout(s, this.#lines);
237
+ this.#lines = 0;
238
+ this.#box = null;
239
+ return;
240
+ }
241
+ const [px, py] = at;
242
+ const look = {
243
+ metrics: this.styleFor(['callout']), text: this.styleFor(['calloutText', 'text']),
244
+ box: this.styleFor(['calloutBox', 'shape']), arm: this.styleFor(['calloutArm', 'line']),
245
+ };
246
+ const { metrics } = look, { width: w, height: h } = calloutSize(this.requireChart().measureText, a.text, look);
247
+ const place = (cx, cy) => ({ x: cx - w / 2, y: cy - h / 2, width: w, height: h });
248
+ let box;
249
+ if (a.dx !== undefined || a.dy !== undefined) {
250
+ box = place(px + (a.dx ?? 0), py + (a.dy ?? 0));
251
+ }
252
+ else {
253
+ let best = Infinity;
254
+ box = place(px, py);
255
+ DIRECTIONS.forEach(([ux, uy], i) => {
256
+ const candidate = place(px + ux * (w / 2 + metrics.offset), py + uy * (h / 2 + metrics.offset));
257
+ const score = this.trouble(plane, candidate, i);
258
+ if (score < best) {
259
+ best = score;
260
+ box = candidate;
261
+ }
262
+ });
263
+ }
264
+ this.#box = box;
265
+ this.#lines = paintCallout(s, a.text, box, [px, py], look, this.#lines);
266
+ }
267
+ }
package/dist/aria.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Accessibility attributes from an element's `meta`.
3
+ *
4
+ * The core carries no words of its own: whatever a screen reader says comes from `meta` (a `title` is the accessible name,
5
+ * a `description` the longer description) or from an opt-in component such as `ChartSummary`, which is where the
6
+ * language-specific text lives. This module only turns `meta` into attributes on a node.
7
+ */
8
+ import type { Meta } from './element.ts';
9
+ import type { PlatformNode } from './platform.ts';
10
+ import type { Surface } from './surface.ts';
11
+ /** What an element supplies when its `meta` says nothing: a role that fits what it is, and a name it knows. */
12
+ export interface AriaDefaults {
13
+ role?: string;
14
+ label?: string;
15
+ description?: string;
16
+ }
17
+ /**
18
+ * Write the node's role, name, description and any other ARIA attributes. `meta.aria` wins over everything: its keys are
19
+ * attribute names (the `aria-` prefix is optional) and its values are written as they are.
20
+ * A role is only written when there is something to name: an unnamed `group` is noise to a screen reader.
21
+ */
22
+ export declare function writeAria(surface: Surface, node: PlatformNode, meta: Meta | undefined, defaults?: AriaDefaults): void;
23
+ /** Keep a decorative node (gridlines, tick labels, a swatch) out of the accessibility tree. */
24
+ export declare function hideFromAssistive(surface: Surface, node: PlatformNode): void;
package/dist/aria.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Write the node's role, name, description and any other ARIA attributes. `meta.aria` wins over everything: its keys are
3
+ * attribute names (the `aria-` prefix is optional) and its values are written as they are.
4
+ * A role is only written when there is something to name: an unnamed `group` is noise to a screen reader.
5
+ */
6
+ export function writeAria(surface, node, meta, defaults = {}) {
7
+ const extra = meta?.aria;
8
+ const label = extra?.['aria-label'] ?? extra?.['label'] ?? meta?.title ?? defaults.label;
9
+ const description = meta?.description ?? defaults.description;
10
+ const role = meta?.role ?? defaults.role;
11
+ if (role !== undefined && (label !== undefined || description !== undefined || meta?.role !== undefined))
12
+ surface.set(node, 'role', role);
13
+ if (label !== undefined)
14
+ surface.set(node, 'aria-label', String(label));
15
+ if (description !== undefined)
16
+ surface.set(node, 'aria-description', description);
17
+ if (extra) {
18
+ for (const key in extra) {
19
+ if (key === 'label')
20
+ continue;
21
+ const name = key.startsWith('aria-') ? key : `aria-${key}`;
22
+ if (name === 'aria-label')
23
+ continue;
24
+ surface.set(node, name, String(extra[key]));
25
+ }
26
+ }
27
+ }
28
+ /** Keep a decorative node (gridlines, tick labels, a swatch) out of the accessibility tree. */
29
+ export function hideFromAssistive(surface, node) {
30
+ surface.set(node, 'aria-hidden', 'true');
31
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Attribute names and types for the places that only have strings and flat names: XML-like syntaxes (JSX, HTML).
3
+ *
4
+ * Two jobs, both small:
5
+ *
6
+ * 1. `expandAttributes` turns hyphenated names into nested objects, because those syntaxes carry only primitives:
7
+ * `meta-title="Sales"` is `{ meta: { title: 'Sales' } }`. Having both an object and nested names for one key is an error.
8
+ * 2. Every element class lists, in `static attrTypes`, the attributes an HTML parser would get wrong: names with capitals
9
+ * (HTML lowercases them, so `strokewidth` must find `strokeWidth`) and text that looks like something else (a title
10
+ * of `2024` must stay text). Attributes that hold objects list their parts the same way, nested:
11
+ * `{ meta: { title: STRING }, padding: { topSide: NUMBER } }`. Everything else is left to automatic coercion.
12
+ * The compiler enforces the list (`AttrTypesFor`), so a new attribute cannot be forgotten.
13
+ *
14
+ * Only the Web Components adapter reads `attrTypes`; the classes carry them as plain data, which costs a few bytes each.
15
+ */
16
+ import type { AnyElement } from './element.ts';
17
+ import type { Theme } from './theme.ts';
18
+ /** How an HTML attribute's text becomes a value. AUTO: `true`/`false` and numbers convert, `[…]` and `{…}` are JSON, the rest is text. */
19
+ export declare const AUTO = 0;
20
+ /** Always text: titles, names, ids, colours. Needed wherever the text might look like a number or a boolean. */
21
+ export declare const STRING = 1;
22
+ /** A number, whatever it looks like. */
23
+ export declare const NUMBER = 2;
24
+ /** A boolean: present with no value, or `true`, is true; `false` is false. */
25
+ export declare const BOOLEAN = 3;
26
+ export type Coerce = typeof AUTO | typeof STRING | typeof NUMBER | typeof BOOLEAN;
27
+ /** How one attribute's text becomes a value, or, for an attribute that holds an object, the same for each part of it. */
28
+ export type AttrType = Coerce | AttrTypes;
29
+ /** An element class' attribute types by name, as in `{ strokeWidth: NUMBER, padding: { topSide: NUMBER } }`. Inherited ones are repeated by spreading the parent's. */
30
+ export interface AttrTypes {
31
+ readonly [name: string]: AttrType;
32
+ }
33
+ declare const opaque: unique symbol;
34
+ /** Marks an attribute that holds an object which only code can supply (a library namespace), so it has no nested spec. */
35
+ export type Opaque<T> = T & {
36
+ readonly [opaque]?: never;
37
+ };
38
+ /** The plain object an attribute type holds (what a nested spec describes), or never: not functions, arrays, data, elements, dates or the style theme. */
39
+ type Inner<T> = NonNullable<T> extends infer N ? N extends (...args: never[]) => unknown ? never : typeof opaque extends keyof N ? never : N extends readonly unknown[] | ArrayLike<unknown> | AnyElement | Date | Theme | string | number | boolean ? never : N extends object ? (string extends keyof N ? never : N) : never : never;
40
+ /** A text-only type: the AUTO guess could turn its text into a number or a boolean. */
41
+ type TextOnly<T> = string extends NonNullable<T> ? number extends NonNullable<T> ? never : boolean extends NonNullable<T> ? never : true : never;
42
+ /**
43
+ * The attributes of `A` that must be listed in `attrTypes`: those with a capital in the name; those that are text and
44
+ * nothing else (not also a number or a boolean), because AUTO could turn their text into one of those; and those that hold an
45
+ * object with parts that need one of the two (`meta` has a `title`), which are listed with a nested spec.
46
+ */
47
+ export type NeededAttrs<A> = {
48
+ [K in keyof A & string]-?: [Inner<A[K]>] extends [never] ? (K extends Lowercase<K> ? ([TextOnly<A[K]>] extends [never] ? never : K) : K) : (K extends Lowercase<K> ? ([NeededAttrs<Inner<A[K]>>] extends [never] ? never : K) : K);
49
+ }[keyof A & string];
50
+ type SpecFor<T> = [Inner<T>] extends [never] ? Coerce : [NeededAttrs<Inner<T>>] extends [never] ? Coerce | AttrTypes : AttrTypesFor<Inner<T>>;
51
+ /** What `static attrTypes` must satisfy for an element whose attributes are `A`: every needed name, each with a type (or a nested spec). */
52
+ export type AttrTypesFor<A> = {
53
+ readonly [K in NeededAttrs<A>]: SpecFor<A[K]>;
54
+ } & AttrTypes;
55
+ type Obj = Record<string, unknown>;
56
+ /** What `expandAttributes` can be told. Both are for the Web Components adapter; JSX and React pass names as written. */
57
+ export interface ExpandOptions {
58
+ /** The real name for a path segment, given the segments above it (HTML hands over lowercase). Default: as written. */
59
+ name?: (segment: string, parents: readonly string[]) => string;
60
+ /** The value for the attribute at `path` (HTML hands over text). Default: as given. */
61
+ value?: (path: readonly string[], value: unknown) => unknown;
62
+ }
63
+ /**
64
+ * The attributes an element takes, from a flat set of names and values in which hyphens mean nesting.
65
+ * Names without hyphens pass through, and values are never copied, so objects and functions stay the same objects.
66
+ * An attribute whose value is `undefined` counts as not given for conflicts, but stays in the result when nothing else claims the key.
67
+ * Throws when one key is given both as a value and as a path to nested values (`meta` and `meta-title`, or `a-b` and `a-b-c`),
68
+ * when a name has an empty part (`a--b`), or for `__proto__`.
69
+ */
70
+ export declare function expandAttributes(flat: Readonly<Obj>, options?: ExpandOptions): Obj;
71
+ /** The hyphenated names of nested attributes in a props type (`meta-title`, `style-tickLabel-size`), which are checked as leaves. */
72
+ export type NestedProps = {
73
+ [name: `${string}-${string}`]: string | number | boolean | undefined;
74
+ };
75
+ /** A child as written: an element, nothing (a skipped condition), or a list of those. */
76
+ export type JsxChild<E> = E | null | undefined | boolean | readonly JsxChild<E>[];
77
+ /** Receives the element made for a JSX tag. */
78
+ export type JsxRef<E> = ((element: E) => void) | {
79
+ current: E | null;
80
+ };
81
+ export {};