@juspay/svelte-ui-components 2.81.1 → 2.81.3

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.
@@ -13,7 +13,11 @@
13
13
  import ChartTooltip from '../_chart/ChartTooltip.svelte';
14
14
  import Legend from '../_chart/Legend.svelte';
15
15
  import { createBandScale, createLinearScale, niceLinearDomain } from '../_chart/scales';
16
- import { computeChartDimensions } from '../_chart/geometry';
16
+ import {
17
+ computeChartDimensions,
18
+ computeHorizontalCategoryGutter,
19
+ measureTextWidth
20
+ } from '../_chart/geometry';
17
21
  import { getColor } from '../_chart/colors';
18
22
  import { formatNumber } from '../_chart/format';
19
23
  import { roundedRectPath } from '../_chart/paths';
@@ -110,17 +114,6 @@
110
114
 
111
115
  let format = $derived(valueFormat ?? formatNumber);
112
116
  let isVertical = $derived(orientation === 'vertical');
113
- // When an axis is hidden its gutter (Y = 50px for tick labels, X = 40px) is dead
114
- // space that squeezes the plot into the centre. Collapse the tick-label gutter but
115
- // keep a symmetric breathing-room inset so the edge bars (and their value labels)
116
- // don't sit flush against the container edges.
117
- let dims = $derived(
118
- computeChartDimensions(chartWidth, chartHeight, {
119
- left: showYAxis ? 50 : 28,
120
- right: 28,
121
- bottom: showXAxis ? 40 : 8
122
- })
123
- );
124
117
 
125
118
  let isMulti = $derived(Array.isArray(series) && series.length > 0);
126
119
 
@@ -197,6 +190,58 @@
197
190
  return first.map((d) => d.label);
198
191
  });
199
192
 
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
+
200
245
  // ── Effective highlighted index: merge declarative + imperative ─
201
246
 
202
247
  /**
@@ -1,5 +1,25 @@
1
1
  import type { Margin, ChartDimensions, PieSliceLayout, ComputedSankeyNode, ComputedSankeyLink, StackedPoint } from './types';
2
2
  export declare function computeChartDimensions(width: number, height: number, margin?: Partial<Margin>): ChartDimensions;
3
+ /**
4
+ * Measures rendered text width via a shared offscreen canvas context.
5
+ * Returns null when measurement is unavailable (SSR, or the environment
6
+ * provides no working 2D canvas — e.g. jsdom) so callers can fall back to
7
+ * a fixed layout instead of acting on a bogus 0.
8
+ */
9
+ export declare function measureTextWidth(text: string, font: string): number | null;
10
+ /**
11
+ * Left margin for a horizontal bar chart's category axis, sized to fit the
12
+ * widest category label. Category tick labels render right-aligned 10px left
13
+ * of the axis line (tick mark 6px + 4px gap), so any label wider than
14
+ * `margin.left - 10` bleeds out of the SVG and gets clipped by the page.
15
+ *
16
+ * - Never shrinks below `fallback` (the legacy fixed gutter), so charts whose
17
+ * labels already fit keep their exact current layout.
18
+ * - Caps at 45% of the chart width so one pathological label cannot crush the
19
+ * plot area; past the cap the label bleeds as before, but the plot survives.
20
+ * - `widestLabelWidth === null` (SSR / unmeasurable) keeps the legacy gutter.
21
+ */
22
+ export declare function computeHorizontalCategoryGutter(widestLabelWidth: number | null, chartWidth: number, fallback?: number): number;
3
23
  export declare function computePieLayout(data: Array<{
4
24
  label: string;
5
25
  value: number;
@@ -13,6 +13,51 @@ export function computeChartDimensions(width, height, margin = {}) {
13
13
  innerHeight: Math.max(0, height - m.top - m.bottom)
14
14
  };
15
15
  }
16
+ // ── Text measurement ────────────────────────────────────────────
17
+ let textMeasurementContext = null;
18
+ /**
19
+ * Measures rendered text width via a shared offscreen canvas context.
20
+ * Returns null when measurement is unavailable (SSR, or the environment
21
+ * provides no working 2D canvas — e.g. jsdom) so callers can fall back to
22
+ * a fixed layout instead of acting on a bogus 0.
23
+ */
24
+ export function measureTextWidth(text, font) {
25
+ if (typeof document === 'undefined') {
26
+ return null;
27
+ }
28
+ if (textMeasurementContext === null) {
29
+ textMeasurementContext = document.createElement('canvas').getContext('2d');
30
+ }
31
+ if (textMeasurementContext === null) {
32
+ return null;
33
+ }
34
+ textMeasurementContext.font = font;
35
+ const width = textMeasurementContext.measureText(text).width;
36
+ // jsdom's canvas stub reports 0 for any text; treat that as "cannot measure".
37
+ return width > 0 ? width : null;
38
+ }
39
+ /**
40
+ * Left margin for a horizontal bar chart's category axis, sized to fit the
41
+ * widest category label. Category tick labels render right-aligned 10px left
42
+ * of the axis line (tick mark 6px + 4px gap), so any label wider than
43
+ * `margin.left - 10` bleeds out of the SVG and gets clipped by the page.
44
+ *
45
+ * - Never shrinks below `fallback` (the legacy fixed gutter), so charts whose
46
+ * labels already fit keep their exact current layout.
47
+ * - Caps at 45% of the chart width so one pathological label cannot crush the
48
+ * plot area; past the cap the label bleeds as before, but the plot survives.
49
+ * - `widestLabelWidth === null` (SSR / unmeasurable) keeps the legacy gutter.
50
+ */
51
+ export function computeHorizontalCategoryGutter(widestLabelWidth, chartWidth, fallback = 50) {
52
+ if (widestLabelWidth === null) {
53
+ return fallback;
54
+ }
55
+ const tickLabelInset = 10;
56
+ const breathingPad = 4;
57
+ const cap = Math.max(fallback, chartWidth * 0.45);
58
+ const fitted = widestLabelWidth + tickLabelInset + breathingPad;
59
+ return Math.round(Math.min(Math.max(fallback, fitted), cap));
60
+ }
16
61
  // ── Pie layout ──────────────────────────────────────────────────
17
62
  export function computePieLayout(data, startAngle = -Math.PI / 2, padAngle = 0) {
18
63
  const total = data.reduce((sum, d) => sum + Math.max(0, d.value), 0);
@@ -94,25 +139,37 @@ export function computeSankeyLayout(nodes, links, width, height, nodeWidth = 16,
94
139
  columnGroups.get(col).push(n.id);
95
140
  }
96
141
  const colWidth = maxCol === 0 ? 0 : (width - nodeWidth) / maxCol;
142
+ const columnPadding = new Map();
97
143
  // Initialize y positions
98
144
  const nodeY = new Map();
99
145
  const nodeH = new Map();
100
- for (const [, ids] of columnGroups) {
146
+ for (const [col, ids] of columnGroups) {
101
147
  const totalValue = ids.reduce((s, id) => s + (nodeValues.get(id) ?? 0), 0);
102
- const availableHeight = height - (ids.length - 1) * nodePadding;
103
- let y = 0;
104
- for (const id of ids) {
148
+ const gapCount = ids.length - 1;
149
+ const availableHeight = height - gapCount * nodePadding;
150
+ const renderedHeights = ids.map((id) => {
105
151
  const val = nodeValues.get(id) ?? 0;
106
152
  const h = totalValue > 0 ? (val / totalValue) * availableHeight : availableHeight / ids.length;
107
- const renderedH = Math.max(minLinkWidth, h);
153
+ return Math.max(minLinkWidth, h);
154
+ });
155
+ const sumRendered = renderedHeights.reduce((s, h) => s + h, 0);
156
+ const paddingBudget = gapCount > 0 ? (height - sumRendered) / gapCount : 0;
157
+ const effectivePadding = Math.max(0, Math.min(nodePadding, paddingBudget));
158
+ const scale = sumRendered > height && sumRendered > 0 ? height / sumRendered : 1;
159
+ columnPadding.set(col, effectivePadding);
160
+ let y = 0;
161
+ for (let index = 0; index < ids.length; index++) {
162
+ const id = ids[index];
163
+ const renderedH = renderedHeights[index] * scale;
108
164
  nodeY.set(id, y);
109
165
  nodeH.set(id, renderedH);
110
- y += renderedH + nodePadding;
166
+ y += renderedH + effectivePadding;
111
167
  }
112
168
  }
113
169
  // Iterative relaxation (upstream pass)
114
170
  for (let iter = 0; iter < iterations; iter++) {
115
- for (const [, ids] of columnGroups) {
171
+ for (const [col, ids] of columnGroups) {
172
+ const padding = columnPadding.get(col) ?? nodePadding;
116
173
  for (const id of ids) {
117
174
  const deps = incoming.get(id) ?? [];
118
175
  const totalDepValue = deps.reduce((s, d) => s + d.value, 0);
@@ -128,15 +185,13 @@ export function computeSankeyLayout(nodes, links, width, height, nodeWidth = 16,
128
185
  nodeY.set(id, Math.max(0, weightedY - (nodeH.get(id) ?? 0) / 2));
129
186
  }
130
187
  }
131
- // Resolve overlaps: push down from the top.
132
- ids.sort((a, b) => (nodeY.get(a) ?? 0) - (nodeY.get(b) ?? 0));
133
188
  let y = 0;
134
189
  for (const id of ids) {
135
190
  const cy = nodeY.get(id) ?? 0;
136
191
  if (cy < y) {
137
192
  nodeY.set(id, y);
138
193
  }
139
- y = (nodeY.get(id) ?? 0) + (nodeH.get(id) ?? 0) + nodePadding;
194
+ y = (nodeY.get(id) ?? 0) + (nodeH.get(id) ?? 0) + padding;
140
195
  }
141
196
  // Resolve overlaps: pull back up from the bottom. The push-down pass
142
197
  // above only ever grows a column's block downward, so a fan-out whose
@@ -151,7 +206,7 @@ export function computeSankeyLayout(nodes, links, width, height, nodeWidth = 16,
151
206
  if (nodeBottom > bottomBoundary) {
152
207
  nodeY.set(id, bottomBoundary - (nodeH.get(id) ?? 0));
153
208
  }
154
- bottomBoundary = (nodeY.get(id) ?? 0) - nodePadding;
209
+ bottomBoundary = (nodeY.get(id) ?? 0) - padding;
155
210
  }
156
211
  // Re-centre the column's node group within [0, height]: once overlaps
157
212
  // are resolved, anchor the block at the midpoint of its remaining
@@ -184,25 +239,53 @@ export function computeSankeyLayout(nodes, links, width, height, nodeWidth = 16,
184
239
  column: columns.get(n.id) ?? 0
185
240
  }));
186
241
  const nodeById = new Map(computedNodes.map((n) => [n.id, n]));
187
- // Build computed links with Bezier paths
188
- const sourceOffsets = new Map();
189
- const targetOffsets = new Map();
190
- for (const n of nodes) {
191
- sourceOffsets.set(n.id, nodeY.get(n.id) ?? 0);
192
- targetOffsets.set(n.id, nodeY.get(n.id) ?? 0);
242
+ const linkKey = (l) => `${l.source}${l.target}`;
243
+ const linkWidths = new Map();
244
+ for (const l of links) {
245
+ const sVal = nodeValues.get(l.source) ?? 1;
246
+ const sourceH = nodeH.get(l.source) ?? 0;
247
+ linkWidths.set(linkKey(l), Math.max(minLinkWidth, (l.value / Math.max(sVal, 1)) * sourceH));
248
+ }
249
+ const linkSy = new Map();
250
+ const linkTy = new Map();
251
+ const bySource = new Map();
252
+ const byTarget = new Map();
253
+ for (const l of links) {
254
+ if (!bySource.has(l.source)) {
255
+ bySource.set(l.source, []);
256
+ }
257
+ bySource.get(l.source).push(l);
258
+ if (!byTarget.has(l.target)) {
259
+ byTarget.set(l.target, []);
260
+ }
261
+ byTarget.get(l.target).push(l);
262
+ }
263
+ for (const [source, group] of bySource) {
264
+ group.sort((a, b) => (nodeY.get(a.target) ?? 0) - (nodeY.get(b.target) ?? 0));
265
+ let offset = nodeY.get(source) ?? 0;
266
+ for (const l of group) {
267
+ const w = linkWidths.get(linkKey(l)) ?? 0;
268
+ linkSy.set(linkKey(l), offset + w / 2);
269
+ offset += w;
270
+ }
271
+ }
272
+ for (const [target, group] of byTarget) {
273
+ group.sort((a, b) => (nodeY.get(a.source) ?? 0) - (nodeY.get(b.source) ?? 0));
274
+ let offset = nodeY.get(target) ?? 0;
275
+ for (const l of group) {
276
+ const w = linkWidths.get(linkKey(l)) ?? 0;
277
+ linkTy.set(linkKey(l), offset + w / 2);
278
+ offset += w;
279
+ }
193
280
  }
194
281
  const computedLinks = links.map((l) => {
195
282
  const sourceNode = nodeById.get(l.source);
196
283
  const targetNode = nodeById.get(l.target);
197
- const sVal = nodeValues.get(l.source) ?? 1;
198
- const sourceH = nodeH.get(l.source) ?? 0;
199
- const linkWidth = Math.max(minLinkWidth, (l.value / Math.max(sVal, 1)) * sourceH);
284
+ const linkWidth = linkWidths.get(linkKey(l)) ?? minLinkWidth;
200
285
  const sx = (sourceNode?.x ?? 0) + nodeWidth;
201
- const sy = (sourceOffsets.get(l.source) ?? 0) + linkWidth / 2;
286
+ const sy = linkSy.get(linkKey(l)) ?? 0;
202
287
  const tx = targetNode?.x ?? 0;
203
- const ty = (targetOffsets.get(l.target) ?? 0) + linkWidth / 2;
204
- sourceOffsets.set(l.source, (sourceOffsets.get(l.source) ?? 0) + linkWidth);
205
- targetOffsets.set(l.target, (targetOffsets.get(l.target) ?? 0) + linkWidth);
288
+ const ty = linkTy.get(linkKey(l)) ?? 0;
206
289
  const midX = (sx + tx) / 2;
207
290
  const path = `M ${sx} ${sy} C ${midX} ${sy}, ${midX} ${ty}, ${tx} ${ty}`;
208
291
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/svelte-ui-components",
3
- "version": "2.81.1",
3
+ "version": "2.81.3",
4
4
  "description": "A themeable Svelte 5 UI component library with CSS custom property driven styling",
5
5
  "keywords": [
6
6
  "svelte",