add editor

This commit is contained in:
sfja 2025-05-06 17:36:53 +02:00
parent c1c6e145a0
commit e17953ae4a
8 changed files with 457 additions and 4 deletions

105
src/canvas.ts Normal file
View File

@ -0,0 +1,105 @@
import { EvHandler, EvHandlerRes, Mouse } from "./input.ts";
import { Renderer } from "./renderer.ts";
export class CanvasRenderer implements Renderer {
constructor(
private c: HTMLCanvasElement,
private g: CanvasRenderingContext2D,
) {}
get width(): number {
return this.c.width;
}
get height(): number {
return this.c.height;
}
clear(color: string): void {
const { g } = this;
g.fillStyle = color;
g.fillRect(0, 0, this.c.width, this.c.height);
}
fillRect(x: number, y: number, w: number, h: number, color: string): void {
const { g } = this;
g.fillStyle = color;
g.fillRect(x, y, w, h);
}
fillCirc(x: number, y: number, radius: number, color: string): void {
const { g } = this;
g.fillStyle = color;
g.beginPath();
g.arc(x, y, radius, 0, Math.PI * 2);
g.fill();
}
putImageData(data: ImageData, x: number, y: number, _hash = 0n): void {
const { g } = this;
g.putImageData(data, x, y);
}
}
export class CanvasMouse implements Mouse {
public x = 0;
public y = 0;
private pressHandlers: EvHandler[] = [];
private releaseHandlers: EvHandler[] = [];
private moveHandlers: EvHandler[] = [];
private scrollHandlers: ((direction: "up" | "down") => EvHandlerRes)[] = [];
constructor(c: HTMLCanvasElement) {
c.onmousemove = (ev) => {
this.x = ev.x;
this.y = ev.y;
};
c.onmousedown = (ev) => {
if (ev.button === 0) {
this.runHandlers(this.pressHandlers);
}
};
c.onmouseup = (ev) => {
if (ev.button === 0) {
this.runHandlers(this.releaseHandlers);
}
};
c.onmousemove = (ev) => {
this.x = ev.x;
this.y = ev.y;
this.runHandlers(this.moveHandlers);
};
c.onwheel = (ev) => {
if (ev.deltaY !== 0) {
this.runHandlers(
this.scrollHandlers,
ev.deltaY < 0 ? "up" : "down",
);
}
};
}
private runHandlers<Args extends unknown[] = []>(
handlers: EvHandler<Args>[],
...args: Args
) {
for (const handler of handlers.toReversed()) {
if (handler(...args) === "stop") {
break;
}
}
}
addOnPress(handler: EvHandler) {
this.pressHandlers.push(handler);
}
addOnRelease(handler: EvHandler) {
this.releaseHandlers.push(handler);
}
addOnMove(handler: EvHandler) {
this.moveHandlers.push(handler);
}
addOnScroll(handler: EvHandler<["up" | "down"]>) {
this.scrollHandlers.push(handler);
}
}

219
src/grid.ts Normal file
View File

@ -0,0 +1,219 @@
import { Mouse } from "./input.ts";
import { Renderer } from "./renderer.ts";
export class Grid {
private t: Transformation = { s: 2, ox: 0, oy: 0 };
private transformer = new Transformer(this.t);
private tr = new TransformingRenderer(this.t);
constructor(
private mouse: Mouse,
) {
this.mouse.addOnPress(() => {
this.transformer.startPan();
return "stop";
});
this.mouse.addOnRelease(() => {
this.transformer.endPan();
return "stop";
});
this.mouse.addOnMove(() => {
const { x, y } = this.mouse;
this.transformer.move(x, y);
return "stop";
});
this.mouse.addOnScroll((direction) => {
if (direction === "up") {
this.transformer.zoomIn();
} else {
this.transformer.zoomOut();
}
this.tr.clearCache();
return "stop";
});
}
render(r: Renderer, renderTransformed: (r: Renderer) => void) {
this.transformer.updateCanvas(r.width, r.height);
this.drawGrid(r);
this.tr.render(r, (tr) => {
renderTransformed(tr);
});
}
private drawGrid(r: Renderer) {
const { t: { s, ox, oy } } = this;
r.clear("#ddd");
const dotSpace = 32 * s;
const dotOffsetX = ox % dotSpace - dotSpace;
const dotOffsetY = oy % dotSpace - dotSpace;
const dotWidth = r.width / dotSpace + 1;
const dotHeight = r.height / dotSpace + 1;
if (s < 0.2) {
return;
}
for (let y = 0; y < Math.min(dotHeight, 200); ++y) {
for (let x = 0; x < Math.min(dotWidth, 200); ++x) {
r.fillCirc(
x * dotSpace + dotOffsetX + 16 * s,
y * dotSpace + dotOffsetY + 16 * s,
2 * s,
"#bbb",
);
}
}
}
}
// offset is screen offset
type Transformation = {
s: number;
ox: number;
oy: number;
};
class Transformer {
private isPanning = false;
private lastMouseX = 0;
private lastMouseY = 0;
private lastCanvasWidth = 0;
private lastCanvasHeight = 0;
constructor(
private t: Transformation,
) {}
updateCanvas(width: number, height: number) {
this.lastCanvasWidth = width;
this.lastCanvasHeight = height;
}
startPan() {
this.isPanning = true;
}
endPan() {
this.isPanning = false;
}
move(x: number, y: number) {
const deltaX = x - this.lastMouseX;
const deltaY = y - this.lastMouseY;
this.lastMouseX = x;
this.lastMouseY = y;
if (this.isPanning) {
this.t.ox += deltaX;
this.t.oy += deltaY;
}
}
zoomIn() {
this.zoom(1 - 1 / 1.1);
}
zoomOut() {
this.zoom(1 - 1.1);
}
private zoom(factor: number) {
this.t.s *= 1 + factor;
const mouseRatioX = (this.lastMouseX - this.t.ox) /
this.lastCanvasWidth;
const mouseRatioY = (this.lastMouseY - this.t.oy) /
this.lastCanvasHeight;
const deltaOffsetX = this.lastCanvasWidth * factor *
mouseRatioX * -1;
const deltaOffsetY = this.lastCanvasHeight * factor *
mouseRatioY * -1;
this.t.ox += deltaOffsetX;
this.t.oy += deltaOffsetY;
}
}
class TransformingRenderer implements Renderer {
private imageDataCache = new Map<bigint, ImageData>();
private r!: Renderer;
constructor(
private t: Transformation,
) {}
render(r: Renderer, renderTransformed: (r: Renderer) => void): void {
this.r = r;
renderTransformed(this);
}
clearCache() {
this.imageDataCache.clear();
}
get width(): number {
return this.r.width / this.t.s;
}
get height(): number {
return this.r.height / this.t.s;
}
clear(color: string): void {
this.r.clear(color);
}
fillRect(x: number, y: number, w: number, h: number, color: string): void {
const { r, t: { s, ox, oy } } = this;
r.fillRect(x * s + ox, y * s + oy, w * s, h * s, color);
}
fillCirc(x: number, y: number, radius: number, color: string): void {
const { r, t: { s, ox, oy } } = this;
r.fillCirc(x * s + ox, y * s + oy, radius * s, color);
}
putImageData(data: ImageData, x: number, y: number, hash = 0n): void {
const { r, t: { s, ox, oy } } = this;
if (hash === 0n || !this.imageDataCache.has(hash)) {
const scaledWidth = data.width * s;
const scaledHeight = data.height * s;
const canvas = new OffscreenCanvas(
Math.max(data.width, scaledWidth),
Math.max(data.height, scaledHeight),
);
const ctx = canvas.getContext("2d")!;
ctx.putImageData(data, 0, 0);
ctx.scale(s, s);
const scaledData = ctx.getImageData(
0,
0,
scaledWidth,
scaledHeight,
);
if (hash === 0n) {
r.putImageData(
scaledData,
x * s + ox,
y * s + oy,
);
return;
}
if (this.imageDataCache.size > 128) {
this.imageDataCache.delete(
this.imageDataCache.keys().take(1).toArray()[0],
);
}
this.imageDataCache.set(hash, scaledData);
console.log(this.imageDataCache);
}
r.putImageData(
this.imageDataCache.get(hash)!,
x * s + ox,
y * s + oy,
);
}
}

14
src/input.ts Normal file
View File

@ -0,0 +1,14 @@
export type EvHandlerRes = "bubble" | "stop";
export type EvHandler<Args extends unknown[] = []> = (
...args: Args
) => EvHandlerRes;
export interface Mouse {
get x(): number;
get y(): number;
addOnPress(handler: EvHandler): void;
addOnRelease(handler: EvHandler): void;
addOnMove(handler: EvHandler): void;
addOnScroll(handler: EvHandler<["up" | "down"]>): void;
}

View File

@ -1,9 +1,23 @@
import { CanvasMouse, CanvasRenderer } from "./canvas.ts";
import { Simulator } from "./simulator.ts";
const c = document.querySelector<HTMLCanvasElement>("canvas#editor")!;
const g = c.getContext("2d")!;
const g = c.getContext("2d", { alpha: false })!;
c.width = document.body.clientWidth;
c.height = document.body.clientHeight;
c.style.position = "absolute";
g.fillStyle = "#aa2233";
g.fillRect(0, 0, c.width, c.height);
const r = new CanvasRenderer(c, g);
const mouse = new CanvasMouse(c);
const simulator = new Simulator(mouse);
function render() {
simulator.render(r);
requestAnimationFrame(() => {
render();
});
}
render();

52
src/painter.ts Normal file
View File

@ -0,0 +1,52 @@
import { CanvasRenderer } from "./canvas.ts";
import { Renderer } from "./renderer.ts";
export class Painter implements Renderer {
private c: OffscreenCanvas;
private g: OffscreenCanvasRenderingContext2D;
private r: Renderer;
private hash: bigint = 0n;
constructor(width: number, height: number) {
this.c = new OffscreenCanvas(width, height);
this.g = this.c.getContext("2d", { alpha: false })!;
this.r = new CanvasRenderer(
this.c as unknown as HTMLCanvasElement,
this.g as unknown as CanvasRenderingContext2D,
);
this.rehash();
}
private rehash() {
this.hash = BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER));
}
render(r: Renderer, x: number, y: number) {
const data = this.g.getImageData(0, 0, this.c.width, this.c.height);
r.putImageData(data, x, y, this.hash);
}
get width(): number {
return this.r.width;
}
get height(): number {
return this.r.height;
}
clear(color: string): void {
this.rehash();
this.r.clear(color);
}
fillRect(x: number, y: number, w: number, h: number, color: string): void {
this.rehash();
this.r.fillRect(x, y, w, h, color);
}
fillCirc(x: number, y: number, radius: number, color: string): void {
this.rehash();
this.r.fillCirc(x, y, radius, color);
}
putImageData(data: ImageData, x: number, y: number, hash?: bigint): void {
this.rehash();
this.r.putImageData(data, x, y, hash);
}
}

10
src/renderer.ts Normal file
View File

@ -0,0 +1,10 @@
type N = number;
export interface Renderer {
get width(): N;
get height(): N;
clear(color: string): void;
fillRect(x: N, y: N, w: N, h: N, color: string): void;
fillCirc(x: N, y: N, radius: N, color: string): void;
putImageData(data: ImageData, x: N, y: N, hash?: bigint): void;
}

39
src/simulator.ts Normal file
View File

@ -0,0 +1,39 @@
import { Grid } from "./grid.ts";
import { Mouse } from "./input.ts";
import { Painter } from "./painter.ts";
import { Renderer } from "./renderer.ts";
export class Simulator {
private grid: Grid;
private myComponent = new Component();
constructor(
private mouse: Mouse,
) {
this.grid = new Grid(this.mouse);
}
render(r: Renderer) {
r.clear("black");
this.grid.render(r, (r) => {
this.myComponent.render(r);
});
}
}
class Circuit {
private components: Component[] = [];
}
class Component {
private p = new Painter(64, 64);
constructor() {
this.p.clear("black");
}
render(r: Renderer) {
this.p.render(r, 0, 0);
}
}

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="style.css">
<script src="bundle.js" defer></script>
<script src="bundle.js" type="module" defer></script>
<title>LogiCirc</title>
</head>
<body>