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 // Standalone toggles the inline :root palette in graph_svg.tmpl. Set to // true on ?download=svg responses (no outer HTML page providing vars); // false when the SVG is embedded inside the layout chrome. Standalone bool } 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"`) payload.Standalone = true 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, r, "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 }