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:
@@ -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
255
internal/graph/layout.go
Normal 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
|
||||
}
|
||||
133
internal/graph/layout_test.go
Normal file
133
internal/graph/layout_test.go
Normal 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
238
web/graph.go
Normal 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
132
web/graph_test.go
Normal 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
49
web/templates/graph.tmpl
Normal 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
|
||||
<select name="tag" multiple size="3">
|
||||
{{$sel := .Filter.Tags}}
|
||||
{{range .AllTags}}<option value="{{.}}" {{if contains $sel .}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>mgmt
|
||||
<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}}
|
||||
60
web/templates/graph_svg.tmpl
Normal file
60
web/templates/graph_svg.tmpl
Normal 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}}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user