merge parallel edges in graph visualization; update link representation to include combined types and max count

This commit is contained in:
ImBenji 2026-04-23 17:03:37 +01:00
parent 8588f4904f
commit 16d95f5392
2 changed files with 65 additions and 8 deletions

View file

@ -1109,6 +1109,9 @@ async function loadIntelligenceStats() {
const queueMap = {}; const queueMap = {};
(data.queue || []).forEach(r => queueMap[r.status] = r.n); (data.queue || []).forEach(r => queueMap[r.status] = r.n);
const rate5m = (data.processed5m / 5).toFixed(1);
const rate1m = data.processed1m;
document.getElementById('intel-stats-row').innerHTML = [ document.getElementById('intel-stats-row').innerHTML = [
['Queue pending', (queueMap.pending || 0).toLocaleString()], ['Queue pending', (queueMap.pending || 0).toLocaleString()],
['Processed', (queueMap.processed || 0).toLocaleString()], ['Processed', (queueMap.processed || 0).toLocaleString()],
@ -1116,6 +1119,8 @@ async function loadIntelligenceStats() {
['Knowledge rows', data.knowledge.toLocaleString()], ['Knowledge rows', data.knowledge.toLocaleString()],
['Predictions', data.predictions.toLocaleString()], ['Predictions', data.predictions.toLocaleString()],
['Companies', `${data.embeddings}/${data.companies} embedded`], ['Companies', `${data.embeddings}/${data.companies} embedded`],
['Rate (5m avg)', `${rate5m}/min`],
['Rate (last 1m)', `${rate1m}/min`],
].map(([label, value]) => ` ].map(([label, value]) => `
<div class="intel-stat-card"> <div class="intel-stat-card">
<span class="label">${label}</span> <span class="label">${label}</span>
@ -1402,26 +1407,68 @@ function renderIntelGraph() {
} }
const maxDegree = Math.max(...Object.values(degree), 1); const maxDegree = Math.max(...Object.values(degree), 1);
// custom force: push nodes away from edges they are not endpoints of
function forceNodeEdgeRepulsion() {
const minDist = 55;
return function() {
for (const node of nodesCopy) {
for (const link of linksCopy) {
const s = link.source;
const t = link.target;
if (s === node || t === node) continue;
const dx = t.x - s.x;
const dy = t.y - s.y;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) continue;
// parameter of closest point on segment
let u = ((node.x - s.x) * dx + (node.y - s.y) * dy) / lenSq;
u = Math.max(0, Math.min(1, u));
const cx = s.x + u * dx;
const cy = s.y + u * dy;
const ex = node.x - cx;
const ey = node.y - cy;
const dist = Math.sqrt(ex * ex + ey * ey);
if (dist < minDist && dist > 0) {
const push = (minDist - dist) / dist * 0.6;
node.vx += ex * push;
node.vy += ey * push;
}
}
}
};
}
const simulation = d3.forceSimulation(nodesCopy) const simulation = d3.forceSimulation(nodesCopy)
.force('link', d3.forceLink(linksCopy).id(d => d.key).distance(60)) .force('link', d3.forceLink(linksCopy).id(d => d.key).distance(d => {
return Math.max(90, d.type.length * 5 + 60);
}))
.force('charge', d3.forceManyBody().strength(-120)) .force('charge', d3.forceManyBody().strength(-120))
.force('center', d3.forceCenter(width / 2, height / 2)) .force('center', d3.forceCenter(width / 2, height / 2))
.force('collide', d3.forceCollide(d => d.tracked ? 48 : 34).strength(1)) .force('collide', d3.forceCollide(d => d.tracked ? 48 : 34).strength(1))
.force('nodeEdgeRepulsion', forceNodeEdgeRepulsion())
.alphaDecay(0.015); .alphaDecay(0.015);
// links — use <path> so textPath can align labels to the line // links — use <path> so textPath can align labels to the line
const linkG = g.append('g'); const linkG = g.append('g');
// stroke-width min 10 so text (7px) always fits inside the line
const strokeWidth = d => 10 + Math.pow(d.count / maxCount, 0.55) * 8;
const linkPaths = linkG.selectAll('path.ig-edge') const linkPaths = linkG.selectAll('path.ig-edge')
.data(linksCopy) .data(linksCopy)
.join('path') .join('path')
.attr('class', 'ig-edge') .attr('class', 'ig-edge')
.attr('id', (d, i) => `ig-ep-${i}`) .attr('id', (d, i) => `ig-ep-${i}`)
.attr('fill', 'none') .attr('fill', 'none')
.attr('stroke', '#334155') .attr('stroke', '#1e293b')
.attr('stroke-width', d => 1 + Math.pow(d.count / maxCount, 0.55) * 6) .attr('stroke-width', strokeWidth)
.attr('stroke-opacity', d => 0.2 + (d.count / maxCount) * 0.7) .attr('stroke-opacity', d => 0.55 + (d.count / maxCount) * 0.35)
.on('mouseenter', function(ev, d) { .on('mouseenter', function(ev, d) {
d3.select(this).attr('stroke', '#60a5fa').attr('stroke-opacity', 1); d3.select(this).attr('stroke', '#60a5fa').attr('stroke-opacity', 1);
}) })
@ -1430,13 +1477,13 @@ function renderIntelGraph() {
.attr('stroke-opacity', 0.2 + (d.count / maxCount) * 0.7); .attr('stroke-opacity', 0.2 + (d.count / maxCount) * 0.7);
}); });
// labels along each edge via textPath // labels along each edge via textPath — font-size slightly smaller than stroke-width
const linkLabels = linkG.selectAll('text.ig-elabel') const linkLabels = linkG.selectAll('text.ig-elabel')
.data(linksCopy) .data(linksCopy)
.join('text') .join('text')
.attr('class', 'ig-elabel') .attr('class', 'ig-elabel')
.attr('font-size', 9) .attr('font-size', 7)
.attr('fill', '#475569') .attr('fill', '#94a3b8')
.attr('text-anchor', 'middle') .attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle') .attr('dominant-baseline', 'middle')
.attr('pointer-events', 'none') .attr('pointer-events', 'none')

View file

@ -234,7 +234,17 @@ async function adminRoutes(fastify) {
const companies = db.prepare(`SELECT COUNT(*) as n FROM tracked_companies`).get().n; const companies = db.prepare(`SELECT COUNT(*) as n FROM tracked_companies`).get().n;
const embeddings = db.prepare(`SELECT COUNT(*) as n FROM company_embeddings`).get().n; const embeddings = db.prepare(`SELECT COUNT(*) as n FROM company_embeddings`).get().n;
return { available: true, queue, knowledge, predictions, companies, embeddings }; const processed5m = db.prepare(`
SELECT COUNT(*) as n FROM article_queue
WHERE status = 'processed' AND updated_at >= datetime('now', '-5 minutes')
`).get().n;
const processed1m = db.prepare(`
SELECT COUNT(*) as n FROM article_queue
WHERE status = 'processed' AND updated_at >= datetime('now', '-1 minute')
`).get().n;
return { available: true, queue, knowledge, predictions, companies, embeddings, processed5m, processed1m };
}); });
fastify.get('/admin/api/intelligence/companies', async (request, reply) => { fastify.get('/admin/api/intelligence/companies', async (request, reply) => {