This commit is contained in:
sfja 2026-05-19 16:37:45 +02:00
parent a889c03929
commit bdf4a01e7f
7 changed files with 215 additions and 182 deletions

View File

@ -1,3 +1,4 @@
import type { Renderer } from "./Renderer";
import { pointInsideRect, rectsCollide, v2, V2 } from "./V2"; import { pointInsideRect, rectsCollide, v2, V2 } from "./V2";
export class Board { export class Board {
@ -16,81 +17,9 @@ export class Board {
this.components.push({ def, pos }); this.components.push({ def, pos });
} }
render(_canvas: HTMLCanvasElement, c: CanvasRenderingContext2D, offset: V2) { render(r: Renderer) {
for (const comp of this.components) { for (const comp of this.components) {
const { r.drawComponent(comp, this.hoveredOverInput, this.hoveredOverOutput);
def: {
size: { x: w, y: h },
label,
inputs,
outputs,
},
pos,
} = comp;
const [x, y] = [pos.x + offset.x, pos.y + offset.y];
c.fillStyle = `#6abbde`;
c.fillRect(x, y, w, h);
c.strokeStyle = `#333333`;
c.lineWidth = 2;
c.strokeRect(x, y, w, h);
c.fillStyle = `#333333`;
c.font = "bold 16px monospace";
const textMetrix = c.measureText(label);
c.fillText(
label,
x + w / 2 - textMetrix.width / 2,
y + 13 + h / 2 - 16 / 2,
);
{
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();
if (
this.hoveredOverInput?.[0] === comp &&
this.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();
if (
this.hoveredOverOutput?.[0] === comp &&
this.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();
}
}
}
} }
} }
@ -239,7 +168,7 @@ export class ComponentRepo {
} }
} }
type Component = { export type Component = {
def: ComponentDef; def: ComponentDef;
pos: V2; pos: V2;
}; };

View File

@ -1,6 +1,9 @@
import { Board, ComponentRepo } from "./Board"; import { Board, ComponentRepo } from "./Board";
import { Renderer } from "./Renderer";
import type { State } from "./State"; import type { State } from "./State";
import { Normal } from "./states/Normal"; import { Normal } from "./states/Normal";
import { Panning } from "./states/Panning";
import { Placing } from "./states/Placing";
import { v2, V2 } from "./V2"; import { v2, V2 } from "./V2";
export class Cx { export class Cx {
@ -9,59 +12,20 @@ export class Cx {
private state = new Normal(this) as State; private state = new Normal(this) as State;
private updateActions: (() => void)[] = []; private updateActions: (() => void)[] = [];
private selectionRect: SelectionRect | null = null; private selectionBox: SelectionBox | null = null;
private componentPlacer: ComponentPlacer | null = null; private componentPlacer: ComponentPlacer | null = null;
public board = new Board(); public board = new Board();
public componentRepo = ComponentRepo.withDefaults(); public componentRepo = ComponentRepo.withDefaults();
render(canvas: HTMLCanvasElement) { render(canvas: HTMLCanvasElement) {
const c = canvas.getContext("2d")!; const r = new Renderer(canvas, this.offset);
c.imageSmoothingEnabled = false; r.clear();
c.fillStyle = "#666"; r.drawGrid();
c.fillRect(0, 0, canvas.width, canvas.height); this.board.render(r);
this.selectionBox?.render(r);
const dotSize = { x: 2, y: 2 }; this.componentPlacer?.render(r);
const gridSize = v2(20, 20);
c.fillStyle = "#111";
for (let y = 0; y < canvas.width / gridSize.x + 1; ++y) {
for (let x = 0; x < canvas.height / gridSize.y + 1; ++x) {
c.fillRect(
(this.offset.x % gridSize.x) + x * gridSize.x - dotSize.x / 2,
(this.offset.y % gridSize.y) + y * gridSize.y - dotSize.y / 2,
dotSize.x,
dotSize.y,
);
}
}
this.board.render(canvas, c, this.offset);
if (this.selectionRect) {
const {
pos: { x, y },
size: { x: w, y: h },
} = this.selectionRect;
c.fillStyle = `#ff880088`;
c.fillRect(x, y, w, h);
c.strokeStyle = `#ff8800`;
c.lineWidth = 2;
c.strokeRect(x, y, w, h);
}
if (this.componentPlacer) {
const {
pos: { x, y },
size: { x: w, y: h },
} = this.componentPlacer;
c.strokeStyle = `#ffffff`;
c.lineWidth = 2;
c.strokeRect(x, y, w, h);
}
} }
renderIfNeeded(canvas: HTMLCanvasElement) { renderIfNeeded(canvas: HTMLCanvasElement) {
@ -91,16 +55,20 @@ export class Cx {
this.state.onKeyUp?.(key); this.state.onKeyUp?.(key);
} }
selectTool(tool: Tool) { selectTool(tool: Tool) {
// this is very much a hack so that other tools switch (tool) {
// can be selected from any tool, without me case "pan":
// having to add that in every state. this.transitionTo(new Panning(this));
if (!(this.state instanceof Normal)) { break;
this.transitionTo(new Normal(this)); case "input":
case "output":
case "and":
case "or":
case "not":
this.transitionTo(new Placing(this, tool));
break;
default:
this.transitionTo(new Normal(this));
} }
this.state.selectTool?.(tool);
}
selectedTool(): Tool | null {
return this.state.selectedTool?.() ?? null;
} }
addUpdateAction(action: () => void): object { addUpdateAction(action: () => void): object {
@ -134,25 +102,25 @@ export class Cx {
} }
addSelectionRect(pos: V2) { addSelectionRect(pos: V2) {
this.selectionRect = { pos, size: v2(0, 0) }; this.selectionBox = new SelectionBox(pos, v2(0, 0));
this.renderNeeded = true; this.renderNeeded = true;
} }
removeSelectionRect() { removeSelectionRect() {
this.selectionRect = null; this.selectionBox = null;
this.renderNeeded = true; this.renderNeeded = true;
} }
moveSelectionRect(deltaPos: V2) { moveSelectionRect(deltaPos: V2) {
if (this.selectionRect) { if (this.selectionBox) {
this.selectionRect.size.x += deltaPos.x; this.selectionBox.size.x += deltaPos.x;
this.selectionRect.size.y += deltaPos.y; this.selectionBox.size.y += deltaPos.y;
this.renderNeeded = true; this.renderNeeded = true;
} }
} }
addComponentPlacer(pos: V2, size: V2) { addComponentPlacer(pos: V2, size: V2) {
this.componentPlacer = { pos, size }; this.componentPlacer = new ComponentPlacer(pos, size);
this.renderNeeded = true; this.renderNeeded = true;
} }
@ -175,14 +143,26 @@ export class Cx {
} }
} }
export type SelectionRect = { export class SelectionBox {
pos: V2; constructor(
size: V2; public pos: V2,
}; public size: V2,
) {}
export type ComponentPlacer = { render(r: Renderer) {
pos: V2; r.drawSelectionBox(this.pos, this.size);
size: V2; }
}; }
export class ComponentPlacer {
constructor(
public pos: V2,
public size: V2,
) {}
render(r: Renderer) {
r.drawComponentPlacer(this.pos, this.size);
}
}
export type Tool = string; export type Tool = string;

View File

@ -0,0 +1,134 @@
import type { Component } from "./Board";
import { v2, type V2 } from "./V2";
export class Renderer {
private c: CanvasRenderingContext2D;
constructor(
private canvas: HTMLCanvasElement,
private offset: V2,
) {
this.c = this.canvas.getContext("2d")!;
this.c.imageSmoothingEnabled = false;
}
clear() {
const { canvas, c } = this;
c.fillStyle = "#666";
c.fillRect(0, 0, canvas.width, canvas.height);
}
drawGrid() {
const { canvas, c } = this;
const dotSize = { x: 2, y: 2 };
const gridSize = v2(20, 20);
c.fillStyle = "#111";
for (let y = 0; y < canvas.width / gridSize.x + 1; ++y) {
for (let x = 0; x < canvas.height / gridSize.y + 1; ++x) {
c.fillRect(
(this.offset.x % gridSize.x) + x * gridSize.x - dotSize.x / 2,
(this.offset.y % gridSize.y) + y * gridSize.y - dotSize.y / 2,
dotSize.x,
dotSize.y,
);
}
}
}
drawSelectionBox(pos: V2, size: V2) {
const { c } = this;
const { x, y } = pos;
const { x: w, y: h } = size;
c.fillStyle = `#ff880088`;
c.fillRect(x, y, w, h);
c.strokeStyle = `#ff8800`;
c.lineWidth = 2;
c.strokeRect(x, y, w, h);
}
drawComponentPlacer(pos: V2, size: V2) {
const { c } = this;
const { x, y } = pos;
const { x: w, y: h } = size;
c.strokeStyle = `#ffffff`;
c.lineWidth = 2;
c.strokeRect(x, y, w, h);
}
drawComponent(
comp: Component,
hoveredOverInput: [Component, number] | null,
hoveredOverOutput: [Component, number] | null,
) {
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];
c.fillStyle = `#6abbde`;
c.fillRect(x, y, w, h);
c.strokeStyle = `#333333`;
c.lineWidth = 2;
c.strokeRect(x, y, w, h);
c.fillStyle = `#333333`;
c.font = "bold 16px monospace";
const textMetrix = c.measureText(label);
c.fillText(
label,
x + w / 2 - textMetrix.width / 2,
y + 13 + h / 2 - 16 / 2,
);
{
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();
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();
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();
}
}
}
}
}

View File

@ -1,14 +1,13 @@
import type { Tool } from "./Cx"; import type { Tool } from "./Cx";
import type { V2_ } from "./V2"; import type { V2 } from "./V2";
export interface State { export interface State {
enterState?(): void; enterState?(): void;
leaveState?(): void; leaveState?(): void;
onMouseDown?(pos: V2_): void; onMouseDown?(pos: V2): void;
onMouseUp?(pos: V2_): void; onMouseUp?(pos: V2): void;
onMouseMove?(deltaPos: V2_, pos: V2_): void; onMouseMove?(deltaPos: V2, pos: V2): void;
onKeyDown?(key: string): void; onKeyDown?(key: string): void;
onKeyUp?(key: string): void; onKeyUp?(key: string): void;
selectTool?(tool: Tool): void;
selectedTool?(): Tool | null; selectedTool?(): Tool | null;
} }

View File

@ -2,26 +2,11 @@ import type { Cx, Tool } from "../Cx";
import type { V2 } from "../V2"; import type { V2 } from "../V2";
import type { State } from "../State"; import type { State } from "../State";
import { Panning } from "./Panning"; import { Panning } from "./Panning";
import { Placing } from "./Placing"; import { SelectingBox } from "./SelectingBox";
import { Selecting } from "./Selecting";
export class Normal implements State { export class Normal implements State {
constructor(private cx: Cx) {} constructor(private cx: Cx) {}
selectTool(tool: Tool): void {
switch (tool) {
case "pan":
this.cx.transitionTo(new Panning(this.cx));
break;
case "input":
case "output":
case "and":
case "or":
case "not":
this.cx.transitionTo(new Placing(this.cx, tool));
}
}
onMouseDown(pos: V2): void { onMouseDown(pos: V2): void {
if ( if (
this.cx.board.handleMouseClick( this.cx.board.handleMouseClick(
@ -34,7 +19,7 @@ export class Normal implements State {
return; return;
} else { } else {
this.cx.addSelectionRect(pos); this.cx.addSelectionRect(pos);
this.cx.transitionTo(new Selecting(this.cx)); this.cx.transitionTo(new SelectingBox(this.cx));
} }
} }

View File

@ -1,21 +1,6 @@
import type { Cx, Tool } from "../Cx"; import type { Cx } from "../Cx";
import type { V2 } from "../V2";
import type { State } from "../State"; import type { State } from "../State";
import { Normal } from "./Normal";
export class Selecting implements State { export class Selecting implements State {
constructor(private cx: Cx) {} constructor(private cx: Cx) {}
onMouseUp(_pos: V2): void {
this.cx.removeSelectionRect();
this.cx.transitionTo(new Normal(this.cx));
}
onMouseMove(deltaPos: V2): void {
this.cx.moveSelectionRect(deltaPos);
}
selectedTool(): Tool | null {
return "select";
}
} }

View File

@ -0,0 +1,21 @@
import type { Cx, Tool } from "../Cx";
import type { V2 } from "../V2";
import type { State } from "../State";
import { Normal } from "./Normal";
export class SelectingBox implements State {
constructor(private cx: Cx) {}
onMouseUp(_pos: V2): void {
this.cx.removeSelectionRect();
this.cx.transitionTo(new Normal(this.cx));
}
onMouseMove(deltaPos: V2): void {
this.cx.moveSelectionRect(deltaPos);
}
selectedTool(): Tool | null {
return "select";
}
}