diff --git a/admin.html b/admin.html
index 7812d5e..e73d302 100644
--- a/admin.html
+++ b/admin.html
@@ -1109,6 +1109,9 @@ async function loadIntelligenceStats() {
const queueMap = {};
(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 = [
['Queue pending', (queueMap.pending || 0).toLocaleString()],
['Processed', (queueMap.processed || 0).toLocaleString()],
@@ -1116,6 +1119,8 @@ async function loadIntelligenceStats() {
['Knowledge rows', data.knowledge.toLocaleString()],
['Predictions', data.predictions.toLocaleString()],
['Companies', `${data.embeddings}/${data.companies} embedded`],
+ ['Rate (5m avg)', `${rate5m}/min`],
+ ['Rate (last 1m)', `${rate1m}/min`],
].map(([label, value]) => `
${label}
@@ -1402,26 +1407,68 @@ function renderIntelGraph() {
}
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)
- .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('center', d3.forceCenter(width / 2, height / 2))
.force('collide', d3.forceCollide(d => d.tracked ? 48 : 34).strength(1))
+ .force('nodeEdgeRepulsion', forceNodeEdgeRepulsion())
.alphaDecay(0.015);
// links — use
so textPath can align labels to the line
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')
.data(linksCopy)
.join('path')
.attr('class', 'ig-edge')
.attr('id', (d, i) => `ig-ep-${i}`)
.attr('fill', 'none')
- .attr('stroke', '#334155')
- .attr('stroke-width', d => 1 + Math.pow(d.count / maxCount, 0.55) * 6)
- .attr('stroke-opacity', d => 0.2 + (d.count / maxCount) * 0.7)
+ .attr('stroke', '#1e293b')
+ .attr('stroke-width', strokeWidth)
+ .attr('stroke-opacity', d => 0.55 + (d.count / maxCount) * 0.35)
.on('mouseenter', function(ev, d) {
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);
});
- // 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')
.data(linksCopy)
.join('text')
.attr('class', 'ig-elabel')
- .attr('font-size', 9)
- .attr('fill', '#475569')
+ .attr('font-size', 7)
+ .attr('fill', '#94a3b8')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('pointer-events', 'none')
diff --git a/src/routes/admin.js b/src/routes/admin.js
index cbadf00..f9fbe5d 100644
--- a/src/routes/admin.js
+++ b/src/routes/admin.js
@@ -234,7 +234,17 @@ async function adminRoutes(fastify) {
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;
- 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) => {