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
package/src/legend.ts
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BLACK,
|
|
3
|
+
type Color,
|
|
4
|
+
type FrameRect,
|
|
5
|
+
type GlyphAtlas,
|
|
6
|
+
type Group,
|
|
7
|
+
type Layer,
|
|
8
|
+
withAlpha,
|
|
9
|
+
} from "insomni";
|
|
10
|
+
import { placeable, stack, type BoxOrigin, type Placeable } from "./layout/box.ts";
|
|
11
|
+
import { emitPointSwatch, type PointBorderStyle, type PointShapeKind } from "./marks.ts";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Swatch specs — one per mark style. Each mark contributes a swatch ctor so
|
|
15
|
+
// the legend matches the mark drawing exactly (e.g. dashed line legend for a
|
|
16
|
+
// dashed line mark).
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export interface PointSwatchSpec {
|
|
20
|
+
kind: "point";
|
|
21
|
+
fill?: Color;
|
|
22
|
+
stroke?: Color;
|
|
23
|
+
strokeWidth?: number;
|
|
24
|
+
shape?: PointShapeKind;
|
|
25
|
+
/** Default `5`. */
|
|
26
|
+
radius?: number;
|
|
27
|
+
/** Per-shape border treatment — matches the mark side. Default `"solid"`. */
|
|
28
|
+
borderStyle?: PointBorderStyle;
|
|
29
|
+
/** Optional secondary glyph drawn on the same anchor. `null`/undefined = none. */
|
|
30
|
+
overlayGlyph?: PointShapeKind | null;
|
|
31
|
+
/** Overlay radius as a fraction of `radius`. Default `0.6`. */
|
|
32
|
+
overlayScale?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface LineSwatchSpec {
|
|
36
|
+
kind: "line";
|
|
37
|
+
stroke: Color;
|
|
38
|
+
strokeWidth?: number;
|
|
39
|
+
dashPattern?: readonly number[];
|
|
40
|
+
/** Default `18`. */
|
|
41
|
+
width?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface BarSwatchSpec {
|
|
45
|
+
kind: "bar";
|
|
46
|
+
fill?: Color;
|
|
47
|
+
stroke?: Color;
|
|
48
|
+
strokeWidth?: number;
|
|
49
|
+
cornerRadius?: number;
|
|
50
|
+
/** Default `12`. */
|
|
51
|
+
size?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AreaSwatchSpec {
|
|
55
|
+
kind: "area";
|
|
56
|
+
fill?: Color;
|
|
57
|
+
stroke?: Color;
|
|
58
|
+
strokeWidth?: number;
|
|
59
|
+
/** Default `14` (slight rectangle, not a square). */
|
|
60
|
+
width?: number;
|
|
61
|
+
/** Default `8`. */
|
|
62
|
+
height?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type SwatchSpec = PointSwatchSpec | LineSwatchSpec | BarSwatchSpec | AreaSwatchSpec;
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Swatch constructors — call from your mark site so the legend matches.
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
export function pointSwatch(opts: Omit<PointSwatchSpec, "kind">): PointSwatchSpec {
|
|
72
|
+
return { kind: "point", ...opts };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function lineSwatch(opts: Omit<LineSwatchSpec, "kind">): LineSwatchSpec {
|
|
76
|
+
return { kind: "line", ...opts };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function barSwatch(opts: Omit<BarSwatchSpec, "kind">): BarSwatchSpec {
|
|
80
|
+
return { kind: "bar", ...opts };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function areaSwatch(opts: Omit<AreaSwatchSpec, "kind">): AreaSwatchSpec {
|
|
84
|
+
return { kind: "area", ...opts };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Drawing a swatch (centred at given point, returns occupied width)
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
function swatchWidth(spec: SwatchSpec): number {
|
|
92
|
+
switch (spec.kind) {
|
|
93
|
+
case "point":
|
|
94
|
+
return (spec.radius ?? 5) * 2;
|
|
95
|
+
case "line":
|
|
96
|
+
return spec.width ?? 18;
|
|
97
|
+
case "bar":
|
|
98
|
+
return spec.size ?? 12;
|
|
99
|
+
case "area":
|
|
100
|
+
return spec.width ?? 14;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function swatchHeight(spec: SwatchSpec): number {
|
|
105
|
+
switch (spec.kind) {
|
|
106
|
+
case "point":
|
|
107
|
+
return (spec.radius ?? 5) * 2;
|
|
108
|
+
case "line":
|
|
109
|
+
return Math.max(spec.strokeWidth ?? 2, 2);
|
|
110
|
+
case "bar":
|
|
111
|
+
return spec.size ?? 12;
|
|
112
|
+
case "area":
|
|
113
|
+
return spec.height ?? 8;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function drawSwatch(
|
|
118
|
+
layer: Layer,
|
|
119
|
+
spec: SwatchSpec,
|
|
120
|
+
cx: number,
|
|
121
|
+
cy: number,
|
|
122
|
+
alpha: number = 1,
|
|
123
|
+
group?: Group,
|
|
124
|
+
): void {
|
|
125
|
+
const alphaize = (c: Color) => withAlpha(c, (c.a ?? 1) * alpha);
|
|
126
|
+
switch (spec.kind) {
|
|
127
|
+
case "point": {
|
|
128
|
+
const radius = spec.radius ?? 5;
|
|
129
|
+
const shape = spec.shape ?? "circle";
|
|
130
|
+
const fill = alphaize(spec.fill ?? BLACK);
|
|
131
|
+
const stroke = spec.stroke ? alphaize(spec.stroke) : undefined;
|
|
132
|
+
emitPointSwatch(layer, {
|
|
133
|
+
cx,
|
|
134
|
+
cy,
|
|
135
|
+
radius,
|
|
136
|
+
fill,
|
|
137
|
+
stroke,
|
|
138
|
+
strokeWidth: spec.strokeWidth,
|
|
139
|
+
shape,
|
|
140
|
+
borderStyle: spec.borderStyle,
|
|
141
|
+
overlayGlyph: spec.overlayGlyph ?? null,
|
|
142
|
+
overlayScale: spec.overlayScale,
|
|
143
|
+
group,
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
case "line": {
|
|
148
|
+
const width = spec.width ?? 18;
|
|
149
|
+
const half = width / 2;
|
|
150
|
+
layer.pushPolyline({
|
|
151
|
+
points: [
|
|
152
|
+
{ x: cx - half, y: cy },
|
|
153
|
+
{ x: cx + half, y: cy },
|
|
154
|
+
],
|
|
155
|
+
color: alphaize(spec.stroke),
|
|
156
|
+
width: spec.strokeWidth ?? 2,
|
|
157
|
+
dashPattern: spec.dashPattern,
|
|
158
|
+
cap: "round",
|
|
159
|
+
group,
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
case "bar": {
|
|
164
|
+
const size = spec.size ?? 12;
|
|
165
|
+
layer.pushRect({
|
|
166
|
+
x: cx - size / 2,
|
|
167
|
+
y: cy - size / 2,
|
|
168
|
+
width: size,
|
|
169
|
+
height: size,
|
|
170
|
+
fill: spec.fill ? alphaize(spec.fill) : undefined,
|
|
171
|
+
stroke: spec.stroke ? alphaize(spec.stroke) : undefined,
|
|
172
|
+
strokeWidth: spec.strokeWidth,
|
|
173
|
+
cornerRadius: spec.cornerRadius,
|
|
174
|
+
group,
|
|
175
|
+
});
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
case "area": {
|
|
179
|
+
const width = spec.width ?? 14;
|
|
180
|
+
const height = spec.height ?? 8;
|
|
181
|
+
layer.pushRect({
|
|
182
|
+
x: cx - width / 2,
|
|
183
|
+
y: cy - height / 2,
|
|
184
|
+
width,
|
|
185
|
+
height,
|
|
186
|
+
fill: spec.fill ? alphaize(spec.fill) : undefined,
|
|
187
|
+
stroke: spec.stroke ? alphaize(spec.stroke) : undefined,
|
|
188
|
+
strokeWidth: spec.strokeWidth,
|
|
189
|
+
group,
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Legend
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
export interface LegendEntry {
|
|
201
|
+
label: string;
|
|
202
|
+
swatch: SwatchSpec;
|
|
203
|
+
/** Optional hint for interactivity layers to render the entry as "off". */
|
|
204
|
+
hidden?: boolean;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export type LegendOrientation = "horizontal" | "vertical";
|
|
208
|
+
export type LegendAlign = "start" | "end";
|
|
209
|
+
|
|
210
|
+
export interface LegendOptions {
|
|
211
|
+
atlas: GlyphAtlas;
|
|
212
|
+
orientation?: LegendOrientation;
|
|
213
|
+
/** Anchor inside the layout box. Default `"start"` (left/top edge of box). */
|
|
214
|
+
align?: LegendAlign;
|
|
215
|
+
fontSize?: number;
|
|
216
|
+
fontStyle?: string;
|
|
217
|
+
labelColor?: Color;
|
|
218
|
+
/** Pixel gap between swatch and label. Default `6`. */
|
|
219
|
+
swatchGap?: number;
|
|
220
|
+
/** Pixel gap between entries. Default `14` (horizontal) or `4` (vertical). */
|
|
221
|
+
entryGap?: number;
|
|
222
|
+
/** Optional title rendered above the entries (left-aligned, bold). */
|
|
223
|
+
title?: string;
|
|
224
|
+
titleFontSize?: number;
|
|
225
|
+
titleColor?: Color;
|
|
226
|
+
/** Pixel gap between title and entries. Default `4`. */
|
|
227
|
+
titleGap?: number;
|
|
228
|
+
/** Alpha for hidden entries. Default `0.2`. */
|
|
229
|
+
dimAlpha?: number;
|
|
230
|
+
group?: Group;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface LegendBuilder extends Placeable {
|
|
234
|
+
/** Number of entries (or, for color bars, palette samples). */
|
|
235
|
+
readonly length: number;
|
|
236
|
+
/** Total laid-out width and height in layer-space pixels. */
|
|
237
|
+
measure(): { width: number; height: number };
|
|
238
|
+
/** Bounding boxes of each entry, relative to the legend's top-left. */
|
|
239
|
+
getEntryBboxes(): (FrameRect & { label: string })[];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* `Guide` is the shared shape every legend-like component (categorical
|
|
244
|
+
* `legend()`, continuous `colorBar()`, future `sizeGuide()` / `shapeGuide()`)
|
|
245
|
+
* returns. It's a `Placeable` so the chart layout engine can size and place
|
|
246
|
+
* it generically. Concrete builders may add extra fields (e.g. `length`).
|
|
247
|
+
*/
|
|
248
|
+
export type Guide = Placeable;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Lay out a row (or column) of swatch + label pairs. Each mark contributes
|
|
252
|
+
* its own swatch shape via the `*Swatch(...)` helpers, so a dashed line shows
|
|
253
|
+
* a dashed swatch, a triangle point shows a triangle, etc.
|
|
254
|
+
*
|
|
255
|
+
* The legend is positioned by `origin` when added: `origin` is the *top-left*
|
|
256
|
+
* of the layout box. To right-align, measure first then offset:
|
|
257
|
+
*
|
|
258
|
+
* ```ts
|
|
259
|
+
* const lg = legend(entries, { atlas: a });
|
|
260
|
+
* const { width } = lg.measure();
|
|
261
|
+
* lg.addTo(layer, { x: outer.x + outer.width - width, y: outer.y });
|
|
262
|
+
* ```
|
|
263
|
+
*/
|
|
264
|
+
export function legend(entries: readonly LegendEntry[], options: LegendOptions): LegendBuilder {
|
|
265
|
+
const orientation = options.orientation ?? "horizontal";
|
|
266
|
+
const fontSize = options.fontSize ?? 11;
|
|
267
|
+
const fontStyle = options.fontStyle ?? "normal";
|
|
268
|
+
const swatchGap = options.swatchGap ?? 6;
|
|
269
|
+
const entryGap = options.entryGap ?? (orientation === "horizontal" ? 14 : 4);
|
|
270
|
+
const labelColor = options.labelColor ?? BLACK;
|
|
271
|
+
const align = options.align ?? "start";
|
|
272
|
+
const dimAlpha = options.dimAlpha ?? 0.2;
|
|
273
|
+
|
|
274
|
+
const measureFontOpts = { fontSize, fontStyle, simple: true };
|
|
275
|
+
|
|
276
|
+
// Pre-measure each entry: its swatch box, its label width, and the row height.
|
|
277
|
+
const measured = entries.map((entry) => {
|
|
278
|
+
const sw = swatchWidth(entry.swatch);
|
|
279
|
+
const sh = swatchHeight(entry.swatch);
|
|
280
|
+
const lw = options.atlas.measureText(entry.label, measureFontOpts).width;
|
|
281
|
+
return { entry, swatchW: sw, swatchH: sh, labelW: lw };
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const rowHeight = Math.max(fontSize, ...measured.map((m) => m.swatchH));
|
|
285
|
+
const entriesWidth =
|
|
286
|
+
orientation === "horizontal"
|
|
287
|
+
? measured.reduce((sum, m) => sum + m.swatchW + swatchGap + m.labelW, 0) +
|
|
288
|
+
entryGap * Math.max(0, measured.length - 1)
|
|
289
|
+
: Math.max(0, ...measured.map((m) => m.swatchW + swatchGap + m.labelW));
|
|
290
|
+
const entriesHeight =
|
|
291
|
+
orientation === "horizontal"
|
|
292
|
+
? rowHeight
|
|
293
|
+
: measured.length * rowHeight + entryGap * Math.max(0, measured.length - 1);
|
|
294
|
+
|
|
295
|
+
const getBodyBboxes = (o: BoxOrigin): (FrameRect & { label: string })[] => {
|
|
296
|
+
const bboxes: (FrameRect & { label: string })[] = [];
|
|
297
|
+
if (orientation === "horizontal") {
|
|
298
|
+
let x = o.x;
|
|
299
|
+
for (const m of measured) {
|
|
300
|
+
bboxes.push({
|
|
301
|
+
label: m.entry.label,
|
|
302
|
+
x,
|
|
303
|
+
y: o.y,
|
|
304
|
+
width: m.swatchW + swatchGap + m.labelW,
|
|
305
|
+
height: rowHeight,
|
|
306
|
+
});
|
|
307
|
+
x += m.swatchW + swatchGap + m.labelW + entryGap;
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
let y = o.y;
|
|
311
|
+
for (const m of measured) {
|
|
312
|
+
bboxes.push({
|
|
313
|
+
label: m.entry.label,
|
|
314
|
+
x: o.x,
|
|
315
|
+
y,
|
|
316
|
+
width: m.swatchW + swatchGap + m.labelW,
|
|
317
|
+
height: rowHeight,
|
|
318
|
+
});
|
|
319
|
+
y += rowHeight + entryGap;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return bboxes;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const buildBody = (): Placeable =>
|
|
326
|
+
placeable({ width: entriesWidth, height: entriesHeight }, (layer, o) => {
|
|
327
|
+
if (orientation === "horizontal") {
|
|
328
|
+
let x = o.x;
|
|
329
|
+
const cy = o.y + rowHeight / 2;
|
|
330
|
+
for (const m of measured) {
|
|
331
|
+
const entryAlpha = m.entry.hidden ? dimAlpha : 1;
|
|
332
|
+
drawSwatch(layer, m.entry.swatch, x + m.swatchW / 2, cy, entryAlpha, options.group);
|
|
333
|
+
layer.pushText({
|
|
334
|
+
simple: true,
|
|
335
|
+
text: m.entry.label,
|
|
336
|
+
x: x + m.swatchW + swatchGap,
|
|
337
|
+
y: o.y + (rowHeight - fontSize) / 2,
|
|
338
|
+
fontSize,
|
|
339
|
+
color: withAlpha(labelColor, (labelColor.a ?? 1) * entryAlpha),
|
|
340
|
+
align: "left",
|
|
341
|
+
group: options.group,
|
|
342
|
+
});
|
|
343
|
+
x += m.swatchW + swatchGap + m.labelW + entryGap;
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
let y = o.y;
|
|
347
|
+
for (const m of measured) {
|
|
348
|
+
const cy = y + rowHeight / 2;
|
|
349
|
+
const entryAlpha = m.entry.hidden ? dimAlpha : 1;
|
|
350
|
+
drawSwatch(layer, m.entry.swatch, o.x + m.swatchW / 2, cy, entryAlpha, options.group);
|
|
351
|
+
layer.pushText({
|
|
352
|
+
simple: true,
|
|
353
|
+
text: m.entry.label,
|
|
354
|
+
x: o.x + m.swatchW + swatchGap,
|
|
355
|
+
y: y + (rowHeight - fontSize) / 2,
|
|
356
|
+
fontSize,
|
|
357
|
+
color: withAlpha(labelColor, (labelColor.a ?? 1) * entryAlpha),
|
|
358
|
+
align: "left",
|
|
359
|
+
group: options.group,
|
|
360
|
+
});
|
|
361
|
+
y += rowHeight + entryGap;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const buildTitle = (titleText: string): Placeable => {
|
|
367
|
+
const titleFontSize = options.titleFontSize ?? fontSize;
|
|
368
|
+
const titleColor = options.titleColor ?? labelColor;
|
|
369
|
+
const titleWidth = options.atlas.measureText(titleText, {
|
|
370
|
+
fontSize: titleFontSize,
|
|
371
|
+
fontStyle,
|
|
372
|
+
simple: true,
|
|
373
|
+
}).width;
|
|
374
|
+
return placeable({ width: titleWidth, height: titleFontSize }, (layer, o) => {
|
|
375
|
+
layer.pushText({
|
|
376
|
+
simple: true,
|
|
377
|
+
text: titleText,
|
|
378
|
+
x: o.x,
|
|
379
|
+
y: o.y,
|
|
380
|
+
fontSize: titleFontSize,
|
|
381
|
+
color: titleColor,
|
|
382
|
+
align: "left",
|
|
383
|
+
group: options.group,
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const body = buildBody();
|
|
389
|
+
|
|
390
|
+
// align="start"; outer chart code positions the whole stack. align="end"
|
|
391
|
+
// (right-anchor) is honored in addTo() below by offsetting origin.x by the
|
|
392
|
+
// composed width — matching legacy "right-edge as origin" behavior.
|
|
393
|
+
const titleText = options.title && options.title.length > 0 ? options.title : undefined;
|
|
394
|
+
let composed: Placeable = body;
|
|
395
|
+
if (titleText) {
|
|
396
|
+
composed = stack([buildTitle(titleText), body], {
|
|
397
|
+
direction: "vertical",
|
|
398
|
+
align: "start",
|
|
399
|
+
gap: options.titleGap ?? 4,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const composedSize = composed.measure();
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
length: entries.length,
|
|
407
|
+
measure: () => composedSize,
|
|
408
|
+
getEntryBboxes() {
|
|
409
|
+
// If there's a title, it's stacked above the body.
|
|
410
|
+
// stack([titleBox, body]) with align="start" and direction="vertical"
|
|
411
|
+
// titleBox height = titleFontSize, gap = titleGap
|
|
412
|
+
const yOffset = titleText ? (options.titleFontSize ?? fontSize) + (options.titleGap ?? 4) : 0;
|
|
413
|
+
return getBodyBboxes({ x: 0, y: yOffset });
|
|
414
|
+
},
|
|
415
|
+
addTo(layer: Layer, origin: BoxOrigin) {
|
|
416
|
+
// Legacy behavior: align="end" means the caller passed the *right* edge
|
|
417
|
+
// as origin.x and expects the legend to draw to its left.
|
|
418
|
+
const x = align === "end" ? origin.x - composedSize.width : origin.x;
|
|
419
|
+
composed.addTo(layer, { x, y: origin.y });
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Vec2 } from "insomni";
|
|
2
|
+
/**
|
|
3
|
+
* Built-in interpolation modes between successive points:
|
|
4
|
+
* - `"linear"` — straight line (default).
|
|
5
|
+
* - `"step"` — horizontal then vertical at midpoint.
|
|
6
|
+
* - `"step-before"` — vertical then horizontal (value changes immediately).
|
|
7
|
+
* - `"step-after"` — horizontal then vertical (value held until next x).
|
|
8
|
+
* - `"monotone-x"` — Fritsch–Carlson monotone cubic. Smooth, no overshoot.
|
|
9
|
+
* Safe default for noisy series.
|
|
10
|
+
* - `"basis"` — uniform B-spline. Very smooth; clipped at endpoints.
|
|
11
|
+
* - `"catmull-rom"` — Catmull–Rom spline with tension 0.5 — passes through every point.
|
|
12
|
+
* - `"cardinal"` — Cardinal spline with tension 0.5 — looser, rounder curves.
|
|
13
|
+
*
|
|
14
|
+
* You can also pass a `CustomCurve` function for full control. See `defineCurve`.
|
|
15
|
+
*/
|
|
16
|
+
export type LineCurvePreset = "linear" | "step" | "step-before" | "step-after" | "monotone-x" | "basis" | "catmull-rom" | "cardinal";
|
|
17
|
+
/**
|
|
18
|
+
* Custom resampler. Receives the raw points and a sample-density hint
|
|
19
|
+
* (passed via `lineMark({ curveSamples })`), returns the polyline to draw.
|
|
20
|
+
* Endpoints should generally pass through `points[0]` and `points.at(-1)`.
|
|
21
|
+
*/
|
|
22
|
+
export type CustomCurve = (points: Vec2[], samples: number) => Vec2[];
|
|
23
|
+
export type LineCurve = LineCurvePreset | CustomCurve;
|
|
24
|
+
/**
|
|
25
|
+
* Wrap a resampler function so it has a stable identity for use as a curve
|
|
26
|
+
* preset. The returned value is callable like a `CustomCurve` and may be
|
|
27
|
+
* passed wherever `LineCurve` is expected.
|
|
28
|
+
*
|
|
29
|
+
* ```ts
|
|
30
|
+
* const wobble = defineCurve((points, samples) => {
|
|
31
|
+
* // …emit your own polyline…
|
|
32
|
+
* });
|
|
33
|
+
* lineMark(data, { curve: wobble });
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export declare function defineCurve(resample: CustomCurve): CustomCurve;
|
|
37
|
+
/**
|
|
38
|
+
* Build a Catmull–Rom-family curve at a given tension.
|
|
39
|
+
* `tension = 0.5` is classic Catmull–Rom; `0` is a tighter spline; `1` is
|
|
40
|
+
* very loose. Useful for hand-drawn looking line charts.
|
|
41
|
+
*/
|
|
42
|
+
export declare function cardinalCurve(tension?: number): CustomCurve;
|
|
43
|
+
export declare function resamplePoints(points: Vec2[], curve: LineCurve, samples: number): Vec2[];
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import type { Vec2 } from "insomni";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Built-in interpolation modes between successive points:
|
|
5
|
+
* - `"linear"` — straight line (default).
|
|
6
|
+
* - `"step"` — horizontal then vertical at midpoint.
|
|
7
|
+
* - `"step-before"` — vertical then horizontal (value changes immediately).
|
|
8
|
+
* - `"step-after"` — horizontal then vertical (value held until next x).
|
|
9
|
+
* - `"monotone-x"` — Fritsch–Carlson monotone cubic. Smooth, no overshoot.
|
|
10
|
+
* Safe default for noisy series.
|
|
11
|
+
* - `"basis"` — uniform B-spline. Very smooth; clipped at endpoints.
|
|
12
|
+
* - `"catmull-rom"` — Catmull–Rom spline with tension 0.5 — passes through every point.
|
|
13
|
+
* - `"cardinal"` — Cardinal spline with tension 0.5 — looser, rounder curves.
|
|
14
|
+
*
|
|
15
|
+
* You can also pass a `CustomCurve` function for full control. See `defineCurve`.
|
|
16
|
+
*/
|
|
17
|
+
export type LineCurvePreset =
|
|
18
|
+
| "linear"
|
|
19
|
+
| "step"
|
|
20
|
+
| "step-before"
|
|
21
|
+
| "step-after"
|
|
22
|
+
| "monotone-x"
|
|
23
|
+
| "basis"
|
|
24
|
+
| "catmull-rom"
|
|
25
|
+
| "cardinal";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Custom resampler. Receives the raw points and a sample-density hint
|
|
29
|
+
* (passed via `lineMark({ curveSamples })`), returns the polyline to draw.
|
|
30
|
+
* Endpoints should generally pass through `points[0]` and `points.at(-1)`.
|
|
31
|
+
*/
|
|
32
|
+
export type CustomCurve = (points: Vec2[], samples: number) => Vec2[];
|
|
33
|
+
|
|
34
|
+
export type LineCurve = LineCurvePreset | CustomCurve;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Wrap a resampler function so it has a stable identity for use as a curve
|
|
38
|
+
* preset. The returned value is callable like a `CustomCurve` and may be
|
|
39
|
+
* passed wherever `LineCurve` is expected.
|
|
40
|
+
*
|
|
41
|
+
* ```ts
|
|
42
|
+
* const wobble = defineCurve((points, samples) => {
|
|
43
|
+
* // …emit your own polyline…
|
|
44
|
+
* });
|
|
45
|
+
* lineMark(data, { curve: wobble });
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function defineCurve(resample: CustomCurve): CustomCurve {
|
|
49
|
+
return resample;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build a Catmull–Rom-family curve at a given tension.
|
|
54
|
+
* `tension = 0.5` is classic Catmull–Rom; `0` is a tighter spline; `1` is
|
|
55
|
+
* very loose. Useful for hand-drawn looking line charts.
|
|
56
|
+
*/
|
|
57
|
+
export function cardinalCurve(tension = 0.5): CustomCurve {
|
|
58
|
+
return (points, samples) => cardinalResample(points, samples, tension);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resamplePoints(points: Vec2[], curve: LineCurve, samples: number): Vec2[] {
|
|
62
|
+
if (points.length < 2) return points;
|
|
63
|
+
|
|
64
|
+
if (typeof curve === "function") {
|
|
65
|
+
return curve(points, samples);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
switch (curve) {
|
|
69
|
+
case "linear":
|
|
70
|
+
return points;
|
|
71
|
+
|
|
72
|
+
case "step": {
|
|
73
|
+
const out: Vec2[] = [points[0]!];
|
|
74
|
+
for (let i = 1; i < points.length; i++) {
|
|
75
|
+
const a = points[i - 1]!;
|
|
76
|
+
const b = points[i]!;
|
|
77
|
+
const midX = (a.x + b.x) / 2;
|
|
78
|
+
out.push({ x: midX, y: a.y });
|
|
79
|
+
out.push({ x: midX, y: b.y });
|
|
80
|
+
out.push(b);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
case "step-before": {
|
|
86
|
+
const out: Vec2[] = [points[0]!];
|
|
87
|
+
for (let i = 1; i < points.length; i++) {
|
|
88
|
+
const a = points[i - 1]!;
|
|
89
|
+
const b = points[i]!;
|
|
90
|
+
out.push({ x: a.x, y: b.y });
|
|
91
|
+
out.push(b);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
case "step-after": {
|
|
97
|
+
const out: Vec2[] = [points[0]!];
|
|
98
|
+
for (let i = 1; i < points.length; i++) {
|
|
99
|
+
const a = points[i - 1]!;
|
|
100
|
+
const b = points[i]!;
|
|
101
|
+
out.push({ x: b.x, y: a.y });
|
|
102
|
+
out.push(b);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
case "monotone-x":
|
|
108
|
+
return monotoneCubicResample(points, samples);
|
|
109
|
+
|
|
110
|
+
case "basis":
|
|
111
|
+
return basisSplineResample(points, samples);
|
|
112
|
+
|
|
113
|
+
case "catmull-rom":
|
|
114
|
+
return cardinalResample(points, samples, 0.5);
|
|
115
|
+
|
|
116
|
+
case "cardinal":
|
|
117
|
+
return cardinalResample(points, samples, 0.5);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Fritsch–Carlson monotone cubic: compute slopes that don't overshoot.
|
|
122
|
+
function monotoneCubicResample(points: Vec2[], samples: number): Vec2[] {
|
|
123
|
+
const n = points.length;
|
|
124
|
+
const dx: number[] = Array.from({ length: n - 1 });
|
|
125
|
+
const dy: number[] = Array.from({ length: n - 1 });
|
|
126
|
+
const m: number[] = Array.from({ length: n - 1 });
|
|
127
|
+
for (let i = 0; i < n - 1; i++) {
|
|
128
|
+
dx[i] = points[i + 1]!.x - points[i]!.x;
|
|
129
|
+
dy[i] = points[i + 1]!.y - points[i]!.y;
|
|
130
|
+
m[i] = dx[i] === 0 ? 0 : dy[i]! / dx[i]!;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Slopes at each point.
|
|
134
|
+
const t: number[] = Array.from({ length: n });
|
|
135
|
+
t[0] = m[0]!;
|
|
136
|
+
t[n - 1] = m[n - 2]!;
|
|
137
|
+
for (let i = 1; i < n - 1; i++) {
|
|
138
|
+
if (m[i - 1]! * m[i]! <= 0) {
|
|
139
|
+
t[i] = 0;
|
|
140
|
+
} else {
|
|
141
|
+
t[i] = (m[i - 1]! + m[i]!) / 2;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Limit to keep monotonic.
|
|
145
|
+
for (let i = 0; i < n - 1; i++) {
|
|
146
|
+
if (m[i] === 0) {
|
|
147
|
+
t[i] = 0;
|
|
148
|
+
t[i + 1] = 0;
|
|
149
|
+
} else {
|
|
150
|
+
const a = t[i]! / m[i]!;
|
|
151
|
+
const b = t[i + 1]! / m[i]!;
|
|
152
|
+
const h = a * a + b * b;
|
|
153
|
+
if (h > 9) {
|
|
154
|
+
const s = (3 / Math.sqrt(h)) * m[i]!;
|
|
155
|
+
t[i] = s * a;
|
|
156
|
+
t[i + 1] = s * b;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Sample each segment.
|
|
162
|
+
const out: Vec2[] = [points[0]!];
|
|
163
|
+
for (let i = 0; i < n - 1; i++) {
|
|
164
|
+
const x0 = points[i]!.x;
|
|
165
|
+
const x1 = points[i + 1]!.x;
|
|
166
|
+
const y0 = points[i]!.y;
|
|
167
|
+
const y1 = points[i + 1]!.y;
|
|
168
|
+
const h = x1 - x0;
|
|
169
|
+
if (h === 0) {
|
|
170
|
+
out.push(points[i + 1]!);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
for (let s = 1; s <= samples; s++) {
|
|
174
|
+
const u = s / samples;
|
|
175
|
+
const u2 = u * u;
|
|
176
|
+
const u3 = u2 * u;
|
|
177
|
+
const h00 = 2 * u3 - 3 * u2 + 1;
|
|
178
|
+
const h10 = u3 - 2 * u2 + u;
|
|
179
|
+
const h01 = -2 * u3 + 3 * u2;
|
|
180
|
+
const h11 = u3 - u2;
|
|
181
|
+
const y = h00 * y0 + h10 * h * t[i]! + h01 * y1 + h11 * h * t[i + 1]!;
|
|
182
|
+
const x = x0 + u * h;
|
|
183
|
+
out.push({ x, y });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Uniform B-spline (cubic), sampled. Endpoints are anchored by triplicating.
|
|
190
|
+
function basisSplineResample(points: Vec2[], samples: number): Vec2[] {
|
|
191
|
+
const n = points.length;
|
|
192
|
+
if (n < 2) return points;
|
|
193
|
+
// Triplicate endpoints to clamp.
|
|
194
|
+
const ext: Vec2[] = [points[0]!, points[0]!, ...points, points[n - 1]!, points[n - 1]!];
|
|
195
|
+
const out: Vec2[] = [];
|
|
196
|
+
for (let i = 1; i < ext.length - 2; i++) {
|
|
197
|
+
const p0 = ext[i - 1]!;
|
|
198
|
+
const p1 = ext[i]!;
|
|
199
|
+
const p2 = ext[i + 1]!;
|
|
200
|
+
const p3 = ext[i + 2]!;
|
|
201
|
+
for (let s = 0; s <= samples; s++) {
|
|
202
|
+
const u = s / samples;
|
|
203
|
+
const u2 = u * u;
|
|
204
|
+
const u3 = u2 * u;
|
|
205
|
+
const b0 = (1 - 3 * u + 3 * u2 - u3) / 6;
|
|
206
|
+
const b1 = (4 - 6 * u2 + 3 * u3) / 6;
|
|
207
|
+
const b2 = (1 + 3 * u + 3 * u2 - 3 * u3) / 6;
|
|
208
|
+
const b3 = u3 / 6;
|
|
209
|
+
const x = b0 * p0.x + b1 * p1.x + b2 * p2.x + b3 * p3.x;
|
|
210
|
+
const y = b0 * p0.y + b1 * p1.y + b2 * p2.y + b3 * p3.y;
|
|
211
|
+
out.push({ x, y });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Cardinal/Catmull–Rom spline. Tension 0.5 is the classic CR formulation.
|
|
218
|
+
// Endpoints are duplicated so the curve passes through `points[0]` / `points.at(-1)`.
|
|
219
|
+
function cardinalResample(points: Vec2[], samples: number, tension: number): Vec2[] {
|
|
220
|
+
const n = points.length;
|
|
221
|
+
if (n < 2) return points;
|
|
222
|
+
const c = 1 - tension;
|
|
223
|
+
const out: Vec2[] = [points[0]!];
|
|
224
|
+
for (let i = 0; i < n - 1; i++) {
|
|
225
|
+
const p0 = points[i - 1] ?? points[i]!;
|
|
226
|
+
const p1 = points[i]!;
|
|
227
|
+
const p2 = points[i + 1]!;
|
|
228
|
+
const p3 = points[i + 2] ?? points[i + 1]!;
|
|
229
|
+
for (let s = 1; s <= samples; s++) {
|
|
230
|
+
const t = s / samples;
|
|
231
|
+
const t2 = t * t;
|
|
232
|
+
const t3 = t2 * t;
|
|
233
|
+
// Cardinal basis.
|
|
234
|
+
const b0 = -c * t + 2 * c * t2 - c * t3;
|
|
235
|
+
const b1 = 1 + (c - 3) * t2 + (2 - c) * t3;
|
|
236
|
+
const b2 = c * t + (3 - 2 * c) * t2 + (c - 2) * t3;
|
|
237
|
+
const b3 = -c * t2 + c * t3;
|
|
238
|
+
const x = b0 * p0.x + b1 * p1.x + b2 * p2.x + b3 * p3.x;
|
|
239
|
+
const y = b0 * p0.y + b1 * p1.y + b2 * p2.y + b3 * p3.y;
|
|
240
|
+
out.push({ x, y });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|