import { useEffect, useState, useRef } from "react"; import { usePythonStatus, type Result } from "@elvis/pyodide"; import type { Dataset, ModelResults } from "@elvis/ui"; import type { ModelSettings, DataPoints, DecisionBoundarySettings, DecisionBoundaryResult } from "./types.ts"; import { LabelColors } from "./colors.ts"; import { python } from "./python.ts"; const DEFAULT_RESOLUTION = 500; const DEFAULT_X_RANGE: [number, number] = [-10, 10]; const DEFAULT_Y_RANGE: [number, number] = [-10, 10]; // With auto-fit on, wait this long after a model setting stops changing const AUTO_FIT_DELAY_MS = 500; // The axis range covering values, with 10% padding on each side function paddedRange(values: number[]): [number, number] { const min = Math.min(...values); const max = Math.max(...values); const pad = max > min ? 0.1 * (max - min) : 1; return [min - pad, max + pad]; } // Returns the call's data, or null on error. User errors go to showUserError // if given; everything else is logged. function dataOf(result: Result, showUserError?: (message: string) => void): T | null { if (result.ok) { return result.data; } if (result.error.kind === "user" && showUserError) { showUserError(result.error.message); } else { console.error(`Python ${result.error.kind} error: ${result.error.message}`); } return null; } export default function useAppLogic() { const pythonStatus = usePythonStatus(python); const [dataPoints, setDataPoints] = useState({ xPoints: [], yPoints: [], labels: [], }); const labelColors = new LabelColors(dataPoints.labels); const [resolution, _] = useState(DEFAULT_RESOLUTION); // todo user defined const xRangeRef = useRef<[number, number]>(DEFAULT_X_RANGE); const yRangeRef = useRef<[number, number]>(DEFAULT_Y_RANGE); function handleRangeChange(xRange: [number, number], yRange: [number, number]) { xRangeRef.current = xRange; yRangeRef.current = yRange; updateDecisionBoundary(); } const [modelSettings, setModelSettings] = useState({ type: "logistic_regression", params: {}, }); const [modelError, setModelError] = useState(null); // From the last fit; cleared when the data or model changes without a refit const [modelResults, setModelResults] = useState(null); // Refit whenever the data or model changes, instead of waiting for the button const [autoFit, setAutoFit] = useState(true); const autoFitTimer = useRef | null>(null); // Counts dataset changes, so only the newest one triggers a refit const datasetVersion = useRef(0); // After the data or model changes: refit if auto-fit is on and there are // two classes to separate, otherwise just redraw (an unfitted model draws nothing) async function refresh(labels: string[]) { if (autoFit && new Set(labels).size >= 2) { await handleGetDecisionBoundary(); return; } if (autoFit) { setModelError(null); } setModelResults(null); await updateDecisionBoundary(); } function handleAutoFitChange(on: boolean) { setAutoFit(on); if (on && new Set(dataPoints.labels).size >= 2) { handleGetDecisionBoundary(); } } async function handleModelSettingsChange(settings: ModelSettings) { // A setting changes with each keystroke; a new model doesn't const settingChanged = settings.type === modelSettings.type; setModelSettings(settings); // The old results no longer describe the model; with auto-fit they stay until the refit if (!autoFit) { setModelResults(null); } dataOf(await python.call("handle_set_model_config", settings)); if (!autoFit) { await updateDecisionBoundary(); return; } // Wait for a pause so half-typed values don't show errors if (autoFitTimer.current) { clearTimeout(autoFitTimer.current); } autoFitTimer.current = setTimeout(() => refresh(dataPoints.labels), settingChanged ? AUTO_FIT_DELAY_MS : 0); } const [decisionBoundaryResult, setDecisionBoundaryResult] = useState(null); // this is needed for svg export const plotHandle = useRef(null); // The dataset from the picker: plot its projected points and send the full // inputs to Python. fitView zooms to the points; an empty plot resets the view. async function handleSetDataset(dataset: Dataset | null, fitView: boolean) { if (!autoFit) { setModelResults(null); } const labels = dataset ? dataset.targets.map(String) : []; const next = { xPoints: dataset?.points.x ?? [], yPoints: dataset?.points.y ?? [], labels }; if (labels.length === 0) { xRangeRef.current = DEFAULT_X_RANGE; yRangeRef.current = DEFAULT_Y_RANGE; } else if (fitView) { xRangeRef.current = paddedRange(next.xPoints); yRangeRef.current = paddedRange(next.yPoints); } setDataPoints(next); // A new table's labels don't match the old boundary, so don't show it meanwhile if (fitView) { setDecisionBoundaryResult(null); } const version = ++datasetVersion.current; dataOf(await python.call("handle_set_dataset", dataset ? { inputs: dataset.inputs, outputs: labels, input_names: dataset.inputNames, projection: dataset.projection } : { inputs: [], outputs: [] })); // A newer dataset is already on its way; let it do the refit if (version === datasetVersion.current) { await refresh(labels); } } async function updateDecisionBoundary() { const settings: DecisionBoundarySettings = { xmin: xRangeRef.current[0], xmax: xRangeRef.current[1], ymin: yRangeRef.current[0], ymax: yRangeRef.current[1], resolution: resolution, }; const result = dataOf(await python.call("handle_get_decision_boundary", settings), setModelError); if (result) { setDecisionBoundaryResult(result); } } async function handleGetDecisionBoundary() { const built = await python.call("handle_build_model"); setModelResults(dataOf(built, setModelError)); if (!built.ok) { return; } setModelError(null); await updateDecisionBoundary(); } function getPlotImage(format: "svg" | "png") { if (!plotHandle.current) { return; } (plotHandle.current as any)?.downloadImage(format) } console.log("rendering app"); // Python runs calls in the order they are made, so send these together // rather than awaiting each: user actions made while Python loads queue after them useEffect(() => { python.call("handle_set_model_config", modelSettings).then((result) => dataOf(result)); updateDecisionBoundary(); }, []); return { pythonStatus, dataPoints, labelColors, xRangeRef, yRangeRef, handleRangeChange, modelSettings, handleModelSettingsChange, modelError, modelResults, autoFit, handleAutoFitChange, decisionBoundaryResult, handleSetDataset, handleGetDecisionBoundary, plotHandle, getPlotImage, } }