Draw the board as track, split the site into three pages, and fix curve geometry

Adds SVG board rendering shared by the game and the replay, a splash page with a
shareable replay directory, hover tooltips for reference detail, and makes curves
two-port arcs so sidings and run-arounds can finally be built.
This commit is contained in:
Jesse
2026-08-01 13:35:53 -04:00
parent f00255ce31
commit f52ff0e9ac
21 changed files with 5359 additions and 291 deletions
+90
View File
@@ -0,0 +1,90 @@
/**
* One floating tooltip, shared by every page.
*
* Reference detail — what a card does, why an action is offered, what a limit means — is needed
* occasionally and read once. Printing all of it costs the space that the board, the hand and the
* action list actually need. So anything carrying `data-tip` shows its text on hover or focus and
* nothing the rest of the time.
*
* Not the browser's native `title`: that waits about a second, cannot be styled, wraps badly at
* this length, and never appears for keyboard users.
*/
let tip: HTMLElement | null = null;
function ensure(): HTMLElement {
if (tip) return tip;
const el = document.createElement('div');
el.id = 'tip';
el.setAttribute('role', 'tooltip');
document.body.appendChild(el);
tip = el;
return el;
}
function place(el: HTMLElement, text: string): void {
const t = ensure();
t.textContent = text;
t.style.display = 'block';
// Measure, then keep it on screen: flip above when it would fall off the bottom, and pull back
// from the right edge rather than letting the text run off it.
const r = el.getBoundingClientRect();
const box = t.getBoundingClientRect();
const margin = 8;
let left = r.left + r.width / 2 - box.width / 2;
left = Math.max(margin, Math.min(left, window.innerWidth - box.width - margin));
let top = r.bottom + 6;
if (top + box.height > window.innerHeight - margin) top = r.top - box.height - 6;
t.style.left = `${Math.round(left + window.scrollX)}px`;
t.style.top = `${Math.round(top + window.scrollY)}px`;
}
function hide(): void {
if (tip) tip.style.display = 'none';
}
/**
* Start listening. Delegated from the document, so markup replaced later — the board redraws on
* every action — needs no re-binding.
*/
export function installTooltips(): void {
const find = (node: EventTarget | null): HTMLElement | null => {
let el = node as HTMLElement | null;
while (el && el !== document.body) {
// SVG elements have no closest() in older engines, so walk manually.
if (el.getAttribute && el.getAttribute('data-tip')) return el;
el = (el.parentNode as HTMLElement) ?? null;
}
return null;
};
document.addEventListener('mouseover', (e) => {
const el = find(e.target);
if (el) place(el, el.getAttribute('data-tip') ?? '');
else hide();
});
document.addEventListener('mouseout', (e) => {
if (!find((e as MouseEvent).relatedTarget)) hide();
});
document.addEventListener('focusin', (e) => {
const el = find(e.target);
if (el) place(el, el.getAttribute('data-tip') ?? '');
});
document.addEventListener('focusout', hide);
document.addEventListener('scroll', hide, true);
// Escape closes it, which matters when one is pinned open by focus.
document.addEventListener('keydown', (e) => {
if ((e as KeyboardEvent).key === 'Escape') hide();
});
}
/** Styling, kept here so every page that installs tooltips gets the same one. */
export const TOOLTIP_CSS = `
#tip{position:absolute;z-index:9999;display:none;max-width:340px;
background:#0b0e12;color:#e6e9ee;border:1px solid #4d6fa8;border-radius:6px;
padding:7px 10px;font:12px/1.45 ui-monospace,Menlo,Consolas,monospace;
box-shadow:0 6px 22px rgba(0,0,0,.55);pointer-events:none;white-space:pre-wrap}
[data-tip]{cursor:help}
button[data-tip],a[data-tip]{cursor:pointer}
`;