feat: establish reproducible FreeCAD web compatibility baseline

This commit is contained in:
2026-08-10 16:11:38 -04:00
parent e5a5d74dbc
commit b962a5c3b5
733 changed files with 349081 additions and 647 deletions

101
src/facade/plot.ts Normal file
View File

@@ -0,0 +1,101 @@
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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<string, PlotSeries>()
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 `<path data-series="${xmlEscape(entry.id)}" d="${path}" fill="none" stroke="${entry.style.color}" stroke-width="${entry.style.lineWidth}"${dash}/>`
}).join('')
const legendItems = legend ? series.map((entry, index) => `<g transform="translate(${width - margin.right + 18} ${margin.top + 18 + index * 22})"><line x2="22" stroke="${entry.style.color}" stroke-width="${entry.style.lineWidth}"/><text x="30" y="4">${xmlEscape(entry.label)}</text></g>`).join('') : ''
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${xmlEscape(label)}" data-x-scale="${xAxis.scale}" data-y-scale="${yAxis.scale}"><rect width="100%" height="100%" fill="#ffffff"/><g font-family="sans-serif" font-size="12" fill="#202428"><line x1="${margin.left}" y1="${margin.top + plotHeight}" x2="${margin.left + plotWidth}" y2="${margin.top + plotHeight}" stroke="#59616a"/><line x1="${margin.left}" y1="${margin.top}" x2="${margin.left}" y2="${margin.top + plotHeight}" stroke="#59616a"/>${paths}<text x="${margin.left + plotWidth / 2}" y="${height - 12}" text-anchor="middle">${xmlEscape(xAxis.label)}</text><text transform="translate(16 ${margin.top + plotHeight / 2}) rotate(-90)" text-anchor="middle">${xmlEscape(yAxis.label)}</text>${legendItems}</g></svg>`
}
return { snapshot, setAxes, setLegend, setSeries, bindSeries, updateBoundSeries, exportCsv, exportSvg }
}