@juspay/svelte-ui-components 2.86.0 → 2.88.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.
@@ -1,3 +1,5 @@
1
+ import { measureText } from './measure';
2
+ import { thinTicks } from './labels';
1
3
  export function computeChartDimensions(width, height, margin = {}) {
2
4
  const m = {
3
5
  top: margin.top ?? 20,
@@ -13,50 +15,53 @@ export function computeChartDimensions(width, height, margin = {}) {
13
15
  innerHeight: Math.max(0, height - m.top - m.bottom)
14
16
  };
15
17
  }
16
- // ── Text measurement ────────────────────────────────────────────
17
- let textMeasurementContext = null;
18
+ // Offset from the axis line to the tick-label text (Axis.svelte TICK_SIZE + 4).
19
+ const TICK_PAD = 10;
20
+ // Vertical space reserved for a rotated/horizontal axis title.
21
+ const TITLE_BAND = 18;
22
+ // Cap on how deep rotated x labels may grow the bottom margin (long labels crop).
23
+ const MAX_ROTATED_DEPTH = 72;
18
24
  /**
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.
25
+ * Measured, Highcharts-style margins: gutters grow to fit formatted tick labels
26
+ * (instead of clipping) and the bottom axis rotates/thins its labels when the
27
+ * per-category step is too narrow. Order matters to avoid feedback loops:
28
+ * left/right derive from label text only, then innerWidth decides x rotation,
29
+ * then bottom derives from the rotation outcome.
23
30
  */
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));
31
+ export function computeAutoLayout(input) {
32
+ const font = input.font ?? { size: 11 };
33
+ const labelHeight = font.size * 1.2;
34
+ const widthOf = (labels) => labels.reduce((max, t) => Math.max(max, measureText(t, font).width), 0);
35
+ const left = Math.max(input.base?.left ?? 0, input.yTickLabels.length > 0
36
+ ? Math.ceil(widthOf(input.yTickLabels)) +
37
+ TICK_PAD +
38
+ 6 +
39
+ (input.hasYAxisLabel ? TITLE_BAND : 0)
40
+ : 0);
41
+ const xWidths = input.xTickLabels.map((t) => measureText(t, font).width);
42
+ const maxXWidth = xWidths.reduce((m, w) => Math.max(m, w), 0);
43
+ const y2Width = typeof input.y2TickLabels !== 'undefined' && input.y2TickLabels.length > 0
44
+ ? Math.ceil(widthOf(input.y2TickLabels)) +
45
+ TICK_PAD +
46
+ 6 +
47
+ (input.hasY2AxisLabel ? TITLE_BAND : 0)
48
+ : 0;
49
+ // Right gutter: the right axis when present, else half the last x label so
50
+ // edge labels don't clip.
51
+ const right = Math.max(input.base?.right ?? 0, y2Width, Math.ceil(maxXWidth / 2) + 8);
52
+ const innerWidth = Math.max(0, input.width - left - right);
53
+ const step = input.xTickLabels.length > 0 ? innerWidth / input.xTickLabels.length : innerWidth;
54
+ const { rotate, every } = input.xTickLabels.length > 0
55
+ ? thinTicks({ labelWidths: xWidths, labelHeight, step })
56
+ : { rotate: false, every: 1 };
57
+ const rotatedDepth = rotate
58
+ ? Math.min(MAX_ROTATED_DEPTH, Math.ceil(maxXWidth * Math.SQRT1_2))
59
+ : 0;
60
+ const xLabelDepth = input.xTickLabels.length > 0 ? TICK_PAD + (rotate ? rotatedDepth : Math.ceil(labelHeight)) : 0;
61
+ const bottom = Math.max(input.base?.bottom ?? 0, xLabelDepth + 6 + (input.hasXAxisLabel ? TITLE_BAND : 0));
62
+ const top = Math.max(input.base?.top ?? 0, Math.ceil(labelHeight / 2) + 8);
63
+ const dims = computeChartDimensions(input.width, input.height, { top, right, bottom, left });
64
+ return { ...dims, xRotate: rotate, xEvery: every, xLabelOffset: xLabelDepth + TITLE_BAND };
60
65
  }
61
66
  // ── Pie layout ──────────────────────────────────────────────────
62
67
  export function computePieLayout(data, startAngle = -Math.PI / 2, padAngle = 0) {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Shared pointer-interaction helpers used by the chart components.
3
+ */
4
+ export type RelativePointerPosition = {
5
+ x: number;
6
+ y: number;
7
+ };
8
+ /**
9
+ * Pointer position relative to the top-left corner of `el`, or null when the
10
+ * element is not mounted yet.
11
+ */
12
+ export declare function pointerPositionIn(el: HTMLElement | null, event: PointerEvent): RelativePointerPosition | null;
13
+ /**
14
+ * Touch taps never fire pointerleave, so a tap-opened tooltip would otherwise
15
+ * stay stuck: dismiss when a pointerdown lands outside `containerEl`.
16
+ *
17
+ * Attaches a window listener and returns its cleanup, making it directly
18
+ * usable as an `$effect` body's return value. No-op during SSR.
19
+ */
20
+ export declare function dismissOnOutsidePointerDown(containerEl: HTMLElement | null, onDismiss: () => void): () => void;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Shared pointer-interaction helpers used by the chart components.
3
+ */
4
+ /**
5
+ * Pointer position relative to the top-left corner of `el`, or null when the
6
+ * element is not mounted yet.
7
+ */
8
+ export function pointerPositionIn(el, event) {
9
+ if (el === null) {
10
+ return null;
11
+ }
12
+ const rect = el.getBoundingClientRect();
13
+ return { x: event.clientX - rect.left, y: event.clientY - rect.top };
14
+ }
15
+ /**
16
+ * Touch taps never fire pointerleave, so a tap-opened tooltip would otherwise
17
+ * stay stuck: dismiss when a pointerdown lands outside `containerEl`.
18
+ *
19
+ * Attaches a window listener and returns its cleanup, making it directly
20
+ * usable as an `$effect` body's return value. No-op during SSR.
21
+ */
22
+ export function dismissOnOutsidePointerDown(containerEl, onDismiss) {
23
+ if (typeof window === 'undefined') {
24
+ return () => { };
25
+ }
26
+ const dismiss = (event) => {
27
+ const target = event.target;
28
+ if (containerEl !== null && !(target instanceof Node && containerEl.contains(target))) {
29
+ onDismiss();
30
+ }
31
+ };
32
+ window.addEventListener('pointerdown', dismiss);
33
+ return () => window.removeEventListener('pointerdown', dismiss);
34
+ }
@@ -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
+ }