trackplot 0.1.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.
@@ -0,0 +1,1541 @@
1
+ import * as d3 from "d3"
2
+
3
+ // ─── Constants ───────────────────────────────────────────────────────────────
4
+
5
+ const COLORS = [
6
+ "#6366f1", "#06b6d4", "#10b981", "#f59e0b",
7
+ "#ef4444", "#8b5cf6", "#ec4899", "#14b8a6"
8
+ ]
9
+
10
+ const FONT = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
11
+ const DURATION = 750
12
+ const EASE = d3.easeCubicOut
13
+ const DEFAULT_MARGIN = { top: 24, right: 24, bottom: 44, left: 52 }
14
+
15
+ const ALL_SERIES_TYPES = ["line", "bar", "area", "pie", "scatter", "radar", "horizontal_bar", "candlestick", "funnel"]
16
+ const CARTESIAN_TYPES = ["line", "bar", "area", "scatter", "candlestick"]
17
+
18
+ // ─── Default Theme ──────────────────────────────────────────────────────────
19
+
20
+ const DEFAULT_THEME = {
21
+ colors: COLORS,
22
+ background: "transparent",
23
+ text_color: "#374151",
24
+ axis_color: "#d1d5db",
25
+ grid_color: "#e5e7eb",
26
+ tooltip_bg: "rgba(255, 255, 255, 0.96)",
27
+ tooltip_text: "#111827",
28
+ tooltip_border: "#e5e7eb",
29
+ font: FONT
30
+ }
31
+
32
+ // ─── Format Presets ─────────────────────────────────────────────────────────
33
+
34
+ const FORMAT_PRESETS = {
35
+ currency: v => "$" + d3.format(",.0f")(v),
36
+ percent: v => d3.format(",.0f")(v) + "%",
37
+ compact: d3.format("~s"),
38
+ decimal: d3.format(",.2f"),
39
+ integer: d3.format(",.0f")
40
+ }
41
+
42
+ function resolveFormatter(fmt) {
43
+ if (!fmt) return v => v
44
+ if (FORMAT_PRESETS[fmt]) return FORMAT_PRESETS[fmt]
45
+ try { return d3.format(fmt) } catch { return v => v }
46
+ }
47
+
48
+ // ─── Click Event Helper ─────────────────────────────────────────────────────
49
+
50
+ function dispatchClick(element, detail) {
51
+ element.dispatchEvent(new CustomEvent("trackplot:click", {
52
+ bubbles: true,
53
+ detail: detail
54
+ }))
55
+ }
56
+
57
+ // ─── Utilities ───────────────────────────────────────────────────────────────
58
+
59
+ function sanitizeClass(str) {
60
+ return String(str).replace(/[^a-zA-Z0-9_-]/g, "_")
61
+ }
62
+
63
+ function getXKey(components) {
64
+ const xAxis = components.find(c => c.type === "axis" && c.direction === "x")
65
+ return xAxis ? xAxis.data_key : null
66
+ }
67
+
68
+ function detectScaleType(data, key) {
69
+ if (!key) return "band"
70
+ const sample = data[0]?.[key]
71
+ if (sample != null && !isNaN(parseFloat(sample)) && isFinite(sample)) return "linear"
72
+ return "band"
73
+ }
74
+
75
+ function createXScale(data, key, type, width) {
76
+ if (type === "band") {
77
+ const domain = key ? data.map(d => d[key]) : data.map((_, i) => i)
78
+ return d3.scaleBand().domain(domain).range([0, width]).padding(0.2)
79
+ }
80
+ const extent = d3.extent(data, d => +d[key])
81
+ const padding = (extent[1] - extent[0]) * 0.05 || 1
82
+ return d3.scaleLinear().domain([extent[0] - padding, extent[1] + padding]).nice().range([0, width])
83
+ }
84
+
85
+ function createYScale(data, seriesConfigs, height) {
86
+ let maxVal = 0
87
+ let minVal = 0
88
+
89
+ const stackGroups = {}
90
+ seriesConfigs.forEach(s => {
91
+ if (s.type === "bar" && s.stack) {
92
+ stackGroups[s.stack] = stackGroups[s.stack] || []
93
+ stackGroups[s.stack].push(s.data_key)
94
+ }
95
+ if (s.type === "area" && s.stack) {
96
+ const key = `area_${s.stack}`
97
+ stackGroups[key] = stackGroups[key] || []
98
+ stackGroups[key].push(s.data_key)
99
+ }
100
+ })
101
+
102
+ seriesConfigs.forEach(s => {
103
+ if (s.type === "candlestick") {
104
+ const hi = d3.max(data, d => +d[s.high] || 0)
105
+ const lo = d3.min(data, d => +d[s.low] || 0)
106
+ if (hi > maxVal) maxVal = hi
107
+ if (lo < minVal) minVal = lo
108
+ } else if (!((s.type === "bar" || s.type === "area") && s.stack)) {
109
+ if (s.data_key) {
110
+ const sMax = d3.max(data, d => +d[s.data_key] || 0)
111
+ if (sMax > maxVal) maxVal = sMax
112
+ }
113
+ }
114
+ })
115
+
116
+ Object.values(stackGroups).forEach(keys => {
117
+ data.forEach(d => {
118
+ const sum = keys.reduce((acc, k) => acc + (+d[k] || 0), 0)
119
+ if (sum > maxVal) maxVal = sum
120
+ })
121
+ })
122
+
123
+ if (maxVal === 0) maxVal = 1
124
+ return d3.scaleLinear().domain([minVal, maxVal]).nice().range([height, 0])
125
+ }
126
+
127
+ function xAccessorFor(xScale, xKey) {
128
+ const offset = xScale.bandwidth ? xScale.bandwidth() / 2 : 0
129
+ return xKey
130
+ ? d => xScale(d[xKey]) + offset
131
+ : (_, i) => xScale(i) + offset
132
+ }
133
+
134
+ function findNearestIndex(mouseX, data, getX) {
135
+ let nearest = 0
136
+ let minDist = Infinity
137
+ for (let i = 0; i < data.length; i++) {
138
+ const dist = Math.abs(getX(data[i], i) - mouseX)
139
+ if (dist < minDist) { minDist = dist; nearest = i }
140
+ }
141
+ return nearest
142
+ }
143
+
144
+ function createTooltipDiv(container, theme) {
145
+ const t = theme || DEFAULT_THEME
146
+ const el = document.createElement("div")
147
+ Object.assign(el.style, {
148
+ position: "absolute",
149
+ pointerEvents: "none",
150
+ background: t.tooltip_bg,
151
+ backdropFilter: "blur(8px)",
152
+ WebkitBackdropFilter: "blur(8px)",
153
+ border: `1px solid ${t.tooltip_border}`,
154
+ borderRadius: "8px",
155
+ padding: "10px 14px",
156
+ fontSize: "13px",
157
+ fontFamily: t.font || FONT,
158
+ boxShadow: "0 4px 16px rgba(0, 0, 0, 0.12)",
159
+ opacity: "0",
160
+ transition: "opacity 0.15s ease",
161
+ zIndex: "10",
162
+ whiteSpace: "nowrap",
163
+ lineHeight: "1.6"
164
+ })
165
+ container.style.position = "relative"
166
+ container.appendChild(el)
167
+ return el
168
+ }
169
+
170
+ // ─── Grid Renderer ───────────────────────────────────────────────────────────
171
+
172
+ function renderGrid(g, config, xScale, yScale, width, height, theme) {
173
+ const t = theme || DEFAULT_THEME
174
+ const grid = g.append("g").attr("class", "trackplot-grid")
175
+
176
+ if (config.horizontal !== false) {
177
+ grid.append("g")
178
+ .call(d3.axisLeft(yScale).tickSize(-width).tickFormat(""))
179
+ .call(g => g.selectAll("line").attr("stroke", t.grid_color).attr("stroke-dasharray", "3 3"))
180
+ .call(g => g.selectAll(".domain").remove())
181
+ .call(g => g.selectAll(".tick text").remove())
182
+ }
183
+
184
+ if (config.vertical) {
185
+ grid.append("g")
186
+ .attr("transform", `translate(0,${height})`)
187
+ .call(d3.axisBottom(xScale).tickSize(-height).tickFormat(""))
188
+ .call(g => g.selectAll("line").attr("stroke", t.grid_color).attr("stroke-dasharray", "3 3"))
189
+ .call(g => g.selectAll(".domain").remove())
190
+ .call(g => g.selectAll(".tick text").remove())
191
+ }
192
+ }
193
+
194
+ // ─── Axes Renderer ───────────────────────────────────────────────────────────
195
+
196
+ function renderAxes(g, axesConfigs, xScale, yScale, width, height, theme) {
197
+ const t = theme || DEFAULT_THEME
198
+ const textColor = t.text_color
199
+ const axisStroke = t.axis_color
200
+ const font = t.font || FONT
201
+
202
+ axesConfigs.forEach(axis => {
203
+ if (axis.direction === "x") {
204
+ const xG = g.append("g")
205
+ .attr("class", "trackplot-axis-x")
206
+ .attr("transform", `translate(0,${height})`)
207
+
208
+ let gen = d3.axisBottom(xScale)
209
+ const fmt = resolveFormatter(axis.format)
210
+ if (axis.format) gen = gen.tickFormat(fmt)
211
+ if (axis.tick_count) gen = gen.ticks(axis.tick_count)
212
+
213
+ xG.call(gen)
214
+ xG.selectAll("text").attr("fill", textColor).attr("font-size", "12px").attr("font-family", font)
215
+ if (axis.tick_rotation) {
216
+ xG.selectAll("text").attr("transform", `rotate(${axis.tick_rotation})`).attr("text-anchor", "end")
217
+ }
218
+ xG.selectAll("line").attr("stroke", axisStroke)
219
+ xG.select(".domain").attr("stroke", axisStroke)
220
+
221
+ if (axis.label) {
222
+ xG.append("text").attr("x", width / 2).attr("y", 36)
223
+ .attr("fill", textColor).attr("font-size", "13px").attr("font-family", font)
224
+ .attr("text-anchor", "middle").text(axis.label)
225
+ }
226
+ }
227
+
228
+ if (axis.direction === "y") {
229
+ const yG = g.append("g").attr("class", "trackplot-axis-y")
230
+ let gen = d3.axisLeft(yScale)
231
+ const fmt = resolveFormatter(axis.format)
232
+ if (axis.format) gen = gen.tickFormat(fmt)
233
+ if (axis.tick_count) gen = gen.ticks(axis.tick_count)
234
+
235
+ yG.call(gen)
236
+ yG.selectAll("text").attr("fill", textColor).attr("font-size", "12px").attr("font-family", font)
237
+ yG.selectAll("line").attr("stroke", axisStroke)
238
+ yG.select(".domain").attr("stroke", axisStroke)
239
+
240
+ if (axis.label) {
241
+ yG.append("text").attr("transform", "rotate(-90)")
242
+ .attr("x", -height / 2).attr("y", -40)
243
+ .attr("fill", textColor).attr("font-size", "13px").attr("font-family", font)
244
+ .attr("text-anchor", "middle").text(axis.label)
245
+ }
246
+ }
247
+ })
248
+ }
249
+
250
+ // ─── Reference Lines Renderer ───────────────────────────────────────────────
251
+
252
+ function renderReferenceLines(g, refs, xScale, yScale, width, height, theme) {
253
+ if (!refs || refs.length === 0) return
254
+ const t = theme || DEFAULT_THEME
255
+
256
+ refs.forEach(ref => {
257
+ const color = ref.color || "#ef4444"
258
+ const strokeWidth = ref.stroke_width || 1.5
259
+ const dashed = ref.dashed !== false
260
+
261
+ if (ref.direction === "y") {
262
+ const y = yScale(ref.value)
263
+ if (y == null || isNaN(y)) return
264
+
265
+ g.append("line")
266
+ .attr("class", "trackplot-reference-line")
267
+ .attr("x1", 0).attr("x2", width)
268
+ .attr("y1", y).attr("y2", y)
269
+ .attr("stroke", color)
270
+ .attr("stroke-width", strokeWidth)
271
+ .attr("stroke-dasharray", dashed ? "6 4" : null)
272
+
273
+ if (ref.label) {
274
+ g.append("text")
275
+ .attr("class", "trackplot-reference-label")
276
+ .attr("x", width - 4).attr("y", y - 6)
277
+ .attr("text-anchor", "end")
278
+ .attr("fill", color)
279
+ .attr("font-size", "11px")
280
+ .attr("font-family", t.font || FONT)
281
+ .attr("font-weight", "500")
282
+ .text(ref.label)
283
+ }
284
+ }
285
+
286
+ if (ref.direction === "x") {
287
+ let x
288
+ if (xScale.bandwidth) {
289
+ x = xScale(ref.value) + xScale.bandwidth() / 2
290
+ } else {
291
+ x = xScale(ref.value)
292
+ }
293
+ if (x == null || isNaN(x)) return
294
+
295
+ g.append("line")
296
+ .attr("class", "trackplot-reference-line")
297
+ .attr("x1", x).attr("x2", x)
298
+ .attr("y1", 0).attr("y2", height)
299
+ .attr("stroke", color)
300
+ .attr("stroke-width", strokeWidth)
301
+ .attr("stroke-dasharray", dashed ? "6 4" : null)
302
+
303
+ if (ref.label) {
304
+ g.append("text")
305
+ .attr("class", "trackplot-reference-label")
306
+ .attr("x", x + 6).attr("y", 12)
307
+ .attr("text-anchor", "start")
308
+ .attr("fill", color)
309
+ .attr("font-size", "11px")
310
+ .attr("font-family", t.font || FONT)
311
+ .attr("font-weight", "500")
312
+ .text(ref.label)
313
+ }
314
+ }
315
+ })
316
+ }
317
+
318
+ // ─── Line Renderer ───────────────────────────────────────────────────────────
319
+
320
+ function renderLine(g, data, xScale, yScale, xKey, series, animate, chartElement) {
321
+ const getX = xAccessorFor(xScale, xKey)
322
+ const cls = sanitizeClass(series.data_key)
323
+
324
+ const lineGen = d3.line()
325
+ .x((d, i) => getX(d, i))
326
+ .y(d => yScale(+d[series.data_key]))
327
+ .defined(d => d[series.data_key] != null)
328
+
329
+ if (series.curve) lineGen.curve(d3.curveMonotoneX)
330
+
331
+ const path = g.append("path")
332
+ .datum(data)
333
+ .attr("class", `trackplot-line trackplot-line-${cls}`)
334
+ .attr("fill", "none")
335
+ .attr("stroke", series.color)
336
+ .attr("stroke-width", series.stroke_width || 2)
337
+ .attr("stroke-linecap", "round")
338
+ .attr("stroke-linejoin", "round")
339
+ .attr("d", lineGen)
340
+
341
+ if (series.dashed) path.attr("stroke-dasharray", "6 3")
342
+
343
+ if (animate) {
344
+ const len = path.node().getTotalLength()
345
+ path
346
+ .attr("stroke-dasharray", `${len} ${len}`)
347
+ .attr("stroke-dashoffset", len)
348
+ .transition().duration(DURATION).ease(EASE)
349
+ .attr("stroke-dashoffset", 0)
350
+ .on("end", function () {
351
+ if (!series.dashed) d3.select(this).attr("stroke-dasharray", null)
352
+ })
353
+ }
354
+
355
+ if (series.dot !== false) {
356
+ const dotR = series.dot_size || 4
357
+ const dots = g.selectAll(null)
358
+ .data(data.filter(d => d[series.data_key] != null))
359
+ .enter().append("circle")
360
+ .attr("class", `trackplot-dot trackplot-dot-${cls}`)
361
+ .attr("cx", (d, i) => getX(d, i))
362
+ .attr("cy", d => yScale(+d[series.data_key]))
363
+ .attr("fill", "white")
364
+ .attr("stroke", series.color)
365
+ .attr("stroke-width", 2)
366
+ .style("cursor", "pointer")
367
+
368
+ if (animate) {
369
+ dots.attr("r", 0).transition().delay(DURATION).duration(300).attr("r", dotR)
370
+ } else {
371
+ dots.attr("r", dotR)
372
+ }
373
+
374
+ if (chartElement) {
375
+ dots.on("click", function (event, d) {
376
+ const i = data.indexOf(d)
377
+ dispatchClick(chartElement, {
378
+ chartType: "line",
379
+ dataKey: series.data_key,
380
+ datum: d,
381
+ index: i,
382
+ value: d[series.data_key]
383
+ })
384
+ })
385
+ }
386
+ }
387
+ }
388
+
389
+ // ─── Bar Renderer ────────────────────────────────────────────────────────────
390
+
391
+ function renderBars(g, data, xScale, yScale, xKey, barSeries, animate, chartElement) {
392
+ if (barSeries.length === 0) return
393
+
394
+ const bandwidth = xScale.bandwidth()
395
+ const subScale = d3.scaleBand()
396
+ .domain(barSeries.map(s => s.data_key))
397
+ .range([0, bandwidth])
398
+ .padding(0.05)
399
+
400
+ barSeries.forEach(series => {
401
+ const cls = sanitizeClass(series.data_key)
402
+ const radius = Math.min(series.radius ?? 4, subScale.bandwidth() / 2)
403
+
404
+ const rects = g.selectAll(null)
405
+ .data(data)
406
+ .enter().append("rect")
407
+ .attr("class", `trackplot-bar trackplot-bar-${cls}`)
408
+ .attr("x", d => {
409
+ const base = xKey ? xScale(d[xKey]) : xScale(data.indexOf(d))
410
+ return base + subScale(series.data_key)
411
+ })
412
+ .attr("width", subScale.bandwidth())
413
+ .attr("rx", radius)
414
+ .attr("ry", radius)
415
+ .attr("fill", series.color)
416
+ .attr("opacity", series.opacity ?? 1)
417
+ .attr("y", animate ? yScale(0) : d => yScale(+d[series.data_key] || 0))
418
+ .attr("height", animate ? 0 : d => Math.max(0, yScale(0) - yScale(+d[series.data_key] || 0)))
419
+ .style("cursor", "pointer")
420
+ .transition().duration(animate ? DURATION : 0).ease(EASE)
421
+ .delay((_, i) => animate ? i * 40 : 0)
422
+ .attr("y", d => yScale(+d[series.data_key] || 0))
423
+ .attr("height", d => Math.max(0, yScale(0) - yScale(+d[series.data_key] || 0)))
424
+
425
+ if (chartElement) {
426
+ g.selectAll(`.trackplot-bar-${cls}`)
427
+ .on("click", function (event, d) {
428
+ const i = data.indexOf(d)
429
+ dispatchClick(chartElement, {
430
+ chartType: "bar",
431
+ dataKey: series.data_key,
432
+ datum: d,
433
+ index: i,
434
+ value: d[series.data_key]
435
+ })
436
+ })
437
+ }
438
+ })
439
+ }
440
+
441
+ // ─── Area Renderer ───────────────────────────────────────────────────────────
442
+
443
+ function renderArea(g, data, xScale, yScale, xKey, series, animate, chartElement) {
444
+ const getX = xAccessorFor(xScale, xKey)
445
+ const cls = sanitizeClass(series.data_key)
446
+ const gradientId = `trackplot-grad-${cls}-${Math.random().toString(36).slice(2, 9)}`
447
+
448
+ const defs = g.append("defs")
449
+ const grad = defs.append("linearGradient")
450
+ .attr("id", gradientId).attr("x1", "0%").attr("y1", "0%").attr("x2", "0%").attr("y2", "100%")
451
+ grad.append("stop").attr("offset", "0%")
452
+ .attr("stop-color", series.color).attr("stop-opacity", series.opacity || 0.3)
453
+ grad.append("stop").attr("offset", "100%")
454
+ .attr("stop-color", series.color).attr("stop-opacity", 0.05)
455
+
456
+ const areaGen = d3.area()
457
+ .x((d, i) => getX(d, i))
458
+ .y0(yScale(0))
459
+ .y1(d => yScale(+d[series.data_key]))
460
+ .defined(d => d[series.data_key] != null)
461
+ if (series.curve) areaGen.curve(d3.curveMonotoneX)
462
+
463
+ const areaPath = g.append("path")
464
+ .datum(data)
465
+ .attr("class", `trackplot-area trackplot-area-${cls}`)
466
+ .attr("fill", `url(#${gradientId})`)
467
+ .attr("d", areaGen)
468
+
469
+ if (animate) {
470
+ areaPath.attr("opacity", 0).transition().duration(DURATION).ease(EASE).attr("opacity", 1)
471
+ }
472
+
473
+ const lineGen = d3.line()
474
+ .x((d, i) => getX(d, i))
475
+ .y(d => yScale(+d[series.data_key]))
476
+ .defined(d => d[series.data_key] != null)
477
+ if (series.curve) lineGen.curve(d3.curveMonotoneX)
478
+
479
+ const linePath = g.append("path")
480
+ .datum(data)
481
+ .attr("fill", "none")
482
+ .attr("stroke", series.color)
483
+ .attr("stroke-width", series.stroke_width || 2)
484
+ .attr("d", lineGen)
485
+
486
+ if (animate) {
487
+ const len = linePath.node().getTotalLength()
488
+ linePath
489
+ .attr("stroke-dasharray", `${len} ${len}`)
490
+ .attr("stroke-dashoffset", len)
491
+ .transition().duration(DURATION).ease(EASE)
492
+ .attr("stroke-dashoffset", 0)
493
+ .on("end", function () { d3.select(this).attr("stroke-dasharray", null) })
494
+ }
495
+ }
496
+
497
+ // ─── Stacked Area Renderer ───────────────────────────────────────────────────
498
+
499
+ function renderStackedAreas(g, data, xScale, yScale, xKey, stackedAreas, animate) {
500
+ const keys = stackedAreas.map(s => s.data_key)
501
+ const stack = d3.stack().keys(keys)
502
+ const stackedData = stack(data)
503
+ const getX = xAccessorFor(xScale, xKey)
504
+
505
+ stackedData.forEach((layer, idx) => {
506
+ const series = stackedAreas[idx]
507
+ const cls = sanitizeClass(series.data_key)
508
+
509
+ const areaGen = d3.area()
510
+ .x((d, i) => getX(data[i], i))
511
+ .y0(d => yScale(d[0]))
512
+ .y1(d => yScale(d[1]))
513
+ if (series.curve) areaGen.curve(d3.curveMonotoneX)
514
+
515
+ const path = g.append("path")
516
+ .datum(layer)
517
+ .attr("class", `trackplot-stacked-area trackplot-stacked-area-${cls}`)
518
+ .attr("fill", series.color)
519
+ .attr("fill-opacity", series.opacity || 0.6)
520
+ .attr("stroke", series.color)
521
+ .attr("stroke-width", 1.5)
522
+ .attr("d", areaGen)
523
+
524
+ if (animate) {
525
+ path.attr("opacity", 0).transition().duration(DURATION).ease(EASE).attr("opacity", 1)
526
+ }
527
+ })
528
+ }
529
+
530
+ // ─── Scatter Renderer ────────────────────────────────────────────────────────
531
+
532
+ function renderScatter(g, data, xScale, yScale, xKey, series, animate, chartElement) {
533
+ const xDataKey = series.x_key || xKey
534
+ const cls = sanitizeClass(series.data_key)
535
+ const dotR = series.dot_size || 5
536
+
537
+ const valid = data.filter(d => d[series.data_key] != null && d[xDataKey] != null)
538
+
539
+ const dots = g.selectAll(null)
540
+ .data(valid)
541
+ .enter().append("circle")
542
+ .attr("class", `trackplot-scatter trackplot-scatter-${cls}`)
543
+ .attr("cx", d => {
544
+ if (xScale.bandwidth) return xScale(d[xDataKey]) + xScale.bandwidth() / 2
545
+ return xScale(+d[xDataKey])
546
+ })
547
+ .attr("cy", d => yScale(+d[series.data_key]))
548
+ .attr("fill", series.color)
549
+ .attr("fill-opacity", series.opacity || 0.7)
550
+ .attr("stroke", "white")
551
+ .attr("stroke-width", 1.5)
552
+ .style("cursor", "pointer")
553
+
554
+ if (animate) {
555
+ dots.attr("r", 0).transition().duration(DURATION).ease(EASE)
556
+ .delay((_, i) => i * 15).attr("r", dotR)
557
+ } else {
558
+ dots.attr("r", dotR)
559
+ }
560
+
561
+ dots
562
+ .on("mouseenter", function () {
563
+ d3.select(this).transition().duration(150).attr("r", dotR * 1.4).attr("fill-opacity", 1)
564
+ })
565
+ .on("mouseleave", function () {
566
+ d3.select(this).transition().duration(150).attr("r", dotR).attr("fill-opacity", series.opacity || 0.7)
567
+ })
568
+
569
+ if (chartElement) {
570
+ dots.on("click", function (event, d) {
571
+ const i = data.indexOf(d)
572
+ dispatchClick(chartElement, {
573
+ chartType: "scatter",
574
+ dataKey: series.data_key,
575
+ datum: d,
576
+ index: i,
577
+ value: d[series.data_key]
578
+ })
579
+ })
580
+ }
581
+ }
582
+
583
+ // ─── Horizontal Bar Renderer ─────────────────────────────────────────────────
584
+
585
+ function renderHorizontalBars(g, data, xScale, yScale, yKey, barSeries, animate, chartElement) {
586
+ if (barSeries.length === 0) return
587
+
588
+ const bandwidth = yScale.bandwidth()
589
+ const subScale = d3.scaleBand()
590
+ .domain(barSeries.map(s => s.data_key))
591
+ .range([0, bandwidth])
592
+ .padding(0.05)
593
+
594
+ barSeries.forEach(series => {
595
+ const cls = sanitizeClass(series.data_key)
596
+ const radius = Math.min(series.radius ?? 4, subScale.bandwidth() / 2)
597
+
598
+ g.selectAll(null)
599
+ .data(data)
600
+ .enter().append("rect")
601
+ .attr("class", `trackplot-hbar trackplot-hbar-${cls}`)
602
+ .attr("y", d => yScale(d[yKey]) + subScale(series.data_key))
603
+ .attr("height", subScale.bandwidth())
604
+ .attr("x", 0)
605
+ .attr("rx", radius)
606
+ .attr("ry", radius)
607
+ .attr("fill", series.color)
608
+ .attr("opacity", series.opacity ?? 1)
609
+ .style("cursor", "pointer")
610
+ .attr("width", animate ? 0 : d => Math.max(0, xScale(+d[series.data_key] || 0)))
611
+ .transition().duration(animate ? DURATION : 0).ease(EASE)
612
+ .delay((_, i) => animate ? i * 40 : 0)
613
+ .attr("width", d => Math.max(0, xScale(+d[series.data_key] || 0)))
614
+
615
+ if (chartElement) {
616
+ g.selectAll(`.trackplot-hbar-${cls}`)
617
+ .on("click", function (event, d) {
618
+ const i = data.indexOf(d)
619
+ dispatchClick(chartElement, {
620
+ chartType: "horizontal_bar",
621
+ dataKey: series.data_key,
622
+ datum: d,
623
+ index: i,
624
+ value: d[series.data_key]
625
+ })
626
+ })
627
+ }
628
+ })
629
+ }
630
+
631
+ // ─── Candlestick Renderer ────────────────────────────────────────────────────
632
+
633
+ function renderCandlestick(g, data, xScale, yScale, xKey, series, animate, chartElement) {
634
+ const getX = xAccessorFor(xScale, xKey)
635
+ const candleWidth = xScale.bandwidth ? xScale.bandwidth() * 0.6 : 8
636
+
637
+ data.forEach((d, i) => {
638
+ const open = +d[series.open]
639
+ const high = +d[series.high]
640
+ const low = +d[series.low]
641
+ const close = +d[series.close]
642
+ const x = getX(d, i)
643
+ const isUp = close >= open
644
+ const color = isUp ? (series.up_color || "#10b981") : (series.down_color || "#ef4444")
645
+
646
+ // Wick
647
+ const wick = g.append("line")
648
+ .attr("class", "trackplot-wick")
649
+ .attr("x1", x).attr("x2", x)
650
+ .attr("y1", yScale(high)).attr("y2", yScale(low))
651
+ .attr("stroke", color).attr("stroke-width", 1.5)
652
+
653
+ // Body
654
+ const bodyTop = yScale(Math.max(open, close))
655
+ const bodyH = Math.max(1, Math.abs(yScale(open) - yScale(close)))
656
+
657
+ const body = g.append("rect")
658
+ .attr("class", "trackplot-candle")
659
+ .attr("x", x - candleWidth / 2)
660
+ .attr("y", bodyTop)
661
+ .attr("width", candleWidth)
662
+ .attr("height", bodyH)
663
+ .attr("fill", isUp ? color : color)
664
+ .attr("stroke", color)
665
+ .attr("stroke-width", 1)
666
+ .attr("rx", 1.5)
667
+ .style("cursor", "pointer")
668
+
669
+ if (animate) {
670
+ wick.attr("opacity", 0).transition().delay(i * 20).duration(300).attr("opacity", 1)
671
+ body.attr("opacity", 0).transition().delay(i * 20).duration(300).attr("opacity", 1)
672
+ }
673
+
674
+ if (chartElement) {
675
+ body.on("click", function () {
676
+ dispatchClick(chartElement, {
677
+ chartType: "candlestick",
678
+ dataKey: null,
679
+ datum: d,
680
+ index: i,
681
+ value: { open, high, low, close }
682
+ })
683
+ })
684
+ }
685
+ })
686
+ }
687
+
688
+ // ─── Pie Renderer ────────────────────────────────────────────────────────────
689
+
690
+ function renderPieSlices(g, data, series, width, height, animate, theme, chartElement) {
691
+ const t = theme || DEFAULT_THEME
692
+ const radius = Math.min(width, height) / 2
693
+ const innerR = series.donut ? radius * 0.6 : 0
694
+
695
+ const pie = d3.pie()
696
+ .value(d => +d[series.data_key])
697
+ .padAngle(series.pad_angle ?? 0.02)
698
+ .sort(null)
699
+
700
+ const arc = d3.arc().innerRadius(innerR).outerRadius(radius - 8).cornerRadius(3)
701
+ const arcHover = d3.arc().innerRadius(innerR).outerRadius(radius - 2).cornerRadius(3)
702
+
703
+ const center = g.append("g")
704
+ .attr("class", "trackplot-pie")
705
+ .attr("transform", `translate(${width / 2},${height / 2})`)
706
+
707
+ const pieData = pie(data)
708
+
709
+ const slices = center.selectAll("path")
710
+ .data(pieData)
711
+ .enter().append("path")
712
+ .attr("fill", (_, i) => t.colors[i % t.colors.length])
713
+ .attr("stroke", "white")
714
+ .attr("stroke-width", 2)
715
+ .style("cursor", "pointer")
716
+
717
+ if (animate) {
718
+ const zeroArc = d3.arc().innerRadius(innerR).outerRadius(innerR).cornerRadius(3)
719
+ slices
720
+ .attr("d", zeroArc)
721
+ .transition().duration(DURATION).ease(EASE)
722
+ .attrTween("d", function (d) {
723
+ const interp = d3.interpolate({ startAngle: d.startAngle, endAngle: d.startAngle }, d)
724
+ return t => arc(interp(t))
725
+ })
726
+ } else {
727
+ slices.attr("d", arc)
728
+ }
729
+
730
+ slices
731
+ .on("mouseenter", function (_, d) {
732
+ d3.select(this).transition().duration(150).attr("d", arcHover(d))
733
+ })
734
+ .on("mouseleave", function (_, d) {
735
+ d3.select(this).transition().duration(150).attr("d", arc(d))
736
+ })
737
+
738
+ if (chartElement) {
739
+ slices.on("click", function (event, d) {
740
+ dispatchClick(chartElement, {
741
+ chartType: "pie",
742
+ dataKey: series.data_key,
743
+ datum: d.data,
744
+ index: d.index,
745
+ value: d.data[series.data_key]
746
+ })
747
+ })
748
+ }
749
+ }
750
+
751
+ // ─── Radar Renderer ──────────────────────────────────────────────────────────
752
+
753
+ function renderRadarChart(g, data, radarSeries, labelKey, width, height, animate, theme, chartElement) {
754
+ const t = theme || DEFAULT_THEME
755
+ const cx = width / 2
756
+ const cy = height / 2
757
+ const radius = Math.min(width, height) / 2 - 40
758
+ const categories = data.map(d => labelKey ? d[labelKey] : "")
759
+ const n = categories.length
760
+ if (n === 0) return
761
+ const angleSlice = (2 * Math.PI) / n
762
+
763
+ let maxVal = 0
764
+ radarSeries.forEach(s => {
765
+ data.forEach(d => {
766
+ const v = +d[s.data_key] || 0
767
+ if (v > maxVal) maxVal = v
768
+ })
769
+ })
770
+ if (maxVal === 0) maxVal = 1
771
+ const rScale = d3.scaleLinear().domain([0, maxVal]).range([0, radius])
772
+
773
+ const center = g.append("g")
774
+ .attr("class", "trackplot-radar")
775
+ .attr("transform", `translate(${cx},${cy})`)
776
+
777
+ // Grid rings
778
+ const levels = 5
779
+ for (let lvl = 1; lvl <= levels; lvl++) {
780
+ const r = (radius / levels) * lvl
781
+ const pts = categories.map((_, j) => {
782
+ const a = angleSlice * j - Math.PI / 2
783
+ return [r * Math.cos(a), r * Math.sin(a)]
784
+ })
785
+ center.append("polygon")
786
+ .attr("points", pts.map(p => p.join(",")).join(" "))
787
+ .attr("fill", "none")
788
+ .attr("stroke", t.grid_color)
789
+ .attr("stroke-width", lvl === levels ? 1.5 : 0.8)
790
+
791
+ // Level label
792
+ if (lvl < levels) {
793
+ center.append("text")
794
+ .attr("x", 4).attr("y", -r + 4)
795
+ .attr("fill", t.axis_color).attr("font-size", "10px").attr("font-family", t.font || FONT)
796
+ .text(Math.round((maxVal / levels) * lvl))
797
+ }
798
+ }
799
+
800
+ // Axis spokes + labels
801
+ categories.forEach((cat, i) => {
802
+ const a = angleSlice * i - Math.PI / 2
803
+ const x = radius * Math.cos(a)
804
+ const y = radius * Math.sin(a)
805
+
806
+ center.append("line")
807
+ .attr("x1", 0).attr("y1", 0).attr("x2", x).attr("y2", y)
808
+ .attr("stroke", t.axis_color).attr("stroke-width", 0.8)
809
+
810
+ const lx = (radius + 18) * Math.cos(a)
811
+ const ly = (radius + 18) * Math.sin(a)
812
+ center.append("text")
813
+ .attr("x", lx).attr("y", ly)
814
+ .attr("text-anchor", "middle").attr("dominant-baseline", "middle")
815
+ .attr("fill", t.text_color).attr("font-size", "12px").attr("font-family", t.font || FONT)
816
+ .text(cat)
817
+ })
818
+
819
+ // Series polygons
820
+ radarSeries.forEach(series => {
821
+ const pts = data.map((d, i) => {
822
+ const a = angleSlice * i - Math.PI / 2
823
+ const r = rScale(+d[series.data_key] || 0)
824
+ return [r * Math.cos(a), r * Math.sin(a)]
825
+ })
826
+
827
+ const polygon = center.append("polygon")
828
+ .attr("points", pts.map(p => p.join(",")).join(" "))
829
+ .attr("fill", series.color)
830
+ .attr("fill-opacity", series.opacity || 0.15)
831
+ .attr("stroke", series.color)
832
+ .attr("stroke-width", series.stroke_width || 2)
833
+ .attr("stroke-linejoin", "round")
834
+
835
+ if (animate) {
836
+ polygon.attr("opacity", 0).transition().duration(DURATION).ease(EASE).attr("opacity", 1)
837
+ }
838
+
839
+ // Dots
840
+ if (series.dot !== false) {
841
+ const dotR = series.dot_size || 4
842
+ pts.forEach((p, i) => {
843
+ const dot = center.append("circle")
844
+ .attr("class", `trackplot-radar-dot`)
845
+ .attr("cx", p[0]).attr("cy", p[1])
846
+ .attr("fill", "white").attr("stroke", series.color).attr("stroke-width", 2)
847
+ .style("cursor", "pointer")
848
+
849
+ if (animate) {
850
+ dot.attr("r", 0).transition().delay(DURATION).duration(200).attr("r", dotR)
851
+ } else {
852
+ dot.attr("r", dotR)
853
+ }
854
+
855
+ // Hover
856
+ dot
857
+ .on("mouseenter", function () { d3.select(this).transition().duration(100).attr("r", dotR * 1.5).attr("fill", series.color) })
858
+ .on("mouseleave", function () { d3.select(this).transition().duration(100).attr("r", dotR).attr("fill", "white") })
859
+
860
+ if (chartElement) {
861
+ dot.on("click", function () {
862
+ dispatchClick(chartElement, {
863
+ chartType: "radar",
864
+ dataKey: series.data_key,
865
+ datum: data[i],
866
+ index: i,
867
+ value: data[i][series.data_key]
868
+ })
869
+ })
870
+ }
871
+ })
872
+ }
873
+ })
874
+ }
875
+
876
+ // ─── Funnel Renderer ─────────────────────────────────────────────────────────
877
+
878
+ function renderFunnelChart(g, data, series, width, height, animate, theme, chartElement) {
879
+ const t = theme || DEFAULT_THEME
880
+ const labelKey = series.label_key
881
+ const maxVal = d3.max(data, d => +d[series.data_key]) || 1
882
+ const n = data.length
883
+ const gap = 3
884
+ const stageH = (height - gap * (n - 1)) / n
885
+ const maxW = width * 0.85
886
+ const cx = width / 2
887
+
888
+ data.forEach((d, i) => {
889
+ const val = +d[series.data_key]
890
+ const nextVal = i < n - 1 ? +data[i + 1][series.data_key] : val * 0.6
891
+ const topW = (val / maxVal) * maxW
892
+ const botW = (nextVal / maxVal) * maxW
893
+ const y = i * (stageH + gap)
894
+ const color = t.colors[i % t.colors.length]
895
+
896
+ const path = [
897
+ `M${cx - topW / 2},${y}`,
898
+ `L${cx + topW / 2},${y}`,
899
+ `L${cx + botW / 2},${y + stageH}`,
900
+ `L${cx - botW / 2},${y + stageH}`,
901
+ "Z"
902
+ ].join(" ")
903
+
904
+ const stage = g.append("path")
905
+ .attr("class", "trackplot-funnel-stage")
906
+ .attr("d", path)
907
+ .attr("fill", color)
908
+ .attr("stroke", "white")
909
+ .attr("stroke-width", 2)
910
+ .style("cursor", "pointer")
911
+
912
+ if (animate) {
913
+ stage.attr("opacity", 0).transition().delay(i * 80).duration(400).ease(EASE).attr("opacity", 1)
914
+ }
915
+
916
+ // Hover
917
+ stage
918
+ .on("mouseenter", function () { d3.select(this).transition().duration(150).attr("opacity", 0.85) })
919
+ .on("mouseleave", function () { d3.select(this).transition().duration(150).attr("opacity", 1) })
920
+
921
+ if (chartElement) {
922
+ stage.on("click", function () {
923
+ dispatchClick(chartElement, {
924
+ chartType: "funnel",
925
+ dataKey: series.data_key,
926
+ datum: d,
927
+ index: i,
928
+ value: val
929
+ })
930
+ })
931
+ }
932
+
933
+ // Label
934
+ const label = labelKey ? d[labelKey] : `Stage ${i + 1}`
935
+ const textEl = g.append("text")
936
+ .attr("x", cx).attr("y", y + stageH / 2)
937
+ .attr("text-anchor", "middle").attr("dominant-baseline", "middle")
938
+ .attr("fill", "white").attr("font-weight", "600")
939
+ .attr("font-size", "13px").attr("font-family", t.font || FONT)
940
+ .text(`${label} — ${val}`)
941
+
942
+ if (animate) {
943
+ textEl.attr("opacity", 0).transition().delay(i * 80 + 200).duration(300).attr("opacity", 1)
944
+ }
945
+ })
946
+ }
947
+
948
+ // ─── Cartesian Tooltip ───────────────────────────────────────────────────────
949
+
950
+ function setupCartesianTooltip(element, g, data, xScale, yScale, xKey, series, config, width, height, margin, theme) {
951
+ const t = theme || DEFAULT_THEME
952
+ const tooltip = createTooltipDiv(element, t)
953
+ const getX = xAccessorFor(xScale, xKey)
954
+ const fmtValue = resolveFormatter(config.format)
955
+
956
+ const crosshair = g.append("line")
957
+ .attr("class", "trackplot-crosshair")
958
+ .attr("stroke", t.axis_color).attr("stroke-width", 1).attr("stroke-dasharray", "4 3")
959
+ .attr("y1", 0).attr("y2", height)
960
+ .style("opacity", 0)
961
+
962
+ g.append("rect")
963
+ .attr("class", "trackplot-overlay")
964
+ .attr("width", width).attr("height", height)
965
+ .attr("fill", "transparent").style("cursor", "crosshair")
966
+ .on("mousemove", function (event) {
967
+ const [mx] = d3.pointer(event, this)
968
+ const idx = findNearestIndex(mx, data, getX)
969
+ const xPos = getX(data[idx], idx)
970
+ const d = data[idx]
971
+
972
+ crosshair.attr("x1", xPos).attr("x2", xPos).style("opacity", 1)
973
+
974
+ series.forEach(s => {
975
+ if (!s.data_key) return
976
+ const cls = sanitizeClass(s.data_key)
977
+ const dotR = s.dot_size || 4
978
+ g.selectAll(`.trackplot-dot-${cls}`)
979
+ .attr("r", (dd, i) => i === idx ? dotR * 1.5 : dotR)
980
+ .attr("fill", (dd, i) => i === idx ? s.color : "white")
981
+ })
982
+
983
+ const label = xKey ? d[xKey] : `#${idx}`
984
+ let html = `<div style="font-weight:600;color:${t.tooltip_text};margin-bottom:4px">${label}</div>`
985
+
986
+ series.forEach(s => {
987
+ if (s.type === "candlestick") {
988
+ const o = d[s.open], h = d[s.high], l = d[s.low], c = d[s.close]
989
+ html += `<div style="color:${t.text_color};display:grid;grid-template-columns:auto auto;gap:0 12px">`
990
+ html += `<span>Open:</span><span style="font-weight:500;color:${t.tooltip_text}">${fmtValue(+o)}</span>`
991
+ html += `<span>High:</span><span style="font-weight:500;color:${t.tooltip_text}">${fmtValue(+h)}</span>`
992
+ html += `<span>Low:</span><span style="font-weight:500;color:${t.tooltip_text}">${fmtValue(+l)}</span>`
993
+ html += `<span>Close:</span><span style="font-weight:500;color:${t.tooltip_text}">${fmtValue(+c)}</span>`
994
+ html += `</div>`
995
+ } else if (s.data_key) {
996
+ const val = d[s.data_key]
997
+ if (val != null) {
998
+ const formatted = isNaN(+val) ? val : fmtValue(+val)
999
+ html += `<div style="display:flex;align-items:center;gap:8px">`
1000
+ html += `<span style="width:8px;height:8px;border-radius:50%;background:${s.color};flex-shrink:0"></span>`
1001
+ html += `<span style="color:${t.text_color}">${s.data_key}</span>`
1002
+ html += `<span style="font-weight:500;color:${t.tooltip_text};margin-left:auto;padding-left:12px">${formatted}</span>`
1003
+ html += `</div>`
1004
+ }
1005
+ }
1006
+ })
1007
+ tooltip.innerHTML = html
1008
+ tooltip.style.opacity = "1"
1009
+
1010
+ const tipRect = tooltip.getBoundingClientRect()
1011
+ const elRect = element.getBoundingClientRect()
1012
+ let left = xPos + margin.left + 16
1013
+ if (left + tipRect.width > elRect.width - 8) {
1014
+ left = xPos + margin.left - tipRect.width - 16
1015
+ }
1016
+ const [, my] = d3.pointer(event, element)
1017
+ let top = my - tipRect.height / 2
1018
+ top = Math.max(8, Math.min(top, elRect.height - tipRect.height - 8))
1019
+ tooltip.style.left = `${left}px`
1020
+ tooltip.style.top = `${top}px`
1021
+ })
1022
+ .on("mouseleave", function () {
1023
+ tooltip.style.opacity = "0"
1024
+ crosshair.style("opacity", 0)
1025
+ series.forEach(s => {
1026
+ if (!s.data_key) return
1027
+ const cls = sanitizeClass(s.data_key)
1028
+ const dotR = s.dot_size || 4
1029
+ g.selectAll(`.trackplot-dot-${cls}`).attr("r", dotR).attr("fill", "white")
1030
+ })
1031
+ })
1032
+ }
1033
+
1034
+ // ─── Pie Tooltip ─────────────────────────────────────────────────────────────
1035
+
1036
+ function setupPieTooltip(element, g, data, series, config, theme) {
1037
+ const t = theme || DEFAULT_THEME
1038
+ const tooltip = createTooltipDiv(element, t)
1039
+ const total = d3.sum(data, d => +d[series.data_key])
1040
+ const labelKey = series.label_key
1041
+
1042
+ g.selectAll(".trackplot-pie path")
1043
+ .on("mouseenter.tooltip", function (event, d) {
1044
+ const val = +d.data[series.data_key]
1045
+ const pct = ((val / total) * 100).toFixed(1)
1046
+ const name = labelKey ? d.data[labelKey] : ""
1047
+ let html = ""
1048
+ if (name) html += `<div style="font-weight:600;color:${t.tooltip_text};margin-bottom:2px">${name}</div>`
1049
+ html += `<div style="color:${t.text_color}">${val} <span style="color:${t.axis_color}">(${pct}%)</span></div>`
1050
+ tooltip.innerHTML = html
1051
+ tooltip.style.opacity = "1"
1052
+ })
1053
+ .on("mousemove.tooltip", function (event) {
1054
+ const [x, y] = d3.pointer(event, element)
1055
+ tooltip.style.left = `${x + 16}px`
1056
+ tooltip.style.top = `${y - 16}px`
1057
+ })
1058
+ .on("mouseleave.tooltip", function () {
1059
+ tooltip.style.opacity = "0"
1060
+ })
1061
+ }
1062
+
1063
+ // ─── Radar Tooltip ───────────────────────────────────────────────────────────
1064
+
1065
+ function setupRadarTooltip(element, g, data, radarSeries, labelKey, config, theme) {
1066
+ const t = theme || DEFAULT_THEME
1067
+ const tooltip = createTooltipDiv(element, t)
1068
+
1069
+ g.selectAll(".trackplot-radar-dot")
1070
+ .on("mouseenter.tooltip", function (event) {
1071
+ const [mx, my] = d3.pointer(event, element)
1072
+
1073
+ const allDots = g.selectAll(".trackplot-radar-dot").nodes()
1074
+ const dotIndex = allDots.indexOf(this)
1075
+ const totalPoints = data.length
1076
+ const seriesIdx = Math.floor(dotIndex / totalPoints)
1077
+ const pointIdx = dotIndex % totalPoints
1078
+
1079
+ const series = radarSeries[seriesIdx]
1080
+ const d = data[pointIdx]
1081
+ if (!series || !d) return
1082
+
1083
+ const cat = labelKey ? d[labelKey] : `#${pointIdx}`
1084
+ let html = `<div style="font-weight:600;color:${t.tooltip_text};margin-bottom:2px">${cat}</div>`
1085
+ html += `<div style="display:flex;align-items:center;gap:8px">`
1086
+ html += `<span style="width:8px;height:8px;border-radius:50%;background:${series.color};flex-shrink:0"></span>`
1087
+ html += `<span style="color:${t.text_color}">${series.data_key}</span>`
1088
+ html += `<span style="font-weight:500;color:${t.tooltip_text};margin-left:auto;padding-left:12px">${d[series.data_key]}</span>`
1089
+ html += `</div>`
1090
+ tooltip.innerHTML = html
1091
+ tooltip.style.opacity = "1"
1092
+ tooltip.style.left = `${mx + 16}px`
1093
+ tooltip.style.top = `${my - 16}px`
1094
+ })
1095
+ .on("mouseleave.tooltip", function () {
1096
+ tooltip.style.opacity = "0"
1097
+ })
1098
+ }
1099
+
1100
+ // ─── Funnel Tooltip ──────────────────────────────────────────────────────────
1101
+
1102
+ function setupFunnelTooltip(element, g, data, series, config, theme) {
1103
+ const t = theme || DEFAULT_THEME
1104
+ const tooltip = createTooltipDiv(element, t)
1105
+ const labelKey = series.label_key
1106
+ const total = +data[0]?.[series.data_key] || 1
1107
+
1108
+ g.selectAll(".trackplot-funnel-stage")
1109
+ .on("mouseenter.tooltip", function (event, d, i) {
1110
+ const stages = g.selectAll(".trackplot-funnel-stage").nodes()
1111
+ const idx = stages.indexOf(this)
1112
+ const datum = data[idx]
1113
+ if (!datum) return
1114
+
1115
+ const val = +datum[series.data_key]
1116
+ const pct = ((val / total) * 100).toFixed(1)
1117
+ const name = labelKey ? datum[labelKey] : `Stage ${idx + 1}`
1118
+
1119
+ let html = `<div style="font-weight:600;color:${t.tooltip_text};margin-bottom:2px">${name}</div>`
1120
+ html += `<div style="color:${t.text_color}">${val} <span style="color:${t.axis_color}">(${pct}% of first)</span></div>`
1121
+ tooltip.innerHTML = html
1122
+ tooltip.style.opacity = "1"
1123
+ })
1124
+ .on("mousemove.tooltip", function (event) {
1125
+ const [x, y] = d3.pointer(event, element)
1126
+ tooltip.style.left = `${x + 16}px`
1127
+ tooltip.style.top = `${y - 16}px`
1128
+ })
1129
+ .on("mouseleave.tooltip", function () {
1130
+ tooltip.style.opacity = "0"
1131
+ })
1132
+ }
1133
+
1134
+ // ─── Legend Renderer ─────────────────────────────────────────────────────────
1135
+
1136
+ function renderLegend(element, items, config, theme) {
1137
+ const t = theme || DEFAULT_THEME
1138
+ const legend = document.createElement("div")
1139
+ Object.assign(legend.style, {
1140
+ display: "flex",
1141
+ flexWrap: "wrap",
1142
+ gap: "16px",
1143
+ justifyContent: "center",
1144
+ padding: "8px 0 0",
1145
+ fontFamily: t.font || FONT,
1146
+ fontSize: "13px"
1147
+ })
1148
+
1149
+ items.forEach(item => {
1150
+ const el = document.createElement("div")
1151
+ Object.assign(el.style, {
1152
+ display: "flex", alignItems: "center", gap: "6px",
1153
+ cursor: config.clickable !== false ? "pointer" : "default",
1154
+ userSelect: "none", transition: "opacity 0.2s ease"
1155
+ })
1156
+
1157
+ const dot = document.createElement("span")
1158
+ Object.assign(dot.style, {
1159
+ width: "10px", height: "10px", borderRadius: "50%",
1160
+ background: item.color, flexShrink: "0"
1161
+ })
1162
+ const label = document.createElement("span")
1163
+ label.textContent = item.data_key
1164
+ label.style.color = t.text_color
1165
+
1166
+ el.appendChild(dot)
1167
+ el.appendChild(label)
1168
+ legend.appendChild(el)
1169
+ })
1170
+
1171
+ if (config.position === "top") {
1172
+ element.insertBefore(legend, element.firstChild)
1173
+ } else {
1174
+ element.appendChild(legend)
1175
+ }
1176
+ }
1177
+
1178
+ // ─── Chart Class ─────────────────────────────────────────────────────────────
1179
+
1180
+ class Chart {
1181
+ constructor(element, config) {
1182
+ this.element = element
1183
+ this.data = config.data || []
1184
+ this.components = config.components || []
1185
+ this.animate = config.animate !== false
1186
+ this.margin = { ...DEFAULT_MARGIN }
1187
+ this.theme = config.theme || DEFAULT_THEME
1188
+
1189
+ this.seriesList = this.components.filter(c => ALL_SERIES_TYPES.includes(c.type))
1190
+ this.axesList = this.components.filter(c => c.type === "axis")
1191
+ this.gridConfig = this.components.find(c => c.type === "grid")
1192
+ this.tooltipConfig = this.components.find(c => c.type === "tooltip")
1193
+ this.legendConfig = this.components.find(c => c.type === "legend")
1194
+ this.referenceLines = this.components.filter(c => c.type === "reference_line")
1195
+
1196
+ const themeColors = this.theme.colors || COLORS
1197
+ this.seriesList.forEach((s, i) => { s.color = s.color || themeColors[i % themeColors.length] })
1198
+ this.isPie = this.seriesList.some(s => s.type === "pie")
1199
+ this.isRadar = this.seriesList.some(s => s.type === "radar")
1200
+ this.isFunnel = this.seriesList.some(s => s.type === "funnel")
1201
+ this.isHorizontal = this.seriesList.some(s => s.type === "horizontal_bar")
1202
+ this.xKey = getXKey(this.components)
1203
+
1204
+ // Apply theme background
1205
+ if (this.theme.background && this.theme.background !== "transparent") {
1206
+ this.element.style.background = this.theme.background
1207
+ this.element.style.borderRadius = "8px"
1208
+ }
1209
+ }
1210
+
1211
+ render() {
1212
+ this.clear()
1213
+ if (this.data.length === 0) return
1214
+
1215
+ const rect = this.element.getBoundingClientRect()
1216
+ if (rect.width === 0 || rect.height === 0) return
1217
+
1218
+ if (this.isRadar) this.renderRadar(rect.width, rect.height)
1219
+ else if (this.isFunnel) this.renderFunnel(rect.width, rect.height)
1220
+ else if (this.isPie) this.renderPie(rect.width, rect.height)
1221
+ else if (this.isHorizontal) this.renderHorizontalCartesian(rect.width, rect.height)
1222
+ else this.renderCartesian(rect.width, rect.height)
1223
+ }
1224
+
1225
+ renderCartesian(totalW, totalH) {
1226
+ const legendH = this.legendConfig ? 32 : 0
1227
+ const m = this.margin
1228
+ const w = totalW - m.left - m.right
1229
+ const h = totalH - m.top - m.bottom - legendH
1230
+ if (w <= 0 || h <= 0) return
1231
+
1232
+ const svg = d3.select(this.element)
1233
+ .append("svg").attr("width", totalW).attr("height", totalH - legendH)
1234
+ const g = svg.append("g").attr("transform", `translate(${m.left},${m.top})`)
1235
+
1236
+ const hasBars = this.seriesList.some(s => s.type === "bar")
1237
+ const hasScatter = this.seriesList.some(s => s.type === "scatter")
1238
+ let scaleType = hasBars ? "band" : detectScaleType(this.data, this.xKey)
1239
+ if (hasScatter && !hasBars && scaleType === "band") scaleType = "band"
1240
+ const xScale = createXScale(this.data, this.xKey, scaleType, w)
1241
+ const cartesianSeries = this.seriesList.filter(s => CARTESIAN_TYPES.includes(s.type))
1242
+ const yScale = createYScale(this.data, cartesianSeries, h)
1243
+
1244
+ if (this.gridConfig) renderGrid(g, this.gridConfig, xScale, yScale, w, h, this.theme)
1245
+ renderAxes(g, this.axesList, xScale, yScale, w, h, this.theme)
1246
+
1247
+ // Reference lines (rendered after grid/axes, before series overlay)
1248
+ renderReferenceLines(g, this.referenceLines, xScale, yScale, w, h, this.theme)
1249
+
1250
+ // Stacked areas
1251
+ const areaList = this.seriesList.filter(s => s.type === "area")
1252
+ const stackedGroups = {}
1253
+ const freeAreas = []
1254
+ areaList.forEach(s => {
1255
+ if (s.stack) {
1256
+ stackedGroups[s.stack] = stackedGroups[s.stack] || []
1257
+ stackedGroups[s.stack].push(s)
1258
+ } else {
1259
+ freeAreas.push(s)
1260
+ }
1261
+ })
1262
+ Object.values(stackedGroups).forEach(group => renderStackedAreas(g, this.data, xScale, yScale, this.xKey, group, this.animate))
1263
+ freeAreas.forEach(s => renderArea(g, this.data, xScale, yScale, this.xKey, s, this.animate, this.element))
1264
+
1265
+ // Bars
1266
+ const barSeries = this.seriesList.filter(s => s.type === "bar")
1267
+ if (barSeries.length > 0) renderBars(g, this.data, xScale, yScale, this.xKey, barSeries, this.animate, this.element)
1268
+
1269
+ // Candlestick
1270
+ this.seriesList.filter(s => s.type === "candlestick").forEach(s => renderCandlestick(g, this.data, xScale, yScale, this.xKey, s, this.animate, this.element))
1271
+
1272
+ // Lines
1273
+ this.seriesList.filter(s => s.type === "line").forEach(s => renderLine(g, this.data, xScale, yScale, this.xKey, s, this.animate, this.element))
1274
+
1275
+ // Scatter
1276
+ this.seriesList.filter(s => s.type === "scatter").forEach(s => renderScatter(g, this.data, xScale, yScale, this.xKey, s, this.animate, this.element))
1277
+
1278
+ if (this.tooltipConfig) {
1279
+ setupCartesianTooltip(this.element, g, this.data, xScale, yScale, this.xKey, cartesianSeries, this.tooltipConfig, w, h, m, this.theme)
1280
+ }
1281
+ if (this.legendConfig) renderLegend(this.element, this.seriesList.filter(s => s.data_key), this.legendConfig, this.theme)
1282
+ }
1283
+
1284
+ renderHorizontalCartesian(totalW, totalH) {
1285
+ const legendH = this.legendConfig ? 32 : 0
1286
+ const m = { ...this.margin, left: 80 }
1287
+ const w = totalW - m.left - m.right
1288
+ const h = totalH - m.top - m.bottom - legendH
1289
+ if (w <= 0 || h <= 0) return
1290
+
1291
+ const svg = d3.select(this.element)
1292
+ .append("svg").attr("width", totalW).attr("height", totalH - legendH)
1293
+ const g = svg.append("g").attr("transform", `translate(${m.left},${m.top})`)
1294
+
1295
+ const catKey = this.xKey
1296
+ const hBarSeries = this.seriesList.filter(s => s.type === "horizontal_bar")
1297
+
1298
+ const yScale = d3.scaleBand()
1299
+ .domain(this.data.map(d => d[catKey]))
1300
+ .range([0, h]).padding(0.2)
1301
+
1302
+ const maxVal = d3.max(this.data, d => d3.max(hBarSeries, s => +d[s.data_key] || 0)) || 1
1303
+ const xScale = d3.scaleLinear().domain([0, maxVal]).nice().range([0, w])
1304
+
1305
+ if (this.gridConfig) {
1306
+ const grid = g.append("g").attr("class", "trackplot-grid")
1307
+ grid.append("g")
1308
+ .call(d3.axisBottom(xScale).tickSize(h).tickFormat(""))
1309
+ .attr("transform", `translate(0,0)`)
1310
+ .call(g => g.selectAll("line").attr("stroke", this.theme.grid_color).attr("stroke-dasharray", "3 3"))
1311
+ .call(g => g.selectAll(".domain").remove())
1312
+ }
1313
+
1314
+ const t = this.theme
1315
+ // Y axis (categories)
1316
+ const yG = g.append("g").attr("class", "trackplot-axis-y")
1317
+ yG.call(d3.axisLeft(yScale))
1318
+ yG.selectAll("text").attr("fill", t.text_color).attr("font-size", "12px").attr("font-family", t.font || FONT)
1319
+ yG.selectAll("line").attr("stroke", t.axis_color)
1320
+ yG.select(".domain").attr("stroke", t.axis_color)
1321
+
1322
+ // X axis (values)
1323
+ this.axesList.filter(a => a.direction === "y" || (a.direction === "x" && !a.data_key)).forEach(axis => {
1324
+ const xG = g.append("g").attr("class", "trackplot-axis-x")
1325
+ .attr("transform", `translate(0,${h})`)
1326
+ let gen = d3.axisBottom(xScale)
1327
+ const fmt = resolveFormatter(axis.format)
1328
+ if (axis.format) gen = gen.tickFormat(fmt)
1329
+ xG.call(gen)
1330
+ xG.selectAll("text").attr("fill", t.text_color).attr("font-size", "12px").attr("font-family", t.font || FONT)
1331
+ xG.selectAll("line").attr("stroke", t.axis_color)
1332
+ xG.select(".domain").attr("stroke", t.axis_color)
1333
+ if (axis.label) {
1334
+ xG.append("text").attr("x", w / 2).attr("y", 36)
1335
+ .attr("fill", t.text_color).attr("font-size", "13px").attr("font-family", t.font || FONT)
1336
+ .attr("text-anchor", "middle").text(axis.label)
1337
+ }
1338
+ })
1339
+
1340
+ renderHorizontalBars(g, this.data, xScale, yScale, catKey, hBarSeries, this.animate, this.element)
1341
+
1342
+ if (this.tooltipConfig) {
1343
+ const tooltip = createTooltipDiv(this.element, t)
1344
+ const fmtValue = resolveFormatter(this.tooltipConfig.format)
1345
+
1346
+ g.selectAll(".trackplot-hbar")
1347
+ .on("mouseenter", function (event) {
1348
+ const datum = d3.select(this).datum()
1349
+ const cat = catKey ? datum[catKey] : ""
1350
+ let html = `<div style="font-weight:600;color:${t.tooltip_text};margin-bottom:4px">${cat}</div>`
1351
+ hBarSeries.forEach(s => {
1352
+ const val = datum[s.data_key]
1353
+ if (val != null) {
1354
+ const formatted = isNaN(+val) ? val : fmtValue(+val)
1355
+ html += `<div style="display:flex;align-items:center;gap:8px">`
1356
+ html += `<span style="width:8px;height:8px;border-radius:50%;background:${s.color};flex-shrink:0"></span>`
1357
+ html += `<span style="color:${t.text_color}">${s.data_key}</span>`
1358
+ html += `<span style="font-weight:500;color:${t.tooltip_text};margin-left:auto;padding-left:12px">${formatted}</span>`
1359
+ html += `</div>`
1360
+ }
1361
+ })
1362
+ tooltip.innerHTML = html
1363
+ tooltip.style.opacity = "1"
1364
+ })
1365
+ .on("mousemove", function (event) {
1366
+ const [x, y] = d3.pointer(event, this.closest("trackplot-chart"))
1367
+ tooltip.style.left = `${x + 16}px`
1368
+ tooltip.style.top = `${y - 16}px`
1369
+ })
1370
+ .on("mouseleave", function () {
1371
+ tooltip.style.opacity = "0"
1372
+ })
1373
+ }
1374
+
1375
+ if (this.legendConfig) renderLegend(this.element, hBarSeries, this.legendConfig, this.theme)
1376
+ }
1377
+
1378
+ renderPie(totalW, totalH) {
1379
+ const pieSeries = this.seriesList.find(s => s.type === "pie")
1380
+ if (!pieSeries) return
1381
+
1382
+ const legendH = this.legendConfig ? 40 : 0
1383
+ const chartH = totalH - legendH
1384
+ const svg = d3.select(this.element).append("svg").attr("width", totalW).attr("height", chartH)
1385
+ const g = svg.append("g")
1386
+
1387
+ renderPieSlices(g, this.data, pieSeries, totalW, chartH, this.animate, this.theme, this.element)
1388
+ if (this.tooltipConfig) setupPieTooltip(this.element, g, this.data, pieSeries, this.tooltipConfig, this.theme)
1389
+
1390
+ if (this.legendConfig) {
1391
+ const labelKey = pieSeries.label_key || this.xKey
1392
+ const themeColors = this.theme.colors || COLORS
1393
+ const items = this.data.map((d, i) => ({
1394
+ data_key: labelKey ? d[labelKey] : `Slice ${i + 1}`,
1395
+ color: themeColors[i % themeColors.length]
1396
+ }))
1397
+ renderLegend(this.element, items, this.legendConfig, this.theme)
1398
+ }
1399
+ }
1400
+
1401
+ renderRadar(totalW, totalH) {
1402
+ const legendH = this.legendConfig ? 40 : 0
1403
+ const chartH = totalH - legendH
1404
+ const svg = d3.select(this.element).append("svg").attr("width", totalW).attr("height", chartH)
1405
+ const g = svg.append("g")
1406
+
1407
+ const radarSeries = this.seriesList.filter(s => s.type === "radar")
1408
+ renderRadarChart(g, this.data, radarSeries, this.xKey, totalW, chartH, this.animate, this.theme, this.element)
1409
+ if (this.tooltipConfig) setupRadarTooltip(this.element, g, this.data, radarSeries, this.xKey, this.tooltipConfig, this.theme)
1410
+ if (this.legendConfig) renderLegend(this.element, radarSeries, this.legendConfig, this.theme)
1411
+ }
1412
+
1413
+ renderFunnel(totalW, totalH) {
1414
+ const funnelSeries = this.seriesList.find(s => s.type === "funnel")
1415
+ if (!funnelSeries) return
1416
+
1417
+ const legendH = this.legendConfig ? 40 : 0
1418
+ const chartH = totalH - legendH
1419
+ const svg = d3.select(this.element).append("svg").attr("width", totalW).attr("height", chartH)
1420
+ const g = svg.append("g")
1421
+
1422
+ renderFunnelChart(g, this.data, funnelSeries, totalW, chartH, this.animate, this.theme, this.element)
1423
+ if (this.tooltipConfig) setupFunnelTooltip(this.element, g, this.data, funnelSeries, this.tooltipConfig, this.theme)
1424
+
1425
+ if (this.legendConfig) {
1426
+ const labelKey = funnelSeries.label_key
1427
+ const themeColors = this.theme.colors || COLORS
1428
+ const items = this.data.map((d, i) => ({
1429
+ data_key: labelKey ? d[labelKey] : `Stage ${i + 1}`,
1430
+ color: themeColors[i % themeColors.length]
1431
+ }))
1432
+ renderLegend(this.element, items, this.legendConfig, this.theme)
1433
+ }
1434
+ }
1435
+
1436
+ clear() {
1437
+ // Preserve background on re-render
1438
+ const bg = this.element.style.background
1439
+ this.element.innerHTML = ""
1440
+ if (bg) this.element.style.background = bg
1441
+ }
1442
+
1443
+ destroy() {
1444
+ this.resizeObserver?.disconnect()
1445
+ this.clear()
1446
+ }
1447
+ }
1448
+
1449
+ // ─── Custom Element ──────────────────────────────────────────────────────────
1450
+
1451
+ class TrackplotElement extends HTMLElement {
1452
+ connectedCallback() {
1453
+ try {
1454
+ this.chartConfig = JSON.parse(this.getAttribute("config"))
1455
+ } catch (e) {
1456
+ console.error("Trackplot: invalid config JSON", e)
1457
+ return
1458
+ }
1459
+
1460
+ // Clear stale content from Turbo cache restoration or Turbo Stream replace
1461
+ this.innerHTML = ""
1462
+
1463
+ this.chart = new Chart(this, this.chartConfig)
1464
+ requestAnimationFrame(() => {
1465
+ this.chart.render()
1466
+ this._dispatchRender()
1467
+ })
1468
+
1469
+ this._resizeTimeout = null
1470
+ this.resizeObserver = new ResizeObserver(() => {
1471
+ clearTimeout(this._resizeTimeout)
1472
+ this._resizeTimeout = setTimeout(() => {
1473
+ if (this.chart) {
1474
+ this.chart.animate = false
1475
+ this.chart.render()
1476
+ }
1477
+ }, 150)
1478
+ })
1479
+ this.resizeObserver.observe(this)
1480
+
1481
+ // Clear before Turbo caches the page so snapshots don't contain stale SVG
1482
+ this._turboCacheHandler = () => this.chart?.clear()
1483
+ document.addEventListener("turbo:before-cache", this._turboCacheHandler)
1484
+ }
1485
+
1486
+ disconnectedCallback() {
1487
+ clearTimeout(this._resizeTimeout)
1488
+ this.resizeObserver?.disconnect()
1489
+ document.removeEventListener("turbo:before-cache", this._turboCacheHandler)
1490
+ this.chart?.destroy()
1491
+ this.chart = null
1492
+ }
1493
+
1494
+ static get observedAttributes() { return ["config"] }
1495
+
1496
+ attributeChangedCallback(name, oldVal, newVal) {
1497
+ if (name === "config" && oldVal !== null && newVal) {
1498
+ try {
1499
+ this.chartConfig = JSON.parse(newVal)
1500
+ this.chart = new Chart(this, this.chartConfig)
1501
+ this.chart.animate = false
1502
+ this.chart.render()
1503
+ this._dispatchRender()
1504
+ } catch (e) {
1505
+ console.error("Trackplot: invalid config JSON", e)
1506
+ }
1507
+ }
1508
+ }
1509
+
1510
+ // ── Public API ──────────────────────────────────────────
1511
+
1512
+ /** Replace chart data and re-render without animation. */
1513
+ updateData(newData) {
1514
+ if (!this.chartConfig) return
1515
+ this.chartConfig.data = newData
1516
+ this._rebuildChart(false)
1517
+ }
1518
+
1519
+ /** Replace the full config object and re-render. */
1520
+ updateConfig(config) {
1521
+ this.chartConfig = config
1522
+ this._rebuildChart(false)
1523
+ }
1524
+
1525
+ // ── Internals ───────────────────────────────────────────
1526
+
1527
+ _rebuildChart(animate) {
1528
+ this.chart = new Chart(this, this.chartConfig)
1529
+ this.chart.animate = animate
1530
+ this.chart.render()
1531
+ // Sync attribute so Turbo morphing sees current state
1532
+ this.setAttribute("config", JSON.stringify(this.chartConfig))
1533
+ this._dispatchRender()
1534
+ }
1535
+
1536
+ _dispatchRender() {
1537
+ this.dispatchEvent(new CustomEvent("trackplot:render", { bubbles: true }))
1538
+ }
1539
+ }
1540
+
1541
+ customElements.define("trackplot-chart", TrackplotElement)