@juspay/svelte-ui-components 2.90.0 → 2.90.1

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.
@@ -10,7 +10,11 @@
10
10
  import { computePieLayout } from '../_chart/geometry';
11
11
  import { getColor } from '../_chart/colors';
12
12
  import { formatNumber } from '../_chart/format';
13
+ import { measureText, readCssVarPx } from '../_chart/measure';
14
+ import { truncateToWidth, placedLabelRect, dropOverlapping } from '../_chart/labels';
15
+ import type { LabelRect } from '../_chart/labels';
13
16
  import type { LegendItem } from '../_chart/types';
17
+ import { SvelteMap } from 'svelte/reactivity';
14
18
 
15
19
  // ── Props ──────────────────────────────────────────────────────
16
20
 
@@ -153,6 +157,76 @@
153
157
  data.map((d, i) => ({ label: d.label, color: d.color ?? getColor(i) }))
154
158
  );
155
159
 
160
+ // ── Label engine ───────────────────────────────────────────────
161
+ // A crowded pie (many slices, long labels) used to render every label
162
+ // unconditionally at its mid-angle: stacked unreadable text that also ran
163
+ // past the chart box. Labels are now measured, truncated to the horizontal
164
+ // room the chart actually has, gated on the slice's arc length (inside
165
+ // position), and de-collided with larger slices winning. Dropped or
166
+ // truncated text stays available on the tooltip and aria-label.
167
+ let visibleSliceLabels = $derived.by(() => {
168
+ const visible = new SvelteMap<number, string>();
169
+ if ((!showLabels && !showValues) || chartWidth <= 0) {
170
+ return visible;
171
+ }
172
+ const font = {
173
+ size: containerEl ? readCssVarPx(containerEl, '--piechart-label-font-size', 12) : 12
174
+ };
175
+ const lineHeight = measureText('Ag', font).height;
176
+
177
+ type LabelCandidate = { index: number; value: number; text: string; rect: LabelRect };
178
+ const candidates: LabelCandidate[] = [];
179
+ for (const slice of slices) {
180
+ const parts: string[] = [];
181
+ if (showLabels) {
182
+ parts.push(slice.label);
183
+ }
184
+ if (showValues) {
185
+ parts.push(pctFormat(slice.value));
186
+ }
187
+ const raw = parts.join(' ').trim();
188
+ if (raw.length === 0) {
189
+ continue;
190
+ }
191
+ const absX = cx + slice.labelX;
192
+ const absY = cy + slice.labelY;
193
+ // text-anchor is middle, so the budget is twice the room to the nearer edge.
194
+ const budget = Math.max(0, Math.min(absX, chartWidth - absX) * 2 - 8);
195
+ const labelRadius = labelPosition === 'outside' ? outerR : (innerR + outerR) / 2;
196
+ const arcLength = (slice.endAngle - slice.startAngle) * labelRadius;
197
+ // An inside label sits ON its wedge — hide it when the wedge is thinner
198
+ // than one text line (outside labels rely on the collision pass instead).
199
+ if (labelPosition === 'inside' && arcLength < lineHeight) {
200
+ continue;
201
+ }
202
+ const text = truncateToWidth(raw, budget, font);
203
+ if (text === '') {
204
+ continue;
205
+ }
206
+ const size = measureText(text, font);
207
+ candidates.push({
208
+ index: slice.index,
209
+ value: slice.value,
210
+ text,
211
+ rect: placedLabelRect(
212
+ { x: absX, y: absY, textAnchor: 'middle', dominantBaseline: 'middle' },
213
+ size
214
+ )
215
+ });
216
+ }
217
+
218
+ // Feed the greedy first-come collision pass in value order so the larger
219
+ // slice keeps its label whenever two collide.
220
+ const ordered = [...candidates].sort((a, b) => b.value - a.value);
221
+ const keptFlags = dropOverlapping(ordered.map((candidate) => candidate.rect));
222
+ ordered.forEach((candidate, orderedIndex) => {
223
+ if (keptFlags[orderedIndex]) {
224
+ visible.set(candidate.index, candidate.text);
225
+ }
226
+ });
227
+ return visible;
228
+ });
229
+
156
230
  let centerBoxSize = $derived(innerR > 0 ? Math.max(0, innerR * 1.3) : 0);
157
231
 
158
232
  // The foreignObject for the center snippet is positioned relative to the <g>
@@ -243,19 +317,15 @@
243
317
  onmouseleave={handleLeave}
244
318
  onclick={() => onsliceclick?.({ index: slice.index, slice: data[slice.index] })}
245
319
  />
246
- {#if showLabels || showValues}
320
+ {#if visibleSliceLabels.has(slice.index)}
247
321
  <text
248
322
  class="slice-label"
249
323
  class:label-outside={labelPosition === 'outside'}
250
324
  x={slice.labelX}
251
325
  y={slice.labelY}
252
326
  text-anchor="middle"
253
- dominant-baseline="middle"
327
+ dominant-baseline="middle">{visibleSliceLabels.get(slice.index)}</text
254
328
  >
255
- {#if showLabels}{slice.label}{/if}
256
- {#if showValues}
257
- {pctFormat(slice.value)}{/if}
258
- </text>
259
329
  {/if}
260
330
  {/each}
261
331
 
@@ -5,6 +5,8 @@
5
5
  import { computeSankeyLayout } from '../_chart/geometry';
6
6
  import { getColor } from '../_chart/colors';
7
7
  import { formatNumber } from '../_chart/format';
8
+ import { measureText, readCssVarPx } from '../_chart/measure';
9
+ import { truncateToWidth } from '../_chart/labels';
8
10
  import { DEFAULT_CHART_CORNER_RADIUS, DEFAULT_CHART_MAX_HEIGHT } from '../_chart/types';
9
11
  import { SvelteMap, SvelteSet } from 'svelte/reactivity';
10
12
 
@@ -52,54 +54,20 @@
52
54
  let format = $derived(valueFormat ?? formatNumber);
53
55
  let isEmpty = $derived(nodes.length === 0);
54
56
  const MARGIN = 40;
55
- const LABEL_CHAR_PX = 7.2; // ≈ 0.6em at the 12px default label size
56
57
  // A 12px label's rendered line box measures ~16px (≈1.33em) across common
57
58
  // font stacks; two label centres closer than this overlap visibly.
58
59
  const LABEL_LINE_PX = 16;
59
60
 
60
- // Per-character width estimate at the 12px default label size. A flat
61
- // 7.2px/char average underestimates uppercase-heavy labels ("OTP SKIPPED
62
- // (1,234)" is ~8.2px/char), so "truncated" labels still overflowed their
63
- // budget and slid under the next column's node bar.
64
- const estimateCharWidth = (ch: string): number => {
65
- if (/[mwMW@]/.test(ch)) {
66
- return 10.6;
67
- }
68
- if (/[A-Z0-9_#%&]/.test(ch)) {
69
- return 8.2;
70
- }
71
- if (/[iljtfr.,:;'’()[\]!|]/.test(ch)) {
72
- return 3.6;
73
- }
74
- if (ch === ' ') {
75
- return 3.8;
76
- }
77
- return 6.6;
78
- };
79
-
80
- const estimateTextWidth = (text: string): number => {
81
- let width = 0;
82
- for (const ch of text) {
83
- width += estimateCharWidth(ch);
84
- }
85
- return width;
86
- };
87
-
88
- // Trim `text` (appending an ellipsis) until its estimated width fits
89
- // `available` px. Returns '' when even 3 chars + ellipsis cannot fit —
90
- // callers hide the label and rely on the <title> tooltip instead.
91
- const fitTextToWidth = (text: string, available: number): string => {
92
- if (estimateTextWidth(text) <= available) {
93
- return text;
94
- }
95
- for (let keep = text.length - 1; keep >= 3; keep--) {
96
- const candidate = text.slice(0, keep) + '…';
97
- if (estimateTextWidth(candidate) <= available) {
98
- return candidate;
99
- }
100
- }
101
- return '';
102
- };
61
+ // Real text measurement via the shared canvas-backed helper (exact on the
62
+ // client, 0.6em/char heuristic under SSR/tests). Character estimates used
63
+ // to both over-reserve the right label gutter (dead canvas) and under-budget
64
+ // uppercase-heavy labels (text sliding under the next column's bars).
65
+ let labelFont = $derived({
66
+ size: containerEl ? readCssVarPx(containerEl, '--sankey-label-font-size', 12) : 12
67
+ });
68
+ let colLabelFont = $derived({
69
+ size: containerEl ? readCssVarPx(containerEl, '--sankey-col-label-font-size', 11) : 11
70
+ });
103
71
 
104
72
  // Final-column labels render to the RIGHT of their node; the bare 40px margin is
105
73
  // nowhere near enough for real funnel labels ("PARTIALLY_FAILED (1,234)"), so they
@@ -118,8 +86,8 @@
118
86
  return 0;
119
87
  }
120
88
  const longestPx =
121
- Math.max(...sinkLabels.map((label) => estimateTextWidth(label))) +
122
- (showValues ? 9 * LABEL_CHAR_PX : 0);
89
+ Math.max(...sinkLabels.map((label) => measureText(label, labelFont).width)) +
90
+ (showValues ? measureText(' (999,999)', labelFont).width : 0);
123
91
  const wanted = longestPx + 10 + dataLabelOffsetX;
124
92
  // Cap the reservation so labels can never squeeze the diagram below 3/4 width,
125
93
  // and floor at 0 — a negative dataLabelOffsetX must not inflate the plot
@@ -175,7 +143,7 @@
175
143
  // column count grow; untruncated they collide into one unreadable run. Clip to
176
144
  // the column pitch with an ellipsis — the full text stays on the <title>.
177
145
  const truncateColumnLabel = (text: string): string => {
178
- return fitTextToWidth(text, Math.max(0, colWidth - 6));
146
+ return truncateToWidth(text, Math.max(0, colWidth - 6), colLabelFont);
179
147
  };
180
148
 
181
149
  const truncateLabel = (text: string, column: number): string => {
@@ -194,7 +162,7 @@
194
162
  : Math.max(0, colWidth - nodeWidth - 12 - dataLabelOffsetX);
195
163
  // No usable room — hide the label rather than force text that would overflow;
196
164
  // the full text is still reachable via the node's <title> on hover.
197
- return fitTextToWidth(text, available);
165
+ return truncateToWidth(text, available, labelFont);
198
166
  };
199
167
 
200
168
  // Vertical label de-collision: labels sit at each node's centre-y, so two
@@ -24,61 +24,23 @@ export const tooltip = (node, options) => {
24
24
  let delayTimer = null;
25
25
  const bubbleId = `sui-tooltip-${++tooltipIdCounter}`;
26
26
  const OFFSET = 8; // px — matches --tooltip-offset default
27
- const computeCoords = (rect, pos) => {
28
- const arrowSize = 5; // px — matches --tooltip-arrow-size default
29
- const bg = 'var(--tooltip-arrow-color,var(--tooltip-background,#333333))';
30
- const t = 'transparent';
31
- if (pos === 'top') {
32
- return {
33
- top: rect.top - OFFSET,
34
- left: rect.left + rect.width / 2,
35
- transform: 'translate(-50%, -100%)',
36
- arrowTop: '100%',
37
- arrowLeft: '50%',
38
- arrowRight: '',
39
- arrowTransform: 'translateX(-50%)',
40
- arrowBorderWidth: `${arrowSize}px ${arrowSize}px 0 ${arrowSize}px`,
41
- arrowBorderColor: `${bg} ${t} ${t} ${t}`
42
- };
43
- }
44
- if (pos === 'bottom') {
45
- return {
46
- top: rect.bottom + OFFSET,
47
- left: rect.left + rect.width / 2,
48
- transform: 'translate(-50%, 0)',
49
- arrowTop: `-${arrowSize}px`,
50
- arrowLeft: '50%',
51
- arrowRight: '',
52
- arrowTransform: 'translateX(-50%)',
53
- arrowBorderWidth: `0 ${arrowSize}px ${arrowSize}px ${arrowSize}px`,
54
- arrowBorderColor: `${t} ${t} ${bg} ${t}`
55
- };
56
- }
57
- if (pos === 'left') {
58
- return {
59
- top: rect.top + rect.height / 2,
60
- left: rect.left - OFFSET,
61
- transform: 'translate(-100%, -50%)',
62
- arrowTop: '50%',
63
- arrowLeft: '100%',
64
- arrowRight: '',
65
- arrowTransform: 'translateY(-50%)',
66
- arrowBorderWidth: `${arrowSize}px 0 ${arrowSize}px ${arrowSize}px`,
67
- arrowBorderColor: `${t} ${t} ${t} ${bg}`
68
- };
69
- }
70
- // right
71
- return {
72
- top: rect.top + rect.height / 2,
73
- left: rect.right + OFFSET,
74
- transform: 'translate(0, -50%)',
75
- arrowTop: '50%',
76
- arrowLeft: '',
77
- arrowRight: `${arrowSize}px`,
78
- arrowTransform: 'translateY(-50%)',
79
- arrowBorderWidth: `${arrowSize}px ${arrowSize}px ${arrowSize}px 0`,
80
- arrowBorderColor: `${t} ${bg} ${t} ${t}`
81
- };
27
+ const EDGE_MARGIN = 8; // px — minimum air between the bubble and the viewport edge
28
+ const ARROW_INSET = 9; // px — arrow centre never closer than this to a bubble corner
29
+ const oppositeOf = (side) => {
30
+ if (side === 'top') {
31
+ return 'bottom';
32
+ }
33
+ if (side === 'bottom') {
34
+ return 'top';
35
+ }
36
+ if (side === 'left') {
37
+ return 'right';
38
+ }
39
+ return 'left';
40
+ };
41
+ // min > max (bubble wider/taller than the viewport) degrades to the raw value.
42
+ const clampValue = (value, min, max) => {
43
+ return max < min ? value : Math.min(Math.max(value, min), max);
82
44
  };
83
45
  /**
84
46
  * Build the bubble and arrow elements and attach them to `document.body`.
@@ -127,25 +89,81 @@ export const tooltip = (node, options) => {
127
89
  /**
128
90
  * Compute and apply `top`/`left` fixed coordinates plus arrow styles based on the
129
91
  * current bounding rect of the host element and the active `position` option.
92
+ *
93
+ * The bubble is measured after mounting and then (1) FLIPPED to the opposite
94
+ * side when the preferred side has no room but the opposite side does, and
95
+ * (2) CLAMPED so it never crosses the viewport edge — a tooltip on a trigger
96
+ * near the screen edge used to spill off-screen or cover the nav beneath it.
97
+ * The arrow is positioned in bubble-local pixels anchored to the TRIGGER
98
+ * centre, so it keeps pointing at the trigger even when the bubble shifts.
130
99
  */
131
100
  const positionBubble = () => {
132
101
  if (bubbleEl === null || arrowEl === null) {
133
102
  return;
134
103
  }
135
104
  const rect = node.getBoundingClientRect();
136
- const pos = currentOptions.position ?? 'top';
137
- const coords = computeCoords(rect, pos);
138
- bubbleEl.style.top = `${coords.top}px`;
139
- bubbleEl.style.left = `${coords.left}px`;
140
- bubbleEl.style.transform = coords.transform;
141
- arrowEl.style.top = coords.arrowTop;
142
- arrowEl.style.left = coords.arrowLeft;
143
- if (coords.arrowRight !== '') {
144
- arrowEl.style.right = coords.arrowRight;
145
- }
146
- arrowEl.style.transform = coords.arrowTransform;
147
- arrowEl.style.borderWidth = coords.arrowBorderWidth;
148
- arrowEl.style.borderColor = coords.arrowBorderColor;
105
+ const preferred = currentOptions.position ?? 'top';
106
+ const arrowSize = 5; // px — matches --tooltip-arrow-size default
107
+ const bg = 'var(--tooltip-arrow-color,var(--tooltip-background,#333333))';
108
+ const t = 'transparent';
109
+ // Stubbed DOMs (unit tests) report no dimensions; clamping then no-ops.
110
+ const bubbleWidth = bubbleEl.offsetWidth || 0;
111
+ const bubbleHeight = bubbleEl.offsetHeight || 0;
112
+ const viewportWidth = typeof window !== 'undefined' && window.innerWidth > 0
113
+ ? window.innerWidth
114
+ : Number.POSITIVE_INFINITY;
115
+ const viewportHeight = typeof window !== 'undefined' && window.innerHeight > 0
116
+ ? window.innerHeight
117
+ : Number.POSITIVE_INFINITY;
118
+ const fits = (side) => {
119
+ if (side === 'top') {
120
+ return rect.top - OFFSET - bubbleHeight >= EDGE_MARGIN;
121
+ }
122
+ if (side === 'bottom') {
123
+ return rect.bottom + OFFSET + bubbleHeight <= viewportHeight - EDGE_MARGIN;
124
+ }
125
+ if (side === 'left') {
126
+ return rect.left - OFFSET - bubbleWidth >= EDGE_MARGIN;
127
+ }
128
+ return rect.right + OFFSET + bubbleWidth <= viewportWidth - EDGE_MARGIN;
129
+ };
130
+ const side = !fits(preferred) && fits(oppositeOf(preferred)) ? oppositeOf(preferred) : preferred;
131
+ let top;
132
+ let left;
133
+ if (side === 'top' || side === 'bottom') {
134
+ top = side === 'top' ? rect.top - OFFSET - bubbleHeight : rect.bottom + OFFSET;
135
+ left = clampValue(rect.left + rect.width / 2 - bubbleWidth / 2, EDGE_MARGIN, viewportWidth - EDGE_MARGIN - bubbleWidth);
136
+ }
137
+ else {
138
+ left = side === 'left' ? rect.left - OFFSET - bubbleWidth : rect.right + OFFSET;
139
+ top = clampValue(rect.top + rect.height / 2 - bubbleHeight / 2, EDGE_MARGIN, viewportHeight - EDGE_MARGIN - bubbleHeight);
140
+ }
141
+ bubbleEl.style.top = `${top}px`;
142
+ bubbleEl.style.left = `${left}px`;
143
+ bubbleEl.style.transform = 'none';
144
+ arrowEl.style.right = '';
145
+ if (side === 'top' || side === 'bottom') {
146
+ const arrowLeft = clampValue(rect.left + rect.width / 2 - left, ARROW_INSET, Math.max(ARROW_INSET, bubbleWidth - ARROW_INSET));
147
+ arrowEl.style.left = `${arrowLeft}px`;
148
+ arrowEl.style.top = side === 'top' ? '100%' : `-${arrowSize}px`;
149
+ arrowEl.style.transform = 'translateX(-50%)';
150
+ arrowEl.style.borderWidth =
151
+ side === 'top'
152
+ ? `${arrowSize}px ${arrowSize}px 0 ${arrowSize}px`
153
+ : `0 ${arrowSize}px ${arrowSize}px ${arrowSize}px`;
154
+ arrowEl.style.borderColor = side === 'top' ? `${bg} ${t} ${t} ${t}` : `${t} ${t} ${bg} ${t}`;
155
+ }
156
+ else {
157
+ const arrowTop = clampValue(rect.top + rect.height / 2 - top, ARROW_INSET, Math.max(ARROW_INSET, bubbleHeight - ARROW_INSET));
158
+ arrowEl.style.top = `${arrowTop}px`;
159
+ arrowEl.style.left = side === 'left' ? '100%' : `-${arrowSize}px`;
160
+ arrowEl.style.transform = 'translateY(-50%)';
161
+ arrowEl.style.borderWidth =
162
+ side === 'left'
163
+ ? `${arrowSize}px 0 ${arrowSize}px ${arrowSize}px`
164
+ : `${arrowSize}px ${arrowSize}px ${arrowSize}px 0`;
165
+ arrowEl.style.borderColor = side === 'left' ? `${t} ${t} ${t} ${bg}` : `${t} ${bg} ${t} ${t}`;
166
+ }
149
167
  };
150
168
  const show = () => {
151
169
  // Guard against overlapping events (e.g. mouseenter + focusin firing simultaneously,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/svelte-ui-components",
3
- "version": "2.90.0",
3
+ "version": "2.90.1",
4
4
  "description": "A themeable Svelte 5 UI component library with CSS custom property driven styling",
5
5
  "keywords": [
6
6
  "svelte",