[number];
let hits: Hit[] = [];
let activeIdx = -1;
const esc = (s: string) =>
s.replace(/[&<>"]/g, (c) =>
({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!,
);
function renderSuggest() {
suggestEl.innerHTML = hits
.map(
(h, i) =>
`` +
`` +
`${esc(h.label)}` +
(h.detail ? `${esc(h.detail)}` : "") +
`
`,
)
.join("");
}
function clearSearch() {
searchEl.value = "";
hits = [];
activeIdx = -1;
renderSuggest();
}
function doSearch() {
const q = searchEl.value;
const tl = activeTimeline();
hits = tl && q.trim() ? tl.find(q, 7) : [];
activeIdx = hits.length ? 0 : -1;
renderSuggest();
}
function focusHit(h: Hit) {
lane.focus({ center: h.center, zoom: lane.view.height / h.scale });
activeTimeline()?.setPulse(h);
hits = [];
renderSuggest();
searchEl.blur();
}
searchEl.addEventListener("input", doSearch);
searchEl.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown") {
activeIdx = Math.min(hits.length - 1, activeIdx + 1);
renderSuggest();
e.preventDefault();
} else if (e.key === "ArrowUp") {
activeIdx = Math.max(0, activeIdx - 1);
renderSuggest();
e.preventDefault();
} else if (e.key === "Enter") {
if (hits[activeIdx]) focusHit(hits[activeIdx]!);
} else if (e.key === "Escape") {
clearSearch();
searchEl.blur();
}
});
// mousedown (not click) so it fires before the input's blur clears the list
suggestEl.addEventListener("mousedown", (e) => {
const el = (e.target as HTMLElement).closest(".hit");
if (!el) return;
e.preventDefault();
focusHit(hits[+el.dataset.i!]!);
});
searchEl.addEventListener("blur", () => {
setTimeout(() => {
hits = [];
renderSuggest();
}, 120);
});
// ── log-scale toggle (only for the Signal dataset) ────────────────────────
logBtn?.addEventListener("click", () => {
seriesLog = !seriesLog;
sources.series = buildSeries();
lane.setSource(sources.series);
refreshChrome();
});
refreshChrome();
// NOTE: the initial applyPrefs() runs at the END of this module — its
// setHeatCells → onUpdate → translateNew chain touches `let translator`,
// which is declared further down and still in its TDZ at this point.
// ── folder tree from any GitHub repo (default: torvalds/linux) ────────────
interface GhEntry {
path: string;
type: string;
size?: number;
}
function buildFileTree(name: string, entries: GhEntry[]): FileNode {
const root: FileNode = { name, children: [] };
const dirs = new Map([["", root]]);
const ensureDir = (path: string): FileNode => {
const hit = dirs.get(path);
if (hit) return hit;
const parts = path.split("/");
const nm = parts.pop()!;
const parent = ensureDir(parts.join("/"));
const d: FileNode = { name: nm, children: [], path };
parent.children!.push(d);
dirs.set(path, d);
return d;
};
for (const e of entries) {
if (e.type === "tree") ensureDir(e.path);
else if (e.type === "blob") {
const parts = e.path.split("/");
const fn = parts.pop()!;
ensureDir(parts.join("/")).children!.push({
name: fn,
size: e.size ?? 0,
path: e.path,
});
}
}
return root;
}
function parseRepo(
s: string,
): { owner: string; repo: string; branch?: string } | null {
const t = s.trim();
const m =
t.match(/github\.com[/:]([^/]+)\/([^/#?\s]+)(?:\/tree\/([^#?\s]+))?/) ??
t.match(/^([^/\s]+)\/([^/\s]+)(?:\/tree\/([^\s]+))?$/);
return m
? { owner: m[1]!, repo: m[2]!.replace(/\.git$/, ""), branch: m[3] }
: null;
}
const repoSpecOf = (p: { owner: string; repo: string; branch?: string }) =>
`${p.owner}/${p.repo}` + (p.branch ? `/tree/${p.branch}` : "");
// swap in a fresh git-history timeline for a new repo set (same pattern as the
// tree demo: sources are cheap, replacing one beats teaching it to reset)
function installGitSource(repos: string[]) {
gitRepos = repos;
if (repoInput.value.trim() !== repos.join(" ")) repoInput.value = repos.join(" ");
gitSource = createGitHistorySource({ repos, pageCap: ghToken ? 12 : 4, graphql: !!ghToken });
sources.git = gitSource;
timelines.git = gitSource;
wireGit(gitSource);
gitSource.setGlide(prefs.glide);
gitSource.setHeatCells(prefs.heat);
Object.assign(window as object, { gitSource });
if (current === "git") {
lane.setSource(gitSource);
lane.fit();
}
refreshChrome(); // rebuilds the track chips for the new source
updateGitStat();
}
let repoToken = 0; // guards against out-of-order loads
async function loadRepo(owner: string, repo: string, branchArg?: string) {
const my = ++repoToken;
setTreeStat("loading…");
try {
// an explicit /tree/branch spec skips the default-branch lookup
let branch = branchArg;
if (!branch) {
const info = await fetch(`https://api.github.com/repos/${owner}/${repo}`).then(
(r) => (r.ok ? r.json() : null),
);
branch = info?.default_branch ?? "HEAD";
}
const tree = await fetch(
`https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`,
).then((r) => (r.ok ? r.json() : null));
if (my !== repoToken) return; // superseded
if (!tree?.tree) {
setTreeStat("repo not found");
return;
}
const root = buildFileTree(`${owner}/${repo}`, tree.tree as GhEntry[]);
const raw = (path: string) =>
`https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${encodeURI(path)}`;
const src = createTreeSource(root, {
fetchContent: (path) =>
fetch(raw(path))
.then((r) => (r.ok ? r.text() : null))
.catch(() => null),
// real pixels for media files — animated GIFs included
loadImage: (path) =>
fetch(raw(path))
.then((r) => (r.ok ? r.blob() : null))
.then((b) => (b ? decodeImage(b, path) : null))
.catch(() => null),
});
src.setOnUpdate(() => lane.invalidate());
sources.tree = src;
if (current === "tree") lane.setSource(src);
setTreeStat(
`${(tree.tree as GhEntry[]).length.toLocaleString()} entries` +
(tree.truncated ? " (truncated)" : ""),
);
} catch {
if (my === repoToken) setTreeStat("load failed");
}
}
// commit the box's content for the current view
function loadRepoBox() {
if (current === "git") {
// commit-history view: accept one or many specs (space/comma separated);
// empty input restores the default
const raw = repoInput.value.trim();
const parsed = (raw ? raw.split(/[\s,]+/) : DEFAULT_GIT_REPOS).map(parseRepo);
if (parsed.length && parsed.every(Boolean)) {
const specs = parsed.map((p) => repoSpecOf(p!));
specs.forEach(pushRepoHistory);
installGitSource(specs);
} else {
repoStat.textContent = "bad repo";
}
return;
}
const parsed = parseRepo(repoInput.value);
if (parsed) {
pushRepoHistory(repoSpecOf(parsed));
void loadRepo(parsed.owner, parsed.repo, parsed.branch);
} else repoStat.textContent = "bad repo";
}
// ── repo omnibox: dropdown suggestions ────────────────────────────────────
// Sources by shape of the LAST space-separated segment (the git view holds
// several specs in one box): recents always; "owner/…" lists that owner's
// repos; "owner/repo/…" lists branches; anything else falls back to GitHub
// repo search (its own rate-limit pool). Enter uses the typed text unless a
// suggestion is highlighted with the arrow keys.
const repoSuggestEl = document.querySelector("#repoSuggest");
type RepoHit = { value: string; det?: string };
let repoHits: RepoHit[] = [];
let repoIdx = -1;
const REPO_HISTORY_KEY = "lane-repo-history";
let repoHistory: string[] = [];
try {
const h = JSON.parse(localStorage.getItem(REPO_HISTORY_KEY) ?? "[]");
if (Array.isArray(h)) repoHistory = h.filter((s) => typeof s === "string");
} catch { /* corrupted history → empty */ }
function pushRepoHistory(spec: string) {
repoHistory = [spec, ...repoHistory.filter((s) => s !== spec)].slice(0, 12);
try { localStorage.setItem(REPO_HISTORY_KEY, JSON.stringify(repoHistory)); } catch { /* private mode */ }
}
const ownerRepoCache = new Map();
const branchListCache = new Map();
let suggestGen = 0;
let suggestTimer = 0;
function repoSegment(): { start: number; text: string } {
const v = repoInput.value;
const start = v.lastIndexOf(" ") + 1;
return { start, text: v.slice(start) };
}
function renderRepoSuggest() {
if (!repoSuggestEl) return;
repoSuggestEl.innerHTML = repoHits
.map(
(h, i) =>
`` +
`${esc(h.value)}` +
(h.det ? `${esc(h.det)}` : "") +
`
`,
)
.join("");
}
function closeRepoSuggest() {
repoHits = [];
repoIdx = -1;
renderRepoSuggest();
}
async function ghJson(url: string): Promise {
try {
const r = await fetch(url);
return r.ok ? r.json() : null;
} catch {
return null;
}
}
async function computeRepoSuggest() {
const gen = ++suggestGen;
const q = repoSegment().text.trim();
const hits: RepoHit[] = [];
const have = new Set();
const push = (h: RepoHit) => {
if (!have.has(h.value) && hits.length < 8) {
have.add(h.value);
hits.push(h);
}
};
for (const s of repoHistory)
if (!q || s.toLowerCase().includes(q.toLowerCase())) push({ value: s, det: "recent" });
const mBranch = /^([^/\s]+\/[^/\s]+)\/(?:tree\/?)?([^/\s]*)$/.exec(q);
const mOwner = /^([^/\s]+)\/([^/\s]*)$/.exec(q);
if (mBranch) {
const path = mBranch[1]!;
let branches = branchListCache.get(path);
if (!branches) {
const data = await ghJson(`https://api.github.com/repos/${path}/branches?per_page=100`);
if (gen !== suggestGen) return;
branches = Array.isArray(data)
? (data as { name: string }[]).map((b) => b.name)
: [];
branchListCache.set(path, branches);
}
for (const b of branches)
if (b.toLowerCase().startsWith(mBranch[2]!.toLowerCase()))
push({ value: `${path}/tree/${b}`, det: "branch" });
} else if (mOwner) {
const owner = mOwner[1]!;
let list = ownerRepoCache.get(owner);
if (!list) {
const data = await ghJson(
`https://api.github.com/users/${owner}/repos?sort=updated&per_page=100`,
);
if (gen !== suggestGen) return;
list = Array.isArray(data)
? (data as { full_name: string; stargazers_count: number }[]).map((r) => ({
name: r.full_name,
stars: r.stargazers_count,
}))
: [];
ownerRepoCache.set(owner, list);
}
for (const r of list)
if (r.name.toLowerCase().startsWith(q.toLowerCase()))
push({ value: r.name, det: `⭐ ${r.stars}` });
} else if (q.length >= 2) {
const data = await ghJson(
`https://api.github.com/search/repositories?q=${encodeURIComponent(q)}&per_page=8`,
);
if (gen !== suggestGen) return;
const items =
(data as { items?: { full_name: string; stargazers_count: number }[] })?.items ?? [];
for (const r of items) push({ value: r.full_name, det: `⭐ ${r.stargazers_count}` });
}
if (gen !== suggestGen) return;
repoHits = hits;
repoIdx = -1; // typed text wins on Enter until the user arrows down
renderRepoSuggest();
}
function applyRepoHit(h: RepoHit) {
const { start } = repoSegment();
repoInput.value = repoInput.value.slice(0, start) + h.value;
closeRepoSuggest();
loadRepoBox();
}
repoInput.addEventListener("input", () => {
clearTimeout(suggestTimer);
suggestTimer = window.setTimeout(() => void computeRepoSuggest(), 250);
});
repoInput.addEventListener("focus", () => void computeRepoSuggest());
repoInput.addEventListener("blur", () => setTimeout(closeRepoSuggest, 140));
repoSuggestEl?.addEventListener("mousedown", (e) => {
const el = (e.target as HTMLElement).closest(".hit");
if (!el) return;
e.preventDefault(); // keep focus so blur doesn't wipe the list mid-click
applyRepoHit(repoHits[+el.dataset.i!]!);
});
repoInput.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown" && repoHits.length) {
repoIdx = Math.min(repoHits.length - 1, repoIdx + 1);
renderRepoSuggest();
e.preventDefault();
return;
}
if (e.key === "ArrowUp" && repoHits.length) {
repoIdx = Math.max(-1, repoIdx - 1);
renderRepoSuggest();
e.preventDefault();
return;
}
if (e.key === "Escape") {
closeRepoSuggest();
return;
}
if (e.key !== "Enter") return;
if (repoIdx >= 0 && repoHits[repoIdx]) {
applyRepoHit(repoHits[repoIdx]!);
return;
}
closeRepoSuggest();
loadRepoBox();
});
// Don't fetch a multi-MB repo tree on load — the synthetic tree (with built-in
// content) is the instant default; a real GitHub repo is one ↵ away.
repoStat.textContent = "↵ load any GitHub repo";
// ── Wikipedia hover cards (deep-time view) ────────────────────────────────
interface WikiSummary {
title?: string;
extract?: string;
type?: string;
thumbnail?: { source: string };
content_urls?: { desktop?: { page?: string } };
}
const cardEl = document.querySelector("#card")!;
const wikiCache = new Map();
let cardTitle: string | null = null;
let hoverTimer = 0;
let hideTimer = 0;
let overCard = false;
const WIKI_LANGS = (() => {
const l = (navigator.language || "en").split("-")[0];
return l === "en" ? ["en"] : [l, "en"]; // browser language first, en fallback
})();
async function wikiSummary(title: string): Promise {
if (wikiCache.has(title)) return wikiCache.get(title) ?? null;
for (const lang of WIKI_LANGS) {
try {
const r = await fetch(
`https://${lang}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}?redirect=true`,
);
if (r.ok) {
const j = (await r.json()) as WikiSummary;
if (j?.extract && j.type !== "disambiguation") {
wikiCache.set(title, j);
return j;
}
}
} catch {
/* try next language */
}
}
wikiCache.set(title, null);
return null;
}
function positionCard(x: number, y: number) {
const w = 290;
const h = cardEl.offsetHeight || 200;
let left = x + 18;
let top = y + 12;
if (left + w > innerWidth - 8) left = Math.max(8, x - w - 18);
if (top + h > innerHeight - 8) top = Math.max(8, innerHeight - h - 8);
cardEl.style.left = `${left}px`;
cardEl.style.top = `${top}px`;
}
async function showCard(title: string, x: number, y: number) {
const s = await wikiSummary(title);
if (cardTitle !== title) return; // pointer moved to another event
if (!s || s.type === "disambiguation" || !s.extract) {
hideCard();
return;
}
cardEl.href =
s.content_urls?.desktop?.page ??
`https://en.wikipedia.org/wiki/${encodeURIComponent(title)}`;
const img = s.thumbnail?.source ? `
` : "";
cardEl.innerHTML =
`${img}${esc(s.title ?? title)}
` +
`
${esc(s.extract)}
WIKIPEDIA ↗
`;
cardEl.classList.add("show");
positionCard(x, y);
}
function hideCard() {
if (overCard) return;
cardEl.classList.remove("show");
cardTitle = null;
}
canvas.addEventListener("pointermove", (e) => {
if (current !== "time" || e.buttons) {
hideCard();
return;
}
const rect = canvas.getBoundingClientRect();
const hit = timeSource.eventAt(e.clientX - rect.left, e.clientY - rect.top, lane.view);
clearTimeout(hoverTimer);
clearTimeout(hideTimer);
if (hit) {
if (hit.title !== cardTitle) {
cardTitle = hit.title;
const cx = e.clientX;
const cy = e.clientY;
hoverTimer = window.setTimeout(() => showCard(hit.title, cx, cy), 200);
} else if (cardEl.classList.contains("show")) {
positionCard(e.clientX, e.clientY);
}
} else {
hideTimer = window.setTimeout(hideCard, 160);
}
});
canvas.addEventListener("pointerleave", () => {
hideTimer = window.setTimeout(hideCard, 160);
});
cardEl.addEventListener("pointerenter", () => {
overCard = true;
clearTimeout(hideTimer);
});
cardEl.addEventListener("pointerleave", () => {
overCard = false;
hideCard();
});
// ── theme toggle (mirrors index.html) ─────────────────────────────────────
const themeToggle = document.querySelector("#theme-toggle");
themeToggle?.addEventListener("click", () => {
const next =
document.documentElement.dataset.theme === "light" ? "dark" : "light";
document.documentElement.dataset.theme = next;
localStorage.setItem("rgui-theme", next);
lane.setTheme(next);
});
// ── i18n: translate labels into the browser's language (progressive) ──────
// Uses the built-in browser Translator API when available; renders English
// first and swaps in translations as they arrive — including labels from live
// fetches (translateNew runs again on each source update). No-op when
// unavailable or the browser is already English.
let translator: { translate(s: string): Promise } | null = null;
const trCache = new Map();
let translating = false;
async function translateNew() {
const gen = langGen; // language switches mid-flight invalidate this pass
const tr = translator;
if (!tr || translating) return;
const todo = timeSource.strings().filter((s) => !trCache.has(s));
if (!todo.length) return;
translating = true;
try {
for (const s of todo) {
let out = s; // keep English on failure
try {
out = await tr.translate(s);
} catch { /* Translator hiccup — English stays */ }
if (gen !== langGen) return; // stale language: drop, don't pollute cache
trCache.set(s, out);
}
if (gen === langGen) {
timeSource.setTranslate((s) => trCache.get(s) ?? s); // redraw with new text
}
} finally {
translating = false;
// re-kick when a switch happened mid-pass OR new strings arrived while
// this pass ran (they were filtered out of `todo` at entry); failures
// cache as identity, so this converges instead of spinning
if (
translator &&
(gen !== langGen || timeSource.strings().some((s) => !trCache.has(s)))
) {
void translateNew();
}
}
}
// language switcher: "auto" follows the browser; anything else is explicit.
// Switching tears the old translator down, reverts to English immediately,
// then swaps translations in as the new model delivers them.
const LANG_KEY = "lane-lang";
let langGen = 0;
async function setLang(choice: string) {
const gen = ++langGen;
translator = null;
trCache.clear();
timeSource.setTranslate((s) => s); // English right away; translations follow
const target = choice === "auto" ? (navigator.language || "en").split("-")[0]! : choice;
if (target === "en") return;
const T = (globalThis as unknown as { Translator?: any }).Translator;
if (!T?.create) return;
try {
const avail = await T.availability?.({ sourceLanguage: "en", targetLanguage: target });
if (avail === "unavailable") return;
const tr = await T.create({ sourceLanguage: "en", targetLanguage: target });
if (gen !== langGen) return; // user switched again while the model loaded
translator = tr;
await translateNew();
} catch {
/* Translator API unavailable — stay English */
}
}
const langSel = document.querySelector("#lang");
const savedLang = localStorage.getItem(LANG_KEY) ?? "auto";
if (langSel) {
langSel.value = savedLang;
langSel.addEventListener("change", () => {
try { localStorage.setItem(LANG_KEY, langSel.value); } catch { /* private mode */ }
void setLang(langSel.value);
});
}
void setLang(savedLang);
// initial preference application — after ALL module state (incl. the i18n
// `translator` binding) exists, so no callback lands in a TDZ
applyPrefs();
// expose for host debugging / e2e
Object.assign(window as object, { lane, timeSource, gitSource, treeSource, lazyTreeSource });