Files
projax/web/graph.go
mAi 3901a1888e feat(phase 3f graph): visual /graph view, server-rendered SVG, layered DAG
- internal/graph package: pure-Go layered top-down DAG layout
  - LayerByLongestPath (multi-parent sits at max(parent-layer)+1)
  - OrderInLayer (slug-sort, deterministic)
  - Compute returns positions + edges + canvas size
  - cycle-safe (depth-cap)
- web/graph.go handler: filter chips reused from tree_filter
  - dim mode default (opacity 0.15 on non-matches)
  - ?isolate=1 hides non-matches + prunes orphaned edges
  - ?download=svg serves raw SVG attachment
- graph_svg.tmpl renders inline SVG: border colour by management
  (mai blue / self green / external orange / mixed dashed purple),
  opacity by status, tag pills, ×N multi-parent badge, click-navigate
- nav adds "graph" link; design.md §"Graph view" documents the surface
- 4 integration tests cover render, dim, isolate, SVG download
- 6 layout unit tests cover layering, ordering, cycle-guard
2026-05-15 19:06:57 +02:00

239 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package web
import (
"net/http"
"github.com/m/projax/internal/graph"
"github.com/m/projax/store"
)
// graphNode is the template-facing shape: position + style hints derived from
// the underlying item's management/status/tags.
type graphNodeView struct {
ID string
Slug string
Title string
Path string
Pos graph.Pos
Tags []string
TagsShown []string // capped to 3 with "+N" overflow logic via TagOverflow
TagOverflow int
Status string
Management []string
MgmtClass string // "mai" | "self" | "external" | "mixed" | "unmanaged"
StatusOp float64 // 1.0 active, 0.6 done, 0.3 archived
PathCount int // ×N badge when > 1
Matched bool // matches the filter (when filter is active)
}
// graphPayload is everything the SVG template needs.
type graphPayload struct {
Nodes []graphNodeView
Edges []graph.Edge
CanvasWidth float64
CanvasHeight float64
Isolate bool // when true, dim-only stays off — non-match nodes hidden entirely
NodeW, NodeH float64
}
func (s *Server) handleGraph(w http.ResponseWriter, r *http.Request) {
items, err := s.Store.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
linkKinds, err := s.linkKindsByItem(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
allTags, err := s.Store.AllTags(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
filter := ParseTreeFilter(r.URL.Query())
isolate := r.URL.Query().Get("isolate") == "1"
// Build layout-input nodes from every live item (the graph deliberately
// shows the full DAG; the filter dims non-matches via opacity unless
// isolate=1 hides them).
nodes := make([]graph.Node, 0, len(items))
for _, it := range items {
nodes = append(nodes, graph.Node{
ID: it.ID,
Slug: it.Slug,
ParentIDs: it.ParentIDs,
})
}
opts := graph.Opts{NodeWidth: 130, NodeHeight: 44, HGap: 28, VGap: 90, MarginX: 40, MarginY: 32}
layout, err := graph.Compute(nodes, opts)
if err != nil {
s.fail(w, r, err)
return
}
// Filter matching: every node carries its match state so the template can
// branch on the dim/isolate behaviour.
byID := make(map[string]*store.Item, len(items))
for _, it := range items {
byID[it.ID] = it
}
var views []graphNodeView
visibleEdges := layout.Edges
for _, it := range items {
pos, ok := layout.Positions[it.ID]
if !ok {
continue
}
matched := filter.Matches(it, linkKinds[it.ID])
if isolate && filter.Active() && !matched {
continue
}
v := graphNodeView{
ID: it.ID,
Slug: it.Slug,
Title: it.Title,
Path: it.PrimaryPath(),
Pos: pos,
Tags: it.Tags,
Status: it.Status,
Management: it.Management,
MgmtClass: managementClass(it.Management),
StatusOp: statusOpacity(it.Status, it.Archived),
PathCount: len(it.Paths),
Matched: matched,
}
if len(it.Tags) > 3 {
v.TagsShown = it.Tags[:3]
v.TagOverflow = len(it.Tags) - 3
} else {
v.TagsShown = it.Tags
}
views = append(views, v)
}
if isolate && filter.Active() {
// Drop edges referencing removed nodes.
visible := map[string]struct{}{}
for _, v := range views {
visible[v.ID] = struct{}{}
}
kept := visibleEdges[:0]
for _, e := range visibleEdges {
if _, ok := visible[e.ParentID]; !ok {
continue
}
if _, ok := visible[e.ChildID]; !ok {
continue
}
kept = append(kept, e)
}
visibleEdges = kept
}
payload := graphPayload{
Nodes: views,
Edges: visibleEdges,
CanvasWidth: layout.CanvasWidth,
CanvasHeight: layout.CanvasHeight,
Isolate: isolate,
NodeW: opts.NodeWidth,
NodeH: opts.NodeHeight,
}
// Download mode: serve raw SVG with attachment headers.
if r.URL.Query().Get("download") == "svg" {
w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="projax-graph.svg"`)
s.renderRaw(w, "graph_svg", payload)
return
}
data := map[string]any{
"Title": "graph",
"P": payload,
"Filter": filter,
"Isolate": isolate,
"AllTags": allTags,
"Total": len(items),
"Matched": countMatches(items, filter, linkKinds),
}
s.render(w, "graph", data)
}
// renderRaw is like render but writes the body to w without the page-layout
// chrome — used for the SVG download path.
func (s *Server) renderRaw(w http.ResponseWriter, name string, data any) {
t, ok := s.pages[name]
if !ok {
http.Error(w, "unknown page: "+name, http.StatusInternalServerError)
return
}
entry := "graph-svg"
if err := t.ExecuteTemplate(w, entry, data); err != nil {
s.Logger.Error("render svg", "page", name, "err", err)
}
}
func managementClass(m []string) string {
hasMai, hasSelf, hasExt := false, false, false
for _, x := range m {
switch x {
case "mai":
hasMai = true
case "self":
hasSelf = true
case "external":
hasExt = true
}
}
count := 0
for _, b := range []bool{hasMai, hasSelf, hasExt} {
if b {
count++
}
}
if count == 0 {
return "unmanaged"
}
if count > 1 {
return "mixed"
}
switch {
case hasMai:
return "mai"
case hasSelf:
return "self"
case hasExt:
return "external"
}
return "unmanaged"
}
func statusOpacity(status string, archived bool) float64 {
if archived {
return 0.3
}
switch status {
case "done":
return 0.6
case "archived":
return 0.3
default:
return 1.0
}
}
func countMatches(items []*store.Item, f TreeFilter, linkKinds map[string]map[string]struct{}) int {
if !f.Active() {
return len(items)
}
n := 0
for _, it := range items {
if f.Matches(it, linkKinds[it.ID]) {
n++
}
}
return n
}