selecting and wiring

This commit is contained in:
sfja 2026-05-20 03:11:40 +02:00
parent 2a172f687c
commit 0ec608ee54
5 changed files with 431 additions and 196 deletions

View File

@ -11,12 +11,18 @@ export default defineConfig([
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
tseslint.configs.recommendedTypeChecked,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: {
projectService: true,
}
},
rules: {
"@typescript-eslint/no-unused-vars": "off"
}
},
])

View File

@ -1,25 +1,61 @@
import type { Selection } from "./Cx";
import type { Renderer } from "./Renderer";
import { pointInsideRect, rectsCollide, v2, V2 } from "./V2";
export class Board {
private components: Component[] = [];
private components: PlacedComponent[] = [];
private hoveredOverInput: [Component, number] | null = null;
private hoveredOverOutput: [Component, number] | null = null;
private hoveredOverInput: [PlacedComponent, number] | null = null;
private hoveredOverOutput: [PlacedComponent, number] | null = null;
canPlaceComponent(def: ComponentDef, pos: V2): boolean {
constructor() {}
canPlaceComponent(kind: ComponentKind, pos: V2): boolean {
return !this.components.some((comp) =>
rectsCollide(comp.pos, comp.def.size, pos, def.size),
rectsCollide(comp.pos, comp.kind.size, pos, kind.size),
);
}
placeComponent(def: ComponentDef, pos: V2) {
this.components.push({ def, pos });
placeComponent(kind: ComponentKind, pos: V2) {
this.components.push({ kind: kind, pos });
}
render(r: Renderer) {
render(r: Renderer, selection: Selection | null) {
for (const comp of this.components) {
r.drawComponent(comp, this.hoveredOverInput, this.hoveredOverOutput);
const { pos, kind } = comp;
if (selection?.isComponentSelected(comp)) {
r.drawComponentBodySelected(pos, kind);
} else {
r.drawComponentBody(pos, kind);
}
for (const { i, pinOffset } of kind.inputPinIter()) {
if (kind.inputs[i] !== null) {
throw new Error("pin text not implemented");
}
r.drawComponentInputPin(pos, pinOffset);
if (
this.hoveredOverInput?.[0] === comp &&
this.hoveredOverInput[1] === i
) {
r.drawComponentInputPinHover(pos, pinOffset);
}
}
for (const { i, pinOffset } of kind.outputPinIter()) {
if (kind.outputs[i] !== null) {
throw new Error("pin text not implemented");
}
r.drawComponentOutputPin(pos, kind, pinOffset);
if (
this.hoveredOverOutput?.[0] === comp &&
this.hoveredOverOutput[1] === i
) {
r.drawComponentOutputPinHover(pos, kind, pinOffset);
}
}
}
}
@ -30,10 +66,8 @@ export class Board {
for (const comp of this.components) {
const {
pos: { x, y },
def: {
size: { x: w, y: h },
inputs,
outputs,
kind: {
size: { x: w },
},
} = comp;
@ -41,25 +75,19 @@ export class Board {
!pointInsideRect(
pos,
comp.pos.sub(v2(5, 5)),
comp.def.size.add(v2(10, 10)),
comp.kind.size.add(v2(10, 10)),
)
) {
continue;
}
{
const pinSpace = h / (inputs.length + 1);
for (let i = 0; i < inputs.length; ++i) {
if (v2(x, y + (i + 1) * pinSpace).distance(pos) < 5) {
this.hoveredOverInput = [comp, i];
}
for (const { i, pinOffset } of comp.kind.inputPinIter()) {
if (v2(x, y + pinOffset).distance(pos) < 6) {
this.hoveredOverInput = [comp, i];
}
}
{
const pinSpace = h / (outputs.length + 1);
for (let i = 0; i < outputs.length; ++i) {
if (v2(x + w, y + (i + 1) * pinSpace).distance(pos) < 5) {
this.hoveredOverOutput = [comp, i];
}
for (const { i, pinOffset } of comp.kind.outputPinIter()) {
if (v2(x + w, y + pinOffset).distance(pos) < 6) {
this.hoveredOverOutput = [comp, i];
}
}
}
@ -67,17 +95,15 @@ export class Board {
handleMouseClick(
pos: V2,
inputPinClicked: (comp: Component, i: number) => void,
outputPinClicked: (comp: Component, i: number) => void,
componentClicked: (comp: Component) => void,
inputPinClicked: (comp: PlacedComponent, i: number) => void,
outputPinClicked: (comp: PlacedComponent, i: number) => void,
componentClicked: (comp: PlacedComponent) => void,
): "handled" | "not handled" {
for (const comp of this.components) {
const {
pos: { x, y },
def: {
size: { x: w, y: h },
inputs,
outputs,
kind: {
size: { x: w },
},
} = comp;
@ -85,27 +111,21 @@ export class Board {
!pointInsideRect(
pos,
comp.pos.sub(v2(5, 5)),
comp.def.size.add(v2(10, 10)),
comp.kind.size.add(v2(10, 10)),
)
) {
continue;
}
{
const pinSpace = h / (inputs.length + 1);
for (let i = 0; i < inputs.length; ++i) {
if (v2(x, y + (i + 1) * pinSpace).distance(pos) < 5) {
inputPinClicked(comp, i);
return "handled";
}
for (const { i, pinOffset } of comp.kind.inputPinIter()) {
if (v2(x, y + pinOffset).distance(pos) < 6) {
inputPinClicked(comp, i);
return "handled";
}
}
{
const pinSpace = h / (outputs.length + 1);
for (let i = 0; i < outputs.length; ++i) {
if (v2(x + w, y + (i + 1) * pinSpace).distance(pos) < 5) {
outputPinClicked(comp, i);
return "handled";
}
for (const { i, pinOffset } of comp.kind.outputPinIter()) {
if (v2(x + w, y + pinOffset).distance(pos) < 6) {
outputPinClicked(comp, i);
return "handled";
}
}
componentClicked(comp);
@ -113,69 +133,96 @@ export class Board {
}
return "not handled";
}
componentsInRect(pos: V2, size: V2): PlacedComponent[] {
return this.components.filter((comp) =>
rectsCollide(pos, size, comp.pos, comp.kind.size),
);
}
}
export class ComponentRepo {
private defs = new Map<string, ComponentDef>();
private defs = new Map<string, ComponentKind>();
static withDefaults(): ComponentRepo {
const repo = new ComponentRepo();
repo.add("input", {
label: "input",
size: v2(80, 40),
inputs: [],
outputs: [null],
});
repo.add("output", {
label: "output",
size: v2(80, 40),
inputs: [null],
outputs: [],
});
repo.add("and", {
label: "and",
size: v2(80, 40),
inputs: [null, null],
outputs: [null],
});
repo.add("or", {
label: "or",
size: v2(80, 40),
inputs: [null, null],
outputs: [null],
});
repo.add("not", {
label: "not",
size: v2(80, 40),
inputs: [null],
outputs: [null],
});
for (const { label, size, inputs, outputs } of defaultDefs) {
repo.add(label, new ComponentKind(size, label, inputs, outputs));
}
return repo;
}
add(ident: string, def: ComponentDef) {
this.defs.set(ident, def);
add(ident: string, kind: ComponentKind) {
this.defs.set(ident, kind);
}
get(ident: string): ComponentDef {
const def = this.defs.get(ident);
if (!def) {
get(ident: string): ComponentKind {
const kind = this.defs.get(ident);
if (!kind) {
throw new Error("should be defined");
}
return def;
return kind;
}
}
export type Component = {
def: ComponentDef;
export type PlacedComponent = {
kind: ComponentKind;
pos: V2;
};
export type ComponentDef = {
size: V2;
label: string;
inputs: (string | null)[];
outputs: (string | null)[];
};
export class ComponentKind {
constructor(
public size: V2,
public label: string,
public inputs: (string | null)[],
public outputs: (string | null)[],
) {}
inputPinIter(): { i: number; pinOffset: number }[] {
return this.inputs.map((_, i) => ({
i,
pinOffset: ((i + 1) * this.size.y) / (this.inputs.length + 1),
}));
}
outputPinIter(): { i: number; pinOffset: number }[] {
return this.outputs.map((_, i) => ({
i,
pinOffset: ((i + 1) * this.size.y) / (this.outputs.length + 1),
}));
}
}
const defaultDefs = [
{
label: "input",
size: v2(80, 40),
inputs: [],
outputs: [null],
},
{
label: "output",
size: v2(80, 40),
inputs: [null],
outputs: [],
},
{
label: "and",
size: v2(80, 40),
inputs: [null, null],
outputs: [null],
},
{
label: "or",
size: v2(80, 40),
inputs: [null, null],
outputs: [null],
},
{
label: "not",
size: v2(80, 40),
inputs: [null],
outputs: [null],
},
];

View File

@ -1,28 +1,35 @@
import { Board, ComponentRepo } from "./Board";
import { Board, ComponentRepo, type PlacedComponent } from "./Board";
import { Renderer } from "./Renderer";
import * as states from "./states";
import { v2, V2 } from "./V2";
export type Tool = string;
export class Cx {
public offset = v2(0, 0);
private renderNeeded = false;
private state = new states.Normal(this) as states.State;
private updateActions: (() => void)[] = [];
private selectionBox: SelectionBox | null = null;
public selectionBox: SelectionBox | null = null;
private componentPlacer: ComponentPlacer | null = null;
public selection: Selection | null = null;
public connectingWire: ConnectingWire | null = null;
public board = new Board();
public componentRepo = ComponentRepo.withDefaults();
public keysPressed = new Set<string>();
render(canvas: HTMLCanvasElement) {
const r = new Renderer(canvas, this.offset);
r.clear();
r.drawGrid();
this.board.render(r);
this.board.render(r, this.selection);
this.selectionBox?.render(r);
this.componentPlacer?.render(r);
this.connectingWire?.render(r);
}
renderIfNeeded(canvas: HTMLCanvasElement) {
@ -32,24 +39,27 @@ export class Cx {
}
}
setRenderNeeded() {
this.renderNeeded = true;
}
mouseDown(pos: V2) {
this.state.onMouseDown?.(pos);
this.renderNeeded = true;
}
mouseUp(pos: V2) {
this.state.onMouseUp?.(pos);
this.renderNeeded = true;
}
mouseMove(deltaPos: V2, pos: V2) {
this.state.onMouseMove?.(deltaPos, pos);
this.renderNeeded = true;
}
keyDown(key: string) {
this.keysPressed.add(key);
this.state.onKeyDown?.(key);
this.renderNeeded = true;
}
keyUp(key: string) {
this.keysPressed.delete(key);
this.state.onKeyUp?.(key);
this.renderNeeded = true;
}
selectTool(tool: Tool) {
switch (tool) {
@ -67,6 +77,9 @@ export class Cx {
this.transitionTo(new states.Normal(this));
}
}
selectedTool(): Tool {
return this.state.selectedTool?.() || "select";
}
addUpdateAction(action: () => void): object {
this.updateActions.push(action);
@ -82,6 +95,7 @@ export class Cx {
transitionTo(newState: states.State) {
this.state.leaveState?.();
this.state = newState;
console.log(`Entering state ${newState.constructor.name}`);
this.state.enterState?.();
this.notifyListeners();
}
@ -95,41 +109,19 @@ export class Cx {
moveOffset(deltaPos: V2) {
this.offset.x += deltaPos.x;
this.offset.y += deltaPos.y;
this.renderNeeded = true;
}
addSelectionRect(pos: V2) {
this.selectionBox = new SelectionBox(pos, v2(0, 0));
this.renderNeeded = true;
}
removeSelectionRect() {
this.selectionBox = null;
this.renderNeeded = true;
}
moveSelectionRect(deltaPos: V2) {
if (this.selectionBox) {
this.selectionBox.size.x += deltaPos.x;
this.selectionBox.size.y += deltaPos.y;
this.renderNeeded = true;
}
}
addComponentPlacer(pos: V2, size: V2) {
this.componentPlacer = new ComponentPlacer(pos, size);
this.renderNeeded = true;
}
removeComponentPlacer() {
this.componentPlacer = null;
this.renderNeeded = true;
}
setComponentPlacerPos(pos: V2) {
if (this.componentPlacer) {
this.componentPlacer.pos = pos;
this.renderNeeded = true;
}
}
@ -141,14 +133,27 @@ export class Cx {
}
export class SelectionBox {
constructor(
public pos: V2,
public size: V2,
) {}
public size = v2(0, 0);
constructor(public pos: V2) {}
render(r: Renderer) {
r.drawSelectionBox(this.pos, this.size);
}
move(deltaPos: V2) {
this.size = this.size.add(deltaPos);
}
normalized(): { pos: V2; size: V2 } {
const normalizedAxis = (p: number, s: number): [number, number] =>
s >= 0 ? [p, s] : [p + s, -s];
const [x, w] = normalizedAxis(this.pos.x, this.size.x);
const [y, h] = normalizedAxis(this.pos.y, this.size.y);
return { pos: v2(x, y), size: v2(w, h) };
}
}
export class ComponentPlacer {
@ -162,4 +167,62 @@ export class ComponentPlacer {
}
}
export type Tool = string;
export class Selection {
selectedComponents = new Set<PlacedComponent>();
addComponent(comp: PlacedComponent) {
this.selectedComponents.add(comp);
}
toggleComponent(comp: PlacedComponent) {
if (this.selectedComponents.has(comp)) {
this.selectedComponents.delete(comp);
} else {
this.selectedComponents.add(comp);
}
}
isComponentSelected(comp: PlacedComponent) {
return this.selectedComponents.has(comp);
}
}
export class ConnectingWire {
constructor(
public kind: ConnectingWireKind,
public pos: V2,
) {}
render(r: Renderer) {
switch (this.kind.tag) {
case "InputPin":
case "OutputPin":
}
r.drawConnectingWire(this.beginPos(), this.pos);
}
move(pos: V2) {
this.pos = pos;
}
private beginPos(): V2 {
switch (this.kind.tag) {
case "InputPin":
return v2(
this.kind.comp.pos.x,
this.kind.comp.pos.y +
this.kind.comp.kind.inputPinIter()[this.kind.i].pinOffset,
);
case "OutputPin":
return v2(
this.kind.comp.pos.x + this.kind.comp.kind.size.x,
this.kind.comp.pos.y +
this.kind.comp.kind.outputPinIter()[this.kind.i].pinOffset,
);
}
}
}
export type ConnectingWireKind =
| { tag: "InputPin"; comp: PlacedComponent; i: number }
| { tag: "OutputPin"; comp: PlacedComponent; i: number };

View File

@ -1,4 +1,4 @@
import type { Component } from "./Board";
import type { ComponentKind } from "./Board";
import { v2, type V2 } from "./V2";
export class Renderer {
@ -57,23 +57,10 @@ export class Renderer {
c.strokeRect(x, y, w, h);
}
drawComponent(
comp: Component,
hoveredOverInput: [Component, number] | null,
hoveredOverOutput: [Component, number] | null,
) {
drawComponentBody(pos: V2, kind: ComponentKind) {
const { c, offset } = this;
const {
def: {
size: { x: w, y: h },
label,
inputs,
outputs,
},
pos,
} = comp;
const [x, y] = [pos.x + offset.x, pos.y + offset.y];
const { x, y } = pos.add(offset);
const { x: w, y: h } = kind.size;
c.fillStyle = `#6abbde`;
c.fillRect(x, y, w, h);
@ -83,52 +70,92 @@ export class Renderer {
c.fillStyle = `#333333`;
c.font = "bold 16px monospace";
const textMetrix = c.measureText(label);
const textMetrix = c.measureText(kind.label);
c.fillText(
label,
kind.label,
x + w / 2 - textMetrix.width / 2,
y + 13 + h / 2 - 16 / 2,
);
}
drawComponentBodySelected(pos: V2, kind: ComponentKind) {
const { c, offset } = this;
const { x, y } = pos.add(offset);
const { x: w, y: h } = kind.size;
{
const pinSpace = h / (inputs.length + 1);
for (let i = 0; i < inputs.length; ++i) {
if (inputs[i] !== null) {
throw new Error("pin text not implemented");
}
c.fillStyle = `#333333`;
c.beginPath();
c.arc(x, y + (i + 1) * pinSpace, 4, 0, Math.PI * 2);
c.fill();
c.fillStyle = `#6abbde`;
c.fillRect(x, y, w, h);
c.strokeStyle = `#ff8800`;
c.lineWidth = 2;
c.strokeRect(x, y, w, h);
if (hoveredOverInput?.[0] === comp && hoveredOverInput[1] === i) {
c.strokeStyle = `#bbbbbb`;
c.lineWidth = 2;
c.beginPath();
c.arc(x, y + (i + 1) * pinSpace, 5, 0, Math.PI * 2);
c.stroke();
}
}
}
{
const pinSpace = h / (outputs.length + 1);
for (let i = 0; i < outputs.length; ++i) {
if (outputs[i] !== null) {
throw new Error("pin text not implemented");
}
c.fillStyle = `#333333`;
c.beginPath();
c.arc(x + w, y + (i + 1) * pinSpace, 4, 0, Math.PI * 2);
c.fill();
c.fillStyle = `#333333`;
c.font = "bold 16px monospace";
const textMetrix = c.measureText(kind.label);
c.fillText(
kind.label,
x + w / 2 - textMetrix.width / 2,
y + 13 + h / 2 - 16 / 2,
);
}
if (hoveredOverOutput?.[0] === comp && hoveredOverOutput[1] === i) {
c.strokeStyle = `#eee`;
c.lineWidth = 2;
c.beginPath();
c.arc(x + w, y + (i + 1) * pinSpace, 5, 0, Math.PI * 2);
c.stroke();
}
}
}
drawComponentInputPin(pos: V2, pinOffset: number) {
const { c, offset } = this;
const { x, y } = pos.add(offset);
c.fillStyle = `#333333`;
c.beginPath();
c.arc(x, y + pinOffset, 4, 0, Math.PI * 2);
c.fill();
}
drawComponentInputPinHover(pos: V2, pinOffset: number) {
const { c, offset } = this;
const { x, y } = pos.add(offset);
c.strokeStyle = `#eee`;
c.lineWidth = 2;
c.beginPath();
c.arc(x, y + pinOffset, 5, 0, Math.PI * 2);
c.stroke();
}
drawComponentOutputPin(pos: V2, kind: ComponentKind, pinOffset: number) {
const { c, offset } = this;
const {
size: { x: w },
} = kind;
const { x, y } = pos.add(offset);
c.fillStyle = `#333333`;
c.beginPath();
c.arc(x + w, y + pinOffset, 4, 0, Math.PI * 2);
c.fill();
}
drawComponentOutputPinHover(pos: V2, kind: ComponentKind, pinOffset: number) {
const { c, offset } = this;
const {
size: { x: w },
} = kind;
const { x, y } = pos.add(offset);
c.strokeStyle = `#eee`;
c.lineWidth = 2;
c.beginPath();
c.arc(x + w, y + pinOffset, 5, 0, Math.PI * 2);
c.stroke();
}
drawConnectingWire(begin: V2, end: V2) {
const { c, offset } = this;
const { x: x0, y: y0 } = begin.add(offset);
const { x: x1, y: y1 } = end.add(offset);
c.strokeStyle = `#333333`;
c.lineWidth = 3;
c.beginPath();
c.moveTo(x0, y0);
c.lineTo(x1, y1);
c.stroke();
}
}

View File

@ -1,5 +1,6 @@
import type { ComponentDef } from "./Board";
import type { Cx, Tool } from "./Cx";
import { type ComponentKind } from "./Board";
import { ConnectingWire, Selection } from "./Cx";
import { SelectionBox, type Cx, type Tool } from "./Cx";
import { v2, type V2 } from "./V2";
export interface State {
@ -20,21 +21,45 @@ export class Normal implements State {
if (
this.cx.board.handleMouseClick(
pos.sub(this.cx.offset),
(comp, i) => {},
(comp, i) => {},
(comp) => {},
(comp, i) => {
console.log({ comp, i });
this.cx.connectingWire = new ConnectingWire(
{
tag: "InputPin",
comp,
i,
},
pos,
);
this.cx.transitionTo(new Wiring(this.cx));
},
(comp, i) => {
this.cx.connectingWire = new ConnectingWire(
{
tag: "OutputPin",
comp,
i,
},
pos,
);
this.cx.transitionTo(new Wiring(this.cx));
},
(comp) => {
this.cx.selection = new Selection();
this.cx.selection.addComponent(comp);
this.cx.transitionTo(new Selecting(this.cx));
},
) === "handled"
) {
return;
} else {
this.cx.addSelectionRect(pos);
this.cx.selectionBox = new SelectionBox(pos);
this.cx.transitionTo(new SelectingBox(this.cx));
}
}
onMouseMove(_deltaPos: V2, pos: V2): void {
this.cx.board.updateMouseHover(pos.sub(this.cx.offset));
this.cx.setRenderNeeded();
}
onKeyDown(key: string): void {
@ -88,7 +113,7 @@ export class Panning implements State {
}
export class Placing implements State {
private compDef: ComponentDef;
private compDef: ComponentKind;
constructor(
private cx: Cx,
@ -131,21 +156,88 @@ export class Placing implements State {
export class Selecting implements State {
constructor(private cx: Cx) {}
onMouseDown(pos: V2): void {
if (
this.cx.board.handleMouseClick(
pos.sub(this.cx.offset),
(_comp, _i) => {},
(_comp, _i) => {},
(comp) => {
if (this.cx.keysPressed.has("Control")) {
this.cx.selection?.toggleComponent(comp);
} else {
this.cx.selection = new Selection();
this.cx.selection.addComponent(comp);
}
},
) === "handled"
) {
return;
} else {
if (this.cx.keysPressed.has("Control")) {
this.cx.selectionBox = new SelectionBox(pos);
this.cx.transitionTo(new SelectingBox(this.cx));
} else {
this.cx.selection = null;
this.cx.transitionTo(new Normal(this.cx));
}
}
}
}
export class SelectingBox implements State {
constructor(private cx: Cx) {}
onMouseUp(_pos: V2): void {
this.cx.removeSelectionRect();
this.cx.transitionTo(new Normal(this.cx));
if (!this.cx.selectionBox) {
throw new Error("expected selectionBox to active");
}
const { pos, size } = this.cx.selectionBox.normalized();
const selected = this.cx.board.componentsInRect(
pos.sub(this.cx.offset),
size,
);
if (selected.length > 0) {
this.cx.selection ??= new Selection();
}
for (const comp of selected) {
this.cx.selection?.addComponent(comp);
}
if (this.cx.selection) {
this.cx.selectionBox = null;
this.cx.transitionTo(new Selecting(this.cx));
} else {
this.cx.selectionBox = null;
this.cx.transitionTo(new Normal(this.cx));
}
}
onMouseMove(deltaPos: V2): void {
this.cx.moveSelectionRect(deltaPos);
this.cx.selectionBox?.move(deltaPos);
}
selectedTool(): Tool | null {
return "select";
}
}
export class Wiring implements State {
constructor(private cx: Cx) {}
onMouseMove(_deltaPos: V2, pos: V2): void {
if (!this.cx.connectingWire) {
throw new Error("expected connectingWire to be active");
}
this.cx.connectingWire.move(pos);
this.cx.board.updateMouseHover(pos.sub(this.cx.offset));
}
onKeyDown(key: string): void {
if (key === "Escape") {
this.cx.transitionTo(new Normal(this.cx));
this.cx.connectingWire = null;
return;
}
}
}