Skip to main content

knowdit_kg/
knowledge_graph.rs

1use crate::error::Result;
2use knowdit_kg_model::db::{
3    audit_finding, audit_finding_category, category, finding_category, finding_link_status,
4    finding_merge, project, project_category, project_finding, project_platform, project_semantic,
5    semantic_finding_link, semantic_function, semantic_merge, semantic_node,
6};
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet};
9
10/// In-memory representation of the full knowledge graph.
11/// Built via `HistoricalDatabase::load_knowledge_graph()`.
12#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
13pub struct KnowledgeGraph {
14    pub projects: Vec<project::Model>,
15    pub project_platforms: Vec<project_platform::Model>,
16    pub categories: Vec<category::Model>,
17    pub nodes: Vec<semantic_node::Model>,
18    pub semantic_functions: Vec<semantic_function::Model>,
19    pub project_categories: Vec<project_category::Model>,
20    /// Project ↔ semantic provenance edges. Replaces the old scalar
21    /// `semantic_node.project_id` so that a single canonical semantic (the
22    /// merge target — the surviving node after raw siblings fold into it) can
23    /// list every contributing project.
24    pub project_semantics: Vec<project_semantic::Model>,
25    pub semantic_merges: Vec<semantic_merge::Model>,
26    pub findings: Vec<audit_finding::Model>,
27    pub finding_categories: Vec<finding_category::Model>,
28    pub audit_finding_categories: Vec<audit_finding_category::Model>,
29    /// Project ↔ finding provenance edges. Replaces the old scalar
30    /// `audit_finding.project_id` for the same reason.
31    pub project_findings: Vec<project_finding::Model>,
32    pub semantic_finding_links: Vec<semantic_finding_link::Model>,
33    /// Per-finding completion marker. A finding's sfl rows are only
34    /// downstream-visible once a row for it lands here, so the
35    /// status set must round-trip through snapshots — otherwise a
36    /// freshly-restored DB would have orphaned sfl rows that
37    /// `load_knowledge_graph`'s gate filters out, and the snapshot
38    /// would no longer be self-contained.
39    #[serde(default)]
40    pub finding_link_statuses: Vec<finding_link_status::Model>,
41    pub finding_merges: Vec<finding_merge::Model>,
42}
43
44impl KnowledgeGraph {
45    /// Build a `semantic_node_id → [project_id]` lookup map from the
46    /// `project_semantic` join rows. Used by visualization paths that need
47    /// to draw a project→node edge per contributor.
48    fn project_ids_per_semantic(&self) -> HashMap<i32, Vec<i32>> {
49        let mut out: HashMap<i32, Vec<i32>> = HashMap::new();
50        for row in &self.project_semantics {
51            out.entry(row.semantic_node_id)
52                .or_default()
53                .push(row.project_id);
54        }
55        out
56    }
57
58    /// Build a `audit_finding_id → [project_id]` lookup map.
59    fn project_ids_per_finding(&self) -> HashMap<i32, Vec<i32>> {
60        let mut out: HashMap<i32, Vec<i32>> = HashMap::new();
61        for row in &self.project_findings {
62            out.entry(row.audit_finding_id)
63                .or_default()
64                .push(row.project_id);
65        }
66        out
67    }
68}
69
70impl KnowledgeGraph {
71    /// Export the knowledge graph as a GraphViz DOT string.
72    pub fn export_dot(&self) -> String {
73        let merged_from_semantics: HashSet<i32> = self
74            .semantic_merges
75            .iter()
76            .map(|merge| merge.from_semantic_id)
77            .collect();
78        let merged_from_findings: HashSet<i32> = self
79            .finding_merges
80            .iter()
81            .map(|merge| merge.from_finding_id)
82            .collect();
83
84        let platform_labels: HashMap<i32, String> = self
85            .project_platforms
86            .iter()
87            .map(|pp| (pp.project_id, pp.platform_id.clone()))
88            .collect();
89        let finding_category_by_id: HashMap<i32, finding_category::Model> = self
90            .finding_categories
91            .iter()
92            .cloned()
93            .map(|category| (category.id, category))
94            .collect();
95        let finding_category_for_finding: HashMap<i32, finding_category::Model> = self
96            .audit_finding_categories
97            .iter()
98            .filter_map(|link| {
99                finding_category_by_id
100                    .get(&link.finding_category_id)
101                    .cloned()
102                    .map(|category| (link.audit_finding_id, category))
103            })
104            .collect();
105
106        let mut dot = String::new();
107        dot.push_str("digraph KnowledgeGraph {\n");
108        dot.push_str("  rankdir=LR;\n");
109        dot.push_str("  node [shape=box, style=filled];\n\n");
110
111        for cat in &self.categories {
112            let node_ids: Vec<i32> = self
113                .nodes
114                .iter()
115                .filter(|node| node.category == cat.name)
116                .map(|node| node.id)
117                .filter(|id| !merged_from_semantics.contains(id))
118                .collect();
119
120            if node_ids.is_empty() {
121                continue;
122            }
123
124            dot.push_str(&format!("  subgraph cluster_cat_{} {{\n", cat.id));
125            dot.push_str(&format!(
126                "    label=\"{}\";\n    style=filled;\n    color=lightblue;\n",
127                escape_dot(&cat.name.to_string())
128            ));
129
130            for nid in &node_ids {
131                if let Some(node) = self.nodes.iter().find(|n| n.id == *nid) {
132                    dot.push_str(&format!(
133                        "    sem_{} [label=\"{}\", fillcolor=lightyellow];\n",
134                        node.id,
135                        escape_dot(&node.name)
136                    ));
137                }
138            }
139            dot.push_str("  }\n\n");
140        }
141
142        let mut finding_categories = self
143            .finding_categories
144            .iter()
145            .map(|category| category.category)
146            .collect::<Vec<_>>();
147        finding_categories.sort_by_key(|category| category.as_str().to_string());
148        finding_categories.dedup();
149
150        for category_name in finding_categories {
151            let finding_ids: Vec<i32> = self
152                .findings
153                .iter()
154                .filter(|finding| !merged_from_findings.contains(&finding.id))
155                .filter(|finding| {
156                    finding_category_for_finding
157                        .get(&finding.id)
158                        .map(|category| category.category == category_name)
159                        .unwrap_or(false)
160                })
161                .map(|finding| finding.id)
162                .collect();
163
164            if finding_ids.is_empty() {
165                continue;
166            }
167
168            let cluster_id = dot_identifier(category_name.as_str());
169            dot.push_str(&format!("  subgraph cluster_vuln_{} {{\n", cluster_id));
170            dot.push_str(&format!(
171                "    label=\"Vulnerability: {}\";\n    style=filled;\n    color=mistyrose;\n",
172                escape_dot(&category_name.to_string())
173            ));
174
175            for finding_id in &finding_ids {
176                if let Some(finding) = self
177                    .findings
178                    .iter()
179                    .find(|finding| finding.id == *finding_id)
180                {
181                    let subcategory = finding_category_for_finding
182                        .get(&finding.id)
183                        .map(|category| category.name.clone())
184                        .unwrap_or_else(|| "Uncategorized".to_string());
185                    let label =
186                        format!("[{}] {}\\n{}", finding.severity, finding.title, subcategory);
187                    dot.push_str(&format!(
188                        "    finding_{} [label=\"{}\", shape=note, fillcolor={}];\n",
189                        finding.id,
190                        escape_dot(&label),
191                        finding_fill_color(finding.severity)
192                    ));
193                }
194            }
195
196            dot.push_str("  }\n\n");
197        }
198
199        for proj in &self.projects {
200            let label = if let Some(plat_label) = platform_labels.get(&proj.id) {
201                format!("{} ({})", proj.name, plat_label)
202            } else {
203                proj.name.clone()
204            };
205            dot.push_str(&format!(
206                "  proj_{} [label=\"{}\", shape=ellipse, fillcolor=lightgreen];\n",
207                proj.id,
208                escape_dot(&label)
209            ));
210        }
211        dot.push('\n');
212
213        for pc in &self.project_categories {
214            dot.push_str(&format!(
215                "  proj_{} -> cat_{} [style=dashed, color=gray];\n",
216                pc.project_id, pc.category_id
217            ));
218        }
219
220        let projects_per_semantic = self.project_ids_per_semantic();
221        for node in &self.nodes {
222            if merged_from_semantics.contains(&node.id) {
223                continue;
224            }
225            for project_id in projects_per_semantic.get(&node.id).into_iter().flatten() {
226                dot.push_str(&format!(
227                    "  proj_{} -> sem_{} [color=darkgreen];\n",
228                    project_id, node.id
229                ));
230            }
231        }
232
233        let projects_per_finding = self.project_ids_per_finding();
234        for finding in &self.findings {
235            if merged_from_findings.contains(&finding.id) {
236                continue;
237            }
238            for project_id in projects_per_finding.get(&finding.id).into_iter().flatten() {
239                dot.push_str(&format!(
240                    "  proj_{} -> finding_{} [color=firebrick];\n",
241                    project_id, finding.id
242                ));
243            }
244        }
245        dot.push('\n');
246
247        for merge in &self.semantic_merges {
248            dot.push_str(&format!(
249                "  sem_{} -> sem_{} [label=\"raw→canonical\", style=dotted, color=red];\n",
250                merge.from_semantic_id, merge.to_semantic_id
251            ));
252        }
253
254        for merge in &self.finding_merges {
255            dot.push_str(&format!(
256                "  finding_{} -> finding_{} [label=\"raw→canonical\", style=dotted, color=orangered];\n",
257                merge.from_finding_id, merge.to_finding_id
258            ));
259        }
260
261        for link in &self.semantic_finding_links {
262            if merged_from_semantics.contains(&link.semantic_node_id)
263                || merged_from_findings.contains(&link.audit_finding_id)
264            {
265                continue;
266            }
267
268            dot.push_str(&format!(
269                "  sem_{} -> finding_{} [color=steelblue, penwidth=1.5];\n",
270                link.semantic_node_id, link.audit_finding_id
271            ));
272        }
273
274        for cat in &self.categories {
275            dot.push_str(&format!(
276                "  cat_{} [label=\"{}\", shape=diamond, fillcolor=lightblue, style=filled];\n",
277                cat.id,
278                escape_dot(&cat.name.to_string())
279            ));
280        }
281
282        dot.push_str("}\n");
283        dot
284    }
285
286    /// Export the knowledge graph as an interactive HTML page backed by
287    /// vis-network. Node labels stay concise, while all details are embedded
288    /// into the page and shown in a side panel when selected.
289    pub fn export_html(
290        &self,
291        graph_data_script_url: &str,
292        details_script_url: &str,
293        viewport_edge_limit: usize,
294        project_rows: usize,
295        semantic_rows: usize,
296        finding_rows: usize,
297    ) -> Result<HtmlExportAssets> {
298        let project_rows = project_rows.max(1);
299        let semantic_rows = semantic_rows.max(1);
300        let finding_rows = finding_rows.max(1);
301        let merged_from_semantics: HashSet<i32> = self
302            .semantic_merges
303            .iter()
304            .map(|merge| merge.from_semantic_id)
305            .collect();
306        let merged_from_findings: HashSet<i32> = self
307            .finding_merges
308            .iter()
309            .map(|merge| merge.from_finding_id)
310            .collect();
311        let semantic_merge_targets: HashMap<i32, i32> = self
312            .semantic_merges
313            .iter()
314            .map(|merge| (merge.from_semantic_id, merge.to_semantic_id))
315            .collect();
316        let finding_merge_targets: HashMap<i32, i32> = self
317            .finding_merges
318            .iter()
319            .map(|merge| (merge.from_finding_id, merge.to_finding_id))
320            .collect();
321
322        let projects_by_id: HashMap<i32, &project::Model> = self
323            .projects
324            .iter()
325            .map(|project| (project.id, project))
326            .collect();
327        let categories_by_id: HashMap<i32, &category::Model> = self
328            .categories
329            .iter()
330            .map(|category| (category.id, category))
331            .collect();
332        let nodes_by_id: HashMap<i32, &semantic_node::Model> =
333            self.nodes.iter().map(|node| (node.id, node)).collect();
334        let findings_by_id: HashMap<i32, &audit_finding::Model> = self
335            .findings
336            .iter()
337            .map(|finding| (finding.id, finding))
338            .collect();
339
340        let platform_labels: HashMap<i32, String> = self
341            .project_platforms
342            .iter()
343            .map(|pp| (pp.project_id, pp.platform_id.clone()))
344            .collect();
345        let mut project_category_names: HashMap<i32, Vec<String>> = HashMap::new();
346        for link in &self.project_categories {
347            if let Some(category) = categories_by_id.get(&link.category_id) {
348                project_category_names
349                    .entry(link.project_id)
350                    .or_default()
351                    .push(category.name.to_string());
352            }
353        }
354        for categories in project_category_names.values_mut() {
355            categories.sort();
356            categories.dedup();
357        }
358
359        let finding_category_by_id: HashMap<i32, &finding_category::Model> = self
360            .finding_categories
361            .iter()
362            .map(|category| (category.id, category))
363            .collect();
364        let mut finding_category_for_finding: HashMap<i32, &finding_category::Model> =
365            HashMap::new();
366        for link in &self.audit_finding_categories {
367            if let Some(category) = finding_category_by_id.get(&link.finding_category_id) {
368                finding_category_for_finding.insert(link.audit_finding_id, *category);
369            }
370        }
371
372        let mut semantic_functions_by_node: HashMap<i32, Vec<String>> = HashMap::new();
373        for func in &self.semantic_functions {
374            semantic_functions_by_node
375                .entry(func.semantic_node_id)
376                .or_default()
377                .push(format!("{} — {}", func.contract_path, func.function_name));
378        }
379        for functions in semantic_functions_by_node.values_mut() {
380            functions.sort();
381            functions.dedup();
382        }
383
384        let project_ids = self
385            .projects
386            .iter()
387            .map(|project| project.id)
388            .collect::<Vec<_>>();
389        let category_ids = self
390            .categories
391            .iter()
392            .map(|category| category.id)
393            .collect::<Vec<_>>();
394        let semantic_ids = self.nodes.iter().map(|node| node.id).collect::<Vec<_>>();
395        let finding_ids = self
396            .findings
397            .iter()
398            .map(|finding| finding.id)
399            .collect::<Vec<_>>();
400        let project_positions = right_aligned_grid_node_positions(
401            &project_ids,
402            PROJECT_COLUMN_X,
403            project_rows,
404            PROJECT_ROW_SPACING,
405            PROJECT_COLUMN_SPACING,
406            0.0,
407        );
408        let category_positions =
409            vertical_node_positions(&category_ids, CATEGORY_COLUMN_X, CATEGORY_ROW_SPACING);
410        let semantic_positions = grid_node_positions(
411            &semantic_ids,
412            SEMANTIC_COLUMN_X,
413            semantic_rows,
414            SEMANTIC_ROW_SPACING,
415            SEMANTIC_COLUMN_SPACING,
416            0.0,
417        );
418        let finding_vertical_offset =
419            grid_band_half_height(semantic_ids.len(), semantic_rows, SEMANTIC_ROW_SPACING)
420                + grid_band_half_height(finding_ids.len(), finding_rows, FINDING_ROW_SPACING)
421                + FINDING_VERTICAL_GAP;
422        let finding_positions = grid_node_positions(
423            &finding_ids,
424            FINDING_COLUMN_X,
425            finding_rows,
426            FINDING_ROW_SPACING,
427            FINDING_COLUMN_SPACING,
428            finding_vertical_offset,
429        );
430
431        let mut nodes = Vec::new();
432        let mut node_details = HashMap::new();
433        let mut edge_details = HashMap::new();
434        let mut node_type_counts: HashMap<&'static str, usize> = HashMap::new();
435        let mut edge_type_counts: HashMap<&'static str, usize> = HashMap::new();
436
437        for project in &self.projects {
438            let mut fields = Vec::new();
439            push_detail(&mut fields, "Project Name", Some(project.name.clone()));
440            push_detail(
441                &mut fields,
442                "Platform ID",
443                platform_labels.get(&project.id).cloned(),
444            );
445            push_detail(&mut fields, "Status", Some(project.status.clone()));
446            push_detail(
447                &mut fields,
448                "Categories",
449                project_category_names
450                    .get(&project.id)
451                    .map(|categories| categories.join(", ")),
452            );
453
454            let raw_semantic_count = self
455                .project_semantics
456                .iter()
457                .filter(|row| row.project_id == project.id)
458                .count();
459            let raw_finding_count = self
460                .project_findings
461                .iter()
462                .filter(|row| row.project_id == project.id)
463                .count();
464            push_detail(
465                &mut fields,
466                "Semantic Nodes",
467                Some(raw_semantic_count.to_string()),
468            );
469            push_detail(
470                &mut fields,
471                "Audit Findings",
472                Some(raw_finding_count.to_string()),
473            );
474
475            let node_id = format!("proj_{}", project.id);
476            node_details.insert(
477                node_id.clone(),
478                HtmlSelectionDetails {
479                    title: project.name.clone(),
480                    subtitle: platform_labels.get(&project.id).cloned(),
481                    fields,
482                },
483            );
484            bump_node_type_count(&mut node_type_counts, NODE_TYPE_PROJECT);
485            let position =
486                project_positions
487                    .get(&project.id)
488                    .copied()
489                    .unwrap_or(HtmlNodePosition {
490                        x: PROJECT_COLUMN_X,
491                        y: 0.0,
492                    });
493            nodes.push(HtmlGraphNode {
494                id: node_id,
495                label: wrap_label(&project.name, 22),
496                node_type: NODE_TYPE_PROJECT.to_string(),
497                is_merged: false,
498                level: 0,
499                shape: "ellipse".to_string(),
500                color: HtmlNodeColor {
501                    background: "#dcfce7".to_string(),
502                    border: "#16a34a".to_string(),
503                },
504                border_width: 2,
505                x: position.x,
506                y: position.y,
507                fixed: HtmlNodeFixed::locked(),
508            });
509        }
510
511        for category in &self.categories {
512            let project_count = self
513                .project_categories
514                .iter()
515                .filter(|link| link.category_id == category.id)
516                .count();
517            let active_semantic_count = self
518                .nodes
519                .iter()
520                .filter(|node| node.category == category.name)
521                .filter(|node| !merged_from_semantics.contains(&node.id))
522                .count();
523
524            let node_id = format!("cat_{}", category.id);
525            node_details.insert(
526                node_id.clone(),
527                HtmlSelectionDetails {
528                    title: category.name.to_string(),
529                    subtitle: Some("DeFi Category".to_string()),
530                    fields: vec![
531                        HtmlDetailField {
532                            label: "Category".to_string(),
533                            value: category.name.to_string(),
534                        },
535                        HtmlDetailField {
536                            label: "Projects".to_string(),
537                            value: project_count.to_string(),
538                        },
539                        HtmlDetailField {
540                            label: "Active Semantic Nodes".to_string(),
541                            value: active_semantic_count.to_string(),
542                        },
543                    ],
544                },
545            );
546            bump_node_type_count(&mut node_type_counts, NODE_TYPE_CATEGORY);
547            let position =
548                category_positions
549                    .get(&category.id)
550                    .copied()
551                    .unwrap_or(HtmlNodePosition {
552                        x: CATEGORY_COLUMN_X,
553                        y: 0.0,
554                    });
555            nodes.push(HtmlGraphNode {
556                id: node_id,
557                label: wrap_label(category.name.as_str(), 18),
558                node_type: NODE_TYPE_CATEGORY.to_string(),
559                is_merged: false,
560                level: 1,
561                shape: "diamond".to_string(),
562                color: HtmlNodeColor {
563                    background: "#dbeafe".to_string(),
564                    border: "#2563eb".to_string(),
565                },
566                border_width: 2,
567                x: position.x,
568                y: position.y,
569                fixed: HtmlNodeFixed::locked(),
570            });
571        }
572
573        let projects_per_semantic = self.project_ids_per_semantic();
574        let projects_per_finding = self.project_ids_per_finding();
575        let resolve_project_label = |pids: Option<&Vec<i32>>| -> Option<String> {
576            let pids = pids?;
577            let mut names: Vec<String> = pids
578                .iter()
579                .filter_map(|pid| projects_by_id.get(pid).map(|p| p.name.clone()))
580                .collect();
581            names.sort();
582            names.dedup();
583            if names.is_empty() {
584                None
585            } else {
586                Some(names.join(", "))
587            }
588        };
589
590        for node in &self.nodes {
591            let is_merged = merged_from_semantics.contains(&node.id);
592            let project_name = resolve_project_label(projects_per_semantic.get(&node.id));
593            let mut fields = Vec::new();
594            push_detail(&mut fields, "Name", Some(node.name.clone()));
595            push_detail(&mut fields, "Definition", Some(node.definition.clone()));
596            push_detail(&mut fields, "Description", Some(node.description.clone()));
597            push_detail(&mut fields, "Category", Some(node.category.to_string()));
598            push_detail(&mut fields, "Project", project_name.clone());
599            push_detail(
600                &mut fields,
601                "Functions",
602                semantic_functions_by_node
603                    .get(&node.id)
604                    .map(|functions| functions.join("\n")),
605            );
606            if let Some(target_id) = semantic_merge_targets.get(&node.id) {
607                let merged_into = nodes_by_id
608                    .get(target_id)
609                    .map(|target| format!("sem_{} — {}", target.id, target.name))
610                    .unwrap_or_else(|| format!("sem_{}", target_id));
611                push_detail(&mut fields, "Merged Into", Some(merged_into));
612            }
613            push_detail(
614                &mut fields,
615                "Status",
616                Some(if is_merged {
617                    "Raw (folded into canonical)".to_string()
618                } else {
619                    "Canonical".to_string()
620                }),
621            );
622
623            let label_source = if node.definition.trim().is_empty() {
624                node.name.as_str()
625            } else {
626                node.definition.as_str()
627            };
628            let node_id = format!("sem_{}", node.id);
629            node_details.insert(
630                node_id.clone(),
631                HtmlSelectionDetails {
632                    title: node.name.clone(),
633                    subtitle: Some(format!(
634                        "{} semantic{}",
635                        node.category,
636                        if is_merged { " (raw, merged-away)" } else { "" }
637                    )),
638                    fields,
639                },
640            );
641            bump_node_type_count(&mut node_type_counts, NODE_TYPE_SEMANTIC);
642            let position = semantic_positions
643                .get(&node.id)
644                .copied()
645                .unwrap_or(HtmlNodePosition {
646                    x: SEMANTIC_COLUMN_X,
647                    y: 0.0,
648                });
649            nodes.push(HtmlGraphNode {
650                id: node_id,
651                label: wrap_label(&truncate_text(label_source, 84), 24),
652                node_type: NODE_TYPE_SEMANTIC.to_string(),
653                is_merged,
654                level: 2,
655                shape: "box".to_string(),
656                color: semantic_node_color(is_merged),
657                border_width: if is_merged { 1 } else { 2 },
658                x: position.x,
659                y: position.y,
660                fixed: HtmlNodeFixed::locked(),
661            });
662        }
663
664        for finding in &self.findings {
665            let is_merged = merged_from_findings.contains(&finding.id);
666            let project_name = resolve_project_label(projects_per_finding.get(&finding.id));
667            let category = finding_category_for_finding.get(&finding.id).copied();
668            let mut fields = Vec::new();
669            push_detail(&mut fields, "Title", Some(finding.title.clone()));
670            push_detail(&mut fields, "Severity", Some(finding.severity.to_string()));
671            push_detail(
672                &mut fields,
673                "Category",
674                category.map(|category| category.category.to_string()),
675            );
676            push_detail(
677                &mut fields,
678                "Subcategory",
679                category.map(|category| category.name.clone()),
680            );
681            push_detail(&mut fields, "Root Cause", Some(finding.root_cause.clone()));
682            push_detail(
683                &mut fields,
684                "Description",
685                Some(finding.description.clone()),
686            );
687            push_detail(&mut fields, "Patterns", Some(finding.patterns.clone()));
688            push_detail(&mut fields, "Exploits", Some(finding.exploits.clone()));
689            push_detail(&mut fields, "Project", project_name.clone());
690            if let Some(target_id) = finding_merge_targets.get(&finding.id) {
691                let merged_into = findings_by_id
692                    .get(target_id)
693                    .map(|target| format!("finding_{} — {}", target.id, target.title))
694                    .unwrap_or_else(|| format!("finding_{}", target_id));
695                push_detail(&mut fields, "Merged Into", Some(merged_into));
696            }
697            push_detail(
698                &mut fields,
699                "Status",
700                Some(if is_merged {
701                    "Raw (folded into canonical)".to_string()
702                } else {
703                    "Canonical".to_string()
704                }),
705            );
706
707            let node_id = format!("finding_{}", finding.id);
708            node_details.insert(
709                node_id.clone(),
710                HtmlSelectionDetails {
711                    title: finding.title.clone(),
712                    subtitle: Some(format!(
713                        "{} severity{}",
714                        finding.severity,
715                        if is_merged { " (raw, merged-away)" } else { "" }
716                    )),
717                    fields,
718                },
719            );
720            bump_node_type_count(&mut node_type_counts, NODE_TYPE_FINDING);
721            let position =
722                finding_positions
723                    .get(&finding.id)
724                    .copied()
725                    .unwrap_or(HtmlNodePosition {
726                        x: FINDING_COLUMN_X,
727                        y: 0.0,
728                    });
729            nodes.push(HtmlGraphNode {
730                id: node_id,
731                label: wrap_label(&truncate_text(&finding.title, 84), 24),
732                node_type: NODE_TYPE_FINDING.to_string(),
733                is_merged,
734                level: 3,
735                shape: "box".to_string(),
736                color: finding_node_color(finding.severity, is_merged),
737                border_width: if is_merged { 1 } else { 2 },
738                x: position.x,
739                y: position.y,
740                fixed: HtmlNodeFixed::locked(),
741            });
742        }
743
744        let mut edges = Vec::new();
745
746        for link in &self.project_categories {
747            if let (Some(project), Some(category)) = (
748                projects_by_id.get(&link.project_id),
749                categories_by_id.get(&link.category_id),
750            ) {
751                let edge_id = format!("proj-cat-{}-{}", link.project_id, link.category_id);
752                edge_details.insert(
753                    edge_id.clone(),
754                    HtmlSelectionDetails {
755                        title: "Project -> Category".to_string(),
756                        subtitle: Some("Membership".to_string()),
757                        fields: vec![
758                            HtmlDetailField {
759                                label: "Project".to_string(),
760                                value: project.name.clone(),
761                            },
762                            HtmlDetailField {
763                                label: "Category".to_string(),
764                                value: category.name.to_string(),
765                            },
766                            HtmlDetailField {
767                                label: "Relation".to_string(),
768                                value: "Project belongs to DeFi category".to_string(),
769                            },
770                        ],
771                    },
772                );
773                bump_edge_type_count(&mut edge_type_counts, EDGE_TYPE_PROJECT_CATEGORY);
774                edges.push(HtmlGraphEdge {
775                    id: edge_id,
776                    from: format!("proj_{}", link.project_id),
777                    to: format!("cat_{}", link.category_id),
778                    edge_type: EDGE_TYPE_PROJECT_CATEGORY.to_string(),
779                    arrows: "to".to_string(),
780                    color: HtmlEdgeColor {
781                        color: "#6b7280".to_string(),
782                    },
783                    dashes: true,
784                    width: 1.2,
785                });
786            }
787        }
788
789        for node in &self.nodes {
790            let is_merged = merged_from_semantics.contains(&node.id);
791            let Some(project_ids) = projects_per_semantic.get(&node.id) else {
792                continue;
793            };
794            for project_id in project_ids {
795                let Some(project) = projects_by_id.get(project_id) else {
796                    continue;
797                };
798                let edge_id = format!("proj-sem-{}-{}", project_id, node.id);
799                let mut fields = vec![
800                    HtmlDetailField {
801                        label: "Project".to_string(),
802                        value: project.name.clone(),
803                    },
804                    HtmlDetailField {
805                        label: "Semantic".to_string(),
806                        value: node.name.clone(),
807                    },
808                    HtmlDetailField {
809                        label: "Definition".to_string(),
810                        value: node.definition.clone(),
811                    },
812                    HtmlDetailField {
813                        label: "Status".to_string(),
814                        value: if is_merged {
815                            "Raw (folded into canonical)".to_string()
816                        } else {
817                            "Canonical".to_string()
818                        },
819                    },
820                ];
821                if let Some(target_id) = semantic_merge_targets.get(&node.id) {
822                    let merged_into = nodes_by_id
823                        .get(target_id)
824                        .map(|target| format!("sem_{} — {}", target.id, target.name))
825                        .unwrap_or_else(|| format!("sem_{}", target_id));
826                    fields.push(HtmlDetailField {
827                        label: "Merged Into".to_string(),
828                        value: merged_into,
829                    });
830                }
831                edge_details.insert(
832                    edge_id.clone(),
833                    HtmlSelectionDetails {
834                        title: "Project -> Semantic".to_string(),
835                        subtitle: Some(if is_merged {
836                            "Raw (merged-away) semantic still originates from this project"
837                                .to_string()
838                        } else {
839                            "Contains canonical semantic node".to_string()
840                        }),
841                        fields,
842                    },
843                );
844                bump_edge_type_count(&mut edge_type_counts, EDGE_TYPE_PROJECT_SEMANTIC);
845                edges.push(HtmlGraphEdge {
846                    id: edge_id,
847                    from: format!("proj_{}", project_id),
848                    to: format!("sem_{}", node.id),
849                    edge_type: EDGE_TYPE_PROJECT_SEMANTIC.to_string(),
850                    arrows: "to".to_string(),
851                    color: HtmlEdgeColor {
852                        color: if is_merged {
853                            "#65a30d".to_string()
854                        } else {
855                            "#166534".to_string()
856                        },
857                    },
858                    dashes: is_merged,
859                    width: if is_merged { 1.5 } else { 1.8 },
860                });
861            }
862        }
863
864        for finding in &self.findings {
865            let is_merged = merged_from_findings.contains(&finding.id);
866            let Some(project_ids) = projects_per_finding.get(&finding.id) else {
867                continue;
868            };
869            for project_id in project_ids {
870                let Some(project) = projects_by_id.get(project_id) else {
871                    continue;
872                };
873                let edge_id = format!("proj-finding-{}-{}", project_id, finding.id);
874                let mut fields = vec![
875                    HtmlDetailField {
876                        label: "Project".to_string(),
877                        value: project.name.clone(),
878                    },
879                    HtmlDetailField {
880                        label: "Finding".to_string(),
881                        value: finding.title.clone(),
882                    },
883                    HtmlDetailField {
884                        label: "Severity".to_string(),
885                        value: finding.severity.to_string(),
886                    },
887                    HtmlDetailField {
888                        label: "Status".to_string(),
889                        value: if is_merged {
890                            "Raw (folded into canonical)".to_string()
891                        } else {
892                            "Canonical".to_string()
893                        },
894                    },
895                ];
896                if let Some(target_id) = finding_merge_targets.get(&finding.id) {
897                    let merged_into = findings_by_id
898                        .get(target_id)
899                        .map(|target| format!("finding_{} — {}", target.id, target.title))
900                        .unwrap_or_else(|| format!("finding_{}", target_id));
901                    fields.push(HtmlDetailField {
902                        label: "Merged Into".to_string(),
903                        value: merged_into,
904                    });
905                }
906                edge_details.insert(
907                    edge_id.clone(),
908                    HtmlSelectionDetails {
909                        title: "Project -> Audit Finding".to_string(),
910                        subtitle: Some(if is_merged {
911                            "Raw (merged-away) finding still originates from this project"
912                                .to_string()
913                        } else {
914                            "Canonical finding originates from project".to_string()
915                        }),
916                        fields,
917                    },
918                );
919                bump_edge_type_count(&mut edge_type_counts, EDGE_TYPE_PROJECT_FINDING);
920                edges.push(HtmlGraphEdge {
921                    id: edge_id,
922                    from: format!("proj_{}", project_id),
923                    to: format!("finding_{}", finding.id),
924                    edge_type: EDGE_TYPE_PROJECT_FINDING.to_string(),
925                    arrows: "to".to_string(),
926                    color: HtmlEdgeColor {
927                        color: if is_merged {
928                            "#c2410c".to_string()
929                        } else {
930                            "#b91c1c".to_string()
931                        },
932                    },
933                    dashes: is_merged,
934                    width: if is_merged { 1.5 } else { 1.8 },
935                });
936            }
937        }
938
939        for merge in &self.semantic_merges {
940            let from_label = nodes_by_id
941                .get(&merge.from_semantic_id)
942                .map(|node| node.name.clone())
943                .unwrap_or_else(|| format!("sem_{}", merge.from_semantic_id));
944            let to_label = nodes_by_id
945                .get(&merge.to_semantic_id)
946                .map(|node| node.name.clone())
947                .unwrap_or_else(|| format!("sem_{}", merge.to_semantic_id));
948            let edge_id = format!(
949                "sem-merge-{}-{}",
950                merge.from_semantic_id, merge.to_semantic_id
951            );
952            edge_details.insert(
953                edge_id.clone(),
954                HtmlSelectionDetails {
955                    title: "Semantic Merge".to_string(),
956                    subtitle: Some("Raw semantic folded into canonical semantic".to_string()),
957                    fields: vec![
958                        HtmlDetailField {
959                            label: "Raw Node".to_string(),
960                            value: from_label,
961                        },
962                        HtmlDetailField {
963                            label: "Canonical Node".to_string(),
964                            value: to_label,
965                        },
966                        HtmlDetailField {
967                            label: "Relation".to_string(),
968                            value:
969                                "Raw semantic redirects into its canonical (merge target) semantic"
970                                    .to_string(),
971                        },
972                    ],
973                },
974            );
975            bump_edge_type_count(&mut edge_type_counts, EDGE_TYPE_SEMANTIC_MERGE);
976            edges.push(HtmlGraphEdge {
977                id: edge_id,
978                from: format!("sem_{}", merge.from_semantic_id),
979                to: format!("sem_{}", merge.to_semantic_id),
980                edge_type: EDGE_TYPE_SEMANTIC_MERGE.to_string(),
981                arrows: "to".to_string(),
982                color: HtmlEdgeColor {
983                    color: "#7c3aed".to_string(),
984                },
985                dashes: true,
986                width: 2.8,
987            });
988        }
989
990        for merge in &self.finding_merges {
991            let from_label = findings_by_id
992                .get(&merge.from_finding_id)
993                .map(|finding| finding.title.clone())
994                .unwrap_or_else(|| format!("finding_{}", merge.from_finding_id));
995            let to_label = findings_by_id
996                .get(&merge.to_finding_id)
997                .map(|finding| finding.title.clone())
998                .unwrap_or_else(|| format!("finding_{}", merge.to_finding_id));
999            let edge_id = format!(
1000                "finding-merge-{}-{}",
1001                merge.from_finding_id, merge.to_finding_id
1002            );
1003            edge_details.insert(
1004                edge_id.clone(),
1005                HtmlSelectionDetails {
1006                    title: "Finding Merge".to_string(),
1007                    subtitle: Some("Raw finding folded into canonical finding".to_string()),
1008                    fields: vec![
1009                        HtmlDetailField {
1010                            label: "Raw Node".to_string(),
1011                            value: from_label,
1012                        },
1013                        HtmlDetailField {
1014                            label: "Canonical Node".to_string(),
1015                            value: to_label,
1016                        },
1017                        HtmlDetailField {
1018                            label: "Relation".to_string(),
1019                            value:
1020                                "Raw finding redirects into its canonical (merge target) finding"
1021                                    .to_string(),
1022                        },
1023                    ],
1024                },
1025            );
1026            bump_edge_type_count(&mut edge_type_counts, EDGE_TYPE_FINDING_MERGE);
1027            edges.push(HtmlGraphEdge {
1028                id: edge_id,
1029                from: format!("finding_{}", merge.from_finding_id),
1030                to: format!("finding_{}", merge.to_finding_id),
1031                edge_type: EDGE_TYPE_FINDING_MERGE.to_string(),
1032                arrows: "to".to_string(),
1033                color: HtmlEdgeColor {
1034                    color: "#ea580c".to_string(),
1035                },
1036                dashes: true,
1037                width: 2.8,
1038            });
1039        }
1040
1041        for link in &self.semantic_finding_links {
1042            let semantic_label = nodes_by_id
1043                .get(&link.semantic_node_id)
1044                .map(|node| node.name.clone())
1045                .unwrap_or_else(|| format!("sem_{}", link.semantic_node_id));
1046            let finding_label = findings_by_id
1047                .get(&link.audit_finding_id)
1048                .map(|finding| finding.title.clone())
1049                .unwrap_or_else(|| format!("finding_{}", link.audit_finding_id));
1050            let edge_id = format!(
1051                "semantic-finding-{}-{}",
1052                link.semantic_node_id, link.audit_finding_id
1053            );
1054            edge_details.insert(
1055                edge_id.clone(),
1056                HtmlSelectionDetails {
1057                    title: "Semantic -> Finding".to_string(),
1058                    subtitle: Some("Linked concept".to_string()),
1059                    fields: vec![
1060                        HtmlDetailField {
1061                            label: "Semantic".to_string(),
1062                            value: semantic_label,
1063                        },
1064                        HtmlDetailField {
1065                            label: "Finding".to_string(),
1066                            value: finding_label,
1067                        },
1068                    ],
1069                },
1070            );
1071            bump_edge_type_count(&mut edge_type_counts, EDGE_TYPE_SEMANTIC_FINDING);
1072            edges.push(HtmlGraphEdge {
1073                id: edge_id,
1074                from: format!("sem_{}", link.semantic_node_id),
1075                to: format!("finding_{}", link.audit_finding_id),
1076                edge_type: EDGE_TYPE_SEMANTIC_FINDING.to_string(),
1077                arrows: "to".to_string(),
1078                color: HtmlEdgeColor {
1079                    color: "#2563eb".to_string(),
1080                },
1081                dashes: false,
1082                width: 2.2,
1083            });
1084        }
1085
1086        let edge_count = edges.len();
1087        let large_graph_mode =
1088            nodes.len() > LARGE_GRAPH_NODE_THRESHOLD || edge_count > LARGE_GRAPH_EDGE_THRESHOLD;
1089        let node_filters = html_node_filters(&node_type_counts);
1090        let edge_filters = html_edge_filters(&edge_type_counts, large_graph_mode);
1091        let graph_payload = HtmlGraphPayload {
1092            nodes,
1093            edges,
1094            node_filters,
1095            edge_filters,
1096            viewport_edge_limit,
1097            stats: HtmlGraphStats {
1098                project_count: self.projects.len(),
1099                category_count: self.categories.len(),
1100                semantic_count: self.nodes.len(),
1101                active_semantic_count: self.nodes.len().saturating_sub(merged_from_semantics.len()),
1102                finding_count: self.findings.len(),
1103                active_finding_count: self
1104                    .findings
1105                    .len()
1106                    .saturating_sub(merged_from_findings.len()),
1107                edge_count,
1108                large_graph_mode,
1109            },
1110        };
1111        let details_payload = HtmlGraphDetailsPayload {
1112            node_details,
1113            edge_details,
1114        };
1115        let graph_payload_js = json_for_js(&graph_payload)?;
1116        let details_payload_js = json_for_js(&details_payload)?;
1117        let graph_data_script_url_json = json_for_js(&graph_data_script_url)?;
1118        let details_script_url_json = json_for_js(&details_script_url)?;
1119
1120        let mut html = String::new();
1121        html.push_str(
1122            r#"<!DOCTYPE html>
1123<html lang="en">
1124<head>
1125  <meta charset="utf-8">
1126  <meta name="viewport" content="width=device-width, initial-scale=1">
1127  <title>Knowdit Knowledge Graph</title>
1128  <link rel="preconnect" href="https://unpkg.com">
1129  <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
1130  <style>
1131    :root {
1132      color-scheme: light;
1133      --bg: #f8fafc;
1134      --panel: #ffffff;
1135      --border: #cbd5e1;
1136      --text: #0f172a;
1137      --muted: #475569;
1138    }
1139    * { box-sizing: border-box; }
1140    body {
1141      margin: 0;
1142      font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1143      color: var(--text);
1144      background: var(--bg);
1145    }
1146    header {
1147      padding: 16px 20px;
1148      border-bottom: 1px solid var(--border);
1149      background: var(--panel);
1150      position: sticky;
1151      top: 0;
1152      z-index: 10;
1153    }
1154    .header-top {
1155      display: flex;
1156      align-items: flex-start;
1157      justify-content: space-between;
1158      gap: 16px;
1159      flex-wrap: wrap;
1160    }
1161    .title-block {
1162      min-width: 0;
1163      flex: 1 1 320px;
1164    }
1165    h1 {
1166      margin: 0;
1167      font-size: 22px;
1168    }
1169    .subtitle {
1170      margin-top: 6px;
1171      color: var(--muted);
1172      font-size: 14px;
1173    }
1174    .toolbar {
1175      display: flex;
1176      gap: 12px;
1177      align-items: center;
1178      flex-wrap: wrap;
1179      margin-top: 14px;
1180    }
1181    button {
1182      border: 1px solid var(--border);
1183      background: #eff6ff;
1184      color: #1d4ed8;
1185      border-radius: 8px;
1186      padding: 8px 12px;
1187      font-weight: 600;
1188      cursor: pointer;
1189    }
1190    button:hover {
1191      background: #dbeafe;
1192    }
1193    .stats {
1194      color: var(--muted);
1195      font-size: 14px;
1196    }
1197    .layout {
1198      display: grid;
1199      grid-template-columns: minmax(0, 1fr) 380px;
1200      min-height: calc(100vh - 126px);
1201      align-items: stretch;
1202    }
1203    #graph {
1204      height: auto;
1205      min-height: 720px;
1206      border-right: 1px solid var(--border);
1207      background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
1208      position: relative;
1209    }
1210    aside {
1211      background: var(--panel);
1212      padding: 18px;
1213      overflow: auto;
1214    }
1215    .panel-block + .panel-block {
1216      margin-top: 18px;
1217    }
1218    .legend {
1219      display: grid;
1220      grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
1221      gap: 8px 12px;
1222    }
1223    .legend-item {
1224      display: flex;
1225      align-items: center;
1226      gap: 8px;
1227      color: var(--muted);
1228      font-size: 13px;
1229    }
1230    .legend-note {
1231      margin: 10px 0 0 0;
1232      color: var(--muted);
1233      font-size: 12px;
1234      line-height: 1.5;
1235    }
1236    .panel-title {
1237      margin: 0;
1238      font-size: 16px;
1239    }
1240    .legend-swatch {
1241      width: 14px;
1242      height: 14px;
1243      border-radius: 4px;
1244      border: 1px solid rgba(15, 23, 42, 0.2);
1245      flex: 0 0 14px;
1246    }
1247    .graph-status {
1248      position: absolute;
1249      inset: 0;
1250      display: flex;
1251      align-items: center;
1252      justify-content: center;
1253      padding: 24px;
1254      text-align: center;
1255      color: var(--muted);
1256      font-size: 15px;
1257      line-height: 1.6;
1258      background: rgba(248, 250, 252, 0.92);
1259      z-index: 2;
1260    }
1261    .graph-status.is-error {
1262      color: #b91c1c;
1263      background: rgba(254, 242, 242, 0.96);
1264    }
1265    .graph-warning {
1266      flex: 0 1 560px;
1267      max-width: min(560px, 100%);
1268      padding: 12px 14px;
1269      border: 1px solid #f59e0b;
1270      border-radius: 12px;
1271      background: rgba(255, 251, 235, 0.96);
1272      color: #92400e;
1273      font-size: 13px;
1274      line-height: 1.5;
1275      box-shadow: 0 12px 24px rgba(15, 23, 42, 0.12);
1276    }
1277    .graph-warning[hidden] {
1278      display: none;
1279    }
1280    .edge-limit-control {
1281      margin-top: 12px;
1282      padding: 12px;
1283      border: 1px solid var(--border);
1284      border-radius: 10px;
1285      background: #f8fafc;
1286    }
1287    .edge-limit-header {
1288      display: flex;
1289      align-items: baseline;
1290      justify-content: space-between;
1291      gap: 8px;
1292    }
1293    .edge-limit-label {
1294      font-size: 14px;
1295      font-weight: 600;
1296      color: var(--text);
1297    }
1298    .edge-limit-value {
1299      font-size: 12px;
1300      color: var(--muted);
1301      white-space: nowrap;
1302    }
1303    .edge-limit-slider {
1304      width: 100%;
1305      margin-top: 10px;
1306      accent-color: #2563eb;
1307    }
1308    .edge-limit-note {
1309      margin: 10px 0 0 0;
1310      color: var(--muted);
1311      font-size: 12px;
1312      line-height: 1.5;
1313    }
1314    .edge-filter-list {
1315      margin-top: 12px;
1316      display: grid;
1317      gap: 10px;
1318    }
1319    .node-filter-list {
1320      margin-top: 12px;
1321      display: grid;
1322      gap: 10px;
1323    }
1324    .node-filter-option {
1325      display: flex;
1326      align-items: center;
1327      gap: 10px;
1328      padding: 10px 12px;
1329      border: 1px solid var(--border);
1330      border-radius: 10px;
1331      background: #f8fafc;
1332      cursor: pointer;
1333    }
1334    .node-filter-option.is-disabled {
1335      opacity: 0.55;
1336      cursor: not-allowed;
1337    }
1338    .node-filter-option input {
1339      margin: 0;
1340      cursor: pointer;
1341    }
1342    .node-filter-option.is-disabled input {
1343      cursor: not-allowed;
1344    }
1345    .node-filter-swatch {
1346      width: 14px;
1347      height: 14px;
1348      border-radius: 4px;
1349      border: 2px solid var(--node-border);
1350      background: var(--node-background);
1351      flex: 0 0 14px;
1352    }
1353    .edge-filter-option {
1354      display: flex;
1355      align-items: center;
1356      gap: 10px;
1357      padding: 10px 12px;
1358      border: 1px solid var(--border);
1359      border-radius: 10px;
1360      background: #f8fafc;
1361      cursor: pointer;
1362    }
1363    .edge-filter-option.is-disabled {
1364      opacity: 0.55;
1365      cursor: not-allowed;
1366    }
1367    .edge-filter-option input {
1368      margin: 0;
1369      cursor: pointer;
1370    }
1371    .edge-filter-option.is-disabled input {
1372      cursor: not-allowed;
1373    }
1374    .edge-filter-swatch {
1375      width: 28px;
1376      flex: 0 0 28px;
1377      border-top-width: 3px;
1378      border-top-style: solid;
1379      border-top-color: var(--edge-color);
1380    }
1381    .edge-filter-text {
1382      display: flex;
1383      align-items: baseline;
1384      justify-content: space-between;
1385      gap: 8px;
1386      width: 100%;
1387    }
1388    .edge-filter-name {
1389      font-size: 14px;
1390      color: var(--text);
1391    }
1392    .edge-filter-meta {
1393      font-size: 12px;
1394      color: var(--muted);
1395      white-space: nowrap;
1396    }
1397    .detail-title {
1398      margin: 0;
1399      font-size: 18px;
1400    }
1401    .detail-subtitle {
1402      margin-top: 4px;
1403      color: var(--muted);
1404      font-size: 14px;
1405    }
1406    .hint {
1407      margin: 0;
1408      color: var(--muted);
1409      font-size: 14px;
1410      line-height: 1.5;
1411    }
1412    dl {
1413      margin: 16px 0 0 0;
1414      display: grid;
1415      gap: 10px;
1416    }
1417    dt {
1418      font-size: 12px;
1419      font-weight: 700;
1420      text-transform: uppercase;
1421      letter-spacing: 0.03em;
1422      color: var(--muted);
1423      margin: 0;
1424    }
1425    dd {
1426      margin: 2px 0 0 0;
1427      white-space: pre-wrap;
1428      line-height: 1.45;
1429      font-size: 14px;
1430    }
1431    @media (max-width: 1100px) {
1432      .header-top {
1433        align-items: stretch;
1434      }
1435      .graph-warning {
1436        max-width: 100%;
1437      }
1438      .layout {
1439        grid-template-columns: 1fr;
1440      }
1441      #graph {
1442        height: 60vh;
1443        min-height: 480px;
1444        border-right: none;
1445        border-bottom: 1px solid var(--border);
1446      }
1447    }
1448  </style>
1449</head>
1450<body>
1451  <header>
1452    <div class="header-top">
1453      <div class="title-block">
1454        <h1>Knowdit Knowledge Graph</h1>
1455        <div class="subtitle">
1456          Interactive HTML export. Node labels stay concise; click any node or edge to inspect the full details.
1457        </div>
1458      </div>
1459      <div id="graph-warning" class="graph-warning" hidden></div>
1460    </div>
1461    <div class="toolbar">
1462      <button id="fit-button" type="button">Fit graph</button>
1463      <button id="stabilize-button" type="button">Stabilize layout</button>
1464      <div id="summary-stats" class="stats"></div>
1465    </div>
1466  </header>
1467  <div class="layout">
1468    <div id="graph"></div>
1469    <aside>
1470      <section class="panel-block">
1471        <div class="legend">
1472          <div class="legend-item"><span class="legend-swatch" style="background:#dcfce7;border-color:#16a34a;"></span>Project</div>
1473          <div class="legend-item"><span class="legend-swatch" style="background:#dbeafe;border-color:#2563eb;"></span>DeFi Category</div>
1474          <div class="legend-item"><span class="legend-swatch" style="background:#fff4cc;border-color:#a67c00;"></span>Semantic</div>
1475          <div class="legend-item"><span class="legend-swatch" style="background:#fee2e2;border-color:#dc2626;"></span>High Finding</div>
1476          <div class="legend-item"><span class="legend-swatch" style="background:#fef3c7;border-color:#d97706;"></span>Medium Finding</div>
1477          <div class="legend-item"><span class="legend-swatch" style="background:#f5f5f4;border-color:#57534e;"></span>Low Finding</div>
1478        </div>
1479        <p class="legend-note">Dashed project edges mark merged source nodes. Thick dashed purple/orange edges show merged-to-canonical relationships.</p>
1480        <p id="performance-note" class="legend-note"></p>
1481      </section>
1482      <section class="panel-block">
1483        <h2 class="panel-title">Node filters</h2>
1484        <p class="hint">Hide or show whole node classes while keeping the remaining layout stable.</p>
1485        <div id="node-filters" class="node-filter-list"></div>
1486        <div id="merged-node-filters" class="node-filter-list"></div>
1487        <p id="node-filter-summary" class="legend-note"></p>
1488        <p id="merged-node-filter-summary" class="legend-note"></p>
1489      </section>
1490      <section class="panel-block">
1491        <h2 class="panel-title">Edge filters</h2>
1492        <p class="hint">Toggle relationship types on and off to reduce edge density without changing the node set.</p>
1493        <div class="edge-limit-control">
1494          <div class="edge-limit-header">
1495            <span class="edge-limit-label">Viewport edge limit</span>
1496            <span id="edge-limit-value" class="edge-limit-value"></span>
1497          </div>
1498          <input id="edge-limit-slider" class="edge-limit-slider" type="range" min="1" max="1" step="1">
1499          <p id="edge-limit-note" class="edge-limit-note"></p>
1500        </div>
1501        <div id="edge-filters" class="edge-filter-list"></div>
1502        <p id="edge-filter-summary" class="legend-note"></p>
1503      </section>
1504      <section id="details-panel" class="panel-block">
1505        <h2 class="detail-title">Selection details</h2>
1506        <p class="hint">Click a node or edge in the graph to inspect its full metadata. Large detail payloads load on demand.</p>
1507      </section>
1508    </aside>
1509  </div>
1510  <script>
1511    const graphDataScriptUrl = "#,
1512        );
1513        html.push_str(&graph_data_script_url_json);
1514        html.push_str(
1515            r#";
1516    const detailsScriptUrl = "#,
1517        );
1518        html.push_str(&details_script_url_json);
1519        html.push_str(
1520            r#";
1521    let detailsPayload = window.__KNOWDIT_GRAPH_DETAILS__ || null;
1522    let detailsLoadPromise = null;
1523    const container = document.getElementById('graph');
1524    const performanceNote = document.getElementById('performance-note');
1525
1526    function renderGraphStatus(message, isError = false) {
1527      container.innerHTML = '';
1528      const status = document.createElement('div');
1529      status.className = isError ? 'graph-status is-error' : 'graph-status';
1530      status.textContent = message;
1531      container.appendChild(status);
1532    }
1533
1534    function clearGraphStatus() {
1535      const status = container.querySelector('.graph-status');
1536      if (status) {
1537        status.remove();
1538      }
1539    }
1540
1541    function loadScript(url) {
1542      return new Promise((resolve, reject) => {
1543        const script = document.createElement('script');
1544        script.src = url;
1545        script.async = true;
1546        script.onload = resolve;
1547        script.onerror = () => reject(new Error(`Failed to load ${url}. Keep the exported asset files together.`));
1548        document.head.appendChild(script);
1549      });
1550    }
1551
1552    async function ensureGraphDataLoaded() {
1553      if (window.__KNOWDIT_GRAPH_DATA__) {
1554        return window.__KNOWDIT_GRAPH_DATA__;
1555      }
1556      await loadScript(graphDataScriptUrl);
1557      if (!window.__KNOWDIT_GRAPH_DATA__) {
1558        throw new Error('Graph data payload did not initialize correctly.');
1559      }
1560      return window.__KNOWDIT_GRAPH_DATA__;
1561    }
1562
1563    async function ensureDetailsLoaded() {
1564      if (detailsPayload) {
1565        return detailsPayload;
1566      }
1567      if (!detailsLoadPromise) {
1568        detailsLoadPromise = loadScript(detailsScriptUrl).then(() => {
1569          if (!window.__KNOWDIT_GRAPH_DETAILS__) {
1570            throw new Error('Detail payload did not initialize correctly.');
1571          }
1572          detailsPayload = window.__KNOWDIT_GRAPH_DETAILS__;
1573          return detailsPayload;
1574        });
1575      }
1576      return detailsLoadPromise;
1577    }
1578
1579    async function bootstrap() {
1580      renderGraphStatus('Loading graph data…');
1581      const graphData = await ensureGraphDataLoaded();
1582      clearGraphStatus();
1583
1584      const nodes = new vis.DataSet(graphData.nodes);
1585      const nodeFilters = graphData.nodeFilters;
1586      const edgeFilters = graphData.edgeFilters;
1587    const nodeFiltersContainer = document.getElementById('node-filters');
1588      const mergedNodeFiltersContainer = document.getElementById('merged-node-filters');
1589      const nodeFilterSummary = document.getElementById('node-filter-summary');
1590      const mergedNodeFilterSummary = document.getElementById('merged-node-filter-summary');
1591      const edgeFiltersContainer = document.getElementById('edge-filters');
1592      const edgeFilterSummary = document.getElementById('edge-filter-summary');
1593      const edgeLimitWarning = document.getElementById('graph-warning');
1594      const edgeLimitSlider = document.getElementById('edge-limit-slider');
1595      const edgeLimitValue = document.getElementById('edge-limit-value');
1596      const edgeLimitNote = document.getElementById('edge-limit-note');
1597      const sidebar = document.querySelector('.layout > aside');
1598      const headerElement = document.querySelector('header');
1599      const nodeFilterState = new Map(nodeFilters.map((filter) => [filter.id, filter.enabledByDefault]));
1600      // "Merged-away" = raw nodes folded into a canonical via semantic_merge /
1601      // finding_merge. The toggle hides the raw provenance siblings, leaving
1602      // only the canonical (un-merged) nodes plus everything else.
1603      const mergedNodeFilters = [
1604        {
1605          id: 'merged-semantic',
1606          label: 'Raw Merged-Away Semantics',
1607          count: graphData.nodes.filter((node) => node.nodeType === 'semantic' && node.isMerged).length,
1608          color: { background: '#fef3c7', border: '#b45309' },
1609        },
1610        {
1611          id: 'merged-finding',
1612          label: 'Raw Merged-Away Findings',
1613          count: graphData.nodes.filter((node) => node.nodeType === 'finding' && node.isMerged).length,
1614          color: { background: '#fde68a', border: '#b45309' },
1615        },
1616      ];
1617      const mergedNodeFilterState = new Map(mergedNodeFilters.map((filter) => [filter.id, true]));
1618      const edgeFilterState = new Map(edgeFilters.map((filter) => [filter.id, filter.enabledByDefault]));
1619      const edgeFilterById = new Map(edgeFilters.map((filter) => [filter.id, filter]));
1620      const edgeFilterOrder = new Map(edgeFilters.map((filter, index) => [filter.id, index]));
1621      const nodeById = new Map(graphData.nodes.map((node) => [node.id, node]));
1622      const nodeTypeById = new Map();
1623      const nodeIdsByType = new Map();
1624      const defaultViewportEdgeLimit = Math.max(1, graphData.viewportEdgeLimit);
1625      const edgeLimitSliderMin = Math.min(defaultViewportEdgeLimit, 25);
1626      const edgeLimitSliderMax = Math.max(defaultViewportEdgeLimit, graphData.stats.edgeCount);
1627      const edgeLimitSliderStep = graphData.stats.edgeCount <= 500
1628        ? 10
1629        : graphData.stats.edgeCount <= 2000
1630          ? 25
1631          : graphData.stats.edgeCount <= 5000
1632            ? 50
1633            : 100;
1634      const lockedNodePositions = new Map(
1635        graphData.nodes
1636          .filter((node) => Number.isFinite(node.x) && Number.isFinite(node.y))
1637          .map((node) => [node.id, { x: node.x, y: node.y }])
1638      );
1639      const edgeOrderById = new Map(graphData.edges.map((edge, index) => [edge.id, index]));
1640      for (const node of graphData.nodes) {
1641        nodeTypeById.set(node.id, node.nodeType);
1642        if (!nodeIdsByType.has(node.nodeType)) {
1643          nodeIdsByType.set(node.nodeType, []);
1644        }
1645        nodeIdsByType.get(node.nodeType).push(node.id);
1646      }
1647      let layoutLocked = lockedNodePositions.size === graphData.nodes.length;
1648      let hiddenNodeIds = new Set();
1649      let viewportVisibleNodeIds = new Set(graphData.nodes.map((node) => node.id));
1650      let viewportSyncHandle = null;
1651      let eligibleVisibleEdgeCount = 0;
1652      let edgeLimitExceeded = false;
1653      let currentViewportEdgeLimit = defaultViewportEdgeLimit;
1654      let graphHeightSyncHandle = null;
1655
1656      function edgeRenderPriority(edge) {
1657        const filter = edgeFilterById.get(edge.edgeType);
1658        return {
1659          viewportPenalty: filter && filter.viewportCulled ? 0 : 1,
1660          filterOrder: edgeFilterOrder.get(edge.edgeType) ?? Number.MAX_SAFE_INTEGER,
1661          edgeOrder: edgeOrderById.get(edge.id) ?? Number.MAX_SAFE_INTEGER,
1662        };
1663      }
1664
1665      function clampViewportEdgeLimit(rawValue) {
1666        const nextValue = Number(rawValue);
1667        if (!Number.isFinite(nextValue) || nextValue <= 0) {
1668          return defaultViewportEdgeLimit;
1669        }
1670        return Math.min(edgeLimitSliderMax, Math.max(edgeLimitSliderMin, nextValue));
1671      }
1672
1673      function updateEdgeLimitControls() {
1674        edgeLimitSlider.min = String(edgeLimitSliderMin);
1675        edgeLimitSlider.max = String(edgeLimitSliderMax);
1676        edgeLimitSlider.step = String(edgeLimitSliderStep);
1677        edgeLimitSlider.value = String(currentViewportEdgeLimit);
1678        edgeLimitValue.textContent =
1679          `${currentViewportEdgeLimit} current · ${defaultViewportEdgeLimit} exported default`;
1680        edgeLimitNote.textContent =
1681          `Adjust the per-view render cap for this page only. The exported default remains ${defaultViewportEdgeLimit}.`;
1682      }
1683
1684      function limitEdgeRecords(candidateEdges) {
1685        const eligibleCount = candidateEdges.length;
1686        const activeViewportEdgeLimit = currentViewportEdgeLimit;
1687        if (eligibleCount <= activeViewportEdgeLimit) {
1688          return {
1689            edgeRecords: candidateEdges,
1690            eligibleCount,
1691            limitExceeded: false,
1692          };
1693        }
1694
1695        const prioritized = candidateEdges.slice().sort((left, right) => {
1696          const leftPriority = edgeRenderPriority(left);
1697          const rightPriority = edgeRenderPriority(right);
1698          if (leftPriority.viewportPenalty !== rightPriority.viewportPenalty) {
1699            return leftPriority.viewportPenalty - rightPriority.viewportPenalty;
1700          }
1701          if (leftPriority.filterOrder !== rightPriority.filterOrder) {
1702            return leftPriority.filterOrder - rightPriority.filterOrder;
1703          }
1704          return leftPriority.edgeOrder - rightPriority.edgeOrder;
1705        });
1706
1707        return {
1708          edgeRecords: prioritized.slice(0, activeViewportEdgeLimit),
1709          eligibleCount,
1710          limitExceeded: true,
1711        };
1712      }
1713
1714      const initialEdgeSelection = limitEdgeRecords(
1715        graphData.edges.filter((edge) => edgeBoolean(edgeFilterState.get(edge.edgeType)))
1716      );
1717      eligibleVisibleEdgeCount = initialEdgeSelection.eligibleCount;
1718      edgeLimitExceeded = initialEdgeSelection.limitExceeded;
1719      const initialEdges = initialEdgeSelection.edgeRecords;
1720      const edges = new vis.DataSet(initialEdges);
1721    const network = new vis.Network(
1722      container,
1723      { nodes, edges },
1724      {
1725        autoResize: true,
1726        layout: {
1727          improvedLayout: false
1728        },
1729        interaction: {
1730          hover: true,
1731          navigationButtons: true,
1732          keyboard: true,
1733          multiselect: true,
1734          hideEdgesOnDrag: true
1735        },
1736        physics: false,
1737        nodes: {
1738          margin: 12,
1739          widthConstraint: { maximum: 260 },
1740          font: { size: 14, face: 'Inter, ui-sans-serif, system-ui, sans-serif' }
1741        },
1742        edges: {
1743          smooth: { type: 'cubicBezier', forceDirection: 'horizontal', roundness: 0.28 },
1744          arrows: { to: { enabled: true, scaleFactor: 0.65 } }
1745        }
1746      }
1747    );
1748    window.__knowditNetwork = network;
1749    window.__knowditNodeFilterState = nodeFilterState;
1750    window.__knowditEdgeFilterState = edgeFilterState;
1751
1752    const summaryStats = document.getElementById('summary-stats');
1753    summaryStats.textContent =
1754      `${graphData.stats.projectCount} projects · ` +
1755      `${graphData.stats.categoryCount} categories · ` +
1756      `${graphData.stats.semanticCount} semantics (${graphData.stats.activeSemanticCount} active) · ` +
1757      `${graphData.stats.findingCount} findings (${graphData.stats.activeFindingCount} active) · ` +
1758      `${graphData.stats.edgeCount} edges`;
1759
1760    const detailsPanel = document.getElementById('details-panel');
1761      let currentSelection = null;
1762
1763    function mergedNodeFilterIdForNode(node) {
1764      if (!node || !node.isMerged) {
1765        return null;
1766      }
1767      if (node.nodeType === 'semantic') {
1768        return 'merged-semantic';
1769      }
1770      if (node.nodeType === 'finding') {
1771        return 'merged-finding';
1772      }
1773      return null;
1774    }
1775
1776    function shouldHideNode(node) {
1777      if (!node) {
1778        return false;
1779      }
1780      if (!edgeBoolean(nodeFilterState.get(node.nodeType))) {
1781        return true;
1782      }
1783      const mergedFilterId = mergedNodeFilterIdForNode(node);
1784      if (mergedFilterId && !edgeBoolean(mergedNodeFilterState.get(mergedFilterId))) {
1785        return true;
1786      }
1787      return false;
1788    }
1789
1790    function isNodeVisible(nodeId) {
1791      const node = nodeById.get(nodeId);
1792      return node ? !shouldHideNode(node) : true;
1793    }
1794
1795    function edgeBoolean(value) {
1796      return value !== false;
1797    }
1798
1799    function visibleNodeIds() {
1800      return graphData.nodes
1801        .filter((node) => !shouldHideNode(node))
1802        .map((node) => node.id);
1803    }
1804
1805    function syncGraphHeight({ redraw = true } = {}) {
1806      if (graphHeightSyncHandle !== null) {
1807        window.cancelAnimationFrame(graphHeightSyncHandle);
1808      }
1809
1810      graphHeightSyncHandle = window.requestAnimationFrame(() => {
1811        graphHeightSyncHandle = null;
1812
1813        if (window.matchMedia('(max-width: 1100px)').matches) {
1814          container.style.height = '';
1815          if (redraw) {
1816            network.redraw();
1817            scheduleVisibleEdgeSync();
1818          }
1819          return;
1820        }
1821
1822        const headerHeight = headerElement ? headerElement.getBoundingClientRect().height : 126;
1823        const minHeight = Math.max(720, window.innerHeight - headerHeight);
1824        const sidebarHeight = sidebar ? sidebar.scrollHeight : minHeight;
1825        const nextHeight = `${Math.ceil(Math.max(minHeight, sidebarHeight))}px`;
1826        const changed = container.style.height !== nextHeight;
1827        container.style.height = nextHeight;
1828
1829        if (redraw || changed) {
1830          network.redraw();
1831          scheduleVisibleEdgeSync();
1832        }
1833      });
1834    }
1835
1836    function updatePerformanceNote() {
1837      const viewportCullingActive = edgeFilters.some((filter) =>
1838        filter.viewportCulled && edgeBoolean(edgeFilterState.get(filter.id))
1839      );
1840      const baseMessage = graphData.stats.largeGraphMode
1841        ? 'Large graph mode: only lightweight edge classes start enabled, and full selection details load from a companion file on first click.'
1842        : 'Full selection details load from a companion file on first click to keep the page responsive.';
1843      const viewportMessage = viewportCullingActive
1844        ? ' Dense project relationship edges render only for nodes inside the current viewport.'
1845        : '';
1846      performanceNote.textContent =
1847        `${baseMessage}${viewportMessage} Each view currently renders at most ${currentViewportEdgeLimit} edges; the exported default is ${defaultViewportEdgeLimit}.`;
1848    }
1849
1850    function updateEdgeLimitWarning() {
1851      if (!edgeLimitExceeded) {
1852        edgeLimitWarning.hidden = true;
1853        edgeLimitWarning.textContent = '';
1854        return;
1855      }
1856
1857      edgeLimitWarning.hidden = false;
1858      edgeLimitWarning.textContent =
1859        `Too many edges in view (${eligibleVisibleEdgeCount} > ${currentViewportEdgeLimit} limit). ` +
1860        'Zoom in or raise the viewport edge limit in the right sidebar.';
1861    }
1862
1863    function lockCurrentLayout() {
1864      if (layoutLocked) {
1865        return;
1866      }
1867
1868      const updates = graphData.nodes
1869        .map((node) => {
1870          const position = network.getPosition(node.id);
1871          if (!Number.isFinite(position.x) || !Number.isFinite(position.y)) {
1872            return null;
1873          }
1874          lockedNodePositions.set(node.id, position);
1875          return {
1876            id: node.id,
1877            x: position.x,
1878            y: position.y,
1879            fixed: { x: true, y: true },
1880          };
1881        })
1882        .filter(Boolean);
1883
1884      if (updates.length === 0) {
1885        return;
1886      }
1887
1888      nodes.update(updates);
1889      network.setOptions({
1890        layout: { hierarchical: false },
1891        physics: false,
1892      });
1893      layoutLocked = true;
1894      reapplyLockedLayout();
1895    }
1896
1897    function reapplyLockedLayout() {
1898      if (!layoutLocked) {
1899        return;
1900      }
1901
1902      const updates = graphData.nodes
1903        .map((node) => {
1904          const position = lockedNodePositions.get(node.id);
1905          if (!position) {
1906            return null;
1907          }
1908          return {
1909            id: node.id,
1910            x: position.x,
1911            y: position.y,
1912            fixed: { x: true, y: true },
1913          };
1914        })
1915        .filter(Boolean);
1916
1917      if (updates.length > 0) {
1918        nodes.update(updates);
1919      }
1920    }
1921
1922    function getNodePosition(nodeId) {
1923      const locked = lockedNodePositions.get(nodeId);
1924      if (locked) {
1925        return locked;
1926      }
1927      const position = network.getPosition(nodeId);
1928      if (!Number.isFinite(position.x) || !Number.isFinite(position.y)) {
1929        return null;
1930      }
1931      return position;
1932    }
1933
1934    function currentViewportBounds() {
1935      if (!layoutLocked) {
1936        return null;
1937      }
1938      const topLeft = network.DOMtoCanvas({ x: 0, y: 0 });
1939      const bottomRight = network.DOMtoCanvas({ x: container.clientWidth, y: container.clientHeight });
1940      const scale = Math.max(network.getScale(), 0.01);
1941      const canvasMargin = 180 / scale;
1942      return {
1943        minX: Math.min(topLeft.x, bottomRight.x) - canvasMargin,
1944        maxX: Math.max(topLeft.x, bottomRight.x) + canvasMargin,
1945        minY: Math.min(topLeft.y, bottomRight.y) - canvasMargin,
1946        maxY: Math.max(topLeft.y, bottomRight.y) + canvasMargin,
1947      };
1948    }
1949
1950    function computeViewportVisibleNodeIds() {
1951      const bounds = currentViewportBounds();
1952      if (!bounds) {
1953        return new Set(visibleNodeIds());
1954      }
1955
1956      const nextViewportNodeIds = new Set();
1957      for (const node of graphData.nodes) {
1958        if (hiddenNodeIds.has(node.id)) {
1959          continue;
1960        }
1961        const position = getNodePosition(node.id);
1962        if (!position) {
1963          nextViewportNodeIds.add(node.id);
1964          continue;
1965        }
1966        if (
1967          position.x >= bounds.minX &&
1968          position.x <= bounds.maxX &&
1969          position.y >= bounds.minY &&
1970          position.y <= bounds.maxY
1971        ) {
1972          nextViewportNodeIds.add(node.id);
1973        }
1974      }
1975      return nextViewportNodeIds;
1976    }
1977
1978    function shouldRenderEdge(edge) {
1979      if (!edgeBoolean(edgeFilterState.get(edge.edgeType))) {
1980        return false;
1981      }
1982      if (hiddenNodeIds.has(edge.from) || hiddenNodeIds.has(edge.to)) {
1983        return false;
1984      }
1985      const filter = edgeFilterById.get(edge.edgeType);
1986      if (filter && filter.viewportCulled) {
1987        return viewportVisibleNodeIds.has(edge.from) && viewportVisibleNodeIds.has(edge.to);
1988      }
1989      return true;
1990    }
1991
1992    function updateNodeFilterSummary() {
1993      const availableFilterCount = nodeFilters.filter((filter) => filter.count > 0).length;
1994      let enabledFilterCount = 0;
1995      for (const filter of nodeFilters) {
1996        if (filter.count === 0) {
1997          continue;
1998        }
1999        if (edgeBoolean(nodeFilterState.get(filter.id))) {
2000          enabledFilterCount += 1;
2001        }
2002      }
2003      const visibleNodeCount = graphData.nodes.filter((node) => !shouldHideNode(node)).length;
2004      nodeFilterSummary.textContent =
2005        `Showing ${visibleNodeCount} / ${graphData.nodes.length} nodes across ` +
2006        `${enabledFilterCount} / ${availableFilterCount} available node types.`;
2007    }
2008
2009    function updateMergedNodeFilterSummary() {
2010      const hiddenMergedCount = mergedNodeFilters.reduce((count, filter) => {
2011        if (edgeBoolean(mergedNodeFilterState.get(filter.id))) {
2012          return count;
2013        }
2014        return count + filter.count;
2015      }, 0);
2016      const totalMergedCount = mergedNodeFilters.reduce((count, filter) => count + filter.count, 0);
2017
2018      if (totalMergedCount === 0) {
2019        mergedNodeFilterSummary.textContent = 'No raw merged-away semantic or finding nodes are present in this export.';
2020        return;
2021      }
2022
2023      if (hiddenMergedCount === 0) {
2024        mergedNodeFilterSummary.textContent = `Showing all ${totalMergedCount} raw merged-away semantic/finding nodes.`;
2025        return;
2026      }
2027
2028      mergedNodeFilterSummary.textContent = `Hiding ${hiddenMergedCount} / ${totalMergedCount} raw merged-away semantic/finding nodes.`;
2029    }
2030
2031    function updateEdgeFilterSummary() {
2032      const availableFilterCount = edgeFilters.filter((filter) => filter.count > 0).length;
2033      let enabledFilterCount = 0;
2034      const viewportCullingActive = edgeFilters.some((filter) =>
2035        filter.viewportCulled && edgeBoolean(edgeFilterState.get(filter.id))
2036      );
2037      for (const filter of edgeFilters) {
2038        if (filter.count === 0) {
2039          continue;
2040        }
2041        if (edgeBoolean(edgeFilterState.get(filter.id))) {
2042          enabledFilterCount += 1;
2043        }
2044      }
2045      edgeFilterSummary.textContent =
2046        `Rendering ${edges.getIds().length} / ${graphData.stats.edgeCount} edges across ` +
2047        `${enabledFilterCount} / ${availableFilterCount} available edge types.` +
2048        (viewportCullingActive
2049          ? ' Dense project relationship edges are limited to nodes in the current viewport.'
2050          : '') +
2051        (edgeLimitExceeded
2052          ? ` Current view matches ${eligibleVisibleEdgeCount} edges, so rendering is capped at ${currentViewportEdgeLimit}.`
2053          : '');
2054    }
2055
2056    function syncVisibleEdges({ clearSelection = false } = {}) {
2057      viewportVisibleNodeIds = computeViewportVisibleNodeIds();
2058      const candidateEdges = [];
2059      for (const edge of graphData.edges) {
2060        if (shouldRenderEdge(edge)) {
2061          candidateEdges.push(edge);
2062        }
2063      }
2064      const edgeSelection = limitEdgeRecords(candidateEdges);
2065      eligibleVisibleEdgeCount = edgeSelection.eligibleCount;
2066      edgeLimitExceeded = edgeSelection.limitExceeded;
2067      const activeEdgeIds = new Set(edges.getIds());
2068      const nextEdgeIds = new Set(edgeSelection.edgeRecords.map((edge) => edge.id));
2069      const edgeIdsToRemove = [];
2070      const edgeRecordsToAdd = [];
2071      for (const edgeId of activeEdgeIds) {
2072        if (!nextEdgeIds.has(edgeId)) {
2073          edgeIdsToRemove.push(edgeId);
2074        }
2075      }
2076      for (const edge of edgeSelection.edgeRecords) {
2077        if (!activeEdgeIds.has(edge.id)) {
2078          edgeRecordsToAdd.push(edge);
2079        }
2080      }
2081      if (edgeIdsToRemove.length > 0) {
2082        edges.remove(edgeIdsToRemove);
2083      }
2084      if (edgeRecordsToAdd.length > 0) {
2085        edges.add(edgeRecordsToAdd);
2086      }
2087      if (clearSelection) {
2088        network.unselectAll();
2089        renderEmptyDetails();
2090        currentSelection = null;
2091      }
2092      reapplyLockedLayout();
2093      if (edgeIdsToRemove.length > 0 || edgeRecordsToAdd.length > 0) {
2094        network.redraw();
2095      }
2096      updateEdgeFilterSummary();
2097      updatePerformanceNote();
2098      updateEdgeLimitWarning();
2099    }
2100
2101    function scheduleVisibleEdgeSync() {
2102      if (viewportSyncHandle !== null) {
2103        window.clearTimeout(viewportSyncHandle);
2104      }
2105      viewportSyncHandle = window.setTimeout(() => {
2106        viewportSyncHandle = null;
2107        syncVisibleEdges();
2108      }, 80);
2109    }
2110
2111    function applyFilters({ clearSelection = true } = {}) {
2112      const nextHiddenNodeIds = new Set();
2113      const nodeUpdates = [];
2114      for (const node of graphData.nodes) {
2115        const hidden = shouldHideNode(node);
2116        nodeUpdates.push({ id: node.id, hidden });
2117        if (hidden) {
2118          nextHiddenNodeIds.add(node.id);
2119        }
2120      }
2121      hiddenNodeIds = nextHiddenNodeIds;
2122      if (nodeUpdates.length > 0) {
2123        nodes.update(nodeUpdates);
2124      }
2125      updateNodeFilterSummary();
2126      updateMergedNodeFilterSummary();
2127      syncVisibleEdges({ clearSelection });
2128    }
2129
2130    function renderNodeFilters() {
2131      nodeFiltersContainer.innerHTML = '';
2132      for (const filter of nodeFilters) {
2133        const option = document.createElement('label');
2134        option.className = 'node-filter-option';
2135        if (filter.count === 0) {
2136          option.classList.add('is-disabled');
2137        }
2138
2139        const input = document.createElement('input');
2140        input.type = 'checkbox';
2141        input.checked = edgeBoolean(nodeFilterState.get(filter.id));
2142        input.disabled = filter.count === 0;
2143        input.dataset.nodeType = filter.id;
2144        input.addEventListener('change', () => {
2145          nodeFilterState.set(filter.id, input.checked);
2146          applyFilters();
2147        });
2148
2149        const swatch = document.createElement('span');
2150        swatch.className = 'node-filter-swatch';
2151        swatch.style.setProperty('--node-background', filter.color.background);
2152        swatch.style.setProperty('--node-border', filter.color.border);
2153
2154        const text = document.createElement('span');
2155        text.className = 'edge-filter-text';
2156
2157        const name = document.createElement('span');
2158        name.className = 'edge-filter-name';
2159        name.textContent = filter.label;
2160
2161        const meta = document.createElement('span');
2162        meta.className = 'edge-filter-meta';
2163        meta.textContent = filter.count === 1 ? '1 node' : `${filter.count} nodes`;
2164
2165        text.appendChild(name);
2166        text.appendChild(meta);
2167        option.appendChild(input);
2168        option.appendChild(swatch);
2169        option.appendChild(text);
2170        nodeFiltersContainer.appendChild(option);
2171      }
2172
2173      updateNodeFilterSummary();
2174    }
2175
2176    function renderMergedNodeFilters() {
2177      mergedNodeFiltersContainer.innerHTML = '';
2178      for (const filter of mergedNodeFilters) {
2179        const option = document.createElement('label');
2180        option.className = 'node-filter-option';
2181        if (filter.count === 0) {
2182          option.classList.add('is-disabled');
2183        }
2184
2185        const input = document.createElement('input');
2186        input.type = 'checkbox';
2187        input.checked = edgeBoolean(mergedNodeFilterState.get(filter.id));
2188        input.disabled = filter.count === 0;
2189        input.dataset.mergedNodeType = filter.id;
2190        input.addEventListener('change', () => {
2191          mergedNodeFilterState.set(filter.id, input.checked);
2192          applyFilters();
2193        });
2194
2195        const swatch = document.createElement('span');
2196        swatch.className = 'node-filter-swatch';
2197        swatch.style.setProperty('--node-background', filter.color.background);
2198        swatch.style.setProperty('--node-border', filter.color.border);
2199
2200        const text = document.createElement('span');
2201        text.className = 'edge-filter-text';
2202
2203        const name = document.createElement('span');
2204        name.className = 'edge-filter-name';
2205        name.textContent = filter.label;
2206
2207        const meta = document.createElement('span');
2208        meta.className = 'edge-filter-meta';
2209        meta.textContent = filter.count === 1 ? '1 node' : `${filter.count} nodes`;
2210
2211        text.appendChild(name);
2212        text.appendChild(meta);
2213        option.appendChild(input);
2214        option.appendChild(swatch);
2215        option.appendChild(text);
2216        mergedNodeFiltersContainer.appendChild(option);
2217      }
2218
2219      updateMergedNodeFilterSummary();
2220    }
2221
2222    function renderEdgeFilters() {
2223      edgeFiltersContainer.innerHTML = '';
2224      for (const filter of edgeFilters) {
2225        const option = document.createElement('label');
2226        option.className = 'edge-filter-option';
2227        if (filter.count === 0) {
2228          option.classList.add('is-disabled');
2229        }
2230
2231        const input = document.createElement('input');
2232        input.type = 'checkbox';
2233        input.checked = edgeFilterState.get(filter.id);
2234        input.disabled = filter.count === 0;
2235        input.dataset.edgeType = filter.id;
2236        input.addEventListener('change', () => {
2237          edgeFilterState.set(filter.id, input.checked);
2238          applyFilters();
2239        });
2240
2241        const swatch = document.createElement('span');
2242        swatch.className = 'edge-filter-swatch';
2243        swatch.style.setProperty('--edge-color', filter.color);
2244        swatch.style.borderTopStyle = filter.dashed ? 'dashed' : 'solid';
2245
2246        const text = document.createElement('span');
2247        text.className = 'edge-filter-text';
2248
2249        const name = document.createElement('span');
2250        name.className = 'edge-filter-name';
2251        name.textContent = filter.label;
2252
2253        const meta = document.createElement('span');
2254        meta.className = 'edge-filter-meta';
2255        const edgeCountLabel = filter.count === 1 ? '1 edge total' : `${filter.count} edges total`;
2256        meta.textContent = filter.viewportCulled
2257          ? `${edgeCountLabel} · current view only`
2258          : edgeCountLabel;
2259
2260        text.appendChild(name);
2261        text.appendChild(meta);
2262        option.appendChild(input);
2263        option.appendChild(swatch);
2264        option.appendChild(text);
2265        edgeFiltersContainer.appendChild(option);
2266      }
2267
2268      updateEdgeFilterSummary();
2269    }
2270
2271    edgeLimitSlider.addEventListener('input', () => {
2272      currentViewportEdgeLimit = clampViewportEdgeLimit(edgeLimitSlider.value);
2273      updateEdgeLimitControls();
2274      syncVisibleEdges();
2275    });
2276
2277    function selectionKey(selection) {
2278      return selection ? `${selection.kind}:${selection.id}` : '';
2279    }
2280
2281    function renderDetails(details) {
2282      if (!details) {
2283        renderEmptyDetails();
2284        return;
2285      }
2286      detailsPanel.innerHTML = '';
2287
2288      const title = document.createElement('h2');
2289      title.className = 'detail-title';
2290      title.textContent = details.title;
2291      detailsPanel.appendChild(title);
2292
2293      if (details.subtitle) {
2294        const subtitle = document.createElement('div');
2295        subtitle.className = 'detail-subtitle';
2296        subtitle.textContent = details.subtitle;
2297        detailsPanel.appendChild(subtitle);
2298      }
2299
2300      if (!details.fields || details.fields.length === 0) {
2301        const empty = document.createElement('p');
2302        empty.className = 'hint';
2303        empty.textContent = 'No additional details available.';
2304        detailsPanel.appendChild(empty);
2305        return;
2306      }
2307
2308      const list = document.createElement('dl');
2309      for (const field of details.fields) {
2310        const wrapper = document.createElement('div');
2311        const dt = document.createElement('dt');
2312        dt.textContent = field.label;
2313        const dd = document.createElement('dd');
2314        dd.textContent = field.value;
2315        wrapper.appendChild(dt);
2316        wrapper.appendChild(dd);
2317        list.appendChild(wrapper);
2318      }
2319      detailsPanel.appendChild(list);
2320      syncGraphHeight();
2321    }
2322
2323    function renderLoadingDetails(selection) {
2324      detailsPanel.innerHTML = '';
2325
2326      const title = document.createElement('h2');
2327      title.className = 'detail-title';
2328      title.textContent = selection.kind === 'node' ? 'Loading node details…' : 'Loading edge details…';
2329      detailsPanel.appendChild(title);
2330
2331      const hint = document.createElement('p');
2332      hint.className = 'hint';
2333      hint.textContent = 'The full metadata lives in a companion details file and is being loaded now.';
2334      detailsPanel.appendChild(hint);
2335      syncGraphHeight();
2336    }
2337
2338    function renderDetailsLoadError(error) {
2339      detailsPanel.innerHTML = '';
2340
2341      const title = document.createElement('h2');
2342      title.className = 'detail-title';
2343      title.textContent = 'Could not load selection details';
2344      detailsPanel.appendChild(title);
2345
2346      const hint = document.createElement('p');
2347      hint.className = 'hint';
2348      hint.textContent = `${error.message}. Keep the HTML, graph data, and details files together when opening the export.`;
2349      detailsPanel.appendChild(hint);
2350      syncGraphHeight();
2351    }
2352
2353    function renderMissingDetails(selection) {
2354      detailsPanel.innerHTML = '';
2355
2356      const title = document.createElement('h2');
2357      title.className = 'detail-title';
2358      title.textContent = selection.kind === 'node' ? 'Node details unavailable' : 'Edge details unavailable';
2359      detailsPanel.appendChild(title);
2360
2361      const hint = document.createElement('p');
2362      hint.className = 'hint';
2363      hint.textContent = 'The selection exists in the graph, but no companion detail record was found for it.';
2364      detailsPanel.appendChild(hint);
2365      syncGraphHeight();
2366    }
2367
2368    function renderEmptyDetails() {
2369      detailsPanel.innerHTML = '';
2370
2371      const title = document.createElement('h2');
2372      title.className = 'detail-title';
2373      title.textContent = 'Selection details';
2374      detailsPanel.appendChild(title);
2375
2376      const hint = document.createElement('p');
2377      hint.className = 'hint';
2378      hint.textContent = 'Click a node or edge to inspect the full metadata. Drag the canvas to pan, scroll to zoom, and use the toolbar to refit or restabilize the graph. Large detail payloads load on demand.';
2379      detailsPanel.appendChild(hint);
2380      syncGraphHeight();
2381    }
2382
2383    async function showSelection(selection) {
2384      currentSelection = selection;
2385      renderLoadingDetails(selection);
2386      try {
2387        const payload = await ensureDetailsLoaded();
2388        if (selectionKey(currentSelection) !== selectionKey(selection)) {
2389          return;
2390        }
2391        const details = selection.kind === 'node'
2392          ? payload.nodeDetails[selection.id]
2393          : payload.edgeDetails[selection.id];
2394        if (details) {
2395          renderDetails(details);
2396        } else {
2397          renderMissingDetails(selection);
2398        }
2399      } catch (error) {
2400        if (selectionKey(currentSelection) === selectionKey(selection)) {
2401          renderDetailsLoadError(error);
2402        }
2403      }
2404    }
2405
2406    window.__knowditShowSelection = showSelection;
2407    window.__knowditEnsureDetailsLoaded = ensureDetailsLoaded;
2408    window.__knowditSyncVisibleEdges = syncVisibleEdges;
2409
2410    network.on('click', (params) => {
2411      if (params.nodes.length > 0) {
2412        void showSelection({ kind: 'node', id: params.nodes[0] });
2413        return;
2414      }
2415      if (params.edges.length > 0) {
2416        void showSelection({ kind: 'edge', id: params.edges[0] });
2417        return;
2418      }
2419      currentSelection = null;
2420      renderEmptyDetails();
2421    });
2422
2423    function ensureReadableScale(animated) {
2424      const minReadableScale = 0.08;
2425      const currentScale = network.getScale();
2426      if (currentScale < minReadableScale) {
2427        network.moveTo({
2428          position: network.getViewPosition(),
2429          scale: minReadableScale,
2430          animation: animated ? { duration: 250, easingFunction: 'easeInOutQuad' } : false
2431        });
2432      }
2433    }
2434
2435    function initialViewportTarget() {
2436      const projectNodes = graphData.nodes.filter((node) => node.nodeType === 'project');
2437      const categoryNodes = graphData.nodes.filter((node) => node.nodeType === 'category');
2438      const semanticNodes = graphData.nodes.filter((node) => node.nodeType === 'semantic');
2439      const findingNodes = graphData.nodes.filter((node) => node.nodeType === 'finding');
2440
2441      const uniqueSemanticXs = Array.from(new Set(semanticNodes.map((node) => node.x))).sort((left, right) => left - right);
2442      const categoryMaxX = categoryNodes.length > 0
2443        ? Math.max(...categoryNodes.map((node) => node.x))
2444        : (projectNodes.length > 0 ? Math.max(...projectNodes.map((node) => node.x)) : 0);
2445      const semanticFocusX = uniqueSemanticXs.length > 0
2446        ? uniqueSemanticXs[Math.min(1, uniqueSemanticXs.length - 1)]
2447        : categoryMaxX;
2448
2449      const semanticCenterY = semanticNodes.length > 0
2450        ? (Math.min(...semanticNodes.map((node) => node.y)) + Math.max(...semanticNodes.map((node) => node.y))) / 2
2451        : 0;
2452      const findingMinY = findingNodes.length > 0
2453        ? Math.min(...findingNodes.map((node) => node.y))
2454        : semanticCenterY;
2455      const findingCenterY = findingNodes.length > 0
2456        ? (findingMinY + Math.max(...findingNodes.map((node) => node.y))) / 2
2457        : semanticCenterY;
2458
2459      return {
2460        position: {
2461          x: (categoryMaxX + semanticFocusX) / 2,
2462          y: findingNodes.length > 0 ? (semanticCenterY + findingMinY) / 2 : semanticCenterY,
2463        },
2464        scale: graphData.stats.largeGraphMode ? 0.28 : 0.42,
2465      };
2466    }
2467
2468    function moveToInitialViewport() {
2469      const target = initialViewportTarget();
2470      network.moveTo({
2471        position: target.position,
2472        scale: target.scale,
2473        animation: false,
2474      });
2475    }
2476
2477    function fitGraph(animated = true) {
2478      const nodesToFit = visibleNodeIds();
2479      if (nodesToFit.length === 0) {
2480        return;
2481      }
2482      if (animated) {
2483        network.once('animationFinished', () => ensureReadableScale(true));
2484        network.fit({
2485          nodes: nodesToFit,
2486          animation: { duration: 400, easingFunction: 'easeInOutQuad' },
2487        });
2488        return;
2489      }
2490
2491      network.fit({ nodes: nodesToFit, animation: false });
2492      ensureReadableScale(false);
2493      scheduleVisibleEdgeSync();
2494    }
2495
2496    network.on('dragEnd', () => {
2497      scheduleVisibleEdgeSync();
2498    });
2499    network.on('zoom', () => {
2500      scheduleVisibleEdgeSync();
2501    });
2502    network.on('animationFinished', () => {
2503      scheduleVisibleEdgeSync();
2504    });
2505
2506    renderNodeFilters();
2507    renderMergedNodeFilters();
2508    renderEdgeFilters();
2509    updateEdgeLimitControls();
2510    if (!layoutLocked) {
2511      lockCurrentLayout();
2512    } else {
2513      reapplyLockedLayout();
2514    }
2515    moveToInitialViewport();
2516    applyFilters({ clearSelection: false });
2517    window.addEventListener('resize', () => {
2518      syncGraphHeight();
2519    });
2520
2521    document.getElementById('fit-button').addEventListener('click', () => {
2522      fitGraph(true);
2523    });
2524
2525    document.getElementById('stabilize-button').addEventListener('click', () => {
2526      network.redraw();
2527      fitGraph(true);
2528    });
2529
2530    renderEmptyDetails();
2531  syncGraphHeight({ redraw: false });
2532    }
2533
2534    bootstrap().catch((error) => {
2535      renderGraphStatus(`${error.message}. Keep the exported asset files together and ensure the browser can load local scripts.`, true);
2536      const detailsPanel = document.getElementById('details-panel');
2537      detailsPanel.innerHTML = '';
2538      const title = document.createElement('h2');
2539      title.className = 'detail-title';
2540      title.textContent = 'Could not initialize graph';
2541      detailsPanel.appendChild(title);
2542      const hint = document.createElement('p');
2543      hint.className = 'hint';
2544      hint.textContent = `${error.message}.`;
2545      detailsPanel.appendChild(hint);
2546    });
2547  </script>
2548</body>
2549</html>
2550"#,
2551        );
2552
2553        Ok(HtmlExportAssets {
2554            html,
2555            graph_data_js: format!("window.__KNOWDIT_GRAPH_DATA__ = {};\n", graph_payload_js),
2556            details_js: format!(
2557                "window.__KNOWDIT_GRAPH_DETAILS__ = {};\n",
2558                details_payload_js
2559            ),
2560        })
2561    }
2562}
2563
2564#[derive(Serialize)]
2565#[serde(rename_all = "camelCase")]
2566struct HtmlGraphPayload {
2567    nodes: Vec<HtmlGraphNode>,
2568    edges: Vec<HtmlGraphEdge>,
2569    node_filters: Vec<HtmlNodeFilter>,
2570    edge_filters: Vec<HtmlEdgeFilter>,
2571    viewport_edge_limit: usize,
2572    stats: HtmlGraphStats,
2573}
2574
2575#[derive(Serialize)]
2576#[serde(rename_all = "camelCase")]
2577struct HtmlGraphDetailsPayload {
2578    node_details: HashMap<String, HtmlSelectionDetails>,
2579    edge_details: HashMap<String, HtmlSelectionDetails>,
2580}
2581
2582pub struct HtmlExportAssets {
2583    pub html: String,
2584    pub graph_data_js: String,
2585    pub details_js: String,
2586}
2587
2588#[derive(Serialize)]
2589#[serde(rename_all = "camelCase")]
2590struct HtmlGraphStats {
2591    project_count: usize,
2592    category_count: usize,
2593    semantic_count: usize,
2594    active_semantic_count: usize,
2595    finding_count: usize,
2596    active_finding_count: usize,
2597    edge_count: usize,
2598    large_graph_mode: bool,
2599}
2600
2601#[derive(Serialize)]
2602#[serde(rename_all = "camelCase")]
2603struct HtmlGraphNode {
2604    id: String,
2605    label: String,
2606    node_type: String,
2607    is_merged: bool,
2608    level: u8,
2609    shape: String,
2610    color: HtmlNodeColor,
2611    border_width: u8,
2612    x: f64,
2613    y: f64,
2614    fixed: HtmlNodeFixed,
2615}
2616
2617#[derive(Serialize)]
2618struct HtmlNodeFixed {
2619    x: bool,
2620    y: bool,
2621}
2622
2623impl HtmlNodeFixed {
2624    fn locked() -> Self {
2625        Self { x: true, y: true }
2626    }
2627}
2628
2629#[derive(Clone, Copy)]
2630struct HtmlNodePosition {
2631    x: f64,
2632    y: f64,
2633}
2634
2635#[derive(Serialize)]
2636struct HtmlNodeColor {
2637    background: String,
2638    border: String,
2639}
2640
2641#[derive(Serialize)]
2642#[serde(rename_all = "camelCase")]
2643struct HtmlNodeFilter {
2644    id: String,
2645    label: String,
2646    count: usize,
2647    enabled_by_default: bool,
2648    color: HtmlNodeColor,
2649}
2650
2651#[derive(Serialize)]
2652#[serde(rename_all = "camelCase")]
2653struct HtmlGraphEdge {
2654    id: String,
2655    from: String,
2656    to: String,
2657    edge_type: String,
2658    arrows: String,
2659    color: HtmlEdgeColor,
2660    dashes: bool,
2661    width: f32,
2662}
2663
2664#[derive(Serialize)]
2665struct HtmlEdgeColor {
2666    color: String,
2667}
2668
2669#[derive(Serialize)]
2670#[serde(rename_all = "camelCase")]
2671struct HtmlEdgeFilter {
2672    id: String,
2673    label: String,
2674    count: usize,
2675    enabled_by_default: bool,
2676    viewport_culled: bool,
2677    color: String,
2678    dashed: bool,
2679}
2680
2681#[derive(Serialize)]
2682#[serde(rename_all = "camelCase")]
2683struct HtmlSelectionDetails {
2684    title: String,
2685    subtitle: Option<String>,
2686    fields: Vec<HtmlDetailField>,
2687}
2688
2689#[derive(Serialize)]
2690struct HtmlDetailField {
2691    label: String,
2692    value: String,
2693}
2694
2695const NODE_TYPE_PROJECT: &str = "project";
2696const NODE_TYPE_CATEGORY: &str = "category";
2697const NODE_TYPE_SEMANTIC: &str = "semantic";
2698const NODE_TYPE_FINDING: &str = "finding";
2699
2700const EDGE_TYPE_PROJECT_CATEGORY: &str = "project-category";
2701const EDGE_TYPE_PROJECT_SEMANTIC: &str = "project-semantic";
2702const EDGE_TYPE_PROJECT_FINDING: &str = "project-finding";
2703const EDGE_TYPE_SEMANTIC_MERGE: &str = "semantic-merge";
2704const EDGE_TYPE_FINDING_MERGE: &str = "finding-merge";
2705const EDGE_TYPE_SEMANTIC_FINDING: &str = "semantic-finding";
2706
2707const LARGE_GRAPH_NODE_THRESHOLD: usize = 2_000;
2708const LARGE_GRAPH_EDGE_THRESHOLD: usize = 4_000;
2709
2710const PROJECT_COLUMN_X: f64 = 0.0;
2711const CATEGORY_COLUMN_X: f64 = 460.0;
2712const SEMANTIC_COLUMN_X: f64 = 1040.0;
2713const FINDING_COLUMN_X: f64 = 1840.0;
2714
2715const PROJECT_ROW_SPACING: f64 = 180.0;
2716const CATEGORY_ROW_SPACING: f64 = 180.0;
2717const SEMANTIC_ROW_SPACING: f64 = 180.0;
2718const FINDING_ROW_SPACING: f64 = 180.0;
2719
2720const PROJECT_COLUMN_SPACING: f64 = 280.0;
2721const SEMANTIC_COLUMN_SPACING: f64 = 380.0;
2722const FINDING_COLUMN_SPACING: f64 = 380.0;
2723const FINDING_VERTICAL_GAP: f64 = 360.0;
2724
2725fn vertical_node_positions(
2726    ids: &[i32],
2727    column_x: f64,
2728    row_spacing: f64,
2729) -> HashMap<i32, HtmlNodePosition> {
2730    ids.iter()
2731        .enumerate()
2732        .map(|(index, id)| {
2733            (
2734                *id,
2735                HtmlNodePosition {
2736                    x: column_x,
2737                    y: centered_axis_offset(index, ids.len(), row_spacing),
2738                },
2739            )
2740        })
2741        .collect()
2742}
2743
2744fn grid_node_positions(
2745    ids: &[i32],
2746    start_x: f64,
2747    rows: usize,
2748    row_spacing: f64,
2749    column_spacing: f64,
2750    center_y: f64,
2751) -> HashMap<i32, HtmlNodePosition> {
2752    if ids.is_empty() {
2753        return HashMap::new();
2754    }
2755
2756    let row_count = ids.len().min(rows.max(1));
2757    ids.iter()
2758        .enumerate()
2759        .map(|(index, id)| {
2760            let column_index = index / row_count;
2761            let row_index = index % row_count;
2762            (
2763                *id,
2764                HtmlNodePosition {
2765                    x: start_x + column_index as f64 * column_spacing,
2766                    y: center_y + centered_axis_offset(row_index, row_count, row_spacing),
2767                },
2768            )
2769        })
2770        .collect()
2771}
2772
2773fn right_aligned_grid_node_positions(
2774    ids: &[i32],
2775    anchor_x: f64,
2776    rows: usize,
2777    row_spacing: f64,
2778    column_spacing: f64,
2779    center_y: f64,
2780) -> HashMap<i32, HtmlNodePosition> {
2781    if ids.is_empty() {
2782        return HashMap::new();
2783    }
2784
2785    let row_count = ids.len().min(rows.max(1));
2786    ids.iter()
2787        .enumerate()
2788        .map(|(index, id)| {
2789            let column_index = index / row_count;
2790            let row_index = index % row_count;
2791            (
2792                *id,
2793                HtmlNodePosition {
2794                    x: anchor_x - column_index as f64 * column_spacing,
2795                    y: center_y + centered_axis_offset(row_index, row_count, row_spacing),
2796                },
2797            )
2798        })
2799        .collect()
2800}
2801
2802fn grid_band_half_height(item_count: usize, rows: usize, row_spacing: f64) -> f64 {
2803    let row_count = item_count.min(rows.max(1));
2804    if row_count <= 1 {
2805        0.0
2806    } else {
2807        (row_count.saturating_sub(1) as f64 / 2.0) * row_spacing
2808    }
2809}
2810
2811fn centered_axis_offset(index: usize, count: usize, spacing: f64) -> f64 {
2812    if count <= 1 {
2813        return 0.0;
2814    }
2815
2816    (index as f64 - (count.saturating_sub(1) as f64 / 2.0)) * spacing
2817}
2818
2819fn bump_node_type_count(
2820    node_type_counts: &mut HashMap<&'static str, usize>,
2821    node_type: &'static str,
2822) {
2823    *node_type_counts.entry(node_type).or_insert(0) += 1;
2824}
2825
2826fn bump_edge_type_count(
2827    edge_type_counts: &mut HashMap<&'static str, usize>,
2828    edge_type: &'static str,
2829) {
2830    *edge_type_counts.entry(edge_type).or_insert(0) += 1;
2831}
2832
2833fn html_node_filters(node_type_counts: &HashMap<&'static str, usize>) -> Vec<HtmlNodeFilter> {
2834    vec![
2835        html_node_filter(
2836            NODE_TYPE_PROJECT,
2837            "Projects",
2838            HtmlNodeColor {
2839                background: "#dcfce7".to_string(),
2840                border: "#16a34a".to_string(),
2841            },
2842            node_type_counts,
2843        ),
2844        html_node_filter(
2845            NODE_TYPE_CATEGORY,
2846            "DeFi Categories",
2847            HtmlNodeColor {
2848                background: "#dbeafe".to_string(),
2849                border: "#2563eb".to_string(),
2850            },
2851            node_type_counts,
2852        ),
2853        html_node_filter(
2854            NODE_TYPE_SEMANTIC,
2855            "Semantic Nodes",
2856            HtmlNodeColor {
2857                background: "#fff4cc".to_string(),
2858                border: "#a67c00".to_string(),
2859            },
2860            node_type_counts,
2861        ),
2862        html_node_filter(
2863            NODE_TYPE_FINDING,
2864            "Audit Findings",
2865            HtmlNodeColor {
2866                background: "#fee2e2".to_string(),
2867                border: "#dc2626".to_string(),
2868            },
2869            node_type_counts,
2870        ),
2871    ]
2872}
2873
2874fn html_node_filter(
2875    id: &'static str,
2876    label: &'static str,
2877    color: HtmlNodeColor,
2878    node_type_counts: &HashMap<&'static str, usize>,
2879) -> HtmlNodeFilter {
2880    HtmlNodeFilter {
2881        id: id.to_string(),
2882        label: label.to_string(),
2883        count: *node_type_counts.get(id).unwrap_or(&0),
2884        enabled_by_default: true,
2885        color,
2886    }
2887}
2888
2889fn html_edge_filters(
2890    edge_type_counts: &HashMap<&'static str, usize>,
2891    large_graph_mode: bool,
2892) -> Vec<HtmlEdgeFilter> {
2893    vec![
2894        html_edge_filter(
2895            EDGE_TYPE_PROJECT_CATEGORY,
2896            "Project -> Category",
2897            "#6b7280",
2898            true,
2899            true,
2900            true,
2901            edge_type_counts,
2902        ),
2903        html_edge_filter(
2904            EDGE_TYPE_PROJECT_SEMANTIC,
2905            "Project -> Semantic",
2906            "#166534",
2907            false,
2908            !large_graph_mode,
2909            true,
2910            edge_type_counts,
2911        ),
2912        html_edge_filter(
2913            EDGE_TYPE_PROJECT_FINDING,
2914            "Project -> Finding",
2915            "#b91c1c",
2916            false,
2917            !large_graph_mode,
2918            true,
2919            edge_type_counts,
2920        ),
2921        html_edge_filter(
2922            EDGE_TYPE_SEMANTIC_MERGE,
2923            "Semantic Merge",
2924            "#7c3aed",
2925            true,
2926            !large_graph_mode,
2927            false,
2928            edge_type_counts,
2929        ),
2930        html_edge_filter(
2931            EDGE_TYPE_FINDING_MERGE,
2932            "Finding Merge",
2933            "#ea580c",
2934            true,
2935            !large_graph_mode,
2936            false,
2937            edge_type_counts,
2938        ),
2939        html_edge_filter(
2940            EDGE_TYPE_SEMANTIC_FINDING,
2941            "Semantic -> Finding",
2942            "#2563eb",
2943            false,
2944            !large_graph_mode,
2945            false,
2946            edge_type_counts,
2947        ),
2948    ]
2949}
2950
2951fn html_edge_filter(
2952    id: &'static str,
2953    label: &'static str,
2954    color: &'static str,
2955    dashed: bool,
2956    enabled_by_default: bool,
2957    viewport_culled: bool,
2958    edge_type_counts: &HashMap<&'static str, usize>,
2959) -> HtmlEdgeFilter {
2960    HtmlEdgeFilter {
2961        id: id.to_string(),
2962        label: label.to_string(),
2963        count: *edge_type_counts.get(id).unwrap_or(&0),
2964        enabled_by_default,
2965        viewport_culled,
2966        color: color.to_string(),
2967        dashed,
2968    }
2969}
2970
2971fn push_detail(fields: &mut Vec<HtmlDetailField>, label: &str, value: Option<String>) {
2972    let Some(value) = value else {
2973        return;
2974    };
2975    let compact = value.trim();
2976    if compact.is_empty() {
2977        return;
2978    }
2979
2980    fields.push(HtmlDetailField {
2981        label: label.to_string(),
2982        value: compact.to_string(),
2983    });
2984}
2985
2986fn semantic_node_color(is_merged: bool) -> HtmlNodeColor {
2987    if is_merged {
2988        HtmlNodeColor {
2989            background: "#fef3c7".to_string(),
2990            border: "#b45309".to_string(),
2991        }
2992    } else {
2993        HtmlNodeColor {
2994            background: "#fff4cc".to_string(),
2995            border: "#a67c00".to_string(),
2996        }
2997    }
2998}
2999
3000fn finding_node_color(severity: audit_finding::FindingSeverity, is_merged: bool) -> HtmlNodeColor {
3001    match (severity, is_merged) {
3002        (audit_finding::FindingSeverity::High, false) => HtmlNodeColor {
3003            background: "#fee2e2".to_string(),
3004            border: "#dc2626".to_string(),
3005        },
3006        (audit_finding::FindingSeverity::High, true) => HtmlNodeColor {
3007            background: "#fecaca".to_string(),
3008            border: "#b91c1c".to_string(),
3009        },
3010        (audit_finding::FindingSeverity::Medium, false) => HtmlNodeColor {
3011            background: "#fef3c7".to_string(),
3012            border: "#d97706".to_string(),
3013        },
3014        (audit_finding::FindingSeverity::Medium, true) => HtmlNodeColor {
3015            background: "#fde68a".to_string(),
3016            border: "#b45309".to_string(),
3017        },
3018        (audit_finding::FindingSeverity::Low, false) => HtmlNodeColor {
3019            background: "#f5f5f4".to_string(),
3020            border: "#57534e".to_string(),
3021        },
3022        (audit_finding::FindingSeverity::Low, true) => HtmlNodeColor {
3023            background: "#e7e5e4".to_string(),
3024            border: "#44403c".to_string(),
3025        },
3026    }
3027}
3028
3029fn truncate_text(value: &str, max_chars: usize) -> String {
3030    let compact = value.split_whitespace().collect::<Vec<_>>().join(" ");
3031    let mut chars = compact.chars();
3032    let truncated: String = chars.by_ref().take(max_chars).collect();
3033    if chars.next().is_some() {
3034        format!("{}…", truncated.trim_end())
3035    } else {
3036        compact
3037    }
3038}
3039
3040fn wrap_label(value: &str, line_width: usize) -> String {
3041    let mut lines = Vec::new();
3042    let mut current = String::new();
3043
3044    for word in value.split_whitespace() {
3045        let next_len = if current.is_empty() {
3046            word.len()
3047        } else {
3048            current.len() + 1 + word.len()
3049        };
3050
3051        if next_len > line_width && !current.is_empty() {
3052            lines.push(current);
3053            current = word.to_string();
3054        } else {
3055            if !current.is_empty() {
3056                current.push(' ');
3057            }
3058            current.push_str(word);
3059        }
3060    }
3061
3062    if !current.is_empty() {
3063        lines.push(current);
3064    }
3065
3066    if lines.is_empty() {
3067        value.to_string()
3068    } else {
3069        lines.join("\n")
3070    }
3071}
3072
3073fn json_for_js<T: Serialize>(value: &T) -> Result<String> {
3074    Ok(serde_json::to_string(value)?)
3075}
3076
3077fn escape_dot(s: &str) -> String {
3078    s.replace('\\', "\\\\")
3079        .replace('"', "\\\"")
3080        .replace('\n', "\\n")
3081}
3082
3083fn dot_identifier(s: &str) -> String {
3084    let mut out = String::new();
3085    let mut last_was_sep = false;
3086
3087    for ch in s.chars() {
3088        if ch.is_ascii_alphanumeric() {
3089            out.push(ch.to_ascii_lowercase());
3090            last_was_sep = false;
3091        } else if !last_was_sep {
3092            out.push('_');
3093            last_was_sep = true;
3094        }
3095    }
3096
3097    let trimmed = out.trim_matches('_');
3098    if trimmed.is_empty() {
3099        "cluster".to_string()
3100    } else {
3101        trimmed.to_string()
3102    }
3103}
3104
3105fn finding_fill_color(severity: audit_finding::FindingSeverity) -> &'static str {
3106    match severity {
3107        audit_finding::FindingSeverity::High => "lightcoral",
3108        audit_finding::FindingSeverity::Medium => "khaki",
3109        audit_finding::FindingSeverity::Low => "ghostwhite",
3110    }
3111}