- 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
239 lines
5.6 KiB
Go
239 lines
5.6 KiB
Go
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
|
||
}
|