@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.
- 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/src/core/axes.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { format } from 'd3-format'
|
|
2
|
+
import { timeFormat } from 'd3-time-format'
|
|
3
|
+
import type { ScaleBand, ScaleLinear, ScalePoint, ScaleTime } from 'd3-scale'
|
|
4
|
+
import type { AxisSpec } from '../props'
|
|
5
|
+
import type { Mark } from './marks'
|
|
6
|
+
import type { PlotBox, XScale, XScaleKind, YScale } from './scales'
|
|
7
|
+
import { xCenter } from './scales'
|
|
8
|
+
|
|
9
|
+
/** A positioned text label — rendered as a real element in the overlay,
|
|
10
|
+
* never as svg `<text>` (androidsvg/SVGKit text is degraded). `x`/`y` is the
|
|
11
|
+
* box origin in chart space; `width` bounds it for alignment. */
|
|
12
|
+
export interface LabelSpec {
|
|
13
|
+
x: number
|
|
14
|
+
y: number
|
|
15
|
+
width: number
|
|
16
|
+
align: 'start' | 'center' | 'end'
|
|
17
|
+
text: string
|
|
18
|
+
axis: 'x' | 'y'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AxisModel {
|
|
22
|
+
labels: LabelSpec[]
|
|
23
|
+
/** Spine and tick stubs — painted over marks. */
|
|
24
|
+
frame: Mark[]
|
|
25
|
+
/** Gridlines — painted under marks. */
|
|
26
|
+
grid: Mark[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const AXIS_COLOR = '#9aa3ad'
|
|
30
|
+
const GRID_COLOR = '#9aa3ad'
|
|
31
|
+
const GRID_OPACITY = 0.22
|
|
32
|
+
|
|
33
|
+
const si = format('~s')
|
|
34
|
+
const plain = format('~g')
|
|
35
|
+
const day = timeFormat('%b %d')
|
|
36
|
+
|
|
37
|
+
/** Default tick label formatting — SI suffixes for big magnitudes, plain
|
|
38
|
+
* significant digits otherwise, short month-day for dates. */
|
|
39
|
+
export function defaultFormat(value: number | string | Date): string {
|
|
40
|
+
if (value instanceof Date) {
|
|
41
|
+
return day(value)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof value === 'number') {
|
|
45
|
+
return Math.abs(value) >= 1000 ? si(value) : plain(value)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return String(value)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Cap categorical label count so boxes don't collide — keep every nth. */
|
|
52
|
+
function thinDomain(domain: string[], plotWidth: number): string[] {
|
|
53
|
+
const maxLabels = Math.max(2, Math.floor(plotWidth / 56))
|
|
54
|
+
if (domain.length <= maxLabels) {
|
|
55
|
+
return domain
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const step = Math.ceil(domain.length / maxLabels)
|
|
59
|
+
return domain.filter((_, i) => i % step === 0)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function xAxisModel(
|
|
63
|
+
kind: XScaleKind,
|
|
64
|
+
scale: XScale,
|
|
65
|
+
spec: AxisSpec | undefined,
|
|
66
|
+
plot: PlotBox,
|
|
67
|
+
): AxisModel {
|
|
68
|
+
const labels = spec?.labels !== false
|
|
69
|
+
const stubs = spec?.ticks !== false
|
|
70
|
+
const grid = spec?.grid === true
|
|
71
|
+
const fmt = spec?.format ?? defaultFormat
|
|
72
|
+
|
|
73
|
+
const frame: Mark[] = [
|
|
74
|
+
{
|
|
75
|
+
kind: 'line',
|
|
76
|
+
x1: plot.x,
|
|
77
|
+
y1: plot.y + plot.height,
|
|
78
|
+
x2: plot.x + plot.width,
|
|
79
|
+
y2: plot.y + plot.height,
|
|
80
|
+
stroke: AXIS_COLOR,
|
|
81
|
+
strokeWidth: 1,
|
|
82
|
+
},
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
const gridMarks: Mark[] = []
|
|
86
|
+
const out: LabelSpec[] = []
|
|
87
|
+
|
|
88
|
+
let values: (string | number | Date)[]
|
|
89
|
+
let format: (v: number | string | Date) => string = fmt
|
|
90
|
+
if (kind === 'band' || kind === 'point') {
|
|
91
|
+
values = thinDomain((scale as ScaleBand<string> | ScalePoint<string>).domain(), plot.width)
|
|
92
|
+
} else {
|
|
93
|
+
const continuous = scale as ScaleLinear<number, number> | ScaleTime<number, number>
|
|
94
|
+
const count = spec?.tickCount ?? 6
|
|
95
|
+
values = continuous.ticks(count) as (number | Date)[]
|
|
96
|
+
if (!spec?.format && kind === 'time') {
|
|
97
|
+
const tf = (continuous as ScaleTime<number, number>).tickFormat(count)
|
|
98
|
+
format = (v) => tf(v as Date)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const v of values) {
|
|
103
|
+
const px = plot.x + xCenter(kind, scale, v)
|
|
104
|
+
if (stubs) {
|
|
105
|
+
frame.push({
|
|
106
|
+
kind: 'line',
|
|
107
|
+
x1: px,
|
|
108
|
+
y1: plot.y + plot.height,
|
|
109
|
+
x2: px,
|
|
110
|
+
y2: plot.y + plot.height + 4,
|
|
111
|
+
stroke: AXIS_COLOR,
|
|
112
|
+
strokeWidth: 1,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (grid) {
|
|
117
|
+
gridMarks.push({
|
|
118
|
+
kind: 'line',
|
|
119
|
+
x1: px,
|
|
120
|
+
y1: plot.y,
|
|
121
|
+
x2: px,
|
|
122
|
+
y2: plot.y + plot.height,
|
|
123
|
+
stroke: GRID_COLOR,
|
|
124
|
+
strokeOpacity: GRID_OPACITY,
|
|
125
|
+
strokeWidth: 1,
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (labels) {
|
|
130
|
+
const width = Math.min(72, plot.width / Math.max(1, values.length))
|
|
131
|
+
const boxX = Math.min(
|
|
132
|
+
Math.max(0, px - width / 2),
|
|
133
|
+
Math.max(0, plot.x + plot.width + 8 - width),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
out.push({
|
|
137
|
+
x: boxX,
|
|
138
|
+
y: plot.y + plot.height + 6,
|
|
139
|
+
width,
|
|
140
|
+
align: 'center',
|
|
141
|
+
text: format(v),
|
|
142
|
+
axis: 'x',
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { labels: out, frame, grid: gridMarks }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function yAxisModel(scale: YScale, spec: AxisSpec | undefined, plot: PlotBox): AxisModel {
|
|
151
|
+
const labels = spec?.labels !== false
|
|
152
|
+
const stubs = spec?.ticks !== false
|
|
153
|
+
const grid = spec?.grid === true
|
|
154
|
+
const fmt = spec?.format ?? defaultFormat
|
|
155
|
+
|
|
156
|
+
const frame: Mark[] = [
|
|
157
|
+
{
|
|
158
|
+
kind: 'line',
|
|
159
|
+
x1: plot.x,
|
|
160
|
+
y1: plot.y,
|
|
161
|
+
x2: plot.x,
|
|
162
|
+
y2: plot.y + plot.height,
|
|
163
|
+
stroke: AXIS_COLOR,
|
|
164
|
+
strokeWidth: 1,
|
|
165
|
+
},
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
const gridMarks: Mark[] = []
|
|
169
|
+
const out: LabelSpec[] = []
|
|
170
|
+
|
|
171
|
+
for (const v of scale.ticks(spec?.tickCount ?? 5)) {
|
|
172
|
+
const py = plot.y + scale(v)
|
|
173
|
+
if (stubs) {
|
|
174
|
+
frame.push({
|
|
175
|
+
kind: 'line',
|
|
176
|
+
x1: plot.x - 4,
|
|
177
|
+
y1: py,
|
|
178
|
+
x2: plot.x,
|
|
179
|
+
y2: py,
|
|
180
|
+
stroke: AXIS_COLOR,
|
|
181
|
+
strokeWidth: 1,
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (grid) {
|
|
186
|
+
gridMarks.push({
|
|
187
|
+
kind: 'line',
|
|
188
|
+
x1: plot.x,
|
|
189
|
+
y1: py,
|
|
190
|
+
x2: plot.x + plot.width,
|
|
191
|
+
y2: py,
|
|
192
|
+
stroke: GRID_COLOR,
|
|
193
|
+
strokeOpacity: GRID_OPACITY,
|
|
194
|
+
strokeWidth: 1,
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (labels) {
|
|
199
|
+
out.push({
|
|
200
|
+
x: 0,
|
|
201
|
+
y: py - 6,
|
|
202
|
+
width: Math.max(0, plot.x - 8),
|
|
203
|
+
align: 'end',
|
|
204
|
+
text: fmt(v),
|
|
205
|
+
axis: 'y',
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { labels: out, frame, grid: gridMarks }
|
|
211
|
+
}
|
package/src/core/hit.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { ChartHit } from '../props'
|
|
2
|
+
import { defaultFormat } from './axes'
|
|
3
|
+
import type { ChartScene } from './scene'
|
|
4
|
+
|
|
5
|
+
export interface SceneHit extends ChartHit {
|
|
6
|
+
/** Datum anchor in chart space — tooltip/crosshair position. */
|
|
7
|
+
px: number
|
|
8
|
+
py: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Touch slop — how far outside the plot a pointer may sit and still hit.
|
|
12
|
+
* Also the nearest-point distance gate for scatter. */
|
|
13
|
+
export const HIT_SLOP = 24
|
|
14
|
+
|
|
15
|
+
/** Map a chart-space pointer position to the nearest datum. Cartesian charts
|
|
16
|
+
* snap to the nearest x group, then pick the closest point inside it —
|
|
17
|
+
* scatter gates on pointer distance instead of always snapping. Pie hits
|
|
18
|
+
* resolve by ring + angle. Coordinates outside the plot (+slop) miss. */
|
|
19
|
+
export function hitTest(scene: ChartScene, x: number, y: number, slop = HIT_SLOP): SceneHit | null {
|
|
20
|
+
if (scene.pie) {
|
|
21
|
+
return pieHit(scene, x, y, slop)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { plot, points } = scene
|
|
25
|
+
if (!points.length) {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (
|
|
30
|
+
x < plot.x - slop ||
|
|
31
|
+
x > plot.x + plot.width + slop ||
|
|
32
|
+
y < plot.y - slop ||
|
|
33
|
+
y > plot.y + plot.height + slop
|
|
34
|
+
) {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const centers = new Map<string, number>()
|
|
39
|
+
for (const p of points) {
|
|
40
|
+
const cur = centers.get(p.key)
|
|
41
|
+
centers.set(p.key, cur === undefined ? p.cx : (cur + p.cx) / 2)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let key: string | null = null
|
|
45
|
+
let keyDist = Infinity
|
|
46
|
+
for (const [k, cx] of centers) {
|
|
47
|
+
const d = Math.abs(cx - x)
|
|
48
|
+
if (d < keyDist) {
|
|
49
|
+
keyDist = d
|
|
50
|
+
key = k
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (key === null) {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let best: SceneHit | null = null
|
|
59
|
+
let bestDist = Infinity
|
|
60
|
+
for (const p of points) {
|
|
61
|
+
if (p.key !== key) {
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const d = Math.hypot(p.cx - x, p.cy - y)
|
|
66
|
+
if (d < bestDist) {
|
|
67
|
+
bestDist = d
|
|
68
|
+
best = { series: p.series, index: p.index, x: p.x, y: p.y, px: p.cx, py: p.cy }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Scatter is a point cloud, not a snapped axis — miss when nothing is
|
|
73
|
+
// actually near the pointer.
|
|
74
|
+
if (best && scene.type === 'scatter' && bestDist > slop) {
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return best
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function pieHit(scene: ChartScene, x: number, y: number, slop: number): SceneHit | null {
|
|
82
|
+
const pie = scene.pie!
|
|
83
|
+
const dx = x - pie.cx
|
|
84
|
+
const dy = y - pie.cy
|
|
85
|
+
const r = Math.hypot(dx, dy)
|
|
86
|
+
if (r < pie.rInner - slop || r > pie.rOuter + slop) {
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// d3 pie angles run clockwise from 12 o'clock in y-down space:
|
|
91
|
+
// θ = atan2(dx, −dy) normalized into [0, 2π).
|
|
92
|
+
const angle = (Math.atan2(dx, -dy) + Math.PI * 2) % (Math.PI * 2)
|
|
93
|
+
const slice = pie.slices.find((s) => angle >= s.startAngle && angle < s.endAngle)
|
|
94
|
+
if (!slice) {
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
series: slice.series,
|
|
100
|
+
index: slice.index,
|
|
101
|
+
x: slice.label,
|
|
102
|
+
y: slice.value,
|
|
103
|
+
px: slice.cx,
|
|
104
|
+
py: slice.cy,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type ScrubPhase = 'down' | 'move' | 'up' | 'cancel' | 'hover' | 'leave' | 'tap'
|
|
109
|
+
|
|
110
|
+
export interface ScrubProps {
|
|
111
|
+
tooltip?: boolean
|
|
112
|
+
crosshair?: boolean
|
|
113
|
+
onPress?: (hit: ChartHit) => void
|
|
114
|
+
onScrub?: (hit: ChartHit | null) => void
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Shared gesture → cursor state machine. Both leaves normalize their pointer
|
|
118
|
+
* source (DOM pointer/click events, NS `touch`+`tap`) to phases and call
|
|
119
|
+
* this; hit math stays identical across targets. `move` while pressed
|
|
120
|
+
* scrubs and fires `onScrub`; `hover` (web-only, unpressed move) moves the
|
|
121
|
+
* cursor without scrubbing; `up` ends the scrub and persists the cursor;
|
|
122
|
+
* `tap` is a completed press — fires `onPress` and updates the cursor;
|
|
123
|
+
* `cancel`/`leave` clear. `onPress` lives only on `tap` so NS's tap
|
|
124
|
+
* recognizer and the web click both reach it exactly once. */
|
|
125
|
+
export function createScrub(
|
|
126
|
+
getScene: () => ChartScene | null,
|
|
127
|
+
getProps: () => ScrubProps,
|
|
128
|
+
setCursor: (hit: SceneHit | null) => void,
|
|
129
|
+
): (phase: ScrubPhase, x: number, y: number) => void {
|
|
130
|
+
let downAt: { x: number; y: number; t: number } | null = null
|
|
131
|
+
return (phase, x, y) => {
|
|
132
|
+
const scene = getScene()
|
|
133
|
+
const props = getProps()
|
|
134
|
+
const showCursor = props.tooltip === true || props.crosshair === true
|
|
135
|
+
switch (phase) {
|
|
136
|
+
case 'down': {
|
|
137
|
+
downAt = { x, y, t: Date.now() }
|
|
138
|
+
const hit = scene && showCursor ? hitTest(scene, x, y) : null
|
|
139
|
+
setCursor(hit)
|
|
140
|
+
if (hit) {
|
|
141
|
+
props.onScrub?.(hit)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
break
|
|
145
|
+
}
|
|
146
|
+
case 'move': {
|
|
147
|
+
if (!downAt) {
|
|
148
|
+
break
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const hit = scene && showCursor ? hitTest(scene, x, y) : null
|
|
152
|
+
setCursor(hit)
|
|
153
|
+
props.onScrub?.(hit)
|
|
154
|
+
break
|
|
155
|
+
}
|
|
156
|
+
case 'hover': {
|
|
157
|
+
if (downAt) {
|
|
158
|
+
break
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
setCursor(scene && showCursor ? hitTest(scene, x, y) : null)
|
|
162
|
+
break
|
|
163
|
+
}
|
|
164
|
+
case 'up': {
|
|
165
|
+
downAt = null
|
|
166
|
+
setCursor(scene ? hitTest(scene, x, y) : null)
|
|
167
|
+
props.onScrub?.(null)
|
|
168
|
+
break
|
|
169
|
+
}
|
|
170
|
+
case 'tap': {
|
|
171
|
+
downAt = null
|
|
172
|
+
const hit = scene ? hitTest(scene, x, y) : null
|
|
173
|
+
setCursor(hit)
|
|
174
|
+
if (hit) {
|
|
175
|
+
props.onPress?.(hit)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
break
|
|
179
|
+
}
|
|
180
|
+
case 'cancel':
|
|
181
|
+
case 'leave': {
|
|
182
|
+
downAt = null
|
|
183
|
+
setCursor(null)
|
|
184
|
+
props.onScrub?.(null)
|
|
185
|
+
break
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Tooltip body for a hit — series name + formatted y for cartesian charts,
|
|
192
|
+
* slice label + value for pie. */
|
|
193
|
+
export function hitLabel(scene: ChartScene, hit: ChartHit): string {
|
|
194
|
+
if (scene.pie) {
|
|
195
|
+
const slice = scene.pie.slices.find((s) => s.series === hit.series && s.index === hit.index)
|
|
196
|
+
return slice ? `${slice.label}: ${defaultFormat(slice.value)}` : defaultFormat(hit.y)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const name = scene.series[hit.series]?.name ?? String(hit.series)
|
|
200
|
+
return `${name}: ${defaultFormat(hit.y)}`
|
|
201
|
+
}
|