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
This commit is contained in:
mAi
2026-05-15 19:06:57 +02:00
parent b10ecf1e85
commit 3901a1888e
10 changed files with 951 additions and 0 deletions

View File

@@ -375,6 +375,31 @@ A single landing surface at `/dashboard` that aggregates open work and recent ac
**Out of scope for 3e**: stale-projects card (3f), real-time updates, full per-section pagination, dashboard-as-root-landing. Tree at `/` stays the default surface; nav bar adds a "dashboard" link so m chooses when to switch.
## Graph view (Phase 3f)
A read-only top-down DAG render of every projax item at `/graph`, server-rendered inline SVG — no client-side layout library, no Excalidraw file. Trade-offs: m gets a single page that prints, downloads, and reflows in a regular browser; no drag-to-rearrange (read-only is enough for the daily glance).
**Layout** (in `internal/graph`):
- `LayerByLongestPath(nodes)` → each node's layer is `max(layer(parent)) + 1`, so a multi-parent item like `paliad` (under both `work` and `dev`) sits below whichever lineage is longer. Roots are layer 0. Depth-capped at 64 to bail loudly on cycles (the schema trigger already forbids cycles on write).
- `OrderInLayer(layers)` — alphabetical by slug inside each layer for deterministic rendering. No barycenter / crossing-minimisation pass — at m's scale (≤ a few hundred items) the readability cost is negligible.
- `Compute(nodes, opts)` returns positions + edges + canvas size. Pure-Go, no external deps. Unit-tested with multi-parent, longest-path-wins, sort, and cycle-guard fixtures.
**Node styling**:
- 130×44 px box per item.
- Border colour = management mode: `mai` blue, `self` green, `external` orange, mixed dashed purple, unmanaged grey.
- Box opacity = status: active 1.0, done 0.6, archived 0.3.
- Slug as the main label; first three tags rendered as small pills along the bottom (`+N` overflow); `×N` badge top-right for multi-parent items.
- `<title>` element gives a hover tooltip with title + status + management.
- Each node wrapped in an `<a href="/i/{path}">` so a click navigates to the detail page.
**Filter chips**: same `tree_filter.go` URL params (`q`, `tag`, `mgmt`, `status`, `has`). Default behaviour is to *dim* non-matching nodes (opacity 0.15) so the structural relationships stay visible. `?isolate=1` switches to hide-non-matching mode and drops every edge whose endpoint is hidden.
**Print + download**: SVG is inline so the browser's "Print" produces a real vector page. `?download=svg` serves the raw SVG with `Content-Disposition: attachment; filename="projax-graph.svg"` — useful for stashing a snapshot in slides or in mBrian.
**Out of scope for 3f**: editable layout (drag-to-rearrange), Excalidraw file export, auto-refresh on item changes.
## 9. Phase-1 deliverable checklist
- [ ] `projax.items` + `projax.item_links` migrations in `db/migrations/`

255
internal/graph/layout.go Normal file
View File

@@ -0,0 +1,255 @@
// Package graph computes layered top-down DAG layouts for the /graph view.
// It is intentionally minimal — pure Go, no external deps — and tuned for
// m's scale (≤ a few hundred items, single render per request).
//
// Algorithm: longest-path-from-root layering, then deterministic in-layer
// ordering by slug. Positions are assigned on a fixed grid with configurable
// node size and gaps. Multi-parent items resolve to the *maximum* layer
// of any of their parents + 1, so an item that surfaces under both `work`
// and `dev` sits below whichever lineage is longer.
package graph
import (
"fmt"
"sort"
)
// Node is the minimal item shape the layout needs. The web handler builds
// these from store.Item; this package never reaches into store/web so it
// stays independently testable.
type Node struct {
ID string
Slug string
ParentIDs []string
}
// Pos is a node's computed position in the rendered SVG. Coordinates are in
// the SVG's user-units (pixels at 1:1 viewBox).
type Pos struct {
X, Y float64
Layer int
Order int // 0-based position within the layer
}
// Edge is a parent→child edge with the four points needed for a smooth
// cubic Bézier: (Px,Py) source center-bottom, (Cx,Cy) target center-top,
// plus two control points laid out by the renderer.
type Edge struct {
ParentID string
ChildID string
SourceX, SourceY float64
TargetX, TargetY float64
}
// Opts shapes the layout grid.
type Opts struct {
NodeWidth float64 // default 120
NodeHeight float64 // default 40
HGap float64 // horizontal gap between sibling nodes (default 24)
VGap float64 // vertical gap between layers (default 80)
MarginX float64 // left/right canvas margin (default 24)
MarginY float64 // top/bottom canvas margin (default 24)
}
// applyDefaults fills zeroed Opts fields.
func (o *Opts) applyDefaults() {
if o.NodeWidth == 0 {
o.NodeWidth = 120
}
if o.NodeHeight == 0 {
o.NodeHeight = 40
}
if o.HGap == 0 {
o.HGap = 24
}
if o.VGap == 0 {
o.VGap = 80
}
if o.MarginX == 0 {
o.MarginX = 24
}
if o.MarginY == 0 {
o.MarginY = 24
}
}
// Layout is the rendered output: per-node positions + a flat list of edges
// + the total canvas size.
type Layout struct {
Positions map[string]Pos
Edges []Edge
CanvasWidth float64
CanvasHeight float64
Layers [][]string // node IDs per layer, top-down
}
// LayerByLongestPath assigns each node a layer index. Roots (no parents) sit
// at layer 0; every other node sits at `max(layer(parent) for parent in
// parents) + 1`. Cycle-safe by depth-cap (if a cycle exists the trigger
// guard already rejected it on write, but be paranoid in tests too).
func LayerByLongestPath(nodes []Node) (map[string]int, error) {
byID := make(map[string]Node, len(nodes))
for _, n := range nodes {
byID[n.ID] = n
}
layer := make(map[string]int, len(nodes))
const maxDepth = 64
var visit func(id string, depth int) (int, error)
visit = func(id string, depth int) (int, error) {
if depth > maxDepth {
return 0, fmt.Errorf("graph: depth exceeds %d at %s (cycle?)", maxDepth, id)
}
if v, ok := layer[id]; ok {
return v, nil
}
n, ok := byID[id]
if !ok {
// Parent referenced but not in input — treat as a synthetic root.
layer[id] = 0
return 0, nil
}
if len(n.ParentIDs) == 0 {
layer[id] = 0
return 0, nil
}
best := -1
for _, pid := range n.ParentIDs {
pl, err := visit(pid, depth+1)
if err != nil {
return 0, err
}
if pl+1 > best {
best = pl + 1
}
}
layer[id] = best
return best, nil
}
for _, n := range nodes {
if _, err := visit(n.ID, 0); err != nil {
return nil, err
}
}
return layer, nil
}
// OrderInLayer groups nodes by layer index and sorts each layer
// alphabetically by slug for stable rendering.
func OrderInLayer(nodes []Node, layers map[string]int) [][]string {
if len(layers) == 0 {
return nil
}
max := 0
for _, l := range layers {
if l > max {
max = l
}
}
byID := make(map[string]Node, len(nodes))
for _, n := range nodes {
byID[n.ID] = n
}
out := make([][]string, max+1)
for id, l := range layers {
out[l] = append(out[l], id)
}
for i := range out {
sort.Slice(out[i], func(a, b int) bool {
return byID[out[i][a]].Slug < byID[out[i][b]].Slug
})
}
return out
}
// Compute produces a full Layout from raw nodes. Pure function — callers can
// reuse it from handler tests or scripts without HTTP plumbing.
func Compute(nodes []Node, opts Opts) (*Layout, error) {
opts.applyDefaults()
if len(nodes) == 0 {
return &Layout{
Positions: map[string]Pos{},
CanvasWidth: opts.NodeWidth + 2*opts.MarginX,
CanvasHeight: opts.NodeHeight + 2*opts.MarginY,
}, nil
}
layers, err := LayerByLongestPath(nodes)
if err != nil {
return nil, err
}
ordered := OrderInLayer(nodes, layers)
widest := 0
for _, layer := range ordered {
if len(layer) > widest {
widest = len(layer)
}
}
canvasW := opts.MarginX*2 + float64(widest)*opts.NodeWidth + float64(widest-1)*opts.HGap
if widest <= 1 {
canvasW = opts.MarginX*2 + opts.NodeWidth
}
positions := make(map[string]Pos, len(nodes))
byID := make(map[string]Node, len(nodes))
for _, n := range nodes {
byID[n.ID] = n
}
for li, layer := range ordered {
rowW := float64(len(layer))*opts.NodeWidth + float64(len(layer)-1)*opts.HGap
if len(layer) <= 1 {
rowW = opts.NodeWidth
}
startX := (canvasW - rowW) / 2
for i, id := range layer {
x := startX + float64(i)*(opts.NodeWidth+opts.HGap)
y := opts.MarginY + float64(li)*(opts.NodeHeight+opts.VGap)
positions[id] = Pos{X: x, Y: y, Layer: li, Order: i}
}
}
canvasH := opts.MarginY*2 + float64(len(ordered))*opts.NodeHeight + float64(len(ordered)-1)*opts.VGap
if len(ordered) <= 1 {
canvasH = opts.MarginY*2 + opts.NodeHeight
}
// Edges: one per (parent, child) pair, source = parent center-bottom,
// target = child center-top. Multi-parent items emit one edge per parent.
var edges []Edge
for _, n := range nodes {
cp, ok := positions[n.ID]
if !ok {
continue
}
for _, pid := range n.ParentIDs {
pp, ok := positions[pid]
if !ok {
continue
}
edges = append(edges, Edge{
ParentID: pid,
ChildID: n.ID,
SourceX: pp.X + opts.NodeWidth/2,
SourceY: pp.Y + opts.NodeHeight,
TargetX: cp.X + opts.NodeWidth/2,
TargetY: cp.Y,
})
}
}
// Stable edge order for deterministic rendering.
sort.Slice(edges, func(i, j int) bool {
if edges[i].ParentID != edges[j].ParentID {
return edges[i].ParentID < edges[j].ParentID
}
return edges[i].ChildID < edges[j].ChildID
})
return &Layout{
Positions: positions,
Edges: edges,
CanvasWidth: canvasW,
CanvasHeight: canvasH,
Layers: ordered,
}, nil
}

View File

@@ -0,0 +1,133 @@
package graph
import (
"testing"
)
func TestLayerByLongestPathRootsAndChildren(t *testing.T) {
// Simple 2-layer tree:
// work, dev (roots)
// foo (under dev), bar (under work)
nodes := []Node{
{ID: "w", Slug: "work"},
{ID: "d", Slug: "dev"},
{ID: "foo", Slug: "foo", ParentIDs: []string{"d"}},
{ID: "bar", Slug: "bar", ParentIDs: []string{"w"}},
}
layers, err := LayerByLongestPath(nodes)
if err != nil {
t.Fatalf("LayerByLongestPath: %v", err)
}
for id, want := range map[string]int{"w": 0, "d": 0, "foo": 1, "bar": 1} {
if layers[id] != want {
t.Errorf("layer[%s] = %d, want %d", id, layers[id], want)
}
}
}
// Multi-parent: paliad under both work and dev. Its layer must be 1.
func TestLayerByLongestPathMultiParent(t *testing.T) {
nodes := []Node{
{ID: "w", Slug: "work"},
{ID: "d", Slug: "dev"},
{ID: "p", Slug: "paliad", ParentIDs: []string{"w", "d"}},
}
layers, err := LayerByLongestPath(nodes)
if err != nil {
t.Fatalf("LayerByLongestPath: %v", err)
}
if layers["p"] != 1 {
t.Errorf("multi-parent paliad layer = %d, want 1", layers["p"])
}
}
// A grandchild whose grandparent is two hops up should land at layer 2 —
// LongestPath, not shortest path.
func TestLayerByLongestPathRespectsLongerPath(t *testing.T) {
// Graph:
// r0 (root)
// ├── a
// │ └── x (depth-2 via a)
// └── x (also direct child via r0 — would be depth-1)
// LongestPath → x should be at layer 2.
nodes := []Node{
{ID: "r0", Slug: "r0"},
{ID: "a", Slug: "a", ParentIDs: []string{"r0"}},
{ID: "x", Slug: "x", ParentIDs: []string{"r0", "a"}},
}
layers, _ := LayerByLongestPath(nodes)
if layers["x"] != 2 {
t.Errorf("longest-path: x = %d, want 2", layers["x"])
}
}
// OrderInLayer must sort by slug for deterministic rendering.
func TestOrderInLayerSortsBySlug(t *testing.T) {
nodes := []Node{
{ID: "1", Slug: "bravo"},
{ID: "2", Slug: "alpha"},
{ID: "3", Slug: "charlie"},
}
layers := map[string]int{"1": 0, "2": 0, "3": 0}
out := OrderInLayer(nodes, layers)
if len(out) != 1 || len(out[0]) != 3 {
t.Fatalf("OrderInLayer output shape unexpected: %v", out)
}
want := []string{"2", "1", "3"} // alpha, bravo, charlie
for i, id := range want {
if out[0][i] != id {
t.Errorf("layer[0][%d] = %s, want %s (slug order)", i, out[0][i], id)
}
}
}
func TestComputeProducesEdgesAndPositions(t *testing.T) {
nodes := []Node{
{ID: "w", Slug: "work"},
{ID: "d", Slug: "dev"},
{ID: "p", Slug: "paliad", ParentIDs: []string{"w", "d"}},
}
out, err := Compute(nodes, Opts{})
if err != nil {
t.Fatalf("Compute: %v", err)
}
if len(out.Positions) != 3 {
t.Errorf("expected 3 positions, got %d", len(out.Positions))
}
if len(out.Edges) != 2 {
t.Errorf("expected 2 edges (paliad↔work + paliad↔dev), got %d", len(out.Edges))
}
// Paliad sits below both work and dev → its Y should exceed both parents'.
pp := out.Positions["p"]
if !(pp.Y > out.Positions["w"].Y && pp.Y > out.Positions["d"].Y) {
t.Errorf("paliad Y=%.1f, expected below work=%.1f and dev=%.1f", pp.Y, out.Positions["w"].Y, out.Positions["d"].Y)
}
// Edges should connect parent center-bottom to child center-top.
for _, e := range out.Edges {
if e.SourceY >= e.TargetY {
t.Errorf("edge %s→%s: source Y=%.1f should be above target Y=%.1f", e.ParentID, e.ChildID, e.SourceY, e.TargetY)
}
}
}
func TestComputeEmptyInputDoesNotPanic(t *testing.T) {
out, err := Compute(nil, Opts{})
if err != nil {
t.Fatalf("Compute(nil): %v", err)
}
if out == nil || len(out.Positions) != 0 {
t.Fatalf("expected empty layout, got %+v", out)
}
}
// Cycle guard: depth-cap returns error rather than blowing the stack.
func TestLayerByLongestPathCycleErrors(t *testing.T) {
// a -> b -> a (cycle). LayerByLongestPath should bail out cleanly.
nodes := []Node{
{ID: "a", Slug: "a", ParentIDs: []string{"b"}},
{ID: "b", Slug: "b", ParentIDs: []string{"a"}},
}
if _, err := LayerByLongestPath(nodes); err == nil {
t.Fatalf("expected error on cycle, got nil")
}
}

238
web/graph.go Normal file
View File

@@ -0,0 +1,238 @@
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
}

132
web/graph_test.go Normal file
View File

@@ -0,0 +1,132 @@
package web_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// TestGraphPageRenders proves GET /graph returns an SVG containing every
// seeded root + the filter chip strip and the legend.
func TestGraphPageRenders(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, body := get(t, h, "/graph")
if code != 200 {
t.Fatalf("GET /graph → %d body=%s", code, body)
}
for _, want := range []string{
`<svg`,
`class="gnode`,
`graph-canvas`,
`graph-legend`,
`>work<`,
`>dev<`,
} {
if !strings.Contains(body, want) {
t.Errorf("graph page missing %q", want)
}
}
}
// TestGraphFilterDimsNonMatching: ?tag=nonexistent should dim every node
// (class="dimmed") but never remove them unless isolate=1.
func TestGraphFilterDimsNonMatching(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
// Use a definitely-unused tag to force every node to mismatch.
code, body := get(t, h, "/graph?tag=ZZZZ-unused-tag")
if code != 200 {
t.Fatalf("GET /graph?tag=ZZZ → %d", code)
}
if !strings.Contains(body, "dimmed") {
t.Errorf("expected at least one dimmed node when filter matches nothing")
}
// Body should still contain every root (graph structure preserved).
if !strings.Contains(body, ">work<") || !strings.Contains(body, ">dev<") {
t.Errorf("expected dim-mode to keep every node visible (root nodes missing)")
}
}
// TestGraphIsolateHidesNonMatching: ?isolate=1 + a filter should remove
// non-matching nodes from the rendered SVG.
func TestGraphIsolateHidesNonMatching(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Seed a unique tag on one item so the filter has a known target.
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slug := "graph-iso-" + stamp
tag := "graphiso" + stamp
var dev string
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
t.Fatalf("dev: %v", err)
}
var id string
if err := pool.QueryRow(ctx,
`insert into projax.items (kind, title, slug, parent_ids, tags)
values (array['project']::text[], 'iso', $1, ARRAY[$2]::uuid[], ARRAY[$3]::text[])
returning id`,
slug, dev, tag,
).Scan(&id); err != nil {
t.Fatalf("seed: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
code, body := get(t, h, "/graph?tag="+tag+"&isolate=1")
if code != 200 {
t.Fatalf("GET /graph?isolate → %d", code)
}
if !strings.Contains(body, ">"+slug+"<") {
t.Errorf("expected isolated slug %q in body", slug)
}
// A seeded root that does NOT carry this tag must be hidden.
if strings.Contains(body, ">finances<") {
t.Errorf("isolate=1 should hide non-matching nodes — 'finances' still rendered")
}
}
// TestGraphSVGDownload: ?download=svg returns the raw SVG (no HTML chrome)
// with the right Content-Type + attachment header.
func TestGraphSVGDownload(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
req := httptest.NewRequest(http.MethodGet, "/graph?download=svg", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Result().StatusCode != 200 {
t.Fatalf("GET /graph?download=svg → %d", w.Result().StatusCode)
}
if ct := w.Result().Header.Get("Content-Type"); !strings.HasPrefix(ct, "image/svg+xml") {
t.Errorf("Content-Type = %q, want image/svg+xml", ct)
}
if cd := w.Result().Header.Get("Content-Disposition"); !strings.Contains(cd, "attachment") {
t.Errorf("Content-Disposition = %q, want attachment", cd)
}
bodyBytes, _ := io.ReadAll(w.Result().Body)
body := string(bodyBytes)
if !strings.HasPrefix(strings.TrimSpace(body), "<svg") {
t.Errorf("download body should start with <svg, got: %s", body[:min2(80, len(body))])
}
if strings.Contains(body, "<html") {
t.Errorf("download body should be bare SVG, not HTML")
}
}
func min2(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -58,6 +58,9 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
}
return false
},
"addF": func(a, b any) float64 { return toFloat(a) + toFloat(b) },
"subF": func(a, b any) float64 { return toFloat(a) - toFloat(b) },
"mulF": func(a, b any) float64 { return toFloat(a) * toFloat(b) },
"tagToggleURL": func(active []string, tag string, isActive bool) string {
next := []string{}
if isActive {
@@ -135,6 +138,23 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
}
pages["login"] = loginTmpl
// Graph page (layout chrome + SVG body) and a standalone SVG entry for
// the ?download=svg path.
graphTmpl, err := template.New("graph").Funcs(funcs).ParseFS(templatesFS,
"templates/layout.tmpl",
"templates/graph.tmpl",
"templates/graph_svg.tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse graph: %w", err)
}
pages["graph"] = graphTmpl
graphSVG, err := template.New("graph_svg").Funcs(funcs).ParseFS(templatesFS, "templates/graph_svg.tmpl")
if err != nil {
return nil, fmt.Errorf("parse graph_svg: %w", err)
}
pages["graph_svg"] = graphSVG
// Dashboard page + its section fragment.
dashTmpl, err := template.New("dashboard").Funcs(funcs).ParseFS(templatesFS,
"templates/layout.tmpl",
@@ -197,6 +217,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("POST /new", s.handleNewSubmit)
mux.HandleFunc("GET /admin/classify", s.handleClassify)
mux.HandleFunc("GET /dashboard", s.handleDashboard)
mux.HandleFunc("GET /graph", s.handleGraph)
mux.HandleFunc("POST /dashboard/task/done", s.handleDashboardTaskDone)
mux.HandleFunc("GET /admin/bulk", s.handleBulk)
mux.HandleFunc("POST /admin/bulk/apply", s.handleBulkApply)
@@ -648,6 +669,25 @@ func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
s.Logger.Error("handler", "path", r.URL.Path, "err", err)
}
// toFloat coerces template numeric inputs (int, int64, float, etc.) to
// float64 so the SVG template's coordinate math composes without per-call
// type juggling.
func toFloat(v any) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
case int32:
return float64(x)
}
return 0
}
// logging wraps the mux with a tiny access log.
func logging(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@@ -217,3 +217,21 @@ table.bulk .chip-add input { padding: 1px 4px; font-size: 0.85em; width: 7em; }
background: var(--bg-alt); color: var(--muted); border: 1px solid var(--border);
}
.dashboard .issue-row .upd { font-size: 0.8em; }
/* --- /graph --- */
.graph-canvas { overflow: auto; border: 1px solid var(--border); margin-top: 12px; background: #fafafa; }
.graph-svg { display: block; }
#graph-filterbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
#graph-filterbar input[type=search] { width: 22em; }
#graph-filterbar select[multiple] { min-width: 9em; }
#graph-filterbar .download { color: var(--accent); margin-left: auto; }
.graph-legend { margin: 8px 0; font-size: 0.85em; }
.graph-legend .legend-key {
display: inline-block; padding: 2px 8px; border-radius: 3px;
border: 2px solid; margin-right: 4px; font-family: ui-monospace, monospace; font-size: 0.85em;
}
.graph-legend .key-mai { border-color: #2563eb; color: #2563eb; }
.graph-legend .key-self { border-color: #15803d; color: #15803d; }
.graph-legend .key-external { border-color: #ea580c; color: #ea580c; }
.graph-legend .key-mixed { border-color: #7c3aed; color: #7c3aed; border-style: dashed; }
.graph-legend .key-unmanaged { border-color: #9ca3af; color: #9ca3af; }

49
web/templates/graph.tmpl Normal file
View File

@@ -0,0 +1,49 @@
{{define "content"}}
<h1>Graph <small class="muted">{{.Matched}} / {{.Total}} items</small></h1>
<section class="tagbar" id="graph-filterbar">
<form id="graph-filter" class="search"
hx-get="/graph"
hx-target="main"
hx-select="main"
hx-swap="outerHTML"
hx-trigger="change from:select, change from:input[type=checkbox], keyup changed delay:200ms from:input[name=q]"
hx-push-url="true">
<input type="search" name="q" value="{{.Filter.Q}}" placeholder="search…" autocomplete="off">
<label>tag&nbsp;
<select name="tag" multiple size="3">
{{$sel := .Filter.Tags}}
{{range .AllTags}}<option value="{{.}}" {{if contains $sel .}}selected{{end}}>{{.}}</option>{{end}}
</select>
</label>
<label>mgmt&nbsp;
<select name="mgmt" multiple size="4">
{{$selM := .Filter.Management}}
<option value="mai" {{if contains $selM "mai"}}selected{{end}}>mai</option>
<option value="self" {{if contains $selM "self"}}selected{{end}}>self</option>
<option value="external" {{if contains $selM "external"}}selected{{end}}>external</option>
<option value="unmanaged"{{if contains $selM "unmanaged"}}selected{{end}}>unmanaged</option>
</select>
</label>
<label class="checkbox">
<input type="checkbox" name="isolate" value="1" {{if .Isolate}}checked{{end}}>
isolate (hide non-matches)
</label>
{{if .Filter.Active}}<a class="clear" href="/graph">clear filters</a>{{end}}
<a class="download" href="/graph?download=svg">download SVG</a>
</form>
</section>
<section class="graph-canvas">
{{template "graph-svg" .P}}
</section>
<section class="graph-legend muted">
<span class="legend-key key-mai">mai</span>
<span class="legend-key key-self">self</span>
<span class="legend-key key-external">external</span>
<span class="legend-key key-mixed">mixed</span>
<span class="legend-key key-unmanaged">unmanaged</span>
· status opacity: active 1.0 · done 0.6 · archived 0.3
</section>
{{end}}

View File

@@ -0,0 +1,60 @@
{{define "graph-svg"}}<svg xmlns="http://www.w3.org/2000/svg"
class="graph-svg"
viewBox="0 0 {{printf "%.0f" .CanvasWidth}} {{printf "%.0f" .CanvasHeight}}"
width="{{printf "%.0f" .CanvasWidth}}" height="{{printf "%.0f" .CanvasHeight}}">
<defs>
<style>
.gnode rect { fill: #fff; stroke-width: 2; }
.gnode.mgmt-mai rect { stroke: #2563eb; }
.gnode.mgmt-self rect { stroke: #15803d; }
.gnode.mgmt-external rect { stroke: #ea580c; }
.gnode.mgmt-mixed rect { stroke: #7c3aed; stroke-dasharray: 4 2; }
.gnode.mgmt-unmanaged rect { stroke: #9ca3af; }
.gnode text.slug { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 12px; fill: #111827; }
.gnode.dimmed { opacity: 0.15; }
.gnode .tag-pill { font-size: 9px; fill: #6b7280; }
.gnode .badge { font-size: 10px; fill: #2563eb; font-weight: 600; }
.gedge { fill: none; stroke: #9ca3af; stroke-width: 1.4; }
.gedge.dimmed { opacity: 0.1; }
</style>
</defs>
<g class="edges">
{{range .Edges}}
{{$dx := 0.0}}
{{$dy := 32.0}}
<path class="gedge" d="M {{printf "%.1f" .SourceX}} {{printf "%.1f" .SourceY}}
C {{printf "%.1f" .SourceX}} {{printf "%.1f" (addF .SourceY $dy)}},
{{printf "%.1f" .TargetX}} {{printf "%.1f" (subF .TargetY $dy)}},
{{printf "%.1f" .TargetX}} {{printf "%.1f" .TargetY}}"/>
{{end}}
</g>
<g class="nodes">
{{$NodeW := .NodeW}}
{{$NodeH := .NodeH}}
{{$isolate := .Isolate}}
{{range .Nodes}}
{{$dim := and (not .Matched) (not $isolate)}}
<a xlink:href="/i/{{.Path}}" href="/i/{{.Path}}">
<g class="gnode mgmt-{{.MgmtClass}} {{if $dim}}dimmed{{end}}"
transform="translate({{printf "%.1f" .Pos.X}} {{printf "%.1f" .Pos.Y}})"
opacity="{{printf "%.2f" .StatusOp}}">
<title>{{.Title}} — {{.Path}} · {{.Status}} · mgmt:{{join "," .Management}}</title>
<rect width="{{printf "%.0f" $NodeW}}" height="{{printf "%.0f" $NodeH}}" rx="6"/>
<text class="slug" x="8" y="18">{{.Slug}}</text>
{{if gt .PathCount 1}}
<text class="badge" x="{{subF $NodeW 22}}" y="14">×{{.PathCount}}</text>
{{end}}
{{$y := subF $NodeH 6}}
{{range $i, $t := .TagsShown}}
<text class="tag-pill" x="{{addF 8 (mulF 40 $i)}}" y="{{printf "%.0f" $y}}">{{$t}}</text>
{{end}}
{{if gt .TagOverflow 0}}
<text class="tag-pill" x="{{addF 8 (mulF 40 (len .TagsShown))}}" y="{{printf "%.0f" $y}}">+{{.TagOverflow}}</text>
{{end}}
</g>
</a>
{{end}}
</g>
</svg>{{end}}

View File

@@ -11,6 +11,7 @@
<nav>
<a href="/" class="brand">projax</a>
<a href="/dashboard">dashboard</a>
<a href="/graph">graph</a>
<a href="/admin/classify">classify orphans</a>
<a href="/admin/bulk">bulk edit</a>
<a href="/admin/caldav">caldav</a>