Merge pull request #889 from quantified-uncertainty/autorun-improvements

Autorun improvements
This commit is contained in:
Ozzie Gooen 2022-07-27 12:36:34 -07:00 committed by GitHub
commit dcd25c2254
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 208 additions and 89 deletions

View File

@ -14,6 +14,8 @@ import { SquiggleViewer } from "./SquiggleViewer";
export interface SquiggleChartProps { export interface SquiggleChartProps {
/** The input string for squiggle */ /** The input string for squiggle */
code?: string; code?: string;
/** Allows to re-run the code if code hasn't changed */
executionId?: number;
/** If the output requires monte carlo sampling, the amount of samples */ /** If the output requires monte carlo sampling, the amount of samples */
sampleCount?: number; sampleCount?: number;
/** The amount of points returned to draw the distribution */ /** The amount of points returned to draw the distribution */
@ -59,6 +61,7 @@ const defaultOnChange = () => {};
export const SquiggleChart: React.FC<SquiggleChartProps> = React.memo( export const SquiggleChart: React.FC<SquiggleChartProps> = React.memo(
({ ({
code = "", code = "",
executionId = 0,
environment, environment,
onChange = defaultOnChange, // defaultOnChange must be constant, don't move its definition here onChange = defaultOnChange, // defaultOnChange must be constant, don't move its definition here
height = 200, height = 200,
@ -85,6 +88,7 @@ export const SquiggleChart: React.FC<SquiggleChartProps> = React.memo(
environment, environment,
jsImports, jsImports,
onChange, onChange,
executionId,
}); });
const distributionPlotSettings = { const distributionPlotSettings = {

View File

@ -8,7 +8,7 @@ import React, {
} from "react"; } from "react";
import { useForm, UseFormRegister, useWatch } from "react-hook-form"; import { useForm, UseFormRegister, useWatch } from "react-hook-form";
import * as yup from "yup"; import * as yup from "yup";
import { useMaybeControlledValue } from "../lib/hooks"; import { useMaybeControlledValue, useRunnerState } from "../lib/hooks";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { import {
ChartSquareBarIcon, ChartSquareBarIcon,
@ -191,51 +191,12 @@ const RunControls: React.FC<{
icons={[CheckCircleIcon, PauseIcon]} icons={[CheckCircleIcon, PauseIcon]}
status={autorunMode} status={autorunMode}
onChange={onAutorunModeChange} onChange={onAutorunModeChange}
spinIcon={autorunMode && isRunning}
/> />
</div> </div>
); );
}; };
const useRunnerState = (code: string) => {
const [autorunMode, setAutorunMode] = useState(true);
const [renderedCode, setRenderedCode] = useState(code); // used in manual run mode only
const [isRunning, setIsRunning] = useState(false); // used in manual run mode only
// This part is tricky and fragile; we need to re-render first to make sure that the icon is spinning,
// and only then evaluate the squiggle code (which freezes the UI).
// Also note that `useEffect` execution order matters here.
// Hopefully it'll all go away after we make squiggle code evaluation async.
useEffect(() => {
if (renderedCode === code && isRunning) {
// It's not possible to put this after `setRenderedCode(code)` below because React would apply
// `setIsRunning` and `setRenderedCode` together and spinning icon will disappear immediately.
setIsRunning(false);
}
}, [renderedCode, code, isRunning]);
useEffect(() => {
if (!autorunMode && isRunning) {
setRenderedCode(code); // TODO - force run even if code hasn't changed
}
}, [autorunMode, code, isRunning]);
const run = () => {
// The rest will be handled by useEffects above, but we need to update the spinner first.
setIsRunning(true);
};
return {
run,
renderedCode: autorunMode ? code : renderedCode,
isRunning,
autorunMode,
setAutorunMode: (newValue: boolean) => {
if (!newValue) setRenderedCode(code);
setAutorunMode(newValue);
},
};
};
type PlaygroundContextShape = { type PlaygroundContextShape = {
getLeftPanelElement: () => HTMLDivElement | undefined; getLeftPanelElement: () => HTMLDivElement | undefined;
}; };
@ -305,18 +266,31 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
[vars.sampleCount, vars.xyPointLength] [vars.sampleCount, vars.xyPointLength]
); );
const { run, autorunMode, setAutorunMode, isRunning, renderedCode } = const {
useRunnerState(code); run,
autorunMode,
setAutorunMode,
isRunning,
renderedCode,
executionId,
} = useRunnerState(code);
const squiggleChart = ( const squiggleChart =
renderedCode === "" ? null : (
<div className="relative">
{isRunning ? (
<div className="absolute inset-0 bg-white opacity-0 animate-semi-appear" />
) : null}
<SquiggleChart <SquiggleChart
code={renderedCode} code={renderedCode}
executionId={executionId}
environment={env} environment={env}
{...vars} {...vars}
bindings={defaultBindings} bindings={defaultBindings}
jsImports={imports} jsImports={imports}
enableLocalSettings={true} enableLocalSettings={true}
/> />
</div>
); );
const firstTab = vars.showEditor ? ( const firstTab = vars.showEditor ? (

View File

@ -1,5 +1,5 @@
import { RefreshIcon } from "@heroicons/react/solid";
import clsx from "clsx"; import clsx from "clsx";
import { motion } from "framer-motion";
import React from "react"; import React from "react";
type IconType = (props: React.ComponentProps<"svg">) => JSX.Element; type IconType = (props: React.ComponentProps<"svg">) => JSX.Element;
@ -9,19 +9,19 @@ type Props = {
onChange: (status: boolean) => void; onChange: (status: boolean) => void;
texts: [string, string]; texts: [string, string];
icons: [IconType, IconType]; icons: [IconType, IconType];
spinIcon?: boolean;
}; };
export const Toggle: React.FC<Props> = ({ export const Toggle: React.FC<Props> = ({
texts: [onText, offText],
icons: [OnIcon, OffIcon],
status, status,
onChange, onChange,
texts: [onText, offText],
icons: [OnIcon, OffIcon],
spinIcon,
}) => { }) => {
const CurrentIcon = status ? OnIcon : OffIcon; const CurrentIcon = status ? OnIcon : OffIcon;
return ( return (
<motion.button <button
layout
transition={{ duration: 0.2 }}
className={clsx( className={clsx(
"rounded-md py-0.5 bg-slate-500 text-white text-xs font-semibold flex items-center space-x-1", "rounded-md py-0.5 bg-slate-500 text-white text-xs font-semibold flex items-center space-x-1",
status ? "bg-slate-500" : "bg-gray-400", status ? "bg-slate-500" : "bg-gray-400",
@ -30,12 +30,18 @@ export const Toggle: React.FC<Props> = ({
)} )}
onClick={() => onChange(!status)} onClick={() => onChange(!status)}
> >
<motion.div layout transition={{ duration: 0.2 }}> <div className="relative w-6 h-6" key={String(spinIcon)}>
<CurrentIcon className="w-6 h-6" /> <CurrentIcon
</motion.div> className={clsx(
<motion.span layout transition={{ duration: 0.2 }}> "w-6 h-6 absolute opacity-100",
{status ? onText : offText} spinIcon && "animate-hide"
</motion.span> )}
</motion.button> />
{spinIcon && (
<RefreshIcon className="w-6 h-6 absolute opacity-0 animate-appear-and-spin" />
)}
</div>
<span>{status ? onText : offText}</span>
</button>
); );
}; };

View File

@ -0,0 +1,3 @@
export { useMaybeControlledValue } from "./useMaybeControlledValue";
export { useSquiggle, useSquigglePartial } from "./useSquiggle";
export { useRunnerState } from "./useRunnerState";

View File

@ -0,0 +1,22 @@
import { useState } from "react";
type ControlledValueArgs<T> = {
value?: T;
defaultValue: T;
onChange?: (x: T) => void;
};
export function useMaybeControlledValue<T>(
args: ControlledValueArgs<T>
): [T, (x: T) => void] {
let [uncontrolledValue, setUncontrolledValue] = useState(args.defaultValue);
let value = args.value ?? uncontrolledValue;
let onChange = (newValue: T) => {
if (args.value === undefined) {
// uncontrolled mode
setUncontrolledValue(newValue);
}
args.onChange?.(newValue);
};
return [value, onChange];
}

View File

@ -0,0 +1,100 @@
import { useLayoutEffect, useReducer } from "react";
type State = {
autorunMode: boolean;
renderedCode: string;
// "prepared" is for rendering a spinner; "run" for executing squiggle code; then it gets back to "none" on the next render
runningState: "none" | "prepared" | "run";
executionId: number;
};
const buildInitialState = (code: string): State => ({
autorunMode: true,
renderedCode: "",
runningState: "none",
executionId: 1,
});
type Action =
| {
type: "SET_AUTORUN_MODE";
value: boolean;
code: string;
}
| {
type: "PREPARE_RUN";
}
| {
type: "RUN";
code: string;
}
| {
type: "STOP_RUN";
};
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "SET_AUTORUN_MODE":
return {
...state,
autorunMode: action.value,
};
case "PREPARE_RUN":
return {
...state,
runningState: "prepared",
};
case "RUN":
return {
...state,
runningState: "run",
renderedCode: action.code,
executionId: state.executionId + 1,
};
case "STOP_RUN":
return {
...state,
runningState: "none",
};
}
};
export const useRunnerState = (code: string) => {
const [state, dispatch] = useReducer(reducer, buildInitialState(code));
useLayoutEffect(() => {
if (state.runningState === "prepared") {
// this is necessary for async playground loading - otherwise it executes the code synchronously on the initial load
// (it's surprising that this is necessary, but empirically it _is_ necessary, both with `useEffect` and `useLayoutEffect`)
setTimeout(() => {
dispatch({ type: "RUN", code });
}, 0);
} else if (state.runningState === "run") {
dispatch({ type: "STOP_RUN" });
}
}, [state.runningState, code]);
const run = () => {
// The rest will be handled by dispatches above on following renders, but we need to update the spinner first.
dispatch({ type: "PREPARE_RUN" });
};
if (
state.autorunMode &&
state.renderedCode !== code &&
state.runningState === "none"
) {
run();
}
return {
run,
autorunMode: state.autorunMode,
renderedCode: state.renderedCode,
isRunning: state.runningState !== "none",
executionId: state.executionId,
setAutorunMode: (newValue: boolean) => {
dispatch({ type: "SET_AUTORUN_MODE", value: newValue, code });
},
};
};

View File

@ -5,10 +5,11 @@ import {
run, run,
runPartial, runPartial,
} from "@quri/squiggle-lang"; } from "@quri/squiggle-lang";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo } from "react";
type SquiggleArgs<T extends ReturnType<typeof run | typeof runPartial>> = { type SquiggleArgs<T extends ReturnType<typeof run | typeof runPartial>> = {
code: string; code: string;
executionId?: number;
bindings?: bindings; bindings?: bindings;
jsImports?: jsImports; jsImports?: jsImports;
environment?: environment; environment?: environment;
@ -21,7 +22,15 @@ const useSquiggleAny = <T extends ReturnType<typeof run | typeof runPartial>>(
) => { ) => {
const result: T = useMemo<T>( const result: T = useMemo<T>(
() => f(args.code, args.bindings, args.environment, args.jsImports), () => f(args.code, args.bindings, args.environment, args.jsImports),
[f, args.code, args.bindings, args.environment, args.jsImports] // eslint-disable-next-line react-hooks/exhaustive-deps
[
f,
args.code,
args.bindings,
args.environment,
args.jsImports,
args.executionId,
]
); );
const { onChange } = args; const { onChange } = args;
@ -42,23 +51,3 @@ export const useSquigglePartial = (
export const useSquiggle = (args: SquiggleArgs<ReturnType<typeof run>>) => { export const useSquiggle = (args: SquiggleArgs<ReturnType<typeof run>>) => {
return useSquiggleAny(args, run); return useSquiggleAny(args, run);
}; };
type ControlledValueArgs<T> = {
value?: T;
defaultValue: T;
onChange?: (x: T) => void;
};
export function useMaybeControlledValue<T>(
args: ControlledValueArgs<T>
): [T, (x: T) => void] {
let [uncontrolledValue, setUncontrolledValue] = useState(args.defaultValue);
let value = args.value ?? uncontrolledValue;
let onChange = (newValue: T) => {
if (args.value === undefined) {
// uncontrolled mode
setUncontrolledValue(newValue);
}
args.onChange?.(newValue);
};
return [value, onChange];
}

View File

@ -5,6 +5,27 @@ module.exports = {
}, },
important: ".squiggle", important: ".squiggle",
theme: { theme: {
extend: {}, extend: {
animation: {
"appear-and-spin":
"spin 1s linear infinite, squiggle-appear 0.2s forwards",
"semi-appear": "squiggle-semi-appear 0.2s forwards",
hide: "squiggle-hide 0.2s forwards",
},
keyframes: {
"squiggle-appear": {
from: { opacity: 0 },
to: { opacity: 1 },
},
"squiggle-semi-appear": {
from: { opacity: 0 },
to: { opacity: 0.5 },
},
"squiggle-hide": {
from: { opacity: 1 },
to: { opacity: 0 },
},
},
},
}, },
}; };