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.
Files changed (242) hide show
  1. package/LICENSE.md +674 -0
  2. package/README.md +81 -0
  3. package/dist/core.d.mts +340 -0
  4. package/dist/core.mjs +1047 -0
  5. package/dist/index.d.mts +3426 -0
  6. package/dist/index.mjs +12762 -0
  7. package/dist/interactions-DEFL_F4E.mjs +5395 -0
  8. package/dist/range-presets-CzECsu3V.d.mts +1523 -0
  9. package/package.json +34 -0
  10. package/src/annotations.d.ts +121 -0
  11. package/src/annotations.ts +438 -0
  12. package/src/axis.d.ts +184 -0
  13. package/src/axis.test.ts +131 -0
  14. package/src/axis.ts +765 -0
  15. package/src/colorbar.d.ts +69 -0
  16. package/src/colorbar.ts +294 -0
  17. package/src/colors.d.ts +57 -0
  18. package/src/colors.test.ts +28 -0
  19. package/src/colors.ts +486 -0
  20. package/src/core.ts +299 -0
  21. package/src/format.d.ts +54 -0
  22. package/src/format.ts +138 -0
  23. package/src/grammar/accessibility.d.ts +147 -0
  24. package/src/grammar/accessibility.test.ts +199 -0
  25. package/src/grammar/accessibility.ts +443 -0
  26. package/src/grammar/aes.d.ts +35 -0
  27. package/src/grammar/aes.test.ts +75 -0
  28. package/src/grammar/aes.ts +120 -0
  29. package/src/grammar/annotations.d.ts +86 -0
  30. package/src/grammar/annotations.test.ts +68 -0
  31. package/src/grammar/annotations.ts +336 -0
  32. package/src/grammar/attach-brush.d.ts +44 -0
  33. package/src/grammar/attach-brush.test.ts +214 -0
  34. package/src/grammar/attach-brush.ts +111 -0
  35. package/src/grammar/attach-presets.d.ts +33 -0
  36. package/src/grammar/attach-presets.test.ts +106 -0
  37. package/src/grammar/attach-presets.ts +215 -0
  38. package/src/grammar/chart.d.ts +952 -0
  39. package/src/grammar/chart.test.ts +118 -0
  40. package/src/grammar/chart.ts +1172 -0
  41. package/src/grammar/color-utils.d.ts +29 -0
  42. package/src/grammar/color-utils.test.ts +53 -0
  43. package/src/grammar/color-utils.ts +66 -0
  44. package/src/grammar/constants.d.ts +45 -0
  45. package/src/grammar/constants.ts +61 -0
  46. package/src/grammar/coord.d.ts +183 -0
  47. package/src/grammar/coord.test.ts +355 -0
  48. package/src/grammar/coord.ts +619 -0
  49. package/src/grammar/data/pivot.d.ts +57 -0
  50. package/src/grammar/data/pivot.ts +107 -0
  51. package/src/grammar/emphasis-driver.d.ts +69 -0
  52. package/src/grammar/emphasis-driver.test.ts +199 -0
  53. package/src/grammar/emphasis-driver.ts +205 -0
  54. package/src/grammar/equality.d.ts +3 -0
  55. package/src/grammar/equality.ts +40 -0
  56. package/src/grammar/facet.d.ts +63 -0
  57. package/src/grammar/facet.test.ts +60 -0
  58. package/src/grammar/facet.ts +175 -0
  59. package/src/grammar/geoms/_categorical.d.ts +94 -0
  60. package/src/grammar/geoms/_categorical.ts +0 -0
  61. package/src/grammar/geoms/_distribution.d.ts +52 -0
  62. package/src/grammar/geoms/_distribution.ts +125 -0
  63. package/src/grammar/geoms/_mark.d.ts +69 -0
  64. package/src/grammar/geoms/_mark.ts +136 -0
  65. package/src/grammar/geoms/_shape.d.ts +41 -0
  66. package/src/grammar/geoms/_shape.ts +74 -0
  67. package/src/grammar/geoms/aggregate.d.ts +95 -0
  68. package/src/grammar/geoms/aggregate.test.ts +554 -0
  69. package/src/grammar/geoms/aggregate.ts +840 -0
  70. package/src/grammar/geoms/area.d.ts +32 -0
  71. package/src/grammar/geoms/area.test.ts +165 -0
  72. package/src/grammar/geoms/area.ts +578 -0
  73. package/src/grammar/geoms/band.d.ts +27 -0
  74. package/src/grammar/geoms/band.test.ts +57 -0
  75. package/src/grammar/geoms/band.ts +126 -0
  76. package/src/grammar/geoms/bar.d.ts +56 -0
  77. package/src/grammar/geoms/bar.test.ts +367 -0
  78. package/src/grammar/geoms/bar.ts +1054 -0
  79. package/src/grammar/geoms/boxplot.d.ts +129 -0
  80. package/src/grammar/geoms/boxplot.test.ts +299 -0
  81. package/src/grammar/geoms/boxplot.ts +834 -0
  82. package/src/grammar/geoms/connected-scatter.d.ts +27 -0
  83. package/src/grammar/geoms/connected-scatter.test.ts +157 -0
  84. package/src/grammar/geoms/connected-scatter.ts +63 -0
  85. package/src/grammar/geoms/emphasis.d.ts +76 -0
  86. package/src/grammar/geoms/emphasis.test.ts +135 -0
  87. package/src/grammar/geoms/emphasis.ts +162 -0
  88. package/src/grammar/geoms/histogram.d.ts +75 -0
  89. package/src/grammar/geoms/histogram.test.ts +262 -0
  90. package/src/grammar/geoms/histogram.ts +740 -0
  91. package/src/grammar/geoms/index.d.ts +20 -0
  92. package/src/grammar/geoms/index.ts +77 -0
  93. package/src/grammar/geoms/interval.d.ts +31 -0
  94. package/src/grammar/geoms/interval.test.ts +154 -0
  95. package/src/grammar/geoms/interval.ts +342 -0
  96. package/src/grammar/geoms/line.d.ts +38 -0
  97. package/src/grammar/geoms/line.test.ts +247 -0
  98. package/src/grammar/geoms/line.ts +659 -0
  99. package/src/grammar/geoms/point.d.ts +57 -0
  100. package/src/grammar/geoms/point.test.ts +163 -0
  101. package/src/grammar/geoms/point.ts +545 -0
  102. package/src/grammar/geoms/polar.test.ts +216 -0
  103. package/src/grammar/geoms/ribbon.d.ts +21 -0
  104. package/src/grammar/geoms/ribbon.test.ts +170 -0
  105. package/src/grammar/geoms/ribbon.ts +87 -0
  106. package/src/grammar/geoms/ridgeline.d.ts +89 -0
  107. package/src/grammar/geoms/ridgeline.test.ts +247 -0
  108. package/src/grammar/geoms/ridgeline.ts +1164 -0
  109. package/src/grammar/geoms/rolling.d.ts +43 -0
  110. package/src/grammar/geoms/rolling.test.ts +217 -0
  111. package/src/grammar/geoms/rolling.ts +387 -0
  112. package/src/grammar/geoms/rug.d.ts +28 -0
  113. package/src/grammar/geoms/rug.test.ts +126 -0
  114. package/src/grammar/geoms/rug.ts +214 -0
  115. package/src/grammar/geoms/rule.d.ts +23 -0
  116. package/src/grammar/geoms/rule.test.ts +69 -0
  117. package/src/grammar/geoms/rule.ts +212 -0
  118. package/src/grammar/geoms/smooth.d.ts +54 -0
  119. package/src/grammar/geoms/smooth.test.ts +78 -0
  120. package/src/grammar/geoms/smooth.ts +337 -0
  121. package/src/grammar/geoms/text.d.ts +29 -0
  122. package/src/grammar/geoms/text.test.ts +64 -0
  123. package/src/grammar/geoms/text.ts +234 -0
  124. package/src/grammar/geoms/tile.d.ts +61 -0
  125. package/src/grammar/geoms/tile.test.ts +157 -0
  126. package/src/grammar/geoms/tile.ts +621 -0
  127. package/src/grammar/geoms/types.d.ts +319 -0
  128. package/src/grammar/geoms/types.ts +362 -0
  129. package/src/grammar/geoms/violin.d.ts +85 -0
  130. package/src/grammar/geoms/violin.test.ts +187 -0
  131. package/src/grammar/geoms/violin.ts +672 -0
  132. package/src/grammar/index.d.ts +22 -0
  133. package/src/grammar/index.ts +269 -0
  134. package/src/grammar/interactions/_disposable.d.ts +5 -0
  135. package/src/grammar/interactions/_disposable.ts +23 -0
  136. package/src/grammar/interactions/_z.d.ts +4 -0
  137. package/src/grammar/interactions/_z.ts +16 -0
  138. package/src/grammar/interactions/brush-selection.test.ts +262 -0
  139. package/src/grammar/interactions/brush.d.ts +63 -0
  140. package/src/grammar/interactions/brush.test.ts +483 -0
  141. package/src/grammar/interactions/brush.ts +452 -0
  142. package/src/grammar/interactions/crosshair.d.ts +19 -0
  143. package/src/grammar/interactions/crosshair.test.ts +127 -0
  144. package/src/grammar/interactions/crosshair.ts +76 -0
  145. package/src/grammar/interactions/hit-layer.d.ts +64 -0
  146. package/src/grammar/interactions/hit-layer.ts +246 -0
  147. package/src/grammar/interactions/legend.d.ts +19 -0
  148. package/src/grammar/interactions/legend.ts +101 -0
  149. package/src/grammar/interactions/menu.d.ts +93 -0
  150. package/src/grammar/interactions/menu.test.ts +373 -0
  151. package/src/grammar/interactions/menu.ts +342 -0
  152. package/src/grammar/interactions/selection.d.ts +25 -0
  153. package/src/grammar/interactions/selection.test.ts +289 -0
  154. package/src/grammar/interactions/selection.ts +142 -0
  155. package/src/grammar/interactions/series-readout.d.ts +91 -0
  156. package/src/grammar/interactions/series-readout.test.ts +668 -0
  157. package/src/grammar/interactions/series-readout.ts +422 -0
  158. package/src/grammar/interactions/series-snap.d.ts +70 -0
  159. package/src/grammar/interactions/series-snap.test.ts +214 -0
  160. package/src/grammar/interactions/series-snap.ts +218 -0
  161. package/src/grammar/interactions/tooltip-axis.test.ts +176 -0
  162. package/src/grammar/interactions/tooltip-touch.browser.test.ts +49 -0
  163. package/src/grammar/interactions/tooltip-touch.test.ts +161 -0
  164. package/src/grammar/interactions/tooltip.d.ts +140 -0
  165. package/src/grammar/interactions/tooltip.test.ts +406 -0
  166. package/src/grammar/interactions/tooltip.ts +622 -0
  167. package/src/grammar/interactions/transitions.d.ts +34 -0
  168. package/src/grammar/interactions/transitions.test.ts +172 -0
  169. package/src/grammar/interactions/transitions.ts +160 -0
  170. package/src/grammar/layout.d.ts +68 -0
  171. package/src/grammar/layout.ts +186 -0
  172. package/src/grammar/legend-merge.test.ts +332 -0
  173. package/src/grammar/mount.d.ts +78 -0
  174. package/src/grammar/mount.test.ts +479 -0
  175. package/src/grammar/mount.ts +2112 -0
  176. package/src/grammar/palettes.d.ts +54 -0
  177. package/src/grammar/palettes.test.ts +80 -0
  178. package/src/grammar/palettes.ts +167 -0
  179. package/src/grammar/pan-zoom.test.ts +398 -0
  180. package/src/grammar/phylo.d.ts +65 -0
  181. package/src/grammar/phylo.test.ts +59 -0
  182. package/src/grammar/phylo.ts +112 -0
  183. package/src/grammar/pipeline.auto-ticks.test.ts +40 -0
  184. package/src/grammar/pipeline.d.ts +158 -0
  185. package/src/grammar/pipeline.test.ts +463 -0
  186. package/src/grammar/pipeline.ts +1233 -0
  187. package/src/grammar/profiling.d.ts +8 -0
  188. package/src/grammar/profiling.ts +24 -0
  189. package/src/grammar/scales.d.ts +188 -0
  190. package/src/grammar/scales.test.ts +181 -0
  191. package/src/grammar/scales.ts +800 -0
  192. package/src/grammar/svg.d.ts +3 -0
  193. package/src/grammar/svg.ts +39 -0
  194. package/src/grammar/theme.d.ts +261 -0
  195. package/src/grammar/theme.test.ts +105 -0
  196. package/src/grammar/theme.ts +490 -0
  197. package/src/heatmap/cpu.ts +109 -0
  198. package/src/heatmap/gpu.ts +565 -0
  199. package/src/heatmap/types.ts +177 -0
  200. package/src/heatmap.browser.test.ts +308 -0
  201. package/src/heatmap.test.ts +320 -0
  202. package/src/heatmap.ts +123 -0
  203. package/src/index.d.ts +1 -0
  204. package/src/index.ts +8 -0
  205. package/src/interactions.d.ts +48 -0
  206. package/src/interactions.test.ts +226 -0
  207. package/src/interactions.ts +394 -0
  208. package/src/layout/box.d.ts +48 -0
  209. package/src/layout/box.test.ts +107 -0
  210. package/src/layout/box.ts +143 -0
  211. package/src/legend.d.ts +115 -0
  212. package/src/legend.ts +422 -0
  213. package/src/marks/curve.d.ts +43 -0
  214. package/src/marks/curve.ts +244 -0
  215. package/src/marks/stack.d.ts +53 -0
  216. package/src/marks/stack.ts +184 -0
  217. package/src/marks.d.ts +273 -0
  218. package/src/marks.test.ts +541 -0
  219. package/src/marks.ts +1292 -0
  220. package/src/navigator.test.ts +174 -0
  221. package/src/navigator.ts +393 -0
  222. package/src/range-presets.d.ts +113 -0
  223. package/src/range-presets.test.ts +345 -0
  224. package/src/range-presets.ts +349 -0
  225. package/src/scales.d.ts +98 -0
  226. package/src/scales.test.ts +103 -0
  227. package/src/scales.ts +695 -0
  228. package/src/stats/index.d.ts +200 -0
  229. package/src/stats/index.test.ts +349 -0
  230. package/src/stats/index.ts +740 -0
  231. package/src/stats/regression.d.ts +38 -0
  232. package/src/stats/regression.test.ts +56 -0
  233. package/src/stats/regression.ts +396 -0
  234. package/src/stats/rolling-window.d.ts +55 -0
  235. package/src/stats/rolling-window.test.ts +237 -0
  236. package/src/stats/rolling-window.ts +256 -0
  237. package/src/test-setup.ts +19 -0
  238. package/src/viewport/axis-state.d.ts +72 -0
  239. package/src/viewport/axis-state.ts +476 -0
  240. package/src/viewport.d.ts +170 -0
  241. package/src/viewport.test.ts +363 -0
  242. package/src/viewport.ts +510 -0
@@ -0,0 +1,659 @@
1
+ // ---------------------------------------------------------------------------
2
+ // line geom
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import { lerpColor, type Color, type Layer } from "insomni";
6
+ import { lineSwatch } from "../../legend.ts";
7
+ import {
8
+ DASHED_PATTERN,
9
+ DOTTED_PATTERN,
10
+ lineMark,
11
+ type LineCurve,
12
+ type LineDashStyle,
13
+ type MarkBuilder,
14
+ } from "../../marks.ts";
15
+ import { resamplePoints } from "../../marks/curve.ts";
16
+ import type { Aes } from "../aes.ts";
17
+ import { materialize, resolveAes } from "../aes.ts";
18
+ import { alphaize } from "../color-utils.ts";
19
+ import type { Coord, Point } from "../coord.ts";
20
+ import type {
21
+ CompileContext,
22
+ CompiledHitTest,
23
+ Geom,
24
+ GeomFrame,
25
+ GeomHoverDecorator,
26
+ HoveredHit,
27
+ ResolvedChannelMap,
28
+ } from "./types.ts";
29
+ import {
30
+ haloRing,
31
+ inlineMark,
32
+ resolveCoord,
33
+ SELECTION_DIM_ALPHA,
34
+ selectedIndicesFor,
35
+ selectionActive,
36
+ wrapMark,
37
+ } from "./_mark.ts";
38
+ import { emphasisContext } from "./emphasis.ts";
39
+
40
+ export interface LineChannels<T> {
41
+ x: Aes<T, number | Date>;
42
+ y: Aes<T, number | Date>;
43
+ /** Categorical color channel splits the line into one stroke per category. */
44
+ color?: Aes<T, unknown>;
45
+ /**
46
+ * Optional ordering aesthetic. When present, rows are connected in ascending
47
+ * order (globally, or within each color-grouped series).
48
+ */
49
+ order?: Aes<T, number | Date>;
50
+ }
51
+
52
+ export interface LineOptions {
53
+ stroke?: Color;
54
+ strokeWidth?: number;
55
+ curve?: LineCurve;
56
+ curveSamples?: number;
57
+ dashPattern?: readonly number[];
58
+ /**
59
+ * Categorical dash treatment. `dashPattern` takes precedence when both
60
+ * are supplied. See {@link LineDashStyle}.
61
+ */
62
+ dashStyle?: LineDashStyle;
63
+ label?: string;
64
+ /**
65
+ * When true, hover hit-tests resolve to the nearest vertex *by x* — the
66
+ * cursor's vertical position doesn't influence which datum is picked, so
67
+ * the user can hover anywhere along the line. Default `false` (per-vertex
68
+ * Euclidean pick within `pickRadius`). For multi-line charts this means
69
+ * the cursor's x picks one vertex per series; the topmost series wins
70
+ * via PointCloud zIndex order.
71
+ */
72
+ nearestX?: boolean;
73
+ }
74
+
75
+ export function line<T>(channels: LineChannels<T>, options: LineOptions = {}): Geom<T> {
76
+ return {
77
+ kind: "line",
78
+ channels,
79
+ label: options.label,
80
+ legendSwatch: (color, theme) => lineSwatch({ stroke: color, width: theme.marks.strokeWidth }),
81
+ compile(ctx: CompileContext<T>) {
82
+ const { data, scales, plot, theme } = ctx;
83
+ const coord = resolveCoord(ctx);
84
+
85
+ const xAes = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
86
+ const yAes = resolveAes<T, unknown>(channels.y as Aes<T, unknown>);
87
+ const colorAes = channels.color ? resolveAes<T, unknown>(channels.color) : undefined;
88
+ const orderAes = channels.order ? resolveAes<T, unknown>(channels.order) : undefined;
89
+
90
+ const xScale = scales.x.fn;
91
+ const yScale = scales.y.fn;
92
+ const colorScale = scales.color?.fn;
93
+
94
+ const baseStroke: Color = options.stroke ?? theme.palettes.categorical(0);
95
+ const strokeWidth = options.strokeWidth ?? theme.marks.strokeWidth;
96
+ const curve: LineCurve = options.curve ?? "linear";
97
+
98
+ const builders: MarkBuilder[] = [];
99
+ const anim = ctx.activeTransition;
100
+ // Dim non-selected line strokes when *any* selection is active anywhere
101
+ // in the chart. For single-line: dim if no vertex of this line is in the
102
+ // selected set. For multi-line: dim per-group if no row in the group is
103
+ // selected. Selection rings still mark the active vertices on top.
104
+ const selActive = selectionActive(ctx);
105
+ const selectedSetAll = selectedIndicesFor(ctx, "line");
106
+ // Hover dim now rides the core's GPU emphasis uniform (P5-T3): each color
107
+ // GROUP is tagged with a stable per-series key (ordinal = the group's
108
+ // position in `groupKeys`); the mount fades sibling groups with no marks
109
+ // recompile. Single-line charts have nothing to dim against, so they tag
110
+ // nothing. Resolved by `emphasisResolution` below.
111
+ const fromIndexFor = (d: T, i: number) =>
112
+ anim
113
+ ? ctx.transitionKey
114
+ ? anim.matchIndex(ctx.transitionKey(d, i), i)
115
+ : anim.matchIndex(String(i), i)
116
+ : undefined;
117
+
118
+ const sortedIndices = (indices: readonly number[]): number[] => {
119
+ const out = [...indices];
120
+ if (!orderAes) return out;
121
+ out.sort((a, b) => compareOrder(orderAes.fn(data[a]!, a), orderAes.fn(data[b]!, b), a, b));
122
+ return out;
123
+ };
124
+
125
+ // Single-line path (no color channel): one mark.
126
+ if (!colorAes) {
127
+ const indices = sortedIndices(data.map((_, i) => i));
128
+ // For animation, lerp the stroke color using datum 0's stored rgba as representative.
129
+ const animatedStroke: Color =
130
+ anim && anim.from.count > 0
131
+ ? lerpColor(
132
+ {
133
+ r: anim.from.rgba[0]!,
134
+ g: anim.from.rgba[1]!,
135
+ b: anim.from.rgba[2]!,
136
+ a: anim.from.rgba[3]!,
137
+ },
138
+ baseStroke,
139
+ anim.t,
140
+ )
141
+ : baseStroke;
142
+ const dimSingle = selActive && selectedSetAll === null;
143
+ const finalStroke = dimSingle
144
+ ? alphaize(animatedStroke, SELECTION_DIM_ALPHA)
145
+ : animatedStroke;
146
+ // Combined per-vertex projection through the active coord. Under
147
+ // `coordCartesian()` this is the identity. Under polar (Phase 3) the
148
+ // (x, y) pair projects together; we lerp the plot-frame values for
149
+ // animation and project the final pair.
150
+ const projectVertex = (globalIndex: number): { x: number; y: number } => {
151
+ const d = data[globalIndex]!;
152
+ const toX = xScale(xAes.fn(d, globalIndex));
153
+ const toY = yScale(yAes.fn(d, globalIndex));
154
+ const fromIndex = fromIndexFor(d, globalIndex);
155
+ let px = toX;
156
+ let py = toY;
157
+ if (anim && fromIndex !== undefined) {
158
+ if (Number.isFinite(anim.from.x[fromIndex]!)) {
159
+ px = anim.from.x[fromIndex]! + (toX - anim.from.x[fromIndex]!) * anim.t;
160
+ }
161
+ if (Number.isFinite(anim.from.y[fromIndex]!)) {
162
+ py = anim.from.y[fromIndex]! + (toY - anim.from.y[fromIndex]!) * anim.t;
163
+ }
164
+ }
165
+ return coord.project({ x: px, y: py });
166
+ };
167
+ if (coord.kind === "cartesian") {
168
+ const mark = lineMark(indices, {
169
+ x: (globalIndex) => projectVertex(globalIndex).x,
170
+ y: (globalIndex) => projectVertex(globalIndex).y,
171
+ stroke: finalStroke,
172
+ strokeWidth,
173
+ curve,
174
+ curveSamples: options.curveSamples,
175
+ dashPattern: options.dashPattern,
176
+ dashStyle: options.dashStyle,
177
+ });
178
+ builders.push(wrapMark(mark, plot.topLeft, indices.length));
179
+ } else {
180
+ // Non-cartesian coord: tessellate per-segment through `coord.segment`
181
+ // so adjacent vertices arc/curve correctly under polar instead of
182
+ // being connected by straight chords through projected endpoints.
183
+ builders.push(
184
+ tessellatedPolyline({
185
+ coord,
186
+ indices,
187
+ projectVertex: (globalIndex: number, _localIndex: number) =>
188
+ projectVertex(globalIndex),
189
+ ox: plot.topLeft.x,
190
+ oy: plot.topLeft.y,
191
+ stroke: finalStroke,
192
+ strokeWidth,
193
+ curve,
194
+ curveSamples: options.curveSamples,
195
+ dashPattern: options.dashPattern,
196
+ dashStyle: options.dashStyle,
197
+ emphasisKey: undefined,
198
+ }),
199
+ );
200
+ }
201
+ } else {
202
+ // Multi-line: bucket by color value, one mark per group. Track each
203
+ // row's original index in `data` alongside the row itself so the
204
+ // transition `from` lookup uses the same indexing as `captureFrame`
205
+ // (which iterates the full data array). Without this, group rows 0..n
206
+ // would all read positions from the FIRST series' captured frame.
207
+ const colorValues = materialize(colorAes, data);
208
+ type GroupBucket = { indices: number[] };
209
+ const groups = new Map<unknown, GroupBucket>();
210
+ // Stable first-seen group-key order — the ordinal source shared by
211
+ // tagging and `emphasisResolution` (both walk the same `colorValues`).
212
+ const groupKeys: string[] = [];
213
+ for (let i = 0; i < data.length; i++) {
214
+ const key = colorValues[i];
215
+ let bucket = groups.get(key);
216
+ if (!bucket) {
217
+ bucket = { indices: [] };
218
+ groups.set(key, bucket);
219
+ groupKeys.push(String(key));
220
+ }
221
+ bucket.indices.push(i);
222
+ }
223
+ // GPU emphasis context — only meaningful for multi-line (sibling groups
224
+ // to dim against). The resolver maps a hovered vertex's color group to
225
+ // its ordinal via the same `groupKeys`.
226
+ const emph = emphasisContext(ctx, "line", (hit) => {
227
+ const k = colorValues[hit.dataIndex];
228
+ if (k === undefined) return null;
229
+ // oxlint-disable-next-line no-base-to-string -- color key is a scalar (string/number); String() is safe here
230
+ const ord = groupKeys.indexOf(String(k));
231
+ return ord < 0 ? null : ord;
232
+ });
233
+
234
+ for (const [key, bucket] of groups) {
235
+ if (ctx.hidden?.has(String(key))) continue;
236
+ const indices = sortedIndices(bucket.indices);
237
+ const stroke = colorScale ? colorScale(key) : baseStroke;
238
+ // Group dims when selection is active and no selected datum's color
239
+ // matches this group's key. `selectedSetAll` indexes into the full
240
+ // `data` array, so we look up each selected row's color value.
241
+ let groupHasSel = false;
242
+ if (selActive && selectedSetAll) {
243
+ for (const i of selectedSetAll) {
244
+ if (colorValues[i] === key) {
245
+ groupHasSel = true;
246
+ break;
247
+ }
248
+ }
249
+ }
250
+ // Selection dim stays compile-time (full recompile on select). Hover
251
+ // dim is now GPU-side via the per-group emphasis key below.
252
+ const groupDim = selActive && !groupHasSel;
253
+ const finalGroupStroke = groupDim ? alphaize(stroke, SELECTION_DIM_ALPHA) : stroke;
254
+ const groupEmphasisKey = emph?.keyFor(groupKeys.indexOf(String(key)));
255
+ const groupFromIndexFor = (d: T, localIndex: number) => {
256
+ const globalIndex = indices[localIndex]!;
257
+ return anim
258
+ ? ctx.transitionKey
259
+ ? anim.matchIndex(ctx.transitionKey(d, globalIndex), globalIndex)
260
+ : anim.matchIndex(String(globalIndex), globalIndex)
261
+ : undefined;
262
+ };
263
+ const projectGroupVertex = (
264
+ globalIndex: number,
265
+ localIndex: number,
266
+ ): { x: number; y: number } => {
267
+ const d = data[globalIndex]!;
268
+ const toX = xScale(xAes.fn(d, globalIndex));
269
+ const toY = yScale(yAes.fn(d, globalIndex));
270
+ const fromIndex = groupFromIndexFor(d, localIndex);
271
+ let px = toX;
272
+ let py = toY;
273
+ if (anim && fromIndex !== undefined) {
274
+ if (Number.isFinite(anim.from.x[fromIndex]!)) {
275
+ px = anim.from.x[fromIndex]! + (toX - anim.from.x[fromIndex]!) * anim.t;
276
+ }
277
+ if (Number.isFinite(anim.from.y[fromIndex]!)) {
278
+ py = anim.from.y[fromIndex]! + (toY - anim.from.y[fromIndex]!) * anim.t;
279
+ }
280
+ }
281
+ return coord.project({ x: px, y: py });
282
+ };
283
+ if (coord.kind === "cartesian") {
284
+ const mark = lineMark(indices, {
285
+ x: (globalIndex, localIndex) => projectGroupVertex(globalIndex, localIndex).x,
286
+ y: (globalIndex, localIndex) => projectGroupVertex(globalIndex, localIndex).y,
287
+ stroke: finalGroupStroke,
288
+ strokeWidth,
289
+ curve,
290
+ curveSamples: options.curveSamples,
291
+ dashPattern: options.dashPattern,
292
+ dashStyle: options.dashStyle,
293
+ emphasisKey: groupEmphasisKey,
294
+ });
295
+ builders.push(wrapMark(mark, plot.topLeft, indices.length));
296
+ } else {
297
+ builders.push(
298
+ tessellatedPolyline({
299
+ coord,
300
+ indices,
301
+ projectVertex: (globalIndex: number, localIndex: number) =>
302
+ projectGroupVertex(globalIndex, localIndex),
303
+ ox: plot.topLeft.x,
304
+ oy: plot.topLeft.y,
305
+ stroke: finalGroupStroke,
306
+ strokeWidth,
307
+ curve,
308
+ curveSamples: options.curveSamples,
309
+ dashPattern: options.dashPattern,
310
+ dashStyle: options.dashStyle,
311
+ emphasisKey: groupEmphasisKey,
312
+ }),
313
+ );
314
+ }
315
+ }
316
+ }
317
+
318
+ // Hover treatment for line is the GPU dim above (sibling series fade);
319
+ // the prior compile-time hover halo on the active vertex required a marks
320
+ // recompile per pointer move (gone under P5-T3), so it is dropped. The
321
+ // tooltip/crosshair still track the hovered vertex via the overlay.
322
+
323
+ // Selection rings — one per selected vertex. Stroke dimming for non-
324
+ // selected lines/groups happens above (during mark construction); the
325
+ // rings overlay selected vertices on top.
326
+ const selectedSet = selectedSetAll;
327
+ if (selectedSet) {
328
+ for (const i of selectedSet) {
329
+ const d = data[i];
330
+ if (d === undefined) continue;
331
+ const xv = xAes.fn(d, i);
332
+ const yv = yAes.fn(d, i);
333
+ if (xv == null || yv == null) continue;
334
+ const rawX = xScale(xv);
335
+ const rawY = yScale(yv);
336
+ if (!Number.isFinite(rawX) || !Number.isFinite(rawY)) continue;
337
+ const projected = coord.project({ x: rawX, y: rawY });
338
+ const ringColor: Color =
339
+ colorAes && colorScale ? colorScale(colorAes.fn(d, i)) : baseStroke;
340
+ const cx = plot.topLeft.x + projected.x;
341
+ const cy = plot.topLeft.y + projected.y;
342
+ const r = Math.max(4, strokeWidth * 1.5 + 2);
343
+ builders.push(inlineMark((layer) => haloRing(layer, cx, cy, r, ringColor, 2)));
344
+ }
345
+ }
346
+
347
+ return builders;
348
+ },
349
+ emphasisResolution(ctx) {
350
+ // Only multi-line (a color channel) dims sibling series; single-line has
351
+ // nothing to dim against, so it tags nothing and resolves to null.
352
+ const colorAes = channels.color ? resolveAes<T, unknown>(channels.color) : undefined;
353
+ if (!colorAes) return null;
354
+ const data = ctx.data;
355
+ const colorValues = materialize(colorAes, data);
356
+ // Recompute the SAME first-seen group-key order the compile path used.
357
+ const groupKeys: string[] = [];
358
+ const seen = new Set<string>();
359
+ for (let i = 0; i < data.length; i++) {
360
+ const k = String(colorValues[i]);
361
+ if (!seen.has(k)) {
362
+ seen.add(k);
363
+ groupKeys.push(k);
364
+ }
365
+ }
366
+ return (
367
+ emphasisContext(ctx, "line", (hit) => {
368
+ const k = colorValues[hit.dataIndex];
369
+ if (k === undefined) return null;
370
+ // oxlint-disable-next-line no-base-to-string -- color key is a scalar (string/number); String() is safe here
371
+ const ord = groupKeys.indexOf(String(k));
372
+ return ord < 0 ? null : ord;
373
+ })?.resolver() ?? null
374
+ );
375
+ },
376
+ hoverDecoration(ctx: CompileContext<T>): GeomHoverDecorator | null {
377
+ // Focus halo on the hovered vertex — recovered from the old snap path's
378
+ // compile-time `haloRing` (deleted in e6d5643). Now an OVERLAY decorator
379
+ // replayed into the live overlay layer (NO marks recompile). The overlay
380
+ // ring leaves emphasisKey 0 ⇒ EXEMPT ⇒ it stays full-strength while sibling
381
+ // series dim via the GPU uniform. The hit's `dataIndex` is the vertex's
382
+ // datum index. Mirrors point.ts (re-derive geometry; theme hover fallback).
383
+ const { data, plot } = ctx;
384
+ if (data.length === 0) return null;
385
+ const coord = resolveCoord(ctx);
386
+ const hoverCfg = ctx.theme.interactions.hover;
387
+ const xAes = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
388
+ const yAes = resolveAes<T, unknown>(channels.y as Aes<T, unknown>);
389
+ const colorAes = channels.color ? resolveAes<T, unknown>(channels.color) : undefined;
390
+ const xScale = ctx.scales.x.fn;
391
+ const yScale = ctx.scales.y.fn;
392
+ const colorScale = ctx.scales.color?.fn;
393
+ const baseStroke: Color = options.stroke ?? ctx.theme.palettes.categorical(0);
394
+ const strokeWidth = options.strokeWidth ?? ctx.theme.marks.strokeWidth;
395
+ const r = Math.max(4, strokeWidth * 1.5 + 2);
396
+ const ox = plot.topLeft.x;
397
+ const oy = plot.topLeft.y;
398
+
399
+ return {
400
+ geomKind: "line",
401
+ data,
402
+ decorate(hit: HoveredHit, layer: Layer): void {
403
+ if (!hoverCfg.enabled || hit.data !== data) return;
404
+ const i = hit.dataIndex;
405
+ const d = data[i];
406
+ if (d === undefined) return;
407
+ const xv = xAes.fn(d, i);
408
+ const yv = yAes.fn(d, i);
409
+ if (xv == null || yv == null) return;
410
+ const rawX = xScale(xv);
411
+ const rawY = yScale(yv);
412
+ if (!Number.isFinite(rawX) || !Number.isFinite(rawY)) return;
413
+ const p = coord.project({ x: rawX, y: rawY });
414
+ const ringColor: Color =
415
+ hoverCfg.haloColor ??
416
+ (colorAes && colorScale ? colorScale(colorAes.fn(d, i)) : baseStroke);
417
+ haloRing(layer, ox + p.x, oy + p.y, r, ringColor, hoverCfg.haloStrokeWidth);
418
+ },
419
+ };
420
+ },
421
+ compileHitTest(ctx: CompileContext<T>): CompiledHitTest<T> | null {
422
+ const { data, scales, plot, theme, hidden } = ctx;
423
+ if (data.length === 0) return null;
424
+ const coord = resolveCoord(ctx);
425
+
426
+ const xAes = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
427
+ const yAes = resolveAes<T, unknown>(channels.y as Aes<T, unknown>);
428
+ const colorAes = channels.color ? resolveAes<T, unknown>(channels.color) : undefined;
429
+
430
+ const xScale = scales.x.fn;
431
+ const yScale = scales.y.fn;
432
+
433
+ const positions = new Float32Array(data.length * 2);
434
+ const dataIndex = new Int32Array(data.length);
435
+ const ox = plot.topLeft.x;
436
+ const oy = plot.topLeft.y;
437
+ let n = 0;
438
+ for (let i = 0; i < data.length; i++) {
439
+ const d = data[i]!;
440
+
441
+ if (hidden && colorAes && hidden.has(String(colorAes.fn(d, i)))) {
442
+ continue;
443
+ }
444
+
445
+ const xv = xAes.fn(d, i);
446
+ const yv = yAes.fn(d, i);
447
+ if (xv == null || yv == null) continue;
448
+ const rawX = xScale(xv);
449
+ const rawY = yScale(yv);
450
+ if (!Number.isFinite(rawX) || !Number.isFinite(rawY)) continue;
451
+ const projected = coord.project({ x: rawX, y: rawY });
452
+ positions[n * 2] = ox + projected.x;
453
+ positions[n * 2 + 1] = oy + projected.y;
454
+ dataIndex[n] = i;
455
+ n++;
456
+ }
457
+ if (n === 0) return null;
458
+
459
+ const strokeWidth = options.strokeWidth ?? theme.marks.strokeWidth;
460
+ const channelsMap: ResolvedChannelMap<T> = {
461
+ x: xAes,
462
+ y: yAes,
463
+ color: colorAes,
464
+ };
465
+
466
+ // In nearestX mode the picker ignores the off-axis y, so only the
467
+ // along-axis (x) tolerance matters. Stretch the radius to a safely
468
+ // large value (cursor anywhere within plot width still picks).
469
+ const pickRadius = options.nearestX
470
+ ? Math.max(plot.width, plot.height)
471
+ : Math.max(8, strokeWidth * 2 + 4);
472
+ return {
473
+ geomKind: "line",
474
+ label: options.label,
475
+ positions: positions.subarray(0, n * 2),
476
+ dataIndex: dataIndex.subarray(0, n),
477
+ pickRadius,
478
+ pickAxis: options.nearestX ? "x" : undefined,
479
+ channels: channelsMap,
480
+ data,
481
+ };
482
+ },
483
+ captureFrame(ctx: CompileContext<T>): GeomFrame | null {
484
+ const { data, scales, theme } = ctx;
485
+ if (data.length === 0) return null;
486
+
487
+ const xAes = resolveAes<T, unknown>(channels.x as Aes<T, unknown>);
488
+ const yAes = resolveAes<T, unknown>(channels.y as Aes<T, unknown>);
489
+ const colorAes = channels.color ? resolveAes<T, unknown>(channels.color) : undefined;
490
+
491
+ const xScale = scales.x.fn;
492
+ const yScale = scales.y.fn;
493
+ const colorScale = scales.color?.fn;
494
+
495
+ const baseStroke: Color = options.stroke ?? theme.palettes.categorical(0);
496
+ const transitionKey = ctx.transitionKey;
497
+
498
+ const count = data.length;
499
+ const x = new Float32Array(count);
500
+ const y = new Float32Array(count);
501
+ const rgba = new Float32Array(count * 4);
502
+ const a = new Float32Array(count);
503
+ const ids = transitionKey ? Array.from<string>({ length: count }) : undefined;
504
+
505
+ for (let i = 0; i < count; i++) {
506
+ const d = data[i]!;
507
+ const xv = xAes.fn(d, i);
508
+ const yv = yAes.fn(d, i);
509
+ const px = xScale(xv);
510
+ const py = yScale(yv);
511
+ x[i] = Number.isFinite(px) ? px : NaN;
512
+ y[i] = Number.isFinite(py) ? py : NaN;
513
+
514
+ // Per-datum stroke color (same as the series color for multi-line).
515
+ const c: Color = colorAes && colorScale ? colorScale(colorAes.fn(d, i)) : baseStroke;
516
+ rgba[i * 4] = c.r;
517
+ rgba[i * 4 + 1] = c.g;
518
+ rgba[i * 4 + 2] = c.b;
519
+ rgba[i * 4 + 3] = c.a;
520
+ a[i] = c.a;
521
+ if (ids && transitionKey) ids[i] = transitionKey(d, i);
522
+ }
523
+
524
+ return { count, x, y, rgba, a, ids };
525
+ },
526
+ };
527
+ }
528
+
529
+ function compareOrder(a: unknown, b: unknown, fallbackA: number, fallbackB: number): number {
530
+ const av = orderValue(a);
531
+ const bv = orderValue(b);
532
+ const aFinite = Number.isFinite(av);
533
+ const bFinite = Number.isFinite(bv);
534
+ if (aFinite && bFinite) {
535
+ if (av < bv) return -1;
536
+ if (av > bv) return 1;
537
+ return fallbackA - fallbackB;
538
+ }
539
+ if (aFinite) return -1;
540
+ if (bFinite) return 1;
541
+ return fallbackA - fallbackB;
542
+ }
543
+
544
+ function orderValue(value: unknown): number {
545
+ if (value instanceof Date) return value.getTime();
546
+ return typeof value === "number" ? value : Number.NaN;
547
+ }
548
+
549
+ // ---------------------------------------------------------------------------
550
+ // Polar / non-cartesian tessellation
551
+ // ---------------------------------------------------------------------------
552
+ // Under `coordCartesian` the lineMark path emits a single polyline whose
553
+ // vertices are the projected per-datum (x, y). That works because projection
554
+ // is the identity — straight chords between adjacent vertices visit every
555
+ // pixel along the desired line.
556
+ //
557
+ // Under polar (or any non-identity coord) two adjacent vertices that share a
558
+ // radius but differ in θ should connect by an *arc*, not a chord. We resolve
559
+ // this by walking each consecutive pair through `coord.segment` (which
560
+ // tessellates at the coord's quality knob, ~1°) and stitching the results
561
+ // into a single polyline. The tessellated polyline is then run through the
562
+ // configured `curve` (still useful for non-linear smoothing) before push.
563
+
564
+ interface TessellatedPolylineArgs {
565
+ coord: Coord;
566
+ indices: readonly number[];
567
+ projectVertex: (globalIndex: number, localIndex: number) => Point;
568
+ ox: number;
569
+ oy: number;
570
+ stroke: Color;
571
+ strokeWidth: number;
572
+ curve: LineCurve;
573
+ curveSamples: number | undefined;
574
+ dashPattern: readonly number[] | undefined;
575
+ dashStyle: LineDashStyle | undefined;
576
+ emphasisKey: number | undefined;
577
+ }
578
+
579
+ function tessellatedPolyline(args: TessellatedPolylineArgs): MarkBuilder {
580
+ const {
581
+ coord,
582
+ indices,
583
+ projectVertex,
584
+ ox,
585
+ oy,
586
+ stroke,
587
+ strokeWidth,
588
+ curve,
589
+ curveSamples,
590
+ dashPattern,
591
+ dashStyle,
592
+ emphasisKey,
593
+ } = args;
594
+ // Resolve dash style → pattern up front (mirrors lineMark's behavior).
595
+ const pattern =
596
+ dashPattern ??
597
+ (dashStyle === "dashed" ? DASHED_PATTERN : dashStyle === "dotted" ? DOTTED_PATTERN : undefined);
598
+ return {
599
+ length: indices.length,
600
+ addTo(layer) {
601
+ // Build the per-segment tessellated chain, breaking on non-finite
602
+ // projections (matches `collectSegments` semantics in `lineMark`).
603
+ const chains: { x: number; y: number }[][] = [];
604
+ let current: { x: number; y: number }[] = [];
605
+ const isFinitePoint = (p: Point) => Number.isFinite(p.x) && Number.isFinite(p.y);
606
+ for (let local = 0; local < indices.length; local++) {
607
+ const global = indices[local]!;
608
+ const cur = projectVertex(global, local);
609
+ if (!isFinitePoint(cur)) {
610
+ if (current.length > 0) {
611
+ chains.push(current);
612
+ current = [];
613
+ }
614
+ continue;
615
+ }
616
+ if (current.length === 0) {
617
+ current.push({ x: ox + cur.x, y: oy + cur.y });
618
+ continue;
619
+ }
620
+ // Tessellate from the previous to current vertex through `coord.segment`.
621
+ // `projectVertex` returns the post-projection plot-frame px; we recover
622
+ // the pre-projection input via `coord.unproject`, run `segment`, then
623
+ // re-offset back to layer-px for the polyline.
624
+ const prev = current[current.length - 1]!;
625
+ const prevProjected: Point = { x: prev.x - ox, y: prev.y - oy };
626
+ const prevUn = coord.unproject(prevProjected);
627
+ const curUn = coord.unproject(cur);
628
+ if (prevUn && curUn) {
629
+ const tess = coord.segment(prevUn, curUn);
630
+ // Skip first sample — same as `prev` (already pushed). The final
631
+ // sample equals `cur` (within FP error).
632
+ for (let k = 1; k < tess.length; k++) {
633
+ const p = tess[k]!;
634
+ current.push({ x: ox + p.x, y: oy + p.y });
635
+ }
636
+ } else {
637
+ // Outside the polar domain — fall back to a straight chord to the
638
+ // projected endpoint. Keeps render robust on edge cases.
639
+ current.push({ x: ox + cur.x, y: oy + cur.y });
640
+ }
641
+ }
642
+ if (current.length > 0) chains.push(current);
643
+
644
+ const samples = Math.max(2, curveSamples ?? 16);
645
+ for (const raw of chains) {
646
+ if (raw.length < 2) continue;
647
+ const points = curve === "linear" ? raw : resamplePoints(raw, curve, samples);
648
+ layer.pushPolyline({
649
+ points,
650
+ color: stroke,
651
+ width: strokeWidth,
652
+ dashPattern: pattern,
653
+ emphasisKey,
654
+ });
655
+ }
656
+ return layer;
657
+ },
658
+ };
659
+ }