nhamid289's picture
Deploy decision_boundary from optimization-design-system (plain background, shared UI updates)
6c17bbb verified
Raw History Blame Contribute Delete
7.15 kB
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<T>(result: Result<T>, 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<DataPoints>({
xPoints: [],
yPoints: [],
labels: [],
});
const labelColors = new LabelColors(dataPoints.labels);
const [resolution, _] = useState<number>(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<ModelSettings>({
type: "logistic_regression",
params: {},
});
const [modelError, setModelError] = useState<string | null>(null);
// From the last fit; cleared when the data or model changes without a refit
const [modelResults, setModelResults] = useState<ModelResults | null>(null);
// Refit whenever the data or model changes, instead of waiting for the button
const [autoFit, setAutoFit] = useState(true);
const autoFitTimer = useRef<ReturnType<typeof setTimeout> | 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<DecisionBoundaryResult | null>(null);
// this is needed for svg export
const plotHandle = useRef<Plotly.PlotlyHTMLElement | null>(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,
}
}