This commit is contained in:
sfja 2025-04-16 02:33:08 +02:00
commit 52b3f8c1a9
8 changed files with 859 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
dist/

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: "./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();
}

12
deno.jsonc Normal file
View File

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

171
deno.lock generated Normal file
View File

@ -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"
]
}
}
}

66
dev.ts Normal file
View File

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

525
src/main.ts Normal file
View File

@ -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<HTMLCanvasElement>("#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();

14
static/index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="style.css">
<script src="bundle.js" type="module" defer></script>
</head>
<body>
<script>
0;
</script>
<canvas id="canvas"></canvas>
</body>
</html>

19
static/style.css Normal file
View File

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