export type PlotPoint = { x: number; y: number } export type PlotSeriesStyle = { color: string; lineWidth: number; dash?: number[] } export type PlotSeriesBinding = { sourceId: string; xRange: string; yRange: string } export type PlotSeries = { id: string; label: string; points: PlotPoint[]; style: PlotSeriesStyle; binding?: PlotSeriesBinding } export type PlotAxis = { label: string; minimum?: number; maximum?: number; scale: 'linear' | 'log' } export type PlotSnapshot = { id: string; label: string; xAxis: PlotAxis; yAxis: PlotAxis; legend: boolean; series: PlotSeries[]; version: number } export type PlotApi = { snapshot(): PlotSnapshot setAxes(input: { x: PlotAxis; y: PlotAxis }): PlotSnapshot setLegend(visible: boolean): PlotSnapshot setSeries(series: PlotSeries): PlotSeries bindSeries(seriesId: string, binding: PlotSeriesBinding): PlotSeries updateBoundSeries(seriesId: string, points: PlotPoint[]): PlotSeries exportCsv(): string exportSvg(width?: number, height?: number): string } const cloneAxis = (axis: PlotAxis): PlotAxis => ({ ...axis }) const cloneSeries = (series: PlotSeries): PlotSeries => ({ ...series, points: series.points.map((point) => ({ ...point })), style: { ...series.style, dash: series.style.dash ? [...series.style.dash] : undefined }, binding: series.binding ? { ...series.binding } : undefined }) const xmlEscape = (value: string) => value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') const csvEscape = (value: string) => /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value const validateAxis = (axis: PlotAxis, name: string) => { if (axis.minimum !== undefined && !Number.isFinite(axis.minimum)) throw new RangeError(`${name} axis minimum must be finite.`) if (axis.maximum !== undefined && !Number.isFinite(axis.maximum)) throw new RangeError(`${name} axis maximum must be finite.`) if (axis.minimum !== undefined && axis.maximum !== undefined && axis.minimum >= axis.maximum) throw new RangeError(`${name} axis minimum must be less than maximum.`) if (axis.scale === 'log' && ((axis.minimum !== undefined && axis.minimum <= 0) || (axis.maximum !== undefined && axis.maximum <= 0))) throw new RangeError(`${name} log axis bounds must be greater than zero.`) } const validateSeries = (series: PlotSeries, xAxis?: PlotAxis, yAxis?: PlotAxis) => { if (!series.id.trim() || !series.label.trim()) throw new RangeError('Plot series requires an id and label.') if (series.points.length < 2 || series.points.some((point) => !Number.isFinite(point.x) || !Number.isFinite(point.y))) throw new RangeError('Plot series requires at least two finite points.') if (!/^#[0-9a-f]{6}$/i.test(series.style.color)) throw new RangeError(`Plot series color must be a six-digit hex value: ${series.style.color}`) if (!Number.isFinite(series.style.lineWidth) || series.style.lineWidth <= 0 || series.style.lineWidth > 20) throw new RangeError('Plot series line width must be greater than zero and no more than 20.') if (series.style.dash?.some((value) => !Number.isFinite(value) || value <= 0)) throw new RangeError('Plot dash values must be positive finite numbers.') if (xAxis?.scale === 'log' && series.points.some((point) => point.x <= 0)) throw new RangeError('Plot log X axis requires strictly positive series values.') if (yAxis?.scale === 'log' && series.points.some((point) => point.y <= 0)) throw new RangeError('Plot log Y axis requires strictly positive series values.') } const bounds = (series: PlotSeries[], axis: 'x' | 'y', configured: PlotAxis) => { const values = series.flatMap((entry) => entry.points.map((point) => point[axis])) const minimum = configured.minimum ?? Math.min(...values) const maximum = configured.maximum ?? Math.max(...values) if (!Number.isFinite(minimum) || !Number.isFinite(maximum)) return { minimum: 0, maximum: 1 } if (configured.scale === 'log' && (minimum <= 0 || maximum <= 0)) throw new RangeError(`Plot log ${axis.toUpperCase()} axis requires strictly positive values.`) if (minimum === maximum) return configured.scale === 'log' ? { minimum: minimum / 10, maximum: maximum * 10 } : { minimum: minimum - 0.5, maximum: maximum + 0.5 } return { minimum, maximum } } export const createPlot = (id = 'plot', label = 'Plot'): PlotApi => { let xAxis: PlotAxis = { label: 'X', scale: 'linear' } let yAxis: PlotAxis = { label: 'Y', scale: 'linear' } let legend = true let version = 0 const seriesById = new Map() const snapshot = (): PlotSnapshot => ({ id, label, xAxis: cloneAxis(xAxis), yAxis: cloneAxis(yAxis), legend, series: [...seriesById.values()].map(cloneSeries), version }) const setAxes = (input: { x: PlotAxis; y: PlotAxis }) => { validateAxis(input.x, 'X'); validateAxis(input.y, 'Y'); for (const series of seriesById.values()) validateSeries(series, input.x, input.y); xAxis = cloneAxis(input.x); yAxis = cloneAxis(input.y); version += 1; return snapshot() } const setLegend = (visible: boolean) => { legend = visible; version += 1; return snapshot() } const setSeries = (series: PlotSeries) => { validateSeries(series, xAxis, yAxis); const copy = cloneSeries(series); seriesById.set(copy.id, copy); version += 1; return cloneSeries(copy) } const bindSeries = (seriesId: string, binding: PlotSeriesBinding) => { const series = seriesById.get(seriesId) if (!series) throw new RangeError(`Plot series does not exist: ${seriesId}`) if (!binding.sourceId || !binding.xRange || !binding.yRange) throw new RangeError('Plot series binding requires source and ranges.') series.binding = { ...binding } version += 1 return cloneSeries(series) } const updateBoundSeries = (seriesId: string, points: PlotPoint[]) => { const series = seriesById.get(seriesId) if (!series?.binding) throw new RangeError(`Plot series is not bound: ${seriesId}`) return setSeries({ ...cloneSeries(series), points }) } const exportCsv = () => { const rows = [['series', 'label', 'x', 'y']] for (const series of seriesById.values()) for (const point of series.points) rows.push([series.id, series.label, String(point.x), String(point.y)]) return rows.map((row) => row.map(csvEscape).join(',')).join('\n') } const exportSvg = (width = 640, height = 360) => { if (!Number.isFinite(width) || !Number.isFinite(height) || width < 200 || height < 160 || width > 4096 || height > 4096) throw new RangeError('Plot SVG dimensions are outside the supported range.') const series = [...seriesById.values()] const x = bounds(series, 'x', xAxis) const y = bounds(series, 'y', yAxis) const margin = { left: 56, right: legend ? 144 : 24, top: 24, bottom: 48 } const plotWidth = width - margin.left - margin.right const plotHeight = height - margin.top - margin.bottom const normalize = (value: number, axis: PlotAxis, range: { minimum: number; maximum: number }) => axis.scale === 'log' ? (Math.log10(value) - Math.log10(range.minimum)) / (Math.log10(range.maximum) - Math.log10(range.minimum)) : (value - range.minimum) / (range.maximum - range.minimum) const mapX = (value: number) => margin.left + normalize(value, xAxis, x) * plotWidth const mapY = (value: number) => margin.top + plotHeight - normalize(value, yAxis, y) * plotHeight const paths = series.map((entry) => { const path = entry.points.map((point, index) => `${index === 0 ? 'M' : 'L'}${mapX(point.x).toFixed(3)} ${mapY(point.y).toFixed(3)}`).join(' ') const dash = entry.style.dash ? ` stroke-dasharray="${entry.style.dash.join(' ')}"` : '' return `` }).join('') const legendItems = legend ? series.map((entry, index) => `${xmlEscape(entry.label)}`).join('') : '' return `${paths}${xmlEscape(xAxis.label)}${xmlEscape(yAxis.label)}${legendItems}` } return { snapshot, setAxes, setLegend, setSeries, bindSeries, updateBoundSeries, exportCsv, exportSvg } }