fantasyland/script.js

501 lines
13 KiB
JavaScript

// @ts-nocheck
const cleanTerm = (text) => text.replace(/\s+/g, " ").replace(/\.$/, "").trim();
const slugify = (text) =>
text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const entrySpans = Array.from(document.querySelectorAll("span.none2"));
const entryMap = new Map();
entrySpans.forEach((span, index) => {
const term = cleanTerm(span.textContent);
if (!term) {
return;
}
const id = `entry-${slugify(term)}`;
span.setAttribute("id", id);
span.classList.add("entry-title");
span.style.setProperty("--entry-delay", `${Math.min(index * 0.01, 0.4)}s`);
entryMap.set(term, id);
});
const tocList = document.getElementById("toc-list");
const tocLetters = document.getElementById("toc-letters");
const termEntries = Array.from(entryMap.entries()).sort(([a], [b]) =>
a.localeCompare(b),
);
const firstByLetter = new Map();
termEntries.forEach(([term, id]) => {
const letter = term[0] ? term[0].toUpperCase() : "#";
if (!firstByLetter.has(letter)) {
firstByLetter.set(letter, id);
}
const link = document.createElement("a");
link.href = `#${id}`;
link.textContent = term;
const item = document.createElement("li");
item.appendChild(link);
tocList?.appendChild(item);
});
Array.from(firstByLetter.entries())
.sort(([a], [b]) => a.localeCompare(b))
.forEach(([letter, id]) => {
const link = document.createElement("a");
link.href = `#${id}`;
link.textContent = letter;
tocLetters?.appendChild(link);
});
// scroll TOC list when clicking a letter
tocLetters?.addEventListener("click", (e) => {
if (e.target.tagName !== "A") return;
const href = e.target.getAttribute("href");
if (!href) return;
const tocLink = tocList?.querySelector(`a[href="${href}"]`);
tocLink?.scrollIntoView({ block: "start" });
});
const searchInput = document.getElementById("toc-search");
let searchTimeout;
searchInput?.addEventListener("input", (event) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const query = event.target.value.trim().toLowerCase();
Array.from(tocList?.querySelectorAll("li") ?? []).forEach((item) => {
const text = item.textContent.toLowerCase();
item.style.display = text.includes(query) ? "" : "none";
});
}, 50);
});
// OCR/formatting errors from epub conversion
const typoFixes = {
"GAY MA G E": "GAY MAGE",
"MORE ON RIVERS RIVERS": "RIVERS",
"REEK OF WRONG NESS": "REEK OF WRONGNESS",
"WITCH LIGHT": "WITCHLIGHT",
};
// truncated possessives and hyphenated terms
const partialMatches = {
"A KING'S": "KINGS",
"KING'": "KINGS",
"MERCHANT'": "MERCHANTS",
"POWER-": "POWER",
"WIZARD'": "WIZARDS",
"WIZARD'S": "WIZARDS",
"WIZARD'S BREEDING PROGRAMME": "BREEDING PROGRAMMES",
"WIZARD'S STAFF": "STAFFS",
};
// British/American spelling and verb forms
const spellingVariants = {
AMBUSHED: "AMBUSHES",
BACKLASHES: "BACKLASH OF MAGIC",
CURSED: "CURSES",
"FELLOW TRAVELLER": "FELLOW TRAVELERS",
"FELLOW TRAVELLERS": "FELLOW TRAVELERS",
};
// different terms mapping to canonical entries
const synonyms = {
"A BARD": "BARDS",
"A COURTIER": "COURTIERS",
"A DEMON": "DEMONS",
"A GODDESS OR GOD": "GODDESSES AND GODS",
"A HERBWOMAN": "HERBWOMAN",
"A MAGIC USER": "MAGIC USERS",
"A SEER": "SEER",
"A WIZARD": "WIZARDS",
ASSASSIN: "ASSASSINS, GUILD OF",
ASSASSINS: "ASSASSINS, GUILD OF",
BALLAD: "BALLAD, How to Compose a",
BALLADS: "BALLAD, How to Compose a",
BLOCKED: "MOUNTAIN PASS, BLOCKED",
CHILD: "CHILDREN",
ETERNAL: "ETERNAL QUEST",
"EVIL SPELLS": "SPELLS",
FEMALE: "SLAVES, FEMALE,",
"GIFT OF SIGHT": "SIGHT",
GOD: "GODDESSES AND GODS",
GODDESS: "GODDESSES AND GODS",
"GODDESS OR GOD": "GODDESSES AND GODS",
GODS: "GODDESSES AND GODS",
"GOOD KING": "KINGS",
"GOOD SPY": "SPIES",
"GOOD WIZARDS": "WIZARDS",
"GUILD OF": "ASSASSINS, GUILD OF",
HERBWOMEN: "HERBWOMAN",
HERO: "HEROES",
"HIDDEN CITY": "HIDDEN KINGDOM",
"HOMESPUN ROBES": "ROBES",
"HOW TO INTERACT WITH WIZARDS": "WIZARDS",
IMPORT: "IMPORT/EXPORT",
"JOURNEY CAKE": "WAYBREAD OR JOURNEY CAKE",
"LOST LAND": "LOST LANDS,",
"LOST LANDS": "LOST LANDS,",
MAGELIGHT: "MAGELIGHT OR MAGEFIRE",
MALE: "SLAVES, MALE,",
MINION: "MINIONS OF THE DARK LORD",
"MINION OF THE DARK LORD": "MINIONS OF THE DARK LORD",
MINIONS: "MINIONS OF THE DARK LORD",
MOON: "MOON(S)",
"MORE ON RIVERS": "RIVERS",
"MOUNTAIN PASS": "MOUNTAIN PASS, BLOCKED",
"NORTHERN BARBARIANS DWELL": "NORTHERN BARBARIANS",
PANCELTIC: "PANCELTIC TOURS",
"PANCELTIC TOUR": "PANCELTIC TOURS",
PENTAGRAM: "PENTAGRAM OR PENTACLE",
PENTAGRAMS: "PENTAGRAM OR PENTACLE",
"PRACTICE RING": "PRACTICE RING OR COMBAT RING",
PRIESTESSES: "HIGH PRIESTESSES",
RIVERBOAT: "RIVERS",
"SECRET VALLEY": "VALLEYS",
SLAVE: "GALLEY SLAVE",
SLAVES: "SLAVES, MALE,",
"STANDING STONES": "STONE CIRCLES",
THIEVES: "THIEVES' GUILD",
"TOUR COMPANIONS": "COMPANIONS",
"TOWN COUNCIL": "COUNCIL",
TROTS: "TROTS, THE",
WAYBREAD: "WAYBREAD OR JOURNEY CAKE",
};
const specialCaseMap = {
...typoFixes,
...partialMatches,
...spellingVariants,
...synonyms,
};
const resolveTerm = (term) => {
const normalized = term.replace(/'/g, "'");
const special = specialCaseMap[normalized];
if (special && entryMap.has(special)) {
return special;
}
if (entryMap.has(normalized)) {
return normalized;
}
const lower = normalized.toLowerCase();
const candidates = [];
// Singularize
if (lower.endsWith("ies")) {
candidates.push(normalized.slice(0, -3) + "Y");
}
if (lower.endsWith("ves")) {
candidates.push(normalized.slice(0, -3) + "F");
candidates.push(normalized.slice(0, -3) + "FE");
}
if (lower.endsWith("es")) {
candidates.push(normalized.slice(0, -2));
}
if (lower.endsWith("s")) {
candidates.push(normalized.slice(0, -1));
}
// Pluralize
if (lower.endsWith("y") && !/[aeiou]y$/.test(lower)) {
candidates.push(normalized.slice(0, -1) + "IES");
}
if (lower.endsWith("f")) {
candidates.push(normalized.slice(0, -1) + "VES");
}
if (lower.endsWith("fe")) {
candidates.push(normalized.slice(0, -2) + "VES");
}
candidates.push(`${normalized}S`);
if (/(s|x|z|ch|sh)$/i.test(lower)) {
candidates.push(`${normalized}ES`);
}
return candidates.find((candidate) => entryMap.has(candidate)) || null;
};
const CAPS_TERM_REGEX = /\b[A-Z][A-Z0-9''\-]*(?:\s+[A-Z][A-Z0-9''\-]*)*\b/g;
const linkifyTextNode = (node) => {
const text = node.nodeValue;
if (!text || !/[A-Z]{2}/.test(text)) {
return;
}
const matches = [];
CAPS_TERM_REGEX.lastIndex = 0;
let match;
while ((match = CAPS_TERM_REGEX.exec(text)) !== null) {
const raw = match[0];
const cleaned = cleanTerm(raw);
const resolved = resolveTerm(cleaned);
if (resolved) {
matches.push({
start: match.index,
end: match.index + raw.length,
term: resolved,
raw,
});
}
}
if (!matches.length) {
return;
}
// Prefer longer matches to avoid linking a short term inside a longer one.
const byLength = matches
.slice()
.sort((a, b) => b.end - b.start - (a.end - a.start) || a.start - b.start);
const selected = [];
byLength.forEach((candidate) => {
const overlaps = selected.some(
(picked) => candidate.start < picked.end && candidate.end > picked.start,
);
if (!overlaps) {
selected.push(candidate);
}
});
selected.sort((a, b) => a.start - b.start);
const fragment = document.createDocumentFragment();
let lastIndex = 0;
selected.forEach(({ start, end, term }) => {
if (start > lastIndex) {
fragment.appendChild(
document.createTextNode(text.slice(lastIndex, start)),
);
}
const link = document.createElement("a");
link.href = `#${entryMap.get(term)}`;
link.textContent = text.slice(start, end);
link.className = "entry-link";
fragment.appendChild(link);
lastIndex = end;
});
if (lastIndex < text.length) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
}
node.parentNode.replaceChild(fragment, node);
};
const contentEl = document.getElementById("content");
if (contentEl) {
const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
const parent = node.parentElement;
if (!parent) {
return NodeFilter.FILTER_REJECT;
}
if (parent.closest("a, script, style")) {
return NodeFilter.FILTER_REJECT;
}
if (parent.closest("span.none2")) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
});
const nodesToLinkify = [];
while (walker.nextNode()) {
nodesToLinkify.push(walker.currentNode);
}
nodesToLinkify.forEach((node) => linkifyTextNode(node));
}
// TOC collapse/expand toggle
const tocToggle = document.createElement("button");
tocToggle.className = "toc-toggle";
tocToggle.setAttribute("aria-label", "Toggle sidebar");
tocToggle.setAttribute("title", "Toggle sidebar");
document.body.appendChild(tocToggle);
const toc = document.querySelector(".toc");
const layout = document.querySelector(".layout");
const isMobile = () => window.matchMedia("(max-width: 700px)").matches;
tocToggle.addEventListener("click", () => {
toc?.classList.toggle("collapsed");
layout?.classList.toggle("toc-hidden");
if (isMobile()) {
document.body.classList.toggle("toc-open");
} else {
document.body.classList.toggle("toc-collapsed");
}
});
// auto-collapse on mobile (CSS defaults to collapsed, just ensure classes match)
if (isMobile()) {
toc?.classList.add("collapsed");
layout?.classList.add("toc-hidden");
document.body.classList.remove("toc-open");
}
// close drawer when clicking a toc link on mobile
tocList?.addEventListener("click", (e) => {
if (e.target.tagName === "A" && isMobile()) {
toc?.classList.add("collapsed");
layout?.classList.add("toc-hidden");
document.body.classList.remove("toc-open");
}
});
// scroll TOC item into view when navigating via hash
window.addEventListener("hashchange", () => {
const id = location.hash.slice(1);
if (id) {
tocList?.querySelector(`a[href="#${id}"]`)?.scrollIntoView({ block: "nearest" });
}
});
// Entry popup preview on hover
const popup = document.createElement("div");
popup.className = "entry-popup";
popup.hidden = true;
document.body.appendChild(popup);
let hoverTimer = null;
let closeTimer = null;
const HOVER_DELAY = 500;
const CLOSE_DELAY = 500;
const getEntryContent = (id) => {
const titleEl = document.getElementById(id);
if (!titleEl) return null;
const allTitles = Array.from(document.querySelectorAll(".entry-title"));
const titleIndex = allTitles.indexOf(titleEl);
const nextTitle = allTitles[titleIndex + 1] || null;
const range = document.createRange();
range.setStartBefore(titleEl);
if (nextTitle) {
range.setEndBefore(nextTitle);
} else {
range.setEndAfter(contentEl.lastChild);
}
const content = range.cloneContents();
const clonedTitle = content.querySelector(".entry-title");
if (clonedTitle) {
clonedTitle.removeAttribute("id");
}
return content;
};
const positionPopup = (linkRect) => {
const pad = 8;
const popupRect = popup.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let top = linkRect.top - popupRect.height - pad;
let left = linkRect.left;
if (top < pad) {
top = linkRect.bottom + pad;
}
if (left + popupRect.width > vw - pad) {
left = vw - popupRect.width - pad;
}
if (left < pad) {
left = pad;
}
popup.style.top = `${top}px`;
popup.style.left = `${left}px`;
};
const showPopup = (link) => {
const href = link.getAttribute("href");
if (!href?.startsWith("#entry-")) return;
const id = href.slice(1);
const content = getEntryContent(id);
if (!content) return;
popup.innerHTML = "";
popup.appendChild(content);
popup.hidden = false;
popup.style.top = "0";
popup.style.left = "0";
popup.classList.remove("visible");
requestAnimationFrame(() => {
popup.scrollTop = 0;
const linkRect = link.getBoundingClientRect();
positionPopup(linkRect);
popup.classList.add("visible");
});
};
const hidePopup = () => {
popup.classList.remove("visible");
setTimeout(() => {
if (!popup.classList.contains("visible")) {
popup.hidden = true;
}
}, 200);
};
const clearTimers = () => {
clearTimeout(hoverTimer);
clearTimeout(closeTimer);
hoverTimer = null;
closeTimer = null;
};
const isEntryLink = (el) =>
el?.tagName === "A" &&
el.classList.contains("entry-link") &&
el.getAttribute("href")?.startsWith("#entry-");
contentEl?.addEventListener("mouseenter", (e) => {
if (isMobile()) return;
if (!isEntryLink(e.target)) return;
clearTimers();
hoverTimer = setTimeout(() => showPopup(e.target), HOVER_DELAY);
}, true);
contentEl?.addEventListener("mouseleave", (e) => {
if (isMobile()) return;
if (!isEntryLink(e.target)) return;
clearTimers();
closeTimer = setTimeout(hidePopup, CLOSE_DELAY);
}, true);
popup.addEventListener("mouseenter", () => {
clearTimers();
});
popup.addEventListener("mouseleave", () => {
clearTimers();
closeTimer = setTimeout(hidePopup, CLOSE_DELAY);
});
popup.addEventListener("click", (e) => {
if (e.target.tagName === "A") {
clearTimers();
hidePopup();
}
});