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.
- checksums.yaml +4 -4
- data/app/assets/javascripts/trackplot/index.d.ts +292 -0
- data/app/assets/javascripts/trackplot/index.js +1126 -60
- data/app/helpers/trackplot/chart_helper.rb +5 -0
- data/lib/generators/trackplot/install_generator.rb +96 -10
- data/lib/generators/trackplot/templates/trackplot_controller.js +52 -0
- data/lib/trackplot/chart_builder.rb +44 -4
- data/lib/trackplot/color_scale.rb +158 -0
- data/lib/trackplot/component.rb +21 -0
- data/lib/trackplot/components/area.rb +2 -1
- data/lib/trackplot/components/axis.rb +2 -1
- data/lib/trackplot/components/bar.rb +2 -1
- data/lib/trackplot/components/brush.rb +13 -0
- data/lib/trackplot/components/data_label.rb +30 -0
- data/lib/trackplot/components/drilldown.rb +12 -0
- data/lib/trackplot/components/heatmap.rb +16 -0
- data/lib/trackplot/components/line.rb +2 -1
- data/lib/trackplot/components/scatter.rb +2 -1
- data/lib/trackplot/components/treemap.rb +14 -0
- data/lib/trackplot/phlex_component.rb +45 -0
- data/lib/trackplot/sparkline_builder.rb +44 -0
- data/lib/trackplot/version.rb +1 -1
- data/lib/trackplot.rb +11 -0
- metadata +12 -1
|
@@ -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
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
yG.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
396
|
-
|
|
397
|
-
.range([0, bandwidth])
|
|
398
|
-
.padding(0.05)
|
|
617
|
+
const barWidth = bandwidth * 0.8
|
|
618
|
+
const barOffset = (bandwidth - barWidth) / 2
|
|
399
619
|
|
|
400
|
-
|
|
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,
|
|
623
|
+
const radius = idx === stackedData.length - 1 ? Math.min(series.radius ?? 4, barWidth / 2) : 0
|
|
403
624
|
|
|
404
|
-
|
|
405
|
-
.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(
|
|
410
|
-
return base +
|
|
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",
|
|
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(
|
|
418
|
-
.attr("height", animate ? 0 : d => Math.max(0, yScale(0) - yScale(
|
|
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(
|
|
423
|
-
.attr("height", d => Math.max(0, yScale(0) - yScale(
|
|
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 =
|
|
650
|
+
const i = layer.indexOf(d)
|
|
429
651
|
dispatchClick(chartElement, {
|
|
430
652
|
chartType: "bar",
|
|
431
653
|
dataKey: series.data_key,
|
|
432
|
-
datum:
|
|
654
|
+
datum: data[i],
|
|
433
655
|
index: i,
|
|
434
|
-
value:
|
|
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
|
-
|
|
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.
|
|
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
|
|
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",
|
|
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(
|
|
1846
|
+
let scaleType = hasBars ? "band" : detectScaleType(data, this.xKey)
|
|
1239
1847
|
if (hasScatter && !hasBars && scaleType === "band") scaleType = "band"
|
|
1240
|
-
const xScale = createXScale(
|
|
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
|
-
|
|
1245
|
-
|
|
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
|
-
|
|
1248
|
-
|
|
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 =>
|
|
1263
|
-
|
|
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)
|
|
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,
|
|
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 =>
|
|
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 =>
|
|
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,
|
|
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)
|