trackplot 0.1.0 → 0.3.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.
@@ -12,7 +12,7 @@ const DURATION = 750
12
12
  const EASE = d3.easeCubicOut
13
13
  const DEFAULT_MARGIN = { top: 24, right: 24, bottom: 44, left: 52 }
14
14
 
15
- const ALL_SERIES_TYPES = ["line", "bar", "area", "pie", "scatter", "radar", "horizontal_bar", "candlestick", "funnel"]
15
+ const ALL_SERIES_TYPES = ["line", "bar", "area", "pie", "scatter", "radar", "horizontal_bar", "candlestick", "funnel", "heatmap", "treemap"]
16
16
  const CARTESIAN_TYPES = ["line", "bar", "area", "scatter", "candlestick"]
17
17
 
18
18
  // ─── Default Theme ──────────────────────────────────────────────────────────
@@ -60,6 +60,10 @@ function sanitizeClass(str) {
60
60
  return String(str).replace(/[^a-zA-Z0-9_-]/g, "_")
61
61
  }
62
62
 
63
+ function seriesLabel(s) {
64
+ return s.name || s.data_key
65
+ }
66
+
63
67
  function getXKey(components) {
64
68
  const xAxis = components.find(c => c.type === "axis" && c.direction === "x")
65
69
  return xAxis ? xAxis.data_key : null
@@ -124,6 +128,23 @@ function createYScale(data, seriesConfigs, height) {
124
128
  return d3.scaleLinear().domain([minVal, maxVal]).nice().range([height, 0])
125
129
  }
126
130
 
131
+ function createYScaleRight(data, seriesConfigs, height) {
132
+ let maxVal = 0
133
+ let minVal = 0
134
+
135
+ seriesConfigs.forEach(s => {
136
+ if (s.data_key) {
137
+ const sMax = d3.max(data, d => +d[s.data_key] || 0)
138
+ const sMin = d3.min(data, d => +d[s.data_key] || 0)
139
+ if (sMax > maxVal) maxVal = sMax
140
+ if (sMin < minVal) minVal = sMin
141
+ }
142
+ })
143
+
144
+ if (maxVal === 0) maxVal = 1
145
+ return d3.scaleLinear().domain([minVal, maxVal]).nice().range([height, 0])
146
+ }
147
+
127
148
  function xAccessorFor(xScale, xKey) {
128
149
  const offset = xScale.bandwidth ? xScale.bandwidth() / 2 : 0
129
150
  return xKey
@@ -171,7 +192,7 @@ function createTooltipDiv(container, theme) {
171
192
 
172
193
  function renderGrid(g, config, xScale, yScale, width, height, theme) {
173
194
  const t = theme || DEFAULT_THEME
174
- const grid = g.append("g").attr("class", "trackplot-grid")
195
+ const grid = g.append("g").attr("class", "trackplot-grid").attr("aria-hidden", "true")
175
196
 
176
197
  if (config.horizontal !== false) {
177
198
  grid.append("g")
@@ -193,7 +214,7 @@ function renderGrid(g, config, xScale, yScale, width, height, theme) {
193
214
 
194
215
  // ─── Axes Renderer ───────────────────────────────────────────────────────────
195
216
 
196
- function renderAxes(g, axesConfigs, xScale, yScale, width, height, theme) {
217
+ function renderAxes(g, axesConfigs, xScale, yScale, width, height, theme, yScaleRight) {
197
218
  const t = theme || DEFAULT_THEME
198
219
  const textColor = t.text_color
199
220
  const axisStroke = t.axis_color
@@ -226,22 +247,47 @@ function renderAxes(g, axesConfigs, xScale, yScale, width, height, theme) {
226
247
  }
227
248
 
228
249
  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)
250
+ const isRight = axis.axis_id === "right"
251
+ const scale = isRight && yScaleRight ? yScaleRight : yScale
252
+
253
+ if (isRight) {
254
+ const yG = g.append("g")
255
+ .attr("class", "trackplot-axis-y-right")
256
+ .attr("transform", `translate(${width},0)`)
257
+ let gen = d3.axisRight(scale)
258
+ const fmt = resolveFormatter(axis.format)
259
+ if (axis.format) gen = gen.tickFormat(fmt)
260
+ if (axis.tick_count) gen = gen.ticks(axis.tick_count)
261
+
262
+ yG.call(gen)
263
+ yG.selectAll("text").attr("fill", textColor).attr("font-size", "12px").attr("font-family", font)
264
+ yG.selectAll("line").attr("stroke", axisStroke)
265
+ yG.select(".domain").attr("stroke", axisStroke)
266
+
267
+ if (axis.label) {
268
+ yG.append("text").attr("transform", "rotate(90)")
269
+ .attr("x", height / 2).attr("y", -40)
270
+ .attr("fill", textColor).attr("font-size", "13px").attr("font-family", font)
271
+ .attr("text-anchor", "middle").text(axis.label)
272
+ }
273
+ } else {
274
+ const yG = g.append("g").attr("class", "trackplot-axis-y")
275
+ let gen = d3.axisLeft(scale)
276
+ const fmt = resolveFormatter(axis.format)
277
+ if (axis.format) gen = gen.tickFormat(fmt)
278
+ if (axis.tick_count) gen = gen.ticks(axis.tick_count)
279
+
280
+ yG.call(gen)
281
+ yG.selectAll("text").attr("fill", textColor).attr("font-size", "12px").attr("font-family", font)
282
+ yG.selectAll("line").attr("stroke", axisStroke)
283
+ yG.select(".domain").attr("stroke", axisStroke)
284
+
285
+ if (axis.label) {
286
+ yG.append("text").attr("transform", "rotate(-90)")
287
+ .attr("x", -height / 2).attr("y", -40)
288
+ .attr("fill", textColor).attr("font-size", "13px").attr("font-family", font)
289
+ .attr("text-anchor", "middle").text(axis.label)
290
+ }
245
291
  }
246
292
  }
247
293
  })
@@ -264,6 +310,7 @@ function renderReferenceLines(g, refs, xScale, yScale, width, height, theme) {
264
310
 
265
311
  g.append("line")
266
312
  .attr("class", "trackplot-reference-line")
313
+ .attr("aria-hidden", "true")
267
314
  .attr("x1", 0).attr("x2", width)
268
315
  .attr("y1", y).attr("y2", y)
269
316
  .attr("stroke", color)
@@ -294,6 +341,7 @@ function renderReferenceLines(g, refs, xScale, yScale, width, height, theme) {
294
341
 
295
342
  g.append("line")
296
343
  .attr("class", "trackplot-reference-line")
344
+ .attr("aria-hidden", "true")
297
345
  .attr("x1", x).attr("x2", x)
298
346
  .attr("y1", 0).attr("y2", height)
299
347
  .attr("stroke", color)
@@ -315,6 +363,108 @@ function renderReferenceLines(g, refs, xScale, yScale, width, height, theme) {
315
363
  })
316
364
  }
317
365
 
366
+ // ─── Data Labels Renderer ───────────────────────────────────────────────────
367
+
368
+ function renderDataLabels(g, data, xScale, yScale, xKey, seriesList, dataLabelConfig, theme) {
369
+ if (!dataLabelConfig) return
370
+ const t = theme || DEFAULT_THEME
371
+ const fmt = resolveFormatter(dataLabelConfig.format)
372
+ const position = dataLabelConfig.position || "top"
373
+ const fontSize = dataLabelConfig.font_size || 11
374
+
375
+ seriesList.forEach(series => {
376
+ if (!series.data_key) return
377
+ const getX = xAccessorFor(xScale, xKey)
378
+
379
+ if (series.type === "bar") {
380
+ const barSeries = seriesList.filter(s => s.type === "bar")
381
+ const bandwidth = xScale.bandwidth ? xScale.bandwidth() : 0
382
+ const subScale = d3.scaleBand()
383
+ .domain(barSeries.map(s => s.data_key))
384
+ .range([0, bandwidth])
385
+ .padding(0.05)
386
+
387
+ data.forEach((d, i) => {
388
+ const val = +d[series.data_key] || 0
389
+ const xPos = (xKey ? xScale(d[xKey]) : xScale(i)) + subScale(series.data_key) + subScale.bandwidth() / 2
390
+ let yPos
391
+ if (position === "center") {
392
+ yPos = yScale(val) + (yScale(0) - yScale(val)) / 2
393
+ } else {
394
+ yPos = yScale(val) - 6
395
+ }
396
+
397
+ g.append("text")
398
+ .attr("class", "trackplot-data-label")
399
+ .attr("x", xPos)
400
+ .attr("y", yPos)
401
+ .attr("text-anchor", "middle")
402
+ .attr("dominant-baseline", "middle")
403
+ .attr("fill", position === "center" ? "white" : t.text_color)
404
+ .attr("font-size", `${fontSize}px`)
405
+ .attr("font-family", t.font || FONT)
406
+ .attr("font-weight", "500")
407
+ .text(fmt(val))
408
+ })
409
+ } else if (series.type === "line" || series.type === "area" || series.type === "scatter") {
410
+ data.forEach((d, i) => {
411
+ if (d[series.data_key] == null) return
412
+ const val = +d[series.data_key]
413
+ const xPos = getX(d, i)
414
+ const yPos = yScale(val) - 10
415
+
416
+ g.append("text")
417
+ .attr("class", "trackplot-data-label")
418
+ .attr("x", xPos)
419
+ .attr("y", yPos)
420
+ .attr("text-anchor", "middle")
421
+ .attr("fill", t.text_color)
422
+ .attr("font-size", `${fontSize}px`)
423
+ .attr("font-family", t.font || FONT)
424
+ .attr("font-weight", "500")
425
+ .text(fmt(val))
426
+ })
427
+ }
428
+ })
429
+ }
430
+
431
+ function renderPieDataLabels(g, data, series, dataLabelConfig, width, height, theme) {
432
+ if (!dataLabelConfig) return
433
+ const t = theme || DEFAULT_THEME
434
+ const fmt = resolveFormatter(dataLabelConfig.format)
435
+ const fontSize = dataLabelConfig.font_size || 11
436
+
437
+ const radius = Math.min(width, height) / 2
438
+ const innerR = series.donut ? radius * 0.6 : 0
439
+ const labelRadius = series.donut ? (innerR + radius - 8) / 2 : radius * 0.65
440
+
441
+ const pie = d3.pie()
442
+ .value(d => +d[series.data_key])
443
+ .padAngle(series.pad_angle ?? 0.02)
444
+ .sort(null)
445
+
446
+ const labelArc = d3.arc().innerRadius(labelRadius).outerRadius(labelRadius)
447
+ const pieData = pie(data)
448
+
449
+ const labelsG = g.select(".trackplot-pie")
450
+ if (labelsG.empty()) return
451
+
452
+ pieData.forEach(d => {
453
+ const [x, y] = labelArc.centroid(d)
454
+ labelsG.append("text")
455
+ .attr("class", "trackplot-data-label")
456
+ .attr("x", x)
457
+ .attr("y", y)
458
+ .attr("text-anchor", "middle")
459
+ .attr("dominant-baseline", "middle")
460
+ .attr("fill", dataLabelConfig.position === "outside" ? t.text_color : "white")
461
+ .attr("font-size", `${fontSize}px`)
462
+ .attr("font-family", t.font || FONT)
463
+ .attr("font-weight", "600")
464
+ .text(fmt(+d.data[series.data_key]))
465
+ })
466
+ }
467
+
318
468
  // ─── Line Renderer ───────────────────────────────────────────────────────────
319
469
 
320
470
  function renderLine(g, data, xScale, yScale, xKey, series, animate, chartElement) {
@@ -364,6 +514,7 @@ function renderLine(g, data, xScale, yScale, xKey, series, animate, chartElement
364
514
  .attr("stroke", series.color)
365
515
  .attr("stroke-width", 2)
366
516
  .style("cursor", "pointer")
517
+ .attr("aria-label", d => `${seriesLabel(series)}: ${d[series.data_key]}`)
367
518
 
368
519
  if (animate) {
369
520
  dots.attr("r", 0).transition().delay(DURATION).duration(300).attr("r", dotR)
@@ -391,47 +542,122 @@ function renderLine(g, data, xScale, yScale, xKey, series, animate, chartElement
391
542
  function renderBars(g, data, xScale, yScale, xKey, barSeries, animate, chartElement) {
392
543
  if (barSeries.length === 0) return
393
544
 
545
+ // Separate stacked and grouped bars
546
+ const stackGroups = {}
547
+ const groupedSeries = []
548
+ barSeries.forEach(s => {
549
+ if (s.stack) {
550
+ stackGroups[s.stack] = stackGroups[s.stack] || []
551
+ stackGroups[s.stack].push(s)
552
+ } else {
553
+ groupedSeries.push(s)
554
+ }
555
+ })
556
+
557
+ // Render stacked bars
558
+ Object.entries(stackGroups).forEach(([, group]) => {
559
+ renderStackedBars(g, data, xScale, yScale, xKey, group, animate, chartElement)
560
+ })
561
+
562
+ // Render grouped bars
563
+ if (groupedSeries.length > 0) {
564
+ const bandwidth = xScale.bandwidth()
565
+ const subScale = d3.scaleBand()
566
+ .domain(groupedSeries.map(s => s.data_key))
567
+ .range([0, bandwidth])
568
+ .padding(0.05)
569
+
570
+ groupedSeries.forEach(series => {
571
+ const cls = sanitizeClass(series.data_key)
572
+ const radius = Math.min(series.radius ?? 4, subScale.bandwidth() / 2)
573
+
574
+ const rects = g.selectAll(null)
575
+ .data(data)
576
+ .enter().append("rect")
577
+ .attr("class", `trackplot-bar trackplot-bar-${cls}`)
578
+ .attr("x", d => {
579
+ const base = xKey ? xScale(d[xKey]) : xScale(data.indexOf(d))
580
+ return base + subScale(series.data_key)
581
+ })
582
+ .attr("width", subScale.bandwidth())
583
+ .attr("rx", radius)
584
+ .attr("ry", radius)
585
+ .attr("fill", series.color)
586
+ .attr("opacity", series.opacity ?? 1)
587
+ .attr("y", animate ? yScale(0) : d => yScale(+d[series.data_key] || 0))
588
+ .attr("height", animate ? 0 : d => Math.max(0, yScale(0) - yScale(+d[series.data_key] || 0)))
589
+ .attr("aria-label", d => `${seriesLabel(series)}: ${d[series.data_key]}`)
590
+ .style("cursor", "pointer")
591
+ .transition().duration(animate ? DURATION : 0).ease(EASE)
592
+ .delay((_, i) => animate ? i * 40 : 0)
593
+ .attr("y", d => yScale(+d[series.data_key] || 0))
594
+ .attr("height", d => Math.max(0, yScale(0) - yScale(+d[series.data_key] || 0)))
595
+
596
+ if (chartElement) {
597
+ g.selectAll(`.trackplot-bar-${cls}`)
598
+ .on("click", function (event, d) {
599
+ const i = data.indexOf(d)
600
+ dispatchClick(chartElement, {
601
+ chartType: "bar",
602
+ dataKey: series.data_key,
603
+ datum: d,
604
+ index: i,
605
+ value: d[series.data_key]
606
+ })
607
+ })
608
+ }
609
+ })
610
+ }
611
+ }
612
+
613
+ // ─── Stacked Bar Renderer ───────────────────────────────────────────────────
614
+
615
+ function renderStackedBars(g, data, xScale, yScale, xKey, stackedSeries, animate, chartElement) {
616
+ const keys = stackedSeries.map(s => s.data_key)
617
+ const stack = d3.stack().keys(keys)
618
+ const stackedData = stack(data)
619
+
394
620
  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)
621
+ const barWidth = bandwidth * 0.8
622
+ const barOffset = (bandwidth - barWidth) / 2
399
623
 
400
- barSeries.forEach(series => {
624
+ stackedData.forEach((layer, idx) => {
625
+ const series = stackedSeries[idx]
401
626
  const cls = sanitizeClass(series.data_key)
402
- const radius = Math.min(series.radius ?? 4, subScale.bandwidth() / 2)
627
+ const radius = idx === stackedData.length - 1 ? Math.min(series.radius ?? 4, barWidth / 2) : 0
403
628
 
404
- const rects = g.selectAll(null)
405
- .data(data)
629
+ g.selectAll(null)
630
+ .data(layer)
406
631
  .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)
632
+ .attr("class", `trackplot-bar trackplot-stacked-bar trackplot-bar-${cls}`)
633
+ .attr("x", (d, i) => {
634
+ const base = xKey ? xScale(data[i][xKey]) : xScale(i)
635
+ return base + barOffset
411
636
  })
412
- .attr("width", subScale.bandwidth())
637
+ .attr("width", barWidth)
413
638
  .attr("rx", radius)
414
639
  .attr("ry", radius)
415
640
  .attr("fill", series.color)
416
641
  .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)))
642
+ .attr("y", animate ? yScale(0) : d => yScale(d[1]))
643
+ .attr("height", animate ? 0 : d => Math.max(0, yScale(d[0]) - yScale(d[1])))
644
+ .attr("aria-label", (d, i) => `${seriesLabel(series)}: ${data[i][series.data_key]}`)
419
645
  .style("cursor", "pointer")
420
646
  .transition().duration(animate ? DURATION : 0).ease(EASE)
421
647
  .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)))
648
+ .attr("y", d => yScale(d[1]))
649
+ .attr("height", d => Math.max(0, yScale(d[0]) - yScale(d[1])))
424
650
 
425
651
  if (chartElement) {
426
652
  g.selectAll(`.trackplot-bar-${cls}`)
427
653
  .on("click", function (event, d) {
428
- const i = data.indexOf(d)
654
+ const i = layer.indexOf(d)
429
655
  dispatchClick(chartElement, {
430
656
  chartType: "bar",
431
657
  dataKey: series.data_key,
432
- datum: d,
658
+ datum: data[i],
433
659
  index: i,
434
- value: d[series.data_key]
660
+ value: data[i][series.data_key]
435
661
  })
436
662
  })
437
663
  }
@@ -549,6 +775,7 @@ function renderScatter(g, data, xScale, yScale, xKey, series, animate, chartElem
549
775
  .attr("fill-opacity", series.opacity || 0.7)
550
776
  .attr("stroke", "white")
551
777
  .attr("stroke-width", 1.5)
778
+ .attr("aria-label", d => `${seriesLabel(series)}: ${d[series.data_key]}`)
552
779
  .style("cursor", "pointer")
553
780
 
554
781
  if (animate) {
@@ -606,6 +833,7 @@ function renderHorizontalBars(g, data, xScale, yScale, yKey, barSeries, animate,
606
833
  .attr("ry", radius)
607
834
  .attr("fill", series.color)
608
835
  .attr("opacity", series.opacity ?? 1)
836
+ .attr("aria-label", d => `${seriesLabel(series)}: ${d[series.data_key]}`)
609
837
  .style("cursor", "pointer")
610
838
  .attr("width", animate ? 0 : d => Math.max(0, xScale(+d[series.data_key] || 0)))
611
839
  .transition().duration(animate ? DURATION : 0).ease(EASE)
@@ -646,6 +874,7 @@ function renderCandlestick(g, data, xScale, yScale, xKey, series, animate, chart
646
874
  // Wick
647
875
  const wick = g.append("line")
648
876
  .attr("class", "trackplot-wick")
877
+ .attr("aria-hidden", "true")
649
878
  .attr("x1", x).attr("x2", x)
650
879
  .attr("y1", yScale(high)).attr("y2", yScale(low))
651
880
  .attr("stroke", color).attr("stroke-width", 1.5)
@@ -664,6 +893,7 @@ function renderCandlestick(g, data, xScale, yScale, xKey, series, animate, chart
664
893
  .attr("stroke", color)
665
894
  .attr("stroke-width", 1)
666
895
  .attr("rx", 1.5)
896
+ .attr("aria-label", `O:${open} H:${high} L:${low} C:${close}`)
667
897
  .style("cursor", "pointer")
668
898
 
669
899
  if (animate) {
@@ -712,6 +942,7 @@ function renderPieSlices(g, data, series, width, height, animate, theme, chartEl
712
942
  .attr("fill", (_, i) => t.colors[i % t.colors.length])
713
943
  .attr("stroke", "white")
714
944
  .attr("stroke-width", 2)
945
+ .attr("aria-label", d => `${series.label_key ? d.data[series.label_key] : ""}: ${d.data[series.data_key]}`)
715
946
  .style("cursor", "pointer")
716
947
 
717
948
  if (animate) {
@@ -787,6 +1018,7 @@ function renderRadarChart(g, data, radarSeries, labelKey, width, height, animate
787
1018
  .attr("fill", "none")
788
1019
  .attr("stroke", t.grid_color)
789
1020
  .attr("stroke-width", lvl === levels ? 1.5 : 0.8)
1021
+ .attr("aria-hidden", "true")
790
1022
 
791
1023
  // Level label
792
1024
  if (lvl < levels) {
@@ -806,6 +1038,7 @@ function renderRadarChart(g, data, radarSeries, labelKey, width, height, animate
806
1038
  center.append("line")
807
1039
  .attr("x1", 0).attr("y1", 0).attr("x2", x).attr("y2", y)
808
1040
  .attr("stroke", t.axis_color).attr("stroke-width", 0.8)
1041
+ .attr("aria-hidden", "true")
809
1042
 
810
1043
  const lx = (radius + 18) * Math.cos(a)
811
1044
  const ly = (radius + 18) * Math.sin(a)
@@ -844,6 +1077,7 @@ function renderRadarChart(g, data, radarSeries, labelKey, width, height, animate
844
1077
  .attr("class", `trackplot-radar-dot`)
845
1078
  .attr("cx", p[0]).attr("cy", p[1])
846
1079
  .attr("fill", "white").attr("stroke", series.color).attr("stroke-width", 2)
1080
+ .attr("aria-label", `${categories[i]}: ${data[i][series.data_key]}`)
847
1081
  .style("cursor", "pointer")
848
1082
 
849
1083
  if (animate) {
@@ -907,6 +1141,7 @@ function renderFunnelChart(g, data, series, width, height, animate, theme, chart
907
1141
  .attr("fill", color)
908
1142
  .attr("stroke", "white")
909
1143
  .attr("stroke-width", 2)
1144
+ .attr("aria-label", `${labelKey ? d[labelKey] : `Stage ${i + 1}`}: ${val}`)
910
1145
  .style("cursor", "pointer")
911
1146
 
912
1147
  if (animate) {
@@ -945,6 +1180,244 @@ function renderFunnelChart(g, data, series, width, height, animate, theme, chart
945
1180
  })
946
1181
  }
947
1182
 
1183
+ // ─── Heatmap Renderer ───────────────────────────────────────────────────────
1184
+
1185
+ function renderHeatmapChart(g, data, config, width, height, theme, chartElement) {
1186
+ const t = theme || DEFAULT_THEME
1187
+ const xKey = config.x_key
1188
+ const yKey = config.y_key
1189
+ const valueKey = config.value_key
1190
+ const colorRange = config.color_range || ["#f0f9ff", "#1e40af"]
1191
+ const radius = config.radius || 2
1192
+
1193
+ const xDomain = [...new Set(data.map(d => d[xKey]))]
1194
+ const yDomain = [...new Set(data.map(d => d[yKey]))]
1195
+
1196
+ const xScale = d3.scaleBand().domain(xDomain).range([0, width]).padding(0.05)
1197
+ const yScale = d3.scaleBand().domain(yDomain).range([0, height]).padding(0.05)
1198
+
1199
+ const extent = d3.extent(data, d => +d[valueKey])
1200
+ const colorScale = d3.scaleSequential()
1201
+ .domain(extent)
1202
+ .interpolator(d3.interpolateRgb(colorRange[0], colorRange[1]))
1203
+
1204
+ // Render cells
1205
+ g.selectAll(null)
1206
+ .data(data)
1207
+ .enter().append("rect")
1208
+ .attr("class", "trackplot-heatmap-cell")
1209
+ .attr("x", d => xScale(d[xKey]))
1210
+ .attr("y", d => yScale(d[yKey]))
1211
+ .attr("width", xScale.bandwidth())
1212
+ .attr("height", yScale.bandwidth())
1213
+ .attr("rx", radius)
1214
+ .attr("ry", radius)
1215
+ .attr("fill", d => colorScale(+d[valueKey]))
1216
+ .attr("aria-label", d => `${d[xKey]}, ${d[yKey]}: ${d[valueKey]}`)
1217
+ .style("cursor", "pointer")
1218
+
1219
+ // X axis
1220
+ const xG = g.append("g")
1221
+ .attr("class", "trackplot-axis-x")
1222
+ .attr("transform", `translate(0,${height})`)
1223
+ xG.call(d3.axisBottom(xScale))
1224
+ xG.selectAll("text").attr("fill", t.text_color).attr("font-size", "11px").attr("font-family", t.font || FONT)
1225
+ xG.selectAll("line").attr("stroke", t.axis_color)
1226
+ xG.select(".domain").attr("stroke", t.axis_color)
1227
+
1228
+ // Y axis
1229
+ const yG = g.append("g").attr("class", "trackplot-axis-y")
1230
+ yG.call(d3.axisLeft(yScale))
1231
+ yG.selectAll("text").attr("fill", t.text_color).attr("font-size", "11px").attr("font-family", t.font || FONT)
1232
+ yG.selectAll("line").attr("stroke", t.axis_color)
1233
+ yG.select(".domain").attr("stroke", t.axis_color)
1234
+
1235
+ if (chartElement) {
1236
+ g.selectAll(".trackplot-heatmap-cell")
1237
+ .on("click", function (event, d) {
1238
+ dispatchClick(chartElement, {
1239
+ chartType: "heatmap",
1240
+ dataKey: valueKey,
1241
+ datum: d,
1242
+ index: data.indexOf(d),
1243
+ value: d[valueKey]
1244
+ })
1245
+ })
1246
+ }
1247
+ }
1248
+
1249
+ // ─── Treemap Renderer ───────────────────────────────────────────────────────
1250
+
1251
+ function renderTreemapChart(g, data, config, width, height, theme, chartElement) {
1252
+ const t = theme || DEFAULT_THEME
1253
+ const valueKey = config.value_key
1254
+ const labelKey = config.label_key
1255
+ const parentKey = config.parent_key
1256
+
1257
+ let root
1258
+ if (parentKey) {
1259
+ // Build hierarchy from parent_key
1260
+ const grouped = d3.group(data, d => d[parentKey])
1261
+ const children = Array.from(grouped, ([key, values]) => ({
1262
+ name: key,
1263
+ children: values.map(v => ({ name: labelKey ? v[labelKey] : "", value: +v[valueKey], _data: v }))
1264
+ }))
1265
+ root = d3.hierarchy({ name: "root", children })
1266
+ .sum(d => d.value || 0)
1267
+ } else {
1268
+ // Flat data
1269
+ const children = data.map(d => ({ name: labelKey ? d[labelKey] : "", value: +d[valueKey], _data: d }))
1270
+ root = d3.hierarchy({ name: "root", children })
1271
+ .sum(d => d.value || 0)
1272
+ }
1273
+
1274
+ d3.treemap()
1275
+ .size([width, height])
1276
+ .padding(2)
1277
+ .round(true)(root)
1278
+
1279
+ const themeColors = t.colors || COLORS
1280
+ const leaves = root.leaves()
1281
+
1282
+ const cells = g.selectAll(null)
1283
+ .data(leaves)
1284
+ .enter().append("g")
1285
+ .attr("transform", d => `translate(${d.x0},${d.y0})`)
1286
+
1287
+ cells.append("rect")
1288
+ .attr("class", "trackplot-treemap-cell")
1289
+ .attr("width", d => d.x1 - d.x0)
1290
+ .attr("height", d => d.y1 - d.y0)
1291
+ .attr("fill", (_, i) => themeColors[i % themeColors.length])
1292
+ .attr("rx", 3)
1293
+ .attr("ry", 3)
1294
+ .attr("aria-label", d => `${d.data.name}: ${d.value}`)
1295
+ .style("cursor", "pointer")
1296
+
1297
+ // Labels
1298
+ cells.append("text")
1299
+ .attr("x", 6)
1300
+ .attr("y", 16)
1301
+ .attr("fill", "white")
1302
+ .attr("font-size", d => {
1303
+ const w = d.x1 - d.x0
1304
+ const h = d.y1 - d.y0
1305
+ return w > 60 && h > 24 ? "12px" : w > 40 && h > 18 ? "10px" : "0px"
1306
+ })
1307
+ .attr("font-family", t.font || FONT)
1308
+ .attr("font-weight", "600")
1309
+ .text(d => d.data.name)
1310
+ .each(function (d) {
1311
+ const maxW = d.x1 - d.x0 - 12
1312
+ const node = d3.select(this)
1313
+ if (node.node().getComputedTextLength() > maxW) {
1314
+ const text = d.data.name
1315
+ for (let i = text.length - 1; i >= 0; i--) {
1316
+ node.text(text.slice(0, i) + "…")
1317
+ if (node.node().getComputedTextLength() <= maxW) break
1318
+ }
1319
+ }
1320
+ })
1321
+
1322
+ // Value labels
1323
+ cells.append("text")
1324
+ .attr("x", 6)
1325
+ .attr("y", 30)
1326
+ .attr("fill", "rgba(255,255,255,0.75)")
1327
+ .attr("font-size", d => {
1328
+ const w = d.x1 - d.x0
1329
+ const h = d.y1 - d.y0
1330
+ return w > 50 && h > 36 ? "10px" : "0px"
1331
+ })
1332
+ .attr("font-family", t.font || FONT)
1333
+ .text(d => d.value)
1334
+
1335
+ if (chartElement) {
1336
+ cells.select("rect")
1337
+ .on("click", function (event, d) {
1338
+ const datum = d.data._data || d.data
1339
+ dispatchClick(chartElement, {
1340
+ chartType: "treemap",
1341
+ dataKey: valueKey,
1342
+ datum: datum,
1343
+ index: leaves.indexOf(d),
1344
+ value: d.value
1345
+ })
1346
+ })
1347
+ }
1348
+ }
1349
+
1350
+ // ─── Brush Renderer ─────────────────────────────────────────────────────────
1351
+
1352
+ function setupBrush(svg, g, data, xScale, yScale, xKey, chart, width, height, margin, brushConfig) {
1353
+ const brushHeight = brushConfig.height || 40
1354
+ const brushMarginTop = 8
1355
+
1356
+ const brushG = svg.append("g")
1357
+ .attr("class", "trackplot-brush")
1358
+ .attr("transform", `translate(${margin.left},${height + margin.top + brushMarginTop})`)
1359
+
1360
+ const brushXScale = xScale.copy()
1361
+ const brush = d3.brushX()
1362
+ .extent([[0, 0], [width, brushHeight]])
1363
+ .on("end", function (event) {
1364
+ if (!event.selection) {
1365
+ // Reset on double-click / clear
1366
+ chart._brushDomain = null
1367
+ chart.animate = false
1368
+ chart.render()
1369
+ return
1370
+ }
1371
+
1372
+ const [x0, x1] = event.selection
1373
+
1374
+ if (xScale.bandwidth) {
1375
+ // Band scale: find which bands fall in range
1376
+ const domain = xScale.domain()
1377
+ const filtered = domain.filter(d => {
1378
+ const pos = xScale(d) + xScale.bandwidth() / 2
1379
+ return pos >= x0 && pos <= x1
1380
+ })
1381
+ if (filtered.length > 0) {
1382
+ chart._brushDomain = filtered
1383
+ chart.animate = false
1384
+ chart.render()
1385
+ }
1386
+ } else {
1387
+ const d0 = xScale.invert(x0)
1388
+ const d1 = xScale.invert(x1)
1389
+ chart._brushDomain = [d0, d1]
1390
+ chart.animate = false
1391
+ chart.render()
1392
+ }
1393
+ })
1394
+
1395
+ // Mini preview area
1396
+ const miniYScale = d3.scaleLinear()
1397
+ .domain(yScale.domain())
1398
+ .range([brushHeight, 0])
1399
+
1400
+ const seriesWithData = chart.seriesList.filter(s => s.data_key && CARTESIAN_TYPES.includes(s.type))
1401
+ if (seriesWithData.length > 0) {
1402
+ const firstSeries = seriesWithData[0]
1403
+ const getX = xAccessorFor(brushXScale, xKey)
1404
+ const miniLine = d3.line()
1405
+ .x((d, i) => getX(d, i))
1406
+ .y(d => miniYScale(+d[firstSeries.data_key] || 0))
1407
+ .defined(d => d[firstSeries.data_key] != null)
1408
+
1409
+ brushG.append("path")
1410
+ .datum(data)
1411
+ .attr("fill", "none")
1412
+ .attr("stroke", firstSeries.color || "#6366f1")
1413
+ .attr("stroke-width", 1)
1414
+ .attr("stroke-opacity", 0.5)
1415
+ .attr("d", miniLine)
1416
+ }
1417
+
1418
+ brushG.append("g").call(brush)
1419
+ }
1420
+
948
1421
  // ─── Cartesian Tooltip ───────────────────────────────────────────────────────
949
1422
 
950
1423
  function setupCartesianTooltip(element, g, data, xScale, yScale, xKey, series, config, width, height, margin, theme) {
@@ -955,6 +1428,7 @@ function setupCartesianTooltip(element, g, data, xScale, yScale, xKey, series, c
955
1428
 
956
1429
  const crosshair = g.append("line")
957
1430
  .attr("class", "trackplot-crosshair")
1431
+ .attr("aria-hidden", "true")
958
1432
  .attr("stroke", t.axis_color).attr("stroke-width", 1).attr("stroke-dasharray", "4 3")
959
1433
  .attr("y1", 0).attr("y2", height)
960
1434
  .style("opacity", 0)
@@ -998,7 +1472,7 @@ function setupCartesianTooltip(element, g, data, xScale, yScale, xKey, series, c
998
1472
  const formatted = isNaN(+val) ? val : fmtValue(+val)
999
1473
  html += `<div style="display:flex;align-items:center;gap:8px">`
1000
1474
  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>`
1475
+ html += `<span style="color:${t.text_color}">${seriesLabel(s)}</span>`
1002
1476
  html += `<span style="font-weight:500;color:${t.tooltip_text};margin-left:auto;padding-left:12px">${formatted}</span>`
1003
1477
  html += `</div>`
1004
1478
  }
@@ -1084,7 +1558,7 @@ function setupRadarTooltip(element, g, data, radarSeries, labelKey, config, them
1084
1558
  let html = `<div style="font-weight:600;color:${t.tooltip_text};margin-bottom:2px">${cat}</div>`
1085
1559
  html += `<div style="display:flex;align-items:center;gap:8px">`
1086
1560
  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>`
1561
+ html += `<span style="color:${t.text_color}">${seriesLabel(series)}</span>`
1088
1562
  html += `<span style="font-weight:500;color:${t.tooltip_text};margin-left:auto;padding-left:12px">${d[series.data_key]}</span>`
1089
1563
  html += `</div>`
1090
1564
  tooltip.innerHTML = html
@@ -1131,6 +1605,61 @@ function setupFunnelTooltip(element, g, data, series, config, theme) {
1131
1605
  })
1132
1606
  }
1133
1607
 
1608
+ // ─── Heatmap Tooltip ────────────────────────────────────────────────────────
1609
+
1610
+ function setupHeatmapTooltip(element, g, data, config, theme) {
1611
+ const t = theme || DEFAULT_THEME
1612
+ const tooltip = createTooltipDiv(element, t)
1613
+
1614
+ g.selectAll(".trackplot-heatmap-cell")
1615
+ .on("mouseenter.tooltip", function (event, d) {
1616
+ const xVal = d[config.x_key]
1617
+ const yVal = d[config.y_key]
1618
+ const val = d[config.value_key]
1619
+ let html = `<div style="font-weight:600;color:${t.tooltip_text};margin-bottom:2px">${xVal}, ${yVal}</div>`
1620
+ html += `<div style="color:${t.text_color}">${val}</div>`
1621
+ tooltip.innerHTML = html
1622
+ tooltip.style.opacity = "1"
1623
+ })
1624
+ .on("mousemove.tooltip", function (event) {
1625
+ const [x, y] = d3.pointer(event, element)
1626
+ tooltip.style.left = `${x + 16}px`
1627
+ tooltip.style.top = `${y - 16}px`
1628
+ })
1629
+ .on("mouseleave.tooltip", function () {
1630
+ tooltip.style.opacity = "0"
1631
+ })
1632
+ }
1633
+
1634
+ // ─── Treemap Tooltip ────────────────────────────────────────────────────────
1635
+
1636
+ function setupTreemapTooltip(element, g, data, config, theme) {
1637
+ const t = theme || DEFAULT_THEME
1638
+ const tooltip = createTooltipDiv(element, t)
1639
+
1640
+ g.selectAll(".trackplot-treemap-cell")
1641
+ .on("mouseenter.tooltip", function (event) {
1642
+ const parent = d3.select(this.parentNode)
1643
+ const d = parent.datum()
1644
+ if (!d) return
1645
+ const name = d.data.name || ""
1646
+ const val = d.value
1647
+ let html = ""
1648
+ if (name) html += `<div style="font-weight:600;color:${t.tooltip_text};margin-bottom:2px">${name}</div>`
1649
+ html += `<div style="color:${t.text_color}">${val}</div>`
1650
+ tooltip.innerHTML = html
1651
+ tooltip.style.opacity = "1"
1652
+ })
1653
+ .on("mousemove.tooltip", function (event) {
1654
+ const [x, y] = d3.pointer(event, element)
1655
+ tooltip.style.left = `${x + 16}px`
1656
+ tooltip.style.top = `${y - 16}px`
1657
+ })
1658
+ .on("mouseleave.tooltip", function () {
1659
+ tooltip.style.opacity = "0"
1660
+ })
1661
+ }
1662
+
1134
1663
  // ─── Legend Renderer ─────────────────────────────────────────────────────────
1135
1664
 
1136
1665
  function renderLegend(element, items, config, theme) {
@@ -1160,7 +1689,7 @@ function renderLegend(element, items, config, theme) {
1160
1689
  background: item.color, flexShrink: "0"
1161
1690
  })
1162
1691
  const label = document.createElement("span")
1163
- label.textContent = item.data_key
1692
+ label.textContent = seriesLabel(item)
1164
1693
  label.style.color = t.text_color
1165
1694
 
1166
1695
  el.appendChild(dot)
@@ -1175,6 +1704,51 @@ function renderLegend(element, items, config, theme) {
1175
1704
  }
1176
1705
  }
1177
1706
 
1707
+ // ─── Empty State Renderer ───────────────────────────────────────────────────
1708
+
1709
+ function renderEmptyState(element, message, theme) {
1710
+ const t = theme || DEFAULT_THEME
1711
+ const rect = element.getBoundingClientRect()
1712
+ if (rect.width === 0 || rect.height === 0) return
1713
+
1714
+ const svg = d3.select(element)
1715
+ .append("svg")
1716
+ .attr("width", rect.width)
1717
+ .attr("height", rect.height)
1718
+ .attr("role", "img")
1719
+ .attr("aria-label", message)
1720
+
1721
+ // Icon (empty chart placeholder)
1722
+ const cx = rect.width / 2
1723
+ const cy = rect.height / 2 - 12
1724
+ const iconG = svg.append("g")
1725
+ .attr("transform", `translate(${cx - 20},${cy - 24})`)
1726
+ .attr("aria-hidden", "true")
1727
+
1728
+ iconG.append("rect")
1729
+ .attr("x", 0).attr("y", 24).attr("width", 8).attr("height", 16)
1730
+ .attr("rx", 2).attr("fill", t.axis_color).attr("opacity", 0.4)
1731
+ iconG.append("rect")
1732
+ .attr("x", 12).attr("y", 16).attr("width", 8).attr("height", 24)
1733
+ .attr("rx", 2).attr("fill", t.axis_color).attr("opacity", 0.4)
1734
+ iconG.append("rect")
1735
+ .attr("x", 24).attr("y", 8).attr("width", 8).attr("height", 32)
1736
+ .attr("rx", 2).attr("fill", t.axis_color).attr("opacity", 0.4)
1737
+ iconG.append("rect")
1738
+ .attr("x", 36).attr("y", 20).attr("width", 8).attr("height", 20)
1739
+ .attr("rx", 2).attr("fill", t.axis_color).attr("opacity", 0.4)
1740
+
1741
+ svg.append("text")
1742
+ .attr("x", cx)
1743
+ .attr("y", cy + 32)
1744
+ .attr("text-anchor", "middle")
1745
+ .attr("dominant-baseline", "middle")
1746
+ .attr("fill", t.axis_color)
1747
+ .attr("font-size", "14px")
1748
+ .attr("font-family", t.font || FONT)
1749
+ .text(message)
1750
+ }
1751
+
1178
1752
  // ─── Chart Class ─────────────────────────────────────────────────────────────
1179
1753
 
1180
1754
  class Chart {
@@ -1185,6 +1759,7 @@ class Chart {
1185
1759
  this.animate = config.animate !== false
1186
1760
  this.margin = { ...DEFAULT_MARGIN }
1187
1761
  this.theme = config.theme || DEFAULT_THEME
1762
+ this.chartConfig = config
1188
1763
 
1189
1764
  this.seriesList = this.components.filter(c => ALL_SERIES_TYPES.includes(c.type))
1190
1765
  this.axesList = this.components.filter(c => c.type === "axis")
@@ -1192,6 +1767,8 @@ class Chart {
1192
1767
  this.tooltipConfig = this.components.find(c => c.type === "tooltip")
1193
1768
  this.legendConfig = this.components.find(c => c.type === "legend")
1194
1769
  this.referenceLines = this.components.filter(c => c.type === "reference_line")
1770
+ this.dataLabelConfig = this.components.find(c => c.type === "data_label")
1771
+ this.brushConfig = this.components.find(c => c.type === "brush")
1195
1772
 
1196
1773
  const themeColors = this.theme.colors || COLORS
1197
1774
  this.seriesList.forEach((s, i) => { s.color = s.color || themeColors[i % themeColors.length] })
@@ -1199,6 +1776,8 @@ class Chart {
1199
1776
  this.isRadar = this.seriesList.some(s => s.type === "radar")
1200
1777
  this.isFunnel = this.seriesList.some(s => s.type === "funnel")
1201
1778
  this.isHorizontal = this.seriesList.some(s => s.type === "horizontal_bar")
1779
+ this.isHeatmap = this.seriesList.some(s => s.type === "heatmap")
1780
+ this.isTreemap = this.seriesList.some(s => s.type === "treemap")
1202
1781
  this.xKey = getXKey(this.components)
1203
1782
 
1204
1783
  // Apply theme background
@@ -1210,12 +1789,19 @@ class Chart {
1210
1789
 
1211
1790
  render() {
1212
1791
  this.clear()
1213
- if (this.data.length === 0) return
1792
+
1793
+ if (this.data.length === 0) {
1794
+ const message = this.chartConfig.empty_message || "No data available"
1795
+ renderEmptyState(this.element, message, this.theme)
1796
+ return
1797
+ }
1214
1798
 
1215
1799
  const rect = this.element.getBoundingClientRect()
1216
1800
  if (rect.width === 0 || rect.height === 0) return
1217
1801
 
1218
- if (this.isRadar) this.renderRadar(rect.width, rect.height)
1802
+ if (this.isHeatmap) this.renderHeatmap(rect.width, rect.height)
1803
+ else if (this.isTreemap) this.renderTreemap(rect.width, rect.height)
1804
+ else if (this.isRadar) this.renderRadar(rect.width, rect.height)
1219
1805
  else if (this.isFunnel) this.renderFunnel(rect.width, rect.height)
1220
1806
  else if (this.isPie) this.renderPie(rect.width, rect.height)
1221
1807
  else if (this.isHorizontal) this.renderHorizontalCartesian(rect.width, rect.height)
@@ -1224,28 +1810,57 @@ class Chart {
1224
1810
 
1225
1811
  renderCartesian(totalW, totalH) {
1226
1812
  const legendH = this.legendConfig ? 32 : 0
1227
- const m = this.margin
1813
+ const brushH = this.brushConfig ? (this.brushConfig.height || 40) + 16 : 0
1814
+ const hasRightAxis = this.axesList.some(a => a.axis_id === "right")
1815
+ const m = { ...this.margin }
1816
+ if (hasRightAxis) m.right = Math.max(m.right, 52)
1817
+
1228
1818
  const w = totalW - m.left - m.right
1229
- const h = totalH - m.top - m.bottom - legendH
1819
+ const h = totalH - m.top - m.bottom - legendH - brushH
1230
1820
  if (w <= 0 || h <= 0) return
1231
1821
 
1822
+ const svgH = totalH - legendH
1232
1823
  const svg = d3.select(this.element)
1233
- .append("svg").attr("width", totalW).attr("height", totalH - legendH)
1824
+ .append("svg").attr("width", totalW).attr("height", svgH)
1825
+
1826
+ // Accessibility
1827
+ if (this.chartConfig.title) {
1828
+ svg.attr("role", "img").attr("aria-label", this.chartConfig.title)
1829
+ svg.append("title").text(this.chartConfig.title)
1830
+ if (this.chartConfig.description) svg.append("desc").text(this.chartConfig.description)
1831
+ }
1832
+
1234
1833
  const g = svg.append("g").attr("transform", `translate(${m.left},${m.top})`)
1235
1834
 
1835
+ // Apply brush domain filtering if active
1836
+ let data = this.data
1837
+ if (this._brushDomain) {
1838
+ if (Array.isArray(this._brushDomain) && typeof this._brushDomain[0] === "number") {
1839
+ data = data.filter(d => {
1840
+ const v = +d[this.xKey]
1841
+ return v >= this._brushDomain[0] && v <= this._brushDomain[1]
1842
+ })
1843
+ } else if (Array.isArray(this._brushDomain)) {
1844
+ data = data.filter(d => this._brushDomain.includes(d[this.xKey]))
1845
+ }
1846
+ }
1847
+
1236
1848
  const hasBars = this.seriesList.some(s => s.type === "bar")
1237
1849
  const hasScatter = this.seriesList.some(s => s.type === "scatter")
1238
- let scaleType = hasBars ? "band" : detectScaleType(this.data, this.xKey)
1850
+ let scaleType = hasBars ? "band" : detectScaleType(data, this.xKey)
1239
1851
  if (hasScatter && !hasBars && scaleType === "band") scaleType = "band"
1240
- const xScale = createXScale(this.data, this.xKey, scaleType, w)
1852
+ const xScale = createXScale(data, this.xKey, scaleType, w)
1853
+
1854
+ // Split series by y-axis
1855
+ const leftSeries = this.seriesList.filter(s => CARTESIAN_TYPES.includes(s.type) && s.y_axis !== "right")
1856
+ const rightSeries = this.seriesList.filter(s => CARTESIAN_TYPES.includes(s.type) && s.y_axis === "right")
1241
1857
  const cartesianSeries = this.seriesList.filter(s => CARTESIAN_TYPES.includes(s.type))
1242
- const yScale = createYScale(this.data, cartesianSeries, h)
1243
1858
 
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)
1859
+ const yScale = createYScale(data, leftSeries.length > 0 ? leftSeries : cartesianSeries, h)
1860
+ const yScaleRight = rightSeries.length > 0 ? createYScaleRight(data, rightSeries, h) : null
1246
1861
 
1247
- // Reference lines (rendered after grid/axes, before series overlay)
1248
- renderReferenceLines(g, this.referenceLines, xScale, yScale, w, h, this.theme)
1862
+ if (this.gridConfig) renderGrid(g, this.gridConfig, xScale, yScale, w, h, this.theme)
1863
+ renderAxes(g, this.axesList, xScale, yScale, w, h, this.theme, yScaleRight)
1249
1864
 
1250
1865
  // Stacked areas
1251
1866
  const areaList = this.seriesList.filter(s => s.type === "area")
@@ -1259,26 +1874,54 @@ class Chart {
1259
1874
  freeAreas.push(s)
1260
1875
  }
1261
1876
  })
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))
1877
+ Object.values(stackedGroups).forEach(group => {
1878
+ const scale = group[0].y_axis === "right" && yScaleRight ? yScaleRight : yScale
1879
+ renderStackedAreas(g, data, xScale, scale, this.xKey, group, this.animate)
1880
+ })
1881
+ freeAreas.forEach(s => {
1882
+ const scale = s.y_axis === "right" && yScaleRight ? yScaleRight : yScale
1883
+ renderArea(g, data, xScale, scale, this.xKey, s, this.animate, this.element)
1884
+ })
1264
1885
 
1265
1886
  // Bars
1266
1887
  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)
1888
+ if (barSeries.length > 0) {
1889
+ const scale = barSeries[0].y_axis === "right" && yScaleRight ? yScaleRight : yScale
1890
+ renderBars(g, data, xScale, scale, this.xKey, barSeries, this.animate, this.element)
1891
+ }
1268
1892
 
1269
1893
  // Candlestick
1270
- this.seriesList.filter(s => s.type === "candlestick").forEach(s => renderCandlestick(g, this.data, xScale, yScale, this.xKey, s, this.animate, this.element))
1894
+ this.seriesList.filter(s => s.type === "candlestick").forEach(s => renderCandlestick(g, data, xScale, yScale, this.xKey, s, this.animate, this.element))
1271
1895
 
1272
1896
  // Lines
1273
- this.seriesList.filter(s => s.type === "line").forEach(s => renderLine(g, this.data, xScale, yScale, this.xKey, s, this.animate, this.element))
1897
+ this.seriesList.filter(s => s.type === "line").forEach(s => {
1898
+ const scale = s.y_axis === "right" && yScaleRight ? yScaleRight : yScale
1899
+ renderLine(g, data, xScale, scale, this.xKey, s, this.animate, this.element)
1900
+ })
1274
1901
 
1275
1902
  // Scatter
1276
- this.seriesList.filter(s => s.type === "scatter").forEach(s => renderScatter(g, this.data, xScale, yScale, this.xKey, s, this.animate, this.element))
1903
+ this.seriesList.filter(s => s.type === "scatter").forEach(s => {
1904
+ const scale = s.y_axis === "right" && yScaleRight ? yScaleRight : yScale
1905
+ renderScatter(g, data, xScale, scale, this.xKey, s, this.animate, this.element)
1906
+ })
1907
+
1908
+ // Reference lines (rendered after series so they appear on top)
1909
+ renderReferenceLines(g, this.referenceLines, xScale, yScale, w, h, this.theme)
1910
+
1911
+ // Data labels
1912
+ if (this.dataLabelConfig) {
1913
+ renderDataLabels(g, data, xScale, yScale, this.xKey, cartesianSeries, this.dataLabelConfig, this.theme)
1914
+ }
1277
1915
 
1278
1916
  if (this.tooltipConfig) {
1279
- setupCartesianTooltip(this.element, g, this.data, xScale, yScale, this.xKey, cartesianSeries, this.tooltipConfig, w, h, m, this.theme)
1917
+ setupCartesianTooltip(this.element, g, data, xScale, yScale, this.xKey, cartesianSeries, this.tooltipConfig, w, h, m, this.theme)
1280
1918
  }
1281
1919
  if (this.legendConfig) renderLegend(this.element, this.seriesList.filter(s => s.data_key), this.legendConfig, this.theme)
1920
+
1921
+ // Brush
1922
+ if (this.brushConfig && !this._brushDomain) {
1923
+ setupBrush(svg, g, this.data, xScale, yScale, this.xKey, this, w, h, m, this.brushConfig)
1924
+ }
1282
1925
  }
1283
1926
 
1284
1927
  renderHorizontalCartesian(totalW, totalH) {
@@ -1290,6 +1933,13 @@ class Chart {
1290
1933
 
1291
1934
  const svg = d3.select(this.element)
1292
1935
  .append("svg").attr("width", totalW).attr("height", totalH - legendH)
1936
+
1937
+ if (this.chartConfig.title) {
1938
+ svg.attr("role", "img").attr("aria-label", this.chartConfig.title)
1939
+ svg.append("title").text(this.chartConfig.title)
1940
+ if (this.chartConfig.description) svg.append("desc").text(this.chartConfig.description)
1941
+ }
1942
+
1293
1943
  const g = svg.append("g").attr("transform", `translate(${m.left},${m.top})`)
1294
1944
 
1295
1945
  const catKey = this.xKey
@@ -1303,7 +1953,7 @@ class Chart {
1303
1953
  const xScale = d3.scaleLinear().domain([0, maxVal]).nice().range([0, w])
1304
1954
 
1305
1955
  if (this.gridConfig) {
1306
- const grid = g.append("g").attr("class", "trackplot-grid")
1956
+ const grid = g.append("g").attr("class", "trackplot-grid").attr("aria-hidden", "true")
1307
1957
  grid.append("g")
1308
1958
  .call(d3.axisBottom(xScale).tickSize(h).tickFormat(""))
1309
1959
  .attr("transform", `translate(0,0)`)
@@ -1354,7 +2004,7 @@ class Chart {
1354
2004
  const formatted = isNaN(+val) ? val : fmtValue(+val)
1355
2005
  html += `<div style="display:flex;align-items:center;gap:8px">`
1356
2006
  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>`
2007
+ html += `<span style="color:${t.text_color}">${seriesLabel(s)}</span>`
1358
2008
  html += `<span style="font-weight:500;color:${t.tooltip_text};margin-left:auto;padding-left:12px">${formatted}</span>`
1359
2009
  html += `</div>`
1360
2010
  }
@@ -1382,9 +2032,22 @@ class Chart {
1382
2032
  const legendH = this.legendConfig ? 40 : 0
1383
2033
  const chartH = totalH - legendH
1384
2034
  const svg = d3.select(this.element).append("svg").attr("width", totalW).attr("height", chartH)
2035
+
2036
+ if (this.chartConfig.title) {
2037
+ svg.attr("role", "img").attr("aria-label", this.chartConfig.title)
2038
+ svg.append("title").text(this.chartConfig.title)
2039
+ if (this.chartConfig.description) svg.append("desc").text(this.chartConfig.description)
2040
+ }
2041
+
1385
2042
  const g = svg.append("g")
1386
2043
 
1387
2044
  renderPieSlices(g, this.data, pieSeries, totalW, chartH, this.animate, this.theme, this.element)
2045
+
2046
+ // Data labels on pie
2047
+ if (this.dataLabelConfig) {
2048
+ renderPieDataLabels(g, this.data, pieSeries, this.dataLabelConfig, totalW, chartH, this.theme)
2049
+ }
2050
+
1388
2051
  if (this.tooltipConfig) setupPieTooltip(this.element, g, this.data, pieSeries, this.tooltipConfig, this.theme)
1389
2052
 
1390
2053
  if (this.legendConfig) {
@@ -1402,6 +2065,13 @@ class Chart {
1402
2065
  const legendH = this.legendConfig ? 40 : 0
1403
2066
  const chartH = totalH - legendH
1404
2067
  const svg = d3.select(this.element).append("svg").attr("width", totalW).attr("height", chartH)
2068
+
2069
+ if (this.chartConfig.title) {
2070
+ svg.attr("role", "img").attr("aria-label", this.chartConfig.title)
2071
+ svg.append("title").text(this.chartConfig.title)
2072
+ if (this.chartConfig.description) svg.append("desc").text(this.chartConfig.description)
2073
+ }
2074
+
1405
2075
  const g = svg.append("g")
1406
2076
 
1407
2077
  const radarSeries = this.seriesList.filter(s => s.type === "radar")
@@ -1417,6 +2087,13 @@ class Chart {
1417
2087
  const legendH = this.legendConfig ? 40 : 0
1418
2088
  const chartH = totalH - legendH
1419
2089
  const svg = d3.select(this.element).append("svg").attr("width", totalW).attr("height", chartH)
2090
+
2091
+ if (this.chartConfig.title) {
2092
+ svg.attr("role", "img").attr("aria-label", this.chartConfig.title)
2093
+ svg.append("title").text(this.chartConfig.title)
2094
+ if (this.chartConfig.description) svg.append("desc").text(this.chartConfig.description)
2095
+ }
2096
+
1420
2097
  const g = svg.append("g")
1421
2098
 
1422
2099
  renderFunnelChart(g, this.data, funnelSeries, totalW, chartH, this.animate, this.theme, this.element)
@@ -1433,6 +2110,49 @@ class Chart {
1433
2110
  }
1434
2111
  }
1435
2112
 
2113
+ renderHeatmap(totalW, totalH) {
2114
+ const heatmapConfig = this.seriesList.find(s => s.type === "heatmap")
2115
+ if (!heatmapConfig) return
2116
+
2117
+ const m = { ...this.margin, left: 60 }
2118
+ const w = totalW - m.left - m.right
2119
+ const h = totalH - m.top - m.bottom
2120
+ if (w <= 0 || h <= 0) return
2121
+
2122
+ const svg = d3.select(this.element)
2123
+ .append("svg").attr("width", totalW).attr("height", totalH)
2124
+
2125
+ if (this.chartConfig.title) {
2126
+ svg.attr("role", "img").attr("aria-label", this.chartConfig.title)
2127
+ svg.append("title").text(this.chartConfig.title)
2128
+ if (this.chartConfig.description) svg.append("desc").text(this.chartConfig.description)
2129
+ }
2130
+
2131
+ const g = svg.append("g").attr("transform", `translate(${m.left},${m.top})`)
2132
+ renderHeatmapChart(g, this.data, heatmapConfig, w, h, this.theme, this.element)
2133
+ if (this.tooltipConfig) setupHeatmapTooltip(this.element, g, this.data, heatmapConfig, this.theme)
2134
+ }
2135
+
2136
+ renderTreemap(totalW, totalH) {
2137
+ const treemapConfig = this.seriesList.find(s => s.type === "treemap")
2138
+ if (!treemapConfig) return
2139
+
2140
+ const legendH = this.legendConfig ? 40 : 0
2141
+ const chartH = totalH - legendH
2142
+ const svg = d3.select(this.element)
2143
+ .append("svg").attr("width", totalW).attr("height", chartH)
2144
+
2145
+ if (this.chartConfig.title) {
2146
+ svg.attr("role", "img").attr("aria-label", this.chartConfig.title)
2147
+ svg.append("title").text(this.chartConfig.title)
2148
+ if (this.chartConfig.description) svg.append("desc").text(this.chartConfig.description)
2149
+ }
2150
+
2151
+ const g = svg.append("g")
2152
+ renderTreemapChart(g, this.data, treemapConfig, totalW, chartH, this.theme, this.element)
2153
+ if (this.tooltipConfig) setupTreemapTooltip(this.element, g, this.data, treemapConfig, this.theme)
2154
+ }
2155
+
1436
2156
  clear() {
1437
2157
  // Preserve background on re-render
1438
2158
  const bg = this.element.style.background
@@ -1446,6 +2166,19 @@ class Chart {
1446
2166
  }
1447
2167
  }
1448
2168
 
2169
+ // ─── Export Helpers ──────────────────────────────────────────────────────────
2170
+
2171
+ function triggerDownload(blob, filename) {
2172
+ const url = URL.createObjectURL(blob)
2173
+ const a = document.createElement("a")
2174
+ a.href = url
2175
+ a.download = filename
2176
+ document.body.appendChild(a)
2177
+ a.click()
2178
+ document.body.removeChild(a)
2179
+ URL.revokeObjectURL(url)
2180
+ }
2181
+
1449
2182
  // ─── Custom Element ──────────────────────────────────────────────────────────
1450
2183
 
1451
2184
  class TrackplotElement extends HTMLElement {
@@ -1460,10 +2193,17 @@ class TrackplotElement extends HTMLElement {
1460
2193
  // Clear stale content from Turbo cache restoration or Turbo Stream replace
1461
2194
  this.innerHTML = ""
1462
2195
 
2196
+ // Drill-down state
2197
+ this._drillConfig = this.chartConfig.components?.find(c => c.type === "drilldown") || null
2198
+ this._drillStack = []
2199
+ this._originalData = this.chartConfig.data ? [...this.chartConfig.data] : []
2200
+ this._drillClickHandler = null
2201
+
1463
2202
  this.chart = new Chart(this, this.chartConfig)
1464
2203
  requestAnimationFrame(() => {
1465
2204
  this.chart.render()
1466
2205
  this._dispatchRender()
2206
+ if (this._drillConfig) this._setupDrillListener()
1467
2207
  })
1468
2208
 
1469
2209
  this._resizeTimeout = null
@@ -1487,6 +2227,7 @@ class TrackplotElement extends HTMLElement {
1487
2227
  clearTimeout(this._resizeTimeout)
1488
2228
  this.resizeObserver?.disconnect()
1489
2229
  document.removeEventListener("turbo:before-cache", this._turboCacheHandler)
2230
+ this._removeDrillListener()
1490
2231
  this.chart?.destroy()
1491
2232
  this.chart = null
1492
2233
  }
@@ -1494,13 +2235,19 @@ class TrackplotElement extends HTMLElement {
1494
2235
  static get observedAttributes() { return ["config"] }
1495
2236
 
1496
2237
  attributeChangedCallback(name, oldVal, newVal) {
1497
- if (name === "config" && oldVal !== null && newVal) {
2238
+ if (name === "config" && oldVal !== null && newVal && !this._internalUpdate) {
1498
2239
  try {
1499
2240
  this.chartConfig = JSON.parse(newVal)
2241
+ this._drillConfig = this.chartConfig.components?.find(c => c.type === "drilldown") || null
2242
+ this._drillStack = []
2243
+ this._originalData = this.chartConfig.data ? [...this.chartConfig.data] : []
2244
+ this._removeDrillListener()
2245
+ this._removeBreadcrumb()
1500
2246
  this.chart = new Chart(this, this.chartConfig)
1501
2247
  this.chart.animate = false
1502
2248
  this.chart.render()
1503
2249
  this._dispatchRender()
2250
+ if (this._drillConfig) this._setupDrillListener()
1504
2251
  } catch (e) {
1505
2252
  console.error("Trackplot: invalid config JSON", e)
1506
2253
  }
@@ -1512,6 +2259,9 @@ class TrackplotElement extends HTMLElement {
1512
2259
  /** Replace chart data and re-render without animation. */
1513
2260
  updateData(newData) {
1514
2261
  if (!this.chartConfig) return
2262
+ this._drillStack = []
2263
+ this._originalData = [...newData]
2264
+ this._removeBreadcrumb()
1515
2265
  this.chartConfig.data = newData
1516
2266
  this._rebuildChart(false)
1517
2267
  }
@@ -1519,7 +2269,78 @@ class TrackplotElement extends HTMLElement {
1519
2269
  /** Replace the full config object and re-render. */
1520
2270
  updateConfig(config) {
1521
2271
  this.chartConfig = config
2272
+ this._drillConfig = config.components?.find(c => c.type === "drilldown") || null
2273
+ this._drillStack = []
2274
+ this._originalData = config.data ? [...config.data] : []
2275
+ this._removeDrillListener()
2276
+ this._removeBreadcrumb()
2277
+ this._rebuildChart(false)
2278
+ if (this._drillConfig) this._setupDrillListener()
2279
+ }
2280
+
2281
+ /** Append new data points and re-render with sliding window. */
2282
+ appendData(newPoints, { maxPoints = 50 } = {}) {
2283
+ if (!this.chartConfig) return
2284
+ // Reset to root level if drilled in
2285
+ if (this._drillStack.length > 0) {
2286
+ this._drillStack = []
2287
+ this._removeBreadcrumb()
2288
+ this.chartConfig.data = this._originalData
2289
+ }
2290
+ const current = this.chartConfig.data || []
2291
+ this.chartConfig.data = [...current, ...newPoints].slice(-maxPoints)
2292
+ this._originalData = [...this.chartConfig.data]
1522
2293
  this._rebuildChart(false)
2294
+ this.dispatchEvent(new CustomEvent("trackplot:data-update", {
2295
+ bubbles: true,
2296
+ detail: { count: this.chartConfig.data.length }
2297
+ }))
2298
+ }
2299
+
2300
+ /** Export chart as SVG file download. Returns a Promise. */
2301
+ exportSVG(filename = "chart.svg") {
2302
+ return new Promise((resolve) => {
2303
+ const svgEl = this.querySelector("svg")
2304
+ if (!svgEl) { resolve(null); return }
2305
+
2306
+ const clone = svgEl.cloneNode(true)
2307
+ clone.setAttribute("xmlns", "http://www.w3.org/2000/svg")
2308
+ const blob = new Blob([clone.outerHTML], { type: "image/svg+xml;charset=utf-8" })
2309
+ triggerDownload(blob, filename)
2310
+ resolve(blob)
2311
+ })
2312
+ }
2313
+
2314
+ /** Export chart as PNG file download. Returns a Promise. */
2315
+ exportPNG(scale = 2, filename = "chart.png") {
2316
+ return new Promise((resolve, reject) => {
2317
+ const svgEl = this.querySelector("svg")
2318
+ if (!svgEl) { resolve(null); return }
2319
+
2320
+ const clone = svgEl.cloneNode(true)
2321
+ clone.setAttribute("xmlns", "http://www.w3.org/2000/svg")
2322
+ const svgData = new XMLSerializer().serializeToString(clone)
2323
+ const svgBlob = new Blob([svgData], { type: "image/svg+xml;charset=utf-8" })
2324
+ const url = URL.createObjectURL(svgBlob)
2325
+
2326
+ const img = new Image()
2327
+ img.onload = () => {
2328
+ const canvas = document.createElement("canvas")
2329
+ canvas.width = svgEl.clientWidth * scale
2330
+ canvas.height = svgEl.clientHeight * scale
2331
+ const ctx = canvas.getContext("2d")
2332
+ ctx.scale(scale, scale)
2333
+ ctx.drawImage(img, 0, 0)
2334
+ URL.revokeObjectURL(url)
2335
+
2336
+ canvas.toBlob(blob => {
2337
+ triggerDownload(blob, filename)
2338
+ resolve(blob)
2339
+ }, "image/png")
2340
+ }
2341
+ img.onerror = reject
2342
+ img.src = url
2343
+ })
1523
2344
  }
1524
2345
 
1525
2346
  // ── Internals ───────────────────────────────────────────
@@ -1529,13 +2350,262 @@ class TrackplotElement extends HTMLElement {
1529
2350
  this.chart.animate = animate
1530
2351
  this.chart.render()
1531
2352
  // Sync attribute so Turbo morphing sees current state
2353
+ this._internalUpdate = true
1532
2354
  this.setAttribute("config", JSON.stringify(this.chartConfig))
2355
+ this._internalUpdate = false
1533
2356
  this._dispatchRender()
1534
2357
  }
1535
2358
 
1536
2359
  _dispatchRender() {
1537
2360
  this.dispatchEvent(new CustomEvent("trackplot:render", { bubbles: true }))
1538
2361
  }
2362
+
2363
+ // ── Drill-down Public API ────────────────────────────────
2364
+
2365
+ /** Go back one drill level. Returns false if already at root. */
2366
+ drillUp() {
2367
+ if (this._drillStack.length === 0) return false
2368
+ const prev = this._drillStack.pop()
2369
+ this.chartConfig.data = prev.data
2370
+ this._rebuildChart(true)
2371
+ this._renderBreadcrumb()
2372
+ this.dispatchEvent(new CustomEvent("trackplot:drillup", {
2373
+ bubbles: true,
2374
+ detail: { level: this._drillStack.length }
2375
+ }))
2376
+ return true
2377
+ }
2378
+
2379
+ /** Reset to root data from any drill depth. Returns false if already at root. */
2380
+ drillReset() {
2381
+ if (this._drillStack.length === 0) return false
2382
+ this._drillStack = []
2383
+ this.chartConfig.data = [...this._originalData]
2384
+ this._rebuildChart(true)
2385
+ this._removeBreadcrumb()
2386
+ this.dispatchEvent(new CustomEvent("trackplot:drillup", {
2387
+ bubbles: true,
2388
+ detail: { level: 0 }
2389
+ }))
2390
+ return true
2391
+ }
2392
+
2393
+ // ── Drill-down Internals ─────────────────────────────────
2394
+
2395
+ _setupDrillListener() {
2396
+ this._removeDrillListener()
2397
+ this._drillClickHandler = (e) => {
2398
+ const { chartType, datum } = e.detail
2399
+ if (chartType !== "bar" && chartType !== "pie" && chartType !== "heatmap" && chartType !== "treemap") return
2400
+
2401
+ const drillKey = this._drillConfig.key
2402
+ const children = datum?.[drillKey]
2403
+ if (!Array.isArray(children) || children.length === 0) return
2404
+
2405
+ e.stopImmediatePropagation()
2406
+
2407
+ // Determine label from the datum
2408
+ const xAxis = this.chartConfig.components?.find(c => c.type === "axis" && c.direction === "x")
2409
+ const pieSeries = this.chartConfig.components?.find(c => c.type === "pie")
2410
+ let label
2411
+ if (chartType === "pie" && pieSeries?.label_key) {
2412
+ label = datum[pieSeries.label_key]
2413
+ } else if (xAxis?.data_key) {
2414
+ label = datum[xAxis.data_key]
2415
+ }
2416
+ label = label ?? `Level ${this._drillStack.length + 1}`
2417
+
2418
+ this._drillStack.push({ data: this.chartConfig.data, label })
2419
+ this.chartConfig.data = children
2420
+ this._rebuildChart(true)
2421
+ this._renderBreadcrumb()
2422
+ this.dispatchEvent(new CustomEvent("trackplot:drilldown", {
2423
+ bubbles: true,
2424
+ detail: { level: this._drillStack.length, datum, label }
2425
+ }))
2426
+ }
2427
+ this.addEventListener("trackplot:click", this._drillClickHandler)
2428
+ }
2429
+
2430
+ _removeDrillListener() {
2431
+ if (this._drillClickHandler) {
2432
+ this.removeEventListener("trackplot:click", this._drillClickHandler)
2433
+ this._drillClickHandler = null
2434
+ }
2435
+ }
2436
+
2437
+ _drillToLevel(n) {
2438
+ while (this._drillStack.length > n) {
2439
+ const prev = this._drillStack.pop()
2440
+ this.chartConfig.data = prev.data
2441
+ }
2442
+ this._rebuildChart(true)
2443
+ this._renderBreadcrumb()
2444
+ this.dispatchEvent(new CustomEvent("trackplot:drillup", {
2445
+ bubbles: true,
2446
+ detail: { level: this._drillStack.length }
2447
+ }))
2448
+ }
2449
+
2450
+ _renderBreadcrumb() {
2451
+ this._removeBreadcrumb()
2452
+ if (this._drillStack.length === 0) return
2453
+
2454
+ const t = this.chartConfig.theme || DEFAULT_THEME
2455
+ const crumbDiv = document.createElement("div")
2456
+ crumbDiv.className = "trackplot-breadcrumb"
2457
+ crumbDiv.style.cssText = `display:flex;align-items:center;gap:4px;padding:4px 8px;font-family:${t.font || FONT};font-size:13px;color:${t.text_color || "#374151"};`
2458
+
2459
+ // "All" link (root)
2460
+ const allLink = document.createElement("span")
2461
+ allLink.textContent = "All"
2462
+ allLink.style.cssText = "cursor:pointer;text-decoration:underline;"
2463
+ allLink.addEventListener("click", () => this._drillToLevel(0))
2464
+ crumbDiv.appendChild(allLink)
2465
+
2466
+ // Intermediate levels
2467
+ this._drillStack.forEach((entry, i) => {
2468
+ const sep = document.createElement("span")
2469
+ sep.textContent = " \u203A "
2470
+ crumbDiv.appendChild(sep)
2471
+
2472
+ if (i < this._drillStack.length - 1) {
2473
+ const link = document.createElement("span")
2474
+ link.textContent = entry.label
2475
+ link.style.cssText = "cursor:pointer;text-decoration:underline;"
2476
+ const level = i + 1
2477
+ link.addEventListener("click", () => this._drillToLevel(level))
2478
+ crumbDiv.appendChild(link)
2479
+ } else {
2480
+ // Current level (bold, not clickable)
2481
+ const current = document.createElement("span")
2482
+ current.textContent = entry.label
2483
+ current.style.fontWeight = "bold"
2484
+ crumbDiv.appendChild(current)
2485
+ }
2486
+ })
2487
+
2488
+ this.insertBefore(crumbDiv, this.firstChild)
2489
+ }
2490
+
2491
+ _removeBreadcrumb() {
2492
+ const crumb = this.querySelector(".trackplot-breadcrumb")
2493
+ if (crumb) crumb.remove()
2494
+ }
1539
2495
  }
1540
2496
 
1541
2497
  customElements.define("trackplot-chart", TrackplotElement)
2498
+
2499
+ // ─── Sparkline Custom Element ───────────────────────────────────────────────
2500
+
2501
+ class SparklineElement extends HTMLElement {
2502
+ connectedCallback() {
2503
+ try {
2504
+ this.sparkConfig = JSON.parse(this.getAttribute("config"))
2505
+ } catch (e) {
2506
+ console.error("Trackplot: invalid sparkline config JSON", e)
2507
+ return
2508
+ }
2509
+
2510
+ this.innerHTML = ""
2511
+ requestAnimationFrame(() => this._render())
2512
+
2513
+ this.resizeObserver = new ResizeObserver(() => {
2514
+ clearTimeout(this._resizeTimeout)
2515
+ this._resizeTimeout = setTimeout(() => this._render(), 100)
2516
+ })
2517
+ this.resizeObserver.observe(this)
2518
+ }
2519
+
2520
+ disconnectedCallback() {
2521
+ clearTimeout(this._resizeTimeout)
2522
+ this.resizeObserver?.disconnect()
2523
+ }
2524
+
2525
+ _render() {
2526
+ this.innerHTML = ""
2527
+ const rect = this.getBoundingClientRect()
2528
+ if (rect.width === 0 || rect.height === 0) return
2529
+
2530
+ const { data, key, type, color, fill, stroke_width, dot } = this.sparkConfig
2531
+ if (!data || data.length === 0) return
2532
+
2533
+ const w = rect.width
2534
+ const h = rect.height
2535
+ const pad = 2
2536
+
2537
+ const svg = d3.select(this).append("svg")
2538
+ .attr("width", w).attr("height", h)
2539
+ .attr("aria-hidden", "true")
2540
+ .style("display", "block")
2541
+
2542
+ const values = data.map(d => +d[key]).filter(v => !isNaN(v))
2543
+ if (values.length === 0) return
2544
+
2545
+ const xScale = d3.scaleLinear().domain([0, values.length - 1]).range([pad, w - pad])
2546
+ const yScale = d3.scaleLinear().domain(d3.extent(values)).range([h - pad, pad])
2547
+
2548
+ if (type === "bar") {
2549
+ const barW = Math.max(1, (w - pad * 2) / values.length - 1)
2550
+ svg.selectAll(null)
2551
+ .data(values)
2552
+ .enter().append("rect")
2553
+ .attr("x", (_, i) => xScale(i) - barW / 2)
2554
+ .attr("y", v => yScale(v))
2555
+ .attr("width", barW)
2556
+ .attr("height", v => Math.max(0, h - pad - yScale(v)))
2557
+ .attr("fill", color || "#6366f1")
2558
+ .attr("rx", 1)
2559
+ } else if (type === "area") {
2560
+ const areaGen = d3.area()
2561
+ .x((_, i) => xScale(i))
2562
+ .y0(h - pad)
2563
+ .y1((_, i) => yScale(values[i]))
2564
+ .curve(d3.curveMonotoneX)
2565
+
2566
+ svg.append("path")
2567
+ .datum(values)
2568
+ .attr("fill", fill || color || "#6366f1")
2569
+ .attr("fill-opacity", 0.2)
2570
+ .attr("d", areaGen)
2571
+
2572
+ const lineGen = d3.line()
2573
+ .x((_, i) => xScale(i))
2574
+ .y((_, i) => yScale(values[i]))
2575
+ .curve(d3.curveMonotoneX)
2576
+
2577
+ svg.append("path")
2578
+ .datum(values)
2579
+ .attr("fill", "none")
2580
+ .attr("stroke", color || "#6366f1")
2581
+ .attr("stroke-width", stroke_width || 1.5)
2582
+ .attr("d", lineGen)
2583
+ } else {
2584
+ // line (default)
2585
+ const lineGen = d3.line()
2586
+ .x((_, i) => xScale(i))
2587
+ .y((_, i) => yScale(values[i]))
2588
+ .curve(d3.curveMonotoneX)
2589
+
2590
+ svg.append("path")
2591
+ .datum(values)
2592
+ .attr("fill", "none")
2593
+ .attr("stroke", color || "#6366f1")
2594
+ .attr("stroke-width", stroke_width || 1.5)
2595
+ .attr("stroke-linecap", "round")
2596
+ .attr("d", lineGen)
2597
+
2598
+ // Last dot indicator
2599
+ if (dot !== false) {
2600
+ const lastIdx = values.length - 1
2601
+ svg.append("circle")
2602
+ .attr("cx", xScale(lastIdx))
2603
+ .attr("cy", yScale(values[lastIdx]))
2604
+ .attr("r", 2.5)
2605
+ .attr("fill", color || "#6366f1")
2606
+ }
2607
+ }
2608
+ }
2609
+ }
2610
+
2611
+ customElements.define("trackplot-sparkline", SparklineElement)