add event_date column to event_knowledge and event_predictions tables; update related logic in admin panel and augorWorker
This commit is contained in:
+225
-64
@@ -478,6 +478,15 @@
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
#intel-graph-svg-wrap.graph-expanded {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 500;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
#intel-graph-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -689,6 +698,7 @@
|
||||
<div id="intel-graph-svg-wrap">
|
||||
<svg id="intel-graph-svg"></svg>
|
||||
<div id="graph-empty" style="display:none; position:absolute; inset:0; color:var(--muted); font-size:13px; text-align:center; padding-top:120px">No relationship data yet</div>
|
||||
<button id="graph-expand-btn" onclick="toggleGraphExpand()" style="position:absolute; top:10px; right:10px; padding:5px 10px; font-size:12px; background:var(--bg-card); border:1px solid var(--border); color:var(--muted); border-radius:var(--radius); cursor:pointer; z-index:10" title="Expand graph">⤢ Expand</button>
|
||||
</div>
|
||||
<div id="graph-sidebar">
|
||||
<div id="graph-sidebar-title"></div>
|
||||
@@ -697,9 +707,15 @@
|
||||
</div>
|
||||
|
||||
<div id="graph-legend">
|
||||
<span><span class="graph-legend-dot" style="background:#3b82f6; border:2px solid #1d4ed8"></span>Tracked company</span>
|
||||
<span><span class="graph-legend-dot" style="background:#1e293b; border:2px solid #475569"></span>Untracked entity</span>
|
||||
<span style="margin-left:8px; color:var(--muted-dark)">Scroll to zoom · drag nodes · click to see facts</span>
|
||||
<span><span class="graph-legend-dot" style="background:#1e3a5f; border:2px solid #3b82f6"></span>Tracked company</span>
|
||||
<span><span class="graph-legend-dot" style="background:#0f172a; border:2px solid #475569"></span>Untracked entity</span>
|
||||
|
||||
<label style="display:flex; align-items:center; gap:6px; margin-left:16px; cursor:pointer; user-select:none">
|
||||
<input type="checkbox" id="graph-show-untracked" style="accent-color:var(--accent); width:13px; height:13px" />
|
||||
<span>Show untracked entities</span>
|
||||
</label>
|
||||
|
||||
<span style="margin-left:auto; color:var(--muted-dark)">Scroll to zoom · drag nodes · click for facts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1215,6 +1231,32 @@ document.getElementById('i-view').onchange = () => { intelOffset = 0; loadIntell
|
||||
|
||||
// ── intelligence graph ─────────────────────────────────────────────────────
|
||||
|
||||
// tickers that dont make a readable label inside a node
|
||||
const TICKER_DISPLAY = {
|
||||
'005930.KS': 'SMSNG',
|
||||
'000660.KS': 'HYNIX',
|
||||
'OPENAI': 'OAI',
|
||||
'ANTHROPIC': 'ANTH',
|
||||
};
|
||||
|
||||
// split a name into up to two lines at a natural word boundary
|
||||
function splitLabel(name, maxLine) {
|
||||
if (name.length <= maxLine) return [name];
|
||||
const mid = Math.floor(name.length / 2);
|
||||
// find a space near the middle to break on
|
||||
let best = -1;
|
||||
let bestDist = Infinity;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
if (name[i] === ' ') {
|
||||
const dist = Math.abs(i - mid);
|
||||
if (dist < bestDist) { bestDist = dist; best = i; }
|
||||
}
|
||||
}
|
||||
if (best === -1) return [name]; // no spaces, just show as-is
|
||||
return [name.slice(0, best), name.slice(best + 1)];
|
||||
}
|
||||
|
||||
|
||||
function showGraphView(visible) {
|
||||
document.getElementById('intel-graph-wrap').style.display = visible ? 'block' : 'none';
|
||||
document.getElementById('intel-table-wrap').style.display = visible ? 'none' : '';
|
||||
@@ -1222,6 +1264,9 @@ function showGraphView(visible) {
|
||||
}
|
||||
|
||||
|
||||
let graphAllNodes = [];
|
||||
let graphAllLinks = [];
|
||||
|
||||
async function loadIntelGraph() {
|
||||
showGraphView(true);
|
||||
|
||||
@@ -1237,22 +1282,20 @@ async function loadIntelGraph() {
|
||||
|
||||
document.getElementById('graph-empty').style.display = 'none';
|
||||
|
||||
// build keyed nodes
|
||||
const nodeMap = new Map();
|
||||
for (const n of data.nodes) {
|
||||
const key = n.tracked ? `c_${n.id}` : `u_${n.name}`;
|
||||
nodeMap.set(key, { ...n, key });
|
||||
}
|
||||
|
||||
const nodes = Array.from(nodeMap.values());
|
||||
graphAllNodes = Array.from(nodeMap.values());
|
||||
|
||||
const links = [];
|
||||
graphAllLinks = [];
|
||||
for (const e of data.edges) {
|
||||
const src = `c_${e.from_company_id}`;
|
||||
const tgt = e.to_company_id ? `c_${e.to_company_id}` : `u_${e.to_entity}`;
|
||||
|
||||
if (nodeMap.has(src) && nodeMap.has(tgt)) {
|
||||
links.push({
|
||||
graphAllLinks.push({
|
||||
source: src,
|
||||
target: tgt,
|
||||
type: e.relationship_type,
|
||||
@@ -1262,62 +1305,132 @@ async function loadIntelGraph() {
|
||||
}
|
||||
}
|
||||
|
||||
renderIntelGraph(nodes, links);
|
||||
renderIntelGraph();
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') {
|
||||
const wrap = document.getElementById('intel-graph-svg-wrap');
|
||||
if (wrap.classList.contains('graph-expanded')) toggleGraphExpand();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('graph-show-untracked').addEventListener('change', () => {
|
||||
if (graphAllNodes.length) renderIntelGraph();
|
||||
});
|
||||
|
||||
|
||||
function toggleGraphExpand() {
|
||||
const wrap = document.getElementById('intel-graph-svg-wrap');
|
||||
const btn = document.getElementById('graph-expand-btn');
|
||||
const expanded = wrap.classList.toggle('graph-expanded');
|
||||
btn.textContent = expanded ? '⤡ Collapse' : '⤢ Expand';
|
||||
// re-render so the simulation uses the new dimensions
|
||||
if (graphAllNodes.length) renderIntelGraph();
|
||||
}
|
||||
|
||||
|
||||
function renderIntelGraph(nodes, links) {
|
||||
function renderIntelGraph() {
|
||||
const showUntracked = document.getElementById('graph-show-untracked').checked;
|
||||
|
||||
const nodes = showUntracked
|
||||
? graphAllNodes
|
||||
: graphAllNodes.filter(n => n.tracked);
|
||||
|
||||
const visibleKeys = new Set(nodes.map(n => n.key));
|
||||
const links = graphAllLinks.filter(l => visibleKeys.has(
|
||||
typeof l.source === 'object' ? l.source.key : l.source
|
||||
) && visibleKeys.has(
|
||||
typeof l.target === 'object' ? l.target.key : l.target
|
||||
));
|
||||
|
||||
// deep-copy so d3 doesnt mutate our stored arrays on re-render
|
||||
const nodesCopy = nodes.map(n => ({ ...n }));
|
||||
const linksCopy = links.map(l => ({
|
||||
...l,
|
||||
source: typeof l.source === 'object' ? l.source.key : l.source,
|
||||
target: typeof l.target === 'object' ? l.target.key : l.target,
|
||||
}));
|
||||
|
||||
const svgEl = document.getElementById('intel-graph-svg');
|
||||
svgEl.innerHTML = '';
|
||||
|
||||
const width = svgEl.clientWidth || 800;
|
||||
const width = svgEl.clientWidth || 900;
|
||||
const height = svgEl.clientHeight || 600;
|
||||
|
||||
const svg = d3.select(svgEl);
|
||||
|
||||
const g = svg.append('g');
|
||||
|
||||
// arrow marker
|
||||
svg.append('defs').append('marker')
|
||||
// svg defs — arrow marker + glow filter
|
||||
const defs = svg.append('defs');
|
||||
|
||||
defs.append('marker')
|
||||
.attr('id', 'ig-arrow')
|
||||
.attr('viewBox', '0 -5 10 10')
|
||||
.attr('refX', 22)
|
||||
.attr('refX', 28)
|
||||
.attr('refY', 0)
|
||||
.attr('markerWidth', 5)
|
||||
.attr('markerHeight', 5)
|
||||
.attr('orient', 'auto')
|
||||
.append('path')
|
||||
.attr('d', 'M0,-5L10,0L0,5')
|
||||
.attr('fill', '#334155');
|
||||
.attr('fill', '#475569');
|
||||
|
||||
const maxCount = Math.max(...links.map(l => l.count), 1);
|
||||
const glow = defs.append('filter')
|
||||
.attr('id', 'ig-glow')
|
||||
.attr('x', '-50%').attr('y', '-50%')
|
||||
.attr('width', '200%').attr('height', '200%');
|
||||
glow.append('feGaussianBlur').attr('stdDeviation', '4').attr('result', 'blur');
|
||||
const merge = glow.append('feMerge');
|
||||
merge.append('feMergeNode').attr('in', 'blur');
|
||||
merge.append('feMergeNode').attr('in', 'SourceGraphic');
|
||||
|
||||
const zoomBehavior = d3.zoom().scaleExtent([0.1, 5]).on('zoom', ev => g.attr('transform', ev.transform));
|
||||
const maxCount = Math.max(...linksCopy.map(l => l.count), 1);
|
||||
|
||||
const zoomBehavior = d3.zoom().scaleExtent([0.08, 6]).on('zoom', ev => g.attr('transform', ev.transform));
|
||||
svg.call(zoomBehavior);
|
||||
|
||||
const simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(links).id(d => d.key).distance(150))
|
||||
.force('charge', d3.forceManyBody().strength(-500))
|
||||
const simulation = d3.forceSimulation(nodesCopy)
|
||||
.force('link', d3.forceLink(linksCopy).id(d => d.key).distance(d => {
|
||||
// tracked-tracked links spread wider
|
||||
const bothTracked = typeof d.source === 'object' ? d.source.tracked && d.target.tracked : false;
|
||||
return bothTracked ? 220 : 160;
|
||||
}))
|
||||
.force('charge', d3.forceManyBody().strength(-900))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collide', d3.forceCollide(40));
|
||||
.force('collide', d3.forceCollide(d => d.tracked ? 52 : 38));
|
||||
|
||||
|
||||
const linkLines = g.append('g')
|
||||
.selectAll('line')
|
||||
.data(links)
|
||||
// links
|
||||
const linkG = g.append('g');
|
||||
|
||||
const linkLines = linkG.selectAll('line')
|
||||
.data(linksCopy)
|
||||
.join('line')
|
||||
.attr('stroke', '#334155')
|
||||
.attr('stroke-width', d => 1 + (d.count / maxCount) * 3)
|
||||
.attr('stroke-opacity', d => 0.3 + (d.count / maxCount) * 0.6)
|
||||
.attr('stroke-width', d => {
|
||||
// more dramatic: 1px at count=1, up to 7px at maxCount
|
||||
return 1 + Math.pow(d.count / maxCount, 0.6) * 6;
|
||||
})
|
||||
.attr('stroke-opacity', d => 0.25 + (d.count / maxCount) * 0.65)
|
||||
.attr('marker-end', 'url(#ig-arrow)');
|
||||
|
||||
// relationship type on hover only via title
|
||||
linkLines.append('title').text(d => `${d.type} (×${d.count})`);
|
||||
// edge labels — size and opacity proportional to count
|
||||
const linkLabels = linkG.selectAll('text')
|
||||
.data(linksCopy)
|
||||
.join('text')
|
||||
.text(d => d.type)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', d => 7 + Math.round((d.count / maxCount) * 4))
|
||||
.attr('fill', '#475569')
|
||||
.attr('opacity', d => 0.4 + (d.count / maxCount) * 0.5)
|
||||
.attr('pointer-events', 'none');
|
||||
|
||||
|
||||
// nodes
|
||||
const nodeGroups = g.append('g')
|
||||
.selectAll('g')
|
||||
.data(nodes)
|
||||
.data(nodesCopy)
|
||||
.join('g')
|
||||
.attr('cursor', d => d.tracked ? 'pointer' : 'default')
|
||||
.call(
|
||||
@@ -1336,29 +1449,66 @@ function renderIntelGraph(nodes, links) {
|
||||
if (d.tracked) openGraphSidebar(d);
|
||||
});
|
||||
|
||||
// tracked node: glow circle + main circle
|
||||
nodeGroups.filter(d => d.tracked).append('circle')
|
||||
.attr('r', 24)
|
||||
.attr('fill', 'rgba(59,130,246,0.08)')
|
||||
.attr('stroke', 'rgba(59,130,246,0.25)')
|
||||
.attr('stroke-width', 1)
|
||||
.attr('filter', 'url(#ig-glow)')
|
||||
.attr('pointer-events', 'none');
|
||||
|
||||
nodeGroups.append('circle')
|
||||
.attr('r', d => d.tracked ? 16 : 11)
|
||||
.attr('fill', d => d.tracked ? '#1e3a5f' : '#0f172a')
|
||||
.attr('stroke', d => d.tracked ? '#3b82f6' : '#475569')
|
||||
.attr('r', d => d.tracked ? 20 : 12)
|
||||
.attr('fill', d => d.tracked ? '#1a3152' : '#0b1524')
|
||||
.attr('stroke', d => d.tracked ? '#3b82f6' : '#334155')
|
||||
.attr('stroke-width', d => d.tracked ? 2 : 1.5);
|
||||
|
||||
// ticker label inside tracked nodes; short name below untracked nodes
|
||||
// short ticker label inside circle for tracked nodes
|
||||
nodeGroups.filter(d => d.tracked).append('text')
|
||||
.text(d => d.ticker || d.name.slice(0, 5))
|
||||
.text(d => TICKER_DISPLAY[d.ticker] || (d.ticker && d.ticker.length <= 5 ? d.ticker : d.name.slice(0, 4).toUpperCase()))
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'middle')
|
||||
.attr('font-size', 9)
|
||||
.attr('font-weight', '600')
|
||||
.attr('font-size', 8)
|
||||
.attr('font-weight', '700')
|
||||
.attr('fill', '#93c5fd')
|
||||
.attr('letter-spacing', '0.04em')
|
||||
.attr('pointer-events', 'none');
|
||||
|
||||
nodeGroups.filter(d => !d.tracked).append('text')
|
||||
.text(d => d.name.length > 12 ? d.name.slice(0, 11) + '…' : d.name)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('y', 20)
|
||||
.attr('font-size', 9)
|
||||
.attr('fill', '#475569')
|
||||
.attr('pointer-events', 'none');
|
||||
// full company name below tracked nodes — wrapped
|
||||
nodeGroups.filter(d => d.tracked).each(function(d) {
|
||||
const lines = splitLabel(d.name, 14);
|
||||
const el = d3.select(this).append('text')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', 10)
|
||||
.attr('font-weight', '500')
|
||||
.attr('fill', '#cbd5e1')
|
||||
.attr('pointer-events', 'none');
|
||||
|
||||
lines.forEach((line, i) => {
|
||||
el.append('tspan')
|
||||
.attr('x', 0)
|
||||
.attr('dy', i === 0 ? 32 : 13)
|
||||
.text(line);
|
||||
});
|
||||
});
|
||||
|
||||
// full entity name below untracked nodes — wrapped
|
||||
nodeGroups.filter(d => !d.tracked).each(function(d) {
|
||||
const lines = splitLabel(d.name, 14);
|
||||
const el = d3.select(this).append('text')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', 9)
|
||||
.attr('fill', '#475569')
|
||||
.attr('pointer-events', 'none');
|
||||
|
||||
lines.forEach((line, i) => {
|
||||
el.append('tspan')
|
||||
.attr('x', 0)
|
||||
.attr('dy', i === 0 ? 20 : 12)
|
||||
.text(line);
|
||||
});
|
||||
});
|
||||
|
||||
nodeGroups.append('title').text(d => d.name);
|
||||
|
||||
@@ -1370,15 +1520,18 @@ function renderIntelGraph(nodes, links) {
|
||||
.attr('x2', d => d.target.x)
|
||||
.attr('y2', d => d.target.y);
|
||||
|
||||
linkLabels
|
||||
.attr('x', d => (d.source.x + d.target.x) / 2)
|
||||
.attr('y', d => (d.source.y + d.target.y) / 2 - 3);
|
||||
|
||||
nodeGroups.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
|
||||
// fit everything into view once the simulation has cooled enough
|
||||
simulation.on('end', () => {
|
||||
const bounds = g.node().getBBox();
|
||||
if (!bounds.width || !bounds.height) return;
|
||||
|
||||
const pad = 40;
|
||||
const pad = 60;
|
||||
const scaleX = width / (bounds.width + pad * 2);
|
||||
const scaleY = height / (bounds.height + pad * 2);
|
||||
const scale = Math.min(scaleX, scaleY, 1);
|
||||
@@ -1386,7 +1539,7 @@ function renderIntelGraph(nodes, links) {
|
||||
const tx = width / 2 - scale * (bounds.x + bounds.width / 2);
|
||||
const ty = height / 2 - scale * (bounds.y + bounds.height / 2);
|
||||
|
||||
svg.transition().duration(600)
|
||||
svg.transition().duration(700)
|
||||
.call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
|
||||
});
|
||||
}
|
||||
@@ -1490,24 +1643,32 @@ async function runSql() {
|
||||
|
||||
elapsedEl.textContent = `${data.elapsed}ms`;
|
||||
|
||||
if (data.rows && data.rows.length > 0) {
|
||||
const cols = Object.keys(data.rows[0]);
|
||||
resultsEl.innerHTML = `
|
||||
<div class="table-wrap" style="margin-top:4px">
|
||||
<table>
|
||||
<thead><tr>${cols.map(c => `<th>${c}</th>`).join('')}</tr></thead>
|
||||
<tbody>${data.rows.map(r =>
|
||||
`<tr>${cols.map(c => `<td><span class="truncate" style="max-width:300px" title="${String(r[c] ?? '').replace(/"/g,'"')}">${r[c] ?? '<span style="color:var(--muted-dark)">NULL</span>'}</span></td>`).join('')}</tr>`
|
||||
).join('')}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="color:var(--muted-dark); font-size:12px; margin-top:8px">${data.rows.length} row${data.rows.length !== 1 ? 's' : ''}</div>
|
||||
`;
|
||||
} else if (data.rows) {
|
||||
resultsEl.innerHTML = '<div style="color:var(--muted); font-size:13px; padding-top:8px">No rows returned.</div>';
|
||||
} else {
|
||||
resultsEl.innerHTML = `<div style="color:#86efac; font-size:13px; padding-top:8px">${data.changes} row${data.changes !== 1 ? 's' : ''} affected.</div>`;
|
||||
const blocks = [];
|
||||
for (const r of (data.results || [])) {
|
||||
if (r.error) {
|
||||
blocks.push(`<div style="color:#fca5a5; font-size:13px; margin-bottom:12px"><span style="color:var(--muted-dark); font-size:11px; display:block; margin-bottom:4px; font-family:'SF Mono','Fira Code',monospace">${r.sql.slice(0, 120)}</span>${r.error}</div>`);
|
||||
} else if (r.rows && r.rows.length > 0) {
|
||||
const cols = Object.keys(r.rows[0]);
|
||||
blocks.push(`
|
||||
<div style="margin-bottom:16px">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr>${cols.map(c => `<th>${c}</th>`).join('')}</tr></thead>
|
||||
<tbody>${r.rows.map(row =>
|
||||
`<tr>${cols.map(c => `<td><span class="truncate" style="max-width:300px" title="${String(row[c] ?? '').replace(/"/g,'"')}">${row[c] ?? '<span style="color:var(--muted-dark)">NULL</span>'}</span></td>`).join('')}</tr>`
|
||||
).join('')}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="color:var(--muted-dark); font-size:12px; margin-top:6px">${r.rows.length} row${r.rows.length !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
`);
|
||||
} else if (r.rows) {
|
||||
blocks.push(`<div style="color:var(--muted); font-size:13px; margin-bottom:12px">No rows returned.</div>`);
|
||||
} else {
|
||||
blocks.push(`<div style="color:#86efac; font-size:13px; margin-bottom:12px">${r.changes} row${r.changes !== 1 ? 's' : ''} affected.</div>`);
|
||||
}
|
||||
}
|
||||
resultsEl.innerHTML = blocks.join('');
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.style.display = '';
|
||||
|
||||
Reference in New Issue
Block a user