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.
91 lines
3.3 KiB
TypeScript
91 lines
3.3 KiB
TypeScript
/**
|
|
* 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}
|
|
`;
|