diff --git a/packages/components/package.json b/packages/components/package.json index 3dcccf16ef..2c9558a042 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -42,6 +42,7 @@ "shikiji": "^0.10.2", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", + "yoga-layout": "^3.0.4", "zod": "^3.22.4" }, "devDependencies": { @@ -87,7 +88,8 @@ "storybook": "^8.0.9", "tailwindcss": "^3.4.3", "typescript": "^5.3.3", - "vite": "^5.2.10" + "vite": "^5.2.10", + "vite-plugin-top-level-await": "^1.4.1" }, "peerDependencies": { "react": "^18", diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 3c6d7a8b01..1f41a097c6 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -35,7 +35,7 @@ export { useCanvasCursor, } from "./lib/hooks/index.js"; -export { drawAxes } from "./lib/draw/index.js"; +export { drawAxes } from "./lib/draw/AxesBox.js"; export const d3Extended = { scaleLinear, diff --git a/packages/components/src/lib/d3/patchedScales.ts b/packages/components/src/lib/d3/patchedScales.ts index 385990346d..0011997fe6 100644 --- a/packages/components/src/lib/d3/patchedScales.ts +++ b/packages/components/src/lib/d3/patchedScales.ts @@ -11,7 +11,7 @@ export const defaultTickFormatSpecifier: SquiggleDefaultFormat = function shouldUseSquiggleDefaultFormat( specifier: string | undefined ): specifier is SquiggleDefaultFormat { - return specifier === "squiggle-default" || specifier === undefined; + return specifier === defaultTickFormatSpecifier || specifier === undefined; } function squiggleDefaultFormat() { @@ -63,16 +63,31 @@ type ScaleLogarithmic = d3.ScaleLogarithmic; type ScaleSymLog = d3.ScaleSymLog; type ScalePower = d3.ScalePower; +function patchCopy( + scale: T +): T { + const originalCopy = scale.copy; + scale.copy = (() => { + const copiedScale = originalCopy(); + copiedScale.tickFormat = scale.tickFormat; + copiedScale.ticks = scale.ticks; + return copiedScale; + }) as typeof scale.copy; + + return scale; +} + function patchLinearishTickFormat< T extends ScaleLinear | ScaleSymLog | ScalePower, >(scale: T): T { // copy-pasted from https://github.com/d3/d3-scale/blob/83555bd759c7314420bd4240642beda5e258db9e/src/linear.js#L14 - scale.tickFormat = (count, specifier) => { + const tickFormat: typeof scale.tickFormat = (count, specifier) => { const d = scale.domain(); return tickFormatWithCustom(d[0], d[d.length - 1], count ?? 10, specifier); }; + scale.tickFormat = tickFormat; - return scale; + return patchCopy(scale); } function patchDateTickFormat(scale: T): T { @@ -99,7 +114,8 @@ function patchDateTickFormat(scale: T): T { .ticks(count ?? 10) .map((d) => d.getTime()); }; - return scale; + + return patchCopy(scale); } function patchSymlogTickFormat(scale: ScaleSymLog): ScaleSymLog { @@ -197,7 +213,7 @@ function patchSymlogTickFormat(scale: ScaleSymLog): ScaleSymLog { return ticks; }; - return scale; + return patchCopy(scale); } function patchLogarithmicTickFormat(scale: ScaleLogarithmic): ScaleLogarithmic { @@ -213,7 +229,7 @@ function patchLogarithmicTickFormat(scale: ScaleLogarithmic): ScaleLogarithmic { : specifier ); }; - return scale; + return patchCopy(scale); } // Original d3.scale* should never be used; they won't support our custom tick formats. diff --git a/packages/components/src/lib/draw/AxesBox.ts b/packages/components/src/lib/draw/AxesBox.ts new file mode 100644 index 0000000000..8913d5da04 --- /dev/null +++ b/packages/components/src/lib/draw/AxesBox.ts @@ -0,0 +1,263 @@ +import * as d3 from "d3"; + +import { CanvasElement } from "./CanvasElement.js"; + +export type AnyNumericScale = d3.ScaleContinuousNumeric; + +type Props = { + xScale: AnyNumericScale; + yScale: AnyNumericScale; + showXAxis?: boolean; + showYAxis?: boolean; + showAxisLines?: boolean; + xTickCount?: number; + yTickCount?: number; + xTickFormat?: string; + yTickFormat?: string; + child: CanvasElement; +}; + +const defaultProps: Partial = { + showYAxis: true, + showXAxis: true, +}; + +type DerivedProps = { + xScale: AnyNumericScale; // copy of props.xScale with the appropriate range + yScale: AnyNumericScale; + showAxisLines: boolean; + xTickCount: number; + yTickCount: number; + xTicks: number[]; + yTicks: number[]; + xTickFormat: (x: number) => string; + yTickFormat: (x: number) => string; +}; + +// export class AxesBox extends CanvasElement { +// props: Props; +// derivedProps: DerivedProps | undefined; + +// constructor(props: Props) { +// super(); +// this.props = { +// ...props, +// }; +// for (const [key, value] of Object.entries(defaultProps)) { +// if (this.props[key] === undefined) { +// this.props[key] = value; +// } +// } +// } + +// computeDerivedProps({ width, height }: Dimensions) { +// const showAxisLines = +// this.props.showAxisLines ?? (height > 150 && width > 150); + +// const xTickCount = +// this.props.xTickCount || tickCountInterpolator(width * height); +// const yTickCount = +// this.props.yTickCount || tickCountInterpolator(height * width); + +// const xTicks = this.props.xScale.ticks(xTickCount); +// const xTickFormat = this.props.xScale.tickFormat( +// xTickCount, +// this.props.xTickFormat ?? defaultTickFormatSpecifier +// ); + +// const yTicks = this.props.yScale.ticks(yTickCount); +// const yTickFormat = this.props.yScale.tickFormat( +// yTickCount, +// this.props.yTickFormat ?? defaultTickFormatSpecifier +// ); + +// this.derivedProps = { +// showAxisLines, +// xTickCount, +// yTickCount, +// xTicks, +// yTicks, +// xTickFormat, +// yTickFormat, +// xScale: this.props.xScale.copy(), +// yScale: this.props.yScale.copy(), +// }; +// return this.derivedProps; +// } + +// getDerivedProps() { +// if (!this.derivedProps) { +// throw new Error("layout() hasn't been called yet"); +// } +// return this.derivedProps; +// } + +// layout(context: CanvasRenderingContext2D, recommendedSize: Dimensions) { +// const derivedProps = this.computeDerivedProps(recommendedSize); + +// let leftPadding = 0, +// bottomPadding = 0; + +// // update padding to fit things outside of the main cartesian frame +// // measure x tick sizes for dynamic padding +// if (this.props.showYAxis) { +// derivedProps.yTicks.forEach((d) => { +// const measured = context.measureText(derivedProps.yTickFormat(d)); +// leftPadding = Math.max( +// leftPadding, +// measured.actualBoundingBoxLeft + +// measured.actualBoundingBoxRight + +// yLabelOffset +// ); +// }); +// } + +// if (this.props.showXAxis) { +// bottomPadding = 20; // TODO - measure +// } + +// this.props.child.x = this.x + leftPadding; +// this.props.child.y = this.y; +// this.props.child.layout(context, { +// width: recommendedSize.width - leftPadding, +// height: recommendedSize.height - bottomPadding, +// }); +// this.width = this.props.child.width + leftPadding; +// this.height = this.props.child.height + bottomPadding; +// } + +// draw(context: CanvasRenderingContext2D) { +// this.props.child.draw(context); + +// const tickSize = 2; + +// const { +// xTicks, +// xTickFormat, +// yTicks, +// yTickFormat, +// showAxisLines, +// xScale, +// yScale, +// } = this.getDerivedProps(); + +// // FIXME - mutates props +// xScale.range([this.x, this.x + this.width]); +// yScale.range([this.y + this.height, this.y]); + +// // x axis +// if (this.props.showXAxis) { +// context.save(); + +// if (this.props.showAxisLines) { +// context.beginPath(); +// context.strokeStyle = axisColor; +// context.lineWidth = 1; +// context.moveTo(this.x, this.props.yScale(0)); +// context.lineTo(this.x + this.width, this.props.yScale(0)); +// context.stroke(); +// } + +// context.fillStyle = labelColor; +// context.font = labelFont; + +// let prevBoundary = this.x; +// const y = yScale(0); +// for (let i = 0; i < xTicks.length; i++) { +// const xTick = xTicks[i]; +// const x = xScale(xTick); + +// context.beginPath(); +// context.strokeStyle = labelColor; +// context.lineWidth = 1; +// context.moveTo(x, y); +// context.lineTo(x, y - tickSize); +// context.stroke(); + +// const text = xTickFormat(xTick); +// if (text === "") { +// continue; // we're probably rendering scaleLog, which has empty labels +// } +// const { width: textWidth } = context.measureText(text); +// let startX: number; +// if (i === 0) { +// startX = Math.max(x - textWidth / 2, prevBoundary); +// } else if (i === xTicks.length - 1) { +// startX = Math.min(x - textWidth / 2, this.x + this.width - textWidth); +// } else { +// startX = x - textWidth / 2; +// } +// if (startX < prevBoundary) { +// continue; // doesn't fit, skip +// } + +// context.textAlign = "left"; +// context.textBaseline = "top"; +// context.fillText(text, startX, y + xLabelOffset); + +// prevBoundary = startX + textWidth; +// } + +// context.restore(); +// } + +// // y axis +// if (this.props.showYAxis) { +// context.save(); + +// if (showAxisLines) { +// context.beginPath(); +// context.strokeStyle = axisColor; +// context.lineWidth = 1; +// context.moveTo(this.x, this.y); +// context.lineTo(this.x, this.y + this.height); +// context.stroke(); +// } + +// let prevBoundary = this.y + this.height; +// for (let i = 0; i < yTicks.length; i++) { +// const yTick = yTicks[i]; +// context.beginPath(); +// const y = yScale(yTick); + +// const text = yTickFormat(yTick); +// context.textBaseline = "bottom"; +// const { actualBoundingBoxAscent: textHeight } = +// context.measureText(text); + +// context.beginPath(); +// context.strokeStyle = labelColor; +// context.lineWidth = 1; +// context.moveTo(this.x, y); +// context.lineTo(this.x - tickSize, y); +// context.stroke(); + +// let startY = 0; +// if (i === 0) { +// startY = Math.min(y + textHeight / 2, prevBoundary); +// } else if (i === yTicks.length - 1) { +// startY = Math.max(y + textHeight / 2, this.y + textHeight); +// } else { +// startY = y + textHeight / 2; +// } + +// if (startY < prevBoundary) { +// continue; // doesn't fit, skip +// } + +// context.textAlign = "right"; +// context.textBaseline = "bottom"; +// context.fillStyle = labelColor; +// context.font = labelFont; +// context.fillText(text, this.x - yLabelOffset, startY - 1); +// prevBoundary = startY + textHeight; +// } + +// context.restore(); +// } +// } +// } + +export function drawAxes() { + throw new Error("legacy"); +} diff --git a/packages/components/src/lib/draw/AxesContainer.ts b/packages/components/src/lib/draw/AxesContainer.ts new file mode 100644 index 0000000000..a1519ac36a --- /dev/null +++ b/packages/components/src/lib/draw/AxesContainer.ts @@ -0,0 +1,303 @@ +import * as d3 from "d3"; +import { Align, Edge, FlexDirection, PositionType } from "yoga-layout"; + +import { defaultTickFormatSpecifier } from "../d3/patchedScales.js"; +import { AnyNumericScale } from "./AxesBox.js"; +import { CanvasElement, CC, makeNode } from "./CanvasElement.js"; +import { contextWidthHeight } from "./CanvasFrame.js"; +import { MainChartHandle } from "./MainChart.js"; +import { axisColor, labelColor, labelFont } from "./styles.js"; +import { drawElement, getLocalPoint, measureText } from "./utils.js"; + +const xLabelOffset = 6; +const yLabelOffset = 6; + +const tickSize = 2; + +export const tickCountInterpolator = d3 + .scaleLinear() + .domain([40, 1000]) // The potential width*height of the chart + .range([3, 16]) // The range of circle radiuses + .clamp(true); + +type AxisProps = { + scale: AnyNumericScale; + tickCount: number; + tickFormat: (x: number) => string; + show: boolean; + showLine: boolean; +}; + +const YAxis: CC = ({ + scale, + tickCount, + tickFormat, + show, + showLine, +}) => { + const node = makeNode(); + + const ticks = scale.ticks(tickCount); + + // measure x tick sizes for content-driven size + let width = 0; + ticks.forEach((d) => { + const measured = measureText({ + text: tickFormat(d), + font: labelFont, + }); + width = Math.max( + width, + measured.actualBoundingBoxLeft + + measured.actualBoundingBoxRight + + yLabelOffset + ); + }); + node.setWidth(width); + + return { + node, + draw: (context, layout) => { + if (!show) { + return; + } + + const usedScale = scale.copy().range([layout.height, 0]); + + if (showLine) { + context.beginPath(); + context.strokeStyle = axisColor; + context.lineWidth = 1; + context.moveTo(layout.width, 0); + context.lineTo(layout.width, layout.height); + context.stroke(); + } + + // Bottom boundary for text label that shouldn't be exceeded, to avoid overlapping text issues. + // TODO - allow more (overflow outside of node boundaries) if the canvas is big enough. + let prevBoundary = layout.height; + + for (let i = 0; i < ticks.length; i++) { + const yTick = ticks[i]; + context.beginPath(); + const y = usedScale(yTick); + + const text = tickFormat(yTick); + context.textBaseline = "bottom"; + const { actualBoundingBoxAscent } = context.measureText(text); + + const textHeight = actualBoundingBoxAscent; + + context.beginPath(); + context.strokeStyle = labelColor; + context.lineWidth = 1; + context.moveTo(layout.width, y); + context.lineTo(layout.width - tickSize, y); + context.stroke(); + + let startY = layout.height; + if (i === ticks.length - 1) { + startY = Math.max(y + textHeight / 2, textHeight); + } else { + startY = y + textHeight / 2; + } + + if (startY > prevBoundary) { + continue; // doesn't fit, skip + } + + context.textAlign = "right"; + context.textBaseline = "bottom"; + context.fillStyle = labelColor; + context.font = labelFont; + context.fillText(text, layout.width - yLabelOffset, startY + 1); // with +1, text looks more centered + prevBoundary = startY - textHeight; + } + }, + }; +}; + +const XAxis: CC = ({ + scale, + tickCount, + tickFormat, + show, + showLine, +}) => { + const node = makeNode(); + if (show) { + // TODO - measure + node.setHeight(20); + } + + return { + node, + draw(context, layout) { + // x axis + if (!show) { + return; + } + + const usedScale = scale.copy().range([0, layout.width]); + const ticks = usedScale.ticks(tickCount); + + if (showLine) { + context.beginPath(); + context.strokeStyle = axisColor; + context.lineWidth = 1; + context.moveTo(0, 0); + context.lineTo(layout.width, 0); + context.stroke(); + } + context.fillStyle = labelColor; + context.font = labelFont; + + // Allow overflow outside of node boundaries if there's space on canvas + let prevBoundary = Math.max( + -node.getComputedLeft(), + getLocalPoint(context, { x: 0, y: 0 }).x + ); + + for (let i = 0; i < ticks.length; i++) { + const xTick = ticks[i]; + const x = usedScale(xTick); + context.beginPath(); + context.strokeStyle = labelColor; + context.lineWidth = 1; + context.moveTo(x, 0); + context.lineTo(x, tickSize); + context.stroke(); + const text = tickFormat(xTick); + if (text === "") { + continue; // we're probably rendering scaleLog, which has empty labels + } + const { width: textWidth } = context.measureText(text); + let startX: number; + if (i === 0) { + startX = Math.max(x - textWidth / 2, prevBoundary); + } else if (i === ticks.length - 1) { + startX = Math.min( + x - textWidth / 2, + getLocalPoint(context, { + x: contextWidthHeight(context).width, + y: 0, + }).x - textWidth + ); + } else { + startX = x - textWidth / 2; + } + if (startX < prevBoundary) { + continue; // doesn't fit, skip + } + context.textAlign = "left"; + context.textBaseline = "top"; + context.fillText(text, startX, xLabelOffset); + prevBoundary = startX + textWidth; + } + context.restore(); + }, + }; +}; + +export const AxesContainer: CC<{ + xScale: AnyNumericScale; + yScale: AnyNumericScale; + showXAxis: boolean; + showYAxis: boolean; + showAxisLines: boolean; + xTickCount?: number; + yTickCount?: number; + xTickFormat?: string; + yTickFormat?: string; + child: CanvasElement; +}> = (props) => { + /* + * Axes container is a grid, and yoga doesn't support grids. + * + * It'd be quite difficult to layout axes container if the chart height is + * not known, because x axis width must match the chart width, and we should + * set it correctly before `getComputedLayout` is called. + * + * If we ever need it: maybe incremental layouts could help, + * https://www.yogalayout.dev/docs/advanced/incremental-layout + */ + const height = props.child.handle.getHeight(); + + const node = makeNode(); + + // two columns: first, y axis; then the main chart and x axis positioned on top of it + node.setFlexDirection(FlexDirection.Row); + + const showAxisLines = props.showAxisLines ?? height > 150; + + const xTickCount = props.xTickCount || tickCountInterpolator(height); // width * height); + const yTickCount = props.yTickCount || tickCountInterpolator(height); // width * height); + + // y axis column + let yAxis: CanvasElement | undefined; + if (props.showYAxis) { + const yAxis = YAxis({ + scale: props.yScale, + tickCount: yTickCount, + tickFormat: props.yScale.tickFormat( + yTickCount, + props.yTickFormat ?? defaultTickFormatSpecifier + ), + show: props.showYAxis, + showLine: showAxisLines, + }); + + yAxis.node.setAlignSelf(Align.FlexStart); + yAxis.node.setHeight(height); + node.insertChild(yAxis.node, node.getChildCount()); + } + + // main chart column + const chartAndXAxis = makeNode(); + chartAndXAxis.insertChild(props.child.node, 0); + + const xAxis = XAxis({ + scale: props.xScale, + tickCount: xTickCount, + tickFormat: props.xScale.tickFormat( + xTickCount, + props.xTickFormat ?? defaultTickFormatSpecifier + ), + show: props.showXAxis, + showLine: showAxisLines, + }); + chartAndXAxis.insertChild(xAxis.node, 1); + + // X axis can be positioned in the middle of the chart, in case the chart contains negative y values. + // So we use absolute positioning. + xAxis.node.setPositionType(PositionType.Absolute); + const xAxisTop = props.yScale.copy().range([height, 0])(0); + xAxis.node.setMargin(Edge.All, props.child.handle.getMargin()); + xAxis.node.setPosition(Edge.Left, 0); + xAxis.node.setPosition(Edge.Right, 0); + xAxis.node.setPosition(Edge.Top, xAxisTop); + chartAndXAxis.setMinHeight(xAxisTop + xAxis.node.getHeight().value); + + chartAndXAxis.setFlexGrow(1); + node.insertChild(chartAndXAxis, node.getChildCount()); + + return { + node, + draw: (context) => { + if (yAxis) drawElement(yAxis, context); + + { + // TODO - extract chartAndXAxis to a reusable CC + context.save(); + context.translate( + chartAndXAxis.getComputedLeft(), + chartAndXAxis.getComputedTop() + ); + drawElement(props.child, context); + drawElement(xAxis, context); + + context.restore(); + } + }, + }; +}; diff --git a/packages/components/src/lib/draw/AxesTitlesContainer.ts b/packages/components/src/lib/draw/AxesTitlesContainer.ts new file mode 100644 index 0000000000..5d476fe77d --- /dev/null +++ b/packages/components/src/lib/draw/AxesTitlesContainer.ts @@ -0,0 +1,109 @@ +import { FlexDirection } from "yoga-layout"; + +import { CanvasElement, CC, makeNode } from "./CanvasElement.js"; +import { axisTitleColor, axisTitleFont } from "./styles.js"; +import { drawElement, measureText } from "./utils.js"; + +type Props = { + xAxisTitle?: string; + yAxisTitle?: string; + child: CanvasElement; +}; + +const textPadding = 3; + +const setTextStyles = (context: CanvasRenderingContext2D) => { + context.textAlign = "center"; + context.textBaseline = "top"; + context.font = axisTitleFont; + context.fillStyle = axisTitleColor; +}; + +export const makeNull: CC = () => { + const node = makeNode(); + + return { + node, + draw: () => {}, + }; +}; + +const VerticalText: CC<{ text: string }> = ({ text }) => { + const node = makeNode(); + + const measured = measureText({ + text, + font: axisTitleFont, + }); + node.setMinWidth(measured.actualBoundingBoxAscent + 2 * textPadding); + + return { + node, + draw: (context) => { + // TODO: center the title vertically within the charting area + context.rotate(-Math.PI / 2); // rotate 90 degrees counter-clockwise + setTextStyles(context); + context.fillText(text, -node.getComputedHeight() / 2, textPadding); + }, + }; +}; + +const makeText: CC<{ text: string }> = ({ text }) => { + const node = makeNode(); + + const measured = measureText({ + text, + font: axisTitleFont, + }); + node.setMinHeight(measured.actualBoundingBoxAscent + 2 * textPadding); + + return { + node, + draw: (context) => { + setTextStyles(context); + context.fillText(text, node.getComputedWidth() / 2, textPadding); + }, + }; +}; + +export const AxesTitlesContainer: CC = ({ + xAxisTitle, + yAxisTitle, + child, +}) => { + // Column layout; y title, then chart + x title. + // See also: `makeAxesContainer`, which uses a similar layout. + const node = makeNode(); + node.setFlexDirection(FlexDirection.Row); + + const yTitle = yAxisTitle ? VerticalText({ text: yAxisTitle }) : makeNull({}); + + node.insertChild(yTitle.node, 0); + + const childAndXTitle = makeNode(); + const xTitle = xAxisTitle ? makeText({ text: xAxisTitle }) : makeNull({}); + childAndXTitle.insertChild(child.node, 0); + childAndXTitle.insertChild(xTitle.node, 1); + + childAndXTitle.setFlexGrow(1); + node.insertChild(childAndXTitle, 1); + + return { + node, + draw: (context) => { + drawElement(yTitle, context); + + { + context.save(); + context.translate( + childAndXTitle.getComputedLeft(), + childAndXTitle.getComputedTop() + ); + drawElement(child, context); + drawElement(xTitle, context); + + context.restore(); + } + }, + }; +}; diff --git a/packages/components/src/lib/draw/BarSamples.ts b/packages/components/src/lib/draw/BarSamples.ts new file mode 100644 index 0000000000..0826fec682 --- /dev/null +++ b/packages/components/src/lib/draw/BarSamples.ts @@ -0,0 +1,33 @@ +import { AnyNumericScale } from "./AxesBox.js"; +import { CC, makeNode } from "./CanvasElement.js"; +import { getColor } from "./MainChart.js"; + +export const BarSamples: CC<{ + behindShapes: boolean; // affects the color + samples: number[]; + scale: AnyNumericScale; +}> = ({ behindShapes, samples, scale: _scale }) => { + const node = makeNode(); + + return { + node, + draw: (context, layout) => { + const scale = _scale.copy().range([0, layout.width]); + + const color = behindShapes ? getColor(0, false, 0.4) : getColor(0, false); + + context.save(); + context.lineWidth = 0.5; + context.strokeStyle = color; + samples.forEach((sample) => { + context.beginPath(); + const x = scale(sample); + context.beginPath(); + context.moveTo(x, 0); + context.lineTo(x, layout.height); + context.stroke(); + }); + context.restore(); + }, + }; +}; diff --git a/packages/components/src/lib/draw/CanvasElement.ts b/packages/components/src/lib/draw/CanvasElement.ts new file mode 100644 index 0000000000..57954e1e39 --- /dev/null +++ b/packages/components/src/lib/draw/CanvasElement.ts @@ -0,0 +1,40 @@ +import Yoga, { Edge, type Node } from "yoga-layout"; + +export type CanvasLayout = { + width: number; + height: number; + left: number; + top: number; +}; +/* + * "CanvasComponent". Mirrors `FC` (`FunctionalComponent`) from React. + */ +export type CC< + Props extends object = Record, + Handle extends object | null = null, +> = (props: Props) => CanvasElement; + +/* + * This is the rendered component type; it mirrors `ReactElement` type. + * + * `Handle` type parameter allows parent elements to peek into some properties + * of child elements. It's similar to `useImperativeHandle` ref from React. + * + * For example, when "axes container" component wraps the main "chart" + * component, it needs to know the exact range that was used for drawing the + * chart. + */ +export type CanvasElement = { + node: Node; + draw(context: CanvasRenderingContext2D, layout: CanvasLayout): void; +} & (Handle extends null + ? { handle?: undefined } + : { + handle: Handle; + }); + +export function makeNode() { + const node = Yoga.Node.create(); + node.setMargin(Edge.All, 0); + return node; +} diff --git a/packages/components/src/lib/draw/CanvasFrame.ts b/packages/components/src/lib/draw/CanvasFrame.ts new file mode 100644 index 0000000000..a42860f4d9 --- /dev/null +++ b/packages/components/src/lib/draw/CanvasFrame.ts @@ -0,0 +1,173 @@ +import { Padding, Point } from "./types.js"; + +type TextOptions = { + textAlign?: CanvasTextAlign; + textBaseline?: CanvasTextBaseline; + font?: string; + fillStyle?: string; +}; + +/* + * Usage example: + * + * frame.enter(); // Entering the frame + * + * // Draw your 2D charts here + * + * frame.drawText('Label', 100, 50, { textAlign: 'center', textBaseline: 'middle', font: '14px Arial', fillStyle: 'blue' }); + * + * frame.exit(); // Exiting the frame + */ + +/* + * `down` is the default for HTML canvas - (0,0) is at top-left corner. + * + * For drawing charts, though, it's useful to flip the direction to `up`, with + * (0,0) in bottom-left corner. + * + * Note that this only affects the frame's internal coordinates that you get by + * calling `frame.enter()`. `frame.y0` always points to the frame's top compared + * to the canvas, to simplify the math. + */ + +type YDirection = "up" | "down"; + +export function contextWidthHeight(context: CanvasRenderingContext2D) { + const devicePixelRatio = + typeof window === "undefined" ? 1 : window.devicePixelRatio; + + return { + width: context.canvas.width / devicePixelRatio, + height: context.canvas.height / devicePixelRatio, + }; +} + +export class CanvasFrame { + public context: CanvasRenderingContext2D; + public x0: number; + public y0: number; + public width: number; + public height: number; + public yDirection: YDirection = "up"; + + constructor(props: { + context: CanvasRenderingContext2D; + x0: number; + y0: number; + width: number; + height: number; + yDirection?: YDirection; + }) { + this.context = props.context; + this.x0 = props.x0; + this.y0 = props.y0; + this.width = props.width; + this.height = props.height; + this.yDirection = props.yDirection ?? "up"; + } + + padding(): Padding { + const contextSizes = contextWidthHeight(this.context); + return { + left: this.x0, + right: contextSizes.width - this.x0 - this.width, + top: this.y0, + bottom: contextSizes.height - this.y0 - this.height, + }; + } + + static fullFrame(context: CanvasRenderingContext2D) { + const { width, height } = contextWidthHeight(context); + return new CanvasFrame({ + context, + x0: 0, + y0: 0, + width, + height, + yDirection: "down", + }); + } + + flip(): CanvasFrame { + return new CanvasFrame({ + context: this.context, + x0: this.x0, + y0: this.y0, + width: this.width, + height: this.height, + yDirection: this.yDirection === "up" ? "down" : "up", + }); + } + + subframeWithPadding(padding: Padding): CanvasFrame { + return new CanvasFrame({ + context: this.context, + x0: this.x0 + padding.left, + y0: this.y0 + padding.top, + width: this.width - padding.left - padding.right, + height: this.height - padding.top - padding.bottom, + yDirection: this.yDirection, + }); + } + + // Useful for converting cursor coordinates to frame. + translatedPoint(point: Point): Point { + return { + x: point.x - this.x0, + y: + this.yDirection === "down" + ? point.y - this.y0 + : this.y0 + this.height - point.y, + }; + } + + // `point` is in global canvas coordinates, e.g. `cursor` from `useCanvasCursor()` + containsPoint(point: Point): boolean { + const translated = this.translatedPoint(point); + return ( + translated.x >= 0 && + translated.x <= this.width && + translated.y >= 0 && + translated.y <= this.height + ); + } + + // Note: `using framed = frame.enter()` would be good after + // https://github.com/tc39/proposal-explicit-resource-management is widely + // supported. + enter() { + this.context.save(); + this.context.translate( + this.x0, + this.yDirection === "up" ? this.y0 + this.height : this.y0 + ); + if (this.yDirection === "up") { + this.context.scale(1, -1); + } + } + + exit() { + this.context.restore(); + } + + fillText(text: string, x: number, y: number, options: TextOptions = {}) { + this.context.save(); + if (this.yDirection === "up") { + this.context.scale(1, -1); // Invert the scale for the text rendering + } + if (options.textAlign !== undefined) { + this.context.textAlign = options.textAlign; + } + if (options.font !== undefined) { + this.context.font = options.font; + } + if (options.fillStyle !== undefined) { + this.context.fillStyle = options.fillStyle; + } + if (options.textBaseline !== undefined) { + this.context.textBaseline = options.textBaseline; // TODO - does this make sense in both yDirection modes? + } + this.context.fillText(text, x, this.yDirection === "up" ? -y : y); + this.context.restore(); + } +} diff --git a/packages/components/src/lib/draw/CartesianFrame.ts b/packages/components/src/lib/draw/CartesianFrame.ts deleted file mode 100644 index daa4afba8b..0000000000 --- a/packages/components/src/lib/draw/CartesianFrame.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Point } from "./types.js"; - -type TextOptions = { - textAlign?: CanvasTextAlign; - textBaseline?: CanvasTextBaseline; - font?: string; - fillStyle?: string; -}; - -/* -Implemented by GPT-4. - -Usage example: - -frame.enter(); // Entering the Cartesian frame - -// Draw your 2D charts here - -frame.drawText('Label', 100, 50, { textAlign: 'center', textBaseline: 'middle', font: '14px Arial', fillStyle: 'blue' }); - -frame.exit(); // Exiting the Cartesian frame -*/ - -export class CartesianFrame { - public context: CanvasRenderingContext2D; - public x0: number; - public y0: number; - public width: number; - public height: number; - - constructor(props: { - context: CanvasRenderingContext2D; - x0: number; - y0: number; - width: number; - height: number; - }) { - this.context = props.context; - this.x0 = props.x0; - this.y0 = props.y0; - this.width = props.width; - this.height = props.height; - } - - // useful for converting cursor coordinates to frame - translatedPoint(point: Point): Point { - return { - x: point.x - this.x0, - y: this.y0 - point.y, - }; - } - - // `point` is in global canvas coordinates, e.g. `cursor` from `useCanvasCursor()` - containsPoint(point: Point): boolean { - return ( - point.x >= this.x0 && - point.x - this.x0 <= this.width && - point.y <= this.y0 && - this.y0 - point.y <= this.height - ); - } - - // TODO: `using framed = frame.enter()` would be good after Typescript 5.2 is released. - // See also: https://devblogs.microsoft.com/typescript/announcing-typescript-5-2-beta/#using-declarations-and-explicit-resource-management - enter(): void { - this.context.save(); - this.context.translate(this.x0, this.y0); - this.context.scale(1, -1); - } - - exit(): void { - this.context.restore(); - } - - fillText( - text: string, - x: number, - y: number, - options: TextOptions = {} - ): void { - this.context.save(); - this.context.scale(1, -1); // Invert the scale for the text rendering - if (options.textAlign !== undefined) { - this.context.textAlign = options.textAlign; - } - if (options.font !== undefined) { - this.context.font = options.font; - } - if (options.fillStyle !== undefined) { - this.context.fillStyle = options.fillStyle; - } - if (options.textBaseline !== undefined) { - this.context.textBaseline = options.textBaseline; - } - this.context.fillText(text, x, -y); // Use negative y value to adjust the position - this.context.restore(); - } -} diff --git a/packages/components/src/lib/draw/GuideLines.ts b/packages/components/src/lib/draw/GuideLines.ts new file mode 100644 index 0000000000..db05701154 --- /dev/null +++ b/packages/components/src/lib/draw/GuideLines.ts @@ -0,0 +1,245 @@ +import * as d3 from "d3"; +import { Edge, PositionType } from "yoga-layout"; + +import { defaultTickFormatSpecifier } from "../d3/patchedScales.js"; +import { AnyNumericScale } from "./AxesBox.js"; +import { CanvasElement, CC, makeNode } from "./CanvasElement.js"; +import { guideLineColor, labelColor, labelFont } from "./styles.js"; +import { Point } from "./types.js"; +import { drawElement, getLocalPoint } from "./utils.js"; + +const TOOLTIP_OFFSETS = { + px: 4, + py: 2, + mx: 4, + my: 4, +}; + +type ValueMode = "absolute-x" | "absolute-y" | "relative" | "domain"; + +function valueToRelative({ + value, + mode, + context, + scale, +}: { + value: number; + mode: ValueMode; + context: CanvasRenderingContext2D; + scale: AnyNumericScale; +}): number { + switch (mode) { + case "relative": + return value; + case "absolute-x": + return getLocalPoint(context, { x: value, y: 0 }).x; + case "absolute-y": + return getLocalPoint(context, { x: 0, y: value }).y; + case "domain": + return scale(value); + default: + throw mode satisfies never; + } +} + +export const VerticalGuideLine: CC<{ + scale: d3.ScaleContinuousNumeric; + format?: string | undefined; + value: number; + mode: ValueMode; +}> = ({ scale: _scale, format, value, mode }) => { + const node = makeNode(); + + return { + node, + draw: (context, layout) => { + const scale = _scale.copy().range([0, layout.width]); + + const x = valueToRelative({ + value, + mode, + context, + scale, + }); + + if (x < 0 || x > layout.width) { + return; + } + + // 1. line + context.save(); + context.beginPath(); + context.strokeStyle = guideLineColor; + context.lineWidth = 1; + context.setLineDash([5, 5]); // setting the dashed line pattern + context.moveTo(x, layout.height); + context.lineTo(x, 0); + context.stroke(); + context.restore(); + + // 2. measure label + context.textAlign = "left"; + context.textBaseline = "bottom"; + context.fillStyle = labelColor; + context.font = labelFont; + const text = scale.tickFormat( + Infinity, // important for scaleLog; https://github.com/d3/d3-scale/tree/main#log_tickFormat + format + )(scale.invert(x)); + const measured = context.measureText(text); + const textHeight = measured.actualBoundingBoxAscent; + const textWidth = measured.width; + + // 3. semi-transparent box + let boxWidth = textWidth + TOOLTIP_OFFSETS.px * 2; + const boxHeight = textHeight + TOOLTIP_OFFSETS.py * 2; + const boxOrigin: Point = { + x: x + TOOLTIP_OFFSETS.mx, + y: layout.height - TOOLTIP_OFFSETS.my - boxHeight, + }; + const flip = + boxOrigin.x + boxWidth > layout.width && + // In pathological cases, we can't fit the box on either side because the text is too long. + // In this case, we don't flip because first digits are more significant. + boxWidth <= x; + + if (flip) { + boxOrigin.x = x - TOOLTIP_OFFSETS.mx; + boxWidth = -boxWidth; + context.textAlign = "right"; + } + + context.globalAlpha = 0.7; + context.fillStyle = "white"; + context.fillRect(boxOrigin.x, boxOrigin.y, boxWidth, boxHeight); + context.globalAlpha = 1; + + // 4. render label + context.fillStyle = labelColor; + context.fillText( + text, + boxOrigin.x + TOOLTIP_OFFSETS.px * (flip ? -1 : 1), + // unsure why "+1" is needed, probably related to measureText result and could be fixed + boxOrigin.y + boxHeight - TOOLTIP_OFFSETS.py + 1 + ); + }, + }; +}; + +export const HorizontalGuideLine: CC<{ + scale: d3.ScaleContinuousNumeric; + format?: string | undefined; + value: number; + mode: ValueMode; +}> = ({ scale, format, value, mode }) => { + const node = makeNode(); + return { + node, + draw: (context, layout) => { + const y = valueToRelative({ + value, + mode, + context, + scale, + }); + + if (y < 0 || y > layout.height) { + return; + } + + context.beginPath(); + context.strokeStyle = guideLineColor; + context.lineWidth = 1; + context.setLineDash([5, 5]); // setting the dashed line pattern + context.moveTo(0, y); + context.lineTo(layout.width, y); + context.stroke(); + context.setLineDash([]); // resetting the dashed line pattern so it doesn't affect other lines + + context.textAlign = "left"; + context.textBaseline = "bottom"; + context.fillStyle = labelColor; + context.font = labelFont; + const text = scale.tickFormat( + Infinity, // important for scaleLog; https://github.com/d3/d3-scale/tree/main#log_tickFormat + format + )(scale.invert(y)); + const measured = context.measureText(text); + + const boxWidth = measured.width + TOOLTIP_OFFSETS.px * 2; + let boxHeight = measured.actualBoundingBoxAscent + TOOLTIP_OFFSETS.py * 2; + const boxOrigin: Point = { + x: TOOLTIP_OFFSETS.mx, + y: TOOLTIP_OFFSETS.my + y, + }; + const flip = boxOrigin.y + boxHeight > layout.height; + + if (flip) { + boxOrigin.y = y - TOOLTIP_OFFSETS.mx; + boxHeight = -boxHeight; + context.textBaseline = "top"; + } + + context.globalAlpha = 0.7; + context.fillStyle = "white"; + context.fillRect(boxOrigin.x, boxOrigin.y, boxWidth, boxHeight); + context.globalAlpha = 1; + context.fillStyle = labelColor; + context.fillText( + text, + TOOLTIP_OFFSETS.mx + TOOLTIP_OFFSETS.px, + boxOrigin.y + TOOLTIP_OFFSETS.py * (flip ? -1 : 1) - 1 + ); + }, + }; +}; + +export const CursorGuideLines: CC<{ + // original canvas coordinates; + // can be undefined for convenience (this function will check if cursor lines are necessary) + cursor?: Point; + x?: { + scale: d3.ScaleContinuousNumeric; + format?: string | undefined; + }; + y?: { + scale: d3.ScaleContinuousNumeric; + format?: string | undefined; + }; +}> = ({ cursor, x: xLine, y: yLine }) => { + const node = makeNode(); + + let vertical: CanvasElement; + if (cursor && xLine) { + vertical = VerticalGuideLine({ + scale: xLine.scale, + format: xLine.format ?? defaultTickFormatSpecifier, + value: cursor.x, + mode: "absolute-x", + }); + vertical.node.setPositionType(PositionType.Absolute); + vertical.node.setPosition(Edge.All, 0); + node.insertChild(vertical.node, node.getChildCount()); + } + + let horizontal: CanvasElement; + if (cursor && yLine) { + horizontal = HorizontalGuideLine({ + scale: yLine.scale, + format: yLine.format ?? defaultTickFormatSpecifier, + value: cursor.y, + mode: "absolute-y", + }); + horizontal.node.setPositionType(PositionType.Absolute); + horizontal.node.setPosition(Edge.All, 0); + node.insertChild(horizontal.node, node.getChildCount()); + } + + return { + node, + draw: (context) => { + if (vertical) drawElement(vertical, context); + if (horizontal) drawElement(horizontal, context); + }, + }; +}; diff --git a/packages/components/src/lib/draw/MainChart.ts b/packages/components/src/lib/draw/MainChart.ts new file mode 100644 index 0000000000..ee0bc612a6 --- /dev/null +++ b/packages/components/src/lib/draw/MainChart.ts @@ -0,0 +1,339 @@ +import * as d3 from "d3"; +import isEqual from "lodash/isEqual.js"; +import Yoga, { Edge, PositionType } from "yoga-layout"; + +import { result, SqDistributionError, SqShape } from "@quri/squiggle-lang"; + +import { AnyNumericScale } from "./AxesBox.js"; +import { makeNull } from "./AxesTitlesContainer.js"; +import { CanvasElement, CC, makeNode } from "./CanvasElement.js"; +import { drawCircle } from "./drawCircle.js"; +import { CursorGuideLines, VerticalGuideLine } from "./GuideLines.js"; +import { distributionColor } from "./styles.js"; +import { Point } from "./types.js"; +import { distance, drawElement, getLocalPoint } from "./utils.js"; + +// We have a similar function in squiggle-lang, but it's not exported, and this function is simple enough. +type DataPoint = { + x: number; + y: number; +}; +function interpolateYAtX( + xValue: number, + continuousData: { x: number; y: number }[], + yScale: d3.ScaleContinuousNumeric +): number | null { + let pointBefore: DataPoint | null = null, + pointAfter: DataPoint | null = null; + for (const point of continuousData) { + if (point.x <= xValue) { + pointBefore = point; + } else { + pointAfter = point; + break; + } + } + + if (pointBefore && pointAfter) { + const xInterpolate = d3 + .scaleLinear() + .domain([pointBefore.x, pointAfter.x]) + .range([yScale(pointBefore.y), yScale(pointAfter.y)]); + return xInterpolate(xValue); + } else { + return null; + } +} + +export function getColor(i: number, isMulti: boolean, lightening?: number) { + const color = isMulti ? d3.schemeCategory10[i] : distributionColor; + if (lightening) { + return d3.interpolateLab(color, "#fff")(lightening); + } else { + return color; + } +} + +const distRadiusScalingFromHeight = d3 + .scaleLinear() + .domain([10, 300]) // The potential height of the chart + .range([2, 5]) // The range of circle radiuses + .clamp(true); + +type Shapes = (SqShape & { + name: string; + p5: result; + p50: result; + p95: result; +})[]; + +const LegendItem: CC<{ shape: Shapes[number]; i: number }> = ({ shape, i }) => { + const legendItemHeight = 16; + const legendCircleRadius = 5; + + const node = makeNode(); + node.setHeight(legendItemHeight); + + return { + node, + draw: (context) => { + context.fillStyle = getColor(i, true); + drawCircle({ + context, + x: legendCircleRadius, + y: legendCircleRadius, + r: legendCircleRadius, + }); + + context.textAlign = "left"; + context.textBaseline = "middle"; + context.fillStyle = "black"; + context.font = "12px sans-serif"; + context.fillText(shape.name, 16, legendCircleRadius); + }, + }; +}; + +const Legend: CC<{ shapes: Shapes }> = ({ shapes }) => { + const node = makeNode(); + + const children = shapes.map((shape, i) => { + const child = LegendItem({ shape, i }); + node.insertChild(child.node, node.getChildCount()); + child.node.setPadding(Edge.Bottom, 2); + return child; + }); + + return { + node, + draw: (context) => { + for (const child of children) { + drawElement(child, context); + } + }, + }; +}; + +export type MainChartHandle = { + getMargin(): number; + getHeight(): number; +}; + +export const MainChart: CC< + { + shapes: Shapes; + isMulti: boolean; + showPercentileLines: boolean; + discreteTooltip: { value: number; probability: number } | undefined; + setDiscreteTooltip: ( + tooltip: { value: number; probability: number } | undefined + ) => void; + height: number; + cursor: Point | undefined; + xScale: AnyNumericScale; + yScale: AnyNumericScale; + xTickFormat?: string; // useful for guidelines + verticalLine?: number; + }, + MainChartHandle +> = ({ + shapes, + isMulti, + showPercentileLines, + discreteTooltip, + setDiscreteTooltip, + height, + cursor, + xScale: _xScale, + yScale: _yScale, + xTickFormat, + verticalLine, +}) => { + const discreteRadius = distRadiusScalingFromHeight(height); + + const node = makeNode(); + node.setHeight(height); + node.setMargin(Yoga.EDGE_ALL, discreteRadius); + + const cursorGuideLines = cursor + ? CursorGuideLines({ + cursor, + x: { + scale: _xScale, + format: xTickFormat, + }, + }) + : makeNull({}); + node.insertChild(cursorGuideLines.node, node.getChildCount()); + cursorGuideLines.node.setPositionType(PositionType.Absolute); + cursorGuideLines.node.setPosition(Edge.All, 0); + + const verticalGuideLine = + verticalLine === undefined + ? makeNull({}) + : VerticalGuideLine({ + scale: _xScale, + value: verticalLine, + mode: "domain", + format: xTickFormat, + }); + node.insertChild(verticalGuideLine.node, node.getChildCount()); + verticalGuideLine.node.setPositionType(PositionType.Absolute); + verticalGuideLine.node.setPosition(Edge.All, 0); + + let legend: CanvasElement | undefined; + if (isMulti) { + legend = Legend({ shapes }); + node.insertChild(legend.node, node.getChildCount()); + } + + return { + node, + draw: (context, layout) => { + const xScale = _xScale.copy(); + xScale.range([0, layout.width]); + const yScale = _yScale.copy(); + yScale.range([layout.height, 0]); + + const translatedCursor: Point | undefined = cursor + ? getLocalPoint(context, cursor) + : undefined; + + // shapes + { + // there can be only one + let newDiscreteTooltip: typeof discreteTooltip = undefined; + + for (let i = 0; i < shapes.length; i++) { + const shape = shapes[i]; + + // Continuous fill. + // In the case of one distribution, we don't want it to be + // transparent, so that we can show the samples lines. In the case of + // multiple distributions, we want them to be transparent so that we + // can see the other distributions. + context.fillStyle = isMulti + ? getColor(i, isMulti, 0) + : getColor(i, isMulti, 0.7); + context.globalAlpha = isMulti ? 0.4 : 1; + context.beginPath(); + d3 + .area() + .x((d) => xScale(d.x)) + .y0((d) => yScale(d.y)) + .y1(yScale(0)) + .context(context)(shape.continuous); + context.fill(); + context.globalAlpha = 1; + + // Percentile lines + if (showPercentileLines) { + const percentiles = [ + [shape.p5, "p5"], + [shape.p50, "p50"], + [shape.p95, "p95"], + ] as const; + percentiles.forEach(([percentile, name]) => { + if (!percentile.ok) { + return; + } + const xPoint = percentile.value; + + // We need to find the y value of the percentile in question, to + // draw the line only up to the top of the distribution. We have + // to do this with interpolation, which is not provided + // straightforwardly by d3. + const interpolateY = interpolateYAtX( + xPoint, + shape.continuous, + yScale + ); + if (!interpolateY) { + return; + } + + context.save(); + context.beginPath(); + context.strokeStyle = getColor( + i, + isMulti, + name === "p50" ? 0.4 : 0.3 + ); + if (name === "p50") { + context.setLineDash([6, 4]); + } else { + context.setLineDash([2, 2]); + } + context.lineWidth = 1; + context.moveTo(xScale(xPoint), yScale(0)); + context.lineTo(xScale(xPoint), interpolateY); + context.stroke(); + context.restore(); + }); + } + + // The top line + context.strokeStyle = getColor(i, isMulti); + context.beginPath(); + d3 + .line() + .x((d) => xScale(d.x)) + .y((d) => yScale(d.y)) + .context(context)(shape.continuous); + context.stroke(); + + const darkenAmountCircle = isMulti ? 0.05 : 0.1; + + const discreteLineColor = getColor(i, isMulti, -darkenAmountCircle); + const discreteCircleColor = getColor(i, isMulti, -darkenAmountCircle); + + context.fillStyle = discreteCircleColor; + context.strokeStyle = discreteLineColor; + for (const point of shape.discrete) { + context.beginPath(); + context.lineWidth = 1; + const x = xScale(point.x); + const y = yScale(point.y); + if ( + translatedCursor && + distance({ x, y }, translatedCursor) <= discreteRadius + 2 + ) { + // the last discrete point always wins over overlapping previous points + // this makes sense because it's drawn last + newDiscreteTooltip = { value: point.x, probability: point.y }; + //darken the point if it's hovered + context.fillStyle = getColor(i, isMulti, -1); + context.strokeStyle = getColor(i, isMulti, -1); + } + context.moveTo(x, yScale(0)); + context.lineTo(x, y); + context.globalAlpha = 0.5; // We want the lines to be transparent - the circles are the main focus + context.stroke(); + context.globalAlpha = 1; + drawCircle({ + context, + x, + y, + r: discreteRadius, + }); + } + } + + if (!isEqual(discreteTooltip, newDiscreteTooltip)) { + setDiscreteTooltip(newDiscreteTooltip); + } + } + + if (legend) { + drawElement(legend, context); + } + + drawElement(cursorGuideLines, context); + drawElement(verticalGuideLine, context); + }, + handle: { + getMargin: () => discreteRadius, + getHeight: () => height, + }, + }; +}; diff --git a/packages/components/src/lib/draw/ReactCanvas.tsx b/packages/components/src/lib/draw/ReactCanvas.tsx new file mode 100644 index 0000000000..c8510aa9cf --- /dev/null +++ b/packages/components/src/lib/draw/ReactCanvas.tsx @@ -0,0 +1,282 @@ +import { + cloneElement, + createContext, + FC, + ReactElement, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Node } from "yoga-layout"; + +import { DrawFunction } from "../hooks/useCanvas.js"; +import { CanvasLayout, makeNode } from "./CanvasElement.js"; + +function useWidth() { + // We throttle to get around a Firefox bug. + // See: https://github.com/quantified-uncertainty/squiggle/issues/2263 + const RESIZE_DELAY = 30; + + const [width, setWidth] = useState(); + + const throttleTimeout = useRef(null); + + const handleResize = useCallback((entries: ResizeObserverEntry[]) => { + if (!entries[0]) return; + + if (throttleTimeout.current) { + clearTimeout(throttleTimeout.current); + } + + throttleTimeout.current = window.setTimeout(() => { + setWidth(entries[0].contentRect.width); + }, RESIZE_DELAY); + }, []); + + const observer = useMemo(() => { + if (typeof window === "undefined") { + return undefined; + } + return new window.ResizeObserver(handleResize); + }, []); + + useEffect(() => { + return () => { + if (throttleTimeout.current) { + clearTimeout(throttleTimeout.current); + } + observer?.disconnect(); + }; + }, []); + + const setElement = useCallback((element: HTMLElement) => { + observer?.disconnect(); + observer?.observe(element); + }, []); + + return { width, setInitialWidth: setWidth, setElement }; +} + +export function useReactCanvas({ + rootNode, + init, +}: { + // TODO - support `initialHeight` to miminize the reflows + rootNode: Node; + init?: DrawFunction; // useful for cursor initializations, see useCanvasCursor() +}) { + const { width, setInitialWidth, setElement: setWidthElement } = useWidth(); + const [context, setContext] = useState< + CanvasRenderingContext2D | undefined + >(); + + // Initialized height and calls Yoga layout + const height = useMemo(() => { + if (width === undefined) { + return undefined; + } + rootNode.calculateLayout(width, undefined); + return rootNode.getComputedHeight(); + }, [width, rootNode]); + + // TODO - move outside of useReactCanvas? it's weird that rootNode is passed from outside but destroyed by this hook + useEffect(() => { + // Yoga doesn't have garbage collection; https://www.yogalayout.dev/docs/getting-started/laying-out-a-tree#building-a-yoga-tree + return () => rootNode.freeRecursive(); + }, [rootNode]); + + const devicePixelRatio = + typeof window === "undefined" ? 1 : window.devicePixelRatio; + + const ref = useCallback( + (canvas: HTMLCanvasElement) => { + if (!canvas) { + return; + } + const usedWidth = canvas.getBoundingClientRect().width; + setInitialWidth(usedWidth); + + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Failed to initialize 2d context"); // shouldn't happen, all browsers support 2d context + } + context.resetTransform(); + context.scale(devicePixelRatio, devicePixelRatio); + + setContext(context); + init?.({ + context, + width: usedWidth, + height: canvas.height / devicePixelRatio, + }); + // TODO - call `draw` too? would be slightly faster; but we can't put `draw` in callback dependencies + + setWidthElement(canvas); + }, + [setWidthElement, setInitialWidth, devicePixelRatio, init] + ); + + useLayoutEffect(() => { + if (width === undefined || context === undefined) { + return; + } + // We have to do this here and not on observer's callback, because otherwise there's a delay between + // width change and drawing (setWidth is not synchronous), and that causes flickering and other issues. + const { canvas } = context; + canvas.width = width * devicePixelRatio; + }, [context, width, devicePixelRatio]); + + useLayoutEffect(() => { + if (height === undefined || context === undefined) { + return; + } + + const { canvas } = context; + canvas.style.height = `${height}px`; + canvas.height = height * devicePixelRatio; + }, [context, devicePixelRatio, height]); + + // happens on each render + useEffect(() => { + return () => { + if (!context) return; + // context.reset() would be better, but it's still unsupported in older Safari versions + context.resetTransform(); + context.scale(devicePixelRatio, devicePixelRatio); + context.clearRect( + 0, + 0, + context.canvas.width / devicePixelRatio, + context.canvas.height / devicePixelRatio + ); + }; + }); + + return { + ref, + width, + context, + }; +} + +type NodeElement = ReactElement; // TODO - limit to canvas components only + +function addYogaNodeProp(element: NodeElement) { + return cloneElement(element, { + ...element.props, + node: element.props.node ?? makeNode(), + }); +} + +const CanvasContext = createContext<{ context?: CanvasRenderingContext2D }>({}); + +export const CanvasNode: FC<{ + node?: Node; + draw(context: CanvasRenderingContext2D, layout: CanvasLayout): void; +}> = ({ node, draw }) => { + const { context } = useContext(CanvasContext); + useEffect(() => { + if (!node || !context) return; + // note that we don't take `right` and `bottom`; they seem broken in current yoga-layout (always zero) + const { width, height, left, top } = node.getComputedLayout(); + + context.save(); + context.translate(left, top); + + draw(context, { width, height, left, top }); + + context.restore(); + }); + + return null; +}; + +export const ReactCanvas: FC<{ + children: NodeElement[]; + alt?: string; +}> = ({ children, alt }) => { + const patchedChildren = useMemo( + () => children.map((child) => addYogaNodeProp(child)), + [children] + ); + + // Build root node + const rootNode = useMemo(() => { + const rootNode = makeNode(); + for (const child of patchedChildren) { + rootNode.insertChild(child.props.node, rootNode.getChildCount()); + } + return rootNode; + }, [patchedChildren]); + + const { ref, context } = useReactCanvas({ + rootNode, + // init: opts?.init, + }); + + return ( + + + {alt} + {patchedChildren} + + + ); +}; + +// Example + +const CanvasRow: FC<{ node?: Node, children: NodeElement[] }> = ({ node, children }) => { + if (!node) { + return null; + } + + useLayoutEffect(() => { + for (const child of children) { + + } + + }, [node, children]); + return ( + + + ); + +}; + +const Example1: FC<{ node?: Node; color: string }> = ({ node, color }) => { + node?.setHeight(100); + return ( + { + context.fillStyle = color; + context.fillRect(0, 0, layout.width / 2, layout.height); + }} + /> + ); +}; + +export const CanvasExample: FC = () => ( + + + + +); + +const Example1: FC<{ node?: Node; color: string }> = ({ node, color }) => { + node?.setHeight(100); + return ( + { + context.fillStyle = color; + context.fillRect(0, 0, layout.width / 2, layout.height); + }} + /> + ); +}; diff --git a/packages/components/src/lib/draw/drawCircle.ts b/packages/components/src/lib/draw/drawCircle.ts new file mode 100644 index 0000000000..ddb6fb3ada --- /dev/null +++ b/packages/components/src/lib/draw/drawCircle.ts @@ -0,0 +1,15 @@ +export function drawCircle({ + context, + x, + y, + r, +}: { + context: CanvasRenderingContext2D; + x: number; + y: number; + r: number; +}) { + context.beginPath(); + context.arc(x, y, r, 0, Math.PI * 2, true); + context.fill(); +} diff --git a/packages/components/src/lib/draw/index.ts b/packages/components/src/lib/draw/index.ts deleted file mode 100644 index b47603a850..0000000000 --- a/packages/components/src/lib/draw/index.ts +++ /dev/null @@ -1,475 +0,0 @@ -import * as d3 from "d3"; - -import { defaultTickFormatSpecifier } from "../d3/patchedScales.js"; -import { CartesianFrame } from "./CartesianFrame.js"; -import { Padding, Point } from "./types.js"; - -const axisColor = "rgba(114, 125, 147, 0.1)"; -export const labelColor = "rgb(114, 125, 147)"; -export const cursorLineColor = "rgba(114, 125, 147, 0.4)"; -export const primaryColor = "#4c78a8"; // for lines and areas -export const distributionColor = "#649ece"; // for distributions. Slightly lighter than primaryColor -export const axisTitleColor = "rgb(100 116 139)"; -export const axisTitleFont = "bold 12px ui-sans-serif, system-ui"; -const labelFont = "10px sans-serif"; -const xLabelOffset = 6; -const yLabelOffset = 6; - -export type AnyChartScale = d3.ScaleContinuousNumeric; - -export function distance(point1: Point, point2: Point) { - return Math.sqrt((point1.x - point2.x) ** 2 + (point1.y - point2.y) ** 2); -} - -interface DrawAxesParams { - context: CanvasRenderingContext2D; - xScale: AnyChartScale; - yScale: AnyChartScale; - suggestedPadding: Padding; - width: number; - height: number; - showXAxis?: boolean; - showYAxis?: boolean; - showAxisLines?: boolean; - xTickCount?: number; - yTickCount?: number; - xTickFormat?: string; - yTickFormat?: string; - xAxisTitle?: string; - yAxisTitle?: string; - frame?: CartesianFrame; -} - -const _tickCountInterpolator = d3 - .scaleLinear() - .domain([40000, 1000000]) // The potential height of the chart - .range([3, 16]) // The range of circle radiuses - .clamp(true); - -export function calculatePadding({ - suggestedPadding, - hasXAxisTitle, - hasYAxisTitle, -}: { - suggestedPadding: Padding; - hasXAxisTitle: boolean; - hasYAxisTitle: boolean; -}): Padding { - const padding: Padding = { ...suggestedPadding }; - if (hasXAxisTitle) { - padding.bottom = padding.bottom + 20; - } - if (hasYAxisTitle) { - padding.left = padding.left + 35; - } - return padding; -} - -export function makeCartesianFrame({ - context, - padding, - width, - height, -}: { - context: CanvasRenderingContext2D; - padding: Padding; - width: number; - height: number; -}) { - return new CartesianFrame({ - context, - x0: padding.left, - y0: height - padding.bottom, - width: width - padding.left - padding.right, - height: height - padding.top - padding.bottom, - }); -} - -export function drawAxes({ - context, - xScale, // will be mutated with the correct range - yScale, - suggestedPadding, - width, - height, - showYAxis = true, - showXAxis = true, - showAxisLines = height > 150 && width > 150, - xTickCount, - yTickCount, - xTickFormat: xTickFormatSpecifier = defaultTickFormatSpecifier, - yTickFormat: yTickFormatSpecifier = defaultTickFormatSpecifier, - xAxisTitle, - yAxisTitle, - frame: _frame, -}: DrawAxesParams) { - const _xTickCount = xTickCount || _tickCountInterpolator(width * height); - const _yTickCount = yTickCount || _tickCountInterpolator(height * width); - - const xTicks = xScale.ticks(_xTickCount); - const xTickFormat = xScale.tickFormat(_xTickCount, xTickFormatSpecifier); - - const yTicks = yScale.ticks(_yTickCount); - const yTickFormat = yScale.tickFormat(_yTickCount, yTickFormatSpecifier); - - const tickSize = 2; - - const padding: Padding = calculatePadding({ - suggestedPadding, - hasXAxisTitle: !!xAxisTitle, - hasYAxisTitle: !!yAxisTitle, - }); - - // measure tick sizes for dynamic padding - if (showYAxis) { - yTicks.forEach((d) => { - const measured = context.measureText(yTickFormat(d)); - padding.left = Math.max( - padding.left, - measured.actualBoundingBoxLeft + - measured.actualBoundingBoxRight + - yLabelOffset - ); - }); - } - - const frame = - _frame || makeCartesianFrame({ context, padding, width, height }); - - xScale.range([0, frame.width]); - yScale.range([0, frame.height]); - - // x axis - if (showXAxis) { - frame.enter(); - if (showAxisLines) { - context.beginPath(); - context.strokeStyle = axisColor; - context.lineWidth = 1; - context.moveTo(0, yScale(0)); - context.lineTo(frame.width, yScale(0)); - context.stroke(); - } - - context.fillStyle = labelColor; - context.font = labelFont; - - let prevBoundary = -padding.left; - for (let i = 0; i < xTicks.length; i++) { - const xTick = xTicks[i]; - const x = xScale(xTick); - const y = yScale(0); - - context.beginPath(); - context.strokeStyle = labelColor; - context.lineWidth = 1; - context.moveTo(x, y); - context.lineTo(x, y - tickSize); - context.stroke(); - - const text = xTickFormat(xTick); - if (text === "") { - continue; // we're probably rendering scaleLog, which has empty labels - } - const { width: textWidth } = context.measureText(text); - let startX = 0; - if (i === 0) { - startX = Math.max(x - textWidth / 2, prevBoundary); - } else if (i === xTicks.length - 1) { - startX = Math.min(x - textWidth / 2, frame.width - textWidth); - } else { - startX = x - textWidth / 2; - } - if (startX < prevBoundary) { - continue; // doesn't fit, skip - } - frame.fillText(text, startX, y - xLabelOffset, { - textAlign: "left", - textBaseline: "top", - }); - prevBoundary = startX + textWidth; - } - frame.exit(); - } - - // y axis - if (showYAxis) { - frame.enter(); - if (showAxisLines) { - context.beginPath(); - context.strokeStyle = axisColor; - context.lineWidth = 1; - context.moveTo(0, 0); - context.lineTo(0, frame.height); - context.stroke(); - } - - let prevBoundary = -padding.bottom; - const x = 0; - for (let i = 0; i < yTicks.length; i++) { - const yTick = yTicks[i]; - context.beginPath(); - const y = yScale(yTick); - - const text = yTickFormat(yTick); - context.textBaseline = "bottom"; - const { actualBoundingBoxAscent: textHeight } = context.measureText(text); - - context.beginPath(); - context.strokeStyle = labelColor; - context.lineWidth = 1; - context.moveTo(x, y); - context.lineTo(x - tickSize, y); - context.stroke(); - - let startY = 0; - if (i === 0) { - startY = Math.max(y - textHeight / 2, prevBoundary); - } else if (i === yTicks.length - 1) { - startY = Math.min(y - textHeight / 2, frame.height - textHeight); - } else { - startY = y - textHeight / 2; - } - - if (startY < prevBoundary) { - continue; // doesn't fit, skip - } - frame.fillText(text, x - yLabelOffset, startY - 1, { - textAlign: "right", - textBaseline: "bottom", - fillStyle: labelColor, - font: labelFont, - }); - prevBoundary = startY + textHeight; - } - frame.exit(); - } - - if (xAxisTitle) { - const chartWidth = width - padding.left - padding.right; // Actual charting area width - const titleX = padding.left + chartWidth / 2; // center the title within the charting area - const titleY = height - padding.bottom + 33; // adjust this value based on desired distance from x-axis - context.textAlign = "center"; - context.textBaseline = "bottom"; - context.font = axisTitleFont; - context.fillStyle = axisTitleColor; - context.fillText(xAxisTitle, titleX, titleY); - } - if (yAxisTitle) { - const chartHeight = height - padding.top - padding.bottom; // Actual charting area height - const titleY = padding.top + chartHeight / 2; // center the title vertically within the charting area - const titleX = 0; - context.save(); // save the current context state - context.translate(titleX, titleY); - context.rotate(-Math.PI / 2); // rotate 90 degrees counter-clockwise - context.textAlign = "center"; - context.textBaseline = "top"; - context.font = axisTitleFont; - context.fillStyle = axisTitleColor; - context.fillText(yAxisTitle, 0, 0); - context.restore(); // restore the context state to before rotation and translation - } - - return { - xScale, - yScale, - xTickFormat, - padding, - frame, - }; -} - -const TOOLTIP_OFFSETS = { - px: 4, - py: 2, - mx: 4, - my: 4, -}; - -export function drawVerticalLine({ - scale, - format, - x, - frame, -}: { - scale: d3.ScaleContinuousNumeric; - format?: string | undefined; - x: number; - frame: CartesianFrame; -}) { - const context = frame.context; - frame.enter(); - - context.beginPath(); - context.strokeStyle = cursorLineColor; - context.lineWidth = 1; - context.setLineDash([5, 5]); // setting the dashed line pattern - context.moveTo(x, 0); - context.lineTo(x, frame.height); - context.stroke(); - context.setLineDash([]); // resetting the dashed line pattern so it doesn't affect other lines - - context.textAlign = "left"; - context.textBaseline = "bottom"; - const text = scale.tickFormat( - Infinity, // important for scaleLog; https://github.com/d3/d3-scale/tree/main#log_tickFormat - format - )(scale.invert(x)); - const measured = context.measureText(text); - - let boxWidth = measured.width + TOOLTIP_OFFSETS.px * 2; - const boxHeight = measured.actualBoundingBoxAscent + TOOLTIP_OFFSETS.py * 2; - const boxOrigin: Point = { - x: x + TOOLTIP_OFFSETS.mx, - y: TOOLTIP_OFFSETS.my, - }; - const flip = - boxOrigin.x + boxWidth > frame.width && - // In pathological cases, we can't fit the box on either side because the text is too long. - // In this case, we don't flip because first digits are more significant. - boxWidth <= x; - - if (flip) { - boxOrigin.x = x - TOOLTIP_OFFSETS.mx; - boxWidth = -boxWidth; - context.textAlign = "right"; - } - - context.globalAlpha = 0.7; - context.fillStyle = "white"; - context.fillRect(boxOrigin.x, boxOrigin.y, boxWidth, boxHeight); - context.globalAlpha = 1; - context.fillStyle = labelColor; - frame.fillText( - text, - boxOrigin.x + TOOLTIP_OFFSETS.px * (flip ? -1 : 1), - // unsure why "-1" is needed, probably related to measureText result and could be fixed - TOOLTIP_OFFSETS.my + TOOLTIP_OFFSETS.py - 1, - { - fillStyle: labelColor, - } - ); - frame.exit(); -} - -export function drawHorizontalLine({ - scale, - format, - y, - frame, -}: { - scale: d3.ScaleContinuousNumeric; - format?: string | undefined; - y: number; - frame: CartesianFrame; -}) { - // TODO - copy-pasted from drawVerticalLine - const context = frame.context; - frame.enter(); - - context.beginPath(); - context.strokeStyle = cursorLineColor; - context.lineWidth = 1; - context.setLineDash([5, 5]); // setting the dashed line pattern - context.moveTo(0, y); - context.lineTo(frame.width, y); - context.stroke(); - context.setLineDash([]); // resetting the dashed line pattern so it doesn't affect other lines - - context.textAlign = "left"; - context.textBaseline = "bottom"; - const text = scale.tickFormat( - Infinity, // important for scaleLog; https://github.com/d3/d3-scale/tree/main#log_tickFormat - format - )(scale.invert(y)); - const measured = context.measureText(text); - - const boxWidth = measured.width + TOOLTIP_OFFSETS.px * 2; - let boxHeight = measured.actualBoundingBoxAscent + TOOLTIP_OFFSETS.py * 2; - const boxOrigin: Point = { - x: TOOLTIP_OFFSETS.mx, - y: TOOLTIP_OFFSETS.my + y, - }; - const flip = boxOrigin.y + boxHeight > frame.height; - - if (flip) { - boxOrigin.y = y - TOOLTIP_OFFSETS.mx; - boxHeight = -boxHeight; - context.textBaseline = "top"; - } - - context.globalAlpha = 0.7; - context.fillStyle = "white"; - context.fillRect(boxOrigin.x, boxOrigin.y, boxWidth, boxHeight); - context.globalAlpha = 1; - context.fillStyle = labelColor; - frame.fillText( - text, - TOOLTIP_OFFSETS.mx + TOOLTIP_OFFSETS.px, - boxOrigin.y + TOOLTIP_OFFSETS.py * (flip ? -1 : 1) - 1, - { - fillStyle: labelColor, - } - ); - frame.exit(); -} - -export function drawCursorLines({ - cursor, - frame, - x: xLine, - y: yLine, -}: { - // original canvas coordinates; - // can be undefined for convenience (this function will check if cursor lines are necessary) - cursor?: Point; - frame: CartesianFrame; - x?: { - scale: d3.ScaleContinuousNumeric; - format?: string | undefined; - }; - y?: { - scale: d3.ScaleContinuousNumeric; - format?: string | undefined; - }; -}) { - if (!cursor || !frame.containsPoint(cursor)) { - return; - } - - const point = frame.translatedPoint(cursor); - - if (xLine) { - drawVerticalLine({ - frame, - scale: xLine.scale, - format: xLine.format, - x: point.x, - }); - } - - if (yLine) { - drawHorizontalLine({ - frame, - scale: yLine.scale, - format: yLine.format, - y: point.y, - }); - } -} - -export function drawCircle({ - context, - x, - y, - r, -}: { - context: CanvasRenderingContext2D; - x: number; - y: number; - r: number; -}) { - context.beginPath(); - context.arc(x, y, r, 0, Math.PI * 2, true); - context.fill(); -} diff --git a/packages/components/src/lib/draw/styles.ts b/packages/components/src/lib/draw/styles.ts new file mode 100644 index 0000000000..192f2be8bf --- /dev/null +++ b/packages/components/src/lib/draw/styles.ts @@ -0,0 +1,8 @@ +export const axisColor = "rgba(114, 125, 147, 0.1)"; +export const labelColor = "rgb(114, 125, 147)"; +export const primaryColor = "#4c78a8"; // for lines and areas +export const distributionColor = "#649ece"; // for distributions. Slightly lighter than primaryColor +export const axisTitleColor = "rgb(100 116 139)"; +export const axisTitleFont = "bold 12px ui-sans-serif, system-ui"; +export const guideLineColor = "rgba(114, 125, 147, 0.4)"; +export const labelFont = "10px sans-serif"; diff --git a/packages/components/src/lib/draw/types.ts b/packages/components/src/lib/draw/types.ts index b9c44f9648..ce46029360 100644 --- a/packages/components/src/lib/draw/types.ts +++ b/packages/components/src/lib/draw/types.ts @@ -9,3 +9,8 @@ export type Padding = { bottom: number; right: number; }; + +export type Dimensions = { + width: number; + height: number; +}; diff --git a/packages/components/src/lib/draw/utils.ts b/packages/components/src/lib/draw/utils.ts new file mode 100644 index 0000000000..d81cd77f9b --- /dev/null +++ b/packages/components/src/lib/draw/utils.ts @@ -0,0 +1,115 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; + +import { DrawContext, DrawFunction, useCanvas } from "../hooks/useCanvas.js"; +import { CanvasElement, makeNode } from "./CanvasElement.js"; +import { CanvasFrame } from "./CanvasFrame.js"; +import { Padding, Point } from "./types.js"; + +export function distance(point1: Point, point2: Point) { + return Math.sqrt((point1.x - point2.x) ** 2 + (point1.y - point2.y) ** 2); +} + +export function makeCartesianFrame({ + context, + padding, + width, + height, +}: { + context: CanvasRenderingContext2D; + padding: Padding; + width: number; + height: number; +}) { + return new CanvasFrame({ + context, + x0: padding.left, + y0: height - padding.bottom, + width: width - padding.left - padding.right, + height: height - padding.top - padding.bottom, + }); +} + +export function measureText(params: { text: string; font: string }) { + // TODO - cache canvas + const canvas = window.document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Failed to create canvas context"); + } + context.scale(window.devicePixelRatio, window.devicePixelRatio); + context.font = params.font; + return context.measureText(params.text); +} + +export function drawElement( + element: CanvasElement, + context: CanvasRenderingContext2D +) { + // note that we don't take `right` and `bottom`; they seem broken in current yoga-layout (always zero) + const { width, height, left, top } = element.node.getComputedLayout(); + + context.save(); + context.translate(left, top); + + element.draw(context, { width, height, left, top }); + + context.restore(); +} + +// converts the absolute point coordinates (absolute relative to canvas) to the coordinates in the current context transform +export function getLocalPoint(context: CanvasRenderingContext2D, point: Point) { + const devicePixelRatio = + typeof window === "undefined" ? 1 : window.devicePixelRatio; + + const domPoint = new DOMPoint( + point.x * devicePixelRatio, + point.y * devicePixelRatio + ).matrixTransform(context.getTransform().inverse()); + return { x: domPoint.x, y: domPoint.y }; +} + +export function useYogaCanvas( + element: CanvasElement, + opts: { + init?: DrawFunction; + } = {} +) { + const rootNode = useMemo(() => { + const node = makeNode(); + node.insertChild(element.node, 0); + return node; + }, [element]); + + const draw = useCallback( + ({ context, width, height }: DrawContext) => { + context.clearRect(0, 0, width, height); + drawElement(element, context); + }, + [element] + ); + + const outerRef = useRef(null); + const width = outerRef.current?.clientWidth; + + const elementWithCalculatedLayout = useMemo(() => { + if (width === undefined) { + return undefined; + } + rootNode.calculateLayout(width, undefined); + + return element; + }, [rootNode, element, width]); + + const { ref } = useCanvas({ + height: elementWithCalculatedLayout?.node.getComputedHeight() ?? 0, // TODO - support `initialHeight` to miminize the reflows + init: opts?.init, + draw, + }); + + useEffect(() => { + // Yoga doesn't have garbage collection; https://www.yogalayout.dev/docs/getting-started/laying-out-a-tree#building-a-yoga-tree + return () => element.node.freeRecursive(); + }, [element.node]); + + return { outerRef, ref }; +} diff --git a/packages/components/src/lib/hooks/useCanvas.ts b/packages/components/src/lib/hooks/useCanvas.ts index c665e159f1..41f7893a75 100644 --- a/packages/components/src/lib/hooks/useCanvas.ts +++ b/packages/components/src/lib/hooks/useCanvas.ts @@ -10,9 +10,10 @@ import { export type DrawContext = { context: CanvasRenderingContext2D; width: number; + height: number; }; -type DrawFunction = (context: DrawContext) => void; +export type DrawFunction = (context: DrawContext) => void; // We throttle to get around a Firefox bug. // See: https://github.com/quantified-uncertainty/squiggle/issues/2263 @@ -81,7 +82,11 @@ export function useCanvas({ context.scale(devicePixelRatio, devicePixelRatio); setContext(context); - init?.({ context, width: usedWidth }); + init?.({ + context, + width: usedWidth, + height: canvas.height / devicePixelRatio, + }); // TODO - call `draw` too? would be slightly faster; but we can't put `draw` in callback dependencies observer?.disconnect(); @@ -105,7 +110,7 @@ export function useCanvas({ context.resetTransform(); context.scale(devicePixelRatio, devicePixelRatio); - draw({ width, context }); + draw({ width, height, context }); }, [draw, width, height, context, devicePixelRatio]); useLayoutEffect(() => { @@ -116,5 +121,6 @@ export function useCanvas({ ref, redraw, width, + context, }; } diff --git a/packages/components/src/stories/ReactCanvas.stories.tsx b/packages/components/src/stories/ReactCanvas.stories.tsx new file mode 100644 index 0000000000..0ea0637742 --- /dev/null +++ b/packages/components/src/stories/ReactCanvas.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { CanvasExample } from "../lib/draw/ReactCanvas.js"; + +/** + * The number shower is a simple component to display a number. + * It uses the symbols "K", "M", "B", and "T", to represent thousands, millions, billions, and trillions. Outside of that range, it uses scientific notation. + */ +const meta = { + component: CanvasExample, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Main: Story = {}; diff --git a/packages/components/src/widgets/DistWidget/DistributionsChart.tsx b/packages/components/src/widgets/DistWidget/DistributionsChart.tsx index 0130e54ad3..d4e06f69c9 100644 --- a/packages/components/src/widgets/DistWidget/DistributionsChart.tsx +++ b/packages/components/src/widgets/DistWidget/DistributionsChart.tsx @@ -1,7 +1,7 @@ import clsx from "clsx"; import * as d3 from "d3"; -import isEqual from "lodash/isEqual.js"; -import { FC, useCallback, useMemo, useState } from "react"; +import { FC, useMemo, useState } from "react"; +import { Edge, PositionType } from "yoga-layout"; import { Env, @@ -18,70 +18,166 @@ import { useViewerType } from "../../components/SquiggleViewer/ViewerProvider.js import { ErrorAlert } from "../../components/ui/Alert.js"; import { sqScaleToD3 } from "../../lib/d3/index.js"; import { hasMassBelowZero } from "../../lib/distributionUtils.js"; -import { - calculatePadding, - distance, - distributionColor, - drawAxes, - drawCircle, - drawCursorLines, - drawVerticalLine, - makeCartesianFrame, -} from "../../lib/draw/index.js"; +import { AnyNumericScale } from "../../lib/draw/AxesBox.js"; +import { AxesContainer } from "../../lib/draw/AxesContainer.js"; +import { AxesTitlesContainer } from "../../lib/draw/AxesTitlesContainer.js"; +import { BarSamples } from "../../lib/draw/BarSamples.js"; +import { CanvasElement, CC, makeNode } from "../../lib/draw/CanvasElement.js"; +import { MainChart, MainChartHandle } from "../../lib/draw/MainChart.js"; +import { ReactCanvas } from "../../lib/draw/ReactCanvas.js"; import { Point } from "../../lib/draw/types.js"; -import { useCanvas, useCanvasCursor } from "../../lib/hooks/index.js"; -import { DrawContext } from "../../lib/hooks/useCanvas.js"; -import { canvasClasses, flattenResult } from "../../lib/utility.js"; +import { drawElement } from "../../lib/draw/utils.js"; +import { useCanvasCursor } from "../../lib/hooks/index.js"; +import { flattenResult } from "../../lib/utility.js"; import { PlotTitle } from "../PlotWidget/PlotTitle.js"; import { DistProvider, useSelectedVerticalLine } from "./DistProvider.js"; import { SummaryTable } from "./SummaryTable.js"; import { adjustPdfHeightToScale } from "./utils.js"; -const distRadiusScalingFromHeight = d3 - .scaleLinear() - .domain([10, 300]) // The potential height of the chart - .range([2, 5]) // The range of circle radiuses - .clamp(true); - export type DistributionsChartProps = { plot: SqDistributionsPlot; environment: Env; height: number; }; -// We have a similar function in squiggle-lang, but it's not exported, and this function is simple enough. -type DataPoint = { - x: number; - y: number; +type SampleBarSetting = "none" | "bottom" | "behind"; +type DiscreteTooltip = { value: number; probability: number }; + +const MainChartWithBarSamplesBehind: CC< + { + mainChart: CanvasElement; + barSamples: CanvasElement; + }, + MainChartHandle +> = ({ mainChart, barSamples }) => { + const node = makeNode(); + node.insertChild(mainChart.node, 0); + + node.insertChild(barSamples.node, 1); + barSamples.node.setPositionType(PositionType.Absolute); + barSamples.node.setPosition(Edge.Bottom, mainChart.handle.getMargin()); + barSamples.node.setPosition(Edge.Left, 0); + barSamples.node.setPosition(Edge.Right, 0); + barSamples.node.setHeight( + Math.min(7, mainChart.node.getHeight().value * 0.04 + 1) + ); + + return { + node, + draw: (context) => { + drawElement(barSamples, context); + drawElement(mainChart, context); + }, + handle: mainChart.handle, // forward to main chart - this should work... + }; }; -function interpolateYAtX( - xValue: number, - continuousData: { x: number; y: number }[], - yScale: d3.ScaleContinuousNumeric -): number | null { - let pointBefore: DataPoint | null = null, - pointAfter: DataPoint | null = null; - for (const point of continuousData) { - if (point.x <= xValue) { - pointBefore = point; - } else { - pointAfter = point; - break; - } + +const CanvasDistChart: CC<{ + shapes: (SqShape & { + name: string; + p5: result; + p50: result; + p95: result; + })[]; + samples: number[]; + innerHeight: number; + isMulti: boolean; // enables legend and semi-transparent rendering + samplesBarSetting: SampleBarSetting; + showCursorLine: boolean; + showPercentileLines: boolean; + showXAxis: boolean; + discreteTooltip: DiscreteTooltip | undefined; + setDiscreteTooltip: (value: DiscreteTooltip | undefined) => void; + cursor: Point | undefined; + xScale: AnyNumericScale; + yScale: AnyNumericScale; + verticalLine: number | undefined; + xTickFormat: string | undefined; + xAxisTitle: string | undefined; +}> = ({ + innerHeight, + shapes, + isMulti, + showPercentileLines, + setDiscreteTooltip, + showCursorLine, + xScale, + yScale, + verticalLine, + showXAxis, + discreteTooltip, + cursor, + xTickFormat, + xAxisTitle, + samples, + samplesBarSetting, +}) => { + let barSamples: CanvasElement | undefined; + if (samplesBarSetting !== "none") { + barSamples = BarSamples({ + behindShapes: samplesBarSetting === "behind", + samples, + scale: xScale, + }); } - if (pointBefore && pointAfter) { - const xInterpolate = d3 - .scaleLinear() - .domain([pointBefore.x, pointAfter.x]) - .range([yScale(pointBefore.y), yScale(pointAfter.y)]); - return xInterpolate(xValue); - } else { - return null; + const mainChart = MainChart({ + height: innerHeight, + shapes, + isMulti, + showPercentileLines, + discreteTooltip, + setDiscreteTooltip, + cursor: showCursorLine ? cursor : undefined, + xScale, + yScale, + xTickFormat, + verticalLine, + }); + + const mainChartWithBarSamplesBehind = + samplesBarSetting === "behind" + ? MainChartWithBarSamplesBehind({ + mainChart, + barSamples: BarSamples({ + behindShapes: true, + samples, + scale: xScale, + }), + }) + : mainChart; + + const chart = AxesContainer({ + xScale, + yScale, + showAxisLines: true, + showXAxis, + showYAxis: false, + child: mainChartWithBarSamplesBehind, + xTickFormat, + }); + const chartWithTitles = AxesTitlesContainer({ + xAxisTitle, + child: chart, + }); + + const rootNode = makeNode(); + rootNode.insertChild(chartWithTitles.node, rootNode.getChildCount()); + + if (barSamples && samplesBarSetting === "bottom") { + rootNode.insertChild(barSamples.node, rootNode.getChildCount()); } -} -type SampleBarSetting = "none" | "bottom" | "behind"; + return { + node: rootNode, + draw: (context) => { + drawElement(chartWithTitles, context); + if (barSamples && samplesBarSetting === "bottom") { + drawElement(barSamples, context); + } + }, + }; +}; const InnerDistributionsChart: FC<{ shapes: (SqShape & { @@ -114,7 +210,7 @@ const InnerDistributionsChart: FC<{ const verticalLine = useSelectedVerticalLine(); const [discreteTooltip, setDiscreteTooltip] = useState< - { value: number; probability: number } | undefined + DiscreteTooltip | undefined >(); const shapes = unAdjustedShapes.map( @@ -131,26 +227,6 @@ const InnerDistributionsChart: FC<{ shape.discrete.concat(shape.continuous) ); - const legendItemHeight = 16; - const legendOffset = 2; - - const legendHeight = isMulti - ? legendItemHeight * shapes.length + legendOffset - : 0; - const samplesFooterHeight = samplesBarSetting === "bottom" ? 20 : 0; - - const bottomPadding = (showXAxis ? 14 : 0) + samplesFooterHeight; - - const height = Math.max( - innerHeight + bottomPadding, - legendHeight + bottomPadding - ); - - const discreteRadius = distRadiusScalingFromHeight(height); - - const sampleBarHeight = - samplesBarSetting === "behind" ? Math.min(7, innerHeight * 0.04 + 1) : 7; - const { xScale, yScale } = useMemo(() => { const xScale = sqScaleToD3(plot.xScale); @@ -173,258 +249,48 @@ const InnerDistributionsChart: FC<{ const { cursor, initCursor } = useCanvasCursor(); - const draw = useCallback( - ({ context, width }: DrawContext) => { - context.clearRect(0, 0, width, height); - - const getColor = (i: number, lightening?: number) => { - const color = isMulti ? d3.schemeCategory10[i] : distributionColor; - if (lightening) { - return d3.interpolateLab(color, "#fff")(lightening); - } else { - return color; - } - }; - - const suggestedPadding = { - left: discreteRadius, - right: discreteRadius, - top: discreteRadius, - bottom: bottomPadding, - }; - - const padding = calculatePadding({ - suggestedPadding, - hasXAxisTitle: showAxisTitles && !!plot.xScale.title, - hasYAxisTitle: false, - }); - - const frame = makeCartesianFrame({ context, padding, width, height }); - - xScale.range([0, frame.width]); - yScale.range([0, frame.height]); - // samplesBar - function samplesBarShowSettings(): { yOffset: number; color: string } { - if (samplesBarSetting === "behind") { - return { yOffset: bottomPadding, color: getColor(0, 0.4) }; - } else if (samplesBarSetting === "bottom") { - return { yOffset: 0, color: getColor(0) }; - } else { - // Only for the case of samplesBarSetting === "none", should not happen - return { yOffset: 0, color: getColor(0) }; - } - } - if (samplesBarSetting !== "none") { - context.save(); - const { yOffset, color } = samplesBarShowSettings(); - context.lineWidth = 0.5; - context.strokeStyle = color; - samples.forEach((sample) => { - context.beginPath(); - const x = xScale(sample); - context.beginPath(); - context.moveTo(padding.left + x, height - yOffset - sampleBarHeight); - context.lineTo(padding.left + x, height - yOffset); - context.stroke(); - }); - context.restore(); - } - - // shapes - { - frame.enter(); - const translatedCursor: Point | undefined = cursor - ? frame.translatedPoint(cursor) - : undefined; - - // there can be only one - let newDiscreteTooltip: typeof discreteTooltip = undefined; - - for (let i = 0; i < shapes.length; i++) { - const shape = shapes[i]; - - // continuous fill - //In the case of one distribution, we don't want it to be transparent, so that we can show the samples lines. In the case of multiple distributions, we want them to be transparent so that we can see the other distributions. - context.fillStyle = isMulti ? getColor(i, 0) : getColor(i, 0.7); - context.globalAlpha = isMulti ? 0.4 : 1; - context.beginPath(); - d3 - .area() - .x((d) => xScale(d.x)) - .y0((d) => yScale(d.y)) - .y1(yScale(0)) - .context(context)(shape.continuous); - context.fill(); - context.globalAlpha = 1; - - // Percentile lines - if (showPercentileLines) { - const percentiles = [ - [shape.p5, "p5"], - [shape.p50, "p50"], - [shape.p95, "p95"], - ] as const; - percentiles.forEach(([percentile, name]) => { - if (percentile.ok) { - const xPoint = percentile.value; - //We need to find the y value of the percentile in question, to draw the line only up to the top of the distribution. We have to do this with interpolation, which is not provided straightforwardly by d3. - const interpolateY = interpolateYAtX( - xPoint, - shape.continuous, - yScale - ); - if (interpolateY) { - context.beginPath(); - context.strokeStyle = getColor(i, name === "p50" ? 0.4 : 0.3); - if (name === "p50") { - context.setLineDash([6, 4]); - } else { - context.setLineDash([2, 2]); - } - context.lineWidth = 1; - context.moveTo(xScale(xPoint), 0); - context.lineTo(xScale(xPoint), interpolateY); - context.stroke(); - context.setLineDash([]); - } - } - }); - } - - // The top line - context.strokeStyle = getColor(i); - context.beginPath(); - d3 - .line() - .x((d) => xScale(d.x)) - .y((d) => yScale(d.y)) - .context(context)(shape.continuous); - context.stroke(); - - const darkenAmountCircle = isMulti ? 0.05 : 0.1; - - const discreteLineColor = getColor(i, -darkenAmountCircle); - const discreteCircleColor = getColor(i, -darkenAmountCircle); - - context.fillStyle = discreteCircleColor; - context.strokeStyle = discreteLineColor; - for (const point of shape.discrete) { - context.beginPath(); - context.lineWidth = 1; - const x = xScale(point.x); - // The circle is drawn from the top of the circle, so we need to subtract the radius to get the center of the circle to be at the top of the bar. - const y = yScale(point.y) - discreteRadius; - if ( - translatedCursor && - distance({ x, y }, translatedCursor) <= discreteRadius + 2 - ) { - // the last discrete point always wins over overlapping previous points - // this makes sense because it's drawn last - newDiscreteTooltip = { value: point.x, probability: point.y }; - //darken the point if it's hovered - context.fillStyle = getColor(i, -1); - context.strokeStyle = getColor(i, -1); - } - context.moveTo(x, 0); - context.lineTo(x, y); - context.globalAlpha = 0.5; // We want the lines to be transparent - the circles are the main focus - context.stroke(); - context.globalAlpha = 1; - drawCircle({ - context, - x, - y, - r: discreteRadius, - }); - } - } - if (!isEqual(discreteTooltip, newDiscreteTooltip)) { - setDiscreteTooltip(newDiscreteTooltip); - } - frame.exit(); - } - - drawAxes({ - context, - width, - height, - suggestedPadding, + // TODO - memoize this and `calculateLayout` call below? + const chart = useMemo( + () => + CanvasDistChart({ + innerHeight: innerHeight, + shapes, + isMulti, + showPercentileLines, + discreteTooltip, + setDiscreteTooltip, + cursor, xScale, yScale, - showYAxis: false, showXAxis, xTickFormat: plot.xScale.tickFormat, xAxisTitle: showAxisTitles ? plot.xScale.title : undefined, - showAxisLines: true, // Set this to true to show the axis lines - }); - - if (isMulti) { - const radius = 5; - for (let i = 0; i < shapes.length; i++) { - context.save(); - context.translate(padding.left, legendItemHeight * i + legendOffset); - context.fillStyle = getColor(i); - drawCircle({ - context, - x: radius, - y: radius, - r: radius, - }); - - context.textAlign = "left"; - context.textBaseline = "middle"; - context.fillStyle = "black"; - context.font = "12px sans-serif"; - context.fillText(shapes[i].name, 16, radius); - context.restore(); - } - } - - { - showCursorLine && - drawCursorLines({ - frame, - cursor, - x: { - scale: xScale, - format: plot.xScale.tickFormat, - }, - }); - } - - if (verticalLine) { - drawVerticalLine({ - frame, - scale: xScale, - format: plot.xScale.tickFormat, - x: xScale(verticalLine), - }); - } - }, + verticalLine, + showCursorLine, + samples, + samplesBarSetting, + }), [ - height, - discreteRadius, + innerHeight, shapes, - samples, - plot, + isMulti, + showPercentileLines, discreteTooltip, + setDiscreteTooltip, cursor, - isMulti, xScale, yScale, - verticalLine, - sampleBarHeight, - bottomPadding, - samplesBarSetting, - showCursorLine, - showPercentileLines, showXAxis, + plot.xScale.tickFormat, + plot.xScale.title, + verticalLine, showAxisTitles, + showCursorLine, + samples, + samplesBarSetting, ] ); - const { ref } = useCanvas({ height, init: initCursor, draw }); - return ( - - Distribution plot - +
+ +
); }; @@ -583,9 +445,17 @@ export const DistributionsChart: FC = ({
; }>(); diff --git a/packages/components/src/widgets/LambdaWidget/FunctionChart/NumericFunctionChart.tsx b/packages/components/src/widgets/LambdaWidget/FunctionChart/NumericFunctionChart.tsx index 9855e19af2..b52a3a5735 100644 --- a/packages/components/src/widgets/LambdaWidget/FunctionChart/NumericFunctionChart.tsx +++ b/packages/components/src/widgets/LambdaWidget/FunctionChart/NumericFunctionChart.tsx @@ -4,11 +4,9 @@ import { FC, useCallback, useMemo } from "react"; import { Env, SqNumericFnPlot } from "@quri/squiggle-lang"; import { sqScaleToD3 } from "../../../lib/d3/index.js"; -import { - drawAxes, - drawCursorLines, - primaryColor, -} from "../../../lib/draw/index.js"; +import { drawAxes } from "../../../lib/draw/AxesBox.js"; +import { CanvasFrame } from "../../../lib/draw/CanvasFrame.js"; +import { primaryColor } from "../../../lib/draw/styles.js"; import { DrawContext, useCanvas, @@ -55,10 +53,7 @@ export const NumericFunctionChart: FC = ({ ); const { frame } = drawAxes({ - context, - width, - height, - suggestedPadding: { left: 20, right: 10, top: 10, bottom: 20 }, + frame: CanvasFrame.fullFrame(context), xScale, yScale, xTickFormat: plot.xScale.tickFormat, @@ -104,18 +99,18 @@ export const NumericFunctionChart: FC = ({ context.stroke(); frame.exit(); - drawCursorLines({ - frame, - cursor, - x: { - scale: xScale, - format: plot.xScale.tickFormat, - }, - y: { - scale: yScale, - format: plot.yScale.tickFormat, - }, - }); + // drawCursorGuideLines({ + // frame, + // cursor, + // x: { + // scale: xScale, + // format: plot.xScale.tickFormat, + // }, + // y: { + // scale: yScale, + // format: plot.yScale.tickFormat, + // }, + // }); }, [functionImage, height, cursor, plot, xScale] ); diff --git a/packages/components/src/widgets/PlotWidget/ScatterChart/index.tsx b/packages/components/src/widgets/PlotWidget/ScatterChart/index.tsx index bbf628fb30..b8867c3ba2 100644 --- a/packages/components/src/widgets/PlotWidget/ScatterChart/index.tsx +++ b/packages/components/src/widgets/PlotWidget/ScatterChart/index.tsx @@ -4,12 +4,10 @@ import { FC, useCallback, useMemo } from "react"; import { Env, SqScale, SqScatterPlot } from "@quri/squiggle-lang"; import { sqScaleToD3 } from "../../../lib/d3/index.js"; -import { - drawAxes, - drawCircle, - drawCursorLines, - primaryColor, -} from "../../../lib/draw/index.js"; +import { drawAxes } from "../../../lib/draw/AxesBox.js"; +import { CanvasFrame } from "../../../lib/draw/CanvasFrame.js"; +import { drawCircle } from "../../../lib/draw/drawCircle.js"; +import { primaryColor } from "../../../lib/draw/styles.js"; import { DrawContext, useCanvas, @@ -35,7 +33,7 @@ export const ScatterChart: FC = ({ plot, height }) => { [plot] ); const draw = useCallback( - ({ context, width }: DrawContext) => { + ({ context }: DrawContext) => { const points = SqScatterPlot.zipToPoints(xDist, yDist); const xSqScale = plot.xScale ?? SqScale.linearDefault(); @@ -53,15 +51,12 @@ export const ScatterChart: FC = ({ plot, height }) => { ]); const { frame } = drawAxes({ - context, - width, - height, - suggestedPadding: { + frame: CanvasFrame.fullFrame(context).subframeWithPadding({ top: 10, bottom: 16, left: 0, right: 0, - }, + }), xScale, yScale, xTickFormat: plot.xScale?.tickFormat, @@ -83,18 +78,18 @@ export const ScatterChart: FC = ({ plot, height }) => { context.globalAlpha = 1; frame.exit(); - drawCursorLines({ - frame, - cursor, - x: { - scale: xScale, - format: xSqScale.tickFormat, - }, - y: { - scale: yScale, - format: ySqScale.tickFormat, - }, - }); + // drawCursorGuideLines({ + // frame, + // cursor, + // x: { + // scale: xScale, + // format: xSqScale.tickFormat, + // }, + // y: { + // scale: yScale, + // format: ySqScale.tickFormat, + // }, + // }); }, [xDist, yDist, height, cursor, plot.xScale, plot.yScale] ); diff --git a/packages/components/vite.config.js b/packages/components/vite.config.js index 459f1dc4f7..792a702c3a 100644 --- a/packages/components/vite.config.js +++ b/packages/components/vite.config.js @@ -1,4 +1,5 @@ import { createRequire } from "node:module"; +import topLevelAwait from "vite-plugin-top-level-await"; const require = createRequire(import.meta.url); @@ -9,6 +10,14 @@ const config = { fs: require.resolve("rollup-plugin-node-builtins"), }, }, + plugins: [ + topLevelAwait({ + // The export name of top-level await promise for each chunk module + promiseExportName: "__tla", + // The function to generate import names of top-level await promise in each chunk module + promiseImportName: (i) => `__tla_${i}`, + }), + ], }; export default config; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4c271231b..61e8a2526c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,9 @@ importers: unist-util-visit-parents: specifier: ^6.0.1 version: 6.0.1 + yoga-layout: + specifier: ^3.0.4 + version: 3.0.4 zod: specifier: ^3.22.4 version: 3.22.4 @@ -274,6 +277,9 @@ importers: vite: specifier: ^5.2.10 version: 5.2.10(@types/node@20.12.7) + vite-plugin-top-level-await: + specifier: ^1.4.1 + version: 1.4.1(vite@5.2.10) packages/configs: {} @@ -5996,6 +6002,16 @@ packages: /@repeaterjs/repeater@3.0.4: resolution: {integrity: sha512-AW8PKd6iX3vAZ0vA43nOUOnbq/X5ihgU+mSXXqunMkeQADGiqw/PY0JNeYtD5sr0PAy51YPgAPbDoeapv9r8WA==} + /@rollup/plugin-virtual@3.0.2: + resolution: {integrity: sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + dev: true + /@rollup/pluginutils@5.1.0: resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==} engines: {node: '>=14.0.0'} @@ -7139,9 +7155,123 @@ packages: file-system-cache: 2.3.0 dev: true + /@swc/core-darwin-arm64@1.5.7: + resolution: {integrity: sha512-bZLVHPTpH3h6yhwVl395k0Mtx8v6CGhq5r4KQdAoPbADU974Mauz1b6ViHAJ74O0IVE5vyy7tD3OpkQxL/vMDQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@swc/core-darwin-x64@1.5.7: + resolution: {integrity: sha512-RpUyu2GsviwTc2qVajPL0l8nf2vKj5wzO3WkLSHAHEJbiUZk83NJrZd1RVbEknIMO7+Uyjh54hEh8R26jSByaw==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@swc/core-linux-arm-gnueabihf@1.5.7: + resolution: {integrity: sha512-cTZWTnCXLABOuvWiv6nQQM0hP6ZWEkzdgDvztgHI/+u/MvtzJBN5lBQ2lue/9sSFYLMqzqff5EHKlFtrJCA9dQ==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@swc/core-linux-arm64-gnu@1.5.7: + resolution: {integrity: sha512-hoeTJFBiE/IJP30Be7djWF8Q5KVgkbDtjySmvYLg9P94bHg9TJPSQoC72tXx/oXOgXvElDe/GMybru0UxhKx4g==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@swc/core-linux-arm64-musl@1.5.7: + resolution: {integrity: sha512-+NDhK+IFTiVK1/o7EXdCeF2hEzCiaRSrb9zD7X2Z7inwWlxAntcSuzZW7Y6BRqGQH89KA91qYgwbnjgTQ22PiQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@swc/core-linux-x64-gnu@1.5.7: + resolution: {integrity: sha512-25GXpJmeFxKB+7pbY7YQLhWWjkYlR+kHz5I3j9WRl3Lp4v4UD67OGXwPe+DIcHqcouA1fhLhsgHJWtsaNOMBNg==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@swc/core-linux-x64-musl@1.5.7: + resolution: {integrity: sha512-0VN9Y5EAPBESmSPPsCJzplZHV26akC0sIgd3Hc/7S/1GkSMoeuVL+V9vt+F/cCuzr4VidzSkqftdP3qEIsXSpg==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@swc/core-win32-arm64-msvc@1.5.7: + resolution: {integrity: sha512-RtoNnstBwy5VloNCvmvYNApkTmuCe4sNcoYWpmY7C1+bPR+6SOo8im1G6/FpNem8AR5fcZCmXHWQ+EUmRWJyuA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@swc/core-win32-ia32-msvc@1.5.7: + resolution: {integrity: sha512-Xm0TfvcmmspvQg1s4+USL3x8D+YPAfX2JHygvxAnCJ0EHun8cm2zvfNBcsTlnwYb0ybFWXXY129aq1wgFC9TpQ==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@swc/core-win32-x64-msvc@1.5.7: + resolution: {integrity: sha512-tp43WfJLCsKLQKBmjmY/0vv1slVywR5Q4qKjF5OIY8QijaEW7/8VwPyUyVoJZEnDgv9jKtUTG5PzqtIYPZGnyg==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@swc/core@1.5.7: + resolution: {integrity: sha512-U4qJRBefIJNJDRCCiVtkfa/hpiZ7w0R6kASea+/KLp+vkus3zcLSB8Ub8SvKgTIxjWpwsKcZlPf5nrv4ls46SQ==} + engines: {node: '>=10'} + requiresBuild: true + peerDependencies: + '@swc/helpers': ^0.5.0 + peerDependenciesMeta: + '@swc/helpers': + optional: true + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.7 + optionalDependencies: + '@swc/core-darwin-arm64': 1.5.7 + '@swc/core-darwin-x64': 1.5.7 + '@swc/core-linux-arm-gnueabihf': 1.5.7 + '@swc/core-linux-arm64-gnu': 1.5.7 + '@swc/core-linux-arm64-musl': 1.5.7 + '@swc/core-linux-x64-gnu': 1.5.7 + '@swc/core-linux-x64-musl': 1.5.7 + '@swc/core-win32-arm64-msvc': 1.5.7 + '@swc/core-win32-ia32-msvc': 1.5.7 + '@swc/core-win32-x64-msvc': 1.5.7 + dev: true + /@swc/counter@0.1.3: resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} - dev: false /@swc/helpers@0.5.5: resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} @@ -7150,6 +7280,12 @@ packages: tslib: 2.6.2 dev: false + /@swc/types@0.1.7: + resolution: {integrity: sha512-scHWahbHF0eyj3JsxG9CFJgFdFNaVQCNAimBlT6PzS3n/HptxqREjsm4OH6AN3lYcffZYSPxXW8ua2BEHp0lJQ==} + dependencies: + '@swc/counter': 0.1.3 + dev: true + /@tailwindcss/forms@0.5.7(tailwindcss@3.4.3): resolution: {integrity: sha512-QE7X69iQI+ZXwldE+rzasvbJiyV/ju1FGHH0Qn2W3FKbuYtqp8LKcy6iSw79fVUT5/Vvf+0XgLCeYVG+UV6hOw==} peerDependencies: @@ -20149,6 +20285,20 @@ packages: vfile-message: 4.0.2 dev: false + /vite-plugin-top-level-await@1.4.1(vite@5.2.10): + resolution: {integrity: sha512-hogbZ6yT7+AqBaV6lK9JRNvJDn4/IJvHLu6ET06arNfo0t2IsyCaon7el9Xa8OumH+ESuq//SDf8xscZFE0rWw==} + peerDependencies: + vite: '>=2.8' + dependencies: + '@rollup/plugin-virtual': 3.0.2 + '@swc/core': 1.5.7 + uuid: 9.0.1 + vite: 5.2.10(@types/node@20.12.7) + transitivePeerDependencies: + - '@swc/helpers' + - rollup + dev: true + /vite@5.2.10(@types/node@20.12.7): resolution: {integrity: sha512-PAzgUZbP7msvQvqdSD+ErD5qGnSFiGOoWmV5yAKUEI0kdhjbH6nMWVyZQC/hSc4aXwc0oJ9aEdIiF9Oje0JFCw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -20718,6 +20868,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + /yoga-layout@3.0.4: + resolution: {integrity: sha512-mNmdkofwKGSXUYvo4sDTNI9BXTLAXw/LsBsftEmelPtS/PeePzs4yWe2Q0Y+p5NqadPSAMhAGuIiXs1QiZ+lBg==} + dev: false + /zod@3.22.4: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} dev: false