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