@juspay/svelte-ui-components 2.57.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.
@@ -1,5 +1,11 @@
1
1
  <script lang="ts">
2
- import type { BarChartProperties } from './properties';
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
- const totalsPerLabel = labels.map((_, i) =>
69
- resolvedSeries.reduce((sum, s) => sum + Math.max(0, s.data[i]?.value ?? 0), 0)
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
- const all = resolvedSeries.flatMap((s) => s.data.map((d) => d.value));
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 seriesColor = s.color ?? getColor(si);
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 valPos = valScale(d.value);
100
- const color = d.color ?? seriesColor;
101
- result.push(
102
- isVertical
103
- ? {
104
- x: catPos,
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 seriesColor = s.color ?? getColor(si);
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 val = Math.max(0, d.value);
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 color = d.color ?? seriesColor;
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 valPos = valScale(d.value);
173
- const color = d.color ?? getColor(pi);
174
- result.push(
175
- isVertical
176
- ? {
177
- x: catPos,
178
- y: d.value >= 0 ? valPos : zeroPos,
179
- width: catScale.bandwidth,
180
- height: Math.max(2, Math.abs(valPos - zeroPos)),
181
- color,
182
- si: 0,
183
- pi,
184
- dataPoint: d,
185
- seriesName: singleSeries.name
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 ? resolvedSeries.map((s, i) => ({ label: s.name, color: s.color ?? getColor(i) })) : []
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: format(bar.dataPoint.value), color: bar.color }]
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
- <ChartContainer bind:width={chartWidth} bind:height={chartHeight} {aspectRatio}>
273
- <g transform="translate({dims.margin.left}, {dims.margin.top})">
274
- {#if showYAxis}
275
- <Axis
276
- orientation="left"
277
- scale={isVertical ? valScale : catScale}
278
- {showGridlines}
279
- gridlineLength={dims.innerWidth}
280
- label={yAxisLabel}
281
- />
282
- {/if}
283
- {#if showXAxis}
284
- <g transform="translate(0, {dims.innerHeight})">
285
- <Axis
286
- orientation="bottom"
287
- scale={isVertical ? catScale : valScale}
288
- showGridlines={!isVertical && showGridlines}
289
- gridlineLength={dims.innerHeight}
290
- label={xAxisLabel}
291
- />
292
- </g>
293
- {/if}
294
-
295
- {#each bars as bar, i (i)}
296
- <!-- svelte-ignore a11y_no_static_element_interactions -->
297
- <!-- svelte-ignore a11y_click_events_have_key_events -->
298
- <rect
299
- class="bar"
300
- class:hovered={hovered?.si === bar.si && hovered?.pi === bar.pi}
301
- class:dimmed={hovered !== null && (hovered.si !== bar.si || hovered.pi !== bar.pi)}
302
- x={bar.x}
303
- y={bar.y}
304
- width={bar.width}
305
- height={bar.height}
306
- rx={barRadius}
307
- ry={barRadius}
308
- fill={bar.color}
309
- aria-label="{bar.dataPoint.label}: {format(bar.dataPoint.value)}"
310
- onmouseenter={(e) => handleEnter(e, bar)}
311
- onmousemove={trackMouse}
312
- onmouseleave={handleLeave}
313
- onclick={() => handleClick(bar)}
314
- />
315
- {#if showValues && !(isMulti && groupMode === 'stacked')}
316
- <text
317
- class="bar-value"
318
- x={isVertical ? bar.x + bar.width / 2 : bar.x + bar.width + 4}
319
- y={isVertical ? bar.y - 4 : bar.y + bar.height / 2}
320
- text-anchor={isVertical ? 'middle' : 'start'}
321
- dominant-baseline={isVertical ? 'auto' : 'middle'}>{format(bar.dataPoint.value)}</text
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
- {/each}
325
- </g>
326
- </ChartContainer>
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
- color?: string;
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?: string;
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
  };
@@ -5,7 +5,7 @@
5
5
  import { computeSankeyLayout } from '../_chart/geometry';
6
6
  import { getColor } from '../_chart/colors';
7
7
  import { formatNumber } from '../_chart/format';
8
- import { SvelteSet } from 'svelte/reactivity';
8
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
9
9
 
10
10
  // ── Props ──────────────────────────────────────────────────────
11
11
 
@@ -26,7 +26,9 @@
26
26
  onnodehover,
27
27
  onlinkhover,
28
28
  testId,
29
- classes
29
+ classes,
30
+ columnLabels,
31
+ nodeColorResolver
30
32
  }: SankeyChartProperties = $props();
31
33
 
32
34
  // ── State ──────────────────────────────────────────────────────
@@ -56,6 +58,45 @@
56
58
  )
57
59
  );
58
60
 
61
+ /**
62
+ * Pre-computed colour map for all nodes. Keyed by node id. Computed once per layout
63
+ * change rather than re-running find() + indexOf() for every link on every render.
64
+ *
65
+ * Note: nodeColorResolver also controls link stroke colours (links inherit source-node
66
+ * colour), not just node fill colours. See Props docs for full description.
67
+ */
68
+ let nodeColorMap = $derived.by(() => {
69
+ const map = new SvelteMap<string, string>();
70
+ for (let ni = 0; ni < layout.nodes.length; ni++) {
71
+ const node = layout.nodes[ni];
72
+ const color = node.color ?? nodeColorResolver?.(node.id, node.label ?? null) ?? getColor(ni);
73
+ map.set(node.id, color);
74
+ }
75
+ return map;
76
+ });
77
+
78
+ // Column count and width — used by columnLabels rendering
79
+ let columnCount = $derived(
80
+ layout.nodes.length > 0 ? Math.max(...layout.nodes.map((n) => n.column)) + 1 : 0
81
+ );
82
+ let colWidth = $derived(
83
+ columnCount <= 1
84
+ ? Math.max(0, chartWidth - MARGIN * 2)
85
+ : (Math.max(0, chartWidth - MARGIN * 2) - nodeWidth) / (columnCount - 1)
86
+ );
87
+
88
+ // ── Helpers ────────────────────────────────────────────────────
89
+
90
+ /** Percentage of source node's total value carried by a link (0–100, 2 dp). */
91
+ const computeLinkPct = (sourceId: string, linkValue: number): number => {
92
+ const sourceNode = layout.nodes.find((nd) => nd.id === sourceId);
93
+ if (!sourceNode) {
94
+ return 0;
95
+ }
96
+ const sourceTotal = sourceNode.value;
97
+ return sourceTotal > 0 ? Math.round((linkValue / sourceTotal) * 10000) / 100 : 0;
98
+ };
99
+
59
100
  let connectedNodes = $derived.by(() => {
60
101
  if (hoveredNode !== null) {
61
102
  const connected = new SvelteSet<string>([hoveredNode]);
@@ -75,6 +116,27 @@
75
116
 
76
117
  // ── Tooltip ────────────────────────────────────────────────────
77
118
 
119
+ /**
120
+ * Pre-computed link tooltip data for the currently hovered link. Computed once and shared
121
+ * by both `tooltipData` and `tooltipContext` to avoid running the O(n) percentage lookup
122
+ * twice on every hover state change.
123
+ */
124
+ let hoveredLinkCache = $derived.by(() => {
125
+ if (hoveredLink === null) {
126
+ return null;
127
+ }
128
+ const l = links.find(
129
+ (lk) => lk.source === hoveredLink!.source && lk.target === hoveredLink!.target
130
+ );
131
+ if (!l) {
132
+ return null;
133
+ }
134
+ const sourceLabelText = nodes.find((nd) => nd.id === l.source)?.label ?? l.source;
135
+ const targetLabelText = nodes.find((nd) => nd.id === l.target)?.label ?? l.target;
136
+ const pct = computeLinkPct(l.source, l.value);
137
+ return { link: l, sourceLabel: sourceLabelText, targetLabel: targetLabelText, percentage: pct };
138
+ });
139
+
78
140
  let tooltipData = $derived.by(() => {
79
141
  if (hoveredNode !== null) {
80
142
  const n = layout.nodes.find((nd) => nd.id === hoveredNode);
@@ -92,16 +154,14 @@
92
154
  ]
93
155
  };
94
156
  }
95
- if (hoveredLink !== null) {
96
- const l = links.find(
97
- (lk) => lk.source === hoveredLink!.source && lk.target === hoveredLink!.target
98
- );
99
- if (!l) {
100
- return null;
101
- }
157
+ if (hoveredLinkCache !== null) {
158
+ const { link: l, sourceLabel, targetLabel, percentage: pct } = hoveredLinkCache;
102
159
  return {
103
- title: `${nodes.find((n) => n.id === l.source)?.label ?? l.source} → ${nodes.find((n) => n.id === l.target)?.label ?? l.target}`,
104
- items: [{ label: 'Flow', value: format(l.value) }]
160
+ title: `${sourceLabel} → ${targetLabel}`,
161
+ items: [
162
+ { label: 'Flow', value: format(l.value) },
163
+ { label: 'of source', value: `${pct.toFixed(2)}%` }
164
+ ]
105
165
  };
106
166
  }
107
167
  return null;
@@ -116,12 +176,15 @@
116
176
  }
117
177
  return { type: 'node', node: n, value: computed.value };
118
178
  }
119
- if (hoveredLink !== null) {
120
- const l = findLink(hoveredLink.source, hoveredLink.target);
121
- if (!l) {
122
- return null;
123
- }
124
- return { type: 'link', link: l };
179
+ if (hoveredLinkCache !== null) {
180
+ const { link: l, sourceLabel, targetLabel, percentage: pct } = hoveredLinkCache;
181
+ return {
182
+ type: 'link',
183
+ link: l,
184
+ sourceLabel,
185
+ targetLabel,
186
+ percentage: pct
187
+ };
125
188
  }
126
189
  return null;
127
190
  });
@@ -208,6 +271,18 @@
208
271
  {:else}
209
272
  <ChartContainer bind:width={chartWidth} bind:height={chartHeight} {aspectRatio}>
210
273
  <g transform="translate({MARGIN}, {MARGIN})">
274
+ {#if columnLabels != null && columnLabels.length > 0}
275
+ {#each columnLabels as label, ci (ci)}
276
+ <text
277
+ class="sankey-col-label"
278
+ x={ci * colWidth + nodeWidth / 2}
279
+ y={-8}
280
+ text-anchor="middle"
281
+ dominant-baseline="auto">{label}</text
282
+ >
283
+ {/each}
284
+ {/if}
285
+
211
286
  {#each layout.links as link, i (i)}
212
287
  {@const highlighted = isLinkHighlighted(link.source, link.target)}
213
288
  {@const dimmed = (hoveredNode !== null || hoveredLink !== null) && !highlighted}
@@ -217,7 +292,7 @@
217
292
  class="sankey-link"
218
293
  d={link.path}
219
294
  fill="none"
220
- stroke={link.color ?? getColor(layout.nodes.findIndex((n) => n.id === link.source))}
295
+ stroke={link.color ?? nodeColorMap.get(link.source) ?? getColor(0)}
221
296
  stroke-width={Math.max(1, link.width)}
222
297
  stroke-opacity={highlighted ? 0.7 : dimmed ? 0.08 : 0.4}
223
298
  onmouseenter={(e) => handleLinkEnter(e, link.source, link.target)}
@@ -228,7 +303,7 @@
228
303
  {/each}
229
304
 
230
305
  {#each layout.nodes as node, ni (ni)}
231
- {@const color = node.color ?? getColor(ni)}
306
+ {@const color = nodeColorMap.get(node.id) ?? getColor(ni)}
232
307
  {@const dimmed = connectedNodes !== null && !connectedNodes.has(node.id)}
233
308
  <!-- svelte-ignore a11y_no_static_element_interactions -->
234
309
  <!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -296,6 +371,12 @@
296
371
  pointer-events: none;
297
372
  transition: opacity var(--chart-transition-duration, 0.2s) ease;
298
373
  }
374
+ .sankey-col-label {
375
+ fill: var(--sankey-col-label-color, #666);
376
+ font-size: var(--sankey-col-label-font-size, 11px);
377
+ font-family: var(--chart-font-family, inherit);
378
+ pointer-events: none;
379
+ }
299
380
  .sankey-label.node-dimmed {
300
381
  opacity: var(--sankey-dimmed-opacity, 0.15);
301
382
  }
@@ -10,6 +10,14 @@ export type SankeyLink = {
10
10
  value: number;
11
11
  color?: string;
12
12
  };
13
+ /**
14
+ * Context passed to the `tooltipSnippet` prop on each hover event.
15
+ *
16
+ * The `'link'` branch gained three new optional fields (`sourceLabel`, `targetLabel`,
17
+ * `percentage`) in this release. They are always populated by the chart — the fields are
18
+ * typed optional so that existing consumer code that typed a variable explicitly as
19
+ * `{ type: 'link'; link: SankeyLink }` continues to compile without changes.
20
+ */
13
21
  export type SankeyTooltipContext = {
14
22
  type: 'node';
15
23
  node: SankeyNode;
@@ -17,6 +25,12 @@ export type SankeyTooltipContext = {
17
25
  } | {
18
26
  type: 'link';
19
27
  link: SankeyLink;
28
+ /** Human-readable label of the source node (falls back to node id when label is undefined). Always present at runtime. */
29
+ sourceLabel?: string;
30
+ /** Human-readable label of the target node (falls back to node id when label is undefined). Always present at runtime. */
31
+ targetLabel?: string;
32
+ /** link.value as a percentage of the source node's total outgoing value (0–100, rounded to 2 dp). Always present at runtime. */
33
+ percentage?: number;
20
34
  };
21
35
  export type SankeyChartProperties = MandatorySankeyChartProperties & OptionalSankeyChartProperties & SankeyChartEventProperties;
22
36
  export type MandatorySankeyChartProperties = {
@@ -35,6 +49,14 @@ export type OptionalSankeyChartProperties = {
35
49
  empty?: Snippet;
36
50
  testId?: string;
37
51
  classes?: string;
52
+ /** Labels rendered above each column, indexed by column position (0-based). */
53
+ columnLabels?: string[];
54
+ /**
55
+ * Called for each node and also for link strokes (links inherit the resolved source-node
56
+ * colour). Return a CSS colour string to override the default palette, or `null` to fall
57
+ * through to the default palette colour.
58
+ */
59
+ nodeColorResolver?: (id: string, label: string | null) => string | null;
38
60
  };
39
61
  export type SankeyChartEventProperties = {
40
62
  onnodeclick?: (event: {
@@ -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;
@@ -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
+ }
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/svelte-ui-components",
3
- "version": "2.57.0",
3
+ "version": "2.59.0",
4
4
  "description": "A themeable Svelte 5 UI component library with CSS custom property driven styling",
5
5
  "keywords": [
6
6
  "svelte",