mirror of
https://github.com/perstarkse/minne.git
synced 2026-03-29 22:01:59 +02:00
feat: d3js instead of plotly, improved graph visualisation
This commit is contained in:
@@ -26,7 +26,6 @@ minijinja-embed = { workspace = true }
|
||||
minijinja-contrib = {workspace = true }
|
||||
axum-htmx = { workspace = true }
|
||||
async-stream = { workspace = true }
|
||||
plotly = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
chrono-tz = { workspace = true }
|
||||
tower-serve-static = { workspace = true }
|
||||
|
||||
2
html-router/assets/d3.min.js
vendored
Normal file
2
html-router/assets/d3.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
387
html-router/assets/knowledge-graph.js
Normal file
387
html-router/assets/knowledge-graph.js
Normal file
@@ -0,0 +1,387 @@
|
||||
// Knowledge graph renderer: interactive 2D force graph with
|
||||
// zoom/pan, search, neighbor highlighting, curved links with arrows,
|
||||
// responsive resize, and type/relationship legends.
|
||||
(function () {
|
||||
const D3_SRC = '/assets/d3.min.js';
|
||||
|
||||
let d3Loading = null;
|
||||
|
||||
function ensureD3() {
|
||||
if (window.d3) return Promise.resolve();
|
||||
if (d3Loading) return d3Loading;
|
||||
d3Loading = new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = D3_SRC;
|
||||
s.async = true;
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => reject(new Error('Failed to load D3'));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
return d3Loading;
|
||||
}
|
||||
|
||||
// Simple palettes (kept deterministic across renders)
|
||||
const PALETTE_A = ['#60A5FA', '#34D399', '#F59E0B', '#A78BFA', '#F472B6', '#F87171', '#22D3EE', '#84CC16', '#FB7185'];
|
||||
const PALETTE_B = ['#94A3B8', '#A3A3A3', '#9CA3AF', '#C084FC', '#FDA4AF', '#FCA5A5', '#67E8F9', '#A3E635', '#FDBA74'];
|
||||
|
||||
function buildMap(values) {
|
||||
const unique = Array.from(new Set(values.filter(Boolean)));
|
||||
const map = new Map();
|
||||
unique.forEach((v, i) => map.set(v, PALETTE_A[i % PALETTE_A.length]));
|
||||
return map;
|
||||
}
|
||||
|
||||
function linkColorMap(values) {
|
||||
const unique = Array.from(new Set(values.filter(Boolean)));
|
||||
const map = new Map();
|
||||
unique.forEach((v, i) => map.set(v, PALETTE_B[i % PALETTE_B.length]));
|
||||
return map;
|
||||
}
|
||||
|
||||
function radiusForDegree(deg) {
|
||||
const d = Math.max(0, +deg || 0);
|
||||
const r = 6 + Math.sqrt(d) * 3; // gentle growth
|
||||
return Math.max(6, Math.min(r, 24));
|
||||
}
|
||||
|
||||
function curvedPath(d) {
|
||||
const sx = d.source.x, sy = d.source.y, tx = d.target.x, ty = d.target.y;
|
||||
const dx = tx - sx, dy = ty - sy;
|
||||
const dr = Math.hypot(dx, dy) * 0.7; // curve radius
|
||||
const mx = (sx + tx) / 2;
|
||||
const my = (sy + ty) / 2;
|
||||
// Offset normal to create a consistent arc
|
||||
const nx = -dy / (Math.hypot(dx, dy) || 1);
|
||||
const ny = dx / (Math.hypot(dx, dy) || 1);
|
||||
const cx = mx + nx * 20;
|
||||
const cy = my + ny * 20;
|
||||
return `M ${sx},${sy} Q ${cx},${cy} ${tx},${ty}`;
|
||||
}
|
||||
|
||||
function buildAdjacency(nodes, links) {
|
||||
const idToNode = new Map(nodes.map(n => [n.id, n]));
|
||||
const neighbors = new Map();
|
||||
nodes.forEach(n => neighbors.set(n.id, new Set()));
|
||||
links.forEach(l => {
|
||||
const s = typeof l.source === 'object' ? l.source.id : l.source;
|
||||
const t = typeof l.target === 'object' ? l.target.id : l.target;
|
||||
if (neighbors.has(s)) neighbors.get(s).add(t);
|
||||
if (neighbors.has(t)) neighbors.get(t).add(s);
|
||||
});
|
||||
return { idToNode, neighbors };
|
||||
}
|
||||
|
||||
function attachOverlay(container, { onSearch, onToggleLabels, onCenter }) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'absolute top-2 left-2 z-10 flex gap-2 items-center';
|
||||
|
||||
// search box
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.placeholder = 'Search nodes…';
|
||||
input.className = 'input input-sm input-bordered';
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') onSearch && onSearch(input.value.trim());
|
||||
});
|
||||
|
||||
const searchBtn = document.createElement('button');
|
||||
searchBtn.className = 'btn btn-sm';
|
||||
searchBtn.textContent = 'Go';
|
||||
searchBtn.addEventListener('click', () => onSearch && onSearch(input.value.trim()));
|
||||
|
||||
const labelToggle = document.createElement('button');
|
||||
labelToggle.className = 'btn btn-sm';
|
||||
labelToggle.textContent = 'Labels';
|
||||
labelToggle.addEventListener('click', () => onToggleLabels && onToggleLabels());
|
||||
|
||||
const centerBtn = document.createElement('button');
|
||||
centerBtn.className = 'btn btn-sm';
|
||||
centerBtn.textContent = 'Center';
|
||||
centerBtn.addEventListener('click', () => onCenter && onCenter());
|
||||
|
||||
overlay.appendChild(input);
|
||||
overlay.appendChild(searchBtn);
|
||||
overlay.appendChild(labelToggle);
|
||||
overlay.appendChild(centerBtn);
|
||||
|
||||
container.style.position = 'relative';
|
||||
container.appendChild(overlay);
|
||||
|
||||
return { input, overlay };
|
||||
}
|
||||
|
||||
function attachLegends(container, typeColor, relColor) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'absolute bottom-2 left-2 z-10 flex gap-6 flex-wrap';
|
||||
|
||||
function section(title, items) {
|
||||
const sec = document.createElement('div');
|
||||
sec.className = 'rounded-box bg-base-100/80 backdrop-blur shadow p-2';
|
||||
const h = document.createElement('div'); h.className = 'text-xs opacity-70 mb-1'; h.textContent = title; sec.appendChild(h);
|
||||
items.forEach(([label, color]) => {
|
||||
const row = document.createElement('div'); row.className = 'flex items-center gap-2 text-xs';
|
||||
const sw = document.createElement('span'); sw.style.background = color; sw.style.width = '10px'; sw.style.height = '10px'; sw.style.borderRadius = '9999px';
|
||||
const t = document.createElement('span'); t.textContent = label || '—';
|
||||
row.appendChild(sw); row.appendChild(t); sec.appendChild(row);
|
||||
});
|
||||
return sec;
|
||||
}
|
||||
|
||||
const typeItems = Array.from(typeColor.entries());
|
||||
if (typeItems.length) wrap.appendChild(section('Entity Type', typeItems));
|
||||
const relItems = Array.from(relColor.entries());
|
||||
if (relItems.length) wrap.appendChild(section('Relationship', relItems));
|
||||
|
||||
container.appendChild(wrap);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function renderKnowledgeGraph(root) {
|
||||
const container = (root || document).querySelector('#knowledge-graph');
|
||||
if (!container) return;
|
||||
|
||||
await ensureD3().catch(() => {
|
||||
const err = document.createElement('div');
|
||||
err.className = 'alert alert-error';
|
||||
err.textContent = 'Unable to load graph library (D3).';
|
||||
container.appendChild(err);
|
||||
});
|
||||
if (!window.d3) return;
|
||||
|
||||
// Clear previous render
|
||||
container.innerHTML = '';
|
||||
|
||||
const width = container.clientWidth || 800;
|
||||
const height = container.clientHeight || 600;
|
||||
|
||||
const et = container.dataset.entityType || '';
|
||||
const cc = container.dataset.contentCategory || '';
|
||||
const qs = new URLSearchParams();
|
||||
if (et) qs.set('entity_type', et);
|
||||
if (cc) qs.set('content_category', cc);
|
||||
|
||||
const url = '/knowledge/graph.json' + (qs.toString() ? ('?' + qs.toString()) : '');
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
|
||||
if (!res.ok) throw new Error('Failed to load graph data');
|
||||
data = await res.json();
|
||||
} catch (_e) {
|
||||
const err = document.createElement('div');
|
||||
err.className = 'alert alert-error';
|
||||
err.textContent = 'Unable to load graph data.';
|
||||
container.appendChild(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Color maps
|
||||
const typeColor = buildMap(data.nodes.map(n => n.entity_type));
|
||||
const relColor = linkColorMap(data.links.map(l => l.relationship_type));
|
||||
const { neighbors } = buildAdjacency(data.nodes, data.links);
|
||||
|
||||
// Build overlay controls
|
||||
let labelsVisible = true;
|
||||
const { input } = attachOverlay(container, {
|
||||
onSearch: (q) => focusSearch(q),
|
||||
onToggleLabels: () => { labelsVisible = !labelsVisible; label.style('display', labelsVisible ? null : 'none'); },
|
||||
onCenter: () => zoomTo(1, [width / 2, height / 2])
|
||||
});
|
||||
|
||||
// SVG + zoom
|
||||
const svg = d3.select(container)
|
||||
.append('svg')
|
||||
.attr('width', '100%')
|
||||
.attr('height', height)
|
||||
.attr('viewBox', [0, 0, width, height])
|
||||
.attr('style', 'cursor: grab; touch-action: none; background: transparent;')
|
||||
.call(d3.zoom().scaleExtent([0.25, 5]).on('zoom', (event) => {
|
||||
g.attr('transform', event.transform);
|
||||
}));
|
||||
|
||||
const g = svg.append('g');
|
||||
|
||||
// Defs for arrows
|
||||
const defs = svg.append('defs');
|
||||
const markerFor = (key, color) => {
|
||||
const id = `arrow-${key.replace(/[^a-z0-9_-]/gi, '_')}`;
|
||||
if (!document.getElementById(id)) {
|
||||
defs.append('marker')
|
||||
.attr('id', id)
|
||||
.attr('viewBox', '0 -5 10 10')
|
||||
.attr('refX', 16)
|
||||
.attr('refY', 0)
|
||||
.attr('markerWidth', 6)
|
||||
.attr('markerHeight', 6)
|
||||
.attr('orient', 'auto')
|
||||
.append('path')
|
||||
.attr('d', 'M0,-5L10,0L0,5')
|
||||
.attr('fill', color);
|
||||
}
|
||||
return `url(#${id})`;
|
||||
};
|
||||
|
||||
// Forces
|
||||
const linkForce = d3.forceLink(data.links)
|
||||
.id(d => d.id)
|
||||
.distance(l => 70)
|
||||
.strength(0.5);
|
||||
|
||||
const simulation = d3.forceSimulation(data.nodes)
|
||||
.force('link', linkForce)
|
||||
.force('charge', d3.forceManyBody().strength(-220))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide().radius(d => radiusForDegree(d.degree) + 6))
|
||||
.force('y', d3.forceY(height / 2).strength(0.02))
|
||||
.force('x', d3.forceX(width / 2).strength(0.02));
|
||||
|
||||
// Links as paths so we can curve + arrow
|
||||
const link = g.append('g')
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke-opacity', 0.7)
|
||||
.selectAll('path')
|
||||
.data(data.links)
|
||||
.join('path')
|
||||
.attr('stroke', d => relColor.get(d.relationship_type) || '#CBD5E1')
|
||||
.attr('stroke-width', 1.5)
|
||||
.attr('marker-end', d => markerFor(d.relationship_type || 'rel', relColor.get(d.relationship_type) || '#CBD5E1'));
|
||||
|
||||
// Optional edge labels (midpoint)
|
||||
const linkLabel = g.append('g')
|
||||
.selectAll('text')
|
||||
.data(data.links)
|
||||
.join('text')
|
||||
.attr('font-size', 9)
|
||||
.attr('fill', '#475569')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('opacity', 0.7)
|
||||
.text(d => d.relationship_type || '');
|
||||
|
||||
// Nodes
|
||||
const node = g.append('g')
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 1.5)
|
||||
.selectAll('circle')
|
||||
.data(data.nodes)
|
||||
.join('circle')
|
||||
.attr('r', d => radiusForDegree(d.degree))
|
||||
.attr('fill', d => typeColor.get(d.entity_type) || '#94A3B8')
|
||||
.attr('cursor', 'pointer')
|
||||
.on('mouseenter', function (_evt, d) { setHighlight(d); })
|
||||
.on('mouseleave', function () { clearHighlight(); })
|
||||
.on('click', function (_evt, d) {
|
||||
// pin/unpin on click
|
||||
if (d.fx == null) { d.fx = d.x; d.fy = d.y; this.setAttribute('data-pinned', 'true'); }
|
||||
else { d.fx = null; d.fy = null; this.removeAttribute('data-pinned'); }
|
||||
})
|
||||
.call(d3.drag()
|
||||
.on('start', (event, d) => {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x; d.fy = d.y;
|
||||
})
|
||||
.on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; })
|
||||
.on('end', (event, d) => { if (!event.active) simulation.alphaTarget(0); }));
|
||||
|
||||
node.append('title').text(d => `${d.name} • ${d.entity_type} • deg ${d.degree}`);
|
||||
|
||||
// Labels
|
||||
const label = g.append('g')
|
||||
.selectAll('text')
|
||||
.data(data.nodes)
|
||||
.join('text')
|
||||
.text(d => d.name)
|
||||
.attr('font-size', 11)
|
||||
.attr('fill', '#111827')
|
||||
.attr('stroke', 'white')
|
||||
.attr('paint-order', 'stroke')
|
||||
.attr('stroke-width', 3)
|
||||
.attr('dx', d => radiusForDegree(d.degree) + 6)
|
||||
.attr('dy', 4);
|
||||
|
||||
// Legends
|
||||
attachLegends(container, typeColor, relColor);
|
||||
|
||||
// Highlight logic
|
||||
function setHighlight(n) {
|
||||
const ns = neighbors.get(n.id) || new Set();
|
||||
node.attr('opacity', d => (d.id === n.id || ns.has(d.id)) ? 1 : 0.15);
|
||||
label.attr('opacity', d => (d.id === n.id || ns.has(d.id)) ? 1 : 0.15);
|
||||
link
|
||||
.attr('stroke-opacity', d => {
|
||||
const s = (typeof d.source === 'object') ? d.source.id : d.source;
|
||||
const t = (typeof d.target === 'object') ? d.target.id : d.target;
|
||||
return (s === n.id || t === n.id || (ns.has(s) && ns.has(t))) ? 0.9 : 0.05;
|
||||
})
|
||||
.attr('marker-end', d => {
|
||||
const c = relColor.get(d.relationship_type) || '#CBD5E1';
|
||||
return markerFor(d.relationship_type || 'rel', c);
|
||||
});
|
||||
linkLabel.attr('opacity', d => {
|
||||
const s = (typeof d.source === 'object') ? d.source.id : d.source;
|
||||
const t = (typeof d.target === 'object') ? d.target.id : d.target;
|
||||
return (s === n.id || t === n.id) ? 0.9 : 0.05;
|
||||
});
|
||||
}
|
||||
function clearHighlight() {
|
||||
node.attr('opacity', 1);
|
||||
label.attr('opacity', 1);
|
||||
link.attr('stroke-opacity', 0.7);
|
||||
linkLabel.attr('opacity', 0.7);
|
||||
}
|
||||
|
||||
// Search + center helpers
|
||||
function centerOnNode(n) {
|
||||
const k = 1.5; // zoom factor
|
||||
const x = n.x, y = n.y;
|
||||
const transform = d3.zoomIdentity.translate(width / 2 - k * x, height / 2 - k * y).scale(k);
|
||||
svg.transition().duration(350).call(zoom.transform, transform);
|
||||
}
|
||||
function focusSearch(query) {
|
||||
if (!query) return;
|
||||
const q = query.toLowerCase();
|
||||
const found = data.nodes.find(n => (n.name || '').toLowerCase().includes(q));
|
||||
if (found) { setHighlight(found); centerOnNode(found); }
|
||||
}
|
||||
|
||||
// Expose zoom instance
|
||||
const zoom = d3.zoom().scaleExtent([0.25, 5]).on('zoom', (event) => g.attr('transform', event.transform));
|
||||
svg.call(zoom);
|
||||
function zoomTo(k, center) {
|
||||
const transform = d3.zoomIdentity.translate(width / 2 - k * center[0], height / 2 - k * center[1]).scale(k);
|
||||
svg.transition().duration(250).call(zoom.transform, transform);
|
||||
}
|
||||
|
||||
// Tick update
|
||||
simulation.on('tick', () => {
|
||||
link.attr('d', curvedPath);
|
||||
node.attr('cx', d => d.x).attr('cy', d => d.y);
|
||||
label.attr('x', d => d.x).attr('y', d => d.y);
|
||||
linkLabel.attr('x', d => (d.source.x + d.target.x) / 2).attr('y', d => (d.source.y + d.target.y) / 2);
|
||||
});
|
||||
|
||||
// Resize handling
|
||||
const ro = new ResizeObserver(() => {
|
||||
const w = container.clientWidth || width;
|
||||
const h = container.clientHeight || height;
|
||||
svg.attr('viewBox', [0, 0, w, h]).attr('height', h);
|
||||
simulation.force('center', d3.forceCenter(w / 2, h / 2));
|
||||
simulation.alpha(0.3).restart();
|
||||
});
|
||||
ro.observe(container);
|
||||
}
|
||||
|
||||
function tryRender(root) {
|
||||
const container = (root || document).querySelector('#knowledge-graph');
|
||||
if (container) renderKnowledgeGraph(root);
|
||||
}
|
||||
|
||||
// Expose for debugging/manual re-render
|
||||
window.renderKnowledgeGraph = () => renderKnowledgeGraph(document);
|
||||
|
||||
// Full page load
|
||||
document.addEventListener('DOMContentLoaded', () => tryRender(document));
|
||||
|
||||
// HTMX partial swaps
|
||||
document.body.addEventListener('htmx:afterSettle', (evt) => {
|
||||
tryRender(evt && evt.target ? evt.target : document);
|
||||
});
|
||||
})();
|
||||
@@ -1,16 +1,11 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
response::IntoResponse,
|
||||
Form,
|
||||
Form, Json,
|
||||
};
|
||||
use axum_htmx::{HxBoosted, HxRequest};
|
||||
use plotly::{
|
||||
common::{Line, Marker, Mode},
|
||||
layout::{Axis, LayoutScene},
|
||||
Layout, Plot, Scatter3D,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use common::storage::types::{
|
||||
@@ -39,7 +34,6 @@ pub struct KnowledgeBaseData {
|
||||
entities: Vec<KnowledgeEntity>,
|
||||
relationships: Vec<KnowledgeRelationship>,
|
||||
user: User,
|
||||
plot_html: String,
|
||||
entity_types: Vec<String>,
|
||||
content_categories: Vec<String>,
|
||||
selected_entity_type: Option<String>,
|
||||
@@ -54,12 +48,9 @@ pub async fn show_knowledge_page(
|
||||
HxBoosted(is_boosted): HxBoosted,
|
||||
Query(mut params): Query<FilterParams>,
|
||||
) -> Result<impl IntoResponse, HtmlError> {
|
||||
// Normalize filters
|
||||
params.entity_type = params.entity_type.take().filter(|s| !s.trim().is_empty());
|
||||
params.content_category = params
|
||||
.content_category
|
||||
.take()
|
||||
.filter(|s| !s.trim().is_empty());
|
||||
// Normalize filters: treat empty or "none" as no filter
|
||||
params.entity_type = normalize_filter(params.entity_type.take());
|
||||
params.content_category = normalize_filter(params.content_category.take());
|
||||
|
||||
// Load relevant data
|
||||
let entity_types = User::get_entity_types(&user.id, &state.db).await?;
|
||||
@@ -77,14 +68,12 @@ pub async fn show_knowledge_page(
|
||||
};
|
||||
|
||||
let relationships = User::get_knowledge_relationships(&user.id, &state.db).await?;
|
||||
let plot_html = get_plot_html(&entities, &relationships)?;
|
||||
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
||||
|
||||
let kb_data = KnowledgeBaseData {
|
||||
entities,
|
||||
relationships,
|
||||
user,
|
||||
plot_html,
|
||||
entity_types,
|
||||
content_categories,
|
||||
selected_entity_type: params.entity_type.clone(),
|
||||
@@ -111,170 +100,94 @@ pub async fn show_knowledge_page(
|
||||
}
|
||||
}
|
||||
|
||||
fn get_plot_html(
|
||||
entities: &[KnowledgeEntity],
|
||||
relationships: &[KnowledgeRelationship],
|
||||
) -> Result<String, HtmlError> {
|
||||
if entities.is_empty() {
|
||||
return Ok(String::new());
|
||||
#[derive(Serialize)]
|
||||
pub struct GraphNode {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub entity_type: String,
|
||||
pub degree: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GraphLink {
|
||||
pub source: String,
|
||||
pub target: String,
|
||||
pub relationship_type: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct GraphData {
|
||||
pub nodes: Vec<GraphNode>,
|
||||
pub links: Vec<GraphLink>,
|
||||
}
|
||||
|
||||
pub async fn get_knowledge_graph_json(
|
||||
State(state): State<HtmlState>,
|
||||
RequireUser(user): RequireUser,
|
||||
Query(mut params): Query<FilterParams>,
|
||||
) -> Result<impl IntoResponse, HtmlError> {
|
||||
// Normalize filters: treat empty or "none" as no filter
|
||||
params.entity_type = normalize_filter(params.entity_type.take());
|
||||
params.content_category = normalize_filter(params.content_category.take());
|
||||
|
||||
// Load entities based on filters
|
||||
let entities: Vec<KnowledgeEntity> = match ¶ms.content_category {
|
||||
Some(cat) => {
|
||||
User::get_knowledge_entities_by_content_category(&user.id, cat, &state.db).await?
|
||||
}
|
||||
None => match ¶ms.entity_type {
|
||||
Some(etype) => User::get_knowledge_entities_by_type(&user.id, etype, &state.db).await?,
|
||||
None => User::get_knowledge_entities(&user.id, &state.db).await?,
|
||||
},
|
||||
};
|
||||
|
||||
// All relationships for user, then filter to those whose endpoints are in the set
|
||||
let relationships: Vec<KnowledgeRelationship> =
|
||||
User::get_knowledge_relationships(&user.id, &state.db).await?;
|
||||
|
||||
let entity_ids: std::collections::HashSet<String> =
|
||||
entities.iter().map(|e| e.id.clone()).collect();
|
||||
|
||||
let mut degree_count: HashMap<String, usize> = HashMap::new();
|
||||
let mut links: Vec<GraphLink> = Vec::new();
|
||||
for rel in relationships.iter() {
|
||||
if entity_ids.contains(&rel.in_) && entity_ids.contains(&rel.out) {
|
||||
// undirected counting for degree
|
||||
*degree_count.entry(rel.in_.clone()).or_insert(0) += 1;
|
||||
*degree_count.entry(rel.out.clone()).or_insert(0) += 1;
|
||||
links.push(GraphLink {
|
||||
source: rel.out.clone(),
|
||||
target: rel.in_.clone(),
|
||||
relationship_type: rel.metadata.relationship_type.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let id_to_idx: HashMap<_, _> = entities
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| (e.id.clone(), i))
|
||||
let nodes: Vec<GraphNode> = entities
|
||||
.into_iter()
|
||||
.map(|e| GraphNode {
|
||||
id: e.id.clone(),
|
||||
name: e.name.clone(),
|
||||
entity_type: format!("{:?}", e.entity_type),
|
||||
degree: *degree_count.get(&e.id).unwrap_or(&0),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Build adjacency list
|
||||
let mut graph: Vec<Vec<usize>> = vec![Vec::new(); entities.len()];
|
||||
for rel in relationships {
|
||||
if let (Some(&from_idx), Some(&to_idx)) = (id_to_idx.get(&rel.out), id_to_idx.get(&rel.in_))
|
||||
{
|
||||
graph[from_idx].push(to_idx);
|
||||
graph[to_idx].push(from_idx);
|
||||
}
|
||||
}
|
||||
|
||||
// Find clusters (connected components)
|
||||
let mut visited = vec![false; entities.len()];
|
||||
let mut clusters: Vec<Vec<usize>> = Vec::new();
|
||||
|
||||
for i in 0..entities.len() {
|
||||
if !visited[i] {
|
||||
let mut queue = VecDeque::new();
|
||||
let mut cluster = Vec::new();
|
||||
queue.push_back(i);
|
||||
visited[i] = true;
|
||||
while let Some(node) = queue.pop_front() {
|
||||
cluster.push(node);
|
||||
for &nbr in &graph[node] {
|
||||
if !visited[nbr] {
|
||||
visited[nbr] = true;
|
||||
queue.push_back(nbr);
|
||||
}
|
||||
}
|
||||
Ok(Json(GraphData { nodes, links }))
|
||||
}
|
||||
// Normalize filter parameters: convert empty strings or "none" (case-insensitive) to None
|
||||
fn normalize_filter(input: Option<String>) -> Option<String> {
|
||||
match input {
|
||||
None => None,
|
||||
Some(s) => {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
clusters.push(cluster);
|
||||
}
|
||||
}
|
||||
|
||||
// Layout params
|
||||
let cluster_spacing = 20.0; // Distance between clusters
|
||||
let node_spacing = 3.0; // Distance between nodes within cluster
|
||||
|
||||
// Arrange clusters on a Fibonacci sphere (uniform 3D positioning on unit sphere)
|
||||
let cluster_count = clusters.len();
|
||||
let golden_angle = std::f64::consts::PI * (3.0 - (5.0f64).sqrt());
|
||||
|
||||
// Will hold final positions of nodes: (x,y,z)
|
||||
let mut nodes_pos = vec![(0.0f64, 0.0f64, 0.0f64); entities.len()];
|
||||
|
||||
for (i, cluster) in clusters.iter().enumerate() {
|
||||
// Position cluster center on unit sphere scaled by cluster_spacing
|
||||
let theta = golden_angle * i as f64;
|
||||
let z = 1.0 - (2.0 * i as f64 + 1.0) / cluster_count as f64;
|
||||
let radius = (1.0 - z * z).sqrt();
|
||||
|
||||
let cluster_center = (
|
||||
radius * theta.cos() * cluster_spacing,
|
||||
radius * theta.sin() * cluster_spacing,
|
||||
z * cluster_spacing,
|
||||
);
|
||||
|
||||
// Layout nodes within cluster as small 3D grid (cube)
|
||||
// Calculate cube root to determine grid side length
|
||||
let cluster_size = cluster.len();
|
||||
let side_len = (cluster_size as f64).cbrt().ceil() as usize;
|
||||
|
||||
for (pos_in_cluster, &node_idx) in cluster.iter().enumerate() {
|
||||
let x_in_cluster = (pos_in_cluster % side_len) as f64;
|
||||
let y_in_cluster = ((pos_in_cluster / side_len) % side_len) as f64;
|
||||
let z_in_cluster = (pos_in_cluster / (side_len * side_len)) as f64;
|
||||
|
||||
nodes_pos[node_idx] = (
|
||||
cluster_center.0 + x_in_cluster * node_spacing,
|
||||
cluster_center.1 + y_in_cluster * node_spacing,
|
||||
cluster_center.2 + z_in_cluster * node_spacing,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let (node_x, node_y, node_z): (Vec<_>, Vec<_>, Vec<_>) = nodes_pos.iter().cloned().unzip3();
|
||||
|
||||
// Nodes trace
|
||||
let nodes_trace = Scatter3D::new(node_x, node_y, node_z)
|
||||
.mode(Mode::Markers)
|
||||
.marker(Marker::new().size(8).color("#1f77b4"))
|
||||
.text_array(
|
||||
entities
|
||||
.iter()
|
||||
.map(|e| e.description.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.hover_template("Entity: %{text}<extra></extra>");
|
||||
|
||||
// Edges traces
|
||||
let mut plot = Plot::new();
|
||||
for rel in relationships {
|
||||
if let (Some(&from_idx), Some(&to_idx)) = (id_to_idx.get(&rel.out), id_to_idx.get(&rel.in_))
|
||||
{
|
||||
let edge_x = vec![nodes_pos[from_idx].0, nodes_pos[to_idx].0];
|
||||
let edge_y = vec![nodes_pos[from_idx].1, nodes_pos[to_idx].1];
|
||||
let edge_z = vec![nodes_pos[from_idx].2, nodes_pos[to_idx].2];
|
||||
|
||||
let edge_trace = Scatter3D::new(edge_x, edge_y, edge_z)
|
||||
.mode(Mode::Lines)
|
||||
.line(Line::new().color("#888").width(2.0))
|
||||
.hover_template(format!(
|
||||
"Relationship: {}<extra></extra>",
|
||||
rel.metadata.relationship_type
|
||||
))
|
||||
.show_legend(false);
|
||||
plot.add_trace(edge_trace);
|
||||
}
|
||||
}
|
||||
|
||||
plot.add_trace(nodes_trace);
|
||||
|
||||
// Layout scene configuration
|
||||
let layout = Layout::new()
|
||||
.scene(
|
||||
LayoutScene::new()
|
||||
.x_axis(Axis::new().visible(false))
|
||||
.y_axis(Axis::new().visible(false))
|
||||
.z_axis(Axis::new().visible(false))
|
||||
.camera(
|
||||
plotly::layout::Camera::new()
|
||||
.projection(plotly::layout::ProjectionType::Perspective.into())
|
||||
.eye((2.0, 2.0, 2.0).into()),
|
||||
),
|
||||
)
|
||||
.show_legend(false)
|
||||
.paper_background_color("rgba(255,255,255,0)")
|
||||
.plot_background_color("rgba(255,255,255,0)");
|
||||
|
||||
plot.set_layout(layout);
|
||||
|
||||
Ok(plot.to_html())
|
||||
}
|
||||
|
||||
// Small utility to unzip tuple3 vectors from iterators
|
||||
trait Unzip3<A, B, C> {
|
||||
fn unzip3(self) -> (Vec<A>, Vec<B>, Vec<C>);
|
||||
}
|
||||
impl<I, A, B, C> Unzip3<A, B, C> for I
|
||||
where
|
||||
I: Iterator<Item = (A, B, C)>,
|
||||
{
|
||||
fn unzip3(self) -> (Vec<A>, Vec<B>, Vec<C>) {
|
||||
let (mut va, mut vb, mut vc) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for (a, b, c) in self {
|
||||
va.push(a);
|
||||
vb.push(b);
|
||||
vc.push(c);
|
||||
}
|
||||
(va, vb, vc)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn show_edit_knowledge_entity_form(
|
||||
|
||||
@@ -6,8 +6,9 @@ use axum::{
|
||||
Router,
|
||||
};
|
||||
use handlers::{
|
||||
delete_knowledge_entity, delete_knowledge_relationship, patch_knowledge_entity,
|
||||
save_knowledge_relationship, show_edit_knowledge_entity_form, show_knowledge_page,
|
||||
delete_knowledge_entity, delete_knowledge_relationship, get_knowledge_graph_json,
|
||||
patch_knowledge_entity, save_knowledge_relationship, show_edit_knowledge_entity_form,
|
||||
show_knowledge_page,
|
||||
};
|
||||
|
||||
use crate::html_state::HtmlState;
|
||||
@@ -19,6 +20,7 @@ where
|
||||
{
|
||||
Router::new()
|
||||
.route("/knowledge", get(show_knowledge_page))
|
||||
.route("/knowledge/graph.json", get(get_knowledge_graph_json))
|
||||
.route(
|
||||
"/knowledge-entity/{id}",
|
||||
get(show_edit_knowledge_entity_form)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<script src="/assets/toast.js" defer></script>
|
||||
<script src="/assets/htmx-head-ext.js" defer></script>
|
||||
<script src="/assets/marked.min.js" defer></script>
|
||||
<script src="/assets/knowledge-graph.js" defer></script>
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="icon" href="/assets/icon/favicon.ico">
|
||||
@@ -76,4 +77,4 @@
|
||||
window.renderAllMarkdown = renderAllMarkdown;
|
||||
</script>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
{% block title %}Minne - Knowledge{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<main id="knowledge_pane" class="flex justify-center grow mt-2 sm:mt-4 gap-6 mb-10">
|
||||
<div class="container">
|
||||
<div id="knowledge_pane" class="flex justify-center grow mt-2 sm:mt-4 gap-6 ">
|
||||
<div class="container overflow-y-auto">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4">
|
||||
<h2 class="text-2xl font-bold">Entities</h2>
|
||||
<h2 class="text-2xl font-bold">Knowledge Entities</h2>
|
||||
<form hx-get="/knowledge" hx-target="#knowledge_pane" hx-push-url="true" hx-swap="outerHTML"
|
||||
class="flex items-center gap-4 mt-2 sm:mt-0">
|
||||
<div class="form-control">
|
||||
@@ -30,12 +30,16 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h2 class="text-2xl font-bold mb-2 mt-10">Graph</h2>
|
||||
<div class="rounded-box overflow-clip mt-4 shadow p-2 mb-10">
|
||||
<div id="knowledge-graph" class="w-full" style="height: 640px;"
|
||||
data-entity-type="{{ selected_entity_type | default(value='') }}"
|
||||
data-content-category="{{ selected_content_category | default(value='') }}">
|
||||
</div>
|
||||
</div>
|
||||
{% include "knowledge/entity_list.html" %}
|
||||
<h2 class="text-2xl font-bold mb-2 mt-10">Relationships</h2>
|
||||
{% include "knowledge/relationship_table.html" %}
|
||||
<div class="rounded-box overflow-clip mt-10 shadow">
|
||||
{{ plot_html | safe }}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,5 @@
|
||||
<div id="relationship_table_section"
|
||||
class="overflow-x-auto shadow rounded-box border border-base-content/5 bg-base-100">
|
||||
class="overflow-x-auto shadow rounded-box border border-base-content/5 bg-base-100 mb-10">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
Reference in New Issue
Block a user