@juspay/svelte-ui-components 2.85.0 → 2.87.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.
@@ -12,18 +12,28 @@
12
12
  import Axis from '../_chart/Axis.svelte';
13
13
  import ChartTooltip from '../_chart/ChartTooltip.svelte';
14
14
  import Legend from '../_chart/Legend.svelte';
15
- import { createBandScale, createLinearScale, niceLinearDomain } from '../_chart/scales';
16
15
  import {
17
- computeChartDimensions,
18
- computeHorizontalCategoryGutter,
19
- measureTextWidth
20
- } from '../_chart/geometry';
21
- import { getColor } from '../_chart/colors';
16
+ createBandScale,
17
+ createLinearScale,
18
+ niceLinearDomain,
19
+ computeLinearTicks
20
+ } from '../_chart/scales';
21
+ import { computeAutoLayout } from '../_chart/geometry';
22
+ import { getColor, getContrastColor } from '../_chart/colors';
22
23
  import { formatNumber } from '../_chart/format';
23
24
  import { roundedRectPath } from '../_chart/paths';
25
+ import { measureText, readCssVarPx } from '../_chart/measure';
26
+ import {
27
+ resolveEndLabel,
28
+ resolveInsideLabel,
29
+ placedLabelRect,
30
+ dropOverlapping
31
+ } from '../_chart/labels';
24
32
  import type { LegendItem, BarRect } from '../_chart/types';
25
33
  import { DEFAULT_CHART_CORNER_RADIUS, DEFAULT_CHART_MAX_HEIGHT } from '../_chart/types';
26
- import { SvelteMap } from 'svelte/reactivity';
34
+ import type { TooltipAnchor } from '../_chart/types';
35
+ import { pointerPositionIn, dismissOnOutsidePointerDown } from '../_chart/interactions';
36
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
27
37
 
28
38
  // ── Per-instance uid prefix for <defs> ids (A1-3) ─────────────
29
39
  // Derived at module scope per the library uid pattern (e.g. AreaChart
@@ -77,6 +87,9 @@
77
87
  topN,
78
88
  overflowLabel = 'Other',
79
89
  hideBarGraphics = false,
90
+ interactiveLegend = false,
91
+ hideLegendBelow = 360,
92
+ tooltipPortal = false,
80
93
  onbarclick,
81
94
  onbarhover,
82
95
  testId,
@@ -86,17 +99,22 @@
86
99
  // ── State ──────────────────────────────────────────────────────
87
100
 
88
101
  let containerEl: HTMLDivElement | null = $state(null);
102
+ let plotEl: HTMLDivElement | null = $state(null);
89
103
  let chartWidth = $state(0);
90
104
  let chartHeight = $state(0);
91
105
  let hovered = $state<{ si: number; pi: number } | null>(null);
92
106
  let mouseX = $state(0);
93
107
  let mouseY = $state(0);
108
+ let anchor = $state<TooltipAnchor | null>(null);
109
+ let valueFontSize = $state(14);
94
110
  /** Internal tracking for the imperative highlight API (onChartReady). */
95
111
  let apiHighlightedIndex = $state<number | null>(null);
112
+ const hiddenSeries = new SvelteSet<number>();
96
113
 
97
114
  // ── onMount: emit ChartHighlightAPI via onChartReady ──────────
98
115
 
99
116
  onMount(() => {
117
+ valueFontSize = readCssVarPx(containerEl, '--barchart-value-font-size', 14);
100
118
  if (typeof onChartReady !== 'function') {
101
119
  return;
102
120
  }
@@ -185,63 +203,26 @@
185
203
  });
186
204
  });
187
205
 
206
+ // Legend-toggled series are excluded from geometry and scales (Highcharts
207
+ // rescales on toggle) but stay in legendItems so they can be re-enabled.
208
+ let visibleEntries = $derived(
209
+ resolvedSeries.map((s, si) => ({ s, si })).filter((e) => !hiddenSeries.has(e.si))
210
+ );
211
+ let visibleOrder = $derived(new Map(visibleEntries.map((e, vi) => [e.si, vi])));
212
+
213
+ function toggleSeries(index: number): void {
214
+ if (hiddenSeries.has(index)) {
215
+ hiddenSeries.delete(index);
216
+ } else {
217
+ hiddenSeries.add(index);
218
+ }
219
+ }
220
+
188
221
  let labels = $derived.by(() => {
189
222
  const first = resolvedSeries[0]?.data ?? [];
190
223
  return first.map((d) => d.label);
191
224
  });
192
225
 
193
- /**
194
- * Widest category label in the axis tick font, for the horizontal-orientation
195
- * gutter below. Horizontal charts put category text (not short numeric ticks)
196
- * on the Y axis, so the gutter must fit real words like "Submitted Address" —
197
- * a fixed gutter clips them. Resolved from the mounted container so CSS-var
198
- * theming (--chart-axis-font-size / --chart-axis-font-family) is honoured.
199
- * Null when unmeasurable (SSR, no canvas) — the gutter then keeps its legacy
200
- * fixed width.
201
- */
202
- let widestCategoryLabelWidth = $derived.by(() => {
203
- if (isVertical || !showYAxis || labels.length === 0 || containerEl === null) {
204
- return null;
205
- }
206
- const containerStyle = getComputedStyle(containerEl);
207
- const axisFontSize = containerStyle.getPropertyValue('--chart-axis-font-size').trim() || '11px';
208
- const axisFontFamilyToken = containerStyle.getPropertyValue('--chart-axis-font-family').trim();
209
- const axisFontFamily =
210
- axisFontFamilyToken === '' || axisFontFamilyToken === 'inherit'
211
- ? containerStyle.fontFamily || 'sans-serif'
212
- : axisFontFamilyToken;
213
- const axisFont = `${axisFontSize} ${axisFontFamily}`;
214
- let widest: number | null = null;
215
- for (const label of labels) {
216
- const labelWidth = measureTextWidth(label, axisFont);
217
- if (labelWidth === null) {
218
- return null;
219
- }
220
- if (widest === null || labelWidth > widest) {
221
- widest = labelWidth;
222
- }
223
- }
224
- return widest;
225
- });
226
-
227
- // When an axis is hidden its gutter (Y = 50px for tick labels, X = 40px) is dead
228
- // space that squeezes the plot into the centre. Collapse the tick-label gutter but
229
- // keep a symmetric breathing-room inset so the edge bars (and their value labels)
230
- // don't sit flush against the container edges. In horizontal orientation the
231
- // visible Y axis carries category text, so its gutter grows to fit the widest
232
- // label (never shrinking below the legacy 50px, capped at 45% of the width).
233
- let dims = $derived(
234
- computeChartDimensions(chartWidth, chartHeight, {
235
- left: showYAxis
236
- ? isVertical
237
- ? 50
238
- : computeHorizontalCategoryGutter(widestCategoryLabelWidth, chartWidth)
239
- : 28,
240
- right: 28,
241
- bottom: showXAxis ? 40 : 8
242
- })
243
- );
244
-
245
226
  // ── Effective highlighted index: merge declarative + imperative ─
246
227
 
247
228
  /**
@@ -262,6 +243,18 @@
262
243
  isNormalized && valueFormat == null ? (v: number) => `${formatNumber(v)}%` : format
263
244
  );
264
245
 
246
+ let isStackedMode = $derived(isMulti && groupMode === 'stacked');
247
+
248
+ function getDisplayValue(bar: BarRect): string {
249
+ if (isNormalized && bar.normalizedValue != null) {
250
+ return normalizedFormat(bar.normalizedValue);
251
+ }
252
+ if (bar.isFloating && Array.isArray(bar.dataPoint.range)) {
253
+ return `${format(bar.dataPoint.range[0])} – ${format(bar.dataPoint.range[1])}`;
254
+ }
255
+ return format(bar.dataPoint.value);
256
+ }
257
+
265
258
  // ── Y-extent: account for floating bars (A1-1) ────────────────
266
259
 
267
260
  let yExtent = $derived.by<[number, number]>(() => {
@@ -273,13 +266,13 @@
273
266
  return [0, 100];
274
267
  }
275
268
  const totalsPerLabel = labels.map((_, labelIndex) =>
276
- resolvedSeries.reduce((sum, s) => sum + Math.max(0, s.data[labelIndex]?.value ?? 0), 0)
269
+ visibleEntries.reduce((sum, { s }) => sum + Math.max(0, s.data[labelIndex]?.value ?? 0), 0)
277
270
  );
278
271
  return niceLinearDomain(0, Math.max(0, ...totalsPerLabel));
279
272
  }
280
273
  // Collect all individual values including floating-bar low/high endpoints
281
274
  const all: number[] = [];
282
- for (const s of resolvedSeries) {
275
+ for (const { s } of visibleEntries) {
283
276
  for (const d of s.data) {
284
277
  if (Array.isArray(d.range)) {
285
278
  all.push(d.range[0], d.range[1]);
@@ -294,6 +287,27 @@
294
287
  return niceLinearDomain(Math.min(0, ...all), Math.max(0, ...all));
295
288
  });
296
289
 
290
+ let valTickCount = $derived(
291
+ isVertical
292
+ ? Math.max(2, Math.min(6, Math.floor(chartHeight / 70)))
293
+ : Math.max(2, Math.min(8, Math.floor(chartWidth / 90)))
294
+ );
295
+
296
+ let layout = $derived.by(() => {
297
+ const valFmt = isNormalized ? normalizedFormat : format;
298
+ const valTicks = computeLinearTicks(yExtent, valTickCount).map((t) => valFmt(t));
299
+ return computeAutoLayout({
300
+ width: chartWidth,
301
+ height: chartHeight,
302
+ yTickLabels: showYAxis ? (isVertical ? valTicks : labels) : [],
303
+ xTickLabels: showXAxis ? (isVertical ? labels : valTicks) : [],
304
+ hasYAxisLabel: Boolean(yAxisLabel) && showYAxis,
305
+ hasXAxisLabel: Boolean(xAxisLabel) && showXAxis,
306
+ base: { top: 20, left: showYAxis ? 50 : 28, right: 28, bottom: showXAxis ? 40 : 8 }
307
+ });
308
+ });
309
+ let dims = $derived(layout);
310
+
297
311
  let catScale = $derived(
298
312
  createBandScale(labels, isVertical ? [0, dims.innerWidth] : [0, dims.innerHeight], barPadding)
299
313
  );
@@ -366,13 +380,13 @@
366
380
  };
367
381
 
368
382
  if (isMulti && groupMode === 'grouped') {
369
- const subBand = catScale.bandwidth / resolvedSeries.length;
370
- for (let si = 0; si < resolvedSeries.length; si++) {
371
- const s = resolvedSeries[si];
383
+ const subBand = catScale.bandwidth / Math.max(1, visibleEntries.length);
384
+ for (let vi = 0; vi < visibleEntries.length; vi++) {
385
+ const { s, si } = visibleEntries[vi];
372
386
  const seriesFill: BarFill = s.color ?? getColor(si);
373
387
  for (let pi = 0; pi < s.data.length; pi++) {
374
388
  const d = s.data[pi];
375
- const catPos = catScale(d.label) + si * subBand;
389
+ const catPos = catScale(d.label) + vi * subBand;
376
390
  const barW = Math.max(1, subBand * 0.9);
377
391
  const geom = barGeometry(d, catPos, barW);
378
392
  const effectiveFill: BarFill = d.color ?? seriesFill;
@@ -383,11 +397,11 @@
383
397
  }
384
398
  } else if (isMulti && groupMode === 'stacked') {
385
399
  const categoryTotals = labels.map((_, labelIndex) =>
386
- resolvedSeries.reduce((sum, s) => sum + Math.max(0, s.data[labelIndex]?.value ?? 0), 0)
400
+ visibleEntries.reduce((sum, { s }) => sum + Math.max(0, s.data[labelIndex]?.value ?? 0), 0)
387
401
  );
388
402
  const stackBase = new Array(labels.length).fill(0);
389
- for (let si = 0; si < resolvedSeries.length; si++) {
390
- const s = resolvedSeries[si];
403
+ for (let vi = 0; vi < visibleEntries.length; vi++) {
404
+ const { s, si } = visibleEntries[vi];
391
405
  const seriesFill: BarFill = s.color ?? getColor(si);
392
406
  for (let pi = 0; pi < s.data.length; pi++) {
393
407
  const d = s.data[pi];
@@ -461,6 +475,41 @@
461
475
  return result;
462
476
  });
463
477
 
478
+ let valueFont = $derived({ size: valueFontSize, weight: 600 });
479
+
480
+ // Highcharts label chain per bar (outside → justify inside → crop), then a
481
+ // global allowOverlap:false pass so neighbouring labels never collide.
482
+ let barLabels = $derived.by(() => {
483
+ if (!showValues) {
484
+ return [];
485
+ }
486
+ const plot = { width: dims.innerWidth, height: dims.innerHeight };
487
+ const placements = bars.map((bar) => {
488
+ const text = getDisplayValue(bar);
489
+ const size = measureText(text, valueFont);
490
+ const p = isStackedMode
491
+ ? resolveInsideLabel({ bar, label: size })
492
+ : resolveEndLabel({
493
+ bar,
494
+ plot,
495
+ label: size,
496
+ orientation,
497
+ negative: !bar.isFloating && bar.dataPoint.value < 0
498
+ });
499
+ return { bar, text, size, p };
500
+ });
501
+ const mask = dropOverlapping(
502
+ placements.map(({ p, size }) => (p.placement === 'hidden' ? null : placedLabelRect(p, size)))
503
+ );
504
+ return placements.map((entry, i) => ({
505
+ ...entry,
506
+ visible: mask[i] === true && entry.p.placement !== 'hidden'
507
+ }));
508
+ });
509
+
510
+ const contrastOutline = (fill: string): string =>
511
+ getContrastColor(fill) === '#000000' ? '#ffffff' : '#000000';
512
+
464
513
  // ── Defs: pattern and gradient fill resolution (A1-2 / A1-3) ──
465
514
 
466
515
  /**
@@ -520,7 +569,8 @@
520
569
  isMulti
521
570
  ? resolvedSeries.map((s, i) => ({
522
571
  label: s.name,
523
- color: fallbackColor(s.color ?? getColor(i), i)
572
+ color: fallbackColor(s.color ?? getColor(i), i),
573
+ hidden: hiddenSeries.has(i)
524
574
  }))
525
575
  : []
526
576
  );
@@ -535,14 +585,13 @@
535
585
 
536
586
  // ── Stacked bar path helper ────────────────────────────────────
537
587
 
538
- let lastSeriesIndex = $derived(resolvedSeries.length - 1);
539
-
540
588
  function stackedBarPath(bar: BarRect): string {
541
589
  if (barRadius <= 0) {
542
590
  return roundedRectPath(bar.x, bar.y, bar.width, bar.height, 0, 0, 0, 0);
543
591
  }
544
- const isFirst = bar.si === 0;
545
- const isLast = bar.si === lastSeriesIndex;
592
+ const vi = visibleOrder.get(bar.si) ?? 0;
593
+ const isFirst = vi === 0;
594
+ const isLast = vi === visibleEntries.length - 1;
546
595
  if (isVertical) {
547
596
  const tl = isLast ? barRadius : 0;
548
597
  const tr = isLast ? barRadius : 0;
@@ -597,26 +646,75 @@
597
646
 
598
647
  // ── Interactions ───────────────────────────────────────────────
599
648
 
600
- function trackMouse(e: MouseEvent) {
601
- if (containerEl === null) {
602
- return;
649
+ // Narrows an event's currentTarget to Element without an `as` cast (repo
650
+ // lint bans type assertions outside test files).
651
+ const targetElement = (e: Event): Element | null =>
652
+ e.currentTarget instanceof Element ? e.currentTarget : null;
653
+
654
+ function trackMouse(e: PointerEvent): void {
655
+ const position = pointerPositionIn(plotEl, e);
656
+ if (position !== null) {
657
+ mouseX = position.x;
658
+ mouseY = position.y;
659
+ }
660
+ }
661
+
662
+ // Anchor from the live element rect (not SVG math) so legend offset and
663
+ // scrollable-mode scroll position are automatically accounted for.
664
+ function anchorFromElement(el: Element): TooltipAnchor | null {
665
+ if (plotEl === null) {
666
+ return null;
603
667
  }
604
- const rect = containerEl.getBoundingClientRect();
605
- mouseX = e.clientX - rect.left;
606
- mouseY = e.clientY - rect.top;
668
+ const r = el.getBoundingClientRect();
669
+ const c = plotEl.getBoundingClientRect();
670
+ return isVertical
671
+ ? { x: r.left + r.width / 2 - c.left, y: r.top - c.top, side: 'top' }
672
+ : { x: r.right - c.left, y: r.top + r.height / 2 - c.top, side: 'right' };
607
673
  }
608
674
 
609
- function handleEnter(e: MouseEvent, bar: BarRect) {
675
+ function activateBar(target: Element, bar: BarRect): void {
610
676
  hovered = { si: bar.si, pi: bar.pi };
611
- trackMouse(e);
677
+ anchor = anchorFromElement(target);
612
678
  onbarhover?.({ index: bar.pi, dataPoint: bar.dataPoint });
613
679
  }
614
680
 
615
- function handleLeave() {
681
+ function handleEnter(e: PointerEvent, bar: BarRect): void {
682
+ const el = targetElement(e);
683
+ if (el !== null) {
684
+ activateBar(el, bar);
685
+ }
686
+ trackMouse(e);
687
+ }
688
+
689
+ function handleFocus(e: FocusEvent, bar: BarRect): void {
690
+ const el = targetElement(e);
691
+ if (el !== null) {
692
+ activateBar(el, bar);
693
+ }
694
+ }
695
+
696
+ function handleLeave(): void {
616
697
  hovered = null;
698
+ anchor = null;
617
699
  onbarhover?.(null);
618
700
  }
619
701
 
702
+ function handleKeydown(e: KeyboardEvent, bar: BarRect): void {
703
+ if (e.key === 'Enter' || e.key === ' ') {
704
+ e.preventDefault();
705
+ handleClick(bar);
706
+ }
707
+ }
708
+
709
+ // Touch taps have no pointerleave: dismiss when a pointerdown lands outside.
710
+ // eslint-disable-next-line no-restricted-syntax
711
+ $effect(() => {
712
+ if (hovered === null) {
713
+ return;
714
+ }
715
+ return dismissOnOutsidePointerDown(containerEl, handleLeave);
716
+ });
717
+
620
718
  function handleClick(bar: BarRect) {
621
719
  onbarclick?.({ index: bar.pi, dataPoint: bar.dataPoint });
622
720
  }
@@ -626,18 +724,6 @@
626
724
  ? null
627
725
  : (bars.find((b) => b.si === hovered!.si && b.pi === hovered!.pi) ?? null);
628
726
  }
629
-
630
- let isStackedMode = $derived(isMulti && groupMode === 'stacked');
631
-
632
- function getDisplayValue(bar: BarRect): string {
633
- if (isNormalized && bar.normalizedValue != null) {
634
- return normalizedFormat(bar.normalizedValue);
635
- }
636
- if (bar.isFloating && Array.isArray(bar.dataPoint.range)) {
637
- return `${format(bar.dataPoint.range[0])} – ${format(bar.dataPoint.range[1])}`;
638
- }
639
- return format(bar.dataPoint.value);
640
- }
641
727
  </script>
642
728
 
643
729
  <div
@@ -648,80 +734,85 @@
648
734
  {#if isEmpty && typeof empty === 'function'}
649
735
  <div class="chart-empty">{@render empty()}</div>
650
736
  {:else}
651
- {#if isMulti && showLegend}
652
- <Legend items={legendItems} position="top" />
737
+ {#if isMulti && showLegend && (chartWidth === 0 || hideLegendBelow === 0 || chartWidth >= hideLegendBelow)}
738
+ {#if interactiveLegend}
739
+ <Legend items={legendItems} position="top" onToggle={toggleSeries} />
740
+ {:else}
741
+ <Legend items={legendItems} position="top" />
742
+ {/if}
653
743
  {/if}
654
744
 
655
- <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
656
- <div
657
- class="chart-scroll-area"
658
- role="region"
659
- aria-label={yAxisLabel ? `${yAxisLabel} bar chart` : 'Bar chart'}
660
- tabindex={scrollable ? 0 : null}
661
- style={scrollable
662
- ? `overflow-x: auto; -webkit-overflow-scrolling: touch; height: var(--barchart-scroll-area-height, auto);`
663
- : ''}
664
- >
665
- <div style={scrollable ? `min-width: ${minScrollWidth}px;` : ''}>
666
- <ChartContainer
667
- bind:width={chartWidth}
668
- bind:height={chartHeight}
669
- {aspectRatio}
670
- {maxHeight}
671
- {minHeight}
672
- >
673
- <!-- A1-2 / A1-3: SVG <defs> for pattern and gradient fills -->
674
- {#if defsEntries.length > 0}
675
- <defs>
676
- {#each defsEntries as entry (entry.id)}
677
- {#if 'pattern' in entry.fill}
678
- {@const pat = entry.fill.pattern}
679
- {@const patSize = pat.size ?? 8}
680
- {@const patColor = pat.color ?? entry.bar.color}
681
- {@const patBg = pat.background ?? 'transparent'}
682
- {@const patStrokeW = pat.strokeWidth ?? 1.5}
683
- <pattern
684
- id={entry.id}
685
- patternUnits="userSpaceOnUse"
686
- width={patSize}
687
- height={patSize}
688
- >
689
- <rect width={patSize} height={patSize} fill={patBg} />
690
- {#if pat.type === 'lines'}
691
- <line
692
- x1="0"
693
- y1={patSize}
694
- x2={patSize}
695
- y2="0"
696
- stroke={patColor}
697
- stroke-width={patStrokeW}
698
- />
699
- {:else if pat.type === 'crosshatch'}
700
- <line
701
- x1="0"
702
- y1={patSize}
703
- x2={patSize}
704
- y2="0"
705
- stroke={patColor}
706
- stroke-width={patStrokeW}
707
- />
708
- <line
709
- x1="0"
710
- y1="0"
711
- x2={patSize}
712
- y2={patSize}
713
- stroke={patColor}
714
- stroke-width={patStrokeW}
715
- />
716
- {:else}
717
- <!-- dots -->
718
- <circle cx={patSize / 2} cy={patSize / 2} r={patStrokeW} fill={patColor} />
719
- {/if}
720
- </pattern>
721
- {:else if 'gradient' in entry.fill}
722
- {@const grad = entry.fill.gradient}
723
- {@const isHoriz = grad.direction === 'horizontal'}
724
- <!--
745
+ <div class="chart-plot" bind:this={plotEl}>
746
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
747
+ <div
748
+ class="chart-scroll-area"
749
+ role="region"
750
+ aria-label={yAxisLabel ? `${yAxisLabel} bar chart` : 'Bar chart'}
751
+ tabindex={scrollable ? 0 : null}
752
+ style={scrollable
753
+ ? `overflow-x: auto; -webkit-overflow-scrolling: touch; height: var(--barchart-scroll-area-height, auto);`
754
+ : ''}
755
+ >
756
+ <div style={scrollable ? `min-width: ${minScrollWidth}px;` : ''}>
757
+ <ChartContainer
758
+ bind:width={chartWidth}
759
+ bind:height={chartHeight}
760
+ {aspectRatio}
761
+ {maxHeight}
762
+ {minHeight}
763
+ >
764
+ <!-- A1-2 / A1-3: SVG <defs> for pattern and gradient fills -->
765
+ {#if defsEntries.length > 0}
766
+ <defs>
767
+ {#each defsEntries as entry (entry.id)}
768
+ {#if 'pattern' in entry.fill}
769
+ {@const pat = entry.fill.pattern}
770
+ {@const patSize = pat.size ?? 8}
771
+ {@const patColor = pat.color ?? entry.bar.color}
772
+ {@const patBg = pat.background ?? 'transparent'}
773
+ {@const patStrokeW = pat.strokeWidth ?? 1.5}
774
+ <pattern
775
+ id={entry.id}
776
+ patternUnits="userSpaceOnUse"
777
+ width={patSize}
778
+ height={patSize}
779
+ >
780
+ <rect width={patSize} height={patSize} fill={patBg} />
781
+ {#if pat.type === 'lines'}
782
+ <line
783
+ x1="0"
784
+ y1={patSize}
785
+ x2={patSize}
786
+ y2="0"
787
+ stroke={patColor}
788
+ stroke-width={patStrokeW}
789
+ />
790
+ {:else if pat.type === 'crosshatch'}
791
+ <line
792
+ x1="0"
793
+ y1={patSize}
794
+ x2={patSize}
795
+ y2="0"
796
+ stroke={patColor}
797
+ stroke-width={patStrokeW}
798
+ />
799
+ <line
800
+ x1="0"
801
+ y1="0"
802
+ x2={patSize}
803
+ y2={patSize}
804
+ stroke={patColor}
805
+ stroke-width={patStrokeW}
806
+ />
807
+ {:else}
808
+ <!-- dots -->
809
+ <circle cx={patSize / 2} cy={patSize / 2} r={patStrokeW} fill={patColor} />
810
+ {/if}
811
+ </pattern>
812
+ {:else if 'gradient' in entry.fill}
813
+ {@const grad = entry.fill.gradient}
814
+ {@const isHoriz = grad.direction === 'horizontal'}
815
+ <!--
725
816
  gradientUnits="userSpaceOnUse" is required here.
726
817
  objectBoundingBox ratios are undefined on degenerate (zero-height)
727
818
  path bounding boxes (stacked segments, Firefox renders black).
@@ -731,129 +822,163 @@
731
822
  without any margin offset. This matches the AreaChart pattern
732
823
  (feat/linechart-gradient ea5f794, lines 282-284).
733
824
  -->
734
- <linearGradient
735
- id={entry.id}
736
- x1={entry.bar.x}
737
- y1={entry.bar.y}
738
- x2={isHoriz ? entry.bar.x + entry.bar.width : entry.bar.x}
739
- y2={isHoriz ? entry.bar.y : entry.bar.y + entry.bar.height}
740
- gradientUnits="userSpaceOnUse"
741
- >
742
- {#each grad.stops as stop, stopIndex (`${stopIndex}-${stop.offset}`)}
743
- <stop
744
- offset="{stop.offset * 100}%"
745
- stop-color={stop.color}
746
- stop-opacity={stop.opacity ?? 1}
747
- />
748
- {/each}
749
- </linearGradient>
750
- {/if}
751
- {/each}
752
- </defs>
753
- {/if}
754
-
755
- <g transform="translate({dims.margin.left}, {dims.margin.top})">
756
- {#if showYAxis}
757
- <Axis
758
- orientation="left"
759
- scale={isVertical ? valScale : catScale}
760
- {showGridlines}
761
- gridlineLength={dims.innerWidth}
762
- label={yAxisLabel}
763
- />
825
+ <linearGradient
826
+ id={entry.id}
827
+ x1={entry.bar.x}
828
+ y1={entry.bar.y}
829
+ x2={isHoriz ? entry.bar.x + entry.bar.width : entry.bar.x}
830
+ y2={isHoriz ? entry.bar.y : entry.bar.y + entry.bar.height}
831
+ gradientUnits="userSpaceOnUse"
832
+ >
833
+ {#each grad.stops as stop, stopIndex (`${stopIndex}-${stop.offset}`)}
834
+ <stop
835
+ offset="{stop.offset * 100}%"
836
+ stop-color={stop.color}
837
+ stop-opacity={stop.opacity ?? 1}
838
+ />
839
+ {/each}
840
+ </linearGradient>
841
+ {/if}
842
+ {/each}
843
+ </defs>
764
844
  {/if}
765
- {#if showXAxis}
766
- <g transform="translate(0, {dims.innerHeight})">
845
+
846
+ <g transform="translate({dims.margin.left}, {dims.margin.top})">
847
+ {#if showYAxis}
767
848
  <Axis
768
- orientation="bottom"
769
- scale={isVertical ? catScale : valScale}
770
- showGridlines={!isVertical && showGridlines}
771
- gridlineLength={dims.innerHeight}
772
- label={xAxisLabel}
849
+ orientation="left"
850
+ scale={isVertical ? valScale : catScale}
851
+ tickCount={valTickCount}
852
+ {showGridlines}
853
+ gridlineLength={dims.innerWidth}
854
+ label={yAxisLabel}
773
855
  />
774
- </g>
775
- {/if}
776
-
777
- {#if !hideBarGraphics}
778
- {#each bars as bar, i (i)}
779
- <!-- svelte-ignore a11y_no_static_element_interactions -->
780
- <!-- svelte-ignore a11y_click_events_have_key_events -->
781
- {#if isStackedMode && barRadius > 0}
782
- <path
783
- class="bar"
784
- class:hovered={hovered?.si === bar.si && hovered?.pi === bar.pi}
785
- class:highlighted={effectiveHighlightedIndex !== null &&
786
- effectiveHighlightedIndex === bar.pi}
787
- class:dimmed={(hovered !== null &&
788
- (hovered.si !== bar.si || hovered.pi !== bar.pi)) ||
789
- (effectiveHighlightedIndex !== null && effectiveHighlightedIndex !== bar.pi)}
790
- d={stackedBarPath(bar)}
791
- fill={barFillAttr(bar)}
792
- aria-label="{bar.dataPoint.label}: {getDisplayValue(bar)}"
793
- onmouseenter={(e) => handleEnter(e, bar)}
794
- onmousemove={trackMouse}
795
- onmouseleave={handleLeave}
796
- onclick={() => handleClick(bar)}
856
+ {/if}
857
+ {#if showXAxis}
858
+ <g transform="translate(0, {dims.innerHeight})">
859
+ <Axis
860
+ orientation="bottom"
861
+ scale={isVertical ? catScale : valScale}
862
+ tickCount={valTickCount}
863
+ rotateTicks={layout.xRotate}
864
+ tickEvery={layout.xEvery}
865
+ labelOffset={layout.xLabelOffset}
866
+ showGridlines={!isVertical && showGridlines}
867
+ gridlineLength={dims.innerHeight}
868
+ label={xAxisLabel}
797
869
  />
798
- {:else}
799
- <rect
800
- class="bar"
801
- class:hovered={hovered?.si === bar.si && hovered?.pi === bar.pi}
802
- class:highlighted={effectiveHighlightedIndex !== null &&
803
- effectiveHighlightedIndex === bar.pi}
804
- class:dimmed={(hovered !== null &&
805
- (hovered.si !== bar.si || hovered.pi !== bar.pi)) ||
806
- (effectiveHighlightedIndex !== null && effectiveHighlightedIndex !== bar.pi)}
807
- x={bar.x}
808
- y={bar.y}
809
- width={bar.width}
810
- height={bar.height}
811
- rx={barRadius}
812
- ry={barRadius}
813
- fill={barFillAttr(bar)}
814
- aria-label="{bar.dataPoint.label}: {getDisplayValue(bar)}"
815
- onmouseenter={(e) => handleEnter(e, bar)}
816
- onmousemove={trackMouse}
817
- onmouseleave={handleLeave}
818
- onclick={() => handleClick(bar)}
819
- />
820
- {/if}
821
- <!-- Suppress the horizontal value label when its sub-band is thinner
822
- than the ~11px label, so cramped multi-series charts hide labels
823
- instead of overlapping them into an unreadable cluster. Consumers
824
- restore labels by giving the chart more height (aspectRatio /
825
- scrollable / minBandWidth). -->
826
- {#if showValues && !isStackedMode && (isVertical || bar.height >= 13)}
827
- <text
828
- class="bar-value"
829
- x={isVertical ? bar.x + bar.width / 2 : bar.x + bar.width + 4}
830
- y={isVertical ? bar.y - 4 : bar.y + bar.height / 2}
831
- text-anchor={isVertical ? 'middle' : 'start'}
832
- dominant-baseline={isVertical ? 'auto' : 'middle'}>{getDisplayValue(bar)}</text
833
- >
834
- {/if}
835
- {/each}
836
- {/if}
837
-
838
- <!-- A1-4: renderOverlay escape hatch — rendered after all bars -->
839
- {#if typeof renderOverlay === 'function'}
840
- {@render renderOverlay(overlayContext)}
841
- {/if}
842
- </g>
843
- </ChartContainer>
870
+ </g>
871
+ {/if}
872
+
873
+ {#if !hideBarGraphics}
874
+ {#each bars as bar, i (i)}
875
+ {#if isStackedMode && barRadius > 0}
876
+ <path
877
+ class="bar"
878
+ class:hovered={hovered?.si === bar.si && hovered?.pi === bar.pi}
879
+ class:highlighted={effectiveHighlightedIndex !== null &&
880
+ effectiveHighlightedIndex === bar.pi}
881
+ class:dimmed={(hovered !== null &&
882
+ (hovered.si !== bar.si || hovered.pi !== bar.pi)) ||
883
+ (effectiveHighlightedIndex !== null &&
884
+ effectiveHighlightedIndex !== bar.pi)}
885
+ d={stackedBarPath(bar)}
886
+ fill={barFillAttr(bar)}
887
+ tabindex="0"
888
+ role="button"
889
+ aria-label="{bar.dataPoint.label}: {getDisplayValue(bar)}"
890
+ onpointerenter={(e) => handleEnter(e, bar)}
891
+ onpointermove={trackMouse}
892
+ onpointerleave={handleLeave}
893
+ onfocus={(e) => handleFocus(e, bar)}
894
+ onblur={handleLeave}
895
+ onkeydown={(e) => handleKeydown(e, bar)}
896
+ onclick={() => handleClick(bar)}
897
+ />
898
+ {:else}
899
+ <rect
900
+ class="bar"
901
+ class:hovered={hovered?.si === bar.si && hovered?.pi === bar.pi}
902
+ class:highlighted={effectiveHighlightedIndex !== null &&
903
+ effectiveHighlightedIndex === bar.pi}
904
+ class:dimmed={(hovered !== null &&
905
+ (hovered.si !== bar.si || hovered.pi !== bar.pi)) ||
906
+ (effectiveHighlightedIndex !== null &&
907
+ effectiveHighlightedIndex !== bar.pi)}
908
+ x={bar.x}
909
+ y={bar.y}
910
+ width={bar.width}
911
+ height={bar.height}
912
+ rx={barRadius}
913
+ ry={barRadius}
914
+ fill={barFillAttr(bar)}
915
+ tabindex="0"
916
+ role="button"
917
+ aria-label="{bar.dataPoint.label}: {getDisplayValue(bar)}"
918
+ onpointerenter={(e) => handleEnter(e, bar)}
919
+ onpointermove={trackMouse}
920
+ onpointerleave={handleLeave}
921
+ onfocus={(e) => handleFocus(e, bar)}
922
+ onblur={handleLeave}
923
+ onkeydown={(e) => handleKeydown(e, bar)}
924
+ onclick={() => handleClick(bar)}
925
+ />
926
+ {/if}
927
+ {/each}
928
+ {#each barLabels as bl, i (i)}
929
+ {#if bl.visible}
930
+ <text
931
+ class="bar-value"
932
+ class:bar-value-inside={bl.p.placement === 'inside'}
933
+ x={bl.p.x}
934
+ y={bl.p.y}
935
+ text-anchor={bl.p.textAnchor}
936
+ dominant-baseline={bl.p.dominantBaseline}
937
+ style={bl.p.placement === 'inside'
938
+ ? `fill: ${getContrastColor(bl.bar.color)}; stroke: ${contrastOutline(bl.bar.color)};`
939
+ : ''}>{bl.text}</text
940
+ >
941
+ {/if}
942
+ {/each}
943
+ {/if}
944
+
945
+ <!-- A1-4: renderOverlay escape hatch — rendered after all bars -->
946
+ {#if typeof renderOverlay === 'function'}
947
+ {@render renderOverlay(overlayContext)}
948
+ {/if}
949
+ </g>
950
+ </ChartContainer>
951
+ </div>
844
952
  </div>
845
- </div>
846
953
 
847
- {#if typeof tooltipSnippet === 'function' && hoveredBar()}
848
- {@const hb = hoveredBar()}
849
- {#if hb}
850
- <div class="chart-tooltip-slot" style="left: {mouseX + 12}px; top: {mouseY - 12}px;">
851
- {@render tooltipSnippet(hb.dataPoint, hb.pi)}
852
- </div>
954
+ {#if typeof tooltipSnippet === 'function'}
955
+ <ChartTooltip
956
+ data={tooltipData}
957
+ {mouseX}
958
+ {mouseY}
959
+ {anchor}
960
+ portal={tooltipPortal}
961
+ originEl={plotEl}
962
+ unstyled
963
+ >
964
+ {#snippet content()}
965
+ {@const hb = hoveredBar()}
966
+ {#if hb}
967
+ {@render tooltipSnippet(hb.dataPoint, hb.pi)}
968
+ {/if}
969
+ {/snippet}
970
+ </ChartTooltip>
971
+ {:else}
972
+ <ChartTooltip
973
+ data={tooltipData}
974
+ {mouseX}
975
+ {mouseY}
976
+ {anchor}
977
+ portal={tooltipPortal}
978
+ originEl={plotEl}
979
+ />
853
980
  {/if}
854
- {:else}
855
- <ChartTooltip data={tooltipData} {mouseX} {mouseY} />
856
- {/if}
981
+ </div>
857
982
  {/if}
858
983
  </div>
859
984
 
@@ -865,6 +990,9 @@
865
990
  .chart-scroll-area {
866
991
  width: 100%;
867
992
  }
993
+ .chart-plot {
994
+ position: relative;
995
+ }
868
996
  .bar {
869
997
  transition: opacity var(--chart-transition-duration, 0.2s) ease;
870
998
  cursor: pointer;
@@ -878,21 +1006,26 @@
878
1006
  .bar.dimmed {
879
1007
  opacity: var(--barchart-bar-dimmed-opacity, 0.3);
880
1008
  }
1009
+ .bar:focus-visible {
1010
+ outline: 2px solid var(--chart-axis-label-color, light-dark(#333, #e5e7eb));
1011
+ outline-offset: 1px;
1012
+ }
881
1013
  .bar-value {
882
- fill: var(--barchart-value-color, #333);
1014
+ fill: var(--barchart-value-color, light-dark(#333, #e5e7eb));
883
1015
  font-size: var(--barchart-value-font-size, 14px);
884
1016
  font-weight: var(--barchart-value-font-weight, 600);
885
1017
  font-family: var(--chart-font-family, inherit);
886
1018
  pointer-events: none;
887
1019
  }
888
- .chart-tooltip-slot {
889
- position: absolute;
890
- z-index: 10;
891
- pointer-events: none;
1020
+ .bar-value-inside {
1021
+ paint-order: stroke;
1022
+ stroke-width: 2px;
1023
+ stroke-opacity: 0.35;
1024
+ stroke-linejoin: round;
892
1025
  }
893
1026
  .chart-empty {
894
1027
  padding: var(--chart-empty-padding, 32px 24px);
895
- color: var(--chart-empty-color, #9ca3af);
1028
+ color: var(--chart-empty-color, light-dark(#9ca3af, #6b7280));
896
1029
  text-align: center;
897
1030
  }
898
1031
  </style>