@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 -
|
|
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 +
|
|
189
|
-
: column === columnCount - 1
|
|
190
|
-
? Math.max(0, lastColumnLabelGutter +
|
|
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 <
|
|
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({
|
|
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
|
|
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
|
|
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
|
|
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: {
|
package/dist/_chart/measure.d.ts
CHANGED