init
This commit is contained in:
commit
52b3f8c1a9
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
dist/
|
51
bundle.ts
Normal file
51
bundle.ts
Normal 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
12
deno.jsonc
Normal 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
171
deno.lock
generated
Normal 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
66
dev.ts
Normal 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
525
src/main.ts
Normal 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
14
static/index.html
Normal 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
19
static/style.css
Normal 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;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user