import { useMemo } from "react"; import { Box, Text } from "../../../ui"; import { colors } from "../../../theme/colors"; import type { ResolvedSeries } from "../../../time-series/types"; import { CompositeChart } from "../composite/composite-chart"; import type { CompositeChartXMarker } from "../composite/types"; import { scalarPoint, staticSeries } from "./series"; export interface ScatterChartPoint { x: number; y: number; highlight?: boolean; } interface ScatterRegressionLine { slope: number; intercept: number; color: string; } interface ScatterChartColors { bgColor: string; gridColor: string; axisColor: string; pointColor: string; highlightColor: string; } export interface StaticScatterChartSurfaceProps { points: ScatterChartPoint[]; width: number; height: number; colors?: ScatterChartColors; regression?: ScatterRegressionLine | null; xLabel?: string; yLabel?: string; } /** * The composite scale is time, so x rides in the timestamp. Returns are small * decimals and Date truncates to whole milliseconds, so x is scaled up first. */ const X_TO_TIME = 1_000_000; function timeForX(x: number): Date { return new Date(Math.round(x * X_TO_TIME)); } export interface ScatterChartModel { series: ResolvedSeries[]; markers: CompositeChartXMarker[]; } export function buildScatterChartModel( points: readonly ScatterChartPoint[], regression: ScatterRegressionLine | null, chartColors: ScatterChartColors, ): ScatterChartModel | null { const valid = points.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y)); if (valid.length === 0) return null; let xMin = Number.POSITIVE_INFINITY; let xMax = Number.NEGATIVE_INFINITY; let yMin = Number.POSITIVE_INFINITY; let yMax = Number.NEGATIVE_INFINITY; for (const point of valid) { xMin = Math.min(xMin, point.x); xMax = Math.max(xMax, point.x); yMin = Math.min(yMin, point.y); yMax = Math.max(yMax, point.y); } const plain = valid.filter((point) => !point.highlight); const highlighted = valid.filter((point) => point.highlight); const series: ResolvedSeries[] = []; const markers: CompositeChartXMarker[] = []; if (yMin <= 0 && yMax >= 0 && xMin < xMax) { series.push(staticSeries( [scalarPoint(timeForX(xMin), 0), scalarPoint(timeForX(xMax), 0)], { id: "zero-y", color: chartColors.axisColor, calendarSpaced: true }, )); } if (xMin <= 0 && xMax >= 0 && xMin < xMax) { markers.push({ id: "zero-x", xRatio: (0 - xMin) / (xMax - xMin), color: chartColors.axisColor }); } if (regression && xMin < xMax) { // Clip the fitted line to the data's vertical extent so it cannot stretch // the axis, then draw whatever segment survives. const lineY = (x: number) => regression.slope * x + regression.intercept; let left = xMin; let right = xMax; if (regression.slope !== 0) { const xAt = (y: number) => (y - regression.intercept) / regression.slope; const bounds = [xAt(yMin), xAt(yMax)].sort((a, b) => a - b); left = Math.max(left, bounds[0]!); right = Math.min(right, bounds[1]!); } const ends = [left, right] .map((x) => ({ x, y: lineY(x) })) .filter((end) => Number.isFinite(end.y) && end.y >= yMin && end.y <= yMax); if (left < right && ends.length === 2) { series.push(staticSeries( ends.map((end) => scalarPoint(timeForX(end.x), end.y)), { id: "regression", color: regression.color, calendarSpaced: true }, )); } } if (plain.length > 0) { series.push(staticSeries( plain.map((point) => scalarPoint(timeForX(point.x), point.y)), { id: "points", color: chartColors.pointColor, style: "points", calendarSpaced: true }, )); } if (highlighted.length > 0) { series.push(staticSeries( highlighted.map((point) => scalarPoint(timeForX(point.x), point.y)), { id: "highlight", color: chartColors.highlightColor, style: "points", calendarSpaced: true }, )); } return { series, markers }; } const PANELS = [{ id: "main" }]; export function StaticScatterChartSurface({ points, width, height, colors: chartColors = { bgColor: colors.bg, gridColor: colors.border, axisColor: colors.textDim, pointColor: "#b197fc", highlightColor: colors.negative, }, regression = null, xLabel, yLabel, }: StaticScatterChartSurfaceProps) { const totalWidth = Math.max(1, Math.floor(width)); const totalHeight = Math.max(4, Math.floor(height)); const labelRows = (yLabel ? 1 : 0) + (xLabel ? 1 : 0); const plotHeight = Math.max(2, totalHeight - labelRows); const model = useMemo( () => buildScatterChartModel(points, regression, chartColors), [chartColors, points, regression], ); const compositeColors = useMemo(() => ({ background: chartColors.bgColor, grid: chartColors.gridColor, crosshair: chartColors.axisColor, text: chartColors.axisColor, textDim: chartColors.axisColor, negative: colors.negative, }), [chartColors]); const xAxis = useMemo(() => model ? { markers: model.markers } : undefined, [model]); if (!model) { return ( No scatter data ); } return ( {yLabel ? {yLabel} : null} {xLabel ? {xLabel} : null} ); }