datly 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1566 @@
1
+ // d3-selection
2
+ import { select, selectAll } from "d3-selection";
3
+ import {
4
+ scaleLinear,
5
+ scaleBand,
6
+ scalePoint,
7
+ scaleOrdinal,
8
+ scaleSequential,
9
+ } from "d3-scale";
10
+ import {
11
+ extent,
12
+ max,
13
+ min,
14
+ sum,
15
+ range,
16
+ mean,
17
+ deviation,
18
+ histogram,
19
+ quantile,
20
+ } from "d3-array";
21
+ import { axisBottom, axisLeft } from "d3-axis";
22
+ import {
23
+ schemeCategory10,
24
+ interpolateRdYlBu,
25
+ interpolateViridis,
26
+ } from "d3-scale-chromatic";
27
+ import { line, area, curveBasis, pie, arc } from "d3-shape";
28
+
29
+ const d3 = {
30
+ select,
31
+ selectAll,
32
+ scaleLinear,
33
+ scaleBand,
34
+ scalePoint,
35
+ scaleOrdinal,
36
+ scaleSequential,
37
+ extent,
38
+ max,
39
+ min,
40
+ sum,
41
+ deviation,
42
+ mean,
43
+ quantile,
44
+ histogram,
45
+ range,
46
+ axisBottom,
47
+ axisLeft,
48
+ line,
49
+ area,
50
+ curveBasis,
51
+ pie,
52
+ arc,
53
+ schemeCategory10,
54
+ interpolateViridis,
55
+ interpolateRdYlBu,
56
+ };
57
+
58
+ class DataViz {
59
+ constructor(containerId = "dataviz-container") {
60
+ this.containerId = containerId;
61
+ this.defaultWidth = 800;
62
+ this.defaultHeight = 600;
63
+ this.defaultMargin = { top: 40, right: 40, bottom: 60, left: 60 };
64
+ this.colors = d3.schemeCategory10;
65
+ }
66
+
67
+ /**
68
+ * Cria ou atualiza um container para visualização
69
+ * @param {string} containerId - ID do elemento container
70
+ * @param {number} width - Largura do SVG
71
+ * @param {number} height - Altura do SVG
72
+ */
73
+ createContainer(
74
+ containerId,
75
+ width = this.defaultWidth,
76
+ height = this.defaultHeight
77
+ ) {
78
+ const targetId = containerId || this.containerId;
79
+ let container = d3.select(`#${targetId}`);
80
+
81
+ if (container.empty()) {
82
+ container = d3
83
+ .select("body")
84
+ .append("div")
85
+ .attr("id", targetId)
86
+ .style("margin", "20px");
87
+ }
88
+
89
+ container.selectAll("*").remove();
90
+
91
+ const svg = container
92
+ .append("svg")
93
+ .attr("width", width)
94
+ .attr("height", height)
95
+ .style("background", "#fff")
96
+ .style("border", "1px solid #ddd")
97
+ .style("border-radius", "8px");
98
+
99
+ return { container, svg };
100
+ }
101
+
102
+ // ============================================
103
+ // HISTOGRAMA
104
+ // ============================================
105
+ histogram(data, options = {}) {
106
+ const {
107
+ title = "Histogram",
108
+ xlabel = "Value",
109
+ ylabel = "Frequency",
110
+ bins = 30,
111
+ color = "#4299e1",
112
+ width = this.defaultWidth,
113
+ height = this.defaultHeight,
114
+ containerId = null,
115
+ } = options;
116
+
117
+ const { svg } = this.createContainer(containerId, width, height);
118
+ const margin = this.defaultMargin;
119
+ const innerWidth = width - margin.left - margin.right;
120
+ const innerHeight = height - margin.top - margin.bottom;
121
+
122
+ const g = svg
123
+ .append("g")
124
+ .attr("transform", `translate(${margin.left},${margin.top})`);
125
+
126
+ const x = d3.scaleLinear().domain(d3.extent(data)).range([0, innerWidth]);
127
+
128
+ const histogram = d3
129
+ .histogram()
130
+ .domain(x.domain())
131
+ .thresholds(x.ticks(bins));
132
+
133
+ const histData = histogram(data);
134
+
135
+ const y = d3
136
+ .scaleLinear()
137
+ .domain([0, d3.max(histData, (d) => d.length)])
138
+ .range([innerHeight, 0]);
139
+
140
+ g.selectAll("rect")
141
+ .data(histData)
142
+ .join("rect")
143
+ .attr("x", (d) => x(d.x0) + 1)
144
+ .attr("width", (d) => Math.max(0, x(d.x1) - x(d.x0) - 2))
145
+ .attr("y", (d) => y(d.length))
146
+ .attr("height", (d) => innerHeight - y(d.length))
147
+ .attr("fill", color)
148
+ .attr("opacity", 0.8)
149
+ .on("mouseover", function () {
150
+ d3.select(this).attr("opacity", 1);
151
+ })
152
+ .on("mouseout", function () {
153
+ d3.select(this).attr("opacity", 0.8);
154
+ });
155
+
156
+ g.append("g")
157
+ .attr("transform", `translate(0,${innerHeight})`)
158
+ .call(d3.axisBottom(x))
159
+ .append("text")
160
+ .attr("x", innerWidth / 2)
161
+ .attr("y", 40)
162
+ .attr("fill", "#000")
163
+ .attr("text-anchor", "middle")
164
+ .text(xlabel);
165
+
166
+ g.append("g")
167
+ .call(d3.axisLeft(y))
168
+ .append("text")
169
+ .attr("transform", "rotate(-90)")
170
+ .attr("x", -innerHeight / 2)
171
+ .attr("y", -40)
172
+ .attr("fill", "#000")
173
+ .attr("text-anchor", "middle")
174
+ .text(ylabel);
175
+
176
+ svg
177
+ .append("text")
178
+ .attr("x", width / 2)
179
+ .attr("y", 20)
180
+ .attr("text-anchor", "middle")
181
+ .style("font-size", "16px")
182
+ .style("font-weight", "bold")
183
+ .text(title);
184
+
185
+ return this;
186
+ }
187
+
188
+ // ============================================
189
+ // BOX PLOT
190
+ // ============================================
191
+ boxplot(data, options = {}) {
192
+ const {
193
+ title = "Box Plot",
194
+ xlabel = "Category",
195
+ ylabel = "Value",
196
+ labels = null,
197
+ color = "#4299e1",
198
+ width = this.defaultWidth,
199
+ height = this.defaultHeight,
200
+ containerId = null,
201
+ } = options;
202
+
203
+ const { svg } = this.createContainer(containerId, width, height);
204
+ const margin = this.defaultMargin;
205
+ const innerWidth = width - margin.left - margin.right;
206
+ const innerHeight = height - margin.top - margin.bottom;
207
+
208
+ const g = svg
209
+ .append("g")
210
+ .attr("transform", `translate(${margin.left},${margin.top})`);
211
+
212
+ const datasets = Array.isArray(data[0]) ? data : [data];
213
+ const categoryLabels = labels || datasets.map((_, i) => `Group ${i + 1}`);
214
+
215
+ const boxData = datasets.map((dataset, i) => {
216
+ const sorted = [...dataset].sort((a, b) => a - b);
217
+ const q1 = d3.quantile(sorted, 0.25);
218
+ const median = d3.quantile(sorted, 0.5);
219
+ const q3 = d3.quantile(sorted, 0.75);
220
+ const iqr = q3 - q1;
221
+ const min = Math.max(d3.min(sorted), q1 - 1.5 * iqr);
222
+ const max = Math.min(d3.max(sorted), q3 + 1.5 * iqr);
223
+ const outliers = sorted.filter((d) => d < min || d > max);
224
+
225
+ return { label: categoryLabels[i], q1, median, q3, min, max, outliers };
226
+ });
227
+
228
+ const x = d3
229
+ .scaleBand()
230
+ .domain(categoryLabels)
231
+ .range([0, innerWidth])
232
+ .padding(0.3);
233
+
234
+ const y = d3
235
+ .scaleLinear()
236
+ .domain([d3.min(boxData, (d) => d.min), d3.max(boxData, (d) => d.max)])
237
+ .nice()
238
+ .range([innerHeight, 0]);
239
+
240
+ boxData.forEach((d, i) => {
241
+ const center = x(d.label) + x.bandwidth() / 2;
242
+ const boxWidth = x.bandwidth();
243
+
244
+ g.append("line")
245
+ .attr("x1", center)
246
+ .attr("x2", center)
247
+ .attr("y1", y(d.min))
248
+ .attr("y2", y(d.max))
249
+ .attr("stroke", "#000")
250
+ .attr("stroke-width", 1);
251
+
252
+ g.append("rect")
253
+ .attr("x", x(d.label))
254
+ .attr("y", y(d.q3))
255
+ .attr("width", boxWidth)
256
+ .attr("height", y(d.q1) - y(d.q3))
257
+ .attr("fill", color)
258
+ .attr("stroke", "#000")
259
+ .attr("opacity", 0.7);
260
+
261
+ g.append("line")
262
+ .attr("x1", x(d.label))
263
+ .attr("x2", x(d.label) + boxWidth)
264
+ .attr("y1", y(d.median))
265
+ .attr("y2", y(d.median))
266
+ .attr("stroke", "#000")
267
+ .attr("stroke-width", 2);
268
+
269
+ [d.min, d.max].forEach((val) => {
270
+ g.append("line")
271
+ .attr("x1", center - boxWidth / 4)
272
+ .attr("x2", center + boxWidth / 4)
273
+ .attr("y1", y(val))
274
+ .attr("y2", y(val))
275
+ .attr("stroke", "#000")
276
+ .attr("stroke-width", 1);
277
+ });
278
+
279
+ d.outliers.forEach((outlier) => {
280
+ g.append("circle")
281
+ .attr("cx", center)
282
+ .attr("cy", y(outlier))
283
+ .attr("r", 3)
284
+ .attr("fill", "red")
285
+ .attr("opacity", 0.6);
286
+ });
287
+ });
288
+
289
+ g.append("g")
290
+ .attr("transform", `translate(0,${innerHeight})`)
291
+ .call(d3.axisBottom(x))
292
+ .append("text")
293
+ .attr("x", innerWidth / 2)
294
+ .attr("y", 40)
295
+ .attr("fill", "#000")
296
+ .attr("text-anchor", "middle")
297
+ .text(xlabel);
298
+
299
+ g.append("g")
300
+ .call(d3.axisLeft(y))
301
+ .append("text")
302
+ .attr("transform", "rotate(-90)")
303
+ .attr("x", -innerHeight / 2)
304
+ .attr("y", -40)
305
+ .attr("fill", "#000")
306
+ .attr("text-anchor", "middle")
307
+ .text(ylabel);
308
+
309
+ svg
310
+ .append("text")
311
+ .attr("x", width / 2)
312
+ .attr("y", 20)
313
+ .attr("text-anchor", "middle")
314
+ .style("font-size", "16px")
315
+ .style("font-weight", "bold")
316
+ .text(title);
317
+
318
+ return this;
319
+ }
320
+
321
+ // ============================================
322
+ // SCATTER PLOT
323
+ // ============================================
324
+ scatter(xData, yData, options = {}) {
325
+ const {
326
+ title = "Scatter Plot",
327
+ xlabel = "X",
328
+ ylabel = "Y",
329
+ color = "#4299e1",
330
+ size = 5,
331
+ labels = null,
332
+ width = this.defaultWidth,
333
+ height = this.defaultHeight,
334
+ containerId = null,
335
+ } = options;
336
+
337
+ const { svg } = this.createContainer(containerId, width, height);
338
+ const margin = this.defaultMargin;
339
+ const innerWidth = width - margin.left - margin.right;
340
+ const innerHeight = height - margin.top - margin.bottom;
341
+
342
+ const g = svg
343
+ .append("g")
344
+ .attr("transform", `translate(${margin.left},${margin.top})`);
345
+
346
+ const data = xData.map((x, i) => ({
347
+ x,
348
+ y: yData[i],
349
+ label: labels ? labels[i] : null,
350
+ }));
351
+
352
+ const x = d3
353
+ .scaleLinear()
354
+ .domain(d3.extent(xData))
355
+ .nice()
356
+ .range([0, innerWidth]);
357
+
358
+ const y = d3
359
+ .scaleLinear()
360
+ .domain(d3.extent(yData))
361
+ .nice()
362
+ .range([innerHeight, 0]);
363
+
364
+ const tooltip = d3
365
+ .select("body")
366
+ .append("div")
367
+ .style("position", "absolute")
368
+ .style("background", "rgba(0,0,0,0.8)")
369
+ .style("color", "#fff")
370
+ .style("padding", "8px")
371
+ .style("border-radius", "4px")
372
+ .style("font-size", "12px")
373
+ .style("pointer-events", "none")
374
+ .style("opacity", 0);
375
+
376
+ g.selectAll("circle")
377
+ .data(data)
378
+ .join("circle")
379
+ .attr("cx", (d) => x(d.x))
380
+ .attr("cy", (d) => y(d.y))
381
+ .attr("r", size)
382
+ .attr("fill", color)
383
+ .attr("opacity", 0.7)
384
+ .on("mouseover", function (event, d) {
385
+ d3.select(this)
386
+ .attr("r", size * 1.5)
387
+ .attr("opacity", 1);
388
+ tooltip
389
+ .style("opacity", 1)
390
+ .html(
391
+ `X: ${d.x.toFixed(2)}<br>Y: ${d.y.toFixed(2)}${
392
+ d.label ? "<br>" + d.label : ""
393
+ }`
394
+ )
395
+ .style("left", event.pageX + 10 + "px")
396
+ .style("top", event.pageY - 10 + "px");
397
+ })
398
+ .on("mouseout", function () {
399
+ d3.select(this).attr("r", size).attr("opacity", 0.7);
400
+ tooltip.style("opacity", 0);
401
+ });
402
+
403
+ g.append("g")
404
+ .attr("transform", `translate(0,${innerHeight})`)
405
+ .call(d3.axisBottom(x))
406
+ .append("text")
407
+ .attr("x", innerWidth / 2)
408
+ .attr("y", 40)
409
+ .attr("fill", "#000")
410
+ .attr("text-anchor", "middle")
411
+ .text(xlabel);
412
+
413
+ g.append("g")
414
+ .call(d3.axisLeft(y))
415
+ .append("text")
416
+ .attr("transform", "rotate(-90)")
417
+ .attr("x", -innerHeight / 2)
418
+ .attr("y", -40)
419
+ .attr("fill", "#000")
420
+ .attr("text-anchor", "middle")
421
+ .text(ylabel);
422
+
423
+ svg
424
+ .append("text")
425
+ .attr("x", width / 2)
426
+ .attr("y", 20)
427
+ .attr("text-anchor", "middle")
428
+ .style("font-size", "16px")
429
+ .style("font-weight", "bold")
430
+ .text(title);
431
+
432
+ return this;
433
+ }
434
+
435
+ // ============================================
436
+ // LINE CHART
437
+ // ============================================
438
+ line(xData, yData, options = {}) {
439
+ const {
440
+ title = "Line Chart",
441
+ xlabel = "X",
442
+ ylabel = "Y",
443
+ color = "#4299e1",
444
+ lineWidth = 2,
445
+ showPoints = true,
446
+ width = this.defaultWidth,
447
+ height = this.defaultHeight,
448
+ containerId = null,
449
+ } = options;
450
+
451
+ const { svg } = this.createContainer(containerId, width, height);
452
+ const margin = this.defaultMargin;
453
+ const innerWidth = width - margin.left - margin.right;
454
+ const innerHeight = height - margin.top - margin.bottom;
455
+
456
+ const g = svg
457
+ .append("g")
458
+ .attr("transform", `translate(${margin.left},${margin.top})`);
459
+
460
+ const data = xData.map((x, i) => ({ x, y: yData[i] }));
461
+
462
+ const x = d3.scaleLinear().domain(d3.extent(xData)).range([0, innerWidth]);
463
+
464
+ const y = d3
465
+ .scaleLinear()
466
+ .domain(d3.extent(yData))
467
+ .nice()
468
+ .range([innerHeight, 0]);
469
+
470
+ const line = d3
471
+ .line()
472
+ .x((d) => x(d.x))
473
+ .y((d) => y(d.y));
474
+
475
+ g.append("path")
476
+ .datum(data)
477
+ .attr("fill", "none")
478
+ .attr("stroke", color)
479
+ .attr("stroke-width", lineWidth)
480
+ .attr("d", line);
481
+
482
+ if (showPoints) {
483
+ g.selectAll("circle")
484
+ .data(data)
485
+ .join("circle")
486
+ .attr("cx", (d) => x(d.x))
487
+ .attr("cy", (d) => y(d.y))
488
+ .attr("r", 4)
489
+ .attr("fill", color);
490
+ }
491
+
492
+ g.append("g")
493
+ .attr("transform", `translate(0,${innerHeight})`)
494
+ .call(d3.axisBottom(x))
495
+ .append("text")
496
+ .attr("x", innerWidth / 2)
497
+ .attr("y", 40)
498
+ .attr("fill", "#000")
499
+ .attr("text-anchor", "middle")
500
+ .text(xlabel);
501
+
502
+ g.append("g")
503
+ .call(d3.axisLeft(y))
504
+ .append("text")
505
+ .attr("transform", "rotate(-90)")
506
+ .attr("x", -innerHeight / 2)
507
+ .attr("y", -40)
508
+ .attr("fill", "#000")
509
+ .attr("text-anchor", "middle")
510
+ .text(ylabel);
511
+
512
+ svg
513
+ .append("text")
514
+ .attr("x", width / 2)
515
+ .attr("y", 20)
516
+ .attr("text-anchor", "middle")
517
+ .style("font-size", "16px")
518
+ .style("font-weight", "bold")
519
+ .text(title);
520
+
521
+ return this;
522
+ }
523
+
524
+ // ============================================
525
+ // BAR CHART
526
+ // ============================================
527
+ bar(categories, values, options = {}) {
528
+ const {
529
+ title = "Bar Chart",
530
+ xlabel = "Category",
531
+ ylabel = "Value",
532
+ color = "#4299e1",
533
+ horizontal = false,
534
+ width = this.defaultWidth,
535
+ height = this.defaultHeight,
536
+ containerId = null,
537
+ } = options;
538
+
539
+ const { svg } = this.createContainer(containerId, width, height);
540
+ const margin = this.defaultMargin;
541
+ const innerWidth = width - margin.left - margin.right;
542
+ const innerHeight = height - margin.top - margin.bottom;
543
+
544
+ const g = svg
545
+ .append("g")
546
+ .attr("transform", `translate(${margin.left},${margin.top})`);
547
+
548
+ const data = categories.map((cat, i) => ({
549
+ category: cat,
550
+ value: values[i],
551
+ }));
552
+
553
+ if (!horizontal) {
554
+ const x = d3
555
+ .scaleBand()
556
+ .domain(categories)
557
+ .range([0, innerWidth])
558
+ .padding(0.2);
559
+
560
+ const y = d3
561
+ .scaleLinear()
562
+ .domain([0, d3.max(values)])
563
+ .nice()
564
+ .range([innerHeight, 0]);
565
+
566
+ g.selectAll("rect")
567
+ .data(data)
568
+ .join("rect")
569
+ .attr("x", (d) => x(d.category))
570
+ .attr("y", (d) => y(d.value))
571
+ .attr("width", x.bandwidth())
572
+ .attr("height", (d) => innerHeight - y(d.value))
573
+ .attr("fill", color)
574
+ .attr("opacity", 0.8)
575
+ .on("mouseover", function () {
576
+ d3.select(this).attr("opacity", 1);
577
+ })
578
+ .on("mouseout", function () {
579
+ d3.select(this).attr("opacity", 0.8);
580
+ });
581
+
582
+ g.append("g")
583
+ .attr("transform", `translate(0,${innerHeight})`)
584
+ .call(d3.axisBottom(x))
585
+ .selectAll("text")
586
+ .attr("transform", "rotate(-45)")
587
+ .style("text-anchor", "end");
588
+
589
+ g.append("g").call(d3.axisLeft(y));
590
+ } else {
591
+ const x = d3
592
+ .scaleLinear()
593
+ .domain([0, d3.max(values)])
594
+ .nice()
595
+ .range([0, innerWidth]);
596
+
597
+ const y = d3
598
+ .scaleBand()
599
+ .domain(categories)
600
+ .range([0, innerHeight])
601
+ .padding(0.2);
602
+
603
+ g.selectAll("rect")
604
+ .data(data)
605
+ .join("rect")
606
+ .attr("x", 0)
607
+ .attr("y", (d) => y(d.category))
608
+ .attr("width", (d) => x(d.value))
609
+ .attr("height", y.bandwidth())
610
+ .attr("fill", color)
611
+ .attr("opacity", 0.8)
612
+ .on("mouseover", function () {
613
+ d3.select(this).attr("opacity", 1);
614
+ })
615
+ .on("mouseout", function () {
616
+ d3.select(this).attr("opacity", 0.8);
617
+ });
618
+
619
+ g.append("g")
620
+ .attr("transform", `translate(0,${innerHeight})`)
621
+ .call(d3.axisBottom(x));
622
+
623
+ g.append("g").call(d3.axisLeft(y));
624
+ }
625
+
626
+ svg
627
+ .append("text")
628
+ .attr("x", width / 2)
629
+ .attr("y", height - 10)
630
+ .attr("text-anchor", "middle")
631
+ .text(xlabel);
632
+
633
+ svg
634
+ .append("text")
635
+ .attr("transform", "rotate(-90)")
636
+ .attr("x", -height / 2)
637
+ .attr("y", 15)
638
+ .attr("text-anchor", "middle")
639
+ .text(ylabel);
640
+
641
+ svg
642
+ .append("text")
643
+ .attr("x", width / 2)
644
+ .attr("y", 20)
645
+ .attr("text-anchor", "middle")
646
+ .style("font-size", "16px")
647
+ .style("font-weight", "bold")
648
+ .text(title);
649
+
650
+ return this;
651
+ }
652
+
653
+ // ============================================
654
+ // PIE CHART
655
+ // ============================================
656
+ pie(labels, values, options = {}) {
657
+ const {
658
+ title = "Pie Chart",
659
+ width = this.defaultWidth,
660
+ height = this.defaultHeight,
661
+ showLabels = true,
662
+ showPercentage = true,
663
+ containerId = null,
664
+ } = options;
665
+
666
+ const { svg } = this.createContainer(containerId, width, height);
667
+ const radius = Math.min(width, height) / 2 - 40;
668
+
669
+ const g = svg
670
+ .append("g")
671
+ .attr("transform", `translate(${width / 2},${height / 2})`);
672
+
673
+ const data = labels.map((label, i) => ({ label, value: values[i] }));
674
+ const total = d3.sum(values);
675
+
676
+ const color = d3.scaleOrdinal().domain(labels).range(this.colors);
677
+
678
+ const pie = d3.pie().value((d) => d.value);
679
+
680
+ const arc = d3.arc().innerRadius(0).outerRadius(radius);
681
+
682
+ const labelArc = d3
683
+ .arc()
684
+ .innerRadius(radius * 0.7)
685
+ .outerRadius(radius * 0.7);
686
+
687
+ const arcs = g
688
+ .selectAll("arc")
689
+ .data(pie(data))
690
+ .join("g")
691
+ .attr("class", "arc");
692
+
693
+ arcs
694
+ .append("path")
695
+ .attr("d", arc)
696
+ .attr("fill", (d) => color(d.data.label))
697
+ .attr("stroke", "#fff")
698
+ .attr("stroke-width", 2)
699
+ .attr("opacity", 0.8)
700
+ .on("mouseover", function () {
701
+ d3.select(this).attr("opacity", 1);
702
+ })
703
+ .on("mouseout", function () {
704
+ d3.select(this).attr("opacity", 0.8);
705
+ });
706
+
707
+ if (showLabels) {
708
+ arcs
709
+ .append("text")
710
+ .attr("transform", (d) => `translate(${labelArc.centroid(d)})`)
711
+ .attr("text-anchor", "middle")
712
+ .style("font-size", "12px")
713
+ .style("font-weight", "bold")
714
+ .style("fill", "#fff")
715
+ .text((d) => {
716
+ if (showPercentage) {
717
+ const percentage = ((d.data.value / total) * 100).toFixed(1);
718
+ return `${d.data.label}\n${percentage}%`;
719
+ }
720
+ return d.data.label;
721
+ });
722
+ }
723
+
724
+ svg
725
+ .append("text")
726
+ .attr("x", width / 2)
727
+ .attr("y", 20)
728
+ .attr("text-anchor", "middle")
729
+ .style("font-size", "16px")
730
+ .style("font-weight", "bold")
731
+ .text(title);
732
+
733
+ return this;
734
+ }
735
+
736
+ // ============================================
737
+ // HEATMAP
738
+ // ============================================
739
+ heatmap(matrix, options = {}) {
740
+ const {
741
+ title = "Heatmap",
742
+ labels = null,
743
+ colorScheme = "RdYlBu",
744
+ showValues = true,
745
+ width = this.defaultWidth,
746
+ height = this.defaultHeight,
747
+ containerId = null,
748
+ } = options;
749
+
750
+ const { svg } = this.createContainer(containerId, width, height);
751
+ const margin = { top: 80, right: 40, bottom: 80, left: 80 };
752
+ const innerWidth = width - margin.left - margin.right;
753
+ const innerHeight = height - margin.top - margin.bottom;
754
+
755
+ const g = svg
756
+ .append("g")
757
+ .attr("transform", `translate(${margin.left},${margin.top})`);
758
+
759
+ const n = matrix.length;
760
+ const rowLabels =
761
+ labels || Array.from({ length: n }, (_, i) => `Var${i + 1}`);
762
+ const colLabels =
763
+ labels || Array.from({ length: n }, (_, i) => `Var${i + 1}`);
764
+
765
+ const data = [];
766
+ for (let i = 0; i < n; i++) {
767
+ for (let j = 0; j < n; j++) {
768
+ data.push({
769
+ row: i,
770
+ col: j,
771
+ value: matrix[i][j],
772
+ rowLabel: rowLabels[i],
773
+ colLabel: colLabels[j],
774
+ });
775
+ }
776
+ }
777
+
778
+ const x = d3
779
+ .scaleBand()
780
+ .domain(colLabels)
781
+ .range([0, innerWidth])
782
+ .padding(0.05);
783
+
784
+ const y = d3
785
+ .scaleBand()
786
+ .domain(rowLabels)
787
+ .range([0, innerHeight])
788
+ .padding(0.05);
789
+
790
+ const colorScale = d3
791
+ .scaleSequential()
792
+ .domain([-1, 1])
793
+ .interpolator(d3[`interpolate${colorScheme}`]);
794
+
795
+ g.selectAll("rect")
796
+ .data(data)
797
+ .join("rect")
798
+ .attr("x", (d) => x(d.colLabel))
799
+ .attr("y", (d) => y(d.rowLabel))
800
+ .attr("width", x.bandwidth())
801
+ .attr("height", y.bandwidth())
802
+ .attr("fill", (d) => colorScale(d.value))
803
+ .attr("stroke", "#fff")
804
+ .attr("stroke-width", 1);
805
+
806
+ if (showValues) {
807
+ g.selectAll("text.value")
808
+ .data(data)
809
+ .join("text")
810
+ .attr("class", "value")
811
+ .attr("x", (d) => x(d.colLabel) + x.bandwidth() / 2)
812
+ .attr("y", (d) => y(d.rowLabel) + y.bandwidth() / 2)
813
+ .attr("text-anchor", "middle")
814
+ .attr("dominant-baseline", "middle")
815
+ .style("font-size", "10px")
816
+ .style("fill", (d) => (Math.abs(d.value) > 0.5 ? "#fff" : "#000"))
817
+ .text((d) => d.value.toFixed(2));
818
+ }
819
+
820
+ g.append("g")
821
+ .attr("transform", `translate(0,${innerHeight})`)
822
+ .call(d3.axisBottom(x))
823
+ .selectAll("text")
824
+ .attr("transform", "rotate(-45)")
825
+ .style("text-anchor", "end");
826
+
827
+ g.append("g").call(d3.axisLeft(y));
828
+
829
+ const legendWidth = 200;
830
+ const legendHeight = 20;
831
+ const legendG = svg
832
+ .append("g")
833
+ .attr(
834
+ "transform",
835
+ `translate(${width - margin.right - legendWidth},${margin.top - 40})`
836
+ );
837
+
838
+ const legendScale = d3
839
+ .scaleLinear()
840
+ .domain([-1, 1])
841
+ .range([0, legendWidth]);
842
+
843
+ const legendAxis = d3.axisBottom(legendScale).ticks(5);
844
+
845
+ legendG
846
+ .selectAll("rect")
847
+ .data(d3.range(-1, 1, 0.01))
848
+ .join("rect")
849
+ .attr("x", (d) => legendScale(d))
850
+ .attr("y", 0)
851
+ .attr("width", legendWidth / 200)
852
+ .attr("height", legendHeight)
853
+ .attr("fill", (d) => colorScale(d));
854
+
855
+ legendG
856
+ .append("g")
857
+ .attr("transform", `translate(0,${legendHeight})`)
858
+ .call(legendAxis);
859
+
860
+ svg
861
+ .append("text")
862
+ .attr("x", width / 2)
863
+ .attr("y", 20)
864
+ .attr("text-anchor", "middle")
865
+ .style("font-size", "16px")
866
+ .style("font-weight", "bold")
867
+ .text(title);
868
+
869
+ return this;
870
+ }
871
+
872
+ // ============================================
873
+ // VIOLIN PLOT
874
+ // ============================================
875
+ violin(data, options = {}) {
876
+ const {
877
+ title = "Violin Plot",
878
+ xlabel = "Category",
879
+ ylabel = "Value",
880
+ labels = null,
881
+ color = "#4299e1",
882
+ width = this.defaultWidth,
883
+ height = this.defaultHeight,
884
+ containerId = null,
885
+ } = options;
886
+
887
+ const { svg } = this.createContainer(containerId, width, height);
888
+ const margin = this.defaultMargin;
889
+ const innerWidth = width - margin.left - margin.right;
890
+ const innerHeight = height - margin.top - margin.bottom;
891
+
892
+ const g = svg
893
+ .append("g")
894
+ .attr("transform", `translate(${margin.left},${margin.top})`);
895
+
896
+ const datasets = Array.isArray(data[0]) ? data : [data];
897
+ const categoryLabels = labels || datasets.map((_, i) => `Group ${i + 1}`);
898
+
899
+ const x = d3
900
+ .scaleBand()
901
+ .domain(categoryLabels)
902
+ .range([0, innerWidth])
903
+ .padding(0.3);
904
+
905
+ const allValues = datasets.flat();
906
+ const y = d3
907
+ .scaleLinear()
908
+ .domain(d3.extent(allValues))
909
+ .nice()
910
+ .range([innerHeight, 0]);
911
+
912
+ const kde = (data, bandwidth = 0.5) => {
913
+ const thresholds = y.ticks(50);
914
+ return thresholds.map((t) => {
915
+ const density = d3.mean(data, (d) => {
916
+ return (
917
+ Math.exp(-0.5 * Math.pow((d - t) / bandwidth, 2)) /
918
+ (bandwidth * Math.sqrt(2 * Math.PI))
919
+ );
920
+ });
921
+ return [t, density];
922
+ });
923
+ };
924
+
925
+ datasets.forEach((dataset, i) => {
926
+ const density = kde(dataset);
927
+ const maxDensity = d3.max(density, (d) => d[1]);
928
+
929
+ const xScale = d3
930
+ .scaleLinear()
931
+ .domain([0, maxDensity])
932
+ .range([0, x.bandwidth() / 2]);
933
+
934
+ const area = d3
935
+ .area()
936
+ .x0((d) => x(categoryLabels[i]) + x.bandwidth() / 2 - xScale(d[1]))
937
+ .x1((d) => x(categoryLabels[i]) + x.bandwidth() / 2 + xScale(d[1]))
938
+ .y((d) => y(d[0]))
939
+ .curve(d3.curveBasis);
940
+
941
+ g.append("path")
942
+ .datum(density)
943
+ .attr("fill", color)
944
+ .attr("opacity", 0.6)
945
+ .attr("stroke", "#000")
946
+ .attr("stroke-width", 1)
947
+ .attr("d", area);
948
+ });
949
+
950
+ g.append("g")
951
+ .attr("transform", `translate(0,${innerHeight})`)
952
+ .call(d3.axisBottom(x))
953
+ .append("text")
954
+ .attr("x", innerWidth / 2)
955
+ .attr("y", 40)
956
+ .attr("fill", "#000")
957
+ .attr("text-anchor", "middle")
958
+ .text(xlabel);
959
+
960
+ g.append("g")
961
+ .call(d3.axisLeft(y))
962
+ .append("text")
963
+ .attr("transform", "rotate(-90)")
964
+ .attr("x", -innerHeight / 2)
965
+ .attr("y", -40)
966
+ .attr("fill", "#000")
967
+ .attr("text-anchor", "middle")
968
+ .text(ylabel);
969
+
970
+ svg
971
+ .append("text")
972
+ .attr("x", width / 2)
973
+ .attr("y", 20)
974
+ .attr("text-anchor", "middle")
975
+ .style("font-size", "16px")
976
+ .style("font-weight", "bold")
977
+ .text(title);
978
+
979
+ return this;
980
+ }
981
+
982
+ // ============================================
983
+ // QQ PLOT
984
+ // ============================================
985
+ qqplot(data, options = {}) {
986
+ const {
987
+ title = "Q-Q Plot",
988
+ xlabel = "Theoretical Quantiles",
989
+ ylabel = "Sample Quantiles",
990
+ color = "#4299e1",
991
+ width = this.defaultWidth,
992
+ height = this.defaultHeight,
993
+ containerId = null,
994
+ } = options;
995
+
996
+ const { svg } = this.createContainer(containerId, width, height);
997
+ const margin = this.defaultMargin;
998
+ const innerWidth = width - margin.left - margin.right;
999
+ const innerHeight = height - margin.top - margin.bottom;
1000
+
1001
+ const g = svg
1002
+ .append("g")
1003
+ .attr("transform", `translate(${margin.left},${margin.top})`);
1004
+
1005
+ const sorted = [...data].sort((a, b) => a - b);
1006
+ const n = sorted.length;
1007
+
1008
+ const theoretical = sorted.map((_, i) => {
1009
+ const p = (i + 0.5) / n;
1010
+ return this.invNormalCDF(p);
1011
+ });
1012
+
1013
+ const qqData = theoretical.map((t, i) => ({ x: t, y: sorted[i] }));
1014
+
1015
+ const x = d3
1016
+ .scaleLinear()
1017
+ .domain(d3.extent(theoretical))
1018
+ .nice()
1019
+ .range([0, innerWidth]);
1020
+
1021
+ const y = d3
1022
+ .scaleLinear()
1023
+ .domain(d3.extent(sorted))
1024
+ .nice()
1025
+ .range([innerHeight, 0]);
1026
+
1027
+ const minVal = Math.max(x.domain()[0], y.domain()[0]);
1028
+ const maxVal = Math.min(x.domain()[1], y.domain()[1]);
1029
+
1030
+ g.append("line")
1031
+ .attr("x1", x(minVal))
1032
+ .attr("y1", y(minVal))
1033
+ .attr("x2", x(maxVal))
1034
+ .attr("y2", y(maxVal))
1035
+ .attr("stroke", "red")
1036
+ .attr("stroke-width", 2)
1037
+ .attr("stroke-dasharray", "5,5");
1038
+
1039
+ g.selectAll("circle")
1040
+ .data(qqData)
1041
+ .join("circle")
1042
+ .attr("cx", (d) => x(d.x))
1043
+ .attr("cy", (d) => y(d.y))
1044
+ .attr("r", 4)
1045
+ .attr("fill", color)
1046
+ .attr("opacity", 0.7);
1047
+
1048
+ g.append("g")
1049
+ .attr("transform", `translate(0,${innerHeight})`)
1050
+ .call(d3.axisBottom(x))
1051
+ .append("text")
1052
+ .attr("x", innerWidth / 2)
1053
+ .attr("y", 40)
1054
+ .attr("fill", "#000")
1055
+ .attr("text-anchor", "middle")
1056
+ .text(xlabel);
1057
+
1058
+ g.append("g")
1059
+ .call(d3.axisLeft(y))
1060
+ .append("text")
1061
+ .attr("transform", "rotate(-90)")
1062
+ .attr("x", -innerHeight / 2)
1063
+ .attr("y", -40)
1064
+ .attr("fill", "#000")
1065
+ .attr("text-anchor", "middle")
1066
+ .text(ylabel);
1067
+
1068
+ svg
1069
+ .append("text")
1070
+ .attr("x", width / 2)
1071
+ .attr("y", 20)
1072
+ .attr("text-anchor", "middle")
1073
+ .style("font-size", "16px")
1074
+ .style("font-weight", "bold")
1075
+ .text(title);
1076
+
1077
+ return this;
1078
+ }
1079
+
1080
+ invNormalCDF(p) {
1081
+ const a1 = -39.6968302866538;
1082
+ const a2 = 220.946098424521;
1083
+ const a3 = -275.928510446969;
1084
+ const a4 = 138.357751867269;
1085
+ const a5 = -30.6647980661472;
1086
+ const a6 = 2.50662827745924;
1087
+
1088
+ const b1 = -54.4760987982241;
1089
+ const b2 = 161.585836858041;
1090
+ const b3 = -155.698979859887;
1091
+ const b4 = 66.8013118877197;
1092
+ const b5 = -13.2806815528857;
1093
+
1094
+ const c1 = -0.00778489400243029;
1095
+ const c2 = -0.322396458041136;
1096
+ const c3 = -2.40075827716184;
1097
+ const c4 = -2.54973253934373;
1098
+ const c5 = 4.37466414146497;
1099
+ const c6 = 2.93816398269878;
1100
+
1101
+ const d1 = 0.00778469570904146;
1102
+ const d2 = 0.32246712907004;
1103
+ const d3 = 2.445134137143;
1104
+ const d4 = 3.75440866190742;
1105
+
1106
+ const pLow = 0.02425;
1107
+ const pHigh = 1 - pLow;
1108
+
1109
+ let q, r, x;
1110
+
1111
+ if (p < pLow) {
1112
+ q = Math.sqrt(-2 * Math.log(p));
1113
+ x =
1114
+ (((((c1 * q + c2) * q + c3) * q + c4) * q + c5) * q + c6) /
1115
+ ((((d1 * q + d2) * q + d3) * q + d4) * q + 1);
1116
+ } else if (p <= pHigh) {
1117
+ q = p - 0.5;
1118
+ r = q * q;
1119
+ x =
1120
+ ((((((a1 * r + a2) * r + a3) * r + a4) * r + a5) * r + a6) * q) /
1121
+ (((((b1 * r + b2) * r + b3) * r + b4) * r + b5) * r + 1);
1122
+ } else {
1123
+ q = Math.sqrt(-2 * Math.log(1 - p));
1124
+ x =
1125
+ -(((((c1 * q + c2) * q + c3) * q + c4) * q + c5) * q + c6) /
1126
+ ((((d1 * q + d2) * q + d3) * q + d4) * q + 1);
1127
+ }
1128
+
1129
+ return x;
1130
+ }
1131
+
1132
+ // ============================================
1133
+ // DENSITY PLOT
1134
+ // ============================================
1135
+ density(data, options = {}) {
1136
+ const {
1137
+ title = "Density Plot",
1138
+ xlabel = "Value",
1139
+ ylabel = "Density",
1140
+ color = "#4299e1",
1141
+ bandwidth = null,
1142
+ width = this.defaultWidth,
1143
+ height = this.defaultHeight,
1144
+ containerId = null,
1145
+ } = options;
1146
+
1147
+ const { svg } = this.createContainer(containerId, width, height);
1148
+ const margin = this.defaultMargin;
1149
+ const innerWidth = width - margin.left - margin.right;
1150
+ const innerHeight = height - margin.top - margin.bottom;
1151
+
1152
+ const g = svg
1153
+ .append("g")
1154
+ .attr("transform", `translate(${margin.left},${margin.top})`);
1155
+
1156
+ const bw =
1157
+ bandwidth || 1.06 * d3.deviation(data) * Math.pow(data.length, -1 / 5);
1158
+
1159
+ const extent = d3.extent(data);
1160
+ const range = extent[1] - extent[0];
1161
+ const xPoints = d3.range(
1162
+ extent[0] - range * 0.1,
1163
+ extent[1] + range * 0.1,
1164
+ range / 200
1165
+ );
1166
+
1167
+ const densityData = xPoints.map((xi) => {
1168
+ const density = d3.mean(data, (d) => {
1169
+ return (
1170
+ Math.exp(-0.5 * Math.pow((d - xi) / bw, 2)) /
1171
+ (bw * Math.sqrt(2 * Math.PI))
1172
+ );
1173
+ });
1174
+ return { x: xi, y: density };
1175
+ });
1176
+
1177
+ const x = d3
1178
+ .scaleLinear()
1179
+ .domain([xPoints[0], xPoints[xPoints.length - 1]])
1180
+ .range([0, innerWidth]);
1181
+
1182
+ const y = d3
1183
+ .scaleLinear()
1184
+ .domain([0, d3.max(densityData, (d) => d.y)])
1185
+ .nice()
1186
+ .range([innerHeight, 0]);
1187
+
1188
+ const area = d3
1189
+ .area()
1190
+ .x((d) => x(d.x))
1191
+ .y0(innerHeight)
1192
+ .y1((d) => y(d.y))
1193
+ .curve(d3.curveBasis);
1194
+
1195
+ const line = d3
1196
+ .line()
1197
+ .x((d) => x(d.x))
1198
+ .y((d) => y(d.y))
1199
+ .curve(d3.curveBasis);
1200
+
1201
+ g.append("path")
1202
+ .datum(densityData)
1203
+ .attr("fill", color)
1204
+ .attr("opacity", 0.3)
1205
+ .attr("d", area);
1206
+
1207
+ g.append("path")
1208
+ .datum(densityData)
1209
+ .attr("fill", "none")
1210
+ .attr("stroke", color)
1211
+ .attr("stroke-width", 2)
1212
+ .attr("d", line);
1213
+
1214
+ g.append("g")
1215
+ .attr("transform", `translate(0,${innerHeight})`)
1216
+ .call(d3.axisBottom(x))
1217
+ .append("text")
1218
+ .attr("x", innerWidth / 2)
1219
+ .attr("y", 40)
1220
+ .attr("fill", "#000")
1221
+ .attr("text-anchor", "middle")
1222
+ .text(xlabel);
1223
+
1224
+ g.append("g")
1225
+ .call(d3.axisLeft(y))
1226
+ .append("text")
1227
+ .attr("transform", "rotate(-90)")
1228
+ .attr("x", -innerHeight / 2)
1229
+ .attr("y", -40)
1230
+ .attr("fill", "#000")
1231
+ .attr("text-anchor", "middle")
1232
+ .text(ylabel);
1233
+
1234
+ svg
1235
+ .append("text")
1236
+ .attr("x", width / 2)
1237
+ .attr("y", 20)
1238
+ .attr("text-anchor", "middle")
1239
+ .style("font-size", "16px")
1240
+ .style("font-weight", "bold")
1241
+ .text(title);
1242
+
1243
+ return this;
1244
+ }
1245
+
1246
+ // ============================================
1247
+ // PARALLEL COORDINATES
1248
+ // ============================================
1249
+ parallel(data, dimensions, options = {}) {
1250
+ const {
1251
+ title = "Parallel Coordinates",
1252
+ colors = null,
1253
+ width = this.defaultWidth,
1254
+ height = this.defaultHeight,
1255
+ containerId = null,
1256
+ } = options;
1257
+
1258
+ const { svg } = this.createContainer(containerId, width, height);
1259
+ const margin = { top: 60, right: 40, bottom: 40, left: 40 };
1260
+ const innerWidth = width - margin.left - margin.right;
1261
+ const innerHeight = height - margin.top - margin.bottom;
1262
+
1263
+ const g = svg
1264
+ .append("g")
1265
+ .attr("transform", `translate(${margin.left},${margin.top})`);
1266
+
1267
+ const y = {};
1268
+ dimensions.forEach((dim) => {
1269
+ y[dim] = d3
1270
+ .scaleLinear()
1271
+ .domain(d3.extent(data, (d) => d[dim]))
1272
+ .range([innerHeight, 0]);
1273
+ });
1274
+
1275
+ const x = d3.scalePoint().domain(dimensions).range([0, innerWidth]);
1276
+
1277
+ const line = d3.line();
1278
+
1279
+ const path = (d) => {
1280
+ return line(dimensions.map((dim) => [x(dim), y[dim](d[dim])]));
1281
+ };
1282
+
1283
+ const colorScale = colors
1284
+ ? d3.scaleOrdinal().domain(d3.range(data.length)).range(colors)
1285
+ : d3.scaleSequential(d3.interpolateViridis).domain([0, data.length]);
1286
+
1287
+ g.selectAll("path.line")
1288
+ .data(data)
1289
+ .join("path")
1290
+ .attr("class", "line")
1291
+ .attr("d", path)
1292
+ .attr("fill", "none")
1293
+ .attr("stroke", (d, i) => colorScale(i))
1294
+ .attr("opacity", 0.3)
1295
+ .attr("stroke-width", 2)
1296
+ .on("mouseover", function () {
1297
+ d3.select(this).attr("opacity", 1).attr("stroke-width", 3);
1298
+ })
1299
+ .on("mouseout", function () {
1300
+ d3.select(this).attr("opacity", 0.3).attr("stroke-width", 2);
1301
+ });
1302
+
1303
+ dimensions.forEach((dim) => {
1304
+ const axis = g
1305
+ .append("g")
1306
+ .attr("transform", `translate(${x(dim)},0)`)
1307
+ .call(d3.axisLeft(y[dim]));
1308
+
1309
+ axis
1310
+ .append("text")
1311
+ .attr("y", -10)
1312
+ .attr("text-anchor", "middle")
1313
+ .style("fill", "#000")
1314
+ .style("font-weight", "bold")
1315
+ .text(dim);
1316
+ });
1317
+
1318
+ svg
1319
+ .append("text")
1320
+ .attr("x", width / 2)
1321
+ .attr("y", 20)
1322
+ .attr("text-anchor", "middle")
1323
+ .style("font-size", "16px")
1324
+ .style("font-weight", "bold")
1325
+ .text(title);
1326
+
1327
+ return this;
1328
+ }
1329
+
1330
+ // ============================================
1331
+ // PAIR PLOT
1332
+ // ============================================
1333
+ pairplot(data, variables, options = {}) {
1334
+ const {
1335
+ title = "Pair Plot",
1336
+ color = "#4299e1",
1337
+ size = 3,
1338
+ width = 900,
1339
+ height = 900,
1340
+ containerId = null,
1341
+ } = options;
1342
+
1343
+ const { svg } = this.createContainer(containerId, width, height);
1344
+ const n = variables.length;
1345
+ const padding = 60;
1346
+ const cellSize = (Math.min(width, height) - padding * 2) / n;
1347
+
1348
+ const scales = {};
1349
+ variables.forEach((variable) => {
1350
+ scales[variable] = d3
1351
+ .scaleLinear()
1352
+ .domain(d3.extent(data, (d) => d[variable]))
1353
+ .range([0, cellSize - 20]);
1354
+ });
1355
+
1356
+ for (let i = 0; i < n; i++) {
1357
+ for (let j = 0; j < n; j++) {
1358
+ const xVar = variables[j];
1359
+ const yVar = variables[i];
1360
+
1361
+ const cellG = svg
1362
+ .append("g")
1363
+ .attr(
1364
+ "transform",
1365
+ `translate(${padding + j * cellSize},${padding + i * cellSize})`
1366
+ );
1367
+
1368
+ if (i === j) {
1369
+ const values = data.map((d) => d[xVar]);
1370
+ const histogram = d3
1371
+ .histogram()
1372
+ .domain(scales[xVar].domain())
1373
+ .thresholds(20);
1374
+
1375
+ const bins = histogram(values);
1376
+ const yScale = d3
1377
+ .scaleLinear()
1378
+ .domain([0, d3.max(bins, (d) => d.length)])
1379
+ .range([cellSize - 20, 0]);
1380
+
1381
+ cellG
1382
+ .selectAll("rect")
1383
+ .data(bins)
1384
+ .join("rect")
1385
+ .attr("x", (d) => scales[xVar](d.x0))
1386
+ .attr("y", (d) => yScale(d.length))
1387
+ .attr("width", (d) => scales[xVar](d.x1) - scales[xVar](d.x0) - 1)
1388
+ .attr("height", (d) => cellSize - 20 - yScale(d.length))
1389
+ .attr("fill", color)
1390
+ .attr("opacity", 0.7);
1391
+ } else {
1392
+ cellG
1393
+ .selectAll("circle")
1394
+ .data(data)
1395
+ .join("circle")
1396
+ .attr("cx", (d) => scales[xVar](d[xVar]))
1397
+ .attr("cy", (d) => scales[yVar](d[yVar]))
1398
+ .attr("r", size)
1399
+ .attr("fill", color)
1400
+ .attr("opacity", 0.5);
1401
+ }
1402
+
1403
+ if (i === n - 1) {
1404
+ cellG
1405
+ .append("g")
1406
+ .attr("transform", `translate(0,${cellSize - 20})`)
1407
+ .call(d3.axisBottom(scales[xVar]).ticks(3));
1408
+ }
1409
+
1410
+ if (j === 0) {
1411
+ cellG.append("g").call(d3.axisLeft(scales[yVar]).ticks(3));
1412
+ }
1413
+
1414
+ if (i === n - 1) {
1415
+ cellG
1416
+ .append("text")
1417
+ .attr("x", (cellSize - 20) / 2)
1418
+ .attr("y", cellSize - 5)
1419
+ .attr("text-anchor", "middle")
1420
+ .style("font-size", "10px")
1421
+ .text(xVar);
1422
+ }
1423
+
1424
+ if (j === 0) {
1425
+ cellG
1426
+ .append("text")
1427
+ .attr("transform", "rotate(-90)")
1428
+ .attr("x", -(cellSize - 20) / 2)
1429
+ .attr("y", -25)
1430
+ .attr("text-anchor", "middle")
1431
+ .style("font-size", "10px")
1432
+ .text(yVar);
1433
+ }
1434
+ }
1435
+ }
1436
+
1437
+ svg
1438
+ .append("text")
1439
+ .attr("x", width / 2)
1440
+ .attr("y", 30)
1441
+ .attr("text-anchor", "middle")
1442
+ .style("font-size", "16px")
1443
+ .style("font-weight", "bold")
1444
+ .text(title);
1445
+
1446
+ return this;
1447
+ }
1448
+
1449
+ // ============================================
1450
+ // MULTI-LINE CHART
1451
+ // ============================================
1452
+ multiline(series, options = {}) {
1453
+ const {
1454
+ title = "Multi-Line Chart",
1455
+ xlabel = "X",
1456
+ ylabel = "Y",
1457
+ legend = true,
1458
+ width = this.defaultWidth,
1459
+ height = this.defaultHeight,
1460
+ containerId = null,
1461
+ } = options;
1462
+
1463
+ const { svg } = this.createContainer(containerId, width, height);
1464
+ const margin = this.defaultMargin;
1465
+ const innerWidth = width - margin.left - margin.right;
1466
+ const innerHeight = height - margin.top - margin.bottom;
1467
+
1468
+ const g = svg
1469
+ .append("g")
1470
+ .attr("transform", `translate(${margin.left},${margin.top})`);
1471
+
1472
+ const allX = series.flatMap((s) => s.data.map((d) => d.x));
1473
+ const allY = series.flatMap((s) => s.data.map((d) => d.y));
1474
+
1475
+ const x = d3.scaleLinear().domain(d3.extent(allX)).range([0, innerWidth]);
1476
+
1477
+ const y = d3
1478
+ .scaleLinear()
1479
+ .domain(d3.extent(allY))
1480
+ .nice()
1481
+ .range([innerHeight, 0]);
1482
+
1483
+ const color = d3
1484
+ .scaleOrdinal()
1485
+ .domain(series.map((s) => s.name))
1486
+ .range(this.colors);
1487
+
1488
+ const line = d3
1489
+ .line()
1490
+ .x((d) => x(d.x))
1491
+ .y((d) => y(d.y));
1492
+
1493
+ series.forEach((s) => {
1494
+ g.append("path")
1495
+ .datum(s.data)
1496
+ .attr("fill", "none")
1497
+ .attr("stroke", color(s.name))
1498
+ .attr("stroke-width", 2)
1499
+ .attr("d", line);
1500
+ });
1501
+
1502
+ g.append("g")
1503
+ .attr("transform", `translate(0,${innerHeight})`)
1504
+ .call(d3.axisBottom(x))
1505
+ .append("text")
1506
+ .attr("x", innerWidth / 2)
1507
+ .attr("y", 40)
1508
+ .attr("fill", "#000")
1509
+ .attr("text-anchor", "middle")
1510
+ .text(xlabel);
1511
+
1512
+ g.append("g")
1513
+ .call(d3.axisLeft(y))
1514
+ .append("text")
1515
+ .attr("transform", "rotate(-90)")
1516
+ .attr("x", -innerHeight / 2)
1517
+ .attr("y", -40)
1518
+ .attr("fill", "#000")
1519
+ .attr("text-anchor", "middle")
1520
+ .text(ylabel);
1521
+
1522
+ if (legend) {
1523
+ const legendG = svg
1524
+ .append("g")
1525
+ .attr(
1526
+ "transform",
1527
+ `translate(${width - margin.right - 100},${margin.top})`
1528
+ );
1529
+
1530
+ series.forEach((s, i) => {
1531
+ const legendItem = legendG
1532
+ .append("g")
1533
+ .attr("transform", `translate(0,${i * 25})`);
1534
+
1535
+ legendItem
1536
+ .append("line")
1537
+ .attr("x1", 0)
1538
+ .attr("x2", 30)
1539
+ .attr("y1", 10)
1540
+ .attr("y2", 10)
1541
+ .attr("stroke", color(s.name))
1542
+ .attr("stroke-width", 2);
1543
+
1544
+ legendItem
1545
+ .append("text")
1546
+ .attr("x", 35)
1547
+ .attr("y", 15)
1548
+ .style("font-size", "12px")
1549
+ .text(s.name);
1550
+ });
1551
+ }
1552
+
1553
+ svg
1554
+ .append("text")
1555
+ .attr("x", width / 2)
1556
+ .attr("y", 20)
1557
+ .attr("text-anchor", "middle")
1558
+ .style("font-size", "16px")
1559
+ .style("font-weight", "bold")
1560
+ .text(title);
1561
+
1562
+ return this;
1563
+ }
1564
+ }
1565
+
1566
+ export default DataViz;