import { BADGE_METRICS_DEFAULTS } from "../constants"; import type { BadgeMetrics, ChartInsets, LiveChartPoint } from "../types"; export { BADGE_DOT_GAP, BADGE_MARGIN_RIGHT, BADGE_PILL_PAD_X, BADGE_PILL_PAD_Y, BADGE_TAIL_LEN, } from "../constants"; export interface ChartPadding { top: number; right: number; bottom: number; left: number; } export const DEFAULT_PADDING: ChartPadding = { top: 12, right: 12, bottom: 28, left: 12, }; /** * Combined tail + rounded-cap offset. * The pill body starts `tl` px to the right of the gutter left edge (= dot x), * so the tail spans the gap between the dot and the pill body. * * When `showTail` is false there is no tail geometry at all, so the pill body * starts flush at the gutter left edge (+ dotGap) and no cap inset is reserved. */ export function badgeTailAndCap( fontSize: number, showTail = true, badge: BadgeMetrics = BADGE_METRICS_DEFAULTS, ): number { "worklet"; if (!showTail) return 0; const pillH = fontSize + badge.padY * 2; return badge.tailLength + pillH / 2; } /** * Text left-edge X so the label is horizontally centered in the badge pill. * Uses the ASYMMETRIC layout: tail gap (`tl`) on the left, `BADGE_MARGIN_RIGHT` on * the right. Both `useBadge` and `GridOverlay` (when badge is shown) call this so * the badge text and y-axis labels share the exact same horizontal position. * * Layout: |dot| tl |PAD_X| text |PAD_X| BADGE_MARGIN_RIGHT |canvas edge * ↑ same x for grid labels and badge text */ export function pillTextLeftX( canvasWidth: number, paddingRight: number, tl: number, textWidth: number, badge: BadgeMetrics = BADGE_METRICS_DEFAULTS, ): number { "worklet"; const bodyLeft = canvasWidth - paddingRight + tl; const bodyRight = canvasWidth - badge.marginEdge; return (bodyLeft + bodyRight - textWidth) / 2; } /** * Symmetric gutter centering — used for y-axis labels when badge is NOT shown. * When badge IS shown, use `pillTextLeftX` instead so labels align with badge text. */ export function gutterCenteredTextLeftX( canvasWidth: number, paddingRight: number, textWidth: number, ): number { "worklet"; return canvasWidth - paddingRight / 2 - textWidth / 2; } /** * Right-aligned text positioning — used for y-axis labels when inline series * labels occupy the left portion of the right gutter. */ export function gutterRightAlignedTextLeftX( canvasWidth: number, textWidth: number, rightMargin = 4, ): number { "worklet"; return canvasWidth - textWidth - rightMargin; } /** * Minimum `padding.right` for the badge gutter. * * Layout: |dot| BADGE_DOT_GAP | tl | PAD_X | text | PAD_X | BADGE_MARGIN_RIGHT |canvas edge */ export function minPaddingRightForBadgeYAxisAlign( fontSize: number, textWidth: number, showTail = true, badge: BadgeMetrics = BADGE_METRICS_DEFAULTS, ): number { const tl = badgeTailAndCap(fontSize, showTail, badge); return Math.ceil( badge.dotGap + tl + 2 * badge.padX + textWidth + badge.marginEdge, ); } /** Auto-right-padding: badge needs space for the pill, y-axis labels need less. */ export function resolveAutoRight( yAxis: boolean, badge: boolean, showTail = true, badgeMetrics: BadgeMetrics = BADGE_METRICS_DEFAULTS, ): number { if (badge) return minPaddingRightForBadgeYAxisAlign(12, 49, showTail, badgeMetrics); if (yAxis) return 44; return DEFAULT_PADDING.right; } /** * Minimum `padding.left` for a badge pill drawn in the left chart margin (label width + horizontal padding + dot gap). * `resolveChartLayout` does not call this; it remains available for custom layouts or `resolvePadding(..., badgeOnLeft: true)`. */ export function minPaddingLeftForBadge( textWidth: number, badge: BadgeMetrics = BADGE_METRICS_DEFAULTS, ): number { return Math.ceil( badge.marginEdge + 2 * badge.padX + textWidth + badge.dotGap, ); } /** Default left inset, or a wider inset when `badgeOnLeft` is true (see `minPaddingLeftForBadge`). */ export function resolveAutoLeft( badgeOnLeft: boolean, badgeMetrics: BadgeMetrics = BADGE_METRICS_DEFAULTS, ): number { if (badgeOnLeft) return minPaddingLeftForBadge(49, badgeMetrics); return DEFAULT_PADDING.left; } /** * Minimum inset from the canvas edge so the live-dot pulse ring is not clipped. * Matches `DotOverlay`: circle radius up to `maxRadius` with a centered stroke. * * Used by `resolveChartLayout` when `pulse` is set; keep in sync with `DotOverlay` pulse rendering. * * @see `resolveChartLayout` in `hooks/resolveChartLayout.ts` * @see `DotOverlay` in `components/DotOverlay.tsx` */ export function pulseRadialOutset( maxRadius: number, strokeWidth: number, ): number { return Math.ceil(maxRadius + strokeWidth / 2); } /** * Conservative canvas footprint for a blurred live-dot glow. Two blur radii * retain the visible low-opacity falloff without over-inflating chart gutters. * Keep this in sync with the `Circle` + `BlurMask` rendering in the dot overlays. */ export function dotGlowRadialOutset(radius: number, blur: number): number { return Math.ceil(Math.max(0, radius) + Math.max(0, blur) * 2); } /** Extra space beyond label width so the pulse ring does not touch glyphs. */ const PULSE_Y_AXIS_LABEL_GAP = 8; /** * Minimum `padding.right` when the live dot (and pulse) sit on the inner edge of the * right gutter and y-axis labels are centered in that gutter (`gutterCenteredTextLeftX`). * Otherwise the pulse can overlap labels to the right of the dot. */ export function minPaddingRightForYAxisWithPulse( pulseOutlet: number, yAxisLabelTextWidth: number, gap = PULSE_Y_AXIS_LABEL_GAP, ): number { return Math.ceil(2 * pulseOutlet + yAxisLabelTextWidth + gap); } export function resolvePadding( override?: ChartInsets, yAxis = false, badge = false, badgeOnLeft = false, xAxis = true, showTail = true, badgeMetrics: BadgeMetrics = BADGE_METRICS_DEFAULTS, ): ChartPadding { const autoRight = resolveAutoRight( yAxis, badge && !badgeOnLeft, showTail, badgeMetrics, ); const autoLeft = resolveAutoLeft(badgeOnLeft, badgeMetrics); const autoBottom = xAxis ? DEFAULT_PADDING.bottom : 8; if (!override) { return { ...DEFAULT_PADDING, right: autoRight, left: autoLeft, bottom: autoBottom, }; } return { top: override.top ?? DEFAULT_PADDING.top, right: override.right ?? autoRight, bottom: override.bottom ?? autoBottom, left: override.left ?? autoLeft, }; } /** * Build screen-space points as a flat number array [x0, y0, x1, y1, ...]. * Includes one point before the window for smooth left-edge entry, * and appends a live tip at (now, displayValue). * * Flat layout avoids ~150 tuple object allocations per frame. * * Pass `out` to reuse a persistent array instead of allocating a fresh one each * frame (it is cleared and refilled). The per-frame allocation otherwise scales * with the visible point count, so pooling it cuts UI-thread GC pressure on * busy / wide-window charts. Callers that pool `out` must ping-pong two buffers * so the returned reference still changes each frame (Reanimated's value-equality * check skips notifying subscribers when the reference is unchanged). */ export function buildLinePoints( data: LiveChartPoint[], displayValue: number, now: number, windowSecs: number, displayMin: number, displayMax: number, canvasWidth: number, canvasHeight: number, padding: ChartPadding, out?: number[], omitTipBeyondData = false, ): number[] { "worklet"; const pts: number[] = out ?? []; if (out) out.length = 0; const chartW = canvasWidth - padding.left - padding.right; const chartH = canvasHeight - padding.top - padding.bottom; const valRange = displayMax - displayMin; if (valRange === 0 || chartW <= 0 || chartH <= 0 || data.length === 0) return pts; const winStart = now - windowSecs; // Binary search for first point >= winStart let lo = 0; let hi = data.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (data[mid].time < winStart) lo = mid + 1; else hi = mid; } const startIdx = Math.max(0, lo - 1); // End of the visible range: first index strictly after `now` (upper bound). let elo = startIdx; let ehi = data.length; while (elo < ehi) { const emid = (elo + ehi) >> 1; if (data[emid].time <= now) elo = emid + 1; else ehi = emid; } const endIdx = elo; const xScale = chartW / windowSecs; const yScale = chartH / valRange; // Decimate to ~2 points per horizontal pixel once the window is denser than // that. Drawing more points than the canvas is wide is wasted per-frame work // (array build + Skia stroke) that scales with sample count rather than // pixels — the dominant cost on dense / wide-window charts, and the reason // scrubbing a saturated window drops frames. Below the threshold we keep the // exact per-sample path (and existing behaviour) untouched. const maxPlainPoints = Math.ceil(chartW * 2); if (endIdx - startIdx <= maxPlainPoints) { for (let i = startIdx; i < endIdx; i++) { pts.push( padding.left + (data[i].time - winStart) * xScale, padding.top + (displayMax - data[i].value) * yScale, ); } } else { // Min/max-per-pixel-width decimation: anchor buckets to absolute time, not // the moving viewport origin. Existing samples then keep the same bucket as // `now` advances, so interior history translates without being reshaped; // only the buckets entering or leaving the viewport can change. Within each // bucket keep the lowest- and highest-value samples (emitted in their // original time order) so the line's envelope and volatility spikes survive // at full vertical fidelity while the point count stays bounded by width. let curCol = -2147483648; let minIdx = startIdx; let maxIdx = startIdx; for (let i = startIdx; i < endIdx; i++) { const col = Math.floor(data[i].time * xScale); if (col !== curCol) { if (curCol !== -2147483648) { const a = minIdx <= maxIdx ? minIdx : maxIdx; const b = minIdx <= maxIdx ? maxIdx : minIdx; pts.push( padding.left + (data[a].time - winStart) * xScale, padding.top + (displayMax - data[a].value) * yScale, ); if (b !== a) { pts.push( padding.left + (data[b].time - winStart) * xScale, padding.top + (displayMax - data[b].value) * yScale, ); } } curCol = col; minIdx = i; maxIdx = i; } else { if (data[i].value < data[minIdx].value) minIdx = i; if (data[i].value > data[maxIdx].value) maxIdx = i; } } // Flush the final column. const a = minIdx <= maxIdx ? minIdx : maxIdx; const b = minIdx <= maxIdx ? maxIdx : minIdx; pts.push( padding.left + (data[a].time - winStart) * xScale, padding.top + (displayMax - data[a].value) * yScale, ); if (b !== a) { pts.push( padding.left + (data[b].time - winStart) * xScale, padding.top + (displayMax - data[b].value) * yScale, ); } } // A scrolled-back / overscrolled window can end after the last data point; // the synthetic tip would then fabricate a flat segment from that point to // the right edge. Callers pass `omitTipBeyondData` to end the line at the // last real point instead. if ( omitTipBeyondData && endIdx === data.length && data[endIdx - 1].time < now ) { return pts; } // Live tip at current time with smoothed value pts.push( padding.left + chartW, padding.top + ((displayMax - displayValue) / valRange) * chartH, ); return pts; }