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