@juspay/svelte-ui-components 2.86.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.
@@ -0,0 +1,76 @@
1
+ import { type FontSpec } from './measure';
2
+ export type LabelRect = {
3
+ x: number;
4
+ y: number;
5
+ width: number;
6
+ height: number;
7
+ };
8
+ export type LabelSize = {
9
+ width: number;
10
+ height: number;
11
+ };
12
+ export type LabelPlacement = {
13
+ x: number;
14
+ y: number;
15
+ placement: 'outside' | 'inside' | 'hidden';
16
+ textAnchor: 'start' | 'middle' | 'end';
17
+ dominantBaseline: 'auto' | 'middle' | 'hanging';
18
+ };
19
+ /**
20
+ * Highcharts-style end-of-bar label chain: place just past the bar's value end
21
+ * (outside) → justify back inside the bar end when the plot area would clip it →
22
+ * hide (crop) when the bar cannot fit the label either.
23
+ */
24
+ export declare function resolveEndLabel(opts: {
25
+ bar: LabelRect;
26
+ plot: LabelSize;
27
+ label: LabelSize;
28
+ orientation: 'vertical' | 'horizontal';
29
+ negative?: boolean;
30
+ gap?: number;
31
+ padding?: number;
32
+ }): LabelPlacement;
33
+ /** Center a label inside a segment (stacked bars, funnel stages); hide when it cannot fit. */
34
+ export declare function resolveInsideLabel(opts: {
35
+ bar: LabelRect;
36
+ label: LabelSize;
37
+ padding?: number;
38
+ }): LabelPlacement;
39
+ /** Line/area point-value labels: above the point, flipped below at the plot top, thinned by density. */
40
+ export declare function resolvePointLabels(opts: {
41
+ points: Array<{
42
+ x: number;
43
+ y: number;
44
+ }>;
45
+ labels: LabelSize[];
46
+ plot: LabelSize;
47
+ gap?: number;
48
+ }): Array<{
49
+ x: number;
50
+ y: number;
51
+ visible: boolean;
52
+ dominantBaseline: 'auto' | 'hanging';
53
+ }>;
54
+ /**
55
+ * Axis tick-label crowding chain: horizontal → rotate -45° → thin to every Nth,
56
+ * where N is the smallest integer such that rotated labels no longer overlap.
57
+ */
58
+ export declare function thinTicks(opts: {
59
+ labelWidths: number[];
60
+ labelHeight: number;
61
+ step: number;
62
+ gap?: number;
63
+ }): {
64
+ rotate: boolean;
65
+ every: number;
66
+ };
67
+ /** Measurement-based ellipsis truncation; empty string when fewer than 2 chars fit. */
68
+ export declare function truncateToWidth(text: string, maxWidth: number, font: FontSpec): string;
69
+ /** Convert an anchored SVG text placement into its bounding rect for collision checks. */
70
+ export declare function placedLabelRect(p: Pick<LabelPlacement, 'x' | 'y' | 'textAnchor' | 'dominantBaseline'>, label: LabelSize): LabelRect;
71
+ /**
72
+ * Highcharts `allowOverlap: false` equivalent: greedy first-come pass that hides
73
+ * any label whose rect intersects an already-kept label. `null` entries are
74
+ * pre-hidden labels and always return false.
75
+ */
76
+ export declare function dropOverlapping(rects: Array<LabelRect | null>, gap?: number): boolean[];
@@ -0,0 +1,213 @@
1
+ import { measureText } from './measure';
2
+ /**
3
+ * Highcharts-style end-of-bar label chain: place just past the bar's value end
4
+ * (outside) → justify back inside the bar end when the plot area would clip it →
5
+ * hide (crop) when the bar cannot fit the label either.
6
+ */
7
+ export function resolveEndLabel(opts) {
8
+ const { bar, plot, label, orientation } = opts;
9
+ const gap = opts.gap ?? 4;
10
+ const padding = opts.padding ?? 4;
11
+ const negative = opts.negative ?? false;
12
+ if (orientation === 'vertical') {
13
+ const x = bar.x + bar.width / 2;
14
+ const insideFits = bar.height >= label.height + 2 * padding;
15
+ if (!negative) {
16
+ if (bar.y - gap - label.height >= 0) {
17
+ return {
18
+ x,
19
+ y: bar.y - gap,
20
+ placement: 'outside',
21
+ textAnchor: 'middle',
22
+ dominantBaseline: 'auto'
23
+ };
24
+ }
25
+ if (insideFits) {
26
+ return {
27
+ x,
28
+ y: bar.y + padding,
29
+ placement: 'inside',
30
+ textAnchor: 'middle',
31
+ dominantBaseline: 'hanging'
32
+ };
33
+ }
34
+ return { x, y: 0, placement: 'hidden', textAnchor: 'middle', dominantBaseline: 'auto' };
35
+ }
36
+ const end = bar.y + bar.height;
37
+ if (end + gap + label.height <= plot.height) {
38
+ return {
39
+ x,
40
+ y: end + gap,
41
+ placement: 'outside',
42
+ textAnchor: 'middle',
43
+ dominantBaseline: 'hanging'
44
+ };
45
+ }
46
+ if (insideFits) {
47
+ return {
48
+ x,
49
+ y: end - padding,
50
+ placement: 'inside',
51
+ textAnchor: 'middle',
52
+ dominantBaseline: 'auto'
53
+ };
54
+ }
55
+ return { x, y: 0, placement: 'hidden', textAnchor: 'middle', dominantBaseline: 'auto' };
56
+ }
57
+ const y = bar.y + bar.height / 2;
58
+ // A sub-band thinner than the label height cannot host a legible label in
59
+ // either position (generalises the old `bar.height >= 13` special case).
60
+ if (bar.height < label.height) {
61
+ return { x: 0, y, placement: 'hidden', textAnchor: 'start', dominantBaseline: 'middle' };
62
+ }
63
+ const insideFits = bar.width >= label.width + 2 * padding;
64
+ if (!negative) {
65
+ const end = bar.x + bar.width;
66
+ if (end + gap + label.width <= plot.width) {
67
+ return {
68
+ x: end + gap,
69
+ y,
70
+ placement: 'outside',
71
+ textAnchor: 'start',
72
+ dominantBaseline: 'middle'
73
+ };
74
+ }
75
+ if (insideFits) {
76
+ return {
77
+ x: end - padding,
78
+ y,
79
+ placement: 'inside',
80
+ textAnchor: 'end',
81
+ dominantBaseline: 'middle'
82
+ };
83
+ }
84
+ return { x: 0, y, placement: 'hidden', textAnchor: 'start', dominantBaseline: 'middle' };
85
+ }
86
+ if (bar.x - gap - label.width >= 0) {
87
+ return {
88
+ x: bar.x - gap,
89
+ y,
90
+ placement: 'outside',
91
+ textAnchor: 'end',
92
+ dominantBaseline: 'middle'
93
+ };
94
+ }
95
+ if (insideFits) {
96
+ return {
97
+ x: bar.x + padding,
98
+ y,
99
+ placement: 'inside',
100
+ textAnchor: 'start',
101
+ dominantBaseline: 'middle'
102
+ };
103
+ }
104
+ return { x: 0, y, placement: 'hidden', textAnchor: 'start', dominantBaseline: 'middle' };
105
+ }
106
+ /** Center a label inside a segment (stacked bars, funnel stages); hide when it cannot fit. */
107
+ export function resolveInsideLabel(opts) {
108
+ const { bar, label } = opts;
109
+ const padding = opts.padding ?? 4;
110
+ const fits = bar.width >= label.width + 2 * padding && bar.height >= label.height + 2 * padding;
111
+ return {
112
+ x: bar.x + bar.width / 2,
113
+ y: bar.y + bar.height / 2,
114
+ placement: fits ? 'inside' : 'hidden',
115
+ textAnchor: 'middle',
116
+ dominantBaseline: 'middle'
117
+ };
118
+ }
119
+ /** Line/area point-value labels: above the point, flipped below at the plot top, thinned by density. */
120
+ export function resolvePointLabels(opts) {
121
+ const gap = opts.gap ?? 8;
122
+ const n = opts.points.length;
123
+ // Thinning cadence from the tightest real gap between consecutive points —
124
+ // non-uniform x data must thin for its densest run, not the average spread.
125
+ let step = Number.POSITIVE_INFINITY;
126
+ for (let i = 1; i < n; i++) {
127
+ step = Math.min(step, Math.abs(opts.points[i].x - opts.points[i - 1].x));
128
+ }
129
+ const maxWidth = opts.labels.reduce((m, l) => Math.max(m, l.width), 0);
130
+ const every = Math.max(1, Math.ceil((maxWidth + 4) / step));
131
+ return opts.points.map((point, i) => {
132
+ const label = opts.labels[i] ?? { width: 0, height: 0 };
133
+ const flip = point.y - gap - label.height < 0;
134
+ return {
135
+ x: point.x,
136
+ y: flip ? point.y + gap : point.y - gap,
137
+ visible: i % every === 0,
138
+ dominantBaseline: flip ? 'hanging' : 'auto'
139
+ };
140
+ });
141
+ }
142
+ /**
143
+ * Axis tick-label crowding chain: horizontal → rotate -45° → thin to every Nth,
144
+ * where N is the smallest integer such that rotated labels no longer overlap.
145
+ */
146
+ export function thinTicks(opts) {
147
+ const gap = opts.gap ?? 8;
148
+ const maxWidth = opts.labelWidths.reduce((m, w) => Math.max(m, w), 0);
149
+ if (opts.step <= 0) {
150
+ return { rotate: false, every: 1 };
151
+ }
152
+ if (maxWidth + gap <= opts.step) {
153
+ return { rotate: false, every: 1 };
154
+ }
155
+ // A -45°-rotated label's horizontal footprint is governed by its line height
156
+ // projected onto the axis: height * √2.
157
+ const rotatedFootprint = opts.labelHeight * Math.SQRT2 + gap;
158
+ return { rotate: true, every: Math.max(1, Math.ceil(rotatedFootprint / opts.step)) };
159
+ }
160
+ /** Measurement-based ellipsis truncation; empty string when fewer than 2 chars fit. */
161
+ export function truncateToWidth(text, maxWidth, font) {
162
+ if (measureText(text, font).width <= maxWidth) {
163
+ return text;
164
+ }
165
+ let lo = 0;
166
+ let hi = text.length;
167
+ while (lo < hi) {
168
+ const mid = Math.ceil((lo + hi) / 2);
169
+ if (measureText(text.slice(0, mid) + '…', font).width <= maxWidth) {
170
+ lo = mid;
171
+ }
172
+ else {
173
+ hi = mid - 1;
174
+ }
175
+ }
176
+ return lo >= 2 ? text.slice(0, lo) + '…' : '';
177
+ }
178
+ /** Convert an anchored SVG text placement into its bounding rect for collision checks. */
179
+ export function placedLabelRect(p, label) {
180
+ const x = p.textAnchor === 'middle'
181
+ ? p.x - label.width / 2
182
+ : p.textAnchor === 'end'
183
+ ? p.x - label.width
184
+ : p.x;
185
+ const y = p.dominantBaseline === 'middle'
186
+ ? p.y - label.height / 2
187
+ : p.dominantBaseline === 'hanging'
188
+ ? p.y
189
+ : p.y - label.height;
190
+ return { x, y, width: label.width, height: label.height };
191
+ }
192
+ /**
193
+ * Highcharts `allowOverlap: false` equivalent: greedy first-come pass that hides
194
+ * any label whose rect intersects an already-kept label. `null` entries are
195
+ * pre-hidden labels and always return false.
196
+ */
197
+ export function dropOverlapping(rects, gap = 2) {
198
+ const kept = [];
199
+ return rects.map((r) => {
200
+ if (r === null) {
201
+ return false;
202
+ }
203
+ const collides = kept.some((k) => r.x < k.x + k.width + gap &&
204
+ k.x < r.x + r.width + gap &&
205
+ r.y < k.y + k.height + gap &&
206
+ k.y < r.y + r.height + gap);
207
+ if (collides) {
208
+ return false;
209
+ }
210
+ kept.push(r);
211
+ return true;
212
+ });
213
+ }
@@ -0,0 +1,12 @@
1
+ export type FontSpec = {
2
+ /** Font size in px. */
3
+ size: number;
4
+ family?: string;
5
+ weight?: number | string;
6
+ };
7
+ export declare function measureText(text: string, font: FontSpec): {
8
+ width: number;
9
+ height: number;
10
+ };
11
+ /** Reads a px-valued CSS custom property off an element, with an SSR-safe fallback. */
12
+ export declare function readCssVarPx(el: Element | null, name: string, fallback: number): number;
@@ -0,0 +1,46 @@
1
+ const DEFAULT_FAMILY = 'system-ui, -apple-system, sans-serif';
2
+ // Average glyph width ≈ 0.6em for UI sans fonts — only used when canvas is
3
+ // unavailable (SSR / unit tests); the client always re-measures after mount.
4
+ const HEURISTIC_WIDTH_PER_CHAR = 0.6;
5
+ const LINE_HEIGHT_FACTOR = 1.2;
6
+ const CACHE_LIMIT = 4000;
7
+ let ctx = null;
8
+ const cache = new Map();
9
+ function context() {
10
+ if (ctx !== null) {
11
+ return ctx;
12
+ }
13
+ if (typeof document === 'undefined') {
14
+ return null;
15
+ }
16
+ ctx = document.createElement('canvas').getContext('2d');
17
+ return ctx;
18
+ }
19
+ export function measureText(text, font) {
20
+ const height = font.size * LINE_HEIGHT_FACTOR;
21
+ // NUL-delimited so free-form family/text strings (which may contain any
22
+ // printable character, e.g. "12,000 | 100%") can never collide across fields.
23
+ const key = [font.weight ?? 400, font.size, font.family ?? '', text].join('\u0000');
24
+ const cached = cache.get(key);
25
+ if (typeof cached !== 'undefined') {
26
+ return { width: cached, height };
27
+ }
28
+ const c = context();
29
+ const width = c === null
30
+ ? text.length * font.size * HEURISTIC_WIDTH_PER_CHAR
31
+ : ((c.font = `${font.weight ?? 400} ${font.size}px ${font.family ?? DEFAULT_FAMILY}`),
32
+ c.measureText(text).width);
33
+ if (cache.size >= CACHE_LIMIT) {
34
+ cache.clear();
35
+ }
36
+ cache.set(key, width);
37
+ return { width, height };
38
+ }
39
+ /** Reads a px-valued CSS custom property off an element, with an SSR-safe fallback. */
40
+ export function readCssVarPx(el, name, fallback) {
41
+ if (typeof window === 'undefined' || el === null) {
42
+ return fallback;
43
+ }
44
+ const parsed = parseFloat(getComputedStyle(el).getPropertyValue(name));
45
+ return Number.isNaN(parsed) ? fallback : parsed;
46
+ }
@@ -1,5 +1,5 @@
1
1
  import type { LinearScale, BandScale } from './types';
2
2
  export declare function niceLinearDomain(min: number, max: number): [number, number];
3
- export declare function computeLinearTicks(domain: [number, number], count?: number): number[];
3
+ export declare function computeLinearTicks(domain: [number, number], count?: number, integer?: boolean): number[];
4
4
  export declare function createLinearScale(domain: [number, number], range: [number, number]): LinearScale;
5
5
  export declare function createBandScale(domain: string[], range: [number, number], padding?: number): BandScale;
@@ -9,7 +9,7 @@ export function niceLinearDomain(min, max) {
9
9
  const niceMax = Math.ceil(max / step) * step;
10
10
  return [niceMin, niceMax];
11
11
  }
12
- export function computeLinearTicks(domain, count = 5) {
12
+ export function computeLinearTicks(domain, count = 5, integer = false) {
13
13
  const [min, max] = domain;
14
14
  if (min === max) {
15
15
  return [min];
@@ -31,6 +31,11 @@ export function computeLinearTicks(domain, count = 5) {
31
31
  else {
32
32
  step = base * 10;
33
33
  }
34
+ // Category axes (Highcharts semantics): ticks sit on whole category
35
+ // positions, so a fractional step would repeat labels — floor it to 1.
36
+ if (integer && step < 1) {
37
+ step = 1;
38
+ }
34
39
  const ticks = [];
35
40
  let tick = Math.ceil(min / step) * step;
36
41
  while (tick <= max + step * 0.001) {
@@ -48,7 +53,7 @@ export function createLinearScale(domain, range) {
48
53
  return Object.assign(fn, {
49
54
  domain,
50
55
  range,
51
- ticks: (count) => computeLinearTicks(domain, count),
56
+ ticks: (count, integer) => computeLinearTicks(domain, count, integer),
52
57
  invert: (pixel) => d0 + ((pixel - r0) / rSpan) * dSpan
53
58
  });
54
59
  }
@@ -0,0 +1,22 @@
1
+ import type { TooltipAnchor } from './types';
2
+ type Size = {
3
+ width: number;
4
+ height: number;
5
+ };
6
+ /**
7
+ * Pure tooltip placement: anchor mode positions relative to a data point with
8
+ * side-flipping; cursor mode follows the pointer with flip-then-clamp on both
9
+ * axes. All coordinates are relative to the positioned container.
10
+ */
11
+ export declare function computeTooltipPosition(opts: {
12
+ mouseX?: number;
13
+ mouseY?: number;
14
+ anchor?: TooltipAnchor | null;
15
+ tooltip: Size;
16
+ container: Size;
17
+ offset?: number;
18
+ }): {
19
+ left: number;
20
+ top: number;
21
+ };
22
+ export {};
@@ -0,0 +1,49 @@
1
+ const clamp = (value, max) => Math.max(0, Number.isFinite(max) ? Math.min(value, max) : value);
2
+ /**
3
+ * Pure tooltip placement: anchor mode positions relative to a data point with
4
+ * side-flipping; cursor mode follows the pointer with flip-then-clamp on both
5
+ * axes. All coordinates are relative to the positioned container.
6
+ */
7
+ export function computeTooltipPosition(opts) {
8
+ const offset = opts.offset ?? 12;
9
+ const { tooltip, container } = opts;
10
+ const maxLeft = container.width - tooltip.width;
11
+ const maxTop = container.height - tooltip.height;
12
+ const a = opts.anchor ?? null;
13
+ if (a !== null) {
14
+ let left;
15
+ let top;
16
+ if (a.side === 'top' || a.side === 'bottom') {
17
+ left = a.x - tooltip.width / 2;
18
+ top = a.side === 'top' ? a.y - tooltip.height - offset : a.y + offset;
19
+ if (a.side === 'top' && top < 0) {
20
+ top = a.y + offset;
21
+ }
22
+ else if (a.side === 'bottom' && top > maxTop) {
23
+ top = a.y - tooltip.height - offset;
24
+ }
25
+ }
26
+ else {
27
+ top = a.y - tooltip.height / 2;
28
+ left = a.side === 'right' ? a.x + offset : a.x - tooltip.width - offset;
29
+ if (a.side === 'right' && left > maxLeft) {
30
+ left = a.x - tooltip.width - offset;
31
+ }
32
+ else if (a.side === 'left' && left < 0) {
33
+ left = a.x + offset;
34
+ }
35
+ }
36
+ return { left: clamp(left, maxLeft), top: clamp(top, maxTop) };
37
+ }
38
+ const mouseX = opts.mouseX ?? 0;
39
+ const mouseY = opts.mouseY ?? 0;
40
+ let left = mouseX + offset;
41
+ if (left + tooltip.width > container.width) {
42
+ left = mouseX - tooltip.width - offset;
43
+ }
44
+ let top = mouseY - offset;
45
+ if (top + tooltip.height > container.height) {
46
+ top = mouseY - tooltip.height - offset;
47
+ }
48
+ return { left: clamp(left, maxLeft), top: clamp(top, maxTop) };
49
+ }
@@ -39,7 +39,7 @@ export type LinearScale = {
39
39
  (value: number): number;
40
40
  domain: [number, number];
41
41
  range: [number, number];
42
- ticks: (count?: number) => number[];
42
+ ticks: (count?: number, integer?: boolean) => number[];
43
43
  invert: (pixel: number) => number;
44
44
  };
45
45
  export type BandScale = {
@@ -124,6 +124,14 @@ export type TooltipData = {
124
124
  export type LegendItem = {
125
125
  label: string;
126
126
  color: string;
127
+ /** True when the series is toggled off via an interactive legend. */
128
+ hidden?: boolean;
129
+ };
130
+ /** Data-space anchor for point/category-anchored tooltips (coords are container px). */
131
+ export type TooltipAnchor = {
132
+ x: number;
133
+ y: number;
134
+ side: 'top' | 'right' | 'bottom' | 'left';
127
135
  };
128
136
  export type ChartContainerProperties = {
129
137
  width?: number;
@@ -143,18 +151,38 @@ export type AxisProperties = {
143
151
  showGridlines?: boolean;
144
152
  gridlineLength?: number;
145
153
  label?: string;
154
+ /** Rotate horizontal-axis tick labels -45° (crowding fallback). */
155
+ rotateTicks?: boolean;
156
+ /** Render every Nth tick label (tick marks always render). */
157
+ tickEvery?: number;
158
+ /** y-offset of the bottom axis title (grows when tick labels rotate). */
159
+ labelOffset?: number;
160
+ /** Clamp linear tick steps to whole numbers (category axes). */
161
+ integerTicks?: boolean;
146
162
  classes?: string;
147
163
  };
148
164
  export type ChartTooltipProperties = {
149
165
  data: TooltipData | null;
150
166
  mouseX?: number;
151
167
  mouseY?: number;
168
+ /** When set, the tooltip anchors to this point instead of following the cursor. */
169
+ anchor?: TooltipAnchor | null;
170
+ /** Render into document.body with position:fixed, clamped to the viewport. */
171
+ portal?: boolean;
172
+ /** The positioned chart wrapper; required to convert coords in portal mode. */
173
+ originEl?: HTMLElement | null;
174
+ /** Strip the tooltip card chrome (used when `content` supplies its own UI). */
175
+ unstyled?: boolean;
176
+ /** Custom content rendered inside the positioned (and clamped) wrapper. */
177
+ content?: Snippet;
152
178
  customSnippet?: Snippet<[TooltipData]>;
153
179
  classes?: string;
154
180
  };
155
181
  export type LegendProperties = {
156
182
  items: LegendItem[];
157
183
  position?: 'top' | 'bottom';
184
+ /** When provided, items render as toggle buttons and call back with their index. */
185
+ onToggle?: (index: number) => void;
158
186
  customSnippet?: Snippet<[LegendItem[]]>;
159
187
  classes?: string;
160
188
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/svelte-ui-components",
3
- "version": "2.86.0",
3
+ "version": "2.87.0",
4
4
  "description": "A themeable Svelte 5 UI component library with CSS custom property driven styling",
5
5
  "keywords": [
6
6
  "svelte",