Merge pull request #824 from quantified-uncertainty/multiple-charts

Add multiple plotting
This commit is contained in:
Vyacheslav Matyukhin 2022-08-19 21:45:52 +04:00 committed by GitHub
commit 514af05fc2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 409 additions and 197 deletions

View File

@ -4,6 +4,8 @@ import {
result, result,
distributionError, distributionError,
distributionErrorToString, distributionErrorToString,
squiggleExpression,
resultMap,
} from "@quri/squiggle-lang"; } from "@quri/squiggle-lang";
import { Vega } from "react-vega"; import { Vega } from "react-vega";
import { ErrorAlert } from "./Alert"; import { ErrorAlert } from "./Alert";
@ -14,6 +16,8 @@ import {
DistributionChartSpecOptions, DistributionChartSpecOptions,
} from "../lib/distributionSpecBuilder"; } from "../lib/distributionSpecBuilder";
import { NumberShower } from "./NumberShower"; import { NumberShower } from "./NumberShower";
import { Plot, parsePlot } from "../lib/plotParser";
import { flattenResult } from "../lib/utility";
import { hasMassBelowZero } from "../lib/distributionUtils"; import { hasMassBelowZero } from "../lib/distributionUtils";
export type DistributionPlottingSettings = { export type DistributionPlottingSettings = {
@ -23,26 +27,41 @@ export type DistributionPlottingSettings = {
} & DistributionChartSpecOptions; } & DistributionChartSpecOptions;
export type DistributionChartProps = { export type DistributionChartProps = {
distribution: Distribution; plot: Plot;
width?: number; width?: number;
height: number; height: number;
} & DistributionPlottingSettings; } & DistributionPlottingSettings;
export function defaultPlot(distribution: Distribution): Plot {
return { distributions: [{ name: "default", distribution }] };
}
export function makePlot(record: {
[key: string]: squiggleExpression;
}): Plot | void {
const plotResult = parsePlot(record);
if (plotResult.tag === "Ok") {
return plotResult.value;
}
}
export const DistributionChart: React.FC<DistributionChartProps> = (props) => { export const DistributionChart: React.FC<DistributionChartProps> = (props) => {
const { const { plot, height, showSummary, width, logX, actions = false } = props;
distribution,
height,
showSummary,
width,
logX,
actions = false,
} = props;
const shape = distribution.pointSet();
const [sized] = useSize((size) => { const [sized] = useSize((size) => {
if (shape.tag === "Error") { let shapes = flattenResult(
plot.distributions.map((x) =>
resultMap(x.distribution.pointSet(), (shape) => ({
name: x.name,
// color: x.color, // not supported yet
continuous: shape.continuous,
discrete: shape.discrete,
}))
)
);
if (shapes.tag === "Error") {
return ( return (
<ErrorAlert heading="Distribution Error"> <ErrorAlert heading="Distribution Error">
{distributionErrorToString(shape.value)} {distributionErrorToString(shapes.value)}
</ErrorAlert> </ErrorAlert>
); );
} }
@ -56,24 +75,29 @@ export const DistributionChart: React.FC<DistributionChartProps> = (props) => {
); );
widthProp = 20; widthProp = 20;
} }
const domain = shapes.value.flatMap((shape) =>
shape.discrete.concat(shape.continuous)
);
return ( return (
<div style={{ width: widthProp }}> <div style={{ width: widthProp }}>
{logX && hasMassBelowZero(shape.value) ? ( {logX && shapes.value.some(hasMassBelowZero) ? (
<ErrorAlert heading="Log Domain Error"> <ErrorAlert heading="Log Domain Error">
Cannot graph distribution with negative values on logarithmic scale. Cannot graph distribution with negative values on logarithmic scale.
</ErrorAlert> </ErrorAlert>
) : ( ) : (
<Vega <Vega
spec={spec} spec={spec}
data={{ con: shape.value.continuous, dis: shape.value.discrete }} data={{ data: shapes.value, domain }}
width={widthProp - 10} width={widthProp - 10}
height={height} height={height}
actions={actions} actions={actions}
/> />
)} )}
<div className="flex justify-center"> <div className="flex justify-center">
{showSummary && <SummaryTable distribution={distribution} />} {showSummary && plot.distributions.length === 1 && (
<SummaryTable distribution={plot.distributions[0].distribution} />
)}
</div> </div>
</div> </div>
); );

View File

@ -16,6 +16,7 @@ import * as percentilesSpec from "../vega-specs/spec-percentiles.json";
import { import {
DistributionChart, DistributionChart,
DistributionPlottingSettings, DistributionPlottingSettings,
defaultPlot,
} from "./DistributionChart"; } from "./DistributionChart";
import { NumberShower } from "./NumberShower"; import { NumberShower } from "./NumberShower";
import { ErrorAlert } from "./Alert"; import { ErrorAlert } from "./Alert";
@ -179,7 +180,7 @@ export const FunctionChart1Dist: React.FC<FunctionChart1DistProps> = ({
let showChart = let showChart =
mouseItem.tag === "Ok" && mouseItem.value.tag === "distribution" ? ( mouseItem.tag === "Ok" && mouseItem.value.tag === "distribution" ? (
<DistributionChart <DistributionChart
distribution={mouseItem.value.value} plot={defaultPlot(mouseItem.value.value)}
width={400} width={400}
height={50} height={50}
{...distributionPlotSettings} {...distributionPlotSettings}

View File

@ -37,10 +37,7 @@ import { InputItem } from "./ui/InputItem";
import { Text } from "./ui/Text"; import { Text } from "./ui/Text";
import { ViewSettings, viewSettingsSchema } from "./ViewSettings"; import { ViewSettings, viewSettingsSchema } from "./ViewSettings";
import { HeadedSection } from "./ui/HeadedSection"; import { HeadedSection } from "./ui/HeadedSection";
import { import { defaultTickFormat } from "../lib/distributionSpecBuilder";
defaultColor,
defaultTickFormat,
} from "../lib/distributionSpecBuilder";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
type PlaygroundProps = SquiggleChartProps & { type PlaygroundProps = SquiggleChartProps & {
@ -240,7 +237,6 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
title, title,
minX, minX,
maxX, maxX,
color = defaultColor,
tickFormat = defaultTickFormat, tickFormat = defaultTickFormat,
distributionChartActions, distributionChartActions,
code: controlledCode, code: controlledCode,
@ -268,7 +264,6 @@ export const SquigglePlayground: FC<PlaygroundProps> = ({
title, title,
minX, minX,
maxX, maxX,
color,
tickFormat, tickFormat,
distributionChartActions, distributionChartActions,
showSummary, showSummary,

View File

@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { squiggleExpression, declaration } from "@quri/squiggle-lang"; import { squiggleExpression, declaration } from "@quri/squiggle-lang";
import { NumberShower } from "../NumberShower"; import { NumberShower } from "../NumberShower";
import { DistributionChart } from "../DistributionChart"; import { DistributionChart, defaultPlot, makePlot } from "../DistributionChart";
import { FunctionChart, FunctionChartSettings } from "../FunctionChart"; import { FunctionChart, FunctionChartSettings } from "../FunctionChart";
import clsx from "clsx"; import clsx from "clsx";
import { VariableBox } from "./VariableBox"; import { VariableBox } from "./VariableBox";
@ -102,7 +102,7 @@ export const ExpressionViewer: React.FC<Props> = ({
{(settings) => { {(settings) => {
return ( return (
<DistributionChart <DistributionChart
distribution={expression.value} plot={defaultPlot(expression.value)}
{...settings.distributionPlotSettings} {...settings.distributionPlotSettings}
height={settings.height} height={settings.height}
width={width} width={width}
@ -241,9 +241,9 @@ export const ExpressionViewer: React.FC<Props> = ({
case "module": { case "module": {
return ( return (
<VariableList path={path} heading="Module"> <VariableList path={path} heading="Module">
{(settings) => {(_) =>
Object.entries(expression.value) Object.entries(expression.value)
.filter(([key, r]) => !key.match(/^(Math|System)\./)) .filter(([key, _]) => !key.match(/^(Math|System)\./))
.map(([key, r]) => ( .map(([key, r]) => (
<ExpressionViewer <ExpressionViewer
key={key} key={key}
@ -257,24 +257,61 @@ export const ExpressionViewer: React.FC<Props> = ({
); );
} }
case "record": case "record":
return ( const plot = makePlot(expression.value);
<VariableList path={path} heading="Record"> if (plot) {
{(settings) => return (
Object.entries(expression.value).map(([key, r]) => ( <VariableBox
<ExpressionViewer path={path}
key={key} heading={"Plot"}
path={[...path, key]} renderSettingsMenu={({ onChange }) => {
expression={r} let disableLogX = plot.distributions.some((x) => {
width={width !== undefined ? width - 20 : width} let pointSet = x.distribution.pointSet();
/> return (
)) pointSet.tag === "Ok" && hasMassBelowZero(pointSet.value)
} );
</VariableList> });
); return (
<ItemSettingsMenu
path={path}
onChange={onChange}
disableLogX={disableLogX}
withFunctionSettings={false}
/>
);
}}
>
{(settings) => {
return (
<DistributionChart
plot={plot}
{...settings.distributionPlotSettings}
height={settings.height}
width={width}
/>
);
}}
</VariableBox>
);
} else {
return (
<VariableList path={path} heading="Record">
{(_) =>
Object.entries(expression.value).map(([key, r]) => (
<ExpressionViewer
key={key}
path={[...path, key]}
expression={r}
width={width !== undefined ? width - 20 : width}
/>
))
}
</VariableList>
);
}
case "array": case "array":
return ( return (
<VariableList path={path} heading="Array"> <VariableList path={path} heading="Array">
{(settings) => {(_) =>
expression.value.map((r, i) => ( expression.value.map((r, i) => (
<ExpressionViewer <ExpressionViewer
key={i} key={i}

View File

@ -6,10 +6,7 @@ import { Modal } from "../ui/Modal";
import { ViewSettings, viewSettingsSchema } from "../ViewSettings"; import { ViewSettings, viewSettingsSchema } from "../ViewSettings";
import { Path, pathAsString } from "./utils"; import { Path, pathAsString } from "./utils";
import { ViewerContext } from "./ViewerContext"; import { ViewerContext } from "./ViewerContext";
import { import { defaultTickFormat } from "../../lib/distributionSpecBuilder";
defaultColor,
defaultTickFormat,
} from "../../lib/distributionSpecBuilder";
import { PlaygroundContext } from "../SquigglePlayground"; import { PlaygroundContext } from "../SquigglePlayground";
type Props = { type Props = {
@ -46,7 +43,6 @@ const ItemSettingsModal: React.FC<
tickFormat: tickFormat:
mergedSettings.distributionPlotSettings.format || defaultTickFormat, mergedSettings.distributionPlotSettings.format || defaultTickFormat,
title: mergedSettings.distributionPlotSettings.title, title: mergedSettings.distributionPlotSettings.title,
color: mergedSettings.distributionPlotSettings.color || defaultColor,
minX: mergedSettings.distributionPlotSettings.minX, minX: mergedSettings.distributionPlotSettings.minX,
maxX: mergedSettings.distributionPlotSettings.maxX, maxX: mergedSettings.distributionPlotSettings.maxX,
distributionChartActions: mergedSettings.distributionPlotSettings.actions, distributionChartActions: mergedSettings.distributionPlotSettings.actions,
@ -66,7 +62,6 @@ const ItemSettingsModal: React.FC<
expY: vars.expY, expY: vars.expY,
format: vars.tickFormat, format: vars.tickFormat,
title: vars.title, title: vars.title,
color: vars.color,
minX: vars.minX, minX: vars.minX,
maxX: vars.maxX, maxX: vars.maxX,
actions: vars.distributionChartActions, actions: vars.distributionChartActions,

View File

@ -5,10 +5,7 @@ import { InputItem } from "./ui/InputItem";
import { Checkbox } from "./ui/Checkbox"; import { Checkbox } from "./ui/Checkbox";
import { HeadedSection } from "./ui/HeadedSection"; import { HeadedSection } from "./ui/HeadedSection";
import { Text } from "./ui/Text"; import { Text } from "./ui/Text";
import { import { defaultTickFormat } from "../lib/distributionSpecBuilder";
defaultColor,
defaultTickFormat,
} from "../lib/distributionSpecBuilder";
export const viewSettingsSchema = yup.object({}).shape({ export const viewSettingsSchema = yup.object({}).shape({
chartHeight: yup.number().required().positive().integer().default(350), chartHeight: yup.number().required().positive().integer().default(350),
@ -18,7 +15,6 @@ export const viewSettingsSchema = yup.object({}).shape({
expY: yup.boolean().required(), expY: yup.boolean().required(),
tickFormat: yup.string().default(defaultTickFormat), tickFormat: yup.string().default(defaultTickFormat),
title: yup.string(), title: yup.string(),
color: yup.string().default(defaultColor).required(),
minX: yup.number(), minX: yup.number(),
maxX: yup.number(), maxX: yup.number(),
distributionChartActions: yup.boolean(), distributionChartActions: yup.boolean(),
@ -114,12 +110,6 @@ export const ViewSettings: React.FC<{
register={register} register={register}
label="Tick Format" label="Tick Format"
/> />
<InputItem
name="color"
type="color"
register={register}
label="Color"
/>
</div> </div>
</HeadedSection> </HeadedSection>
</div> </div>

View File

@ -10,8 +10,6 @@ export type DistributionChartSpecOptions = {
minX?: number; minX?: number;
/** The maximum x coordinate shown on the chart */ /** The maximum x coordinate shown on the chart */
maxX?: number; maxX?: number;
/** The color of the chart */
color?: string;
/** The title of the chart */ /** The title of the chart */
title?: string; title?: string;
/** The formatting of the ticks */ /** The formatting of the ticks */
@ -25,36 +23,14 @@ export let linearXScale: LinearScale = {
range: "width", range: "width",
zero: false, zero: false,
nice: false, nice: false,
domain: { domain: { data: "domain", field: "x" },
fields: [
{
data: "con",
field: "x",
},
{
data: "dis",
field: "x",
},
],
},
}; };
export let linearYScale: LinearScale = { export let linearYScale: LinearScale = {
name: "yscale", name: "yscale",
type: "linear", type: "linear",
range: "height", range: "height",
zero: true, zero: true,
domain: { domain: { data: "domain", field: "y" },
fields: [
{
data: "con",
field: "y",
},
{
data: "dis",
field: "y",
},
],
},
}; };
export let logXScale: LogScale = { export let logXScale: LogScale = {
@ -65,18 +41,7 @@ export let logXScale: LogScale = {
base: 10, base: 10,
nice: false, nice: false,
clamp: true, clamp: true,
domain: { domain: { data: "domain", field: "x" },
fields: [
{
data: "con",
field: "x",
},
{
data: "dis",
field: "x",
},
],
},
}; };
export let expYScale: PowScale = { export let expYScale: PowScale = {
@ -86,29 +51,16 @@ export let expYScale: PowScale = {
range: "height", range: "height",
zero: true, zero: true,
nice: false, nice: false,
domain: { domain: { data: "domain", field: "y" },
fields: [
{
data: "con",
field: "y",
},
{
data: "dis",
field: "y",
},
],
},
}; };
export const defaultTickFormat = ".9~s"; export const defaultTickFormat = ".9~s";
export const defaultColor = "#739ECC";
export function buildVegaSpec( export function buildVegaSpec(
specOptions: DistributionChartSpecOptions specOptions: DistributionChartSpecOptions
): VisualizationSpec { ): VisualizationSpec {
let { const {
format = defaultTickFormat, format = defaultTickFormat,
color = defaultColor,
title, title,
minX, minX,
maxX, maxX,
@ -127,20 +79,32 @@ export function buildVegaSpec(
let spec: VisualizationSpec = { let spec: VisualizationSpec = {
$schema: "https://vega.github.io/schema/vega/v5.json", $schema: "https://vega.github.io/schema/vega/v5.json",
description: "A basic area chart example", description: "Squiggle plot chart",
width: 500, width: 500,
height: 100, height: 100,
padding: 5, padding: 5,
data: [ data: [
{ {
name: "con", name: "data",
}, },
{ {
name: "dis", name: "domain",
}, },
], ],
signals: [], signals: [],
scales: [xScale, expY ? expYScale : linearYScale], scales: [
xScale,
expY ? expYScale : linearYScale,
{
name: "color",
type: "ordinal",
domain: {
data: "data",
field: "name",
},
range: { scheme: "blues" },
},
],
axes: [ axes: [
{ {
orient: "bottom", orient: "bottom",
@ -152,108 +116,178 @@ export function buildVegaSpec(
domainOpacity: 0.0, domainOpacity: 0.0,
format: format, format: format,
tickCount: 10, tickCount: 10,
labelOverlap: "greedy",
}, },
], ],
marks: [ marks: [
{ {
type: "area", name: "all_distributions",
type: "group",
from: { from: {
data: "con", facet: {
}, name: "distribution_facet",
encode: { data: "data",
update: { groupby: ["name"],
interpolate: { value: "linear" },
x: {
scale: "xscale",
field: "x",
},
y: {
scale: "yscale",
field: "y",
},
y2: {
scale: "yscale",
value: 0,
},
fill: {
value: color,
},
fillOpacity: {
value: 1,
},
}, },
}, },
marks: [
{
name: "continuous_distribution",
type: "group",
from: {
facet: {
name: "continuous_facet",
data: "distribution_facet",
field: "continuous",
},
},
encode: {
update: {},
},
marks: [
{
name: "continuous_area",
type: "area",
from: {
data: "continuous_facet",
},
encode: {
update: {
interpolate: { value: "linear" },
x: {
scale: "xscale",
field: "x",
},
y: {
scale: "yscale",
field: "y",
},
fill: {
scale: "color",
field: { parent: "name" },
},
y2: {
scale: "yscale",
value: 0,
},
fillOpacity: {
value: 1,
},
},
},
},
],
},
{
name: "discrete_distribution",
type: "group",
from: {
facet: {
name: "discrete_facet",
data: "distribution_facet",
field: "discrete",
},
},
marks: [
{
type: "rect",
from: {
data: "discrete_facet",
},
encode: {
enter: {
width: {
value: 1,
},
},
update: {
x: {
scale: "xscale",
field: "x",
},
y: {
scale: "yscale",
field: "y",
},
y2: {
scale: "yscale",
value: 0,
},
fill: {
scale: "color",
field: { parent: "name" },
},
},
},
},
{
type: "symbol",
from: {
data: "discrete_facet",
},
encode: {
enter: {
shape: {
value: "circle",
},
size: [{ value: 100 }],
tooltip: {
signal: "{ probability: datum.y, value: datum.x }",
},
},
update: {
x: {
scale: "xscale",
field: "x",
},
y: {
scale: "yscale",
field: "y",
},
fill: {
scale: "color",
field: { parent: "name" },
},
},
},
},
],
},
],
}, },
],
legends: [
{ {
type: "rect", fill: "color",
from: { orient: "top",
data: "dis", labelFontSize: 12,
},
encode: { encode: {
enter: { symbols: {
width: { update: {
value: 1, fill: [
{ test: "length(domain('color')) == 1", value: "transparent" },
{ scale: "color", field: "value" },
],
}, },
}, },
update: { labels: {
x: { interactive: true,
scale: "xscale", update: {
field: "x", fill: [
}, { test: "length(domain('color')) == 1", value: "transparent" },
y: { { value: "black" },
scale: "yscale", ],
field: "y",
},
y2: {
scale: "yscale",
value: 0,
},
fill: {
value: "#2f65a7",
},
},
},
},
{
type: "symbol",
from: {
data: "dis",
},
encode: {
enter: {
shape: {
value: "circle",
},
size: [{ value: 100 }],
tooltip: {
signal: "{ probability: datum.y, value: datum.x }",
},
},
update: {
x: {
scale: "xscale",
field: "x",
},
y: {
scale: "yscale",
field: "y",
},
fill: {
value: "#1e4577",
}, },
}, },
}, },
}, },
], ],
}; ...(title && {
if (title) {
spec = {
...spec,
title: { title: {
text: title, text: title,
}, },
}; }),
} };
return spec; return spec;
} }

View File

@ -0,0 +1,72 @@
import * as yup from "yup";
import { Distribution, result, squiggleExpression } from "@quri/squiggle-lang";
export type LabeledDistribution = {
name: string;
distribution: Distribution;
color?: string;
};
export type Plot = {
distributions: LabeledDistribution[];
};
function error<a, b>(err: b): result<a, b> {
return { tag: "Error", value: err };
}
function ok<a, b>(x: a): result<a, b> {
return { tag: "Ok", value: x };
}
const schema = yup
.object()
.strict()
.noUnknown()
.shape({
distributions: yup.object().shape({
tag: yup.mixed().oneOf(["array"]),
value: yup
.array()
.of(
yup.object().shape({
tag: yup.mixed().oneOf(["record"]),
value: yup.object({
name: yup.object().shape({
tag: yup.mixed().oneOf(["string"]),
value: yup.string().required(),
}),
// color: yup
// .object({
// tag: yup.mixed().oneOf(["string"]),
// value: yup.string().required(),
// })
// .default(undefined),
distribution: yup.object({
tag: yup.mixed().oneOf(["distribution"]),
value: yup.mixed(),
}),
}),
})
)
.required(),
}),
});
export function parsePlot(record: {
[key: string]: squiggleExpression;
}): result<Plot, string> {
try {
const plotRecord = schema.validateSync(record);
return ok({
distributions: plotRecord.distributions.value.map((x) => ({
name: x.value.name.value,
// color: x.value.color?.value, // not supported yet
distribution: x.value.distribution.value,
})),
});
} catch (e) {
const message = e instanceof Error ? e.message : "Unknown error";
return error(message);
}
}

View File

@ -0,0 +1,37 @@
import { result } from "@quri/squiggle-lang";
export function flattenResult<a, b>(x: result<a, b>[]): result<a[], b> {
if (x.length === 0) {
return { tag: "Ok", value: [] };
} else {
if (x[0].tag === "Error") {
return x[0];
} else {
let rest = flattenResult(x.splice(1));
if (rest.tag === "Error") {
return rest;
} else {
return { tag: "Ok", value: [x[0].value].concat(rest.value) };
}
}
}
}
export function resultBind<a, b, c>(
x: result<a, b>,
fn: (y: a) => result<c, b>
): result<c, b> {
if (x.tag === "Ok") {
return fn(x.value);
} else {
return x;
}
}
export function all(arr: boolean[]): boolean {
return arr.reduce((x, y) => x && y, true);
}
export function some(arr: boolean[]): boolean {
return arr.reduce((x, y) => x || y, false);
}

View File

@ -93,6 +93,33 @@ could be continuous, discrete or mixed.
</Story> </Story>
</Canvas> </Canvas>
## Multiple plots
<Canvas>
<Story
name="Multiple plots"
args={{
code: `
{
distributions: [
{
name: "one",
distribution: mx(0.5, normal(0,1))
},
{
name: "two",
distribution: mx(2, normal(5, 2)),
}
]
}
`,
width,
}}
>
{Template.bind({})}
</Story>
</Canvas>
## Constants ## Constants
A constant is a simple number as a result. This has special formatting rules A constant is a simple number as a result. This has special formatting rules