merge parallel edges in graph visualization; update link representation to include combined types and max count
This commit is contained in:
parent
8588f4904f
commit
16d95f5392
2 changed files with 65 additions and 8 deletions
61
admin.html
61
admin.html
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue