From fab55a0ad60f77b289a3426032e4ce44b8ab399f Mon Sep 17 00:00:00 2001 From: Railly Hugo Date: Sun, 27 Sep 2026 01:31:18 -0300 Subject: [PATCH 01/14] refactor(dashboard): define source-neutral project metadata --- src/dashboard-types.ts | 25 +++++++++++++++++++++++++ src/html.ts | 38 +++++++++++++++++++++++++++++++++++--- src/index.ts | 1 + 3 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 src/dashboard-types.ts diff --git a/src/dashboard-types.ts b/src/dashboard-types.ts new file mode 100644 index 0000000..32e60ba --- /dev/null +++ b/src/dashboard-types.ts @@ -0,0 +1,25 @@ +export interface OpenItemCount { + value: number; + complete: boolean; + observedAt: string; + issues: number; + pullRequests?: number; +} + +export interface ReadCoverage { + source: string; + pages: number; + complete: boolean; + reason?: "page-limit" | "node-limit" | "invalid-page" | "read-failed" | "changed-during-read"; +} + +export interface WorkLink { + url: string; + title: string; + evidence: { kind: "attachment"; id: string }; +} + +export interface RelationshipEvidence { + kind: "relation" | "hierarchy"; + id: string; +} diff --git a/src/html.ts b/src/html.ts index aeb96ee..366a01b 100644 --- a/src/html.ts +++ b/src/html.ts @@ -1,5 +1,11 @@ import { isSupersededVerdict } from "./classify.js"; import { components } from "./crawl.js"; +import type { + OpenItemCount, + ReadCoverage, + RelationshipEvidence, + WorkLink, +} from "./dashboard-types.js"; import { fileOverlaps } from "./overlaps.js"; import { prioritize } from "./priority.js"; import type { GraphNode, NodeKey } from "./types.js"; @@ -57,11 +63,17 @@ export interface ProviderDescriptor { repoUrl: string; /** Signals only this provider has, shown in the sidebar's provider slot. */ signals: Array<{ id: string; label: string; tone: "warn" | "danger" }>; + views?: Array<"explore" | "impact" | "swarm" | "rank">; + metrics?: Array<"heat" | "links" | "blast" | "depth">; } export interface Model { - /** Referenced items the node cap left out of this run. */ notCrawled?: number; + id?: string; + label?: string; + url?: string; + openCount?: OpenItemCount; + grouping?: "components" | "themes"; provider: ProviderDescriptor; repo: string; seeds: NodeKey[]; @@ -69,14 +81,27 @@ export interface Model { cleanup: CleanupItem[]; stats: Record; nodes: Record; + coverage?: { + complete: boolean; + generatedAt: string; + maxDepth: number; + messages: string[]; + warnings?: string[]; + }; } export interface ClientNode { repo: string; key: NodeKey; num: number; + identifier?: string; kind: "PullRequest" | "Issue" | "Unknown"; state: string; + stateLabel?: string; + stateType?: string; + archived?: boolean; + read?: { fetched: boolean; error?: string; coverage: ReadCoverage[] }; + attachments?: WorkLink[]; title: string; url: string; author?: string; @@ -103,8 +128,15 @@ export interface ClientNode { daysOpen: number; inboundRefs: number; }; - out: Array<{ to: NodeKey; via: string; by?: string; at?: string }>; - in: Array<{ from: NodeKey; via: string; by?: string }>; + out: Array<{ + to: NodeKey; + via: string; + by?: string; + at?: string; + undirected?: boolean; + evidence?: RelationshipEvidence; + }>; + in: Array<{ from: NodeKey; via: string; by?: string; evidence?: RelationshipEvidence }>; overlaps: Array<{ with: NodeKey; shared: string[]; significant: number; sharedIssue?: NodeKey }>; } diff --git a/src/index.ts b/src/index.ts index 2cbc3f5..c5857c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ */ export * from "./classify.js"; export * from "./crawl.js"; +export * from "./dashboard-types.js"; export * from "./github.js"; export * from "./html.js"; export * from "./overlaps.js"; From 188516e4bfb8869745a9a4bde7ed9c4c7cd7e33d Mon Sep 17 00:00:00 2001 From: Railly Hugo Date: Sun, 27 Sep 2026 01:31:18 -0300 Subject: [PATCH 02/14] fix(dashboard): capture repository-wide open totals --- scripts/verify-package.ts | 4 +++- src/cli.ts | 9 +++++-- src/github.ts | 48 ++++++++++++++++++++++++++++++++++++++ src/project-counts.test.ts | 42 +++++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 src/project-counts.test.ts diff --git a/scripts/verify-package.ts b/scripts/verify-package.ts index 1fa863d..196dd7d 100644 --- a/scripts/verify-package.ts +++ b/scripts/verify-package.ts @@ -90,7 +90,9 @@ const pageInfo = { hasNextPage: false, endCursor: null }; const author = { login: "smoke-author" }; const createdAt = "2026-01-01T00:00:00Z"; let repository; -if (fields.query.includes("issueOrPullRequest(number:$n)")) { +if (fields.query.includes("IssueGraphRepositoryOpen")) { + repository = { nameWithOwner: "package-smoke/fixture", issues: { totalCount: 12 }, pullRequests: { totalCount: 7 } }; +} else if (fields.query.includes("issueOrPullRequest(number:$n)")) { assert.ok(fields.n === "1" || fields.n === "2"); const pr = fields.n === "2"; repository = { diff --git a/src/cli.ts b/src/cli.ts index 042ff86..3899bb0 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,7 +14,7 @@ import { runAgent, } from "./cluster.js"; import { components, crawl } from "./crawl.js"; -import { labelSeeds, makeFetchNode, openBacklogSeeds } from "./github.js"; +import { labelSeeds, makeFetchNode, openBacklogSeeds, repositoryOpenCount } from "./github.js"; import { applyClusters, type ClustersConfig, @@ -452,7 +452,7 @@ export async function runCli(argv = process.argv.slice(2)): Promise { } const out = args.htmlOut || join(tmpdir(), `issue-graph-dashboard-${Date.now()}.html`); await writeOutput(out, renderDashboard(models)); - process.stderr.write(`wrote ${out} (${models.map((m) => m.repo).join(", ")})\n`); + process.stderr.write(`wrote ${out} (${models.map((m) => m.label ?? m.repo).join(", ")})\n`); if (args.openMode === "yes" || (args.openMode === "auto" && interactive())) openInBrowser(out); return; } @@ -741,6 +741,11 @@ export async function runCli(argv = process.argv.slice(2)): Promise { args.htmlOut || join(tmpdir(), `issue-graph-${primary.owner}-${primary.repo}-${Date.now()}.html`); const model = dashboardModel(nodes, seedKeys, repoName, clusters, cappedOut.size); + try { + model.openCount = await repositoryOpenCount(transport, primary.owner, primary.repo); + } catch { + process.stderr.write("Repository open totals unavailable; selector count will be unknown.\n"); + } await writeOutput(out, renderDashboard([model])); if (args.command !== "open" && args.command !== "cluster") process.stderr.write(`wrote ${out}\n`); diff --git a/src/github.ts b/src/github.ts index c96e0a0..68a3189 100644 --- a/src/github.ts +++ b/src/github.ts @@ -1,7 +1,55 @@ +import type { OpenItemCount } from "./dashboard-types.js"; import { extractClosingRefs, extractRefs } from "./refs.js"; import type { GhTransport } from "./transport.js"; import type { Edge, GraphNode, NodeKey, Via } from "./types.js"; +export const REPOSITORY_OPEN_QUERY = `query IssueGraphRepositoryOpen($owner:String!,$repo:String!){ + repository(owner:$owner,name:$repo){ + nameWithOwner + issues(states:OPEN){totalCount} + pullRequests(states:OPEN){totalCount} + } +}`; + +export async function repositoryOpenCount( + transport: Pick, + owner: string, + repo: string, +): Promise { + const response = (await transport.graphql(REPOSITORY_OPEN_QUERY, { owner, repo })) as { + data?: { + repository?: { + nameWithOwner?: string; + issues?: { totalCount?: number }; + pullRequests?: { totalCount?: number }; + }; + }; + errors?: unknown[]; + }; + const found = response?.data?.repository; + const issues = found?.issues?.totalCount; + const pullRequests = found?.pullRequests?.totalCount; + if ( + response?.errors?.length || + found?.nameWithOwner?.toLowerCase() !== `${owner}/${repo}`.toLowerCase() || + typeof issues !== "number" || + typeof pullRequests !== "number" || + !Number.isSafeInteger(issues) || + !Number.isSafeInteger(pullRequests) || + issues < 0 || + pullRequests < 0 || + !Number.isSafeInteger(issues + pullRequests) + ) + throw new Error("Repository open totals unavailable"); + return { + value: issues + pullRequests, + issues, + pullRequests, + complete: true, + observedAt: new Date().toISOString(), + }; +} + /** One call: node state + title + body + comments + structural edges. */ export const NODE_QUERY = `query($owner:String!,$repo:String!,$n:Int!){ repository(owner:$owner,name:$repo){ diff --git a/src/project-counts.test.ts b/src/project-counts.test.ts new file mode 100644 index 0000000..fc6a43b --- /dev/null +++ b/src/project-counts.test.ts @@ -0,0 +1,42 @@ +import { expect, test, vi } from "vitest"; +import { repositoryOpenCount } from "./github.js"; + +const githubResponse = (issues = 301, pullRequests = 47) => ({ + data: { + repository: { + nameWithOwner: "Example/Repo", + issues: { totalCount: issues }, + pullRequests: { totalCount: pullRequests }, + }, + }, +}); + +test("reads GitHub's entire repository counts, including both issues and PRs", async () => { + const transport = { graphql: vi.fn(async () => githubResponse()) }; + expect(await repositoryOpenCount(transport, "example", "repo")).toMatchObject({ + value: 348, + issues: 301, + pullRequests: 47, + complete: true, + }); + expect(transport.graphql.mock.calls[0]).toEqual([ + expect.stringContaining("issues(states:OPEN){totalCount}"), + { owner: "example", repo: "repo" }, + ]); + expect( + await repositoryOpenCount({ graphql: async () => githubResponse(0, 0) }, "example", "repo"), + ).toMatchObject({ value: 0, complete: true }); +}); + +test.each([ + {}, + { ...githubResponse(), errors: [{ message: "partial" }] }, + { data: { repository: { ...githubResponse().data.repository, nameWithOwner: "other/repo" } } }, + githubResponse(-1), + githubResponse(1.5), + githubResponse(Number.MAX_SAFE_INTEGER, 1), +])("rejects an unavailable or unreliable GitHub total: %j", async (response) => { + await expect( + repositoryOpenCount({ graphql: async () => response }, "example", "repo"), + ).rejects.toThrow(); +}); From 59652923f9c0762fd2f80423b7dfc040fb620c61 Mon Sep 17 00:00:00 2001 From: Railly Hugo Date: Sun, 27 Sep 2026 01:31:19 -0300 Subject: [PATCH 03/14] feat(dashboard): unify graph views and clarify swarm legends --- src/dashboard-presentation.test.ts | 65 ++++++ src/html.ts | 350 +++++++++++++++++++---------- 2 files changed, 299 insertions(+), 116 deletions(-) create mode 100644 src/dashboard-presentation.test.ts diff --git a/src/dashboard-presentation.test.ts b/src/dashboard-presentation.test.ts new file mode 100644 index 0000000..db79107 --- /dev/null +++ b/src/dashboard-presentation.test.ts @@ -0,0 +1,65 @@ +import { runInNewContext, Script } from "node:vm"; +import { expect, test } from "vitest"; +import { dashboardModel, renderDashboard } from "./html.js"; + +function presentation(expression: string, data: unknown = { groups: [] }): T { + const html = renderDashboard([dashboardModel(new Map(), [], "test/project")]); + const script = html.slice(html.lastIndexOf("")); + new Script(script); + const helpers = script.slice( + script.indexOf("function statusInfo("), + script.indexOf("const statGrid="), + ); + return runInNewContext(`${helpers}\n${expression}`, { DATA: data }); +} + +test("cluster colors remain unique through the node limit without wrapping a short palette", () => { + const colors = presentation("Array.from({length:1000},(_,i)=>groupColor(i))"); + expect(new Set(colors).size).toBe(1000); + expect(presentation("groupColor(3)")).toBe(colors[3]); +}); + +test("shared statuses distinguish completed, canceled, archived and unavailable items", () => { + const statuses = presentation>( + `[ + {state:'OPEN',kind:'Issue'}, + {state:'CLOSED',stateType:'completed'}, + {state:'CLOSED',stateType:'canceled'}, + {state:'CLOSED',stateType:'duplicate'}, + {state:'OPEN',archived:true}, + {state:'UNKNOWN',read:{fetched:false}} + ].map(statusInfo)`, + ); + expect(statuses.map((s) => s.label)).toEqual([ + "Open issue", + "Completed", + "Canceled", + "Duplicate", + "Archived", + "Unavailable", + ]); + expect(new Set(statuses.map((s) => s.color)).size).toBe(statuses.length); +}); + +test("legends partition actual items and omit absent categories for any provider", () => { + const groups = presentation>( + "statusGroups([{key:'a',state:'OPEN',kind:'Issue'},{key:'b',state:'OPEN',kind:'Issue'},{key:'c',state:'MERGED',kind:'PullRequest'}])", + ); + expect(groups.map((g) => g.members)).toEqual([["a", "b"], ["c"]]); + expect(presentation("statusGroups([])")).toEqual([]); +}); + +test("superseded open work retains its hollow category without overriding closed or archived states", () => { + expect( + presentation("['OPEN','CLOSED'].map(state=>statusInfo({state,verdict:'SUPERSEDED'}).label)"), + ).toEqual(["Superseded", "Closed"]); +}); + +test.each([ + [{ grouping: "themes", groups: [] }, true], + [{ grouping: "components", groups: [{ label: "Component 1" }] }, false], + [{ groups: [{ label: "Component 12" }] }, false], + [{ groups: [{ label: "Keyboard input" }] }, true], +])("saved grouping metadata stays meaningful without a provider branch", (data, expected) => { + expect(presentation("proposedGroups()", data)).toBe(expected); +}); diff --git a/src/html.ts b/src/html.ts index 366a01b..70979af 100644 --- a/src/html.ts +++ b/src/html.ts @@ -269,7 +269,16 @@ function buildModel( overlaps: fileOverlaps(nodes).length, }; - return { provider: GITHUB, repo, seeds: seedKeys, groups, cleanup, stats, nodes: clientNodes }; + return { + provider: GITHUB, + repo, + grouping: clusters.length ? "themes" : "components", + seeds: seedKeys, + groups, + cleanup, + stats, + nodes: clientNodes, + }; } /** Render the graph as a self-contained, Geist-styled master–detail explorer. */ @@ -305,7 +314,13 @@ export function applyClusters( if (rest.length) groups.push({ label: "Ungrouped", subtitle: "no cluster", members: rest }); for (const c of cleanup) if (c.key && !nodes[c.key]) unknown.add(c.key); return { - model: { ...model, nodes, groups, cleanup: cleanup.filter((c) => !c.key || nodes[c.key]) }, + model: { + ...model, + nodes, + groups, + grouping: "themes", + cleanup: cleanup.filter((c) => !c.key || nodes[c.key]), + }, unknown: [...unknown], }; } @@ -381,7 +396,7 @@ code{font-family:var(--mono);font-size:12px;background:var(--closed-bg);padding: .shell{display:grid;grid-template-columns:340px 1fr;height:100vh} @media(max-width:820px){.shell{grid-template-columns:1fr;height:auto}} /* sidebar */ -.side{border-right:1px solid var(--border);display:flex;flex-direction:column;min-height:0;background:var(--bg)} +.side{border-right:1px solid var(--border);display:flex;flex-direction:column;min-height:0;min-width:0;background:var(--bg)} .side-top{padding:20px 16px 16px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:16px} .brand{display:flex;flex-direction:column;gap:4px} .brand-row{display:flex;align-items:center;gap:8px} @@ -404,16 +419,16 @@ code{font-family:var(--mono);font-size:12px;background:var(--closed-bg);padding: .ropt{display:grid;grid-template-columns:16px minmax(0,1fr) auto;gap:6px;align-items:center;width:100%;height:32px;padding:0 8px;border:0;border-radius:6px;background:none;color:var(--fg);font-family:var(--sans);font-size:13px;text-align:left;cursor:pointer} .ropt:hover,.ropt:focus{background:var(--bg2);outline:none} .ropt b{font-weight:400;color:var(--muted);font-variant-numeric:tabular-nums} -.rname{display:inline-flex;align-items:center;gap:6px;min-width:0;overflow:hidden}.rname .mono{font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rname .gh-mark{width:13px;height:13px} +.rname{display:inline-flex;align-items:center;gap:6px;min-width:0;overflow:hidden}.rname .mono{font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rname svg{width:13px;height:13px} .rcheck{font-size:12px;color:var(--fg)} @media (prefers-reduced-motion:reduce){.repo-menu,.pchev{animation:none;transition:none}} -.gh-mark{width:14px;height:14px;flex-shrink:0;display:block} -.coverage{font-size:12px;color:var(--muted);margin-top:-8px} -.coverage span{text-decoration:underline dotted;text-underline-offset:3px;cursor:help} +.gh-mark,:where(.project>svg:first-child,.rname>svg,.pv-head>svg,.pv-link>svg){width:14px;height:14px;flex-shrink:0;display:block} +.capture-counts{font-size:12px;color:var(--muted);margin-top:-8px} +.capture-counts span{text-decoration:underline dotted;text-underline-offset:3px;cursor:help} .pv-slot{display:flex;flex-direction:column;gap:6px} .pv-head{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)} -.pv-head .gh-mark{width:12px;height:12px} -.pv-link{display:inline-flex;align-items:center;gap:6px}.pv-link .gh-mark{width:13px;height:13px} +.pv-head svg{width:12px;height:12px} +.pv-link{display:inline-flex;align-items:center;gap:6px}.pv-link svg{width:13px;height:13px} .brand-sep{color:var(--border2);font-size:18px;font-weight:300;line-height:1} .brand-name{font-weight:600;font-size:15px;letter-spacing:-.01em} .mix{display:flex;flex-direction:column;gap:8px} @@ -429,6 +444,16 @@ code{font-family:var(--mono);font-size:12px;background:var(--closed-bg);padding: .stat-l{font-size:12px;color:var(--muted)} .stat.warn .stat-n{color:var(--warn-fg)}.stat.danger .stat-n{color:var(--danger-fg)}.stat.zero .stat-n{color:var(--muted)} .view-toggle{display:grid;grid-template-columns:repeat(4,1fr);gap:2px;padding:2px;background:var(--bg2);border:1px solid var(--border);border-radius:9999px} +.coverage{font-size:12px;line-height:18px;color:var(--fg2)} +.coverage.partial{padding:8px 10px;border:1px solid var(--warn-fg);border-radius:6px} +.coverage summary{cursor:pointer} +.coverage ul{padding-left:16px;margin:6px 0} +.coverage time{float:right;font-size:11px} +.coverage .coverage-warning{color:var(--warn-fg)} +.read-coverage{font-size:12px;color:var(--fg2)} +.read-coverage summary{cursor:pointer} +.read-coverage.partial{padding:12px 14px;border:1px solid var(--warn-fg);border-radius:6px} +.read-coverage ul{padding-left:18px;margin:6px 0 0} .view-btn{height:28px;border:0;border-radius:9999px;background:transparent;color:var(--muted);font-family:var(--sans);font-size:13px;cursor:pointer;transition:color 150ms,background 150ms} .view-btn:hover{color:var(--fg)}.view-btn.active{background:var(--bg);color:var(--fg);box-shadow:0 0 0 1px var(--border2)} .view-btn:focus-visible,.item:focus-visible,.cleanup-pill:focus-visible,.grp>summary:focus-visible{outline:2px solid var(--accent);outline-offset:1px} @@ -459,15 +484,15 @@ code{font-family:var(--mono);font-size:12px;background:var(--closed-bg);padding: .item .num{font-family:var(--mono);font-size:12px;color:var(--muted);flex-shrink:0;font-variant-numeric:tabular-nums} .item .t{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1} .dot{width:8px;height:8px;border-radius:9999px;flex-shrink:0} -.k-iss{background:var(--open-fg)}.k-pr{background:var(--accent)}.k-merged{background:var(--merged-fg)}.k-closed{background:var(--closed-fg)} +.k-iss{background:var(--open-fg)}.k-pr{background:var(--accent)}.k-merged{background:var(--merged-fg)}.k-closed{background:var(--danger-fg)} .k-sup{background:transparent;box-shadow:inset 0 0 0 1.5px var(--accent)} -.dot-OPEN{background:var(--open-fg)}.dot-MERGED{background:var(--merged-fg)}.dot-CLOSED{background:var(--closed-fg)}.dot-UNKNOWN{background:var(--muted)} +.dot-OPEN{background:var(--open-fg)}.dot-MERGED{background:var(--merged-fg)}.dot-CLOSED{background:var(--danger-fg)}.dot-UNKNOWN{background:var(--muted)} .fl{color:var(--warn-fg);flex-shrink:0;font-size:12px} .swarm .d.hl circle{stroke:var(--fg);stroke-width:2.5} .swarm .d.hl{filter:drop-shadow(0 0 0 var(--fg))} @media (prefers-reduced-motion:reduce){.chev,.mix-bar span,.grp.anim .grp-items .item{transition:none;animation:none}} /* main */ -.main{overflow-y:auto;padding:32px 40px 80px;min-height:0} +.main{overflow-y:auto;padding:32px 40px 80px;min-height:0;min-width:0} .empty{color:var(--muted);display:flex;height:100%;align-items:center;justify-content:center} .insp h1{font-size:22px;font-weight:600;letter-spacing:-.02em;margin:0 0 4px;display:flex;gap:10px;align-items:baseline;flex-wrap:wrap} .insp h1 .num{font-family:var(--mono);color:var(--muted);font-size:18px} @@ -475,6 +500,7 @@ code{font-family:var(--mono);font-size:12px;background:var(--closed-bg);padding: .sec{margin-top:24px} .sec h3{font-size:12px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:0 0 8px;font-weight:600} .rel{display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border)} +.rel>a[href]{overflow-wrap:anywhere;min-width:0} .rel .via{font-family:var(--mono);font-size:11px;padding:1px 6px;border-radius:4px;background:var(--closed-bg);color:var(--fg2);min-width:78px;text-align:center} .rel .via-closes,.rel .via-closed-by{background:var(--merged-bg);color:var(--merged-fg)} .rel .via-competes,.rel .via-overlaps{background:var(--warn-bg);color:var(--warn-fg)} @@ -482,7 +508,7 @@ code{font-family:var(--mono);font-size:12px;background:var(--closed-bg);padding: .verdict{padding:12px 14px;border-radius:var(--radius-md);background:var(--warn-bg);color:var(--warn-fg);font-weight:500;border:1px solid var(--border)} .badge{font-size:12px;font-weight:500;padding:2px 8px;border-radius:9999px;white-space:nowrap} .b-open{background:var(--open-bg);color:var(--open-fg)}.b-merged{background:var(--merged-bg);color:var(--merged-fg)} -.b-closed{background:var(--closed-bg);color:var(--closed-fg)}.b-warn{background:var(--warn-bg);color:var(--warn-fg)} +.b-closed{background:var(--danger-bg);color:var(--danger-fg)}.b-warn{background:var(--warn-bg);color:var(--warn-fg)} .b-danger{background:var(--danger-bg);color:var(--danger-fg)}.b-muted{background:var(--closed-bg);color:var(--muted)} .kind{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em} /* swarm */ @@ -494,11 +520,16 @@ code{font-family:var(--mono);font-size:12px;background:var(--closed-bg);padding: .seg button.on{background:var(--bg);color:var(--fg);box-shadow:0 0 0 1px var(--border2)} .seg button:focus-visible{outline:2px solid var(--accent);outline-offset:1px} .segs{display:flex;gap:8px;flex-wrap:wrap} -.swarm-legend{display:flex;gap:16px;flex-wrap:wrap;align-items:center;font-size:13px;color:var(--fg2);margin:4px 0 12px} +.swarm-legend{display:flex;gap:8px 16px;flex-wrap:wrap;align-items:center;font-size:13px;color:var(--fg2);margin:4px 0 12px;max-height:112px;overflow:auto} .swarm-legend .lg{display:inline-flex;align-items:center;gap:6px} +.swarm-legend svg{flex-shrink:0} +.group-swatch{display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0;margin-right:8px;vertical-align:middle} .swarm-legend .lg b{font-weight:500;color:var(--muted);font-variant-numeric:tabular-nums} .swarm-legend .tot{margin-left:auto;color:var(--muted);font-variant-numeric:tabular-nums} -.swarm-card{border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg);padding:16px 20px} +#sw-legend{height:32px;max-height:32px;flex-wrap:nowrap;overflow-x:auto;overflow-y:hidden} +#sw-legend>*{flex-shrink:0;white-space:nowrap} +.swarm-legend .mark{stroke-width:1.5} +.swarm-card{overflow:auto;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg);padding:16px 20px} .swarm svg{display:block;overflow:visible} .swarm .row-lbl{font-family:var(--sans);font-size:12px;fill:var(--fg2)} .swarm .row-n{font-family:var(--sans);font-size:12px;fill:var(--muted)} @@ -506,11 +537,16 @@ code{font-family:var(--mono);font-size:12px;background:var(--closed-bg);padding: .swarm .tick{font-family:var(--mono);font-size:11px;fill:var(--muted)} .swarm .axis-t{font-family:var(--sans);font-size:12px;fill:var(--fg2)}.swarm .muted-t{fill:var(--muted)} .swarm .d{cursor:pointer;transition:transform 700ms cubic-bezier(.2,.8,.2,1),opacity 300ms} -.swarm .d circle{stroke-width:1.5} -.swarm .d:hover circle{stroke:var(--fg);stroke-width:2} +.swarm .d .mark{stroke-width:1.5} +.swarm .d:hover .mark{stroke:var(--fg);stroke-width:2} .swarm .d.gone{opacity:0;pointer-events:none} .c-iss{fill:var(--open-fg);stroke:var(--open-fg)}.c-pr{fill:var(--accent);stroke:var(--accent)} -.c-merged{fill:var(--merged-fg);stroke:var(--merged-fg)}.c-closed{fill:var(--closed-fg);stroke:var(--closed-fg)} +.c-merged{fill:var(--merged-fg);stroke:var(--merged-fg)}.c-closed{fill:var(--danger-fg);stroke:var(--danger-fg)} +.c-canceled{fill:var(--danger-fg);stroke:var(--danger-fg)}.c-archived{fill:var(--warn-fg);stroke:var(--warn-fg)} +.c-duplicate{fill:#bf5af2;stroke:#bf5af2}.c-unknown{fill:var(--muted);stroke:var(--muted)} +.c-sup-outline{fill:var(--bg);stroke:#6486ac} +.c-group{fill:var(--group-color);stroke:var(--group-color)} +.k-canceled{background:var(--danger-fg)}.k-archived{background:var(--warn-fg)}.k-duplicate{background:#bf5af2}.k-unknown{background:var(--muted)} .c-sup{fill:var(--bg)!important} .swarm-tip{position:fixed;pointer-events:none;z-index:10;max-width:320px;background:var(--bg);border:1px solid var(--border2);border-radius:var(--radius);padding:8px 10px;font-size:12px;line-height:17px;box-shadow:0 4px 12px rgba(0,0,0,.08)} .swarm-tip .mono{color:var(--muted)} @@ -701,16 +737,23 @@ const LABS_SVG = const APP = ` const LABS_SVG=${JSON.stringify(LABS_SVG)}; const PROJECTS=JSON.parse(document.getElementById('data').textContent).projects; +const projectId=p=>p.id||p.repo; +const projectLabel=p=>p.label||p.repo; const wanted=(()=>{try{return new URLSearchParams(location.search).get('project')}catch{return null}})(); -let DATA=PROJECTS.find(p=>p.repo===wanted)||PROJECTS[0]; +let DATA=PROJECTS.find(p=>projectId(p)===wanted)||PROJECTS[0]; let N=DATA.nodes; let PV=DATA.provider; -document.title='issue-graph · '+DATA.repo; +document.title='issue-graph · '+projectLabel(DATA); const repoUrl=r=>PV.repoUrl.replace('{repo}',r); const app=document.getElementById('app'); const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); +const hasView=name=>(PV.views||['explore','impact','swarm','rank']).includes(name); +const hasMetric=name=>(PV.metrics||['heat','links','blast','depth']).includes(name); +const stateLabel=n=>n.stateLabel||({OPEN:'Open',MERGED:'Merged',CLOSED:'Closed',UNKNOWN:'Unavailable'}[n.state]||n.state); +const active=n=>n.state==='OPEN'&&!n.archived; // keys from the primary repo read as #123; others keep enough of their name to tell apart -const short=k=>{const [r,n]=String(k).split('#');if(!n)return k;if(r===DATA.repo)return '#'+n; +const shortIdentifier=id=>/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(id)?id.slice(0,8)+'…'+id.slice(-4):id; +const short=k=>{if(N[k]?.identifier)return shortIdentifier(N[k].identifier);const [r,n]=String(k).split('#');if(!n)return shortIdentifier(String(k));if(r===DATA.repo)return '#'+n; const [o,name]=r.split('/'),[po]=DATA.repo.split('/');return (o===po?name:r)+'#'+n}; function statsOf(){ // headline counts cover the project's own repo; referenced items are reported as linked @@ -730,27 +773,77 @@ let view='explore'; let impactSel=null; function kcls(n){ - if(n.state==='MERGED')return 'k-merged'; - if(n.state!=='OPEN')return 'k-closed'; - if(/SUPERSEDED/.test(n.verdict||''))return 'k-sup'; - return n.kind==='PullRequest'?'k-pr':'k-iss'; + if(active(n)&&/SUPERSEDED/.test(n.verdict||''))return 'k-sup'; + return 'k-'+statusInfo(n).color; +} +function statusInfo(n){ + if(n.state==='UNKNOWN'||n.state==='FETCH_ERROR'||n.read?.fetched===false)return {label:'Unavailable',color:'unknown'}; + if(n.archived)return {label:'Archived',color:'archived'}; + if(n.stateType==='duplicate')return {label:'Duplicate',color:'duplicate'}; + if(n.stateType==='canceled')return {label:'Canceled',color:'canceled'}; + if(n.stateType==='completed')return {label:'Completed',color:'merged'}; + if(n.state==='MERGED')return {label:'Merged',color:'merged'}; + if(n.state!=='OPEN')return {label:'Closed',color:'closed'}; + if(/SUPERSEDED/.test(n.verdict||''))return {label:'Superseded',color:'sup-outline'}; + return n.kind==='PullRequest'?{label:'Open PR',color:'pr'}:{label:'Open issue',color:'iss'}; +} +function statusGroups(nodes){ + const groups=new Map(); + for(const n of nodes){const s=statusInfo(n),key=s.color; + if(!groups.has(key))groups.set(key,{...s,members:[],kinds:[]}); + const g=groups.get(key);g.members.push(n.key); + if(!g.kinds.includes(n.kind))g.kinds.push(n.kind);} + return [...groups.values()]; +} +function colorAttrs(color){ + return color.startsWith('hsl(')?'class="c-group" style="--group-color:'+color+'"':'class="c-'+color+'"'; +} +function stateColor(n){ + const color=statusInfo(n).color; + return color==='sup-outline'?(n.kind==='PullRequest'?'pr':'iss'):color; +} +function stateGroupLabel(n){ + const label=n.state==='OPEN'&&!n.stateLabel?(n.kind==='PullRequest'?'Open PR':'Open issue'):stateLabel(n); + return label+(n.archived?' · archived':''); +} +function diamondPath(r){ + const d=r*1.3; + return 'M0,-'+d+'L'+d+',0 0,'+d+' -'+d+',0Z'; +} +function itemMark(kind,r){ + return kind==='PullRequest'?'':''; +} +function legendMarkup(groups,shapes=false){ + return groups.map(g=>''+(shapes? + g.kinds.map(kind=>'').join(''): + '')+ + esc(g.label)+' '+g.members.length+'').join(''); +} +function groupColor(index){ + return 'hsl('+((216+index*137.508)%360).toFixed(3)+' 62% 55%)'; +} +function proposedGroups(){ + return DATA.grouping==='themes'||(!DATA.grouping&&DATA.groups.some(g=>!/^Component \\d+$/.test(g.label))); } const statGrid=rows=>'
'+rows.map(t=>'
'+t[1]+'
'+t[0]+'
').join('')+'
'; function setProject(repo){ - const next=PROJECTS.find(p=>p.repo===repo);if(!next||next===DATA)return; + const next=PROJECTS.find(p=>projectId(p)===repo);if(!next||next===DATA)return; DATA=next;N=DATA.nodes;PV=DATA.provider;impactSel=null;REACH=null;loadDone(); try{const u=new URL(location.href);u.searchParams.set('project',repo);history.replaceState(null,'',u)}catch{} - document.title='issue-graph · '+repo; + document.title='issue-graph · '+projectLabel(DATA); app.querySelector('.side').outerHTML=sidebar();wireSidebar(); lastHash=null;route(); } const CHEV=''; function projectMenu(){ - const opt=p=>''; + const count=p=>{const c=p.openCount;if(!c||!Number.isSafeInteger(c.value)||c.value<0)return '—'; + const text=(c.complete?'':'≥')+c.value+' open',detail=c.issues+' open issues'+(c.pullRequests===undefined?'':' + '+c.pullRequests+' open PRs')+' · '+(c.complete?'entire project':'partial count')+' · '+c.observedAt; + return ''+text+''}; + const opt=p=>''; return ''+ + PV.logo+''+esc(projectLabel(DATA))+''+CHEV+''+ ''; } function wireProjectMenu(){ @@ -769,16 +862,13 @@ function wireProjectMenu(){ } function projectRow(){ if(PROJECTS.length>1)return '
'+projectMenu()+ - '↗
'; - return '
'+PV.logo+''+esc(DATA.repo)+''+ - '↗
'; + '↗'; + return '
'+PV.logo+''+esc(projectLabel(DATA))+''+ + '↗
'; } function sidebar(){ const s=statsOf(),all=Object.values(N); - const mix=[['k-iss','Open issues',all.filter(n=>n.state==='OPEN'&&n.kind!=='PullRequest').length], - ['k-pr','Open PRs',all.filter(n=>n.state==='OPEN'&&n.kind==='PullRequest').length], - ['k-merged','Merged',all.filter(n=>n.state==='MERGED').length], - ['k-closed','Closed',all.filter(n=>n.state!=='OPEN'&&n.state!=='MERGED').length]].filter(m=>m[2]); + const mix=statusGroups(all).map(g=>['k-'+g.color,g.label,g.members.length]); const stats=[['Open here',s.openHere,''],['Open PRs',s.openPRs,''],['Open issues',s.openIssues,'']]; const signals=PV.signals.map(g=>[g.label,s[g.id]??0,g.tone]); const chev=''; @@ -787,9 +877,9 @@ function sidebar(){ if(!ms.length)return ''; const items=ms.map((k,i)=>{const n=N[k]; const fl=n.flags.length?'⚠':''; - const t=(n.key+' '+n.title+' '+(n.author||'')).toLowerCase(); + const t=(n.key+' '+(n.identifier||'')+' '+n.title+' '+(n.author||'')).toLowerCase(); return ''; }).join(''); const dots=ms.slice().sort((a,b)=>kcls(N[a]).localeCompare(kcls(N[b]))).map(k=>'').join(''); @@ -807,12 +897,23 @@ function sidebar(){ '
'+ '
'+mix.map(m=>''+m[1]+' '+m[2]+'').join('')+'
'+ + (DATA.coverage?'
'+ + ''+(DATA.coverage.complete?'Snapshot details':'Some data is missing')+ + ''+ + '

Read-only snapshot · '+esc(new Date(DATA.coverage.generatedAt).toLocaleString())+'. Regenerate the capture to refresh.

'+ + (DATA.coverage.warnings?.length?'
    '+DATA.coverage.warnings.map(m=>'
  • '+esc(m)+'
  • ').join('')+'
':'')+ + '
    '+DATA.coverage.messages.map(m=>'
  • '+esc(m)+'
  • ').join('')+'
':'')+ statGrid(stats)+ - ((s.linked||DATA.notCrawled)?'
'+(s.linked?'+'+s.linked+' linked item'+(s.linked===1?'':'s'):'')+ + ((s.linked||DATA.notCrawled)?'
'+(s.linked?'+'+s.linked+' linked item'+(s.linked===1?'':'s'):'')+ (DATA.notCrawled?(s.linked?' · ':'')+''+DATA.notCrawled+' not crawled (node cap)':'')+'
':'')+ (signals.length?'
'+PV.logo+''+esc(PV.name)+' signals
'+statGrid(signals)+'
':'')+ - '
'+ - ''+cleanupBtn+'
'+ + '
'+ + ''+ + (hasView('impact')?'':'')+ + ''+ + (hasView('rank')?'':'')+'
'+ + ''+cleanupBtn+''+ '
'+groups+'
'; } @@ -826,13 +927,13 @@ function egoSvg(n){ const parts=cap.map((x,i)=>{const a=(i/cap.length)*2*Math.PI-Math.PI/2,px=cx+r*Math.cos(a)*1.9,py=cy+r*Math.sin(a); const t=N[x.k]; return ''+ - ''+ - ''+short(x.k)+' '+esc(x.via)+''; + ''+ + ''+esc(x.via+' '+short(x.k))+''; }).join(''); const more=nb.length>cap.length?'+'+(nb.length-cap.length)+' more':''; return ''+parts+ ''+ - ''+short(n.key)+''+more+''; + ''+esc(short(n.key))+''+more+''; } function relList(title,arr,fmt){if(!arr.length)return ''; @@ -841,10 +942,11 @@ function relList(title,arr,fmt){if(!arr.length)return ''; function inspector(k){ const n=N[k];if(!n){app.querySelector('.main').innerHTML='
not found
';return} const badges=[]; - badges.push(''+n.state+''); + badges.push(''+esc(stateLabel(n))+''); badges.push(''+(n.kind==='PullRequest'?'PR':'issue')+''); if(n.author)badges.push('@'+esc(n.author)+''); if(n.seed)badges.push('seed'); + if(n.archived)badges.push('archived'); let pr=''; if(n.pr){const p=n.pr,m=[]; if(p.draft)m.push('draft'); @@ -859,35 +961,42 @@ function inspector(k){ const otherOut=n.out.filter(e=>e.via!=='closes'); const closedByIn=n.in.filter(e=>e.via==='closes').map(e=>({k:e.from,via:'closed by'})); const otherIn=n.in.filter(e=>e.via!=='closes'); - const rel=(x)=>{const t=N[x.k];const lbl=t?esc(t.title):'(beyond depth)'; - return '
'+esc(x.via)+''+ - ''+ - (t?''+short(x.k)+'':short(x.k))+ + const rel=(x)=>{const t=N[x.k];const lbl=t?esc(t.title):'(outside this view)'; + return '
'+esc(x.via)+''+ + ''+ + (t?''+esc(short(x.k))+'':esc(short(x.k)))+ ' '+lbl+'
';}; const ovl=n.overlaps.slice().sort((a,b)=>b.significant-a.significant).map(o=>{ - const dup=o.sharedIssue?' both close '+short(o.sharedIssue)+'':''; + const dup=o.sharedIssue?' both close '+esc(short(o.sharedIssue))+'':''; return '
overlaps'+ - ''+short(o.with)+''+dup+ + ''+esc(short(o.with))+''+dup+ ' '+o.shared.map(f=>''+esc(f)+'').join(' ')+'
';}); - const ext=n.external.map(u=>'
external'+esc(u)+'
'); + const ext=n.attachments + ?n.attachments.map(a=>'') + :n.external.map(u=>'
external'+esc(u)+'
'); const ment=n.mentionedBy.length?'

mentioned by

'+n.mentionedBy.map(u=>'@'+esc(u)+'').join(' ')+'
':''; + const readIncomplete=n.read&&(!n.read.fetched||n.read.coverage.some(c=>!c.complete)); + const read=n.read?'
'+ + (!n.read.fetched?'Issue unavailable':readIncomplete?'Some connections could not be read':'Connection details')+''+ + (n.read.error?'
'+esc(n.read.error)+'
':'')+ + '
    '+n.read.coverage.map(c=>'
  • '+esc(c.source)+': '+(c.complete?'read':esc(c.reason||'incomplete'))+' · '+c.pages+' page(s)
  • ').join('')+'
':''; app.querySelector('.main').innerHTML='
'+ - '

'+short(n.key)+' '+esc(n.title||'(no title)')+'

'+ - ''+ - pr+flags+verdict+ + '

'+esc(short(n.key))+' '+esc(n.title||'(no title)')+'

'+ + '
'+badges.join(' ')+(n.url?' '+PV.logo+'Open on '+esc(PV.name)+' ↗':'')+'
'+ + pr+flags+verdict+read+ '

neighborhood

'+egoSvg(n)+'
'+ relList('closes',closesOut,rel)+relList('closed by',closedByIn,rel)+ relList('overlaps (shared files)',ovl,x=>x)+ - relList('references out',otherOut.map(e=>({k:e.to,via:e.via})),rel)+ - relList('referenced by',otherIn.map(e=>({k:e.from,via:e.via})),rel)+ + relList('Relationships from this item',otherOut.map(e=>({k:e.to,via:e.via})),rel)+ + relList('Relationships to this item',otherIn.map(e=>({k:e.from,via:e.via})),rel)+ ment+relList('external links',ext,x=>x)+'
'; for(const b of app.querySelectorAll('.item'))b.classList.toggle('sel',b.dataset.key===k); } // Cleanup progress is per-viewer scratch; nothing is posted to GitHub. let CL_KEY='',DONE=new Set(); -function loadDone(){CL_KEY='issue-graph:cleanup:'+DATA.repo; +function loadDone(){CL_KEY='issue-graph:cleanup:'+projectId(DATA); DONE=new Set((()=>{try{return JSON.parse(localStorage.getItem(CL_KEY)||'[]')}catch{return []}})());} loadDone(); const saveDone=()=>{try{localStorage.setItem(CL_KEY,JSON.stringify([...DONE]))}catch{}}; @@ -909,7 +1018,7 @@ function cleanupView(){ const rs=rows.filter(r=>r.a[0]===a[0]);if(!rs.length)return ''; return '

'+a[1]+' '+rs.length+'

'+rs.map(r=>{ const n=r.c.key&&N[r.c.key]; - const target=n?''+short(r.c.key)+'':''+esc(r.c.key?r.c.key.split('/').slice(1).join('/'):'')+''; + const target=n?''+esc(short(r.c.key))+'':''+esc(r.c.key?r.c.key.split('/').slice(1).join('/'):'')+''; return '
'+ + '
'+(cards||'
No issues in this capture.
')+'
'; for(const c of app.querySelectorAll('.ex-card'))c.onclick=()=>exploreView(+c.dataset.gi); setHash('explore');sel='explore'; } function layoutCluster(keys,W,H){ const P={},n=keys.length,idx=new Map(keys.map((k,i)=>[k,i])); keys.forEach((k,i)=>{const a=(i/n)*2*Math.PI;P[k]={x:W/2+Math.cos(a)*W*.3,y:H/2+Math.sin(a)*H*.3,vx:0,vy:0}}); - const links=[];keys.forEach(k=>N[k].out.forEach(e=>{if(idx.has(e.to)&&e.to!==k)links.push([k,e.to,e.via])})); + const links=[];keys.forEach(k=>N[k].out.forEach(e=>{if(idx.has(e.to)&&e.to!==k&&(!e.undirected||kN[k].overlaps.forEach(o=>{if(idx.has(o.with)&&k{const a=P[s],b=P[t],len=Math.round(Math.hypot(b.x-a.x,b.y-a.y)); - return ''+esc(via)+'';}).join(''); + const edges=links.map(([s,t,via,directed],i)=>{const a=P[s],b=P[t],len=Math.round(Math.hypot(b.x-a.x,b.y-a.y)); + return ''+esc(short(s)+' '+via+' '+short(t))+'';}).join(''); const nodes=keys.map((k,i)=>{const p=P[k],n=N[k],r=n.heat?5+Math.min(9,Math.sqrt(heatScore(n))):5; return ''+esc(short(k)+' '+n.title)+''+ (pend.has(k)?'':'')+ ''+ - ''+short(k)+'';}).join(''); + ''+esc(short(k))+'';}).join(''); const labels=DATA.cleanup.reduce((m,c,i)=>{if(c.key&&!DONE.has(clId(c,i)))m[c.key]=c.text;return m},{}); const rows=keys.slice().sort((a,b)=>(N[b].heat?heatScore(N[b]):-1)-(N[a].heat?heatScore(N[a]):-1)).map(k=>{const n=N[k]; - return ''+short(k)+''+esc(n.title)+''+ + return ''+esc(short(k))+''+esc(n.title)+''+ (labels[k]?'
'+esc(labels[k])+'
':'')+(n.verdict&&!labels[k]?'
'+esc(n.verdict)+'
':'')+''+ - ''+n.state.toLowerCase()+''+(n.heat?heatScore(n):'')+''+neighbors(k).size+'';}).join(''); + ''+esc(stateLabel(n))+(n.archived?' · archived':'')+''+(hasMetric('heat')?''+(n.heat?heatScore(n):'')+'':'')+''+neighbors(k).size+'';}).join(''); app.querySelector('.main').innerHTML='
'+ - '

'+esc(g.label)+'

'+esc(g.subtitle||'')+'
'+ - '
'+st.open+' open · '+st.prs+' PRs · '+st.heat+' heat'+(st.cleanup?' · '+st.cleanup+' to clean up':'')+'
'+ - '
'+edges+nodes+'
'+ - '
Dot size is heat; purple lines close, grey lines reference or share files; an amber ring needs cleanup. Hover to trace, click to inspect.
'+ - '
'+rows+'
ItemStateHeatLinks
'; + '

'+esc(g.label)+'

'+esc(g.subtitle||'')+'
'+ + '
'+ + '
'+(proposedGroups()?'Proposed theme':'Connected component')+' · '+keys.length+' items · '+st.open+' active · '+links.length+' connections'+(hasMetric('heat')?' · '+st.heat+' heat':'')+(st.cleanup?' · '+st.cleanup+' to clean up':'')+'
'+ + '
'+legendMarkup(statusGroups(keys.map(k=>N[k])))+'
'+ + '
'+edges+nodes+'
'+ + '
'+ + (links.length?'Lines show captured relationships; arrows show explicit direction.':'No explicit connections between these items in this capture.')+ + (keys.some(k=>N[k].heat)?' Dot size reflects heat.':'')+(st.cleanup?' Amber rings mark pending cleanup.':'')+' Hover to trace, click to inspect.
'+ + '
'+(hasMetric('heat')?'':'')+''+rows+'
ItemStateHeatLinks
'; const graph=document.getElementById('ex-graph'); const focus=k=>{graph.classList.toggle('focus',!!k);if(!k)return; const on=new Set([k]);for(const e of graph.querySelectorAll('.e')){const hit=e.dataset.s===k||e.dataset.t===k;e.classList.toggle('on',hit);if(hit){on.add(e.dataset.s);on.add(e.dataset.t)}} @@ -1200,6 +1314,7 @@ function clusterView(gi){ for(const tr of app.querySelectorAll('.tbl tbody tr')){tr.onclick=()=>select(tr.dataset.key); tr.onmouseenter=()=>focus(tr.dataset.key);tr.onmouseleave=()=>focus(null);} document.getElementById('ex-back').onclick=()=>exploreView(); + document.getElementById('ex-swarm').onclick=()=>swarmView('cluster','links'); setHash('explore:'+gi);sel='explore:'+gi; } @@ -1217,22 +1332,18 @@ const SW_META={ say:'the items whose fix unblocks the most other work'}, depth:{axis:'How many reference hops from the seed bugs?',dir:'further away', say:'items pulled in indirectly, several references away from the seeds'}}; -const SW_BY_SAY={cluster:'Each row is a root cause the agent found.',state:'Each row is a state: open, merged, closed.', - kind:'Each row is issues or pull requests.',all:'One row with every node.'}; function swClass(n){ - if(n.state==='MERGED')return 'c-merged'; - if(n.state!=='OPEN')return 'c-closed'; - return n.kind==='PullRequest'?'c-pr':'c-iss'; + return 'c-'+statusInfo(n).color; } function swGroups(){ const keys=Object.keys(N); - if(SW.by==='all')return [{label:'All nodes',members:keys}]; + if(SW.by==='all')return keys.length?[{label:'All items',members:keys}]:[]; if(SW.by==='state'||SW.by==='kind'){ - const f=SW.by==='state'?(n=>n.state):(n=>n.kind==='PullRequest'?'Pull requests':'Issues'); + const f=SW.by==='state'?stateGroupLabel:(n=>n.kind==='PullRequest'?'Pull requests':'Issues'); const m=new Map();keys.forEach(k=>{const g=f(N[k]);(m.get(g)??m.set(g,[]).get(g)).push(k)}); - return [...m].map(([label,members])=>({label:label.charAt(0)+label.slice(1).toLowerCase(),members})).sort((a,b)=>b.members.length-a.members.length); + return [...m].map(([label,members])=>({label,members})).sort((a,b)=>b.members.length-a.members.length||a.label.localeCompare(b.label)).map(g=>({...g,color:SW.by==='state'?stateColor(N[g.members[0]]):N[g.members[0]].kind==='PullRequest'?'pr':'iss'})); } - return DATA.groups.map(g=>({label:g.label,members:g.members.filter(k=>N[k])})).filter(g=>g.members.length); + return DATA.groups.map((g,index)=>({label:g.label,members:g.members.filter(k=>N[k]),color:groupColor(index)})).filter(g=>g.members.length); } function swMetric(k){ const n=N[k]; @@ -1298,7 +1409,7 @@ function swLayoutAt(W,avail,R){ const h=Math.max(30,2*spread+D+20); const cy=y+h/2+6; placed.forEach(p=>{pos[p.k]={x:p.x,y:cy+p.o}}); - rows.push({label:g.label,n:g.members.length,top:y,h,cy}); + rows.push({label:g.label,color:g.color,n:g.members.length,top:y,h,cy}); y+=h; } let ticks=[]; @@ -1314,7 +1425,7 @@ function swLayoutAt(W,avail,R){ // layouts are pure given these inputs, so toggling back to a view reuses its layout const SW_CACHE=new Map(); function swLayout(W,avail){ - const ck=[DATA.repo,SW.by,SW.x,Math.round(W),Math.round(avail),RK.join(',')].join('|'); + const ck=[projectId(DATA),SW.by,SW.x,Math.round(W),Math.round(avail),RK.join(',')].join('|'); if(SW_CACHE.has(ck))return SW_CACHE.get(ck); const L=swLayoutFit(W,avail);SW_CACHE.set(ck,L);return L; } @@ -1327,7 +1438,8 @@ function swarmSvg(W,avail){ const L=swLayout(W,avail); const rows=L.rows.map((r,i)=> (i?'':'')+ - ''+esc(r.label.length>26?r.label.slice(0,25)+'…':r.label)+''+ + (SW.by==='all'?'':'')+ + ''+esc(r.label)+''+esc(r.label.length>23?r.label.slice(0,22)+'…':r.label)+''+ ''+r.n+'').join(''); const ay=L.H-48,meta=SW_META[SW.x]; const axis=''+ @@ -1339,14 +1451,22 @@ function swarmSvg(W,avail){ } function swarmRender(){ const card=document.getElementById('swarm-card');if(!card)return; + const visible=new Set(Object.keys(N).filter(k=>swMetric(k)!=null)); + const legend=statusGroups([...visible].map(k=>N[k])); + const rowCount=swGroups().filter(g=>g.members.some(k=>visible.has(k))).length,rowUnit=SW.by==='cluster'?'cluster':'row'; + document.getElementById('sw-legend').innerHTML=legendMarkup(legend,true)+ + (DATA.cleanup.length?'Needs cleanup (ring) '+clPending().size+'':'')+ + ''+visible.size+(visible.size===Object.keys(N).length?'':' of '+Object.keys(N).length)+' items · '+rowCount+' '+rowUnit+(rowCount===1?'':'s')+''; + const say=document.getElementById('sw-say'); + if(say)say.textContent='Each dot is a captured item. Further right: '+SW_META[SW.x].say+'.'; + if(!Object.keys(N).length){card.innerHTML='
No items in this capture.
';return} const W=Math.max(480,card.clientWidth-40); // card padding (32) plus the axis block (52) sit outside the rows const avail=Math.max(240,window.innerHeight-card.getBoundingClientRect().top-32-52-24); const {L,frame}=swarmSvg(W,avail); let svg=card.querySelector('svg'); if(!svg){ - const dots=Object.keys(N).map(k=>{const n=N[k];const sup=/SUPERSEDED/.test(n.verdict||'')?' c-sup':''; - return ''}).join(''); + const dots=Object.keys(N).map(k=>''+itemMark(N[k].kind,L.R)+'').join(''); card.innerHTML=''+dots+''; svg=card.querySelector('svg'); // first paint: start every dot on the baseline, then let it travel in @@ -1355,10 +1475,14 @@ function swarmRender(){ } svg.setAttribute('width',W);svg.setAttribute('height',L.H);svg.setAttribute('viewBox','0 0 '+W+' '+L.H); svg.querySelector('.sw-frame').innerHTML=frame; - const say=document.getElementById('sw-say'); - if(say)say.textContent='Each dot is an issue or PR. '+SW_BY_SAY[SW.by]+' Further right: '+SW_META[SW.x].say+'.'; const pend=clPending(); - for(const g of svg.querySelectorAll('.d'))g.classList.toggle('cl',pend.has(g.dataset.key)); + for(const g of svg.querySelectorAll('.d')){ + g.classList.toggle('cl',pend.has(g.dataset.key)); + const n=N[g.dataset.key],dot=g.querySelector('.mark'); + dot.setAttribute('class','mark '+swClass(n)+(/SUPERSEDED/.test(n.verdict||'')?' c-sup':'')); + if(n.kind==='PullRequest')dot.setAttribute('d',diamondPath(L.R));else dot.setAttribute('r',L.R); + g.querySelector('.ring').setAttribute('r',L.R+2.5); + } const ordered=[...svg.querySelectorAll('.d')].sort((a,b)=>(L.pos[a.dataset.key]?.x??0)-(L.pos[b.dataset.key]?.x??0)); ordered.forEach((g,i)=>{const p=L.pos[g.dataset.key]; g.style.transitionDelay=Math.min(i*2,240)+'ms'; @@ -1366,21 +1490,14 @@ function swarmRender(){ } function swarmView(by,x){ if(by&&SW_BY.some(b=>b[0]===by))SW.by=by; - if(x&&SW_X.some(b=>b[0]===x))SW.x=x; + if(x&&SW_X.some(b=>b[0]===x)&&hasMetric(x))SW.x=x; + if(!hasMetric(SW.x))SW.x='links'; setView('swarm'); const seg=(id,opts,cur)=>'
'+opts.map(o=>'').join('')+'
'; - const all=Object.values(N); - const cnt=f=>all.filter(f).length; - const lg=(c,l,n,hollow)=>''+l+' '+n+''; app.querySelector('.main').innerHTML='
'+ '

Swarm

'+ - '
'+seg('sw-x',SW_X,SW.x)+seg('sw-by',SW_BY,SW.by)+'
'+ - '
'+lg('c-iss','Open issue',cnt(n=>n.state==='OPEN'&&n.kind!=='PullRequest'))+ - lg('c-pr','Open PR',cnt(n=>n.state==='OPEN'&&n.kind==='PullRequest'))+ - lg('c-merged','Merged',cnt(n=>n.state==='MERGED'))+lg('c-closed','Closed',cnt(n=>n.state!=='OPEN'&&n.state!=='MERGED'))+ - lg('c-pr','Superseded',statsOf().superseded,true)+ - (DATA.cleanup.length?'Needs cleanup (ring) '+clPending().size+'':'')+ - ''+all.length+' nodes, '+DATA.groups.length+' groups
'+ + '
'+seg('sw-x',SW_X.filter(m=>hasMetric(m[0])),SW.x)+seg('sw-by',SW_BY,SW.by)+'
'+ + '
'+ '
'; for(const b of app.querySelectorAll('.item'))b.classList.remove('sel'); const wireSeg=(id,key)=>{for(const b of document.querySelectorAll('#'+id+' button'))b.onclick=()=>{ @@ -1393,7 +1510,7 @@ function swarmView(by,x){ if(!tip){tip=document.createElement('div');tip.id='swarm-tip';tip.className='swarm-tip';tip.hidden=true;document.body.appendChild(tip)} card.onmousemove=e=>{const g=e.target.closest('.d');if(!g){tip.hidden=true;return} const n=N[g.dataset.key];tip.hidden=false; - tip.innerHTML=''+short(n.key)+' '+esc(n.title||'(no title)')+'
'+n.state.toLowerCase()+' · '+swMetric(n.key)+' '+SW_X.find(o=>o[0]===SW.x)[1].toLowerCase()+''+ + tip.innerHTML=''+esc(short(n.key))+' '+esc(n.title||'(no title)')+'
'+(n.kind==='PullRequest'?'Pull request':'Issue')+' · '+esc(stateLabel(n))+' · '+swMetric(n.key)+' '+SW_X.find(o=>o[0]===SW.x)[1].toLowerCase()+''+ DATA.cleanup.filter((c,i)=>c.key===n.key&&!DONE.has(clId(c,i))).map(c=>'
'+esc(c.text)+'').join(''); tip.style.left=(e.clientX+12)+'px';tip.style.top=(e.clientY+12)+'px'}; card.onmouseleave=()=>{tip.hidden=true}; @@ -1430,7 +1547,7 @@ function rankRender(){ const bar=r.parts.map((p,j)=>p>0?'':'').join(''); return ''+ ''+(i+1)+''+ - ''+short(n.key)+''+ + ''+esc(short(n.key))+''+ ''+esc(n.title||'(no title)')+''+ ''+ ''+esc(labels[n.key]||'Ungrouped')+''+ @@ -1446,6 +1563,7 @@ function rankRender(){ if(f)f.innerHTML=RK_SIGNALS.map((sg,i)=>''+sg[1].toLowerCase()+' × '+RK[i]+'').join(' + '); } function rankView(w){ + if(!hasView('rank'))return exploreView(); if(w){const v=w.split(',').map(Number);if(v.length===5&&v.every(x=>Number.isFinite(x)&&x>=0&&x<=10))RK=v;} setView('rank'); const sliders=RK_SIGNALS.map((sg,i)=>'