commit 52b3f8c1a9fda7a289553f90c50b98f425626fb1 Author: sfja Date: Wed Apr 16 02:33:08 2025 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/bundle.ts b/bundle.ts new file mode 100644 index 0000000..57bb320 --- /dev/null +++ b/bundle.ts @@ -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: "./dist/bundle.js", + bundle: true, + format: "esm", + }); + + esbuild.stop(); +} + +async function copyStatic(path: string[] = []) { + const dir = path.join("/"); + await Deno.mkdir("dist/" + 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}`, + `dist/${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 'dist/'"); + } +} + +if (import.meta.main) { + await bundle(); +} diff --git a/deno.jsonc b/deno.jsonc new file mode 100644 index 0000000..1fc00d2 --- /dev/null +++ b/deno.jsonc @@ -0,0 +1,12 @@ +{ + "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 + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..ae549f2 --- /dev/null +++ b/deno.lock @@ -0,0 +1,171 @@ +{ + "version": "4", + "specifiers": { + "jsr:@luca/esbuild-deno-loader@0.11": "0.11.0", + "jsr:@std/bytes@^1.0.2": "1.0.4", + "jsr:@std/cli@^1.0.12": "1.0.13", + "jsr:@std/encoding@^1.0.5": "1.0.7", + "jsr:@std/encoding@^1.0.7": "1.0.7", + "jsr:@std/fmt@^1.0.5": "1.0.5", + "jsr:@std/html@^1.0.3": "1.0.3", + "jsr:@std/http@*": "1.0.13", + "jsr:@std/media-types@^1.1.0": "1.1.0", + "jsr:@std/net@^1.0.4": "1.0.4", + "jsr:@std/path@^1.0.6": "1.0.8", + "jsr:@std/path@^1.0.8": "1.0.8", + "jsr:@std/streams@^1.0.9": "1.0.9", + "npm:esbuild@0.20.2": "0.20.2" + }, + "jsr": { + "@luca/esbuild-deno-loader@0.11.0": { + "integrity": "c05a989aa7c4ee6992a27be5f15cfc5be12834cab7ff84cabb47313737c51a2c", + "dependencies": [ + "jsr:@std/bytes", + "jsr:@std/encoding@^1.0.5", + "jsr:@std/path@^1.0.6" + ] + }, + "@std/bytes@1.0.4": { + "integrity": "11a0debe522707c95c7b7ef89b478c13fb1583a7cfb9a85674cd2cc2e3a28abc" + }, + "@std/cli@1.0.13": { + "integrity": "5db2d95ab2dca3bca9fb6ad3c19908c314e93d6391c8b026725e4892d4615a69" + }, + "@std/encoding@1.0.5": { + "integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04" + }, + "@std/encoding@1.0.7": { + "integrity": "f631247c1698fef289f2de9e2a33d571e46133b38d042905e3eac3715030a82d" + }, + "@std/fmt@1.0.5": { + "integrity": "0cfab43364bc36650d83c425cd6d99910fc20c4576631149f0f987eddede1a4d" + }, + "@std/html@1.0.3": { + "integrity": "7a0ac35e050431fb49d44e61c8b8aac1ebd55937e0dc9ec6409aa4bab39a7988" + }, + "@std/http@1.0.13": { + "integrity": "d29618b982f7ae44380111f7e5b43da59b15db64101198bb5f77100d44eb1e1e", + "dependencies": [ + "jsr:@std/cli", + "jsr:@std/encoding@^1.0.7", + "jsr:@std/fmt", + "jsr:@std/html", + "jsr:@std/media-types", + "jsr:@std/net", + "jsr:@std/path@^1.0.8", + "jsr:@std/streams" + ] + }, + "@std/media-types@1.1.0": { + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" + }, + "@std/net@1.0.4": { + "integrity": "2f403b455ebbccf83d8a027d29c5a9e3a2452fea39bb2da7f2c04af09c8bc852" + }, + "@std/path@1.0.8": { + "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" + }, + "@std/streams@1.0.9": { + "integrity": "a9d26b1988cdd7aa7b1f4b51e1c36c1557f3f252880fa6cc5b9f37078b1a5035" + } + }, + "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" + ] + } + } +} diff --git a/dev.ts b/dev.ts new file mode 100644 index 0000000..5569bd2 --- /dev/null +++ b/dev.ts @@ -0,0 +1,66 @@ +import { serveDir } from "jsr:@std/http/file-server"; +import { bundle } from "./bundle.ts"; + +function listening(addr: Addr) { + console.log(`Listening on http://${addr.hostname}:${addr.port}/`); +} + +type Addr = { + hostname: string; + port: number; +}; + +async function check() { + const command = new Deno.Command("deno", { + args: ["check", "src"], + stdout: "piped", + }); + const process = command.spawn(); + const output = await process.output(); + await Deno.stdout.write(output.stdout); +} + +async function watchAndBundle(addr: Addr) { + let changeOccurred = true; + let running = false; + setInterval(async () => { + if (!changeOccurred || running) { + return; + } + running = true; + console.clear(); + await bundle().catch((err) => console.error(`bundle: ${err}`)); + await check(); + listening(addr); + changeOccurred = false; + running = false; + }, 250); + const watcher = Deno.watchFs(["src", "static"]); + for await (const _ of watcher) { + changeOccurred = true; + } +} + +function serveDist(addr: Addr) { + Deno.serve({ + port: addr.port, + hostname: addr.hostname, + onListen: (_) => listening(addr), + }, (req: Request) => { + return serveDir(req, { + fsRoot: "dist", + urlRoot: "", + quiet: true, + }); + }); +} + +if (import.meta.main) { + const addr = { + hostname: "0.0.0.0", + port: 8432, + }; + await bundle(); + watchAndBundle(addr); + serveDist(addr); +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..dccd1ba --- /dev/null +++ b/src/main.ts @@ -0,0 +1,525 @@ +export function renderComponent( + r: Renderer, + cellX: number, + cellY: number, + inputs: number, + outputs: number, + text: string, + color: string, + alpha = "ff", +) { + const c = (str: string) => `${str}${alpha}`; + + const width = 96; + const height = Math.max(16 * Math.max(inputs, outputs), 32); + const x = cellX - width / 2 + 16; + const y = cellY - height / 2 + 16; + + r.setColor(c(color)); + r.fillRect(x, y, width, height); + + r.setColor("#333"); + for (let i = 0; i < inputs; ++i) { + r.fillCircle(x, y + i * (height / inputs) + height / inputs / 2, 4); + } + for (let i = 0; i < outputs; ++i) { + r.fillCircle( + x + width, + y + i * (height / outputs) + height / outputs / 2, + 4, + ); + } + r.setColor("#ddd"); + r.write(x + width / 2 - (text.length * 12) / 2, cellY + 8, text, 16); +} + +export class PnpTransistor implements Component { + render(r: Renderer, x: number, y: number): void { + renderComponent(r, x, y, 2, 4, "PNP", "#1144aa", "ff"); + } + + renderTransparent(r: Renderer, x: number, y: number): void { + renderComponent(r, x, y, 2, 4, "PNP", "#1144aa", "77"); + } +} + +export type ComponentPlacement = { + x: number; + y: number; + component: Component; +}; + +export class Board { + private components: ComponentPlacement[] = []; + + constructor(private mouse: Mouse) { + } + + placeComponent(x: number, y: number, component: Component) { + const existing = this.components + .some((comp) => comp.x === x && comp.y === y); + if (existing) { + return; + } + this.components.push({ x, y, component }); + } + + render(r: Renderer) { + for (const comp of this.components) { + comp.component.render(r, comp.x, comp.y); + } + } + + click(x: number, y: number): "handled" | void { + } +} + +export interface Component { + render(r: Renderer, x: number, y: number): void; + renderTransparent(r: Renderer, x: number, y: number): void; +} + +export class Tooltip { + private selected?: Component; + + constructor( + private mouse: Mouse, + private board: Board, + ) { + this.selected = new PnpTransistor(); + } + + render(r: Renderer) { + if (!this.selected) { + return; + } + + const cellSize = 32; + const cellX = this.mouse.x + (cellSize - this.mouse.x % cellSize) - + cellSize; + const cellY = this.mouse.y + (cellSize - this.mouse.y % cellSize) - + cellSize; + + this.selected.renderTransparent(r, cellX, cellY); + } + + click(x: number, y: number): "handled" | void { + if (!this.selected) { + return; + } + + const cellSize = 32; + const cellX = x + (cellSize - x % cellSize) - + cellSize; + const cellY = y + (cellSize - y % cellSize) - + cellSize; + + this.board.placeComponent(cellX, cellY, this.selected); + this.selected = undefined; + return "handled"; + } + + selectComponent(component: Component) { + this.selected = component; + } + + clear() { + this.selected = undefined; + } +} + +export class Toolbar { + private components: Component[] = [ + new PnpTransistor(), + ]; + + private renderedX = 0; + private renderedY = 0; + private renderedWidth = 0; + private renderedHeight = 0; + + constructor( + private tooltip: Tooltip, + ) {} + + render(r: Renderer) { + this.renderedX = 0; + this.renderedY = r.height - 128; + this.renderedWidth = r.width; + this.renderedHeight = 128; + + r.setColor("#eee"); + r.fillRectFixed( + this.renderedX, + this.renderedY, + this.renderedWidth, + this.renderedHeight, + ); + for (const [i, comp] of this.components.entries()) { + const x = i * 64 + 64; + const y = r.height - 128 + 48; + comp.render(r, x, y); + } + } + + click(x: number, y: number): "handled" | void { + if ( + x < this.renderedX || x >= this.renderedX + this.renderedWidth || + y < this.renderedY || y >= this.renderedY + this.renderedHeight + ) { + return; + } + for (const [i, comp] of this.components.entries()) { + const cx = i * 64 + 64; + const cy = this.renderedY + 48; + if (x >= cx && x < cx + 64 && y >= cy && cy < cy + 64) { + this.tooltip.selectComponent(comp); + return "handled"; + } + } + this.tooltip.clear(); + return "handled"; + } +} + +export class Simulator { + private board: Board; + private tooltip: Tooltip; + private toolbar: Toolbar; + + constructor(private mouse: Mouse) { + this.board = new Board(mouse); + this.tooltip = new Tooltip(mouse, this.board); + this.toolbar = new Toolbar(this.tooltip); + } + + render(r: Renderer) { + this.board.render(r); + this.toolbar.render(r); + this.tooltip.render(r); + } + + click(x: number, y: number): "handled" | void { + if (this.toolbar.click(x, y) === "handled") { + return; + } + if (this.tooltip.click(x, y) === "handled") { + return; + } + if (this.board.click(x, y) === "handled") { + return; + } + } +} + +export interface Mouse { + get x(): number; + get y(): number; +} + +export interface Renderer { + setColor(color: string): void; + fillRect(x: number, y: number, width: number, height: number): void; + fillCircle(x: number, y: number, radius: number): void; + write(x: number, y: number, text: string, fontSize: number): void; + get width(): number; + get height(): number; + fillRectFixed(x: number, y: number, width: number, height: number): void; +} + +export class CanvasMouse implements Mouse { + constructor( + private canvasGrid: Canvas, + ) {} + get x(): number { + return this.canvasGrid.mouseX(); + } + get y(): number { + return this.canvasGrid.mouseY(); + } +} + +export class Canvas { + private mouseScreenX = 0; + private mouseScreenY = 0; + + private scale = 2.0; + private screenOffsetX = 0; + private screenOffsetY = 0; + + private isPanning = false; + + private zoomRate = 1.1; + + private onclickHandler?: (x: number, y: number) => "handled" | void; + + constructor( + private canvas: HTMLCanvasElement, + private graphics: CanvasRenderingContext2D, + ) { + canvas.onwheel = (event) => { + if (event.deltaY < 0) { + this.zoomOut(); + } else { + this.zoomIn(); + } + }; + canvas.onmousemove = (event) => { + this.mouseMove(event.clientX, event.clientY); + }; + canvas.onmousedown = (_event) => { + if (this.click() === "handled") { + return; + } + this.panStart(); + }; + canvas.onmouseup = (_event) => { + this.panEnd(); + }; + canvas.onmouseleave = (_event) => { + this.panEnd(); + }; + } + + mouse(): Mouse { + return new CanvasMouse(this); + } + + mouseX() { + return (this.mouseScreenX - this.screenOffsetX) / this.scale; + } + + mouseY() { + return (this.mouseScreenY - this.screenOffsetY) / this.scale; + } + + setColor(color: string): void { + const { + graphics: g, + } = this; + g.fillStyle = color; + g.strokeStyle = color; + } + + fillRect(x: number, y: number, width: number, height: number): void { + const { + graphics: g, + scale: s, + screenOffsetX: ox, + screenOffsetY: oy, + } = this; + g.fillRect( + s * x + ox, + s * y + oy, + width * s, + height * s, + ); + } + + fillCircle(x: number, y: number, radius: number): void { + const { + graphics: g, + scale: s, + screenOffsetX: ox, + screenOffsetY: oy, + } = this; + g.beginPath(); + g.arc( + s * x + ox, + s * y + oy, + radius * s, + 0, + Math.PI * 2, + ); + g.fill(); + } + + write(x: number, y: number, text: string, fontSize: number): void { + const { + graphics: g, + screenOffsetX: ox, + screenOffsetY: oy, + scale: s, + } = this; + + g.font = `bold ${fontSize * this.scale}px sans-serif`; + g.fillText(text, x * s + ox, y * s + oy + fontSize * this.scale); + } + + get width(): number { + const { scale: s } = this; + return this.canvas.width / s; + } + + get height(): number { + const { scale: s } = this; + return this.canvas.height / s; + } + + fillRectFixed( + x: number, + y: number, + width: number, + height: number, + ): void { + const { + graphics: g, + scale: s, + } = this; + g.fillRect( + s * x, + s * y, + width * s, + height * s, + ); + } + + beginRender() { + const g = this.graphics; + g.fillStyle = "#ddd"; + g.fillRect(0, 0, this.canvas.width, this.canvas.height); + + const dotSpace = 32 * this.scale; + const dotOffsetX = this.screenOffsetX % dotSpace - dotSpace; + const dotOffsetY = this.screenOffsetY % dotSpace - dotSpace; + const dotWidth = this.canvas.width / dotSpace + 1; + const dotHeight = this.canvas.height / dotSpace + 1; + + // g.strokeStyle = "#bbb"; + // g.lineWidth = 1; + // g.beginPath(); + // for (let i = 0; i < dotWidth; ++i) { + // g.moveTo(dotOffsetX + i * dotSpace, 0); + // g.lineTo(dotOffsetX + i * dotSpace, this.screenHeight); + // } + // for (let i = 0; i < dotHeight; ++i) { + // g.moveTo(0, dotOffsetY + i * dotSpace); + // g.lineTo(this.screenWidth, dotOffsetY + i * dotSpace); + // } + // g.stroke(); + + g.fillStyle = "#bbb"; + for (let y = 0; y < dotHeight; ++y) { + for (let x = 0; x < dotWidth; ++x) { + g.beginPath(); + g.arc( + dotOffsetX + x * dotSpace + 16 * this.scale, + dotOffsetY + y * dotSpace + 16 * this.scale, + 2 * this.scale, + 0, + Math.PI * 2, + ); + g.fill(); + } + } + } + + endRender() {} + + private zoomOut() { + this.scale *= this.zoomRate; + + const mouseRatioX = (this.mouseScreenX - this.screenOffsetX) / + this.canvas.width; + const mouseRatioY = (this.mouseScreenY - this.screenOffsetY) / + this.canvas.height; + + const deltaOffsetX = this.canvas.width * (this.zoomRate - 1) * + mouseRatioX * -1; + const deltaOffsetY = this.canvas.height * (this.zoomRate - 1) * + mouseRatioY * -1; + + this.screenOffsetX += deltaOffsetX; + this.screenOffsetY += deltaOffsetY; + } + + private zoomIn() { + this.scale /= this.zoomRate; + + const mouseRatioX = (this.mouseScreenX - this.screenOffsetX) / + this.canvas.width; + const mouseRatioY = (this.mouseScreenY - this.screenOffsetY) / + this.canvas.height; + + const deltaOffsetX = this.canvas.width * (1 - 1 / this.zoomRate) * + mouseRatioX * -1; + const deltaOffsetY = this.canvas.height * (1 - 1 / this.zoomRate) * + mouseRatioY * -1; + + this.screenOffsetX -= deltaOffsetX; + this.screenOffsetY -= deltaOffsetY; + } + + private panStart() { + this.isPanning = true; + } + + private panEnd() { + this.isPanning = false; + } + + private mouseMove(x: number, y: number) { + const deltaX = x - this.mouseScreenX; + const deltaY = y - this.mouseScreenY; + this.mouseScreenX = x; + this.mouseScreenY = y; + if (this.isPanning) { + this.screenOffsetX += deltaX; + this.screenOffsetY += deltaY; + } + } + + set onclick(handler: (x: number, y: number) => "handled" | void) { + this.onclickHandler = handler; + } + + private click(): "handled" | void { + return this.onclickHandler?.(this.mouseX(), this.mouseY()); + } +} + +function lerp( + value: number, + oldMin: number, + oldMax: number, + newMin: number, + newMax: number, +) { + return (value - oldMin) / (oldMax - oldMin) * (newMax - newMin) + newMin; +} + +function startSimulator() { + const htmlCanvas = document.querySelector("#canvas")!; + + const graphics = htmlCanvas.getContext("2d")!; + + htmlCanvas.width = htmlCanvas.clientWidth; + htmlCanvas.height = htmlCanvas.clientHeight; + + graphics.fillStyle = "black"; + graphics.fillRect(0, 0, htmlCanvas.width, htmlCanvas.height); + + const canvas = new Canvas( + htmlCanvas, + graphics, + ); + + const simulator = new Simulator(canvas.mouse()); + + canvas.onclick = (x, y) => { + simulator.click(x, y); + }; + + const updateLoop = () => { + htmlCanvas.width = htmlCanvas.clientWidth; + htmlCanvas.height = htmlCanvas.clientHeight; + + canvas.beginRender(); + simulator.render(canvas); + canvas.endRender(); + requestAnimationFrame(() => updateLoop()); + }; + updateLoop(); +} + +startSimulator(); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..bfb14a9 --- /dev/null +++ b/static/index.html @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..acae4cf --- /dev/null +++ b/static/style.css @@ -0,0 +1,19 @@ +:root { + color-scheme: light dark; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0px; + height: 100vh; + overflow: hidden; +} + +canvas#canvas { + margin: 0px; + width: 100vw; + height: 100vh; +}