eleventy-plugin-uncharted 0.5.0 → 0.5.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.
Potentially problematic release.
This version of eleventy-plugin-uncharted might be problematic. Click here for more details.
- package/README.md +1 -1
- package/css/uncharted.css +133 -14
- package/package.json +1 -1
- package/src/renderers/donut.js +2 -2
- package/src/renderers/dot.js +17 -13
- package/src/renderers/sankey.js +53 -105
- package/src/renderers/scatter.js +92 -25
- package/src/renderers/stacked-bar.js +5 -2
- package/src/renderers/stacked-column.js +13 -12
package/README.md
CHANGED
package/css/uncharted.css
CHANGED
|
@@ -7,18 +7,33 @@
|
|
|
7
7
|
========================================================================== */
|
|
8
8
|
|
|
9
9
|
:root {
|
|
10
|
-
|
|
11
|
-
--chart-color-
|
|
12
|
-
--chart-color-
|
|
13
|
-
--chart-color-
|
|
14
|
-
--chart-color-
|
|
15
|
-
--chart-color-
|
|
16
|
-
--chart-color-
|
|
17
|
-
--chart-color-
|
|
18
|
-
--chart-color-
|
|
19
|
-
--chart-color-
|
|
20
|
-
--chart-color-
|
|
21
|
-
--chart-color-
|
|
10
|
+
/* Descriptive color names */
|
|
11
|
+
--chart-color-blue: #2196f3;
|
|
12
|
+
--chart-color-green: #4caf50;
|
|
13
|
+
--chart-color-orange: #ff7043;
|
|
14
|
+
--chart-color-yellow: #ffc107;
|
|
15
|
+
--chart-color-teal: #009688;
|
|
16
|
+
--chart-color-purple: #9c27b0;
|
|
17
|
+
--chart-color-pink: #e91e63;
|
|
18
|
+
--chart-color-indigo: #3f51b5;
|
|
19
|
+
--chart-color-red: #f44336;
|
|
20
|
+
--chart-color-cyan: #00bcd4;
|
|
21
|
+
--chart-color-lime: #cddc39;
|
|
22
|
+
--chart-color-gray: #78909c;
|
|
23
|
+
|
|
24
|
+
/* Numeric aliases (for chart data series) */
|
|
25
|
+
--chart-color-1: var(--chart-color-blue);
|
|
26
|
+
--chart-color-2: var(--chart-color-green);
|
|
27
|
+
--chart-color-3: var(--chart-color-orange);
|
|
28
|
+
--chart-color-4: var(--chart-color-yellow);
|
|
29
|
+
--chart-color-5: var(--chart-color-teal);
|
|
30
|
+
--chart-color-6: var(--chart-color-purple);
|
|
31
|
+
--chart-color-7: var(--chart-color-pink);
|
|
32
|
+
--chart-color-8: var(--chart-color-indigo);
|
|
33
|
+
--chart-color-9: var(--chart-color-red);
|
|
34
|
+
--chart-color-10: var(--chart-color-cyan);
|
|
35
|
+
--chart-color-11: var(--chart-color-lime);
|
|
36
|
+
--chart-color-12: var(--chart-color-gray);
|
|
22
37
|
|
|
23
38
|
/* Backgrounds - neutral with opacity for light/dark adaptability */
|
|
24
39
|
--chart-bg: rgba(128, 128, 128, 0.15);
|
|
@@ -30,6 +45,8 @@
|
|
|
30
45
|
--chart-donut-size: 20rem;
|
|
31
46
|
--chart-donut-hole: 30%;
|
|
32
47
|
--chart-dot-size: 0.75rem;
|
|
48
|
+
--chart-dot-size-min: 0.375rem;
|
|
49
|
+
--chart-dot-size-max: 1.5rem;
|
|
33
50
|
--chart-height: 12rem;
|
|
34
51
|
}
|
|
35
52
|
|
|
@@ -87,10 +104,28 @@
|
|
|
87
104
|
row-gap: 0.375rem;
|
|
88
105
|
list-style: none;
|
|
89
106
|
padding: 0;
|
|
90
|
-
margin: 0
|
|
107
|
+
margin: 0;
|
|
91
108
|
font-size: 0.875rem;
|
|
92
109
|
}
|
|
93
110
|
|
|
111
|
+
/* Legends below chart body */
|
|
112
|
+
.chart-body + .chart-legend,
|
|
113
|
+
.chart-body + .chart-legend-title,
|
|
114
|
+
.chart-body + .chart-size-legend,
|
|
115
|
+
.donut-body + .chart-legend {
|
|
116
|
+
margin-top: 1rem;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* Spacing between series legend and size legend */
|
|
120
|
+
.chart-legend + .chart-size-legend {
|
|
121
|
+
margin-top: 0.75rem;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* Bar charts keep legend above */
|
|
125
|
+
.chart-stacked-bar .chart-legend {
|
|
126
|
+
margin-bottom: 1rem;
|
|
127
|
+
}
|
|
128
|
+
|
|
94
129
|
.chart-legend-item {
|
|
95
130
|
display: flex;
|
|
96
131
|
align-items: center;
|
|
@@ -119,6 +154,13 @@
|
|
|
119
154
|
margin-left: 0.125rem;
|
|
120
155
|
}
|
|
121
156
|
|
|
157
|
+
.chart-legend-title {
|
|
158
|
+
display: block;
|
|
159
|
+
font-size: 0.75rem;
|
|
160
|
+
font-weight: 600;
|
|
161
|
+
margin-bottom: 0.375rem;
|
|
162
|
+
}
|
|
163
|
+
|
|
122
164
|
/* ==========================================================================
|
|
123
165
|
Axes
|
|
124
166
|
========================================================================== */
|
|
@@ -158,9 +200,13 @@
|
|
|
158
200
|
transform: translateY(50%);
|
|
159
201
|
}
|
|
160
202
|
|
|
203
|
+
.chart-y-axis:has(.axis-title) {
|
|
204
|
+
padding-left: 1.25rem;
|
|
205
|
+
}
|
|
206
|
+
|
|
161
207
|
.chart-y-axis .axis-title {
|
|
162
208
|
position: absolute;
|
|
163
|
-
left:
|
|
209
|
+
left: 0.25rem;
|
|
164
210
|
top: 50%;
|
|
165
211
|
transform: rotate(-90deg) translateX(-50%);
|
|
166
212
|
transform-origin: left center;
|
|
@@ -657,6 +703,70 @@
|
|
|
657
703
|
z-index: 1;
|
|
658
704
|
}
|
|
659
705
|
|
|
706
|
+
/* Variable-sized dots (size column) */
|
|
707
|
+
.chart-scatter .dot[style*="--size-scale"] {
|
|
708
|
+
--computed-size: calc(
|
|
709
|
+
var(--chart-dot-size-min) +
|
|
710
|
+
var(--size-scale) * (var(--chart-dot-size-max) - var(--chart-dot-size-min))
|
|
711
|
+
);
|
|
712
|
+
width: var(--computed-size);
|
|
713
|
+
height: var(--computed-size);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/* Size legend */
|
|
717
|
+
.chart-size-legend {
|
|
718
|
+
display: flex;
|
|
719
|
+
flex-direction: column;
|
|
720
|
+
gap: 0.375rem;
|
|
721
|
+
font-size: 0.875rem;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.size-legend-items {
|
|
725
|
+
display: flex;
|
|
726
|
+
align-items: center;
|
|
727
|
+
gap: 1rem;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
.size-legend-item {
|
|
731
|
+
display: flex;
|
|
732
|
+
align-items: center;
|
|
733
|
+
gap: 0.5rem;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
.size-dot {
|
|
737
|
+
border-radius: 50%;
|
|
738
|
+
background: currentColor;
|
|
739
|
+
opacity: 0.5;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
.size-dot-min {
|
|
743
|
+
width: var(--chart-dot-size-min);
|
|
744
|
+
height: var(--chart-dot-size-min);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
.size-dot-max {
|
|
748
|
+
width: var(--chart-dot-size-max);
|
|
749
|
+
height: var(--chart-dot-size-max);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
.size-value {
|
|
753
|
+
font-size: 0.75rem;
|
|
754
|
+
opacity: 0.7;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/* Proportional scatter: maintain data aspect ratio */
|
|
758
|
+
.chart-scatter.chart-proportional .chart-body,
|
|
759
|
+
.chart-scatter.chart-proportional .chart-y-axis {
|
|
760
|
+
min-height: auto;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
.chart-scatter.chart-proportional .dot-area {
|
|
764
|
+
aspect-ratio: var(--data-aspect-ratio, 1);
|
|
765
|
+
min-height: auto;
|
|
766
|
+
width: 100%;
|
|
767
|
+
height: auto;
|
|
768
|
+
}
|
|
769
|
+
|
|
660
770
|
/* ==========================================================================
|
|
661
771
|
Sankey Chart
|
|
662
772
|
========================================================================== */
|
|
@@ -761,6 +871,15 @@
|
|
|
761
871
|
opacity: 0.8;
|
|
762
872
|
}
|
|
763
873
|
|
|
874
|
+
/* Dim all flows and nodes when hovering a node; per-chart inline styles brighten connected ones */
|
|
875
|
+
.chart-sankey-container:has(.chart-sankey-node:hover) .chart-sankey-flow path {
|
|
876
|
+
opacity: 0.1;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
.chart-sankey-container:has(.chart-sankey-node:hover) .chart-sankey-node {
|
|
880
|
+
opacity: 0.2;
|
|
881
|
+
}
|
|
882
|
+
|
|
764
883
|
|
|
765
884
|
/* ==========================================================================
|
|
766
885
|
Animations (Optional)
|
package/package.json
CHANGED
package/src/renderers/donut.js
CHANGED
|
@@ -100,6 +100,8 @@ export function renderDonut(config) {
|
|
|
100
100
|
html += `</div>`;
|
|
101
101
|
html += `</div>`;
|
|
102
102
|
|
|
103
|
+
html += `</div>`; // Close donut-body
|
|
104
|
+
|
|
103
105
|
// Legend with values (or percentages if showPercentages is true)
|
|
104
106
|
const legendLabels = legend ?? segments.map(s => s.label);
|
|
105
107
|
html += `<ul class="chart-legend">`;
|
|
@@ -120,8 +122,6 @@ export function renderDonut(config) {
|
|
|
120
122
|
});
|
|
121
123
|
html += `</ul>`;
|
|
122
124
|
|
|
123
|
-
html += `</div>`; // Close donut-body
|
|
124
|
-
|
|
125
125
|
html += renderDownloadLink(downloadDataUrl, downloadData);
|
|
126
126
|
html += `</figure>`;
|
|
127
127
|
|
package/src/renderers/dot.js
CHANGED
|
@@ -15,7 +15,7 @@ import { formatNumber } from '../formatters.js';
|
|
|
15
15
|
* @returns {string} - HTML string
|
|
16
16
|
*/
|
|
17
17
|
export function renderDot(config) {
|
|
18
|
-
const { title, subtitle, data, max, min, legend, animate, format, id, rotateLabels, downloadData, downloadDataUrl, connectDots, dots: showDots = true, chartType = 'dot' } = config;
|
|
18
|
+
const { title, subtitle, data, max, min, legend, legendTitle, animate, format, id, rotateLabels, downloadData, downloadDataUrl, connectDots, dots: showDots = true, chartType = 'dot' } = config;
|
|
19
19
|
|
|
20
20
|
if (!data || data.length === 0) {
|
|
21
21
|
return `<!-- Dot chart: no data provided -->`;
|
|
@@ -58,18 +58,6 @@ export function renderDot(config) {
|
|
|
58
58
|
html += `</figcaption>`;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
// Legend
|
|
62
|
-
if (seriesKeys.length > 0) {
|
|
63
|
-
html += `<ul class="chart-legend">`;
|
|
64
|
-
seriesKeys.forEach((key, i) => {
|
|
65
|
-
const label = legendLabels[i] ?? key;
|
|
66
|
-
const colorClass = `chart-color-${i + 1}`;
|
|
67
|
-
const seriesClass = `chart-series-${slugify(key)}`;
|
|
68
|
-
html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</li>`;
|
|
69
|
-
});
|
|
70
|
-
html += `</ul>`;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
61
|
html += `<div class="chart-body">`;
|
|
74
62
|
|
|
75
63
|
// Y-axis
|
|
@@ -154,6 +142,22 @@ export function renderDot(config) {
|
|
|
154
142
|
|
|
155
143
|
html += `</div>`; // close chart-scroll
|
|
156
144
|
html += `</div>`; // close chart-body
|
|
145
|
+
|
|
146
|
+
// Legend
|
|
147
|
+
if (seriesKeys.length > 0 || legendTitle) {
|
|
148
|
+
if (legendTitle) {
|
|
149
|
+
html += `<span class="chart-legend-title">${escapeHtml(legendTitle)}</span>`;
|
|
150
|
+
}
|
|
151
|
+
html += `<ul class="chart-legend">`;
|
|
152
|
+
seriesKeys.forEach((key, i) => {
|
|
153
|
+
const label = legendLabels[i] ?? key;
|
|
154
|
+
const colorClass = `chart-color-${i + 1}`;
|
|
155
|
+
const seriesClass = `chart-series-${slugify(key)}`;
|
|
156
|
+
html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</li>`;
|
|
157
|
+
});
|
|
158
|
+
html += `</ul>`;
|
|
159
|
+
}
|
|
160
|
+
|
|
157
161
|
html += renderDownloadLink(downloadDataUrl, downloadData);
|
|
158
162
|
html += `</figure>`;
|
|
159
163
|
|
package/src/renderers/sankey.js
CHANGED
|
@@ -164,7 +164,7 @@ export function renderSankey(config) {
|
|
|
164
164
|
const paddingPct = (nodePadding / 400) * 100; // Convert px to approximate %
|
|
165
165
|
const minNodeHeight = 2; // Minimum node height in percentage points
|
|
166
166
|
const minGapHeight = 4; // Minimum gap to prevent label overlap (%)
|
|
167
|
-
const minFlowHeightBase =
|
|
167
|
+
const minFlowHeightBase = 0.4; // Base minimum flow height (before scaling)
|
|
168
168
|
|
|
169
169
|
// Track maximum height needed across all levels
|
|
170
170
|
let maxLevelHeight = 100;
|
|
@@ -276,105 +276,25 @@ export function renderSankey(config) {
|
|
|
276
276
|
return aTargetLevel - bTargetLevel;
|
|
277
277
|
});
|
|
278
278
|
|
|
279
|
-
// Calculate flow heights
|
|
279
|
+
// Calculate flow heights proportional to each end's node height.
|
|
280
|
+
// Flows taper between source and target, naturally filling both nodes.
|
|
280
281
|
const flowData = aggregatedEdges.map((edge, index) => {
|
|
281
282
|
const sourcePos = nodePosition.get(edge.source);
|
|
282
283
|
const targetPos = nodePosition.get(edge.target);
|
|
283
284
|
const sourceThroughput = nodeThroughput.get(edge.source);
|
|
284
285
|
const targetThroughput = nodeThroughput.get(edge.target);
|
|
285
286
|
|
|
286
|
-
// Flow height proportional to value at each end
|
|
287
|
-
const flowHeightSource = (edge.value / sourceThroughput) * sourcePos.height;
|
|
288
|
-
const flowHeightTarget = (edge.value / targetThroughput) * targetPos.height;
|
|
289
|
-
|
|
290
|
-
// Use max for consistent height across the flow
|
|
291
|
-
const height = Math.max(flowHeightSource, flowHeightTarget);
|
|
292
|
-
|
|
293
287
|
return {
|
|
294
288
|
...edge,
|
|
295
289
|
fromLevel: sourcePos.level,
|
|
296
290
|
toLevel: targetPos.level,
|
|
297
|
-
height,
|
|
291
|
+
fromHeight: (edge.value / sourceThroughput) * sourcePos.height,
|
|
292
|
+
toHeight: (edge.value / targetThroughput) * targetPos.height,
|
|
298
293
|
index
|
|
299
294
|
};
|
|
300
295
|
});
|
|
301
296
|
|
|
302
|
-
//
|
|
303
|
-
const nodeOutTotal = new Map(); // Total outflow heights per node
|
|
304
|
-
const nodeInTotal = new Map(); // Total inflow heights per node
|
|
305
|
-
flowData.forEach(f => {
|
|
306
|
-
nodeOutTotal.set(f.source, (nodeOutTotal.get(f.source) || 0) + f.height);
|
|
307
|
-
nodeInTotal.set(f.target, (nodeInTotal.get(f.target) || 0) + f.height);
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
// Expand nodes that need more height for their flows
|
|
311
|
-
let needsRepositioning = false;
|
|
312
|
-
nodes.forEach(node => {
|
|
313
|
-
const pos = nodePosition.get(node);
|
|
314
|
-
const outTotal = nodeOutTotal.get(node) || 0;
|
|
315
|
-
const inTotal = nodeInTotal.get(node) || 0;
|
|
316
|
-
const neededHeight = Math.max(outTotal, inTotal);
|
|
317
|
-
if (neededHeight > pos.height) {
|
|
318
|
-
pos.height = neededHeight;
|
|
319
|
-
needsRepositioning = true;
|
|
320
|
-
}
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
// Reposition nodes within levels if any heights changed
|
|
324
|
-
if (needsRepositioning) {
|
|
325
|
-
levels.forEach((levelNodes, levelIndex) => {
|
|
326
|
-
let currentTop = 0;
|
|
327
|
-
levelNodes.forEach(node => {
|
|
328
|
-
const pos = nodePosition.get(node);
|
|
329
|
-
pos.top = currentTop;
|
|
330
|
-
currentTop += pos.height + paddingPct;
|
|
331
|
-
});
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
// Recalculate maxLevelHeight and scale
|
|
335
|
-
let newMaxLevelHeight = 100;
|
|
336
|
-
levels.forEach(levelNodes => {
|
|
337
|
-
if (levelNodes.length === 0) return;
|
|
338
|
-
let maxBottom = 0;
|
|
339
|
-
levelNodes.forEach(node => {
|
|
340
|
-
const pos = nodePosition.get(node);
|
|
341
|
-
const bottom = pos.top + pos.height;
|
|
342
|
-
if (bottom > maxBottom) maxBottom = bottom;
|
|
343
|
-
});
|
|
344
|
-
if (maxBottom > newMaxLevelHeight) newMaxLevelHeight = maxBottom;
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
// Scale if needed
|
|
348
|
-
if (newMaxLevelHeight > 100) {
|
|
349
|
-
const scale = newMaxLevelHeight / 100;
|
|
350
|
-
nodePosition.forEach(pos => {
|
|
351
|
-
pos.top = pos.top / scale;
|
|
352
|
-
pos.height = pos.height / scale;
|
|
353
|
-
});
|
|
354
|
-
flowData.forEach(f => {
|
|
355
|
-
f.height = f.height / scale;
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// Re-center levels
|
|
360
|
-
levels.forEach(levelNodes => {
|
|
361
|
-
if (levelNodes.length === 0) return;
|
|
362
|
-
let maxBottom = 0;
|
|
363
|
-
levelNodes.forEach(node => {
|
|
364
|
-
const pos = nodePosition.get(node);
|
|
365
|
-
const bottom = pos.top + pos.height;
|
|
366
|
-
if (bottom > maxBottom) maxBottom = bottom;
|
|
367
|
-
});
|
|
368
|
-
const centerOffset = (100 - maxBottom) / 2;
|
|
369
|
-
if (centerOffset > 0) {
|
|
370
|
-
levelNodes.forEach(node => {
|
|
371
|
-
nodePosition.get(node).top += centerOffset;
|
|
372
|
-
});
|
|
373
|
-
}
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// Calculate flow positions with consistent heights
|
|
297
|
+
// Calculate flow positions within each node
|
|
378
298
|
const nodeOutOffset = new Map();
|
|
379
299
|
const nodeInOffset = new Map();
|
|
380
300
|
nodes.forEach(n => {
|
|
@@ -389,8 +309,8 @@ export function renderSankey(config) {
|
|
|
389
309
|
const sourceOffset = nodeOutOffset.get(f.source);
|
|
390
310
|
const targetOffset = nodeInOffset.get(f.target);
|
|
391
311
|
|
|
392
|
-
nodeOutOffset.set(f.source, sourceOffset + f.
|
|
393
|
-
nodeInOffset.set(f.target, targetOffset + f.
|
|
312
|
+
nodeOutOffset.set(f.source, sourceOffset + f.fromHeight);
|
|
313
|
+
nodeInOffset.set(f.target, targetOffset + f.toHeight);
|
|
394
314
|
|
|
395
315
|
return {
|
|
396
316
|
...f,
|
|
@@ -495,21 +415,28 @@ export function renderSankey(config) {
|
|
|
495
415
|
html += `</figcaption>`;
|
|
496
416
|
}
|
|
497
417
|
|
|
498
|
-
//
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
418
|
+
// Build neighbor map for node hover highlighting
|
|
419
|
+
const nodeNeighbors = new Map();
|
|
420
|
+
nodes.forEach(n => nodeNeighbors.set(n, new Set()));
|
|
421
|
+
aggregatedEdges.forEach(({ source, target }) => {
|
|
422
|
+
nodeNeighbors.get(source).add(target);
|
|
423
|
+
nodeNeighbors.get(target).add(source);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// Generate per-node hover rules: brighten connected flows and nodes, dim the rest
|
|
427
|
+
html += `<style>`;
|
|
428
|
+
nodes.forEach(node => {
|
|
429
|
+
const slug = slugify(node);
|
|
430
|
+
const prefix = `.chart-sankey-container:has(.chart-series-${slug}:hover)`;
|
|
431
|
+
// Brighten connected flows
|
|
432
|
+
html += `${prefix} .chart-flow-${slug} path{opacity:0.8}`;
|
|
433
|
+
// Brighten hovered node + its neighbors
|
|
434
|
+
html += `${prefix} .chart-series-${slug}{opacity:1}`;
|
|
435
|
+
nodeNeighbors.get(node).forEach(neighbor => {
|
|
436
|
+
html += `${prefix} .chart-series-${slugify(neighbor)}{opacity:1}`;
|
|
510
437
|
});
|
|
511
|
-
|
|
512
|
-
|
|
438
|
+
});
|
|
439
|
+
html += `</style>`;
|
|
513
440
|
|
|
514
441
|
html += `<div class="chart-sankey-container">`;
|
|
515
442
|
|
|
@@ -527,19 +454,23 @@ export function renderSankey(config) {
|
|
|
527
454
|
// SVG path coordinates (0-100 viewBox)
|
|
528
455
|
const y1 = flow.fromTop;
|
|
529
456
|
const y2 = flow.toTop;
|
|
530
|
-
const
|
|
457
|
+
const fh = flow.fromHeight;
|
|
458
|
+
const th = flow.toHeight;
|
|
531
459
|
|
|
532
460
|
// Bezier control points at 40% and 60% for smooth S-curve
|
|
533
461
|
const cx1 = 40;
|
|
534
462
|
const cx2 = 60;
|
|
535
463
|
|
|
536
464
|
// Path: top edge (left to right with curve), then bottom edge (right to left with curve)
|
|
465
|
+
// Flow tapers between fromHeight at source and toHeight at target
|
|
537
466
|
// Extend slightly past 0/100 to overlap with node columns and prevent subpixel gaps
|
|
538
467
|
const x0 = -2;
|
|
539
468
|
const x1end = 102;
|
|
540
|
-
const pathD = `M ${x0},${y1.toFixed(2)} C ${cx1},${y1.toFixed(2)} ${cx2},${y2.toFixed(2)} ${x1end},${y2.toFixed(2)} L ${x1end},${(y2 +
|
|
469
|
+
const pathD = `M ${x0},${y1.toFixed(2)} C ${cx1},${y1.toFixed(2)} ${cx2},${y2.toFixed(2)} ${x1end},${y2.toFixed(2)} L ${x1end},${(y2 + th).toFixed(2)} C ${cx2},${(y2 + th).toFixed(2)} ${cx1},${(y1 + fh).toFixed(2)} ${x0},${(y1 + fh).toFixed(2)} Z`;
|
|
541
470
|
|
|
542
|
-
|
|
471
|
+
const sourceSlug = slugify(flow.source);
|
|
472
|
+
const targetSlug = slugify(flow.target);
|
|
473
|
+
html += `<svg class="chart-sankey-flow chart-flow-${sourceSlug} chart-flow-${targetSlug}" viewBox="0 0 100 100" preserveAspectRatio="none" `;
|
|
543
474
|
html += `style="grid-column: ${colStart} / ${colEnd}; --from-level: ${flow.fromLevel}; --flow-index: ${i}; --delay-step: ${delayStep.toFixed(3)}s">`;
|
|
544
475
|
html += `<defs><linearGradient id="sankey-grad-${id || 'default'}-${i}">`;
|
|
545
476
|
html += `<stop offset="0%" style="stop-color: var(--chart-color-${sourceColor})" />`;
|
|
@@ -574,6 +505,23 @@ export function renderSankey(config) {
|
|
|
574
505
|
});
|
|
575
506
|
|
|
576
507
|
html += `</div>`;
|
|
508
|
+
|
|
509
|
+
// Legend (optional)
|
|
510
|
+
if (legend) {
|
|
511
|
+
html += `<ul class="chart-legend">`;
|
|
512
|
+
nodes.forEach((node, i) => {
|
|
513
|
+
const colorClass = `chart-color-${nodeColors.get(node)}`;
|
|
514
|
+
const seriesClass = `chart-series-${slugify(node)}`;
|
|
515
|
+
const throughput = nodeThroughput.get(node);
|
|
516
|
+
html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(node)}`;
|
|
517
|
+
if (format) {
|
|
518
|
+
html += ` <span class="legend-value">${formatNumber(throughput, format) || throughput}</span>`;
|
|
519
|
+
}
|
|
520
|
+
html += `</li>`;
|
|
521
|
+
});
|
|
522
|
+
html += `</ul>`;
|
|
523
|
+
}
|
|
524
|
+
|
|
577
525
|
html += renderDownloadLink(downloadDataUrl, downloadData);
|
|
578
526
|
html += `</figure>`;
|
|
579
527
|
|
package/src/renderers/scatter.js
CHANGED
|
@@ -6,19 +6,21 @@ import { formatNumber } from '../formatters.js';
|
|
|
6
6
|
* @param {Object} config - Chart configuration
|
|
7
7
|
* @param {string} config.title - Chart title
|
|
8
8
|
* @param {string} [config.subtitle] - Chart subtitle
|
|
9
|
-
* @param {Object[]} config.data - Chart data (
|
|
9
|
+
* @param {Object[]} config.data - Chart data (label + named columns: x, y, size, series)
|
|
10
10
|
* @param {number} [config.maxX] - Maximum X value (defaults to max in data)
|
|
11
11
|
* @param {number} [config.maxY] - Maximum Y value (defaults to max in data)
|
|
12
12
|
* @param {number} [config.minX] - Minimum X value (defaults to min in data or 0)
|
|
13
13
|
* @param {number} [config.minY] - Minimum Y value (defaults to min in data or 0)
|
|
14
14
|
* @param {string[]} [config.legend] - Legend labels for series
|
|
15
|
+
* @param {string} [config.legendTitle] - Title for series legend
|
|
16
|
+
* @param {string} [config.sizeTitle] - Title for size legend (enables size legend display)
|
|
15
17
|
* @param {boolean} [config.animate] - Enable animations
|
|
16
18
|
* @param {string} [config.titleX] - X-axis title (defaults to column name)
|
|
17
19
|
* @param {string} [config.titleY] - Y-axis title (defaults to column name)
|
|
18
20
|
* @returns {string} - HTML string
|
|
19
21
|
*/
|
|
20
22
|
export function renderScatter(config) {
|
|
21
|
-
const { title, subtitle, data, maxX, maxY, minX, minY, legend, animate, format, titleX, titleY, id, downloadData, downloadDataUrl } = config;
|
|
23
|
+
const { title, subtitle, data, maxX, maxY, minX, minY, legend, legendTitle, sizeTitle, animate, format, titleX, titleY, id, downloadData, downloadDataUrl, proportional } = config;
|
|
22
24
|
|
|
23
25
|
// Handle nested X/Y format for scatter charts
|
|
24
26
|
const fmtX = format?.x || format || {};
|
|
@@ -30,25 +32,52 @@ export function renderScatter(config) {
|
|
|
30
32
|
|
|
31
33
|
const animateClass = animate ? ' chart-animate' : '';
|
|
32
34
|
|
|
33
|
-
//
|
|
35
|
+
// Named column detection (case-insensitive), with positional fallback for x/y
|
|
34
36
|
const keys = Object.keys(data[0]);
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
37
|
+
const findKey = name => keys.find(k => k.toLowerCase() === name) || null;
|
|
38
|
+
|
|
39
|
+
// First column is always label
|
|
40
|
+
const labelKey = keys[0];
|
|
41
|
+
|
|
42
|
+
// X and Y: named if both exist, otherwise positional (columns 2 and 3)
|
|
43
|
+
const namedX = findKey('x');
|
|
44
|
+
const namedY = findKey('y');
|
|
45
|
+
const xKey = (namedX && namedY) ? namedX : keys[1];
|
|
46
|
+
const yKey = (namedX && namedY) ? namedY : keys[2];
|
|
47
|
+
|
|
48
|
+
// Size and series: named only (no positional fallback)
|
|
49
|
+
const sizeKey = findKey('size');
|
|
50
|
+
const seriesKey = findKey('series');
|
|
39
51
|
|
|
40
52
|
// Axis titles: explicit config overrides column names
|
|
41
53
|
const xAxisTitle = titleX ?? xKey;
|
|
42
54
|
const yAxisTitle = titleY ?? yKey;
|
|
43
55
|
|
|
44
|
-
// Map data to dots
|
|
56
|
+
// Map data to dots
|
|
45
57
|
const dots = data.map(item => ({
|
|
46
58
|
label: item[labelKey] ?? '',
|
|
47
59
|
x: typeof item[xKey] === 'number' ? item[xKey] : parseFloat(item[xKey]) || 0,
|
|
48
60
|
y: typeof item[yKey] === 'number' ? item[yKey] : parseFloat(item[yKey]) || 0,
|
|
61
|
+
rawSize: sizeKey ? (typeof item[sizeKey] === 'number' ? item[sizeKey] : parseFloat(item[sizeKey]) || 0) : null,
|
|
49
62
|
series: seriesKey ? (item[seriesKey] ?? 'default') : 'default'
|
|
50
63
|
}));
|
|
51
64
|
|
|
65
|
+
// Size normalization: non-positive values get minimum size (scale 0)
|
|
66
|
+
if (sizeKey) {
|
|
67
|
+
const sizeValues = dots.map(d => d.rawSize).filter(v => v > 0);
|
|
68
|
+
const minSizeVal = sizeValues.length ? Math.min(...sizeValues) : 1;
|
|
69
|
+
const maxSizeVal = sizeValues.length ? Math.max(...sizeValues) : 1;
|
|
70
|
+
const sizeRange = maxSizeVal - minSizeVal;
|
|
71
|
+
|
|
72
|
+
dots.forEach(dot => {
|
|
73
|
+
if (dot.rawSize <= 0 || sizeRange === 0) {
|
|
74
|
+
dot.sizeScale = 0;
|
|
75
|
+
} else {
|
|
76
|
+
dot.sizeScale = (dot.rawSize - minSizeVal) / sizeRange;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
52
81
|
// Calculate bounds
|
|
53
82
|
const xValues = dots.map(d => d.x);
|
|
54
83
|
const yValues = dots.map(d => d.y);
|
|
@@ -63,6 +92,7 @@ export function renderScatter(config) {
|
|
|
63
92
|
const calcMinY = minY ?? (dataMinY < 0 ? dataMinY : 0);
|
|
64
93
|
const rangeX = calcMaxX - calcMinX;
|
|
65
94
|
const rangeY = calcMaxY - calcMinY;
|
|
95
|
+
const dataAspectRatio = rangeY > 0 ? rangeX / rangeY : 1;
|
|
66
96
|
|
|
67
97
|
const hasNegativeX = calcMinX < 0;
|
|
68
98
|
const hasNegativeY = calcMinY < 0;
|
|
@@ -77,8 +107,9 @@ export function renderScatter(config) {
|
|
|
77
107
|
const seriesIndex = new Map(seriesList.map((s, i) => [s, i]));
|
|
78
108
|
|
|
79
109
|
const negativeClasses = (hasNegativeX ? ' has-negative-x' : '') + (hasNegativeY ? ' has-negative-y' : '');
|
|
110
|
+
const proportionalClass = proportional ? ' chart-proportional' : '';
|
|
80
111
|
const idClass = id ? ` chart-${id}` : '';
|
|
81
|
-
let html = `<figure class="chart chart-scatter${animateClass}${negativeClasses}${idClass}">`;
|
|
112
|
+
let html = `<figure class="chart chart-scatter${animateClass}${negativeClasses}${proportionalClass}${idClass}">`;
|
|
82
113
|
|
|
83
114
|
if (title) {
|
|
84
115
|
html += `<figcaption class="chart-title">${escapeHtml(title)}`;
|
|
@@ -88,19 +119,6 @@ export function renderScatter(config) {
|
|
|
88
119
|
html += `</figcaption>`;
|
|
89
120
|
}
|
|
90
121
|
|
|
91
|
-
// Legend (if multiple series)
|
|
92
|
-
if (seriesList.length > 1 || legend) {
|
|
93
|
-
const legendLabels = legend ?? seriesList;
|
|
94
|
-
html += `<ul class="chart-legend">`;
|
|
95
|
-
seriesList.forEach((series, i) => {
|
|
96
|
-
const label = legendLabels[i] ?? series;
|
|
97
|
-
const colorClass = `chart-color-${i + 1}`;
|
|
98
|
-
const seriesClass = `chart-series-${slugify(series)}`;
|
|
99
|
-
html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</li>`;
|
|
100
|
-
});
|
|
101
|
-
html += `</ul>`;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
122
|
html += `<div class="chart-body">`;
|
|
105
123
|
|
|
106
124
|
// Y-axis
|
|
@@ -119,9 +137,11 @@ export function renderScatter(config) {
|
|
|
119
137
|
if (hasNegativeY) containerStyles.push(`--zero-position-y: ${zeroPctY.toFixed(2)}%`);
|
|
120
138
|
const containerStyle = containerStyles.length > 0 ? ` style="${containerStyles.join('; ')}"` : '';
|
|
121
139
|
html += `<div class="scatter-container"${containerStyle}>`;
|
|
122
|
-
|
|
140
|
+
const dotAreaStyle = proportional ? ` style="--data-aspect-ratio: ${dataAspectRatio.toFixed(4)}"` : '';
|
|
141
|
+
html += `<div class="dot-area"${dotAreaStyle}>`;
|
|
123
142
|
html += `<div class="dot-field">`;
|
|
124
143
|
|
|
144
|
+
const fmtSize = format?.size || {};
|
|
125
145
|
dots.forEach((dot, i) => {
|
|
126
146
|
const xPct = rangeX > 0 ? ((dot.x - calcMinX) / rangeX) * 100 : 0;
|
|
127
147
|
const yPct = rangeY > 0 ? ((dot.y - calcMinY) / rangeY) * 100 : 0;
|
|
@@ -130,10 +150,22 @@ export function renderScatter(config) {
|
|
|
130
150
|
const seriesClass = `chart-series-${slugify(dot.series)}`;
|
|
131
151
|
const fmtXVal = formatNumber(dot.x, fmtX) || dot.x;
|
|
132
152
|
const fmtYVal = formatNumber(dot.y, fmtY) || dot.y;
|
|
133
|
-
|
|
153
|
+
|
|
154
|
+
// Build tooltip with optional size value
|
|
155
|
+
let tooltipText = dot.label ? `${dot.label}: (${fmtXVal}, ${fmtYVal})` : `(${fmtXVal}, ${fmtYVal})`;
|
|
156
|
+
if (sizeKey && dot.rawSize !== null) {
|
|
157
|
+
const fmtSizeVal = formatNumber(dot.rawSize, fmtSize) || dot.rawSize;
|
|
158
|
+
tooltipText += ` [${fmtSizeVal}]`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Build style string with optional size scale
|
|
162
|
+
let styleStr = `--dot-index: ${i}; --x: ${xPct.toFixed(2)}%; --value: ${yPct.toFixed(2)}%`;
|
|
163
|
+
if (sizeKey) {
|
|
164
|
+
styleStr += `; --size-scale: ${dot.sizeScale.toFixed(4)}`;
|
|
165
|
+
}
|
|
134
166
|
|
|
135
167
|
html += `<div class="dot ${colorClass} ${seriesClass}" `;
|
|
136
|
-
html += `style="
|
|
168
|
+
html += `style="${styleStr}" `;
|
|
137
169
|
html += `title="${escapeHtml(tooltipText)}"`;
|
|
138
170
|
html += `></div>`;
|
|
139
171
|
});
|
|
@@ -153,6 +185,41 @@ export function renderScatter(config) {
|
|
|
153
185
|
|
|
154
186
|
html += `</div>`;
|
|
155
187
|
html += `</div>`;
|
|
188
|
+
|
|
189
|
+
// Legend (if multiple series or legendTitle specified)
|
|
190
|
+
if (seriesList.length > 1 || legend || legendTitle) {
|
|
191
|
+
const legendLabels = legend ?? seriesList;
|
|
192
|
+
if (legendTitle) {
|
|
193
|
+
html += `<span class="chart-legend-title">${escapeHtml(legendTitle)}</span>`;
|
|
194
|
+
}
|
|
195
|
+
html += `<ul class="chart-legend">`;
|
|
196
|
+
seriesList.forEach((series, i) => {
|
|
197
|
+
const label = legendLabels[i] ?? series;
|
|
198
|
+
const colorClass = `chart-color-${i + 1}`;
|
|
199
|
+
const seriesClass = `chart-series-${slugify(series)}`;
|
|
200
|
+
html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</li>`;
|
|
201
|
+
});
|
|
202
|
+
html += `</ul>`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Size legend (when sizeTitle is specified and size column exists)
|
|
206
|
+
if (sizeTitle && sizeKey) {
|
|
207
|
+
const sizeValues = dots.map(d => d.rawSize).filter(v => v > 0);
|
|
208
|
+
const minSizeVal = sizeValues.length ? Math.min(...sizeValues) : 0;
|
|
209
|
+
const maxSizeVal = sizeValues.length ? Math.max(...sizeValues) : 0;
|
|
210
|
+
const fmtSizeLegend = format?.size || format || {};
|
|
211
|
+
const minFormatted = formatNumber(minSizeVal, fmtSizeLegend) || minSizeVal;
|
|
212
|
+
const maxFormatted = formatNumber(maxSizeVal, fmtSizeLegend) || maxSizeVal;
|
|
213
|
+
|
|
214
|
+
html += `<div class="chart-size-legend">`;
|
|
215
|
+
html += `<span class="chart-legend-title">${escapeHtml(sizeTitle)}</span>`;
|
|
216
|
+
html += `<div class="size-legend-items">`;
|
|
217
|
+
html += `<span class="size-legend-item"><span class="size-dot size-dot-min"></span><span class="size-value">${minFormatted}</span></span>`;
|
|
218
|
+
html += `<span class="size-legend-item"><span class="size-dot size-dot-max"></span><span class="size-value">${maxFormatted}</span></span>`;
|
|
219
|
+
html += `</div>`;
|
|
220
|
+
html += `</div>`;
|
|
221
|
+
}
|
|
222
|
+
|
|
156
223
|
html += renderDownloadLink(downloadDataUrl, downloadData);
|
|
157
224
|
html += `</figure>`;
|
|
158
225
|
|
|
@@ -13,7 +13,7 @@ import { formatNumber } from '../formatters.js';
|
|
|
13
13
|
* @returns {string} - HTML string
|
|
14
14
|
*/
|
|
15
15
|
export function renderStackedBar(config) {
|
|
16
|
-
const { title, subtitle, data, max, legend, animate, format, id, downloadData, downloadDataUrl } = config;
|
|
16
|
+
const { title, subtitle, data, max, legend, legendTitle, animate, format, id, downloadData, downloadDataUrl } = config;
|
|
17
17
|
|
|
18
18
|
if (!data || data.length === 0) {
|
|
19
19
|
return `<!-- Stacked bar chart: no data provided -->`;
|
|
@@ -46,7 +46,10 @@ export function renderStackedBar(config) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
// Legend
|
|
49
|
-
if (seriesKeys.length > 0) {
|
|
49
|
+
if (seriesKeys.length > 0 || legendTitle) {
|
|
50
|
+
if (legendTitle) {
|
|
51
|
+
html += `<span class="chart-legend-title">${escapeHtml(legendTitle)}</span>`;
|
|
52
|
+
}
|
|
50
53
|
html += `<ul class="chart-legend">`;
|
|
51
54
|
seriesKeys.forEach((key, i) => {
|
|
52
55
|
const label = legendLabels[i] ?? key;
|
|
@@ -67,18 +67,6 @@ export function renderStackedColumn(config) {
|
|
|
67
67
|
html += `</figcaption>`;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
// Legend
|
|
71
|
-
if (seriesKeys.length > 0) {
|
|
72
|
-
html += `<ul class="chart-legend">`;
|
|
73
|
-
seriesKeys.forEach((key, i) => {
|
|
74
|
-
const label = legendLabels[i] ?? key;
|
|
75
|
-
const colorClass = `chart-color-${i + 1}`;
|
|
76
|
-
const seriesClass = `chart-series-${slugify(key)}`;
|
|
77
|
-
html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</li>`;
|
|
78
|
-
});
|
|
79
|
-
html += `</ul>`;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
70
|
html += `<div class="chart-body">`;
|
|
83
71
|
|
|
84
72
|
// Y-axis with --zero-position for label positioning
|
|
@@ -192,6 +180,19 @@ export function renderStackedColumn(config) {
|
|
|
192
180
|
|
|
193
181
|
html += `</div>`; // close chart-scroll
|
|
194
182
|
html += `</div>`; // close chart-body
|
|
183
|
+
|
|
184
|
+
// Legend
|
|
185
|
+
if (seriesKeys.length > 0) {
|
|
186
|
+
html += `<ul class="chart-legend">`;
|
|
187
|
+
seriesKeys.forEach((key, i) => {
|
|
188
|
+
const label = legendLabels[i] ?? key;
|
|
189
|
+
const colorClass = `chart-color-${i + 1}`;
|
|
190
|
+
const seriesClass = `chart-series-${slugify(key)}`;
|
|
191
|
+
html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">${escapeHtml(label)}</li>`;
|
|
192
|
+
});
|
|
193
|
+
html += `</ul>`;
|
|
194
|
+
}
|
|
195
|
+
|
|
195
196
|
html += renderDownloadLink(downloadDataUrl, downloadData);
|
|
196
197
|
html += `</figure>`;
|
|
197
198
|
|