eleventy-plugin-uncharted 0.1.2 → 0.2.1

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/README.md CHANGED
@@ -48,6 +48,61 @@ If you set `injectCss: false`, you'll need to manually include the stylesheet in
48
48
  | `dot` | Categorical dot chart with Y-axis positioning | Yes |
49
49
  | `scatter` | XY scatter plot with continuous axes | Yes (X and Y) |
50
50
 
51
+ ## Value Formatting
52
+
53
+ Format displayed numbers with thousands separators, compact notation, or currency symbols.
54
+ Raw values are preserved for calculations; only display output is affected.
55
+
56
+ ### Options
57
+
58
+ | Option | Type | Description |
59
+ |--------|------|-------------|
60
+ | `thousands` | boolean | Add commas: `1000` → `1,000` |
61
+ | `compact` | boolean | Use suffixes: `1000` → `1K`, `1000000` → `1M` |
62
+ | `decimals` | number | Decimal places (default: 0, or 1 if compact) |
63
+ | `currency.symbol` | string | Currency symbol: `$`, `€`, etc. |
64
+ | `currency.position` | string | `prefix` (default) or `suffix` |
65
+
66
+ ### Examples
67
+
68
+ **Thousands separators:**
69
+
70
+ ```yaml
71
+ format:
72
+ thousands: true
73
+ # 1234567 → 1,234,567
74
+ ```
75
+
76
+ **Compact notation:**
77
+
78
+ ```yaml
79
+ format:
80
+ compact: true
81
+ # 1500 → 1.5K, 2000000 → 2M
82
+ ```
83
+
84
+ **Currency:**
85
+
86
+ ```yaml
87
+ format:
88
+ thousands: true
89
+ currency:
90
+ symbol: "$"
91
+ # 1234 → $1,234
92
+ ```
93
+
94
+ **Scatter charts** support separate X/Y formatting:
95
+
96
+ ```yaml
97
+ format:
98
+ x:
99
+ thousands: true
100
+ y:
101
+ compact: true
102
+ currency:
103
+ symbol: "$"
104
+ ```
105
+
51
106
  ## Usage
52
107
 
53
108
  ### Page Frontmatter
@@ -119,13 +174,24 @@ Sales,16,2
119
174
  Core,8,0
120
175
  ```
121
176
 
122
- For scatter plots, use `label`, `x`, `y`, and optionally `series`:
177
+ For scatter plots, columns are positional: point label, X value, Y value, and optionally series. Column names become axis labels by default:
123
178
 
124
179
  ```csv
125
- label,x,y,series
126
- Point A,10,45,alpha
127
- Point B,25,78,alpha
128
- Point C,15,32,beta
180
+ country,population,gdp,region
181
+ USA,330,21,Americas
182
+ China,1400,14,Asia
183
+ Germany,83,4,Europe
184
+ ```
185
+
186
+ This displays "population" as the X-axis title and "gdp" as the Y-axis title. Override with explicit titles:
187
+
188
+ ```yaml
189
+ charts:
190
+ my-scatter:
191
+ type: scatter
192
+ file: charts/data.csv
193
+ titleX: "Population (millions)"
194
+ titleY: "GDP (trillions)"
129
195
  ```
130
196
 
131
197
  ## Negative Values
@@ -159,15 +225,20 @@ The chart automatically calculates the range from the maximum positive stack to
159
225
  | `maxY` | number | Maximum Y value (scatter only) |
160
226
  | `minX` | number | Minimum X value (scatter only) |
161
227
  | `minY` | number | Minimum Y value (scatter only) |
228
+ | `titleX` | string | X-axis title (scatter only, defaults to column name) |
229
+ | `titleY` | string | Y-axis title (scatter only, defaults to column name) |
162
230
  | `legend` | array | Custom legend labels |
163
231
  | `center` | object | Donut center content (`value`, `label`) |
232
+ | `showPercentages` | boolean | Show percentages instead of values in donut legend |
164
233
  | `animate` | boolean | Override global animation setting |
234
+ | `format` | object | Number formatting options (see Value Formatting) |
235
+ | `rotateLabels` | boolean | Rotate X-axis labels vertically (stacked-column, dot) |
165
236
 
166
237
  ## Styling
167
238
 
168
239
  ### CSS Custom Properties
169
240
 
170
- Override the default color palette:
241
+ Override the default color palette and sizing:
171
242
 
172
243
  ```css
173
244
  :root {
@@ -180,6 +251,29 @@ Override the default color palette:
180
251
  --chart-color-7: #009688;
181
252
  --chart-color-8: #78909c;
182
253
  --chart-bg: rgba(128, 128, 128, 0.15);
254
+ --chart-height: 12rem; /* Height of bar/column/dot/scatter charts */
255
+ --chart-column-width: 1rem; /* Min width per column */
256
+ --chart-donut-size: 12rem; /* Donut chart diameter */
257
+ --chart-donut-thickness: 2.5rem; /* Donut ring thickness */
258
+ }
259
+ ```
260
+
261
+ ### Per-Chart Styling
262
+
263
+ Each chart gets a class based on its ID for targeted styling:
264
+
265
+ ```yaml
266
+ charts:
267
+ sales-growth:
268
+ type: stacked-column
269
+ file: charts/sales.csv
270
+ ```
271
+
272
+ ```css
273
+ /* Target this specific chart */
274
+ .chart-sales-growth {
275
+ --chart-height: 16rem;
276
+ --chart-color-1: #ff6b6b;
183
277
  }
184
278
  ```
185
279
 
package/css/uncharted.css CHANGED
@@ -14,7 +14,11 @@
14
14
  --chart-color-5: #009688; /* Teal */
15
15
  --chart-color-6: #9c27b0; /* Purple */
16
16
  --chart-color-7: #e91e63; /* Pink */
17
- --chart-color-8: #78909c; /* Gray */
17
+ --chart-color-8: #3f51b5; /* Indigo */
18
+ --chart-color-9: #f44336; /* Red */
19
+ --chart-color-10: #00bcd4; /* Cyan */
20
+ --chart-color-11: #cddc39; /* Lime */
21
+ --chart-color-12: #78909c; /* Gray */
18
22
 
19
23
  /* Backgrounds - neutral with opacity for light/dark adaptability */
20
24
  --chart-bg: rgba(128, 128, 128, 0.15);
@@ -22,10 +26,11 @@
22
26
  /* Spacing and sizing */
23
27
  --chart-gap: 0.5rem;
24
28
  --chart-bar-height: 1.5rem;
25
- --chart-column-width: 2.5rem;
29
+ --chart-column-width: 1rem;
26
30
  --chart-donut-size: 12rem;
27
31
  --chart-donut-thickness: 2.5rem;
28
32
  --chart-dot-size: 0.75rem;
33
+ --chart-height: 12rem;
29
34
  }
30
35
 
31
36
  /* ==========================================================================
@@ -40,6 +45,10 @@
40
45
  .chart-color-6 { --color: var(--chart-color-6); background-color: var(--chart-color-6); }
41
46
  .chart-color-7 { --color: var(--chart-color-7); background-color: var(--chart-color-7); }
42
47
  .chart-color-8 { --color: var(--chart-color-8); background-color: var(--chart-color-8); }
48
+ .chart-color-9 { --color: var(--chart-color-9); background-color: var(--chart-color-9); }
49
+ .chart-color-10 { --color: var(--chart-color-10); background-color: var(--chart-color-10); }
50
+ .chart-color-11 { --color: var(--chart-color-11); background-color: var(--chart-color-11); }
51
+ .chart-color-12 { --color: var(--chart-color-12); background-color: var(--chart-color-12); }
43
52
 
44
53
  /* ==========================================================================
45
54
  Base Chart Styles
@@ -71,7 +80,8 @@
71
80
  .chart-legend {
72
81
  display: flex;
73
82
  flex-wrap: wrap;
74
- gap: 1rem;
83
+ column-gap: 1rem;
84
+ row-gap: 0.375rem;
75
85
  list-style: none;
76
86
  padding: 0;
77
87
  margin: 0 0 1rem 0;
@@ -115,13 +125,14 @@
115
125
  }
116
126
 
117
127
  .chart-y-axis {
128
+ position: relative;
118
129
  display: flex;
119
130
  flex-direction: column;
120
131
  justify-content: space-between;
121
132
  align-items: flex-end;
122
133
  min-width: 2rem;
123
134
  box-sizing: border-box;
124
- height: 12rem;
135
+ height: var(--chart-height);
125
136
  padding-top: 0.5rem;
126
137
  padding-bottom: 0;
127
138
  }
@@ -137,12 +148,24 @@
137
148
  transform: translateY(-50%);
138
149
  }
139
150
 
140
- .chart-y-axis .axis-label:last-child {
151
+ .chart-y-axis .axis-label:nth-child(3) {
141
152
  transform: translateY(50%);
142
153
  }
143
154
 
155
+ .chart-y-axis .axis-title {
156
+ position: absolute;
157
+ left: -0.5rem;
158
+ top: 50%;
159
+ transform: rotate(-90deg) translateX(-50%);
160
+ transform-origin: left center;
161
+ font-size: 0.7rem;
162
+ opacity: 0.6;
163
+ white-space: nowrap;
164
+ }
165
+
144
166
  .chart-x-axis {
145
167
  display: flex;
168
+ flex-wrap: wrap;
146
169
  justify-content: space-between;
147
170
  padding: 0.25rem 0;
148
171
  margin-top: 0.25rem;
@@ -159,25 +182,32 @@
159
182
  transform: translateX(-50%);
160
183
  }
161
184
 
162
- .chart-x-axis .axis-label:last-child {
185
+ .chart-x-axis .axis-label:nth-child(3) {
163
186
  transform: translateX(50%);
164
187
  }
165
188
 
189
+ .chart-x-axis .axis-title {
190
+ flex-basis: 100%;
191
+ text-align: center;
192
+ font-size: 0.7rem;
193
+ opacity: 0.6;
194
+ white-space: nowrap;
195
+ margin-top: 0.5rem;
196
+ }
197
+
166
198
  /* ==========================================================================
167
199
  Stacked Bar Chart (Horizontal)
168
200
  ========================================================================== */
169
201
 
170
202
  .chart-stacked-bar .chart-bars {
171
- display: flex;
172
- flex-direction: column;
173
- gap: var(--chart-gap);
203
+ display: grid;
204
+ grid-template-columns: auto 1fr auto;
205
+ gap: var(--chart-gap) 0.75rem;
206
+ align-items: center;
174
207
  }
175
208
 
176
209
  .chart-stacked-bar .bar-row {
177
- display: grid;
178
- grid-template-columns: minmax(6rem, auto) 1fr auto;
179
- align-items: center;
180
- gap: 0.75rem;
210
+ display: contents; /* Children participate in parent grid */
181
211
  }
182
212
 
183
213
  .chart-stacked-bar .bar-label {
@@ -218,14 +248,31 @@
218
248
  align-items: stretch;
219
249
  }
220
250
 
221
- .chart-stacked-column .chart-columns {
222
- display: flex;
223
- gap: var(--chart-gap);
224
- align-items: stretch;
225
- height: 12rem;
251
+ .chart-stacked-column .chart-scroll {
226
252
  flex: 1;
253
+ overflow-x: auto;
254
+ overflow-y: visible;
255
+ position: relative;
256
+ }
257
+
258
+ .chart-stacked-column .chart-scroll::before {
259
+ content: '';
260
+ position: sticky;
261
+ left: 0;
262
+ display: block;
263
+ height: var(--chart-height);
264
+ margin-bottom: calc(-1 * var(--chart-height));
227
265
  background: var(--chart-bg);
228
266
  border-radius: 3px;
267
+ pointer-events: none;
268
+ }
269
+
270
+ .chart-stacked-column .chart-columns {
271
+ position: relative;
272
+ display: flex;
273
+ gap: 0.25rem;
274
+ align-items: stretch;
275
+ height: var(--chart-height);
229
276
  padding: 0.5rem 0.5rem 0 0.5rem;
230
277
  box-sizing: border-box;
231
278
  }
@@ -249,37 +296,69 @@
249
296
 
250
297
  .chart-stacked-column .column-labels {
251
298
  display: flex;
252
- gap: var(--chart-gap);
299
+ gap: 0.25rem;
253
300
  padding: 0.25rem 0.5rem 0;
254
- margin-left: 2.5rem;
255
301
  }
256
302
 
257
303
  .chart-stacked-column .column-label {
258
304
  flex: 1;
259
305
  min-width: var(--chart-column-width);
260
306
  max-width: calc(var(--chart-column-width) * 2);
261
- font-size: 0.75rem;
307
+ font-size: 0.7rem;
308
+ opacity: 0.6;
262
309
  text-align: center;
263
310
  white-space: nowrap;
264
311
  overflow: hidden;
265
312
  text-overflow: ellipsis;
266
313
  }
267
314
 
315
+ /* Rotated column labels (opt-in via rotateLabels config) */
316
+ .chart-stacked-column.rotate-labels .column-labels {
317
+ padding-top: 0.5rem;
318
+ align-items: flex-start;
319
+ }
320
+
321
+ .chart-stacked-column.rotate-labels .column-label {
322
+ writing-mode: vertical-rl;
323
+ transform: rotate(180deg);
324
+ display: flex;
325
+ align-items: center;
326
+ overflow: visible;
327
+ text-overflow: clip;
328
+ }
329
+
268
330
  /* ==========================================================================
269
331
  Donut Chart
270
332
  ========================================================================== */
271
333
 
272
334
  .chart-donut {
273
335
  display: flex;
274
- flex-direction: column;
336
+ flex-direction: row;
337
+ flex-wrap: wrap;
275
338
  align-items: flex-start;
339
+ gap: 1rem;
340
+ --donut-1: var(--chart-color-1);
341
+ --donut-2: var(--chart-color-2);
342
+ --donut-3: var(--chart-color-3);
343
+ --donut-4: var(--chart-color-4);
344
+ --donut-5: var(--chart-color-5);
345
+ --donut-6: var(--chart-color-6);
346
+ --donut-7: var(--chart-color-7);
347
+ --donut-8: var(--chart-color-8);
348
+ --donut-9: var(--chart-color-9);
349
+ --donut-10: var(--chart-color-10);
350
+ --donut-11: var(--chart-color-11);
351
+ --donut-12: var(--chart-color-12);
352
+ }
353
+
354
+ .chart-donut > .chart-title {
355
+ flex-basis: 100%;
276
356
  }
277
357
 
278
358
  .chart-donut .donut-container {
279
359
  position: relative;
280
360
  display: flex;
281
361
  justify-content: center;
282
- margin-bottom: 1rem;
283
362
  }
284
363
 
285
364
  .chart-donut .donut-ring {
@@ -335,6 +414,20 @@
335
414
  min-width: 10rem;
336
415
  }
337
416
 
417
+ /* Donut legend uses same variables as gradient for consistent overrides */
418
+ .chart-donut .chart-color-1 { --color: var(--donut-1, var(--chart-color-1)); }
419
+ .chart-donut .chart-color-2 { --color: var(--donut-2, var(--chart-color-2)); }
420
+ .chart-donut .chart-color-3 { --color: var(--donut-3, var(--chart-color-3)); }
421
+ .chart-donut .chart-color-4 { --color: var(--donut-4, var(--chart-color-4)); }
422
+ .chart-donut .chart-color-5 { --color: var(--donut-5, var(--chart-color-5)); }
423
+ .chart-donut .chart-color-6 { --color: var(--donut-6, var(--chart-color-6)); }
424
+ .chart-donut .chart-color-7 { --color: var(--donut-7, var(--chart-color-7)); }
425
+ .chart-donut .chart-color-8 { --color: var(--donut-8, var(--chart-color-8)); }
426
+ .chart-donut .chart-color-9 { --color: var(--donut-9, var(--chart-color-9)); }
427
+ .chart-donut .chart-color-10 { --color: var(--donut-10, var(--chart-color-10)); }
428
+ .chart-donut .chart-color-11 { --color: var(--donut-11, var(--chart-color-11)); }
429
+ .chart-donut .chart-color-12 { --color: var(--donut-12, var(--chart-color-12)); }
430
+
338
431
  .chart-donut .chart-legend-item .legend-label {
339
432
  flex: 1;
340
433
  }
@@ -347,13 +440,29 @@
347
440
  align-items: stretch;
348
441
  }
349
442
 
350
- .chart-dot .dot-chart {
351
- height: 12rem;
443
+ .chart-dot .chart-scroll {
444
+ flex: 1;
445
+ overflow-x: auto;
446
+ overflow-y: visible;
447
+ position: relative;
448
+ }
449
+
450
+ .chart-dot .chart-scroll::before {
451
+ content: '';
452
+ position: sticky;
453
+ left: 0;
454
+ display: block;
455
+ height: var(--chart-height);
456
+ margin-bottom: calc(-1 * var(--chart-height));
352
457
  background: var(--chart-bg);
353
458
  border-radius: 3px;
354
- flex: 1;
355
- box-sizing: border-box;
459
+ pointer-events: none;
460
+ }
461
+
462
+ .chart-dot .dot-chart {
356
463
  position: relative;
464
+ height: var(--chart-height);
465
+ box-sizing: border-box;
357
466
  }
358
467
 
359
468
  /* Inner field sized to content area - dots position relative to this */
@@ -396,7 +505,6 @@
396
505
  gap: 6px;
397
506
  padding: 0 0.5rem;
398
507
  margin-top: 0.5rem;
399
- margin-left: calc(2rem + 0.5rem); /* y-axis width + gap */
400
508
  }
401
509
 
402
510
  .chart-dot .dot-label {
@@ -406,6 +514,21 @@
406
514
  min-width: 1.5rem;
407
515
  }
408
516
 
517
+ /* Rotated dot labels (opt-in via rotateLabels config) */
518
+ .chart-dot.rotate-labels .dot-labels {
519
+ padding-top: 0.5rem;
520
+ align-items: flex-start;
521
+ }
522
+
523
+ .chart-dot.rotate-labels .dot-label {
524
+ writing-mode: vertical-rl;
525
+ transform: rotate(180deg);
526
+ display: flex;
527
+ align-items: center;
528
+ overflow: visible;
529
+ text-overflow: clip;
530
+ }
531
+
409
532
  /* ==========================================================================
410
533
  Scatter Chart (Continuous X and Y axes)
411
534
  ========================================================================== */
@@ -422,7 +545,7 @@
422
545
 
423
546
  .chart-scatter .dot-area {
424
547
  position: relative;
425
- height: 12rem;
548
+ height: var(--chart-height);
426
549
  background: var(--chart-bg);
427
550
  border-radius: 3px;
428
551
  box-sizing: border-box;
@@ -461,41 +584,13 @@
461
584
  ========================================================================== */
462
585
 
463
586
  /* Row/column index for staggered delays */
464
- .bar-row:nth-child(1) { --row-index: 0; }
465
- .bar-row:nth-child(2) { --row-index: 1; }
466
- .bar-row:nth-child(3) { --row-index: 2; }
467
- .bar-row:nth-child(4) { --row-index: 3; }
468
- .bar-row:nth-child(5) { --row-index: 4; }
469
- .bar-row:nth-child(6) { --row-index: 5; }
470
- .bar-row:nth-child(7) { --row-index: 6; }
471
- .bar-row:nth-child(8) { --row-index: 7; }
472
-
473
- .column-track:nth-child(1) { --col-index: 0; }
474
- .column-track:nth-child(2) { --col-index: 1; }
475
- .column-track:nth-child(3) { --col-index: 2; }
476
- .column-track:nth-child(4) { --col-index: 3; }
477
- .column-track:nth-child(5) { --col-index: 4; }
478
- .column-track:nth-child(6) { --col-index: 5; }
479
- .column-track:nth-child(7) { --col-index: 6; }
480
- .column-track:nth-child(8) { --col-index: 7; }
481
- .column-track:nth-child(9) { --col-index: 8; }
482
- .column-track:nth-child(10) { --col-index: 9; }
483
- .column-track:nth-child(11) { --col-index: 10; }
484
- .column-track:nth-child(12) { --col-index: 11; }
485
-
486
- .dot-col:nth-child(1) { --col-index: 0; }
487
- .dot-col:nth-child(2) { --col-index: 1; }
488
- .dot-col:nth-child(3) { --col-index: 2; }
489
- .dot-col:nth-child(4) { --col-index: 3; }
490
- .dot-col:nth-child(5) { --col-index: 4; }
491
- .dot-col:nth-child(6) { --col-index: 5; }
492
- .dot-col:nth-child(7) { --col-index: 6; }
493
- .dot-col:nth-child(8) { --col-index: 7; }
587
+ /* Row, column, and dot indices set via inline styles in renderers */
494
588
 
495
589
  /* Bar chart: clip-path on fills wrapper, reveals left-to-right */
496
590
  .chart-animate .bar-fills {
591
+ clip-path: inset(0 100% 0 0);
497
592
  animation: bar-reveal 1s cubic-bezier(0.25, 1, 0.5, 1) forwards;
498
- animation-delay: calc(var(--row-index, 0) * 0.08s);
593
+ animation-delay: calc(var(--row-index, 0) * var(--delay-step, 0.08s));
499
594
  }
500
595
 
501
596
  @keyframes bar-reveal {
@@ -505,8 +600,9 @@
505
600
 
506
601
  /* Column chart: clip-path on track, reveals bottom-to-top */
507
602
  .chart-animate .column-track {
603
+ clip-path: inset(100% 0 0 0);
508
604
  animation: column-reveal 0.6s cubic-bezier(0.25, 1, 0.5, 1) forwards;
509
- animation-delay: calc(var(--col-index, 0) * 0.05s);
605
+ animation-delay: calc(var(--col-index, 0) * var(--delay-step, 0.05s));
510
606
  }
511
607
 
512
608
  @keyframes column-reveal {
@@ -518,7 +614,7 @@
518
614
  .chart-animate.has-negative-y .column-track {
519
615
  --zero-from-top: calc(100% - var(--zero-position, 0%));
520
616
  animation: column-expand-from-zero 0.6s cubic-bezier(0.25, 1, 0.5, 1) forwards;
521
- animation-delay: calc(var(--col-index, 0) * 0.05s);
617
+ animation-delay: calc(var(--col-index, 0) * var(--delay-step, 0.05s));
522
618
  clip-path: polygon(
523
619
  0% var(--zero-from-top),
524
620
  100% var(--zero-from-top),
@@ -544,7 +640,7 @@
544
640
  /* Dot chart: dots rise from bottom with staggered delays */
545
641
  .chart-animate.chart-dot .dot {
546
642
  animation: dot-rise 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
547
- animation-delay: calc(var(--col-index, 0) * 0.08s);
643
+ animation-delay: calc(var(--col-index, 0) * var(--delay-step, 0.08s));
548
644
  opacity: 0;
549
645
  }
550
646
 
@@ -564,7 +660,7 @@
564
660
  /* Dot chart with negatives: dots move from zero axis */
565
661
  .chart-animate.chart-dot.has-negative-y .dot {
566
662
  animation: dot-from-zero 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
567
- animation-delay: calc(var(--col-index, 0) * 0.08s);
663
+ animation-delay: calc(var(--col-index, 0) * var(--delay-step, 0.08s));
568
664
  opacity: 0;
569
665
  }
570
666
 
@@ -101,6 +101,7 @@ export default function(eleventyConfig, options = {}) {
101
101
  const animate = chartConfig.animate ?? globalAnimate;
102
102
  return renderer({
103
103
  ...chartConfig,
104
+ id: chartId,
104
105
  data,
105
106
  animate
106
107
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eleventy-plugin-uncharted",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "An Eleventy plugin that renders CSS-based charts from CSV data using shortcodes",
5
5
  "main": "eleventy.config.js",
6
6
  "type": "module",
package/src/csv.js CHANGED
@@ -1,6 +1,51 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
 
4
+ /**
5
+ * Parse a CSV line handling quoted fields
6
+ * @param {string} line - CSV line
7
+ * @returns {string[]} - Array of field values
8
+ */
9
+ function parseCSVLine(line) {
10
+ const fields = [];
11
+ let current = '';
12
+ let inQuotes = false;
13
+
14
+ for (let i = 0; i < line.length; i++) {
15
+ const char = line[i];
16
+ const nextChar = line[i + 1];
17
+
18
+ if (inQuotes) {
19
+ if (char === '"' && nextChar === '"') {
20
+ // Escaped quote
21
+ current += '"';
22
+ i++;
23
+ } else if (char === '"') {
24
+ // End of quoted field
25
+ inQuotes = false;
26
+ } else {
27
+ current += char;
28
+ }
29
+ } else {
30
+ if (char === '"') {
31
+ // Start of quoted field
32
+ inQuotes = true;
33
+ } else if (char === ',') {
34
+ // Field separator
35
+ fields.push(current.trim());
36
+ current = '';
37
+ } else {
38
+ current += char;
39
+ }
40
+ }
41
+ }
42
+
43
+ // Push last field
44
+ fields.push(current.trim());
45
+
46
+ return fields;
47
+ }
48
+
4
49
  /**
5
50
  * Parse CSV content into array of objects
6
51
  * @param {string} content - Raw CSV content
@@ -14,11 +59,11 @@ export function parseCSV(content) {
14
59
 
15
60
  if (lines.length < 2) return [];
16
61
 
17
- const headers = lines[0].split(',').map(h => h.trim());
62
+ const headers = parseCSVLine(lines[0]);
18
63
  const rows = [];
19
64
 
20
65
  for (let i = 1; i < lines.length; i++) {
21
- const values = lines[i].split(',').map(v => v.trim());
66
+ const values = parseCSVLine(lines[i]);
22
67
  const row = {};
23
68
 
24
69
  headers.forEach((header, index) => {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Format a number according to configuration
3
+ * @param {number} value - Raw numeric value
4
+ * @param {Object} config - { thousands, compact, decimals, currency: { symbol, position } }
5
+ * @returns {string} - Formatted string
6
+ */
7
+ export function formatNumber(value, config = {}) {
8
+ if (value == null || isNaN(value)) return '';
9
+
10
+ const { thousands, compact, decimals, currency } = config;
11
+ let num = value;
12
+ let suffix = '';
13
+
14
+ // Compact notation (K/M/B/T)
15
+ if (compact) {
16
+ const abs = Math.abs(num);
17
+ if (abs >= 1e12) { num /= 1e12; suffix = 'T'; }
18
+ else if (abs >= 1e9) { num /= 1e9; suffix = 'B'; }
19
+ else if (abs >= 1e6) { num /= 1e6; suffix = 'M'; }
20
+ else if (abs >= 1e3) { num /= 1e3; suffix = 'K'; }
21
+ }
22
+
23
+ // Decimal places (default: 0, or 1 if compact with suffix)
24
+ const places = decimals ?? (suffix ? 1 : 0);
25
+
26
+ // Format with or without thousands separators
27
+ let formatted = thousands && !suffix
28
+ ? num.toLocaleString('en-US', { minimumFractionDigits: places, maximumFractionDigits: places })
29
+ : num.toFixed(places);
30
+
31
+ formatted += suffix;
32
+
33
+ // Currency symbol
34
+ if (currency?.symbol) {
35
+ const pos = currency.position ?? 'prefix';
36
+ formatted = pos === 'prefix' ? currency.symbol + formatted : formatted + currency.symbol;
37
+ }
38
+
39
+ return formatted;
40
+ }
package/src/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { renderers } from './renderers/index.js';
2
2
  export { loadCSV, parseCSV } from './csv.js';
3
3
  export { slugify, calculatePercentages, getLabelKey, getValueKey, getSeriesNames, escapeHtml } from './utils.js';
4
+ export { formatNumber } from './formatters.js';
@@ -1,4 +1,5 @@
1
1
  import { slugify, escapeHtml, getLabelKey, getValueKey, getSeriesNames } from '../utils.js';
2
+ import { formatNumber } from '../formatters.js';
2
3
 
3
4
  /**
4
5
  * Render a donut/pie chart using conic-gradient
@@ -11,10 +12,11 @@ import { slugify, escapeHtml, getLabelKey, getValueKey, getSeriesNames } from '.
11
12
  * @param {string|number} [config.center.value] - Value to show in center (use "total" for auto-calculated)
12
13
  * @param {string} [config.center.label] - Label below the value
13
14
  * @param {boolean} [config.animate] - Enable animations
15
+ * @param {boolean} [config.showPercentages] - Show percentages instead of values in legend
14
16
  * @returns {string} - HTML string
15
17
  */
16
18
  export function renderDonut(config) {
17
- const { title, subtitle, data, legend, center, animate } = config;
19
+ const { title, subtitle, data, legend, center, animate, format, id, showPercentages } = config;
18
20
 
19
21
  if (!data || data.length === 0) {
20
22
  return `<!-- Donut chart: no data provided -->`;
@@ -57,13 +59,15 @@ export function renderDonut(config) {
57
59
  const startAngle = currentAngle;
58
60
  const endAngle = currentAngle + percentage;
59
61
 
60
- gradientStops.push(`var(--chart-color-${i + 1}) ${startAngle.toFixed(2)}% ${endAngle.toFixed(2)}%`);
62
+ // Use segment-specific variable (defaults set in CSS)
63
+ gradientStops.push(`var(--donut-${i + 1}) ${startAngle.toFixed(2)}% ${endAngle.toFixed(2)}%`);
61
64
  currentAngle = endAngle;
62
65
  });
63
66
 
64
67
  const gradient = `conic-gradient(${gradientStops.join(', ')})`;
65
68
 
66
- let html = `<figure class="chart chart-donut${animateClass}">`;
69
+ const idClass = id ? ` chart-${id}` : '';
70
+ let html = `<figure class="chart chart-donut${animateClass}${idClass}">`;
67
71
 
68
72
  if (title) {
69
73
  html += `<figcaption class="chart-title">${escapeHtml(title)}`;
@@ -82,7 +86,8 @@ export function renderDonut(config) {
82
86
  if (center) {
83
87
  const centerValue = center.value === 'total' ? total : center.value;
84
88
  if (centerValue !== undefined) {
85
- html += `<span class="donut-value">${escapeHtml(String(centerValue))}</span>`;
89
+ const displayValue = typeof centerValue === 'number' ? (formatNumber(centerValue, format) || centerValue) : centerValue;
90
+ html += `<span class="donut-value">${escapeHtml(String(displayValue))}</span>`;
86
91
  }
87
92
  if (center.label) {
88
93
  html += `<span class="donut-label">${escapeHtml(center.label)}</span>`;
@@ -92,17 +97,22 @@ export function renderDonut(config) {
92
97
  html += `</div>`;
93
98
  html += `</div>`;
94
99
 
95
- // Legend with percentages
100
+ // Legend with values (or percentages if showPercentages is true)
96
101
  const legendLabels = legend ?? segments.map(s => s.label);
97
102
  html += `<ul class="chart-legend">`;
98
103
  segments.forEach((segment, i) => {
99
104
  const label = legendLabels[i] ?? segment.label;
100
- const percentage = ((segment.value / total) * 100).toFixed(1);
105
+ let displayValue;
106
+ if (showPercentages) {
107
+ displayValue = ((segment.value / total) * 100).toFixed(1) + '%';
108
+ } else {
109
+ displayValue = formatNumber(segment.value, format) || segment.value;
110
+ }
101
111
  const colorClass = `chart-color-${i + 1}`;
102
112
  const seriesClass = `chart-series-${slugify(segment.label)}`;
103
113
  html += `<li class="chart-legend-item ${colorClass} ${seriesClass}">`;
104
114
  html += `<span class="legend-label">${escapeHtml(label)}</span>`;
105
- html += `<span class="legend-value">${percentage}%</span>`;
115
+ html += `<span class="legend-value">${escapeHtml(String(displayValue))}</span>`;
106
116
  html += `</li>`;
107
117
  });
108
118
  html += `</ul>`;
@@ -1,4 +1,5 @@
1
1
  import { slugify, escapeHtml, getLabelKey, getSeriesNames } from '../utils.js';
2
+ import { formatNumber } from '../formatters.js';
2
3
 
3
4
  /**
4
5
  * Render a categorical dot chart (columns with dots at different Y positions)
@@ -14,7 +15,7 @@ import { slugify, escapeHtml, getLabelKey, getSeriesNames } from '../utils.js';
14
15
  * @returns {string} - HTML string
15
16
  */
16
17
  export function renderDot(config) {
17
- const { title, subtitle, data, max, min, legend, animate } = config;
18
+ const { title, subtitle, data, max, min, legend, animate, format, id, rotateLabels } = config;
18
19
 
19
20
  if (!data || data.length === 0) {
20
21
  return `<!-- Dot chart: no data provided -->`;
@@ -44,7 +45,9 @@ export function renderDot(config) {
44
45
  const zeroPct = hasNegativeY ? ((0 - minValue) / range) * 100 : 0;
45
46
 
46
47
  const negativeClass = hasNegativeY ? ' has-negative-y' : '';
47
- let html = `<figure class="chart chart-dot${animateClass}${negativeClass}">`;
48
+ const idClass = id ? ` chart-${id}` : '';
49
+ const rotateClass = rotateLabels ? ' rotate-labels' : '';
50
+ let html = `<figure class="chart chart-dot${animateClass}${negativeClass}${idClass}${rotateClass}">`;
48
51
 
49
52
  if (title) {
50
53
  html += `<figcaption class="chart-title">${escapeHtml(title)}`;
@@ -71,21 +74,29 @@ export function renderDot(config) {
71
74
  // Y-axis
72
75
  const yAxisStyle = hasNegativeY ? ` style="--zero-position: ${zeroPct.toFixed(2)}%"` : '';
73
76
  html += `<div class="chart-y-axis"${yAxisStyle}>`;
74
- html += `<span class="axis-label">${maxValue}</span>`;
77
+ html += `<span class="axis-label">${formatNumber(maxValue, format) || maxValue}</span>`;
75
78
  const midLabelY = hasNegativeY ? 0 : Math.round((maxValue + minValue) / 2);
76
- html += `<span class="axis-label">${midLabelY}</span>`;
77
- html += `<span class="axis-label">${minValue}</span>`;
79
+ html += `<span class="axis-label">${formatNumber(midLabelY, format) || midLabelY}</span>`;
80
+ html += `<span class="axis-label">${formatNumber(minValue, format) || minValue}</span>`;
78
81
  html += `</div>`;
79
82
 
80
- const zeroStyle = hasNegativeY ? ` style="--zero-position: ${zeroPct.toFixed(2)}%"` : '';
81
- html += `<div class="dot-chart"${zeroStyle}>`;
83
+ // Scroll wrapper for chart + labels
84
+ html += `<div class="chart-scroll">`;
85
+
86
+ // Calculate delay step to cap total stagger at 1s
87
+ const maxStagger = 1; // seconds
88
+ const defaultDelay = 0.08; // seconds
89
+ const delayStep = data.length > 1 ? Math.min(defaultDelay, maxStagger / (data.length - 1)) : 0;
90
+ const styleVars = [`--delay-step: ${delayStep.toFixed(3)}s`];
91
+ if (hasNegativeY) styleVars.push(`--zero-position: ${zeroPct.toFixed(2)}%`);
92
+ html += `<div class="dot-chart" style="${styleVars.join('; ')}">`;
82
93
  html += `<div class="dot-field">`;
83
94
 
84
95
  // Each row becomes a column with dots for each series
85
- data.forEach(row => {
96
+ data.forEach((row, colIndex) => {
86
97
  const label = row[labelKey] ?? '';
87
98
 
88
- html += `<div class="dot-col">`;
99
+ html += `<div class="dot-col" style="--col-index: ${colIndex}">`;
89
100
 
90
101
  seriesKeys.forEach((key, i) => {
91
102
  const val = row[key];
@@ -97,14 +108,13 @@ export function renderDot(config) {
97
108
 
98
109
  html += `<div class="dot ${colorClass} ${seriesClass}" `;
99
110
  html += `style="--value: ${yPct.toFixed(2)}%" `;
100
- html += `title="${escapeHtml(label)}: ${value} ${escapeHtml(tooltipLabel)}"`;
111
+ html += `title="${escapeHtml(tooltipLabel)}: ${formatNumber(value, format) || value}"`;
101
112
  html += `></div>`;
102
113
  });
103
114
 
104
115
  html += `</div>`;
105
116
  });
106
117
 
107
- html += `</div>`;
108
118
  html += `</div>`;
109
119
  html += `</div>`;
110
120
 
@@ -116,6 +126,8 @@ export function renderDot(config) {
116
126
  });
117
127
  html += `</div>`;
118
128
 
129
+ html += `</div>`; // close chart-scroll
130
+ html += `</div>`; // close chart-body
119
131
  html += `</figure>`;
120
132
 
121
133
  return html;
@@ -1,21 +1,28 @@
1
1
  import { slugify, escapeHtml } from '../utils.js';
2
+ import { formatNumber } from '../formatters.js';
2
3
 
3
4
  /**
4
5
  * Render a scatter plot (continuous X and Y axes)
5
6
  * @param {Object} config - Chart configuration
6
7
  * @param {string} config.title - Chart title
7
8
  * @param {string} [config.subtitle] - Chart subtitle
8
- * @param {Object[]} config.data - Chart data (with label, x, y, and optionally series)
9
+ * @param {Object[]} config.data - Chart data (positional: label, x, y, series)
9
10
  * @param {number} [config.maxX] - Maximum X value (defaults to max in data)
10
11
  * @param {number} [config.maxY] - Maximum Y value (defaults to max in data)
11
12
  * @param {number} [config.minX] - Minimum X value (defaults to min in data or 0)
12
13
  * @param {number} [config.minY] - Minimum Y value (defaults to min in data or 0)
13
14
  * @param {string[]} [config.legend] - Legend labels for series
14
15
  * @param {boolean} [config.animate] - Enable animations
16
+ * @param {string} [config.titleX] - X-axis title (defaults to column name)
17
+ * @param {string} [config.titleY] - Y-axis title (defaults to column name)
15
18
  * @returns {string} - HTML string
16
19
  */
17
20
  export function renderScatter(config) {
18
- const { title, subtitle, data, maxX, maxY, minX, minY, legend, animate } = config;
21
+ const { title, subtitle, data, maxX, maxY, minX, minY, legend, animate, format, titleX, titleY, id } = config;
22
+
23
+ // Handle nested X/Y format for scatter charts
24
+ const fmtX = format?.x || format || {};
25
+ const fmtY = format?.y || format || {};
19
26
 
20
27
  if (!data || data.length === 0) {
21
28
  return `<!-- Scatter chart: no data provided -->`;
@@ -23,25 +30,24 @@ export function renderScatter(config) {
23
30
 
24
31
  const animateClass = animate ? ' chart-animate' : '';
25
32
 
26
- // Normalize data format
27
- let dots = [];
28
- if (data[0].x !== undefined && data[0].y !== undefined) {
29
- // Direct {label, x, y, series?} format
30
- dots = data.map(item => ({
31
- label: item.label ?? '',
32
- x: typeof item.x === 'number' ? item.x : parseFloat(item.x) || 0,
33
- y: typeof item.y === 'number' ? item.y : parseFloat(item.y) || 0,
34
- series: item.series ?? 'default'
35
- }));
36
- } else if (data[0].value !== undefined) {
37
- // Simple {label, value} format - use index as x, value as y
38
- dots = data.map((item, i) => ({
39
- label: item.label ?? '',
40
- x: i,
41
- y: typeof item.value === 'number' ? item.value : parseFloat(item.value) || 0,
42
- series: 'default'
43
- }));
44
- }
33
+ // Get column keys positionally
34
+ const keys = Object.keys(data[0]);
35
+ const labelKey = keys[0]; // First column: point labels
36
+ const xKey = keys[1]; // Second column: X values
37
+ const yKey = keys[2]; // Third column: Y values
38
+ const seriesKey = keys[3]; // Fourth column (optional): series
39
+
40
+ // Axis titles: explicit config overrides column names
41
+ const xAxisTitle = titleX ?? xKey;
42
+ const yAxisTitle = titleY ?? yKey;
43
+
44
+ // Map data to dots using positional columns
45
+ const dots = data.map(item => ({
46
+ label: item[labelKey] ?? '',
47
+ x: typeof item[xKey] === 'number' ? item[xKey] : parseFloat(item[xKey]) || 0,
48
+ y: typeof item[yKey] === 'number' ? item[yKey] : parseFloat(item[yKey]) || 0,
49
+ series: seriesKey ? (item[seriesKey] ?? 'default') : 'default'
50
+ }));
45
51
 
46
52
  // Calculate bounds
47
53
  const xValues = dots.map(d => d.x);
@@ -71,7 +77,8 @@ export function renderScatter(config) {
71
77
  const seriesIndex = new Map(seriesList.map((s, i) => [s, i]));
72
78
 
73
79
  const negativeClasses = (hasNegativeX ? ' has-negative-x' : '') + (hasNegativeY ? ' has-negative-y' : '');
74
- let html = `<figure class="chart chart-scatter${animateClass}${negativeClasses}">`;
80
+ const idClass = id ? ` chart-${id}` : '';
81
+ let html = `<figure class="chart chart-scatter${animateClass}${negativeClasses}${idClass}">`;
75
82
 
76
83
  if (title) {
77
84
  html += `<figcaption class="chart-title">${escapeHtml(title)}`;
@@ -99,10 +106,11 @@ export function renderScatter(config) {
99
106
  // Y-axis
100
107
  const yAxisStyle = hasNegativeY ? ` style="--zero-position-y: ${zeroPctY.toFixed(2)}%"` : '';
101
108
  html += `<div class="chart-y-axis"${yAxisStyle}>`;
102
- html += `<span class="axis-label">${calcMaxY}</span>`;
109
+ html += `<span class="axis-label">${formatNumber(calcMaxY, fmtY) || calcMaxY}</span>`;
103
110
  const midLabelY = hasNegativeY ? 0 : Math.round((calcMaxY + calcMinY) / 2);
104
- html += `<span class="axis-label">${midLabelY}</span>`;
105
- html += `<span class="axis-label">${calcMinY}</span>`;
111
+ html += `<span class="axis-label">${formatNumber(midLabelY, fmtY) || midLabelY}</span>`;
112
+ html += `<span class="axis-label">${formatNumber(calcMinY, fmtY) || calcMinY}</span>`;
113
+ html += `<span class="axis-title">${escapeHtml(yAxisTitle)}</span>`;
106
114
  html += `</div>`;
107
115
 
108
116
  // Container gets zero position variables for axis line CSS
@@ -120,7 +128,9 @@ export function renderScatter(config) {
120
128
  const colorIndex = seriesIndex.get(dot.series) + 1;
121
129
  const colorClass = `chart-color-${colorIndex}`;
122
130
  const seriesClass = `chart-series-${slugify(dot.series)}`;
123
- const tooltipText = dot.label ? `${dot.label}: (${dot.x}, ${dot.y})` : `(${dot.x}, ${dot.y})`;
131
+ const fmtXVal = formatNumber(dot.x, fmtX) || dot.x;
132
+ const fmtYVal = formatNumber(dot.y, fmtY) || dot.y;
133
+ const tooltipText = dot.label ? `${dot.label}: (${fmtXVal}, ${fmtYVal})` : `(${fmtXVal}, ${fmtYVal})`;
124
134
 
125
135
  html += `<div class="dot ${colorClass} ${seriesClass}" `;
126
136
  html += `style="--dot-index: ${i}; --x: ${xPct.toFixed(2)}%; --value: ${yPct.toFixed(2)}%" `;
@@ -134,10 +144,11 @@ export function renderScatter(config) {
134
144
  // X-axis
135
145
  const xAxisStyle = hasNegativeX ? ` style="--zero-position-x: ${zeroPctX.toFixed(2)}%"` : '';
136
146
  html += `<div class="chart-x-axis"${xAxisStyle}>`;
137
- html += `<span class="axis-label">${calcMinX}</span>`;
147
+ html += `<span class="axis-label">${formatNumber(calcMinX, fmtX) || calcMinX}</span>`;
138
148
  const midLabelX = hasNegativeX ? 0 : Math.round((calcMaxX + calcMinX) / 2);
139
- html += `<span class="axis-label">${midLabelX}</span>`;
140
- html += `<span class="axis-label">${calcMaxX}</span>`;
149
+ html += `<span class="axis-label">${formatNumber(midLabelX, fmtX) || midLabelX}</span>`;
150
+ html += `<span class="axis-label">${formatNumber(calcMaxX, fmtX) || calcMaxX}</span>`;
151
+ html += `<span class="axis-title">${escapeHtml(xAxisTitle)}</span>`;
141
152
  html += `</div>`;
142
153
 
143
154
  html += `</div>`;
@@ -1,4 +1,5 @@
1
1
  import { slugify, calculatePercentages, getLabelKey, getSeriesNames, escapeHtml } from '../utils.js';
2
+ import { formatNumber } from '../formatters.js';
2
3
 
3
4
  /**
4
5
  * Render a stacked bar chart (horizontal)
@@ -12,7 +13,7 @@ import { slugify, calculatePercentages, getLabelKey, getSeriesNames, escapeHtml
12
13
  * @returns {string} - HTML string
13
14
  */
14
15
  export function renderStackedBar(config) {
15
- const { title, subtitle, data, max, legend, animate } = config;
16
+ const { title, subtitle, data, max, legend, animate, format, id } = config;
16
17
 
17
18
  if (!data || data.length === 0) {
18
19
  return `<!-- Stacked bar chart: no data provided -->`;
@@ -25,7 +26,16 @@ export function renderStackedBar(config) {
25
26
  const legendLabels = legend ?? seriesKeys;
26
27
  const animateClass = animate ? ' chart-animate' : '';
27
28
 
28
- let html = `<figure class="chart chart-stacked-bar${animateClass}">`;
29
+ // Calculate max total across all rows if not provided
30
+ const calculatedMax = max ?? Math.max(...data.map(row => {
31
+ return seriesKeys.reduce((sum, key) => {
32
+ const val = row[key];
33
+ return sum + (typeof val === 'number' ? val : parseFloat(val) || 0);
34
+ }, 0);
35
+ }));
36
+
37
+ const idClass = id ? ` chart-${id}` : '';
38
+ let html = `<figure class="chart chart-stacked-bar${animateClass}${idClass}">`;
29
39
 
30
40
  if (title) {
31
41
  html += `<figcaption class="chart-title">${escapeHtml(title)}`;
@@ -47,22 +57,26 @@ export function renderStackedBar(config) {
47
57
  html += `</ul>`;
48
58
  }
49
59
 
50
- html += `<div class="chart-bars">`;
60
+ // Calculate delay step to cap total stagger at 1s
61
+ const maxStagger = 1; // seconds
62
+ const defaultDelay = 0.08; // seconds
63
+ const delayStep = data.length > 1 ? Math.min(defaultDelay, maxStagger / (data.length - 1)) : 0;
64
+ html += `<div class="chart-bars" style="--delay-step: ${delayStep.toFixed(3)}s">`;
51
65
 
52
- data.forEach(row => {
66
+ data.forEach((row, rowIndex) => {
53
67
  const label = row[labelKey] ?? '';
54
68
  const values = seriesKeys.map(key => {
55
69
  const val = row[key];
56
70
  return typeof val === 'number' ? val : parseFloat(val) || 0;
57
71
  });
58
72
  const total = values.reduce((sum, v) => sum + v, 0);
59
- const percentages = calculatePercentages(values, max);
73
+ const percentages = calculatePercentages(values, calculatedMax);
60
74
  const seriesLabels = legendLabels ?? seriesKeys;
61
75
 
62
- html += `<div class="bar-row">`;
76
+ html += `<div class="bar-row" style="--row-index: ${rowIndex}">`;
63
77
  html += `<span class="bar-label">${escapeHtml(label)}</span>`;
64
78
  html += `<div class="bar-track">`;
65
- html += `<div class="bar-fills" title="${escapeHtml(label)}: ${total}">`;
79
+ html += `<div class="bar-fills" title="${escapeHtml(label)}: ${formatNumber(total, format) || total}">`;
66
80
 
67
81
  seriesKeys.forEach((key, i) => {
68
82
  const pct = percentages[i];
@@ -71,7 +85,7 @@ export function renderStackedBar(config) {
71
85
  const colorClass = `chart-color-${i + 1}`;
72
86
  const seriesClass = `chart-series-${slugify(key)}`;
73
87
  const seriesLabel = seriesLabels[i] ?? key;
74
- html += `<div class="bar-fill ${colorClass} ${seriesClass}" style="--value: ${pct.toFixed(2)}%" title="${escapeHtml(seriesLabel)}: ${value}"></div>`;
88
+ html += `<div class="bar-fill ${colorClass} ${seriesClass}" style="--value: ${pct.toFixed(2)}%" title="${escapeHtml(seriesLabel)}: ${formatNumber(value, format) || value}"></div>`;
75
89
  }
76
90
  });
77
91
 
@@ -79,7 +93,7 @@ export function renderStackedBar(config) {
79
93
  html += `</div>`;
80
94
 
81
95
  // Show total value
82
- html += `<span class="bar-value">${total}</span>`;
96
+ html += `<span class="bar-value">${formatNumber(total, format) || total}</span>`;
83
97
  html += `</div>`;
84
98
  });
85
99
 
@@ -1,4 +1,5 @@
1
1
  import { slugify, getLabelKey, getSeriesNames, escapeHtml } from '../utils.js';
2
+ import { formatNumber } from '../formatters.js';
2
3
 
3
4
  /**
4
5
  * Render a stacked column chart (vertical)
@@ -13,7 +14,7 @@ import { slugify, getLabelKey, getSeriesNames, escapeHtml } from '../utils.js';
13
14
  * @returns {string} - HTML string
14
15
  */
15
16
  export function renderStackedColumn(config) {
16
- const { title, subtitle, data, max, min, legend, animate } = config;
17
+ const { title, subtitle, data, max, min, legend, animate, format, id, rotateLabels } = config;
17
18
 
18
19
  if (!data || data.length === 0) {
19
20
  return `<!-- Stacked column chart: no data provided -->`;
@@ -54,7 +55,9 @@ export function renderStackedColumn(config) {
54
55
  const zeroPct = hasNegativeY ? ((0 - minValue) / range) * 100 : 0;
55
56
 
56
57
  const negativeClass = hasNegativeY ? ' has-negative-y' : '';
57
- let html = `<figure class="chart chart-stacked-column${animateClass}${negativeClass}">`;
58
+ const idClass = id ? ` chart-${id}` : '';
59
+ const rotateClass = rotateLabels ? ' rotate-labels' : '';
60
+ let html = `<figure class="chart chart-stacked-column${animateClass}${negativeClass}${idClass}${rotateClass}">`;
58
61
 
59
62
  if (title) {
60
63
  html += `<figcaption class="chart-title">${escapeHtml(title)}`;
@@ -81,18 +84,27 @@ export function renderStackedColumn(config) {
81
84
  // Y-axis with --zero-position for label positioning
82
85
  const yAxisStyle = hasNegativeY ? ` style="--zero-position: ${zeroPct.toFixed(2)}%"` : '';
83
86
  html += `<div class="chart-y-axis"${yAxisStyle}>`;
84
- html += `<span class="axis-label">${maxValue}</span>`;
87
+ html += `<span class="axis-label">${formatNumber(maxValue, format) || maxValue}</span>`;
85
88
  const midLabelY = hasNegativeY ? 0 : Math.round(maxValue / 2);
86
- html += `<span class="axis-label">${midLabelY}</span>`;
87
- html += `<span class="axis-label">${hasNegativeY ? minValue : 0}</span>`;
89
+ html += `<span class="axis-label">${formatNumber(midLabelY, format) || midLabelY}</span>`;
90
+ const minLabelY = hasNegativeY ? minValue : 0;
91
+ html += `<span class="axis-label">${formatNumber(minLabelY, format) || minLabelY}</span>`;
88
92
  html += `</div>`;
89
93
 
90
- const columnsStyle = hasNegativeY ? ` style="--zero-position: ${zeroPct.toFixed(2)}%"` : '';
91
- html += `<div class="chart-columns"${columnsStyle}>`;
94
+ // Scroll wrapper for columns + labels
95
+ html += `<div class="chart-scroll">`;
92
96
 
93
- data.forEach(row => {
97
+ // Calculate delay step to cap total stagger at 1s
98
+ const maxStagger = 1; // seconds
99
+ const defaultDelay = 0.05; // seconds
100
+ const delayStep = data.length > 1 ? Math.min(defaultDelay, maxStagger / (data.length - 1)) : 0;
101
+ const styleVars = [`--delay-step: ${delayStep.toFixed(3)}s`];
102
+ if (hasNegativeY) styleVars.push(`--zero-position: ${zeroPct.toFixed(2)}%`);
103
+ html += `<div class="chart-columns" style="${styleVars.join('; ')}">`;
104
+
105
+ data.forEach((row, colIndex) => {
94
106
  const label = row[labelKey] ?? '';
95
- html += `<div class="column-track" title="${escapeHtml(label)}">`;
107
+ html += `<div class="column-track" style="--col-index: ${colIndex}" title="${escapeHtml(label)}">`;
96
108
 
97
109
  if (hasNegativeY) {
98
110
  // Build segments first to identify stack ends
@@ -115,7 +127,7 @@ export function renderStackedColumn(config) {
115
127
  classes: `column-segment ${colorClass} ${seriesClass}`,
116
128
  bottom: positiveBottom,
117
129
  height: segmentHeight,
118
- title: `${escapeHtml(seriesLabel)}: ${value}`,
130
+ title: `${escapeHtml(seriesLabel)}: ${formatNumber(value, format) || value}`,
119
131
  isNegative: false
120
132
  });
121
133
  lastPositiveIdx = segments.length - 1;
@@ -126,7 +138,7 @@ export function renderStackedColumn(config) {
126
138
  classes: `column-segment ${colorClass} ${seriesClass} is-negative`,
127
139
  bottom: negativeTop,
128
140
  height: segmentHeight,
129
- title: `${escapeHtml(seriesLabel)}: ${value}`,
141
+ title: `${escapeHtml(seriesLabel)}: ${formatNumber(value, format) || value}`,
130
142
  isNegative: true
131
143
  });
132
144
  lastNegativeIdx = segments.length - 1;
@@ -161,14 +173,13 @@ export function renderStackedColumn(config) {
161
173
  const endClass = idx === lastIdx ? ' is-stack-end' : '';
162
174
  html += `<div class="column-segment ${colorClass} ${seriesClass}${endClass}" `;
163
175
  html += `style="--value: ${seg.pct.toFixed(2)}%" `;
164
- html += `title="${escapeHtml(seriesLabel)}: ${seg.value}"></div>`;
176
+ html += `title="${escapeHtml(seriesLabel)}: ${formatNumber(seg.value, format) || seg.value}"></div>`;
165
177
  });
166
178
  }
167
179
 
168
180
  html += `</div>`;
169
181
  });
170
182
 
171
- html += `</div>`;
172
183
  html += `</div>`;
173
184
 
174
185
  // X-axis labels
@@ -178,6 +189,9 @@ export function renderStackedColumn(config) {
178
189
  html += `<span class="column-label">${escapeHtml(label)}</span>`;
179
190
  });
180
191
  html += `</div>`;
192
+
193
+ html += `</div>`; // close chart-scroll
194
+ html += `</div>`; // close chart-body
181
195
  html += `</figure>`;
182
196
 
183
197
  return html;