insomni-plot 0.1.0-alpha.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/LICENSE.md +674 -0
- package/README.md +81 -0
- package/dist/core.d.mts +340 -0
- package/dist/core.mjs +1047 -0
- package/dist/index.d.mts +3426 -0
- package/dist/index.mjs +12762 -0
- package/dist/interactions-DEFL_F4E.mjs +5395 -0
- package/dist/range-presets-CzECsu3V.d.mts +1523 -0
- package/package.json +34 -0
- package/src/annotations.d.ts +121 -0
- package/src/annotations.ts +438 -0
- package/src/axis.d.ts +184 -0
- package/src/axis.test.ts +131 -0
- package/src/axis.ts +765 -0
- package/src/colorbar.d.ts +69 -0
- package/src/colorbar.ts +294 -0
- package/src/colors.d.ts +57 -0
- package/src/colors.test.ts +28 -0
- package/src/colors.ts +486 -0
- package/src/core.ts +299 -0
- package/src/format.d.ts +54 -0
- package/src/format.ts +138 -0
- package/src/grammar/accessibility.d.ts +147 -0
- package/src/grammar/accessibility.test.ts +199 -0
- package/src/grammar/accessibility.ts +443 -0
- package/src/grammar/aes.d.ts +35 -0
- package/src/grammar/aes.test.ts +75 -0
- package/src/grammar/aes.ts +120 -0
- package/src/grammar/annotations.d.ts +86 -0
- package/src/grammar/annotations.test.ts +68 -0
- package/src/grammar/annotations.ts +336 -0
- package/src/grammar/attach-brush.d.ts +44 -0
- package/src/grammar/attach-brush.test.ts +214 -0
- package/src/grammar/attach-brush.ts +111 -0
- package/src/grammar/attach-presets.d.ts +33 -0
- package/src/grammar/attach-presets.test.ts +106 -0
- package/src/grammar/attach-presets.ts +215 -0
- package/src/grammar/chart.d.ts +952 -0
- package/src/grammar/chart.test.ts +118 -0
- package/src/grammar/chart.ts +1172 -0
- package/src/grammar/color-utils.d.ts +29 -0
- package/src/grammar/color-utils.test.ts +53 -0
- package/src/grammar/color-utils.ts +66 -0
- package/src/grammar/constants.d.ts +45 -0
- package/src/grammar/constants.ts +61 -0
- package/src/grammar/coord.d.ts +183 -0
- package/src/grammar/coord.test.ts +355 -0
- package/src/grammar/coord.ts +619 -0
- package/src/grammar/data/pivot.d.ts +57 -0
- package/src/grammar/data/pivot.ts +107 -0
- package/src/grammar/emphasis-driver.d.ts +69 -0
- package/src/grammar/emphasis-driver.test.ts +199 -0
- package/src/grammar/emphasis-driver.ts +205 -0
- package/src/grammar/equality.d.ts +3 -0
- package/src/grammar/equality.ts +40 -0
- package/src/grammar/facet.d.ts +63 -0
- package/src/grammar/facet.test.ts +60 -0
- package/src/grammar/facet.ts +175 -0
- package/src/grammar/geoms/_categorical.d.ts +94 -0
- package/src/grammar/geoms/_categorical.ts +0 -0
- package/src/grammar/geoms/_distribution.d.ts +52 -0
- package/src/grammar/geoms/_distribution.ts +125 -0
- package/src/grammar/geoms/_mark.d.ts +69 -0
- package/src/grammar/geoms/_mark.ts +136 -0
- package/src/grammar/geoms/_shape.d.ts +41 -0
- package/src/grammar/geoms/_shape.ts +74 -0
- package/src/grammar/geoms/aggregate.d.ts +95 -0
- package/src/grammar/geoms/aggregate.test.ts +554 -0
- package/src/grammar/geoms/aggregate.ts +840 -0
- package/src/grammar/geoms/area.d.ts +32 -0
- package/src/grammar/geoms/area.test.ts +165 -0
- package/src/grammar/geoms/area.ts +578 -0
- package/src/grammar/geoms/band.d.ts +27 -0
- package/src/grammar/geoms/band.test.ts +57 -0
- package/src/grammar/geoms/band.ts +126 -0
- package/src/grammar/geoms/bar.d.ts +56 -0
- package/src/grammar/geoms/bar.test.ts +367 -0
- package/src/grammar/geoms/bar.ts +1054 -0
- package/src/grammar/geoms/boxplot.d.ts +129 -0
- package/src/grammar/geoms/boxplot.test.ts +299 -0
- package/src/grammar/geoms/boxplot.ts +834 -0
- package/src/grammar/geoms/connected-scatter.d.ts +27 -0
- package/src/grammar/geoms/connected-scatter.test.ts +157 -0
- package/src/grammar/geoms/connected-scatter.ts +63 -0
- package/src/grammar/geoms/emphasis.d.ts +76 -0
- package/src/grammar/geoms/emphasis.test.ts +135 -0
- package/src/grammar/geoms/emphasis.ts +162 -0
- package/src/grammar/geoms/histogram.d.ts +75 -0
- package/src/grammar/geoms/histogram.test.ts +262 -0
- package/src/grammar/geoms/histogram.ts +740 -0
- package/src/grammar/geoms/index.d.ts +20 -0
- package/src/grammar/geoms/index.ts +77 -0
- package/src/grammar/geoms/interval.d.ts +31 -0
- package/src/grammar/geoms/interval.test.ts +154 -0
- package/src/grammar/geoms/interval.ts +342 -0
- package/src/grammar/geoms/line.d.ts +38 -0
- package/src/grammar/geoms/line.test.ts +247 -0
- package/src/grammar/geoms/line.ts +659 -0
- package/src/grammar/geoms/point.d.ts +57 -0
- package/src/grammar/geoms/point.test.ts +163 -0
- package/src/grammar/geoms/point.ts +545 -0
- package/src/grammar/geoms/polar.test.ts +216 -0
- package/src/grammar/geoms/ribbon.d.ts +21 -0
- package/src/grammar/geoms/ribbon.test.ts +170 -0
- package/src/grammar/geoms/ribbon.ts +87 -0
- package/src/grammar/geoms/ridgeline.d.ts +89 -0
- package/src/grammar/geoms/ridgeline.test.ts +247 -0
- package/src/grammar/geoms/ridgeline.ts +1164 -0
- package/src/grammar/geoms/rolling.d.ts +43 -0
- package/src/grammar/geoms/rolling.test.ts +217 -0
- package/src/grammar/geoms/rolling.ts +387 -0
- package/src/grammar/geoms/rug.d.ts +28 -0
- package/src/grammar/geoms/rug.test.ts +126 -0
- package/src/grammar/geoms/rug.ts +214 -0
- package/src/grammar/geoms/rule.d.ts +23 -0
- package/src/grammar/geoms/rule.test.ts +69 -0
- package/src/grammar/geoms/rule.ts +212 -0
- package/src/grammar/geoms/smooth.d.ts +54 -0
- package/src/grammar/geoms/smooth.test.ts +78 -0
- package/src/grammar/geoms/smooth.ts +337 -0
- package/src/grammar/geoms/text.d.ts +29 -0
- package/src/grammar/geoms/text.test.ts +64 -0
- package/src/grammar/geoms/text.ts +234 -0
- package/src/grammar/geoms/tile.d.ts +61 -0
- package/src/grammar/geoms/tile.test.ts +157 -0
- package/src/grammar/geoms/tile.ts +621 -0
- package/src/grammar/geoms/types.d.ts +319 -0
- package/src/grammar/geoms/types.ts +362 -0
- package/src/grammar/geoms/violin.d.ts +85 -0
- package/src/grammar/geoms/violin.test.ts +187 -0
- package/src/grammar/geoms/violin.ts +672 -0
- package/src/grammar/index.d.ts +22 -0
- package/src/grammar/index.ts +269 -0
- package/src/grammar/interactions/_disposable.d.ts +5 -0
- package/src/grammar/interactions/_disposable.ts +23 -0
- package/src/grammar/interactions/_z.d.ts +4 -0
- package/src/grammar/interactions/_z.ts +16 -0
- package/src/grammar/interactions/brush-selection.test.ts +262 -0
- package/src/grammar/interactions/brush.d.ts +63 -0
- package/src/grammar/interactions/brush.test.ts +483 -0
- package/src/grammar/interactions/brush.ts +452 -0
- package/src/grammar/interactions/crosshair.d.ts +19 -0
- package/src/grammar/interactions/crosshair.test.ts +127 -0
- package/src/grammar/interactions/crosshair.ts +76 -0
- package/src/grammar/interactions/hit-layer.d.ts +64 -0
- package/src/grammar/interactions/hit-layer.ts +246 -0
- package/src/grammar/interactions/legend.d.ts +19 -0
- package/src/grammar/interactions/legend.ts +101 -0
- package/src/grammar/interactions/menu.d.ts +93 -0
- package/src/grammar/interactions/menu.test.ts +373 -0
- package/src/grammar/interactions/menu.ts +342 -0
- package/src/grammar/interactions/selection.d.ts +25 -0
- package/src/grammar/interactions/selection.test.ts +289 -0
- package/src/grammar/interactions/selection.ts +142 -0
- package/src/grammar/interactions/series-readout.d.ts +91 -0
- package/src/grammar/interactions/series-readout.test.ts +668 -0
- package/src/grammar/interactions/series-readout.ts +422 -0
- package/src/grammar/interactions/series-snap.d.ts +70 -0
- package/src/grammar/interactions/series-snap.test.ts +214 -0
- package/src/grammar/interactions/series-snap.ts +218 -0
- package/src/grammar/interactions/tooltip-axis.test.ts +176 -0
- package/src/grammar/interactions/tooltip-touch.browser.test.ts +49 -0
- package/src/grammar/interactions/tooltip-touch.test.ts +161 -0
- package/src/grammar/interactions/tooltip.d.ts +140 -0
- package/src/grammar/interactions/tooltip.test.ts +406 -0
- package/src/grammar/interactions/tooltip.ts +622 -0
- package/src/grammar/interactions/transitions.d.ts +34 -0
- package/src/grammar/interactions/transitions.test.ts +172 -0
- package/src/grammar/interactions/transitions.ts +160 -0
- package/src/grammar/layout.d.ts +68 -0
- package/src/grammar/layout.ts +186 -0
- package/src/grammar/legend-merge.test.ts +332 -0
- package/src/grammar/mount.d.ts +78 -0
- package/src/grammar/mount.test.ts +479 -0
- package/src/grammar/mount.ts +2112 -0
- package/src/grammar/palettes.d.ts +54 -0
- package/src/grammar/palettes.test.ts +80 -0
- package/src/grammar/palettes.ts +167 -0
- package/src/grammar/pan-zoom.test.ts +398 -0
- package/src/grammar/phylo.d.ts +65 -0
- package/src/grammar/phylo.test.ts +59 -0
- package/src/grammar/phylo.ts +112 -0
- package/src/grammar/pipeline.auto-ticks.test.ts +40 -0
- package/src/grammar/pipeline.d.ts +158 -0
- package/src/grammar/pipeline.test.ts +463 -0
- package/src/grammar/pipeline.ts +1233 -0
- package/src/grammar/profiling.d.ts +8 -0
- package/src/grammar/profiling.ts +24 -0
- package/src/grammar/scales.d.ts +188 -0
- package/src/grammar/scales.test.ts +181 -0
- package/src/grammar/scales.ts +800 -0
- package/src/grammar/svg.d.ts +3 -0
- package/src/grammar/svg.ts +39 -0
- package/src/grammar/theme.d.ts +261 -0
- package/src/grammar/theme.test.ts +105 -0
- package/src/grammar/theme.ts +490 -0
- package/src/heatmap/cpu.ts +109 -0
- package/src/heatmap/gpu.ts +565 -0
- package/src/heatmap/types.ts +177 -0
- package/src/heatmap.browser.test.ts +308 -0
- package/src/heatmap.test.ts +320 -0
- package/src/heatmap.ts +123 -0
- package/src/index.d.ts +1 -0
- package/src/index.ts +8 -0
- package/src/interactions.d.ts +48 -0
- package/src/interactions.test.ts +226 -0
- package/src/interactions.ts +394 -0
- package/src/layout/box.d.ts +48 -0
- package/src/layout/box.test.ts +107 -0
- package/src/layout/box.ts +143 -0
- package/src/legend.d.ts +115 -0
- package/src/legend.ts +422 -0
- package/src/marks/curve.d.ts +43 -0
- package/src/marks/curve.ts +244 -0
- package/src/marks/stack.d.ts +53 -0
- package/src/marks/stack.ts +184 -0
- package/src/marks.d.ts +273 -0
- package/src/marks.test.ts +541 -0
- package/src/marks.ts +1292 -0
- package/src/navigator.test.ts +174 -0
- package/src/navigator.ts +393 -0
- package/src/range-presets.d.ts +113 -0
- package/src/range-presets.test.ts +345 -0
- package/src/range-presets.ts +349 -0
- package/src/scales.d.ts +98 -0
- package/src/scales.test.ts +103 -0
- package/src/scales.ts +695 -0
- package/src/stats/index.d.ts +200 -0
- package/src/stats/index.test.ts +349 -0
- package/src/stats/index.ts +740 -0
- package/src/stats/regression.d.ts +38 -0
- package/src/stats/regression.test.ts +56 -0
- package/src/stats/regression.ts +396 -0
- package/src/stats/rolling-window.d.ts +55 -0
- package/src/stats/rolling-window.test.ts +237 -0
- package/src/stats/rolling-window.ts +256 -0
- package/src/test-setup.ts +19 -0
- package/src/viewport/axis-state.d.ts +72 -0
- package/src/viewport/axis-state.ts +476 -0
- package/src/viewport.d.ts +170 -0
- package/src/viewport.test.ts +363 -0
- package/src/viewport.ts +510 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import { inferDataType, materialize, resolveAes } from "./aes.ts";
|
|
3
|
+
|
|
4
|
+
interface Row {
|
|
5
|
+
revenue: number;
|
|
6
|
+
sector: string;
|
|
7
|
+
founded: Date;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const rows: Row[] = [
|
|
11
|
+
{ revenue: 100, sector: "SaaS", founded: new Date("2010-01-01") },
|
|
12
|
+
{ revenue: 50, sector: "FinTech", founded: new Date("2015-06-01") },
|
|
13
|
+
{ revenue: 200, sector: "DevTools", founded: new Date("2020-03-01") },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
describe("resolveAes", () => {
|
|
17
|
+
it("resolves a column key to an accessor with metadata", () => {
|
|
18
|
+
const r = resolveAes<Row, number>("revenue");
|
|
19
|
+
expect(r.kind).toBe("column");
|
|
20
|
+
expect(r.column).toBe("revenue");
|
|
21
|
+
expect(r.fn(rows[0]!, 0)).toBe(100);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("resolves an accessor function", () => {
|
|
25
|
+
const r = resolveAes<Row, number>((d) => d.revenue * 2);
|
|
26
|
+
expect(r.kind).toBe("accessor");
|
|
27
|
+
expect(r.column).toBeUndefined();
|
|
28
|
+
expect(r.fn(rows[1]!, 1)).toBe(100);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("resolves a constant value", () => {
|
|
32
|
+
const r = resolveAes<Row, number>(7);
|
|
33
|
+
expect(r.kind).toBe("constant");
|
|
34
|
+
expect(r.fn(rows[0]!, 0)).toBe(7);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("uses fallback when aes is undefined", () => {
|
|
38
|
+
const r = resolveAes<Row, number>(undefined, 3);
|
|
39
|
+
expect(r.kind).toBe("constant");
|
|
40
|
+
expect(r.fn(rows[0]!, 0)).toBe(3);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("throws if no aes and no fallback", () => {
|
|
44
|
+
expect(() => resolveAes<Row, number>(undefined)).toThrow();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("materialize", () => {
|
|
49
|
+
it("flattens an aesthetic across the dataset", () => {
|
|
50
|
+
const r = resolveAes<Row, number>("revenue");
|
|
51
|
+
expect(materialize(r, rows)).toEqual([100, 50, 200]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("inferDataType", () => {
|
|
56
|
+
it("detects number", () => {
|
|
57
|
+
expect(inferDataType([1, 2, 3])).toBe("number");
|
|
58
|
+
});
|
|
59
|
+
it("detects string", () => {
|
|
60
|
+
expect(inferDataType(["a", "b"])).toBe("string");
|
|
61
|
+
});
|
|
62
|
+
it("detects date", () => {
|
|
63
|
+
expect(inferDataType([new Date()])).toBe("date");
|
|
64
|
+
});
|
|
65
|
+
it("detects boolean", () => {
|
|
66
|
+
expect(inferDataType([true, false])).toBe("boolean");
|
|
67
|
+
});
|
|
68
|
+
it("skips nullish to find a real type", () => {
|
|
69
|
+
expect(inferDataType([null, undefined, "x"])).toBe("string");
|
|
70
|
+
});
|
|
71
|
+
it("returns unknown for empty / all-nullish", () => {
|
|
72
|
+
expect(inferDataType([])).toBe("unknown");
|
|
73
|
+
expect(inferDataType([null, undefined])).toBe("unknown");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Aes<T, V> — aesthetic mappings
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// A mapping from data row T to channel value V can be:
|
|
5
|
+
// - a column key (keyof T whose value is assignable to V)
|
|
6
|
+
// - an accessor (d, i) => V
|
|
7
|
+
// - a constant V
|
|
8
|
+
//
|
|
9
|
+
// Column-key autocomplete narrows by type: y: "sector" is rejected when
|
|
10
|
+
// the y channel demands `number | Date` and `T["sector"]` is `string`.
|
|
11
|
+
|
|
12
|
+
export type ColumnKeys<T, V> = {
|
|
13
|
+
[K in keyof T]-?: T[K] extends V ? K : never;
|
|
14
|
+
}[keyof T];
|
|
15
|
+
|
|
16
|
+
export type Accessor<T, V> = (datum: T, index: number) => V;
|
|
17
|
+
|
|
18
|
+
export type Aes<T, V> = ColumnKeys<T, V> | Accessor<T, V> | V;
|
|
19
|
+
|
|
20
|
+
export type Row = Record<string, unknown>;
|
|
21
|
+
|
|
22
|
+
export interface ResolvedAes<T, V> {
|
|
23
|
+
readonly kind: "column" | "accessor" | "constant";
|
|
24
|
+
readonly column?: keyof T & string;
|
|
25
|
+
readonly fn: Accessor<T, V>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isAccessor<T, V>(v: unknown): v is Accessor<T, V> {
|
|
29
|
+
return typeof v === "function";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Normalize an `Aes<T, V>` to a `(d, i) => V` accessor plus metadata. The
|
|
34
|
+
* `kind` and `column` fields let consumers (auto-scale inference, legends,
|
|
35
|
+
* tooltip labels) introspect the original mapping.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveAes<T, V>(aes: Aes<T, V> | undefined, fallback?: V): ResolvedAes<T, V> {
|
|
38
|
+
if (aes === undefined) {
|
|
39
|
+
if (fallback === undefined) {
|
|
40
|
+
throw new Error("resolveAes: missing aesthetic and no fallback provided");
|
|
41
|
+
}
|
|
42
|
+
const fb = fallback;
|
|
43
|
+
return { kind: "constant", fn: () => fb };
|
|
44
|
+
}
|
|
45
|
+
if (isAccessor<T, V>(aes)) {
|
|
46
|
+
return { kind: "accessor", fn: aes };
|
|
47
|
+
}
|
|
48
|
+
if (typeof aes === "string") {
|
|
49
|
+
const key = aes as keyof T & string;
|
|
50
|
+
return {
|
|
51
|
+
kind: "column",
|
|
52
|
+
column: key,
|
|
53
|
+
fn: (d: T) => (d as Record<string, unknown>)[key] as V,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
// Constant (number, boolean, object, etc.)
|
|
57
|
+
const constant = aes as V;
|
|
58
|
+
return { kind: "constant", fn: () => constant };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Materialize an aesthetic to a flat array. Used when the chart needs the
|
|
63
|
+
* full column for domain inference.
|
|
64
|
+
*/
|
|
65
|
+
export function materialize<T, V>(aes: ResolvedAes<T, V>, data: readonly T[]): V[] {
|
|
66
|
+
const out = Array.from<V>({ length: data.length });
|
|
67
|
+
for (let i = 0; i < data.length; i++) out[i] = aes.fn(data[i]!, i);
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Null / undefined policy for categorical channels
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Rows whose categorical channel (`color`, `shape`, …) materializes to `null`
|
|
75
|
+
// or `undefined` are *dropped* — they do not become a `"null"` category and
|
|
76
|
+
// they do not throw. Mirrors how numeric channels treat `NaN`. The scale
|
|
77
|
+
// helpers (`uniqueStrings` in `scales.ts`) already filter at domain build;
|
|
78
|
+
// geoms that group rows by a categorical channel (see `smooth`, `histogram`)
|
|
79
|
+
// also drop the row before grouping so the rendered output stays aligned
|
|
80
|
+
// with the scale domain.
|
|
81
|
+
//
|
|
82
|
+
// `dropNullCategoricalIndices` is the shared helper for geoms.
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Return the subset of `indices` whose row produces a non-null categorical
|
|
86
|
+
* value under `aes`. Used by geoms that group by a categorical channel; keeps
|
|
87
|
+
* the drop-row policy in one place.
|
|
88
|
+
*/
|
|
89
|
+
export function dropNullCategoricalIndices<T>(
|
|
90
|
+
indices: readonly number[],
|
|
91
|
+
aes: ResolvedAes<T, unknown>,
|
|
92
|
+
data: readonly T[],
|
|
93
|
+
): number[] {
|
|
94
|
+
const out: number[] = [];
|
|
95
|
+
for (const i of indices) {
|
|
96
|
+
const v = aes.fn(data[i]!, i);
|
|
97
|
+
if (v === null || v === undefined) continue;
|
|
98
|
+
out.push(i);
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Best-effort guess of a channel's data type. Used to pick a default scale
|
|
105
|
+
* type when no override is supplied. Null / undefined values are skipped
|
|
106
|
+
* (see "Null / undefined policy" above).
|
|
107
|
+
*/
|
|
108
|
+
export type ChannelDataType = "number" | "date" | "string" | "boolean" | "unknown";
|
|
109
|
+
|
|
110
|
+
export function inferDataType(values: readonly unknown[]): ChannelDataType {
|
|
111
|
+
for (const v of values) {
|
|
112
|
+
if (v === null || v === undefined) continue;
|
|
113
|
+
if (typeof v === "number") return "number";
|
|
114
|
+
if (typeof v === "string") return "string";
|
|
115
|
+
if (typeof v === "boolean") return "boolean";
|
|
116
|
+
if (v instanceof Date) return "date";
|
|
117
|
+
return "unknown";
|
|
118
|
+
}
|
|
119
|
+
return "unknown";
|
|
120
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Color, Frame, Layer } from "insomni";
|
|
2
|
+
import { type LabelBoxStyle, type ValueLabelAlign } from "../annotations.ts";
|
|
3
|
+
import type { ScaleBundle } from "./geoms/types.ts";
|
|
4
|
+
import type { Theme } from "./theme.ts";
|
|
5
|
+
export type FrameAnchorX = "left" | "center" | "right";
|
|
6
|
+
export type FrameAnchorY = "top" | "middle" | "bottom";
|
|
7
|
+
/** Coordinate value accepted by annotation factories — data value or frame anchor. */
|
|
8
|
+
export type AnnotationX = FrameAnchorX | number | Date | string;
|
|
9
|
+
export type AnnotationY = FrameAnchorY | number | Date | string;
|
|
10
|
+
export interface TextAnnotationSpec {
|
|
11
|
+
kind?: "text";
|
|
12
|
+
text: string;
|
|
13
|
+
/**
|
|
14
|
+
* Horizontal position. A `FrameAnchorX` resolves to the plot frame edges;
|
|
15
|
+
* a number/Date is run through the active x scale.
|
|
16
|
+
*/
|
|
17
|
+
x: AnnotationX;
|
|
18
|
+
/** Vertical position. See `x` — but resolves against the y scale. */
|
|
19
|
+
y: AnnotationY;
|
|
20
|
+
/** Pixel offset added after position resolution. */
|
|
21
|
+
offsetX?: number;
|
|
22
|
+
offsetY?: number;
|
|
23
|
+
align?: ValueLabelAlign;
|
|
24
|
+
color?: Color;
|
|
25
|
+
fontSize?: number;
|
|
26
|
+
fontStyle?: string;
|
|
27
|
+
box?: LabelBoxStyle;
|
|
28
|
+
}
|
|
29
|
+
export interface ArrowAnnotationSpec {
|
|
30
|
+
kind: "arrow";
|
|
31
|
+
/** Tail of the arrow — `[x, y]` in data or frame-anchor coordinates. */
|
|
32
|
+
from: readonly [AnnotationX, AnnotationY];
|
|
33
|
+
/** Head of the arrow. Direction is `from → to`. */
|
|
34
|
+
to: readonly [AnnotationX, AnnotationY];
|
|
35
|
+
/** Optional label rendered near the arrowhead. */
|
|
36
|
+
label?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Pixel offset for the label relative to the arrowhead. Default
|
|
39
|
+
* `[6, -6]` — to-the-right-and-above the head.
|
|
40
|
+
*/
|
|
41
|
+
labelOffset?: readonly [number, number];
|
|
42
|
+
labelAlign?: ValueLabelAlign;
|
|
43
|
+
labelBox?: LabelBoxStyle;
|
|
44
|
+
/** Stroke color for the shaft and head fill. Defaults to `theme.text.color`. */
|
|
45
|
+
color?: Color;
|
|
46
|
+
/** Shaft thickness in CSS pixels. Default `1.5`. */
|
|
47
|
+
strokeWidth?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Arrowhead size in CSS pixels — total length from base to tip. Default
|
|
50
|
+
* `9`; the head is `headSize × 0.5` wide at the base.
|
|
51
|
+
*/
|
|
52
|
+
headSize?: number;
|
|
53
|
+
fontSize?: number;
|
|
54
|
+
fontStyle?: string;
|
|
55
|
+
}
|
|
56
|
+
export interface CalloutAnnotationSpec {
|
|
57
|
+
kind: "callout";
|
|
58
|
+
/** Data point the callout is anchored to. */
|
|
59
|
+
at: readonly [AnnotationX, AnnotationY];
|
|
60
|
+
/** Label text rendered at the offset position. */
|
|
61
|
+
label: string;
|
|
62
|
+
/**
|
|
63
|
+
* Pixel offset from the anchor to the label position. The leader line
|
|
64
|
+
* runs between them. Default `[40, -20]`.
|
|
65
|
+
*/
|
|
66
|
+
offset?: readonly [number, number];
|
|
67
|
+
/** Leader stroke color. Defaults to `theme.text.color` (faded by `leaderAlpha`). */
|
|
68
|
+
color?: Color;
|
|
69
|
+
/** Multiplier on the leader's stroke alpha. Default `0.6`. */
|
|
70
|
+
leaderAlpha?: number;
|
|
71
|
+
/** Leader thickness in CSS pixels. Default `1`. */
|
|
72
|
+
strokeWidth?: number;
|
|
73
|
+
/** Label text color. Defaults to `theme.text.color`. */
|
|
74
|
+
labelColor?: Color;
|
|
75
|
+
align?: ValueLabelAlign;
|
|
76
|
+
box?: LabelBoxStyle;
|
|
77
|
+
fontSize?: number;
|
|
78
|
+
fontStyle?: string;
|
|
79
|
+
}
|
|
80
|
+
export type AnnotationSpec = TextAnnotationSpec | ArrowAnnotationSpec | CalloutAnnotationSpec;
|
|
81
|
+
export declare const annotate: {
|
|
82
|
+
readonly arrow: (spec: Omit<ArrowAnnotationSpec, "kind">) => ArrowAnnotationSpec;
|
|
83
|
+
readonly callout: (spec: Omit<CalloutAnnotationSpec, "kind">) => CalloutAnnotationSpec;
|
|
84
|
+
readonly text: (spec: Omit<TextAnnotationSpec, "kind">) => TextAnnotationSpec;
|
|
85
|
+
};
|
|
86
|
+
export declare function renderAnnotations(specs: readonly AnnotationSpec[], scales: ScaleBundle, plot: Frame, theme: Theme, hud: Layer): void;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, test } from "vite-plus/test";
|
|
3
|
+
|
|
4
|
+
import { annotate, type AnnotationSpec } from "./annotations.ts";
|
|
5
|
+
import { ruleMark } from "../annotations.ts";
|
|
6
|
+
import { createLayer } from "insomni";
|
|
7
|
+
import { INSTANCE_BYTES } from "insomni/advanced";
|
|
8
|
+
|
|
9
|
+
describe("annotate namespace", () => {
|
|
10
|
+
test("annotate.arrow tags the spec with kind: 'arrow'", () => {
|
|
11
|
+
const spec = annotate.arrow({ from: [0, 0], to: [10, 10], label: "Peak" });
|
|
12
|
+
expect(spec.kind).toBe("arrow");
|
|
13
|
+
expect(spec.from).toEqual([0, 0]);
|
|
14
|
+
expect(spec.to).toEqual([10, 10]);
|
|
15
|
+
expect(spec.label).toBe("Peak");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("annotate.callout tags the spec with kind: 'callout'", () => {
|
|
19
|
+
const spec = annotate.callout({
|
|
20
|
+
at: [5, 5],
|
|
21
|
+
label: "Started medication",
|
|
22
|
+
offset: [40, -20],
|
|
23
|
+
});
|
|
24
|
+
expect(spec.kind).toBe("callout");
|
|
25
|
+
expect(spec.at).toEqual([5, 5]);
|
|
26
|
+
expect(spec.offset).toEqual([40, -20]);
|
|
27
|
+
expect(spec.label).toBe("Started medication");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("annotate.text tags the spec with kind: 'text'", () => {
|
|
31
|
+
const spec = annotate.text({ text: "p < 0.05", x: "left", y: "top" });
|
|
32
|
+
expect(spec.kind).toBe("text");
|
|
33
|
+
expect(spec.text).toBe("p < 0.05");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("union spec discriminates on kind", () => {
|
|
37
|
+
const specs: AnnotationSpec[] = [
|
|
38
|
+
annotate.arrow({ from: [0, 0], to: [1, 1] }),
|
|
39
|
+
annotate.callout({ at: [0, 0], label: "x" }),
|
|
40
|
+
annotate.text({ text: "y", x: 1, y: 2 }),
|
|
41
|
+
// Backward compat: kind-less text annotation still parses as a TextAnnotationSpec.
|
|
42
|
+
{ text: "z", x: "left", y: "top" },
|
|
43
|
+
];
|
|
44
|
+
expect(specs.length).toBe(4);
|
|
45
|
+
expect(specs[0]!.kind).toBe("arrow");
|
|
46
|
+
expect(specs[1]!.kind).toBe("callout");
|
|
47
|
+
expect(specs[2]!.kind).toBe("text");
|
|
48
|
+
// Backward-compat entry has no kind set.
|
|
49
|
+
expect((specs[3] as { kind?: string }).kind).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("annotation marks — atlas-less layer (SVG export path)", () => {
|
|
54
|
+
test("ruleMark with label does not throw on an atlas-less layer and still emits line geometry", () => {
|
|
55
|
+
const layer = createLayer({ space: "ui" });
|
|
56
|
+
|
|
57
|
+
expect(() =>
|
|
58
|
+
ruleMark({
|
|
59
|
+
y: 50,
|
|
60
|
+
extent: [0, 200],
|
|
61
|
+
label: "threshold",
|
|
62
|
+
}).addTo(layer, { x: 0, y: 0 }),
|
|
63
|
+
).not.toThrow();
|
|
64
|
+
|
|
65
|
+
// The rule line should still be emitted even without an atlas.
|
|
66
|
+
expect(layer.pack.byteLength / INSTANCE_BYTES).toBeGreaterThan(0);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Free-form chart annotations
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// `chart.annotate({ ... })` adds a one-off label/box to the HUD layer. Position
|
|
5
|
+
// can be a domain value (run through the scale) or a frame anchor — useful for
|
|
6
|
+
// model equations, p-values, callouts.
|
|
7
|
+
//
|
|
8
|
+
// Discriminated by `kind`:
|
|
9
|
+
//
|
|
10
|
+
// - `kind: "text"` (or omitted): the original text/box annotation — a
|
|
11
|
+
// `valueLabelMark` at a resolved position.
|
|
12
|
+
// - `kind: "arrow"`: a line segment + filled arrowhead from `from` to `to`,
|
|
13
|
+
// with an optional label near the head. Both endpoints resolve through
|
|
14
|
+
// the active scales (or frame anchors). Use for "the peak happened
|
|
15
|
+
// here" callouts where direction matters.
|
|
16
|
+
// - `kind: "callout"`: a single anchor `at` plus a pixel `offset` to the
|
|
17
|
+
// label position. A leader line connects them, then the label
|
|
18
|
+
// (optionally boxed) renders at the offset. Use for narrative
|
|
19
|
+
// annotations whose label needs to dodge nearby marks without losing
|
|
20
|
+
// the connection to the data point.
|
|
21
|
+
//
|
|
22
|
+
// The `annotate` namespace exports `arrow` / `callout` factory functions
|
|
23
|
+
// that produce these specs. Pair with `chart.annotate(annotate.arrow(...))`
|
|
24
|
+
// or pass the literal object directly.
|
|
25
|
+
|
|
26
|
+
import type { Color, Frame, Layer, Vec2 } from "insomni";
|
|
27
|
+
import { valueLabelMark, type LabelBoxStyle, type ValueLabelAlign } from "../annotations.ts";
|
|
28
|
+
import type { ScaleBundle } from "./geoms/types.ts";
|
|
29
|
+
import type { Theme } from "./theme.ts";
|
|
30
|
+
|
|
31
|
+
export type FrameAnchorX = "left" | "center" | "right";
|
|
32
|
+
export type FrameAnchorY = "top" | "middle" | "bottom";
|
|
33
|
+
|
|
34
|
+
/** Coordinate value accepted by annotation factories — data value or frame anchor. */
|
|
35
|
+
// oxlint-disable-next-line no-redundant-type-constituents -- FrameAnchorX/Y string literals are intentionally kept for IDE autocomplete; `string` subsumes them by design.
|
|
36
|
+
export type AnnotationX = FrameAnchorX | number | Date | string;
|
|
37
|
+
// oxlint-disable-next-line no-redundant-type-constituents -- same as AnnotationX
|
|
38
|
+
export type AnnotationY = FrameAnchorY | number | Date | string;
|
|
39
|
+
|
|
40
|
+
export interface TextAnnotationSpec {
|
|
41
|
+
kind?: "text";
|
|
42
|
+
text: string;
|
|
43
|
+
/**
|
|
44
|
+
* Horizontal position. A `FrameAnchorX` resolves to the plot frame edges;
|
|
45
|
+
* a number/Date is run through the active x scale.
|
|
46
|
+
*/
|
|
47
|
+
x: AnnotationX;
|
|
48
|
+
/** Vertical position. See `x` — but resolves against the y scale. */
|
|
49
|
+
y: AnnotationY;
|
|
50
|
+
/** Pixel offset added after position resolution. */
|
|
51
|
+
offsetX?: number;
|
|
52
|
+
offsetY?: number;
|
|
53
|
+
align?: ValueLabelAlign;
|
|
54
|
+
color?: Color;
|
|
55
|
+
fontSize?: number;
|
|
56
|
+
fontStyle?: string;
|
|
57
|
+
box?: LabelBoxStyle;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ArrowAnnotationSpec {
|
|
61
|
+
kind: "arrow";
|
|
62
|
+
/** Tail of the arrow — `[x, y]` in data or frame-anchor coordinates. */
|
|
63
|
+
from: readonly [AnnotationX, AnnotationY];
|
|
64
|
+
/** Head of the arrow. Direction is `from → to`. */
|
|
65
|
+
to: readonly [AnnotationX, AnnotationY];
|
|
66
|
+
/** Optional label rendered near the arrowhead. */
|
|
67
|
+
label?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Pixel offset for the label relative to the arrowhead. Default
|
|
70
|
+
* `[6, -6]` — to-the-right-and-above the head.
|
|
71
|
+
*/
|
|
72
|
+
labelOffset?: readonly [number, number];
|
|
73
|
+
labelAlign?: ValueLabelAlign;
|
|
74
|
+
labelBox?: LabelBoxStyle;
|
|
75
|
+
/** Stroke color for the shaft and head fill. Defaults to `theme.text.color`. */
|
|
76
|
+
color?: Color;
|
|
77
|
+
/** Shaft thickness in CSS pixels. Default `1.5`. */
|
|
78
|
+
strokeWidth?: number;
|
|
79
|
+
/**
|
|
80
|
+
* Arrowhead size in CSS pixels — total length from base to tip. Default
|
|
81
|
+
* `9`; the head is `headSize × 0.5` wide at the base.
|
|
82
|
+
*/
|
|
83
|
+
headSize?: number;
|
|
84
|
+
fontSize?: number;
|
|
85
|
+
fontStyle?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface CalloutAnnotationSpec {
|
|
89
|
+
kind: "callout";
|
|
90
|
+
/** Data point the callout is anchored to. */
|
|
91
|
+
at: readonly [AnnotationX, AnnotationY];
|
|
92
|
+
/** Label text rendered at the offset position. */
|
|
93
|
+
label: string;
|
|
94
|
+
/**
|
|
95
|
+
* Pixel offset from the anchor to the label position. The leader line
|
|
96
|
+
* runs between them. Default `[40, -20]`.
|
|
97
|
+
*/
|
|
98
|
+
offset?: readonly [number, number];
|
|
99
|
+
/** Leader stroke color. Defaults to `theme.text.color` (faded by `leaderAlpha`). */
|
|
100
|
+
color?: Color;
|
|
101
|
+
/** Multiplier on the leader's stroke alpha. Default `0.6`. */
|
|
102
|
+
leaderAlpha?: number;
|
|
103
|
+
/** Leader thickness in CSS pixels. Default `1`. */
|
|
104
|
+
strokeWidth?: number;
|
|
105
|
+
/** Label text color. Defaults to `theme.text.color`. */
|
|
106
|
+
labelColor?: Color;
|
|
107
|
+
align?: ValueLabelAlign;
|
|
108
|
+
box?: LabelBoxStyle;
|
|
109
|
+
fontSize?: number;
|
|
110
|
+
fontStyle?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type AnnotationSpec = TextAnnotationSpec | ArrowAnnotationSpec | CalloutAnnotationSpec;
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Factory namespace — sugar for producing specs with sensible defaults
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
export const annotate = {
|
|
120
|
+
arrow: (spec: Omit<ArrowAnnotationSpec, "kind">): ArrowAnnotationSpec => ({
|
|
121
|
+
kind: "arrow",
|
|
122
|
+
...spec,
|
|
123
|
+
}),
|
|
124
|
+
callout: (spec: Omit<CalloutAnnotationSpec, "kind">): CalloutAnnotationSpec => ({
|
|
125
|
+
kind: "callout",
|
|
126
|
+
...spec,
|
|
127
|
+
}),
|
|
128
|
+
text: (spec: Omit<TextAnnotationSpec, "kind">): TextAnnotationSpec => ({
|
|
129
|
+
kind: "text",
|
|
130
|
+
...spec,
|
|
131
|
+
}),
|
|
132
|
+
} as const;
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Coordinate resolution
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
const FRAME_X: ReadonlySet<string> = new Set(["left", "center", "right"]);
|
|
139
|
+
const FRAME_Y: ReadonlySet<string> = new Set(["top", "middle", "bottom"]);
|
|
140
|
+
|
|
141
|
+
function isFrameAnchorX(v: unknown): v is FrameAnchorX {
|
|
142
|
+
return typeof v === "string" && FRAME_X.has(v);
|
|
143
|
+
}
|
|
144
|
+
function isFrameAnchorY(v: unknown): v is FrameAnchorY {
|
|
145
|
+
return typeof v === "string" && FRAME_Y.has(v);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function resolveX(value: AnnotationX, scales: ScaleBundle, plot: Frame): number {
|
|
149
|
+
if (isFrameAnchorX(value)) {
|
|
150
|
+
if (value === "left") return plot.x;
|
|
151
|
+
if (value === "right") return plot.x + plot.width;
|
|
152
|
+
return plot.x + plot.width / 2;
|
|
153
|
+
}
|
|
154
|
+
return plot.x + scales.x.fn(value);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function resolveY(value: AnnotationY, scales: ScaleBundle, plot: Frame): number {
|
|
158
|
+
if (isFrameAnchorY(value)) {
|
|
159
|
+
if (value === "top") return plot.y;
|
|
160
|
+
if (value === "bottom") return plot.y + plot.height;
|
|
161
|
+
return plot.y + plot.height / 2;
|
|
162
|
+
}
|
|
163
|
+
return plot.y + scales.y.fn(value);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function resolvePoint(
|
|
167
|
+
coord: readonly [AnnotationX, AnnotationY],
|
|
168
|
+
scales: ScaleBundle,
|
|
169
|
+
plot: Frame,
|
|
170
|
+
): Vec2 {
|
|
171
|
+
return { x: resolveX(coord[0], scales, plot), y: resolveY(coord[1], scales, plot) };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Default `align` for a frame anchor: stick text inside the chart. */
|
|
175
|
+
function defaultTextAlign(spec: TextAnnotationSpec): ValueLabelAlign {
|
|
176
|
+
if (spec.align) return spec.align;
|
|
177
|
+
if (isFrameAnchorX(spec.x)) {
|
|
178
|
+
if (spec.x === "left") return "left";
|
|
179
|
+
if (spec.x === "right") return "right";
|
|
180
|
+
return "center";
|
|
181
|
+
}
|
|
182
|
+
return "left";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Arrowhead geometry — three-point filled triangle at `to`
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
function arrowHeadPoints(from: Vec2, to: Vec2, size: number): readonly Vec2[] {
|
|
190
|
+
const dx = to.x - from.x;
|
|
191
|
+
const dy = to.y - from.y;
|
|
192
|
+
const len = Math.hypot(dx, dy);
|
|
193
|
+
if (!Number.isFinite(len) || len === 0) {
|
|
194
|
+
// Degenerate: zero-length arrow. Skip the head — the line itself will
|
|
195
|
+
// also collapse to a point and rendering it is harmless.
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
const ux = dx / len;
|
|
199
|
+
const uy = dy / len;
|
|
200
|
+
// Perpendicular to direction.
|
|
201
|
+
const px = -uy;
|
|
202
|
+
const py = ux;
|
|
203
|
+
const halfWidth = size * 0.5;
|
|
204
|
+
const baseX = to.x - ux * size;
|
|
205
|
+
const baseY = to.y - uy * size;
|
|
206
|
+
return [
|
|
207
|
+
{ x: to.x, y: to.y },
|
|
208
|
+
{ x: baseX + px * halfWidth, y: baseY + py * halfWidth },
|
|
209
|
+
{ x: baseX - px * halfWidth, y: baseY - py * halfWidth },
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Renderers — one branch per kind, dispatched from `renderAnnotations`
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
function renderText(
|
|
218
|
+
spec: TextAnnotationSpec,
|
|
219
|
+
scales: ScaleBundle,
|
|
220
|
+
plot: Frame,
|
|
221
|
+
theme: Theme,
|
|
222
|
+
hud: Layer,
|
|
223
|
+
): void {
|
|
224
|
+
const x = resolveX(spec.x, scales, plot) + (spec.offsetX ?? 0);
|
|
225
|
+
const y = resolveY(spec.y, scales, plot) + (spec.offsetY ?? 0);
|
|
226
|
+
valueLabelMark([0], {
|
|
227
|
+
x: () => x,
|
|
228
|
+
y: () => y,
|
|
229
|
+
text: () => spec.text,
|
|
230
|
+
fontSize: spec.fontSize ?? theme.marks.annotationFontSize,
|
|
231
|
+
fontStyle: spec.fontStyle,
|
|
232
|
+
color: spec.color ?? theme.text.color,
|
|
233
|
+
align: defaultTextAlign(spec),
|
|
234
|
+
offset: { x: 0, y: 0 },
|
|
235
|
+
box: spec.box,
|
|
236
|
+
}).addTo(hud);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function renderArrow(
|
|
240
|
+
spec: ArrowAnnotationSpec,
|
|
241
|
+
scales: ScaleBundle,
|
|
242
|
+
plot: Frame,
|
|
243
|
+
theme: Theme,
|
|
244
|
+
hud: Layer,
|
|
245
|
+
): void {
|
|
246
|
+
const from = resolvePoint(spec.from, scales, plot);
|
|
247
|
+
const to = resolvePoint(spec.to, scales, plot);
|
|
248
|
+
const color = spec.color ?? theme.text.color;
|
|
249
|
+
const width = spec.strokeWidth ?? 1.5;
|
|
250
|
+
const headSize = spec.headSize ?? 9;
|
|
251
|
+
|
|
252
|
+
// Shorten the shaft so it terminates at the back of the arrowhead — keeps
|
|
253
|
+
// the join clean instead of the line poking through the triangle tip.
|
|
254
|
+
const dx = to.x - from.x;
|
|
255
|
+
const dy = to.y - from.y;
|
|
256
|
+
const len = Math.hypot(dx, dy);
|
|
257
|
+
let shaftEnd: Vec2 = to;
|
|
258
|
+
if (len > headSize) {
|
|
259
|
+
const t = (len - headSize * 0.9) / len;
|
|
260
|
+
shaftEnd = { x: from.x + dx * t, y: from.y + dy * t };
|
|
261
|
+
}
|
|
262
|
+
hud.addLines([{ x1: from.x, y1: from.y, x2: shaftEnd.x, y2: shaftEnd.y, color, width }]);
|
|
263
|
+
const head = arrowHeadPoints(from, to, headSize);
|
|
264
|
+
if (head.length === 3) {
|
|
265
|
+
hud.addPolygons([{ points: head, fill: color }]);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (spec.label) {
|
|
269
|
+
const [ox, oy] = spec.labelOffset ?? [6, -6];
|
|
270
|
+
valueLabelMark([0], {
|
|
271
|
+
x: () => to.x + ox,
|
|
272
|
+
y: () => to.y + oy,
|
|
273
|
+
text: () => spec.label!,
|
|
274
|
+
fontSize: spec.fontSize ?? theme.marks.annotationFontSize,
|
|
275
|
+
fontStyle: spec.fontStyle,
|
|
276
|
+
color: spec.color ?? theme.text.color,
|
|
277
|
+
align: spec.labelAlign ?? (ox >= 0 ? "left" : "right"),
|
|
278
|
+
offset: { x: 0, y: 0 },
|
|
279
|
+
box: spec.labelBox,
|
|
280
|
+
}).addTo(hud);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function renderCallout(
|
|
285
|
+
spec: CalloutAnnotationSpec,
|
|
286
|
+
scales: ScaleBundle,
|
|
287
|
+
plot: Frame,
|
|
288
|
+
theme: Theme,
|
|
289
|
+
hud: Layer,
|
|
290
|
+
): void {
|
|
291
|
+
const anchor = resolvePoint(spec.at, scales, plot);
|
|
292
|
+
const [ox, oy] = spec.offset ?? [40, -20];
|
|
293
|
+
const label: Vec2 = { x: anchor.x + ox, y: anchor.y + oy };
|
|
294
|
+
const baseColor = spec.color ?? theme.text.color;
|
|
295
|
+
const alpha = (baseColor.a ?? 1) * (spec.leaderAlpha ?? 0.6);
|
|
296
|
+
const leaderColor: Color = { r: baseColor.r, g: baseColor.g, b: baseColor.b, a: alpha };
|
|
297
|
+
hud.addLines([
|
|
298
|
+
{
|
|
299
|
+
x1: anchor.x,
|
|
300
|
+
y1: anchor.y,
|
|
301
|
+
x2: label.x,
|
|
302
|
+
y2: label.y,
|
|
303
|
+
color: leaderColor,
|
|
304
|
+
width: spec.strokeWidth ?? 1,
|
|
305
|
+
},
|
|
306
|
+
]);
|
|
307
|
+
|
|
308
|
+
valueLabelMark([0], {
|
|
309
|
+
x: () => label.x,
|
|
310
|
+
y: () => label.y,
|
|
311
|
+
text: () => spec.label,
|
|
312
|
+
fontSize: spec.fontSize ?? theme.marks.annotationFontSize,
|
|
313
|
+
fontStyle: spec.fontStyle,
|
|
314
|
+
color: spec.labelColor ?? theme.text.color,
|
|
315
|
+
align: spec.align ?? (ox >= 0 ? "left" : "right"),
|
|
316
|
+
offset: { x: 0, y: 0 },
|
|
317
|
+
box: spec.box,
|
|
318
|
+
}).addTo(hud);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function renderAnnotations(
|
|
322
|
+
specs: readonly AnnotationSpec[],
|
|
323
|
+
scales: ScaleBundle,
|
|
324
|
+
plot: Frame,
|
|
325
|
+
theme: Theme,
|
|
326
|
+
hud: Layer,
|
|
327
|
+
): void {
|
|
328
|
+
if (specs.length === 0) return;
|
|
329
|
+
for (const spec of specs) {
|
|
330
|
+
const kind = spec.kind ?? "text";
|
|
331
|
+
if (kind === "arrow") renderArrow(spec as ArrowAnnotationSpec, scales, plot, theme, hud);
|
|
332
|
+
else if (kind === "callout")
|
|
333
|
+
renderCallout(spec as CalloutAnnotationSpec, scales, plot, theme, hud);
|
|
334
|
+
else renderText(spec as TextAnnotationSpec, scales, plot, theme, hud);
|
|
335
|
+
}
|
|
336
|
+
}
|