diff --git a/src/canvas.ts b/src/canvas.ts new file mode 100644 index 0000000..47c6684 --- /dev/null +++ b/src/canvas.ts @@ -0,0 +1,105 @@ +import { EvHandler, EvHandlerRes, Mouse } from "./input.ts"; +import { Renderer } from "./renderer.ts"; + +export class CanvasRenderer implements Renderer { + constructor( + private c: HTMLCanvasElement, + private g: CanvasRenderingContext2D, + ) {} + + get width(): number { + return this.c.width; + } + get height(): number { + return this.c.height; + } + + clear(color: string): void { + const { g } = this; + g.fillStyle = color; + g.fillRect(0, 0, this.c.width, this.c.height); + } + + fillRect(x: number, y: number, w: number, h: number, color: string): void { + const { g } = this; + g.fillStyle = color; + g.fillRect(x, y, w, h); + } + + fillCirc(x: number, y: number, radius: number, color: string): void { + const { g } = this; + g.fillStyle = color; + g.beginPath(); + g.arc(x, y, radius, 0, Math.PI * 2); + g.fill(); + } + + putImageData(data: ImageData, x: number, y: number, _hash = 0n): void { + const { g } = this; + g.putImageData(data, x, y); + } +} + +export class CanvasMouse implements Mouse { + public x = 0; + public y = 0; + + private pressHandlers: EvHandler[] = []; + private releaseHandlers: EvHandler[] = []; + private moveHandlers: EvHandler[] = []; + private scrollHandlers: ((direction: "up" | "down") => EvHandlerRes)[] = []; + + constructor(c: HTMLCanvasElement) { + c.onmousemove = (ev) => { + this.x = ev.x; + this.y = ev.y; + }; + c.onmousedown = (ev) => { + if (ev.button === 0) { + this.runHandlers(this.pressHandlers); + } + }; + c.onmouseup = (ev) => { + if (ev.button === 0) { + this.runHandlers(this.releaseHandlers); + } + }; + c.onmousemove = (ev) => { + this.x = ev.x; + this.y = ev.y; + this.runHandlers(this.moveHandlers); + }; + c.onwheel = (ev) => { + if (ev.deltaY !== 0) { + this.runHandlers( + this.scrollHandlers, + ev.deltaY < 0 ? "up" : "down", + ); + } + }; + } + + private runHandlers( + handlers: EvHandler[], + ...args: Args + ) { + for (const handler of handlers.toReversed()) { + if (handler(...args) === "stop") { + break; + } + } + } + + addOnPress(handler: EvHandler) { + this.pressHandlers.push(handler); + } + addOnRelease(handler: EvHandler) { + this.releaseHandlers.push(handler); + } + addOnMove(handler: EvHandler) { + this.moveHandlers.push(handler); + } + addOnScroll(handler: EvHandler<["up" | "down"]>) { + this.scrollHandlers.push(handler); + } +} diff --git a/src/grid.ts b/src/grid.ts new file mode 100644 index 0000000..a9a2852 --- /dev/null +++ b/src/grid.ts @@ -0,0 +1,219 @@ +import { Mouse } from "./input.ts"; +import { Renderer } from "./renderer.ts"; + +export class Grid { + private t: Transformation = { s: 2, ox: 0, oy: 0 }; + private transformer = new Transformer(this.t); + private tr = new TransformingRenderer(this.t); + + constructor( + private mouse: Mouse, + ) { + this.mouse.addOnPress(() => { + this.transformer.startPan(); + return "stop"; + }); + this.mouse.addOnRelease(() => { + this.transformer.endPan(); + return "stop"; + }); + this.mouse.addOnMove(() => { + const { x, y } = this.mouse; + this.transformer.move(x, y); + return "stop"; + }); + this.mouse.addOnScroll((direction) => { + if (direction === "up") { + this.transformer.zoomIn(); + } else { + this.transformer.zoomOut(); + } + this.tr.clearCache(); + return "stop"; + }); + } + + render(r: Renderer, renderTransformed: (r: Renderer) => void) { + this.transformer.updateCanvas(r.width, r.height); + this.drawGrid(r); + this.tr.render(r, (tr) => { + renderTransformed(tr); + }); + } + + private drawGrid(r: Renderer) { + const { t: { s, ox, oy } } = this; + r.clear("#ddd"); + + const dotSpace = 32 * s; + + const dotOffsetX = ox % dotSpace - dotSpace; + const dotOffsetY = oy % dotSpace - dotSpace; + + const dotWidth = r.width / dotSpace + 1; + const dotHeight = r.height / dotSpace + 1; + + if (s < 0.2) { + return; + } + for (let y = 0; y < Math.min(dotHeight, 200); ++y) { + for (let x = 0; x < Math.min(dotWidth, 200); ++x) { + r.fillCirc( + x * dotSpace + dotOffsetX + 16 * s, + y * dotSpace + dotOffsetY + 16 * s, + 2 * s, + "#bbb", + ); + } + } + } +} + +// offset is screen offset +type Transformation = { + s: number; + ox: number; + oy: number; +}; + +class Transformer { + private isPanning = false; + + private lastMouseX = 0; + private lastMouseY = 0; + private lastCanvasWidth = 0; + private lastCanvasHeight = 0; + + constructor( + private t: Transformation, + ) {} + + updateCanvas(width: number, height: number) { + this.lastCanvasWidth = width; + this.lastCanvasHeight = height; + } + + startPan() { + this.isPanning = true; + } + + endPan() { + this.isPanning = false; + } + + move(x: number, y: number) { + const deltaX = x - this.lastMouseX; + const deltaY = y - this.lastMouseY; + this.lastMouseX = x; + this.lastMouseY = y; + if (this.isPanning) { + this.t.ox += deltaX; + this.t.oy += deltaY; + } + } + + zoomIn() { + this.zoom(1 - 1 / 1.1); + } + zoomOut() { + this.zoom(1 - 1.1); + } + + private zoom(factor: number) { + this.t.s *= 1 + factor; + + const mouseRatioX = (this.lastMouseX - this.t.ox) / + this.lastCanvasWidth; + const mouseRatioY = (this.lastMouseY - this.t.oy) / + this.lastCanvasHeight; + + const deltaOffsetX = this.lastCanvasWidth * factor * + mouseRatioX * -1; + const deltaOffsetY = this.lastCanvasHeight * factor * + mouseRatioY * -1; + + this.t.ox += deltaOffsetX; + this.t.oy += deltaOffsetY; + } +} + +class TransformingRenderer implements Renderer { + private imageDataCache = new Map(); + private r!: Renderer; + + constructor( + private t: Transformation, + ) {} + + render(r: Renderer, renderTransformed: (r: Renderer) => void): void { + this.r = r; + renderTransformed(this); + } + + clearCache() { + this.imageDataCache.clear(); + } + + get width(): number { + return this.r.width / this.t.s; + } + get height(): number { + return this.r.height / this.t.s; + } + clear(color: string): void { + this.r.clear(color); + } + fillRect(x: number, y: number, w: number, h: number, color: string): void { + const { r, t: { s, ox, oy } } = this; + r.fillRect(x * s + ox, y * s + oy, w * s, h * s, color); + } + fillCirc(x: number, y: number, radius: number, color: string): void { + const { r, t: { s, ox, oy } } = this; + r.fillCirc(x * s + ox, y * s + oy, radius * s, color); + } + putImageData(data: ImageData, x: number, y: number, hash = 0n): void { + const { r, t: { s, ox, oy } } = this; + + if (hash === 0n || !this.imageDataCache.has(hash)) { + const scaledWidth = data.width * s; + const scaledHeight = data.height * s; + + const canvas = new OffscreenCanvas( + Math.max(data.width, scaledWidth), + Math.max(data.height, scaledHeight), + ); + const ctx = canvas.getContext("2d")!; + ctx.putImageData(data, 0, 0); + ctx.scale(s, s); + + const scaledData = ctx.getImageData( + 0, + 0, + scaledWidth, + scaledHeight, + ); + + if (hash === 0n) { + r.putImageData( + scaledData, + x * s + ox, + y * s + oy, + ); + return; + } + if (this.imageDataCache.size > 128) { + this.imageDataCache.delete( + this.imageDataCache.keys().take(1).toArray()[0], + ); + } + this.imageDataCache.set(hash, scaledData); + console.log(this.imageDataCache); + } + + r.putImageData( + this.imageDataCache.get(hash)!, + x * s + ox, + y * s + oy, + ); + } +} diff --git a/src/input.ts b/src/input.ts new file mode 100644 index 0000000..d539774 --- /dev/null +++ b/src/input.ts @@ -0,0 +1,14 @@ +export type EvHandlerRes = "bubble" | "stop"; +export type EvHandler = ( + ...args: Args +) => EvHandlerRes; + +export interface Mouse { + get x(): number; + get y(): number; + + addOnPress(handler: EvHandler): void; + addOnRelease(handler: EvHandler): void; + addOnMove(handler: EvHandler): void; + addOnScroll(handler: EvHandler<["up" | "down"]>): void; +} diff --git a/src/main.ts b/src/main.ts index afb0a6d..375806a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,23 @@ +import { CanvasMouse, CanvasRenderer } from "./canvas.ts"; +import { Simulator } from "./simulator.ts"; + const c = document.querySelector("canvas#editor")!; -const g = c.getContext("2d")!; +const g = c.getContext("2d", { alpha: false })!; c.width = document.body.clientWidth; c.height = document.body.clientHeight; c.style.position = "absolute"; -g.fillStyle = "#aa2233"; -g.fillRect(0, 0, c.width, c.height); +const r = new CanvasRenderer(c, g); +const mouse = new CanvasMouse(c); + +const simulator = new Simulator(mouse); + +function render() { + simulator.render(r); + + requestAnimationFrame(() => { + render(); + }); +} +render(); diff --git a/src/painter.ts b/src/painter.ts new file mode 100644 index 0000000..d58ee11 --- /dev/null +++ b/src/painter.ts @@ -0,0 +1,52 @@ +import { CanvasRenderer } from "./canvas.ts"; +import { Renderer } from "./renderer.ts"; + +export class Painter implements Renderer { + private c: OffscreenCanvas; + private g: OffscreenCanvasRenderingContext2D; + private r: Renderer; + + private hash: bigint = 0n; + + constructor(width: number, height: number) { + this.c = new OffscreenCanvas(width, height); + this.g = this.c.getContext("2d", { alpha: false })!; + this.r = new CanvasRenderer( + this.c as unknown as HTMLCanvasElement, + this.g as unknown as CanvasRenderingContext2D, + ); + this.rehash(); + } + + private rehash() { + this.hash = BigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)); + } + + render(r: Renderer, x: number, y: number) { + const data = this.g.getImageData(0, 0, this.c.width, this.c.height); + r.putImageData(data, x, y, this.hash); + } + + get width(): number { + return this.r.width; + } + get height(): number { + return this.r.height; + } + clear(color: string): void { + this.rehash(); + this.r.clear(color); + } + fillRect(x: number, y: number, w: number, h: number, color: string): void { + this.rehash(); + this.r.fillRect(x, y, w, h, color); + } + fillCirc(x: number, y: number, radius: number, color: string): void { + this.rehash(); + this.r.fillCirc(x, y, radius, color); + } + putImageData(data: ImageData, x: number, y: number, hash?: bigint): void { + this.rehash(); + this.r.putImageData(data, x, y, hash); + } +} diff --git a/src/renderer.ts b/src/renderer.ts new file mode 100644 index 0000000..97276c9 --- /dev/null +++ b/src/renderer.ts @@ -0,0 +1,10 @@ +type N = number; + +export interface Renderer { + get width(): N; + get height(): N; + clear(color: string): void; + fillRect(x: N, y: N, w: N, h: N, color: string): void; + fillCirc(x: N, y: N, radius: N, color: string): void; + putImageData(data: ImageData, x: N, y: N, hash?: bigint): void; +} diff --git a/src/simulator.ts b/src/simulator.ts new file mode 100644 index 0000000..4b870c2 --- /dev/null +++ b/src/simulator.ts @@ -0,0 +1,39 @@ +import { Grid } from "./grid.ts"; +import { Mouse } from "./input.ts"; +import { Painter } from "./painter.ts"; +import { Renderer } from "./renderer.ts"; + +export class Simulator { + private grid: Grid; + + private myComponent = new Component(); + + constructor( + private mouse: Mouse, + ) { + this.grid = new Grid(this.mouse); + } + + render(r: Renderer) { + r.clear("black"); + this.grid.render(r, (r) => { + this.myComponent.render(r); + }); + } +} + +class Circuit { + private components: Component[] = []; +} + +class Component { + private p = new Painter(64, 64); + + constructor() { + this.p.clear("black"); + } + + render(r: Renderer) { + this.p.render(r, 0, 0); + } +} diff --git a/static/index.html b/static/index.html index d51da97..b54b9bc 100644 --- a/static/index.html +++ b/static/index.html @@ -4,7 +4,7 @@ - + LogiCirc