Skip to content

Commit b208619

Browse files
authored
feat(web): render rel-typed edges dashed with predicate
2 parents 0cc6a57 + 4a64cfc commit b208619

9 files changed

Lines changed: 85 additions & 21 deletions

File tree

internal/adapter/inbound/web/graph.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@ const arrowMarker = `<defs><marker id="arrow" markerWidth="9" markerHeight="9" r
4444
// pointing at the target, trimmed back by each endpoint's node radius so the
4545
// line sits between the rims and the arrowhead lands just outside the target
4646
// node instead of hiding under it. fromID/toID tag the edge with its endpoint
47-
// node ids so a node-hover handler can light up every incident edge.
48-
func directedEdge(b *strings.Builder, x1, y1, r1, x2, y2, r2 int, fromID, toID string) {
47+
// node ids so a node-hover handler can light up every incident edge. A
48+
// non-empty rel (a typed relation's predicate) draws dashed with the
49+
// predicate as its hover tooltip; "" is a plain reference.
50+
func directedEdge(b *strings.Builder, x1, y1, r1, x2, y2, r2 int, fromID, toID, rel string) {
4951
dx, dy := float64(x2-x1), float64(y2-y1)
5052
d := math.Hypot(dx, dy)
5153
if d == 0 {
@@ -55,8 +57,13 @@ func directedEdge(b *strings.Builder, x1, y1, r1, x2, y2, r2 int, fromID, toID s
5557
const gap = 3.0 // breathing room between arrow tip and target rim
5658
sx, sy := x1+int(ux*float64(r1)), y1+int(uy*float64(r1))
5759
ex, ey := x2-int(ux*(float64(r2)+gap)), y2-int(uy*(float64(r2)+gap))
58-
fmt.Fprintf(b, `<line class="graph-edge" x1="%d" y1="%d" x2="%d" y2="%d" data-from="%s" data-to="%s" marker-end="url(#arrow)"/>`,
59-
sx, sy, ex, ey, html.EscapeString(fromID), html.EscapeString(toID))
60+
if rel == "" {
61+
fmt.Fprintf(b, `<line class="graph-edge" x1="%d" y1="%d" x2="%d" y2="%d" data-from="%s" data-to="%s" marker-end="url(#arrow)"/>`,
62+
sx, sy, ex, ey, html.EscapeString(fromID), html.EscapeString(toID))
63+
return
64+
}
65+
fmt.Fprintf(b, `<line class="graph-edge edge-rel" x1="%d" y1="%d" x2="%d" y2="%d" data-from="%s" data-to="%s" marker-end="url(#arrow)"><title>%s</title></line>`,
66+
sx, sy, ex, ey, html.EscapeString(fromID), html.EscapeString(toID), html.EscapeString(rel))
6067
}
6168

6269
// GraphPage renders the graph neighborhood as a standalone permalink —
@@ -149,9 +156,9 @@ func graphSVG(n domain.Neighborhood, urlFor func(domain.Ref) string, onTrail map
149156
// outbound link points center→neighbor, a backlink points neighbor→center.
150157
for _, pn := range placed {
151158
if pn.inbound {
152-
directedEdge(&b, pn.x, pn.y, graphNodeR, cx, cy, graphCenterR, pn.ref.Path, n.Center.Path)
159+
directedEdge(&b, pn.x, pn.y, graphNodeR, cx, cy, graphCenterR, pn.ref.Path, n.Center.Path, "")
153160
} else {
154-
directedEdge(&b, cx, cy, graphCenterR, pn.x, pn.y, graphNodeR, n.Center.Path, pn.ref.Path)
161+
directedEdge(&b, cx, cy, graphCenterR, pn.x, pn.y, graphNodeR, n.Center.Path, pn.ref.Path, "")
155162
}
156163
}
157164
// Center node (data-node so hovering it lights up all its edges).

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,9 @@
327327
svg.graph { max-width: 100%; height: auto; }
328328
svg.graph text { fill: var(--muted); font-family: system-ui, sans-serif; }
329329
.graph-edge { stroke: var(--faint); stroke-width: 1.5; }
330+
/* A typed relation (rel-<predicate>) draws dashed; its predicate is the
331+
<title> tooltip on the line. */
332+
.graph-edge.edge-rel { stroke-dasharray: 5 3; }
330333
.edge-arrow { fill: var(--muted); }
331334
/* Node hover (islands.js) lights up the incident edges and the nodes they
332335
connect, so the relationship reads. */

internal/adapter/inbound/web/worldmap.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ func worldMapSVG(wm domain.WorldMap, docURL func(string) string, newURL string)
295295
if !okF || !okT {
296296
continue
297297
}
298-
directedEdge(&b, from.x, from.y, from.r, to.x, to.y, to.r, e.From.Path, e.To.Path)
298+
directedEdge(&b, from.x, from.y, from.r, to.x, to.y, to.r, e.From.Path, e.To.Path, e.Rel)
299299
}
300300
for _, d := range linked {
301301
pn := placed[d.Path]

internal/adapter/inbound/web/worldmap_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,19 @@ func TestTrailWorldMapErrorHandling(t *testing.T) {
186186
t.Errorf("unfocused world-map error must tombstone: %d", rec.Code)
187187
}
188188
}
189+
190+
// A rel-typed edge draws dashed (edge-rel) with its predicate as the tooltip;
191+
// plain edges stay untouched.
192+
func TestWorldMapSVGRelEdge(t *testing.T) {
193+
wm := testWorldMap()
194+
wm.Edges[0].Rel = "supersedes"
195+
svg := string(worldMapSVG(wm, func(p string) string { return p }, ""))
196+
for _, want := range []string{
197+
`class="graph-edge edge-rel"`,
198+
`<title>supersedes</title>`,
199+
} {
200+
if !strings.Contains(svg, want) {
201+
t.Errorf("world-map svg missing %q\n---\n%s", want, svg)
202+
}
203+
}
204+
}

internal/adapter/outbound/graphexport/graphexport.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ import (
1616
type Parser struct{}
1717

1818
// ParseGraphExport decodes body into domain nodes and reference edges:
19-
// mark:// endpoints only, and one edge per document pair (enriched exports
20-
// repeat a pair with rel-typed rows).
19+
// mark:// endpoints only, and one edge per document pair. Enriched exports
20+
// repeat a pair as a plain plus rel-typed rows; the pair keeps its first
21+
// declared predicate so the relation survives the collapse.
2122
func (Parser) ParseGraphExport(body string) ([]domain.GraphNode, []domain.Edge) {
2223
rawNodes, rawEdges := graphstore.ParseExport(body)
2324
var nodes []domain.GraphNode
@@ -29,19 +30,23 @@ func (Parser) ParseGraphExport(body string) ([]domain.GraphNode, []domain.Edge)
2930
nodes = append(nodes, domain.GraphNode{Ref: ref, Status: rawNodes[i].Status})
3031
}
3132
var edges []domain.Edge
32-
seen := map[domain.Edge]struct{}{}
33+
type pair struct{ from, to domain.Ref }
34+
at := map[pair]int{}
3335
for i := range rawEdges {
3436
from, okF := parseMarkRef(rawEdges[i].From)
3537
to, okT := parseMarkRef(rawEdges[i].To)
3638
if !okF || !okT {
3739
continue
3840
}
39-
e := domain.Edge{From: from, To: to, Type: domain.EdgeReference}
40-
if _, dup := seen[e]; dup {
41+
p := pair{from, to}
42+
if j, dup := at[p]; dup {
43+
if edges[j].Rel == "" {
44+
edges[j].Rel = rawEdges[i].Rel
45+
}
4146
continue
4247
}
43-
seen[e] = struct{}{}
44-
edges = append(edges, e)
48+
at[p] = len(edges)
49+
edges = append(edges, domain.Edge{From: from, To: to, Type: domain.EdgeReference, Rel: rawEdges[i].Rel})
4550
}
4651
return nodes, edges
4752
}

internal/adapter/outbound/graphexport/graphexport_test.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ var wantEdges = []domain.Edge{
6060
{From: domain.Ref{World: "world-a.svc:6309", Path: "/guide.md"}, To: domain.Ref{World: "wiki.example.org", Path: "/notes.md"}, Type: domain.EdgeReference},
6161
}
6262

63+
// The enriched fixture's plain and rel-typed rows for the same pair collapse
64+
// to one edge that keeps the predicate.
65+
var wantEnrichedEdges = []domain.Edge{
66+
{From: domain.Ref{World: "root.svc:6309", Path: "/index.md"}, To: domain.Ref{World: "world-a.svc:6309", Path: "/guide.md"}, Type: domain.EdgeReference, Rel: "supersedes"},
67+
{From: domain.Ref{World: "world-a.svc:6309", Path: "/guide.md"}, To: domain.Ref{World: "wiki.example.org", Path: "/notes.md"}, Type: domain.EdgeReference},
68+
}
69+
6370
func TestParseGraphExportLegacy(t *testing.T) {
6471
nodes, edges := Parser{}.ParseGraphExport(legacyExport)
6572
if !reflect.DeepEqual(nodes, wantNodes) {
@@ -79,8 +86,8 @@ func TestParseGraphExportEnriched(t *testing.T) {
7986
if !reflect.DeepEqual(nodes, wantNodes) {
8087
t.Errorf("nodes = %+v, want %+v (edge rows must not become nodes)", nodes, wantNodes)
8188
}
82-
if !reflect.DeepEqual(edges, wantEdges) {
83-
t.Errorf("edges = %+v, want %+v (rel duplicate collapsed)", edges, wantEdges)
89+
if !reflect.DeepEqual(edges, wantEnrichedEdges) {
90+
t.Errorf("edges = %+v, want %+v (pair collapsed, predicate kept)", edges, wantEnrichedEdges)
8491
}
8592
}
8693

internal/core/domain/document.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,12 +152,15 @@ const (
152152
// The floor renders these between world clusters (and to portal nodes); they
153153
// come from the durable hub graph export unioned with the R3 observed-links map.
154154
// Type lets the graph overlay draw references and drop/fade containment without
155-
// recomputing. Edge stays comparable (Type is a string), so it remains a map
156-
// key for the dedup sets in worldEdges/intraWorldEdges.
155+
// recomputing. Rel is the optional declared predicate of a typed relation
156+
// (rel-<predicate> publisher metadata, e.g. "supersedes"); "" for a plain body
157+
// link. Edge stays comparable, so it remains a map key for the dedup sets in
158+
// worldEdges/intraWorldEdges.
157159
type Edge struct {
158160
From Ref
159161
To Ref
160162
Type EdgeType
163+
Rel string
161164
}
162165

163166
// WorldInfo is one world of the universe: a mark_worlds row in broker mode,

internal/core/service/worldmap.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,10 @@ func topDir(path string) string {
228228
// own name. Hub edges are keyed by host, observed edges by world name; host2name
229229
// joins the former. Deduped and sorted for a stable, cacheable render.
230230
func intraWorldEdges(world string, host2name map[string]string, all []domain.Edge, labeled map[string]bool) []domain.Edge {
231-
seen := map[domain.Edge]struct{}{}
231+
// Dedup by remapped edge sans Rel; a pair keeps the first declared
232+
// predicate it sees (a hub rel edge collapsing with its plain observed
233+
// twin must not lose the relation).
234+
at := map[domain.Edge]int{}
232235
var out []domain.Edge
233236
for _, e := range all {
234237
if !worldMember(e.From.World, world, host2name) || !worldMember(e.To.World, world, host2name) {
@@ -242,10 +245,14 @@ func intraWorldEdges(world string, host2name map[string]string, all []domain.Edg
242245
To: domain.Ref{World: world, Path: e.To.Path},
243246
Type: e.Type,
244247
}
245-
if _, dup := seen[ce]; dup {
248+
if i, dup := at[ce]; dup {
249+
if out[i].Rel == "" {
250+
out[i].Rel = e.Rel
251+
}
246252
continue
247253
}
248-
seen[ce] = struct{}{}
254+
at[ce] = len(out)
255+
ce.Rel = e.Rel
249256
out = append(out, ce)
250257
}
251258
sort.Slice(out, func(i, j int) bool {

internal/core/service/worldmap_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,3 +283,19 @@ func TestWorldMapDegradesOnReadError(t *testing.T) {
283283
}
284284
}
285285
}
286+
287+
// A hub rel edge (host-keyed, remapped via host2name) collapsing with its
288+
// plain observed twin (already world-name form) keeps the predicate.
289+
func TestIntraWorldEdgesKeepsRel(t *testing.T) {
290+
labeled := map[string]bool{"/a.md": true, "/b.md": true}
291+
host2name := map[string]string{"w.w.svc:6309": "w"}
292+
all := []domain.Edge{
293+
{From: domain.Ref{World: "w", Path: "/a.md"}, To: domain.Ref{World: "w", Path: "/b.md"}, Type: domain.EdgeReference},
294+
{From: domain.Ref{World: "w.w.svc:6309", Path: "/a.md"}, To: domain.Ref{World: "w.w.svc:6309", Path: "/b.md"}, Type: domain.EdgeReference, Rel: "supersedes"},
295+
}
296+
out := intraWorldEdges("w", host2name, all, labeled)
297+
want := []domain.Edge{{From: domain.Ref{World: "w", Path: "/a.md"}, To: domain.Ref{World: "w", Path: "/b.md"}, Type: domain.EdgeReference, Rel: "supersedes"}}
298+
if !reflect.DeepEqual(out, want) {
299+
t.Errorf("edges = %+v, want %+v (predicate must survive remap and pair collapse)", out, want)
300+
}
301+
}

0 commit comments

Comments
 (0)