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) => {