import { useDerivedValue, type SharedValue } from "react-native-reanimated"; import { useRef } from "react"; import type { SkFont } from "@shopify/react-native-skia"; import type { ChartEngineLayout } from "../core/useLiveChartEngine"; import { rightAnchoredYAxisColumnLayout, type YAxisEntry } from "../draw/grid"; import type { ChartPadding } from "../draw/line"; import { measureFontTextWidth, type TextWidthCache, } from "../lib/measureFontTextWidth"; import { classifyReferenceEdge, referenceLineForm, resolveReferenceBadge, } from "../math/referenceLines"; import { axisBadgeBounds, type AxisBadgeConfig } from "../math/axisBadgeLayout"; import type { ReferenceLine } from "../types"; /** Screen-space geometry for one reference line / band, recomputed each frame. */ export interface ReferenceLineLayout { valueBadge: ValueBadgeLayout | null; visible: boolean; /** Line y, or band top-edge y. */ y: number; /** Band bottom-edge y (value/time band); equals `y` for a plain line. */ yBottom: number; /** Left x extent of the plot (badge / label / connector anchor). */ x1: number; /** Right x extent of the plot (badge / label / connector anchor). */ x2: number; /** * Left x of the **drawn** line / band. Equals {@link x1} normally, or `0` * (canvas edge) when the line is full-width. Decoupled from {@link x1} so the * line can run through the gutter while the badge/label stays in the plot. */ lineX1: number; /** Right x of the drawn line / band: {@link x2} normally, or the canvas width when full-width. */ lineX2: number; /** Stroke the plain horizontal line (true for a plain Form-A line, or a full-width badged line). */ drawLine: boolean; /** Resolved label text (with appended value when `showValue`); "" when hidden. */ label: string; labelX: number; labelY: number; /** True when a Form-A value is off-screen (badge pinned to the edge + chevron). */ offAxis: boolean; /** Off-axis chevron points up (target above range) vs down (below). */ chevronUp: boolean; /** True when the value renders as a pill badge (icon / text / chevron tag). */ badge: boolean; /** Badge pill rect left x. */ pillX: number; /** Badge pill rect width. */ pillW: number; /** Badge icon glyph x (-1 when no icon). */ iconX: number; /** Badge icon glyph (""/none). */ icon: string; /** Off-axis chevron center x (-1 when no chevron). */ chevronCx: number; /** Connector dashed-line start x (-1 when no connector). */ connStart: number; /** Connector dashed-line end x. */ connEnd: number; } export interface ValueBadgeLayout extends ReferenceBadgeRect { text: string; textX: number; textY: number; chevronCx: number; } const INVISIBLE: ReferenceLineLayout = { valueBadge: null, visible: false, y: -1, yBottom: -1, x1: 0, x2: 0, lineX1: 0, lineX2: 0, drawLine: false, label: "", labelX: 0, labelY: -1, offAxis: false, chevronUp: false, badge: false, pillX: 0, pillW: 0, iconX: -1, icon: "", chevronCx: -1, connStart: -1, connEnd: -1, }; /** Off-axis indicator inset from the nearest plot edge, in px. */ const OFF_AXIS_EDGE_INSET = 12; // Badge pill geometry (kept in sync with the overlay's pill height). const BADGE_PAD_X = 6; const BADGE_GAP = 4; const BADGE_CHEV_W = 8; /** Pill inset from the pinned plot edge, in px. */ const BADGE_EDGE_INSET = 2; /** Vertical padding inside the pill (matches ReferenceLineOverlay's BADGE_PILL_PAD_Y). */ const BADGE_PILL_PAD_Y = 3; interface BadgeGeometry { pillX: number; pillW: number; iconX: number; textX: number; chevronCx: number; connStart: number; connEnd: number; } /** * Lays out the badge pill at one plot edge: `[chevron?][icon?][text?]` left→right * inside the pill, with a dashed connector running to the opposite edge. */ function badgeGeometry( position: "left" | "center" | "right", icon: string, text: string, hasChevron: boolean, x1: number, x2: number, font: SkFont, textWidthCache?: TextWidthCache, ): BadgeGeometry { "worklet"; // Measure the icon's visual bounds (not just width): `SkiaText` draws from the // pen origin, so the glyph's left side-bearing (`bounds.x`) must be subtracted // to land the ink in its slot. Without this an asymmetric glyph (a ▼ triangle, // a "+"/"−") sits off-center in an icon-only pill. Mirrors the action-badge // price-pill centering in computeActionBadgeLayout. const iconBounds = icon ? font.measureText(icon) : null; const iconW = iconBounds ? iconBounds.width : 0; const textW = text ? measureFontTextWidth(font, text, textWidthCache) : 0; let contentW = 0; let count = 0; if (hasChevron) { contentW += BADGE_CHEV_W; count++; } if (icon) { contentW += iconW; count++; } if (text) { contentW += textW; count++; } contentW += BADGE_GAP * Math.max(0, count - 1); const pillW = contentW + BADGE_PAD_X * 2; const pillX = position === "right" ? x2 - BADGE_EDGE_INSET - pillW : position === "center" ? (x1 + x2) / 2 - pillW / 2 : x1 + BADGE_EDGE_INSET; let cursor = pillX + BADGE_PAD_X; let chevronCx = -1; if (hasChevron) { chevronCx = cursor + BADGE_CHEV_W / 2; cursor += BADGE_CHEV_W + BADGE_GAP; } let iconX = -1; if (icon) { // Pen origin compensates the left side-bearing so the ink starts at `cursor` // (and is centered in an icon-only pill, whose width is iconW + 2*padX). iconX = cursor - (iconBounds ? iconBounds.x : 0); cursor += iconW + BADGE_GAP; } const textX = text ? cursor : -1; let connStart = -1; let connEnd = -1; if (position === "right") { const end = pillX - 4; if (end > x1) { connStart = x1; connEnd = end; } } else if (position === "left") { const start = pillX + pillW + 4; if (start < x2) { connStart = start; connEnd = x2; } } // "center" floats the pill at the value with no connector. return { pillX, pillW, iconX, textX, chevronCx, connStart, connEnd }; } /** Resolved badge text for a Form-A line (in-range tag or off-axis pin). */ function referenceBadgeText( line: ReferenceLine, badge: ReturnType & object, v: number, formatValue: (value: number) => string, offAxis: boolean, ): string { "worklet"; if (!badge.showText) return ""; if (offAxis && badge.legacyText) { const word = line.offAxisBadgeLabel ?? line.label; return word ? `${word}: ${formatValue(v)}` : formatValue(v); } if (line.showValue && line.label) return `${line.label} ${formatValue(v)}`; return line.label ?? formatValue(v); } /** Axis-aligned screen rect of a reference-line badge pill (the press hit-target). */ export interface ReferenceBadgeRect { x: number; y: number; w: number; h: number; } /** * Derives screen-space layout for a single reference line or band. Supports all * three scalar/band `ReferenceLine` forms (horizontal line, horizontal value * band, vertical time band) plus the pill badge for a Form-A value (in-range tag * + off-screen chevron pin). The series form is handled by * `ReferenceLineSeriesOverlay`. */ export function useReferenceLine( engine: ChartEngineLayout, padding: ChartPadding, line: ReferenceLine | undefined, formatValue: (v: number) => string, font: SkFont, /** * Per-line live value overrides (e.g. dragged values), index-aligned with the * chart's `referenceLines`. When provided and the slot at {@link index} holds a * number, the Form-A line tracks that value instead of the static `line.value`, * so a drag updates the rendered line/badge on the UI thread. Omit for static lines. */ dragValues?: SharedValue, index = 0, /** Y-axis labels used to match the right-anchored grid endpoint. */ yAxisEntries?: SharedValue, /** Enables clipping when set; matches {@link YAxisConfig.labelRightMargin}. */ labelRightMargin?: number, /** Gap before the label column; matches {@link YAxisConfig.gridEndGap}. */ gridEndGap = 0, /** Font used by the Y-axis labels; badge font overrides must not move the line. */ yAxisFont: SkFont = font, valueFont: SkFont = font, valueAxis?: AxisBadgeConfig, ): SharedValue { const textWidthCacheRef = useRef({}); const valueWidthCacheRef = useRef({}); const timeBand = line != null && referenceLineForm(line) === "time-band"; return useDerivedValue(() => computeReferenceLineLayout( engine.canvasWidth.value, engine.canvasHeight.value, padding, line, formatValue, font, engine.displayMin.value, engine.displayMax.value, timeBand ? engine.timestamp.value : 0, timeBand ? engine.displayWindow.value : 1, dragValues?.value[index], yAxisEntries?.value, labelRightMargin, gridEndGap, yAxisFont, valueFont, valueAxis, textWidthCacheRef.current, valueWidthCacheRef.current, ), ); } function computeBaseReferenceLineLayout( w: number, h: number, padding: ChartPadding, line: ReferenceLine | undefined, formatValue: (v: number) => string, font: SkFont, dMin: number, dMax: number, now: number, win: number, valueOverride?: number, yAxisEntries?: YAxisEntry[], labelRightMargin?: number, gridEndGap = 0, yAxisFont: SkFont = font, textWidthCache?: TextWidthCache, ): ReferenceLineLayout { "worklet"; const form = line ? referenceLineForm(line) : "none"; const badge = line ? resolveReferenceBadge(line) : null; if (!line || form === "none") return INVISIBLE; if (w === 0 || h === 0) return INVISIBLE; const chartTop = padding.top; const chartBottom = h - padding.bottom; const chartH = chartBottom - chartTop; const x1 = padding.left; const x2 = w - padding.right; // Full-width lines/bands run edge-to-edge through the gutters (0..canvas); // the badge/label anchor (x1/x2) stays at the plot edges either way. const fullWidth = line.fullWidth ?? false; const lineX1 = fullWidth ? 0 : x1; const yAxisColumn = form === "line" && !fullWidth && yAxisEntries !== undefined && labelRightMargin !== undefined ? rightAnchoredYAxisColumnLayout( w, yAxisEntries, yAxisFont, labelRightMargin, gridEndGap, ) : null; const lineX2 = fullWidth ? w : (yAxisColumn?.gridEndX ?? x2); const fm = font.getMetrics(); const baselineOffset = (fm.ascent + fm.descent) / 2; // ── Form D — vertical time band (independent of the value range) ───────── if (form === "time-band") { if (line.from === undefined || line.to === undefined) return INVISIBLE; const winStart = now - win; const chartW = x2 - x1; if (win <= 0 || chartW <= 0) return INVISIBLE; let bx1 = x1 + ((line.from - winStart) / win) * chartW; let bx2 = x1 + ((line.to - winStart) / win) * chartW; if (bx2 < bx1) { const t = bx1; bx1 = bx2; bx2 = t; } if (bx2 < x1 || bx1 > x2) return INVISIBLE; if (bx1 < x1) bx1 = x1; if (bx2 > x2) bx2 = x2; const label = line.label ?? ""; const labelX = line.labelPosition === "right" ? bx2 - 4 - measureFontTextWidth(font, label) : bx1 + 4; return { ...INVISIBLE, visible: true, y: chartTop, yBottom: chartBottom, x1: bx1, x2: bx2, // Time band is vertical and time-bounded — full-width does not apply. lineX1: bx1, lineX2: bx2, label, labelX, labelY: chartTop - fm.ascent + 2, }; } // Forms A / B project values onto y, so they need a non-degenerate range. const valRange = dMax - dMin; if (valRange <= 0) return INVISIBLE; const toY = (v: number) => chartTop + ((dMax - v) / valRange) * chartH; // ── Form C — horizontal value band ─────────────────────────────────────── if (form === "value-band") { if (line.valueFrom === undefined || line.valueTo === undefined) { return INVISIBLE; } let yTop = toY(line.valueTo); let yBot = toY(line.valueFrom); if (yBot < yTop) { const t = yTop; yTop = yBot; yBot = t; } if (yBot < chartTop || yTop > chartBottom) return INVISIBLE; if (yTop < chartTop) yTop = chartTop; if (yBot > chartBottom) yBot = chartBottom; const label = line.label ?? ""; const labelX = line.labelPosition === "right" ? x2 - 4 - measureFontTextWidth(font, label) : x1 + 4; return { ...INVISIBLE, visible: true, y: yTop, yBottom: yBot, x1, x2, lineX1, lineX2, label, labelX, labelY: yTop - fm.ascent + 2, }; } // ── Form A — horizontal line (with optional pill badge) ────────────────── if (line.value === undefined || chartH <= 0) return INVISIBLE; // Effective value: a live drag override (if present) else the static prop. const v = valueOverride ?? line.value; const edge = classifyReferenceEdge(v, dMin, dMax); // Off-screen: pin the badge to the nearest edge with a chevron (when a badge // is configured); otherwise cull the off-screen line (legacy behavior). if (edge !== "in") { if (!badge && !line.valueBadge) return INVISIBLE; const above = edge === "above"; const clampedY = above ? chartTop + OFF_AXIS_EDGE_INSET : chartBottom - OFF_AXIS_EDGE_INSET; if (!badge) return { ...INVISIBLE, visible: true, y: clampedY, yBottom: clampedY, x1, x2, offAxis: true, chevronUp: above, }; const text = referenceBadgeText(line, badge, v, formatValue, true); const g = badgeGeometry( badge.position, badge.icon, text, true, x1, x2, font, textWidthCache, ); return { ...INVISIBLE, visible: true, y: clampedY, yBottom: clampedY, x1, x2, label: text, labelX: g.textX, labelY: clampedY - baselineOffset, offAxis: true, chevronUp: above, badge: true, pillX: g.pillX, pillW: g.pillW, iconX: g.iconX, icon: badge.icon, chevronCx: g.chevronCx, connStart: g.connStart, connEnd: g.connEnd, }; } const y = toY(v); // In-range pill badge (the `badge` config, not the legacy off-axis-only flag). if (badge && badge.inRange) { const text = referenceBadgeText(line, badge, v, formatValue, false); const g = badgeGeometry( badge.position, badge.icon, text, false, x1, x2, font, textWidthCache, ); return { ...INVISIBLE, visible: true, y, yBottom: y, x1, x2, lineX1, lineX2, // Full-width: the edge-to-edge line replaces the dashed connector. drawLine: fullWidth, label: text, labelX: g.textX, labelY: y - baselineOffset, badge: true, pillX: g.pillX, pillW: g.pillW, iconX: g.iconX, icon: badge.icon, connStart: fullWidth ? -1 : g.connStart, connEnd: fullWidth ? -1 : g.connEnd, }; } // Plain gutter label (no badge, or a legacy off-axis badge that's in range). let label = line.label ?? (line.valueBadge ? "" : formatValue(v)); if (line.showValue && line.label) label = `${line.label} ${formatValue(v)}`; const pos = line.labelPosition ?? (line.valueBadge ? "left" : "right"); let labelX: number; if (pos === "left") labelX = x1 + 4; else if (pos === "center") labelX = (x1 + x2) / 2 - measureFontTextWidth(font, label) / 2; else labelX = x2 + 4; // "right" — legacy gutter position return { ...INVISIBLE, visible: true, y, yBottom: y, x1, x2, lineX1, lineX2, drawLine: true, label, labelX, labelY: y - baselineOffset, }; } /** Pure worklet shared by the drawing and tap-target calculations. */ export function computeReferenceLineLayout( w: number, h: number, padding: ChartPadding, line: ReferenceLine | undefined, formatValue: (v: number) => string, font: SkFont, dMin: number, dMax: number, now: number, win: number, valueOverride?: number, yAxisEntries?: YAxisEntry[], labelRightMargin?: number, gridEndGap = 0, yAxisFont: SkFont = font, valueFont: SkFont = font, valueAxis?: AxisBadgeConfig, textWidthCache?: TextWidthCache, valueWidthCache?: TextWidthCache, ): ReferenceLineLayout { "worklet"; const base = computeBaseReferenceLineLayout( w, h, padding, line, formatValue, font, dMin, dMax, now, win, valueOverride, yAxisEntries, labelRightMargin, gridEndGap, yAxisFont, textWidthCache, ); if (!line?.valueBadge || referenceLineForm(line) !== "line" || !base.visible) return base; const cfg = typeof line.valueBadge === "object" ? line.valueBadge : {}; const text = formatValue(valueOverride ?? line.value!); const textW = measureFontTextWidth(valueFont, text, valueWidthCache); const fm = valueFont.getMetrics(); const height = fm.descent - fm.ascent + BADGE_PILL_PAD_Y * 2; const chevronW = base.offAxis && !base.badge ? BADGE_CHEV_W + BADGE_GAP : 0; const contentW = textW + chevronW; const bounds = cfg.position === "axis" ? axisBadgeBounds( w, padding.right, contentW, valueFont.getSize(), valueAxis, ) : { left: base.x2 - BADGE_EDGE_INSET - contentW - BADGE_PAD_X * 2, right: base.x2 - BADGE_EDGE_INSET, }; // An explicitly undersized inset must not produce an inverted pill. const pillW = Math.max(0, bounds.right - bounds.left); const x = bounds.left + (cfg.offsetX ?? 0); const y = base.y + (cfg.offsetY ?? 0); let textX = x + (pillW - contentW) / 2 + chevronW; // Match live-badge text alignment when its column mode is enabled. if ( cfg.position === "axis" && valueAxis?.alignTextWithYAxis && !valueAxis.float && labelRightMargin !== undefined && yAxisEntries ) { const column = rightAnchoredYAxisColumnLayout( w, yAxisEntries, yAxisFont, labelRightMargin, ); const pad = valueAxis?.metrics?.padX ?? BADGE_PAD_X; const maxX = x + pillW - pad - textW; if (x + pad + chevronW <= maxX) { textX = Math.min( Math.max( Math.min(column.labelX, w - labelRightMargin - textW), x + pad + chevronW, ), maxX, ); } } const result = { ...base, drawLine: Boolean(line.fullWidth && !base.offAxis), valueBadge: { x, y: y - height / 2, w: pillW, h: height, text, textX, textY: y - (fm.ascent + fm.descent) / 2, chevronCx: chevronW ? x + BADGE_PAD_X + BADGE_CHEV_W / 2 : -1, }, }; const badge = resolveReferenceBadge(line); const offsetX = badge?.offsetX ?? 0; // Right/center name pills keep their requested anchor until they would overlap // the value pill; then make room immediately to its left. if (base.badge) { const shift = Math.min(0, x - 8 - (base.pillX + base.pillW + offsetX)); result.pillX += shift; result.labelX += shift; if (result.iconX >= 0) result.iconX += shift; if (result.chevronCx >= 0) result.chevronCx += shift; } // The name badge's transform is applied by the existing connector group. result.connStart = base.badge ? result.pillX + result.pillW + 4 : base.label ? base.labelX + measureFontTextWidth(font, base.label, textWidthCache) + 4 - offsetX : base.x1 - offsetX; result.connEnd = x - 4 - offsetX; if (line.fullWidth && !base.offAxis) result.connStart = -1; if (result.connEnd <= result.connStart) result.connStart = -1; return result; } export function referenceBadgeRect( layout: ReferenceLineLayout, font: SkFont, line: ReferenceLine, ): ReferenceBadgeRect | null { "worklet"; if (!layout.visible || !layout.badge) return null; const fm = font.getMetrics(); const h = fm.descent - fm.ascent + BADGE_PILL_PAD_Y * 2; const badge = resolveReferenceBadge(line); return { x: layout.pillX + (badge?.offsetX ?? 0), y: layout.y - h / 2 + (badge?.offsetY ?? 0), w: layout.pillW, h, }; } /** * Screen-space pill rect for a Form-A reference line's **badge**, or `null` when * the line has no pressable badge (no `badge`/`offAxisBadge`, not a value line, a * legacy off-axis badge while in range, or the canvas isn't laid out / range is * degenerate). Shares `badgeGeometry` + the edge classification with * {@link useReferenceLine}, so the hit-target tracks the rendered pill exactly. * Pure worklet — used by {@link useReferenceLinePress} for tap hit-testing. */ export function computeReferenceBadgeRect( line: ReferenceLine, canvasWidth: number, canvasHeight: number, padding: ChartPadding, dMin: number, dMax: number, font: SkFont, formatValue: (value: number) => string, /** Live value override (e.g. a dragged value), defaulting to `line.value`. */ valueOverride?: number, ): ReferenceBadgeRect | null { "worklet"; const layout = computeReferenceLineLayout( canvasWidth, canvasHeight, padding, line, formatValue, font, dMin, dMax, 0, 1, valueOverride, ); return referenceBadgeRect(layout, font, line); }