104 lines
4.2 KiB
TypeScript
104 lines
4.2 KiB
TypeScript
// Buildings lift on hover or focus and sink when pressed.
|
|
import { UNIT } from "./iso.ts";
|
|
|
|
const SVG = "http://www.w3.org/2000/svg";
|
|
|
|
/** Links each app's blocks to its region, in reading order. */
|
|
export function touch(host: HTMLElement, svg: SVGSVGElement, parts: Map<string, SVGGElement[]>): void {
|
|
host.style.setProperty("--step", `${UNIT}px`);
|
|
const nav = document.createElement("nav");
|
|
nav.className = "doors";
|
|
host.append(nav);
|
|
|
|
const owner = new Map([...parts].flatMap(([name, blocks]) => blocks.map((b) => [b, name] as const)));
|
|
const hits = still(svg, owner);
|
|
|
|
for (const [name, blocks] of parts) {
|
|
const target = `#${name.toLowerCase()}`;
|
|
const state = { hover: false, focus: false, press: false };
|
|
const hit = hits.filter((h) => h.dataset.part === name);
|
|
const caption = document.createElement("p");
|
|
caption.className = "caption";
|
|
caption.textContent = name;
|
|
caption.setAttribute("aria-hidden", "true");
|
|
host.append(caption);
|
|
|
|
const follow = () => place(caption, host, svg, hit);
|
|
const sized = new ResizeObserver(follow);
|
|
const show = () => {
|
|
const up = state.hover || state.focus;
|
|
for (const b of blocks) {
|
|
b.classList.toggle("lifted", up);
|
|
b.classList.toggle("pressed", up && state.press);
|
|
}
|
|
caption.classList.toggle("shown", up);
|
|
// The camera announces each move on the host.
|
|
if (up) {
|
|
host.addEventListener("moved", follow);
|
|
sized.observe(svg);
|
|
follow();
|
|
} else {
|
|
host.removeEventListener("moved", follow);
|
|
sized.disconnect();
|
|
}
|
|
};
|
|
const set = (change: Partial<typeof state>) => {
|
|
Object.assign(state, change);
|
|
show();
|
|
};
|
|
|
|
const own = (node: EventTarget | null) => node instanceof SVGElement && node.dataset.part === name;
|
|
for (const h of hit) {
|
|
h.addEventListener("pointerenter", () => set({ hover: true }));
|
|
h.addEventListener("pointerleave", (e) => own(e.relatedTarget) || set({ hover: false, press: false }));
|
|
h.addEventListener("pointerdown", (e) => e.button === 0 && set({ press: true }));
|
|
h.addEventListener("pointerup", () => set({ press: false }));
|
|
h.addEventListener("pointercancel", () => set({ press: false }));
|
|
h.addEventListener("click", () => (location.hash = target));
|
|
}
|
|
|
|
const door = document.createElement("a");
|
|
door.href = target;
|
|
door.textContent = name;
|
|
door.addEventListener("focus", () => set({ focus: true }));
|
|
door.addEventListener("blur", () => set({ focus: false, press: false }));
|
|
door.addEventListener("keydown", (e) => e.key === "Enter" && !e.repeat && set({ press: true }));
|
|
door.addEventListener("keyup", (e) => e.key === "Enter" && set({ press: false }));
|
|
nav.append(door);
|
|
}
|
|
}
|
|
|
|
/** Resting outlines over the model, layered as drawn. */
|
|
function still(svg: SVGSVGElement, owner: Map<SVGGElement, string>): SVGPolygonElement[] {
|
|
const layer = document.createElementNS(SVG, "g");
|
|
layer.setAttribute("class", "hits");
|
|
const hits: SVGPolygonElement[] = [];
|
|
for (const b of svg.querySelectorAll<SVGGElement>(".block")) {
|
|
const outline = b.querySelector(".outline");
|
|
if (!outline) throw new Error("block has no outline");
|
|
const name = owner.get(b);
|
|
b.dataset.part = name ?? "";
|
|
const face = outline.cloneNode() as SVGPolygonElement;
|
|
face.setAttribute("class", name ? "hit-face" : "hit-face ground");
|
|
face.dataset.part = name ?? "";
|
|
layer.append(face);
|
|
if (name) hits.push(face);
|
|
}
|
|
svg.append(layer);
|
|
return hits;
|
|
}
|
|
|
|
/** Puts the caption one cell above the lifted top. */
|
|
function place(caption: HTMLElement, host: HTMLElement, svg: SVGSVGElement, faces: SVGPolygonElement[]) {
|
|
const origin = host.getBoundingClientRect();
|
|
const ctm = svg.getScreenCTM();
|
|
if (!ctm) throw new Error("model is not rendered");
|
|
// Hit faces never lift, so they give the rest pose.
|
|
const rects = faces.map((f) => f.getBoundingClientRect());
|
|
const left = Math.min(...rects.map((r) => r.left));
|
|
const right = Math.max(...rects.map((r) => r.right));
|
|
const top = Math.min(...rects.map((r) => r.top));
|
|
caption.style.left = `${(left + right) / 2 - origin.left}px`;
|
|
caption.style.top = `${top - origin.top - 2 * UNIT * ctm.d}px`;
|
|
}
|