Add new LinkedIn prospect discovery tool with three main components: - c4ai_discover.py for company and people scraping - c4ai_insights.py for org chart and decision maker analysis - Interactive graph visualization with company/people exploration Features include: - Configurable LinkedIn search and scraping - Org chart generation with decision maker scoring - Interactive network graph visualization - Company similarity analysis - Chat interface for data exploration Requires: crawl4ai, openai, sentence-transformers, networkx
1171 lines
50 KiB
HTML
1171 lines
50 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en" class="dark">
|
|
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>C4AI Insights</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script src="https://unpkg.com/vis-network@9.1.2/dist/vis-network.min.js"></script>
|
|
<!-- our tiny OpenAI wrapper -->
|
|
<script src="ai.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/split.js/1.6.5/split.min.js"></script>
|
|
<link href="https://unpkg.com/vis-network@9.1.2/dist/vis-network.min.css" rel="stylesheet" />
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" />
|
|
<style>
|
|
.vis-network canvas {
|
|
background-color: #1f1f1f !important;
|
|
background-image:
|
|
linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
|
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
|
background-size: 30px 30px;
|
|
}
|
|
|
|
#chatDrawer {
|
|
max-height: 45vh;
|
|
height: 45vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
#chatBody {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
min-height: 0;
|
|
max-height: calc(45vh - 90px);
|
|
}
|
|
|
|
#chatInputContainer {
|
|
min-height: 60px;
|
|
}
|
|
|
|
/* Split.js vertical gutter */
|
|
.gutter.gutter-vertical {
|
|
cursor: row-resize;
|
|
height: 6px;
|
|
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFCAYAAABSIVz6AAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4AkKCQQBdo6l1QAAABl0RVh0Q29tbWVudABDcmVhdGVkIHdpdGggR0lNUFeBDhcAAAArSURBVCjPY2AYBfQMgVAFzGCGIpgBxTklpCgGQ0O54P//Y8zAs14lighENAAAVTsOYMqVl/QAAAAASUVORK5CYII=');
|
|
}
|
|
|
|
/* Split.js styles */
|
|
.gutter {
|
|
background-color: #2d2d2d;
|
|
background-repeat: no-repeat;
|
|
background-position: 50%;
|
|
}
|
|
|
|
.gutter.gutter-horizontal {
|
|
cursor: col-resize;
|
|
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==');
|
|
}
|
|
|
|
/* Sidebar styles */
|
|
.sidebar-collapse-btn {
|
|
position: absolute;
|
|
top: 10px;
|
|
background-color: #2d2d2d;
|
|
color: #999;
|
|
border: none;
|
|
border-radius: 4px;
|
|
width: 24px;
|
|
height: 24px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
z-index: 10;
|
|
}
|
|
|
|
.sidebar-collapse-btn:hover {
|
|
background-color: #444;
|
|
color: #eee;
|
|
}
|
|
|
|
#leftSidebarToggle {
|
|
right: -12px;
|
|
}
|
|
|
|
#leftSidebarToggle.collapsed {
|
|
right: -20px;
|
|
}
|
|
|
|
#rightSidebarToggle {
|
|
left: -12px;
|
|
}
|
|
|
|
.collapsed {
|
|
width: 0 !important;
|
|
padding: 0 !important;
|
|
overflow: visible !important;
|
|
}
|
|
|
|
.full-width {
|
|
width: 100% !important;
|
|
}
|
|
|
|
.splitter-container {
|
|
height: 100%;
|
|
display: flex;
|
|
}
|
|
</style>
|
|
|
|
<!-- Toast notification style -->
|
|
<style>
|
|
#toast {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background-color: #2d2d2d;
|
|
color: #eee;
|
|
padding: 10px 20px;
|
|
border-radius: 4px;
|
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
|
opacity: 0;
|
|
transition: opacity 0.3s ease;
|
|
z-index: 1000;
|
|
pointer-events: none;
|
|
}
|
|
|
|
#toast.show {
|
|
opacity: 1;
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body class="h-screen flex flex-col bg-neutral-900 text-neutral-100 overflow-hidden">
|
|
<!-- Toast notification -->
|
|
<div id="toast"></div>
|
|
|
|
<!-- API Settings Modal -->
|
|
<div id="settingsModal" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center hidden">
|
|
<div class="bg-neutral-800 rounded-lg shadow-lg p-6 max-w-md w-full mx-4">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h3 class="text-lg font-semibold">API Settings</h3>
|
|
<button id="closeSettingsModal" class="text-neutral-400 hover:text-neutral-200">
|
|
<i class="fa fa-times"></i>
|
|
</button>
|
|
</div>
|
|
<div class="mb-4">
|
|
<label for="apiKeyInput" class="block text-sm font-medium text-neutral-300 mb-2">OpenAI API Key</label>
|
|
<input type="password" id="apiKeyInput" class="w-full p-2 rounded bg-neutral-700 text-neutral-100 border border-neutral-600 focus:border-blue-500 focus:outline-none" placeholder="sk-...">
|
|
<p class="text-xs text-neutral-400 mt-1">Your API key is stored locally in your browser and never sent to our servers.</p>
|
|
</div>
|
|
<div class="flex justify-end">
|
|
<button id="saveApiKey" class="bg-emerald-600 hover:bg-emerald-500 text-white px-4 py-2 rounded transition-colors">
|
|
Save Settings
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-1 splitter-container" id="mainSplitter">
|
|
<div id="leftSidebar" class="p-2 border-r border-neutral-700 relative w-72 overflow-y-auto">
|
|
<button id="leftSidebarToggle" class="sidebar-collapse-btn">
|
|
<i class="fa fa-chevron-left"></i>
|
|
</button>
|
|
<input id="search" class="w-full mb-3 p-2 border rounded bg-neutral-800 text-neutral-100 border-neutral-600"
|
|
placeholder="Search company">
|
|
<div class="text-xs text-neutral-400 mb-2 flex justify-between items-center">
|
|
<span>Companies</span>
|
|
<span id="companyCount">0 companies</span>
|
|
</div>
|
|
<ul id="companyList" class="space-y-3 text-sm"></ul>
|
|
</div>
|
|
<div id="mainContent" class="flex-1 relative">
|
|
<div class="absolute top-4 left-4 z-10 flex space-x-2">
|
|
<label class="bg-neutral-800 hover:bg-neutral-700 text-neutral-200 p-2 px-3 rounded-full shadow-lg transition-colors cursor-pointer flex items-center">
|
|
<i class="fas fa-upload mr-2"></i>
|
|
<span>Load Data</span>
|
|
<input type="file" id="dataFileInput" accept=".json" class="hidden">
|
|
</label>
|
|
<button id="clearDataBtn" class="bg-red-800 hover:bg-red-700 text-neutral-200 p-2 px-3 rounded-full shadow-lg transition-colors flex items-center">
|
|
<i class="fas fa-trash-alt mr-2"></i>
|
|
<span>Clear Data</span>
|
|
</button>
|
|
<button id="settingsBtn" class="bg-neutral-800 hover:bg-neutral-700 text-neutral-200 p-2 px-3 rounded-full shadow-lg transition-colors flex items-center">
|
|
<i class="fas fa-cog mr-2"></i>
|
|
<span>Settings</span>
|
|
</button>
|
|
</div>
|
|
<div class="absolute top-4 right-4 z-10 flex space-x-2">
|
|
<button id="zoomInBtn"
|
|
class="bg-neutral-800 hover:bg-neutral-700 text-neutral-200 p-2 px-3 rounded-full shadow-lg transition-colors">
|
|
<i class="fas fa-search-plus"></i>
|
|
</button>
|
|
<button id="zoomOutBtn"
|
|
class="bg-neutral-800 hover:bg-neutral-700 text-neutral-200 p-2 px-3 rounded-full shadow-lg transition-colors">
|
|
<i class="fas fa-search-minus"></i>
|
|
</button>
|
|
<button id="resetViewBtn"
|
|
class="bg-neutral-800 hover:bg-neutral-700 text-neutral-200 p-2 px-3 rounded-full shadow-lg transition-colors">
|
|
<i class="fas fa-compress-arrows-alt"></i>
|
|
</button>
|
|
</div>
|
|
<div class="absolute bottom-4 left-4 z-10 text-xs text-neutral-500">
|
|
<div id="graphInfo" class="bg-neutral-800/70 backdrop-blur-sm p-2 rounded shadow-lg hidden">
|
|
<div id="graphInfoContent"></div>
|
|
</div>
|
|
</div>
|
|
<div id="graph" class="w-full h-full"></div>
|
|
<!-- ───── Chat drawer (hidden by default) ───── -->
|
|
<div id="chatDrawer" class="absolute bottom-0 inset-x-0 bg-neutral-900 border-t
|
|
border-neutral-700 translate-y-full transition-transform
|
|
duration-300">
|
|
<div class="flex items-center px-3 py-2">
|
|
<span class="font-semibold flex-1">🔮 Chat with C4AI Assistant</span>
|
|
<button id="chatClose" class="text-neutral-400 hover:text-neutral-200">
|
|
<i class="fa fa-times"></i>
|
|
</button>
|
|
</div>
|
|
<div id="chatBody" class="flex-1 overflow-y-auto p-3 space-y-2 text-sm max-h-full"></div>
|
|
<div class="p-2 border-t border-neutral-700">
|
|
<input id="chatInput" class="w-full bg-neutral-800 p-2 rounded outline-none"
|
|
placeholder="Ask something… (Enter to send)">
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
<div id="rightSidebar" class="bg-neutral-800 shadow-lg relative w-80 overflow-y-auto">
|
|
<button id="rightSidebarToggle" class="sidebar-collapse-btn">
|
|
<i class="fa fa-chevron-right"></i>
|
|
</button>
|
|
<div id="rightPane" class="h-full">
|
|
<div class="flex flex-col items-center justify-center h-full p-8 text-center">
|
|
<div class="text-neutral-500 mb-4">
|
|
<i class="fas fa-sitemap text-4xl"></i>
|
|
</div>
|
|
<h3 class="text-lg font-semibold mb-2">Organization Details</h3>
|
|
<p class="text-neutral-400 text-sm">Select a company to view its organization structure and key
|
|
decision makers.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<!-- chat floating action button -->
|
|
<button id="chatFab" class="fixed bottom-6 right-6 bg-emerald-500 hover:bg-emerald-400
|
|
p-3 rounded-full shadow-lg focus:outline-none" style="padding: 0.65rem 0.75rem">
|
|
<i class="fa fa-comments text-neutral-900"></i>
|
|
</button>
|
|
|
|
|
|
<script>
|
|
// Check localStorage first, otherwise fetch default data
|
|
let data;
|
|
// Toast notification function
|
|
function showToast(message, duration = 3000) {
|
|
const toast = document.getElementById('toast');
|
|
toast.textContent = message;
|
|
toast.classList.add('show');
|
|
|
|
setTimeout(() => {
|
|
toast.classList.remove('show');
|
|
}, duration);
|
|
}
|
|
|
|
const loadDataFromSource = () => {
|
|
const savedData = localStorage.getItem('companyGraphData');
|
|
|
|
if (savedData) {
|
|
try {
|
|
data = JSON.parse(savedData);
|
|
console.log('Loaded data from localStorage');
|
|
initializeGraph(data);
|
|
showToast('Using data from local storage. Click "Clear Data" to revert to default.');
|
|
} catch (error) {
|
|
console.error('Error parsing stored data:', error);
|
|
fetchDefaultData();
|
|
}
|
|
} else {
|
|
fetchDefaultData();
|
|
}
|
|
};
|
|
|
|
const fetchDefaultData = () => {
|
|
fetch('./company_graph.json')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
initializeGraph(data);
|
|
})
|
|
.catch(error => console.error('Error loading default JSON:', error));
|
|
};
|
|
|
|
// File input handler
|
|
document.getElementById('dataFileInput').addEventListener('change', (event) => {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
try {
|
|
const data = JSON.parse(e.target.result);
|
|
// Validate data structure
|
|
if (!data.nodes || !data.edges) {
|
|
alert('Invalid data format. File must contain nodes and edges arrays.');
|
|
return;
|
|
}
|
|
|
|
// Save to localStorage
|
|
localStorage.setItem('companyGraphData', JSON.stringify(data));
|
|
|
|
// Show notification before reload
|
|
showToast('Data file loaded successfully! Refreshing page...', 1500);
|
|
|
|
// Reload the page to initialize with new data
|
|
setTimeout(() => {
|
|
window.location.reload();
|
|
}, 1500);
|
|
} catch (error) {
|
|
alert('Error parsing JSON file: ' + error.message);
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
});
|
|
|
|
// Clear data button
|
|
document.getElementById('clearDataBtn').addEventListener('click', () => {
|
|
if (confirm('Are you sure you want to clear the loaded data? This will revert to the default dataset.')) {
|
|
// Clear the localStorage data
|
|
localStorage.removeItem('companyGraphData');
|
|
|
|
// Force clear the current data from memory
|
|
window.companyGraphData = null;
|
|
|
|
showToast('Custom data cleared! Loading default dataset...', 1500);
|
|
|
|
// Completely reload the page to ensure fresh start
|
|
setTimeout(() => {
|
|
window.location.href = window.location.href.split('?')[0] + '?nocache=' + new Date().getTime();
|
|
}, 1500);
|
|
}
|
|
});
|
|
|
|
// Initialize
|
|
loadDataFromSource();
|
|
|
|
function initializeGraph(data) {
|
|
window.companyGraphData = data // expose globally
|
|
|
|
// lazy-load people.jsonl once so chat can reference raw rows
|
|
let peopleRows = null
|
|
async function getPeopleRows() {
|
|
if (peopleRows) return peopleRows
|
|
try {
|
|
const txt = await fetch("people.jsonl").then(r => r.text())
|
|
peopleRows = txt.trim().split("\n").map(l => JSON.parse(l))
|
|
} catch { peopleRows = [] }
|
|
return peopleRows
|
|
}
|
|
const container = document.getElementById('graph')
|
|
// Create node objects with enhanced styling and tooltips
|
|
const nodes = new vis.DataSet(data.nodes.map(n => ({
|
|
id: n.id,
|
|
label: n.name,
|
|
title: `${n.name}\n${n.industry || 'Industry: N/A'}\n${n.followers?.toLocaleString() || '0'} followers`,
|
|
shape: 'dot',
|
|
font: {
|
|
color: '#ffffff',
|
|
face: 'Inter, system-ui, sans-serif',
|
|
size: 16,
|
|
strokeWidth: 2,
|
|
strokeColor: '#222222'
|
|
},
|
|
size: Math.max(15, Math.log10((n.followers || 1)) * 6 + 10),
|
|
borderWidth: 2,
|
|
borderWidthSelected: 4,
|
|
color: {
|
|
background: '#3b82f6', // blue-500
|
|
border: '#1e40af', // blue-800
|
|
highlight: {
|
|
background: '#60a5fa', // blue-400
|
|
border: '#ffffff'
|
|
},
|
|
hover: {
|
|
background: '#93c5fd', // blue-300
|
|
border: '#ffffff'
|
|
}
|
|
},
|
|
shadow: {
|
|
enabled: true,
|
|
color: 'rgba(0,0,0,0.3)',
|
|
size: 10,
|
|
x: 0,
|
|
y: 0
|
|
}
|
|
})))
|
|
|
|
// Create edge objects with enhanced styling
|
|
const edges = new vis.DataSet(data.edges.map(e => ({
|
|
from: e.source,
|
|
to: e.target,
|
|
width: Math.max(1, Math.min(8, e.weight * 4)),
|
|
selectionWidth: 2,
|
|
color: {
|
|
color: '#6b7280', // gray-500
|
|
highlight: '#10b981', // emerald-500
|
|
hover: '#a3e635' // lime-400
|
|
},
|
|
arrows: {
|
|
to: {
|
|
enabled: e.weight > 0.3, // Only show arrows for stronger connections
|
|
scaleFactor: 0.5
|
|
}
|
|
},
|
|
smooth: {
|
|
type: 'continuous',
|
|
forceDirection: 'none',
|
|
roundness: 0.2
|
|
}
|
|
})))
|
|
|
|
// Configure and create the network
|
|
const network = new vis.Network(container, { nodes, edges }, {
|
|
physics: {
|
|
barnesHut: {
|
|
gravitationalConstant: -2000,
|
|
springLength: 120,
|
|
springConstant: 0.05,
|
|
avoidOverlap: 0.5,
|
|
damping: 0.09
|
|
},
|
|
stabilization: {
|
|
iterations: 200,
|
|
updateInterval: 25
|
|
},
|
|
enabled: true,
|
|
timestep: 0.5,
|
|
adaptiveTimestep: true
|
|
},
|
|
interaction: {
|
|
hover: true,
|
|
navigationButtons: false,
|
|
keyboard: true,
|
|
tooltipDelay: 100,
|
|
hideEdgesOnDrag: false, // Keep edges visible when dragging
|
|
multiselect: false,
|
|
selectable: true,
|
|
dragNodes: true,
|
|
dragView: true,
|
|
zoomView: true,
|
|
mouseWheel: {
|
|
speed: 0.15, // Reduced from default 1.0
|
|
smooth: true // Enable smooth zooming
|
|
}
|
|
},
|
|
nodes: {
|
|
font: {
|
|
size: 16,
|
|
strokeWidth: 2,
|
|
strokeColor: '#222222'
|
|
},
|
|
fixed: false
|
|
},
|
|
edges: {
|
|
smooth: {
|
|
type: 'continuous',
|
|
forceDirection: 'none',
|
|
roundness: 0.2
|
|
},
|
|
hoverWidth: 1.5,
|
|
selectionWidth: 2
|
|
}
|
|
})
|
|
|
|
window.network = network;
|
|
|
|
network.once('stabilized', () => {
|
|
console.log('Network stabilized')
|
|
// Freeze layout so nodes stop running away and dragging feels crisp
|
|
network.setOptions({ physics: false });
|
|
|
|
// get id of first node
|
|
let firstNodeId = data.nodes[0].id;
|
|
// network.focus(firstNodeId, { animation: true, scale: 1.5 });
|
|
|
|
// Automatically select the first company in the list
|
|
if (data.nodes.length > 0) {
|
|
focusCompany(firstNodeId);
|
|
// Highlight the first company in the sidebar
|
|
const firstCompanyElement = document.querySelector('#companyList li');
|
|
if (firstCompanyElement) {
|
|
firstCompanyElement.classList.add('border-blue-500');
|
|
}
|
|
}
|
|
});
|
|
|
|
const companyList = document.getElementById('companyList')
|
|
const companyCount = document.getElementById('companyCount')
|
|
companyCount.textContent = `${data.nodes.length} companies`
|
|
|
|
data.nodes.forEach(n => {
|
|
const li = document.createElement('li')
|
|
li.className = 'p-3 rounded bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 transition-colors'
|
|
li.innerHTML = `
|
|
<div class="flex justify-between items-start mb-1">
|
|
<h3 class="font-semibold text-blue-400 cursor-pointer">${n.name}</h3>
|
|
<span class="px-2 py-0.5 bg-neutral-700 rounded-full text-xs">${n.industry || 'N/A'}</span>
|
|
</div>
|
|
<p class="text-xs text-neutral-300 mb-2">${n.about || 'No description available'}</p>
|
|
<div class="flex justify-between items-center text-xs">
|
|
<div class="flex items-center">
|
|
<i class="fa fa-users mr-1 text-neutral-400"></i>
|
|
<span>${n.followers?.toLocaleString() || '0'} followers</span>
|
|
</div>
|
|
<a href="https://www.linkedin.com${n.handle}" target="_blank" class="text-emerald-400 hover:text-emerald-300">
|
|
<i class="fab fa-linkedin mr-1"></i>View on LinkedIn
|
|
</a>
|
|
</div>
|
|
`
|
|
// Make the entire card clickable for better UX
|
|
li.style.cursor = 'pointer'
|
|
li.onclick = (e) => {
|
|
// Don't trigger if clicking on the LinkedIn link
|
|
if (e.target.tagName === 'A' || e.target.closest('a')) return
|
|
focusCompany(n.id)
|
|
// Add active state visual indicator
|
|
document.querySelectorAll('#companyList li').forEach(el =>
|
|
el.classList.remove('border-blue-500'))
|
|
li.classList.add('border-blue-500')
|
|
}
|
|
companyList.appendChild(li)
|
|
})
|
|
|
|
// Add search functionality
|
|
const searchInput = document.getElementById('search')
|
|
searchInput.addEventListener('input', () => {
|
|
const query = searchInput.value.toLowerCase()
|
|
const items = companyList.querySelectorAll('li')
|
|
let visibleCount = 0
|
|
|
|
items.forEach(item => {
|
|
const companyName = item.querySelector('h3').textContent.toLowerCase()
|
|
const industryText = item.querySelector('span').textContent.toLowerCase()
|
|
const aboutText = item.querySelector('p').textContent.toLowerCase()
|
|
|
|
if (companyName.includes(query) || industryText.includes(query) || aboutText.includes(query)) {
|
|
item.style.display = ''
|
|
visibleCount++
|
|
} else {
|
|
item.style.display = 'none'
|
|
}
|
|
})
|
|
|
|
companyCount.textContent = `${visibleCount} of ${data.nodes.length} companies`
|
|
})
|
|
|
|
function focusCompany(id) {
|
|
network.focus(id, { scale: 1.5, animation: true, })
|
|
loadOrgChart(id)
|
|
}
|
|
|
|
async function loadOrgChart(id) {
|
|
const pane = document.getElementById('rightPane')
|
|
pane.innerHTML = '<div class="p-4 text-sm">Loading…</div>'
|
|
if (rightSidebar.classList.contains('collapsed')) {
|
|
toggleRightSidebar()
|
|
}
|
|
try {
|
|
const chart = await fetch(`org_chart_${id.replace(/\//g, "_")}.json`).then(r => r.json())
|
|
currentCompany = id
|
|
currentChart = chart
|
|
|
|
// Clear any previously selected person
|
|
selectedPerson = null
|
|
|
|
renderOrg(chart, pane)
|
|
} catch (e) {
|
|
pane.innerHTML = `
|
|
<div class="flex flex-col items-center justify-center h-full p-8 text-center">
|
|
<div class="text-red-500 mb-3">
|
|
<i class="fas fa-exclamation-circle text-4xl"></i>
|
|
</div>
|
|
<h3 class="text-lg font-semibold mb-2">Organization Chart Not Found</h3>
|
|
<p class="text-neutral-400 text-sm">Data for this company is not available.</p>
|
|
</div>`
|
|
}
|
|
}
|
|
|
|
// REMOVED - Using colorForScore instead
|
|
|
|
function renderOrg(chart, pane) {
|
|
// Format company info from chart metadata
|
|
const companyName = chart.meta?.company || 'Company';
|
|
const employeeCount = chart.nodes.length;
|
|
const decisionMakers = chart.nodes.filter(n => n.decision_score >= 0.5);
|
|
|
|
pane.innerHTML = `
|
|
<div class="p-4 border-b border-neutral-700 bg-neutral-800">
|
|
<div class="flex justify-between items-center">
|
|
<h2 class="font-semibold text-lg text-blue-400">${companyName}</h2>
|
|
<span class="px-2 py-1 bg-neutral-700 rounded-full text-xs">${employeeCount} employees</span>
|
|
</div>
|
|
</div>
|
|
<div id="orgNet" style="height:320px" class="border-b border-neutral-700"></div>
|
|
<div class="p-4 bg-neutral-800">
|
|
<div class="flex justify-between items-center mb-3">
|
|
<h3 class="font-semibold">Decision Makers</h3>
|
|
<span class="px-2 py-0.5 bg-emerald-700 text-emerald-100 rounded-full text-xs">${decisionMakers.length} key people</span>
|
|
</div>
|
|
<ul class="text-sm space-y-2 max-h-60 overflow-y-auto pr-1 mb-4">
|
|
${decisionMakers.map(n => `
|
|
<li class="p-2 rounded bg-neutral-700 hover:bg-neutral-600 transition-colors flex justify-between items-center">
|
|
<div>
|
|
<div class="font-medium">${n.name}</div>
|
|
<div class="text-xs text-neutral-300">${n.title}</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-xs px-2 py-0.5 rounded-full" style="background-color:${colorForScore(n.decision_score)}">${(n.decision_score * 100).toFixed(0)}%</span>
|
|
<a href="${n.profile_url}" target="_blank" class="text-emerald-400 hover:text-emerald-300 text-xs">
|
|
<i class="fab fa-linkedin"></i>
|
|
</a>
|
|
</div>
|
|
</li>`).join('')}
|
|
</ul>
|
|
<div class="mt-4 text-xs border-t border-neutral-700 pt-3">
|
|
<div class="font-semibold mb-2">Influence Scale</div>
|
|
<div class="h-2 w-full rounded-full mb-1" style="background: linear-gradient(to right, rgb(255,0,100), rgb(0,255,100))"></div>
|
|
<div class="flex justify-between text-xs text-neutral-400">
|
|
<span>Low influence (0%)</span>
|
|
<span>High influence (100%)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="personPane" class="p-4 border-t border-neutral-700 text-sm bg-neutral-900 hidden"></div>`
|
|
|
|
const n = new vis.DataSet(chart.nodes.map(p => ({
|
|
id: p.id,
|
|
label: p.name,
|
|
title: `${p.name} - ${p.title || 'Employee'} (${(p.decision_score * 100).toFixed(0)}%)`,
|
|
shape: 'box',
|
|
color: {
|
|
background: colorForScore(p.decision_score || 0),
|
|
border: '#333333',
|
|
highlight: {
|
|
background: '#4ade80',
|
|
border: '#ffffff'
|
|
}
|
|
},
|
|
borderWidth: 2
|
|
})))
|
|
|
|
const e = new vis.DataSet(chart.edges.map(e => ({ from: e.source, to: e.target, arrows: 'to' })))
|
|
|
|
const orgNet = new vis.Network(
|
|
pane.querySelector('#orgNet'),
|
|
{ nodes: n, edges: e },
|
|
{
|
|
layout: {
|
|
hierarchical: {
|
|
direction: 'UD',
|
|
sortMethod: 'directed',
|
|
levelSeparation: 100
|
|
}
|
|
},
|
|
nodes: {
|
|
color: { border: '#333333' },
|
|
font: { color: '#ffffff', size: 14 },
|
|
shadow: { enabled: true, color: 'rgba(0,0,0,0.5)', size: 5 }
|
|
},
|
|
edges: {
|
|
color: { color: '#555555' },
|
|
width: 2,
|
|
smooth: { type: 'cubicBezier' }
|
|
},
|
|
interaction: {
|
|
hover: true,
|
|
tooltipDelay: 200
|
|
},
|
|
physics: {
|
|
enabled: false
|
|
}
|
|
}
|
|
)
|
|
|
|
let currentChart = chart // stash for click handler
|
|
|
|
orgNet.on('click', params => {
|
|
if (!params.nodes.length) return
|
|
|
|
// Reset any previously highlighted nodes
|
|
orgNet.selectNodes([]);
|
|
|
|
// Get the selected person
|
|
const person = currentChart.nodes.find(x => x.id === params.nodes[0])
|
|
if (person) {
|
|
// Highlight the selected node
|
|
orgNet.selectNodes([person.id]);
|
|
|
|
selectedPerson = person
|
|
showPersonDetails(person)
|
|
|
|
// Scroll right sidebar to the person details
|
|
const rightSidebar = document.getElementById('rightSidebar');
|
|
rightSidebar.scrollTo({
|
|
top: rightSidebar.scrollHeight,
|
|
behavior: 'smooth'
|
|
});
|
|
}
|
|
})
|
|
}
|
|
|
|
function colorForScore(s) { // 0 → gray, 1 → emerald
|
|
const g = Math.round(200 * (1 - s))
|
|
return `rgb(${g},${255 - g},120)`
|
|
}
|
|
|
|
function showPersonDetails(p) {
|
|
const box = document.getElementById('personPane')
|
|
box.classList.remove('hidden')
|
|
|
|
// Render decision score badge with appropriate color
|
|
const scoreColor = colorForScore(p.decision_score || 0)
|
|
const scorePercentage = (p.decision_score * 100).toFixed(1)
|
|
|
|
box.innerHTML = `
|
|
<div class="bg-neutral-800 rounded-lg p-4">
|
|
<div class="flex items-start space-x-3">
|
|
<img src="${p.avatar_url || 'https://ui-avatars.com/api/?name=' + encodeURIComponent(p.name)}"
|
|
class="h-16 w-16 rounded-full object-cover border-2 border-neutral-700"/>
|
|
<div class="flex-1">
|
|
<div class="flex justify-between items-start">
|
|
<div>
|
|
<div class="font-semibold text-lg">${p.name}</div>
|
|
<div class="text-neutral-300 text-sm">${p.title || 'Employee'}</div>
|
|
</div>
|
|
<span class="px-2 py-1 rounded-full text-sm font-medium"
|
|
style="background-color:${scoreColor}">
|
|
${scorePercentage}% influence
|
|
</span>
|
|
</div>
|
|
|
|
<div class="mt-3 grid grid-cols-2 gap-2 text-xs text-neutral-300">
|
|
<div class="flex items-center">
|
|
<i class="fas fa-sitemap w-5 text-neutral-500"></i>
|
|
<span class="ml-1">${p.dept || 'Department not specified'}</span>
|
|
</div>
|
|
<div class="flex items-center">
|
|
<i class="fas fa-calendar-alt w-5 text-neutral-500"></i>
|
|
<span class="ml-1">${p.yoe_current || '?'} years at company</span>
|
|
</div>
|
|
<div class="flex items-center">
|
|
<i class="fas fa-briefcase w-5 text-neutral-500"></i>
|
|
<span class="ml-1">${p.title_level || 'Level not specified'}</span>
|
|
</div>
|
|
<div class="flex items-center">
|
|
<i class="fas fa-user-friends w-5 text-neutral-500"></i>
|
|
<span class="ml-1">${p.connection_count || '?'} connections</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-3 flex justify-end">
|
|
<a href="${p.id}" target="_blank"
|
|
class="bg-emerald-800 hover:bg-emerald-700 text-emerald-100 px-3 py-1 rounded text-xs flex items-center transition-colors">
|
|
<i class="fab fa-linkedin mr-1"></i> View on LinkedIn
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`
|
|
}
|
|
|
|
// ───── Chat drawer logic ─────
|
|
const chatFab = document.getElementById('chatFab')
|
|
const chatDrawer = document.getElementById('chatDrawer')
|
|
const chatClose = document.getElementById('chatClose')
|
|
const chatBody = document.getElementById('chatBody')
|
|
const chatInput = document.getElementById('chatInput')
|
|
|
|
chatFab.onclick = () => {
|
|
chatDrawer.style.transform = 'translateY(0)';
|
|
chatFab.style.display = 'none';
|
|
}
|
|
|
|
chatClose.onclick = () => {
|
|
chatDrawer.style.transform = 'translateY(100%)'
|
|
chatFab.style.display = 'block';
|
|
}
|
|
|
|
chatInput.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter' && chatInput.value.trim()) {
|
|
sendChat(chatInput.value.trim())
|
|
chatInput.value = ''
|
|
}
|
|
})
|
|
|
|
// context vars
|
|
let currentCompany = null, currentChart = null, companyMeta = null,
|
|
decisionMakers = null, similarCompanies = null, selectedPerson = null
|
|
function loadOrgChart(id) {
|
|
const pane = document.getElementById('rightPane')
|
|
pane.innerHTML = '<div class="p-4 text-sm">Loading…</div>'
|
|
pane.style.transform = 'translateX(0)'
|
|
try {
|
|
fetch(`org_chart_${id.replace(/\//g, "_")}.json`)
|
|
.then(r => r.json())
|
|
.then(chart => {
|
|
currentChart = chart
|
|
currentCompany = id
|
|
companyMeta = data.nodes.find(n => n.id === id) || {}
|
|
decisionMakers = chart.nodes.filter(n => n.decision_score >= 0.5)
|
|
similarCompanies = data.edges.filter(e => e.source === id)
|
|
.sort((a, b) => b.weight - a.weight)
|
|
.slice(0, 3).map(e => e.target)
|
|
|
|
renderOrg(chart, pane)
|
|
})
|
|
} catch (e) { pane.innerHTML = '<div class="p-4 text-red-600">Org chart not found</div>' }
|
|
}
|
|
|
|
async function sendChat(userMsg) {
|
|
appendMsg("you", userMsg)
|
|
try {
|
|
const msgs = []
|
|
const context = {
|
|
company: companyMeta,
|
|
orgChart: currentChart,
|
|
decisionMakers,
|
|
// similarCompanies,
|
|
selectedPerson,
|
|
rawEmployees: (await getPeopleRows()).filter(p => p.company_handle.replace(/\/$/, '') === currentCompany)
|
|
}
|
|
// remove desc_embed from company
|
|
context.company.desc_embed = ""
|
|
|
|
msgs.push({ role: "system", content: `CONTEXT:\n${JSON.stringify(context)}` })
|
|
msgs.push({ role: "user", content: userMsg })
|
|
|
|
for await (const chunk of API.chatStream(msgs)) {
|
|
appendMsg("ai", chunk, true)
|
|
}
|
|
} catch (err) { appendMsg("ai", `[error: ${err.message}]`) }
|
|
}
|
|
|
|
function appendMsg(sender, text, streaming = false) {
|
|
let el = chatBody.lastElementChild
|
|
if (streaming && el && el.dataset.sender === sender) {
|
|
// Just append raw text for streaming mode
|
|
el.lastChild.innerHTML += text.replace(/\n/g, "<br>")
|
|
} else {
|
|
el = document.createElement('div')
|
|
el.className = 'chat-message'
|
|
el.dataset.sender = sender
|
|
|
|
// Create sender element
|
|
const senderEl = document.createElement('span')
|
|
senderEl.className = sender === 'you' ? 'text-emerald-400' : 'text-cyan-400'
|
|
senderEl.textContent = `${sender}:`
|
|
|
|
// Create content element
|
|
const contentEl = document.createElement('div')
|
|
contentEl.className = 'ml-1 mt-1'
|
|
|
|
// Apply markdown parsing
|
|
contentEl.innerHTML = marked.parse(text)
|
|
|
|
// Style markdown elements
|
|
const style = document.createElement('style')
|
|
style.textContent = `
|
|
.chat-message a { color: #34D399; text-decoration: underline; }
|
|
.chat-message p { margin-bottom: 0.5rem; }
|
|
.chat-message h1, .chat-message h2, .chat-message h3 {
|
|
font-weight: bold;
|
|
margin-top: 1rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.chat-message code {
|
|
background-color: #222;
|
|
padding: 0.1rem 0.3rem;
|
|
border-radius: 0.25rem;
|
|
font-family: monospace;
|
|
}
|
|
.chat-message pre {
|
|
background-color: #222;
|
|
padding: 0.5rem;
|
|
border-radius: 0.25rem;
|
|
overflow-x: auto;
|
|
margin: 0.5rem 0;
|
|
}
|
|
.chat-message pre code {
|
|
background-color: transparent;
|
|
padding: 0;
|
|
}
|
|
.chat-message ul, .chat-message ol {
|
|
margin-left: 1.5rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.chat-message ul { list-style-type: disc; }
|
|
.chat-message ol { list-style-type: decimal; }
|
|
`
|
|
document.head.appendChild(style)
|
|
|
|
// Append elements
|
|
el.appendChild(senderEl)
|
|
el.appendChild(contentEl)
|
|
chatBody.appendChild(el)
|
|
}
|
|
chatBody.scrollTop = chatBody.scrollHeight
|
|
}
|
|
|
|
// Settings modal and API key management
|
|
const settingsBtn = document.getElementById('settingsBtn');
|
|
const settingsModal = document.getElementById('settingsModal');
|
|
const closeSettingsModal = document.getElementById('closeSettingsModal');
|
|
const apiKeyInput = document.getElementById('apiKeyInput');
|
|
const saveApiKey = document.getElementById('saveApiKey');
|
|
|
|
// Check for saved API key in localStorage
|
|
const savedApiKey = localStorage.getItem('openai_api_key');
|
|
if (savedApiKey) {
|
|
API.setApiKey(savedApiKey);
|
|
apiKeyInput.value = savedApiKey;
|
|
} else {
|
|
// Show settings modal on page load if no API key is set
|
|
setTimeout(() => {
|
|
settingsModal.classList.remove('hidden');
|
|
}, 500);
|
|
}
|
|
|
|
// Open settings modal when settings button is clicked
|
|
settingsBtn.addEventListener('click', () => {
|
|
settingsModal.classList.remove('hidden');
|
|
});
|
|
|
|
// Close settings modal
|
|
closeSettingsModal.addEventListener('click', () => {
|
|
settingsModal.classList.add('hidden');
|
|
});
|
|
|
|
// Close modal when clicking outside of it
|
|
settingsModal.addEventListener('click', (e) => {
|
|
if (e.target === settingsModal) {
|
|
settingsModal.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
// Save API key
|
|
saveApiKey.addEventListener('click', () => {
|
|
const apiKey = apiKeyInput.value.trim();
|
|
if (apiKey) {
|
|
localStorage.setItem('openai_api_key', apiKey);
|
|
API.setApiKey(apiKey);
|
|
settingsModal.classList.add('hidden');
|
|
showToast('API key saved successfully', 2000);
|
|
} else {
|
|
showToast('Please enter a valid API key', 2000);
|
|
}
|
|
});
|
|
|
|
// Allow Enter key to submit API key
|
|
apiKeyInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
saveApiKey.click();
|
|
}
|
|
});
|
|
|
|
|
|
// ───── Split.js and sidebar setup ─────
|
|
const leftSidebar = document.getElementById('leftSidebar');
|
|
const rightSidebar = document.getElementById('rightSidebar');
|
|
const mainContent = document.getElementById('mainContent');
|
|
const leftSidebarToggle = document.getElementById('leftSidebarToggle');
|
|
const rightSidebarToggle = document.getElementById('rightSidebarToggle');
|
|
|
|
// Load saved splitter sizes
|
|
const savedSizes = localStorage.getItem('mainSplitSizes');
|
|
const defaultSizes = [20, 80, 0];
|
|
|
|
// Initialize Split.js
|
|
const split = Split(['#mainContent', '#rightSidebar'], {
|
|
sizes: savedSizes ? JSON.parse(savedSizes) : defaultSizes,
|
|
minSize: [0, 300, 0],
|
|
gutterSize: 5,
|
|
snapOffset: 0,
|
|
dragInterval: 1,
|
|
direction: 'horizontal',
|
|
elementStyle: function (dimension, size, gutterSize) {
|
|
return {
|
|
'flex-basis': `calc(${size}% - ${gutterSize}px)`,
|
|
}
|
|
},
|
|
gutterStyle: function (dimension, gutterSize) {
|
|
return {
|
|
'flex-basis': `${gutterSize}px`,
|
|
}
|
|
},
|
|
onDragEnd: function (sizes) {
|
|
localStorage.setItem('mainSplitSizes', JSON.stringify(sizes));
|
|
}
|
|
});
|
|
|
|
// Set initial sidebar states based on saved sizes
|
|
if (savedSizes) {
|
|
const sizes = JSON.parse(savedSizes);
|
|
if (sizes[0] < 1) {
|
|
leftSidebar.classList.add('collapsed');
|
|
leftSidebarToggle.innerHTML = '<i class="fa fa-chevron-right"></i>';
|
|
}
|
|
if (sizes[2] < 1) {
|
|
rightSidebar.classList.add('collapsed');
|
|
rightSidebarToggle.innerHTML = '<i class="fa fa-chevron-left"></i>';
|
|
}
|
|
}
|
|
|
|
// Toggle left sidebar
|
|
function toggleLeftSidebar() {
|
|
const isCollapsed = leftSidebar.classList.toggle('collapsed');
|
|
leftSidebarToggle.innerHTML = isCollapsed
|
|
? '<i class="fa fa-chevron-right"></i>'
|
|
: '<i class="fa fa-chevron-left"></i>';
|
|
|
|
// if (isCollapsed) {
|
|
// split.setSizes([0, rightSidebar.classList.contains('collapsed') ? 100 : 70, 30]);
|
|
// } else {
|
|
// split.setSizes([20, rightSidebar.classList.contains('collapsed') ? 80 : 50, 30]);
|
|
// }
|
|
|
|
// Save current sizes
|
|
localStorage.setItem('mainSplitSizes', JSON.stringify(split.getSizes()));
|
|
|
|
// Resize graph when sidebar toggles
|
|
setTimeout(resizeGraph, 300);
|
|
}
|
|
|
|
// Toggle right sidebar
|
|
function toggleRightSidebar() {
|
|
const isCollapsed = rightSidebar.classList.toggle('collapsed');
|
|
|
|
if (isCollapsed) {
|
|
// read current value of "flex-basis"
|
|
let flexBasis = getComputedStyle(rightSidebar).flexBasis;
|
|
rightSidebar.dataset.flexBasis = flexBasis;
|
|
rightSidebar.style.flexBasis = '0px';
|
|
} else {
|
|
// restore the value of "flex-basis" from the dataset
|
|
rightSidebar.style.flexBasis = rightSidebar.dataset.flexBasis;
|
|
}
|
|
|
|
rightSidebarToggle.innerHTML = isCollapsed
|
|
? '<i class="fa fa-chevron-left"></i>'
|
|
: '<i class="fa fa-chevron-right"></i>';
|
|
|
|
// Save current sizes
|
|
localStorage.setItem('mainSplitSizes', JSON.stringify(split.getSizes()));
|
|
|
|
// Resize graph when sidebar toggles
|
|
setTimeout(resizeGraph, 300);
|
|
}
|
|
|
|
// Add event listeners for toggle buttons
|
|
leftSidebarToggle.addEventListener('click', toggleLeftSidebar);
|
|
rightSidebarToggle.addEventListener('click', toggleRightSidebar);
|
|
|
|
// Resize the network graph when window or splitter changes
|
|
function resizeGraph() {
|
|
if (network) {
|
|
const container = document.getElementById('graph');
|
|
const availableWidth = container.clientWidth;
|
|
const availableHeight = container.clientHeight;
|
|
|
|
// Apply smart fit with appropriate scale and offset
|
|
// get the current selected node id
|
|
let selectedNodeId = network.getSelectedNodes()[0]
|
|
// if no node is selected, use the first node
|
|
if (!selectedNodeId) {
|
|
const firstNode = data.nodes[0]
|
|
selectedNodeId = firstNode.id
|
|
// select the first node
|
|
network.selectNodes([selectedNodeId])
|
|
loadOrgChart(selectedNodeId)
|
|
} else {
|
|
network.focus(selectedNodeId, {
|
|
animation: true,
|
|
scale: Math.min(1.5, Math.max(0.5, Math.min(availableWidth, availableHeight) / 500))
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
window.resizeGraph = resizeGraph;
|
|
window.addEventListener('resize', resizeGraph);
|
|
|
|
// Add zoom control buttons functionality
|
|
const ZOOM_STEP = 0.2 // relative factor
|
|
document.getElementById('zoomInBtn').addEventListener('click', () => {
|
|
network.moveTo({
|
|
scale: network.getScale() + ZOOM_STEP,
|
|
animation: { duration: 300, easingFunction: 'easeInOutQuad' }
|
|
})
|
|
})
|
|
document.getElementById('zoomOutBtn').addEventListener('click', () => {
|
|
network.moveTo({
|
|
scale: Math.max(0.1, network.getScale() - ZOOM_STEP),
|
|
animation: { duration: 300, easingFunction: 'easeInOutQuad' }
|
|
})
|
|
});
|
|
|
|
document.getElementById('resetViewBtn').addEventListener('click', () => {
|
|
network.fit({
|
|
animation: { duration: 800, easingFunction: 'easeInOutQuad' }
|
|
})
|
|
});
|
|
|
|
// Add hover information for nodes
|
|
network.on('hoverNode', params => {
|
|
const nodeId = params.node;
|
|
const node = data.nodes.find(n => n.id === nodeId);
|
|
if (node) {
|
|
const graphInfo = document.getElementById('graphInfo');
|
|
const graphInfoContent = document.getElementById('graphInfoContent');
|
|
|
|
graphInfoContent.innerHTML = `
|
|
<div class="font-semibold text-neutral-200">${node.name}</div>
|
|
<div class="text-neutral-400">${node.industry || 'Industry: N/A'}</div>
|
|
<div class="flex items-center mt-1">
|
|
<i class="fas fa-users mr-1 text-blue-400"></i>
|
|
<span>${node.followers?.toLocaleString() || '0'} followers</span>
|
|
</div>
|
|
<div class="mt-1">${node.about || ''}</div>
|
|
`;
|
|
|
|
graphInfo.classList.remove('hidden');
|
|
}
|
|
});
|
|
|
|
network.on('blurNode', () => {
|
|
document.getElementById('graphInfo').classList.add('hidden');
|
|
});
|
|
|
|
// Add selected node styling
|
|
network.on('selectNode', params => {
|
|
const nodeId = params.nodes[0];
|
|
if (nodeId) {
|
|
// Focus on the selected node
|
|
network.focus(nodeId, {
|
|
scale: 1.2,
|
|
animation: true,
|
|
});
|
|
|
|
// Also load the org chart for the selected company
|
|
focusCompany(nodeId);
|
|
|
|
// Add visual indicator in the sidebar
|
|
document.querySelectorAll('#companyList li').forEach(el => {
|
|
el.classList.remove('border-blue-500');
|
|
// Find the corresponding company in the sidebar
|
|
if (el.querySelector('h3').textContent === data.nodes.find(n => n.id === nodeId)?.name) {
|
|
el.classList.add('border-blue-500');
|
|
// Scroll the sidebar to show the selected company
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Initial fit after a short delay to ensure the network is properly initialized
|
|
setTimeout(resizeGraph, 500);
|
|
}
|
|
|
|
</script>
|
|
</body>
|
|
|
|
</html> |