@juspay/svelte-ui-components 2.58.0 → 2.59.0
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/dist/BarChart/BarChart.svelte +464 -120
- package/dist/BarChart/properties.d.ts +85 -2
- package/dist/_chart/paths.d.ts +7 -0
- package/dist/_chart/paths.js +22 -0
- package/dist/_chart/types.d.ts +13 -0
- package/package.json +1 -1
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
BarChartProperties,
|
|
4
|
+
BarChartRenderContext,
|
|
5
|
+
BarFill,
|
|
6
|
+
BarFillGradient,
|
|
7
|
+
BarFillPattern
|
|
8
|
+
} from './properties';
|
|
3
9
|
import ChartContainer from '../_chart/ChartContainer.svelte';
|
|
4
10
|
import Axis from '../_chart/Axis.svelte';
|
|
5
11
|
import ChartTooltip from '../_chart/ChartTooltip.svelte';
|
|
@@ -8,7 +14,28 @@
|
|
|
8
14
|
import { computeChartDimensions } from '../_chart/geometry';
|
|
9
15
|
import { getColor } from '../_chart/colors';
|
|
10
16
|
import { formatNumber } from '../_chart/format';
|
|
17
|
+
import { roundedRectPath } from '../_chart/paths';
|
|
11
18
|
import type { LegendItem, BarRect } from '../_chart/types';
|
|
19
|
+
import { SvelteMap } from 'svelte/reactivity';
|
|
20
|
+
|
|
21
|
+
// ── Per-instance uid prefix for <defs> ids (A1-3) ─────────────
|
|
22
|
+
// Derived at module scope per the library uid pattern (e.g. AreaChart
|
|
23
|
+
// feat/linechart-gradient line 17) to prevent id collision across multiple
|
|
24
|
+
// BarChart instances on the same page.
|
|
25
|
+
const uid = Math.random().toString(36).slice(2, 9);
|
|
26
|
+
|
|
27
|
+
/** Returns the plain CSS fallback color for any BarFill (used for legend, aria-label). */
|
|
28
|
+
function fallbackColor(fill: BarFill, index: number): string {
|
|
29
|
+
if (typeof fill === 'string') {
|
|
30
|
+
return fill;
|
|
31
|
+
}
|
|
32
|
+
if ('pattern' in fill) {
|
|
33
|
+
return fill.pattern.color ?? getColor(index);
|
|
34
|
+
}
|
|
35
|
+
// gradient: use first stop color as fallback
|
|
36
|
+
const firstStop = fill.gradient.stops[0];
|
|
37
|
+
return firstStop?.color ?? getColor(index);
|
|
38
|
+
}
|
|
12
39
|
|
|
13
40
|
// ── Props ──────────────────────────────────────────────────────
|
|
14
41
|
|
|
@@ -29,8 +56,12 @@
|
|
|
29
56
|
yAxisLabel,
|
|
30
57
|
yDomain,
|
|
31
58
|
valueFormat,
|
|
59
|
+
stackNormalize = false,
|
|
60
|
+
scrollable = false,
|
|
61
|
+
minBandWidth = 48,
|
|
32
62
|
tooltipSnippet,
|
|
33
63
|
empty,
|
|
64
|
+
renderOverlay,
|
|
34
65
|
onbarclick,
|
|
35
66
|
onbarhover,
|
|
36
67
|
testId,
|
|
@@ -60,17 +91,41 @@
|
|
|
60
91
|
return first.map((d) => d.label);
|
|
61
92
|
});
|
|
62
93
|
|
|
94
|
+
let isNormalized = $derived(stackNormalize && isMulti && groupMode === 'stacked');
|
|
95
|
+
|
|
96
|
+
/** When stackNormalize is active and no custom valueFormat is supplied, append % so the
|
|
97
|
+
* unitless tick numbers (0, 25, 50, 75, 100) are displayed as percentages consistently
|
|
98
|
+
* in tooltips and value labels. A consumer-supplied valueFormat always takes precedence. */
|
|
99
|
+
let normalizedFormat = $derived(
|
|
100
|
+
isNormalized && valueFormat == null ? (v: number) => `${formatNumber(v)}%` : format
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// ── Y-extent: account for floating bars (A1-1) ────────────────
|
|
104
|
+
|
|
63
105
|
let yExtent = $derived.by<[number, number]>(() => {
|
|
64
106
|
if (yDomain) {
|
|
65
107
|
return yDomain;
|
|
66
108
|
}
|
|
67
109
|
if (isMulti && groupMode === 'stacked') {
|
|
68
|
-
|
|
69
|
-
|
|
110
|
+
if (stackNormalize) {
|
|
111
|
+
return [0, 100];
|
|
112
|
+
}
|
|
113
|
+
const totalsPerLabel = labels.map((_, labelIndex) =>
|
|
114
|
+
resolvedSeries.reduce((sum, s) => sum + Math.max(0, s.data[labelIndex]?.value ?? 0), 0)
|
|
70
115
|
);
|
|
71
116
|
return niceLinearDomain(0, Math.max(0, ...totalsPerLabel));
|
|
72
117
|
}
|
|
73
|
-
|
|
118
|
+
// Collect all individual values including floating-bar low/high endpoints
|
|
119
|
+
const all: number[] = [];
|
|
120
|
+
for (const s of resolvedSeries) {
|
|
121
|
+
for (const d of s.data) {
|
|
122
|
+
if (Array.isArray(d.range)) {
|
|
123
|
+
all.push(d.range[0], d.range[1]);
|
|
124
|
+
} else {
|
|
125
|
+
all.push(d.value);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
74
129
|
if (all.length === 0) {
|
|
75
130
|
return [0, 1];
|
|
76
131
|
}
|
|
@@ -84,59 +139,109 @@
|
|
|
84
139
|
createLinearScale(yExtent, isVertical ? [dims.innerHeight, 0] : [0, dims.innerWidth])
|
|
85
140
|
);
|
|
86
141
|
|
|
142
|
+
// ── Bar geometry (A1-1 floating bars integrated) ──────────────
|
|
143
|
+
|
|
87
144
|
let bars = $derived.by<BarRect[]>(() => {
|
|
88
145
|
const zeroPos = valScale(0);
|
|
89
146
|
const result: BarRect[] = [];
|
|
90
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Computes x/y/width/height for a single data point in vertical or
|
|
150
|
+
* horizontal orientation, handling both normal value bars and floating
|
|
151
|
+
* [low,high] range bars (A1-1).
|
|
152
|
+
*/
|
|
153
|
+
const barGeometry = (
|
|
154
|
+
d: (typeof resolvedSeries)[0]['data'][0],
|
|
155
|
+
catPos: number,
|
|
156
|
+
barW: number
|
|
157
|
+
): { x: number; y: number; width: number; height: number; isFloating: boolean } => {
|
|
158
|
+
const hasRange = Array.isArray(d.range);
|
|
159
|
+
if (isVertical) {
|
|
160
|
+
if (hasRange) {
|
|
161
|
+
const lowPos = valScale(d.range![0]);
|
|
162
|
+
const highPos = valScale(d.range![1]);
|
|
163
|
+
const top = Math.min(lowPos, highPos);
|
|
164
|
+
const bottom = Math.max(lowPos, highPos);
|
|
165
|
+
return {
|
|
166
|
+
x: catPos,
|
|
167
|
+
y: top,
|
|
168
|
+
width: barW,
|
|
169
|
+
height: Math.max(2, bottom - top),
|
|
170
|
+
isFloating: true
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const valPos = valScale(d.value);
|
|
174
|
+
return {
|
|
175
|
+
x: catPos,
|
|
176
|
+
y: d.value >= 0 ? valPos : zeroPos,
|
|
177
|
+
width: barW,
|
|
178
|
+
height: Math.max(2, Math.abs(valPos - zeroPos)),
|
|
179
|
+
isFloating: false
|
|
180
|
+
};
|
|
181
|
+
} else {
|
|
182
|
+
if (hasRange) {
|
|
183
|
+
const lowPos = valScale(d.range![0]);
|
|
184
|
+
const highPos = valScale(d.range![1]);
|
|
185
|
+
const left = Math.min(lowPos, highPos);
|
|
186
|
+
const right = Math.max(lowPos, highPos);
|
|
187
|
+
return {
|
|
188
|
+
x: left,
|
|
189
|
+
y: catPos,
|
|
190
|
+
width: Math.max(2, right - left),
|
|
191
|
+
height: barW,
|
|
192
|
+
isFloating: true
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const valPos = valScale(d.value);
|
|
196
|
+
return {
|
|
197
|
+
x: d.value >= 0 ? zeroPos : valPos,
|
|
198
|
+
y: catPos,
|
|
199
|
+
width: Math.max(2, Math.abs(valPos - zeroPos)),
|
|
200
|
+
height: barW,
|
|
201
|
+
isFloating: false
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
91
206
|
if (isMulti && groupMode === 'grouped') {
|
|
92
207
|
const subBand = catScale.bandwidth / resolvedSeries.length;
|
|
93
208
|
for (let si = 0; si < resolvedSeries.length; si++) {
|
|
94
209
|
const s = resolvedSeries[si];
|
|
95
|
-
const
|
|
210
|
+
const seriesFill: BarFill = s.color ?? getColor(si);
|
|
96
211
|
for (let pi = 0; pi < s.data.length; pi++) {
|
|
97
212
|
const d = s.data[pi];
|
|
98
213
|
const catPos = catScale(d.label) + si * subBand;
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
y: d.value >= 0 ? valPos : zeroPos,
|
|
106
|
-
width: Math.max(1, subBand * 0.9),
|
|
107
|
-
height: Math.max(2, Math.abs(valPos - zeroPos)),
|
|
108
|
-
color,
|
|
109
|
-
si,
|
|
110
|
-
pi,
|
|
111
|
-
dataPoint: d,
|
|
112
|
-
seriesName: s.name
|
|
113
|
-
}
|
|
114
|
-
: {
|
|
115
|
-
x: d.value >= 0 ? zeroPos : valPos,
|
|
116
|
-
y: catPos,
|
|
117
|
-
width: Math.max(2, Math.abs(valPos - zeroPos)),
|
|
118
|
-
height: Math.max(1, subBand * 0.9),
|
|
119
|
-
color,
|
|
120
|
-
si,
|
|
121
|
-
pi,
|
|
122
|
-
dataPoint: d,
|
|
123
|
-
seriesName: s.name
|
|
124
|
-
}
|
|
125
|
-
);
|
|
214
|
+
const barW = Math.max(1, subBand * 0.9);
|
|
215
|
+
const geom = barGeometry(d, catPos, barW);
|
|
216
|
+
const effectiveFill: BarFill = d.color ?? seriesFill;
|
|
217
|
+
const color = fallbackColor(effectiveFill, si);
|
|
218
|
+
const fillId = resolveFillId(effectiveFill, si, pi, d.color != null);
|
|
219
|
+
result.push({ ...geom, color, fillId, si, pi, dataPoint: d, seriesName: s.name });
|
|
126
220
|
}
|
|
127
221
|
}
|
|
128
222
|
} else if (isMulti && groupMode === 'stacked') {
|
|
223
|
+
const categoryTotals = labels.map((_, labelIndex) =>
|
|
224
|
+
resolvedSeries.reduce((sum, s) => sum + Math.max(0, s.data[labelIndex]?.value ?? 0), 0)
|
|
225
|
+
);
|
|
129
226
|
const stackBase = new Array(labels.length).fill(0);
|
|
130
227
|
for (let si = 0; si < resolvedSeries.length; si++) {
|
|
131
228
|
const s = resolvedSeries[si];
|
|
132
|
-
const
|
|
229
|
+
const seriesFill: BarFill = s.color ?? getColor(si);
|
|
133
230
|
for (let pi = 0; pi < s.data.length; pi++) {
|
|
134
231
|
const d = s.data[pi];
|
|
135
|
-
const
|
|
232
|
+
const rawVal = Math.max(0, d.value);
|
|
233
|
+
const normalizedValue = isNormalized
|
|
234
|
+
? categoryTotals[pi] > 0
|
|
235
|
+
? (rawVal / categoryTotals[pi]) * 100
|
|
236
|
+
: 0
|
|
237
|
+
: null;
|
|
238
|
+
const val = isNormalized ? (normalizedValue ?? 0) : rawVal;
|
|
136
239
|
const y0 = stackBase[pi];
|
|
137
240
|
const y1 = y0 + val;
|
|
138
241
|
stackBase[pi] = y1;
|
|
139
|
-
const
|
|
242
|
+
const effectiveFill: BarFill = d.color ?? seriesFill;
|
|
243
|
+
const color = fallbackColor(effectiveFill, si);
|
|
244
|
+
const fillId = resolveFillId(effectiveFill, si, pi, d.color != null);
|
|
140
245
|
if (isVertical) {
|
|
141
246
|
result.push({
|
|
142
247
|
x: catScale(d.label),
|
|
@@ -144,10 +249,13 @@
|
|
|
144
249
|
width: catScale.bandwidth,
|
|
145
250
|
height: Math.max(0, valScale(y0) - valScale(y1)),
|
|
146
251
|
color,
|
|
252
|
+
fillId,
|
|
147
253
|
si,
|
|
148
254
|
pi,
|
|
149
255
|
dataPoint: d,
|
|
150
|
-
seriesName: s.name
|
|
256
|
+
seriesName: s.name,
|
|
257
|
+
normalizedValue,
|
|
258
|
+
isFloating: false
|
|
151
259
|
});
|
|
152
260
|
} else {
|
|
153
261
|
result.push({
|
|
@@ -156,10 +264,13 @@
|
|
|
156
264
|
width: Math.max(0, valScale(y1) - valScale(y0)),
|
|
157
265
|
height: catScale.bandwidth,
|
|
158
266
|
color,
|
|
267
|
+
fillId,
|
|
159
268
|
si,
|
|
160
269
|
pi,
|
|
161
270
|
dataPoint: d,
|
|
162
|
-
seriesName: s.name
|
|
271
|
+
seriesName: s.name,
|
|
272
|
+
normalizedValue,
|
|
273
|
+
isFloating: false
|
|
163
274
|
});
|
|
164
275
|
}
|
|
165
276
|
}
|
|
@@ -169,44 +280,128 @@
|
|
|
169
280
|
for (let pi = 0; pi < singleSeries.data.length; pi++) {
|
|
170
281
|
const d = singleSeries.data[pi];
|
|
171
282
|
const catPos = catScale(d.label);
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
: {
|
|
188
|
-
x: d.value >= 0 ? zeroPos : valPos,
|
|
189
|
-
y: catPos,
|
|
190
|
-
width: Math.max(2, Math.abs(valPos - zeroPos)),
|
|
191
|
-
height: catScale.bandwidth,
|
|
192
|
-
color,
|
|
193
|
-
si: 0,
|
|
194
|
-
pi,
|
|
195
|
-
dataPoint: d,
|
|
196
|
-
seriesName: singleSeries.name
|
|
197
|
-
}
|
|
198
|
-
);
|
|
283
|
+
const barW = catScale.bandwidth;
|
|
284
|
+
const geom = barGeometry(d, catPos, barW);
|
|
285
|
+
const effectiveFill: BarFill = d.color ?? singleSeries.color ?? getColor(pi);
|
|
286
|
+
const color = fallbackColor(effectiveFill, pi);
|
|
287
|
+
const fillId = resolveFillId(effectiveFill, 0, pi, d.color != null);
|
|
288
|
+
result.push({
|
|
289
|
+
...geom,
|
|
290
|
+
color,
|
|
291
|
+
fillId,
|
|
292
|
+
si: 0,
|
|
293
|
+
pi,
|
|
294
|
+
dataPoint: d,
|
|
295
|
+
seriesName: singleSeries.name
|
|
296
|
+
});
|
|
199
297
|
}
|
|
200
298
|
}
|
|
201
299
|
return result;
|
|
202
300
|
});
|
|
203
301
|
|
|
302
|
+
// ── Defs: pattern and gradient fill resolution (A1-2 / A1-3) ──
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Computes a stable defs element id for a given bar fill.
|
|
306
|
+
*
|
|
307
|
+
* Series-level fills (no per-bar override) share ONE def entry keyed by `si`
|
|
308
|
+
* only, so N data points in the same series do not produce N duplicate SVG
|
|
309
|
+
* ids. Per-bar fills (d.color is set) include `pi` so each bar gets its own
|
|
310
|
+
* def. Returns null for plain CSS color strings (no defs entry needed).
|
|
311
|
+
*/
|
|
312
|
+
function resolveFillId(fill: BarFill, si: number, pi: number, isPerBar: boolean): string | null {
|
|
313
|
+
if (typeof fill === 'string') {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
const key = isPerBar ? `${si}-${pi}` : `${si}`;
|
|
317
|
+
if ('pattern' in fill) {
|
|
318
|
+
return `${uid}-pat-${key}`;
|
|
319
|
+
}
|
|
320
|
+
if ('gradient' in fill) {
|
|
321
|
+
return `${uid}-grad-${key}`;
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Collects unique defs entries (pattern or gradient fills) needed by the
|
|
328
|
+
* current set of bars. Uses a SvelteMap keyed by id to guarantee each SVG id
|
|
329
|
+
* is emitted exactly once — series-level fills that apply to many bars collapse
|
|
330
|
+
* to a single shared def, satisfying the SVG uniqueness requirement.
|
|
331
|
+
*/
|
|
332
|
+
let defsEntries = $derived.by(() => {
|
|
333
|
+
const seen = new SvelteMap<
|
|
334
|
+
string,
|
|
335
|
+
{ id: string; bar: BarRect; fill: BarFillPattern | BarFillGradient }
|
|
336
|
+
>();
|
|
337
|
+
for (const bar of bars) {
|
|
338
|
+
if (!bar.fillId) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (seen.has(bar.fillId)) {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const rawFill = bar.dataPoint.color ?? resolvedSeries[bar.si]?.color;
|
|
345
|
+
if (rawFill == null || typeof rawFill === 'string') {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if ('pattern' in rawFill || 'gradient' in rawFill) {
|
|
349
|
+
seen.set(bar.fillId, { id: bar.fillId, bar, fill: rawFill });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return [...seen.values()];
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// ── Legend items ───────────────────────────────────────────────
|
|
356
|
+
|
|
204
357
|
let legendItems = $derived<LegendItem[]>(
|
|
205
|
-
isMulti
|
|
358
|
+
isMulti
|
|
359
|
+
? resolvedSeries.map((s, i) => ({
|
|
360
|
+
label: s.name,
|
|
361
|
+
color: fallbackColor(s.color ?? getColor(i), i)
|
|
362
|
+
}))
|
|
363
|
+
: []
|
|
206
364
|
);
|
|
207
365
|
|
|
208
366
|
let isEmpty = $derived(resolvedSeries.every((s) => s.data.length === 0) || labels.length === 0);
|
|
209
367
|
|
|
368
|
+
// ── Scroll geometry ────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
let minScrollWidth = $derived(
|
|
371
|
+
labels.length * minBandWidth + dims.margin.left + dims.margin.right
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
// ── Stacked bar path helper ────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
let lastSeriesIndex = $derived(resolvedSeries.length - 1);
|
|
377
|
+
|
|
378
|
+
function stackedBarPath(bar: BarRect): string {
|
|
379
|
+
if (barRadius <= 0) {
|
|
380
|
+
return roundedRectPath(bar.x, bar.y, bar.width, bar.height, 0, 0, 0, 0);
|
|
381
|
+
}
|
|
382
|
+
const isFirst = bar.si === 0;
|
|
383
|
+
const isLast = bar.si === lastSeriesIndex;
|
|
384
|
+
if (isVertical) {
|
|
385
|
+
const tl = isLast ? barRadius : 0;
|
|
386
|
+
const tr = isLast ? barRadius : 0;
|
|
387
|
+
const br = isFirst ? barRadius : 0;
|
|
388
|
+
const bl = isFirst ? barRadius : 0;
|
|
389
|
+
return roundedRectPath(bar.x, bar.y, bar.width, bar.height, tl, tr, br, bl);
|
|
390
|
+
} else {
|
|
391
|
+
const tl = isFirst ? barRadius : 0;
|
|
392
|
+
const bl = isFirst ? barRadius : 0;
|
|
393
|
+
const tr = isLast ? barRadius : 0;
|
|
394
|
+
const br = isLast ? barRadius : 0;
|
|
395
|
+
return roundedRectPath(bar.x, bar.y, bar.width, bar.height, tl, tr, br, bl);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── Fill attribute helper ──────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
function barFillAttr(bar: BarRect): string {
|
|
402
|
+
return bar.fillId ? `url(#${bar.fillId})` : bar.color;
|
|
403
|
+
}
|
|
404
|
+
|
|
210
405
|
// ── Tooltip ────────────────────────────────────────────────────
|
|
211
406
|
|
|
212
407
|
let tooltipData = $derived.by(() => {
|
|
@@ -218,12 +413,29 @@
|
|
|
218
413
|
return null;
|
|
219
414
|
}
|
|
220
415
|
const title = isMulti ? `${bar.dataPoint.label} — ${bar.seriesName}` : bar.dataPoint.label;
|
|
416
|
+
const displayValue =
|
|
417
|
+
isNormalized && bar.normalizedValue != null
|
|
418
|
+
? normalizedFormat(bar.normalizedValue)
|
|
419
|
+
: format(bar.dataPoint.value);
|
|
221
420
|
return {
|
|
222
421
|
title,
|
|
223
|
-
items: [{ label: bar.dataPoint.label, value:
|
|
422
|
+
items: [{ label: bar.dataPoint.label, value: displayValue, color: bar.color }]
|
|
224
423
|
};
|
|
225
424
|
});
|
|
226
425
|
|
|
426
|
+
// ── Render context for overlay snippet (A1-4) ─────────────────
|
|
427
|
+
|
|
428
|
+
let overlayContext = $derived<BarChartRenderContext>({
|
|
429
|
+
innerWidth: dims.innerWidth,
|
|
430
|
+
innerHeight: dims.innerHeight,
|
|
431
|
+
margin: {
|
|
432
|
+
top: dims.margin.top,
|
|
433
|
+
right: dims.margin.right,
|
|
434
|
+
bottom: dims.margin.bottom,
|
|
435
|
+
left: dims.margin.left
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
227
439
|
// ── Interactions ───────────────────────────────────────────────
|
|
228
440
|
|
|
229
441
|
function trackMouse(e: MouseEvent) {
|
|
@@ -255,6 +467,18 @@
|
|
|
255
467
|
? null
|
|
256
468
|
: (bars.find((b) => b.si === hovered!.si && b.pi === hovered!.pi) ?? null);
|
|
257
469
|
}
|
|
470
|
+
|
|
471
|
+
let isStackedMode = $derived(isMulti && groupMode === 'stacked');
|
|
472
|
+
|
|
473
|
+
function getDisplayValue(bar: BarRect): string {
|
|
474
|
+
if (isNormalized && bar.normalizedValue != null) {
|
|
475
|
+
return normalizedFormat(bar.normalizedValue);
|
|
476
|
+
}
|
|
477
|
+
if (bar.isFloating && Array.isArray(bar.dataPoint.range)) {
|
|
478
|
+
return `${format(bar.dataPoint.range[0])} – ${format(bar.dataPoint.range[1])}`;
|
|
479
|
+
}
|
|
480
|
+
return format(bar.dataPoint.value);
|
|
481
|
+
}
|
|
258
482
|
</script>
|
|
259
483
|
|
|
260
484
|
<div
|
|
@@ -269,61 +493,178 @@
|
|
|
269
493
|
<Legend items={legendItems} position="top" />
|
|
270
494
|
{/if}
|
|
271
495
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
{
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
496
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
497
|
+
<div
|
|
498
|
+
class="chart-scroll-area"
|
|
499
|
+
role="region"
|
|
500
|
+
aria-label={yAxisLabel ? `${yAxisLabel} bar chart` : 'Bar chart'}
|
|
501
|
+
tabindex={scrollable ? 0 : null}
|
|
502
|
+
style={scrollable
|
|
503
|
+
? `overflow-x: auto; -webkit-overflow-scrolling: touch; height: var(--barchart-scroll-area-height, auto);`
|
|
504
|
+
: ''}
|
|
505
|
+
>
|
|
506
|
+
<div style={scrollable ? `min-width: ${minScrollWidth}px;` : ''}>
|
|
507
|
+
<ChartContainer bind:width={chartWidth} bind:height={chartHeight} {aspectRatio}>
|
|
508
|
+
<!-- A1-2 / A1-3: SVG <defs> for pattern and gradient fills -->
|
|
509
|
+
{#if defsEntries.length > 0}
|
|
510
|
+
<defs>
|
|
511
|
+
{#each defsEntries as entry (entry.id)}
|
|
512
|
+
{#if 'pattern' in entry.fill}
|
|
513
|
+
{@const pat = entry.fill.pattern}
|
|
514
|
+
{@const patSize = pat.size ?? 8}
|
|
515
|
+
{@const patColor = pat.color ?? entry.bar.color}
|
|
516
|
+
{@const patBg = pat.background ?? 'transparent'}
|
|
517
|
+
{@const patStrokeW = pat.strokeWidth ?? 1.5}
|
|
518
|
+
<pattern
|
|
519
|
+
id={entry.id}
|
|
520
|
+
patternUnits="userSpaceOnUse"
|
|
521
|
+
width={patSize}
|
|
522
|
+
height={patSize}
|
|
523
|
+
>
|
|
524
|
+
<rect width={patSize} height={patSize} fill={patBg} />
|
|
525
|
+
{#if pat.type === 'lines'}
|
|
526
|
+
<line
|
|
527
|
+
x1="0"
|
|
528
|
+
y1={patSize}
|
|
529
|
+
x2={patSize}
|
|
530
|
+
y2="0"
|
|
531
|
+
stroke={patColor}
|
|
532
|
+
stroke-width={patStrokeW}
|
|
533
|
+
/>
|
|
534
|
+
{:else if pat.type === 'crosshatch'}
|
|
535
|
+
<line
|
|
536
|
+
x1="0"
|
|
537
|
+
y1={patSize}
|
|
538
|
+
x2={patSize}
|
|
539
|
+
y2="0"
|
|
540
|
+
stroke={patColor}
|
|
541
|
+
stroke-width={patStrokeW}
|
|
542
|
+
/>
|
|
543
|
+
<line
|
|
544
|
+
x1="0"
|
|
545
|
+
y1="0"
|
|
546
|
+
x2={patSize}
|
|
547
|
+
y2={patSize}
|
|
548
|
+
stroke={patColor}
|
|
549
|
+
stroke-width={patStrokeW}
|
|
550
|
+
/>
|
|
551
|
+
{:else}
|
|
552
|
+
<!-- dots -->
|
|
553
|
+
<circle cx={patSize / 2} cy={patSize / 2} r={patStrokeW} fill={patColor} />
|
|
554
|
+
{/if}
|
|
555
|
+
</pattern>
|
|
556
|
+
{:else if 'gradient' in entry.fill}
|
|
557
|
+
{@const grad = entry.fill.gradient}
|
|
558
|
+
{@const isHoriz = grad.direction === 'horizontal'}
|
|
559
|
+
<!--
|
|
560
|
+
gradientUnits="userSpaceOnUse" is required here.
|
|
561
|
+
objectBoundingBox ratios are undefined on degenerate (zero-height)
|
|
562
|
+
path bounding boxes (stacked segments, Firefox renders black).
|
|
563
|
+
userSpaceOnUse resolves in the coordinate system of the
|
|
564
|
+
referencing element — i.e. inner space (inside the <g transform>)
|
|
565
|
+
— so bar.y / bar.x / bar.height / bar.width are used directly
|
|
566
|
+
without any margin offset. This matches the AreaChart pattern
|
|
567
|
+
(feat/linechart-gradient ea5f794, lines 282-284).
|
|
568
|
+
-->
|
|
569
|
+
<linearGradient
|
|
570
|
+
id={entry.id}
|
|
571
|
+
x1={entry.bar.x}
|
|
572
|
+
y1={entry.bar.y}
|
|
573
|
+
x2={isHoriz ? entry.bar.x + entry.bar.width : entry.bar.x}
|
|
574
|
+
y2={isHoriz ? entry.bar.y : entry.bar.y + entry.bar.height}
|
|
575
|
+
gradientUnits="userSpaceOnUse"
|
|
576
|
+
>
|
|
577
|
+
{#each grad.stops as stop (stop.offset)}
|
|
578
|
+
<stop
|
|
579
|
+
offset="{stop.offset * 100}%"
|
|
580
|
+
stop-color={stop.color}
|
|
581
|
+
stop-opacity={stop.opacity ?? 1}
|
|
582
|
+
/>
|
|
583
|
+
{/each}
|
|
584
|
+
</linearGradient>
|
|
585
|
+
{/if}
|
|
586
|
+
{/each}
|
|
587
|
+
</defs>
|
|
323
588
|
{/if}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
589
|
+
|
|
590
|
+
<g transform="translate({dims.margin.left}, {dims.margin.top})">
|
|
591
|
+
{#if showYAxis}
|
|
592
|
+
<Axis
|
|
593
|
+
orientation="left"
|
|
594
|
+
scale={isVertical ? valScale : catScale}
|
|
595
|
+
{showGridlines}
|
|
596
|
+
gridlineLength={dims.innerWidth}
|
|
597
|
+
label={yAxisLabel}
|
|
598
|
+
/>
|
|
599
|
+
{/if}
|
|
600
|
+
{#if showXAxis}
|
|
601
|
+
<g transform="translate(0, {dims.innerHeight})">
|
|
602
|
+
<Axis
|
|
603
|
+
orientation="bottom"
|
|
604
|
+
scale={isVertical ? catScale : valScale}
|
|
605
|
+
showGridlines={!isVertical && showGridlines}
|
|
606
|
+
gridlineLength={dims.innerHeight}
|
|
607
|
+
label={xAxisLabel}
|
|
608
|
+
/>
|
|
609
|
+
</g>
|
|
610
|
+
{/if}
|
|
611
|
+
|
|
612
|
+
{#each bars as bar, i (i)}
|
|
613
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
614
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
615
|
+
{#if isStackedMode && barRadius > 0}
|
|
616
|
+
<path
|
|
617
|
+
class="bar"
|
|
618
|
+
class:hovered={hovered?.si === bar.si && hovered?.pi === bar.pi}
|
|
619
|
+
class:dimmed={hovered !== null &&
|
|
620
|
+
(hovered.si !== bar.si || hovered.pi !== bar.pi)}
|
|
621
|
+
d={stackedBarPath(bar)}
|
|
622
|
+
fill={barFillAttr(bar)}
|
|
623
|
+
aria-label="{bar.dataPoint.label}: {getDisplayValue(bar)}"
|
|
624
|
+
onmouseenter={(e) => handleEnter(e, bar)}
|
|
625
|
+
onmousemove={trackMouse}
|
|
626
|
+
onmouseleave={handleLeave}
|
|
627
|
+
onclick={() => handleClick(bar)}
|
|
628
|
+
/>
|
|
629
|
+
{:else}
|
|
630
|
+
<rect
|
|
631
|
+
class="bar"
|
|
632
|
+
class:hovered={hovered?.si === bar.si && hovered?.pi === bar.pi}
|
|
633
|
+
class:dimmed={hovered !== null &&
|
|
634
|
+
(hovered.si !== bar.si || hovered.pi !== bar.pi)}
|
|
635
|
+
x={bar.x}
|
|
636
|
+
y={bar.y}
|
|
637
|
+
width={bar.width}
|
|
638
|
+
height={bar.height}
|
|
639
|
+
rx={barRadius}
|
|
640
|
+
ry={barRadius}
|
|
641
|
+
fill={barFillAttr(bar)}
|
|
642
|
+
aria-label="{bar.dataPoint.label}: {getDisplayValue(bar)}"
|
|
643
|
+
onmouseenter={(e) => handleEnter(e, bar)}
|
|
644
|
+
onmousemove={trackMouse}
|
|
645
|
+
onmouseleave={handleLeave}
|
|
646
|
+
onclick={() => handleClick(bar)}
|
|
647
|
+
/>
|
|
648
|
+
{/if}
|
|
649
|
+
{#if showValues && !isStackedMode}
|
|
650
|
+
<text
|
|
651
|
+
class="bar-value"
|
|
652
|
+
x={isVertical ? bar.x + bar.width / 2 : bar.x + bar.width + 4}
|
|
653
|
+
y={isVertical ? bar.y - 4 : bar.y + bar.height / 2}
|
|
654
|
+
text-anchor={isVertical ? 'middle' : 'start'}
|
|
655
|
+
dominant-baseline={isVertical ? 'auto' : 'middle'}>{getDisplayValue(bar)}</text
|
|
656
|
+
>
|
|
657
|
+
{/if}
|
|
658
|
+
{/each}
|
|
659
|
+
|
|
660
|
+
<!-- A1-4: renderOverlay escape hatch — rendered after all bars -->
|
|
661
|
+
{#if typeof renderOverlay === 'function'}
|
|
662
|
+
{@render renderOverlay(overlayContext)}
|
|
663
|
+
{/if}
|
|
664
|
+
</g>
|
|
665
|
+
</ChartContainer>
|
|
666
|
+
</div>
|
|
667
|
+
</div>
|
|
327
668
|
|
|
328
669
|
{#if typeof tooltipSnippet === 'function' && hoveredBar()}
|
|
329
670
|
{@const hb = hoveredBar()}
|
|
@@ -343,6 +684,9 @@
|
|
|
343
684
|
width: 100%;
|
|
344
685
|
position: relative;
|
|
345
686
|
}
|
|
687
|
+
.chart-scroll-area {
|
|
688
|
+
width: 100%;
|
|
689
|
+
}
|
|
346
690
|
.bar {
|
|
347
691
|
transition: opacity var(--chart-transition-duration, 0.2s) ease;
|
|
348
692
|
cursor: pointer;
|
|
@@ -1,13 +1,69 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
|
+
export type BarFillPattern = {
|
|
3
|
+
pattern: {
|
|
4
|
+
/** SVG pattern element type: 'lines' | 'dots' | 'crosshatch' */
|
|
5
|
+
type: 'lines' | 'dots' | 'crosshatch';
|
|
6
|
+
/** Foreground stroke/fill color of the pattern marks */
|
|
7
|
+
color?: string;
|
|
8
|
+
/** Background fill color (defaults to transparent) */
|
|
9
|
+
background?: string;
|
|
10
|
+
/** Pattern cell size in px (default 8) */
|
|
11
|
+
size?: number;
|
|
12
|
+
/** Stroke width for line-based patterns (default 1.5) */
|
|
13
|
+
strokeWidth?: number;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
export type BarFillGradientStop = {
|
|
17
|
+
offset: number;
|
|
18
|
+
color: string;
|
|
19
|
+
opacity?: number;
|
|
20
|
+
};
|
|
21
|
+
export type BarFillGradient = {
|
|
22
|
+
gradient: {
|
|
23
|
+
stops: BarFillGradientStop[];
|
|
24
|
+
/** 'vertical' → top-to-bottom (default), 'horizontal' → left-to-right */
|
|
25
|
+
direction?: 'vertical' | 'horizontal';
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
/** A bar's fill: plain CSS color string, SVG pattern fill, or linear gradient fill. */
|
|
29
|
+
export type BarFill = string | BarFillPattern | BarFillGradient;
|
|
2
30
|
export type BarChartDataPoint = {
|
|
3
31
|
label: string;
|
|
32
|
+
/** Value used for a standard bar. Ignored when [low, high] tuple is supplied. */
|
|
4
33
|
value: number;
|
|
5
|
-
|
|
34
|
+
/**
|
|
35
|
+
* A1-1 floating / columnrange bar: [low, high] tuple where both are absolute
|
|
36
|
+
* domain values. When present the bar spans from low to high instead of
|
|
37
|
+
* from zero to value.
|
|
38
|
+
*/
|
|
39
|
+
range?: [number, number];
|
|
40
|
+
/** Per-bar fill: plain color, pattern, or gradient. Overrides series color. */
|
|
41
|
+
color?: BarFill;
|
|
6
42
|
};
|
|
7
43
|
export type BarChartSeries = {
|
|
8
44
|
name: string;
|
|
9
45
|
data: BarChartDataPoint[];
|
|
10
|
-
color
|
|
46
|
+
/** Series-level fill: plain color, pattern, or gradient. */
|
|
47
|
+
color?: BarFill;
|
|
48
|
+
};
|
|
49
|
+
export type BarChartRenderContext = {
|
|
50
|
+
/** Inner drawing width (pixels) */
|
|
51
|
+
innerWidth: number;
|
|
52
|
+
/** Inner drawing height (pixels) */
|
|
53
|
+
innerHeight: number;
|
|
54
|
+
/**
|
|
55
|
+
* Full margin offsets applied to the main <g> transform.
|
|
56
|
+
* All four edges are exposed so consumers can compute chart
|
|
57
|
+
* boundaries in both dimensions (e.g. innerWidth + margin.right
|
|
58
|
+
* for a right-edge annotation, innerHeight + margin.bottom for
|
|
59
|
+
* a bottom-edge connector in a funnel overlay).
|
|
60
|
+
*/
|
|
61
|
+
margin: {
|
|
62
|
+
top: number;
|
|
63
|
+
right: number;
|
|
64
|
+
bottom: number;
|
|
65
|
+
left: number;
|
|
66
|
+
};
|
|
11
67
|
};
|
|
12
68
|
export type BarChartProperties = OptionalBarChartProperties & BarChartEventProperties;
|
|
13
69
|
export type OptionalBarChartProperties = {
|
|
@@ -27,8 +83,35 @@ export type OptionalBarChartProperties = {
|
|
|
27
83
|
valueFormat?: (value: number) => string;
|
|
28
84
|
groupMode?: 'grouped' | 'stacked';
|
|
29
85
|
showLegend?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* When `true` and `groupMode="stacked"`, normalises each category's stack to
|
|
88
|
+
* 100% so bars represent proportions rather than absolute values. The Y axis
|
|
89
|
+
* runs 0–100 and value labels are suffixed with `%` (unless `valueFormat` is
|
|
90
|
+
* provided to override the default formatter).
|
|
91
|
+
*/
|
|
92
|
+
stackNormalize?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* When `true`, wraps the SVG in a horizontally-scrollable container so that
|
|
95
|
+
* wide charts with many categories remain readable at small container widths.
|
|
96
|
+
* Combine with `minBandWidth` to control how much each category band expands
|
|
97
|
+
* before the chart begins to overflow and scroll.
|
|
98
|
+
*/
|
|
99
|
+
scrollable?: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Minimum pixel width per category band when `scrollable` is `true`.
|
|
102
|
+
* The chart's inner width grows until every band is at least this many pixels
|
|
103
|
+
* wide, then the scroll container takes over. Has no effect when `scrollable`
|
|
104
|
+
* is `false`. Default is `48`.
|
|
105
|
+
*/
|
|
106
|
+
minBandWidth?: number;
|
|
30
107
|
tooltipSnippet?: Snippet<[BarChartDataPoint, number]>;
|
|
31
108
|
empty?: Snippet;
|
|
109
|
+
/**
|
|
110
|
+
* A1-4 escape hatch: a Snippet rendered inside the SVG transform group after
|
|
111
|
+
* all bars. Use for overlays, annotations, or drop-off indicators that must
|
|
112
|
+
* live in SVG coordinate space.
|
|
113
|
+
*/
|
|
114
|
+
renderOverlay?: Snippet<[BarChartRenderContext]>;
|
|
32
115
|
testId?: string;
|
|
33
116
|
classes?: string;
|
|
34
117
|
};
|
package/dist/_chart/paths.d.ts
CHANGED
|
@@ -2,3 +2,10 @@ import type { Point, CurveType } from './types';
|
|
|
2
2
|
export declare function arcPath(cx: number, cy: number, innerR: number, outerR: number, startAngle: number, endAngle: number): string;
|
|
3
3
|
export declare function linePath(points: Point[], curve?: CurveType): string;
|
|
4
4
|
export declare function areaPath(points: Point[], baseline: number, curve?: CurveType): string;
|
|
5
|
+
/**
|
|
6
|
+
* Builds an SVG path string for a rectangle with independently-controlled
|
|
7
|
+
* corner radii. Each radius is clamped to Math.min(r, w/2, h/2) so the
|
|
8
|
+
* shape never degenerates. Parameters follow the CSS border-radius order:
|
|
9
|
+
* tl=top-left, tr=top-right, br=bottom-right, bl=bottom-left.
|
|
10
|
+
*/
|
|
11
|
+
export declare function roundedRectPath(x: number, y: number, w: number, h: number, tl: number, tr: number, br: number, bl: number): string;
|
package/dist/_chart/paths.js
CHANGED
|
@@ -136,3 +136,25 @@ export function areaPath(points, baseline, curve = 'linear') {
|
|
|
136
136
|
const firstPoint = points[0];
|
|
137
137
|
return topPath + ` L ${lastPoint.x} ${baseline} L ${firstPoint.x} ${baseline} Z`;
|
|
138
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Builds an SVG path string for a rectangle with independently-controlled
|
|
141
|
+
* corner radii. Each radius is clamped to Math.min(r, w/2, h/2) so the
|
|
142
|
+
* shape never degenerates. Parameters follow the CSS border-radius order:
|
|
143
|
+
* tl=top-left, tr=top-right, br=bottom-right, bl=bottom-left.
|
|
144
|
+
*/
|
|
145
|
+
export function roundedRectPath(x, y, w, h, tl, tr, br, bl) {
|
|
146
|
+
const clamp = (r) => Math.min(r, w / 2, h / 2);
|
|
147
|
+
const rtl = clamp(tl);
|
|
148
|
+
const rtr = clamp(tr);
|
|
149
|
+
const rbr = clamp(br);
|
|
150
|
+
const rbl = clamp(bl);
|
|
151
|
+
return (`M ${x + rtl} ${y}` +
|
|
152
|
+
` H ${x + w - rtr}` +
|
|
153
|
+
` Q ${x + w} ${y} ${x + w} ${y + rtr}` +
|
|
154
|
+
` V ${y + h - rbr}` +
|
|
155
|
+
` Q ${x + w} ${y + h} ${x + w - rbr} ${y + h}` +
|
|
156
|
+
` H ${x + rbl}` +
|
|
157
|
+
` Q ${x} ${y + h} ${x} ${y + h - rbl}` +
|
|
158
|
+
` V ${y + rtl}` +
|
|
159
|
+
` Q ${x} ${y} ${x + rtl} ${y} Z`);
|
|
160
|
+
}
|
package/dist/_chart/types.d.ts
CHANGED
|
@@ -71,11 +71,24 @@ export type BarRect = {
|
|
|
71
71
|
y: number;
|
|
72
72
|
width: number;
|
|
73
73
|
height: number;
|
|
74
|
+
/**
|
|
75
|
+
* Resolved CSS color string used for plain fills and as the fallback when
|
|
76
|
+
* a defs-based fill (pattern / gradient) is in use.
|
|
77
|
+
*/
|
|
74
78
|
color: string;
|
|
79
|
+
/**
|
|
80
|
+
* When non-null, the bar's `fill` attribute should reference `url(#<fillId>)`
|
|
81
|
+
* instead of the plain `color` string. Set by the BarChart defs resolution
|
|
82
|
+
* logic for pattern and gradient fills.
|
|
83
|
+
*/
|
|
84
|
+
fillId: string | null;
|
|
75
85
|
si: number;
|
|
76
86
|
pi: number;
|
|
77
87
|
dataPoint: BarChartDataPoint;
|
|
78
88
|
seriesName: string;
|
|
89
|
+
normalizedValue?: number | null;
|
|
90
|
+
/** True when this bar was produced from a [low, high] range tuple (A1-1). */
|
|
91
|
+
isFloating?: boolean;
|
|
79
92
|
};
|
|
80
93
|
export type StackedPoint = {
|
|
81
94
|
x: number;
|