use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Instant;
use eframe::egui::{self, Color32, Pos2, Sense, Stroke, Vec2};
use crate::knowledge::{self, ScanResult};
pub struct KnowledgeState {
workspace_root: PathBuf,
repos: Vec<String>,
scans: Vec<(String, Result<ScanResult, String>)>,
last_scan: Option<Instant>,
rescan_requested: bool,
}
impl KnowledgeState {
pub fn new(workspace_root: PathBuf, repos: Vec<String>) -> Self {
Self {
workspace_root,
repos,
scans: Vec::new(),
last_scan: None,
rescan_requested: true,
}
}
pub fn draw(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.heading("🗺 Knowledge map");
ui.separator();
if ui.button("↻ rescan").clicked() {
self.rescan_requested = true;
}
if let Some(when) = self.last_scan {
ui.label(format!("last scanned {}s ago", when.elapsed().as_secs()));
}
});
ui.separator();
if self.rescan_requested {
self.scans.clear();
for repo in &self.repos {
let repo_root = self.workspace_root.join(repo);
let scan = knowledge::scan_all(&repo_root, repo)
.map_err(|e| format!("{e:#}"));
self.scans.push((repo.clone(), scan));
}
self.last_scan = Some(Instant::now());
self.rescan_requested = false;
}
let bubbles = collect_bubbles(&self.scans);
if bubbles.is_empty() {
ui.label("No data yet — click ↻ rescan or check workspace path.");
return;
}
egui::CollapsingHeader::new(format!("📊 {} crates scanned", bubbles.len()))
.default_open(true)
.show(ui, |ui| {
egui::Grid::new("kn_summary").striped(true).num_columns(7).show(ui, |ui| {
ui.label("repo"); ui.label("crate");
ui.label("symbols"); ui.label("calls"); ui.label("files");
ui.label("gates"); ui.label("git heat (30d)");
ui.end_row();
for b in &bubbles {
ui.label(&b.repo); ui.label(&b.krate);
ui.label(format!("{}", b.symbols));
ui.label(format!("{}", b.calls));
ui.label(format!("{}", b.files));
ui.label(format!("{}", b.gates));
ui.label(format!("{}", b.heat_30d));
ui.end_row();
}
});
});
ui.separator();
ui.label("Bubble chart — x: symbol count, y: call count, size: files, color: 30d commits");
draw_bubble_chart(ui, &bubbles);
}
}
struct Bubble {
repo: String,
krate: String,
symbols: usize,
calls: usize,
files: usize,
gates: usize,
heat_30d: i64,
}
fn collect_bubbles(scans: &[(String, Result<ScanResult, String>)]) -> Vec<Bubble> {
let mut out = Vec::new();
for (repo, scan) in scans {
let Ok(scan) = scan else { continue };
let mut per_crate_syms: BTreeMap<&str, usize> = BTreeMap::new();
let mut per_crate_calls: BTreeMap<&str, usize> = BTreeMap::new();
let mut per_crate_files: BTreeMap<&str, std::collections::BTreeSet<&str>> = BTreeMap::new();
let mut per_crate_gates: BTreeMap<&str, usize> = BTreeMap::new();
for s in &scan.symbols.symbols {
*per_crate_syms.entry(&s.crate_name).or_default() += 1;
per_crate_files.entry(&s.crate_name).or_default().insert(&s.file);
}
for c in &scan.symbols.calls {
*per_crate_calls.entry(&c.crate_name).or_default() += 1;
}
for f in &scan.symbols.features {
*per_crate_gates.entry(&f.crate_name).or_default() += 1;
}
let max_30d = scan.git.files.iter().map(|f| f.commits_30d).max().unwrap_or(0);
for (krate, sym_n) in &per_crate_syms {
out.push(Bubble {
repo: repo.clone(),
krate: krate.to_string(),
symbols: *sym_n,
calls: per_crate_calls.get(krate).copied().unwrap_or(0),
files: per_crate_files.get(krate).map(|s| s.len()).unwrap_or(0),
gates: per_crate_gates.get(krate).copied().unwrap_or(0),
heat_30d: max_30d,
});
}
}
out.sort_by_key(|b| std::cmp::Reverse(b.symbols));
out
}
fn draw_bubble_chart(ui: &mut egui::Ui, bubbles: &[Bubble]) {
let avail = ui.available_size_before_wrap();
let plot_w = avail.x.max(400.0);
let plot_h = (avail.y - 40.0).max(300.0);
let (rect, _resp) = ui.allocate_exact_size(Vec2::new(plot_w, plot_h), Sense::hover());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 4.0, Color32::from_rgb(20, 20, 28));
let max_sym = bubbles.iter().map(|b| b.symbols).max().unwrap_or(1) as f32;
let max_calls = bubbles.iter().map(|b| b.calls).max().unwrap_or(1) as f32;
let max_files = bubbles.iter().map(|b| b.files).max().unwrap_or(1) as f32;
let max_heat = bubbles.iter().map(|b| b.heat_30d).max().unwrap_or(1) as f32;
let pad = 30.0;
for b in bubbles {
let x_frac = ((b.symbols as f32).max(1.0).ln()) / max_sym.max(1.0).ln().max(0.1);
let y_frac = ((b.calls as f32).max(1.0).ln()) / max_calls.max(1.0).ln().max(0.1);
let cx = rect.min.x + pad + (rect.width() - 2.0 * pad) * x_frac;
let cy = rect.max.y - pad - (rect.height() - 2.0 * pad) * y_frac;
let r = 6.0 + 28.0 * ((b.files as f32) / max_files.max(1.0)).sqrt();
let heat_norm = (b.heat_30d as f32) / max_heat.max(1.0);
let color = heat_color(heat_norm);
painter.circle_filled(Pos2::new(cx, cy), r, color);
painter.circle_stroke(Pos2::new(cx, cy), r, Stroke::new(1.0, Color32::WHITE));
painter.text(
Pos2::new(cx, cy - r - 6.0),
egui::Align2::CENTER_BOTTOM,
&b.krate,
egui::FontId::proportional(10.0),
Color32::LIGHT_GRAY,
);
}
painter.text(
Pos2::new(rect.center().x, rect.max.y - 4.0),
egui::Align2::CENTER_BOTTOM,
"symbols (log) →",
egui::FontId::proportional(11.0),
Color32::GRAY,
);
painter.text(
Pos2::new(rect.min.x + 4.0, rect.center().y),
egui::Align2::LEFT_CENTER,
"↑ calls (log)",
egui::FontId::proportional(11.0),
Color32::GRAY,
);
}
fn heat_color(t: f32) -> Color32 {
let t = t.clamp(0.0, 1.0);
let r = (60.0 + 195.0 * t) as u8;
let g = (160.0 - 80.0 * t) as u8;
let b = (180.0 - 160.0 * t) as u8;
Color32::from_rgb(r, g, b)
}
#[allow(dead_code)]
fn _path_only(_: &Path) {}