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#[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 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 pub project_findings: Vec<project_finding::Model>,
32 pub semantic_finding_links: Vec<semantic_finding_link::Model>,
33 #[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 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 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 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 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}