1import type { Num } from '../engine/types.ts'
3export interface Frame {
4 /** data x to pixel x, within the plotting area */
5 sx: (v: number) => number
6 /** data y to pixel y */
7 sy: (v: number) => number
8 /** pixel x back to data x, for hover */
9 ix: (px: number) => number
10 iw: number
11 ih: number
12 xDomain: [number, number]
13 yDomain: [number, number]
14}
16export function niceTicks(lo: number, hi: number, target = 6): number[] {
17 if (!isFinite(lo) || !isFinite(hi) || hi <= lo) return []
18 const raw = (hi - lo) / target
19 const mag = Math.pow(10, Math.floor(Math.log10(raw)))
20 const norm = raw / mag
21 const step = (norm >= 5 ? 10 : norm >= 2 ? 5 : norm >= 1 ? 2 : 1) * mag
22 const out: number[] = []
23 for (let v = Math.ceil(lo / step) * step; v <= hi + step * 1e-9; v += step) {
24 out.push(Math.abs(v) < step * 1e-9 ? 0 : v)
25 }
26 return out
27}
29/** Decade ticks, thinned so the labels do not collide over a wide range. */
30export function decadeTicks(lo: number, hi: number, maxCount = 9): number[] {
31 if (!(lo > 0) || !(hi > 0)) return []
32 const a = Math.floor(Math.log10(lo))
33 const b = Math.ceil(Math.log10(hi))
34 const every = Math.max(1, Math.ceil((b - a + 1) / maxCount))
35 const out: number[] = []
36 for (let e = a; e <= b; e += every) {
37 const v = Math.pow(10, e)
38 if (v >= lo * 0.999 && v <= hi * 1.001) out.push(v)
39 }
40 return out
41}
43export function formatTick(v: number): string {
44 if (v === 0) return '0'
45 const a = Math.abs(v)
46 if (a >= 1e5 || a < 1e-3) {
47 const e = Math.round(Math.log10(a))
48 if (Math.abs(a - Math.pow(10, e)) < Math.pow(10, e) * 1e-6) return `1e${e}`
49 return v.toExponential(0)
50 }
51 if (Number.isInteger(v)) return String(v)
52 return String(Number(v.toPrecision(3)))
53}
55export function padDomain([lo, hi]: [number, number], frac = 0.06): [number, number] {
56 if (!isFinite(lo) || !isFinite(hi)) return [0, 1]
57 if (hi === lo) return [lo - 0.5, hi + 0.5]
58 const p = (hi - lo) * frac
59 return [lo - p, hi + p]
60}
62/** Range of the finite values in one or more series. */
63export function extent(...series: (readonly Num[] | undefined)[]): [number, number] {
64 let lo = Infinity
65 let hi = -Infinity
66 for (const s of series) {
67 if (!s) continue
68 for (const v of s) {
69 if (v == null || !isFinite(v)) continue
70 if (v < lo) lo = v
71 if (v > hi) hi = v
72 }
73 }
74 return isFinite(lo) ? [lo, hi] : [0, 1]
75}
77/**
78 * A polyline through (xs, ys). Runs of missing values break the path rather
79 * than being bridged, and values far outside the frame are clamped to a band
80 * just off-screen so that a curve running off to infinity still leaves the
81 * frame in the right direction instead of producing unusable path data.
82 */
83export function linePath(xs: readonly number[], ys: readonly Num[], f: Frame): string {
84 const bound = f.ih * 12
85 let out = ''
86 let pen = false
87 for (let i = 0; i < xs.length && i < ys.length; i++) {
88 const y = ys[i]
89 if (y == null || !isFinite(y)) {
90 pen = false
91 continue
92 }
93 const px = f.sx(xs[i])
94 const py = Math.min(bound, Math.max(-bound, f.sy(y)))
95 out += `${pen ? 'L' : 'M'}${px.toFixed(2)} ${py.toFixed(2)}`
96 pen = true
97 }
98 return out
99}
101/** Index of the sample nearest a data-space x. */
102export function nearestIndex(xs: readonly number[], v: number): number {
103 if (xs.length === 0) return -1
104 let lo = 0
105 let hi = xs.length - 1
106 if (v <= xs[lo]) return lo
107 if (v >= xs[hi]) return hi
108 while (hi - lo > 1) {
109 const mid = (lo + hi) >> 1
110 if (xs[mid] <= v) lo = mid
111 else hi = mid
112 }
113 return v - xs[lo] <= xs[hi] - v ? lo : hi
114}