// Marks drawn inside a Plot frame: polylines, markers, reference slopes and // direct end-labels. Every mark is thin (2px lines, >=8px markers) and every // marker carries a shape as well as a colour. import type { ReactNode } from 'react' import type { Num } from '../engine/types.ts' import { ink, surface } from './palette.ts' import { linePath, type Frame } from './scales.ts' export type Shape = 'circle' | 'square' | 'triangle' | 'diamond' | 'cross' | 'plus' | 'star' export function Line({ x, y, f, color, dash, width = 2, opacity = 1, }: { x: readonly number[] y: readonly Num[] f: Frame color: string dash?: string width?: number opacity?: number }) { return ( ) } /** One marker, centred on (cx, cy) in pixels, about 9px across. */ export function markerPath(shape: Shape, cx: number, cy: number, r = 4.5): string { switch (shape) { case 'square': return `M${cx - r} ${cy - r}h${2 * r}v${2 * r}h${-2 * r}z` case 'triangle': return `M${cx} ${cy - r * 1.15}L${cx + r} ${cy + r * 0.8}L${cx - r} ${cy + r * 0.8}z` case 'diamond': return `M${cx} ${cy - r * 1.2}L${cx + r * 1.2} ${cy}L${cx} ${cy + r * 1.2}L${cx - r * 1.2} ${cy}z` case 'cross': return `M${cx - r} ${cy - r}L${cx + r} ${cy + r}M${cx + r} ${cy - r}L${cx - r} ${cy + r}` case 'plus': return `M${cx - r * 1.2} ${cy}h${2.4 * r}M${cx} ${cy - r * 1.2}v${2.4 * r}` case 'star': { let d = '' for (let k = 0; k < 3; k++) { const a = (Math.PI * k) / 3 d += `M${cx - r * Math.cos(a)} ${cy - r * Math.sin(a)}L${cx + r * Math.cos(a)} ${cy + r * Math.sin(a)}` } return d } default: return `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0z` } } export function Markers({ x, y, f, color, shape = 'circle', r = 4.5, }: { x: readonly number[] y: readonly Num[] f: Frame color: string shape?: Shape r?: number }) { const stroked = shape === 'cross' || shape === 'plus' || shape === 'star' const out: ReactNode[] = [] for (let i = 0; i < x.length && i < y.length; i++) { const v = y[i] if (v == null || !isFinite(v) || !isFinite(x[i])) continue const px = f.sx(x[i]) const py = f.sy(v) if (py < -20 || py > f.ih + 20) continue out.push( , ) } return {out} } /** * A filled band between two curves: an exact posterior's plus-or-minus two * standard deviations, a spread over repetitions. Drawn under everything * else, at low opacity, so that it reads as context rather than as a series. */ export function Band({ x, lo, hi, f, color, opacity = 0.16, }: { x: readonly number[] lo: readonly Num[] hi: readonly Num[] f: Frame color: string opacity?: number }) { const up: string[] = [] const down: string[] = [] for (let i = 0; i < x.length; i++) { const a = lo[i] const b = hi[i] if (a == null || b == null || !isFinite(a) || !isFinite(b)) continue up.push(`${f.sx(x[i]).toFixed(2)} ${f.sy(b).toFixed(2)}`) down.push(`${f.sx(x[i]).toFixed(2)} ${f.sy(a).toFixed(2)}`) } if (up.length === 0) return null down.reverse() return } /** Vertical stems from a baseline: a weighted rug, a spike train. */ export function Stems({ x, y, f, color, base = 0, width = 1, opacity = 1, }: { x: readonly Num[] y: readonly Num[] f: Frame color: string base?: number width?: number opacity?: number }) { let d = '' const y0 = f.sy(base) for (let i = 0; i < x.length && i < y.length; i++) { const xv = x[i] const yv = y[i] if (xv == null || yv == null || !isFinite(xv) || !isFinite(yv)) continue d += `M${f.sx(xv).toFixed(2)} ${y0.toFixed(2)}L${f.sx(xv).toFixed(2)} ${f.sy(yv).toFixed(2)}` } return } /** A step function through (edges implied by x), for a histogram. */ export function Steps({ x, y, f, color, width = 1.8, dash, opacity = 1, }: { /** bin centres, equally spaced */ x: readonly number[] y: readonly Num[] f: Frame color: string width?: number dash?: string opacity?: number }) { if (x.length < 2) return null const h = (x[1] - x[0]) / 2 let d = '' let pen = false for (let i = 0; i < x.length; i++) { const v = y[i] if (v == null || !isFinite(v)) { pen = false continue } const yy = f.sy(v).toFixed(2) d += `${pen ? 'L' : 'M'}${f.sx(x[i] - h).toFixed(2)} ${yy}L${f.sx(x[i] + h).toFixed(2)} ${yy}` pen = true } return } /** * A dashed reference line of slope p through a chosen point, for log-log * convergence plots: the eye reads "is the data parallel to this?" much better * than it reads a printed rate. */ export function RefSlope({ f, p, through, label, span = 0.6, labelAt = 'start', }: { f: Frame /** the exponent: error ~ n^p */ p: number /** a point the line passes through, in data coordinates */ through: [number, number] label: string /** fraction of the x range the line covers */ span?: number labelAt?: 'start' | 'end' }) { const [lx, hx] = f.xDomain const l = Math.log10(lx) const h = Math.log10(hx) const c = Math.log10(through[0]) const x0 = Math.pow(10, Math.max(l, c - (span * (h - l)) / 2)) const x1 = Math.pow(10, Math.min(h, c + (span * (h - l)) / 2)) const y = (xx: number) => through[1] * Math.pow(xx / through[0], p) return ( {labelAt === 'end' ? ( {label} ) : ( {label} )} ) } /** * A ring around the data point under the cursor, and a box of its numbers. * * Drawn as SVG rather than an HTML overlay, like everything else in this * layer, so it needs no positioning relative to the host element. The box * width is estimated from the character count, which is why the lines are kept * short; it flips to the other side of the point rather than leave the frame. */ export function PointTip({ f, px, py, color, lines, }: { f: Frame px: number py: number color: string /** first line is the series, in its own colour; the rest are the numbers */ lines: string[] }) { const size = 11.5 const pad = 6 const lh = 15 const w = Math.max(...lines.map((l) => l.length)) * 6.15 + 2 * pad const h = lines.length * lh + 2 * pad - 3 const right = px + 14 + w < f.iw const bx = Math.min(Math.max(right ? px + 14 : px - 14 - w, 2), Math.max(2, f.iw - w - 2)) const by = Math.min(Math.max(py - h / 2, 2), Math.max(2, f.ih - h - 2)) return ( {lines.map((l, i) => ( {l} ))} ) } /** A label placed at the end of a curve, so the reader need not use the legend. */ export function EndLabel({ f, x, y, text, color, dx = 6, dy = 4, anchor = 'start', }: { f: Frame x: number y: number text: string color: string dx?: number dy?: number anchor?: 'start' | 'end' | 'middle' }) { return ( {text} ) }