@octane-xplat/charts 0.0.1 → 0.11.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/LICENSE +21 -0
- package/dist/macos/Chart.macos.tsrx.js +351 -0
- package/dist/macos/core/axes.js +146 -0
- package/dist/macos/core/hit.js +131 -0
- package/dist/macos/core/marks.js +262 -0
- package/dist/macos/core/scales.js +99 -0
- package/dist/macos/core/scene.js +120 -0
- package/dist/macos/core/svg.js +40 -0
- package/dist/macos/index.js +2 -0
- package/dist/macos/octane-client-build.json +8 -0
- package/dist/macos/svg-image.macos.js +14 -0
- package/dist/native/Chart.tsrx.js +340 -0
- package/dist/native/core/axes.js +146 -0
- package/dist/native/core/hit.js +131 -0
- package/dist/native/core/marks.js +262 -0
- package/dist/native/core/scales.js +99 -0
- package/dist/native/core/scene.js +120 -0
- package/dist/native/core/svg.js +40 -0
- package/dist/native/index.js +2 -0
- package/dist/native/octane-client-build.json +8 -0
- package/dist/web/Chart.web.tsrx.js +488 -0
- package/dist/web/core/axes.js +146 -0
- package/dist/web/core/hit.js +131 -0
- package/dist/web/core/marks.js +262 -0
- package/dist/web/core/scales.js +99 -0
- package/dist/web/core/scene.js +120 -0
- package/dist/web/core/svg.js +40 -0
- package/dist/web/index.js +2 -0
- package/dist/web/octane-client-build.json +8 -0
- package/package.json +86 -3
- package/src/Chart.macos.tsrx +134 -0
- package/src/Chart.tsrx +174 -0
- package/src/Chart.web.tsrx +215 -0
- package/src/core/axes.ts +211 -0
- package/src/core/hit.ts +201 -0
- package/src/core/marks.ts +380 -0
- package/src/core/scales.ts +168 -0
- package/src/core/scene.ts +145 -0
- package/src/core/svg.ts +84 -0
- package/src/index.macos.ts +12 -0
- package/src/index.ts +12 -0
- package/src/index.web.ts +12 -0
- package/src/props.ts +90 -0
- package/src/svg-image.macos.ts +34 -0
- package/types/index.d.ts +17 -0
- package/types/props.d.ts +83 -0
- 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
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.11.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.11.0",
|
|
58
|
+
"@octane-xplat/ui": "0.11.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
|
+
}
|