Merge pull request #796 from quantified-uncertainty/collapsible
Collapsible records in playground output
This commit is contained in:
commit
563f82f48c
|
@ -54,7 +54,6 @@ export function DynamicSquiggleChart({ squiggleString }) {
|
||||||
width={445}
|
width={445}
|
||||||
height={200}
|
height={200}
|
||||||
showSummary={true}
|
showSummary={true}
|
||||||
showTypes={true}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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,
|
||||||
}) => (
|
}) => (
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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,
|
||||||
|
});
|
100
packages/components/src/components/SquiggleViewer/index.tsx
Normal file
100
packages/components/src/components/SquiggleViewer/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
22
packages/components/src/components/SquiggleViewer/utils.ts
Normal file
22
packages/components/src/components/SquiggleViewer/utils.ts
Normal 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(".");
|
163
packages/components/src/components/ViewSettings.tsx
Normal file
163
packages/components/src/components/ViewSettings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
13
packages/components/src/components/ui/HeadedSection.tsx
Normal file
13
packages/components/src/components/ui/HeadedSection.tsx
Normal 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>
|
||||||
|
);
|
25
packages/components/src/components/ui/InputItem.tsx
Normal file
25
packages/components/src/components/ui/InputItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
184
packages/components/src/components/ui/Modal.tsx
Normal file
184
packages/components/src/components/ui/Modal.tsx
Normal 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;
|
5
packages/components/src/components/ui/Text.tsx
Normal file
5
packages/components/src/components/ui/Text.tsx
Normal 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>
|
||||||
|
);
|
64
packages/components/src/components/ui/Tooltip.tsx
Normal file
64
packages/components/src/components/ui/Tooltip.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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,
|
||||||
|
|
5
packages/components/src/lib/distributionUtils.ts
Normal file
5
packages/components/src/lib/distributionUtils.ts
Normal 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);
|
|
@ -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),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
38
yarn.lock
38
yarn.lock
|
@ -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==
|
||||||
|
|
Loading…
Reference in New Issue
Block a user