From ab8c0eb1a69f3ee0fd75fc75eeb6db70e88bae80 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 10:38:07 +0000 Subject: [PATCH 1/2] Fix vis-network loading and add structured slog logging throughout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vis-network fix: - Switch from placeholder vendor stub to CDN (unpkg.com) in index.html - Remove vendor/vis-network.min.js from embed.go (no longer embedded) - Add graceful 'vis not loaded' error message in graph.js Structured logging (log/slog): - cmd/root.go: initialise global slog text handler; add --log-level flag (debug/info/warn/error) to control verbosity - cmd/serve.go: replace fmt.Fprintf with slog.Info/Error; log store open, LLM provider init, server start, shutdown lifecycle - cmd/index.go: slog for crawl start/complete, index start/complete, finalization phases - internal/pipeline/pipeline.go: slog throughout all phases — file count, skip/supersede decisions, chunk+embed counts, entity/relationship extraction counts, community detection progress, Finalize phase markers - internal/crawler/crawler.go: slog for sitemap discovery, BFS progress, page fetch errors, crawl summary stats - internal/api/router.go: HTTP logging middleware capturing method, path, status code, duration; 5xx logged at Error, 4xx at Warn, 2xx at Info - internal/api/handlers.go: slog.Error on all 500 responses; Info on search requests and upload jobs https://claude.ai/code/session_012X8wf4jwQFRYfMnbbWCEs5 --- cmd/index.go | 18 +++--- cmd/root.go | 25 ++++++-- cmd/serve.go | 27 +++++---- internal/api/handlers.go | 71 +++++++++++++--------- internal/api/router.go | 39 +++++++++++- internal/crawler/crawler.go | 32 +++++++++- internal/pipeline/pipeline.go | 110 ++++++++++++++++++++-------------- ui/embed.go | 2 +- ui/graph.js | 5 ++ ui/index.html | 2 +- 10 files changed, 232 insertions(+), 99 deletions(-) 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..ebbd741 100644 --- a/ui/graph.js +++ b/ui/graph.js @@ -4,6 +4,11 @@ function renderGraph(data) { const container = document.getElementById('graph-container'); container.innerHTML = ''; + if (typeof vis === 'undefined') { + container.innerHTML = '
vis-network failed to load. Check your internet connection — the graph library is loaded from unpkg.com.
'; + return; + } + if (!data || !data.nodes || !data.nodes.length) { container.innerHTML = '
No graph data. Enter an entity name above.
'; return; diff --git a/ui/index.html b/ui/index.html index c5f9cb2..b2368c6 100644 --- a/ui/index.html +++ b/ui/index.html @@ -84,7 +84,7 @@

Index Statistics

- + From d2c9b5bc36916a9184e71873979aead53665b788 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 10:53:48 +0000 Subject: [PATCH 2/2] Replace vis-network CDN with self-contained Canvas renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the unpkg.com CDN dependency entirely. Implement force-directed graph rendering in ~220 lines of vanilla JS using HTML5 Canvas: - Force-directed layout: repulsion (Coulomb), spring attraction along edges, gravity toward canvas centre, velocity damping - Deterministic initial positions (nodes placed on a circle) - Directional edges with arrowheads, width proportional to weight - Node size proportional to entity rank, coloured by type - Edge labels rendered at midpoint - Interactive: drag nodes, pan (background drag), scroll-to-zoom - Hover tooltips showing entity name, type and description - Cleans up ResizeObserver and cancelAnimationFrame on re-render - Zero external dependencies — works behind corporate firewalls https://claude.ai/code/session_012X8wf4jwQFRYfMnbbWCEs5 --- ui/graph.js | 346 +++++++++++++++++++++++++++++++++++++++++--------- ui/index.html | 1 - 2 files changed, 289 insertions(+), 58 deletions(-) diff --git a/ui/graph.js b/ui/graph.js index ebbd741..73e11f8 100644 --- a/ui/graph.js +++ b/ui/graph.js @@ -1,71 +1,303 @@ -// 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'); - container.innerHTML = ''; - if (typeof vis === 'undefined') { - container.innerHTML = '
vis-network failed to load. Check your internet connection — the graph library is loaded from unpkg.com.
'; - return; - } + // Clean up any previous render (disconnect ResizeObserver, cancel animation) + if (container._graphCleanup) container._graphCleanup(); + container.innerHTML = ''; if (!data || !data.nodes || !data.nodes.length) { container.innerHTML = '
No graph data. Enter an entity name above.
'; 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; + } - 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 } } }, + 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(); + } + + // ── 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 b2368c6..d902bf9 100644 --- a/ui/index.html +++ b/ui/index.html @@ -84,7 +84,6 @@

Index Statistics

-