Skip to content

Commit 7938971

Browse files
authored
feat(nav): on-demand graph overlay (replaces graph pane)
2 parents 94a2dbe + b4cf12b commit 7938971

10 files changed

Lines changed: 278 additions & 19 deletions

File tree

internal/adapter/inbound/web/graph.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func (h *ReadingHandler) GraphPage(c *echo.Context) error {
3636
p := "/" + c.Param("*")
3737
n := h.reading.Neighborhood(world, p)
3838
// Single-pane permalink: nodes link to /w/ document permalinks.
39-
svg := graphSVG(n, func(r domain.Ref) string { return docRoute(r.World, r.Path) })
39+
svg := graphSVG(n, func(r domain.Ref) string { return docRoute(r.World, r.Path) }, nil)
4040
vm := page{
4141
Title: "Graph: " + p,
4242
Host: world,
@@ -75,15 +75,15 @@ func (h *ReadingHandler) graphPaneView(t trail, i int, addr paneAddr) paneVM {
7575
n := h.reading.Neighborhood(addr.World, addr.Value)
7676
vm.Content = graphSVG(n, func(r domain.Ref) string {
7777
return trailURL(trailAfterClick(t, i, paneAddr{Kind: paneDoc, World: r.World, Value: r.Path}))
78-
})
78+
}, trailDocRefs(t))
7979
return vm
8080
}
8181

8282
// graphSVG lays out the neighborhood deterministically (server-side, no client
8383
// physics): the center document in the middle, its neighbors on a ring —
8484
// backlinks on the left arc, outbound links on the right — each joined to the
8585
// center by an edge. urlFor turns each ref into its navigation target.
86-
func graphSVG(n domain.Neighborhood, urlFor func(domain.Ref) string) template.HTML {
86+
func graphSVG(n domain.Neighborhood, urlFor func(domain.Ref) string, onTrail map[domain.Ref]bool) template.HTML {
8787
if len(n.In) == 0 && len(n.Out) == 0 {
8888
return template.HTML(`<p class="graph-empty">No links observed yet — the neighborhood fills in as connected documents are read.</p>`) //nolint:gosec // static markup
8989
}
@@ -111,8 +111,12 @@ func graphSVG(n domain.Neighborhood, urlFor func(domain.Ref) string) template.HT
111111
if !pn.inbound {
112112
dir = "out"
113113
}
114-
fmt.Fprintf(&b, `<a href="%s"><circle class="graph-node graph-%s" cx="%d" cy="%d" r="%d"/>`,
115-
html.EscapeString(urlFor(pn.ref)), dir, pn.x, pn.y, graphNodeR)
114+
cls := "graph-node graph-" + dir
115+
if onTrail[pn.ref] {
116+
cls += " graph-walked" // a neighbor already on your trail
117+
}
118+
fmt.Fprintf(&b, `<a href="%s"><circle class="%s" cx="%d" cy="%d" r="%d"/>`,
119+
html.EscapeString(urlFor(pn.ref)), html.EscapeString(cls), pn.x, pn.y, graphNodeR)
116120
anchor := "middle"
117121
if pn.x < cx {
118122
anchor = "end"
@@ -160,6 +164,18 @@ func arcNodes(refs []domain.Ref, cx, cy int, inbound bool) []placedNode {
160164
return out
161165
}
162166

167+
// trailDocRefs is the set of document refs on the trail — the graph overlay
168+
// marks these neighbors as already-walked (ADR 0006 §4).
169+
func trailDocRefs(t trail) map[domain.Ref]bool {
170+
refs := map[domain.Ref]bool{}
171+
for _, p := range t.Panes {
172+
if p.Kind == paneDoc && !domain.IsListingPath(p.Value) {
173+
refs[domain.Ref{World: p.World, Path: p.Value}] = true
174+
}
175+
}
176+
return refs
177+
}
178+
163179
func trimGraphLabel(s string) string {
164180
runes := []rune(s)
165181
if len(runes) <= graphLabel {

internal/adapter/inbound/web/graph_test.go

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ func TestGraphSVGRendersNeighborhood(t *testing.T) {
1414
Out: []domain.Ref{{World: "soul", Path: "/out.md"}},
1515
In: []domain.Ref{{World: "soul", Path: "/in.md"}},
1616
}
17-
svg := string(graphSVG(n, func(r domain.Ref) string { return docRoute(r.World, r.Path) }))
17+
svg := string(graphSVG(n, func(r domain.Ref) string { return docRoute(r.World, r.Path) }, nil))
1818

1919
if !strings.Contains(svg, "<svg class=\"graph\"") {
2020
t.Errorf("not an svg: %s", svg)
@@ -32,7 +32,7 @@ func TestGraphSVGRendersNeighborhood(t *testing.T) {
3232

3333
func TestGraphSVGEmptyNeighborhood(t *testing.T) {
3434
n := domain.Neighborhood{Center: domain.Ref{World: "soul", Path: "/lonely.md"}}
35-
svg := string(graphSVG(n, func(_ domain.Ref) string { return "" }))
35+
svg := string(graphSVG(n, func(_ domain.Ref) string { return "" }, nil))
3636
if !strings.Contains(svg, "graph-empty") {
3737
t.Errorf("empty neighborhood should render the honest empty state: %s", svg)
3838
}
@@ -76,6 +76,45 @@ func TestTrailGraphPaneContinuesTrail(t *testing.T) {
7676
}
7777
}
7878

79+
func TestGraphOverlayForFocusedDoc(t *testing.T) {
80+
svc := &fakeReading{
81+
docs: map[string]domain.Document{"/x.md": {Title: "X", Path: "/x.md", HTML: "<p>x</p>"}},
82+
neighbor: map[string]domain.Neighborhood{
83+
"/x.md": {Center: domain.Ref{World: "w.io", Path: "/x.md"},
84+
Out: []domain.Ref{{World: "w.io", Path: "/y.md"}}},
85+
},
86+
}
87+
body := get(readingApp(t, svc), "/t/w.io/d/x.md").Body.String()
88+
89+
// The focused doc's graph overlay is embedded (summoned by `g`), not a pane.
90+
if !strings.Contains(body, `id="graph-overlay"`) {
91+
t.Errorf("graph overlay missing for focused doc: %s", body)
92+
}
93+
// A node click is a trail jump from the focus (navigating dismisses the overlay).
94+
if !strings.Contains(body, `href="/t/w.io/d/x.md/~/w.io/d/y.md"`) {
95+
t.Errorf("graph overlay node should jump the trail: %s", body)
96+
}
97+
}
98+
99+
func TestGraphOverlayMarksWalkedNeighbors(t *testing.T) {
100+
// Trail y → x (focus x); x links to y, and y is on the trail, so y renders
101+
// as a walked node in x's overlay.
102+
svc := &fakeReading{
103+
docs: map[string]domain.Document{
104+
"/x.md": {Title: "X", Path: "/x.md", HTML: "<p>x</p>"},
105+
"/y.md": {Title: "Y", Path: "/y.md", HTML: "<p>y</p>"},
106+
},
107+
neighbor: map[string]domain.Neighborhood{
108+
"/x.md": {Center: domain.Ref{World: "w.io", Path: "/x.md"},
109+
Out: []domain.Ref{{World: "w.io", Path: "/y.md"}}},
110+
},
111+
}
112+
body := get(readingApp(t, svc), "/t/w.io/d/y.md/~/w.io/d/x.md").Body.String()
113+
if !strings.Contains(body, "graph-walked") {
114+
t.Errorf("neighbor on the trail should render as walked: %s", body)
115+
}
116+
}
117+
79118
func TestDocMarginOffersGraphAndBacklinks(t *testing.T) {
80119
svc := &fakeReading{
81120
docs: map[string]domain.Document{
@@ -87,8 +126,9 @@ func TestDocMarginOffersGraphAndBacklinks(t *testing.T) {
87126
}
88127
body := get(readingApp(t, svc), "/t/w.io/d/x.md").Body.String()
89128

90-
// The margin's graph affordance opens this doc's graph pane on the trail.
91-
if !strings.Contains(body, `href="/t/w.io/d/x.md/~/w.io/g/x.md"`) {
129+
// The margin's graph affordance opens the graph overlay (ADR 0006 §4): a /g/
130+
// permalink (degrade) that islands.js intercepts on the canvas.
131+
if !strings.Contains(body, `href="/w/w.io/g/x.md" class="graph-open"`) {
92132
t.Errorf("margin graph affordance missing: %s", body)
93133
}
94134
// The backlinks block lists the referrer with a hover-preview link.

internal/adapter/inbound/web/reading_handlers_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -319,8 +319,8 @@ func TestDocRendersMargin(t *testing.T) {
319319
"mark://soul.demarkus.io/adr/0007.md",
320320
`href="/w/soul.demarkus.io/raw/adr/0007.md"`,
321321
`href="/w/soul.demarkus.io/versions/adr/0007.md"`,
322-
`href="/w/soul.demarkus.io/g/adr/0007.md">graph`,
323-
`href="/w/soul.demarkus.io/u">map`,
322+
`href="/w/soul.demarkus.io/g/adr/0007.md" class="graph-open">graph`,
323+
`href="/w/soul.demarkus.io/u" class="map-open">map`,
324324
} {
325325
if !strings.Contains(body, want) {
326326
t.Errorf("doc page missing %q", want)

internal/adapter/inbound/web/static/islands.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,60 @@
207207
}
208208
});
209209

210+
// --- graph overlay (g) — ADR 0006 §4 ----------------------------------
211+
// The overlay is server-rendered (templates/graph-overlay); this is the
212+
// summon/dismiss glue ADR 0003 sanctions. Node clicks are plain trail links,
213+
// so navigating dismisses it. Degrades: the margin "graph" link is a real /g/
214+
// permalink; we only intercept it on the canvas (where the overlay exists).
215+
function graphOverlay() { return document.getElementById("graph-overlay"); }
216+
function openGraph() { var g = graphOverlay(); if (g) g.hidden = false; }
217+
function closeGraph() { var g = graphOverlay(); if (g) g.hidden = true; }
218+
document.addEventListener("click", function (e) {
219+
var link = e.target.closest && e.target.closest("a.graph-open");
220+
if (link && graphOverlay()) { e.preventDefault(); openGraph(); return; }
221+
if (e.target.id === "graph-overlay") closeGraph(); // click outside the panel
222+
});
223+
document.addEventListener("keydown", function (e) {
224+
var g = graphOverlay();
225+
if (e.key === "Escape" && g && !g.hidden) { e.preventDefault(); closeGraph(); return; }
226+
if (e.key !== "g" || e.ctrlKey || e.metaKey || e.altKey) return;
227+
var tag = (e.target.tagName || "").toLowerCase();
228+
if (tag === "input" || tag === "textarea" || e.target.isContentEditable) return;
229+
var p = palette();
230+
if ((p && !p.hidden) || !g) return; // not while the palette is open / no graph here
231+
e.preventDefault();
232+
g.hidden ? openGraph() : closeGraph();
233+
});
234+
235+
// --- world-map overlay (m) — ADR 0006 §5 ------------------------------
236+
// Same overlay chrome as the graph, but lazy: the SVG is htmx-loaded into
237+
// #map-canvas on summon (the map needs a catalog read, so an unopened map
238+
// costs nothing). Node clicks are trail links → navigating dismisses it.
239+
function mapOverlay() { return document.getElementById("map-overlay"); }
240+
function openMap() {
241+
var m = mapOverlay();
242+
if (!m) return;
243+
m.hidden = false;
244+
if (window.htmx) window.htmx.ajax("GET", m.dataset.mapUrl, "#map-canvas");
245+
}
246+
function closeMap() { var m = mapOverlay(); if (m) m.hidden = true; }
247+
document.addEventListener("click", function (e) {
248+
var link = e.target.closest && e.target.closest("a.map-open");
249+
if (link && mapOverlay()) { e.preventDefault(); openMap(); return; }
250+
if (e.target.id === "map-overlay") closeMap(); // click outside the panel
251+
});
252+
document.addEventListener("keydown", function (e) {
253+
var m = mapOverlay();
254+
if (e.key === "Escape" && m && !m.hidden) { e.preventDefault(); closeMap(); return; }
255+
if (e.key !== "m" || e.ctrlKey || e.metaKey || e.altKey) return;
256+
var tag = (e.target.tagName || "").toLowerCase();
257+
if (tag === "input" || tag === "textarea" || e.target.isContentEditable) return;
258+
var p = palette();
259+
if ((p && !p.hidden) || !m) return;
260+
e.preventDefault();
261+
m.hidden ? openMap() : closeMap();
262+
});
263+
210264
document.addEventListener("DOMContentLoaded", function () {
211265
scan(document.body);
212266
showFocusedPane();

internal/adapter/inbound/web/templates/canvas.html

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,43 @@ <h1>{{ .Title }}</h1>
3737
{{template "reader" .}}
3838
{{- end }}
3939
{{template "dock" .Dock}}
40+
{{template "graph-overlay" .Graph}}
41+
{{template "map-overlay" .}}
4042
{{template "palette" .}}
4143
</body>
4244
</html>{{end}}
4345

46+
{{/* The graph overlay (ADR 0006 §4): an on-demand pull-up of the focused doc's
47+
reference neighborhood, summoned by `g` (islands.js) and closed by Esc /
48+
click-outside. Server-rendered SVG, hidden until summoned; node clicks are
49+
plain trail links, so a click jumps and the navigation dismisses it. It
50+
replaces the in-trail graph pane. */}}
51+
{{define "graph-overlay"}}
52+
{{- if .Has}}
53+
<div id="graph-overlay" class="graph-backdrop" hidden>
54+
<div class="graph-panel" role="dialog" aria-modal="true" aria-label="Document graph">
55+
<div class="graph-head"><span class="graph-title">{{ .Title }}</span><span class="graph-hint">g · esc</span></div>
56+
<div class="graph-canvas">{{ .Content }}</div>
57+
</div>
58+
</div>
59+
{{- end}}
60+
{{end}}
61+
62+
{{/* The world-map overlay (ADR 0006 §5): on-demand discovery pull-up, like the
63+
graph. Lazy — the SVG is htmx-loaded into #map-canvas on summon (`m` / the
64+
margin "map" link, islands.js), so an unopened map costs no catalog read.
65+
Nodes extend the trail; navigating dismisses it. Reuses the overlay chrome. */}}
66+
{{define "map-overlay"}}
67+
{{- if .MapHas}}
68+
<div id="map-overlay" class="graph-backdrop" hidden data-map-url="/w/{{ .MapWorldPath }}/u?overlay=1">
69+
<div class="graph-panel">
70+
<div class="graph-head"><span class="graph-title">Map: {{ .MapWorld }}</span><span class="graph-hint">m · esc</span></div>
71+
<div id="map-canvas" class="graph-canvas"></div>
72+
</div>
73+
</div>
74+
{{- end}}
75+
{{end}}
76+
4477
{{/* The trail dock (ADR 0006 §2): the bottom orientation strip. A <details>
4578
gives zero-JS minimize — closed shows just the header, freeing vertical
4679
space. Entries rewind on click; connectors show walk (solid) vs jump

internal/adapter/inbound/web/templates/content.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@
3636
<a href="/w/{{ .WorldPath }}/raw{{ .Path }}">source</a>
3737
· <a href="/w/{{ .WorldPath }}/versions{{ .Path }}">editions</a>
3838
{{- if .ReaderURL }} · <a href="{{ .ReaderURL }}">reader</a>{{ end }}
39-
{{- if .GraphURL }} · <a href="{{ .GraphURL }}">graph</a>{{ end }}
40-
{{- if .MapURL }} · <a href="{{ .MapURL }}">map</a>{{ end }}
39+
{{- if .GraphURL }} · <a href="{{ .GraphURL }}" class="graph-open">graph</a>{{ end }}
40+
{{- if .MapURL }} · <a href="{{ .MapURL }}" class="map-open">map</a>{{ end }}
4141
{{- if .EditURL }} · <a href="{{ .EditURL }}" hx-boost="false" class="edit-link">edit</a>{{ end }}
4242
{{- if .NewURL }} · <a href="{{ .NewURL }}" hx-boost="false" class="edit-link">new</a>{{ end }}
4343
{{- if .AppendURL }} · <a href="{{ .AppendURL }}" hx-boost="false" class="edit-link">append</a>{{ end }}

internal/adapter/inbound/web/templates/page.html

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,9 @@
215215
.graph-node { fill: var(--paper); stroke: var(--muted); stroke-width: 1.5; }
216216
.graph-node.graph-in { stroke: light-dark(#0969da, #4493f8); }
217217
.graph-node.graph-out { stroke: light-dark(#1a7f37, #3fb950); }
218+
/* A neighbor already on your trail (ADR 0006 §4): filled green = walked. */
219+
.graph-node.graph-walked { fill: light-dark(#1a7f37, #3fb950);
220+
stroke: light-dark(#1a7f37, #3fb950); stroke-width: 3; }
218221
.graph-node-label { font-size: 11px; }
219222
svg.graph a:hover circle { stroke-width: 3; }
220223
.graph-empty { color: var(--muted); font-style: italic; }
@@ -324,6 +327,24 @@
324327
font-size: .7rem; color: var(--muted); }
325328
@media (max-width: 640px) { .palette-loc { display: none; } }
326329

330+
/* ── Graph overlay (ADR 0006 §4): on-demand pull-up of the focused doc's
331+
reference neighborhood. Hidden until summoned by `g`. ───────────── */
332+
.graph-backdrop { position: fixed; inset: 0; z-index: 90;
333+
background: light-dark(#11111155, #00000099);
334+
display: flex; justify-content: center; align-items: center;
335+
padding: 4vh 2vw; }
336+
.graph-backdrop[hidden] { display: none; }
337+
.graph-panel { background: var(--paper); color: var(--ink);
338+
border: 1px solid var(--faint); border-radius: 10px;
339+
box-shadow: 0 12px 48px #00000066; max-width: 95vw; max-height: 90vh;
340+
overflow: auto; display: flex; flex-direction: column; }
341+
.graph-head { display: flex; justify-content: space-between; align-items: baseline;
342+
gap: 1rem; padding: .5rem .9rem; border-bottom: 1px solid var(--faint);
343+
font-family: system-ui, sans-serif; }
344+
.graph-title { font-weight: 600; font-size: .9rem; }
345+
.graph-hint { font-size: .68rem; color: var(--muted); }
346+
.graph-canvas { padding: .5rem; }
347+
327348
/* ── World index (ADR 0006 §5): the universe lists worlds as doors, never
328349
loose documents. A card carries door affordances (glyph, chevron) a
329350
document row never has; federated worlds read as a remote server. ── */

internal/adapter/inbound/web/trail_handlers.go

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,25 @@ type canvasVM struct {
2323
Authenticated bool
2424
User string // signed-in identity's email for the nav (empty ⇒ not shown)
2525
Panes []paneVM
26-
Reader *paneVM // the reader overlay (R4); nil when closed
27-
CloseURL string // ✕ / backdrop / Esc target: the bare trail (no overlay)
28-
Dock dockVM // the bottom orientation strip (ADR 0006 §2)
26+
Reader *paneVM // the reader overlay (R4); nil when closed
27+
CloseURL string // ✕ / backdrop / Esc target: the bare trail (no overlay)
28+
Dock dockVM // the bottom orientation strip (ADR 0006 §2)
29+
Graph graphOverlayVM // the on-demand graph overlay (ADR 0006 §4)
30+
31+
// The world-map overlay shell (ADR 0006 §5, on-demand discovery): empty
32+
// until summoned, then htmx-loaded lazily so an unopened map costs no read.
33+
MapHas bool
34+
MapWorld string
35+
MapWorldPath string
36+
}
37+
38+
// graphOverlayVM is the focused doc's graph overlay (ADR 0006 §4): summoned by
39+
// `g`, it replaces the in-trail graph pane. Has is false when the focused pane
40+
// is not a document (then `g` has nothing to show).
41+
type graphOverlayVM struct {
42+
Has bool
43+
Title string
44+
Content template.HTML
2945
}
3046

3147
// paneVM is one pane on the canvas. The margin fields mirror the page VM so
@@ -138,11 +154,33 @@ func (h *ReadingHandler) Trail(c *echo.Context) error {
138154
vm.Title = focusedPane.Title
139155
vm.World = focusedPane.World
140156

157+
// The world-map overlay is available whenever the focus sits in a world (a
158+
// doc, listing, tag, or world-map pane) — the bare universe floor has none.
159+
if fw := t.Panes[t.Focus].World; fw != "" {
160+
vm.MapHas = true
161+
vm.MapWorld = fw
162+
vm.MapWorldPath = url.PathEscape(fw)
163+
}
164+
141165
// The dock is built after the pane loop so the focused doc's links are
142166
// already observed (RecordLinks ran during render), giving the walk/jump
143167
// connectors and "from here →" chips a graph to read.
144168
vm.Dock = h.buildDock(t)
145169

170+
// The graph overlay (ADR 0006 §4): the focused doc's reference neighborhood,
171+
// summoned by `g`. Built here (links observed), embedded hidden — it replaces
172+
// the in-trail graph pane. Node clicks are trail jumps from the focus.
173+
if fa := t.Panes[t.Focus]; fa.Kind == paneDoc && !domain.IsListingPath(fa.Value) {
174+
n := h.reading.Neighborhood(fa.World, fa.Value)
175+
vm.Graph = graphOverlayVM{
176+
Has: true,
177+
Title: refTitle(n.Center),
178+
Content: graphSVG(n, func(r domain.Ref) string {
179+
return trailURL(trailAfterClick(t, t.Focus, paneAddr{Kind: paneDoc, World: r.World, Value: r.Path}))
180+
}, trailDocRefs(t)),
181+
}
182+
}
183+
146184
// The reader overlay reuses the addressed pane's already-fetched document —
147185
// no extra world read (the overlay is pure presentation), and focus is
148186
// untouched so the canvas behind is unchanged. Body links persist the
@@ -290,8 +328,15 @@ func (h *ReadingHandler) paneView(ctx context.Context, t trail, i int, addr pane
290328
// Graph/map open non-prose panes, so they exit the overlay (plain
291329
// trail URLs) even in reader mode; backlinks point at docs, so they
292330
// persist the overlay like any other prose link.
293-
vm.GraphURL = trailURL(trailAfterClick(t, i, paneAddr{Kind: paneGraph, World: addr.World, Value: addr.Value}))
294-
vm.MapURL = trailURL(trailAfterClick(t, i, paneAddr{Kind: paneFloor, World: addr.World}))
331+
// The "graph" affordance opens the graph overlay (ADR 0006 §4); the href
332+
// is the /g/ permalink so it degrades to the standalone graph page when
333+
// JS is off (islands.js intercepts it on the canvas, where the overlay
334+
// exists). The graph is no longer docked as a trail pane.
335+
vm.GraphURL = "/w/" + url.PathEscape(addr.World) + "/g" + addr.Value
336+
// "map" opens the world-map overlay (ADR 0006 §5); the href is the /u
337+
// permalink so it degrades to the standalone map page without JS. The
338+
// map is no longer docked as a trail pane.
339+
vm.MapURL = "/w/" + url.PathEscape(addr.World) + "/u"
295340
// Edit leaves the canvas into the dedicated editor page (a focused-pane
296341
// mode, not a trail chunk); only behind the turnstile.
297342
if authed {

0 commit comments

Comments
 (0)