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.
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);
