@juspay/svelte-ui-components 2.103.0 → 2.105.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.
@@ -36,7 +36,10 @@
36
36
  nodeColorResolver,
37
37
  minLinkWidth = 1,
38
38
  dataLabelOffsetX = 0,
39
- disableDimOnHover = false
39
+ disableDimOnHover = false,
40
+ firstColumnLabelSide = 'left',
41
+ lastColumnLabelSide = 'right',
42
+ marginX = 40
40
43
  }: SankeyChartProperties = $props();
41
44
 
42
45
  // ── State ──────────────────────────────────────────────────────
@@ -54,28 +57,55 @@
54
57
  let format = $derived(valueFormat ?? formatNumber);
55
58
  let isEmpty = $derived(nodes.length === 0);
56
59
  const MARGIN = 40;
57
- // A 12px label's rendered line box measures ~16px (≈1.33em) across common
58
- // font stacks; two label centres closer than this overlap visibly.
59
- const LABEL_LINE_PX = 16;
60
60
 
61
61
  // Real text measurement via the shared canvas-backed helper (exact on the
62
62
  // client, 0.6em/char heuristic under SSR/tests). Character estimates used
63
63
  // to both over-reserve the right label gutter (dead canvas) and under-budget
64
64
  // uppercase-heavy labels (text sliding under the next column's bars).
65
+ // Weight and family are part of the spec: a 700-weight label is measurably
66
+ // wider than a 400 one, and the app's rendered font rarely matches the
67
+ // measurement default — budgets must be computed at the rendered style or
68
+ // borderline labels truncate (or overflow) for no visible reason.
69
+ // Family only enters the spec once the document's fonts have loaded: measuring
70
+ // a not-yet-loaded webfont silently measures its fallback (usually wider) and
71
+ // the width cache would pin that stale value under the loaded font's key.
72
+ // Until then the measurement default applies — same behaviour as before.
73
+ // One-shot promise subscription at init (browser only) — nothing to tear
74
+ // down, and the $state write re-derives every measurement consumer.
75
+ let fontsLoaded = $state(typeof document !== 'undefined' && document.fonts?.status === 'loaded');
76
+ if (typeof document !== 'undefined' && document.fonts?.status !== 'loaded') {
77
+ document.fonts?.ready.then(() => {
78
+ fontsLoaded = true;
79
+ });
80
+ }
81
+ let chartFontFamily = $derived(
82
+ fontsLoaded && containerEl ? getComputedStyle(containerEl).fontFamily : null
83
+ );
65
84
  let labelFont = $derived({
66
- size: containerEl ? readCssVarPx(containerEl, '--sankey-label-font-size', 12) : 12
85
+ size: containerEl ? readCssVarPx(containerEl, '--sankey-label-font-size', 12) : 12,
86
+ weight: containerEl ? readCssVarPx(containerEl, '--sankey-label-font-weight', 400) : 400,
87
+ family: chartFontFamily
67
88
  });
68
89
  let colLabelFont = $derived({
69
- size: containerEl ? readCssVarPx(containerEl, '--sankey-col-label-font-size', 11) : 11
90
+ size: containerEl ? readCssVarPx(containerEl, '--sankey-col-label-font-size', 11) : 11,
91
+ weight: containerEl ? readCssVarPx(containerEl, '--sankey-col-label-font-weight', 400) : 400,
92
+ family: chartFontFamily
70
93
  });
94
+ // A label's rendered line box measures ≈1.33em across common font stacks;
95
+ // two label centres closer than this overlap visibly. Derived from the
96
+ // tokened size so consumers that scale labels keep honest de-collision.
97
+ let labelLinePx = $derived(Math.ceil(labelFont.size * 1.33));
71
98
 
72
99
  // Final-column labels render to the RIGHT of their node; the bare 40px margin is
73
100
  // nowhere near enough for real funnel labels ("PARTIALLY_FAILED (1,234)"), so they
74
101
  // used to run past the svg edge and clip. Reserve a capped gutter sized from the
75
102
  // sink-node labels (sinks are what land in the final column) and lay the diagram
76
103
  // out in the remaining width instead.
104
+ // With `lastColumnLabelSide: 'left'` sink labels render inside the plot over
105
+ // the incoming ribbons (mirroring the first column's 'right' mode), so no
106
+ // right gutter is reserved and the diagram runs to the right edge.
77
107
  let lastColumnLabelGutter = $derived.by(() => {
78
- if (!showLabels || nodes.length === 0 || chartWidth <= 0) {
108
+ if (lastColumnLabelSide === 'left' || !showLabels || nodes.length === 0 || chartWidth <= 0) {
79
109
  return 0;
80
110
  }
81
111
  const sourceIds = new Set(links.map((link) => link.source));
@@ -101,8 +131,11 @@
101
131
  // the room falls below an ellipsis, vanish entirely. Mirror the sink gutter:
102
132
  // reserve a capped left gutter sized from the source-node labels (sources are what
103
133
  // land in the first column) and shift the diagram right by it.
134
+ // With `firstColumnLabelSide: 'right'` the labels render over the outgoing
135
+ // ribbons like every other column, so no gutter is reserved at all and the
136
+ // diagram gains that width.
104
137
  let firstColumnLabelGutter = $derived.by(() => {
105
- if (!showLabels || nodes.length === 0 || chartWidth <= 0) {
138
+ if (firstColumnLabelSide === 'right' || !showLabels || nodes.length === 0 || chartWidth <= 0) {
106
139
  return 0;
107
140
  }
108
141
  const targetIds = new Set(links.map((link) => link.target));
@@ -122,7 +155,7 @@
122
155
  });
123
156
 
124
157
  let plotWidth = $derived(
125
- Math.max(0, chartWidth - MARGIN * 2 - firstColumnLabelGutter - lastColumnLabelGutter)
158
+ Math.max(0, chartWidth - marginX * 2 - firstColumnLabelGutter - lastColumnLabelGutter)
126
159
  );
127
160
  let layout = $derived(
128
161
  computeSankeyLayout(
@@ -174,6 +207,29 @@
174
207
  return truncateToWidth(text, Math.max(0, colWidth - 6), colLabelFont);
175
208
  };
176
209
 
210
+ // Column headers centre on their column's bar; at tight horizontal margins an
211
+ // edge header (e.g. a long last-column title) would otherwise paint past the
212
+ // svg boundary. Clamp each header's centre so its rendered text stays on the
213
+ // canvas — a no-op at the default margins.
214
+ const columnLabelX = (columnIndex: number, label: string): number => {
215
+ const center = columnIndex * colWidth + nodeWidth / 2;
216
+ const textWidth = measureText(truncateColumnLabel(label), colLabelFont).width;
217
+ const canvasLeft = -(marginX + firstColumnLabelGutter) + 2;
218
+ const canvasRight = plotWidth + lastColumnLabelGutter + marginX - 2;
219
+ const min = canvasLeft + textWidth / 2;
220
+ const max = canvasRight - textWidth / 2;
221
+ // Text wider than the whole canvas keeps its centre; the colWidth-based
222
+ // truncation budget makes that unreachable in practice.
223
+ return max < min ? center : Math.min(Math.max(center, min), max);
224
+ };
225
+
226
+ // Which side of its bar a node's label renders on. First and last columns are
227
+ // configurable (`firstColumnLabelSide` / `lastColumnLabelSide`); in a
228
+ // single-column chart the first-column setting wins.
229
+ const labelOnLeft = (column: number): boolean =>
230
+ (column === 0 && firstColumnLabelSide === 'left') ||
231
+ (column === columnCount - 1 && column !== 0 && lastColumnLabelSide === 'left');
232
+
177
233
  const truncateLabel = (text: string, column: number): string => {
178
234
  // Middle columns must budget for dataLabelOffsetX too: the label starts at
179
235
  // node.x + nodeWidth + 6 + dataLabelOffsetX, so the room before the next
@@ -184,10 +240,10 @@
184
240
  // SVG's left edge is that gutter plus the base margin, minus the inset —
185
241
  // symmetric with the last column's sink-gutter budget below.
186
242
  const available =
187
- column === 0
188
- ? Math.max(0, firstColumnLabelGutter + MARGIN - 6 - dataLabelOffsetX)
189
- : column === columnCount - 1
190
- ? Math.max(0, lastColumnLabelGutter + MARGIN - 6 - dataLabelOffsetX)
243
+ column === 0 && firstColumnLabelSide === 'left'
244
+ ? Math.max(0, firstColumnLabelGutter + marginX - 6 - dataLabelOffsetX)
245
+ : column === columnCount - 1 && lastColumnLabelSide === 'right'
246
+ ? Math.max(0, lastColumnLabelGutter + marginX - 6 - dataLabelOffsetX)
191
247
  : Math.max(0, colWidth - nodeWidth - 12 - dataLabelOffsetX);
192
248
  // No usable room — hide the label rather than force text that would overflow;
193
249
  // the full text is still reachable via the node's <title> on hover.
@@ -222,7 +278,7 @@
222
278
  continue;
223
279
  }
224
280
  const centerGap = node.y + node.height / 2 - (lastKept.y + lastKept.height / 2);
225
- if (centerGap < LABEL_LINE_PX) {
281
+ if (centerGap < labelLinePx) {
226
282
  if (node.value > lastKept.value) {
227
283
  hidden.add(lastKept.id);
228
284
  lastKept = node;
@@ -425,12 +481,12 @@
425
481
  <div class="chart-empty">{@render empty()}</div>
426
482
  {:else}
427
483
  <ChartContainer bind:width={chartWidth} bind:height={chartHeight} {aspectRatio} {maxHeight}>
428
- <g transform="translate({MARGIN + firstColumnLabelGutter}, {MARGIN})">
484
+ <g transform="translate({marginX + firstColumnLabelGutter}, {MARGIN})">
429
485
  {#if columnLabels != null && columnLabels.length > 0}
430
486
  {#each columnLabels.slice(0, columnCount) as label, ci (ci)}
431
487
  <text
432
488
  class="sankey-col-label"
433
- x={ci * colWidth + nodeWidth / 2}
489
+ x={columnLabelX(ci, label)}
434
490
  y={-8}
435
491
  text-anchor="middle"
436
492
  dominant-baseline="auto">{truncateColumnLabel(label)}<title>{label}</title></text
@@ -490,11 +546,11 @@
490
546
  <text
491
547
  class="sankey-label"
492
548
  class:node-dimmed={dimmed}
493
- x={node.column === 0
549
+ x={labelOnLeft(node.column)
494
550
  ? node.x - 6 - dataLabelOffsetX
495
551
  : node.x + node.width + 6 + dataLabelOffsetX}
496
552
  y={node.y + node.height / 2}
497
- text-anchor={node.column === 0 ? 'end' : 'start'}
553
+ text-anchor={labelOnLeft(node.column) ? 'end' : 'start'}
498
554
  dominant-baseline="middle"
499
555
  >{truncateLabel(
500
556
  showValues ? `${node.label} (${format(node.value)})` : node.label,
@@ -537,13 +593,22 @@
537
593
  .sankey-label {
538
594
  fill: var(--sankey-label-color, #333);
539
595
  font-size: var(--sankey-label-font-size, 12px);
596
+ font-weight: var(--sankey-label-font-weight, 400);
540
597
  font-family: var(--chart-font-family, inherit);
598
+ /* Highcharts-style text outline: node labels render over link ribbons, so
599
+ a halo in the chart's background colour keeps them legible at any flow
600
+ density. Off (transparent / 0) by default. */
601
+ paint-order: stroke;
602
+ stroke: var(--sankey-label-halo-color, transparent);
603
+ stroke-width: var(--sankey-label-halo-width, 0);
604
+ stroke-linejoin: round;
541
605
  pointer-events: none;
542
606
  transition: opacity var(--chart-transition-duration, 0.2s) ease;
543
607
  }
544
608
  .sankey-col-label {
545
609
  fill: var(--sankey-col-label-color, #666);
546
610
  font-size: var(--sankey-col-label-font-size, 11px);
611
+ font-weight: var(--sankey-col-label-font-weight, 400);
547
612
  font-family: var(--chart-font-family, inherit);
548
613
  pointer-events: none;
549
614
  }
@@ -84,6 +84,29 @@ export type OptionalSankeyChartProperties = {
84
84
  * to read the overall structure. Defaults to `false` (standard dim-on-hover behaviour).
85
85
  */
86
86
  disableDimOnHover?: boolean;
87
+ /**
88
+ * Which side of a first-column node its label renders on. `'left'` (default) anchors the
89
+ * label into a reserved left gutter outside the diagram. `'right'` renders it like every
90
+ * other column — to the right of the bar, over the outgoing ribbons (pair with the
91
+ * `--sankey-label-halo-*` tokens for legibility); no left gutter is reserved, so the
92
+ * diagram gains that width.
93
+ */
94
+ firstColumnLabelSide?: 'left' | 'right';
95
+ /**
96
+ * Which side of a last-column (sink) node its label renders on. `'right'` (default)
97
+ * anchors the label into a reserved right gutter outside the diagram. `'left'` renders it
98
+ * inside the plot — left of the bar, over the incoming ribbons (pair with the
99
+ * `--sankey-label-halo-*` tokens); no right gutter is reserved, so the diagram runs to the
100
+ * right edge. In a single-column chart `firstColumnLabelSide` wins.
101
+ */
102
+ lastColumnLabelSide?: 'left' | 'right';
103
+ /**
104
+ * Horizontal inset in pixels between the svg edges and the diagram (plus any label
105
+ * gutters). Defaults to `40`, matching the fixed vertical margin. Lower it for
106
+ * edge-to-edge funnels when both first/last column labels render inside the plot;
107
+ * column headers clamp to the canvas so they never paint past the svg.
108
+ */
109
+ marginX?: number;
87
110
  };
88
111
  export type SankeyChartEventProperties = {
89
112
  onnodeclick?: (event: {
@@ -1,7 +1,7 @@
1
1
  export type FontSpec = {
2
2
  /** Font size in px. */
3
3
  size: number;
4
- family?: string;
4
+ family?: string | null;
5
5
  weight?: number | string;
6
6
  };
7
7
  export declare function measureText(text: string, font: FontSpec): {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/svelte-ui-components",
3
- "version": "2.103.0",
3
+ "version": "2.105.0",
4
4
  "description": "A themeable Svelte 5 UI component library with CSS custom property driven styling",
5
5
  "keywords": [
6
6
  "svelte",