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