diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..5747cfb --- /dev/null +++ b/backend/app.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import io +from typing import Any + +from flask import Flask, jsonify, request +from openpyxl import load_workbook + +app = Flask(__name__) + + +def _extract_columns_from_xlsx(file_bytes: bytes, sheet_index: int) -> list[str]: + workbook = load_workbook(io.BytesIO(file_bytes), read_only=True, data_only=True) + sheets = workbook.worksheets + if not sheets: + return [] + index = min(max(sheet_index, 0), len(sheets) - 1) + first_row = next(sheets[index].iter_rows(min_row=1, max_row=1, values_only=True), ()) + return [str(cell).strip() for cell in first_row if isinstance(cell, str) and cell.strip()] + + +@app.post('/api/render-plot') +def render_plot() -> Any: + payload = request.get_json(silent=True) or {} + config = payload.get('config') + if not isinstance(config, dict): + return jsonify({'message': 'Missing config payload.'}), 400 + + svg = """ + + Backend SVG placeholder + Implement plotting logic inside backend/app.py render_plot(). + Received config keys: %s +""" % ', '.join(sorted(config.keys())) + + return jsonify({'svg': svg}) + + +@app.post('/api/import-database') +def import_database() -> Any: + if request.is_json: + payload = request.get_json(silent=True) or {} + teable_url = payload.get('teable_url') + api_key = payload.get('API_Key') + if not teable_url or not api_key: + return jsonify({'success': False, 'message': 'Missing teable_url or API_Key.'}), 400 + return jsonify({'success': True, 'columns': []}) + + upload = request.files.get('file') + if upload is None: + return jsonify({'success': False, 'message': 'Missing uploaded file.'}), 400 + + try: + sheet_index = int(request.form.get('import_sheet', '0')) + except ValueError: + sheet_index = 0 + + columns = _extract_columns_from_xlsx(upload.read(), sheet_index) + return jsonify({'success': True, 'columns': columns}) + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8000, debug=True) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..d5fa611 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,2 @@ +flask==3.1.1 +openpyxl==3.1.5 diff --git a/package.json b/package.json index 88b74d5..43cc32d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "backend": "python3 backend/app.py" }, "dependencies": { "plotly.js-dist-min": "^3.5.0", diff --git a/src/App.tsx b/src/App.tsx index b646ffb..b155e1f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react' +import { useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import { PlotPage } from './components/PlotPage' import { Alert } from './components/ui/alert' import { Button } from './components/ui/button' @@ -31,6 +31,9 @@ const WHITELIST_OPTIONS: MultiOption[] = [ { value: 'PETG', label: 'PETG' }, ] +const DEFAULT_THEME = 'system' +type ThemeMode = 'light' | 'dark' | 'system' + const numberValue = (value: number, fallback: number): number => (Number.isFinite(value) ? value : fallback) const parseColumnsFromImportResult = (value: unknown): string[] => { if (!Array.isArray(value)) { @@ -61,6 +64,42 @@ const getAxisBasesFromColumns = (columns: string[]): string[] => { .map(([base]) => base) .sort((a, b) => a.localeCompare(b)) } + +const getNextTabName = (names: Array, prefix: string): string => { + const usedNumbers = new Set() + for (const entry of names) { + const value = entry?.trim() + if (!value) continue + const match = value.match(new RegExp(`^${prefix}\\s+(\\d+)$`, 'i')) + if (match) { + usedNumbers.add(Number(match[1])) + } + } + + let candidate = 1 + while (usedNumbers.has(candidate)) { + candidate += 1 + } + return `${prefix} ${candidate}` +} + +const getConfigLanguages = (config: PlotConfig): string[] => { + const languages = new Set() + for (const dataframe of config.dataframes) { + dataframe.plotLanguages.forEach((entry) => entry && languages.add(entry)) + languages.add(dataframe.language) + Object.keys(dataframe.legendTitle).forEach((entry) => entry && languages.add(entry)) + dataframe.axes.forEach((axis) => Object.keys(axis.labels).forEach((entry) => entry && languages.add(entry))) + dataframe.frames.forEach((frame) => Object.keys(frame.title).forEach((entry) => entry && languages.add(entry))) + } + return [...languages].sort((a, b) => a.localeCompare(b)) +} + +const getConfigWhitelistKeywords = (config: PlotConfig): string[] => + [...new Set(config.dataframes.flatMap((df) => df.frames.flatMap((frame) => frame.layers.flatMap((layer) => layer.whitelist ?? []))))].sort((a, b) => a.localeCompare(b)) + +const getConfigAxisColumns = (config: PlotConfig): string[] => + [...new Set(config.dataframes.flatMap((df) => df.axes.flatMap((axis) => axis.columns)))].sort((a, b) => a.localeCompare(b)) const getSourceMode = (dataframe: DataframeConfig): SourceMode => dataframe._extensions.sourceMode === 'teable' || dataframe._extensions.sourceMode === 'file' ? dataframe._extensions.sourceMode @@ -181,7 +220,17 @@ function App() { const [plotLanguageDraft, setPlotLanguageDraft] = useState('') const [uiLanguage, setUiLanguage] = useState('en') const [availableColumns, setAvailableColumns] = useState([]) + const [availableWhitelistKeywords, setAvailableWhitelistKeywords] = useState(WHITELIST_OPTIONS) const [importInProgress, setImportInProgress] = useState(false) + const [tabRename, setTabRename] = useState<{ type: 'dataframe' | 'frame'; index: number; value: string } | null>(null) + const [showMenu, setShowMenu] = useState(false) + const [showAbout, setShowAbout] = useState(false) + const [showSettings, setShowSettings] = useState(false) + const [themeMode, setThemeMode] = useState(() => { + if (typeof window === 'undefined') return DEFAULT_THEME + const stored = window.localStorage.getItem('ui-theme') + return stored === 'light' || stored === 'dark' || stored === 'system' ? stored : DEFAULT_THEME + }) const activeDataframe = plotConfig.dataframes[activeDataframeIndex] ?? plotConfig.dataframes[0] const activeFrame = activeDataframe.frames[activeFrameIndex] ?? activeDataframe.frames[0] @@ -214,6 +263,33 @@ function App() { .map((column) => ({ value: column, label: column })) }, [availableColumns]) + useEffect(() => { + const params = new URLSearchParams(window.location.search) + const df = Number(params.get('dataframe')) + const frame = Number(params.get('frame')) + if (Number.isInteger(df) && df >= 0) { + setActiveDataframeIndex(df) + } + if (Number.isInteger(frame) && frame >= 0) { + setActiveFrameIndex(frame) + } + }, []) + + useEffect(() => { + const params = new URLSearchParams(window.location.search) + params.set('dataframe', String(activeDataframeIndex)) + params.set('frame', String(activeFrameIndex)) + window.history.replaceState(null, '', `${window.location.pathname}?${params.toString()}`) + }, [activeDataframeIndex, activeFrameIndex]) + + useEffect(() => { + const html = document.documentElement + const darkPreferred = window.matchMedia('(prefers-color-scheme: dark)').matches + const useDark = themeMode === 'dark' || (themeMode === 'system' && darkPreferred) + html.classList.toggle('dark', useDark) + window.localStorage.setItem('ui-theme', themeMode) + }, [themeMode]) + const patchDataframe = (index: number, patch: (current: DataframeConfig) => DataframeConfig) => { setPlotConfig((current) => ({ ...current, dataframes: current.dataframes.map((df, i) => (i === index ? patch(df) : df)) })) } @@ -223,13 +299,23 @@ function App() { } const addDataframe = () => { - setPlotConfig((current) => ({ ...current, dataframes: [...current.dataframes, structuredClone(current.dataframes[0])] })) - setActiveDataframeIndex(plotConfig.dataframes.length) - setActiveFrameIndex(0) + setPlotConfig((current) => { + const nextIndex = current.dataframes.length + const source = structuredClone(current.dataframes[0]) + source.name = getNextTabName(current.dataframes.map((df) => df.name), 'Dataframe') + source.frames = source.frames.map((frame, frameIndex) => ({ ...frame, name: `Frame ${frameIndex + 1}` })) + setActiveDataframeIndex(nextIndex) + setActiveFrameIndex(0) + return { ...current, dataframes: [...current.dataframes, source] } + }) } const addFrame = () => { - patchActiveDataframe((df) => ({ ...df, frames: [...df.frames, structuredClone(df.frames[0])] })) + patchActiveDataframe((df) => { + const next = structuredClone(df.frames[0]) + next.name = getNextTabName(df.frames.map((frame) => frame.name), 'Frame') + return { ...df, frames: [...df.frames, next] } + }) setActiveFrameIndex(activeDataframe.frames.length) } @@ -355,7 +441,29 @@ function App() { } const columns = parseColumnsFromImportResult(payload.columns) + const knownColumns = new Set(columns) + const unknownColumns = new Set() + for (const axis of activeDataframe.axes) { + for (const column of axis.columns) { + if (!knownColumns.has(column)) { + unknownColumns.add(column) + } + } + } + for (const layer of activeFrame.layers) { + if (layer.name && !knownColumns.has(layer.name)) { + unknownColumns.add(layer.name) + } + } + if (unknownColumns.size > 0) { + throw new Error(`Database import error: unknown column(s): ${[...unknownColumns].join(', ')}`) + } setAvailableColumns(columns) + setAvailableWhitelistKeywords( + [...new Set([...activeDataframe.frames.flatMap((frame) => frame.layers.flatMap((layer) => layer.whitelist ?? []))])] + .sort((a, b) => a.localeCompare(b)) + .map((entry) => ({ value: entry, label: entry })), + ) setAlert({ tone: 'success', message: @@ -419,7 +527,21 @@ function App() { if (!file) return try { const normalized = normalizePlotConfig(parseImportedConfig(await file.text(), true)) - setPlotConfig(normalized) + const detectedLanguages = getConfigLanguages(normalized) + const normalizedWithLanguages: PlotConfig = { + ...normalized, + dataframes: normalized.dataframes.map((df) => { + const languages = detectedLanguages.length > 0 ? detectedLanguages : df.plotLanguages + return { + ...df, + plotLanguages: languages, + language: languages.includes(df.language) ? df.language : (languages[0] ?? 'en'), + } + }), + } + setPlotConfig(normalizedWithLanguages) + setAvailableColumns(getConfigAxisColumns(normalizedWithLanguages)) + setAvailableWhitelistKeywords(getConfigWhitelistKeywords(normalizedWithLanguages).map((entry) => ({ value: entry, label: entry }))) setAlert({ tone: 'success', message: `Imported ${file.name} successfully.` }) } catch { setAlert({ tone: 'error', message: 'Invalid config file.' }) @@ -436,6 +558,30 @@ function App() { } } + const openTabWithSelection = (dataframeIndex: number, frameIndex: number) => { + const params = new URLSearchParams(window.location.search) + params.set('dataframe', String(dataframeIndex)) + params.set('frame', String(frameIndex)) + window.open(`${window.location.pathname}?${params.toString()}`, '_blank', 'noopener,noreferrer') + } + + const applyTabRename = () => { + if (!tabRename) return + const trimmed = tabRename.value.trim() + if (tabRename.type === 'dataframe') { + patchDataframe(tabRename.index, (df) => ({ ...df, name: trimmed || undefined })) + if (tabRename.index === activeDataframeIndex) { + setActiveFrameIndex(0) + } + } else { + patchActiveDataframe((df) => ({ + ...df, + frames: df.frames.map((frame, index) => (index === tabRename.index ? { ...frame, name: trimmed || undefined } : frame)), + })) + } + setTabRename(null) + } + return (
@@ -443,25 +589,58 @@ function App() {

Ashby Plot Builder

Human-readable editor with editable JSON popup.

-
+
+ + {showMenu ? ( +
+ + +
+ ) : null}
{activePage === 'config' ? ( -
+
{t('dataframe')} {plotConfig.dataframes.map((df, index) => (
- + {tabRename?.type === 'dataframe' && tabRename.index === index ? ( + setTabRename((current) => (current ? { ...current, value: event.target.value } : current))} + onBlur={applyTabRename} + onKeyDown={(event) => { + if (event.key === 'Enter') applyTabRename() + if (event.key === 'Escape') setTabRename(null) + }} + className="h-8 w-36" + /> + ) : ( + + )}
))} @@ -471,9 +650,35 @@ function App() { {t('frame')} {activeDataframe.frames.map((frame, index) => (
- + {tabRename?.type === 'frame' && tabRename.index === index ? ( + setTabRename((current) => (current ? { ...current, value: event.target.value } : current))} + onBlur={applyTabRename} + onKeyDown={(event) => { + if (event.key === 'Enter') applyTabRename() + if (event.key === 'Escape') setTabRename(null) + }} + className="h-8 w-28" + /> + ) : ( + + )}
))} @@ -490,7 +695,6 @@ function App() { - patchActiveDataframe((c) => ({ ...c, name: e.target.value || undefined }))} /> patchActiveFrame((c) => ({ ...c, name: e.target.value || undefined }))} /> patchActiveFrame((c) => ({ ...c, exportFileName: e.target.value || undefined }))} /> @@ -649,7 +852,7 @@ function App() { - patchActiveFrame((f) => ({ ...f, layers: f.layers.map((x, i) => i === layerIndex ? { ...x, whitelist: next } : x) }))} /> + patchActiveFrame((f) => ({ ...f, layers: f.layers.map((x, i) => i === layerIndex ? { ...x, whitelist: next } : x) }))} /> patchActiveFrame((f) => ({ ...f, layers: f.layers.map((x, i) => i === layerIndex ? { ...x, alpha: Number.isFinite(e.target.valueAsNumber) ? e.target.valueAsNumber : undefined } : x) }))} /> patchActiveFrame((f) => ({ ...f, layers: f.layers.map((x, i) => i === layerIndex ? { ...x, linewidth: Math.max(0, numberValue(e.target.valueAsNumber, x.linewidth ?? 1.5)) } : x) }))} /> patchActiveFrame((f) => ({ ...f, layers: f.layers.map((x, i) => i === layerIndex ? { ...x, alphaPoints: Number.isFinite(e.target.valueAsNumber) ? e.target.valueAsNumber : undefined } : x) }))} /> @@ -729,9 +932,43 @@ function App() {
) : ( - + )} + {showAbout ? ( +
+
+

About

+

+ Ashby Plot Builder helps create and edit plotting configs with live backend SVG rendering. +

+
+ +
+
+
+ ) : null} + + {showSettings ? ( +
+
+

Settings

+
+ + + +
+
+ +
+
+
+ ) : null} + {showJson ? (
diff --git a/src/components/PlotPage.tsx b/src/components/PlotPage.tsx index 1498c60..ec932be 100644 --- a/src/components/PlotPage.tsx +++ b/src/components/PlotPage.tsx @@ -1,381 +1,72 @@ -import { useEffect, useMemo, useRef } from 'react' -import type { AxisConfig, DataframeConfig, FrameConfig, GuidelineConfig, PlotConfig } from '../config/defaultPlotConfig' -import { materialSamples, type MaterialSample } from '../data/materialSamples' +import { useEffect, useState } from 'react' +import { toExternalConfig } from '../utils/configIo' +import type { PlotConfig } from '../config/defaultPlotConfig' import { Alert } from './ui/alert' +import { Button } from './ui/button' interface Props { plotConfig: PlotConfig - activeDataframeIndex: number - activeFrameIndex: number } -type MarkerTrace = { - x: number[] - y: number[] - text: string[] - customdata: string[][] - mode: 'markers' - type: 'scatter' - name: string - marker: { - color: string - opacity: number - size: number - line: { - color: string - width: number - } - } - hovertemplate: string -} - -type AreaTrace = { - x: number[] - y: number[] - type: 'scatter' - mode: 'lines' - fill: 'toself' - name: string - line: { - color: string - width: number - } - fillcolor: string - opacity: number - hoverinfo: 'skip' - showlegend: false -} - -type PlotTrace = MarkerTrace | AreaTrace - -const asNumber = (value: MaterialSample[string]): number | null => - typeof value === 'number' && Number.isFinite(value) ? value : null - -const getAxisLabel = (axis: AxisConfig | undefined, language: string): string => - axis?.labels[language] || axis?.labels.en || axis?.name || '' - -const getRecordNumber = (record: MaterialSample, column: string): number | null => { - const candidates = [ - column, - column.replace('18 MPa', '1.8 MPa'), - `${column} low`, - `${column} high`, - `${column.replace('18 MPa', '1.8 MPa')} low`, - `${column.replace('18 MPa', '1.8 MPa')} high`, - ] - - for (const candidate of candidates) { - const value = asNumber(record[candidate]) - if (value !== null) { - return value - } - } - - return null -} - -const getAxisValue = (record: MaterialSample, axis: AxisConfig | undefined): number | null => { - if (!axis) { - return null - } - - const values = axis.columns - .map((column) => getRecordNumber(record, column)) - .filter((value): value is number => value !== null) - - if (values.length === 0) { - return null - } - - if (axis.mode === 'max' || axis.mode === 'span') { - return Math.max(...values) - } - - if (axis.mode === 'min') { - return Math.min(...values) - } - - return values[0] -} - -const getRelativeValue = (record: MaterialSample, dataframe: DataframeConfig, quantity?: string): number | null => { - if (!quantity) { - return null - } - - const axis = dataframe.axes.find((entry) => entry.name === quantity) - return axis ? getAxisValue(record, axis) : getRecordNumber(record, quantity) -} - -const passesLayerFilter = (record: MaterialSample, layer: FrameConfig['layers'][number]): boolean => { - const field = layer.name || 'Material' - const value = String(record[field] ?? '') - const whitelist = layer.whitelist?.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) ?? [] - - if (whitelist.length === 0) { - return true - } - - const hasMatch = whitelist.some((entry) => value.toLowerCase().includes(entry.toLowerCase())) - return layer.whitelistFlag ? hasMatch : !hasMatch -} - -const getColor = (dataframe: DataframeConfig, material: string): string => - dataframe.materialColors[material] || dataframe.materialColors.default || '#111827' +export function PlotPage({ plotConfig }: Props) { + const [svgMarkup, setSvgMarkup] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) -const getGuidelineShapes = (guidelines: GuidelineConfig[]) => - guidelines - .map((guideline) => { - if (guideline.x !== undefined) { - return { - type: 'line', - xref: 'x', - yref: 'paper', - x0: guideline.x, - x1: guideline.x, - y0: 0, - y1: 1, - line: { - color: guideline.lineProps.color, - width: guideline.lineProps.linewidth, - dash: guideline.lineProps.linestyle === '--' ? 'dash' : 'solid', - }, - } - } - - if (guideline.y !== undefined) { - return { - type: 'line', - xref: 'paper', - yref: 'y', - x0: 0, - x1: 1, - y0: guideline.y, - y1: guideline.y, - line: { - color: guideline.lineProps.color, - width: guideline.lineProps.linewidth, - dash: guideline.lineProps.linestyle === '--' ? 'dash' : 'solid', - }, - } - } + const fetchPlot = async () => { + setLoading(true) + setError(null) - return null - }) - .filter(Boolean) - -const getColoredAreaTraces = (frame: FrameConfig): PlotTrace[] => - frame.coloredAreas - .map((area, index) => { - if (!Array.isArray(area.x) || !Array.isArray(area.y) || area.x.length < 2 || area.y.length < 2) { - return null - } - - const trace: AreaTrace = { - x: area.x, - y: area.y, - type: 'scatter', - mode: 'lines', - fill: 'toself', - name: `Area ${index + 1}`, - line: { color: area.color, width: 1 }, - fillcolor: area.color, - opacity: area.alpha ?? 0.25, - hoverinfo: 'skip', - showlegend: false, - } - return trace - }) - .filter((entry): entry is AreaTrace => entry !== null) - -const getPlotAnnotations = ( - frame: FrameConfig, - xAxis: AxisConfig | undefined, - yAxis: AxisConfig | undefined, -) => - frame.annotations - .map((annotation) => { - if (!annotation.text?.name || !annotation.axes) { - return null - } - - const xAxisName = xAxis?.name ?? frame.xQuantity - const yAxisName = yAxis?.name ?? frame.yQuantity - const xBase = annotation.axes[xAxisName] ?? annotation.axes.x - const yBase = annotation.axes[yAxisName] ?? annotation.axes.y - if (!Number.isFinite(xBase) || !Number.isFinite(yBase)) { - return null - } - - const [xOffset, yOffset] = annotation.text.relPos ?? [0, 0] - return { - x: xBase, - y: yBase, - text: annotation.text.name, - showarrow: true, - arrowhead: 2, - arrowsize: annotation.arrow?.headwidth ?? 1, - arrowwidth: annotation.arrow?.linewidth ?? 1, - arrowcolor: annotation.arrow?.facecolor || annotation.text.color || '#111827', - ax: xOffset, - ay: yOffset, - font: { - size: annotation.text.fontSize ?? annotation.fontSize ?? 12, - color: annotation.text.color || '#111827', + try { + const response = await fetch('/api/render-plot', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', }, - bgcolor: 'rgba(255,255,255,0.7)', - } - }) - .filter(Boolean) + body: JSON.stringify({ config: toExternalConfig(plotConfig) }), + }) -function buildPlot(plotConfig: PlotConfig, activeDataframeIndex: number, activeFrameIndex: number) { - const dataframe = plotConfig.dataframes[activeDataframeIndex] ?? plotConfig.dataframes[0] - const frame = dataframe.frames[activeFrameIndex] ?? dataframe.frames[0] - const xAxis = dataframe.axes.find((axis) => axis.name === frame.xQuantity) - const yAxis = dataframe.axes.find((axis) => axis.name === frame.yQuantity) - const pointGroups = new Map() - - for (const [layerIndex, dataLayer] of frame.layers.entries()) { - for (const record of materialSamples) { - if (!passesLayerFilter(record, dataLayer)) { - continue + if (!response.ok) { + const payload = (await response.json().catch(() => ({}))) as { message?: string } + throw new Error(payload.message || `Plot render failed (${response.status}).`) } - const xBase = getAxisValue(record, xAxis) - const yBase = getAxisValue(record, yAxis) - const xRel = getRelativeValue(record, dataframe, frame.xRelQuantity) - const yRel = getRelativeValue(record, dataframe, frame.yRelQuantity) - const x = xBase !== null && xRel ? xBase / xRel : xBase - const y = yBase !== null && yRel ? yBase / yRel : yBase - - if (x === null || y === null) { - continue + const payload = (await response.json()) as { svg?: string; message?: string } + if (!payload.svg) { + throw new Error(payload.message || 'Backend did not return an SVG.') } - const material = String(record.Material ?? 'Material') - const groupName = `${dataLayer.name || `Layer ${layerIndex + 1}`} · ${material}` - const trace = - pointGroups.get(groupName) ?? - { - x: [], - y: [], - text: [], - customdata: [], - mode: 'markers', - type: 'scatter', - name: groupName, - marker: { - color: getColor(dataframe, material), - opacity: dataLayer.alphaPoints ?? dataLayer.alpha ?? 0.72, - size: 11, - line: { - color: getColor(dataframe, material), - width: dataLayer.linewidth ?? 1.5, - }, - }, - hovertemplate: - '%{text}
%{customdata[0]}
%{customdata[1]}
x: %{x}
y: %{y}', - } - - trace.x.push(x) - trace.y.push(y) - trace.text.push(String(record.Variante ?? material)) - trace.customdata.push([String(record.Hersteller ?? ''), String(record.Gruppe ?? '')]) - pointGroups.set(groupName, trace) + setSvgMarkup(payload.svg) + } catch (renderError) { + setSvgMarkup(null) + setError(renderError instanceof Error ? renderError.message : 'Failed to render plot.') + } finally { + setLoading(false) } } - const darkMode = frame.darkMode || dataframe.darkMode - const paperColor = darkMode ? '#18181b' : '#ffffff' - const plotColor = darkMode ? '#09090b' : '#fafafa' - const textColor = darkMode ? '#f4f4f5' : '#18181b' - const gridColor = darkMode ? '#3f3f46' : '#e4e4e7' - - return { - traces: [...getColoredAreaTraces(frame), ...pointGroups.values()], - layout: { - autosize: true, - paper_bgcolor: paperColor, - plot_bgcolor: plotColor, - font: { - family: dataframe.font.font, - size: dataframe.font.fontSize, - color: textColor, - }, - title: { - text: frame.title[dataframe.language] || frame.title.en || frame.name || 'Ashby plot', - }, - xaxis: { - title: getAxisLabel(xAxis, dataframe.language), - type: frame.logXFlag ? 'log' : 'linear', - range: frame.xLim ?? undefined, - gridcolor: gridColor, - zerolinecolor: gridColor, - }, - yaxis: { - title: getAxisLabel(yAxis, dataframe.language), - type: frame.logYFlag ? 'log' : 'linear', - range: frame.yLim ?? undefined, - gridcolor: gridColor, - zerolinecolor: gridColor, - }, - legend: { - orientation: frame.legendAbove ? 'h' : 'v', - x: frame.legendAbove ? 0 : 1.02, - y: frame.legendAbove ? 1.18 : 1, - title: { text: dataframe.legendTitle[dataframe.language] || dataframe.legendTitle.en || 'Material' }, - }, - margin: { l: 88, r: frame.legendFlag ? 128 : 24, t: frame.legendAbove ? 108 : 64, b: 72 }, - showlegend: frame.legendFlag, - shapes: getGuidelineShapes(frame.guidelines), - annotations: getPlotAnnotations(frame, xAxis, yAxis), - }, - } -} - -export function PlotPage({ plotConfig, activeDataframeIndex, activeFrameIndex }: Props) { - const plotRef = useRef(null) - const { traces, layout } = useMemo( - () => buildPlot(plotConfig, activeDataframeIndex, activeFrameIndex), - [plotConfig, activeDataframeIndex, activeFrameIndex], - ) - useEffect(() => { - const element = plotRef.current - if (!element) { - return - } - - let cancelled = false - let plotly: typeof import('plotly.js-dist-min').default | null = null - - import('plotly.js-dist-min').then((module) => { - if (cancelled) { - return - } - - plotly = module.default - plotly.react(element, traces, layout, { responsive: true, displaylogo: false }) - }) - - const resize = () => plotly?.Plots.resize(element) - window.addEventListener('resize', resize) - - return () => { - cancelled = true - window.removeEventListener('resize', resize) - plotly?.purge(element) - } - }, [layout, traces]) + void fetchPlot() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [plotConfig]) return (
- {traces.length === 0 ? ( - No rows match the active frame axes and layer filter. - ) : null} +
+
+

Backend SVG preview

+

The complete JSON config is sent to the Python backend.

+
+ +
+ + {error ? {error} : null} -
-
+
+ {loading && !svgMarkup ?

Rendering SVG from backend…

: null} + {svgMarkup ?
: null}
) diff --git a/src/index.css b/src/index.css index 98c9b64..198bf1c 100644 --- a/src/index.css +++ b/src/index.css @@ -9,7 +9,7 @@ body { } #root { - width: 1126px; + width: min(96vw, 1920px); max-width: 100%; margin: 0 auto; min-height: 100svh; @@ -17,8 +17,6 @@ body { box-sizing: border-box; } -@media (prefers-color-scheme: dark) { - #root { - border-inline-color: rgb(39 39 42); - } +.dark #root { + border-inline-color: rgb(39 39 42); } diff --git a/src/utils/configIo.ts b/src/utils/configIo.ts index 6f05759..f1acee5 100644 --- a/src/utils/configIo.ts +++ b/src/utils/configIo.ts @@ -9,7 +9,7 @@ function stripJsonComments(text: string): string { .replace(JSONC_LINE_COMMENT, (_match, prefix: string) => prefix) } -function toExternalConfig(config: PlotConfig): unknown { +export function toExternalConfig(config: PlotConfig): unknown { return { version: config.version, create_all_dataframes: config.createAllDataframes, diff --git a/vite.config.ts b/vite.config.ts index 249c3d2..7efcb77 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,4 +4,12 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react(), tailwindcss()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, })