@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
@@ -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
+ }
@@ -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
+ }