Back to Black Site Map Editor
SOURCE / PINNED RELEASE

Made of little things.

Black Site Map Editor

Release
32a57733b7e9…
Author-recorded commit
3f54890f542f…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.shakespeare.diy%2F/black-site-map-editor

Archive hash verified: 7a4b1abbac7db5b0…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/components/bs-map-canvas.ts
import { html } from 'lit';
import { editor, guard, type CanvasView } from '../editor-controller';
import { renderMap } from '../map-renderer';
import { buildGrid, mapSize, type BlackSiteMap, type Point, type Selection } from '../model';
import { keyHint } from './key-hint';
import { LightElement } from './light-element';

type Pointer = {
    id: number;
    from: Point;
    last: Point;
    screenX: number;
    screenY: number;
    offsetX: number;
    offsetY: number;
    mode: string;
    selection?: Selection;
};

/** Canvas toolbar and map canvas. View transform and pointer state stay local. */
export class BsMapCanvas extends LightElement implements CanvasView {
    static properties = { hover: { state: true } };
    declare hover: Point | undefined;
    #canvas?: HTMLCanvasElement;
    #ctx?: CanvasRenderingContext2D;
    #tileSize = 16;
    #offsetX = 0;
    #offsetY = 0;
    #fitMode = true;
    #pointer: Pointer | undefined;
    #gridFor: BlackSiteMap | undefined;
    #grid: ReturnType<typeof buildGrid> | undefined;
    #resizeObserver = new ResizeObserver(() => (this.#fitMode ? this.fit() : this.draw()));
    #cancelPointer = () => {
        this.#pointer = undefined;
        this.draw();
    };

    constructor() {
        super();
        this.watch((s) => [s.map, s.selection, s.showGrid, s.tool, s.busy, s.shortcutsRev]);
    }
    connectedCallback() {
        super.connectedCallback();
        editor.ui.canvas = this;
        window.addEventListener('blur', this.#cancelPointer);
        window.addEventListener('pagehide', this.#teardown);
    }
    disconnectedCallback() {
        super.disconnectedCallback();
        this.#teardown();
    }
    #teardown = () => {
        window.removeEventListener('blur', this.#cancelPointer);
        this.#resizeObserver.disconnect();
        this.#cancelPointer();
    };

    render() {
        const { map, showGrid, tool } = editor.state;
        const [w, h] = mapSize(map);
        const coordinates = this.hover
            ? `${this.hover[0]}, ${this.hover[1]} · ${w} × ${h}`
            : `${w} × ${h} · map tiles`;
        return html`<div class="canvas-tools">
                <button
                    id="zoom-out"
                    aria-label="Zoom out"
                    ${keyHint('view.zoom-out')}
                    @click=${() => this.zoom(1 / 1.3)}
                >
                    −
                </button>
                <button
                    id="zoom-in"
                    aria-label="Zoom in"
                    ${keyHint('view.zoom-in')}
                    @click=${() => this.zoom(1.3)}
                >
                    +
                </button>
                <button id="fit" ${keyHint('view.fit')} @click=${() => this.fit()}>Fit</button>
                <button
                    id="grid-toggle"
                    aria-pressed=${showGrid}
                    ${keyHint('view.grid')}
                    @click=${() => editor.toggleGrid()}
                >
                    Grid
                </button>
                <button id="validate" ${keyHint('map.check')} @click=${() => editor.validate()}>
                    Check route
                </button>
                <button
                    id="shortcuts-open"
                    class="keys-button"
                    aria-label="Keyboard shortcuts"
                    ${keyHint('help.shortcuts')}
                    @click=${() => editor.ui.shortcuts?.show()}
                >
                    Keys
                </button>
                <small id="coordinates">${coordinates}</small>
            </div>
            <canvas
                id="map-canvas"
                tabindex="0"
                style="cursor: ${tool === 'pan' ? 'grab' : 'crosshair'}"
                aria-label="Map canvas. Select a tool and drag to place objects. Use arrow keys to move selected objects. Press question mark for keyboard shortcuts."
                @pointerdown=${this.#pointerDown}
                @pointermove=${this.#pointerMove}
                @pointerup=${this.#pointerUp}
                @pointercancel=${this.#cancelPointer}
                @lostpointercapture=${this.#cancelPointer}
                @contextmenu=${(e: Event) => e.preventDefault()}
                @wheel=${{ handleEvent: (e: WheelEvent) => this.#wheel(e), passive: false }}
            ></canvas>`;
    }
    firstUpdated() {
        this.#canvas = this.querySelector<HTMLCanvasElement>('#map-canvas')!;
        this.#ctx = this.#canvas.getContext('2d')!;
        this.#resizeObserver.observe(this.#canvas);
        this.fit();
    }
    updated() {
        this.draw();
    }

    // ---- view ----
    #renderedGrid() {
        const map = editor.map;
        if (this.#gridFor !== map) {
            this.#gridFor = map;
            this.#grid = buildGrid(map);
        }
        return this.#grid!;
    }
    draw() {
        const canvas = this.#canvas,
            ctx = this.#ctx;
        if (!canvas || !ctx) return;
        const dpr = Math.min(devicePixelRatio || 1, 2);
        const rect = canvas.getBoundingClientRect();
        if (!rect.width || !rect.height) return;
        if (
            canvas.width !== Math.round(rect.width * dpr) ||
            canvas.height !== Math.round(rect.height * dpr)
        ) {
            canvas.width = Math.round(rect.width * dpr);
            canvas.height = Math.round(rect.height * dpr);
        }
        const tileSize = this.#tileSize;
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.fillStyle = '#0b0d0b';
        ctx.fillRect(0, 0, rect.width, rect.height);
        ctx.save();
        ctx.translate(this.#offsetX, this.#offsetY);
        const { showGrid, selection } = editor.state;
        renderMap(ctx, editor.map, {
            tileSize,
            grid: this.#renderedGrid(),
            showGrid,
            selection,
            hover: this.hover,
        });
        const pointer = this.#pointer;
        if (pointer && !['select', 'pan', 'erase'].includes(pointer.mode)) {
            const [x, y] = pointer.from;
            const [tx, ty] = pointer.last;
            ctx.strokeStyle = '#e8c547';
            ctx.fillStyle = '#e8c54722';
            const rx = Math.min(x, tx) * tileSize,
                ry = Math.min(y, ty) * tileSize,
                rw = (Math.abs(tx - x) + 1) * tileSize,
                rh = (Math.abs(ty - y) + 1) * tileSize;
            ctx.fillRect(rx, ry, rw, rh);
            ctx.lineWidth = 2;
            ctx.strokeRect(rx, ry, rw, rh);
        }
        ctx.restore();
    }
    fit() {
        if (!this.#canvas) return;
        this.#fitMode = true;
        const r = this.#canvas.getBoundingClientRect();
        const [w, h] = mapSize(editor.map);
        this.#tileSize = Math.max(1, Math.min((r.width - 24) / w, (r.height - 24) / h));
        this.#offsetX = (r.width - w * this.#tileSize) / 2;
        this.#offsetY = (r.height - h * this.#tileSize) / 2;
        this.draw();
    }
    zoom(factor: number, x?: number, y?: number) {
        if (!this.#canvas) return;
        x ??= this.#canvas.clientWidth / 2;
        y ??= this.#canvas.clientHeight / 2;
        this.#fitMode = false;
        const next = Math.max(1, Math.min(64, this.#tileSize * factor));
        this.#offsetX = x - (x - this.#offsetX) * (next / this.#tileSize);
        this.#offsetY = y - (y - this.#offsetY) * (next / this.#tileSize);
        this.#tileSize = next;
        this.draw();
    }

    // ---- pointer input ----
    #point(event: PointerEvent): Point {
        const r = this.#canvas!.getBoundingClientRect();
        return [
            Math.floor((event.clientX - r.left - this.#offsetX) / this.#tileSize),
            Math.floor((event.clientY - r.top - this.#offsetY) / this.#tileSize),
        ];
    }
    #inside(p: Point) {
        const [w, h] = mapSize(editor.map);
        return p[0] >= 0 && p[1] >= 0 && p[0] < w && p[1] < h;
    }
    #pointerDown = (event: PointerEvent) => {
        const canvas = this.#canvas!;
        const { busy, tool } = editor.state;
        if (busy || this.#pointer) return;
        canvas.focus();
        const p = this.#point(event);
        if (!this.#inside(p) && tool !== 'pan') return;
        event.preventDefault();
        canvas.setPointerCapture(event.pointerId);
        let mode = tool;
        if (event.button === 1 || event.button === 2) mode = 'pan';
        const selection = mode === 'select' ? editor.selectAt(p) : editor.state.selection;
        this.#pointer = {
            id: event.pointerId,
            from: p,
            last: p,
            screenX: event.clientX,
            screenY: event.clientY,
            offsetX: this.#offsetX,
            offsetY: this.#offsetY,
            mode,
            selection,
        };
    };
    #pointerMove = (event: PointerEvent) => {
        const p = this.#point(event);
        const pointer = this.#pointer;
        if (pointer && event.pointerId === pointer.id) {
            pointer.last = p;
            if (pointer.mode === 'pan') {
                this.#fitMode = false;
                this.#offsetX = pointer.offsetX + event.clientX - pointer.screenX;
                this.#offsetY = pointer.offsetY + event.clientY - pointer.screenY;
            }
        }
        const hover = this.#inside(p) ? p : undefined;
        if (hover?.[0] !== this.hover?.[0] || hover?.[1] !== this.hover?.[1]) this.hover = hover;
        this.draw();
    };
    #pointerUp = (event: PointerEvent) => {
        const action = this.#pointer;
        if (!action || event.pointerId !== action.id) return;
        this.#pointer = undefined;
        this.#canvas!.releasePointerCapture(event.pointerId);
        const to = this.#point(event);
        guard(() => {
            if (action.mode === 'pan') return;
            if (action.mode === 'select') {
                if (action.selection && (to[0] !== action.from[0] || to[1] !== action.from[1]))
                    editor.moveSelectionBy(
                        to[0] - action.from[0],
                        to[1] - action.from[1],
                        action.selection,
                    );
            } else if (action.mode === 'erase') editor.eraseAt(to);
            else if (this.#inside(to)) editor.placeRect(action.mode, action.from, to);
        })();
        this.draw();
    };
    #wheel(event: WheelEvent) {
        event.preventDefault();
        const r = this.#canvas!.getBoundingClientRect();
        this.zoom(
            event.deltaY < 0 ? 1.15 : 1 / 1.15,
            event.clientX - r.left,
            event.clientY - r.top,
        );
    }
}
customElements.define('bs-map-canvas', BsMapCanvas);