local chart settings via dropdown menu

This commit is contained in:
Vyacheslav Matyukhin 2022-07-20 23:16:34 +04:00
parent 12eb63c789
commit 8d390c4433
No known key found for this signature in database
GPG Key ID: 3D2A774C5489F96C
9 changed files with 369 additions and 132 deletions

View File

@ -9,7 +9,6 @@ import {
defaultEnvironment, defaultEnvironment,
} from "@quri/squiggle-lang"; } from "@quri/squiggle-lang";
import { useSquiggle } from "../lib/hooks"; import { useSquiggle } from "../lib/hooks";
import { SquiggleErrorAlert } from "./SquiggleErrorAlert";
import { SquiggleViewer } from "./SquiggleViewer"; import { SquiggleViewer } from "./SquiggleViewer";
export interface SquiggleChartProps { export interface SquiggleChartProps {

View File

@ -1,17 +1,11 @@
import React from "react"; import React from "react";
import { import { squiggleExpression, declaration } from "@quri/squiggle-lang";
squiggleExpression,
environment,
declaration,
} from "@quri/squiggle-lang";
import { NumberShower } from "../NumberShower"; import { NumberShower } from "../NumberShower";
import { import { DistributionChart } from "../DistributionChart";
DistributionChart,
DistributionPlottingSettings,
} from "../DistributionChart";
import { FunctionChart, FunctionChartSettings } from "../FunctionChart"; import { FunctionChart, FunctionChartSettings } from "../FunctionChart";
import clsx from "clsx"; import clsx from "clsx";
import { VariableBox } from "./VariableBox"; import { VariableBox } from "./VariableBox";
import { ItemSettingsMenu } from "./ItemSettingsMenu";
function getRange<a>(x: declaration<a>) { function getRange<a>(x: declaration<a>) {
const first = x.args[0]; const first = x.args[0];
@ -42,9 +36,11 @@ const VariableList: React.FC<{
children: React.ReactNode; children: React.ReactNode;
}> = ({ path, heading, children }) => ( }> = ({ path, heading, children }) => (
<VariableBox path={path} heading={heading}> <VariableBox path={path} heading={heading}>
<div className={clsx("space-y-3", path.length ? "pt-1 mt-1" : null)}> {() => (
{children} <div className={clsx("space-y-3", path.length ? "pt-1 mt-1" : null)}>
</div> {children}
</div>
)}
</VariableBox> </VariableBox>
); );
@ -55,11 +51,6 @@ export interface Props {
path: string[]; path: string[];
width?: number; width?: number;
height: number; height: number;
distributionPlotSettings: DistributionPlottingSettings;
/** Settings for displaying functions */
chartSettings: FunctionChartSettings;
/** Environment for further function executions */
environment: environment;
} }
export const ExpressionViewer: React.FC<Props> = ({ export const ExpressionViewer: React.FC<Props> = ({
@ -67,17 +58,16 @@ export const ExpressionViewer: React.FC<Props> = ({
expression, expression,
width, width,
height, height,
distributionPlotSettings,
chartSettings,
environment,
}) => { }) => {
switch (expression.tag) { switch (expression.tag) {
case "number": case "number":
return ( return (
<VariableBox path={path} heading="Number"> <VariableBox path={path} heading="Number">
<div className="font-semibold text-slate-600"> {() => (
<NumberShower precision={3} number={expression.value} /> <div className="font-semibold text-slate-600">
</div> <NumberShower precision={3} number={expression.value} />
</div>
)}
</VariableBox> </VariableBox>
); );
case "distribution": { case "distribution": {
@ -88,95 +78,134 @@ export const ExpressionViewer: React.FC<Props> = ({
heading={`Distribution (${distType})\n${ heading={`Distribution (${distType})\n${
distType === "Symbolic" ? expression.value.toString() : "" distType === "Symbolic" ? expression.value.toString() : ""
}`} }`}
dropdownMenu={({ settings, setSettings }) => {
return (
<ItemSettingsMenu settings={settings} setSettings={setSettings} />
);
}}
> >
<DistributionChart {(settings) => {
distribution={expression.value} return (
{...distributionPlotSettings} <DistributionChart
height={height} distribution={expression.value}
width={width} {...settings.distributionPlotSettings}
/> height={height}
width={width}
/>
);
}}
</VariableBox> </VariableBox>
); );
} }
case "string": case "string":
return ( return (
<VariableBox path={path} heading="String"> <VariableBox path={path} heading="String">
<span className="text-slate-400">"</span> {() => (
<span className="text-slate-600 font-semibold font-mono"> <>
{expression.value} <span className="text-slate-400">"</span>
</span> <span className="text-slate-600 font-semibold font-mono">
<span className="text-slate-400">"</span> {expression.value}
</span>
<span className="text-slate-400">"</span>
</>
)}
</VariableBox> </VariableBox>
); );
case "boolean": case "boolean":
return ( return (
<VariableBox path={path} heading="Boolean"> <VariableBox path={path} heading="Boolean">
{expression.value.toString()} {() => expression.value.toString()}
</VariableBox> </VariableBox>
); );
case "symbol": case "symbol":
return ( return (
<VariableBox path={path} heading="Symbol"> <VariableBox path={path} heading="Symbol">
<span className="text-slate-500 mr-2">Undefined Symbol:</span> {() => (
<span className="text-slate-600">{expression.value}</span> <>
<span className="text-slate-500 mr-2">Undefined Symbol:</span>
<span className="text-slate-600">{expression.value}</span>
</>
)}
</VariableBox> </VariableBox>
); );
case "call": case "call":
return ( return (
<VariableBox path={path} heading="Call"> <VariableBox path={path} heading="Call">
{expression.value} {() => expression.value}
</VariableBox> </VariableBox>
); );
case "arraystring": case "arraystring":
return ( return (
<VariableBox path={path} heading="Array String"> <VariableBox path={path} heading="Array String">
{expression.value.map((r) => `"${r}"`).join(", ")} {() => expression.value.map((r) => `"${r}"`).join(", ")}
</VariableBox> </VariableBox>
); );
case "date": case "date":
return ( return (
<VariableBox path={path} heading="Date"> <VariableBox path={path} heading="Date">
{expression.value.toDateString()} {() => expression.value.toDateString()}
</VariableBox> </VariableBox>
); );
case "timeDuration": { case "timeDuration": {
return ( return (
<VariableBox path={path} heading="Time Duration"> <VariableBox path={path} heading="Time Duration">
<NumberShower precision={3} number={expression.value} /> {() => <NumberShower precision={3} number={expression.value} />}
</VariableBox> </VariableBox>
); );
} }
case "lambda": case "lambda":
return ( return (
<VariableBox path={path} heading="Function"> <VariableBox
<div className="text-amber-700 bg-amber-100 rounded-md font-mono p-1 pl-2 mb-3 mt-1 text-sm">{`function(${expression.value.parameters.join( path={path}
"," heading="Function"
)})`}</div> dropdownMenu={({ settings, setSettings }) => {
<FunctionChart return (
fn={expression.value} <ItemSettingsMenu settings={settings} setSettings={setSettings} />
chartSettings={chartSettings} );
distributionPlotSettings={distributionPlotSettings} }}
height={height} >
environment={{ {(settings) => (
sampleCount: environment.sampleCount / 10, <>
xyPointLength: environment.xyPointLength / 10, <div className="text-amber-700 bg-amber-100 rounded-md font-mono p-1 pl-2 mb-3 mt-1 text-sm">{`function(${expression.value.parameters.join(
}} ","
/> )})`}</div>
<FunctionChart
fn={expression.value}
chartSettings={settings.chartSettings}
distributionPlotSettings={settings.distributionPlotSettings}
height={height}
environment={{
sampleCount: settings.environment.sampleCount / 10,
xyPointLength: settings.environment.xyPointLength / 10,
}}
/>
</>
)}
</VariableBox> </VariableBox>
); );
case "lambdaDeclaration": { case "lambdaDeclaration": {
return ( return (
<VariableBox path={path} heading="Function Declaration"> <VariableBox
<FunctionChart path={path}
fn={expression.value.fn} heading="Function Declaration"
chartSettings={getChartSettings(expression.value)} dropdownMenu={({ settings, setSettings }) => {
distributionPlotSettings={distributionPlotSettings} return (
height={height} <ItemSettingsMenu settings={settings} setSettings={setSettings} />
environment={{ );
sampleCount: environment.sampleCount / 10, }}
xyPointLength: environment.xyPointLength / 10, >
}} {(settings) => (
/> <FunctionChart
fn={expression.value.fn}
chartSettings={getChartSettings(expression.value)}
distributionPlotSettings={settings.distributionPlotSettings}
height={height}
environment={{
sampleCount: settings.environment.sampleCount / 10,
xyPointLength: settings.environment.xyPointLength / 10,
}}
/>
)}
</VariableBox> </VariableBox>
); );
} }
@ -192,9 +221,6 @@ export const ExpressionViewer: React.FC<Props> = ({
expression={r} expression={r}
width={width !== undefined ? width - 20 : width} width={width !== undefined ? width - 20 : width}
height={height / 3} height={height / 3}
distributionPlotSettings={distributionPlotSettings}
chartSettings={chartSettings}
environment={environment}
/> />
))} ))}
</VariableList> </VariableList>
@ -210,9 +236,6 @@ export const ExpressionViewer: React.FC<Props> = ({
expression={r} expression={r}
width={width !== undefined ? width - 20 : width} width={width !== undefined ? width - 20 : width}
height={height / 3} height={height / 3}
distributionPlotSettings={distributionPlotSettings}
chartSettings={chartSettings}
environment={environment}
/> />
))} ))}
</VariableList> </VariableList>
@ -227,9 +250,6 @@ export const ExpressionViewer: React.FC<Props> = ({
expression={r} expression={r}
width={width !== undefined ? width - 20 : width} width={width !== undefined ? width - 20 : width}
height={50} height={50}
distributionPlotSettings={distributionPlotSettings}
chartSettings={chartSettings}
environment={environment}
/> />
))} ))}
</VariableList> </VariableList>

View File

@ -0,0 +1,73 @@
import React from "react";
import { DropdownMenu } from "../ui/DropdownMenu";
import { LocalItemSettings } from "./utils";
type Props = {
settings: LocalItemSettings;
setSettings: (value: LocalItemSettings) => void;
};
export const ItemSettingsMenu: React.FC<Props> = ({
settings,
setSettings,
}) => {
return (
<div className="flex gap-1 items-center">
<DropdownMenu>
<DropdownMenu.CheckboxItem
label="Log X scale"
value={settings.distributionPlotSettings?.logX ?? false}
toggle={() =>
setSettings({
...settings,
distributionPlotSettings: {
...settings.distributionPlotSettings,
logX: !settings.distributionPlotSettings?.logX,
},
})
}
/>
<DropdownMenu.CheckboxItem
label="Exp Y scale"
value={settings.distributionPlotSettings?.expY ?? false}
toggle={() =>
setSettings({
...settings,
distributionPlotSettings: {
...settings.distributionPlotSettings,
expY: !settings.distributionPlotSettings?.expY,
},
})
}
/>
<DropdownMenu.CheckboxItem
label="Show summary"
value={settings.distributionPlotSettings?.showSummary ?? false}
toggle={() =>
setSettings({
...settings,
distributionPlotSettings: {
...settings.distributionPlotSettings,
showSummary: !settings.distributionPlotSettings?.showSummary,
},
})
}
/>
</DropdownMenu>
{settings.distributionPlotSettings || settings.chartSettings ? (
<button
onClick={() =>
setSettings({
...settings,
distributionPlotSettings: undefined,
chartSettings: undefined,
})
}
className="text-xs px-1 py-0.5 rounded bg-slate-300"
>
Reset settings
</button>
) : null}
</div>
);
};

View File

@ -1,61 +1,77 @@
import React, { useContext, useState } from "react"; import React, { useContext, useReducer } from "react";
import { Tooltip } from "../ui/Tooltip"; import { Tooltip } from "../ui/Tooltip";
import { LocalItemSettings, MergedItemSettings } from "./utils";
import { ViewerContext } from "./ViewerContext"; import { ViewerContext } from "./ViewerContext";
interface VariableBoxProps { type DropdownMenuParams = {
settings: LocalItemSettings;
setSettings: (value: LocalItemSettings) => void;
};
type VariableBoxProps = {
path: string[]; path: string[];
heading: string; heading: string;
children: React.ReactNode; dropdownMenu?: (params: DropdownMenuParams) => React.ReactNode;
} children: (settings: MergedItemSettings) => React.ReactNode;
};
export const VariableBox: React.FC<VariableBoxProps> = ({ export const VariableBox: React.FC<VariableBoxProps> = ({
path, path,
heading = "Error", heading = "Error",
dropdownMenu,
children, children,
}) => { }) => {
const { setSettings, getSettings } = useContext(ViewerContext); const { setSettings, getSettings, getMergedSettings } =
const [isCollapsed, setIsCollapsed] = useState( useContext(ViewerContext);
() => getSettings(path).collapsed const [_, forceUpdate] = useReducer((x) => x + 1, 0);
);
const settings = getSettings(path);
const setSettingsAndUpdate = (newSettings: LocalItemSettings) => {
setSettings(path, newSettings);
forceUpdate();
};
const toggleCollapsed = () => { const toggleCollapsed = () => {
setSettings(path, { setSettingsAndUpdate({ ...settings, collapsed: !settings.collapsed });
collapsed: !isCollapsed,
});
setIsCollapsed(!isCollapsed);
}; };
const isTopLevel = path.length === 0; const isTopLevel = path.length === 0;
const name = isTopLevel ? "" : path[path.length - 1]; const name = isTopLevel ? "Result" : path[path.length - 1];
return ( return (
<div> <div>
<div> <header className="inline-flex space-x-1">
{isTopLevel ? null : ( <Tooltip text={heading}>
<header <span
className="inline-flex space-x-1 text-slate-500 font-mono text-sm cursor-pointer" className="text-slate-500 font-mono text-sm cursor-pointer"
onClick={toggleCollapsed} onClick={toggleCollapsed}
> >
<Tooltip text={heading}> {name}:
<span>{name}:</span> </span>
</Tooltip> </Tooltip>
{isCollapsed ? ( {settings.collapsed ? (
<span className="bg-slate-200 rounded p-0.5 font-xs">...</span> <span
) : null} className="rounded p-0.5 bg-slate-200 text-slate-500 font-mono text-xs cursor-pointer"
</header> onClick={toggleCollapsed}
)} >
{isCollapsed ? null : ( ...
<div className="flex w-full"> </span>
{path.length ? ( ) : dropdownMenu ? (
<div dropdownMenu({ settings, setSettings: setSettingsAndUpdate })
className="border-l-2 border-slate-200 hover:border-green-600 w-4 cursor-pointer" ) : null}
onClick={toggleCollapsed} </header>
></div> {settings.collapsed ? null : (
) : null} <div className="flex w-full">
<div className="grow">{children}</div> {path.length ? (
</div> <div
)} className="border-l-2 border-slate-200 hover:border-green-600 w-4 cursor-pointer"
</div> onClick={toggleCollapsed}
></div>
) : null}
<div className="grow">{children(getMergedSettings(path))}</div>
</div>
)}
</div> </div>
); );
}; };

View File

@ -1,15 +1,33 @@
import { defaultEnvironment } from "@quri/squiggle-lang";
import React from "react"; import React from "react";
import { ItemSettings, Path } from "./utils"; import { LocalItemSettings, MergedItemSettings, Path } from "./utils";
type ViewerContextShape = { type ViewerContextShape = {
// Note that we don't store settings themselves in the context (that would cause rerenders of the entire tree on each settings update). // Note that we don't store settings themselves in the context (that would cause rerenders of the entire tree on each settings update).
// Instead, we keep settings in local state and notify the global context via setSettings to pass them down the component tree again if it got rebuilt from scratch. // Instead, we keep settings in local state and notify the global context via setSettings to pass them down the component tree again if it got rebuilt from scratch.
// See ./SquiggleViewer.tsx and ./VariableBox.tsx for other implementation details on this. // See ./SquiggleViewer.tsx and ./VariableBox.tsx for other implementation details on this.
getSettings(path: Path): ItemSettings; getSettings(path: Path): LocalItemSettings;
setSettings(path: Path, value: ItemSettings): void; getMergedSettings(path: Path): MergedItemSettings;
setSettings(path: Path, value: LocalItemSettings): void;
}; };
export const ViewerContext = React.createContext<ViewerContextShape>({ export const ViewerContext = React.createContext<ViewerContextShape>({
getSettings: () => ({ collapsed: false }), getSettings: () => ({ collapsed: false }),
getMergedSettings: () => ({
collapsed: false,
// copy-pasted from SquiggleChart
chartSettings: {
start: 0,
stop: 10,
count: 100,
},
distributionPlotSettings: {
showSummary: false,
showControls: false,
logX: false,
expY: false,
},
environment: defaultEnvironment,
}),
setSettings() {}, setSettings() {},
}); });

View File

@ -4,7 +4,12 @@ import { DistributionPlottingSettings } from "../DistributionChart";
import { FunctionChartSettings } from "../FunctionChart"; import { FunctionChartSettings } from "../FunctionChart";
import { ExpressionViewer } from "./ExpressionViewer"; import { ExpressionViewer } from "./ExpressionViewer";
import { ViewerContext } from "./ViewerContext"; import { ViewerContext } from "./ViewerContext";
import { Path, pathAsString } from "./utils"; import {
LocalItemSettings,
MergedItemSettings,
Path,
pathAsString,
} from "./utils";
import { useSquiggle } from "../../lib/hooks"; import { useSquiggle } from "../../lib/hooks";
import { SquiggleErrorAlert } from "../SquiggleErrorAlert"; import { SquiggleErrorAlert } from "../SquiggleErrorAlert";
@ -20,15 +25,11 @@ type Props = {
environment: environment; environment: environment;
}; };
type ItemSettings = {
collapsed: boolean;
};
type Settings = { type Settings = {
[k: string]: ItemSettings; [k: string]: LocalItemSettings;
}; };
const defaultSettings: ItemSettings = { collapsed: false }; const defaultSettings: LocalItemSettings = { collapsed: false };
export const SquiggleViewer: React.FC<Props> = ({ export const SquiggleViewer: React.FC<Props> = ({
result, result,
@ -38,6 +39,7 @@ export const SquiggleViewer: React.FC<Props> = ({
chartSettings, chartSettings,
environment, environment,
}) => { }) => {
// can't store settings in the state because we don't want to rerender the entire tree on every change
const settingsRef = useRef<Settings>({}); const settingsRef = useRef<Settings>({});
const getSettings = useCallback( const getSettings = useCallback(
@ -48,17 +50,40 @@ export const SquiggleViewer: React.FC<Props> = ({
); );
const setSettings = useCallback( const setSettings = useCallback(
(path: Path, value: ItemSettings) => { (path: Path, value: LocalItemSettings) => {
settingsRef.current[pathAsString(path)] = value; settingsRef.current[pathAsString(path)] = value;
}, },
[settingsRef] [settingsRef]
); );
const getMergedSettings = useCallback(
(path: Path) => {
const localSettings = getSettings(path);
const result: MergedItemSettings = {
distributionPlotSettings: {
...distributionPlotSettings,
...(localSettings.distributionPlotSettings || {}),
},
chartSettings: {
...chartSettings,
...(localSettings.chartSettings || {}),
},
environment: {
...environment,
...(localSettings.environment || {}),
},
};
return result;
},
[distributionPlotSettings, chartSettings, environment, getSettings]
);
return ( return (
<ViewerContext.Provider <ViewerContext.Provider
value={{ value={{
getSettings, getSettings,
setSettings, setSettings,
getMergedSettings,
}} }}
> >
{result.tag === "Ok" ? ( {result.tag === "Ok" ? (
@ -67,9 +92,6 @@ export const SquiggleViewer: React.FC<Props> = ({
expression={result.value} expression={result.value}
width={width} width={width}
height={height} height={height}
distributionPlotSettings={distributionPlotSettings}
chartSettings={chartSettings}
environment={environment}
/> />
) : ( ) : (
<SquiggleErrorAlert error={result.value} /> <SquiggleErrorAlert error={result.value} />

View File

@ -1,6 +1,20 @@
export type ItemSettings = { import { DistributionPlottingSettings } from "../DistributionChart";
import { FunctionChartSettings } from "../FunctionChart";
import { environment } from "@quri/squiggle-lang";
export type LocalItemSettings = {
collapsed: boolean; collapsed: boolean;
distributionPlotSettings?: Partial<DistributionPlottingSettings>;
chartSettings?: Partial<FunctionChartSettings>;
environment?: Partial<environment>;
}; };
export type MergedItemSettings = {
distributionPlotSettings: DistributionPlottingSettings;
chartSettings: FunctionChartSettings;
environment: environment;
};
export type Path = string[]; export type Path = string[];
export const pathAsString = (path: Path) => path.join("."); export const pathAsString = (path: Path) => path.join(".");

View File

@ -0,0 +1,75 @@
import { CheckIcon, CogIcon } from "@heroicons/react/solid";
import React, { useState } from "react";
import {
shift,
useClick,
useDismiss,
useFloating,
useInteractions,
useRole,
} from "@floating-ui/react-dom-interactions";
type Props = {
children: React.ReactNode;
};
type DropdownMenuType = React.FC<Props> & {
CheckboxItem: React.FC<{ label: string; value: boolean; toggle: () => void }>;
};
export const DropdownMenu: DropdownMenuType = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const { x, y, reference, floating, strategy, context } = useFloating({
placement: "bottom-start",
open: isOpen,
onOpenChange: setIsOpen,
middleware: [shift()],
});
const { getReferenceProps, getFloatingProps } = useInteractions([
useClick(context),
useRole(context, { role: "menu" }),
useDismiss(context),
]);
return (
<div>
<CogIcon
className="h-5 w-5 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => setIsOpen(!isOpen)}
{...getReferenceProps({ ref: reference })}
/>
{isOpen ? (
<div
{...getFloatingProps({
className: "rounded shadow z-10 bg-white",
ref: floating,
style: {
position: strategy,
top: y ?? 0,
left: x ?? 0,
},
})}
>
{children}
</div>
) : null}
</div>
);
};
DropdownMenu.CheckboxItem = ({ label, value, toggle }) => {
return (
<div
className="px-4 py-2 cursor-pointer flex space-x-2 hover:bg-gray-100"
onClick={toggle}
>
{value ? (
<CheckIcon className="w-4 h-4 text-gray-700" />
) : (
<div className="w-4 h-4" />
)}
<span>{label}</span>
</div>
);
};

View File

@ -15,12 +15,12 @@ interface Props {
} }
export const Tooltip: React.FC<Props> = ({ text, children }) => { export const Tooltip: React.FC<Props> = ({ text, children }) => {
const [open, setOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { x, y, reference, floating, strategy, context } = useFloating({ const { x, y, reference, floating, strategy, context } = useFloating({
placement: "top", placement: "top",
open, open: isOpen,
onOpenChange: setOpen, onOpenChange: setIsOpen,
middleware: [shift()], middleware: [shift()],
}); });
@ -37,7 +37,7 @@ export const Tooltip: React.FC<Props> = ({ text, children }) => {
getReferenceProps({ ref: reference, ...children.props }) getReferenceProps({ ref: reference, ...children.props })
)} )}
<AnimatePresence> <AnimatePresence>
{open && ( {isOpen && (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}