diff --git a/README.md b/README.md index 35d80ea..f061a9b 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ near-misses to reuse instead: Live sample outputs, committed from that same solution: **[HTML report](https://htmlpreview.github.io/?https://github.com/Karzone/TestAtlas/blob/main/docs/sample-report.html)** (features, scenarios, bindings, class kinds, endpoints) · -**[dependency map](https://htmlpreview.github.io/?https://github.com/Karzone/TestAtlas/blob/main/docs/sample-map.html)** (the eight projects and their edges). +**[dependency map](https://htmlpreview.github.io/?https://github.com/Karzone/TestAtlas/blob/main/docs/sample-map.html)** (the eight projects in layers — click one to see what it uses, what uses it, and which tests a change can reach). (GitHub serves `.html` as source, so the links route through htmlpreview.github.io — or download from [`docs/`](docs/) and open locally.) ## What you get @@ -108,7 +108,7 @@ them — then turns that map into answers: | 💥 | **Impact** | Blast radius — the scenarios affected by changing a class, method, step, or endpoint | | 🔍 | **Search** | FTS5 over step definitions + scenarios — *"does a step for this already exist?"* | | 🔌 | **MCP** | All of it served to an AI agent over stdio — precise answers in a few hundred tokens, no context stuffing | -| 📊 | **Report & map** | Self-contained HTML drill-down of the whole map + project-dependency graph | +| 📊 | **Report & map** | Self-contained HTML drill-down of the whole map + a layered, searchable project-dependency graph | | 📈 | **Stats** | Entity counts, class-kind breakdown, binding coverage, diagnostics | All of it **offline, deterministic, and reproducible** — same input, same map, every time. diff --git a/docs/sample-map.html b/docs/sample-map.html index 804b825..91c63e6 100644 --- a/docs/sample-map.html +++ b/docs/sample-map.html @@ -1,199 +1,700 @@ -TestAtlas map — SampleShop.sln
TestAtlas project map

SampleShop.sln

8 projects · 11 dependencies · 4 build-only references (dashed) · an arrow A→B means A depends on B (binds to / uses / inherits something in B; dashed = .csproj reference only)

BDD testsshared libraryunit testsotherclick a project to pin its dependencies · drag to pan · scroll to zoom
SampleShop.Api.Cart → SampleShop.Core (2 inherits)SampleShop.Api.Catalog → SampleShop.Core (2 inherits)SampleShop.Api.Identity → SampleShop.Core (2 inherits)SampleShop.Tests.Api → SampleShop.Api.Cart (3 uses_type, 1 holds)SampleShop.Tests.Api → SampleShop.Api.Catalog (4 uses_type, 2 holds)SampleShop.Tests.Api → SampleShop.Api.Identity (2 holds, 2 uses_type)SampleShop.Tests.Api → SampleShop.Core (build reference (ProjectReference))SampleShop.Tests.E2E → SampleShop.Api.Cart (1 holds, 1 uses_type)SampleShop.Tests.E2E → SampleShop.Api.Catalog (build reference (ProjectReference))SampleShop.Tests.E2E → SampleShop.Api.Identity (1 holds, 1 uses_type)SampleShop.Tests.E2E → SampleShop.Core (build reference (ProjectReference))SampleShop.Tests.E2E → SampleShop.Ui.Pages (2 holds, 2 uses_type)SampleShop.Tests.Ui → SampleShop.Core (build reference (ProjectReference))SampleShop.Tests.Ui → SampleShop.Ui.Pages (2 holds, 2 uses_type)SampleShop.Ui.Pages → SampleShop.Core (3 inherits)SampleShop.Api.CartSampleShop.Api.Cart · shared_library (depended on by 2, depends on 1)SampleShop.Api.CatalogSampleShop.Api.Catalog · shared_library (depended on by 2, depends on 1)SampleShop.Api.IdentitySampleShop.Api.Identity · shared_library (depended on by 2, depends on 1)SampleShop.CoreSampleShop.Core · shared_library (depended on by 7, depends on 0)SampleShop.Tests.ApiSampleShop.Tests.Api · bdd_tests (depended on by 0, depends on 4)SampleShop.Tests.E2ESampleShop.Tests.E2E · bdd_tests (depended on by 0, depends on 5)SampleShop.Tests.UiSampleShop.Tests.Ui · bdd_tests (depended on by 0, depends on 2)SampleShop.Ui.PagesSampleShop.Ui.Pages · shared_library (depended on by 2, depends on 1)
TestAtlasProject map

SampleShop.sln

8projects
11code dependencies
1build-only reference
Entry points3
Shared code4
Foundation1
Api.Cart
Library
Api.Catalog
Library · 2 endpoints
Api.Identity
Library · 1 endpoint
Core
Library
Tests.Api
BDD tests
Tests.E2E
BDD tests
Tests.Ui
BDD tests
Ui.Pages
Library
Arrows point to what a project depends on
Code dependencyBuild-only referenceWhat it usesWhat uses it
/ search · F fit · Arrow keys move · T scope · Esc clear
\ No newline at end of file + function end(){ if(down&&down.moved){grabbing=false; view.classList.remove('grabbing');} down=null; } + view.addEventListener('pointerup',end); view.addEventListener('pointercancel',end); + // A background click (not a card or edge, not the tail of a drag) clears the pin or selection. + view.addEventListener('click',function(){ if(consumeDrag()) return; if(panelOpen()) unpin(); }); + view.addEventListener('wheel',function(e){ + e.preventDefault(); + var u=e.deltaMode===1?16:e.deltaMode===2?400:1; + if(!e.ctrlKey&&Math.abs(e.deltaX)>Math.abs(e.deltaY)){ + userMoved=true; cancelAnimationFrame(raf); setView({tx:S.tx-e.deltaX*u,ty:S.ty,k:S.k}); return; + } + zoomAt(e.clientX,e.clientY,Math.exp(-e.deltaY*u*(e.ctrlKey?0.01:0.0015))); + },{passive:false}); + + document.getElementById('zout').addEventListener('click',function(){zoomBy(1/1.2);}); + document.getElementById('zin').addEventListener('click',function(){zoomBy(1.2);}); + zpct.addEventListener('click',function(){zoomBy(1/S.k);}); + document.getElementById('zfit').addEventListener('click',function(){userMoved=false; fitView(true);}); + + // ---- search ---- + function computeResults(){ + var q=S.q.trim().toLowerCase(), res=ids.map(function(id){return N[id];}); + if(q){ + var sw=function(n){return n.short.toLowerCase().indexOf(q)===0?1:0;}; + res=res.filter(function(n){return n.name.toLowerCase().indexOf(q)>=0||kl(n.kind).toLowerCase().indexOf(q)>=0;}) + .sort(function(a,b){return sw(b)-sw(a)||a.short.localeCompare(b.short);}); + } else res.sort(function(a,b){return a.col-b.col||a.row-b.row;}); + return res.slice(0,8); + } + function renderSearch(){ + qBox.classList.toggle('has',S.searchFocused||!!S.q); + qIn.setAttribute('aria-expanded',S.searchFocused?'true':'false'); + if(!S.searchFocused){qRes.hidden=true; qIn.removeAttribute('aria-activedescendant'); return;} + results=computeResults(); + var idx=Math.max(0,Math.min(S.searchIdx,results.length-1)); + qRes.innerHTML=results.length?results.map(function(n,i){ + return '
'+esc(n.short)+''+esc(kl(n.kind))+'
'; + }).join(''):'
No project matches “'+esc(S.q.trim())+'”
'; + if(results.length) qIn.setAttribute('aria-activedescendant','qo'+idx); else qIn.removeAttribute('aria-activedescendant'); + qRes.hidden=false; + } + function pickResult(id){ + S.q=''; S.searchIdx=0; qIn.value=''; qIn.blur(); + pin(id,{center:true}); + } + qIn.addEventListener('input',function(){S.q=qIn.value; S.searchIdx=0; renderSearch();}); + qIn.addEventListener('focus',function(){S.searchFocused=true; renderSearch();}); + qIn.addEventListener('blur',function(){S.searchFocused=false; renderSearch();}); + qIn.addEventListener('keydown',function(e){ + if(e.key==='ArrowDown'||e.key==='ArrowUp'){ + e.preventDefault(); + S.searchIdx=e.key==='ArrowDown'?Math.min(results.length-1,S.searchIdx+1):Math.max(0,S.searchIdx-1); + renderSearch(); + var a=qRes.querySelector('.act'); if(a) a.scrollIntoView({block:'nearest'}); + } else if(e.key==='Enter'){ + e.preventDefault(); + var n=results[Math.max(0,Math.min(S.searchIdx,results.length-1))]; if(n) pickResult(n.id); + } else if(e.key==='Escape'){ + e.preventDefault(); S.q=''; qIn.value=''; qIn.blur(); + } + }); + qRes.addEventListener('mousedown',function(e){ + e.preventDefault(); + var o=e.target.closest('.opt'); if(o) pickResult(o.getAttribute('data-id')); + }); + qRes.addEventListener('mouseover',function(e){ + var o=e.target.closest('.opt'); if(!o) return; + var i=+o.getAttribute('data-ix'); if(i!==S.searchIdx){S.searchIdx=i; renderSearch();} + }); + + // ---- keyboard ---- + addEventListener('keydown',function(e){ + var t=e.target, typing=t&&(t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.isContentEditable); + if(typing||e.metaKey||e.ctrlKey||e.altKey) return; + var k=e.key; + if(k==='/'){e.preventDefault(); qIn.focus(); return;} + if(k==='Escape'){ if(panelOpen()) unpin(); return; } + if(k==='f'||k==='F'){userMoved=false; fitView(true); return;} + if(k==='+'||k==='='){zoomBy(1.2); return;} + if(k==='-'||k==='_'){zoomBy(1/1.2); return;} + if(k==='0'){zoomBy(1/S.k); return;} + if((k==='t'||k==='T')&&S.pinned){S.scope=S.scope==='transitive'?'direct':'transitive'; update(); return;} + if(k.indexOf('Arrow')===0){ + if(t&&t.closest&&t.closest('#panel')) return; + e.preventDefault(); move(k); return; + } + if((k==='Enter'||k===' ')&&t&&t.getAttribute&&t.getAttribute('role')==='button'){e.preventDefault(); t.click();} + }); + + // ---- layout lifecycle ---- + function measureBar(){ + var h=Math.round(bar.getBoundingClientRect().height); + if(h&&h!==S.barH){S.barH=h; panel.style.top=(h+12)+'px'; if(!userMoved) fitView(false);} + } + if(window.ResizeObserver) new ResizeObserver(measureBar).observe(bar); + addEventListener('resize',function(){measureBar(); if(!userMoved) fitView(false);}); + measureBar(); update(); fitView(false); + requestAnimationFrame(function(){ if(!userMoved) fitView(false); }); +})(); \ No newline at end of file diff --git a/specs/codemap-indexer.md b/specs/codemap-indexer.md index cdd6909..5b64e13 100644 --- a/specs/codemap-indexer.md +++ b/specs/codemap-indexer.md @@ -283,7 +283,7 @@ codemap stats [] Human-readable summary: entity counts per project, codemap report [] Writes a single self-contained HTML drill-down of the map --html Output path (default: .html) -codemap map [] Writes a self-contained project dependency graph (SVG) +codemap map [] Writes a self-contained project dependency graph (HTML) --html Output path (default: -map.html) codemap impact [] Blast radius: scenarios affected by changing an entity @@ -316,20 +316,25 @@ that call them (directly a step definition, or a client/wrapper method whose cla The `map` command is a companion visualization: a **project dependency graph**. A directed edge A→B ("A depends on B") is derived by aggregating the map's cross-project `binds_to` / `uses_type` / -`inherits` edges to project level (an edge whose endpoints resolve to two different projects). A +`inherits` / `holds` edges to project level (an edge whose endpoints resolve to two different projects). A project pair connected **only** by a `.csproj` `references` edge — no semantic coupling — is drawn as a -distinct **dashed** "build reference", so an infra library consumed purely via DI / `ProjectReference` +distinct **dashed** "build-only reference", so an infra library consumed purely via DI / `ProjectReference` connects instead of reading as an isolated dead node (the header tallies the two kinds separately). -Nodes are laid out on a deterministic circle, sized by in-degree so shared step-definition / page-object -libraries stand out, coloured by project kind, with hover-to-isolate + pan/zoom. **Clicking a project -pins it** and opens a dependency panel listing what it *depends on* and what *depends on it*, each with -the underlying link breakdown (e.g. `→ SharedSteps · 340 binds_to`); clicking a panel entry walks the -chain to that project, and each entry **expands to the class level** — the specific step / page-object -classes behind that dependency, with counts (e.g. `CommonSteps · 320 binds_to`). The pinned panel also -lists the project's **API endpoints** — the routes/operations its methods call (`calls_endpoint`), -with a colour-coded verb badge and an `op` tag on operation-level ones. A manual light/dark toggle -(persisted) and a collapsible header (full-viewport graph) round it out. Self-contained (inline SVG + -vanilla JS, no libraries). Only projects and their cross-project edges (+ endpoints) are read, so it is instant. +Projects are drawn as cards in deterministic **left-to-right layers** — entry points, shared code, +foundation — by longest outgoing path (cycles guarded), ordered within each layer by three barycentre +sweeps to cut crossings; labels drop the solution's common dot-prefix. Hovering a card highlights its +direct neighbours in two hues: **blue** for what it uses, **violet** for what uses it. **Clicking a +project pins it** and opens a details panel with *Depends on* / *Used by* lists, direct or transitive +(transitive reach prefers code paths over build-only ones and labels each row by depth, e.g. +`2 steps away · via Api.Cart`), in plain language (e.g. `Uses types 4× · holds as members 2×`); each +row **expands to the class level** — the specific step / page-object classes behind that dependency. +For a non-test project an impact note names the test projects a change can reach. The panel also lists +the project's **API endpoints** — the routes/operations its methods call (`calls_endpoint`), with a +colour-coded verb badge and an `operation` tag on operation-level ones. **Clicking an edge** opens the +dependency itself with the classes behind it. Search-and-jump (`/`), zoom buttons, fit to view, +keyboard navigation (arrow keys, `T` scope, `Esc`) and a persisted light/dark toggle round it out. +Self-contained (inline CSS + SVG + vanilla JS, no libraries). Only projects and their cross-project +edges (+ endpoints) are read, so it is instant. The `report` command is the **v1.x HTML visualization** on the roadmap (§13). It reads a map file and emits one self-contained HTML document — inline CSS/JS only, no external stylesheet, diff --git a/src/CodeMap.Core/Reporting/ProjectMapBuilder.cs b/src/CodeMap.Core/Reporting/ProjectMapBuilder.cs index ee550fc..731157f 100644 --- a/src/CodeMap.Core/Reporting/ProjectMapBuilder.cs +++ b/src/CodeMap.Core/Reporting/ProjectMapBuilder.cs @@ -7,24 +7,34 @@ namespace TestAtlas.Core.Reporting; /// -/// Renders a project-level dependency graph as a single self-contained HTML page (inline SVG + a -/// little vanilla JS for pan / zoom / hover — no external libraries, opens offline). A directed edge -/// A→B means "project A depends on project B": some entity in A binds to / uses / inherits an entity -/// in B (derived from the map's cross-project binds_to / uses_type / inherits -/// edges). Node size grows with in-degree, so shared step-definition / page-object libraries — the -/// projects many others lean on — stand out. Deterministic: node positions are a fixed circular -/// layout computed here, and the only volatile value (the timestamp) is read from the map. +/// Renders a project-level dependency graph as a single self-contained HTML page (inline CSS, inline +/// SVG edges, HTML project cards and a little vanilla JS for pan / zoom / search / pinning — no external +/// libraries, opens offline). A directed edge A→B means "project A depends on project B": some entity +/// in A binds to / uses / inherits / holds an entity in B (derived from the map's cross-project +/// binds_to / uses_type / inherits / holds edges); a bare ProjectReference +/// with none of those is drawn dashed as a build-only reference. Projects sit in left-to-right layers — +/// entry points, shared code, foundation — ordered within each layer to cut edge crossings. +/// Deterministic: the layered layout is computed here, and the only volatile value (the timestamp) is +/// read from the map. /// public static class ProjectMapBuilder { - private sealed record Node(int Id, string Name, string Kind, double X, double Y, double R, int InDegree, int OutDegree); + private sealed record Node(int Id, string Name, string Short, string Kind, int Col, int Row, double X, double Y, + int InDegree, int OutDegree, int Endpoints); private sealed record ClassBit(string Name, string Kind, int Weight); - private sealed record Link(int From, int To, int Weight, string Detail, IReadOnlyList Classes, bool ReferencesOnly); + private sealed record Link(int From, int To, int Weight, string Detail, IReadOnlyList<(string Kind, int Count)> Kinds, + IReadOnlyList Classes, bool ReferencesOnly); private const int MaxClassRows = 20; + private const int MaxEndpointRows = 60; + + // Layout constants (comfortable density): card size, column / row gaps, header band above the cards. + private const double CardW = 232, CardH = 62, GapX = 124, GapY = 18, Top = 40; + private const double ArrowLen = 7; + private const int SweepIterations = 3; - // SVG canvas constants. - private const double Cx = 500, Cy = 512; + // The semantic kinds a link phrase is built from, in display order. + private static readonly string[] PhraseOrder = { EdgeKinds.UsesType, EdgeKinds.BindsTo, EdgeKinds.Inherits, EdgeKinds.Holds }; public static string Build(MapDocument doc) { @@ -33,6 +43,7 @@ public static string Build(MapDocument doc) var stepDefProj = doc.StepDefinitions.ToDictionary(s => (long)s.Id, s => s.ProjectId); var methodProj = doc.Methods.ToDictionary(m => (long)m.Id, m => m.ProjectId); var classProj = doc.Classes.ToDictionary(c => (long)c.Id, c => c.ProjectId); + var projectIds = doc.Projects.Select(p => p.Id).ToHashSet(); int? ProjectOf(string kind, int? id) => id is not int i ? null : kind switch { @@ -48,7 +59,6 @@ public static string Build(MapDocument doc) // drill from a project dependency down to the specific step / page-object classes behind it. var stepDefClass = doc.StepDefinitions.ToDictionary(s => (long)s.Id, s => s.ClassId); var classNameById = doc.Classes.ToDictionary(c => c.Id, c => c.Name); - var classKindById = doc.Classes.ToDictionary(c => c.Id, c => c.Kind); int? TargetClassOf(EdgeRow e) => e.ToId is not int t ? null : e.EdgeKind switch { @@ -66,6 +76,7 @@ public static string Build(MapDocument doc) var from = ProjectOf(e.FromKind, e.FromId); var to = ProjectOf(e.ToKind, e.ToId); if (from is not int a || to is not int b || a == b) continue; + if (!projectIds.Contains(a) || !projectIds.Contains(b)) continue; if (!agg.TryGetValue((a, b), out var byKind)) agg[(a, b)] = byKind = new(); byKind[e.EdgeKind] = byKind.TryGetValue(e.EdgeKind, out var c) ? c + 1 : 1; @@ -79,41 +90,20 @@ public static string Build(MapDocument doc) // ---- per-project API surface: the endpoints each project's methods call (slice 4) ---- var endpointById = doc.Endpoints.ToDictionary(e => e.Id); - var projectEndpoints = new Dictionary>(); + var projectEndpoints = new Dictionary>(); + var endpointSets = new Dictionary>(); foreach (var e in doc.Edges) { if (e.EdgeKind != EdgeKinds.CallsEndpoint || e.ToId is not int epId) continue; if (!methodProj.TryGetValue(e.FromId, out var pid)) continue; if (!endpointById.TryGetValue(epId, out var ep)) continue; - if (!projectEndpoints.TryGetValue(pid, out var set)) projectEndpoints[pid] = set = new(); + if (!endpointSets.TryGetValue(pid, out var set)) endpointSets[pid] = set = new(); set.Add((ep.Verb, ep.Route)); } - - var inDeg = new Dictionary(); - var outDeg = new Dictionary(); - foreach (var ((a, b), _) in agg) - { - outDeg[a] = outDeg.TryGetValue(a, out var o) ? o + 1 : 1; - inDeg[b] = inDeg.TryGetValue(b, out var i) ? i + 1 : 1; - } - - // ---- circular layout (deterministic: projects ordered by name) ---- - var ordered = doc.Projects.OrderBy(p => p.Name, StringComparer.Ordinal).ToList(); - var n = ordered.Count; - var radius = n <= 1 ? 0 : Math.Min(400, 150 + n * 9); - var maxIn = inDeg.Count == 0 ? 0 : inDeg.Values.Max(); - - var nodes = new Dictionary(); - for (var i = 0; i < n; i++) - { - var p = ordered[i]; - var angle = -Math.PI / 2 + 2 * Math.PI * i / Math.Max(1, n); - var x = Cx + radius * Math.Cos(angle); - var y = Cy + radius * Math.Sin(angle); - var indeg = inDeg.TryGetValue(p.Id, out var d) ? d : 0; - var r = 7 + (maxIn == 0 ? 0 : 13.0 * indeg / maxIn); - nodes[p.Id] = new Node(p.Id, p.Name, p.Kind, x, y, r, indeg, outDeg.TryGetValue(p.Id, out var od) ? od : 0); - } + foreach (var (pid, set) in endpointSets) + projectEndpoints[pid] = set + .OrderBy(e => e.Route, StringComparer.Ordinal).ThenBy(e => e.Verb, StringComparer.Ordinal) + .Take(MaxEndpointRows).ToList(); var links = agg .Select(kv => @@ -126,122 +116,309 @@ public static string Build(MapDocument doc) classNameById.TryGetValue(x.Key.Class, out var nm) ? nm : "?", x.Key.Kind, x.Value)) .ToList() : new List(); - // Weight is the SEMANTIC coupling (binds_to/uses_type/inherits); a bare .csproj reference - // isn't semantic weight. A link carrying only a `references` kind is a build/DI dependency - // the semantic edges missed — drawn faint so an infra lib connects instead of reading dead. - var semantic = kv.Value.Where(x => x.Key != EdgeKinds.References).Sum(x => x.Value); + // Weight is the SEMANTIC coupling (binds_to/uses_type/inherits/holds); a bare .csproj + // reference isn't semantic weight. A link carrying only a `references` kind is a build/DI + // dependency the semantic edges missed — drawn dashed so an infra lib connects instead of + // reading dead. + var semanticKinds = kv.Value.Where(x => x.Key != EdgeKinds.References).ToList(); + var semantic = semanticKinds.Sum(x => x.Value); var referencesOnly = semantic == 0; var detail = referencesOnly ? "build reference (ProjectReference)" - : string.Join(", ", kv.Value.Where(x => x.Key != EdgeKinds.References) - .OrderByDescending(x => x.Value).Select(x => $"{x.Value} {x.Key}")); - return new Link(kv.Key.From, kv.Key.To, semantic, detail, classes, referencesOnly); + : string.Join(", ", semanticKinds.OrderByDescending(x => x.Value).Select(x => $"{x.Value} {x.Key}")); + var kinds = semanticKinds + .OrderBy(x => Array.IndexOf(PhraseOrder, x.Key) is var i and >= 0 ? i : PhraseOrder.Length) + .ThenBy(x => x.Key, StringComparer.Ordinal) + .Select(x => (x.Key, x.Value)) + .ToList(); + return new Link(kv.Key.From, kv.Key.To, semantic, detail, kinds, classes, referencesOnly); }) .OrderBy(l => l.From).ThenBy(l => l.To) .ToList(); var maxWeight = links.Count == 0 || links.All(l => l.Weight == 0) ? 1 : links.Max(l => l.Weight); + // ---- layered layout (deterministic: projects ordered by name, then by barycentre sweeps) ---- + var ordered = doc.Projects.OrderBy(p => p.Name, StringComparer.Ordinal).ToList(); + var n = ordered.Count; + var prefix = CommonPrefix(ordered.Select(p => p.Name).ToList()); + var shortName = ordered.ToDictionary(p => p.Id, p => p.Name[prefix.Length..]); + var cols = Layer(ordered.Select(p => p.Id).ToList(), shortName, links); + + var colHeights = cols.Select(c => c.Count * CardH + Math.Max(0, c.Count - 1) * GapY).ToList(); + var maxColH = colHeights.Count == 0 ? 0 : colHeights.Max(); + var canvasW = cols.Count * CardW + Math.Max(0, cols.Count - 1) * GapX; + var canvasH = Top + maxColH; + + var outDeg = links.GroupBy(l => l.From).ToDictionary(g => g.Key, g => g.Count()); + var inDeg = links.GroupBy(l => l.To).ToDictionary(g => g.Key, g => g.Count()); + var byId = ordered.ToDictionary(p => p.Id); + var nodes = new Dictionary(); + for (var ci = 0; ci < cols.Count; ci++) + { + for (var ri = 0; ri < cols[ci].Count; ri++) + { + var p = byId[cols[ci][ri]]; + nodes[p.Id] = new Node(p.Id, p.Name, shortName[p.Id], p.Kind, ci, ri, + ci * (CardW + GapX), Top + (maxColH - colHeights[ci]) / 2 + ri * (CardH + GapY), + inDeg.TryGetValue(p.Id, out var ind) ? ind : 0, outDeg.TryGetValue(p.Id, out var od) ? od : 0, + projectEndpoints.TryGetValue(p.Id, out var eps) ? eps.Count : 0); + } + } + // ---- render ---- var solutionName = Path.GetFileName(Meta(doc, MapSchema.MetaSolutionPath, "(solution)")); - var sb = new StringBuilder(32 * 1024); + var sb = new StringBuilder(48 * 1024); sb.Append(""); sb.Append(""); sb.Append("TestAtlas map — ").Append(E(solutionName)).Append(""); sb.Append(""); - // Floating, collapsible panel so the graph can use the whole viewport. - sb.Append("
"); - sb.Append("
TestAtlas project map
"); - sb.Append("
"); - sb.Append(""); - sb.Append(""); - sb.Append("
"); - sb.Append("
"); - sb.Append("

").Append(E(solutionName)).Append("

"); - var semanticLinks = links.Count(l => !l.ReferencesOnly); - var refOnlyLinks = links.Count - semanticLinks; - sb.Append("

").Append(n).Append(" projects · ").Append(semanticLinks) - .Append(" dependencies"); - if (refOnlyLinks > 0) - sb.Append(" · ").Append(refOnlyLinks).Append(" build-only reference").Append(refOnlyLinks == 1 ? "" : "s").Append(" (dashed)"); - sb.Append(" · an arrow A→B means A depends on B (binds to / uses / inherits something in B; dashed = .csproj reference only)

"); - sb.Append("
"); - LegendSwatch(sb, "bdd_tests", "BDD tests"); - LegendSwatch(sb, "shared_library", "shared library"); - LegendSwatch(sb, "unit_tests", "unit tests"); - LegendSwatch(sb, "other", "other"); - sb.Append("click a project to pin its dependencies · drag to pan · scroll to zoom"); - sb.Append("
"); + AppendHeader(sb, solutionName, n, links, withSearch: n > 0); if (n == 0) { - sb.Append("

No projects in this map.

"); + sb.Append("

No projects in this map.

"); + sb.Append(""); return sb.ToString(); } - sb.Append(""); - sb.Append(""); - sb.Append("") - .Append(""); - sb.Append(""); + // The canvas: one stage that pans and zooms, holding the edge SVG (under), column headers and cards. + sb.Append("
"); - // edges first (under nodes) - sb.Append(""); - foreach (var l in links) + sb.Append(""); + for (var i = 0; i < links.Count; i++) { - var a = nodes[l.From]; - var b = nodes[l.To]; - var (path, _) = CurvePath(a, b); - var w = l.ReferencesOnly ? 1.0 : 1.0 + 3.5 * l.Weight / maxWeight; + var l = links[i]; + var (path, arrow) = EdgePath(nodes[l.From], nodes[l.To]); + var w = l.ReferencesOnly ? 1.25 : 1.25 + 2.5 * l.Weight / maxWeight; + sb.Append(""); sb.Append(""); - sb.Append("").Append(E(a.Name)).Append(" → ").Append(E(b.Name)).Append(" (").Append(E(l.Detail)).Append(")"); - sb.Append(""); + .Append(path).Append("\" stroke-width=\"").Append(F(w)).Append("\"/>"); + sb.Append(""); + } + sb.Append(""); + for (var i = 0; i < links.Count; i++) + { + var (path, _) = EdgePath(nodes[links[i].From], nodes[links[i].To]); + sb.Append(""); + } + sb.Append(""); + + for (var ci = 0; ci < cols.Count; ci++) + { + sb.Append("
").Append(ColumnLabel(ci, cols.Count)).Append("").Append(cols[ci].Count).Append("
"); } - sb.Append("
"); - // nodes - sb.Append(""); foreach (var p in ordered) { - var node = nodes[p.Id]; - var cos = radius == 0 ? 0 : (node.X - Cx) / radius; - var sin = radius == 0 ? 0 : (node.Y - Cy) / radius; - var lx = node.X + (node.R + 8) * cos; - var ly = node.Y + (node.R + 8) * sin + 4; - var anchor = cos > 0.2 ? "start" : cos < -0.2 ? "end" : "middle"; - - sb.Append(""); - sb.Append(""); - sb.Append("") - .Append(E(p.Name)).Append(""); - sb.Append("").Append(E(p.Name)).Append(" · ").Append(E(p.Kind)) - .Append(" (depended on by ").Append(node.InDegree).Append(", depends on ").Append(node.OutDegree).Append(")"); - sb.Append(""); + var nd = nodes[p.Id]; + var kindLabel = KindLabel(p.Kind); + // (kind labels are fixed ASCII; the separator stays literal rather than an · entity) + var sub = nd.Endpoints == 0 ? kindLabel + : $"{kindLabel} · {nd.Endpoints} endpoint{(nd.Endpoints == 1 ? "" : "s")}"; + sb.Append("
"); + sb.Append("
").Append(Icon(p.Kind, 17)).Append("
"); + sb.Append("
").Append(E(nd.Short)).Append("
") + .Append(sub).Append("
"); + sb.Append(""); + if (nd.OutDegree > 0) sb.Append(""); + sb.Append("
"); } - sb.Append("
"); + sb.Append("
"); - // Click-to-pin dependency panel (populated by JS from the data blob below). - sb.Append(""); + AppendDock(sb); - // Data blob: node labels/kinds + directed weighted links, for the panel. + // Details panel (project or dependency) and the edge tooltip — populated by JS from the blob. + sb.Append(""); + sb.Append(""); + + // Data blob: node labels / kinds / layout + directed weighted links, for the panel and highlights. sb.Append(""); - sb.Append(""); + sb.Append(""); return sb.ToString(); } - private const int MaxEndpointRows = 60; + /// + /// Longest run of leading dot-segments shared by every name, where each name keeps at least one + /// segment after it — "SampleShop.Core" + "SampleShop.Tests.Api" → "SampleShop.". "" when none. + /// + private static string CommonPrefix(IReadOnlyList names) + { + if (names.Count == 0) return ""; + var segs = names.Select(s => s.Split('.')).ToList(); + var pre = 0; + while (segs.All(s => s.Length > pre + 1 && s[pre] == segs[0][pre])) pre++; + return pre == 0 ? "" : string.Join('.', segs[0].Take(pre)) + "."; + } + + /// + /// Assigns each project to a column by its longest outgoing path (all links, build-only included): + /// entry points land in column 0, foundations in the last. Cycles are guarded — a back edge counts as + /// 0. Within a column, projects start in name order, then three down/up barycentre sweeps reorder + /// them to cut crossings (stable: ties keep their previous position). + /// + private static List> Layer(List idsByName, IReadOnlyDictionary shortName, List links) + { + var outs = idsByName.ToDictionary(id => id, _ => new List()); + var ins = idsByName.ToDictionary(id => id, _ => new List()); + foreach (var l in links) + { + outs[l.From].Add(l.To); + ins[l.To].Add(l.From); + } + + var depth = new Dictionary(); + var visiting = new HashSet(); + int Depth(int id) + { + if (depth.TryGetValue(id, out var known)) return known; + if (!visiting.Add(id)) return 0; // back edge in a cycle + var d = 0; + foreach (var to in outs[id]) d = Math.Max(d, 1 + Depth(to)); + visiting.Remove(id); + depth[id] = d; + return d; + } + foreach (var id in idsByName) Depth(id); + + var maxDepth = idsByName.Count == 0 ? 0 : idsByName.Max(id => depth[id]); + var cols = Enumerable.Range(0, idsByName.Count == 0 ? 0 : maxDepth + 1).Select(_ => new List()).ToList(); + foreach (var id in idsByName) cols[maxDepth - depth[id]].Add(id); + for (var c = 0; c < cols.Count; c++) + cols[c] = cols[c].OrderBy(id => shortName[id], StringComparer.Ordinal).ToList(); + + var pos = new Dictionary(); + void SetPos() + { + foreach (var col in cols) + for (var i = 0; i < col.Count; i++) pos[col[i]] = i - (col.Count - 1) / 2.0; + } + void Sweep(int c, Dictionary> neighbours) + { + var key = cols[c].ToDictionary(id => id, id => + { + var nb = neighbours[id]; + if (nb.Count == 0) return pos[id]; + var sum = 0.0; + foreach (var x in nb) sum += pos[x]; + return sum / nb.Count; + }); + cols[c] = cols[c].OrderBy(id => key[id]).ThenBy(id => pos[id]).ToList(); + SetPos(); + } + + SetPos(); + for (var it = 0; it < SweepIterations; it++) + { + for (var c = 1; c < cols.Count; c++) Sweep(c, ins); + for (var c = cols.Count - 2; c >= 0; c--) Sweep(c, outs); + } + return cols; + } + + private static string ColumnLabel(int col, int count) => + count == 1 ? "Projects" + : col == 0 ? "Entry points" + : col == count - 1 ? "Foundation" + : count == 3 ? "Shared code" + : "Shared code, tier " + (count - 1 - col).ToString(CultureInfo.InvariantCulture); + + /// A left-to-right cubic from a's output port to just before b's left edge, plus its arrowhead. + private static (string Path, string Arrow) EdgePath(Node a, Node b) + { + var sx = a.X + CardW + 5; + var sy = a.Y + CardH / 2; + var tip = b.X - 2; + var ey = b.Y + CardH / 2; + var ex = tip - ArrowLen + 1; + var dx = Math.Max(40, (ex - sx) * 0.5); + var path = $"M{F(sx)} {F(sy)}C{F(sx + dx)} {F(sy)} {F(ex - dx)} {F(ey)} {F(ex)} {F(ey)}"; + var arrow = $"M{F(tip - ArrowLen)} {F(ey - 4.5)}L{F(tip)} {F(ey)}L{F(tip - ArrowLen)} {F(ey + 4.5)}Z"; + return (path, arrow); + } + + private static void AppendHeader(StringBuilder sb, string solutionName, int projects, List links, bool withSearch) + { + var codeLinks = links.Count(l => !l.ReferencesOnly); + var refOnlyLinks = links.Count - codeLinks; - private static void AppendJson(StringBuilder sb, List ordered, + sb.Append("
") + .Append("TestAtlasProject map
") + .Append("

").Append(E(solutionName)).Append("

"); + sb.Append("
"); + Figure(sb, projects, "project", "projects"); + Figure(sb, codeLinks, "code dependency", "code dependencies"); + Figure(sb, refOnlyLinks, "build-only reference", "build-only references"); + sb.Append("
"); + if (withSearch) + { + sb.Append("
") + .Append("/") + .Append("
"); + } + sb.Append(""); + sb.Append("
"); + } + + private static void Figure(StringBuilder sb, int count, string one, string many) + => sb.Append("
").Append(count).Append("").Append(count == 1 ? one : many).Append("
"); + + private static void AppendDock(StringBuilder sb) + { + sb.Append("
"); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append("
Arrows point to what a project depends on
"); + KeySample(sb, "stroke:var(--ink-faint);stroke-width:2;stroke-linecap:round", "var(--ink-faint)", "Code dependency"); + KeySample(sb, "stroke:var(--ink-faint);stroke-width:1.4;stroke-dasharray:4 3", "var(--ink-faint)", "Build-only reference"); + KeySample(sb, "stroke:var(--fill-primary);stroke-width:2;stroke-linecap:round", "var(--fill-primary)", "What it uses"); + KeySample(sb, "stroke:var(--violet);stroke-width:2;stroke-linecap:round", "var(--violet)", "What uses it"); + sb.Append("
/ search · F fit · Arrow keys move · T scope · Esc clear
"); + } + + private static void KeySample(StringBuilder sb, string lineStyle, string fill, string label) + => sb.Append("") + .Append(label).Append(""); + + private static string Icon(string kind, int size) + => "" + + KindClass(kind) switch + { + "bdd_tests" => "", + "unit_tests" => "", + "shared_library" => "", + _ => "", + } + + ""; + + private const string FitGlyph = + ""; + + private static void AppendJson(StringBuilder sb, string solutionName, string prefix, List ordered, IReadOnlyDictionary nodes, List links, - IReadOnlyDictionary> projectEndpoints) + IReadOnlyDictionary> projectEndpoints, double canvasW, double canvasH) { - sb.Append("{\"n\":{"); + sb.Append("{\"solution\":").Append(J(solutionName)).Append(",\"prefix\":").Append(J(prefix)); + sb.Append(",\"lay\":{\"W\":").Append(F(CardW)).Append(",\"H\":").Append(F(CardH)) + .Append(",\"w\":").Append(F(canvasW)).Append(",\"h\":").Append(F(canvasH)).Append('}'); + sb.Append(",\"n\":{"); var first = true; foreach (var p in ordered) { @@ -249,19 +426,19 @@ private static void AppendJson(StringBuilder sb, List ordered, first = false; var nd = nodes[p.Id]; sb.Append('"').Append(p.Id).Append("\":{\"name\":").Append(J(p.Name)) + .Append(",\"short\":").Append(J(nd.Short)) .Append(",\"kind\":").Append(J(p.Kind)) - .Append(",\"in\":").Append(nd.InDegree).Append(",\"out\":").Append(nd.OutDegree); + .Append(",\"in\":").Append(nd.InDegree).Append(",\"out\":").Append(nd.OutDegree) + .Append(",\"col\":").Append(nd.Col).Append(",\"row\":").Append(nd.Row) + .Append(",\"x\":").Append(F(nd.X)).Append(",\"y\":").Append(F(nd.Y)); if (projectEndpoints.TryGetValue(p.Id, out var eps) && eps.Count > 0) { - var ordered2 = eps - .OrderBy(e => e.Route, StringComparer.Ordinal).ThenBy(e => e.Verb, StringComparer.Ordinal) - .Take(MaxEndpointRows).ToList(); sb.Append(",\"eps\":["); - for (var k = 0; k < ordered2.Count; k++) + for (var k = 0; k < eps.Count; k++) { if (k > 0) sb.Append(','); - sb.Append("{\"v\":").Append(J(ordered2[k].Verb)).Append(",\"r\":").Append(J(ordered2[k].Route)).Append('}'); + sb.Append("{\"v\":").Append(J(eps[k].Verb)).Append(",\"r\":").Append(J(eps[k].Route)).Append('}'); } sb.Append(']'); } @@ -273,7 +450,13 @@ private static void AppendJson(StringBuilder sb, List ordered, if (i > 0) sb.Append(','); var l = links[i]; sb.Append("{\"a\":").Append(l.From).Append(",\"b\":").Append(l.To) - .Append(",\"w\":").Append(l.Weight).Append(",\"d\":").Append(J(l.Detail)).Append(",\"cls\":["); + .Append(",\"w\":").Append(l.Weight).Append(",\"d\":").Append(J(l.Detail)).Append(",\"k\":{"); + for (var j = 0; j < l.Kinds.Count; j++) + { + if (j > 0) sb.Append(','); + sb.Append(J(l.Kinds[j].Kind)).Append(':').Append(l.Kinds[j].Count); + } + sb.Append("},\"ref\":").Append(l.ReferencesOnly ? "true" : "false").Append(",\"cls\":["); for (var j = 0; j < l.Classes.Count; j++) { if (j > 0) sb.Append(','); @@ -310,31 +493,6 @@ private static string J(string s) return sb.Append('"').ToString(); } - /// A quadratic curve from a's edge to b's edge, bowed to one side so A→B and B→A don't overlap. - private static (string Path, double _) CurvePath(Node a, Node b) - { - double dx = b.X - a.X, dy = b.Y - a.Y; - var len = Math.Max(1e-6, Math.Sqrt(dx * dx + dy * dy)); - double ux = -dy / len, uy = dx / len; // unit perpendicular (consistent side per direction) - var off = len * 0.14; - double mx = (a.X + b.X) / 2 + ux * off, my = (a.Y + b.Y) / 2 + uy * off; - - // Start/end pulled back to the circle edges (so the line and arrowhead sit on the rim, not the centre). - var (sx, sy) = PullTo(a.X, a.Y, mx, my, a.R + 2); - var (ex, ey) = PullTo(b.X, b.Y, mx, my, b.R + 4); - return ($"M{F(sx)} {F(sy)} Q{F(mx)} {F(my)} {F(ex)} {F(ey)}", 0); - } - - private static (double, double) PullTo(double px, double py, double tx, double ty, double dist) - { - double dx = tx - px, dy = ty - py; - var len = Math.Max(1e-6, Math.Sqrt(dx * dx + dy * dy)); - return (px + dx / len * dist, py + dy / len * dist); - } - - private static void LegendSwatch(StringBuilder sb, string kindClass, string label) - => sb.Append("").Append(E(label)).Append(""); - private static string KindClass(string kind) => kind switch { Kinds.BddTests => "bdd_tests", @@ -343,92 +501,224 @@ private static void LegendSwatch(StringBuilder sb, string kindClass, string labe _ => "other", }; + private static string KindLabel(string kind) => kind switch + { + Kinds.BddTests => "BDD tests", + Kinds.UnitTests => "Unit tests", + Kinds.SharedLibrary => "Library", + _ => "Other", + }; + private static string Meta(MapDocument doc, string key, string fallback) => doc.Meta.TryGetValue(key, out var v) && v.Length > 0 ? v : fallback; private static string F(double d) => d.ToString("0.##", CultureInfo.InvariantCulture); private static string E(string s) => WebUtility.HtmlEncode(s); + // Design tokens (JobFocus design system). Dark is the default; light applies when the OS prefers it + // (unless the user forced dark) or when the user forced light. + private const string LightTokens = """ + --canvas:#e2e7ef;--surface-card:#ffffff;--surface-card-muted:#f4f6fa;--surface-raised:#ffffff; + --surface-input:#eef1f7;--bg-2:#d9dfe9;--line:#e0e3eb;--line-soft:#ebedf3;--line-strong:#ced4e0; + --row-hover:#f2f5f9;--ink:#1a1f2b;--ink-dim:#475061;--ink-faint:#5d6575;--ink-ghost:#c5cad4; + --fill-primary:#056aa6;--fill-primary-hover:#055a8f;--amber-glow:rgba(6,118,184,0.10);--violet:#6e4dd9; + --mint:#1e9266;--sky:#0e6cc0;--rose:#c93b50;--atmosphere-tint-a:rgba(6,118,184,0.05); + --atmosphere-tint-b:rgba(14,108,192,0.04);--card-shadow:0 1px 2px rgba(20,30,60,0.07); + --card-shadow-hover:0 2px 6px rgba(20,30,60,0.12); + --shadow-lift:0 1px 0 rgba(255,255,255,0.6) inset,0 18px 36px -18px rgba(20,30,60,0.18);color-scheme:light; + """; + private const string Css = """ - :root{--bg:#f6f7f9;--ink:#1a1d21;--dim:#5b6470;--faint:#8b94a1;--line:#e4e7ec;--card:#fff; - --bdd:#3b5bdb;--shared:#2f9e44;--unit:#e8890c;--other:#98a2b3;--mono:ui-monospace,Menlo,Consolas,monospace} - /* dark = OS-dark unless the user forced light; or explicitly forced dark */ - @media(prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#0f1216;--ink:#e6e9ee;--dim:#9aa4b2; - --faint:#6b7484;--line:#252b33;--card:#171b21;--bdd:#748ffc;--shared:#51cf66;--unit:#ffa94d;--other:#7b8494}} - :root[data-theme="dark"]{--bg:#0f1216;--ink:#e6e9ee;--dim:#9aa4b2;--faint:#6b7484;--line:#252b33;--card:#171b21; - --bdd:#748ffc;--shared:#51cf66;--unit:#ffa94d;--other:#7b8494} - *{box-sizing:border-box}html,body{height:100%} - body{margin:0;font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--ink)} - svg{position:fixed;inset:0;width:100vw;height:100vh;touch-action:none;cursor:grab} - svg.grabbing{cursor:grabbing} - .top{position:fixed;top:12px;left:12px;z-index:5;max-width:min(680px,calc(100vw - 24px)); - background:color-mix(in srgb,var(--card) 90%,transparent);backdrop-filter:blur(8px); - border:1px solid var(--line);border-radius:12px;padding:12px 16px;box-shadow:0 2px 12px rgba(0,0,0,.10)} - .hdr-row{display:flex;align-items:center;gap:12px} - .brand{font-weight:600;color:var(--dim)}.brand span{color:var(--bdd)} - .brand .tag{color:var(--faint);font-size:11px;text-transform:uppercase;letter-spacing:1px;margin-left:6px} - .hdr-actions{margin-left:auto;display:flex;gap:6px} - .ic{width:26px;height:26px;line-height:1;font-size:14px;border:1px solid var(--line);border-radius:7px; - background:var(--bg);color:var(--dim);cursor:pointer;display:flex;align-items:center;justify-content:center} - .ic:hover{color:var(--ink);border-color:var(--faint)} - .top.collapsed .hdr-detail{display:none} - h1{margin:10px 0 3px;font-size:20px;font-weight:600;letter-spacing:-.3px} - .meta{margin:0;color:var(--faint);font-size:12.5px} - .legend{display:flex;gap:14px;align-items:center;flex-wrap:wrap;margin-top:10px;font-size:12px;color:var(--dim)} - .lg{display:inline-flex;align-items:center;gap:6px} - .sw{width:10px;height:10px;border-radius:50%;display:inline-block} - .sw.kind-bdd_tests{background:var(--bdd)}.sw.kind-shared_library{background:var(--shared)} - .sw.kind-unit_tests{background:var(--unit)}.sw.kind-other{background:var(--other)} - .hint{color:var(--faint);font-size:11.5px;flex-basis:100%} - .empty{color:var(--faint);text-align:center;padding:40px} - .edge{fill:none;stroke:var(--faint);opacity:.4} - .edge.ref{stroke-dasharray:3 4;opacity:.22} /* build reference (ProjectReference), no semantic coupling */ - .arrowhead{fill:var(--faint);opacity:.6} - .node circle{stroke:var(--card);stroke-width:2;cursor:pointer} - .node.kind-bdd_tests circle{fill:var(--bdd)}.node.kind-shared_library circle{fill:var(--shared)} - .node.kind-unit_tests circle{fill:var(--unit)}.node.kind-other circle{fill:var(--other)} - .node text{fill:var(--ink);font-size:12px;font-weight:500;pointer-events:none;paint-order:stroke; - stroke:var(--bg);stroke-width:3px} - /* hover-to-isolate: dim everything, then the .active prefix wins on specificity */ - svg.has-focus .edge{opacity:.05} - svg.has-focus .node{opacity:.18} - svg.has-focus .edge.active{opacity:.95;stroke:var(--bdd)} - svg.has-focus .node.active{opacity:1} - .node.pinned circle{stroke:var(--bdd);stroke-width:3} - @media(prefers-reduced-motion:no-preference){.edge,.node{transition:opacity .12s}} - #panel{position:fixed;top:12px;right:12px;bottom:12px;width:min(340px,calc(100vw - 24px));z-index:6; - background:color-mix(in srgb,var(--card) 94%,transparent);backdrop-filter:blur(8px);border:1px solid var(--line); - border-radius:12px;box-shadow:0 4px 18px rgba(0,0,0,.16);padding:16px 16px 8px;overflow:auto} - #panel[hidden]{display:none} - .pclose{position:absolute;top:10px;right:10px} - .p-name{font-size:16px;font-weight:600;margin:0 26px 2px 0;word-break:break-word} - .p-kind{color:var(--faint);font-size:12px;margin-bottom:14px} - .p-sec{font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--faint);font-weight:600; - margin:14px 0 6px} - .pi-wrap{border-radius:8px;margin-bottom:2px} - .pi-main{display:block;width:100%;text-align:left;border:0;background:none;cursor:pointer;padding:7px 8px; - border-radius:8px;color:var(--ink);font:inherit} - .pi-main:hover{background:color-mix(in srgb,var(--bdd) 12%,transparent)} - .pi-name{font-weight:500;display:block;word-break:break-word} - .pi-detail{display:block;color:var(--faint);font-size:11.5px;font-family:var(--mono);margin-top:1px} - .pi-exp{margin:1px 0 5px 8px;border:1px solid var(--line);border-radius:20px;padding:3px 10px;background:var(--bg); - color:var(--dim);font:inherit;font-size:11px;cursor:pointer} - .pi-exp:hover{border-color:var(--bdd);color:var(--bdd)} - .pi-exp .cv{font-family:var(--mono);margin-right:3px} - .pi-classes{padding:2px 8px 8px 18px} - .pi-class{display:flex;justify-content:space-between;gap:10px;padding:4px 0;font-size:12px;border-top:1px solid var(--line)} - .pi-cname{color:var(--ink);word-break:break-word} - .pi-cmeta{color:var(--faint);font-family:var(--mono);font-size:11px;white-space:nowrap} - .p-empty{color:var(--faint);font-size:12.5px;padding:2px 8px} - .p-ep{display:flex;align-items:center;gap:8px;padding:4px 8px;border-top:1px solid var(--line)} - .ep-v{font-family:var(--mono);font-size:9.5px;font-weight:700;letter-spacing:.3px;border:1px solid;border-radius:5px; - padding:1px 5px;min-width:42px;text-align:center;flex:none} - .ep-get{color:var(--shared);border-color:var(--shared)}.ep-post{color:var(--bdd);border-color:var(--bdd)} - .ep-put{color:var(--unit);border-color:var(--unit)}.ep-patch{color:var(--unit);border-color:var(--unit)} - .ep-delete{color:#e03131;border-color:#e03131}.ep-any{color:var(--faint);border-color:var(--line)} - .ep-r{color:var(--ink);font-family:var(--mono);font-size:11.5px;word-break:break-word} - .ep-op{margin-left:auto;color:var(--bdd);font-size:10px;font-family:var(--mono);border:1px solid var(--bdd); - border-radius:20px;padding:0 6px;flex:none} + :root,[data-theme="dark"]{--canvas:#0a0c11;--surface-card:#14171f;--surface-card-muted:#181c26;--surface-raised:#1e2330; + --surface-input:#0e1117;--bg-2:#11141b;--line:#262c3a;--line-soft:#1c212c;--line-strong:#343c4d;--row-hover:#1b2129; + --ink:#eef1f6;--ink-dim:#aab3c5;--ink-faint:#7d8798;--ink-ghost:#444c5e;--fill-primary:#38bdf8;--fill-primary-hover:#0ea5e0; + --amber-glow:rgba(56,189,248,0.16);--violet:#b79bff;--mint:#5ce0a8;--sky:#6fb7ff;--rose:#ff7a8a; + --atmosphere-tint-a:rgba(56,189,248,0.16);--atmosphere-tint-b:rgba(111,183,255,0.06); + --card-shadow:0 1px 0 rgba(255,255,255,0.04) inset,0 1px 2px rgba(0,0,0,0.45); + --card-shadow-hover:0 1px 0 rgba(255,255,255,0.05) inset,0 2px 6px rgba(0,0,0,0.55); + --shadow-lift:0 1px 0 rgba(255,255,255,0.05) inset,0 28px 60px -28px rgba(0,0,0,0.95);color-scheme:dark; + --sans:'Hanken Grotesk',system-ui,-apple-system,sans-serif;--serif:Fraunces,Georgia,'Times New Roman',serif; + --mono:'JetBrains Mono',ui-monospace,'SFMono-Regular',Consolas,monospace} + """ + + "@media(prefers-color-scheme:light){:root:not([data-theme=\"dark\"]){" + LightTokens + "}}" + + ":root[data-theme=\"light\"]{" + LightTokens + "}" + + """ + *{box-sizing:border-box}html,body{height:100%;overflow:hidden}[hidden]{display:none!important} + body{margin:0;font:14px/1.5 var(--sans);color:var(--ink);background-color:var(--canvas); + background-image:radial-gradient(900px 520px at 78% -8%,var(--atmosphere-tint-a),transparent 60%), + radial-gradient(700px 600px at -5% 8%,var(--atmosphere-tint-b),transparent 55%);-webkit-font-smoothing:antialiased} + button{font:inherit;color:inherit} + :focus-visible{outline:2px solid var(--fill-primary);outline-offset:2px} + .ico{fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round} + .empty{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;margin:0;color:var(--ink-faint)} + #view{position:fixed;inset:0;overflow:hidden;touch-action:none;cursor:grab;-webkit-user-select:none;user-select:none; + background-image:linear-gradient(var(--line-soft) 1px,transparent 1px),linear-gradient(90deg,var(--line-soft) 1px,transparent 1px)} + #view.grabbing{cursor:grabbing} + #stage{position:absolute;left:0;top:0;transform-origin:0 0} + #g{position:absolute;left:0;top:0;overflow:visible;pointer-events:none} + .colh{position:absolute;top:0;display:flex;align-items:baseline;gap:8px;padding-left:2px;white-space:nowrap;pointer-events:none} + .colh b{font-size:12.5px;font-weight:600;color:var(--ink-dim)} + .colh span{font-family:var(--mono);font-size:11.5px;color:var(--ink-faint);font-variant-numeric:tabular-nums} + .node{position:absolute;width:232px;height:62px;display:flex;align-items:center;gap:10px;padding:0 12px 0 10px; + background:var(--surface-card);border:1px solid var(--line);border-radius:14px;box-shadow:var(--card-shadow); + cursor:pointer;user-select:none;-webkit-user-select:none} + .node:hover{transform:translateY(-1px)} + .tile{flex:none;width:34px;height:34px;border-radius:9px;display:flex;align-items:center;justify-content:center; + background:var(--surface-card-muted);border:1px solid var(--line-soft);color:var(--ink-dim)} + .txt{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} + .nm{font-size:13.5px;font-weight:600;line-height:1.3;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .sub{font-size:12px;line-height:1.3;color:var(--ink-faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .port{position:absolute;right:-5px;top:50%;width:9px;height:9px;margin-top:-4.5px;border-radius:999px; + background:var(--surface-card);border:1.5px solid var(--line-strong)} + .dbadge{position:absolute;top:-9px;right:12px;font-family:var(--mono);font-size:11px;font-weight:500;line-height:1; + padding:3px 7px;border-radius:999px;font-variant-numeric:tabular-nums;white-space:nowrap;--h:var(--fill-primary);color:var(--h); + background:color-mix(in srgb,var(--h) 14%,var(--surface-card));border:1px solid color-mix(in srgb,var(--h) 45%,var(--surface-card))} + .dbadge.up{--h:var(--violet)} + .node.r-focus{border-color:var(--ink-ghost);box-shadow:var(--card-shadow-hover)} + .node.r-focus .tile{color:var(--ink)} + .node.r-pin{border-color:var(--fill-primary);box-shadow:0 0 0 1px var(--fill-primary),var(--card-shadow-hover)} + .node.r-pin .tile{color:var(--fill-primary)} + .node.r-focus .port,.node.r-pin .port{border-color:var(--fill-primary)} + .node.r-down{border-color:color-mix(in srgb,var(--fill-primary) 55%,var(--line))} + .node.r-down .tile{color:var(--fill-primary)}.node.r-down .port{border-color:var(--fill-primary)} + .node.r-up{border-color:color-mix(in srgb,var(--violet) 55%,var(--line))} + .node.r-up .tile{color:var(--violet)}.node.r-up .port{border-color:var(--violet)} + .node.r-end{border-color:var(--ink-faint)}.node.r-end .tile{color:var(--ink)} + .node.r-dim{opacity:.32} + .eg{opacity:.55}.eg.rf{opacity:.4} + .edge{fill:none;stroke:var(--ink-faint);stroke-linecap:round} + .edge.ref{stroke-dasharray:4 4;stroke-linecap:butt} + .arw{fill:var(--ink-faint)} + .eg.r-hover,.eg.r-down,.eg.r-up,.eg.r-sel{opacity:1} + .eg.r-hover .edge{stroke:var(--ink-dim)}.eg.r-hover .arw{fill:var(--ink-dim)} + .eg.r-down .edge{stroke:var(--fill-primary)}.eg.r-down .arw{fill:var(--fill-primary)} + .eg.r-up .edge{stroke:var(--violet)}.eg.r-up .arw{fill:var(--violet)} + .eg.r-sel .edge{stroke:var(--ink)}.eg.r-sel .arw{fill:var(--ink)} + .eg.r-dim{opacity:.1} + .hit{fill:none;stroke:transparent;stroke-width:14;pointer-events:stroke;cursor:pointer} + @media(prefers-reduced-motion:no-preference){ + .node{transition:opacity .14s ease,border-color .14s ease,box-shadow .14s ease,transform .14s ease} + .tile,.port{transition:color .14s ease,border-color .14s ease} + .eg{transition:opacity .14s ease}.edge{transition:stroke .14s ease}.arw{transition:fill .14s ease}} + .top{position:fixed;top:0;left:0;right:0;z-index:70;display:flex;flex-wrap:wrap;align-items:center;gap:10px 28px; + padding:10px 20px;background:linear-gradient(180deg,color-mix(in srgb,var(--surface-card) 92%,transparent), + color-mix(in srgb,var(--surface-card) 80%,transparent));backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px); + border-bottom:1px solid var(--line)} + .brandg{display:flex;align-items:center;gap:14px;min-width:0} + .brand{display:flex;align-items:baseline;gap:8px;white-space:nowrap} + .wm{font-size:15px;font-weight:700;letter-spacing:-.01em;color:var(--ink-dim)}.wm span{color:var(--fill-primary)} + .tag{font-size:12.5px;color:var(--ink-faint)} + .vr{flex:none;width:1px;height:22px;background:var(--line)} + h1{margin:0;min-width:0;font-family:var(--serif);font-size:19px;font-weight:600;line-height:1.2;letter-spacing:-.01em; + white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .figs{display:flex;align-items:baseline;gap:6px 20px;flex-wrap:wrap} + .fig{display:flex;align-items:baseline;gap:6px;white-space:nowrap} + .fig b{font-family:var(--mono);font-size:17px;font-weight:700;letter-spacing:-.5px;font-variant-numeric:tabular-nums;color:var(--ink)} + .fig span{font-size:12.5px;font-weight:500;color:var(--ink-faint)} + .right{margin-left:auto;display:flex;align-items:center;gap:8px} + .search{position:relative;width:min(320px,70vw)} + #q{display:block;width:100%;font:inherit;font-size:13.5px;line-height:1.4;padding:9px 16px;border-radius:999px; + background:var(--surface-input);border:1px solid var(--line-strong);color:var(--ink);outline:none; + transition:border-color .14s ease,box-shadow .14s ease} + #q::placeholder{color:var(--ink-faint)} + #q:focus{border-color:var(--fill-primary);box-shadow:0 0 0 3px var(--amber-glow)} + .kbd{position:absolute;right:12px;top:50%;transform:translateY(-50%);pointer-events:none;font-family:var(--mono); + font-size:11px;line-height:1;color:var(--ink-faint);border:1px solid var(--line-strong);border-radius:6px;padding:3px 6px} + .search.has .kbd{display:none} + #qres{position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:80;display:flex;flex-direction:column;gap:2px; + max-height:360px;overflow:auto;padding:6px;background:var(--surface-raised);border:1px solid var(--line); + border-radius:14px;box-shadow:var(--shadow-lift)} + .opt{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:9px;cursor:pointer} + .opt.act{background:var(--row-hover);box-shadow:inset 2px 0 0 var(--fill-primary)} + .opt .on{flex:1;min-width:0;font-size:13.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .opt .ok{flex:none;font-size:12px;color:var(--ink-faint)} + .nores{padding:10px;font-size:12.5px;color:var(--ink-faint)} + .ic{flex:none;width:32px;height:30px;padding:0;display:inline-flex;align-items:center;justify-content:center; + border-radius:9px;border:1px solid var(--line);background:var(--surface-card);color:var(--ink);font-size:14px; + line-height:1;cursor:pointer;transition:background .14s ease,border-color .14s ease,color .14s ease} + .ic:hover{background:var(--row-hover);border-color:var(--ink-ghost)} + .dock{position:fixed;left:16px;bottom:16px;z-index:40;display:flex;flex-direction:column;align-items:flex-start; + gap:8px;max-width:calc(100vw - 32px)} + .zrow{display:flex;align-items:center;gap:4px} + .zpct{height:30px;min-width:56px;padding:0 8px;border-radius:9px;border:1px solid var(--line);background:var(--surface-card); + color:var(--ink-dim);font-family:var(--mono);font-size:12px;font-variant-numeric:tabular-nums;cursor:pointer; + transition:border-color .14s ease,color .14s ease} + .zpct:hover{border-color:var(--ink-ghost);color:var(--ink)} + .key{display:flex;flex-direction:column;gap:8px;padding:11px 14px;background:var(--surface-card);border:1px solid var(--line); + border-radius:14px;box-shadow:var(--card-shadow);max-width:100%} + .key-t{font-size:12px;font-weight:600;color:var(--ink)} + .key-g{display:grid;grid-template-columns:repeat(2,max-content);gap:6px 18px;font-size:12px;color:var(--ink-dim)} + .key-g span{display:flex;align-items:center;gap:8px} + .key-s{font-size:11.5px;color:var(--ink-faint)} + #panel{position:fixed;top:72px;right:12px;bottom:12px;width:min(372px,calc(100vw - 24px));z-index:60;display:flex; + flex-direction:column;background:var(--surface-card);border:1px solid var(--line);border-radius:14px; + box-shadow:var(--shadow-lift);overflow:hidden} + .ph{padding:16px 16px 14px;display:flex;flex-direction:column;gap:14px;border-bottom:1px solid var(--line-soft)} + .ph-row{display:flex;align-items:flex-start;gap:12px} + .ptile{flex:none;width:42px;height:42px;border-radius:9px;display:flex;align-items:center;justify-content:center; + background:var(--surface-card-muted);border:1px solid var(--line-soft);color:var(--fill-primary)} + .ptxt{flex:1;min-width:0;display:flex;flex-direction:column;gap:5px} + .pt{font-family:var(--serif);font-size:20px;font-weight:600;line-height:1.2;letter-spacing:-.01em;overflow-wrap:anywhere} + .pmeta{display:flex;align-items:center;gap:6px 8px;flex-wrap:wrap} + .kbadge{font-size:11px;font-weight:700;line-height:1.3;padding:3px 8px;border-radius:6px;border:1px solid var(--line); + background:var(--bg-2);color:var(--ink-dim)} + .pfull{font-family:var(--mono);font-size:11.5px;color:var(--ink-faint);overflow-wrap:anywhere} + .scope{display:flex;align-items:center;gap:8px;flex-wrap:wrap} + .scope>span{font-size:12px;font-weight:500;color:var(--ink-faint)} + .chip{font-size:12px;line-height:1.3;padding:5px 11px;border-radius:999px;border:1px solid var(--line); + background:var(--surface-card-muted);color:var(--ink-dim);cursor:pointer; + transition:background .14s ease,border-color .14s ease,color .14s ease} + .chip:hover{border-color:var(--ink-ghost)} + .chip.on{color:var(--fill-primary);border-color:var(--fill-primary);background:var(--amber-glow)} + .stats{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px} + .stat{display:flex;align-items:baseline;justify-content:space-between;gap:8px;padding:11px 13px;background:var(--surface-card); + border:1px solid var(--line);border-radius:9px;box-shadow:var(--card-shadow)} + .sv{font-family:var(--mono);font-size:21px;font-weight:700;line-height:1.1;letter-spacing:-.5px; + font-variant-numeric:tabular-nums;color:var(--ink)} + .sl{font-size:11.5px;font-weight:500;color:var(--ink-faint)} + .impact{font-size:12.5px;line-height:1.5;color:var(--ink-dim);background:var(--surface-card-muted); + border:1px solid var(--line-soft);border-radius:9px;padding:9px 11px;text-wrap:pretty} + .pb{flex:1;min-height:0;overflow:auto;padding:6px 6px 16px} + .sec{display:flex;align-items:center;gap:8px;padding:12px 10px 6px}.sec.s2{padding-top:16px}.sec.s3{padding-bottom:8px} + .sec b{font-size:12.5px;font-weight:600;color:var(--ink)} + .sec .c{font-family:var(--mono);font-size:11.5px;color:var(--ink-faint)} + .dot{flex:none;width:8px;height:8px;border-radius:999px;background:var(--fill-primary)}.dot.up{background:var(--violet)} + .pempty{padding:2px 10px 8px;font-size:12.5px;color:var(--ink-faint)} + .grp{padding:8px 10px 2px;font-size:11.5px;font-weight:500;color:var(--ink-faint)} + .row{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border-radius:9px;cursor:pointer;transition:background .14s ease} + .row:hover{background:var(--row-hover)} + .r1{display:flex;align-items:baseline;gap:10px} + .rn{flex:1;min-width:0;font-size:13.5px;font-weight:600;line-height:1.3;overflow-wrap:anywhere} + .rk{flex:none;font-size:12px;color:var(--ink-faint)} + .rd{font-size:12px;line-height:1.4;color:var(--ink-faint)} + .clsw{padding:0 10px 6px} + .clist{margin:0 10px 8px;border:1px solid var(--line-soft);border-radius:9px;background:var(--surface-card-muted);overflow:hidden} + .crow{display:flex;justify-content:space-between;align-items:baseline;gap:10px;padding:7px 10px;border-top:1px solid var(--line-soft)} + .crow:first-child{border-top-color:transparent} + .ecls .clist{margin:0 10px}.ecls .crow{padding:8px 10px} + .cn{font-family:var(--mono);font-size:12px;color:var(--ink);overflow-wrap:anywhere} + .cw{flex:none;font-size:12px;color:var(--ink-faint)} + .ep{display:flex;align-items:center;gap:10px;padding:6px 10px} + .verb{flex:none;min-width:52px;text-align:center;font-family:var(--mono);font-size:10.5px;font-weight:700;line-height:1.4; + padding:1px 6px;border-radius:6px;--h:var(--ink-faint);color:var(--h);border:1px solid color-mix(in srgb,var(--h) 35%,transparent); + background:color-mix(in srgb,var(--h) 10%,transparent)} + .verb.v-get{--h:var(--mint)}.verb.v-post,.verb.v-put,.verb.v-patch{--h:var(--sky)}.verb.v-delete{--h:var(--rose)} + .route{flex:1;min-width:0;font-family:var(--mono);font-size:12px;color:var(--ink);overflow-wrap:anywhere} + .opill{flex:none;font-size:11px;color:var(--ink-faint);border:1px solid var(--line);border-radius:999px;padding:1px 7px} + .eh{padding:16px;display:flex;flex-direction:column;gap:14px;border-bottom:1px solid var(--line-soft)} + .ek{display:flex;align-items:center;gap:8px}.ek>span{flex:1;font-size:12.5px;font-weight:600;color:var(--ink-dim)} + .et{font-family:var(--serif);font-size:19px;font-weight:600;line-height:1.3;letter-spacing:-.01em;text-wrap:pretty} + .ft{display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:8px} + .mini{min-width:0;display:flex;flex-direction:column;gap:2px;padding:8px 10px;border:1px solid var(--line);border-radius:9px; + background:var(--surface-card-muted);cursor:pointer;transition:border-color .14s ease} + .mini:hover{border-color:var(--ink-ghost)} + .mini b{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .mini span{font-size:11.5px;color:var(--ink-faint)} + .arr{font-size:16px;color:var(--ink-faint)} + .es{font-size:13px;line-height:1.5;color:var(--ink-dim);text-wrap:pretty} + #tip{position:fixed;z-index:90;pointer-events:none;max-width:300px;display:flex;flex-direction:column;gap:3px;padding:8px 11px; + background:var(--surface-raised);border:1px solid var(--line-strong);border-radius:9px;box-shadow:var(--shadow-lift)} + .tt{font-size:12.5px;font-weight:600;color:var(--ink)}.td{font-size:12px;color:var(--ink-dim)}.th{font-size:11.5px;color:var(--ink-faint)} """; // Runs in before paint so a saved manual theme choice applies without a flash. @@ -436,127 +726,513 @@ private static string Meta(MapDocument doc, string key, string fallback) (function(){try{var s=localStorage.getItem('testatlas:theme');if(s)document.documentElement.setAttribute('data-theme',s);}catch(e){}})(); """; + // Light / dark toggle: with nothing saved the page follows prefers-color-scheme (dark when there is none). + private const string ThemeJs = """ + function currentTheme(){ + var t=document.documentElement.getAttribute('data-theme'); + if(t==='light'||t==='dark') return t; + return window.matchMedia&&matchMedia('(prefers-color-scheme: light)').matches?'light':'dark'; + } + function syncThemeButton(){ + var b=document.getElementById('themeBtn'); if(!b) return; + var l=currentTheme()==='dark'?'Switch to light theme':'Switch to dark theme'; + b.title=l; b.setAttribute('aria-label',l); + } + function toggleTheme(){ + var next=currentTheme()==='dark'?'light':'dark'; + document.documentElement.setAttribute('data-theme',next); + try{localStorage.setItem('testatlas:theme',next);}catch(e){} + syncThemeButton(); + } + syncThemeButton(); + + """; + private const string Js = """ (function(){ - var svg=document.getElementById('g'); if(!svg) return; + var view=document.getElementById('view'); if(!view) return; var DATA=window.__MAP__||{n:{},links:[]}; - var edges=[].slice.call(svg.querySelectorAll('.edge')); - var nodes=[].slice.call(svg.querySelectorAll('.node')); - var byId={}; nodes.forEach(function(nd){byId[nd.getAttribute('data-id')]=nd;}); - var pinned=null; - - function highlight(id){ - var keep={}; keep[id]=1; - edges.forEach(function(e){ - var a=e.getAttribute('data-a'),b=e.getAttribute('data-b'),on=(a===id||b===id); - e.classList.toggle('active',on); if(on){keep[a]=1;keep[b]=1;} + var N=DATA.n, E=DATA.links, LAY=DATA.lay, ids=Object.keys(N); + var stage=document.getElementById('stage'), layer=document.getElementById('edges'); + var bar=document.getElementById('top'), dock=document.getElementById('dock'); + var panel=document.getElementById('panel'), tip=document.getElementById('tip'), zpct=document.getElementById('zpct'); + var qIn=document.getElementById('q'), qRes=document.getElementById('qres'), qBox=document.getElementById('search'); + var KIND={bdd_tests:'BDD tests',unit_tests:'Unit tests',shared_library:'Library'}; + var ROLES=['focus','pin','down','up','end','dim'], EROLES=['none','hover','down','up','sel','dim']; + var Z={dim:0,none:1,hover:2,up:3,down:3,sel:4}; + + function kl(k){return KIND[k]||'Other';} + function isTest(k){return k==='bdd_tests'||k==='unit_tests';} + function cap(t){return t?t.charAt(0).toUpperCase()+t.slice(1):t;} + function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');} + function vcls(v){v=(v||'').toUpperCase();return v==='GET'||v==='POST'||v==='PUT'||v==='PATCH'||v==='DELETE'?v.toLowerCase():'any';} + + // ---- graph ---- + var nodeEl={}, COLS=[], edgeG=[], edgeLine=[], baseW=[]; + ids.forEach(function(id){ + var n=N[id]; n.id=id; n.o=[]; n.i=[]; n.eps=n.eps||[]; + nodeEl[id]=stage.querySelector('.node[data-id="'+id+'"]'); + (COLS[n.col]||(COLS[n.col]=[]))[n.row]=id; + }); + E.forEach(function(e,i){ + e.i=i; e.a=String(e.a); e.b=String(e.b); e.k=e.k||{}; e.cls=e.cls||[]; + N[e.a].o.push(i); N[e.b].i.push(i); + edgeG[i]=layer.querySelector('.eg[data-i="'+i+'"]'); + edgeLine[i]=edgeG[i].querySelector('.edge'); + baseW[i]=parseFloat(edgeLine[i].getAttribute('stroke-width'))||1.25; + }); + + // Plain-language phrasing of a link and of one class behind it. + function phrase(e){ + if(e.ref) return 'build reference only'; + var k=e.k, o=[]; + if(k.uses_type) o.push('uses types '+k.uses_type+'×'); + if(k.binds_to) o.push('binds steps '+k.binds_to+'×'); + if(k.inherits) o.push('inherits '+k.inherits+'×'); + if(k.holds) o.push('holds as members '+k.holds+'×'); + return o.join(' · '); + } + function classWhat(c){ + if(c.k==='uses_type') return 'used '+c.w+'×'; + if(c.k==='inherits') return 'inherited '+c.w+'×'; + if(c.k==='binds_to') return c.w+(c.w===1?' step bound':' steps bound'); + if(c.k==='holds') return 'held '+c.w+'×'; + return c.w+' '+c.k; + } + + // Breadth-first reach from a project, downstream (what it uses) or upstream (what uses it). + function bfs(start,dir,codeOnly){ + var depth={}, via={}, viaEdge={}, q=[start]; depth[start]=0; + while(q.length){ + var id=q.shift(); + (dir==='down'?N[id].o:N[id].i).forEach(function(ei){ + var e=E[ei]; if(codeOnly&&e.ref) return; + var nx=dir==='down'?e.b:e.a; + if(depth[nx]==null){depth[nx]=depth[id]+1; via[nx]=id; viaEdge[nx]=ei; q.push(nx);} + }); + } + delete depth[start]; + return {depth:depth,via:via,viaEdge:viaEdge}; + } + // Transitive reach prefers code paths; a project reachable only through a build-only reference + // takes its depth and parent from the all-links walk. + function reach(start,dir,trans){ + var out={depth:{},via:{},viaEdge:{}}; + if(!trans){ + (dir==='down'?N[start].o:N[start].i).forEach(function(ei){ + var e=E[ei], nx=dir==='down'?e.b:e.a; + out.depth[nx]=1; out.via[nx]=start; out.viaEdge[nx]=ei; + }); + return out; + } + var code=bfs(start,dir,true), all=bfs(start,dir,false); + Object.keys(all.depth).forEach(function(id){ + var src=code.depth[id]!=null?code:all; + out.depth[id]=src.depth[id]; out.via[id]=src.via[id]; out.viaEdge[id]=src.viaEdge[id]; }); - nodes.forEach(function(m){m.classList.toggle('active',!!keep[m.getAttribute('data-id')]);}); - svg.classList.add('has-focus'); + return out; } - function clearHL(){ - svg.classList.remove('has-focus'); - edges.forEach(function(e){e.classList.remove('active');}); - nodes.forEach(function(m){m.classList.remove('active');}); + + // ---- state ---- + var S={tx:0,ty:0,k:1,pinned:null,hover:null,hoverEdge:null,tipX:0,tipY:0,selEdge:null,scope:'transitive', + q:'',searchFocused:false,searchIdx:0,open:{},barH:60}; + var userMoved=false, down=null, wasDrag=false, grabbing=false, raf=0, F=null, panelKey='', edgeOrder='', results=[]; + var reduced=!!(window.matchMedia&&matchMedia('(prefers-reduced-motion: reduce)').matches); + function panelOpen(){return !!S.pinned||S.selEdge!=null;} + + // ---- highlight roles ---- + function compute(){ + var selE=S.selEdge!=null?E[S.selEdge]:null, focus=selE?null:(S.pinned||S.hover); + var trans=!!S.pinned&&!selE&&S.scope==='transitive'; + F={selE:selE,focus:focus,trans:trans, + down:focus?reach(focus,'down',trans):null, up:focus?reach(focus,'up',trans):null}; } - function esc(s){var d=document.createElement('div'); d.textContent=(s==null?'':s); return d.innerHTML;} - function vcls(v){v=(v||'').toUpperCase();return v==='GET'||v==='POST'||v==='PUT'||v==='PATCH'||v==='DELETE'?v.toLowerCase():'any';} - function list(arr,key){ - if(!arr.length) return '
none
'; - return arr.map(function(l){ - var oid=l[key], nm=(DATA.n[oid]||{}).name||('#'+oid), cls=l.cls||[]; - var main=''; - var exp=cls.length? '':''; - var body=cls.length? '' : ''; - return '
'+main+exp+body+'
'; - }).join(''); - } - window.toggleExp=function(btn){ - var wrap=btn.closest('.pi-wrap'), body=wrap.querySelector('.pi-classes'), cv=btn.querySelector('.cv'); - if(body.hasAttribute('hidden')){body.removeAttribute('hidden'); if(cv)cv.textContent='▾';} - else{body.setAttribute('hidden',''); if(cv)cv.textContent='▸';} - }; - function openPanel(id){ - var m=DATA.n[id]; if(!m) return; - var out=DATA.links.filter(function(l){return String(l.a)===id;}).sort(function(x,y){return y.w-x.w;}); - var inc=DATA.links.filter(function(l){return String(l.b)===id;}).sort(function(x,y){return y.w-x.w;}); - var h='
'+esc(m.name)+'
'+esc(m.kind) - +' · depended on by '+m.in+' · depends on '+m.out+'
'; - h+='
Depends on ('+out.length+')
'+list(out,'b'); - h+='
Depended on by ('+inc.length+')
'+list(inc,'a'); - var eps=m.eps||[]; - if(eps.length){ - h+='
API endpoints ('+eps.length+')
'; - h+=eps.map(function(e){ - var op=e.r.indexOf('/')<0; - return '
'+esc(e.v)+'' - +''+esc(e.r)+''+(op?'op':'')+'
'; + function nodeRole(id){ + if(F.selE) return id===F.selE.a||id===F.selE.b?'end':'dim'; + if(!F.focus) return 'none'; + if(id===F.focus) return S.pinned===id?'pin':'focus'; + if(F.down.depth[id]!=null) return 'down'; + if(F.up.depth[id]!=null) return 'up'; + return 'dim'; + } + function edgeRole(e){ + var r; + if(F.selE) r=e.i===F.selE.i?'sel':'dim'; + else if(!F.focus) r='none'; + else if(F.trans){ + if((e.a===F.focus||F.down.depth[e.a]!=null)&&F.down.depth[e.b]!=null) r='down'; + else if((e.b===F.focus||F.up.depth[e.b]!=null)&&F.up.depth[e.a]!=null) r='up'; + else r='dim'; + } else r=e.a===F.focus?'down':e.b===F.focus?'up':'dim'; + if(S.hoverEdge===e.i&&(r==='none'||r==='dim')) r='hover'; + return r; + } + function applyRoles(){ + ids.forEach(function(id){ + var el=nodeEl[id], r=nodeRole(id); + ROLES.forEach(function(x){el.classList.toggle('r-'+x,x===r);}); + el.setAttribute('aria-pressed',r==='pin'?'true':'false'); + var b=el.querySelector('.dbadge'), d=r==='down'?F.down.depth[id]:r==='up'?F.up.depth[id]:null; + if(F.trans&&d!=null){b.textContent=(r==='down'?'→ ':'← ')+d; b.classList.toggle('up',r==='up'); b.hidden=false;} + else b.hidden=true; + }); + // Highlighted edges draw last, so their arrowheads win where several arrows meet at one card. + var items=E.map(function(e){return {i:e.i,r:edgeRole(e)};}); + items.forEach(function(it){ + var g=edgeG[it.i]; + EROLES.forEach(function(x){g.classList.toggle('r-'+x,x===it.r);}); + edgeLine[it.i].setAttribute('stroke-width',String(baseW[it.i]+(it.r==='hover'||it.r==='sel'?0.75:0))); + }); + items.sort(function(x,y){return Z[x.r]-Z[y.r]||x.i-y.i;}); + var ord=items.map(function(it){return it.i;}).join(','); + if(ord!==edgeOrder){edgeOrder=ord; items.forEach(function(it){layer.appendChild(edgeG[it.i]);});} + } + + // ---- details panel ---- + function icon(id,size){ + var s=nodeEl[id].querySelector('.tile svg').cloneNode(true); + s.setAttribute('width',size); s.setAttribute('height',size); + return s.outerHTML; + } + var closeBtn=''; + function clsRows(cls){ + return cls.map(function(c){return '
'+esc(c.c)+''+esc(classWhat(c))+'
';}).join(''); + } + function rows(R,dir,trans){ + var list=Object.keys(R.depth), last=0, h=''; + list.sort(function(x,y){ + return R.depth[x]-R.depth[y]||E[R.viaEdge[y]].w-E[R.viaEdge[x]].w||N[x].short.localeCompare(N[y].short); + }); + list.forEach(function(oid){ + var d=R.depth[oid], e=E[R.viaEdge[oid]], key=dir+':'+e.i, open=!!S.open[key], n=N[oid]; + if(trans&&d!==last) h+='
'+(d===1?'Direct':d+' steps away')+'
'; + last=d; + h+='
' + +esc(n.short)+''+esc(kl(n.kind))+'
' + +esc(cap((d>1?'via '+N[R.via[oid]].short+' · ':'')+phrase(e)))+'
'; + if(e.cls.length){ + h+='
'; + if(open) h+='
'+clsRows(e.cls)+'
'; + } + }); + return {n:list.length,html:h}; + } + function impact(id){ + var all=F.trans?F.up:reach(id,'up',true); + var t=Object.keys(all.depth).filter(function(x){return isTest(N[x].kind);}) + .map(function(x){return N[x].short;}).sort(function(a,b){return a.localeCompare(b);}); + if(!t.length) return 'No test project depends on this, directly or indirectly.'; + var shown=t.slice(0,4), more=t.length-shown.length; + var list=more?shown.join(', ')+' and '+more+' more' + :shown.length>1?shown.slice(0,-1).join(', ')+' and '+shown[shown.length-1]:shown[0]; + return 'Changes here can affect '+t.length+(t.length===1?' test project: ':' test projects: ')+list+'.'; + } + function sec(cls,title,count){ + return '
'+(cls.indexOf('s3')<0?'':'') + +''+title+''+count+'
'; + } + function chip(scope,label){ + var on=S.scope===scope; + return ''; + } + function projectPanel(id){ + var n=N[id], dn=rows(F.down,'d',F.trans), up=rows(F.up,'u',F.trans); + var h='
'+icon(id,20)+'
' + +esc(n.short)+'
'+esc(kl(n.kind))+'' + +esc(n.name)+'
'+closeBtn+'
'; + h+='
Show'+chip('direct','Direct')+chip('transitive','Transitive')+'
'; + h+='
'+dn.n+'Uses
' + +'
'+up.n+'Used by
'; + if(!isTest(n.kind)) h+='
'+esc(impact(id))+'
'; + h+='
'; + h+=sec('','Depends on',dn.n)+(dn.n?dn.html:'
Depends on no other project in this solution.
'); + h+=sec(' s2','Used by',up.n)+(up.n?up.html:'
No project in this solution depends on it.
'); + if(n.eps.length){ + h+='
API endpoints called'+n.eps.length+'
'; + h+=n.eps.map(function(e){ + return '
'+esc(e.v)+''+esc(e.r)+'' + +(e.r.indexOf('/')<0?'operation':'')+'
'; }).join(''); } - document.getElementById('panel-body').innerHTML=h; - document.getElementById('panel').hidden=false; + return h+'
'; + } + function mini(id){ + return '
'+esc(N[id].short) + +''+esc(kl(N[id].kind))+'
'; + } + function edgePanel(e){ + var A=N[e.a], B=N[e.b]; + var h='
'+(e.ref?'Build-only reference':'Code dependency')+''+closeBtn+'
'; + h+='
'+esc(A.short+' depends on '+B.short)+'
'; + h+='
'+mini(e.a)+''+mini(e.b)+'
'; + h+='
'+esc(e.ref?'The project file of '+A.short+' references '+B.short + +', but no code in it uses, binds to or inherits anything there.':cap(phrase(e))+'.')+'
'; + if(e.cls.length) h+='
'+sec(' s3','Classes behind it',e.cls.length)+'
'+clsRows(e.cls)+'
'; + return h; + } + function renderPanel(){ + var open=panelOpen(); + var key=!open?'':S.selEdge!=null?'e'+S.selEdge:'p'+S.pinned+'|'+S.scope+'|'+JSON.stringify(S.open); + if(key===panelKey) return; + var same=open&&panelKey.split('|')[0]===key.split('|')[0], pb=panel.querySelector('.pb'), st=pb&&same?pb.scrollTop:0; + var ae=document.activeElement, fsel=null; + if(same&&ae&&panel.contains(ae)) ['data-toggle','data-scope','data-pin','data-act'].some(function(a){ + var v=ae.getAttribute(a); if(v!=null){fsel='['+a+'="'+v+'"]'; return true;} return false; + }); + panelKey=key; + if(!open){panel.hidden=true; panel.innerHTML=''; return;} + panel.style.top=(S.barH+12)+'px'; + panel.innerHTML=S.selEdge!=null?edgePanel(E[S.selEdge]):projectPanel(S.pinned); + panel.hidden=false; + pb=panel.querySelector('.pb'); if(pb) pb.scrollTop=st; + if(fsel){var f=panel.querySelector(fsel); if(f) f.focus({preventScroll:true});} + } + panel.addEventListener('click',function(ev){ + var t=ev.target.closest('[data-pin],[data-toggle],[data-scope],[data-act]'); if(!t) return; + if(t.hasAttribute('data-pin')) pin(t.getAttribute('data-pin')); + else if(t.hasAttribute('data-toggle')){var k=t.getAttribute('data-toggle'); S.open[k]=!S.open[k]; update();} + else if(t.hasAttribute('data-scope')){S.scope=t.getAttribute('data-scope'); update();} + else unpin(); + }); + + // ---- edge tooltip ---- + function placeTip(){ + tip.style.left=Math.max(8,Math.min(S.tipX+14,innerWidth-316))+'px'; + tip.style.top=Math.min(S.tipY+16,innerHeight-90)+'px'; + } + function renderTip(){ + var te=S.hoverEdge!=null&&!grabbing?E[S.hoverEdge]:null; + if(!te||S.selEdge===S.hoverEdge){tip.hidden=true; return;} + tip.innerHTML='
'+esc(N[te.a].short+' → '+N[te.b].short)+'
'+esc(cap(phrase(te))) + +'
'+(te.cls.length?'Click to see the classes behind it':'Click for details')+'
'; + tip.hidden=false; placeTip(); + } + + function update(){compute(); applyRoles(); renderPanel(); renderTip();} + + // ---- view: pan / zoom / fit ---- + function applyView(){ + stage.style.transform='translate('+S.tx+'px,'+S.ty+'px) scale('+S.k+')'; + var g=34*S.k*(S.k<0.6?2:1); + view.style.backgroundSize=g+'px '+g+'px'; + view.style.backgroundPosition=S.tx+'px '+S.ty+'px'; + zpct.textContent=Math.round(S.k*100)+'%'; + } + function setView(t){S.tx=t.tx; S.ty=t.ty; S.k=t.k; applyView();} + function region(){ + var vw=innerWidth, vh=innerHeight, p=panelOpen()&&vw>760?384:0; + var lh=dock?dock.getBoundingClientRect().height+28:150; + return {L:28,R:vw-28-p,T:S.barH+24,B:vh-lh}; + } + function fitK(){var R=region(); return Math.max(0.3,Math.min(1.15,(R.R-R.L)/LAY.w,(R.B-R.T)/LAY.h));} + function fitView(anim){ + var R=region(), k=fitK(), t={k:k,tx:R.L+(R.R-R.L-LAY.w*k)/2,ty:R.T+(R.B-R.T-LAY.h*k)/2}; + if(anim) animateTo(t); else {cancelAnimationFrame(raf); setView(t);} + } + function centerOn(id){ + var n=N[id], R=region(), k=Math.max(S.k,0.8); + userMoved=true; + animateTo({k:k,tx:(R.L+R.R)/2-(n.x+LAY.W/2)*k,ty:(R.T+R.B)/2-(n.y+LAY.H/2)*k}); + } + function ensureVisible(id){ + var n=N[id], R=region(), m=12; + var x0=n.x*S.k+S.tx, y0=n.y*S.k+S.ty, x1=x0+LAY.W*S.k, y1=y0+LAY.H*S.k, dx=0, dy=0; + if(x0R.R-m) dx=R.R-m-x1; + if(y0R.B-m) dy=R.B-m-y1; + if(dx||dy) animateTo({k:S.k,tx:S.tx+dx,ty:S.ty+dy}); + } + function afterPanelOpen(id){ if(!userMoved&&fitK()>=0.8) fitView(true); else if(id) ensureVisible(id); } + function animateTo(t,dur){ + cancelAnimationFrame(raf); + if(reduced){setView(t); return;} + var f={tx:S.tx,ty:S.ty,k:S.k}, t0=performance.now(), D=dur||380; + function step(now){ + var q=Math.max(0,Math.min(1,(now-t0)/D)), e=1-Math.pow(1-q,3); + setView({tx:f.tx+(t.tx-f.tx)*e,ty:f.ty+(t.ty-f.ty)*e,k:f.k+(t.k-f.k)*e}); + if(q<1) raf=requestAnimationFrame(step); + } + raf=requestAnimationFrame(step); + } + function clampK(k){return Math.max(0.3,Math.min(2.5,k));} + function zoomAt(mx,my,f){ + userMoved=true; cancelAnimationFrame(raf); + var k=clampK(S.k*f), r=k/S.k; + setView({k:k,tx:mx-(mx-S.tx)*r,ty:my-(my-S.ty)*r}); + } + function zoomBy(f){ + var R=region(), k=clampK(S.k*f), r=k/S.k, mx=(R.L+R.R)/2, my=(R.T+R.B)/2; + userMoved=true; + animateTo({k:k,tx:mx-(mx-S.tx)*r,ty:my-(my-S.ty)*r},220); } - window.pin=function(id){ - id=String(id); if(!DATA.n[id]) return; - if(pinned && byId[pinned]) byId[pinned].classList.remove('pinned'); - pinned=id; highlight(id); - if(byId[id]) byId[id].classList.add('pinned'); - openPanel(id); + // ---- selection ---- + window.pin=function(id,opts){ + id=String(id); if(!N[id]) return; + var was=panelOpen(); + S.pinned=id; S.selEdge=null; S.hoverEdge=null; update(); + if(opts&&opts.center) centerOn(id); else if(!was) afterPanelOpen(id); else ensureVisible(id); }; window.unpin=function(){ - if(pinned && byId[pinned]) byId[pinned].classList.remove('pinned'); - pinned=null; clearHL(); document.getElementById('panel').hidden=true; + var had=panelOpen(); + S.pinned=null; S.selEdge=null; update(); + if(had&&!userMoved) fitView(true); }; + var pin=window.pin, unpin=window.unpin; + function selectEdge(i){ + var was=panelOpen(); + S.selEdge=i; S.pinned=null; S.hoverEdge=null; update(); + if(!was) afterPanelOpen(null); + } + function move(key){ + var cur=S.pinned, target=null; + if(!cur){var first=COLS[0]&&COLS[0][0]; if(first){pin(first); nodeEl[first].focus({preventScroll:true});} return;} + var n=N[cur]; + if(key==='ArrowUp'||key==='ArrowDown') target=COLS[n.col][n.row+(key==='ArrowUp'?-1:1)]||null; + else { + var list=key==='ArrowRight'?n.o.map(function(ei){return E[ei].b;}):n.i.map(function(ei){return E[ei].a;}); + list.sort(function(a,b){return Math.abs(N[a].y-n.y)-Math.abs(N[b].y-n.y);}); + target=list[0]||null; + } + if(target){pin(target); nodeEl[target].focus({preventScroll:true});} + } + function consumeDrag(){var w=wasDrag; wasDrag=false; return w;} - nodes.forEach(function(nd){ - var id=nd.getAttribute('data-id'); - nd.addEventListener('mouseenter',function(){ if(pinned===null) highlight(id); }); - nd.addEventListener('mouseleave',function(){ if(pinned===null) clearHL(); }); - nd.addEventListener('click',function(ev){ ev.stopPropagation(); pin(id); }); + ids.forEach(function(id){ + var el=nodeEl[id]; + el.addEventListener('click',function(ev){ev.stopPropagation(); if(consumeDrag()) return; pin(id);}); + el.addEventListener('mouseenter',function(){ if(!(down&&down.moved)&&S.hover!==id){S.hover=id; update();} }); + el.addEventListener('mouseleave',function(){ if(S.hover===id){S.hover=null; update();} }); + }); + [].forEach.call(document.querySelectorAll('#hits .hit'),function(h){ + var i=+h.getAttribute('data-i'); + h.addEventListener('mouseenter',function(ev){ + if(down&&down.moved) return; + S.tipX=ev.clientX; S.tipY=ev.clientY; + if(S.hoverEdge!==i){S.hoverEdge=i; update();} else placeTip(); + }); + h.addEventListener('mousemove',function(ev){ if(S.hoverEdge===i){S.tipX=ev.clientX; S.tipY=ev.clientY; placeTip();} }); + h.addEventListener('mouseleave',function(){ if(S.hoverEdge===i){S.hoverEdge=null; update();} }); + h.addEventListener('click',function(ev){ev.stopPropagation(); if(consumeDrag()) return; selectEdge(i);}); }); - // pan + zoom via viewBox. IMPORTANT: capture the pointer only once a drag actually MOVES — - // capturing on pointerdown retargets the click to the , so node clicks never reach pin(). - var vb={x:0,y:0,w:1000,h:1040}, down=null, wasDrag=false; - function apply(){svg.setAttribute('viewBox',vb.x+' '+vb.y+' '+vb.w+' '+vb.h);} - svg.addEventListener('wheel',function(ev){ - ev.preventDefault(); - var r=svg.getBoundingClientRect(); - var mx=vb.x+(ev.clientX-r.left)/r.width*vb.w, my=vb.y+(ev.clientY-r.top)/r.height*vb.h; - var k=ev.deltaY<0?0.9:1.1111; - k=Math.max(0.2, Math.min(6, k*(vb.w/1000)))/(vb.w/1000); - vb.x=mx-(mx-vb.x)*k; vb.y=my-(my-vb.y)*k; vb.w*=k; vb.h*=k; apply(); - },{passive:false}); - svg.addEventListener('pointerdown',function(ev){down={x:ev.clientX,y:ev.clientY,id:ev.pointerId};wasDrag=false;}); - svg.addEventListener('pointermove',function(ev){ + // ---- pointer, wheel ---- + // Dragging anywhere (cards included) pans. IMPORTANT: capture the pointer only once a drag actually + // MOVES — capturing on pointerdown retargets the click to the canvas, so card clicks never reach pin(). + view.addEventListener('pointerdown',function(ev){ + if(ev.button!==0) return; + wasDrag=false; down={x:ev.clientX,y:ev.clientY,id:ev.pointerId,moved:false}; + }); + view.addEventListener('pointermove',function(ev){ if(!down) return; var dx=ev.clientX-down.x, dy=ev.clientY-down.y; - if(!wasDrag){ + if(!down.moved){ if(Math.abs(dx)+Math.abs(dy)<=3) return; // still a click, not a drag — don't capture - wasDrag=true; svg.classList.add('grabbing'); try{svg.setPointerCapture(down.id);}catch(e){} + down.moved=true; wasDrag=true; grabbing=true; view.classList.add('grabbing'); + try{view.setPointerCapture(down.id);}catch(e){} + cancelAnimationFrame(raf); S.hover=null; S.hoverEdge=null; update(); + } + down.x=ev.clientX; down.y=ev.clientY; userMoved=true; + setView({tx:S.tx+dx,ty:S.ty+dy,k:S.k}); + }); + function end(){ if(down&&down.moved){grabbing=false; view.classList.remove('grabbing');} down=null; } + view.addEventListener('pointerup',end); view.addEventListener('pointercancel',end); + // A background click (not a card or edge, not the tail of a drag) clears the pin or selection. + view.addEventListener('click',function(){ if(consumeDrag()) return; if(panelOpen()) unpin(); }); + view.addEventListener('wheel',function(e){ + e.preventDefault(); + var u=e.deltaMode===1?16:e.deltaMode===2?400:1; + if(!e.ctrlKey&&Math.abs(e.deltaX)>Math.abs(e.deltaY)){ + userMoved=true; cancelAnimationFrame(raf); setView({tx:S.tx-e.deltaX*u,ty:S.ty,k:S.k}); return; + } + zoomAt(e.clientX,e.clientY,Math.exp(-e.deltaY*u*(e.ctrlKey?0.01:0.0015))); + },{passive:false}); + + document.getElementById('zout').addEventListener('click',function(){zoomBy(1/1.2);}); + document.getElementById('zin').addEventListener('click',function(){zoomBy(1.2);}); + zpct.addEventListener('click',function(){zoomBy(1/S.k);}); + document.getElementById('zfit').addEventListener('click',function(){userMoved=false; fitView(true);}); + + // ---- search ---- + function computeResults(){ + var q=S.q.trim().toLowerCase(), res=ids.map(function(id){return N[id];}); + if(q){ + var sw=function(n){return n.short.toLowerCase().indexOf(q)===0?1:0;}; + res=res.filter(function(n){return n.name.toLowerCase().indexOf(q)>=0||kl(n.kind).toLowerCase().indexOf(q)>=0;}) + .sort(function(a,b){return sw(b)-sw(a)||a.short.localeCompare(b.short);}); + } else res.sort(function(a,b){return a.col-b.col||a.row-b.row;}); + return res.slice(0,8); + } + function renderSearch(){ + qBox.classList.toggle('has',S.searchFocused||!!S.q); + qIn.setAttribute('aria-expanded',S.searchFocused?'true':'false'); + if(!S.searchFocused){qRes.hidden=true; qIn.removeAttribute('aria-activedescendant'); return;} + results=computeResults(); + var idx=Math.max(0,Math.min(S.searchIdx,results.length-1)); + qRes.innerHTML=results.length?results.map(function(n,i){ + return '
'+esc(n.short)+''+esc(kl(n.kind))+'
'; + }).join(''):'
No project matches “'+esc(S.q.trim())+'”
'; + if(results.length) qIn.setAttribute('aria-activedescendant','qo'+idx); else qIn.removeAttribute('aria-activedescendant'); + qRes.hidden=false; + } + function pickResult(id){ + S.q=''; S.searchIdx=0; qIn.value=''; qIn.blur(); + pin(id,{center:true}); + } + qIn.addEventListener('input',function(){S.q=qIn.value; S.searchIdx=0; renderSearch();}); + qIn.addEventListener('focus',function(){S.searchFocused=true; renderSearch();}); + qIn.addEventListener('blur',function(){S.searchFocused=false; renderSearch();}); + qIn.addEventListener('keydown',function(e){ + if(e.key==='ArrowDown'||e.key==='ArrowUp'){ + e.preventDefault(); + S.searchIdx=e.key==='ArrowDown'?Math.min(results.length-1,S.searchIdx+1):Math.max(0,S.searchIdx-1); + renderSearch(); + var a=qRes.querySelector('.act'); if(a) a.scrollIntoView({block:'nearest'}); + } else if(e.key==='Enter'){ + e.preventDefault(); + var n=results[Math.max(0,Math.min(S.searchIdx,results.length-1))]; if(n) pickResult(n.id); + } else if(e.key==='Escape'){ + e.preventDefault(); S.q=''; qIn.value=''; qIn.blur(); } - var r=svg.getBoundingClientRect(); - vb.x-=dx/r.width*vb.w; vb.y-=dy/r.height*vb.h; - down.x=ev.clientX; down.y=ev.clientY; apply(); }); - function end(){down=null;svg.classList.remove('grabbing');} - svg.addEventListener('pointerup',end); svg.addEventListener('pointercancel',end); - // background click (not a node, not the tail of a drag) clears the pin. - svg.addEventListener('click',function(ev){ if(!wasDrag && !ev.target.closest('.node')) unpin(); wasDrag=false; }); + qRes.addEventListener('mousedown',function(e){ + e.preventDefault(); + var o=e.target.closest('.opt'); if(o) pickResult(o.getAttribute('data-id')); + }); + qRes.addEventListener('mouseover',function(e){ + var o=e.target.closest('.opt'); if(!o) return; + var i=+o.getAttribute('data-ix'); if(i!==S.searchIdx){S.searchIdx=i; renderSearch();} + }); + + // ---- keyboard ---- + addEventListener('keydown',function(e){ + var t=e.target, typing=t&&(t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.isContentEditable); + if(typing||e.metaKey||e.ctrlKey||e.altKey) return; + var k=e.key; + if(k==='/'){e.preventDefault(); qIn.focus(); return;} + if(k==='Escape'){ if(panelOpen()) unpin(); return; } + if(k==='f'||k==='F'){userMoved=false; fitView(true); return;} + if(k==='+'||k==='='){zoomBy(1.2); return;} + if(k==='-'||k==='_'){zoomBy(1/1.2); return;} + if(k==='0'){zoomBy(1/S.k); return;} + if((k==='t'||k==='T')&&S.pinned){S.scope=S.scope==='transitive'?'direct':'transitive'; update(); return;} + if(k.indexOf('Arrow')===0){ + if(t&&t.closest&&t.closest('#panel')) return; + e.preventDefault(); move(k); return; + } + if((k==='Enter'||k===' ')&&t&&t.getAttribute&&t.getAttribute('role')==='button'){e.preventDefault(); t.click();} + }); + + // ---- layout lifecycle ---- + function measureBar(){ + var h=Math.round(bar.getBoundingClientRect().height); + if(h&&h!==S.barH){S.barH=h; panel.style.top=(h+12)+'px'; if(!userMoved) fitView(false);} + } + if(window.ResizeObserver) new ResizeObserver(measureBar).observe(bar); + addEventListener('resize',function(){measureBar(); if(!userMoved) fitView(false);}); + measureBar(); update(); fitView(false); + requestAnimationFrame(function(){ if(!userMoved) fitView(false); }); })(); - function toggleHeader(){ - var t=document.getElementById('top'); t.classList.toggle('collapsed'); - document.getElementById('collapseBtn').textContent=t.classList.contains('collapsed')?'+':'–'; - } - function toggleTheme(){ - var r=document.documentElement, cur=r.getAttribute('data-theme'); - var dark = cur ? cur==='dark' : matchMedia('(prefers-color-scheme:dark)').matches; - var next = dark?'light':'dark'; r.setAttribute('data-theme',next); - try{localStorage.setItem('testatlas:theme',next);}catch(e){} - } """; } diff --git a/tests/CodeMap.Tests/ProjectMapBuilderTests.cs b/tests/CodeMap.Tests/ProjectMapBuilderTests.cs index 07ea930..7cdcbf5 100644 --- a/tests/CodeMap.Tests/ProjectMapBuilderTests.cs +++ b/tests/CodeMap.Tests/ProjectMapBuilderTests.cs @@ -79,8 +79,9 @@ public void A_project_reference_with_no_semantic_edge_renders_as_a_dashed_build_ // The infra lib is connected (not an isolated 0-degree node) via a distinctly-styled dashed edge. Assert.Contains("class=\"edge ref\" data-a=\"1\" data-b=\"2\"", html); - Assert.Contains("build-only reference", html); // called out in the header - Assert.Contains("0 dependencies", html); // …not counted as a semantic dependency + Assert.Contains("1build-only reference", html); // called out in the header + Assert.Contains("0code dependencies", html); // …not counted as a code dependency + Assert.Contains("\"k\":{},\"ref\":true", html); // flagged in the blob for the panel } [Fact] @@ -92,7 +93,7 @@ public void Escapes_project_names() } [Fact] - public void Has_theme_toggle_collapsible_header_and_a_working_hover_highlight_rule() + public void Has_theme_toggle_search_zoom_and_direction_highlight_rules() { var html = ProjectMapBuilder.Build(Doc()); @@ -102,14 +103,16 @@ public void Has_theme_toggle_collapsible_header_and_a_working_hover_highlight_ru Assert.Contains("[data-theme=\"light\"]", html); Assert.Contains("testatlas:theme", html); - // Full-view: a collapsible floating panel. - Assert.Contains("toggleHeader()", html); - Assert.Contains("id=\"collapseBtn\"", html); + // Navigation chrome: search-and-jump, zoom buttons and fit-to-view, plus the drawn edge key. + Assert.Contains("placeholder=\"Search projects\"", html); + Assert.Contains("title=\"Fit to view (F)\"", html); + Assert.Contains("title=\"Reset zoom to 100% (0)\"", html); + Assert.Contains("Arrows point to what a project depends on", html); - // Hover-highlight specificity fix: the .active rule is scoped under svg.has-focus so it beats - // the dimming rule (the earlier bug was that dimming out-ranked the highlight). - Assert.Contains("svg.has-focus .edge.active", html); - Assert.Contains("svg.has-focus .node.active", html); + // Highlights separate the two directions: blue = what the focus uses, violet = what uses it. + Assert.Contains(".eg.r-down .edge{stroke:var(--fill-primary)}", html); + Assert.Contains(".eg.r-up .edge{stroke:var(--violet)}", html); + Assert.Contains(".node.r-dim{opacity:.32}", html); } [Fact] @@ -122,20 +125,20 @@ public void Embeds_a_dependency_panel_with_the_adjacency_data() Assert.Contains("window.pin=function", html); Assert.Contains("window.unpin=function", html); Assert.Contains("Depends on", html); - Assert.Contains("Depended on by", html); + Assert.Contains("Used by", html); // The data blob carries node labels and the one cross-project link with its kind/count detail, - // so the panel can list "Feature.A → Lib.B · 1 binds_to". Names are JSON-escaped (< → <). + // so the panel can list "Feature.A → Lib.B · binds steps 1×". Names are JSON-escaped (< → <). Assert.Contains("window.__MAP__=", html); - Assert.Contains("\"a\":1,\"b\":2,\"w\":1,\"d\":\"1 binds_to\"", html); + Assert.Contains("\"a\":1,\"b\":2,\"w\":1,\"d\":\"1 binds_to\",\"k\":{\"binds_to\":1},\"ref\":false", html); Assert.Contains("Lib.B \\u003cb>", html); // the < is <-escaped inside the