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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user