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 +100 -6
- package/css/uncharted.css +160 -64
- package/eleventy.config.js +1 -0
- package/package.json +1 -1
- package/src/csv.js +47 -2
- package/src/formatters.js +40 -0
- package/src/index.js +1 -0
- package/src/renderers/donut.js +17 -7
- package/src/renderers/dot.js +23 -11
- package/src/renderers/scatter.js +40 -29
- package/src/renderers/stacked-bar.js +23 -9
- package/src/renderers/stacked-column.js +27 -13
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,
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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: #
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
172
|
-
|
|
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-
|
|
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:
|
|
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.
|
|
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:
|
|
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 .
|
|
351
|
-
|
|
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
|
-
|
|
355
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
package/eleventy.config.js
CHANGED
package/package.json
CHANGED
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]
|
|
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]
|
|
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
package/src/renderers/donut.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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">${
|
|
115
|
+
html += `<span class="legend-value">${escapeHtml(String(displayValue))}</span>`;
|
|
106
116
|
html += `</li>`;
|
|
107
117
|
});
|
|
108
118
|
html += `</ul>`;
|
package/src/renderers/dot.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
81
|
-
html += `<div class="
|
|
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(
|
|
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;
|
package/src/renderers/scatter.js
CHANGED
|
@@ -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 (
|
|
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
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
html += `<div class="chart-
|
|
94
|
+
// Scroll wrapper for columns + labels
|
|
95
|
+
html += `<div class="chart-scroll">`;
|
|
92
96
|
|
|
93
|
-
|
|
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;
|