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,398 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, test } from "vite-plus/test";
|
|
3
|
+
import { createFrame } from "insomni";
|
|
4
|
+
import { __test__ } from "./mount.ts";
|
|
5
|
+
import { coordCartesian, coordPolar, coordRadial } from "./coord.ts";
|
|
6
|
+
import { createDataViewport } from "../viewport.ts";
|
|
7
|
+
import { linearScale } from "../scales.ts";
|
|
8
|
+
import type { ScaleBundle } from "./scales.ts";
|
|
9
|
+
|
|
10
|
+
const { resolvePanZoom, computeVisibleYExtent, wrapViewportThroughCoord, screenToData } = __test__;
|
|
11
|
+
|
|
12
|
+
// ============ Fixtures ============
|
|
13
|
+
|
|
14
|
+
interface Row {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const fixture = {
|
|
20
|
+
series: (): Row[] => [
|
|
21
|
+
{ x: 0, y: 10 },
|
|
22
|
+
{ x: 10, y: 20 },
|
|
23
|
+
{ x: 20, y: 5 },
|
|
24
|
+
{ x: 30, y: 30 },
|
|
25
|
+
{ x: 40, y: 8 },
|
|
26
|
+
],
|
|
27
|
+
/** A "geom"-ish stub — only `channels.x` / `channels.y` are read. */
|
|
28
|
+
pointGeom: (xKey: keyof Row = "x", yKey: keyof Row = "y") => ({
|
|
29
|
+
channels: { x: xKey as unknown, y: yKey as unknown },
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// ============ resolvePanZoom ============
|
|
34
|
+
|
|
35
|
+
describe("resolvePanZoom", () => {
|
|
36
|
+
test("input false returns null (panZoom off)", () => {
|
|
37
|
+
expect(resolvePanZoom(false)).toBeNull();
|
|
38
|
+
expect(resolvePanZoom(undefined)).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("input true uses chart-tightened defaults, not viewport defaults", () => {
|
|
42
|
+
const r = resolvePanZoom(true)!;
|
|
43
|
+
expect(r.minZoom).toBe(1);
|
|
44
|
+
expect(r.maxZoom).toBe(100);
|
|
45
|
+
expect(r.pan).toBe("xy");
|
|
46
|
+
expect(r.zoom).toBe("xy");
|
|
47
|
+
expect(r.yFitPadding).toBeNull();
|
|
48
|
+
expect(r.panBounds).toEqual({ overshoot: 0.1 });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("explicit overrides win over defaults", () => {
|
|
52
|
+
const r = resolvePanZoom({ minZoom: 0.5, maxZoom: 50, pan: "x", zoom: "x" })!;
|
|
53
|
+
expect(r.minZoom).toBe(0.5);
|
|
54
|
+
expect(r.maxZoom).toBe(50);
|
|
55
|
+
expect(r.pan).toBe("x");
|
|
56
|
+
expect(r.zoom).toBe("x");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("panBounds: false disables clamping (undefined at viewport)", () => {
|
|
60
|
+
expect(resolvePanZoom({ panBounds: false })!.panBounds).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("yFit forces x-only pan/zoom and default 5% padding", () => {
|
|
64
|
+
const r = resolvePanZoom({ yFit: true })!;
|
|
65
|
+
expect(r.pan).toBe("x");
|
|
66
|
+
expect(r.zoom).toBe("x");
|
|
67
|
+
expect(r.yFitPadding).toBe(0.05);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("yFit overrides user-supplied pan/zoom — Y is read-only", () => {
|
|
71
|
+
const r = resolvePanZoom({ yFit: true, pan: "xy", zoom: "xy" })!;
|
|
72
|
+
expect(r.pan).toBe("x");
|
|
73
|
+
expect(r.zoom).toBe("x");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("yFit accepts custom padding within [0, 0.5]", () => {
|
|
77
|
+
expect(resolvePanZoom({ yFit: { padding: 0 } })!.yFitPadding).toBe(0);
|
|
78
|
+
expect(resolvePanZoom({ yFit: { padding: 0.2 } })!.yFitPadding).toBe(0.2);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("yFit padding out of range throws", () => {
|
|
82
|
+
expect(() => resolvePanZoom({ yFit: { padding: -0.1 } })).toThrow(/padding/);
|
|
83
|
+
expect(() => resolvePanZoom({ yFit: { padding: 0.6 } })).toThrow(/padding/);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ============ computeVisibleYExtent ============
|
|
88
|
+
|
|
89
|
+
describe("computeVisibleYExtent", () => {
|
|
90
|
+
test("returns padded extent of points whose x falls in the window", () => {
|
|
91
|
+
// Visible x ∈ [5, 25] selects rows at x=10 (y=20) and x=20 (y=5).
|
|
92
|
+
const ext = computeVisibleYExtent([fixture.pointGeom()], fixture.series(), [5, 25], 0.1)!;
|
|
93
|
+
// span = 15, pad = 1.5 → [5 - 1.5, 20 + 1.5]
|
|
94
|
+
expect(ext[0]).toBeCloseTo(3.5, 6);
|
|
95
|
+
expect(ext[1]).toBeCloseTo(21.5, 6);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("inclusive on both edges of the X window", () => {
|
|
99
|
+
const ext = computeVisibleYExtent([fixture.pointGeom()], fixture.series(), [0, 40], 0)!;
|
|
100
|
+
// Whole series, no padding.
|
|
101
|
+
expect(ext).toEqual([5, 30]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("returns null when no point falls inside the window", () => {
|
|
105
|
+
const ext = computeVisibleYExtent([fixture.pointGeom()], fixture.series(), [100, 200], 0.05);
|
|
106
|
+
expect(ext).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("returns null on empty data or no layers", () => {
|
|
110
|
+
expect(computeVisibleYExtent([fixture.pointGeom()], [], [0, 10], 0.05)).toBeNull();
|
|
111
|
+
expect(computeVisibleYExtent([], fixture.series(), [0, 10], 0.05)).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("singleton y collapses to a baseline-padded range, not a zero span", () => {
|
|
115
|
+
const data: Row[] = [{ x: 10, y: 7 }];
|
|
116
|
+
const ext = computeVisibleYExtent([fixture.pointGeom()], data, [0, 20], 0.1)!;
|
|
117
|
+
// pad = |7| * 0.1 = 0.7
|
|
118
|
+
expect(ext[0]).toBeCloseTo(6.3, 6);
|
|
119
|
+
expect(ext[1]).toBeCloseTo(7.7, 6);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("unions extent across multiple layers", () => {
|
|
123
|
+
const a: Row[] = [{ x: 5, y: 100 }];
|
|
124
|
+
// Use same data array but second layer reads a different field — simulate
|
|
125
|
+
// by providing a function accessor that yields 200.
|
|
126
|
+
const geom2 = { channels: { x: "x", y: (() => 200) as unknown } };
|
|
127
|
+
const ext = computeVisibleYExtent([fixture.pointGeom(), geom2], a, [0, 10], 0)!;
|
|
128
|
+
expect(ext).toEqual([100, 200]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("array-shaped channels (stacked / multi-series) are skipped in v1", () => {
|
|
132
|
+
const geom = { channels: { x: ["a", "b"], y: ["a", "b"] } };
|
|
133
|
+
const data = [{ a: 1, b: 1 }];
|
|
134
|
+
expect(computeVisibleYExtent([geom], data, [0, 10], 0)).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("treats x window endpoints in any order (lo/hi swap-safe)", () => {
|
|
138
|
+
const ext = computeVisibleYExtent([fixture.pointGeom()], fixture.series(), [25, 5], 0)!;
|
|
139
|
+
// Same selection as [5, 25].
|
|
140
|
+
expect(ext).toEqual([5, 20]);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ============ wrapViewportThroughCoord ============
|
|
145
|
+
//
|
|
146
|
+
// Verifies the mount-time wrapper that routes `panBy` / `zoomAt` through
|
|
147
|
+
// `coord.handlePan` / `coord.handleZoom`. Cartesian must be byte-identical
|
|
148
|
+
// to a direct `panBy` / `zoomAt` call; polar must rotate `startAngle` on
|
|
149
|
+
// horizontal pan and only zoom the radius scale.
|
|
150
|
+
|
|
151
|
+
describe("wrapViewportThroughCoord", () => {
|
|
152
|
+
function makeViewport() {
|
|
153
|
+
return createDataViewport<number, number>({
|
|
154
|
+
frame: createFrame({ x: 0, y: 0, width: 200, height: 200 }),
|
|
155
|
+
x: { type: "linear", domain: [0, 100] },
|
|
156
|
+
y: { type: "linear", domain: [0, 100] },
|
|
157
|
+
minZoom: 0.01,
|
|
158
|
+
maxZoom: 100,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
type PolarReadout = { __polar__: { startAngle: () => number } };
|
|
163
|
+
|
|
164
|
+
test("cartesian: panBy delegates to underlying viewport with the same delta", () => {
|
|
165
|
+
const vp = makeViewport();
|
|
166
|
+
// Control viewport: a direct panBy on a parallel instance gives us the
|
|
167
|
+
// expected domains.
|
|
168
|
+
const control = makeViewport();
|
|
169
|
+
control.panBy(10, -5);
|
|
170
|
+
const xCtrl = control.visibleXDomain;
|
|
171
|
+
const yCtrl = control.visibleYDomain;
|
|
172
|
+
|
|
173
|
+
let invalidated = 0;
|
|
174
|
+
const wrapped = wrapViewportThroughCoord(
|
|
175
|
+
vp,
|
|
176
|
+
() => coordCartesian(),
|
|
177
|
+
() => {
|
|
178
|
+
invalidated++;
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
wrapped.panBy(10, -5);
|
|
182
|
+
|
|
183
|
+
expect(vp.visibleXDomain).toEqual(xCtrl);
|
|
184
|
+
expect(vp.visibleYDomain).toEqual(yCtrl);
|
|
185
|
+
expect(invalidated).toBe(1);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("cartesian: zoomAt delegates with the same anchor + factor", () => {
|
|
189
|
+
const vp = makeViewport();
|
|
190
|
+
const control = makeViewport();
|
|
191
|
+
control.zoomAt(120, 80, 1.5);
|
|
192
|
+
const xCtrl = control.visibleXDomain;
|
|
193
|
+
const yCtrl = control.visibleYDomain;
|
|
194
|
+
|
|
195
|
+
const wrapped = wrapViewportThroughCoord(
|
|
196
|
+
vp,
|
|
197
|
+
() => coordCartesian(),
|
|
198
|
+
() => {},
|
|
199
|
+
);
|
|
200
|
+
wrapped.zoomAt(120, 80, 1.5);
|
|
201
|
+
|
|
202
|
+
expect(vp.visibleXDomain).toEqual(xCtrl);
|
|
203
|
+
expect(vp.visibleYDomain).toEqual(yCtrl);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("polar: horizontal pan rotates startAngle and leaves the viewport untouched", () => {
|
|
207
|
+
const vp = makeViewport();
|
|
208
|
+
const xBefore = vp.visibleXDomain;
|
|
209
|
+
const yBefore = vp.visibleYDomain;
|
|
210
|
+
const polar = coordPolar();
|
|
211
|
+
polar.bindFrame(vp.frame);
|
|
212
|
+
const polarState = (polar as unknown as PolarReadout).__polar__;
|
|
213
|
+
const startBefore = polarState.startAngle();
|
|
214
|
+
|
|
215
|
+
let invalidated = 0;
|
|
216
|
+
const wrapped = wrapViewportThroughCoord(
|
|
217
|
+
vp,
|
|
218
|
+
() => polar,
|
|
219
|
+
() => {
|
|
220
|
+
invalidated++;
|
|
221
|
+
},
|
|
222
|
+
);
|
|
223
|
+
wrapped.panBy(50, 0);
|
|
224
|
+
|
|
225
|
+
// dx=50, width=200, arc=2π → rotation = π/2.
|
|
226
|
+
expect(polarState.startAngle() - startBefore).toBeCloseTo(Math.PI / 2, 4);
|
|
227
|
+
// No radial component — viewport domains stay put.
|
|
228
|
+
expect(vp.visibleXDomain).toEqual(xBefore);
|
|
229
|
+
expect(vp.visibleYDomain).toEqual(yBefore);
|
|
230
|
+
expect(invalidated).toBe(1);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("polar: vertical pan translates the radius (x) domain, startAngle unchanged", () => {
|
|
234
|
+
const vp = makeViewport();
|
|
235
|
+
const polar = coordPolar(); // angleChannel='y'; radius axis = x.
|
|
236
|
+
polar.bindFrame(vp.frame);
|
|
237
|
+
const polarState = (polar as unknown as PolarReadout).__polar__;
|
|
238
|
+
const startBefore = polarState.startAngle();
|
|
239
|
+
const xBefore = vp.visibleXDomain;
|
|
240
|
+
const yBefore = vp.visibleYDomain;
|
|
241
|
+
|
|
242
|
+
const wrapped = wrapViewportThroughCoord(
|
|
243
|
+
vp,
|
|
244
|
+
() => polar,
|
|
245
|
+
() => {},
|
|
246
|
+
);
|
|
247
|
+
wrapped.panBy(0, 10);
|
|
248
|
+
|
|
249
|
+
expect(polarState.startAngle()).toBe(startBefore);
|
|
250
|
+
expect(vp.visibleXDomain).not.toEqual(xBefore);
|
|
251
|
+
expect(vp.visibleYDomain).toEqual(yBefore);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("polar: zoom only affects the radius (x) domain — y stays put", () => {
|
|
255
|
+
const vp = makeViewport();
|
|
256
|
+
const polar = coordPolar();
|
|
257
|
+
polar.bindFrame(vp.frame);
|
|
258
|
+
const xBefore = vp.visibleXDomain;
|
|
259
|
+
const yBefore = vp.visibleYDomain;
|
|
260
|
+
|
|
261
|
+
const wrapped = wrapViewportThroughCoord(
|
|
262
|
+
vp,
|
|
263
|
+
() => polar,
|
|
264
|
+
() => {},
|
|
265
|
+
);
|
|
266
|
+
// bindDataViewport issues per-axis factors. Both should collapse to the
|
|
267
|
+
// radial channel under polar (angle stays fixed under zoom).
|
|
268
|
+
wrapped.zoomAt(100, 100, { x: 1.5, y: 1.5 });
|
|
269
|
+
|
|
270
|
+
expect(vp.visibleXDomain).not.toEqual(xBefore);
|
|
271
|
+
expect(vp.visibleYDomain).toEqual(yBefore);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("getCoord is read at-event-time so chart.update() can swap coords", () => {
|
|
275
|
+
const vp = makeViewport();
|
|
276
|
+
let current = coordCartesian();
|
|
277
|
+
const wrapped = wrapViewportThroughCoord(
|
|
278
|
+
vp,
|
|
279
|
+
() => current,
|
|
280
|
+
() => {},
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// First pan under cartesian — applies as a straight delta.
|
|
284
|
+
wrapped.panBy(5, 0);
|
|
285
|
+
const xAfterCart = vp.visibleXDomain;
|
|
286
|
+
|
|
287
|
+
// Swap to polar — same delta should now rotate, not pan.
|
|
288
|
+
const polar = coordPolar();
|
|
289
|
+
polar.bindFrame(vp.frame);
|
|
290
|
+
current = polar;
|
|
291
|
+
const polarState = (polar as unknown as PolarReadout).__polar__;
|
|
292
|
+
const startBefore = polarState.startAngle();
|
|
293
|
+
wrapped.panBy(5, 0);
|
|
294
|
+
|
|
295
|
+
expect(vp.visibleXDomain).toEqual(xAfterCart);
|
|
296
|
+
expect(polarState.startAngle()).not.toBe(startBefore);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ============ screenToData (MountedPlot.pickAt primitive) ============
|
|
301
|
+
|
|
302
|
+
describe("screenToData", () => {
|
|
303
|
+
// Build a minimal scale bundle good enough for the helper. Only `x.axisScale`
|
|
304
|
+
// / `y.axisScale` (their `invert`) and the `kind`/`type`/`dataType`/`fn`
|
|
305
|
+
// identity are read.
|
|
306
|
+
function makeScales(
|
|
307
|
+
xDomain: [number, number],
|
|
308
|
+
yDomain: [number, number],
|
|
309
|
+
plotW = 100,
|
|
310
|
+
plotH = 100,
|
|
311
|
+
): ScaleBundle {
|
|
312
|
+
const xRange: [number, number] = [0, plotW];
|
|
313
|
+
const yRange: [number, number] = [plotH, 0]; // flipped (y grows down on screen)
|
|
314
|
+
const xAxis = linearScale(xDomain, xRange);
|
|
315
|
+
const yAxis = linearScale(yDomain, yRange);
|
|
316
|
+
return {
|
|
317
|
+
x: {
|
|
318
|
+
kind: "position" as const,
|
|
319
|
+
type: "linear" as const,
|
|
320
|
+
dataType: "number" as const,
|
|
321
|
+
fn: (v: unknown) => xAxis(v as number),
|
|
322
|
+
axisScale: xAxis,
|
|
323
|
+
},
|
|
324
|
+
y: {
|
|
325
|
+
kind: "position" as const,
|
|
326
|
+
type: "linear" as const,
|
|
327
|
+
dataType: "number" as const,
|
|
328
|
+
fn: (v: unknown) => yAxis(v as number),
|
|
329
|
+
axisScale: yAxis,
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
test("returns null when scales are absent (pre-first-draw)", () => {
|
|
335
|
+
const frame = createFrame({ x: 10, y: 20, width: 100, height: 100 });
|
|
336
|
+
expect(screenToData(50, 50, { frame, scales: null, coord: coordCartesian() })).toBeNull();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("returns null for a point outside the plot frame", () => {
|
|
340
|
+
const frame = createFrame({ x: 10, y: 20, width: 100, height: 100 });
|
|
341
|
+
const scales = makeScales([0, 10], [0, 10]);
|
|
342
|
+
expect(screenToData(0, 0, { frame, scales, coord: coordCartesian() })).toBeNull();
|
|
343
|
+
expect(screenToData(200, 200, { frame, scales, coord: coordCartesian() })).toBeNull();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("Cartesian — canvas px maps to data via axis.invert (frame-relative)", () => {
|
|
347
|
+
const frame = createFrame({ x: 10, y: 20, width: 100, height: 100 });
|
|
348
|
+
// xDomain [0..10] mapped to [0..100], y flipped: yDomain [0..10] → [100..0].
|
|
349
|
+
const scales = makeScales([0, 10], [0, 10]);
|
|
350
|
+
// Canvas (60, 70) → plot-frame (50, 50) → coord identity → invert:
|
|
351
|
+
// dataX = 50 / 100 * 10 = 5, dataY = (100 - 50) / 100 * 10 = 5.
|
|
352
|
+
const got = screenToData(60, 70, { frame, scales, coord: coordCartesian() });
|
|
353
|
+
expect(got).not.toBeNull();
|
|
354
|
+
expect(got!.dataX).toBeCloseTo(5, 6);
|
|
355
|
+
expect(got!.dataY).toBeCloseTo(5, 6);
|
|
356
|
+
expect(got!.plotFrameX).toBeCloseTo(50, 6);
|
|
357
|
+
expect(got!.plotFrameY).toBeCloseTo(50, 6);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("polar — canvas px routes through coord.unproject before invert", () => {
|
|
361
|
+
// A 200×200 frame at origin; coordRadial uses min(w, h)/2 = 100 as
|
|
362
|
+
// outerRadius. coordRadial defaults: angleChannel='y', startAngle=-π/2.
|
|
363
|
+
// The 'y' channel is the angular axis. Pick a point on the +x axis from
|
|
364
|
+
// the centre at r = outerR/2 = 50 → unproject lands on plot-frame
|
|
365
|
+
// (plotW * t_radial, plotH - plotH * t_angle), where t_radial = 0.5 and
|
|
366
|
+
// t_angle corresponds to θ = 0 (one quarter-turn CCW from start θ=-π/2):
|
|
367
|
+
// t_angle = 0.25 → plot-frame y = plotH * (1 - 0.25) = 150.
|
|
368
|
+
// plot-frame x = plotW * t_radial = 100.
|
|
369
|
+
const frame = createFrame({ x: 0, y: 0, width: 200, height: 200 });
|
|
370
|
+
// Same domains the chart would supply: x → [0..plotW], y → [0..plotH].
|
|
371
|
+
const scales = makeScales([0, 200], [0, 200], 200, 200);
|
|
372
|
+
const coord = coordRadial();
|
|
373
|
+
coord.bindFrame(frame);
|
|
374
|
+
// Centre (cx, cy) = (100, 100). Move 50 px along +x.
|
|
375
|
+
const got = screenToData(150, 100, { frame, scales, coord });
|
|
376
|
+
expect(got).not.toBeNull();
|
|
377
|
+
// After unproject, plot-frame y maps to top-half of frame.
|
|
378
|
+
expect(got!.plotFrameX).toBeCloseTo(100, 4);
|
|
379
|
+
expect(got!.plotFrameY).toBeCloseTo(150, 4);
|
|
380
|
+
// And the inverted scales recover those domain values 1:1 here
|
|
381
|
+
// (domain == range orientation modulo y flip).
|
|
382
|
+
expect(got!.dataX).toBeCloseTo(100, 4);
|
|
383
|
+
expect(got!.dataY).toBeCloseTo(50, 4);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("polar — returns null when canvas point falls outside the outerRadius", () => {
|
|
387
|
+
const frame = createFrame({ x: 0, y: 0, width: 200, height: 200 });
|
|
388
|
+
const scales = makeScales([0, 200], [0, 200], 200, 200);
|
|
389
|
+
const coord = coordRadial();
|
|
390
|
+
coord.bindFrame(frame);
|
|
391
|
+
// (100, 100) is the centre; (199, 100) is at r=99, just inside.
|
|
392
|
+
// (10, 100) is at r=90, also inside. (1, 1) is at the corner of the frame
|
|
393
|
+
// — inside the rect but r ≈ √(99²+99²) ≈ 140 > 100 outerR. Within frame
|
|
394
|
+
// bounds but outside the polar disc → unproject returns null.
|
|
395
|
+
const got = screenToData(1, 1, { frame, scales, coord });
|
|
396
|
+
expect(got).toBeNull();
|
|
397
|
+
});
|
|
398
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Color } from "insomni";
|
|
2
|
+
import type { TextAnnotationSpec } from "./annotations.ts";
|
|
3
|
+
import type { BandPositionScaleOptions } from "./scales.ts";
|
|
4
|
+
/**
|
|
5
|
+
* Subset of `@phylon/renderer`'s `TipAxis` consumed by the bridge. Any object
|
|
6
|
+
* with this shape (typically the `tipAxis` field on a `RectLayout`) works.
|
|
7
|
+
*/
|
|
8
|
+
export interface TipAxisLike {
|
|
9
|
+
/** Number of tips. */
|
|
10
|
+
count: number;
|
|
11
|
+
/** Tip node ids in display order (top→bottom for rectilinear). */
|
|
12
|
+
order: ArrayLike<number>;
|
|
13
|
+
/** Tip node id → label, or `null` if the tip is unnamed. */
|
|
14
|
+
name(tipId: number): string | null;
|
|
15
|
+
}
|
|
16
|
+
export interface TipScaleOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Band padding in [0, 1). Default `0` — heatmaps and tile-style geoms want
|
|
19
|
+
* tight rows. Bump to e.g. `0.1` for visible gaps between bars.
|
|
20
|
+
*/
|
|
21
|
+
padding?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Replacement label for unnamed tips. Receives the tip's node id and its
|
|
24
|
+
* row index. Default: `(id) => "tip_" + id`.
|
|
25
|
+
*/
|
|
26
|
+
unnamed?: (tipId: number, row: number) => string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Build a y-channel `PositionScaleOptions` from a phylo `TipAxis`. The
|
|
30
|
+
* resulting scale is a band scale whose domain is the tip names in tree
|
|
31
|
+
* display order (top→bottom). Geoms whose `y` aesthetic resolves to a tip
|
|
32
|
+
* name will land on the matching tree row.
|
|
33
|
+
*
|
|
34
|
+
* Tip labels in the data must match `axis.name(tipId)`. Use `joinByTip`
|
|
35
|
+
* (phylo) to align metadata rows to tip names ahead of time.
|
|
36
|
+
*/
|
|
37
|
+
export declare function tipScale(axis: TipAxisLike, opts?: TipScaleOptions): BandPositionScaleOptions;
|
|
38
|
+
/**
|
|
39
|
+
* Structural shape of a phylo `CladeStrip` — neutral data describing a clade
|
|
40
|
+
* range plus a label. Mirrors `@phylon/renderer`'s exported `CladeStrip` so the
|
|
41
|
+
* plot package doesn't have to depend on phylo to consume them.
|
|
42
|
+
*/
|
|
43
|
+
export interface CladeStripLike {
|
|
44
|
+
label: string;
|
|
45
|
+
tipCenter: string;
|
|
46
|
+
color?: Color;
|
|
47
|
+
offsetX?: number;
|
|
48
|
+
}
|
|
49
|
+
export interface CladeStripAnnotationOptions {
|
|
50
|
+
/**
|
|
51
|
+
* Pixel offset added to every strip's annotation. Default `8` — pushes the
|
|
52
|
+
* label clear of the chart's right edge / tip labels. Per-strip
|
|
53
|
+
* `offsetX` is added on top of this.
|
|
54
|
+
*/
|
|
55
|
+
offsetX?: number;
|
|
56
|
+
fontSize?: number;
|
|
57
|
+
fontStyle?: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Convert phylo `CladeStrip[]` into plot `AnnotationSpec[]`. Labels anchor at
|
|
61
|
+
* the chart's right frame edge, vertically aligned to the clade's y-center
|
|
62
|
+
* (resolved by the y-channel's `tipScale`). v1 emits text-only annotations;
|
|
63
|
+
* brackets are a follow-up that needs a non-text annotation kind.
|
|
64
|
+
*/
|
|
65
|
+
export declare function cladeStripsToAnnotations(strips: readonly CladeStripLike[], opts?: CladeStripAnnotationOptions): TextAnnotationSpec[];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, test } from "vite-plus/test";
|
|
2
|
+
import { cladeStripsToAnnotations, tipScale, type TipAxisLike } from "./phylo.ts";
|
|
3
|
+
|
|
4
|
+
function makeAxis(names: readonly (string | null)[]): TipAxisLike {
|
|
5
|
+
return {
|
|
6
|
+
count: names.length,
|
|
7
|
+
order: names.map((_, i) => i),
|
|
8
|
+
name: (id: number) => names[id] ?? null,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("tipScale", () => {
|
|
13
|
+
test("emits a band scale whose domain is tip names in tree order", () => {
|
|
14
|
+
const axis = makeAxis(["C", "B", "A"]);
|
|
15
|
+
const opts = tipScale(axis);
|
|
16
|
+
expect(opts.type).toBe("band");
|
|
17
|
+
expect(opts.domain).toEqual(["C", "B", "A"]);
|
|
18
|
+
expect(opts.padding).toBe(0);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("padding override is forwarded", () => {
|
|
22
|
+
const axis = makeAxis(["x", "y"]);
|
|
23
|
+
expect(tipScale(axis, { padding: 0.25 }).padding).toBe(0.25);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("unnamed tips fall back to a synthetic id", () => {
|
|
27
|
+
const axis = makeAxis(["A", null, "C"]);
|
|
28
|
+
const opts = tipScale(axis);
|
|
29
|
+
expect(opts.domain).toEqual(["A", "tip_1", "C"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("custom unnamed fallback receives id and row", () => {
|
|
33
|
+
const axis = makeAxis([null, null]);
|
|
34
|
+
const opts = tipScale(axis, { unnamed: (id, row) => `r${row}_id${id}` });
|
|
35
|
+
expect(opts.domain).toEqual(["r0_id0", "r1_id1"]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("cladeStripsToAnnotations", () => {
|
|
40
|
+
test("anchors each strip at the right frame edge with tipCenter as y", () => {
|
|
41
|
+
const annotations = cladeStripsToAnnotations([
|
|
42
|
+
{ label: "AB", tipCenter: "B" },
|
|
43
|
+
{ label: "CD", tipCenter: "D", offsetX: 4 },
|
|
44
|
+
]);
|
|
45
|
+
expect(annotations).toHaveLength(2);
|
|
46
|
+
expect(annotations[0]!.x).toBe("right");
|
|
47
|
+
expect(annotations[0]!.y).toBe("B");
|
|
48
|
+
expect(annotations[0]!.align).toBe("left");
|
|
49
|
+
expect(annotations[0]!.offsetX).toBe(8);
|
|
50
|
+
// Per-strip offsetX adds on top of the base.
|
|
51
|
+
expect(annotations[1]!.offsetX).toBe(12);
|
|
52
|
+
expect(annotations[1]!.text).toBe("CD");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("base offsetX option overrides the default", () => {
|
|
56
|
+
const annotations = cladeStripsToAnnotations([{ label: "X", tipCenter: "T" }], { offsetX: 20 });
|
|
57
|
+
expect(annotations[0]!.offsetX).toBe(20);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Bridge: phylo ↔ plot
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// `tipScale(axis)` returns a `PositionScaleOptions` whose discrete domain is
|
|
5
|
+
// the tree's tip names in display order. Hand it to a chart's y-channel to
|
|
6
|
+
// align any geom (heatmap, bar, point, …) to the tip rows of a phylogeny.
|
|
7
|
+
//
|
|
8
|
+
// The bridge is plot-side so phylo can stay dependency-free. To avoid the
|
|
9
|
+
// reverse dependency, plot does not import from phylo — instead it consumes
|
|
10
|
+
// a structural `TipAxisLike` (the subset of `phylo.TipAxis` we actually use).
|
|
11
|
+
|
|
12
|
+
import type { Color } from "insomni";
|
|
13
|
+
import type { TextAnnotationSpec } from "./annotations.ts";
|
|
14
|
+
import type { BandPositionScaleOptions } from "./scales.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Subset of `@phylon/renderer`'s `TipAxis` consumed by the bridge. Any object
|
|
18
|
+
* with this shape (typically the `tipAxis` field on a `RectLayout`) works.
|
|
19
|
+
*/
|
|
20
|
+
export interface TipAxisLike {
|
|
21
|
+
/** Number of tips. */
|
|
22
|
+
count: number;
|
|
23
|
+
/** Tip node ids in display order (top→bottom for rectilinear). */
|
|
24
|
+
order: ArrayLike<number>;
|
|
25
|
+
/** Tip node id → label, or `null` if the tip is unnamed. */
|
|
26
|
+
name(tipId: number): string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TipScaleOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Band padding in [0, 1). Default `0` — heatmaps and tile-style geoms want
|
|
32
|
+
* tight rows. Bump to e.g. `0.1` for visible gaps between bars.
|
|
33
|
+
*/
|
|
34
|
+
padding?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Replacement label for unnamed tips. Receives the tip's node id and its
|
|
37
|
+
* row index. Default: `(id) => "tip_" + id`.
|
|
38
|
+
*/
|
|
39
|
+
unnamed?: (tipId: number, row: number) => string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build a y-channel `PositionScaleOptions` from a phylo `TipAxis`. The
|
|
44
|
+
* resulting scale is a band scale whose domain is the tip names in tree
|
|
45
|
+
* display order (top→bottom). Geoms whose `y` aesthetic resolves to a tip
|
|
46
|
+
* name will land on the matching tree row.
|
|
47
|
+
*
|
|
48
|
+
* Tip labels in the data must match `axis.name(tipId)`. Use `joinByTip`
|
|
49
|
+
* (phylo) to align metadata rows to tip names ahead of time.
|
|
50
|
+
*/
|
|
51
|
+
export function tipScale(axis: TipAxisLike, opts: TipScaleOptions = {}): BandPositionScaleOptions {
|
|
52
|
+
const fallback = opts.unnamed ?? ((id: number) => `tip_${id}`);
|
|
53
|
+
const domain: string[] = Array.from({ length: axis.count });
|
|
54
|
+
for (let row = 0; row < axis.count; row++) {
|
|
55
|
+
const id = axis.order[row]!;
|
|
56
|
+
domain[row] = axis.name(id) ?? fallback(id, row);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
type: "band",
|
|
60
|
+
domain,
|
|
61
|
+
padding: opts.padding ?? 0,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Structural shape of a phylo `CladeStrip` — neutral data describing a clade
|
|
67
|
+
* range plus a label. Mirrors `@phylon/renderer`'s exported `CladeStrip` so the
|
|
68
|
+
* plot package doesn't have to depend on phylo to consume them.
|
|
69
|
+
*/
|
|
70
|
+
export interface CladeStripLike {
|
|
71
|
+
label: string;
|
|
72
|
+
tipCenter: string;
|
|
73
|
+
color?: Color;
|
|
74
|
+
offsetX?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface CladeStripAnnotationOptions {
|
|
78
|
+
/**
|
|
79
|
+
* Pixel offset added to every strip's annotation. Default `8` — pushes the
|
|
80
|
+
* label clear of the chart's right edge / tip labels. Per-strip
|
|
81
|
+
* `offsetX` is added on top of this.
|
|
82
|
+
*/
|
|
83
|
+
offsetX?: number;
|
|
84
|
+
fontSize?: number;
|
|
85
|
+
fontStyle?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Convert phylo `CladeStrip[]` into plot `AnnotationSpec[]`. Labels anchor at
|
|
90
|
+
* the chart's right frame edge, vertically aligned to the clade's y-center
|
|
91
|
+
* (resolved by the y-channel's `tipScale`). v1 emits text-only annotations;
|
|
92
|
+
* brackets are a follow-up that needs a non-text annotation kind.
|
|
93
|
+
*/
|
|
94
|
+
export function cladeStripsToAnnotations(
|
|
95
|
+
strips: readonly CladeStripLike[],
|
|
96
|
+
opts: CladeStripAnnotationOptions = {},
|
|
97
|
+
): TextAnnotationSpec[] {
|
|
98
|
+
const baseOffset = opts.offsetX ?? 8;
|
|
99
|
+
return strips.map(
|
|
100
|
+
(s): TextAnnotationSpec => ({
|
|
101
|
+
kind: "text",
|
|
102
|
+
text: s.label,
|
|
103
|
+
x: "right",
|
|
104
|
+
y: s.tipCenter,
|
|
105
|
+
align: "left",
|
|
106
|
+
offsetX: baseOffset + (s.offsetX ?? 0),
|
|
107
|
+
color: s.color,
|
|
108
|
+
fontSize: opts.fontSize,
|
|
109
|
+
fontStyle: opts.fontStyle,
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import { makeAxisOptions } from "./pipeline.ts";
|
|
3
|
+
import { themeDefault } from "./theme.ts";
|
|
4
|
+
|
|
5
|
+
describe("makeAxisOptions — default ticks and labelCollision", () => {
|
|
6
|
+
it("defaults ticks to 'auto' when no spec is provided", () => {
|
|
7
|
+
const opts = makeAxisOptions(undefined, themeDefault, undefined);
|
|
8
|
+
expect(opts.ticks).toBe("auto");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("defaults ticks to 'auto' when spec has no ticks field", () => {
|
|
12
|
+
const opts = makeAxisOptions({}, themeDefault, undefined);
|
|
13
|
+
expect(opts.ticks).toBe("auto");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("respects an explicit numeric ticks value", () => {
|
|
17
|
+
const opts = makeAxisOptions({ ticks: 5 }, themeDefault, undefined);
|
|
18
|
+
expect(opts.ticks).toBe(5);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("respects an explicit 'auto' ticks value (no-op, same result)", () => {
|
|
22
|
+
const opts = makeAxisOptions({ ticks: "auto" }, themeDefault, undefined);
|
|
23
|
+
expect(opts.ticks).toBe("auto");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("defaults labelCollision to 'auto' when no spec is provided", () => {
|
|
27
|
+
const opts = makeAxisOptions(undefined, themeDefault, undefined);
|
|
28
|
+
expect(opts.labelCollision).toBe("auto");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("defaults labelCollision to 'auto' when spec has no labelCollision field", () => {
|
|
32
|
+
const opts = makeAxisOptions({}, themeDefault, undefined);
|
|
33
|
+
expect(opts.labelCollision).toBe("auto");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("respects an explicit labelCollision value", () => {
|
|
37
|
+
const opts = makeAxisOptions({ labelCollision: "auto" }, themeDefault, undefined);
|
|
38
|
+
expect(opts.labelCollision).toBe("auto");
|
|
39
|
+
});
|
|
40
|
+
});
|