move some files around to confuse everyone

This commit is contained in:
Joeri Exelmans 2025-10-25 23:30:08 +02:00
parent 710f7be68c
commit 30fa1aaca1
46 changed files with 45 additions and 45 deletions

View file

@ -0,0 +1,91 @@
import { memo } from "react";
import { Arrow, ArrowPart } from "../../statecharts/concrete_syntax";
import { ArcDirection, euclideanDistance } from "./geometry";
import { CORNER_HELPER_RADIUS } from "./parameters";
import { arraysEqual } from "@/util/util";
export const ArrowSVG = memo(function(props: { arrow: Arrow; selected: ArrowPart[]; error: string; highlight: boolean; fired: boolean; arc: ArcDirection; initialMarker: boolean }) {
const { start, end, uid } = props.arrow;
const radius = euclideanDistance(start, end) / 1.6;
let largeArc = "1";
let arcOrLine = props.arc === "no" ? "L" :
`A ${radius} ${radius} 0 ${largeArc} ${props.arc === "ccw" ? "0" : "1"}`;
if (props.initialMarker) {
// largeArc = "0";
arcOrLine = `A ${radius*2} ${radius*2} 0 0 1`
}
return <g>
<path
className={"arrow"
+ (props.selected.length === 2 ? " selected" : "")
+ (props.error ? " error" : "")
+ (props.highlight ? " highlight" : "")
+ (props.fired ? " fired" : "")
}
markerStart={props.initialMarker ? 'url(#initialMarker)' : undefined}
markerEnd='url(#arrowEnd)'
d={`M ${start.x} ${start.y}
${arcOrLine}
${end.x} ${end.y}`}
data-uid={uid}
data-parts="start end" />
{props.error && <text
className="error"
x={(start.x + end.x) / 2 + 5}
y={(start.y + end.y) / 2}
textAnchor="middle"
data-uid={uid}
data-parts="start end">{props.error}</text>}
<path
className="helper"
d={`M ${start.x} ${start.y}
${arcOrLine}
${end.x} ${end.y}`}
data-uid={uid}
data-parts="start end" />
{/* selection helper circles */}
<circle
className="helper"
cx={start.x}
cy={start.y}
r={CORNER_HELPER_RADIUS}
data-uid={uid}
data-parts="start" />
<circle
className="helper"
cx={end.x}
cy={end.y}
r={CORNER_HELPER_RADIUS}
data-uid={uid}
data-parts="end" />
{/* selection indicator circles */}
{props.selected.includes("start") && <circle
className="selected"
cx={start.x}
cy={start.y}
r={CORNER_HELPER_RADIUS}
data-uid={uid}
data-parts="start" />}
{props.selected.includes("end") && <circle
className="selected"
cx={end.x}
cy={end.y}
r={CORNER_HELPER_RADIUS}
data-uid={uid}
data-parts="end" />}
</g>;
}, (prevProps, nextProps) => {
return prevProps.arrow === nextProps.arrow
&& arraysEqual(prevProps.selected, nextProps.selected)
&& prevProps.highlight === nextProps.highlight
&& prevProps.error === nextProps.error
&& prevProps.fired === nextProps.fired
&& prevProps.arc === nextProps.arc
&& prevProps.initialMarker === nextProps.initialMarker
})

View file

@ -0,0 +1,49 @@
import { Diamond, RectSide } from "@/statecharts/concrete_syntax";
import { rountangleMinSize } from "./VisualEditor";
import { Vec2D } from "./geometry";
import { RectHelper } from "./RectHelpers";
import { memo } from "react";
import { arraysEqual } from "@/util/util";
export const DiamondShape = memo(function DiamondShape(props: {size: Vec2D, extraAttrs: object}) {
const minSize = rountangleMinSize(props.size);
return <polygon
points={`
${minSize.x/2} ${0},
${minSize.x} ${minSize.y/2},
${minSize.x/2} ${minSize.y},
${0} ${minSize.y/2}
`}
fill="white"
stroke="black"
strokeWidth={2}
{...props.extraAttrs}
/>;
});
export const DiamondSVG = memo(function DiamondSVG(props: { diamond: Diamond; selected: RectSide[]; highlight: RectSide[]; error?: string; active: boolean; }) {
const minSize = rountangleMinSize(props.diamond.size);
const extraAttrs = {
className: ''
+ (props.selected.length === 4 ? " selected" : "")
+ (props.error ? " error" : "")
+ (props.active ? " active" : ""),
"data-uid": props.diamond.uid,
"data-parts": "left top right bottom",
};
return <g transform={`translate(${props.diamond.topLeft.x} ${props.diamond.topLeft.y})`}>
<DiamondShape size={minSize} extraAttrs={extraAttrs}/>
<text x={minSize.x/2} y={minSize.y/2}
className="uid"
textAnchor="middle">{props.diamond.uid}</text>
<RectHelper uid={props.diamond.uid} size={minSize} highlight={props.highlight} selected={props.selected} />
</g>;
}, (prevProps, nextProps) => {
return prevProps.diamond === nextProps.diamond
&& arraysEqual(prevProps.selected, nextProps.selected)
&& arraysEqual(prevProps.highlight, nextProps.highlight)
&& prevProps.error === nextProps.error
&& prevProps.active === nextProps.active
});

View file

@ -0,0 +1,42 @@
import { memo } from "react";
import { Vec2D } from "./geometry";
import { HISTORY_RADIUS } from "./parameters";
export const HistorySVG = memo(function HistorySVG(props: {uid: string, topLeft: Vec2D, kind: "shallow"|"deep", selected: boolean, highlight: boolean}) {
const text = props.kind === "shallow" ? "H" : "H*";
return <>
<circle
cx={props.topLeft.x+HISTORY_RADIUS}
cy={props.topLeft.y+HISTORY_RADIUS}
r={HISTORY_RADIUS}
fill="white"
stroke="black"
strokeWidth={2}
data-uid={props.uid}
data-parts="history"
/>
<text
x={props.topLeft.x+HISTORY_RADIUS}
y={props.topLeft.y+HISTORY_RADIUS+5}
textAnchor="middle"
fontWeight={500}
>{text}</text>
<circle
className="helper"
cx={props.topLeft.x+HISTORY_RADIUS}
cy={props.topLeft.y+HISTORY_RADIUS}
r={HISTORY_RADIUS}
data-uid={props.uid}
data-parts="history"
/>
{(props.selected || props.highlight) &&
<circle
className={props.selected ? "selected" : props.highlight ? "highlight" : ""}
cx={props.topLeft.x+HISTORY_RADIUS}
cy={props.topLeft.y+HISTORY_RADIUS}
r={HISTORY_RADIUS}
data-uid={props.uid}
data-parts="history"
/>}
</>;
});

View file

@ -0,0 +1,58 @@
import { memo } from "react";
import { RectSide } from "../../statecharts/concrete_syntax";
import { Vec2D } from "./geometry";
import { CORNER_HELPER_OFFSET, CORNER_HELPER_RADIUS } from "./parameters";
function lineGeometryProps(size: Vec2D): [RectSide, object][] {
return [
["top", {x1: 0, y1: 0, x2: size.x, y2: 0 }],
["right", {x1: size.x, y1: 0, x2: size.x, y2: size.y}],
["bottom", {x1: 0, y1: size.y, x2: size.x, y2: size.y}],
["left", {x1: 0, y1: 0, x2: 0, y2: size.y}],
];
}
// no need to memo() this component, the parent component is already memoized
export const RectHelper = function RectHelper(props: { uid: string, size: Vec2D, selected: RectSide[], highlight: string[] }) {
const geomProps = lineGeometryProps(props.size);
return <>
{geomProps.map(([side, ps]) => <g key={side}>
{(props.selected.includes(side) || props.highlight.includes(side)) && <line className={""
+ (props.selected.includes(side) ? " selected" : "")
+ (props.highlight.includes(side) ? " highlight" : "")}
{...ps} data-uid={props.uid} data-parts={side}/>
}
<line className="helper" {...ps} data-uid={props.uid} data-parts={side}/>
</g>)}
{/* The corner-helpers have the DOM class 'corner' added to them, because we ignore them when the user is making a selection. Only if the user clicks directly on them, do we select their respective parts. */}
<circle
className="helper corner"
cx={CORNER_HELPER_OFFSET}
cy={CORNER_HELPER_OFFSET}
r={CORNER_HELPER_RADIUS}
data-uid={props.uid}
data-parts="top left" />
<circle
className="helper corner"
cx={props.size.x - CORNER_HELPER_OFFSET}
cy={CORNER_HELPER_OFFSET}
r={CORNER_HELPER_RADIUS}
data-uid={props.uid}
data-parts="top right" />
<circle
className="helper corner"
cx={props.size.x - CORNER_HELPER_OFFSET}
cy={props.size.y - CORNER_HELPER_OFFSET}
r={CORNER_HELPER_RADIUS}
data-uid={props.uid}
data-parts="bottom right" />
<circle
className="helper corner"
cx={CORNER_HELPER_OFFSET}
cy={props.size.y - CORNER_HELPER_OFFSET}
r={CORNER_HELPER_RADIUS}
data-uid={props.uid}
data-parts="bottom left" />
</>;
};

View file

@ -0,0 +1,48 @@
import { memo } from "react";
import { Rountangle, RectSide } from "../../statecharts/concrete_syntax";
import { ROUNTANGLE_RADIUS } from "./parameters";
import { RectHelper } from "./RectHelpers";
import { rountangleMinSize } from "./VisualEditor";
import { arraysEqual } from "@/util/util";
export const RountangleSVG = memo(function RountangleSVG(props: {rountangle: Rountangle; selected: RectSide[]; highlight: RectSide[]; error?: string; active: boolean; }) {
const { topLeft, size, uid } = props.rountangle;
// always draw a rountangle with a minimum size
// during resizing, rountangle can be smaller than this size and even have a negative size, but we don't show it
const minSize = rountangleMinSize(size);
const extraAttrs = {
className: 'rountangle'
+ (props.selected.length === 4 ? " selected" : "")
+ (' ' + props.rountangle.kind)
+ (props.error ? " error" : "")
+ (props.active ? " active" : ""),
"data-uid": uid,
"data-parts": "left top right bottom",
};
return <g transform={`translate(${topLeft.x} ${topLeft.y})`}>
<rect
rx={ROUNTANGLE_RADIUS} ry={ROUNTANGLE_RADIUS}
x={0}
y={0}
width={minSize.x}
height={minSize.y}
{...extraAttrs}
/>
<text x={10} y={20} className="uid">{props.rountangle.uid}</text>
{props.error &&
<text className="error" x={10} y={40} data-uid={uid} data-parts="left top right bottom">{props.error}</text>}
<RectHelper uid={uid} size={minSize}
selected={props.selected}
highlight={props.highlight} />
</g>;
}, (prevProps, nextProps) => {
return prevProps.rountangle === nextProps.rountangle
&& arraysEqual(prevProps.selected, nextProps.selected)
&& arraysEqual(prevProps.highlight, nextProps.highlight)
&& prevProps.error === nextProps.error
&& prevProps.active === nextProps.active
})

View file

@ -0,0 +1,47 @@
import { TextDialog } from "@/App/Modals/TextDialog";
import { TraceableError } from "../../statecharts/parser";
import {Text} from "../../statecharts/concrete_syntax";
import { Dispatch, memo, ReactElement, SetStateAction } from "react";
export const TextSVG = memo(function TextSVG(props: {text: Text, error: TraceableError|undefined, selected: boolean, highlight: boolean, onEdit: (text: Text, newText: string) => void, setModal: Dispatch<SetStateAction<ReactElement|null>>}) {
const commonProps = {
"data-uid": props.text.uid,
"data-parts": "text",
textAnchor: "middle" as "middle",
className: "draggableText"
+ (props.selected ? " selected":"")
+ (props.highlight ? " highlight":""),
style: {whiteSpace: "preserve"},
}
let textNode;
if (props.error?.data?.location) {
const {start,end} = props.error.data.location;
textNode = <><text {...commonProps}>
{props.text.text.slice(0, start.offset)}
<tspan className="error" data-uid={props.text.uid} data-parts="text">
{props.text.text.slice(start.offset, end.offset)}
{start.offset === end.offset && <>_</>}
</tspan>
{props.text.text.slice(end.offset)}
</text>
<text className="error errorHover" y={20} textAnchor="middle">{props.error.message}</text></>;
}
else {
textNode = <text {...commonProps}>{props.text.text}</text>;
}
return <g
key={props.text.uid}
transform={`translate(${props.text.topLeft.x} ${props.text.topLeft.y})`}
onDoubleClick={() => {
props.setModal(<TextDialog setModal={props.setModal} text={props.text.text} done={newText => {
if (newText) {
props.onEdit(props.text, newText);
}
}} />)
}}>
{textNode}
<text className="draggableText helper" textAnchor="middle" data-uid={props.text.uid} data-parts="text" style={{whiteSpace: "preserve"}}>{props.text.text}</text>
</g>;
});

View file

@ -0,0 +1,195 @@
.svgCanvas {
cursor: crosshair;
background-color: #eee;
}
.svgCanvas.dragging {
cursor: grabbing !important;
}
/* do not render helpers while dragging something */
.svgCanvas.dragging .helper:hover {
visibility: hidden !important;
}
.svgCanvas text {
user-select: none;
}
/* rectangle drawn while a selection is being made */
.selecting {
fill: blue;
fill-opacity: 0.2;
stroke-width: 1px;
stroke:black;
stroke-dasharray: 7 6;
}
.rountangle {
fill: white;
stroke: black;
stroke-width: 2px;
}
.rountangle.selected {
/* fill: rgba(0, 0, 255, 0.2); */
}
.rountangle.error {
stroke: var(--error-color);
}
.rountangle.active {
stroke: rgb(205, 133, 0);
/* stroke: none; */
fill:rgb(255, 240, 214);
/* filter: drop-shadow( 2px 2px 2px rgba(124, 37, 10, 0.729)); */
}
.selected:hover:not(:active) {
cursor: grab;
}
line.helper {
stroke: rgba(0, 0, 0, 0);
stroke-width: 16px;
}
line.helper:hover:not(:active) {
stroke: blue;
stroke-opacity: 0.2;
cursor: grab;
}
path.helper {
fill: none;
stroke: rgba(0, 0, 0, 0);
stroke-width: 16px;
}
path.helper:hover:not(:active) {
stroke: blue;
stroke-opacity: 0.2;
cursor: grab;
}
circle.helper {
fill: rgba(0, 0, 0, 0);
}
circle.helper:hover:not(:active) {
fill: blue;
fill-opacity: 0.2;
cursor: grab;
}
.rountangle.or {
stroke-dasharray: 7 6;
fill: #eee;
}
.arrow {
fill: none;
stroke: black;
stroke-width: 2px;
}
.arrow.selected {
stroke: blue;
stroke-width: 3px;
}
.arrow::marker {
fill: content-stroke;
}
#arrowEnd {
fill: context-stroke;
}
#initialMarker {
fill: context-stroke;
}
.arrow:hover {
cursor: grab;
}
line.selected, circle.selected {
fill: rgba(0, 0, 255, 0.2);
/* stroke-dasharray: 7 6; */
stroke: blue;
stroke-width: 4px;
}
.draggableText.selected, .draggableText.selected:hover {
fill: blue;
font-weight: 600;
}
.draggableText:hover:not(:active) {
/* fill: blue; */
/* cursor: grab; */
}
text.helper {
fill: rgba(0,0,0,0);
stroke: rgba(0,0,0,0);
stroke-width: 6px;
}
text.helper:hover {
stroke: blue;
stroke-opacity: 0.2;
cursor: grab;
}
.draggableText, .draggableText.highlight {
paint-order: stroke;
stroke: white;
stroke-width: 4px;
stroke-linecap: butt;
stroke-linejoin: miter;
stroke-opacity: 1;
fill-opacity:1;
}
.draggableText.highlight:not(.selected) {
fill: green;
font-weight: 600;
}
.highlight:not(.selected):not(text) {
stroke: green;
stroke-width: 3px;
fill: none;
}
.arrow.error {
stroke: var(--error-color);
}
.arrow.fired {
stroke: rgb(160 0 168);
stroke-width: 3px;
animation: blinkTransition 1s;
}
@keyframes blinkTransition {
0% {
stroke: rgb(255, 128, 9);
stroke-width: 6px;
filter: drop-shadow(0 0 5px rgba(255, 128, 9, 1));
}
100% {
stroke: rgb(160 0 168);
}
}
text.error, tspan.error {
fill: var(--error-color);
font-weight: 600;
}
.errorHover {
display: none;
}
g:hover > .errorHover {
display: inline;
}
text.uid {
fill: lightgrey;
}

View file

@ -0,0 +1,776 @@
import { ClipboardEvent, Dispatch, memo, ReactElement, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Statechart } from "../../statecharts/abstract_syntax";
import { Arrow, ArrowPart, Diamond, History, Rountangle, RectSide, Text } from "../../statecharts/concrete_syntax";
import { parseStatechart, TraceableError } from "../../statecharts/parser";
import { ArcDirection, Line2D, Rect2D, Vec2D, addV2D, arcDirection, area, getBottomSide, getLeftSide, getRightSide, getTopSide, isEntirelyWithin, normalizeRect, scaleV2D, subtractV2D, transformLine, transformRect } from "./geometry";
import { MIN_ROUNTANGLE_SIZE } from "./parameters";
import { getBBoxInSvgCoords } from "./svg_helper";
import { ArrowSVG } from "./ArrowSVG";
import { RountangleSVG } from "./RountangleSVG";
import { TextSVG } from "./TextSVG";
import { DiamondSVG } from "./DiamondSVG";
import { HistorySVG } from "./HistorySVG";
import { Connections, detectConnections } from "../../statecharts/detect_connections";
import "./VisualEditor.css";
import { TraceState } from "@/App/App";
import { Mode } from "@/statecharts/runtime_types";
import { arraysEqual, objectsEqual, setsEqual } from "@/util/util";
export type VisualEditorState = {
rountangles: Rountangle[];
texts: Text[];
arrows: Arrow[];
diamonds: Diamond[];
history: History[];
nextID: number;
selection: Selection;
};
type SelectingState = Rect2D | null;
export type RountangleSelectable = {
// kind: "rountangle";
parts: RectSide[];
uid: string;
}
type ArrowSelectable = {
// kind: "arrow";
parts: ArrowPart[];
uid: string;
}
type TextSelectable = {
parts: ["text"];
uid: string;
}
type HistorySelectable = {
parts: ["history"];
uid: string;
}
type Selectable = RountangleSelectable | ArrowSelectable | TextSelectable | HistorySelectable;
type Selection = Selectable[];
export const sides: [RectSide, (r:Rect2D)=>Line2D][] = [
["left", getLeftSide],
["top", getTopSide],
["right", getRightSide],
["bottom", getBottomSide],
];
export type InsertMode = "and"|"or"|"pseudo"|"shallow"|"deep"|"transition"|"text";
type VisualEditorProps = {
state: VisualEditorState,
setState: Dispatch<(v:VisualEditorState) => VisualEditorState>,
conns: Connections,
syntaxErrors: TraceableError[],
trace: TraceState | null,
insertMode: InsertMode,
highlightActive: Set<string>,
highlightTransitions: string[],
setModal: Dispatch<SetStateAction<ReactElement|null>>,
makeCheckPoint: () => void;
zoom: number;
};
export const VisualEditor = memo(function VisualEditor({state, setState, trace, conns, syntaxErrors: errors, insertMode, highlightActive, highlightTransitions, setModal, makeCheckPoint, zoom}: VisualEditorProps) {
const [dragging, setDragging] = useState(false);
// uid's of selected rountangles
const selection = state.selection || [];
const setSelection = useCallback((cb: (oldSelection: Selection) => Selection) =>
setState(oldState => ({...oldState, selection: cb(oldState.selection)})),[setState]);
// not null while the user is making a selection
const [selectingState, setSelectingState] = useState<SelectingState>(null);
const refSVG = useRef<SVGSVGElement>(null);
useEffect(() => {
// bit of a hacky way to force the animation on fired transitions to replay, if the new 'rt' contains the same fired transitions as the previous one
requestAnimationFrame(() => {
document.querySelectorAll(".arrow.fired").forEach(el => {
// @ts-ignore
el.style.animation = 'none';
requestAnimationFrame(() => {
// @ts-ignore
el.style.animation = '';
})
});
})
}, [trace && trace.idx]);
const getCurrentPointer = useCallback((e: {pageX: number, pageY: number}) => {
const bbox = refSVG.current!.getBoundingClientRect();
return {
x: (e.pageX - bbox.left)/zoom,
y: (e.pageY - bbox.top)/zoom,
}
}, [refSVG.current, zoom]);
const onMouseDown = useCallback((e: {button: number, target: any, pageX: number, pageY: number}) => {
const currentPointer = getCurrentPointer(e);
if (e.button === 2) {
makeCheckPoint();
// ignore selection, middle mouse button always inserts
setState(state => {
const newID = state.nextID.toString();
if (insertMode === "and" || insertMode === "or") {
// insert rountangle
return {
...state,
rountangles: [...state.rountangles, {
uid: newID,
topLeft: currentPointer,
size: MIN_ROUNTANGLE_SIZE,
kind: insertMode,
}],
nextID: state.nextID+1,
selection: [{uid: newID, parts: ["bottom", "right"]}],
};
}
else if (insertMode === "pseudo") {
return {
...state,
diamonds: [...state.diamonds, {
uid: newID,
topLeft: currentPointer,
size: MIN_ROUNTANGLE_SIZE,
}],
nextID: state.nextID+1,
selection: [{uid: newID, parts: ["bottom", "right"]}],
};
}
else if (insertMode === "shallow" || insertMode === "deep") {
return {
...state,
history: [...state.history, {
uid: newID,
kind: insertMode,
topLeft: currentPointer,
}],
nextID: state.nextID+1,
selection: [{uid: newID, parts: ["history"]}],
}
}
else if (insertMode === "transition") {
return {
...state,
arrows: [...state.arrows, {
uid: newID,
start: currentPointer,
end: currentPointer,
}],
nextID: state.nextID+1,
selection: [{uid: newID, parts: ["end"]}],
}
}
else if (insertMode === "text") {
return {
...state,
texts: [...state.texts, {
uid: newID,
text: "// Double-click to edit",
topLeft: currentPointer,
}],
nextID: state.nextID+1,
selection: [{uid: newID, parts: ["text"]}],
}
}
throw new Error("unreachable, mode=" + insertMode); // shut up typescript
});
setDragging(true);
return;
}
if (e.button === 0) {
// left mouse button on a shape will drag that shape (and everything else that's selected). if the shape under the pointer was not in the selection then the selection is reset to contain only that shape.
const uid = e.target?.dataset.uid;
const parts: string[] = e.target?.dataset.parts?.split(' ').filter((p:string) => p!=="") || [];
if (uid && parts.length > 0) {
makeCheckPoint();
// if the mouse button is pressed outside of the current selection, we reset the selection to whatever shape the mouse is on
let allPartsInSelection = true;
for (const part of parts) {
if (!(selection.find(s => s.uid === uid)?.parts || [] as string[]).includes(part)) {
allPartsInSelection = false;
break;
}
}
if (!allPartsInSelection) {
if (e.target.classList.contains("helper")) {
setSelection(() => [{uid, parts}] as Selection);
}
else {
setDragging(false);
setSelectingState({
topLeft: currentPointer,
size: {x: 0, y: 0},
});
setSelection(() => []);
return;
}
}
// start dragging
setDragging(true);
return;
}
}
// otherwise, just start making a selection
setDragging(false);
setSelectingState({
topLeft: currentPointer,
size: {x: 0, y: 0},
});
setSelection(() => []);
}, [getCurrentPointer, makeCheckPoint, insertMode, selection]);
const onMouseMove = useCallback((e: {pageX: number, pageY: number, movementX: number, movementY: number}) => {
const currentPointer = getCurrentPointer(e);
if (dragging) {
// const pointerDelta = subtractV2D(currentPointer, dragging.lastMousePos);
const pointerDelta = {x: e.movementX/zoom, y: e.movementY/zoom};
setState(state => ({
...state,
rountangles: state.rountangles.map(r => {
const parts = state.selection.find(selected => selected.uid === r.uid)?.parts || [];
if (parts.length === 0) {
return r;
}
return {
...r,
...transformRect(r, parts, pointerDelta),
};
})
.toSorted((a,b) => area(b) - area(a)), // sort: smaller rountangles are drawn on top
diamonds: state.diamonds.map(d => {
const parts = state.selection.find(selected => selected.uid === d.uid)?.parts || [];
if (parts.length === 0) {
return d;
}
return {
...d,
...transformRect(d, parts, pointerDelta),
}
}),
history: state.history.map(h => {
const parts = state.selection.find(selected => selected.uid === h.uid)?.parts || [];
if (parts.length === 0) {
return h;
}
return {
...h,
topLeft: addV2D(h.topLeft, pointerDelta),
}
}),
arrows: state.arrows.map(a => {
const parts = state.selection.find(selected => selected.uid === a.uid)?.parts || [];
if (parts.length === 0) {
return a;
}
return {
...a,
...transformLine(a, parts, pointerDelta),
}
}),
texts: state.texts.map(t => {
const parts = state.selection.find(selected => selected.uid === t.uid)?.parts || [];
if (parts.length === 0) {
return t;
}
return {
...t,
topLeft: addV2D(t.topLeft, pointerDelta),
}
}),
}));
setDragging(true);
}
else if (selectingState) {
setSelectingState(ss => {
const selectionSize = subtractV2D(currentPointer, ss!.topLeft);
return {
...ss!,
size: selectionSize,
};
});
}
}, [getCurrentPointer, selectingState, dragging]);
const onMouseUp = useCallback((e: {target: any, pageX: number, pageY: number}) => {
if (dragging) {
setDragging(false);
// do not persist sizes smaller than 40x40
setState(state => {
return {
...state,
rountangles: state.rountangles.map(r => ({
...r,
size: rountangleMinSize(r.size),
})),
diamonds: state.diamonds.map(d => ({
...d,
size: rountangleMinSize(d.size),
}))
};
});
}
if (selectingState) {
if (selectingState.size.x === 0 && selectingState.size.y === 0) {
const uid = e.target?.dataset.uid;
if (uid) {
const parts = e.target?.dataset.parts.split(' ').filter((p: string) => p!=="");
if (uid) {
setSelection(() => [{
uid,
parts,
}]);
}
}
}
else {
// we were making a selection
const normalizedSS = normalizeRect(selectingState);
const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle, text") || []) as SVGGraphicsElement[];
const shapesInSelection = shapes.filter(el => {
const bbox = getBBoxInSvgCoords(el, refSVG.current!);
const scaledBBox = {
topLeft: scaleV2D(bbox.topLeft, 1/zoom),
size: scaleV2D(bbox.size, 1/zoom),
}
return isEntirelyWithin(scaledBBox, normalizedSS);
}).filter(el => !el.classList.contains("corner"));
const uidToParts = new Map();
for (const shape of shapesInSelection) {
const uid = shape.dataset.uid;
if (uid) {
const parts: Set<string> = uidToParts.get(uid) || new Set();
for (const part of shape.dataset.parts?.split(' ') || []) {
parts.add(part);
}
uidToParts.set(uid, parts);
}
}
setSelection(() => [...uidToParts.entries()].map(([uid,parts]) => ({
uid,
parts: [...parts],
})));
}
}
setSelectingState(null); // no longer making a selection
}, [dragging, selectingState, refSVG.current]);
const deleteSelection = useCallback(() => {
setState(state => ({
...state,
rountangles: state.rountangles.filter(r => !state.selection.some(rs => rs.uid === r.uid)),
diamonds: state.diamonds.filter(d => !state.selection.some(ds => ds.uid === d.uid)),
history: state.history.filter(h => !state.selection.some(hs => hs.uid === h.uid)),
arrows: state.arrows.filter(a => !state.selection.some(as => as.uid === a.uid)),
texts: state.texts.filter(t => !state.selection.some(ts => ts.uid === t.uid)),
selection: [],
}));
}, [setState]);
const onKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Delete") {
// delete selection
makeCheckPoint();
deleteSelection();
}
if (e.key === "o") {
// selected states become OR-states
setState(state => ({
...state,
rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "or"}) : r),
}));
}
if (e.key === "a") {
// selected states become AND-states
setState(state => ({
...state,
rountangles: state.rountangles.map(r => state.selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "and"}) : r),
}));
}
// if (e.key === "p") {
// // selected states become pseudo-states
// setSelection(selection => {
// setState(state => ({
// ...state,
// rountangles: state.rountangles.map(r => selection.some(rs => rs.uid === r.uid) ? ({...r, kind: "pseudo"}) : r),
// }));
// return selection;
// });
// }
if (e.ctrlKey) {
if (e.key === "a") {
e.preventDefault();
setDragging(false);
setState(state => ({
...state,
// @ts-ignore
selection: [
...state.rountangles.map(r => ({uid: r.uid, parts: ["left", "top", "right", "bottom"]})),
...state.diamonds.map(d => ({uid: d.uid, parts: ["left", "top", "right", "bottom"]})),
...state.arrows.map(a => ({uid: a.uid, parts: ["start", "end"]})),
...state.texts.map(t => ({uid: t.uid, parts: ["text"]})),
]
}))
}
}
}, [makeCheckPoint, deleteSelection, setState, setDragging]);
useEffect(() => {
// mousemove and mouseup are global event handlers so they keep working when pointer is outside of browser window
window.addEventListener("mouseup", onMouseUp);
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
}, [selectingState, dragging]);
// for visual feedback, when selecting/moving one thing, we also highlight (in green) all the things that belong to the thing we selected.
const sidesToHighlight: {[key: string]: RectSide[]} = {};
const arrowsToHighlight: {[key: string]: boolean} = {};
const textsToHighlight: {[key: string]: boolean} = {};
const rountanglesToHighlight: {[key: string]: boolean} = {};
const historyToHighlight: {[key: string]: boolean} = {};
for (const selected of selection) {
const sides = conns.arrow2SideMap.get(selected.uid);
if (sides) {
const [startSide, endSide] = sides;
if (startSide) sidesToHighlight[startSide.uid] = [...sidesToHighlight[startSide.uid]||[], startSide.part];
if (endSide) sidesToHighlight[endSide.uid] = [...sidesToHighlight[endSide.uid]||[], endSide.part];
}
const texts = [
...(conns.arrow2TextMap.get(selected.uid) || []),
...(conns.rountangle2TextMap.get(selected.uid) || []),
];
for (const textUid of texts) {
textsToHighlight[textUid] = true;
}
for (const part of selected.parts) {
const arrows = conns.side2ArrowMap.get(selected.uid + '/' + part) || [];
if (arrows) {
for (const [arrowPart, arrowUid] of arrows) {
arrowsToHighlight[arrowUid] = true;
}
}
}
const arrow2 = conns.text2ArrowMap.get(selected.uid);
if (arrow2) {
arrowsToHighlight[arrow2] = true;
}
const rountangleUid = conns.text2RountangleMap.get(selected.uid)
if (rountangleUid) {
rountanglesToHighlight[rountangleUid] = true;
}
const history = conns.arrow2HistoryMap.get(selected.uid);
if (history) {
historyToHighlight[history] = true;
}
const arrow3 = conns.history2ArrowMap.get(selected.uid) || [];
for (const arrow of arrow3) {
arrowsToHighlight[arrow] = true;
}
}
const onPaste = useCallback((e: ClipboardEvent) => {
const data = e.clipboardData?.getData("text/plain");
if (data) {
let parsed;
try {
parsed = JSON.parse(data);
}
catch (e) {
return;
}
// const offset = {x: 40, y: 40};
const offset = {x: 0, y: 0};
setState(state => {
let nextID = state.nextID;
const copiedRountangles: Rountangle[] = parsed.rountangles.map((r: Rountangle) => ({
...r,
uid: (nextID++).toString(),
topLeft: addV2D(r.topLeft, offset),
} as Rountangle));
const copiedDiamonds: Diamond[] = parsed.diamonds.map((r: Diamond) => ({
...r,
uid: (nextID++).toString(),
topLeft: addV2D(r.topLeft, offset),
} as Diamond));
const copiedArrows: Arrow[] = parsed.arrows.map((a: Arrow) => ({
...a,
uid: (nextID++).toString(),
start: addV2D(a.start, offset),
end: addV2D(a.end, offset),
} as Arrow));
const copiedTexts: Text[] = parsed.texts.map((t: Text) => ({
...t,
uid: (nextID++).toString(),
topLeft: addV2D(t.topLeft, offset),
} as Text));
const copiedHistories: History[] = parsed.history.map((h: History) => ({
...h,
uid: (nextID++).toString(),
topLeft: addV2D(h.topLeft, offset),
}))
// @ts-ignore
const newSelection: Selection = [
...copiedRountangles.map(r => ({uid: r.uid, parts: ["left", "top", "right", "bottom"]})),
...copiedDiamonds.map(d => ({uid: d.uid, parts: ["left", "top", "right", "bottom"]})),
...copiedArrows.map(a => ({uid: a.uid, parts: ["start", "end"]})),
...copiedTexts.map(t => ({uid: t.uid, parts: ["text"]})),
...copiedHistories.map(h => ({uid: h.uid, parts: ["history"]})),
];
return {
...state,
rountangles: [...state.rountangles, ...copiedRountangles],
diamonds: [...state.diamonds, ...copiedDiamonds],
arrows: [...state.arrows, ...copiedArrows],
texts: [...state.texts, ...copiedTexts],
history: [...state.history, ...copiedHistories],
nextID: nextID,
selection: newSelection,
};
});
// copyInternal(newSelection, e); // doesn't work
e.preventDefault();
}
}, [setState]);
const copyInternal = useCallback((state: VisualEditorState, selection: Selection, e: ClipboardEvent) => {
const uidsToCopy = new Set(selection.map(shape => shape.uid));
const rountanglesToCopy = state.rountangles.filter(r => uidsToCopy.has(r.uid));
const diamondsToCopy = state.diamonds.filter(d => uidsToCopy.has(d.uid));
const historiesToCopy = state.history.filter(h => uidsToCopy.has(h.uid));
const arrowsToCopy = state.arrows.filter(a => uidsToCopy.has(a.uid));
const textsToCopy = state.texts.filter(t => uidsToCopy.has(t.uid));
e.clipboardData?.setData("text/plain", JSON.stringify({
rountangles: rountanglesToCopy,
diamonds: diamondsToCopy,
history: historiesToCopy,
arrows: arrowsToCopy,
texts: textsToCopy,
}));
}, []);
const onCopy = useCallback((e: ClipboardEvent) => {
if (selection.length > 0) {
e.preventDefault();
copyInternal(state, selection, e);
}
}, [state, selection]);
const onCut = useCallback((e: ClipboardEvent) => {
if (selection.length > 0) {
copyInternal(state, selection, e);
deleteSelection();
e.preventDefault();
}
}, [state, selection]);
const onEditText = useCallback((text: Text, newText: string) => {
if (newText === "") {
// delete text node
setState(state => ({
...state,
texts: state.texts.filter(t => t.uid !== text.uid),
}));
}
else {
setState(state => ({
...state,
texts: state.texts.map(t => {
if (t.uid === text.uid) {
return {
...text,
text: newText,
}
}
else {
return t;
}
}),
}));
}
}, [setState]);
// @ts-ignore
const active = trace && trace.trace[trace.idx].mode || new Set();
const rootErrors = errors.filter(({shapeUid}) => shapeUid === "root").map(({message}) => message);
const size = 4000*zoom;
return <svg width={size} height={size}
className={"svgCanvas"+(active.has("root")?" active":"")+(dragging ? " dragging" : "")}
onMouseDown={onMouseDown}
onContextMenu={e => e.preventDefault()}
ref={refSVG}
viewBox={`0 0 4000 4000`}
onCopy={onCopy}
onPaste={onPaste}
onCut={onCut}
>
<defs>
<marker
id="initialMarker"
viewBox="0 0 9 9"
refX="4.5"
refY="4.5"
markerWidth="9"
markerHeight="9"
markerUnits="userSpaceOnUse">
<circle cx={4.5} cy={4.5} r={4.5}/>
</marker>
<marker
id="arrowEnd"
viewBox="0 0 10 10"
refX="5"
refY="5"
markerWidth="12"
markerHeight="12"
orient="auto-start-reverse"
markerUnits="userSpaceOnUse">
<path d="M 0 0 L 10 5 L 0 10 z"/>
</marker>
</defs>
{(rootErrors.length>0) && <text className="error" x={5} y={20}>{rootErrors.join(' ')}</text>}
<Rountangles rountangles={state.rountangles} {...{selection, sidesToHighlight, rountanglesToHighlight, errors, highlightActive}}/>
<Diamonds diamonds={state.diamonds} {...{selection, sidesToHighlight, rountanglesToHighlight, errors}}/>
{state.history.map(history => <>
<HistorySVG
key={history.uid}
selected={Boolean(selection.find(h => h.uid === history.uid))}
highlight={Boolean(historyToHighlight[history.uid])}
{...history}
/>
</>)}
{state.arrows.map(arrow => {
const sides = conns.arrow2SideMap.get(arrow.uid);
let arc = "no" as ArcDirection;
if (sides && sides[0]?.uid === sides[1]?.uid && sides[0]!.uid !== undefined) {
arc = arcDirection(sides[0]!.part, sides[1]!.part);
}
const initialMarker = sides && sides[0] === undefined && sides[1] !== undefined;
return <ArrowSVG
key={arrow.uid}
arrow={arrow}
selected={selection.find(a => a.uid === arrow.uid)?.parts as ArrowPart[] || []}
error={errors
.filter(({shapeUid}) => shapeUid === arrow.uid)
.map(({message}) => message).join(', ')}
highlight={arrowsToHighlight.hasOwnProperty(arrow.uid)}
fired={highlightTransitions.includes(arrow.uid)}
arc={arc}
initialMarker={Boolean(initialMarker)}
/>;
}
)}
<Texts texts={state.texts} {...{selection, textsToHighlight, errors, onEditText, setModal}}/>
{selectingState && <Selecting {...selectingState} />}
</svg>;
});
export function rountangleMinSize(size: Vec2D): Vec2D {
if (size.x >= 40 && size.y >= 40) {
return size;
}
return {
x: Math.max(40, size.x),
y: Math.max(40, size.y),
};
}
const Rountangles = memo(function Rountangles({rountangles, selection, sidesToHighlight, rountanglesToHighlight, errors, highlightActive}: {rountangles: Rountangle[], selection: Selection, sidesToHighlight: {[key: string]: RectSide[]}, rountanglesToHighlight: {[key: string]: boolean}, errors: TraceableError[], highlightActive: Mode}) {
return <>{rountangles.map(rountangle => {
return <RountangleSVG
key={rountangle.uid}
rountangle={rountangle}
selected={selection.find(r => r.uid === rountangle.uid)?.parts as RectSide[] || []}
highlight={[...(sidesToHighlight[rountangle.uid] || []), ...(rountanglesToHighlight[rountangle.uid]?["left","right","top","bottom"]:[]) as RectSide[]]}
error={errors
.filter(({shapeUid}) => shapeUid === rountangle.uid)
.map(({message}) => message).join(', ')}
active={highlightActive.has(rountangle.uid)}
/>})}</>;
}, (p, n) => {
return arraysEqual(p.rountangles, n.rountangles)
&& arraysEqual(p.selection, n.selection)
&& objectsEqual(p.sidesToHighlight, n.sidesToHighlight)
&& objectsEqual(p.rountanglesToHighlight, n.rountanglesToHighlight)
&& arraysEqual(p.errors, n.errors)
&& setsEqual(p.highlightActive, n.highlightActive);
});
const Diamonds = memo(function Diamonds({diamonds, selection, sidesToHighlight, rountanglesToHighlight, errors}: {diamonds: Diamond[], selection: Selection, sidesToHighlight: {[key: string]: RectSide[]}, rountanglesToHighlight: {[key: string]: boolean}, errors: TraceableError[]}) {
return <>{diamonds.map(diamond => <>
<DiamondSVG
key={diamond.uid}
diamond={diamond}
selected={selection.find(r => r.uid === diamond.uid)?.parts as RectSide[] || []}
highlight={[...(sidesToHighlight[diamond.uid] || []), ...(rountanglesToHighlight[diamond.uid]?["left","right","top","bottom"]:[]) as RectSide[]]}
error={errors
.filter(({shapeUid}) => shapeUid === diamond.uid)
.map(({message}) => message).join(', ')}
active={false}/>
</>)}</>;
}, (p, n) => {
return arraysEqual(p.diamonds, n.diamonds)
&& arraysEqual(p.selection, n.selection)
&& objectsEqual(p.sidesToHighlight, n.sidesToHighlight)
&& objectsEqual(p.rountanglesToHighlight, n.rountanglesToHighlight)
&& arraysEqual(p.errors, n.errors);
});
const Texts = memo(function Texts({texts, selection, textsToHighlight, errors, onEditText, setModal}: {texts: Text[], selection: Selection, textsToHighlight: {[key: string]: boolean}, errors: TraceableError[], onEditText: (text: Text, newText: string) => void, setModal: Dispatch<SetStateAction<ReactElement|null>>}) {
return <>{texts.map(txt => {
return <TextSVG
key={txt.uid}
error={errors.find(({shapeUid}) => txt.uid === shapeUid)}
text={txt}
selected={Boolean(selection.find(s => s.uid === txt.uid)?.parts?.length)}
highlight={textsToHighlight.hasOwnProperty(txt.uid)}
onEdit={onEditText}
setModal={setModal}
/>
})}</>;
}, (p, n) => {
return arraysEqual(p.texts, n.texts)
&& arraysEqual(p.selection, n.selection)
&& objectsEqual(p.textsToHighlight, n.textsToHighlight)
&& arraysEqual(p.errors, n.errors)
&& p.onEditText === n.onEditText
&& p.setModal === n.setModal;
});
export function Selecting(props: SelectingState) {
const normalizedRect = normalizeRect(props!);
return <rect
className="selecting"
x={normalizedRect.topLeft.x}
y={normalizedRect.topLeft.y}
width={normalizedRect.size.x}
height={normalizedRect.size.y}
/>;
}

View file

View file

@ -0,0 +1,198 @@
import { RectSide } from "../../statecharts/concrete_syntax";
export type Vec2D = {
x: number;
y: number;
};
export type Rect2D = {
topLeft: Vec2D;
size: Vec2D;
};
export type Line2D = {
start: Vec2D;
end: Vec2D;
};
// make sure size is always positive
export function normalizeRect(rect: Rect2D) {
return {
topLeft: {
x: rect.size.x < 0 ? (rect.topLeft.x + rect.size.x) : rect.topLeft.x,
y: rect.size.y < 0 ? (rect.topLeft.y + rect.size.y) : rect.topLeft.y,
},
size: {
x: rect.size.x < 0 ? -rect.size.x : rect.size.x,
y: rect.size.y < 0 ? -rect.size.y : rect.size.y,
}
};
}
export function isEntirelyWithin(child: Rect2D, parent: Rect2D) {
return (
child.topLeft.x >= parent.topLeft.x
&& child.topLeft.y >= parent.topLeft.y
&& child.topLeft.x + child.size.x <= parent.topLeft.x + parent.size.x
&& child.topLeft.y + child.size.y <= parent.topLeft.y + parent.size.y
);
}
export function isWithin(p: Vec2D, r: Rect2D) {
return (
p.x >= r.topLeft.x && p.x <= r.topLeft.x + r.size.x
&& p.y >= r.topLeft.y && p.y <= r.topLeft.y + r.size.y
);
}
export function addV2D(a: Vec2D, b: Vec2D) {
return {
x: a.x + b.x,
y: a.y + b.y,
};
}
export function subtractV2D(a: Vec2D, b: Vec2D) {
return {
x: a.x - b.x,
y: a.y - b.y,
};
}
export function scaleV2D(p: Vec2D, scale: number) {
return {
x: p.x * scale,
y: p.y * scale,
};
}
export function area(rect: Rect2D) {
return rect.size.x * rect.size.y;
}
export function lineBBox(line: Line2D, margin=0): Rect2D {
return {
topLeft: {
x: line.start.x - margin,
y: line.start.y - margin,
},
size: {
x: line.end.x - line.start.x + margin*2,
y: line.end.y - line.start.y + margin*2,
},
}
}
export function transformRect(rect: Rect2D, parts: string[], delta: Vec2D): Rect2D {
return {
topLeft: {
x: parts.includes("left") ? rect.topLeft.x + delta.x : rect.topLeft.x,
y: parts.includes("top") ? rect.topLeft.y + delta.y : rect.topLeft.y,
},
size: {
x: /*Math.max(40,*/ rect.size.x
+ (parts.includes("right") ? delta.x : 0)
- (parts.includes("left") ? delta.x : 0),
y: /*Math.max(40,*/ rect.size.y
+ (parts.includes("bottom") ? delta.y : 0)
- (parts.includes("top") ? delta.y : 0),
},
};
}
export function transformLine(line: Line2D, parts: string[], delta: Vec2D): Line2D {
return {
start: parts.includes("start") ? addV2D(line.start, {x: delta.x, y: delta.y}) : line.start,
end: parts.includes("end") ? addV2D(line.end, {x: delta.x, y: delta.y}) : line.end,
};
}
// intersection point of two lines
// note: point may not be part of the lines
// author: ChatGPT
export function intersectLines(a: Line2D, b: Line2D): Vec2D | null {
const { start: A1, end: A2 } = a;
const { start: B1, end: B2 } = b;
const den =
(A1.x - A2.x) * (B1.y - B2.y) - (A1.y - A2.y) * (B1.x - B2.x);
if (den === 0) return null; // parallel or coincident
const x =
((A1.x * A2.y - A1.y * A2.x) * (B1.x - B2.x) -
(A1.x - A2.x) * (B1.x * B2.y - B1.y * B2.x)) /
den;
const y =
((A1.x * A2.y - A1.y * A2.x) * (B1.y - B2.y) -
(A1.y - A2.y) * (B1.x * B2.y - B1.y * B2.x)) /
den;
return { x, y };
}
export function euclideanDistance(a: Vec2D, b: Vec2D): number {
const diffX = a.x - b.x;
const diffY = a.y - b.y;
return Math.hypot(diffX, diffY);
// return Math.sqrt(diffX*diffX + diffY*diffY);
}
export function getLeftSide(rect: Rect2D): Line2D {
return {
start: rect.topLeft,
end: {x: rect.topLeft.x, y: rect.topLeft.y + rect.size.y},
};
}
export function getTopSide(rect: Rect2D): Line2D {
return {
start: rect.topLeft,
end: { x: rect.topLeft.x + rect.size.x, y: rect.topLeft.y },
};
}
export function getRightSide(rect: Rect2D): Line2D {
return {
start: { x: rect.topLeft.x + rect.size.x, y: rect.topLeft.y },
end: { x: rect.topLeft.x + rect.size.x, y: rect.topLeft.y + rect.size.y },
};
}
export function getBottomSide(rect: Rect2D): Line2D {
return {
start: { x: rect.topLeft.x, y: rect.topLeft.y + rect.size.y },
end: { x: rect.topLeft.x + rect.size.x, y: rect.topLeft.y + rect.size.y },
};
}
export type ArcDirection = "no" | "cw" | "ccw";
export function arcDirection(start: RectSide, end: RectSide): ArcDirection {
if (start === end) {
if (start === "left" || start === "top") {
return "ccw";
}
else {
return "cw";
}
}
const both = [start, end];
if (both.includes("top") && both.includes("bottom")) {
return "no";
}
if (both.includes("left") && both.includes("right")) {
return "no";
}
if (start === "top" && end === "left") {
return "ccw";
}
if (start === "left" && end === "bottom") {
return "ccw";
}
if (start === "bottom" && end === "right") {
return "ccw";
}
if (start === "right" && end === "top") {
return "ccw";
}
return "cw";
}

View file

@ -0,0 +1,17 @@
export const ARROW_SNAP_THRESHOLD = 20;
export const TEXT_SNAP_THRESHOLD = 40;
export const ROUNTANGLE_RADIUS = 20;
export const MIN_ROUNTANGLE_SIZE = { x: ROUNTANGLE_RADIUS*2, y: ROUNTANGLE_RADIUS*2 };
// those hoverable green transparent circles in the corners of rountangles:
export const CORNER_HELPER_OFFSET = 4;
export const CORNER_HELPER_RADIUS = 16;
export const HISTORY_RADIUS = 20;
export const ZOOM_STEP = 1.25;
export const ZOOM_MIN = (1/ZOOM_STEP)**6;
export const ZOOM_MAX = ZOOM_STEP**6;

View file

@ -0,0 +1,30 @@
import { Rect2D } from "./geometry";
// author: ChatGPT
export function getBBoxInSvgCoords(el: SVGGraphicsElement, svg: SVGSVGElement): Rect2D {
const b = el.getBBox();
const m = el.getCTM()!;
const toSvg = (x: number, y: number) => {
const p = svg.createSVGPoint();
p.x = x; p.y = y;
return p.matrixTransform(m);
};
const pts = [
toSvg(b.x, b.y),
toSvg(b.x + b.width, b.y),
toSvg(b.x, b.y + b.height),
toSvg(b.x + b.width, b.y + b.height)
];
const xs = pts.map(p => p.x);
const ys = pts.map(p => p.y);
return {
topLeft: {
x: Math.min(...xs),
y: Math.min(...ys),
},
size: {
x: Math.max(...xs) - Math.min(...xs),
y: Math.max(...ys) - Math.min(...ys),
},
};
}