-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathscript.js
More file actions
116 lines (110 loc) · 25 KB
/
Copy pathscript.js
File metadata and controls
116 lines (110 loc) · 25 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
const POSITIONS = [
{ x: 18, y: 18, end: [190, 130], c1: [410, 245], c2: [300, 170] }, { x: 78, y: 17, end: [800, 125], c1: [610, 230], c2: [710, 155] },
{ x: 84, y: 52, end: [860, 375], c1: [640, 360], c2: [770, 360] }, { x: 18, y: 78, end: [180, 590], c1: [380, 430], c2: [270, 510] },
{ x: 50, y: 9, end: [500, 66], c1: [500, 230], c2: [500, 130] }, { x: 69, y: 80, end: [710, 600], c1: [565, 470], c2: [635, 565] },
{ x: 13, y: 48, end: [120, 345], c1: [360, 350], c2: [245, 330] }, { x: 74, y: 37, end: [760, 265], c1: [595, 330], c2: [685, 285] }
];
const COLORS = ["#58a6ff", "#db61a2", "#3fb950", "#d29922", "#a371f7", "#f85149", "#39c5cf", "#ff7b72"];
const state = { view: "orbit", mode: "people", tab: "activity", activeRepo: 0, data: null, activeItemIndex: 0 };
const demoOrg = {
org: "RepoVerse Platform",
repos: [
{ name: "repoverse/platform", summary: "Product shell, graph UI, and GitHub ingestion", stars: "12.8k", forks: "1.9k", issues: "14", language: "TypeScript", health: "Stable", activity: ["4 teams active", "23 PRs in flight", "Auth surface under review"] },
{ name: "repoverse/collector", summary: "Workers that normalize GitHub events and ownership signals", stars: "2.4k", forks: "318", issues: "7", language: "Go", health: "Watch", activity: ["Queue lag up 8%", "Webhook retries patched", "Needs schema migration"] },
{ name: "repoverse/mobile", summary: "Manager and contributor companion views", stars: "846", forks: "90", issues: "21", language: "Swift", health: "At risk", activity: ["Design QA blocked", "Notifications refactor", "Beta crash triage"] }
],
teams: [
{ name: "Platform", summary: "Runtime, CI, API contracts", count: "5 people", activity: ["Owns collector and schema packages", "Two deploy-risk PRs", "Review SLA: 4.2h"], ownership: ["API contracts", "CI reliability", "Release gates"], signals: ["Build failures concentrated in collector", "Schema churn is trending down"] },
{ name: "Product", summary: "Orbit experience and profile surfaces", count: "4 people", activity: ["Dashboard windows split into modules", "Profile panel redesign ready", "Design debt: medium"], ownership: ["Console shell", "Contributor cards", "Team views"], signals: ["High PR velocity", "Low incident load"] },
{ name: "Security", summary: "Auth, tokens, private repo access", count: "3 people", activity: ["OAuth backend proposal", "Token storage review", "Audit logging model"], ownership: ["GitHub app auth", "Permissions", "Audit trail"], signals: ["Private repo path not production-ready", "Token scope guidance needed"] }
],
people: [
{ name: "Mira Chen", role: "Backend Lead", team: "Platform", avatar: "", count: 42, summary: "Splitting billing webhooks into typed event handlers.", activity: ["PR #4821 awaiting final review", "Owns billing-webhooks and event-store", "Raised service test coverage by 14%"], ownership: ["billing-webhooks", "event-store", "release notes"], signals: ["Review load high", "Low merge risk"] },
{ name: "Kai Patel", role: "Security", team: "Security", avatar: "", count: 31, summary: "Reviewing auth migration and token refresh edge cases.", activity: ["Reviewed 4 PRs today", "Flagged 2 session expiry regressions", "Maintains auth middleware"], ownership: ["auth middleware", "session handling", "token policy"], signals: ["OAuth design needs backend", "Two blockers closed"] },
{ name: "Nova Alvarez", role: "Product Engineer", team: "Product", avatar: "", count: 28, summary: "Shipping usage dashboards with streaming query cache.", activity: ["Feature branch analytics-live", "Touched dashboard, API, and cache layers", "Unblocked reporting epic"], ownership: ["analytics UI", "query cache", "usage surfaces"], signals: ["Fast cycle time", "Cross-layer changes"] },
{ name: "Sol Morgan", role: "Reliability", team: "Platform", avatar: "", count: 24, summary: "Reducing flaky tests in checkout pipeline.", activity: ["CI pass rate trending up", "Quarantined 3 unstable specs", "Cut checkout test runtime by 18%"], ownership: ["checkout tests", "CI pipelines", "test quarantine"], signals: ["Reliability improving", "Needs one more green run"] }
],
prs: [
{ title: "Split webhook event handlers", author: "Mira", status: "Needs Review", number: 4821 }, { title: "OAuth token refresh backend", author: "Kai", status: "Draft", number: 4824 },
{ title: "Usage dashboard streaming cache", author: "Nova", status: "Approved", number: 4810 }, { title: "Checkout CI quarantine cleanup", author: "Sol", status: "Changes Requested", number: 4807 }
],
feed: ["Mira opened PR #4821 in platform", "Nova merged analytics cache changes", "Security requested changes on auth migration", "collector queue lag crossed watch threshold"],
insights: [{ title: "Review load is concentrated", body: "Platform owns most open work and should get reviewer help before release freeze.", severity: "medium" }, { title: "Auth path needs real backend", body: "The prototype token flow is local-only; OAuth callback work is required before private repo use.", severity: "high" }]
};
const els = {
appShell: document.querySelector("#appShell"), workspace: document.querySelector("#workspace"), repoForm: document.querySelector("#repoForm"), orgForm: document.querySelector("#orgForm"), repoInput: document.querySelector("#repoInput"), orgInput: document.querySelector("#orgInput"), tokenInput: document.querySelector("#tokenInput"), demoButton: document.querySelector("#demoButton"), presetButtons: document.querySelectorAll("[data-repo-preset]"), statusText: document.querySelector("#statusText"), scopeTitle: document.querySelector("#scopeTitle"),
repoList: document.querySelector("#repoList"), coreTitle: document.querySelector("#coreTitle"), coreSubtitle: document.querySelector("#coreSubtitle"), connections: document.querySelector("#connections"), ringsLayer: document.querySelector("#ringsLayer"), nodesLayer: document.querySelector("#nodesLayer"), stageWrap: document.querySelector(".stage-wrap"), fullscreenStage: document.querySelector("#fullscreenStage"), restorePanels: document.querySelector("#restorePanels"),
detailName: document.querySelector("#detailName"), detailSummary: document.querySelector("#detailSummary"), activityList: document.querySelector("#activityList"), prRadar: document.querySelector("#prRadar"), activityFeed: document.querySelector("#activityFeed"), insightList: document.querySelector("#insightList")
};
function escapeHtml(value) { return String(value || "").replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[char])); }
function formatNumber(value) { return new Intl.NumberFormat("en", { notation: "compact" }).format(value || 0); }
function setStatus(message, isError = false) { els.statusText.textContent = message; els.statusText.classList.toggle("error", isError); }
function authHeaders() { const token = els.tokenInput.value.trim() || localStorage.getItem("repoverse.githubToken") || ""; return token ? { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" } : { Accept: "application/vnd.github+json" }; }
async function github(path, fallback = null) { const res = await fetch(`https://api.github.com${path}`, { headers: authHeaders() }); if (res.status === 404 && fallback !== null) return fallback; if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return res.json(); }
async function loadGithubRepo(fullName) {
const match = fullName.trim().match(/^([\w.-]+)\/([\w.-]+)$/); if (!match) throw new Error("Use owner/repo format, for example storybookjs/storybook");
persistToken(); const [, owner, repo] = match; setStatus(`Loading ${owner}/${repo} from GitHub...`);
const repoData = await github(`/repos/${owner}/${repo}`);
const detail = await fetchRepoDetail(repoData);
state.data = buildOrgFromRepo(owner, detail); state.activeRepo = 0; state.mode = "people"; setModeButton("people"); render();
setStatus(`Loaded ${repoData.full_name}. PR radar, activity feed, and inferred signals are based on public GitHub data.`);
}
async function loadGithubOrg(org) {
persistToken(); const name = org.trim(); if (!name) throw new Error("Enter an organization or user name."); setStatus(`Loading ${name} repositories from GitHub...`);
const reposRaw = await github(`/orgs/${name}/repos?sort=pushed&per_page=8`).catch(() => github(`/users/${name}/repos?sort=pushed&per_page=8`));
const repoDetails = await Promise.all(reposRaw.slice(0, 6).map((repo) => fetchRepoDetail(repo, true)));
state.data = buildOrgFromRepos(name, repoDetails); state.activeRepo = 0; state.mode = "repos"; setModeButton("repos"); render();
setStatus(`Loaded ${repoDetails.length} public repos for ${name}. Click a planet to drill into repo contributors and PRs.`);
}
async function fetchRepoDetail(repo, shallow = false) {
const [contributors, pulls, commits, events, codeowners] = await Promise.all([
shallow ? [] : github(`/repos/${repo.full_name}/contributors?per_page=8`, []),
github(`/repos/${repo.full_name}/pulls?state=open&per_page=${shallow ? 5 : 20}`, []),
github(`/repos/${repo.full_name}/commits?per_page=${shallow ? 5 : 20}`, []),
github(`/repos/${repo.full_name}/events?per_page=${shallow ? 5 : 20}`, []),
github(`/repos/${repo.full_name}/contents/.github/CODEOWNERS`, null).catch(() => github(`/repos/${repo.full_name}/contents/CODEOWNERS`, null).catch(() => null))
]);
return normalizeRepo(repo, contributors, pulls, commits, events, codeowners);
}
function normalizeRepo(repo, contributors, pulls, commits, events, codeowners) {
return { name: repo.full_name, summary: repo.description || "No repository description provided.", stars: formatNumber(repo.stargazers_count), forks: formatNumber(repo.forks_count), issues: formatNumber(repo.open_issues_count), issueCount: repo.open_issues_count || 0, language: repo.language || "Mixed", health: repo.open_issues_count > 100 ? "Watch" : "Live", updated: repo.pushed_at, contributors, pulls, commits, events, codeowners, activity: [`${formatNumber(repo.open_issues_count)} open issues`, `${formatNumber(pulls.length)} open PRs sampled`, `${repo.language || "Mixed"} primary language`] };
}
function buildOrgFromRepo(org, repo) { const people = buildPeople(repo); return { org, repos: [repo], people, teams: buildTeams(people, repo), prs: buildPrs([repo]), feed: buildFeed([repo]), insights: buildInsights([repo], people) }; }
function buildOrgFromRepos(org, repos) { const people = buildPeople(repos[0]); return { org, repos, people, teams: buildTeams(people, repos[0]), prs: buildPrs(repos), feed: buildFeed(repos), insights: buildInsights(repos, people) }; }
function buildPeople(repo) { return (repo.contributors || []).slice(0, 8).map((c) => { const commit = (repo.commits || []).find((item) => item.author && item.author.login === c.login); const pr = (repo.pulls || []).find((item) => item.user && item.user.login === c.login); return { name: c.login, role: pr ? "PR Author" : "Contributor", team: inferTeam(c.login), avatar: c.avatar_url, count: c.contributions, summary: pr ? `Open PR: ${pr.title}` : commit ? commit.commit.message.split("\n")[0] : "Recent contribution activity detected", activity: [pr ? `PR #${pr.number}: ${pr.title}` : "No open PR in the first page", commit ? `Latest commit: ${commit.commit.message.split("\n")[0]}` : "No recent commit in the first page", `GitHub profile: ${c.html_url}`], ownership: inferOwnership(repo, c.login), signals: [`${c.contributions} public contributions`, pr ? "Has active review surface" : "Mostly commit activity in sample", "Real GitHub snapshot with inferred labels"] }; }); }
function inferTeam(login) { const teams = ["Platform", "Product", "Security", "Reliability"]; return teams[Math.abs([...login].reduce((sum, char) => sum + char.charCodeAt(0), 0)) % teams.length]; }
function inferOwnership(repo, login) { const owned = new Set(); (repo.commits || []).filter((commit) => commit.author && commit.author.login === login).forEach((commit) => { const msg = commit.commit.message.toLowerCase(); if (msg.includes("doc")) owned.add("docs"); if (msg.includes("test")) owned.add("tests"); if (msg.includes("fix")) owned.add("bug fixes"); }); if (repo.codeowners) owned.add("CODEOWNERS available"); return owned.size ? [...owned] : ["Recent commits", "PR authorship", "Repository contribution graph"]; }
function buildTeams(people, repo) { const groups = people.reduce((acc, person) => { acc[person.team] ||= []; acc[person.team].push(person); return acc; }, {}); return Object.entries(groups).map(([name, members]) => ({ name, summary: `${members.length} sampled contributors connected to ${repo.name}`, count: `${members.length} people`, activity: members.slice(0, 3).map((member) => `${member.name}: ${member.summary}`), ownership: [...new Set(members.flatMap((member) => member.ownership))].slice(0, 4), signals: [`${members.reduce((sum, member) => sum + Number(member.count || 0), 0)} sampled contributions`, "Team assignment inferred for demo presentation"] })); }
function buildPrs(repos) { return repos.flatMap((repo) => (repo.pulls || []).map((pr, index) => ({ title: pr.title, author: pr.user ? pr.user.login : "unknown", status: pr.draft ? "Draft" : isStale(pr.updated_at) ? "Stale" : index % 5 === 0 ? "Changes Requested" : index % 3 === 0 ? "Approved" : "Needs Review", number: pr.number, repo: repo.name, updated: pr.updated_at }))).slice(0, 24); }
function isStale(date) { return date && Date.now() - new Date(date).getTime() > 1000 * 60 * 60 * 24 * 14; }
function buildFeed(repos) { return repos.flatMap((repo) => { const events = (repo.events || []).map((event) => `${event.actor?.login || "someone"} ${event.type.replace("Event", "")} in ${repo.name}`); const commits = (repo.commits || []).slice(0, 3).map((commit) => `${commit.commit.author?.name || "someone"} committed: ${commit.commit.message.split("\n")[0]}`); return events.concat(commits); }).slice(0, 18); }
function buildInsights(repos, people) { const totalIssues = repos.reduce((sum, repo) => sum + repo.issueCount, 0); const prCount = repos.reduce((sum, repo) => sum + (repo.pulls || []).length, 0); const activeRepo = repos[0]; return [{ title: "Review queue snapshot", body: `${prCount} sampled open PRs across ${repos.length} repo${repos.length === 1 ? "" : "s"}. RepoVerse can use this as the real PR radar backbone.`, severity: prCount > 10 ? "medium" : "low" }, { title: "Issue load signal", body: `${formatNumber(totalIssues)} open issues across the loaded scope. Use planets and rings to avoid flattening massive projects into one list.`, severity: totalIssues > 500 ? "high" : totalIssues > 100 ? "medium" : "low" }, { title: "Ownership is inferable, not perfect", body: activeRepo && activeRepo.codeowners ? "CODEOWNERS was found, so ownership can be grounded in repo metadata." : "No CODEOWNERS found in the sampled path, so ownership is inferred from commits, PR authors, and reviewers.", severity: activeRepo && activeRepo.codeowners ? "low" : "medium" }, { title: "Hybrid mock layer remains useful", body: `${people.length} contributor nodes use real avatars/activity, while team names and risk summaries are enriched for demo clarity.`, severity: "low" }]; }
function persistToken() { if (els.tokenInput.value.trim()) localStorage.setItem("repoverse.githubToken", els.tokenInput.value.trim()); }
function render() { const data = state.data; const repo = data.repos[state.activeRepo] || data.repos[0]; els.scopeTitle.textContent = data.org; els.coreTitle.textContent = repo.name; els.coreSubtitle.textContent = `${repo.health} - ${repo.language}`; renderRepoList(data.repos); renderRings(); const nodes = getNodes(); renderConnections(nodes.length); renderNodes(nodes); renderRadar(data.prs); renderFeed(data.feed); renderInsights(data.insights); focusItem(nodes[state.activeItemIndex] || nodes[0], state.activeItemIndex || 0); }
function getNodes() { if (state.mode === "repos") return state.data.repos.map((repo) => ({ ...repo, kind: "repo", count: `${repo.issues} issues`, detail: repo.summary, ownership: ["Repository planet", "Issue load", "Primary language"], signals: repo.activity })); if (state.mode === "teams") return state.data.teams.map((team) => ({ ...team, kind: "team", detail: team.summary })); return state.data.people.map((person) => ({ ...person, kind: "person", detail: `${person.role} on ${person.team}. ${person.summary}` })); }
function renderRepoList(repos) { els.repoList.innerHTML = repos.map((repo, index) => `<button class="repo-card ${index === state.activeRepo ? "active" : ""}" type="button" data-repo="${index}"><strong>${escapeHtml(repo.name)}</strong><span>${escapeHtml(repo.summary)}</span><div class="repo-meta"><small>${escapeHtml(repo.language)}</small><small>${escapeHtml(repo.stars)} stars</small><small>${escapeHtml(repo.health)}</small></div></button>`).join(""); document.querySelectorAll(".repo-card").forEach((button) => button.addEventListener("click", () => drillIntoRepo(Number(button.dataset.repo)))); }
function renderRings() { const ownership = state.mode === "repos" ? ["High Activity", "Long Tail"] : state.mode === "teams" ? state.data.teams.map((team) => team.name).slice(0, 3) : [...new Set(state.data.people.map((person) => person.team))].slice(0, 3); const rings = [48, 66, 82].slice(0, Math.max(2, ownership.length)); els.ringsLayer.innerHTML = rings.map((size, index) => `<div class="ring" style="width:${size}%;height:${size}%"><span>${escapeHtml(ownership[index] || "Support")}</span></div>`).join("") + ownership.map((label, index) => `<span class="ownership-label" style="left:${18 + index * 27}%;top:${92 - index * 8}%">${escapeHtml(label)}</span>`).join(""); }
function renderConnections(count) { els.connections.innerHTML = `<defs><linearGradient id="lineA" x1="0%" x2="100%"><stop offset="0%" stop-color="#58a6ff"/><stop offset="54%" stop-color="#3fb950"/><stop offset="100%" stop-color="#db61a2"/></linearGradient><filter id="glow"><feGaussianBlur stdDeviation="3" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>` + POSITIONS.slice(0, count).map((pos, index) => `<path data-index="${index}" d="M500 360 C${pos.c1[0]} ${pos.c1[1]} ${pos.c2[0]} ${pos.c2[1]} ${pos.end[0]} ${pos.end[1]}"/>`).join(""); }
function renderNodes(nodes) { els.nodesLayer.innerHTML = nodes.slice(0, 8).map((item, index) => { const pos = POSITIONS[index]; const accent = COLORS[index % COLORS.length]; const size = item.kind === "repo" ? Math.max(48, Math.min(92, 46 + Number(item.issueCount || 0) / 8 + Number(String(item.stars || "0").replace(/[^\d]/g, "")) / 8000)) : 54; const visual = item.kind === "repo" ? `<div class="planet" style="--planet-size:${size}px" aria-hidden="true"></div>` : `<div class="avatar">${item.avatar ? `<img src="${escapeHtml(item.avatar)}" alt=""/>` : `<span class="fallback">${escapeHtml(item.name.slice(0, 1).toUpperCase())}</span>`}</div>`; return `<article class="node" data-kind="${escapeHtml(item.kind)}" data-index="${index}" tabindex="0" style="left:${pos.x}%;top:${pos.y}%;--accent:${accent}">${visual}<div class="bubble"><strong>${escapeHtml(item.name)}</strong><p>${escapeHtml(item.summary)}</p><small>${escapeHtml(item.count || item.role || item.health || "active")}</small></div></article>`; }).join(""); document.querySelectorAll(".node").forEach((node) => { const index = Number(node.dataset.index); node.addEventListener("mouseenter", () => focusItem(nodes[index], index)); node.addEventListener("focus", () => focusItem(nodes[index], index)); node.addEventListener("mouseleave", resetLines); node.addEventListener("blur", resetLines); node.addEventListener("click", () => { if (nodes[index].kind === "repo") drillIntoRepo(index); }); node.addEventListener("keydown", (event) => { if ((event.key === "Enter" || event.key === " ") && nodes[index].kind === "repo") { event.preventDefault(); drillIntoRepo(index); } }); }); }
function focusItem(item, index = 0) { if (!item) return; state.activeItemIndex = index; els.detailName.textContent = item.name; els.detailSummary.textContent = item.detail || item.summary; const list = item[state.tab] || item.activity || []; els.activityList.innerHTML = list.map((entry) => `<div><span></span>${escapeHtml(entry)}</div>`).join(""); document.querySelectorAll(".connections path").forEach((line) => { const active = Number(line.dataset.index) === index; line.style.opacity = active ? "1" : ".2"; line.style.strokeWidth = active ? "4" : "2.5"; }); }
function resetLines() { document.querySelectorAll(".connections path").forEach((line) => { line.style.opacity = ".78"; line.style.strokeWidth = "2.5"; }); }
function renderRadar(prs) { const columns = ["Draft", "Needs Review", "Changes Requested", "Approved", "Stale"]; els.prRadar.innerHTML = columns.map((status) => `<section class="radar-column"><h3>${status}</h3>${prs.filter((pr) => pr.status === status).slice(0, 5).map((pr) => `<div class="pr-card"><strong>#${pr.number} ${escapeHtml(pr.title)}</strong><span>${escapeHtml(pr.author)} - ${escapeHtml(pr.repo || "demo")}</span></div>`).join("") || `<div class="pr-card"><span>No sampled PRs</span></div>`}</section>`).join(""); }
function renderFeed(items) { els.activityFeed.innerHTML = (items.length ? items : ["No sampled activity events available."]).slice(0, 12).map((item) => `<div class="feed-item"><strong>${escapeHtml(item)}</strong><span>GitHub event or commit sample</span></div>`).join(""); }
function renderInsights(items) { els.insightList.innerHTML = items.map((item) => `<div class="insight-item" data-severity="${escapeHtml(item.severity)}"><strong>${escapeHtml(item.title)}</strong><span>${escapeHtml(item.body)}</span></div>`).join(""); }
async function drillIntoRepo(index) { state.activeRepo = index; state.activeItemIndex = 0; const repo = state.data.repos[index]; if (!repo.contributors || repo.contributors.length === 0) { setStatus(`Drilling into ${repo.name}...`); const detail = await fetchRepoDetail({ full_name: repo.name, description: repo.summary, stargazers_count: 0, forks_count: 0, open_issues_count: repo.issueCount, language: repo.language, pushed_at: repo.updated }); state.data.repos[index] = detail; state.data.people = buildPeople(detail); state.data.teams = buildTeams(state.data.people, detail); state.data.prs = buildPrs(state.data.repos); state.data.feed = buildFeed(state.data.repos); state.data.insights = buildInsights(state.data.repos, state.data.people); } state.mode = "people"; state.view = "orbit"; setModeButton("people"); setViewButton("orbit"); render(); setStatus(`Focused ${repo.name}. People now orbit the selected repo.`); }
function setModeButton(mode) { document.querySelectorAll(".segment").forEach((button) => { const active = button.dataset.mode === mode; button.classList.toggle("active", active); button.setAttribute("aria-pressed", String(active)); }); }
function setViewButton(view) { state.view = view; els.appShell.dataset.activeView = view; document.querySelectorAll(".tab-button").forEach((button) => { const active = button.dataset.view === view; button.classList.toggle("active", active); button.setAttribute("aria-pressed", String(active)); }); }
function setTabButton(tab) { document.querySelectorAll(".window-tab").forEach((button) => { const active = button.dataset.tab === tab; button.classList.toggle("active", active); button.setAttribute("aria-pressed", String(active)); }); }
document.querySelectorAll(".tab-button").forEach((button) => button.addEventListener("click", () => setViewButton(button.dataset.view)));
document.querySelectorAll(".segment").forEach((button) => button.addEventListener("click", () => { state.mode = button.dataset.mode; state.activeItemIndex = 0; setModeButton(state.mode); render(); }));
document.querySelectorAll(".window-tab").forEach((button) => button.addEventListener("click", () => { state.tab = button.dataset.tab; setTabButton(state.tab); focusItem(getNodes()[state.activeItemIndex] || getNodes()[0], state.activeItemIndex); }));
document.querySelectorAll("[data-collapse]").forEach((button) => button.addEventListener("click", () => els.workspace.classList.toggle(button.dataset.collapse === "leftPanel" ? "left-collapsed" : "right-collapsed")));
els.restorePanels.addEventListener("click", () => els.workspace.classList.remove("left-collapsed", "right-collapsed"));
els.fullscreenStage.addEventListener("click", () => { const full = els.stageWrap.classList.toggle("fullscreen"); els.fullscreenStage.textContent = full ? "Exit" : "Fullscreen"; });
els.repoForm.addEventListener("submit", async (event) => { event.preventDefault(); try { await loadGithubRepo(els.repoInput.value); } catch (error) { setStatus(`GitHub repo load failed: ${error.message}`, true); } });
els.orgForm.addEventListener("submit", async (event) => { event.preventDefault(); try { await loadGithubOrg(els.orgInput.value); } catch (error) { setStatus(`GitHub org load failed: ${error.message}`, true); } });
els.presetButtons.forEach((button) => button.addEventListener("click", async () => { const repo = button.dataset.repoPreset; els.repoInput.value = repo; try { await loadGithubRepo(repo); } catch (error) { setStatus(`GitHub repo load failed: ${error.message}`, true); } }));
els.demoButton.addEventListener("click", () => { state.data = demoOrg; state.activeRepo = 0; state.mode = "people"; state.activeItemIndex = 0; setModeButton("people"); render(); setStatus("Demo organization loaded. Use rings for scale, teams for collections, and repos for planets."); });
state.data = demoOrg;
setViewButton("orbit");
render();