@octane-xplat/charts 0.0.1 → 0.10.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/dist/macos/Chart.macos.tsrx.js +351 -0
  3. package/dist/macos/core/axes.js +146 -0
  4. package/dist/macos/core/hit.js +131 -0
  5. package/dist/macos/core/marks.js +262 -0
  6. package/dist/macos/core/scales.js +99 -0
  7. package/dist/macos/core/scene.js +120 -0
  8. package/dist/macos/core/svg.js +40 -0
  9. package/dist/macos/index.js +2 -0
  10. package/dist/macos/octane-client-build.json +8 -0
  11. package/dist/macos/svg-image.macos.js +14 -0
  12. package/dist/native/Chart.tsrx.js +340 -0
  13. package/dist/native/core/axes.js +146 -0
  14. package/dist/native/core/hit.js +131 -0
  15. package/dist/native/core/marks.js +262 -0
  16. package/dist/native/core/scales.js +99 -0
  17. package/dist/native/core/scene.js +120 -0
  18. package/dist/native/core/svg.js +40 -0
  19. package/dist/native/index.js +2 -0
  20. package/dist/native/octane-client-build.json +8 -0
  21. package/dist/web/Chart.web.tsrx.js +488 -0
  22. package/dist/web/core/axes.js +146 -0
  23. package/dist/web/core/hit.js +131 -0
  24. package/dist/web/core/marks.js +262 -0
  25. package/dist/web/core/scales.js +99 -0
  26. package/dist/web/core/scene.js +120 -0
  27. package/dist/web/core/svg.js +40 -0
  28. package/dist/web/index.js +2 -0
  29. package/dist/web/octane-client-build.json +8 -0
  30. package/package.json +86 -3
  31. package/src/Chart.macos.tsrx +134 -0
  32. package/src/Chart.tsrx +174 -0
  33. package/src/Chart.web.tsrx +215 -0
  34. package/src/core/axes.ts +211 -0
  35. package/src/core/hit.ts +201 -0
  36. package/src/core/marks.ts +380 -0
  37. package/src/core/scales.ts +168 -0
  38. package/src/core/scene.ts +145 -0
  39. package/src/core/svg.ts +84 -0
  40. package/src/index.macos.ts +12 -0
  41. package/src/index.ts +12 -0
  42. package/src/index.web.ts +12 -0
  43. package/src/props.ts +90 -0
  44. package/src/svg-image.macos.ts +34 -0
  45. package/types/index.d.ts +17 -0
  46. package/types/props.d.ts +83 -0
  47. package/README.md +0 -4
package/package.json CHANGED
@@ -1,11 +1,94 @@
1
1
  {
2
2
  "name": "@octane-xplat/charts",
3
- "version": "0.0.1",
4
- "description": "Placeholder stub for @octane-xplat/charts — no supported API yet.",
3
+ "version": "0.10.0",
4
+ "description": "Cross-platform SVG charts for Octane xplat — a DOM-free d3 core with web, iOS/Android, and macOS AppKit leaves",
5
+ "keywords": [
6
+ "android",
7
+ "charts",
8
+ "cross-platform",
9
+ "d3",
10
+ "graph",
11
+ "ios",
12
+ "macos",
13
+ "nativescript",
14
+ "octane",
15
+ "svg",
16
+ "visualization",
17
+ "xplat"
18
+ ],
19
+ "homepage": "https://github.com/octane-xplat/octane-xplat/tree/main/packages/charts",
5
20
  "license": "MIT",
6
21
  "repository": {
7
22
  "type": "git",
8
23
  "url": "https://github.com/octane-xplat/octane-xplat",
9
24
  "directory": "packages/charts"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "types",
29
+ "src"
30
+ ],
31
+ "type": "module",
32
+ "exports": {
33
+ ".": {
34
+ "macos": {
35
+ "types": "./types/index.d.ts",
36
+ "default": "./dist/macos/index.js"
37
+ },
38
+ "types": "./types/index.d.ts",
39
+ "web": "./dist/web/index.js",
40
+ "native": "./dist/native/index.js",
41
+ "default": "./dist/native/index.js"
42
+ }
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "dependencies": {
48
+ "d3-array": "3.2.4",
49
+ "d3-format": "3.1.2",
50
+ "d3-scale": "4.0.2",
51
+ "d3-shape": "3.2.0",
52
+ "d3-time-format": "4.1.0"
53
+ },
54
+ "devDependencies": {
55
+ "@nativescript-community/octane": "0.2.4",
56
+ "@nativescript/core": "9.1.2",
57
+ "@octane-xplat/macos-renderer": "0.10.0",
58
+ "@octane-xplat/ui": "0.10.0",
59
+ "@octanejs/vite-plugin": "0.1.61",
60
+ "@tsrx/typescript-plugin": "0.4.11",
61
+ "@types/d3-array": "3.2.2",
62
+ "@types/d3-format": "3.0.4",
63
+ "@types/d3-scale": "4.0.9",
64
+ "@types/d3-shape": "3.2.0",
65
+ "@types/d3-time-format": "4.0.3",
66
+ "octane": "0.6.3",
67
+ "tsrx-typegen": "0.2.2",
68
+ "typescript": "5.9.3",
69
+ "vite": "8.3.0",
70
+ "vitest": "5.0.1"
71
+ },
72
+ "peerDependencies": {
73
+ "@nativescript-community/octane": ">=0.2.1 <1",
74
+ "@nativescript/core": ">=9.1.0 <10",
75
+ "@octane-xplat/ui": ">=0.6.0 <1",
76
+ "octane": ">=0.6.3 <1"
77
+ },
78
+ "peerDependenciesMeta": {
79
+ "@nativescript-community/octane": {
80
+ "optional": true
81
+ },
82
+ "@nativescript/core": {
83
+ "optional": true
84
+ }
85
+ },
86
+ "scripts": {
87
+ "build": "vite build && vite build --mode native && vite build --mode macos && pnpm typegen && node ../../scripts/check-native-dist.mjs",
88
+ "typegen": "tsc -p tsconfig.types.json",
89
+ "typecheck": "tsrx-tsc --noEmit -p tsconfig.check.json && tsrx-tsc --noEmit -p tsconfig.check.web.json && tsrx-tsc --noEmit -p tsconfig.check.macos.json",
90
+ "test": "vitest run",
91
+ "test:packed": "node tests/packed-consumer.mjs",
92
+ "pack:check": "tsrx-typegen --pack-check"
10
93
  }
11
- }
94
+ }
@@ -0,0 +1,134 @@
1
+ /** @jsxImportSource @octane-xplat/macos-renderer */
2
+ import { useMemo, useRef, useState } from 'octane';
3
+ import { appKitImageSource } from './svg-image.macos';
4
+ import { useMeasure } from '@octane-xplat/ui';
5
+ import type { ChartProps } from './props';
6
+ import { computeScene } from './core/scene';
7
+ import type { ChartScene } from './core/scene';
8
+ import { createScrub, hitLabel } from './core/hit';
9
+ import type { SceneHit } from './core/hit';
10
+
11
+ const EMPTY_SVG = '<svg xmlns="http://www.w3.org/2000/svg"/>';
12
+ const TIP_ESTIMATE_CHAR = 6.2;
13
+
14
+ /** AppKit leaf: one NSImage plus native label and cursor overlays.
15
+ * Click recognizers currently supply no coordinates; taps use the scene center.
16
+ * AppKit does not deliver onTouch, so touch scrubbing is unavailable. */
17
+ export function Chart(props: ChartProps) @{
18
+ const measure = useMeasure();
19
+ const [cursor, setCursor] = useState<SceneHit | null>(null);
20
+ const sceneRef = useRef<ChartScene | null>(null);
21
+ const propsRef = useRef(props);
22
+ propsRef.current = props;
23
+
24
+ const bounds = measure.bounds;
25
+ const w = props.width ?? bounds?.width ?? 0;
26
+ const h =
27
+ props.height ??
28
+ (props.aspectRatio && w > 0
29
+ ? w / props.aspectRatio
30
+ : bounds?.height && bounds.height > 0
31
+ ? bounds.height
32
+ : w > 0
33
+ ? w / 1.6
34
+ : 0);
35
+
36
+ const scene = useMemo(
37
+ () => (w > 0 && h > 0 ? computeScene(propsRef.current, w, h) : null),
38
+ [
39
+ props.type,
40
+ props.data,
41
+ props.margin,
42
+ props.stacked,
43
+ props.innerRadiusRatio,
44
+ props.xAxis,
45
+ props.yAxis,
46
+ w,
47
+ h,
48
+ ],
49
+ );
50
+
51
+ sceneRef.current = scene;
52
+
53
+ const interactive = !!(props.onPress || props.onScrub || props.tooltip || props.crosshair);
54
+ const onTap = useMemo(() => {
55
+ const scrub = createScrub(() => sceneRef.current, () => propsRef.current, setCursor);
56
+ return (e?: any) => {
57
+ const scene = sceneRef.current;
58
+ scrub('tap', e?.getX?.() ?? (scene?.width ?? 0) / 2, e?.getY?.() ?? (scene?.height ?? 0) / 2);
59
+ };
60
+ }, []);
61
+
62
+ const markup = scene?.markup ?? EMPTY_SVG;
63
+ const imageSource = useMemo(() => appKitImageSource(markup), [markup]);
64
+
65
+ const legend = props.legend === true ? 'bottom' : props.legend;
66
+ const plot = scene?.plot;
67
+ const dotColor = cursor && scene ? (scene.pie ? scene.pie.slices.find((s) => s.series === cursor.series && s.index === cursor.index)?.color : scene.series[cursor.series]?.color) : undefined;
68
+ const tipText = cursor && scene ? hitLabel(scene, cursor) : null;
69
+ const tipWidth = tipText ? tipText.length * TIP_ESTIMATE_CHAR + 14 : 0;
70
+ const tipLeft = cursor && scene ? Math.min(Math.max(0, cursor.px - tipWidth / 2), Math.max(0, scene.width - tipWidth)) : 0;
71
+ const tipTop = cursor ? cursor.py - 30 : 0;
72
+
73
+ <flexboxlayout
74
+ id={props.id}
75
+ className={props.className}
76
+ style={{ flexDirection: 'column', ...props.style }}
77
+ accessible={props.accessibilityLabel != null}
78
+ accessibilityRole={'image'}
79
+ accessibilityLabel={props.accessibilityLabel}
80
+ >
81
+ @if (legend === 'top' && scene) {
82
+ <flexboxlayout flexDirection="row" flexWrap="wrap" style={{ paddingBottom: 4 }}>
83
+ {scene.legend.map((item) => (
84
+ <flexboxlayout flexDirection="row" alignItems="center" style={{ marginRight: 12 }}>
85
+ <flexboxlayout style={{ width: 10, height: 10, borderRadius: 2, backgroundColor: item.color, marginRight: 4 }} />
86
+ <label text={item.text} style={{ fontSize: 11 }} />
87
+ </flexboxlayout>
88
+ ))}
89
+ </flexboxlayout>
90
+ }
91
+ <gridlayout
92
+ ref={measure.ref}
93
+ style={{
94
+ width: props.width,
95
+ height: props.height ?? (props.aspectRatio && w > 0 ? w / props.aspectRatio : undefined),
96
+ flexGrow: 1,
97
+ }}
98
+ >
99
+ <image id={props.id ? props.id + '-image' : undefined} src={imageSource} style={{ width: w, height: h }} accessibilityLabel="" />
100
+ <flexboxlayout id={props.id ? props.id + '-tap' : undefined} style={{ width: w, height: h }} onTap={interactive ? onTap : undefined}>
101
+ <absolutelayout
102
+ id={props.id ? props.id + '-overlay' : undefined}
103
+ style={{ width: w, height: h }}
104
+ >
105
+ {scene?.labels.map((l) => (
106
+ <label
107
+ text={l.text}
108
+ {...{ left: l.x, top: l.y }} style={{ width: l.width, fontSize: 10, opacity: 0.7, textAlign: l.align === 'center' ? 'center' : l.align === 'end' ? 'right' : 'left' }}
109
+ />
110
+ ))}
111
+ @if (cursor && props.crosshair && !scene?.pie && plot) {
112
+ <flexboxlayout left={cursor.px - 0.5} top={plot.y} style={{ width: 1, height: plot.height, backgroundColor: '#8a8a8a' }} />
113
+ }
114
+ @if (cursor && (props.crosshair || props.tooltip)) {
115
+ <flexboxlayout left={cursor.px - 4} top={cursor.py - 4} style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dotColor ?? '#4e79a7' }} />
116
+ }
117
+ @if (cursor && props.tooltip && tipText) {
118
+ <label id={props.id ? props.id + '-tooltip' : undefined} text={tipText} {...{ left: tipLeft, top: tipTop < 0 ? cursor.py + 12 : tipTop }} style={{ width: tipWidth, height: 24, backgroundColor: 'rgba(20,20,20,0.88)', borderRadius: 4, color: '#ffffff', fontSize: 11, textAlign: 'center' }} />
119
+ }
120
+ </absolutelayout>
121
+ </flexboxlayout>
122
+ </gridlayout>
123
+ @if (legend === 'bottom' && scene) {
124
+ <flexboxlayout flexDirection="row" flexWrap="wrap" style={{ paddingTop: 4 }}>
125
+ {scene.legend.map((item) => (
126
+ <flexboxlayout flexDirection="row" alignItems="center" style={{ marginRight: 12 }}>
127
+ <flexboxlayout style={{ width: 10, height: 10, borderRadius: 2, backgroundColor: item.color, marginRight: 4 }} />
128
+ <label text={item.text} style={{ fontSize: 11 }} />
129
+ </flexboxlayout>
130
+ ))}
131
+ </flexboxlayout>
132
+ }
133
+ </flexboxlayout>
134
+ }
package/src/Chart.tsrx ADDED
@@ -0,0 +1,174 @@
1
+ /** @jsxImportSource @nativescript-community/octane */
2
+ import { useMemo, useRef, useState } from 'octane';
3
+ import { AccessibilityRole, Color } from '@nativescript/core';
4
+ import { useMeasure } from '@octane-xplat/ui';
5
+ import type { ChartProps } from './props';
6
+ import { computeScene } from './core/scene';
7
+ import type { ChartScene } from './core/scene';
8
+ import { createScrub, hitLabel } from './core/hit';
9
+ import type { SceneHit } from './core/hit';
10
+
11
+ const EMPTY_SVG = '<svg xmlns="http://www.w3.org/2000/svg"/>';
12
+ const TIP_ESTIMATE_CHAR = 6.2;
13
+
14
+ /** Chart — NativeScript leaf. The core's Mark[] serialize to one svg markup
15
+ * string fed to `<svgview>` (vendored ui-svg → SVGKit/androidsvg — its
16
+ * registration arrives transitively through `@octane-xplat/ui`'s native
17
+ * entry). Axis labels, legend, tooltip, and crosshair are real views in an
18
+ * `absolutelayout` overlay — never `<text>` in the markup. Touch input on
19
+ * the overlay reports view-relative dips (`e.getX()/getY()`), matching the
20
+ * markup's coordinate space. */
21
+ export function Chart(props: ChartProps) @{
22
+ const measure = useMeasure();
23
+ const [cursor, setCursor] = useState<SceneHit | null>(null);
24
+ const sceneRef = useRef<ChartScene | null>(null);
25
+ const propsRef = useRef(props);
26
+ propsRef.current = props;
27
+
28
+ const bounds = measure.bounds;
29
+ const w = props.width ?? bounds?.width ?? 0;
30
+ const h =
31
+ props.height ??
32
+ (props.aspectRatio && w > 0
33
+ ? w / props.aspectRatio
34
+ : bounds?.height && bounds.height > 0
35
+ ? bounds.height
36
+ : w > 0
37
+ ? w / 1.6
38
+ : 0);
39
+
40
+ const scene = useMemo(
41
+ () => (w > 0 && h > 0 ? computeScene(propsRef.current, w, h) : null),
42
+ [
43
+ props.type,
44
+ props.data,
45
+ props.margin,
46
+ props.stacked,
47
+ props.innerRadiusRatio,
48
+ props.xAxis,
49
+ props.yAxis,
50
+ w,
51
+ h,
52
+ ],
53
+ );
54
+
55
+ sceneRef.current = scene;
56
+
57
+ const interactive = !!(props.onPress || props.onScrub || props.tooltip || props.crosshair);
58
+ // Stable handler identity — the driver's prop apply subscribes `onTouch`
59
+ // per identity change, so the closure reads scene/props through refs.
60
+ const onTouch = useMemo(() => {
61
+ const scrub = createScrub(
62
+ () => sceneRef.current,
63
+ () => propsRef.current,
64
+ setCursor,
65
+ );
66
+
67
+ return (e: any) => {
68
+ scrub(e.action, e.getX(), e.getY());
69
+ };
70
+ }, []);
71
+
72
+ // `tap` is the completed-press phase — NS's tap recognizer (and the probe
73
+ // harness's tap dispatch) reach onPress without a touch-sequence guess.
74
+ const onTap = useMemo(() => {
75
+ const scrub = createScrub(
76
+ () => sceneRef.current,
77
+ () => propsRef.current,
78
+ setCursor,
79
+ );
80
+
81
+ return (e: any) => {
82
+ // Probe harness taps dispatch with a synthetic event lacking
83
+ // getX/getY — fall back to the view center there.
84
+ const size = e.object?.getActualSize?.();
85
+ scrub('tap', e.getX?.() ?? (size ? size.width / 2 : 0), e.getY?.() ?? (size ? size.height / 2 : 0));
86
+ };
87
+ }, []);
88
+
89
+ const legend = props.legend === true ? 'bottom' : props.legend;
90
+ const plot = scene?.plot;
91
+ const dotColor = cursor && scene ? (scene.pie ? scene.pie.slices.find((s) => s.series === cursor.series && s.index === cursor.index)?.color : scene.series[cursor.series]?.color) : undefined;
92
+ const tipText = cursor && scene ? hitLabel(scene, cursor) : null;
93
+ const tipWidth = tipText ? tipText.length * TIP_ESTIMATE_CHAR + 14 : 0;
94
+ const tipLeft = cursor && scene ? Math.min(Math.max(0, cursor.px - tipWidth / 2), Math.max(0, scene.width - tipWidth)) : 0;
95
+ const tipTop = cursor ? cursor.py - 30 : 0;
96
+
97
+ <flexboxlayout
98
+ id={props.id}
99
+ className={props.className}
100
+ style={{ flexDirection: 'column', ...props.style }}
101
+ accessible={props.accessibilityLabel != null}
102
+ accessibilityRole={AccessibilityRole.Image}
103
+ accessibilityLabel={props.accessibilityLabel}
104
+ >
105
+ @if (legend === 'top' && scene) {
106
+ <flexboxlayout flexDirection="row" flexWrap="wrap" style={{ paddingBottom: 4 }}>
107
+ {scene.legend.map((item) => (
108
+ <flexboxlayout flexDirection="row" alignItems="center" style={{ marginRight: 12 }}>
109
+ <contentview style={{ width: 10, height: 10, borderRadius: 2, backgroundColor: new Color(item.color), marginRight: 4 }} />
110
+ <label text={item.text} style={{ fontSize: 11 }} />
111
+ </flexboxlayout>
112
+ ))}
113
+ </flexboxlayout>
114
+ }
115
+ <gridlayout
116
+ ref={measure.ref}
117
+ style={{
118
+ width: props.width,
119
+ height: props.height ?? (props.aspectRatio && w > 0 ? w / props.aspectRatio : undefined),
120
+ flexGrow: 1,
121
+ }}
122
+ >
123
+ <svgview src={scene?.markup ?? EMPTY_SVG} stretch="aspectFit" style={{ width: w, height: h }} />
124
+ <absolutelayout
125
+ id={props.id ? props.id + '-overlay' : undefined}
126
+ style={{ width: w, height: h }}
127
+ isUserInteractionEnabled={interactive}
128
+ onTouch={interactive ? onTouch : undefined}
129
+ onTap={interactive ? onTap : undefined}
130
+ >
131
+ {scene?.labels.map((l) => (
132
+ <label
133
+ left={l.x}
134
+ top={l.y}
135
+ width={l.width}
136
+ text={l.text}
137
+ textAlignment={l.align === 'center' ? 'center' : l.align === 'end' ? 'right' : 'left'}
138
+ style={{ fontSize: 10, opacity: 0.7 }}
139
+ />
140
+ ))}
141
+ @if (cursor && props.crosshair && !scene?.pie && plot) {
142
+ <contentview left={cursor.px - 0.5} top={plot.y} width={1} height={plot.height} backgroundColor={new Color('#8a8a8a')} />
143
+ }
144
+ @if (cursor && (props.crosshair || props.tooltip)) {
145
+ <contentview left={cursor.px - 4} top={cursor.py - 4} width={8} height={8} borderRadius={4} backgroundColor={new Color(dotColor ?? '#4e79a7')} />
146
+ }
147
+ @if (cursor && props.tooltip && tipText) {
148
+ <flexboxlayout
149
+ left={tipLeft}
150
+ top={tipTop < 0 ? cursor.py + 12 : tipTop}
151
+ backgroundColor={new Color('rgba(20,20,20,0.88)')}
152
+ borderRadius={4}
153
+ paddingTop={2}
154
+ paddingBottom={2}
155
+ paddingLeft={6}
156
+ paddingRight={6}
157
+ >
158
+ <label text={tipText} color={new Color('#ffffff')} style={{ fontSize: 11 }} />
159
+ </flexboxlayout>
160
+ }
161
+ </absolutelayout>
162
+ </gridlayout>
163
+ @if (legend === 'bottom' && scene) {
164
+ <flexboxlayout flexDirection="row" flexWrap="wrap" style={{ paddingTop: 4 }}>
165
+ {scene.legend.map((item) => (
166
+ <flexboxlayout flexDirection="row" alignItems="center" style={{ marginRight: 12 }}>
167
+ <contentview style={{ width: 10, height: 10, borderRadius: 2, backgroundColor: new Color(item.color), marginRight: 4 }} />
168
+ <label text={item.text} style={{ fontSize: 11 }} />
169
+ </flexboxlayout>
170
+ ))}
171
+ </flexboxlayout>
172
+ }
173
+ </flexboxlayout>
174
+ }
@@ -0,0 +1,215 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'octane';
2
+ import { useMeasure } from '@octane-xplat/ui';
3
+ import type { ChartProps } from './props';
4
+ import { computeScene } from './core/scene';
5
+ import type { ChartScene } from './core/scene';
6
+ import { createScrub, hitLabel } from './core/hit';
7
+ import type { SceneHit } from './core/hit';
8
+
9
+ const TIP_ESTIMATE_CHAR = 6.2;
10
+
11
+ /** Chart — web leaf. The core's Mark[] serialize to one svg markup string,
12
+ * inlined as `innerHTML` (Icon.web precedent — the same bytes feed
13
+ * `<svgview>` on native). Axis labels, legend, tooltip, and crosshair are
14
+ * real absolutely-positioned elements in the overlay — never `<text>` in
15
+ * the markup. Pointer handlers attach to the plot host so chart coords are
16
+ * client-minus-rect, matching the markup's coordinate space. */
17
+ export function Chart(props: ChartProps) @{
18
+ const measure = useMeasure();
19
+ const [cursor, setCursor] = useState<SceneHit | null>(null);
20
+ const sceneRef = useRef<ChartScene | null>(null);
21
+ const propsRef = useRef(props);
22
+ propsRef.current = props;
23
+ const hostRef = useRef<HTMLElement | null>(null);
24
+
25
+ const bounds = measure.bounds;
26
+ const w = props.width ?? bounds?.width ?? 0;
27
+ const h =
28
+ props.height ??
29
+ (props.aspectRatio && w > 0
30
+ ? w / props.aspectRatio
31
+ : bounds?.height && bounds.height > 0
32
+ ? bounds.height
33
+ : w > 0
34
+ ? w / 1.6
35
+ : 0);
36
+
37
+ const scene = useMemo(
38
+ () => (w > 0 && h > 0 ? computeScene(propsRef.current, w, h) : null),
39
+ [
40
+ props.type,
41
+ props.data,
42
+ props.margin,
43
+ props.stacked,
44
+ props.innerRadiusRatio,
45
+ props.xAxis,
46
+ props.yAxis,
47
+ w,
48
+ h,
49
+ ],
50
+ );
51
+
52
+ sceneRef.current = scene;
53
+
54
+ const interactive = !!(props.onPress || props.onScrub || props.tooltip || props.crosshair);
55
+ useEffect(() => {
56
+ const el = hostRef.current;
57
+ if (!el || !interactive) {
58
+ return;
59
+ }
60
+
61
+ const scrub = createScrub(
62
+ () => sceneRef.current,
63
+ () => propsRef.current,
64
+ setCursor,
65
+ );
66
+
67
+ const at = (e: PointerEvent) => {
68
+ const rect = el.getBoundingClientRect();
69
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
70
+ };
71
+
72
+ const down = (e: PointerEvent) => {
73
+ el.setPointerCapture?.(e.pointerId);
74
+ const p = at(e);
75
+ scrub('down', p.x, p.y);
76
+ };
77
+
78
+ const move = (e: PointerEvent) => {
79
+ const p = at(e);
80
+ scrub(e.buttons !== 0 ? 'move' : 'hover', p.x, p.y);
81
+ };
82
+
83
+ const up = (e: PointerEvent) => {
84
+ const p = at(e);
85
+ scrub('up', p.x, p.y);
86
+ };
87
+
88
+ // `click` (incl. el.click()/AT activation) is the web 'tap' — onPress
89
+ // lives on tap so a real click after pointerup doesn't double-fire.
90
+ // Synthetic clicks carry no pointer position → tap the center.
91
+ const click = (e: MouseEvent) => {
92
+ const p =
93
+ e.clientX === 0 && e.clientY === 0
94
+ ? { x: el.getBoundingClientRect().width / 2, y: el.getBoundingClientRect().height / 2 }
95
+ : at(e as PointerEvent);
96
+
97
+ scrub('tap', p.x, p.y);
98
+ };
99
+
100
+ const end = () => scrub('cancel', 0, 0);
101
+ const leave = () => scrub('leave', 0, 0);
102
+ el.addEventListener('pointerdown', down);
103
+ el.addEventListener('pointermove', move);
104
+ el.addEventListener('pointerup', up);
105
+ el.addEventListener('pointercancel', end);
106
+ el.addEventListener('pointerleave', leave);
107
+ el.addEventListener('click', click);
108
+ return () => {
109
+ el.removeEventListener('pointerdown', down);
110
+ el.removeEventListener('pointermove', move);
111
+ el.removeEventListener('pointerup', up);
112
+ el.removeEventListener('pointercancel', end);
113
+ el.removeEventListener('pointerleave', leave);
114
+ el.removeEventListener('click', click);
115
+ };
116
+ }, [interactive]);
117
+
118
+ const legend = props.legend === true ? 'bottom' : props.legend;
119
+ const plot = scene?.plot;
120
+ const dotColor =
121
+ cursor && scene
122
+ ? scene.pie
123
+ ? scene.pie.slices.find((s) => s.series === cursor.series && s.index === cursor.index)?.color
124
+ : scene.series[cursor.series]?.color
125
+ : undefined;
126
+
127
+ const tipText = cursor && scene ? hitLabel(scene, cursor) : null;
128
+ const tipWidth = tipText ? tipText.length * TIP_ESTIMATE_CHAR + 14 : 0;
129
+ const tipLeft =
130
+ cursor && scene ? Math.min(Math.max(0, cursor.px - tipWidth / 2), Math.max(0, scene.width - tipWidth)) : 0;
131
+
132
+ const tipTop = cursor ? cursor.py - 30 : 0;
133
+
134
+ const legendRow = (items: { color: string; text: string }[], position: 'top' | 'bottom') => (
135
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, paddingTop: position === 'bottom' ? 4 : 0, paddingBottom: position === 'top' ? 4 : 0 }}>
136
+ {items.map((item) => (
137
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11 }}>
138
+ <span style={{ width: 10, height: 10, borderRadius: 2, backgroundColor: item.color, display: 'inline-block' }} />
139
+ {item.text}
140
+ </span>
141
+ ))}
142
+ </div>
143
+ );
144
+
145
+ <div
146
+ id={props.id}
147
+ className={props.className}
148
+ style={{ display: 'flex', flexDirection: 'column', width: props.width, height: props.height, ...props.style }}
149
+ role={props.accessibilityLabel ? 'img' : undefined}
150
+ aria-label={props.accessibilityLabel}
151
+ >
152
+ {legend === 'top' && scene ? legendRow(scene.legend, 'top') : null}
153
+ <div
154
+ id={props.id ? props.id + '-overlay' : undefined}
155
+ ref={(el: HTMLElement | null) => {
156
+ hostRef.current = el;
157
+ measure.ref(el);
158
+ }}
159
+ style={{
160
+ position: 'relative',
161
+ flexGrow: 1,
162
+ minHeight: 0,
163
+ width: props.width,
164
+ height: props.height ?? (props.aspectRatio && w > 0 ? w / props.aspectRatio : undefined),
165
+ }}
166
+ >
167
+ <div dangerouslySetInnerHTML={scene ? { __html: scene.markup } : undefined} />
168
+ <div style={{ position: 'absolute', inset: 0 }}>
169
+ {scene?.labels.map((l) => (
170
+ <div
171
+ style={{
172
+ position: 'absolute',
173
+ left: l.x,
174
+ top: l.y,
175
+ width: l.width,
176
+ fontSize: 10,
177
+ opacity: 0.7,
178
+ textAlign: l.align,
179
+ overflow: 'hidden',
180
+ whiteSpace: 'nowrap',
181
+ textOverflow: 'ellipsis',
182
+ }}
183
+ >
184
+ {l.text}
185
+ </div>
186
+ ))}
187
+ {cursor && props.crosshair && !scene?.pie && plot ? (
188
+ <div style={{ position: 'absolute', left: cursor.px - 0.5, top: plot.y, width: 1, height: plot.height, backgroundColor: '#8a8a8a', pointerEvents: 'none' }} />
189
+ ) : null}
190
+ {cursor && (props.crosshair || props.tooltip) ? (
191
+ <div style={{ position: 'absolute', left: cursor.px - 4, top: cursor.py - 4, width: 8, height: 8, borderRadius: 4, backgroundColor: dotColor ?? '#4e79a7', pointerEvents: 'none' }} />
192
+ ) : null}
193
+ {cursor && props.tooltip && tipText ? (
194
+ <div
195
+ style={{
196
+ position: 'absolute',
197
+ left: tipLeft,
198
+ top: tipTop < 0 ? cursor.py + 12 : tipTop,
199
+ backgroundColor: 'rgba(20,20,20,0.88)',
200
+ color: '#fff',
201
+ fontSize: 11,
202
+ padding: '2px 6px',
203
+ borderRadius: 4,
204
+ whiteSpace: 'nowrap',
205
+ pointerEvents: 'none',
206
+ }}
207
+ >
208
+ {tipText}
209
+ </div>
210
+ ) : null}
211
+ </div>
212
+ </div>
213
+ {legend === 'bottom' && scene ? legendRow(scene.legend, 'bottom') : null}
214
+ </div>
215
+ }