@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.
- package/dist/AreaChart/AreaChart.svelte +229 -131
- package/dist/AreaChart/properties.d.ts +6 -0
- package/dist/BarChart/BarChart.svelte +421 -288
- package/dist/BarChart/properties.d.ts +6 -0
- package/dist/DualAxisBarChart/DualAxisBarChart.svelte +339 -222
- package/dist/DualAxisBarChart/properties.d.ts +4 -0
- package/dist/FunnelChart/FunnelChart.svelte +182 -83
- package/dist/FunnelChart/properties.d.ts +2 -0
- package/dist/LineChart/LineChart.svelte +396 -210
- package/dist/LineChart/properties.d.ts +9 -0
- package/dist/_chart/Axis.svelte +32 -15
- package/dist/_chart/ChartTooltip.svelte +127 -49
- package/dist/_chart/Legend.svelte +36 -6
- package/dist/_chart/geometry.d.ts +28 -18
- package/dist/_chart/geometry.js +47 -42
- package/dist/_chart/interactions.d.ts +20 -0
- package/dist/_chart/interactions.js +34 -0
- package/dist/_chart/labels.d.ts +76 -0
- package/dist/_chart/labels.js +213 -0
- package/dist/_chart/measure.d.ts +12 -0
- package/dist/_chart/measure.js +46 -0
- package/dist/_chart/scales.d.ts +1 -1
- package/dist/_chart/scales.js +7 -2
- package/dist/_chart/tooltipPosition.d.ts +22 -0
- package/dist/_chart/tooltipPosition.js +49 -0
- package/dist/_chart/types.d.ts +29 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/_chart/scales.d.ts
CHANGED
|
@@ -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;
|
package/dist/_chart/scales.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/_chart/types.d.ts
CHANGED
|
@@ -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
|
};
|