Compare commits

...

3 Commits

Author SHA1 Message Date
sfja
e17953ae4a add editor 2025-05-06 17:36:53 +02:00
sfja
c1c6e145a0 add deno web project 2025-05-06 13:11:24 +02:00
sfja
bd6c3b7e5c revise kravsspecifikation 2025-05-06 13:11:11 +02:00
15 changed files with 680 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
build/

51
bundle.ts Normal file
View File

@ -0,0 +1,51 @@
import * as esbuild from "npm:esbuild@0.20.2";
import { denoPlugins } from "jsr:@luca/esbuild-deno-loader@^0.11.0";
async function buildCode() {
await esbuild.build({
plugins: [...denoPlugins()],
entryPoints: ["./src/main.ts"],
outfile: "./build/bundle.js",
bundle: true,
format: "esm",
});
esbuild.stop();
}
async function copyStatic(path: string[] = []) {
const dir = path.join("/");
await Deno.mkdir("build/" + dir).catch((_) => _);
for await (const file of Deno.readDir(`static/${dir}`)) {
if (file.isDirectory) {
await copyStatic([...path, file.name]);
continue;
}
await Deno.copyFile(
`static/${dir}/${file.name}`,
`build/${dir}/${file.name}`,
);
}
}
type BundleOptions = {
quiet?: boolean;
};
export async function bundle(options?: BundleOptions) {
if (!options?.quiet) {
console.log("info: copying static files");
}
await copyStatic();
if (!options?.quiet) {
console.log("info: building code");
}
await buildCode();
if (!options?.quiet) {
console.log("success: output in 'build/'");
}
}
if (import.meta.main) {
await bundle();
}

13
deno.jsonc Normal file
View File

@ -0,0 +1,13 @@
{
"tasks": {
"bundle": "deno run --allow-read --allow-write --allow-env --allow-run bundle.ts",
"dev": "deno run --allow-net --allow-read --allow-write --allow-env --allow-run dev.ts"
},
"compilerOptions": {
"lib": ["dom", "dom.iterable", "dom.asynciterable", "deno.ns"]
},
"fmt": {
"indentWidth": 4
}
}

128
deno.lock Normal file
View File

@ -0,0 +1,128 @@
{
"version": "4",
"specifiers": {
"jsr:@luca/esbuild-deno-loader@0.11": "0.11.1",
"jsr:@std/bytes@^1.0.2": "1.0.4",
"jsr:@std/encoding@^1.0.5": "1.0.5",
"jsr:@std/path@^1.0.6": "1.0.8",
"npm:esbuild@0.20.2": "0.20.2"
},
"jsr": {
"@luca/esbuild-deno-loader@0.11.1": {
"integrity": "dc020d16d75b591f679f6b9288b10f38bdb4f24345edb2f5732affa1d9885267",
"dependencies": [
"jsr:@std/bytes",
"jsr:@std/encoding",
"jsr:@std/path"
]
},
"@std/bytes@1.0.4": {
"integrity": "11a0debe522707c95c7b7ef89b478c13fb1583a7cfb9a85674cd2cc2e3a28abc"
},
"@std/encoding@1.0.5": {
"integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04"
},
"@std/path@1.0.8": {
"integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be"
}
},
"npm": {
"@esbuild/aix-ppc64@0.20.2": {
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g=="
},
"@esbuild/android-arm64@0.20.2": {
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg=="
},
"@esbuild/android-arm@0.20.2": {
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w=="
},
"@esbuild/android-x64@0.20.2": {
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg=="
},
"@esbuild/darwin-arm64@0.20.2": {
"integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA=="
},
"@esbuild/darwin-x64@0.20.2": {
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA=="
},
"@esbuild/freebsd-arm64@0.20.2": {
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw=="
},
"@esbuild/freebsd-x64@0.20.2": {
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw=="
},
"@esbuild/linux-arm64@0.20.2": {
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A=="
},
"@esbuild/linux-arm@0.20.2": {
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg=="
},
"@esbuild/linux-ia32@0.20.2": {
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig=="
},
"@esbuild/linux-loong64@0.20.2": {
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ=="
},
"@esbuild/linux-mips64el@0.20.2": {
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA=="
},
"@esbuild/linux-ppc64@0.20.2": {
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg=="
},
"@esbuild/linux-riscv64@0.20.2": {
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg=="
},
"@esbuild/linux-s390x@0.20.2": {
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ=="
},
"@esbuild/linux-x64@0.20.2": {
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw=="
},
"@esbuild/netbsd-x64@0.20.2": {
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ=="
},
"@esbuild/openbsd-x64@0.20.2": {
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ=="
},
"@esbuild/sunos-x64@0.20.2": {
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w=="
},
"@esbuild/win32-arm64@0.20.2": {
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ=="
},
"@esbuild/win32-ia32@0.20.2": {
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ=="
},
"@esbuild/win32-x64@0.20.2": {
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ=="
},
"esbuild@0.20.2": {
"integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==",
"dependencies": [
"@esbuild/aix-ppc64",
"@esbuild/android-arm",
"@esbuild/android-arm64",
"@esbuild/android-x64",
"@esbuild/darwin-arm64",
"@esbuild/darwin-x64",
"@esbuild/freebsd-arm64",
"@esbuild/freebsd-x64",
"@esbuild/linux-arm",
"@esbuild/linux-arm64",
"@esbuild/linux-ia32",
"@esbuild/linux-loong64",
"@esbuild/linux-mips64el",
"@esbuild/linux-ppc64",
"@esbuild/linux-riscv64",
"@esbuild/linux-s390x",
"@esbuild/linux-x64",
"@esbuild/netbsd-x64",
"@esbuild/openbsd-x64",
"@esbuild/sunos-x64",
"@esbuild/win32-arm64",
"@esbuild/win32-ia32",
"@esbuild/win32-x64"
]
}
}
}

Binary file not shown.

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

23
src/main.ts Normal file
View File

@ -0,0 +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", { alpha: false })!;
c.width = document.body.clientWidth;
c.height = document.body.clientHeight;
c.style.position = "absolute";
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);
}
}

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

13
static/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="style.css">
<script src="bundle.js" type="module" defer></script>
<title>LogiCirc</title>
</head>
<body>
<canvas id="editor"></canvas>
</body>
</html>

11
static/style.css Normal file
View File

@ -0,0 +1,11 @@
:root {
color-scheme: light dark;
}
body {
margin: 0;
height: 100vh;
}