dirty but the initial concept is working

This commit is contained in:
Joeri Exelmans 2025-10-04 23:13:55 +02:00
commit a72653fcce
18 changed files with 1014 additions and 0 deletions

34
.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

21
README.md Normal file
View file

@ -0,0 +1,21 @@
# bun-react-template
To install dependencies:
```bash
bun install
```
To start a development server:
```bash
bun dev
```
To run for production:
```bash
bun start
```
This project was created using `bun init` in bun v1.2.14. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

17
bun-env.d.ts vendored Normal file
View file

@ -0,0 +1,17 @@
// Generated by `bun init`
declare module "*.svg" {
/**
* A path to the SVG file
*/
const path: `${string}.svg`;
export = path;
}
declare module "*.module.css" {
/**
* A record of class names to their corresponding CSS module classes
*/
const classes: { readonly [key: string]: string };
export = classes;
}

38
bun.lock Normal file
View file

@ -0,0 +1,38 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "bun-react-template",
"dependencies": {
"react": "^19",
"react-dom": "^19",
},
"devDependencies": {
"@types/bun": "latest",
"@types/react": "^19",
"@types/react-dom": "^19",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="],
"@types/node": ["@types/node@24.6.2", "", { "dependencies": { "undici-types": "~7.13.0" } }, "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang=="],
"@types/react": ["@types/react@19.2.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA=="],
"@types/react-dom": ["@types/react-dom@19.2.0", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg=="],
"bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
"react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"undici-types": ["undici-types@7.13.0", "", {}, "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ=="],
}
}

2
bunfig.toml Normal file
View file

@ -0,0 +1,2 @@
[serve.static]
env = "BUN_PUBLIC_*"

22
package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "bun-react-template",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.tsx",
"module": "src/index.tsx",
"scripts": {
"dev": "bun --hot src/index.tsx",
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'",
"start": "NODE_ENV=production bun src/index.tsx"
},
"dependencies": {
"react": "^19",
"react-dom": "^19"
},
"devDependencies": {
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/bun": "latest"
}
}

11
src/App.tsx Normal file
View file

@ -0,0 +1,11 @@
import "./index.css";
import { VisualEditor } from "./VisualEditor/VisualEditor";
export function App() {
return (
<VisualEditor/>
);
}
export default App;

View file

@ -0,0 +1,89 @@
.svgCanvas {
cursor: crosshair;
}
svg > text {
user-select: none;
}
.selecting {
fill: rgba(0, 0, 255, 0.2);
stroke-width: 1px;
stroke:black;
stroke-dasharray: 7 6;
}
.rountangle {
fill: rgba(255, 255, 255, 255);
/* fill: none; */
stroke: black;
stroke-width: 2px;
}
.rountangle:hover {
/* fill: lightgrey; */
/* stroke-width: 4px; */
cursor: grab;
}
.rountangle.dragging {
/* fill: lightgrey; */
/* stroke-width: 4px; */
cursor: grabbing;
}
/* .rountangle.selected {
fill: rgba(0, 0, 255, 0.2);
stroke: blue;
stroke-width: 4px;
} */
.lineHelper {
stroke: rgba(0, 0, 0, 0);
stroke-width: 16px;
}
.lineHelper:hover {
stroke: rgba(0, 255, 0, 0.2);
cursor: grab;
}
.circleHelper {
fill: rgba(0, 0, 0, 0);
}
.circleHelper:hover {
fill: rgba(0, 255, 0, 0.2);
cursor: grab;
}
.rountangle.or {
stroke-dasharray: 7 6;
}
.arrow {
stroke: black;
stroke-width: 2px;
}
/* .arrow.selected {
stroke: blue;
stroke-width: 4px;
} */
#arrowEnd {
fill: context-stroke;
}
.arrow:hover {
cursor: grab;
}
.selected {
fill: rgba(0, 0, 255, 0.2);
/* stroke-dasharray: 7 6; */
stroke: blue;
stroke-width: 4px;
}

View file

@ -0,0 +1,565 @@
import { MouseEventHandler, useEffect, useRef, useState } from "react";
import { Line2D, Rect2D, Vec2D, addV2D, area, isEntirelyWithin, normalizeRect, scaleV2D, subtractV2D, transformLine, transformRect } from "./geometry";
import "./VisualEditor.css";
import { getBBoxInSvgCoords } from "./svg_helper";
type Rountangle = {
uid: string;
kind: "and" | "or";
} & Rect2D;
type Text = {
uid: string;
topLeft: Vec2D;
text: string;
};
type Arrow = {
uid: string;
} & Line2D;
type VisualEditorState = {
rountangles: Rountangle[];
texts: Text[];
arrows: Arrow[];
nextID: number;
};
const emptyState = {
rountangles: [], texts: [], arrows: [], nextID: 0,
};
const onOffStateMachine = {
rountangles: [
{ uid: "0", topLeft: {x: 100, y: 100}, size: {x: 100, y: 100}, kind: "and" },
{ uid: "1", topLeft: {x: 100, y: 300}, size: {x: 100, y: 100}, kind: "and" },
],
texts: [],
arrows: [
{ uid: "2", start: {x: 150, y: 200}, end: {x: 160, y: 300} },
],
nextID: 3,
};
type DraggingState = {
lastMousePos: Vec2D;
} | null; // null means: not dragging
type ResizingState = {
lastMousePos: Vec2D;
} | null; // null means: not resizing
type SelectingState = Rect2D | null;
// independently moveable parts of our shapes:
type RountanglePart = "left" | "top" | "right" | "bottom";
type ArrowPart = "start" | "end";
type RountangleSelectable = {
kind: "rountangle";
parts: RountanglePart[];
uid: string;
}
type ArrowSelectable = {
kind: "arrow";
parts: ArrowPart[];
uid: string;
}
type Selectable = RountangleSelectable | ArrowSelectable;
type Selection = Selectable[];
const minStateSize = {x: 40, y: 40};
export function VisualEditor() {
const [state, setState] = useState<VisualEditorState>(onOffStateMachine);
const [dragging, setDragging] = useState<DraggingState>(null);
const [resizing, setResizing] = useState<ResizingState>(null);
const [mode, setMode] = useState<"state"|"transition"|"text">("state");
const [showHelp, setShowHelp] = useState<boolean>(true);
// uid's of selected rountangles
const [selection, setSelection] = useState<Selection>([]);
// not null while the user is making a selection
const [selectingState, setSelectingState] = useState<SelectingState>(null);
const refSVG = useRef<SVGSVGElement>(null);
// useEffect(() => {
// console.log('selection:', selection);
// }, [selection]);
// useEffect(() => {
// console.log('state:', state);
// }, [state]);
// useEffect(() => {
// console.log('selectingState:', selectingState);
// }, [selectingState]);
const onMouseDown: MouseEventHandler<SVGSVGElement> = (e) => {
console.log(e);
const currentPointer = {x: e.clientX, y: e.clientY};
if (e.button === 1) {
// ignore selection, always insert rountangle
setState(state => {
const newID = state.nextID.toString();
setSelection([newID]);
return {
...state,
rountangles: [...state.rountangles, {
uid: newID,
topLeft: currentPointer,
size: minStateSize,
kind: "and",
}],
nextID: state.nextID+1,
};
});
setResizing({lastMousePos: currentPointer});
}
else if (e.button === 0) {
const uid = e.target?.dataset.uid;
const parts: string[] = e.target?.dataset.parts?.split(' ') || [];
if (uid) {
let allPartsInSelection = true;
for (const part of parts) {
if (!(selection.find(s => s.uid === uid)?.parts || []).includes(part)) {
allPartsInSelection = false;
break;
}
}
if (!allPartsInSelection) {
setSelection([{uid, parts, kind: "dontcare"}]);
}
// left mouse button: select and drag
setDragging({
lastMousePos: currentPointer,
});
// // right mouse button: select and resize
// else if (e.button === 2) {
// setResizing({
// lastMousePos: currentPointer,
// });
// }
}
else {
setDragging(null);
setResizing(null);
setSelectingState({
topLeft: currentPointer,
size: {x: 0, y: 0},
});
setSelection([]);
}
}
};
const onMouseMove = (e: MouseEvent) => {
const currentPointer = {x: e.clientX, y: e.clientY};
if (dragging) {
setDragging(prevDragState => {
const pointerDelta = subtractV2D(currentPointer, dragging.lastMousePos);
const halfPointerDelta = scaleV2D(pointerDelta, 0.5);
setState(state => ({
...state,
rountangles: state.rountangles.map(r => {
const parts = selection.find(selected => selected.uid === r.uid)?.parts || [];
if (parts.length === 0) {
return r;
}
return {
uid: r.uid,
kind: r.kind,
...transformRect(r, parts, halfPointerDelta),
};
}),
arrows: state.arrows.map(a => {
const parts = selection.find(selected => selected.uid === a.uid)?.parts || [];
if (parts.length === 0) {
return a;
}
return {
uid: a.uid,
...transformLine(a, parts, halfPointerDelta),
}
})
}));
return {lastMousePos: currentPointer};
});
}
else if (resizing) {
setResizing(prevResizeState => {
const pointerDelta = subtractV2D(currentPointer, prevResizeState!.lastMousePos);
const halfPointerDelta = scaleV2D(pointerDelta, 0.5);
setState(state => ({
...state,
rountangles: state.rountangles.map(r => {
if (selection.includes(r.uid)) {
const newSize = addV2D(r.size, halfPointerDelta);
return {
...r,
size: {
x: Math.max(newSize.x, minStateSize.x),
y: Math.max(newSize.y, minStateSize.y),
},
};
}
else {
return r; // no change
}
}),
}));
return {lastMousePos: currentPointer};
});
}
else if (selectingState) {
setSelectingState(ss => {
const selectionSize = subtractV2D(currentPointer, ss!.topLeft);
return {
...ss!,
size: selectionSize,
};
});
}
};
const onMouseUp = (e: MouseEvent) => {
setDragging(null);
setResizing(null);
setSelectingState(ss => {
if (ss) {
// we were making a selection
const normalizedSS = normalizeRect(ss);
const shapes = Array.from(refSVG.current?.querySelectorAll("rect, line, circle") || []) as SVGGraphicsElement[];
const shapesInSelection = shapes.filter(el => {
const bbox = getBBoxInSvgCoords(el, refSVG.current!);
return isEntirelyWithin(bbox, 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]) => ({
kind: "rountangle",
uid,
parts: [...parts],
})));
// const selected = [
// ...state.rountangles.filter(rountangle =>
// isEntirelyWithin(rountangle, normalizedSS)),
// ...state.arrows.filter(arrow => isEntirelyWithin({
// topLeft: arrow.start,
// size: subtractV2D(arrow.end, arrow.start),
// }, normalizedSS)),
// ];
// setSelection(selected.map(r => r.uid));
}
return null; // no longer selecting
});
// sort: smaller rountangles are drawn on top
setState(state => ({
...state,
rountangles: state.rountangles.toSorted((a,b) => area(b) - area(a)),
}));
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Delete") {
// delete selection
setSelection(selection => {
setState(state => ({
...state,
rountangles: state.rountangles.filter(r => !selection.includes(r.uid)),
}));
return [];
});
}
if (e.key === "o") {
console.log('turn selected states into OR-states...');
// selected states become OR-states
setSelection(selection => {
setState(state => ({
...state,
rountangles: state.rountangles.map(r => selection.includes(r.uid) ? ({...r, kind: "or"}) : r),
}));
return selection;
})
}
if (e.key === "a") {
// selected states become AND-states
setSelection(selection => {
setState(state => ({
...state,
rountangles: state.rountangles.map(r => selection.includes(r.uid) ? ({...r, kind: "and"}) : r),
}));
return selection;
});
}
if (e.key === "h") {
setShowHelp(showHelp => !showHelp);
}
if (e.key === "s") {
setMode("state");
}
if (e.key === "t") {
setMode("transition");
}
if (e.key === "x") {
setMode("text");
}
};
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, resizing]);
return <svg width="100%" height="100%"
className="svgCanvas"
onMouseDown={onMouseDown}
onContextMenu={e => e.preventDefault()}
ref={refSVG}
>
<defs>
<marker
id="arrowEnd"
viewBox="0 0 10 10"
refX="5"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
</defs>
{state.rountangles.map(rountangle => <RountangleSVG
key={rountangle.uid}
rountangle={rountangle}
dragging={(dragging!==null) && selection.includes(rountangle.uid)}
selected={selection.find(r => r.uid === rountangle.uid)?.parts || []}
/>)}
{state.arrows.map(arrow => <ArrowSVG
key={arrow.uid}
arrow={arrow}
dragging={(dragging!==null) && selection.includes(arrow.uid)}
selected={selection.find(a => a.uid === arrow.uid)?.parts || []}
/>
)}
{selectingState && <Selecting {...selectingState} />}
{showHelp && <>
<text x={5} y={20}>
Left mouse button: Select/Drag.
</text>
<text x={5} y={40}>
Right mouse button: Resize.
</text>
<text x={5} y={60}>
Middle mouse button: Insert [S]tates / [T]ransitions / Te[X]t (current mode: {mode})</text>
<text x={5} y={80}>
[Del] Delete selection.
</text>
<text x={5} y={100}>
[O] Turn selected states into OR-states.
</text>
<text x={5} y={120}>
[A] Turn selected states into AND-states.
</text>
<text x={5} y={140}>
[H] Show/hide this help.
</text>
</>}
</svg>;
}
const cornerOffset = 4;
const cornerRadius = 16;
export function RountangleSVG(props: {rountangle: Rountangle, dragging: boolean, selected: string[]}) {
const {topLeft, size, uid} = props.rountangle;
return <g transform={`translate(${topLeft.x} ${topLeft.y})`}>
<rect
className={"rountangle"
+(props.dragging?" dragging":"")
// +(props.selected.length===4?" selected":"")
+((props.rountangle.kind==="or")?" or":"")}
rx={20} ry={20}
x={0}
y={0}
width={size.x}
height={size.y}
data-uid={uid}
data-parts="left top right bottom"
/>
<line
className={"lineHelper"
+(props.selected.includes("top")?" selected":"")}
x1={0}
y1={0}
x2={size.x}
y2={0}
data-uid={uid}
data-parts="top"
/>
<line
className={"lineHelper"
+(props.selected.includes("right")?" selected":"")}
x1={size.x}
y1={0}
x2={size.x}
y2={size.y}
data-uid={uid}
data-parts="right"
/>
<line
className={"lineHelper"
+(props.selected.includes("bottom")?" selected":"")}
x1={0}
y1={size.y}
x2={size.x}
y2={size.y}
data-uid={uid}
data-parts="bottom"
/>
<line
className={"lineHelper"
+(props.selected.includes("left")?" selected":"")}
x1={0}
y1={0}
x2={0}
y2={size.y}
data-uid={uid}
data-parts="left"
/>
<circle
className="circleHelper corner"
cx={cornerOffset}
cy={cornerOffset}
r={cornerRadius}
data-uid={uid}
data-parts="top left"
/>
<circle
className="circleHelper corner"
cx={size.x-cornerOffset}
cy={cornerOffset}
r={cornerRadius}
data-uid={uid}
data-parts="top right"
/>
<circle
className="circleHelper corner"
cx={size.x-cornerOffset}
cy={size.y-cornerOffset}
r={cornerRadius}
data-uid={uid}
data-parts="bottom right"
/>
<circle
className="circleHelper corner"
cx={cornerOffset}
cy={size.y-cornerOffset}
r={cornerRadius}
data-uid={uid}
data-parts="bottom left"
/>
<text x={10} y={20}>{uid}</text>
</g>;
}
export function ArrowSVG(props: {arrow: Arrow, dragging: boolean, selected: string[]}) {
const {start, end, uid} = props.arrow;
return <g>
<line
className={"arrow"
+(props.dragging?" dragging":"")
// +(props.selected.length===2?" selected":"")
}
markerEnd='url(#arrowEnd)'
x1={start.x}
y1={start.y}
x2={end.x}
y2={end.y}
data-uid={uid}
data-parts="start end"
/>
<line
className="lineHelper"
x1={start.x}
y1={start.y}
x2={end.x}
y2={end.y}
data-uid={uid}
data-parts="start end"
/>
<circle
className={"circleHelper"
+(props.selected.includes("start")?" selected":"")}
cx={start.x}
cy={start.y}
r={cornerRadius}
data-uid={uid}
data-parts="start"
/>
<circle
className={"circleHelper"
+(props.selected.includes("end")?" selected":"")}
cx={end.x}
cy={end.y}
r={cornerRadius}
data-uid={uid}
data-parts="end"
/>
</g>;
}
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

@ -0,0 +1,93 @@
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 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: rect.size.x
+ (parts.includes("right") ? delta.x : 0)
- (parts.includes("left") ? delta.x : 0),
y: 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,
};
}

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),
},
};
}

26
src/frontend.tsx Normal file
View file

@ -0,0 +1,26 @@
/**
* This file is the entry point for the React app, it sets up the root
* element and renders the App component to the DOM.
*
* It is included in `src/index.html`.
*/
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
const elem = document.getElementById("root")!;
const app = (
<StrictMode>
<App />
</StrictMode>
);
if (import.meta.hot) {
// With hot module reloading, `import.meta.hot.data` is persisted.
const root = (import.meta.hot.data.root ??= createRoot(elem));
root.render(app);
} else {
// The hot module reloading API is not available in production.
createRoot(elem).render(app);
}

8
src/index.css Normal file
View file

@ -0,0 +1,8 @@
html, body {
margin: 0;
height: 100%;
}
div#root {
height: 100%;
}

13
src/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="./logo.svg" />
<title>StateBuddy</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>

19
src/index.tsx Normal file
View file

@ -0,0 +1,19 @@
import { serve } from "bun";
import index from "./index.html";
const server = serve({
routes: {
// Serve index.html for all unmatched routes.
"/*": index,
},
development: process.env.NODE_ENV !== "production" && {
// Enable browser hot reloading in development
hmr: true,
// Echo console logs from the browser to the server
console: true,
},
});
console.log(`🚀 Server running at ${server.url}`);

1
src/logo.svg Normal file
View file

@ -0,0 +1 @@
<svg id="Bun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 70"><title>Bun Logo</title><path id="Shadow" d="M71.09,20.74c-.16-.17-.33-.34-.5-.5s-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5A26.46,26.46,0,0,1,75.5,35.7c0,16.57-16.82,30.05-37.5,30.05-11.58,0-21.94-4.23-28.83-10.86l.5.5.5.5.5.5.5.5.5.5.5.5.5.5C19.55,65.3,30.14,69.75,42,69.75c20.68,0,37.5-13.48,37.5-30C79.5,32.69,76.46,26,71.09,20.74Z"/><g id="Body"><path id="Background" d="M73,35.7c0,15.21-15.67,27.54-35,27.54S3,50.91,3,35.7C3,26.27,9,17.94,18.22,13S33.18,3,38,3s8.94,4.13,19.78,10C67,17.94,73,26.27,73,35.7Z" style="fill:#fbf0df"/><path id="Bottom_Shadow" data-name="Bottom Shadow" d="M73,35.7a21.67,21.67,0,0,0-.8-5.78c-2.73,33.3-43.35,34.9-59.32,24.94A40,40,0,0,0,38,63.24C57.3,63.24,73,50.89,73,35.7Z" style="fill:#f6dece"/><path id="Light_Shine" data-name="Light Shine" d="M24.53,11.17C29,8.49,34.94,3.46,40.78,3.45A9.29,9.29,0,0,0,38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7c0,.4,0,.8,0,1.19C9.06,15.48,20.07,13.85,24.53,11.17Z" style="fill:#fffefc"/><path id="Top" d="M35.12,5.53A16.41,16.41,0,0,1,29.49,18c-.28.25-.06.73.3.59,3.37-1.31,7.92-5.23,6-13.14C35.71,5,35.12,5.12,35.12,5.53Zm2.27,0A16.24,16.24,0,0,1,39,19c-.12.35.31.65.55.36C41.74,16.56,43.65,11,37.93,5,37.64,4.74,37.19,5.14,37.39,5.49Zm2.76-.17A16.42,16.42,0,0,1,47,17.12a.33.33,0,0,0,.65.11c.92-3.49.4-9.44-7.17-12.53C40.08,4.54,39.82,5.08,40.15,5.32ZM21.69,15.76a16.94,16.94,0,0,0,10.47-9c.18-.36.75-.22.66.18-1.73,8-7.52,9.67-11.12,9.45C21.32,16.4,21.33,15.87,21.69,15.76Z" style="fill:#ccbea7;fill-rule:evenodd"/><path id="Outline" d="M38,65.75C17.32,65.75.5,52.27.5,35.7c0-10,6.18-19.33,16.53-24.92,3-1.6,5.57-3.21,7.86-4.62,1.26-.78,2.45-1.51,3.6-2.19C32,1.89,35,.5,38,.5s5.62,1.2,8.9,3.14c1,.57,2,1.19,3.07,1.87,2.49,1.54,5.3,3.28,9,5.27C69.32,16.37,75.5,25.69,75.5,35.7,75.5,52.27,58.68,65.75,38,65.75ZM38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7,3,50.89,18.7,63.25,38,63.25S73,50.89,73,35.7C73,26.62,67.31,18.13,57.78,13,54,11,51.05,9.12,48.66,7.64c-1.09-.67-2.09-1.29-3-1.84C42.63,4,40.42,3,38,3Z"/></g><g id="Mouth"><g id="Background-2" data-name="Background"><path d="M45.05,43a8.93,8.93,0,0,1-2.92,4.71,6.81,6.81,0,0,1-4,1.88A6.84,6.84,0,0,1,34,47.71,8.93,8.93,0,0,1,31.12,43a.72.72,0,0,1,.8-.81H44.26A.72.72,0,0,1,45.05,43Z" style="fill:#b71422"/></g><g id="Tongue"><path id="Background-3" data-name="Background" d="M34,47.79a6.91,6.91,0,0,0,4.12,1.9,6.91,6.91,0,0,0,4.11-1.9,10.63,10.63,0,0,0,1-1.07,6.83,6.83,0,0,0-4.9-2.31,6.15,6.15,0,0,0-5,2.78C33.56,47.4,33.76,47.6,34,47.79Z" style="fill:#ff6164"/><path id="Outline-2" data-name="Outline" d="M34.16,47a5.36,5.36,0,0,1,4.19-2.08,6,6,0,0,1,4,1.69c.23-.25.45-.51.66-.77a7,7,0,0,0-4.71-1.93,6.36,6.36,0,0,0-4.89,2.36A9.53,9.53,0,0,0,34.16,47Z"/></g><path id="Outline-3" data-name="Outline" d="M38.09,50.19a7.42,7.42,0,0,1-4.45-2,9.52,9.52,0,0,1-3.11-5.05,1.2,1.2,0,0,1,.26-1,1.41,1.41,0,0,1,1.13-.51H44.26a1.44,1.44,0,0,1,1.13.51,1.19,1.19,0,0,1,.25,1h0a9.52,9.52,0,0,1-3.11,5.05A7.42,7.42,0,0,1,38.09,50.19Zm-6.17-7.4c-.16,0-.2.07-.21.09a8.29,8.29,0,0,0,2.73,4.37A6.23,6.23,0,0,0,38.09,49a6.28,6.28,0,0,0,3.65-1.73,8.3,8.3,0,0,0,2.72-4.37.21.21,0,0,0-.2-.09Z"/></g><g id="Face"><ellipse id="Right_Blush" data-name="Right Blush" cx="53.22" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><ellipse id="Left_Bluch" data-name="Left Bluch" cx="22.95" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><path id="Eyes" d="M25.7,38.8a5.51,5.51,0,1,0-5.5-5.51A5.51,5.51,0,0,0,25.7,38.8Zm24.77,0A5.51,5.51,0,1,0,45,33.29,5.5,5.5,0,0,0,50.47,38.8Z" style="fill-rule:evenodd"/><path id="Iris" d="M24,33.64a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,24,33.64Zm24.77,0a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,48.75,33.64Z" style="fill:#fff;fill-rule:evenodd"/></g></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

8
src/react.svg Normal file
View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-11.5 -10.23174 23 20.46348">
<circle cx="0" cy="0" r="2.05" fill="#61dafb"/>
<g stroke="#61dafb" stroke-width="1" fill="none">
<ellipse rx="11" ry="4.2"/>
<ellipse rx="11" ry="4.2" transform="rotate(60)"/>
<ellipse rx="11" ry="4.2" transform="rotate(120)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 338 B

17
tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "Preserve",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"allowJs": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["dist", "node_modules"]
}