Merge pull request #796 from quantified-uncertainty/collapsible

Collapsible records in playground output
This commit is contained in:
Ozzie Gooen 2022-07-26 17:26:21 -07:00 committed by GitHub
commit 563f82f48c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1325 additions and 345 deletions

View File

@ -54,7 +54,6 @@ export function DynamicSquiggleChart({ squiggleString }) {
width={445} width={445}
height={200} height={200}
showSummary={true} showSummary={true}
showTypes={true}
/> />
); );
} }

View File

@ -3,6 +3,8 @@
"version": "0.2.23", "version": "0.2.23",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/react-dom": "^0.7.2",
"@floating-ui/react-dom-interactions": "^0.6.6",
"@headlessui/react": "^1.6.6", "@headlessui/react": "^1.6.6",
"@heroicons/react": "^1.0.6", "@heroicons/react": "^1.0.6",
"@hookform/resolvers": "^2.9.6", "@hookform/resolvers": "^2.9.6",

View File

@ -8,26 +8,24 @@ import {
import { Vega } from "react-vega"; import { Vega } from "react-vega";
import { ErrorAlert } from "./Alert"; import { ErrorAlert } from "./Alert";
import { useSize } from "react-use"; import { useSize } from "react-use";
import clsx from "clsx";
import { import {
buildVegaSpec, buildVegaSpec,
DistributionChartSpecOptions, DistributionChartSpecOptions,
} from "../lib/distributionSpecBuilder"; } from "../lib/distributionSpecBuilder";
import { NumberShower } from "./NumberShower"; import { NumberShower } from "./NumberShower";
import { hasMassBelowZero } from "../lib/distributionUtils";
export type DistributionPlottingSettings = { export type DistributionPlottingSettings = {
/** Whether to show a summary of means, stdev, percentiles etc */ /** Whether to show a summary of means, stdev, percentiles etc */
showSummary: boolean; showSummary: boolean;
/** Whether to show the user graph controls (scale etc) */ actions?: boolean;
showControls: boolean;
} & DistributionChartSpecOptions; } & DistributionChartSpecOptions;
export type DistributionChartProps = { export type DistributionChartProps = {
distribution: Distribution; distribution: Distribution;
width?: number; width?: number;
height: number; height: number;
actions?: boolean;
} & DistributionPlottingSettings; } & DistributionPlottingSettings;
export const DistributionChart: React.FC<DistributionChartProps> = (props) => { export const DistributionChart: React.FC<DistributionChartProps> = (props) => {
@ -36,17 +34,9 @@ export const DistributionChart: React.FC<DistributionChartProps> = (props) => {
height, height,
showSummary, showSummary,
width, width,
showControls,
logX, logX,
expY,
actions = false, actions = false,
} = props; } = props;
const [isLogX, setLogX] = React.useState(logX);
const [isExpY, setExpY] = React.useState(expY);
React.useEffect(() => setLogX(logX), [logX]);
React.useEffect(() => setExpY(expY), [expY]);
const shape = distribution.pointSet(); const shape = distribution.pointSet();
const [sized] = useSize((size) => { const [sized] = useSize((size) => {
if (shape.tag === "Error") { if (shape.tag === "Error") {
@ -57,9 +47,6 @@ export const DistributionChart: React.FC<DistributionChartProps> = (props) => {
); );
} }
const massBelow0 =
shape.value.continuous.some((x) => x.x <= 0) ||
shape.value.discrete.some((x) => x.x <= 0);
const spec = buildVegaSpec(props); const spec = buildVegaSpec(props);
let widthProp = width ? width : size.width; let widthProp = width ? width : size.width;
@ -72,7 +59,11 @@ export const DistributionChart: React.FC<DistributionChartProps> = (props) => {
return ( return (
<div style={{ width: widthProp }}> <div style={{ width: widthProp }}>
{!(isLogX && massBelow0) ? ( {logX && hasMassBelowZero(shape.value) ? (
<ErrorAlert heading="Log Domain Error">
Cannot graph distribution with negative values on logarithmic scale.
</ErrorAlert>
) : (
<Vega <Vega
spec={spec} spec={spec}
data={{ con: shape.value.continuous, dis: shape.value.discrete }} data={{ con: shape.value.continuous, dis: shape.value.discrete }}
@ -80,67 +71,16 @@ export const DistributionChart: React.FC<DistributionChartProps> = (props) => {
height={height} height={height}
actions={actions} actions={actions}
/> />
) : (
<ErrorAlert heading="Log Domain Error">
Cannot graph distribution with negative values on logarithmic scale.
</ErrorAlert>
)} )}
<div className="flex justify-center"> <div className="flex justify-center">
{showSummary && <SummaryTable distribution={distribution} />} {showSummary && <SummaryTable distribution={distribution} />}
</div> </div>
{showControls && (
<div>
<CheckBox
label="Log X scale"
value={isLogX}
onChange={setLogX}
// Check whether we should disable the checkbox
{...(massBelow0
? {
disabled: true,
tooltip:
"Your distribution has mass lower than or equal to 0. Log only works on strictly positive values.",
}
: {})}
/>
<CheckBox label="Exp Y scale" value={isExpY} onChange={setExpY} />
</div>
)}
</div> </div>
); );
}); });
return sized; return sized;
}; };
interface CheckBoxProps {
label: string;
onChange: (x: boolean) => void;
value: boolean;
disabled?: boolean;
tooltip?: string;
}
export const CheckBox: React.FC<CheckBoxProps> = ({
label,
onChange,
value,
disabled = false,
tooltip,
}) => {
return (
<span title={tooltip}>
<input
type="checkbox"
checked={value}
onChange={() => onChange(!value)}
disabled={disabled}
className="form-checkbox"
/>
<label className={clsx(disabled && "text-slate-400")}> {label}</label>
</span>
);
};
const TableHeadCell: React.FC<{ children: React.ReactNode }> = ({ const TableHeadCell: React.FC<{ children: React.ReactNode }> = ({
children, children,
}) => ( }) => (

View File

@ -9,8 +9,7 @@ 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 { SquiggleItem } from "./SquiggleItem";
export interface SquiggleChartProps { export interface SquiggleChartProps {
/** The input string for squiggle */ /** The input string for squiggle */
@ -36,10 +35,6 @@ export interface SquiggleChartProps {
jsImports?: jsImports; jsImports?: jsImports;
/** Whether to show a summary of the distribution */ /** Whether to show a summary of the distribution */
showSummary?: boolean; showSummary?: boolean;
/** Whether to show type information about returns, default false */
showTypes?: boolean;
/** Whether to show graph controls (scale etc)*/
showControls?: boolean;
/** Set the x scale to be logarithmic by deault */ /** Set the x scale to be logarithmic by deault */
logX?: boolean; logX?: boolean;
/** Set the y scale to be exponential by deault */ /** Set the y scale to be exponential by deault */
@ -56,6 +51,7 @@ export interface SquiggleChartProps {
maxX?: number; maxX?: number;
/** Whether to show vega actions to the user, so they can copy the chart spec */ /** Whether to show vega actions to the user, so they can copy the chart spec */
distributionChartActions?: boolean; distributionChartActions?: boolean;
enableLocalSettings?: boolean;
} }
const defaultOnChange = () => {}; const defaultOnChange = () => {};
@ -70,8 +66,6 @@ export const SquiggleChart: React.FC<SquiggleChartProps> = React.memo(
jsImports = defaultImports, jsImports = defaultImports,
showSummary = false, showSummary = false,
width, width,
showTypes = false,
showControls = false,
logX = false, logX = false,
expY = false, expY = false,
diagramStart = 0, diagramStart = 0,
@ -83,6 +77,7 @@ export const SquiggleChart: React.FC<SquiggleChartProps> = React.memo(
color, color,
title, title,
distributionChartActions, distributionChartActions,
enableLocalSettings = false,
}) => { }) => {
const result = useSquiggle({ const result = useSquiggle({
code, code,
@ -92,12 +87,7 @@ export const SquiggleChart: React.FC<SquiggleChartProps> = React.memo(
onChange, onChange,
}); });
if (result.tag !== "Ok") { const distributionPlotSettings = {
return <SquiggleErrorAlert error={result.value} />;
}
let distributionPlotSettings = {
showControls,
showSummary, showSummary,
logX, logX,
expY, expY,
@ -109,21 +99,21 @@ export const SquiggleChart: React.FC<SquiggleChartProps> = React.memo(
actions: distributionChartActions, actions: distributionChartActions,
}; };
let chartSettings = { const chartSettings = {
start: diagramStart, start: diagramStart,
stop: diagramStop, stop: diagramStop,
count: diagramCount, count: diagramCount,
}; };
return ( return (
<SquiggleItem <SquiggleViewer
expression={result.value} result={result}
width={width} width={width}
height={height} height={height}
distributionPlotSettings={distributionPlotSettings} distributionPlotSettings={distributionPlotSettings}
showTypes={showTypes}
chartSettings={chartSettings} chartSettings={chartSettings}
environment={environment ?? defaultEnvironment} environment={environment ?? defaultEnvironment}
enableLocalSettings={enableLocalSettings}
/> />
); );
} }

View File

@ -13,6 +13,7 @@ const SquiggleContext = React.createContext<SquiggleContextShape>({
export const SquiggleContainer: React.FC<Props> = ({ children }) => { export const SquiggleContainer: React.FC<Props> = ({ children }) => {
const context = useContext(SquiggleContext); const context = useContext(SquiggleContext);
if (context.containerized) { if (context.containerized) {
return <>{children}</>; return <>{children}</>;
} else { } else {

View File

@ -1,5 +1,12 @@
import React, { FC, useState, useEffect, useMemo } from "react"; import React, {
import { Path, useForm, UseFormRegister, useWatch } from "react-hook-form"; FC,
useState,
useEffect,
useMemo,
useRef,
useCallback,
} from "react";
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 } from "../lib/hooks";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
@ -24,13 +31,19 @@ import { JsonEditor } from "./JsonEditor";
import { ErrorAlert, SuccessAlert } from "./Alert"; import { ErrorAlert, SuccessAlert } from "./Alert";
import { SquiggleContainer } from "./SquiggleContainer"; import { SquiggleContainer } from "./SquiggleContainer";
import { Toggle } from "./ui/Toggle"; import { Toggle } from "./ui/Toggle";
import { Checkbox } from "./ui/Checkbox";
import { StyledTab } from "./ui/StyledTab"; import { StyledTab } from "./ui/StyledTab";
import { InputItem } from "./ui/InputItem";
import { Text } from "./ui/Text";
import { ViewSettings, viewSettingsSchema } from "./ViewSettings";
import { HeadedSection } from "./ui/HeadedSection";
import {
defaultColor,
defaultTickFormat,
} from "../lib/distributionSpecBuilder";
type PlaygroundProps = SquiggleChartProps & { type PlaygroundProps = SquiggleChartProps & {
/** The initial squiggle string to put in the playground */ /** The initial squiggle string to put in the playground */
defaultCode?: string; defaultCode?: string;
/** How many pixels high is the playground */
onCodeChange?(expr: string): void; onCodeChange?(expr: string): void;
/* When settings change */ /* When settings change */
onSettingsChange?(settings: any): void; onSettingsChange?(settings: any): void;
@ -38,7 +51,9 @@ type PlaygroundProps = SquiggleChartProps & {
showEditor?: boolean; showEditor?: boolean;
}; };
const schema = yup.object({}).shape({ const schema = yup
.object({})
.shape({
sampleCount: yup sampleCount: yup
.number() .number()
.required() .required()
@ -55,74 +70,11 @@ const schema = yup.object({}).shape({
.default(1000) .default(1000)
.min(10) .min(10)
.max(10000), .max(10000),
chartHeight: yup.number().required().positive().integer().default(350), })
leftSizePercent: yup .concat(viewSettingsSchema);
.number()
.required()
.positive()
.integer()
.min(10)
.max(100)
.default(50),
showTypes: yup.boolean().required(),
showControls: yup.boolean().required(),
showSummary: yup.boolean().required(),
showEditor: yup.boolean().required(),
logX: yup.boolean().required(),
expY: yup.boolean().required(),
tickFormat: yup.string().default(".9~s"),
title: yup.string(),
color: yup.string().default("#739ECC").required(),
minX: yup.number(),
maxX: yup.number(),
distributionChartActions: yup.boolean(),
showSettingsPage: yup.boolean().default(false),
diagramStart: yup.number().required().positive().integer().default(0).min(0),
diagramStop: yup.number().required().positive().integer().default(10).min(0),
diagramCount: yup.number().required().positive().integer().default(20).min(2),
});
type FormFields = yup.InferType<typeof schema>; type FormFields = yup.InferType<typeof schema>;
const HeadedSection: FC<{ title: string; children: React.ReactNode }> = ({
title,
children,
}) => (
<div>
<header className="text-lg leading-6 font-medium text-gray-900">
{title}
</header>
<div className="mt-4">{children}</div>
</div>
);
const Text: FC<{ children: React.ReactNode }> = ({ children }) => (
<p className="text-sm text-gray-500">{children}</p>
);
function InputItem<T>({
name,
label,
type,
register,
}: {
name: Path<T>;
label: string;
type: "number" | "text" | "color";
register: UseFormRegister<T>;
}) {
return (
<label className="block">
<div className="text-sm font-medium text-gray-600 mb-1">{label}</div>
<input
type={type}
{...register(name, { valueAsNumber: type === "number" })}
className="form-input max-w-lg block w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:max-w-xs sm:text-sm border-gray-300 rounded-md"
/>
</label>
);
}
const SamplingSettings: React.FC<{ register: UseFormRegister<FormFields> }> = ({ const SamplingSettings: React.FC<{ register: UseFormRegister<FormFields> }> = ({
register, register,
}) => ( }) => (
@ -158,128 +110,6 @@ const SamplingSettings: React.FC<{ register: UseFormRegister<FormFields> }> = ({
</div> </div>
); );
const ViewSettings: React.FC<{ register: UseFormRegister<FormFields> }> = ({
register,
}) => (
<div className="space-y-6 p-3 divide-y divide-gray-200 max-w-xl">
<HeadedSection title="General Display Settings">
<div className="space-y-4">
<Checkbox
name="showEditor"
register={register}
label="Show code editor on left"
/>
<InputItem
name="chartHeight"
type="number"
register={register}
label="Chart Height (in pixels)"
/>
<Checkbox
name="showTypes"
register={register}
label="Show information about displayed types"
/>
</div>
</HeadedSection>
<div className="pt-8">
<HeadedSection title="Distribution Display Settings">
<div className="space-y-2">
<Checkbox
register={register}
name="logX"
label="Show x scale logarithmically"
/>
<Checkbox
register={register}
name="expY"
label="Show y scale exponentially"
/>
<Checkbox
register={register}
name="distributionChartActions"
label="Show vega chart controls"
/>
<Checkbox
register={register}
name="showControls"
label="Show toggles to adjust scale of x and y axes"
/>
<Checkbox
register={register}
name="showSummary"
label="Show summary statistics"
/>
<InputItem
name="minX"
type="number"
register={register}
label="Min X Value"
/>
<InputItem
name="maxX"
type="number"
register={register}
label="Max X Value"
/>
<InputItem
name="title"
type="text"
register={register}
label="Title"
/>
<InputItem
name="tickFormat"
type="text"
register={register}
label="Tick Format"
/>
<InputItem
name="color"
type="color"
register={register}
label="Color"
/>
</div>
</HeadedSection>
</div>
<div className="pt-8">
<HeadedSection title="Function Display Settings">
<div className="space-y-6">
<Text>
When displaying functions of single variables that return numbers or
distributions, we need to use defaults for the x-axis. We need to
select a minimum and maximum value of x to sample, and a number n of
the number of points to sample.
</Text>
<div className="space-y-4">
<InputItem
type="number"
name="diagramStart"
register={register}
label="Min X Value"
/>
<InputItem
type="number"
name="diagramStop"
register={register}
label="Max X Value"
/>
<InputItem
type="number"
name="diagramCount"
register={register}
label="Points between X min and X max to sample"
/>
</div>
</div>
</HeadedSection>
</div>
</div>
);
const InputVariablesSettings: React.FC<{ const InputVariablesSettings: React.FC<{
initialImports: any; // TODO - any json type initialImports: any; // TODO - any json type
setImports: (imports: any) => void; setImports: (imports: any) => void;
@ -406,19 +236,24 @@ const useRunnerState = (code: string) => {
}; };
}; };
type PlaygroundContextShape = {
getLeftPanelElement: () => HTMLDivElement | undefined;
};
export const PlaygroundContext = React.createContext<PlaygroundContextShape>({
getLeftPanelElement: () => undefined,
});
export const SquigglePlayground: FC<PlaygroundProps> = ({ export const SquigglePlayground: FC<PlaygroundProps> = ({
defaultCode = "", defaultCode = "",
height = 500, height = 500,
showTypes = false,
showControls = false,
showSummary = false, showSummary = false,
logX = false, logX = false,
expY = false, expY = false,
title, title,
minX, minX,
maxX, maxX,
color = "#739ECC", color = defaultColor,
tickFormat = ".9~s", tickFormat = defaultTickFormat,
distributionChartActions, distributionChartActions,
code: controlledCode, code: controlledCode,
onCodeChange, onCodeChange,
@ -439,8 +274,6 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
sampleCount: 1000, sampleCount: 1000,
xyPointLength: 1000, xyPointLength: 1000,
chartHeight: 150, chartHeight: 150,
showTypes,
showControls,
logX, logX,
expY, expY,
title, title,
@ -451,8 +284,6 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
distributionChartActions, distributionChartActions,
showSummary, showSummary,
showEditor, showEditor,
leftSizePercent: 50,
showSettingsPage: false,
diagramStart: 0, diagramStart: 0,
diagramStop: 10, diagramStop: 10,
diagramCount: 20, diagramCount: 20,
@ -484,6 +315,7 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
{...vars} {...vars}
bindings={defaultBindings} bindings={defaultBindings}
jsImports={imports} jsImports={imports}
enableLocalSettings={true}
/> />
); );
@ -509,7 +341,15 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
<SamplingSettings register={register} /> <SamplingSettings register={register} />
</StyledTab.Panel> </StyledTab.Panel>
<StyledTab.Panel> <StyledTab.Panel>
<ViewSettings register={register} /> <ViewSettings
register={
// This is dangerous, but doesn't cause any problems.
// I tried to make `ViewSettings` generic (to allow it to accept any extension of a settings schema), but it didn't work.
register as unknown as UseFormRegister<
yup.InferType<typeof viewSettingsSchema>
>
}
/>
</StyledTab.Panel> </StyledTab.Panel>
<StyledTab.Panel> <StyledTab.Panel>
<InputVariablesSettings <InputVariablesSettings
@ -520,17 +360,30 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
</StyledTab.Panels> </StyledTab.Panels>
); );
const leftPanelRef = useRef<HTMLDivElement | null>(null);
const withEditor = ( const withEditor = (
<div className="flex mt-2"> <div className="flex mt-2">
<div className="w-1/2">{tabs}</div> <div
className="w-1/2 relative"
style={{ minHeight: height }}
ref={leftPanelRef}
>
{tabs}
</div>
<div className="w-1/2 p-2 pl-4">{squiggleChart}</div> <div className="w-1/2 p-2 pl-4">{squiggleChart}</div>
</div> </div>
); );
const withoutEditor = <div className="mt-3">{tabs}</div>; const withoutEditor = <div className="mt-3">{tabs}</div>;
const getLeftPanelElement = useCallback(() => {
return leftPanelRef.current ?? undefined;
}, []);
return ( return (
<SquiggleContainer> <SquiggleContainer>
<PlaygroundContext.Provider value={{ getLeftPanelElement }}>
<StyledTab.Group> <StyledTab.Group>
<div className="pb-4"> <div className="pb-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
@ -554,6 +407,7 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
{vars.showEditor ? withEditor : withoutEditor} {vars.showEditor ? withEditor : withoutEditor}
</div> </div>
</StyledTab.Group> </StyledTab.Group>
</PlaygroundContext.Provider>
</SquiggleContainer> </SquiggleContainer>
); );
}; };

View File

@ -0,0 +1,291 @@
import React from "react";
import { squiggleExpression, declaration } from "@quri/squiggle-lang";
import { NumberShower } from "../NumberShower";
import { DistributionChart } from "../DistributionChart";
import { FunctionChart, FunctionChartSettings } from "../FunctionChart";
import clsx from "clsx";
import { VariableBox } from "./VariableBox";
import { ItemSettingsMenu } from "./ItemSettingsMenu";
import { hasMassBelowZero } from "../../lib/distributionUtils";
import { MergedItemSettings } from "./utils";
function getRange<a>(x: declaration<a>) {
const first = x.args[0];
switch (first.tag) {
case "Float": {
return { floats: { min: first.value.min, max: first.value.max } };
}
case "Date": {
return { time: { min: first.value.min, max: first.value.max } };
}
}
}
function getChartSettings<a>(x: declaration<a>): FunctionChartSettings {
const range = getRange(x);
const min = range.floats ? range.floats.min : 0;
const max = range.floats ? range.floats.max : 10;
return {
start: min,
stop: max,
count: 20,
};
}
const VariableList: React.FC<{
path: string[];
heading: string;
children: (settings: MergedItemSettings) => React.ReactNode;
}> = ({ path, heading, children }) => (
<VariableBox path={path} heading={heading}>
{(settings) => (
<div className={clsx("space-y-3", path.length ? "pt-1 mt-1" : null)}>
{children(settings)}
</div>
)}
</VariableBox>
);
export interface Props {
/** The output of squiggle's run */
expression: squiggleExpression;
/** Path to the current item, e.g. `['foo', 'bar', '3']` for `foo.bar[3]`; can be empty on the top-level item. */
path: string[];
width?: number;
}
export const ExpressionViewer: React.FC<Props> = ({
path,
expression,
width,
}) => {
switch (expression.tag) {
case "number":
return (
<VariableBox path={path} heading="Number">
{() => (
<div className="font-semibold text-slate-600">
<NumberShower precision={3} number={expression.value} />
</div>
)}
</VariableBox>
);
case "distribution": {
const distType = expression.value.type();
return (
<VariableBox
path={path}
heading={`Distribution (${distType})\n${
distType === "Symbolic" ? expression.value.toString() : ""
}`}
renderSettingsMenu={({ onChange }) => {
const shape = expression.value.pointSet();
return (
<ItemSettingsMenu
path={path}
onChange={onChange}
disableLogX={
shape.tag === "Ok" && hasMassBelowZero(shape.value)
}
withFunctionSettings={false}
/>
);
}}
>
{(settings) => {
return (
<DistributionChart
distribution={expression.value}
{...settings.distributionPlotSettings}
height={settings.height}
width={width}
/>
);
}}
</VariableBox>
);
}
case "string":
return (
<VariableBox path={path} heading="String">
{() => (
<>
<span className="text-slate-400">"</span>
<span className="text-slate-600 font-semibold font-mono">
{expression.value}
</span>
<span className="text-slate-400">"</span>
</>
)}
</VariableBox>
);
case "boolean":
return (
<VariableBox path={path} heading="Boolean">
{() => expression.value.toString()}
</VariableBox>
);
case "symbol":
return (
<VariableBox path={path} heading="Symbol">
{() => (
<>
<span className="text-slate-500 mr-2">Undefined Symbol:</span>
<span className="text-slate-600">{expression.value}</span>
</>
)}
</VariableBox>
);
case "call":
return (
<VariableBox path={path} heading="Call">
{() => expression.value}
</VariableBox>
);
case "arraystring":
return (
<VariableBox path={path} heading="Array String">
{() => expression.value.map((r) => `"${r}"`).join(", ")}
</VariableBox>
);
case "date":
return (
<VariableBox path={path} heading="Date">
{() => expression.value.toDateString()}
</VariableBox>
);
case "void":
return (
<VariableBox path={path} heading="Void">
{() => "Void"}
</VariableBox>
);
case "timeDuration": {
return (
<VariableBox path={path} heading="Time Duration">
{() => <NumberShower precision={3} number={expression.value} />}
</VariableBox>
);
}
case "lambda":
return (
<VariableBox
path={path}
heading="Function"
renderSettingsMenu={({ onChange }) => {
return (
<ItemSettingsMenu
path={path}
onChange={onChange}
withFunctionSettings={true}
/>
);
}}
>
{(settings) => (
<>
<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={settings.height}
environment={{
sampleCount: settings.environment.sampleCount / 10,
xyPointLength: settings.environment.xyPointLength / 10,
}}
/>
</>
)}
</VariableBox>
);
case "lambdaDeclaration": {
return (
<VariableBox
path={path}
heading="Function Declaration"
renderSettingsMenu={({ onChange }) => {
return (
<ItemSettingsMenu
onChange={onChange}
path={path}
withFunctionSettings={true}
/>
);
}}
>
{(settings) => (
<FunctionChart
fn={expression.value.fn}
chartSettings={getChartSettings(expression.value)}
distributionPlotSettings={settings.distributionPlotSettings}
height={settings.height}
environment={{
sampleCount: settings.environment.sampleCount / 10,
xyPointLength: settings.environment.xyPointLength / 10,
}}
/>
)}
</VariableBox>
);
}
case "module": {
return (
<VariableList path={path} heading="Module">
{(settings) =>
Object.entries(expression.value)
.filter(([key, r]) => !key.match(/^(Math|System)\./))
.map(([key, r]) => (
<ExpressionViewer
key={key}
path={[...path, key]}
expression={r}
width={width !== undefined ? width - 20 : width}
/>
))
}
</VariableList>
);
}
case "record":
return (
<VariableList path={path} heading="Record">
{(settings) =>
Object.entries(expression.value).map(([key, r]) => (
<ExpressionViewer
key={key}
path={[...path, key]}
expression={r}
width={width !== undefined ? width - 20 : width}
/>
))
}
</VariableList>
);
case "array":
return (
<VariableList path={path} heading="Array">
{(settings) =>
expression.value.map((r, i) => (
<ExpressionViewer
key={i}
path={[...path, String(i)]}
expression={r}
width={width !== undefined ? width - 20 : width}
/>
))
}
</VariableList>
);
default: {
return (
<div>
<span>No display for type: </span>{" "}
<span className="font-semibold text-slate-600">{expression.tag}</span>
</div>
);
}
}
};

View File

@ -0,0 +1,168 @@
import { CogIcon } from "@heroicons/react/solid";
import React, { useContext, useRef, useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import { Modal } from "../ui/Modal";
import { ViewSettings, viewSettingsSchema } from "../ViewSettings";
import { Path, pathAsString } from "./utils";
import { ViewerContext } from "./ViewerContext";
import {
defaultColor,
defaultTickFormat,
} from "../../lib/distributionSpecBuilder";
import { PlaygroundContext } from "../SquigglePlayground";
type Props = {
path: Path;
onChange: () => void;
disableLogX?: boolean;
withFunctionSettings: boolean;
};
const ItemSettingsModal: React.FC<
Props & { close: () => void; resetScroll: () => void }
> = ({
path,
onChange,
disableLogX,
withFunctionSettings,
close,
resetScroll,
}) => {
const { setSettings, getSettings, getMergedSettings } =
useContext(ViewerContext);
const mergedSettings = getMergedSettings(path);
const { register, watch } = useForm({
resolver: yupResolver(viewSettingsSchema),
defaultValues: {
// this is a mess and should be fixed
showEditor: true, // doesn't matter
chartHeight: mergedSettings.height,
showSummary: mergedSettings.distributionPlotSettings.showSummary,
logX: mergedSettings.distributionPlotSettings.logX,
expY: mergedSettings.distributionPlotSettings.expY,
tickFormat:
mergedSettings.distributionPlotSettings.format || defaultTickFormat,
title: mergedSettings.distributionPlotSettings.title,
color: mergedSettings.distributionPlotSettings.color || defaultColor,
minX: mergedSettings.distributionPlotSettings.minX,
maxX: mergedSettings.distributionPlotSettings.maxX,
distributionChartActions: mergedSettings.distributionPlotSettings.actions,
diagramStart: mergedSettings.chartSettings.start,
diagramStop: mergedSettings.chartSettings.stop,
diagramCount: mergedSettings.chartSettings.count,
},
});
useEffect(() => {
const subscription = watch((vars) => {
const settings = getSettings(path); // get the latest version
setSettings(path, {
...settings,
distributionPlotSettings: {
showSummary: vars.showSummary,
logX: vars.logX,
expY: vars.expY,
format: vars.tickFormat,
title: vars.title,
color: vars.color,
minX: vars.minX,
maxX: vars.maxX,
actions: vars.distributionChartActions,
},
chartSettings: {
start: vars.diagramStart,
stop: vars.diagramStop,
count: vars.diagramCount,
},
});
onChange();
});
return () => subscription.unsubscribe();
}, [getSettings, setSettings, onChange, path, watch]);
const { getLeftPanelElement } = useContext(PlaygroundContext);
return (
<Modal container={getLeftPanelElement()} close={close}>
<Modal.Header>
Chart settings
{path.length ? (
<>
{" for "}
<span
title="Scroll to item"
className="cursor-pointer"
onClick={resetScroll}
>
{pathAsString(path)}
</span>{" "}
</>
) : (
""
)}
</Modal.Header>
<Modal.Body>
<ViewSettings
register={register}
withShowEditorSetting={false}
withFunctionSettings={withFunctionSettings}
disableLogXSetting={disableLogX}
/>
</Modal.Body>
</Modal>
);
};
export const ItemSettingsMenu: React.FC<Props> = (props) => {
const [isOpen, setIsOpen] = useState(false);
const { enableLocalSettings, setSettings, getSettings } =
useContext(ViewerContext);
const ref = useRef<HTMLDivElement | null>(null);
if (!enableLocalSettings) {
return null;
}
const settings = getSettings(props.path);
const resetScroll = () => {
if (!ref.current) return;
window.scroll({
top: ref.current.getBoundingClientRect().y + window.scrollY,
behavior: "smooth",
});
};
return (
<div className="flex gap-2" ref={ref}>
<CogIcon
className="h-5 w-5 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => setIsOpen(!isOpen)}
/>
{settings.distributionPlotSettings || settings.chartSettings ? (
<button
onClick={() => {
setSettings(props.path, {
...settings,
distributionPlotSettings: undefined,
chartSettings: undefined,
});
props.onChange();
}}
className="text-xs px-1 py-0.5 rounded bg-slate-300"
>
Reset settings
</button>
) : null}
{isOpen ? (
<ItemSettingsModal
{...props}
close={() => setIsOpen(false)}
resetScroll={resetScroll}
/>
) : null}
</div>
);
};

View File

@ -0,0 +1,79 @@
import React, { useContext, useReducer } from "react";
import { Tooltip } from "../ui/Tooltip";
import { LocalItemSettings, MergedItemSettings } from "./utils";
import { ViewerContext } from "./ViewerContext";
type SettingsMenuParams = {
onChange: () => void; // used to notify VariableBox that settings have changed, so that VariableBox could re-render itself
};
type VariableBoxProps = {
path: string[];
heading: string;
renderSettingsMenu?: (params: SettingsMenuParams) => React.ReactNode;
children: (settings: MergedItemSettings) => React.ReactNode;
};
export const VariableBox: React.FC<VariableBoxProps> = ({
path,
heading = "Error",
renderSettingsMenu,
children,
}) => {
const { setSettings, getSettings, getMergedSettings } =
useContext(ViewerContext);
// Since ViewerContext doesn't keep the actual settings, VariableBox won't rerender when setSettings is called.
// So we use `forceUpdate` to force rerendering.
const [_, forceUpdate] = useReducer((x) => x + 1, 0);
const settings = getSettings(path);
const setSettingsAndUpdate = (newSettings: LocalItemSettings) => {
setSettings(path, newSettings);
forceUpdate();
};
const toggleCollapsed = () => {
setSettingsAndUpdate({ ...settings, collapsed: !settings.collapsed });
};
const isTopLevel = path.length === 0;
const name = isTopLevel ? "Result" : path[path.length - 1];
return (
<div>
<header className="inline-flex space-x-1">
<Tooltip text={heading}>
<span
className="text-slate-500 font-mono text-sm cursor-pointer"
onClick={toggleCollapsed}
>
{name}:
</span>
</Tooltip>
{settings.collapsed ? (
<span
className="rounded p-0.5 bg-slate-200 text-slate-500 font-mono text-xs cursor-pointer"
onClick={toggleCollapsed}
>
...
</span>
) : renderSettingsMenu ? (
renderSettingsMenu({ onChange: forceUpdate })
) : null}
</header>
{settings.collapsed ? null : (
<div className="flex w-full">
{path.length ? (
<div
className="border-l-2 border-slate-200 hover:border-indigo-600 w-4 cursor-pointer"
onClick={toggleCollapsed}
></div>
) : null}
<div className="grow">{children(getMergedSettings(path))}</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,35 @@
import { defaultEnvironment } from "@quri/squiggle-lang";
import React from "react";
import { LocalItemSettings, MergedItemSettings, Path } from "./utils";
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).
// 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.
getSettings(path: Path): LocalItemSettings;
getMergedSettings(path: Path): MergedItemSettings;
setSettings(path: Path, value: LocalItemSettings): void;
enableLocalSettings: boolean; // show local settings icon in the UI
};
export const ViewerContext = React.createContext<ViewerContextShape>({
getSettings: () => ({ collapsed: false }),
getMergedSettings: () => ({
collapsed: false,
// copy-pasted from SquiggleChart
chartSettings: {
start: 0,
stop: 10,
count: 100,
},
distributionPlotSettings: {
showSummary: false,
logX: false,
expY: false,
},
environment: defaultEnvironment,
height: 150,
}),
setSettings() {},
enableLocalSettings: false,
});

View File

@ -0,0 +1,100 @@
import React, { useCallback, useRef } from "react";
import { environment } from "@quri/squiggle-lang";
import { DistributionPlottingSettings } from "../DistributionChart";
import { FunctionChartSettings } from "../FunctionChart";
import { ExpressionViewer } from "./ExpressionViewer";
import { ViewerContext } from "./ViewerContext";
import {
LocalItemSettings,
MergedItemSettings,
Path,
pathAsString,
} from "./utils";
import { useSquiggle } from "../../lib/hooks";
import { SquiggleErrorAlert } from "../SquiggleErrorAlert";
type Props = {
/** The output of squiggle's run */
result: ReturnType<typeof useSquiggle>;
width?: number;
height: number;
distributionPlotSettings: DistributionPlottingSettings;
/** Settings for displaying functions */
chartSettings: FunctionChartSettings;
/** Environment for further function executions */
environment: environment;
enableLocalSettings?: boolean;
};
type Settings = {
[k: string]: LocalItemSettings;
};
const defaultSettings: LocalItemSettings = { collapsed: false };
export const SquiggleViewer: React.FC<Props> = ({
result,
width,
height,
distributionPlotSettings,
chartSettings,
environment,
enableLocalSettings = false,
}) => {
// 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 getSettings = useCallback(
(path: Path) => {
return settingsRef.current[pathAsString(path)] || defaultSettings;
},
[settingsRef]
);
const setSettings = useCallback(
(path: Path, value: LocalItemSettings) => {
settingsRef.current[pathAsString(path)] = value;
},
[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 || {}),
},
height: localSettings.height || height,
};
return result;
},
[distributionPlotSettings, chartSettings, environment, height, getSettings]
);
return (
<ViewerContext.Provider
value={{
getSettings,
setSettings,
getMergedSettings,
enableLocalSettings,
}}
>
{result.tag === "Ok" ? (
<ExpressionViewer path={[]} expression={result.value} width={width} />
) : (
<SquiggleErrorAlert error={result.value} />
)}
</ViewerContext.Provider>
);
};

View File

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

View File

@ -0,0 +1,163 @@
import React from "react";
import * as yup from "yup";
import { UseFormRegister } from "react-hook-form";
import { InputItem } from "./ui/InputItem";
import { Checkbox } from "./ui/Checkbox";
import { HeadedSection } from "./ui/HeadedSection";
import { Text } from "./ui/Text";
import {
defaultColor,
defaultTickFormat,
} from "../lib/distributionSpecBuilder";
export const viewSettingsSchema = yup.object({}).shape({
chartHeight: yup.number().required().positive().integer().default(350),
showSummary: yup.boolean().required(),
showEditor: yup.boolean().required(),
logX: yup.boolean().required(),
expY: yup.boolean().required(),
tickFormat: yup.string().default(defaultTickFormat),
title: yup.string(),
color: yup.string().default(defaultColor).required(),
minX: yup.number(),
maxX: yup.number(),
distributionChartActions: yup.boolean(),
diagramStart: yup.number().required().positive().integer().default(0).min(0),
diagramStop: yup.number().required().positive().integer().default(10).min(0),
diagramCount: yup.number().required().positive().integer().default(20).min(2),
});
type FormFields = yup.InferType<typeof viewSettingsSchema>;
// This component is used in two places: for global settings in SquigglePlayground, and for item-specific settings in modal dialogs.
export const ViewSettings: React.FC<{
withShowEditorSetting?: boolean;
withFunctionSettings?: boolean;
disableLogXSetting?: boolean;
register: UseFormRegister<FormFields>;
}> = ({
withShowEditorSetting = true,
withFunctionSettings = true,
disableLogXSetting,
register,
}) => {
return (
<div className="space-y-6 p-3 divide-y divide-gray-200 max-w-xl">
<HeadedSection title="General Display Settings">
<div className="space-y-4">
{withShowEditorSetting ? (
<Checkbox
name="showEditor"
register={register}
label="Show code editor on left"
/>
) : null}
<InputItem
name="chartHeight"
type="number"
register={register}
label="Chart Height (in pixels)"
/>
</div>
</HeadedSection>
<div className="pt-8">
<HeadedSection title="Distribution Display Settings">
<div className="space-y-2">
<Checkbox
register={register}
name="logX"
label="Show x scale logarithmically"
disabled={disableLogXSetting}
tooltip={
disableLogXSetting
? "Your distribution has mass lower than or equal to 0. Log only works on strictly positive values."
: undefined
}
/>
<Checkbox
register={register}
name="expY"
label="Show y scale exponentially"
/>
<Checkbox
register={register}
name="distributionChartActions"
label="Show vega chart controls"
/>
<Checkbox
register={register}
name="showSummary"
label="Show summary statistics"
/>
<InputItem
name="minX"
type="number"
register={register}
label="Min X Value"
/>
<InputItem
name="maxX"
type="number"
register={register}
label="Max X Value"
/>
<InputItem
name="title"
type="text"
register={register}
label="Title"
/>
<InputItem
name="tickFormat"
type="text"
register={register}
label="Tick Format"
/>
<InputItem
name="color"
type="color"
register={register}
label="Color"
/>
</div>
</HeadedSection>
</div>
{withFunctionSettings ? (
<div className="pt-8">
<HeadedSection title="Function Display Settings">
<div className="space-y-6">
<Text>
When displaying functions of single variables that return
numbers or distributions, we need to use defaults for the
x-axis. We need to select a minimum and maximum value of x to
sample, and a number n of the number of points to sample.
</Text>
<div className="space-y-4">
<InputItem
type="number"
name="diagramStart"
register={register}
label="Min X Value"
/>
<InputItem
type="number"
name="diagramStop"
register={register}
label="Max X Value"
/>
<InputItem
type="number"
name="diagramCount"
register={register}
label="Points between X min and X max to sample"
/>
</div>
</div>
</HeadedSection>
</div>
) : null}
</div>
);
};

View File

@ -1,3 +1,4 @@
import clsx from "clsx";
import React from "react"; import React from "react";
import { Path, UseFormRegister } from "react-hook-form"; import { Path, UseFormRegister } from "react-hook-form";
@ -5,20 +6,32 @@ export function Checkbox<T>({
name, name,
label, label,
register, register,
disabled,
tooltip,
}: { }: {
name: Path<T>; name: Path<T>;
label: string; label: string;
register: UseFormRegister<T>; register: UseFormRegister<T>;
disabled?: boolean;
tooltip?: string;
}) { }) {
return ( return (
<label className="flex items-center"> <label className="flex items-center" title={tooltip}>
<input <input
type="checkbox" type="checkbox"
disabled={disabled}
{...register(name)} {...register(name)}
className="form-checkbox focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded" className="form-checkbox focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded"
/> />
{/* Clicking on the div makes the checkbox lose focus while mouse button is pressed, leading to annoying blinking; I couldn't figure out how to fix this. */} {/* Clicking on the div makes the checkbox lose focus while mouse button is pressed, leading to annoying blinking; I couldn't figure out how to fix this. */}
<div className="ml-3 text-sm font-medium text-gray-700">{label}</div> <div
className={clsx(
"ml-3 text-sm font-medium",
disabled ? "text-gray-400" : "text-gray-700"
)}
>
{label}
</div>
</label> </label>
); );
} }

View File

@ -0,0 +1,13 @@
import React from "react";
export const HeadedSection: React.FC<{
title: string;
children: React.ReactNode;
}> = ({ title, children }) => (
<div>
<header className="text-lg leading-6 font-medium text-gray-900">
{title}
</header>
<div className="mt-4">{children}</div>
</div>
);

View File

@ -0,0 +1,25 @@
import React from "react";
import { Path, UseFormRegister } from "react-hook-form";
export function InputItem<T>({
name,
label,
type,
register,
}: {
name: Path<T>;
label: string;
type: "number" | "text" | "color";
register: UseFormRegister<T>;
}) {
return (
<label className="block">
<div className="text-sm font-medium text-gray-600 mb-1">{label}</div>
<input
type={type}
{...register(name, { valueAsNumber: type === "number" })}
className="form-input max-w-lg block w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:max-w-xs sm:text-sm border-gray-300 rounded-md"
/>
</label>
);
}

View File

@ -0,0 +1,184 @@
import { motion } from "framer-motion";
import React, { useContext } from "react";
import * as ReactDOM from "react-dom";
import { XIcon } from "@heroicons/react/solid";
import clsx from "clsx";
import { useWindowScroll, useWindowSize } from "react-use";
type ModalContextShape = {
close: () => void;
};
const ModalContext = React.createContext<ModalContextShape>({
close: () => undefined,
});
const Overlay: React.FC = () => {
const { close } = useContext(ModalContext);
return (
<motion.div
className="absolute inset-0 -z-10 bg-black"
initial={{ opacity: 0 }}
animate={{ opacity: 0.1 }}
onClick={close}
/>
);
};
const ModalHeader: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
const { close } = useContext(ModalContext);
return (
<header className="px-5 py-3 border-b border-gray-200 font-bold flex items-center justify-between">
<div>{children}</div>
<button
className="px-1 bg-transparent cursor-pointer text-gray-700 hover:text-accent-500"
type="button"
onClick={close}
>
<XIcon className="h-5 w-5 cursor-pointer text-slate-400 hover:text-slate-500" />
</button>
</header>
);
};
// TODO - get rid of forwardRef, support `focus` and `{...hotkeys}` via smart props
const ModalBody = React.forwardRef<
HTMLDivElement,
JSX.IntrinsicElements["div"]
>(function ModalBody(props, ref) {
return <div ref={ref} className="px-5 py-3 overflow-auto" {...props} />;
});
const ModalFooter: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div className="px-5 py-3 border-t border-gray-200">{children}</div>
);
const ModalWindow: React.FC<{
children: React.ReactNode;
container?: HTMLElement;
}> = ({ children, container }) => {
// This component works in two possible modes:
// 1. container mode - the modal is rendered inside a container element
// 2. centered mode - the modal is rendered in the middle of the screen
// The mode is determined by the presence of the `container` prop and by whether the available space is large enough to fit the modal.
// Necessary for container mode - need to reposition the modal on scroll and resize events.
useWindowSize();
useWindowScroll();
let position:
| {
left: number;
top: number;
maxWidth: number;
maxHeight: number;
transform: string;
}
| undefined;
// If available space in `visibleRect` is smaller than these, fallback to positioning in the middle of the screen.
const minWidth = 384;
const minHeight = 300;
const offset = 8;
const naturalWidth = 576; // maximum possible width; modal tries to take this much space, but can be smaller
if (container) {
const { clientWidth: screenWidth, clientHeight: screenHeight } =
document.documentElement;
const rect = container?.getBoundingClientRect();
const visibleRect = {
left: Math.max(rect.left, 0),
right: Math.min(rect.right, screenWidth),
top: Math.max(rect.top, 0),
bottom: Math.min(rect.bottom, screenHeight),
};
const maxWidth = visibleRect.right - visibleRect.left - 2 * offset;
const maxHeight = visibleRect.bottom - visibleRect.top - 2 * offset;
const center = {
left: visibleRect.left + (visibleRect.right - visibleRect.left) / 2,
top: visibleRect.top + (visibleRect.bottom - visibleRect.top) / 2,
};
position = {
left: center.left,
top: center.top,
transform: "translate(-50%, -50%)",
maxWidth,
maxHeight,
};
if (maxWidth < minWidth || maxHeight < minHeight) {
position = undefined; // modal is hard to fit in the container, fallback to positioning it in the middle of the screen
}
}
return (
<div
className={clsx(
"bg-white rounded-md shadow-toast flex flex-col overflow-auto border",
position ? "fixed" : null
)}
style={{
width: naturalWidth,
...(position ?? {
maxHeight: "calc(100% - 20px)",
maxWidth: "calc(100% - 20px)",
width: naturalWidth,
}),
}}
>
{children}
</div>
);
};
type ModalType = React.FC<{
children: React.ReactNode;
container?: HTMLElement; // if specified, modal will be positioned over the visible part of the container, if it's not too small
close: () => void;
}> & {
Body: typeof ModalBody;
Footer: typeof ModalFooter;
Header: typeof ModalHeader;
};
export const Modal: ModalType = ({ children, container, close }) => {
const [el] = React.useState(() => document.createElement("div"));
React.useEffect(() => {
document.body.appendChild(el);
return () => {
document.body.removeChild(el);
};
}, [el]);
React.useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
close();
}
};
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("keydown", handleEscape);
};
}, [close]);
const modal = (
<ModalContext.Provider value={{ close }}>
<div className="squiggle">
<div className="fixed inset-0 z-40 flex justify-center items-center">
<Overlay />
<ModalWindow container={container}>{children}</ModalWindow>
</div>
</div>
</ModalContext.Provider>
);
return ReactDOM.createPortal(modal, container || el);
};
Modal.Body = ModalBody;
Modal.Footer = ModalFooter;
Modal.Header = ModalHeader;

View File

@ -0,0 +1,5 @@
import React from "react";
export const Text: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<p className="text-sm text-gray-500">{children}</p>
);

View File

@ -0,0 +1,64 @@
import React, { cloneElement, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
flip,
shift,
useDismiss,
useFloating,
useHover,
useInteractions,
useRole,
} from "@floating-ui/react-dom-interactions";
interface Props {
text: string;
children: JSX.Element;
}
export const Tooltip: React.FC<Props> = ({ text, children }) => {
const [isOpen, setIsOpen] = useState(false);
const { x, y, reference, floating, strategy, context } = useFloating({
placement: "top",
open: isOpen,
onOpenChange: setIsOpen,
middleware: [shift(), flip()],
});
const { getReferenceProps, getFloatingProps } = useInteractions([
useHover(context),
useRole(context, { role: "tooltip" }),
useDismiss(context),
]);
return (
<>
{cloneElement(
children,
getReferenceProps({ ref: reference, ...children.props })
)}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
{...getFloatingProps({
ref: floating,
className:
"text-xs p-2 border border-gray-300 rounded bg-white z-10",
style: {
position: strategy,
top: y ?? 0,
left: x ?? 0,
},
})}
>
<div className="font-mono whitespace-pre">{text}</div>
</motion.div>
)}
</AnimatePresence>
</>
);
};

View File

@ -100,12 +100,15 @@ export let expYScale: PowScale = {
}, },
}; };
export const defaultTickFormat = ".9~s";
export const defaultColor = "#739ECC";
export function buildVegaSpec( export function buildVegaSpec(
specOptions: DistributionChartSpecOptions specOptions: DistributionChartSpecOptions
): VisualizationSpec { ): VisualizationSpec {
let { let {
format = ".9~s", format = defaultTickFormat,
color = "#739ECC", color = defaultColor,
title, title,
minX, minX,
maxX, maxX,

View File

@ -0,0 +1,5 @@
import { shape } from "@quri/squiggle-lang";
export const hasMassBelowZero = (shape: shape) =>
shape.continuous.some((x) => x.x <= 0) ||
shape.discrete.some((x) => x.x <= 0);

View File

@ -9,8 +9,6 @@
React.createElement(squiggle_components.SquigglePlayground, { React.createElement(squiggle_components.SquigglePlayground, {
code: text, code: text,
showEditor: false, showEditor: false,
showTypes: Boolean(showSettings.showTypes),
showControls: Boolean(showSettings.showControls),
showSummary: Boolean(showSettings.showSummary), showSummary: Boolean(showSettings.showSummary),
}) })
); );

View File

@ -105,16 +105,6 @@
"configuration": { "configuration": {
"title": "Squiggle", "title": "Squiggle",
"properties": { "properties": {
"squiggle.playground.showTypes": {
"type": "boolean",
"default": false,
"description": "Whether to show the types of outputs in the playground"
},
"squiggle.playground.showControls": {
"type": "boolean",
"default": false,
"description": "Whether to show the log scale controls in the playground"
},
"squiggle.playground.showSummary": { "squiggle.playground.showSummary": {
"type": "boolean", "type": "boolean",
"default": false, "default": false,

View File

@ -54,6 +54,7 @@ const config = {
({ ({
navbar: { navbar: {
title: "Squiggle", title: "Squiggle",
hideOnScroll: true,
logo: { logo: {
alt: "Squiggle Logo", alt: "Squiggle Logo",
src: "img/quri-logo.png", src: "img/quri-logo.png",

View File

@ -44,12 +44,11 @@ export default function PlaygroundPage() {
const playgroundProps = { const playgroundProps = {
defaultCode: "normal(0,1)", defaultCode: "normal(0,1)",
height: 700, height: 700,
showTypes: true,
...hashData, ...hashData,
onCodeChange: (code) => setHashData({ initialSquiggleString: code }), onCodeChange: (code) => setHashData({ initialSquiggleString: code }),
onSettingsChange: (settings) => { onSettingsChange: (settings) => {
const { showTypes, showControls, showSummary, showEditor } = settings; const { showSummary, showEditor } = settings;
setHashData({ showTypes, showControls, showSummary, showEditor }); setHashData({ showSummary, showEditor });
}, },
}; };
return ( return (

View File

@ -2190,6 +2190,35 @@
minimatch "^3.1.2" minimatch "^3.1.2"
strip-json-comments "^3.1.1" strip-json-comments "^3.1.1"
"@floating-ui/core@^0.7.3":
version "0.7.3"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-0.7.3.tgz#d274116678ffae87f6b60e90f88cc4083eefab86"
integrity sha512-buc8BXHmG9l82+OQXOFU3Kr2XQx9ys01U/Q9HMIrZ300iLc8HLMgh7dcCqgYzAzf4BkoQvDcXf5Y+CuEZ5JBYg==
"@floating-ui/dom@^0.5.3":
version "0.5.4"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-0.5.4.tgz#4eae73f78bcd4bd553ae2ade30e6f1f9c73fe3f1"
integrity sha512-419BMceRLq0RrmTSDxn8hf9R3VCJv2K9PUfugh5JyEFmdjzDo+e8U5EdR8nzKq8Yj1htzLm3b6eQEEam3/rrtg==
dependencies:
"@floating-ui/core" "^0.7.3"
"@floating-ui/react-dom-interactions@^0.6.6":
version "0.6.6"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom-interactions/-/react-dom-interactions-0.6.6.tgz#8542e8c4bcbee2cd0d512de676c6a493e0a2d168"
integrity sha512-qnao6UPjSZNHnXrF+u4/n92qVroQkx0Umlhy3Avk1oIebm/5ee6yvDm4xbHob0OjY7ya8WmUnV3rQlPwX3Atwg==
dependencies:
"@floating-ui/react-dom" "^0.7.2"
aria-hidden "^1.1.3"
use-isomorphic-layout-effect "^1.1.1"
"@floating-ui/react-dom@^0.7.2":
version "0.7.2"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-0.7.2.tgz#0bf4ceccb777a140fc535c87eb5d6241c8e89864"
integrity sha512-1T0sJcpHgX/u4I1OzIEhlcrvkUN8ln39nz7fMoE/2HDHrPiMFoOGR7++GYyfUmIQHkkrTinaeQsO3XWubjSvGg==
dependencies:
"@floating-ui/dom" "^0.5.3"
use-isomorphic-layout-effect "^1.1.1"
"@gar/promisify@^1.0.1": "@gar/promisify@^1.0.1":
version "1.1.3" version "1.1.3"
resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6"
@ -5748,6 +5777,13 @@ argv@0.0.2:
resolved "https://registry.yarnpkg.com/argv/-/argv-0.0.2.tgz#ecbd16f8949b157183711b1bda334f37840185ab" resolved "https://registry.yarnpkg.com/argv/-/argv-0.0.2.tgz#ecbd16f8949b157183711b1bda334f37840185ab"
integrity sha512-dEamhpPEwRUBpLNHeuCm/v+g0anFByHahxodVO/BbAarHVBBg2MccCwf9K+o1Pof+2btdnkJelYVUWjW/VrATw== integrity sha512-dEamhpPEwRUBpLNHeuCm/v+g0anFByHahxodVO/BbAarHVBBg2MccCwf9K+o1Pof+2btdnkJelYVUWjW/VrATw==
aria-hidden@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.1.3.tgz#bb48de18dc84787a3c6eee113709c473c64ec254"
integrity sha512-RhVWFtKH5BiGMycI72q2RAFMLQi8JP9bLuQXgR5a8Znp7P5KOIADSJeyfI8PCVxLEp067B2HbP5JIiI/PXIZeA==
dependencies:
tslib "^1.0.0"
aria-query@^4.2.2: aria-query@^4.2.2:
version "4.2.2" version "4.2.2"
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-4.2.2.tgz#0d2ca6c9aceb56b8977e9fed6aed7e15bbd2f83b" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-4.2.2.tgz#0d2ca6c9aceb56b8977e9fed6aed7e15bbd2f83b"
@ -17418,7 +17454,7 @@ tsconfig-paths@^3.14.1, tsconfig-paths@^3.9.0:
minimist "^1.2.6" minimist "^1.2.6"
strip-bom "^3.0.0" strip-bom "^3.0.0"
tslib@^1.8.1: tslib@^1.0.0, tslib@^1.8.1:
version "1.14.1" version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==