| <?php |
| $title = 'Entity Network - Research Document Archive'; |
| $content = ''; |
| ob_start(); |
|
|
| $sectionNames = [ |
| 'cia_declassified' => 'CIA Declassified', |
| 'cia_mkultra' => 'CIA MKUltra', |
| 'cia_stargate' => 'CIA Stargate', |
| 'doj_disclosures' => 'DOJ Disclosures', |
| 'house_resolutions' => 'House Resolutions', |
| 'jfk_assassination' => 'JFK Assassination', |
| 'lincoln_archives' => 'Lincoln Archives', |
| ]; |
| ?> |
|
|
| <div class="mb-6"> |
| <h1 class="text-2xl font-bold text-gray-900">Entity Network</h1> |
| <p class="mt-1 text-sm text-gray-600">Explore co-occurrence relationships between entities across document collections.</p> |
| </div> |
| |
| <!-- Filters --> |
| <div class="bg-white border border-gray-200 rounded-lg shadow-sm p-4 mb-6"> |
| <form method="get" class="flex flex-wrap gap-4 items-end"> |
| <div> |
| <label class="block text-xs font-medium text-gray-500 uppercase mb-1">Collection</label> |
| <select name="section" class="rounded-md border-gray-300 text-sm py-1.5"> |
| <option value="">Select collection...</option> |
| <?php foreach ($sections as $s): ?> |
| <option value="<?= htmlspecialchars($s['source_section']) ?>" |
| <?= $section === $s['source_section'] ? 'selected' : '' ?>> |
| <?= htmlspecialchars($sectionNames[$s['source_section']] ?? $s['source_section']) ?> |
| </option> |
| <?php endforeach; ?> |
| </select> |
| </div> |
| <div> |
| <label class="block text-xs font-medium text-gray-500 uppercase mb-1">Entity Type</label> |
| <select name="type" class="rounded-md border-gray-300 text-sm py-1.5"> |
| <option value="PERSON" <?= $entityType === 'PERSON' ? 'selected' : '' ?>>People</option> |
| <option value="ORG" <?= $entityType === 'ORG' ? 'selected' : '' ?>>Organizations</option> |
| </select> |
| </div> |
| <div> |
| <label class="block text-xs font-medium text-gray-500 uppercase mb-1">Min Co-occurrences</label> |
| <input type="number" name="min" value="<?= $minCount ?>" min="2" max="1000" |
| class="rounded-md border-gray-300 text-sm py-1.5 w-24"> |
| </div> |
| <button type="submit" class="px-4 py-1.5 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"> |
| Explore |
| </button> |
| </form> |
| </div> |
| |
| <?php if (!empty($relationships)): ?> |
| |
| <!-- Network Graph --> |
| <div class="bg-white border border-gray-200 rounded-lg shadow-sm mb-6 overflow-hidden"> |
| <div class="p-3 border-b border-gray-200 bg-gray-50 flex justify-between items-center"> |
| <h2 class="text-sm font-semibold text-gray-700"> |
| Network Graph — <?= htmlspecialchars($sectionNames[$section] ?? $section) ?> |
| <span class="font-normal text-gray-500">(<?= count($nodes) ?> entities, <?= count($relationships) ?> connections)</span> |
| </h2> |
| </div> |
| <div id="network-graph" style="height: 600px;"></div> |
| </div> |
| |
| <!-- Top Connections Table --> |
| <div class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden"> |
| <div class="p-3 border-b border-gray-200 bg-gray-50"> |
| <h2 class="text-sm font-semibold text-gray-700">Top Connections</h2> |
| </div> |
| <table class="min-w-full divide-y divide-gray-200"> |
| <thead class="bg-gray-50"> |
| <tr> |
| <th class="px-4 py-2 text-left text-[11px] font-semibold text-gray-500 uppercase">Entity A</th> |
| <th class="px-4 py-2 text-left text-[11px] font-semibold text-gray-500 uppercase">Entity B</th> |
| <th class="px-4 py-2 text-center text-[11px] font-semibold text-gray-500 uppercase">Co-occurrences</th> |
| <th class="px-4 py-2 text-center text-[11px] font-semibold text-gray-500 uppercase">Documents</th> |
| <th class="px-4 py-2 text-center text-[11px] font-semibold text-gray-500 uppercase">Sample</th> |
| </tr> |
| </thead> |
| <tbody class="divide-y divide-gray-100"> |
| <?php foreach (array_slice($relationships, 0, 50) as $rel): |
| $sampleIds = $rel['sample_doc_ids'] ?? ''; |
| if (is_string($sampleIds)) { |
| $sampleIds = trim($sampleIds, '{}'); |
| $sampleIds = $sampleIds ? explode(',', $sampleIds) : []; |
| } |
| ?> |
| <tr class="hover:bg-gray-50"> |
| <td class="px-4 py-2 text-sm text-gray-900"><?= htmlspecialchars($rel['entity_a']) ?></td> |
| <td class="px-4 py-2 text-sm text-gray-900"><?= htmlspecialchars($rel['entity_b']) ?></td> |
| <td class="px-4 py-2 text-center text-sm font-mono text-gray-700"><?= number_format((int)$rel['co_occurrence_count']) ?></td> |
| <td class="px-4 py-2 text-center text-sm text-gray-600"><?= number_format((int)$rel['document_count']) ?></td> |
| <td class="px-4 py-2 text-center"> |
| <?php foreach (array_slice($sampleIds, 0, 3) as $sid): ?> |
| <a href="/document/<?= (int)$sid ?>" class="text-xs text-blue-600 hover:underline mr-1">#<?= (int)$sid ?></a> |
| <?php endforeach; ?> |
| </td> |
| </tr> |
| <?php endforeach; ?> |
| </tbody> |
| </table> |
| </div> |
| |
| <!-- D3 Force-Directed Graph --> |
| <script src="https://d3js.org/d3.v7.min.js"></script> |
| <script> |
| (function() { |
| const nodes = <?= json_encode(array_map(function($name, $degree) { |
| return ['id' => $name, 'degree' => $degree]; |
| }, array_keys($nodes), array_values($nodes))) ?>; |
|
|
| const links = <?= json_encode(array_map(function($r) { |
| return ['source' => $r['entity_a'], 'target' => $r['entity_b'], 'value' => (int)$r['co_occurrence_count']]; |
| }, $relationships)) ?>; |
|
|
| const container = document.getElementById('network-graph'); |
| const width = container.clientWidth; |
| const height = 600; |
|
|
| const svg = d3.select('#network-graph') |
| .append('svg') |
| .attr('width', width) |
| .attr('height', height) |
| .attr('viewBox', [0, 0, width, height]); |
|
|
| |
| const maxDegree = d3.max(nodes, d => d.degree) || 1; |
| const sizeScale = d3.scaleSqrt().domain([1, maxDegree]).range([3, 20]); |
| const linkScale = d3.scaleLog().domain([d3.min(links, d => d.value) || 1, d3.max(links, d => d.value) || 1]).range([0.5, 4]); |
|
|
| const simulation = d3.forceSimulation(nodes) |
| .force('link', d3.forceLink(links).id(d => d.id).distance(80)) |
| .force('charge', d3.forceManyBody().strength(-120)) |
| .force('center', d3.forceCenter(width / 2, height / 2)) |
| .force('collision', d3.forceCollide().radius(d => sizeScale(d.degree) + 2)); |
|
|
| const g = svg.append('g'); |
|
|
| |
| svg.call(d3.zoom().scaleExtent([0.1, 5]).on('zoom', e => g.attr('transform', e.transform))); |
|
|
| const link = g.append('g') |
| .selectAll('line') |
| .data(links) |
| .join('line') |
| .attr('stroke', '#999') |
| .attr('stroke-opacity', 0.4) |
| .attr('stroke-width', d => linkScale(d.value)); |
|
|
| const node = g.append('g') |
| .selectAll('circle') |
| .data(nodes) |
| .join('circle') |
| .attr('r', d => sizeScale(d.degree)) |
| .attr('fill', '#3b82f6') |
| .attr('stroke', '#1d4ed8') |
| .attr('stroke-width', 1) |
| .call(d3.drag() |
| .on('start', (e, d) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }) |
| .on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; }) |
| .on('end', (e, d) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; })); |
|
|
| const label = g.append('g') |
| .selectAll('text') |
| .data(nodes.filter(d => d.degree > maxDegree * 0.1)) |
| .join('text') |
| .text(d => d.id) |
| .attr('font-size', '9px') |
| .attr('fill', '#374151') |
| .attr('dx', 8) |
| .attr('dy', 3); |
|
|
| node.append('title').text(d => d.id + ' (' + d.degree + ' co-occurrences)'); |
|
|
| simulation.on('tick', () => { |
| link.attr('x1', d => d.source.x).attr('y1', d => d.source.y) |
| .attr('x2', d => d.target.x).attr('y2', d => d.target.y); |
| node.attr('cx', d => d.x).attr('cy', d => d.y); |
| label.attr('x', d => d.x).attr('y', d => d.y); |
| }); |
| })(); |
| </script> |
|
|
| <?php elseif ($section): ?> |
| <div class="text-center py-12 bg-white rounded-lg border border-gray-200"> |
| <p class="text-sm text-gray-500">No relationships found with minimum <?= $minCount ?> co-occurrences. Try lowering the threshold.</p> |
| </div> |
| <?php else: ?> |
| |
| <!-- Overview Stats --> |
| <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> |
| <?php foreach ($stats as $s): ?> |
| <a href="?section=<?= urlencode($s['section']) ?>&type=<?= urlencode($s['type']) ?>&min=10" |
| class="bg-white border border-gray-200 rounded-lg shadow-sm p-4 hover:shadow-md transition-shadow"> |
| <div class="flex items-center justify-between"> |
| <div> |
| <p class="text-sm font-medium text-gray-900"> |
| <?= htmlspecialchars($sectionNames[$s['section']] ?? $s['section']) ?> |
| </p> |
| <p class="text-xs text-gray-500 mt-0.5"><?= htmlspecialchars($s['type']) ?> entities</p> |
| </div> |
| <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium |
| <?= $s['type'] === 'PERSON' ? 'bg-blue-100 text-blue-800' : 'bg-purple-100 text-purple-800' ?>"> |
| <?= number_format((int)$s['rels']) ?> links |
| </span> |
| </div> |
| <p class="mt-2 text-xs text-gray-500"><?= number_format((int)$s['cooccurrences']) ?> total co-occurrences</p> |
| </a> |
| <?php endforeach; ?> |
| </div> |
| |
| <?php endif; ?> |
| |
| <?php |
| $content = ob_get_clean(); |
| include __DIR__ . '/layout.php'; |
| ?> |
| |