datly 0.0.2 → 0.0.3

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.
package/src/plot.js ADDED
@@ -0,0 +1,609 @@
1
+ // ========================
2
+ // 📊 D3 Core Imports
3
+ // ========================
4
+ // d3-selection
5
+ import { select, selectAll } from "d3-selection";
6
+ // d3-scale
7
+ import {
8
+ scaleLinear,
9
+ scaleBand,
10
+ scalePoint,
11
+ scaleOrdinal,
12
+ scaleSequential,
13
+ } from "d3-scale";
14
+ // d3-array
15
+ import {
16
+ extent,
17
+ max,
18
+ min,
19
+ sum,
20
+ range,
21
+ mean,
22
+ deviation,
23
+ histogram as d3Histogram,
24
+ quantile,
25
+ } from "d3-array";
26
+ // d3-axis
27
+ import { axisBottom, axisLeft } from "d3-axis";
28
+ // d3-colors
29
+ import {
30
+ schemeCategory10,
31
+ interpolateRdYlBu,
32
+ interpolateViridis,
33
+ } from "d3-scale-chromatic";
34
+ // d3-shape
35
+ import { line as d3Line, curveBasis, pie as d3Pie, arc as d3Arc } from "d3-shape";
36
+
37
+ let plotCounter = 0;
38
+
39
+ const defaultConfig = {
40
+ width: 400,
41
+ height: 400,
42
+ color: "#000",
43
+ background: "#fff",
44
+ title: "",
45
+ xlabel: "",
46
+ ylabel: "",
47
+ };
48
+
49
+ function createSvg(userSelector, opts) {
50
+ const config = { ...defaultConfig, ...opts };
51
+ let selector = userSelector;
52
+ let container;
53
+
54
+ if (!selector) {
55
+ selector = `#datly-plot-${plotCounter++}`;
56
+ const div = document.createElement("div");
57
+ div.id = selector.replace("#", "");
58
+ document.body.appendChild(div);
59
+ }
60
+
61
+ container = select(selector);
62
+ container.html("");
63
+ container.style("background", config.background).style("display", "inline-block");
64
+
65
+ if (config.title) {
66
+ container
67
+ .append("h3")
68
+ .style("text-align", "center")
69
+ .style("font-family", "sans-serif")
70
+ .style("margin-bottom", "5px")
71
+ .text(config.title);
72
+ }
73
+
74
+ const svg = container
75
+ .append("svg")
76
+ .attr("width", config.width)
77
+ .attr("height", config.height)
78
+ .style("background", config.background);
79
+
80
+ return { svg, config };
81
+ }
82
+
83
+ // =======================================================
84
+ // HISTOGRAM
85
+ // =======================================================
86
+ export function plotHistogram(data, options = {}, selector) {
87
+ const { svg, config } = createSvg(selector, options);
88
+ const margin = { top: 20, right: 20, bottom: 40, left: 40 };
89
+ const width = config.width - margin.left - margin.right;
90
+ const height = config.height - margin.top - margin.bottom;
91
+
92
+ const x = scaleLinear().domain(extent(data)).nice().range([0, width]);
93
+ const bins = d3Histogram().domain(x.domain()).thresholds(options.bins || 10)(data);
94
+ const y = scaleLinear().domain([0, max(bins, (d) => d.length)]).nice().range([height, 0]);
95
+
96
+ const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
97
+
98
+ g.selectAll("rect")
99
+ .data(bins)
100
+ .enter()
101
+ .append("rect")
102
+ .attr("x", (d) => x(d.x0))
103
+ .attr("y", (d) => y(d.length))
104
+ .attr("width", (d) => x(d.x1) - x(d.x0) - 1)
105
+ .attr("height", (d) => height - y(d.length))
106
+ .attr("fill", config.color);
107
+
108
+ g.append("g").attr("transform", `translate(0,${height})`).call(axisBottom(x));
109
+ g.append("g").call(axisLeft(y));
110
+ }
111
+
112
+ // =======================================================
113
+ // BOXPLOT
114
+ // =======================================================
115
+ export function plotBoxplot(data, options = {}, selector) {
116
+ const groups = Array.isArray(data[0]) ? data : [data];
117
+ const { svg, config } = createSvg(selector, options);
118
+ const margin = { top: 20, right: 20, bottom: 40, left: 40 };
119
+ const width = config.width - margin.left - margin.right;
120
+ const height = config.height - margin.top - margin.bottom;
121
+
122
+ const x = scaleBand()
123
+ .domain(groups.map((_, i) => options.labels ? options.labels[i] : `Group ${i+1}`))
124
+ .range([0, width])
125
+ .padding(0.5);
126
+
127
+ const allValues = groups.flat();
128
+ const y = scaleLinear().domain(extent(allValues)).nice().range([height, 0]);
129
+ const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
130
+
131
+ groups.forEach((group, i) => {
132
+ const sorted = [...group].sort((a, b) => a - b);
133
+ const q1 = quantile(sorted, 0.25);
134
+ const median = quantile(sorted, 0.5);
135
+ const q3 = quantile(sorted, 0.75);
136
+ const minVal = min(sorted);
137
+ const maxVal = max(sorted);
138
+ const xPos = x(options.labels ? options.labels[i] : `Group ${i+1}`) + x.bandwidth()/2;
139
+ const boxWidth = x.bandwidth()/2;
140
+
141
+ g.append("line")
142
+ .attr("x1", xPos)
143
+ .attr("x2", xPos)
144
+ .attr("y1", y(minVal))
145
+ .attr("y2", y(maxVal))
146
+ .attr("stroke", config.color);
147
+
148
+ g.append("rect")
149
+ .attr("x", xPos - boxWidth / 2)
150
+ .attr("y", y(q3))
151
+ .attr("width", boxWidth)
152
+ .attr("height", y(q1) - y(q3))
153
+ .attr("stroke", config.color)
154
+ .attr("fill", "none");
155
+
156
+ g.append("line")
157
+ .attr("x1", xPos - boxWidth / 2)
158
+ .attr("x2", xPos + boxWidth / 2)
159
+ .attr("y1", y(median))
160
+ .attr("y2", y(median))
161
+ .attr("stroke", config.color);
162
+ });
163
+
164
+ g.append("g").attr("transform", `translate(0,${height})`).call(axisBottom(x));
165
+ g.append("g").call(axisLeft(y));
166
+ }
167
+
168
+ // =======================================================
169
+ // SCATTER
170
+ // =======================================================
171
+ export function plotScatter(xData, yData, options = {}, selector) {
172
+ const { svg, config } = createSvg(selector, options);
173
+ const margin = { top: 20, right: 20, bottom: 40, left: 40 };
174
+ const width = config.width - margin.left - margin.right;
175
+ const height = config.height - margin.top - margin.bottom;
176
+
177
+ const x = scaleLinear().domain(extent(xData)).nice().range([0, width]);
178
+ const y = scaleLinear().domain(extent(yData)).nice().range([height, 0]);
179
+ const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
180
+
181
+ g.selectAll("circle")
182
+ .data(xData)
183
+ .enter()
184
+ .append("circle")
185
+ .attr("cx", (_, i) => x(xData[i]))
186
+ .attr("cy", (_, i) => y(yData[i]))
187
+ .attr("r", options.size || 4)
188
+ .attr("fill", config.color);
189
+
190
+ g.append("g").attr("transform", `translate(0,${height})`).call(axisBottom(x));
191
+ g.append("g").call(axisLeft(y));
192
+ }
193
+
194
+ // =======================================================
195
+ // LINE
196
+ // =======================================================
197
+ export function plotLine(xData, yData, options = {}, selector) {
198
+ const { svg, config } = createSvg(selector, options);
199
+ const margin = { top: 20, right: 20, bottom: 40, left: 40 };
200
+ const width = config.width - margin.left - margin.right;
201
+ const height = config.height - margin.top - margin.bottom;
202
+
203
+ const x = scaleLinear().domain(extent(xData)).range([0, width]);
204
+ const y = scaleLinear().domain(extent(yData)).range([height, 0]);
205
+ const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
206
+
207
+ const path = d3Line()
208
+ .x((_, i) => x(xData[i]))
209
+ .y((_, i) => y(yData[i]))
210
+ .curve(curveBasis);
211
+
212
+ g.append("path")
213
+ .datum(xData)
214
+ .attr("fill", "none")
215
+ .attr("stroke", config.color)
216
+ .attr("stroke-width", options.lineWidth || 2)
217
+ .attr("d", path);
218
+
219
+ if (options.showPoints) {
220
+ g.selectAll("circle")
221
+ .data(xData)
222
+ .enter()
223
+ .append("circle")
224
+ .attr("cx", (_, i) => x(xData[i]))
225
+ .attr("cy", (_, i) => y(yData[i]))
226
+ .attr("r", 3)
227
+ .attr("fill", config.color);
228
+ }
229
+
230
+ g.append("g").attr("transform", `translate(0,${height})`).call(axisBottom(x));
231
+ g.append("g").call(axisLeft(y));
232
+ }
233
+
234
+ // =======================================================
235
+ // BAR
236
+ // =======================================================
237
+ export function plotBar(categories, values, options = {}, selector) {
238
+ const { svg, config } = createSvg(selector, options);
239
+ const margin = { top: 20, right: 20, bottom: 40, left: 40 };
240
+ const width = config.width - margin.left - margin.right;
241
+ const height = config.height - margin.top - margin.bottom;
242
+
243
+ const x = scaleBand().domain(categories).range([0, width]).padding(0.2);
244
+ const y = scaleLinear().domain([0, max(values)]).nice().range([height, 0]);
245
+ const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
246
+
247
+ g.selectAll("rect")
248
+ .data(values)
249
+ .enter()
250
+ .append("rect")
251
+ .attr("x", (_, i) => x(categories[i]))
252
+ .attr("y", (d) => y(d))
253
+ .attr("width", x.bandwidth())
254
+ .attr("height", (d) => height - y(d))
255
+ .attr("fill", config.color);
256
+
257
+ g.append("g").attr("transform", `translate(0,${height})`).call(axisBottom(x));
258
+ g.append("g").call(axisLeft(y));
259
+ }
260
+
261
+ // =======================================================
262
+ // PIE
263
+ // =======================================================
264
+ export function plotPie(labels, values, options = {}, selector) {
265
+ const { svg, config } = createSvg(selector, options);
266
+ const radius = Math.min(config.width, config.height) / 2;
267
+ const g = svg.append("g").attr("transform", `translate(${config.width/2},${config.height/2})`);
268
+ const color = scaleOrdinal(schemeCategory10);
269
+ const pieGen = d3Pie();
270
+ const arcs = pieGen(values);
271
+ const arcGen = d3Arc().innerRadius(0).outerRadius(radius);
272
+
273
+ g.selectAll("path")
274
+ .data(arcs)
275
+ .enter()
276
+ .append("path")
277
+ .attr("d", arcGen)
278
+ .attr("fill", (d, i) => color(i));
279
+
280
+ if (options.showLabels) {
281
+ g.selectAll("text")
282
+ .data(arcs)
283
+ .enter()
284
+ .append("text")
285
+ .attr("transform", (d) => `translate(${arcGen.centroid(d)})`)
286
+ .attr("text-anchor", "middle")
287
+ .text((d, i) => labels[i]);
288
+ }
289
+ }
290
+
291
+ // =======================================================
292
+ // HEATMAP
293
+ // =======================================================
294
+ export function plotHeatmap(matrix, options = {}, selector) {
295
+ const { svg, config } = createSvg(selector, options);
296
+ const labels = options.labels || matrix.map((_, i) => `Var${i+1}`);
297
+ const margin = { top: 40, right: 20, bottom: 40, left: 60 };
298
+ const width = config.width - margin.left - margin.right;
299
+ const height = config.height - margin.top - margin.bottom;
300
+
301
+ const x = scaleBand().domain(labels).range([0, width]).padding(0.05);
302
+ const y = scaleBand().domain(labels).range([0, height]).padding(0.05);
303
+ const color = scaleSequential(interpolateRdYlBu).domain([1, -1]);
304
+
305
+ const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
306
+
307
+ const cells = [];
308
+ matrix.forEach((row, i) => {
309
+ row.forEach((value, j) => cells.push({ x: labels[j], y: labels[i], value }));
310
+ });
311
+
312
+ g.selectAll("rect")
313
+ .data(cells)
314
+ .enter()
315
+ .append("rect")
316
+ .attr("x", d => x(d.x))
317
+ .attr("y", d => y(d.y))
318
+ .attr("width", x.bandwidth())
319
+ .attr("height", y.bandwidth())
320
+ .attr("fill", d => color(d.value));
321
+
322
+ if (options.showValues) {
323
+ g.selectAll("text")
324
+ .data(cells)
325
+ .enter()
326
+ .append("text")
327
+ .attr("x", d => x(d.x) + x.bandwidth()/2)
328
+ .attr("y", d => y(d.y) + y.bandwidth()/2)
329
+ .attr("text-anchor", "middle")
330
+ .style("font-size", "10px")
331
+ .text(d => d.value.toFixed(2));
332
+ }
333
+
334
+ g.append("g").attr("transform", `translate(0,${height})`).call(axisBottom(x));
335
+ g.append("g").call(axisLeft(y));
336
+ }
337
+
338
+ // =======================================================
339
+ // VIOLIN
340
+ // =======================================================
341
+ export function plotViolin(groups, options = {}, selector) {
342
+ const { svg, config } = createSvg(selector, options);
343
+ const dataGroups = Array.isArray(groups[0]) ? groups : [groups];
344
+ const margin = { top: 20, right: 20, bottom: 40, left: 40 };
345
+ const width = config.width - margin.left - margin.right;
346
+ const height = config.height - margin.top - margin.bottom;
347
+
348
+ const x = scaleBand()
349
+ .domain(dataGroups.map((_, i) => options.labels ? options.labels[i] : `Group ${i+1}`))
350
+ .range([0, width])
351
+ .padding(0.5);
352
+ const allValues = dataGroups.flat();
353
+ const y = scaleLinear().domain(extent(allValues)).nice().range([height, 0]);
354
+ const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
355
+
356
+ dataGroups.forEach((group, i) => {
357
+ const bins = d3Histogram().domain(y.domain()).thresholds(20)(group);
358
+ const maxLen = max(bins, d => d.length);
359
+ const xPos = x(options.labels ? options.labels[i] : `Group ${i+1}`);
360
+ const scaleW = scaleLinear().domain([0, maxLen]).range([0, x.bandwidth()/2]);
361
+
362
+ const areaGen = d3Line()
363
+ .x(d => scaleW(d.length))
364
+ .y(d => y((d.x0 + d.x1)/2));
365
+
366
+ const mirrored = d3Line()
367
+ .x(d => -scaleW(d.length))
368
+ .y(d => y((d.x0 + d.x1)/2));
369
+
370
+ const g2 = g.append("g").attr("transform", `translate(${xPos + x.bandwidth()/2},0)`);
371
+
372
+ g2.append("path")
373
+ .datum(bins)
374
+ .attr("fill", options.color || config.color)
375
+ .attr("fill-opacity", 0.3)
376
+ .attr("stroke", config.color)
377
+ .attr("d", areaGen);
378
+
379
+ g2.append("path")
380
+ .datum(bins)
381
+ .attr("fill", options.color || config.color)
382
+ .attr("fill-opacity", 0.3)
383
+ .attr("stroke", config.color)
384
+ .attr("d", mirrored);
385
+ });
386
+
387
+ g.append("g").attr("transform", `translate(0,${height})`).call(axisBottom(x));
388
+ g.append("g").call(axisLeft(y));
389
+ }
390
+
391
+ // =======================================================
392
+ // DENSITY
393
+ // =======================================================
394
+ export function plotDensity(data, options = {}, selector) {
395
+ const { svg, config } = createSvg(selector, options);
396
+ const margin = { top: 20, right: 20, bottom: 40, left: 40 };
397
+ const width = config.width - margin.left - margin.right;
398
+ const height = config.height - margin.top - margin.bottom;
399
+
400
+ const x = scaleLinear().domain(extent(data)).nice().range([0, width]);
401
+ const kde = kernelDensityEstimator(epanechnikovKernel(options.bandwidth || 5), x.ticks(50));
402
+ const density = kde(data);
403
+ const y = scaleLinear().domain([0, max(density, d => d[1])]).range([height, 0]);
404
+
405
+ const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
406
+
407
+ const path = d3Line().curve(curveBasis).x(d => x(d[0])).y(d => y(d[1]));
408
+
409
+ g.append("path")
410
+ .datum(density)
411
+ .attr("fill", "none")
412
+ .attr("stroke", config.color)
413
+ .attr("stroke-width", 2)
414
+ .attr("d", path);
415
+
416
+ g.append("g").attr("transform", `translate(0,${height})`).call(axisBottom(x));
417
+ g.append("g").call(axisLeft(y));
418
+ }
419
+
420
+ function kernelDensityEstimator(kernel, X) {
421
+ return function (V) {
422
+ return X.map(function (x) {
423
+ return [x, mean(V, v => kernel(x - v))];
424
+ });
425
+ };
426
+ }
427
+ function epanechnikovKernel(bandwidth) {
428
+ return function (u) {
429
+ u /= bandwidth;
430
+ return Math.abs(u) <= 1 ? 0.75 * (1 - u * u) / bandwidth : 0;
431
+ };
432
+ }
433
+
434
+ // =======================================================
435
+ // QQ PLOT
436
+ // =======================================================
437
+ export function plotQQ(data, options = {}, selector) {
438
+ const sorted = [...data].sort((a,b)=>a-b);
439
+ const n = sorted.length;
440
+ const quantiles = sorted.map((_,i)=>(i+0.5)/n);
441
+ const theoretical = quantiles.map(q => normalQuantile(q));
442
+ plotScatter(theoretical, sorted, options, selector);
443
+ }
444
+
445
+ // Z-score inverse (approx)
446
+ function normalQuantile(p) {
447
+ const a1 = -39.6968302866538, a2 = 220.946098424521, a3 = -275.928510446969;
448
+ const a4 = 138.357751867269, a5 = -30.6647980661472, a6 = 2.50662827745924;
449
+ const b1 = -54.4760987982241, b2 = 161.585836858041, b3 = -155.698979859887;
450
+ const b4 = 66.8013118877197, b5 = -13.2806815528857;
451
+ const c1 = -0.00778489400243029, c2 = -0.322396458041136;
452
+ const c3 = -2.40075827716184, c4 = -2.54973253934373;
453
+ const c5 = 4.37466414146497, c6 = 2.93816398269878;
454
+ const d1 = 0.00778469570904146, d2 = 0.32246712907004;
455
+ const d3 = 2.445134137143, d4 = 3.75440866190742;
456
+ const plow = 0.02425;
457
+ const phigh = 1 - plow;
458
+ let q, r;
459
+ if (p < plow) {
460
+ q = Math.sqrt(-2 * Math.log(p));
461
+ return (((((c1*q+c2)*q+c3)*q+c4)*q+c5)*q+c6)/((((d1*q+d2)*q+d3)*q+d4)*q+1);
462
+ } else if (phigh < p) {
463
+ q = Math.sqrt(-2 * Math.log(1 - p));
464
+ return -(((((c1*q+c2)*q+c3)*q+c4)*q+c5)*q+c6)/((((d1*q+d2)*q+d3)*q+d4)*q+1);
465
+ } else {
466
+ q = p - 0.5;
467
+ r = q * q;
468
+ return (((((a1*r+a2)*r+a3)*r+a4)*r+a5)*r+a6)*q/((((b1*r+b2)*r+b3)*r+b4)*r+b5)+1;
469
+ }
470
+ }
471
+
472
+ // =======================================================
473
+ // PARALLEL COORDINATES
474
+ // =======================================================
475
+ export function plotParallel(data, dimensions, options = {}, selector) {
476
+ const { svg, config } = createSvg(selector, options);
477
+ const margin = { top: 30, right: 30, bottom: 10, left: 30 };
478
+ const width = config.width - margin.left - margin.right;
479
+ const height = config.height - margin.top - margin.bottom;
480
+
481
+ const x = scalePoint().range([0, width]).padding(1).domain(dimensions);
482
+ const y = {};
483
+ dimensions.forEach(dim => {
484
+ y[dim] = scaleLinear()
485
+ .domain(extent(data, d => d[dim]))
486
+ .range([height, 0]);
487
+ });
488
+
489
+ const lineGen = d3Line();
490
+ const path = d => lineGen(dimensions.map(p => [x(p), y[p](d[p])]));
491
+
492
+ const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
493
+
494
+ g.selectAll("path")
495
+ .data(data)
496
+ .enter().append("path")
497
+ .attr("d", path)
498
+ .attr("fill", "none")
499
+ .attr("stroke", (d, i) => options.colors ? options.colors[i % options.colors.length] : config.color)
500
+ .attr("stroke-width", 1)
501
+ .attr("opacity", 0.6);
502
+
503
+ dimensions.forEach(dim => {
504
+ g.append("g")
505
+ .attr("transform", `translate(${x(dim)},0)`)
506
+ .call(axisLeft(y[dim]))
507
+ .append("text")
508
+ .style("text-anchor", "middle")
509
+ .attr("y", -9)
510
+ .text(dim);
511
+ });
512
+ }
513
+
514
+ // =======================================================
515
+ // PAIRPLOT
516
+ // =======================================================
517
+ export function plotPairplot(data, columns, options = {}, selector) {
518
+ const n = columns.length;
519
+ const size = options.size || 120;
520
+ const gap = 10;
521
+ const totalSize = n * (size + gap);
522
+ const container = selector || `#datly-plot-${plotCounter++}`;
523
+ const div = document.createElement("div");
524
+ div.id = container.replace("#", "");
525
+ document.body.appendChild(div);
526
+
527
+ const containerSel = select(container);
528
+ containerSel.html("");
529
+ containerSel.style("display", "inline-block");
530
+
531
+ const svg = containerSel
532
+ .append("svg")
533
+ .attr("width", totalSize)
534
+ .attr("height", totalSize)
535
+ .style("background", "#fff");
536
+
537
+ const x = {};
538
+ const y = {};
539
+ columns.forEach(col => {
540
+ x[col] = scaleLinear().domain(extent(data, d => d[col])).range([gap, size - gap]);
541
+ y[col] = scaleLinear().domain(extent(data, d => d[col])).range([size - gap, gap]);
542
+ });
543
+
544
+ columns.forEach((colX, i) => {
545
+ columns.forEach((colY, j) => {
546
+ const g = svg.append("g")
547
+ .attr("transform", `translate(${i * (size + gap)},${j * (size + gap)})`);
548
+ g.selectAll("circle")
549
+ .data(data)
550
+ .enter()
551
+ .append("circle")
552
+ .attr("cx", d => x[colX](d[colX]))
553
+ .attr("cy", d => y[colY](d[colY]))
554
+ .attr("r", 2)
555
+ .attr("fill", options.color || "#000");
556
+ });
557
+ });
558
+ }
559
+
560
+ // =======================================================
561
+ // MULTILINE
562
+ // =======================================================
563
+ export function plotMultiline(series, options = {}, selector) {
564
+ const { svg, config } = createSvg(selector, options);
565
+ const margin = { top: 20, right: 20, bottom: 40, left: 40 };
566
+ const width = config.width - margin.left - margin.right;
567
+ const height = config.height - margin.top - margin.bottom;
568
+
569
+ const allX = series.flatMap(s => s.data.map(d => d.x));
570
+ const allY = series.flatMap(s => s.data.map(d => d.y));
571
+ const x = scaleLinear().domain(extent(allX)).range([0, width]);
572
+ const y = scaleLinear().domain(extent(allY)).range([height, 0]);
573
+ const color = scaleOrdinal(schemeCategory10);
574
+
575
+ const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
576
+
577
+ series.forEach((s, i) => {
578
+ const path = d3Line()
579
+ .x(d => x(d.x))
580
+ .y(d => y(d.y));
581
+
582
+ g.append("path")
583
+ .datum(s.data)
584
+ .attr("fill", "none")
585
+ .attr("stroke", color(i))
586
+ .attr("stroke-width", 2)
587
+ .attr("d", path);
588
+ });
589
+
590
+ if (options.legend) {
591
+ const legend = svg.append("g").attr("transform", `translate(${width - 100},20)`);
592
+ series.forEach((s, i) => {
593
+ legend.append("rect")
594
+ .attr("x", 0)
595
+ .attr("y", i * 20)
596
+ .attr("width", 12)
597
+ .attr("height", 12)
598
+ .attr("fill", color(i));
599
+ legend.append("text")
600
+ .attr("x", 20)
601
+ .attr("y", i * 20 + 10)
602
+ .text(s.name)
603
+ .style("font-size", "12px");
604
+ });
605
+ }
606
+
607
+ g.append("g").attr("transform", `translate(0,${height})`).call(axisBottom(x));
608
+ g.append("g").call(axisLeft(y));
609
+ }