diff --git a/cmd/index.go b/cmd/index.go
index a568ec8..40d279c 100644
--- a/cmd/index.go
+++ b/cmd/index.go
@@ -2,7 +2,7 @@ package cmd
import (
"fmt"
- "os"
+ "log/slog"
"github.com/RandomCodeSpace/docsgraphcontext/internal/llm"
"github.com/RandomCodeSpace/docsgraphcontext/internal/pipeline"
@@ -39,11 +39,12 @@ var indexCmd = &cobra.Command{
return fmt.Errorf("llm provider: %w", err)
}
pl := pipeline.New(st, prov, cfg)
- fmt.Fprintln(os.Stderr, "Running Phase 3-4: community detection + summaries...")
+ slog.Info("running Phase 3-4: community detection + summaries")
if err := pl.Finalize(cmd.Context(), indexVerbose); err != nil {
+ slog.Error("finalization failed", "err", err)
return err
}
- fmt.Fprintln(os.Stderr, "Finalization complete.")
+ slog.Info("finalization complete")
return nil
}
@@ -62,11 +63,13 @@ var indexCmd = &cobra.Command{
}
if indexURL != "" {
- fmt.Fprintf(os.Stderr, "Crawling %s (workers=%d)...\n", indexURL, indexWorkers)
+ slog.Info("crawling documentation site", "url", indexURL, "workers", indexWorkers,
+ "max_pages", indexMaxPages, "max_depth", indexMaxDepth)
if err := pl.IndexURL(cmd.Context(), indexURL, opts); err != nil {
+ slog.Error("web indexing failed", "url", indexURL, "err", err)
return err
}
- fmt.Fprintln(os.Stderr, "Web indexing complete.")
+ slog.Info("web indexing complete", "url", indexURL)
return nil
}
@@ -74,11 +77,12 @@ var indexCmd = &cobra.Command{
return fmt.Errorf("path or --url required (or use --finalize)")
}
- fmt.Fprintf(os.Stderr, "Indexing %s (workers=%d)...\n", args[0], indexWorkers)
+ slog.Info("indexing path", "path", args[0], "workers", indexWorkers, "force", indexForce)
if err := pl.IndexPath(cmd.Context(), args[0], opts); err != nil {
+ slog.Error("indexing failed", "path", args[0], "err", err)
return err
}
- fmt.Fprintln(os.Stderr, "Indexing complete.")
+ slog.Info("indexing complete", "path", args[0])
return nil
},
}
diff --git a/cmd/root.go b/cmd/root.go
index 84ddb73..7c162ea 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -2,6 +2,7 @@ package cmd
import (
"fmt"
+ "log/slog"
"os"
"github.com/RandomCodeSpace/docsgraphcontext/internal/config"
@@ -9,8 +10,9 @@ import (
)
var (
- cfgFile string
- cfg *config.Config
+ cfgFile string
+ cfg *config.Config
+ logLevel string
)
var rootCmd = &cobra.Command{
@@ -32,17 +34,32 @@ func Execute() {
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default ~/.docsgraph/config.yaml)")
+ rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "Log level: debug, info, warn, error")
}
func initConfig() {
+ // Set up structured logger
+ var level slog.Level
+ switch logLevel {
+ case "debug":
+ level = slog.LevelDebug
+ case "warn":
+ level = slog.LevelWarn
+ case "error":
+ level = slog.LevelError
+ default:
+ level = slog.LevelInfo
+ }
+ slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})))
+
var err error
cfg, err = config.Load(cfgFile)
if err != nil {
- fmt.Fprintln(os.Stderr, "config error:", err)
+ slog.Error("config error", "err", err)
os.Exit(1)
}
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
- fmt.Fprintln(os.Stderr, "mkdir error:", err)
+ slog.Error("failed to create data directory", "path", cfg.DataDir, "err", err)
os.Exit(1)
}
}
diff --git a/cmd/serve.go b/cmd/serve.go
index d89a3a6..75db79a 100644
--- a/cmd/serve.go
+++ b/cmd/serve.go
@@ -3,6 +3,7 @@ package cmd
import (
"context"
"fmt"
+ "log/slog"
"net"
"net/http"
"os"
@@ -26,7 +27,6 @@ var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start MCP + Web UI server",
RunE: func(cmd *cobra.Command, args []string) error {
- // Override config with CLI flags
if serveHost != "" {
cfg.Server.Host = serveHost
}
@@ -39,11 +39,13 @@ var serveCmd = &cobra.Command{
return fmt.Errorf("open store: %w", err)
}
defer st.Close()
+ slog.Info("store opened", "path", cfg.DBPath())
prov, err := llm.NewProvider(&cfg.LLM)
if err != nil {
return fmt.Errorf("llm provider: %w", err)
}
+ slog.Info("LLM provider initialised", "provider", prov.Name(), "model", prov.ModelID())
emb := embedder.New(prov, cfg.Indexing.BatchSize)
router := api.NewRouter(st, prov, emb, cfg)
@@ -56,27 +58,32 @@ var serveCmd = &cobra.Command{
srv := &http.Server{Handler: router, ReadTimeout: 60 * time.Second, WriteTimeout: 120 * time.Second}
- fmt.Fprintf(os.Stderr, "DocsGraphContext server running on http://%s\n", addr)
- fmt.Fprintf(os.Stderr, " Web UI: http://%s/\n", addr)
- fmt.Fprintf(os.Stderr, " MCP: http://%s/mcp\n", addr)
- fmt.Fprintf(os.Stderr, " API: http://%s/api/\n", addr)
- fmt.Fprintf(os.Stderr, " LLM: %s (%s)\n", prov.Name(), prov.ModelID())
+ slog.Info("server started",
+ "addr", "http://"+addr,
+ "ui", "http://"+addr+"/",
+ "mcp", "http://"+addr+"/mcp",
+ "api", "http://"+addr+"/api/",
+ )
- // Graceful shutdown
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
- fmt.Fprintln(os.Stderr, "server error:", err)
+ slog.Error("server error", "err", err)
}
}()
<-ctx.Done()
- fmt.Fprintln(os.Stderr, "\nShutting down...")
+ slog.Info("shutting down...")
shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
- return srv.Shutdown(shutCtx)
+ if err := srv.Shutdown(shutCtx); err != nil {
+ slog.Error("shutdown error", "err", err)
+ return err
+ }
+ slog.Info("shutdown complete")
+ return nil
},
}
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index 9cc666c..2a77e21 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
+ "log/slog"
"net/http"
"os"
"path/filepath"
@@ -25,7 +26,7 @@ type handlers struct {
cfg *config.Config
// Upload progress tracking
- uploadMu sync.Mutex
+ uploadMu sync.Mutex
jobProgress []string
}
@@ -35,14 +36,17 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
json.NewEncoder(w).Encode(v)
}
-func writeError(w http.ResponseWriter, status int, msg string) {
+func writeError(w http.ResponseWriter, r *http.Request, status int, msg string, err error) {
+ if status >= 500 && err != nil {
+ slog.ErrorContext(r.Context(), "handler error", "path", r.URL.Path, "err", err)
+ }
writeJSON(w, status, map[string]string{"error": msg})
}
func (h *handlers) getStats(w http.ResponseWriter, r *http.Request) {
stats, err := h.store.GetStats(r.Context())
if err != nil {
- writeError(w, 500, err.Error())
+ writeError(w, r, 500, err.Error(), err)
return
}
writeJSON(w, 200, stats)
@@ -56,7 +60,7 @@ func (h *handlers) listDocuments(w http.ResponseWriter, r *http.Request) {
docs, err := h.store.ListDocuments(r.Context(), docType, limit, offset)
if err != nil {
- writeError(w, 500, err.Error())
+ writeError(w, r, 500, err.Error(), err)
return
}
writeJSON(w, 200, docs)
@@ -66,16 +70,16 @@ func (h *handlers) getDocumentVersions(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
doc, err := h.store.GetDocument(r.Context(), id)
if err != nil {
- writeError(w, 500, err.Error())
+ writeError(w, r, 500, err.Error(), err)
return
}
if doc == nil {
- writeError(w, 404, "document not found")
+ writeError(w, r, 404, "document not found", nil)
return
}
versions, err := h.store.GetDocumentVersions(r.Context(), doc.CanonicalOrID())
if err != nil {
- writeError(w, 500, err.Error())
+ writeError(w, r, 500, err.Error(), err)
return
}
writeJSON(w, 200, versions)
@@ -85,11 +89,11 @@ func (h *handlers) getDocument(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
doc, err := h.store.GetDocument(r.Context(), id)
if err != nil {
- writeError(w, 500, err.Error())
+ writeError(w, r, 500, err.Error(), err)
return
}
if doc == nil {
- writeError(w, 404, "document not found")
+ writeError(w, r, 404, "document not found", nil)
return
}
writeJSON(w, 200, doc)
@@ -97,7 +101,7 @@ func (h *handlers) getDocument(w http.ResponseWriter, r *http.Request) {
type searchRequest struct {
Query string `json:"query"`
- Mode string `json:"mode"` // local | global
+ Mode string `json:"mode"` // local | global
TopK int `json:"top_k"`
GraphDepth int `json:"graph_depth"`
CommunityLevel int `json:"community_level"`
@@ -106,11 +110,11 @@ type searchRequest struct {
func (h *handlers) search(w http.ResponseWriter, r *http.Request) {
var req searchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- writeError(w, 400, "invalid JSON")
+ writeError(w, r, 400, "invalid JSON", nil)
return
}
if req.Query == "" {
- writeError(w, 400, "query required")
+ writeError(w, r, 400, "query required", nil)
return
}
if req.TopK <= 0 {
@@ -120,19 +124,21 @@ func (h *handlers) search(w http.ResponseWriter, r *http.Request) {
req.GraphDepth = 2
}
+ slog.InfoContext(r.Context(), "search request", "mode", req.Mode, "query", req.Query, "top_k", req.TopK)
+
ctx := r.Context()
switch req.Mode {
case "global":
result, err := search.GlobalSearch(ctx, h.store, h.embedder, h.provider, req.Query, req.CommunityLevel)
if err != nil {
- writeError(w, 500, err.Error())
+ writeError(w, r, 500, err.Error(), err)
return
}
writeJSON(w, 200, result)
default: // local
result, err := search.LocalSearch(ctx, h.store, h.embedder, req.Query, req.TopK, req.GraphDepth)
if err != nil {
- writeError(w, 500, err.Error())
+ writeError(w, r, 500, err.Error(), err)
return
}
writeJSON(w, 200, result)
@@ -146,14 +152,16 @@ func (h *handlers) graphNeighborhood(w http.ResponseWriter, r *http.Request) {
maxNodes := intQuery(q.Get("max_nodes"), 50)
if name == "" {
- writeError(w, 400, "entity parameter required")
+ writeError(w, r, 400, "entity parameter required", nil)
return
}
+ slog.DebugContext(r.Context(), "graph neighborhood request", "entity", name, "depth", depth)
+
ctx := r.Context()
entity, err := h.store.GetEntityByName(ctx, name)
if err != nil {
- writeError(w, 500, err.Error())
+ writeError(w, r, 500, err.Error(), err)
return
}
if entity == nil {
@@ -163,7 +171,7 @@ func (h *handlers) graphNeighborhood(w http.ResponseWriter, r *http.Request) {
rels, err := h.store.RelationshipsForEntity(ctx, entity.ID, depth)
if err != nil {
- writeError(w, 500, err.Error())
+ writeError(w, r, 500, err.Error(), err)
return
}
@@ -198,6 +206,7 @@ func (h *handlers) graphNeighborhood(w http.ResponseWriter, r *http.Request) {
})
}
+ slog.DebugContext(r.Context(), "graph neighborhood result", "entity", name, "nodes", len(nodes), "edges", len(edges))
writeJSON(w, 200, map[string]any{"nodes": nodes, "edges": edges})
}
@@ -209,7 +218,7 @@ func (h *handlers) listEntities(w http.ResponseWriter, r *http.Request) {
entities, err := h.store.ListEntities(r.Context(), typ, limit, offset)
if err != nil {
- writeError(w, 500, err.Error())
+ writeError(w, r, 500, err.Error(), err)
return
}
writeJSON(w, 200, entities)
@@ -221,7 +230,7 @@ func (h *handlers) listCommunities(w http.ResponseWriter, r *http.Request) {
communities, err := h.store.ListCommunities(r.Context(), level)
if err != nil {
- writeError(w, 500, err.Error())
+ writeError(w, r, 500, err.Error(), err)
return
}
writeJSON(w, 200, communities)
@@ -231,16 +240,16 @@ func (h *handlers) getCommunity(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
comm, err := h.store.GetCommunity(r.Context(), id)
if err != nil {
- writeError(w, 500, err.Error())
+ writeError(w, r, 500, err.Error(), err)
return
}
if comm == nil {
- writeError(w, 404, "community not found")
+ writeError(w, r, 404, "community not found", nil)
return
}
members, err := h.store.CommunityMembers(r.Context(), id)
if err != nil {
- writeError(w, 500, err.Error())
+ writeError(w, r, 500, err.Error(), err)
return
}
writeJSON(w, 200, map[string]any{"community": comm, "members": members})
@@ -248,18 +257,18 @@ func (h *handlers) getCommunity(w http.ResponseWriter, r *http.Request) {
func (h *handlers) upload(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(128 << 20); err != nil {
- writeError(w, 400, "parse form: "+err.Error())
+ writeError(w, r, 400, "parse form: "+err.Error(), nil)
return
}
files := r.MultipartForm.File["files"]
if len(files) == 0 {
- writeError(w, 400, "no files provided")
+ writeError(w, r, 400, "no files provided", nil)
return
}
tmpDir, err := os.MkdirTemp("", "docsgraph-upload-*")
if err != nil {
- writeError(w, 500, err.Error())
+ writeError(w, r, 500, err.Error(), err)
return
}
@@ -268,41 +277,45 @@ func (h *handlers) upload(w http.ResponseWriter, r *http.Request) {
dst := filepath.Join(tmpDir, fh.Filename)
f, err := fh.Open()
if err != nil {
- writeError(w, 500, err.Error())
+ writeError(w, r, 500, err.Error(), err)
return
}
out, err := os.Create(dst)
if err != nil {
f.Close()
- writeError(w, 500, err.Error())
+ writeError(w, r, 500, err.Error(), err)
return
}
_, err = io.Copy(out, f)
f.Close()
out.Close()
if err != nil {
- writeError(w, 500, err.Error())
+ writeError(w, r, 500, err.Error(), err)
return
}
paths = append(paths, dst)
}
jobID := fmt.Sprintf("job-%d", len(h.jobProgress))
+ slog.Info("upload job queued", "job_id", jobID, "files", len(paths))
+
h.uploadMu.Lock()
h.jobProgress = append(h.jobProgress, fmt.Sprintf("queued: %d files", len(paths)))
h.uploadMu.Unlock()
- // Run indexing in background
go func() {
defer os.RemoveAll(tmpDir)
pl := pipeline.New(h.store, h.provider, h.cfg)
for _, p := range paths {
+ slog.Info("upload indexing file", "job_id", jobID, "file", filepath.Base(p))
h.setProgress(jobID, fmt.Sprintf("indexing: %s", filepath.Base(p)))
if err := pl.IndexPath(r.Context(), p, pipeline.IndexOptions{}); err != nil {
+ slog.Error("upload indexing failed", "job_id", jobID, "file", filepath.Base(p), "err", err)
h.setProgress(jobID, fmt.Sprintf("error: %v", err))
return
}
}
+ slog.Info("upload job complete", "job_id", jobID, "files", len(paths))
h.setProgress(jobID, "done")
}()
diff --git a/internal/api/router.go b/internal/api/router.go
index c687fb3..e76b752 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -1,7 +1,9 @@
package api
import (
+ "log/slog"
"net/http"
+ "time"
"github.com/RandomCodeSpace/docsgraphcontext/internal/config"
"github.com/RandomCodeSpace/docsgraphcontext/internal/embedder"
@@ -37,5 +39,40 @@ func NewRouter(st *store.Store, prov llm.Provider, emb *embedder.Embedder, cfg *
// Embedded UI
mux.Handle("/", http.FileServer(http.FS(ui.Assets)))
- return mux
+ return loggingMiddleware(mux)
+}
+
+// loggingMiddleware logs method, path, status code, and duration for every request.
+func loggingMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
+ next.ServeHTTP(rw, r)
+ duration := time.Since(start)
+
+ level := slog.LevelInfo
+ if rw.status >= 500 {
+ level = slog.LevelError
+ } else if rw.status >= 400 {
+ level = slog.LevelWarn
+ }
+
+ slog.Log(r.Context(), level, "http",
+ "method", r.Method,
+ "path", r.URL.Path,
+ "status", rw.status,
+ "duration_ms", duration.Milliseconds(),
+ )
+ })
+}
+
+// responseWriter wraps http.ResponseWriter to capture the status code.
+type responseWriter struct {
+ http.ResponseWriter
+ status int
+}
+
+func (rw *responseWriter) WriteHeader(code int) {
+ rw.status = code
+ rw.ResponseWriter.WriteHeader(code)
}
diff --git a/internal/crawler/crawler.go b/internal/crawler/crawler.go
index 8b58017..c82b938 100644
--- a/internal/crawler/crawler.go
+++ b/internal/crawler/crawler.go
@@ -8,6 +8,7 @@ import (
"encoding/xml"
"fmt"
"io"
+ "log/slog"
"net/http"
"net/url"
"strings"
@@ -53,19 +54,29 @@ func Crawl(ctx context.Context, rootURL string, opts Options) ([]*Page, error) {
// Try sitemap first
var urls []string
if !opts.SkipSitemap {
- urls, _ = discoverSitemap(client, base)
+ urls, err = discoverSitemap(client, base)
+ if err != nil {
+ slog.Debug("sitemap not found, falling back to BFS", "url", rootURL, "reason", err)
+ } else {
+ slog.Info("sitemap discovered", "url", rootURL, "urls", len(urls))
+ }
}
// Fall back to BFS
if len(urls) == 0 {
+ slog.Info("starting BFS crawl", "url", rootURL, "max_pages", opts.MaxPages, "max_depth", opts.MaxDepth)
urls = bfsCrawl(ctx, client, base, opts)
+ slog.Info("BFS crawl complete", "url", rootURL, "pages_found", len(urls))
}
// Cap
if opts.MaxPages > 0 && len(urls) > opts.MaxPages {
+ slog.Debug("capping URLs at max_pages", "total", len(urls), "max_pages", opts.MaxPages)
urls = urls[:opts.MaxPages]
}
+ slog.Info("fetching pages", "count", len(urls), "concurrency", opts.Concurrency)
+
// Fetch pages concurrently
pages := make([]*Page, len(urls))
errs := make([]error, len(urls))
@@ -79,6 +90,9 @@ func Crawl(ctx context.Context, rootURL string, opts Options) ([]*Page, error) {
defer wg.Done()
defer func() { <-sem }()
p, err := wl.LoadURL(pageURL)
+ if err != nil {
+ slog.Debug("failed to fetch page", "url", pageURL, "err", err)
+ }
pages[idx] = p
errs[idx] = err
}(i, u)
@@ -87,11 +101,19 @@ func Crawl(ctx context.Context, rootURL string, opts Options) ([]*Page, error) {
// Collect successful fetches
var result []*Page
+ var fetchErrs int
for i, p := range pages {
if errs[i] == nil && p != nil && strings.TrimSpace(p.Content) != "" {
result = append(result, p)
+ } else if errs[i] != nil {
+ fetchErrs++
}
}
+
+ if fetchErrs > 0 {
+ slog.Warn("some pages failed to fetch", "failed", fetchErrs, "succeeded", len(result))
+ }
+ slog.Info("crawl finished", "url", rootURL, "pages_fetched", len(result))
return result, nil
}
@@ -119,6 +141,7 @@ func discoverSitemap(client *http.Client, base *url.URL) ([]string, error) {
for _, candidate := range candidates {
urls, err := parseSitemap(client, candidate, base)
if err == nil && len(urls) > 0 {
+ slog.Debug("sitemap parsed", "url", candidate, "entries", len(urls))
return urls, nil
}
}
@@ -140,6 +163,7 @@ func parseSitemap(client *http.Client, sitemapURL string, base *url.URL) ([]stri
// Try sitemap index first
var idx sitemapIndex
if err := xml.Unmarshal(body, &idx); err == nil && len(idx.Sitemaps) > 0 {
+ slog.Debug("sitemap index found", "url", sitemapURL, "sub_sitemaps", len(idx.Sitemaps))
var all []string
for _, s := range idx.Sitemaps {
sub, err := parseSitemap(client, s.Loc, base)
@@ -178,6 +202,7 @@ func bfsCrawl(ctx context.Context, client *http.Client, base *url.URL, opts Opti
for len(queue) > 0 && (opts.MaxPages == 0 || len(found) < opts.MaxPages) {
select {
case <-ctx.Done():
+ slog.Debug("BFS crawl cancelled by context", "pages_found", len(found))
return found
default:
}
@@ -196,6 +221,7 @@ func bfsCrawl(ctx context.Context, client *http.Client, base *url.URL, opts Opti
}
links := extractLinks(client, item.u, base)
+ slog.Debug("BFS page links extracted", "url", item.u, "depth", item.depth, "links", len(links))
for _, l := range links {
if !visited[l] {
queue = append(queue, struct {
@@ -211,12 +237,16 @@ func bfsCrawl(ctx context.Context, client *http.Client, base *url.URL, opts Opti
func extractLinks(client *http.Client, pageURL string, base *url.URL) []string {
resp, err := client.Get(pageURL)
if err != nil || resp.StatusCode != http.StatusOK {
+ if err != nil {
+ slog.Debug("failed to fetch page for link extraction", "url", pageURL, "err", err)
+ }
return nil
}
defer resp.Body.Close()
doc, err := html.Parse(resp.Body)
if err != nil {
+ slog.Debug("failed to parse HTML", "url", pageURL, "err", err)
return nil
}
diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go
index 25bb73e..4d6a397 100644
--- a/internal/pipeline/pipeline.go
+++ b/internal/pipeline/pipeline.go
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io/fs"
+ "log/slog"
"os"
"path/filepath"
"strings"
@@ -80,6 +81,8 @@ func (p *Pipeline) IndexPath(ctx context.Context, path string, opts IndexOptions
return fmt.Errorf("no supported files found in %s", path)
}
+ slog.Info("indexing files", "path", path, "count", len(files), "workers", workers)
+
bar := progressbar.NewOptions(len(files),
progressbar.OptionSetDescription("Indexing"),
progressbar.OptionShowCount(),
@@ -100,6 +103,7 @@ func (p *Pipeline) IndexPath(ctx context.Context, path string, opts IndexOptions
defer bar.Add(1)
if err := p.indexFile(ctx, filePath, opts); err != nil {
+ slog.Warn("failed to index file", "path", filePath, "err", err)
mu.Lock()
errs = append(errs, fmt.Sprintf("%s: %v", filePath, err))
mu.Unlock()
@@ -109,8 +113,10 @@ func (p *Pipeline) IndexPath(ctx context.Context, path string, opts IndexOptions
wg.Wait()
if len(errs) > 0 {
+ slog.Error("indexing finished with errors", "failed", len(errs), "total", len(files))
return fmt.Errorf("indexing errors:\n%s", strings.Join(errs, "\n"))
}
+ slog.Info("indexing complete", "files", len(files))
return nil
}
@@ -121,7 +127,7 @@ func (p *Pipeline) IndexURL(ctx context.Context, rootURL string, opts IndexOptio
workers = p.cfg.Indexing.Workers
}
- fmt.Fprintf(os.Stderr, "Crawling %s...\n", rootURL)
+ slog.Info("crawling site", "url", rootURL)
pages, err := crawler.Crawl(ctx, rootURL, crawler.Options{
MaxPages: opts.MaxPages,
MaxDepth: opts.MaxDepth,
@@ -134,7 +140,7 @@ func (p *Pipeline) IndexURL(ctx context.Context, rootURL string, opts IndexOptio
if len(pages) == 0 {
return fmt.Errorf("no pages found at %s", rootURL)
}
- fmt.Fprintf(os.Stderr, "Found %d pages — indexing...\n", len(pages))
+ slog.Info("crawl complete, indexing pages", "url", rootURL, "pages", len(pages))
bar := progressbar.NewOptions(len(pages),
progressbar.OptionSetDescription("Indexing pages"),
@@ -155,6 +161,7 @@ func (p *Pipeline) IndexURL(ctx context.Context, rootURL string, opts IndexOptio
defer func() { <-sem }()
defer bar.Add(1)
if err := p.indexRawDoc(ctx, doc, opts); err != nil {
+ slog.Warn("failed to index page", "url", doc.Path, "err", err)
mu.Lock()
errs = append(errs, fmt.Sprintf("%s: %v", doc.Path, err))
mu.Unlock()
@@ -164,14 +171,15 @@ func (p *Pipeline) IndexURL(ctx context.Context, rootURL string, opts IndexOptio
wg.Wait()
if len(errs) > 0 {
+ slog.Error("web indexing finished with errors", "failed", len(errs), "total", len(pages))
return fmt.Errorf("indexing errors:\n%s", strings.Join(errs, "\n"))
}
+ slog.Info("web indexing complete", "url", rootURL, "pages", len(pages))
return nil
}
// indexFile processes a single file through Phases 1-2.
func (p *Pipeline) indexFile(ctx context.Context, path string, opts IndexOptions) error {
- // Hash for dedup
data, err := os.ReadFile(path)
if err != nil {
return err
@@ -179,15 +187,16 @@ func (p *Pipeline) indexFile(ctx context.Context, path string, opts IndexOptions
h := sha256.Sum256(data)
hash := hex.EncodeToString(h[:])
- // Incremental versioning: look up the latest version at this path.
existing, err := p.store.GetDocumentByPath(ctx, path)
if err != nil {
return err
}
if existing != nil && existing.FileHash == hash && !opts.Force {
- return nil // unchanged — skip
+ slog.Debug("skipping unchanged file", "path", path)
+ return nil
}
if existing != nil {
+ slog.Info("superseding existing document version", "path", path, "old_version", existing.Version)
if err := p.store.SupersedeDocument(ctx, existing.ID); err != nil {
return fmt.Errorf("supersede old version: %w", err)
}
@@ -197,9 +206,7 @@ func (p *Pipeline) indexFile(ctx context.Context, path string, opts IndexOptions
doc, err := loader.Load(path)
if err != nil {
if errors.Is(err, loader.ErrBinaryFile) {
- if opts.Verbose {
- fmt.Fprintf(os.Stderr, " skip binary: %s\n", path)
- }
+ slog.Debug("skipping binary file", "path", path)
return nil
}
return fmt.Errorf("load: %w", err)
@@ -209,9 +216,12 @@ func (p *Pipeline) indexFile(ctx context.Context, path string, opts IndexOptions
chunks := p.chunker.Split(doc.Content)
if len(chunks) == 0 {
+ slog.Warn("no chunks produced for file, skipping", "path", path)
return nil
}
+ slog.Debug("indexing file", "path", path, "version", nextVersion, "chunks", len(chunks), "doc_type", doc.DocType)
+
docID := uuid.New().String()
if err := p.store.UpsertDocument(ctx, &store.Document{
ID: docID,
@@ -243,18 +253,17 @@ func (p *Pipeline) indexFile(ctx context.Context, path string, opts IndexOptions
}
}
- // Phase 1c: Batch insert chunks
if err := p.store.BatchInsertChunks(ctx, storeChunks); err != nil {
return fmt.Errorf("batch insert chunks: %w", err)
}
- // Phase 1d: Embed chunks (concurrent batches internally)
+ // Phase 1c: Embed chunks
vecs, err := p.embedder.EmbedTexts(ctx, texts)
if err != nil {
return fmt.Errorf("embed: %w", err)
}
+ slog.Debug("chunks embedded", "path", path, "chunks", len(vecs))
- // Phase 1e: Batch store embeddings
if err := p.store.BatchUpsertEmbeddings(ctx, p.provider.ModelID(), chunkIDs, vecs); err != nil {
return fmt.Errorf("batch store embeddings: %w", err)
}
@@ -291,10 +300,9 @@ func (p *Pipeline) indexFile(ctx context.Context, path string, opts IndexOptions
wg2.Wait()
- // Log non-fatal errors
- for _, e := range []error{graphErr, claimsErr, structureErr} {
- if e != nil && opts.Verbose {
- fmt.Fprintf(os.Stderr, " warning [%s]: %v\n", path, e)
+ for label, e := range map[string]error{"graph": graphErr, "claims": claimsErr, "structure": structureErr} {
+ if e != nil {
+ slog.Warn("extraction warning", "phase", label, "path", path, "err", e)
}
}
@@ -302,7 +310,6 @@ func (p *Pipeline) indexFile(ctx context.Context, path string, opts IndexOptions
}
// extractGraph runs entity/relationship extraction over chunk text batches.
-// It parallelizes batch LLM calls and uses a single batch DB write per document.
func (p *Pipeline) extractGraph(ctx context.Context, docID string, texts []string) error {
const batchChunks = 3
type batchResult struct {
@@ -313,7 +320,6 @@ func (p *Pipeline) extractGraph(ctx context.Context, docID string, texts []strin
numBatches := (len(texts) + batchChunks - 1) / batchChunks
results := make([]batchResult, numBatches)
- // Parallel LLM extraction across chunk batches
sem := make(chan struct{}, 4)
var wg sync.WaitGroup
for bi := 0; bi < numBatches; bi++ {
@@ -328,6 +334,9 @@ func (p *Pipeline) extractGraph(ctx context.Context, docID string, texts []strin
end = len(texts)
}
res, err := extractor.ExtractEntities(ctx, p.provider, texts[start:end])
+ if err != nil {
+ slog.Debug("entity extraction batch failed", "doc_id", docID, "batch", idx, "err", err)
+ }
results[idx] = batchResult{res, err}
}(bi)
}
@@ -350,13 +359,11 @@ func (p *Pipeline) extractGraph(ctx context.Context, docID string, texts []strin
names = append(names, n)
}
- // Single query to fetch all existing entities by name
existingEntities, err := p.store.GetEntitiesByNames(ctx, names)
if err != nil {
return err
}
- // Build entity ID map: name → ID (merge or create)
entityIDMap := make(map[string]string, len(names))
var toUpsert []*store.Entity
@@ -373,7 +380,6 @@ func (p *Pipeline) extractGraph(ctx context.Context, docID string, texts []strin
}
if existing, ok := existingEntities[e.Name]; ok {
entityIDMap[e.Name] = existing.ID
- // Merge description if new one is longer
if len(e.Description) > len(existing.Description) {
existing.Description = e.Description
toUpsert = append(toUpsert, existing)
@@ -391,12 +397,10 @@ func (p *Pipeline) extractGraph(ctx context.Context, docID string, texts []strin
}
}
- // Batch upsert entities
if err := p.store.BatchUpsertEntities(ctx, toUpsert); err != nil {
return fmt.Errorf("batch upsert entities: %w", err)
}
- // Collect all relationships
var rels []*store.Relationship
for _, br := range results {
if br.err != nil || br.result == nil {
@@ -420,6 +424,9 @@ func (p *Pipeline) extractGraph(ctx context.Context, docID string, texts []strin
}
}
+ slog.Debug("graph extraction complete", "doc_id", docID,
+ "entities", len(toUpsert), "relationships", len(rels))
+
return p.store.BatchInsertRelationships(ctx, rels)
}
@@ -431,7 +438,6 @@ func (p *Pipeline) extractClaims(ctx context.Context, docID string, texts []stri
return err
}
- // Lookup entity IDs in batch
names := make([]string, 0, len(claimList))
for _, c := range claimList {
if c.EntityName != "" {
@@ -457,6 +463,8 @@ func (p *Pipeline) extractClaims(ctx context.Context, docID string, texts []stri
DocID: docID,
})
}
+
+ slog.Debug("claims extracted", "doc_id", docID, "claims", len(claims))
return p.store.BatchInsertClaims(ctx, claims)
}
@@ -487,6 +495,7 @@ func (p *Pipeline) structureDocument(ctx context.Context, docID, content string)
// Finalize runs Phases 3-4: community detection + parallel summaries.
func (p *Pipeline) Finalize(ctx context.Context, verbose bool) error {
+ slog.Info("Phase 3: loading entities and relationships")
entities, err := p.store.AllEntities(ctx)
if err != nil {
return fmt.Errorf("load entities: %w", err)
@@ -499,8 +508,9 @@ func (p *Pipeline) Finalize(ctx context.Context, verbose bool) error {
if err != nil {
return fmt.Errorf("load relationships: %w", err)
}
+ slog.Info("Phase 3: running Louvain community detection",
+ "entities", len(entities), "relationships", len(rels))
- // Phase 3: Build graph + Louvain
nodes := make([]string, len(entities))
entityIDtoIdx := map[string]int{}
for i, e := range entities {
@@ -515,6 +525,7 @@ func (p *Pipeline) Finalize(ctx context.Context, verbose bool) error {
g := community.NewGraph(nodes, edges)
levels := community.HierarchicalLouvain(g, p.cfg.Community.MaxLevels, 100)
+ slog.Info("Phase 3: community detection complete", "levels", len(levels))
if err := p.store.ClearCommunities(ctx); err != nil {
return err
@@ -522,18 +533,16 @@ func (p *Pipeline) Finalize(ctx context.Context, verbose bool) error {
communityIDMap := map[string]string{}
- // ── Phase 4: Parallel community summarization ─────────────────────────────
type commWork struct {
- commID string
- parentID string
- level int
- rank int
- entityIDs []string
+ commID string
+ parentID string
+ level int
+ rank int
+ entityIDs []string
entityDescs []string
relDescs []string
}
- // Collect all community work items
var workItems []commWork
for level, assignments := range levels {
communityEntities := map[int][]string{}
@@ -558,7 +567,6 @@ func (p *Pipeline) Finalize(ctx context.Context, verbose bool) error {
parentID = communityIDMap[parentKey]
}
- // Build descriptions
entityDescs := make([]string, 0, len(entityIDs))
for _, eid := range entityIDs {
e, err := p.store.GetEntity(ctx, eid)
@@ -590,7 +598,8 @@ func (p *Pipeline) Finalize(ctx context.Context, verbose bool) error {
}
}
- // Parallel summarization with bounded concurrency
+ slog.Info("Phase 4: summarising communities", "communities", len(workItems))
+
type commResult struct {
work commWork
title string
@@ -611,21 +620,21 @@ func (p *Pipeline) Finalize(ctx context.Context, verbose bool) error {
res := commResult{work: work}
report, err := community.Summarize(ctx, p.provider, work.entityDescs, work.relDescs)
if err != nil {
- if verbose {
- fmt.Fprintf(os.Stderr, " community summary error: %v\n", err)
- }
+ slog.Warn("community summary failed", "community_idx", idx, "level", work.level, "err", err)
res.title = fmt.Sprintf("Community %d (Level %d)", idx, work.level)
res.summary = fmt.Sprintf("Contains %d entities.", work.rank)
} else {
res.title = report.Title
res.summary = report.Summary
+ slog.Debug("community summarised", "idx", idx, "level", work.level, "title", report.Title)
}
- // Embed summary
if res.summary != "" {
vec, err := p.embedder.EmbedOne(ctx, res.summary)
if err == nil {
res.vector = vec
+ } else {
+ slog.Warn("community summary embedding failed", "community_idx", idx, "err", err)
}
}
results[idx] = res
@@ -633,8 +642,8 @@ func (p *Pipeline) Finalize(ctx context.Context, verbose bool) error {
}
wg.Wait()
- // Sequential DB writes (SQLite single writer)
- communityAssignments := map[string]string{} // entityID → communityID
+ slog.Info("Phase 4: writing communities to store")
+ communityAssignments := map[string]string{}
for _, res := range results {
if err := p.store.UpsertCommunity(ctx, &store.Community{
ID: res.work.commID,
@@ -655,12 +664,10 @@ func (p *Pipeline) Finalize(ctx context.Context, verbose bool) error {
}
}
- // Batch update entity community assignments
if err := p.store.BatchUpdateEntityCommunities(ctx, communityAssignments); err != nil {
return err
}
- // Batch update entity ranks (degree centrality)
degreeCounts := map[string]int{}
for _, r := range rels {
degreeCounts[r.SourceID]++
@@ -670,7 +677,7 @@ func (p *Pipeline) Finalize(ctx context.Context, verbose bool) error {
return err
}
- // Embed entity descriptions in parallel, then batch upsert
+ // Embed entity descriptions
toEmbed := make([]*store.Entity, 0, len(entities))
for _, e := range entities {
if e.Description != "" {
@@ -678,6 +685,7 @@ func (p *Pipeline) Finalize(ctx context.Context, verbose bool) error {
}
}
if len(toEmbed) > 0 {
+ slog.Info("embedding entity descriptions", "count", len(toEmbed))
descTexts := make([]string, len(toEmbed))
for i, e := range toEmbed {
descTexts[i] = e.Description
@@ -690,9 +698,14 @@ func (p *Pipeline) Finalize(ctx context.Context, verbose bool) error {
}
}
p.store.BatchUpsertEntities(ctx, toEmbed)
+ } else {
+ slog.Warn("entity description embedding failed", "err", err)
}
}
+ slog.Info("Finalize complete",
+ "communities", len(workItems),
+ "entities_updated", len(communityAssignments))
return nil
}
@@ -718,9 +731,11 @@ func (p *Pipeline) indexRawDoc(ctx context.Context, doc *loader.RawDocument, opt
return err
}
if existing != nil && existing.FileHash == hash && !opts.Force {
+ slog.Debug("skipping unchanged page", "url", doc.Path)
return nil
}
if existing != nil {
+ slog.Info("superseding existing page version", "url", doc.Path, "old_version", existing.Version)
if err := p.store.SupersedeDocument(ctx, existing.ID); err != nil {
return fmt.Errorf("supersede old version: %w", err)
}
@@ -729,9 +744,12 @@ func (p *Pipeline) indexRawDoc(ctx context.Context, doc *loader.RawDocument, opt
nextVersion, canonicalID := versionInfo(existing)
chunks := p.chunker.Split(doc.Content)
if len(chunks) == 0 {
+ slog.Warn("no chunks produced for page, skipping", "url", doc.Path)
return nil
}
+ slog.Debug("indexing page", "url", doc.Path, "version", nextVersion, "chunks", len(chunks))
+
docID := uuid.New().String()
if err := p.store.UpsertDocument(ctx, &store.Document{
ID: docID,
@@ -770,6 +788,8 @@ func (p *Pipeline) indexRawDoc(ctx context.Context, doc *loader.RawDocument, opt
if err != nil {
return fmt.Errorf("embed: %w", err)
}
+ slog.Debug("chunks embedded", "url", doc.Path, "chunks", len(vecs))
+
if err := p.store.BatchUpsertEmbeddings(ctx, p.provider.ModelID(), chunkIDs, vecs); err != nil {
return fmt.Errorf("batch store embeddings: %w", err)
}
@@ -801,9 +821,9 @@ func (p *Pipeline) indexRawDoc(ctx context.Context, doc *loader.RawDocument, opt
}()
wg2.Wait()
- for _, e := range []error{graphErr, claimsErr, structureErr} {
- if e != nil && opts.Verbose {
- fmt.Fprintf(os.Stderr, " warning [%s]: %v\n", doc.Path, e)
+ for label, e := range map[string]error{"graph": graphErr, "claims": claimsErr, "structure": structureErr} {
+ if e != nil {
+ slog.Warn("extraction warning", "phase", label, "url", doc.Path, "err", e)
}
}
return nil
diff --git a/ui/embed.go b/ui/embed.go
index 96b73ab..690b363 100644
--- a/ui/embed.go
+++ b/ui/embed.go
@@ -2,5 +2,5 @@ package ui
import "embed"
-//go:embed index.html app.js graph.js style.css vendor/vis-network.min.js
+//go:embed index.html app.js graph.js style.css
var Assets embed.FS
diff --git a/ui/graph.js b/ui/graph.js
index 5be0912..73e11f8 100644
--- a/ui/graph.js
+++ b/ui/graph.js
@@ -1,7 +1,20 @@
-// Graph visualization using vis-network
+// Graph visualization — pure Canvas, no external dependencies
+
+const TYPE_COLORS = {
+ Person: '#f59e0b',
+ Organization: '#3b82f6',
+ Concept: '#a78bfa',
+ Location: '#10b981',
+ Event: '#f43f5e',
+ Technology: '#06b6d4',
+ Other: '#64748b',
+};
function renderGraph(data) {
const container = document.getElementById('graph-container');
+
+ // Clean up any previous render (disconnect ResizeObserver, cancel animation)
+ if (container._graphCleanup) container._graphCleanup();
container.innerHTML = '';
if (!data || !data.nodes || !data.nodes.length) {
@@ -9,58 +22,282 @@ function renderGraph(data) {
return;
}
- // Color by entity type
- const typeColors = {
- Person: '#f59e0b',
- Organization: '#3b82f6',
- Concept: '#a78bfa',
- Location: '#10b981',
- Event: '#f43f5e',
- Technology: '#06b6d4',
- Other: '#64748b',
- };
+ // ── Canvas + tooltip setup ────────────────────────────────────────────────
+ container.style.position = 'relative';
+
+ const canvas = document.createElement('canvas');
+ canvas.style.cssText = 'display:block;width:100%;height:100%;cursor:default';
+ container.appendChild(canvas);
+
+ const tip = document.createElement('div');
+ tip.style.cssText = [
+ 'position:absolute;background:#1a1d27;border:1px solid #2d3148',
+ 'border-radius:6px;padding:8px 12px;font-size:12px;color:#f1f5f9',
+ 'pointer-events:none;display:none;max-width:220px;z-index:100',
+ 'line-height:1.5',
+ ].join(';');
+ container.appendChild(tip);
+
+ // ── Build node / edge objects ─────────────────────────────────────────────
+ const cx = () => canvas.width / 2;
+ const cy = () => canvas.height / 2;
+
+ // Seed deterministic positions in a circle so first frame looks reasonable
+ const nodes = data.nodes.map((n, i) => {
+ const angle = (2 * Math.PI * i) / data.nodes.length;
+ const r = Math.min(canvas.width, canvas.height) * 0.3 || 150;
+ return {
+ id: n.id,
+ label: n.label || n.id,
+ type: n.type || 'Other',
+ desc: n.description || '',
+ color: TYPE_COLORS[n.type] || TYPE_COLORS.Other,
+ r: 8 + Math.min((n.rank || 0) * 1.5, 14),
+ x: (canvas.width || 800) / 2 + r * Math.cos(angle),
+ y: (canvas.height || 600) / 2 + r * Math.sin(angle),
+ vx: 0, vy: 0,
+ };
+ });
+
+ const byId = {};
+ nodes.forEach(n => { byId[n.id] = n; });
+
+ const edges = (data.edges || [])
+ .map(e => ({ from: byId[e.from], to: byId[e.to], label: e.label || '', weight: e.weight || 0.5 }))
+ .filter(e => e.from && e.to);
+
+ // ── Simulation parameters ─────────────────────────────────────────────────
+ let alpha = 1.0;
+ const DECAY = 0.015;
+ const REPULSE = 1500;
+ const ATTRACT = 0.05;
+ const GRAVITY = 0.01;
+ const DAMP = 0.82;
+
+ // ── Viewport state ────────────────────────────────────────────────────────
+ let tx = 0, ty = 0, scale = 1;
+
+ // ── Input state ───────────────────────────────────────────────────────────
+ let dragging = null;
+ let panning = false;
+ let panStart = null;
+ let rafId = null;
+
+ // ── Resize handling ───────────────────────────────────────────────────────
+ function resize() {
+ canvas.width = container.clientWidth || 800;
+ canvas.height = container.clientHeight || 600;
+ }
+ resize();
+ const ro = new ResizeObserver(resize);
+ ro.observe(container);
+
+ // ── Simulation step ───────────────────────────────────────────────────────
+ function simulate() {
+ if (alpha <= 0.001) return;
+
+ // Repulsion between every pair
+ for (let i = 0; i < nodes.length; i++) {
+ for (let j = i + 1; j < nodes.length; j++) {
+ const a = nodes[i], b = nodes[j];
+ let dx = b.x - a.x || 0.01;
+ let dy = b.y - a.y || 0.01;
+ const d2 = dx * dx + dy * dy;
+ const d = Math.sqrt(d2);
+ const f = (REPULSE * alpha) / d2;
+ const fx = f * dx / d;
+ const fy = f * dy / d;
+ a.vx -= fx; a.vy -= fy;
+ b.vx += fx; b.vy += fy;
+ }
+ }
+
+ // Attraction along edges (spring)
+ for (const e of edges) {
+ const dx = e.to.x - e.from.x;
+ const dy = e.to.y - e.from.y;
+ const f = ATTRACT * alpha;
+ e.from.vx += dx * f; e.from.vy += dy * f;
+ e.to.vx -= dx * f; e.to.vy -= dy * f;
+ }
+
+ // Gravity toward canvas centre
+ for (const n of nodes) {
+ n.vx += (cx() - n.x) * GRAVITY * alpha;
+ n.vy += (cy() - n.y) * GRAVITY * alpha;
+ }
+
+ // Integrate
+ for (const n of nodes) {
+ if (n === dragging) continue;
+ n.vx *= DAMP; n.vy *= DAMP;
+ n.x += n.vx; n.y += n.vy;
+ }
+
+ alpha -= DECAY;
+ }
+
+ // ── Draw ──────────────────────────────────────────────────────────────────
+ function draw() {
+ const ctx = canvas.getContext('2d');
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.save();
+ ctx.translate(tx, ty);
+ ctx.scale(scale, scale);
+
+ // Edges
+ for (const e of edges) {
+ const { from: a, to: b } = e;
+ const dx = b.x - a.x, dy = b.y - a.y;
+ const len = Math.sqrt(dx * dx + dy * dy) || 1;
+ const ux = dx / len, uy = dy / len;
+ const sx = a.x + ux * a.r, sy = a.y + uy * a.r;
+ const ex = b.x - ux * (b.r + 6), ey = b.y - uy * (b.r + 6);
+
+ // Line
+ ctx.beginPath();
+ ctx.moveTo(sx, sy);
+ ctx.lineTo(ex, ey);
+ ctx.strokeStyle = '#2d3148';
+ ctx.lineWidth = Math.max(0.5, e.weight * 2);
+ ctx.stroke();
+
+ // Arrowhead
+ const angle = Math.atan2(dy, dx);
+ const AL = 9, AW = 0.38;
+ ctx.beginPath();
+ ctx.moveTo(ex, ey);
+ ctx.lineTo(ex - AL * Math.cos(angle - AW), ey - AL * Math.sin(angle - AW));
+ ctx.lineTo(ex - AL * Math.cos(angle + AW), ey - AL * Math.sin(angle + AW));
+ ctx.closePath();
+ ctx.fillStyle = '#2d3148';
+ ctx.fill();
+
+ // Edge label
+ if (e.label) {
+ ctx.font = '10px sans-serif';
+ ctx.fillStyle = '#94a3b8';
+ ctx.textAlign = 'center';
+ ctx.fillText(e.label, (sx + ex) / 2, (sy + ey) / 2 - 4);
+ }
+ }
+
+ // Nodes
+ for (const n of nodes) {
+ // Circle
+ ctx.beginPath();
+ ctx.arc(n.x, n.y, n.r, 0, Math.PI * 2);
+ ctx.fillStyle = n.color;
+ ctx.fill();
+ ctx.strokeStyle = '#1a1d27';
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+
+ // Label below node
+ const short = n.label.length > 14 ? n.label.slice(0, 13) + '…' : n.label;
+ ctx.font = `${Math.max(10, Math.floor(n.r))}px sans-serif`;
+ ctx.fillStyle = '#f1f5f9';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ ctx.fillText(short, n.x, n.y + n.r + 3);
+ }
+
+ ctx.restore();
+ }
- const nodes = new vis.DataSet(data.nodes.map(n => ({
- id: n.id,
- label: n.label || n.id,
- title: `${n.label}
${n.type || ''}
${n.description || ''}`,
- color: {
- background: typeColors[n.type] || typeColors.Other,
- border: '#1a1d27',
- highlight: { background: '#7c6af7', border: '#fff' },
- },
- font: { color: '#f1f5f9', size: 13 },
- size: 10 + Math.min((n.rank || 0) * 2, 20),
- })));
-
- const edges = new vis.DataSet(data.edges.map(e => ({
- id: e.id,
- from: e.from,
- to: e.to,
- label: e.label || '',
- title: e.label || '',
- width: Math.max(1, (e.weight || 1) * 2),
- color: { color: '#2d3148', highlight: '#7c6af7' },
- font: { color: '#94a3b8', size: 10, align: 'middle' },
- smooth: { type: 'dynamic' },
- })));
-
- const options = {
- physics: {
- enabled: true,
- stabilization: { iterations: 100 },
- barnesHut: { gravitationalConstant: -3000, centralGravity: 0.3 },
- },
- interaction: {
- hover: true,
- tooltipDelay: 100,
- navigationButtons: false,
- keyboard: false,
- },
- layout: { randomSeed: 42 },
- nodes: { borderWidth: 1, shape: 'dot' },
- edges: { arrows: { to: { enabled: true, scaleFactor: 0.5 } } },
+ // ── Animation loop ────────────────────────────────────────────────────────
+ function loop() {
+ simulate();
+ draw();
+ rafId = requestAnimationFrame(loop);
+ }
+ loop();
+
+ // ── Coordinate helpers ────────────────────────────────────────────────────
+ function toWorld(clientX, clientY) {
+ const rect = canvas.getBoundingClientRect();
+ return {
+ x: (clientX - rect.left - tx) / scale,
+ y: (clientY - rect.top - ty) / scale,
+ };
+ }
+
+ function hitNode(wx, wy) {
+ for (let i = nodes.length - 1; i >= 0; i--) {
+ const n = nodes[i];
+ const dx = wx - n.x, dy = wy - n.y;
+ if (dx * dx + dy * dy <= n.r * n.r) return n;
+ }
+ return null;
+ }
+
+ // ── Mouse events ──────────────────────────────────────────────────────────
+ canvas.addEventListener('mousedown', e => {
+ const w = toWorld(e.clientX, e.clientY);
+ const node = hitNode(w.x, w.y);
+ if (node) {
+ dragging = node;
+ alpha = Math.max(alpha, 0.3);
+ canvas.style.cursor = 'grabbing';
+ } else {
+ panning = true;
+ panStart = { x: e.clientX - tx, y: e.clientY - ty };
+ canvas.style.cursor = 'grabbing';
+ }
+ });
+
+ canvas.addEventListener('mousemove', e => {
+ if (dragging) {
+ const w = toWorld(e.clientX, e.clientY);
+ dragging.x = w.x; dragging.y = w.y;
+ dragging.vx = 0; dragging.vy = 0;
+ return;
+ }
+ if (panning && panStart) {
+ tx = e.clientX - panStart.x;
+ ty = e.clientY - panStart.y;
+ return;
+ }
+ // Tooltip
+ const w = toWorld(e.clientX, e.clientY);
+ const node = hitNode(w.x, w.y);
+ if (node) {
+ const rect = container.getBoundingClientRect();
+ tip.style.left = (e.clientX - rect.left + 14) + 'px';
+ tip.style.top = (e.clientY - rect.top + 14) + 'px';
+ tip.style.display = 'block';
+ tip.innerHTML =
+ `${node.label}
` +
+ `${node.type}` +
+ (node.desc ? `
${node.desc}` : '');
+ canvas.style.cursor = 'pointer';
+ } else {
+ tip.style.display = 'none';
+ canvas.style.cursor = 'default';
+ }
+ });
+
+ const stopDrag = () => {
+ dragging = null; panning = false; canvas.style.cursor = 'default';
};
+ canvas.addEventListener('mouseup', stopDrag);
+ canvas.addEventListener('mouseleave', () => { stopDrag(); tip.style.display = 'none'; });
- new vis.Network(container, { nodes, edges }, options);
+ // Scroll to zoom, centred on cursor
+ canvas.addEventListener('wheel', e => {
+ e.preventDefault();
+ const factor = e.deltaY < 0 ? 1.1 : 0.9;
+ const rect = canvas.getBoundingClientRect();
+ const mx = e.clientX - rect.left;
+ const my = e.clientY - rect.top;
+ tx = mx - factor * (mx - tx);
+ ty = my - factor * (my - ty);
+ scale *= factor;
+ }, { passive: false });
+
+ // ── Cleanup ───────────────────────────────────────────────────────────────
+ container._graphCleanup = () => {
+ cancelAnimationFrame(rafId);
+ ro.disconnect();
+ };
}
diff --git a/ui/index.html b/ui/index.html
index c5f9cb2..d902bf9 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -84,7 +84,6 @@