@juspay/svelte-ui-components 2.58.0 → 2.60.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
  };
@@ -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.58.0",
3
+ "version": "2.60.0",
4
4
  "description": "A themeable Svelte 5 UI component library with CSS custom property driven styling",
5
5
  "keywords": [
6
6
  "svelte",