import type { ChartPanelSpec, PanelScale, ResolvedSeries, TimeSeriesPoint, } from "../../../time-series/types"; import { effectiveTimeSeriesPointTime, timeSeriesObservationKey } from "../../../time-series/alignment"; import type { BuildCompositeChartSceneOptions, CompositeAxisDomain, CompositeAxisSide, CompositeChartScene, CompositeCursorValue, CompositePanelScene, CompositeProjectedPoint, } from "./types"; import { buildCompositeTimeScale, COMPOSITE_RIGHT_OFFSET_RATIO, projectCompositeTimestamp, unprojectCompositeTimestamp, } from "./time-scale"; import { compositeAxisMaxTicks, seriesPriceReference } from "./format"; import type { CompositeLastPriceMarker, CompositeTimeScale } from "./types"; const DAY_MS = 24 * 60 * 60 * 1000; function finiteNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } function resolveTimeSeriesPointValue(point: TimeSeriesPoint): number | null { if (finiteNumber(point.value)) return point.value; if (finiteNumber(point.close)) return point.close; return null; } function pointTime(point: TimeSeriesPoint): number | null { const time = point.date instanceof Date ? point.date.getTime() : new Date(point.date).getTime(); return Number.isFinite(time) ? time : null; } function isMarketObservationSeries(series: ResolvedSeries): boolean { return series.observationKind === "market" || series.timeBasis?.kind === "market"; } function pointTimestampForScale( series: ResolvedSeries, point: TimeSeriesPoint, timeScale?: CompositeTimeScale, ): number | null { const timestamp = pointTime(point); if (timestamp === null) return null; return timeScale?.kind === "market" && !isMarketObservationSeries(series) ? effectiveTimeSeriesPointTime(point) : timestamp; } interface NormalizedSourcePoint { point: TimeSeriesPoint; timestamp: number; value: number | null; } interface NormalizedPointsCache { points: readonly TimeSeriesPoint[]; pointCount: number; last: TimeSeriesPoint | undefined; lastTimestamp: number | null; lastValue: number | null; byPlacement: Map; } // Every scene build normalises each series several times over; a viewport // change must not re-sort thousands of unchanged points. Live ticks mutate // the last point in place, so the cache also keys on its time and value. const normalizedPointsCache = new WeakMap(); function computeNormalizedSourcePoints( series: ResolvedSeries, timeScale?: CompositeTimeScale, ): NormalizedSourcePoint[] { const byObservation = new Map(); for (const point of series.points) { const sourceTimestamp = pointTime(point); const timestamp = pointTimestampForScale(series, point, timeScale); if (sourceTimestamp === null || timestamp === null || !Number.isFinite(timestamp)) continue; byObservation.set(timeSeriesObservationKey(point), { point, timestamp, value: resolveTimeSeriesPointValue(point), }); } return [...byObservation.values()].sort((left, right) => ( left.timestamp - right.timestamp || left.point.date.getTime() - right.point.date.getTime() || left.point.observedAt.getTime() - right.point.observedAt.getTime() )); } function normalizedSourcePoints( series: ResolvedSeries, timeScale?: CompositeTimeScale, ): NormalizedSourcePoint[] { const effectiveTimes = timeScale?.kind === "market" && !isMarketObservationSeries(series); const last = series.points[series.points.length - 1]; const lastTimestamp = last ? pointTime(last) : null; const lastValue = last ? resolveTimeSeriesPointValue(last) : null; let cached = normalizedPointsCache.get(series); if ( !cached || cached.points !== series.points || cached.pointCount !== series.points.length || cached.last !== last || cached.lastTimestamp !== lastTimestamp || cached.lastValue !== lastValue ) { cached = { points: series.points, pointCount: series.points.length, last, lastTimestamp, lastValue, byPlacement: new Map(), }; normalizedPointsCache.set(series, cached); } let normalized = cached.byPlacement.get(effectiveTimes); if (!normalized) { normalized = computeNormalizedSourcePoints(series, timeScale); cached.byPlacement.set(effectiveTimes, normalized); } return normalized; } function normalizedPoints(series: ResolvedSeries): Array<{ point: TimeSeriesPoint; timestamp: number; value: number; }> { return normalizedSourcePoints(series).flatMap((entry) => ( entry.value === null ? [] : [{ ...entry, value: entry.value }] )); } function explicitViewport( options: BuildCompositeChartSceneOptions, ): { startTime: number; endTime: number } | null { const startTime = options.viewport?.start.getTime(); const endTime = options.viewport?.end.getTime(); return finiteNumber(startTime) && finiteNumber(endTime) && startTime <= endTime ? { startTime, endTime } : null; } function scopeSeriesToViewport( series: ResolvedSeries, startTime: number, endTime: number, timeScale: CompositeTimeScale, clipToViewport: boolean, ): ResolvedSeries | null { const points = normalizedSourcePoints(series, timeScale); const placement = timeScale.kind === "market" && !isMarketObservationSeries(series) ? "next-market-slot" as const : "timestamp" as const; const visible = points.filter(({ timestamp }) => { if (timestamp >= startTime && timestamp <= endTime) return true; if (clipToViewport) return false; const projected = projectCompositeTimestamp(timeScale, timestamp, placement); return !!projected && projected.ratio >= 0 && projected.ratio <= 1; }); if (series.interpolation === "step-after" || series.style === "step") { const anchor = points.some(({ timestamp }) => timestamp === startTime) ? undefined : [...points].reverse().find(({ timestamp }) => timestamp < startTime); if (anchor && !visible.includes(anchor)) visible.unshift(anchor); } return visible.some(({ value }) => value !== null) ? { ...series, points: visible.map(({ point }) => point) } : null; } function panelSpecsForSeries(series: ResolvedSeries[], panels: ChartPanelSpec[]): ChartPanelSpec[] { const visiblePanelIds = new Set(series.map((entry) => entry.panelId)); const ordered = panels.filter((panel) => visiblePanelIds.has(panel.id)); const knownIds = new Set(ordered.map((panel) => panel.id)); for (const entry of series) { if (knownIds.has(entry.panelId)) continue; knownIds.add(entry.panelId); ordered.push({ id: entry.panelId }); } return ordered; } export function allocateCompositePanelHeights( panels: ChartPanelSpec[], availableHeight: number, ): Map { const heights = new Map(); if (panels.length === 0) return heights; const totalHeight = Math.max(panels.length, Math.floor(availableHeight)); const weights = panels.map((panel) => ( finiteNumber(panel.height) && panel.height > 0 ? panel.height : 1 )); const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) || panels.length; const quotas = weights.map((weight) => (weight / totalWeight) * totalHeight); const allocations = quotas.map((quota) => Math.max(1, Math.floor(quota))); let allocated = allocations.reduce((sum, value) => sum + value, 0); while (allocated < totalHeight) { let index = 0; for (let candidate = 1; candidate < allocations.length; candidate += 1) { if (quotas[candidate]! - allocations[candidate]! > quotas[index]! - allocations[index]!) { index = candidate; } } allocations[index] = (allocations[index] ?? 0) + 1; allocated += 1; } while (allocated > totalHeight) { let index = -1; for (let candidate = 0; candidate < allocations.length; candidate += 1) { if (allocations[candidate]! <= 1) continue; if (index < 0 || allocations[candidate]! - quotas[candidate]! > allocations[index]! - quotas[index]!) { index = candidate; } } if (index < 0) break; allocations[index] = allocations[index]! - 1; allocated -= 1; } panels.forEach((panel, index) => heights.set(panel.id, allocations[index] ?? 1)); return heights; } function seriesDomainValues(series: ResolvedSeries): number[] { const values: number[] = []; for (const point of series.points) { const scalar = resolveTimeSeriesPointValue(point); if (scalar !== null) values.push(scalar); if (series.dataShape === "ohlcv") { for (const candidate of [point.open, point.high, point.low, point.close]) { if (finiteNumber(candidate)) values.push(candidate); } } } if (series.style === "columns") values.push(0); return values; } function paddedDomain(values: number[], scale: PanelScale): { min: number; max: number } { const usable = scale === "log" ? values.filter((value) => value > 0) : values; if (usable.length === 0) return scale === "log" ? { min: 1, max: 10 } : { min: 0, max: 1 }; const rawMin = Math.min(...usable); const rawMax = Math.max(...usable); if (rawMin === rawMax) { if (scale === "log") { return { min: rawMin / 1.1, max: rawMax * 1.1 }; } // All zero, as in a session without reported volume, sits on the floor. if (rawMin === 0) return { min: 0, max: 1 }; const delta = Math.max(Math.abs(rawMin) * 0.08, 1); return signBounded(rawMin, rawMax, { min: rawMin - delta, max: rawMax + delta }); } if (scale === "log") { const logMin = Math.log(rawMin); const logMax = Math.log(rawMax); const padding = (logMax - logMin) * 0.06; return { min: Math.exp(logMin - padding), max: Math.exp(logMax + padding) }; } const padding = (rawMax - rawMin) * 0.06; return signBounded(rawMin, rawMax, { min: rawMin - padding, max: rawMax + padding }); } /** Headroom never pushes one-signed values across zero: a price or volume * axis does not reach below 0. */ function signBounded(rawMin: number, rawMax: number, padded: { min: number; max: number }): { min: number; max: number } { return { min: rawMin >= 0 ? Math.max(0, padded.min) : padded.min, max: rawMax <= 0 ? Math.min(0, padded.max) : padded.max, }; } function axisPriceReferences(axisSeries: ResolvedSeries[]): Record { const references: Record = {}; for (const entry of axisSeries) { const key = entry.priceAssetCategory ?? ""; if (key in references) continue; const value = seriesPriceReference(entry); if (value !== undefined) references[key] = value; } return references; } function buildAxisDomain( side: CompositeAxisSide, series: ResolvedSeries[], scale: PanelScale, rows: number, ): CompositeAxisDomain | undefined { const axisSeries = series.filter((entry) => entry.axis === side); if (axisSeries.length === 0) return undefined; const { min, max } = paddedDomain(axisSeries.flatMap(seriesDomainValues), scale); const first = axisSeries[0]!; return { side, min, max, scale, unit: first.unit, unitGroup: first.unitGroup, priceAssetCategories: [...new Set(axisSeries.flatMap((entry) => entry.priceAssetCategory ? [entry.priceAssetCategory] : []))], priceReferences: axisPriceReferences(axisSeries), seriesIds: axisSeries.map((entry) => entry.id), maxTicks: compositeAxisMaxTicks(rows), tickRows: rows, }; } /** A panel laid out at another height keeps as many axis ticks as it can * label. Hosts that place labels at exact heights drop the row snapping. */ export function resizeCompositePanel( panel: CompositePanelScene, height: number, snapTicksToRows = true, ): CompositePanelScene { const maxTicks = compositeAxisMaxTicks(height); const tickRows = snapTicksToRows ? height : undefined; const fits = (domain: CompositeAxisDomain) => domain.maxTicks === maxTicks && domain.tickRows === tickRows; if (panel.height === height && (["left", "right"] as const).every((side) => !panel.axes[side] || fits(panel.axes[side]!))) { return panel; } const axes: CompositePanelScene["axes"] = {}; for (const side of ["left", "right"] as const) { const domain = panel.axes[side]; if (domain) axes[side] = fits(domain) ? domain : { ...domain, maxTicks, tickRows }; } return { ...panel, height, axes }; } export function projectCompositeValue(value: number, domain: CompositeAxisDomain): number | null { if (!Number.isFinite(value)) return null; if (domain.scale === "log") { if (value <= 0 || domain.min <= 0 || domain.max <= 0) return null; const span = Math.log(domain.max) - Math.log(domain.min); return span === 0 ? 0.5 : 1 - (Math.log(value) - Math.log(domain.min)) / span; } const span = domain.max - domain.min; return span === 0 ? 0.5 : 1 - (value - domain.min) / span; } export function unprojectCompositeValue( yRatio: number, domain: CompositeAxisDomain, ): number | null { if ( !Number.isFinite(yRatio) || !Number.isFinite(domain.min) || !Number.isFinite(domain.max) ) { return null; } const ratio = Math.max(0, Math.min(1, yRatio)); if (domain.scale === "log") { if (domain.min <= 0 || domain.max <= 0) return null; return Math.exp( Math.log(domain.max) + (Math.log(domain.min) - Math.log(domain.max)) * ratio, ); } return domain.max + (domain.min - domain.max) * ratio; } function projectSeries( series: ResolvedSeries, domain: CompositeAxisDomain, startTime: number, /** Where a step series holds its last level to, never past the newest bar. */ stepEndTime: number, timeScale: CompositeTimeScale, ): CompositeProjectedPoint[] { const projected: CompositeProjectedPoint[] = []; let breakBefore = true; const placement = timeScale.kind === "market" && !isMarketObservationSeries(series) ? "next-market-slot" as const : "timestamp" as const; const stepSeries = series.interpolation === "step-after" || series.style === "step"; for (const { point, timestamp, value } of normalizedSourcePoints(series, timeScale)) { if (value === null) { breakBefore = true; continue; } const yRatio = projectCompositeValue(value, domain); if (yRatio === null) { breakBefore = true; continue; } const projectedTime = projectCompositeTimestamp(timeScale, timestamp, placement); if (!projectedTime) continue; const beforeViewportStepAnchor = stepSeries && timestamp < startTime && projectedTime.ratio < 0; if (!beforeViewportStepAnchor && (projectedTime.ratio < 0 || projectedTime.ratio > 1)) { continue; } projected.push({ point, timestamp: beforeViewportStepAnchor ? startTime : timestamp, value, xRatio: beforeViewportStepAnchor ? 0 : projectedTime.ratio, xSlot: beforeViewportStepAnchor ? undefined : projectedTime.xSlot, yRatio, breakBefore, }); breakBefore = false; } if (stepSeries && projected.length > 0) { const last = projected.at(-1)!; const trailingGap = normalizedSourcePoints(series, timeScale).some(({ timestamp, value }) => ( timestamp > last.timestamp && timestamp <= stepEndTime && (value === null || projectCompositeValue(value, domain) === null) )); // The synthetic tail is a level, not an observation, so it lands on its own // timestamp rather than snapping forward to the next trading slot. const trailingRatio = projectCompositeTimestamp(timeScale, stepEndTime)?.ratio; if (last.timestamp < stepEndTime && !trailingGap && trailingRatio !== undefined) { projected.push({ ...last, timestamp: stepEndTime, xRatio: Math.min(trailingRatio, 1), xSlot: undefined, breakBefore: false, }); } } return projected; } /** * Unit groups a price can wear, raw or transformed. Volume, oscillators and * other derived study outputs are deliberately absent: a level line there * would mark a quantity that has no last price to read. */ const PRICE_UNIT_GROUPS = /^(?:price|currency|percent|index|log)(?::|$)/; /** * The chart's primary price series: the first exchange-traded price, in * authored order. Study outputs are appended after the series they derive * from, so an overlay indicator never wins over the price it follows. */ function primaryPriceSeries(series: readonly ResolvedSeries[]): ResolvedSeries | null { return series.find((entry) => ( entry.timeBasis?.kind === "market" && PRICE_UNIT_GROUPS.test(entry.unitGroup.toLowerCase()) )) ?? null; } function lastCloseOf(points: readonly CompositeProjectedPoint[]): number | null { const last = points.at(-1); if (!last) return null; return finiteNumber(last.point.close) ? last.point.close : last.value; } /** Marks the newest close of the primary price series on the panel that draws it. */ function attachLastPriceMarker( panels: CompositePanelScene[], series: readonly ResolvedSeries[], ): void { const primary = primaryPriceSeries(series); if (!primary) return; const panel = panels.find((entry) => entry.id === primary.panelId); const projected = panel?.series.find((entry) => entry.source.id === primary.id); const domain = panel?.axes[primary.axis]; if (!panel || !projected || !domain) return; // A rejected or missing latest bar cannot make an older close current. const latest = normalizedSourcePoints(primary).at(-1); if (latest?.point.provenance?.priceHistoryIntegrity || latest?.value === null) return; const value = lastCloseOf(projected.points); if (value === null) return; const yRatio = projectCompositeValue(value, domain); if (yRatio === null) return; panel.lastPrice = { seriesId: primary.id, color: primary.color, axis: primary.axis, value, yRatio, }; } function nearestDate(dates: Date[], requested: Date): Date | null { const target = requested.getTime(); if (!Number.isFinite(target) || dates.length === 0) return null; let low = 0; let high = dates.length; while (low < high) { const middle = low + Math.floor((high - low) / 2); if (dates[middle]!.getTime() < target) low = middle + 1; else high = middle; } const next = dates[low] ?? null; const previous = dates[low - 1] ?? null; if (!previous) return next; if (!next) return previous; return target - previous.getTime() <= next.getTime() - target ? previous : next; } function cursorPointForSeries( series: CompositePanelScene["series"][number], cursorTime: number, ): CompositeProjectedPoint | null { const points = series.points; if (points.length === 0) return null; let low = 0; let high = points.length; while (low < high) { const middle = low + Math.floor((high - low) / 2); if (points[middle]!.timestamp <= cursorTime) low = middle + 1; else high = middle; } return points[low - 1] ?? null; } function buildCursorValues( panels: CompositePanelScene[], cursorDate: Date | null, viewport: Pick, ): CompositeCursorValue[] { const cursorTime = cursorDate?.getTime() ?? viewport.endTime; return panels.flatMap((panel) => panel.series.map((entry) => { let projected = cursorPointForSeries(entry, cursorTime); // Sparse observations can carry forward, but an explicitly unavailable // observation ends that value at its date, including the idle legend. const gap = normalizedSourcePoints(entry.source, viewport.timeScale).findLast(({ timestamp, point, value }) => ( timestamp <= cursorTime && (timestamp > (projected?.timestamp ?? Number.NEGATIVE_INFINITY) || (timestamp === projected?.timestamp && point.observedAt > projected.point.observedAt)) && (point.provenance?.priceHistoryIntegrity || value === null) )); if (gap) projected = null; // The drawn navigation buffer can include observations after the chosen // end. An unarmed legend describes the active window, including when the // pointer leaves; explicitly inspected cursor dates keep their own behavior. if (!cursorDate && projected && projected.timestamp < viewport.startTime) projected = null; return { seriesId: entry.source.id, label: entry.source.label, color: entry.source.color, unit: entry.source.unit, value: projected?.value ?? null, point: projected?.point ?? gap?.point ?? null, }; })); } /** * Applies cursor-only state without rebuilding panel domains or projected * series. Keeping those references stable lets renderers reuse the expensive * base chart while the cursor moves. */ export function applyCompositeChartCursor( scene: CompositeChartScene, requestedCursor: Date | null, ): CompositeChartScene { const cursorDate = requestedCursor ? nearestDate(scene.dates, requestedCursor) : null; const currentTimestamp = scene.cursorDate?.getTime() ?? null; const nextTimestamp = cursorDate?.getTime() ?? null; if (currentTimestamp === nextTimestamp) return scene; const cursorXRatio = cursorDate ? projectCompositeTimestamp(scene.timeScale, cursorDate.getTime())?.ratio ?? null : null; return { ...scene, cursorDate, cursorXRatio, cursorValues: buildCursorValues(scene.panels, cursorDate, scene), }; } export function buildCompositeChartScene( series: ResolvedSeries[], panels: ChartPanelSpec[], options: BuildCompositeChartSceneOptions, ): CompositeChartScene | null { const dataSeries = series.filter((entry) => normalizedPoints(entry).length > 0); if (dataSeries.length === 0) return null; // A known missing observation owns a date even though it has no drawable // value. Retain it in the window and cursor timeline, including trailing gaps. const times = dataSeries.flatMap((entry) => normalizedSourcePoints(entry).map((point) => point.timestamp)); const uniqueTimes = [...new Set(times)].sort((left, right) => left - right); if (uniqueTimes.length === 0) return null; const firstTime = uniqueTimes[0]!; const lastTime = uniqueTimes.at(-1)!; const viewport = explicitViewport(options); const startTime = viewport?.startTime ?? (firstTime === lastTime ? firstTime - DAY_MS / 2 : firstTime); const endTime = viewport?.endTime ?? (firstTime === lastTime ? lastTime + DAY_MS / 2 : lastTime); const timelineSeries = options.timelineSeries?.some((entry) => ( entry.timeBasis?.kind === "market" && entry.points.length > 0 )) ? options.timelineSeries : dataSeries; const timeScale = buildCompositeTimeScale( timelineSeries, startTime, endTime, options.rightOffsetRatio ?? COMPOSITE_RIGHT_OFFSET_RATIO, ); // The plot reaches past the viewport by the reserved right offset. Panned // back into history that space holds real observations, so navigation and // the cursor follow the drawn edge rather than the viewport end. const plotEndTime = options.clipToViewport ? endTime : Math.max(endTime, unprojectCompositeTimestamp(timeScale, 1)); // A step holds its level to the newest observation and no further: the space // reserved after it stays empty. With data still ahead of the window, the // level runs to the drawn edge instead. const stepTrailingTime = Math.min(plotEndTime, Math.max(lastTime, endTime)); const scopedSeries = viewport ? dataSeries.flatMap((entry) => scopeSeriesToViewport(entry, startTime, endTime, timeScale, options.clipToViewport === true) ?? []) : dataSeries; // A range without observations still has a chart: keep the panels, axes and // grid and simply draw no points, instead of blanking the whole surface. const emptyRange = scopedSeries.length === 0; const usableSeries = emptyRange ? dataSeries.map((entry) => ({ ...entry, points: [] })) : scopedSeries; const visibleTimes = uniqueTimes.filter((time) => time >= startTime && time <= plotEndTime); const marketTimes = timeScale.kind === "market" // The first market controls session spacing, but every plotted market // observation must remain inspectable. Other exchanges can trade before // or after its anchors. Nonmarket source dates stay excluded: filings // retain their availability-based placement on eligible market slots. ? [...new Set([ ...timeScale.anchors.map(({ timestamp }) => timestamp), ...scopedSeries.filter(isMarketObservationSeries) .flatMap((entry) => normalizedSourcePoints(entry) .filter(({ value, point }) => value !== null || point.provenance?.priceHistoryIntegrity) .map(({ timestamp }) => timestamp)), ])] .filter((time) => time >= startTime && time <= plotEndTime) .sort((left, right) => left - right) : []; const cursorTimes = timeScale.kind === "market" ? marketTimes : visibleTimes; const dates = (cursorTimes.length > 0 ? cursorTimes : viewport ? [...new Set([startTime, endTime])] : uniqueTimes ).map((time) => new Date(time)); const dateRatios = dates.map((date) => ( projectCompositeTimestamp(timeScale, date.getTime())?.ratio ?? 0 )); const requestedCursor = options.cursorDate ?? null; const cursorDate = requestedCursor ? nearestDate(dates, requestedCursor) : null; const cursorXRatio = cursorDate ? projectCompositeTimestamp(timeScale, cursorDate.getTime())?.ratio ?? null : null; // Panels belong to the authored series, not to whichever of them happen to // hold observations right now. A panel that disappears while its data loads // reflows every other panel, and the chart jumps again when it comes back. const orderedPanels = panelSpecsForSeries(series, panels); const panelHeights = allocateCompositePanelHeights(orderedPanels, options.height); const panelScenes: CompositePanelScene[] = orderedPanels.map((panel) => { const panelSeries = usableSeries.filter((entry) => entry.panelId === panel.id); const scale = panel.scale ?? "linear"; // With no in-view values, scale the axes to the loaded history rather than // the meaningless 0..1 fallback, so the panel keeps its gutter and grid. const domainSeries = emptyRange || panelSeries.length === 0 ? dataSeries.filter((entry) => entry.panelId === panel.id) : panelSeries; const rows = panelHeights.get(panel.id) ?? 1; const left = buildAxisDomain("left", domainSeries, scale, rows); const right = buildAxisDomain("right", domainSeries, scale, rows); const axes: Partial> = { left, right }; return { id: panel.id, label: panel.label, height: panelHeights.get(panel.id) ?? 1, scale, axes, series: panelSeries.flatMap((entry) => { const domain = axes[entry.axis]; return domain ? [{ source: entry, points: projectSeries(entry, domain, startTime, stepTrailingTime, timeScale), }] : []; }), }; }); attachLastPriceMarker(panelScenes, usableSeries); return { width: Math.max(1, Math.floor(options.width)), height: panelScenes.reduce((sum, panel) => sum + panel.height, 0), startTime, endTime, timeScale, dates, dateRatios, panels: panelScenes, cursorDate, cursorXRatio, cursorValues: buildCursorValues(panelScenes, cursorDate, { startTime, endTime, timeScale }), }; } export function resolveCompositeCursorDate(scene: CompositeChartScene, localX: number): Date | null { if (scene.dates.length === 0) return null; const ratio = scene.width <= 1 ? 0 : Math.max(0, Math.min(1, localX / Math.max(scene.width - 1, 1))); let nearestIndex = 0; let nearestDistance = Number.POSITIVE_INFINITY; scene.dateRatios.forEach((candidate, index) => { const distance = Math.abs(candidate - ratio); if (distance < nearestDistance) { nearestIndex = index; nearestDistance = distance; } }); return scene.dates[nearestIndex] ?? null; } export function resolveAdjacentCompositeCursorDate( scene: CompositeChartScene, current: Date | null, step: -1 | 1, ): Date | null { if (scene.dates.length === 0) return null; if (!current || !Number.isFinite(current.getTime())) { return step < 0 ? scene.dates.at(-1) ?? null : scene.dates[0] ?? null; } const currentTime = current.getTime(); let currentIndex = scene.dates.findIndex((date) => date.getTime() === currentTime); if (currentIndex < 0) { const snapped = nearestDate(scene.dates, current); currentIndex = snapped ? scene.dates.findIndex((date) => date.getTime() === snapped.getTime()) : 0; } const nextIndex = Math.max(0, Math.min(scene.dates.length - 1, currentIndex + step)); return scene.dates[nextIndex] ?? null; }