nordabiz/templates/connections_modal.html
Maciej Pienczyn 99b44d6ccd feat: Mapa Powiązań - modal fullscreen z filtrami i licznikami
- Fullscreen modal z panelem filtrów (zamiast osobnej strony)
- Filtry węzłów: Firmy, Osoby (checkboxy)
- Filtry powiązań: Zarząd, Wspólnicy, Prokurenci, JDG (checkboxy)
- Liczniki przy każdym filtrze (aktualizowane na bieżąco)
- Wyszukiwarka z autocomplete
- Etykiety ukryte domyślnie, widoczne przy hover
- D3.js v7 do wizualizacji grafu

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 14:09:08 +01:00

1093 lines
33 KiB
HTML

<!-- Fullscreen Connections Map Modal with Filters -->
<div id="connectionsModal" class="connections-modal" style="display: none;">
<div class="connections-modal-header">
<div class="connections-modal-title">
<h2>Mapa Powiązań Norda Biznes</h2>
</div>
<div class="connections-modal-stats" id="modalStats">
<span><strong id="modal-stat-companies">-</strong> firm</span>
<span><strong id="modal-stat-people">-</strong> osób</span>
<span><strong id="modal-stat-connections">-</strong> powiązań</span>
<span class="stats-filtered" id="modal-stat-filtered" style="display: none;">
(pokazano: <strong id="modal-stat-visible">-</strong>)
</span>
</div>
<button class="connections-modal-close" onclick="closeConnectionsMap()" title="Zamknij (ESC)">
<svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="connections-modal-content">
<!-- Filter Panel -->
<div class="connections-filter-panel">
<div class="filter-section">
<h4>Wyszukaj</h4>
<div class="filter-search">
<input type="text" id="filterSearch" placeholder="Wpisz nazwę firmy lub osoby..."
oninput="handleFilterSearch(this.value)">
<div id="filterSearchResults" class="filter-search-results"></div>
</div>
<button class="filter-clear-btn" onclick="clearAllFilters()" id="clearFiltersBtn" style="display: none;">
Wyczyść filtry
</button>
</div>
<div class="filter-section">
<h4>Pokaż węzły</h4>
<div class="filter-checkboxes">
<label class="filter-checkbox">
<input type="checkbox" id="filterCompanies" checked onchange="applyFilters()">
<span class="checkbox-mark" style="border-color: #4a90d9;"></span>
<span class="checkbox-dot" style="background: #4a90d9;"></span>
Firmy
<span class="filter-count" id="countCompanies">0</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" id="filterPeople" checked onchange="applyFilters()">
<span class="checkbox-mark" style="border-color: #f39c12;"></span>
<span class="checkbox-dot" style="background: #f39c12;"></span>
Osoby
<span class="filter-count" id="countPeople">0</span>
</label>
</div>
</div>
<div class="filter-section">
<h4>Pokaż powiązania</h4>
<div class="filter-checkboxes">
<label class="filter-checkbox">
<input type="checkbox" id="filterZarzad" checked onchange="applyFilters()">
<span class="checkbox-mark" style="border-color: #e74c3c;"></span>
<span class="checkbox-line-indicator" style="background: #e74c3c;"></span>
Zarząd
<span class="filter-count" id="countZarzad">0</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" id="filterWspolnik" checked onchange="applyFilters()">
<span class="checkbox-mark" style="border-color: #2ecc71;"></span>
<span class="checkbox-line-indicator" style="background: #2ecc71;"></span>
Wspólnicy
<span class="filter-count" id="countWspolnik">0</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" id="filterProkulrent" checked onchange="applyFilters()">
<span class="checkbox-mark" style="border-color: #f39c12;"></span>
<span class="checkbox-line-indicator" style="background: #f39c12;"></span>
Prokurenci
<span class="filter-count" id="countProkulrent">0</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" id="filterJDG" checked onchange="applyFilters()">
<span class="checkbox-mark" style="border-color: #9b59b6;"></span>
<span class="checkbox-line-indicator" style="background: #9b59b6;"></span>
Właściciele JDG
<span class="filter-count" id="countJDG">0</span>
</label>
</div>
</div>
<div class="filter-section">
<h4>Widok</h4>
<div class="filter-checkboxes">
<label class="filter-checkbox">
<input type="checkbox" id="filterLabels" onchange="toggleModalLabels()">
<span class="checkbox-icon">Aa</span>
Pokaż etykiety
</label>
</div>
<div class="filter-buttons">
<button class="filter-action-btn" onclick="modalFitToScreen()">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/>
</svg>
Dopasuj
</button>
<button class="filter-action-btn" onclick="modalResetZoom()">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Reset
</button>
</div>
</div>
<div class="filter-section filter-legend">
<h4>Legenda</h4>
<div class="legend-items">
<div class="legend-row">
<span class="legend-dot" style="background: #4a90d9;"></span>
<span>Firma</span>
</div>
<div class="legend-row">
<span class="legend-dot" style="background: #f39c12;"></span>
<span>Osoba</span>
</div>
</div>
</div>
<div class="filter-source">
Źródło: <a href="https://ekrs.ms.gov.pl" target="_blank">ekrs.ms.gov.pl</a>,
<a href="https://dane.biznes.gov.pl" target="_blank">dane.biznes.gov.pl</a>
</div>
</div>
<!-- Graph Area -->
<div class="connections-modal-body">
<svg id="connections-graph-modal"></svg>
<div class="connections-modal-loading" id="modalLoading">
<div class="loading-spinner"></div>
<span>Ładowanie danych...</span>
</div>
</div>
</div>
<div class="connections-modal-tooltip" id="modalTooltip"></div>
</div>
<style>
.connections-modal {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #0f172a;
z-index: 10000;
display: flex;
flex-direction: column;
}
.connections-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
background: #1e293b;
border-bottom: 1px solid #334155;
flex-shrink: 0;
}
.connections-modal-title h2 {
margin: 0;
font-size: 16px;
color: #f8fafc;
font-weight: 600;
}
.connections-modal-stats {
display: flex;
gap: 20px;
font-size: 13px;
color: #94a3b8;
}
.connections-modal-stats strong {
color: #4ade80;
font-weight: 600;
}
.stats-filtered {
color: #60a5fa;
}
.connections-modal-close {
background: transparent;
border: none;
color: #94a3b8;
cursor: pointer;
padding: 6px;
border-radius: 6px;
transition: all 0.2s;
}
.connections-modal-close:hover {
background: #334155;
color: #f8fafc;
}
.connections-modal-content {
flex: 1;
display: flex;
overflow: hidden;
}
/* Filter Panel */
.connections-filter-panel {
width: 240px;
background: #1e293b;
border-right: 1px solid #334155;
padding: 16px;
overflow-y: auto;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.filter-section h4 {
margin: 0 0 10px 0;
font-size: 12px;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.filter-search {
position: relative;
}
.filter-search input {
width: 100%;
padding: 8px 12px;
background: #0f172a;
border: 1px solid #475569;
border-radius: 6px;
color: #f8fafc;
font-size: 13px;
outline: none;
box-sizing: border-box;
}
.filter-search input:focus {
border-color: #60a5fa;
}
.filter-search input::placeholder {
color: #64748b;
}
.filter-search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #1e293b;
border: 1px solid #475569;
border-radius: 6px;
margin-top: 4px;
max-height: 200px;
overflow-y: auto;
z-index: 100;
display: none;
}
.filter-search-results.show {
display: block;
}
.filter-search-item {
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
color: #e2e8f0;
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid #334155;
}
.filter-search-item:last-child {
border-bottom: none;
}
.filter-search-item:hover {
background: #334155;
}
.filter-search-item .item-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.filter-search-item .item-type {
font-size: 10px;
color: #64748b;
margin-left: auto;
}
.filter-clear-btn {
width: 100%;
padding: 8px;
margin-top: 8px;
background: #dc2626;
border: none;
border-radius: 6px;
color: white;
font-size: 12px;
cursor: pointer;
transition: background 0.2s;
}
.filter-clear-btn:hover {
background: #ef4444;
}
.filter-checkboxes {
display: flex;
flex-direction: column;
gap: 8px;
}
.filter-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
color: #e2e8f0;
}
.filter-checkbox input {
display: none;
}
.checkbox-mark {
width: 16px;
height: 16px;
border: 2px solid;
border-radius: 4px;
position: relative;
transition: all 0.2s;
}
.filter-checkbox input:checked + .checkbox-mark {
background: currentColor;
}
.filter-checkbox input:checked + .checkbox-mark::after {
content: '✓';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #0f172a;
font-size: 11px;
font-weight: bold;
}
.checkbox-dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-left: -4px;
}
.checkbox-line {
width: 16px;
height: 3px;
border-radius: 2px;
}
.checkbox-line-indicator {
width: 12px;
height: 3px;
border-radius: 2px;
margin-left: -4px;
}
.filter-count {
margin-left: auto;
background: #334155;
color: #94a3b8;
font-size: 11px;
padding: 2px 6px;
border-radius: 10px;
min-width: 20px;
text-align: center;
font-weight: 500;
}
.checkbox-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
color: #64748b;
background: #334155;
border-radius: 3px;
}
.filter-checkbox input:checked ~ .checkbox-icon {
background: #60a5fa;
color: #0f172a;
}
.filter-buttons {
display: flex;
gap: 8px;
margin-top: 8px;
}
.filter-action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px;
background: #334155;
border: none;
border-radius: 6px;
color: #e2e8f0;
font-size: 12px;
cursor: pointer;
transition: background 0.2s;
}
.filter-action-btn:hover {
background: #475569;
}
.filter-legend {
margin-top: auto;
padding-top: 16px;
border-top: 1px solid #334155;
}
.legend-items {
display: flex;
flex-direction: column;
gap: 6px;
}
.legend-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #94a3b8;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.filter-source {
font-size: 10px;
color: #64748b;
padding-top: 12px;
border-top: 1px solid #334155;
}
.filter-source a {
color: #60a5fa;
text-decoration: none;
}
/* Graph Area */
.connections-modal-body {
flex: 1;
position: relative;
overflow: hidden;
background: #0f172a;
}
#connections-graph-modal {
width: 100%;
height: 100%;
display: block;
}
.connections-modal-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
gap: 12px;
color: #94a3b8;
font-size: 14px;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 3px solid #475569;
border-top-color: #4ade80;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Tooltip */
.connections-modal-tooltip {
position: fixed;
padding: 12px 16px;
background: rgba(15, 23, 42, 0.95);
border: 1px solid #4a90d9;
border-radius: 8px;
color: #f8fafc;
font-size: 13px;
pointer-events: none;
z-index: 10001;
max-width: 300px;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
display: none;
}
.connections-modal-tooltip h4 {
margin: 0 0 8px 0;
color: #60a5fa;
font-size: 14px;
}
.connections-modal-tooltip p {
margin: 4px 0;
color: #cbd5e1;
}
.connections-modal-tooltip .role-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
margin: 2px 4px 2px 0;
color: white;
}
.connections-modal-tooltip .role-badge.zarzad { background: #e74c3c; }
.connections-modal-tooltip .role-badge.wspolnik { background: #2ecc71; }
.connections-modal-tooltip .role-badge.prokurent { background: #f39c12; }
.connections-modal-tooltip .role-badge.wlasciciel_jdg { background: #9b59b6; }
/* Graph styles */
.modal-node-company, .modal-node-person { cursor: pointer; }
.modal-link { stroke-opacity: 0.6; }
.modal-link.zarzad { stroke: #e74c3c; }
.modal-link.wspolnik { stroke: #2ecc71; }
.modal-link.prokurent { stroke: #f39c12; }
.modal-link.wlasciciel_jdg { stroke: #9b59b6; }
.modal-node-label {
font-size: 9px;
fill: #94a3b8;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
}
.modal-node-label.show-labels {
opacity: 1;
}
.modal-node-label.hover-visible {
opacity: 1;
fill: #f8fafc;
font-weight: 500;
}
/* Responsive */
@media (max-width: 768px) {
.connections-filter-panel {
width: 200px;
}
}
</style>
<script>
// Connections Map Modal with Filters
let modalSimulation, modalSvg, modalG, modalZoom;
let modalGraphData = { nodes: [], links: [] };
let modalFilteredData = { nodes: [], links: [] };
let modalInitialized = false;
let modalShowLabels = false;
let selectedNodeId = null;
function openConnectionsMap() {
const modal = document.getElementById('connectionsModal');
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
if (!modalInitialized) {
loadModalData();
} else {
setTimeout(modalFitToScreen, 100);
}
document.addEventListener('keydown', handleModalEsc);
}
function closeConnectionsMap() {
const modal = document.getElementById('connectionsModal');
modal.style.display = 'none';
document.body.style.overflow = '';
document.removeEventListener('keydown', handleModalEsc);
// Close search results
document.getElementById('filterSearchResults').classList.remove('show');
}
function handleModalEsc(e) {
if (e.key === 'Escape') closeConnectionsMap();
}
async function loadModalData() {
try {
const response = await fetch('/api/connections');
const data = await response.json();
if (!data.success) throw new Error('Failed to load data');
modalGraphData = data;
modalFilteredData = { nodes: [...data.nodes], links: [...data.links] };
updateStats();
document.getElementById('modalLoading').style.display = 'none';
applyFilters();
modalInitialized = true;
} catch (error) {
console.error('Error loading data:', error);
document.getElementById('modalLoading').innerHTML = '<span style="color: #ef4444;">Błąd ładowania danych</span>';
}
}
function updateStats() {
document.getElementById('modal-stat-companies').textContent = modalGraphData.stats.companies;
document.getElementById('modal-stat-people').textContent = modalGraphData.stats.people;
document.getElementById('modal-stat-connections').textContent = modalGraphData.stats.connections;
const visibleNodes = modalFilteredData.nodes.length;
const totalNodes = modalGraphData.nodes.length;
if (visibleNodes < totalNodes) {
document.getElementById('modal-stat-filtered').style.display = 'inline';
document.getElementById('modal-stat-visible').textContent = visibleNodes;
} else {
document.getElementById('modal-stat-filtered').style.display = 'none';
}
// Update filter counts
updateFilterCounts();
}
function updateFilterCounts() {
// Count nodes by type (from filtered data)
const companyCount = modalFilteredData.nodes.filter(n => n.type === 'company').length;
const personCount = modalFilteredData.nodes.filter(n => n.type === 'person').length;
// Count links by category (from filtered data)
const zarzadCount = modalFilteredData.links.filter(l => l.category === 'zarzad').length;
const wspolnikCount = modalFilteredData.links.filter(l => l.category === 'wspolnik').length;
const prokurentCount = modalFilteredData.links.filter(l => l.category === 'prokurent').length;
const jdgCount = modalFilteredData.links.filter(l => l.category === 'wlasciciel_jdg').length;
// Update DOM
document.getElementById('countCompanies').textContent = companyCount;
document.getElementById('countPeople').textContent = personCount;
document.getElementById('countZarzad').textContent = zarzadCount;
document.getElementById('countWspolnik').textContent = wspolnikCount;
document.getElementById('countProkulrent').textContent = prokurentCount;
document.getElementById('countJDG').textContent = jdgCount;
}
function handleFilterSearch(query) {
const resultsDiv = document.getElementById('filterSearchResults');
if (query.length < 2) {
resultsDiv.classList.remove('show');
return;
}
const q = query.toLowerCase();
const matches = modalGraphData.nodes
.filter(n => n.name.toLowerCase().includes(q))
.slice(0, 10);
if (matches.length === 0) {
resultsDiv.innerHTML = '<div class="filter-search-item" style="color: #64748b;">Brak wyników</div>';
} else {
resultsDiv.innerHTML = matches.map(n => `
<div class="filter-search-item" onclick="selectNode('${n.id}')">
<span class="item-dot" style="background: ${n.type === 'company' ? '#4a90d9' : '#f39c12'};"></span>
<span>${escapeHtmlModal(n.name)}</span>
<span class="item-type">${n.type === 'company' ? 'Firma' : 'Osoba'}</span>
</div>
`).join('');
}
resultsDiv.classList.add('show');
}
function escapeHtmlModal(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function selectNode(nodeId) {
selectedNodeId = nodeId;
document.getElementById('filterSearchResults').classList.remove('show');
document.getElementById('clearFiltersBtn').style.display = 'block';
const node = modalGraphData.nodes.find(n => n.id === nodeId);
if (node) {
document.getElementById('filterSearch').value = node.name;
}
applyFilters();
}
function clearAllFilters() {
selectedNodeId = null;
document.getElementById('filterSearch').value = '';
document.getElementById('clearFiltersBtn').style.display = 'none';
// Reset checkboxes
document.getElementById('filterCompanies').checked = true;
document.getElementById('filterPeople').checked = true;
document.getElementById('filterZarzad').checked = true;
document.getElementById('filterWspolnik').checked = true;
document.getElementById('filterProkulrent').checked = true;
document.getElementById('filterJDG').checked = true;
applyFilters();
}
function applyFilters() {
const showCompanies = document.getElementById('filterCompanies').checked;
const showPeople = document.getElementById('filterPeople').checked;
const showZarzad = document.getElementById('filterZarzad').checked;
const showWspolnik = document.getElementById('filterWspolnik').checked;
const showProkulrent = document.getElementById('filterProkulrent').checked;
const showJDG = document.getElementById('filterJDG').checked;
// Filter links by category
let filteredLinks = modalGraphData.links.filter(l => {
if (l.category === 'zarzad' && !showZarzad) return false;
if (l.category === 'wspolnik' && !showWspolnik) return false;
if (l.category === 'prokurent' && !showProkulrent) return false;
if (l.category === 'wlasciciel_jdg' && !showJDG) return false;
return true;
});
// If a specific node is selected, show only its connections
if (selectedNodeId) {
filteredLinks = filteredLinks.filter(l =>
l.source === selectedNodeId || l.target === selectedNodeId ||
l.source.id === selectedNodeId || l.target.id === selectedNodeId
);
}
// Get node IDs that are connected
const connectedNodeIds = new Set();
filteredLinks.forEach(l => {
const sourceId = typeof l.source === 'object' ? l.source.id : l.source;
const targetId = typeof l.target === 'object' ? l.target.id : l.target;
connectedNodeIds.add(sourceId);
connectedNodeIds.add(targetId);
});
// Filter nodes
let filteredNodes = modalGraphData.nodes.filter(n => {
// Must be connected (or be the selected node)
if (!connectedNodeIds.has(n.id) && n.id !== selectedNodeId) return false;
// Type filter
if (n.type === 'company' && !showCompanies) return false;
if (n.type === 'person' && !showPeople) return false;
return true;
});
// Re-filter links to only include those between visible nodes
const visibleNodeIds = new Set(filteredNodes.map(n => n.id));
filteredLinks = filteredLinks.filter(l => {
const sourceId = typeof l.source === 'object' ? l.source.id : l.source;
const targetId = typeof l.target === 'object' ? l.target.id : l.target;
return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId);
});
// Deep copy nodes to avoid mutation issues
modalFilteredData = {
nodes: filteredNodes.map(n => ({...n})),
links: filteredLinks.map(l => ({
...l,
source: typeof l.source === 'object' ? l.source.id : l.source,
target: typeof l.target === 'object' ? l.target.id : l.target
}))
};
updateStats();
initModalGraph(modalFilteredData.nodes, modalFilteredData.links);
}
function initModalGraph(nodes, links) {
const container = document.querySelector('.connections-modal-body');
const width = container.clientWidth;
const height = container.clientHeight;
modalSvg = d3.select('#connections-graph-modal')
.attr('width', width)
.attr('height', height);
modalSvg.selectAll('*').remove();
if (nodes.length === 0) {
modalSvg.append('text')
.attr('x', width / 2)
.attr('y', height / 2)
.attr('text-anchor', 'middle')
.attr('fill', '#64748b')
.text('Brak danych do wyświetlenia');
return;
}
modalZoom = d3.zoom()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
modalG.attr('transform', event.transform);
});
modalSvg.call(modalZoom);
modalG = modalSvg.append('g');
// Adjust simulation based on node count
const nodeCount = nodes.length;
const chargeStrength = nodeCount > 100 ? -80 : nodeCount > 50 ? -100 : -150;
const linkDistance = nodeCount > 100 ? 40 : nodeCount > 50 ? 60 : 80;
modalSimulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(linkDistance).strength(0.7))
.force('charge', d3.forceManyBody().strength(chargeStrength).distanceMax(200))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(d => d.type === 'company' ? 12 : 8))
.force('x', d3.forceX(width / 2).strength(0.02))
.force('y', d3.forceY(height / 2).strength(0.02));
// Links
const link = modalG.append('g')
.selectAll('line')
.data(links)
.join('line')
.attr('class', d => `modal-link ${d.category}`)
.attr('stroke-width', 1.5);
// Nodes
const node = modalG.append('g')
.selectAll('g')
.data(nodes)
.join('g')
.attr('class', d => d.type === 'company' ? 'modal-node-company' : 'modal-node-person')
.call(modalDrag(modalSimulation));
node.append('circle')
.attr('r', d => {
if (d.type === 'company') return 8;
return 4 + Math.min((d.company_count || 1), 5);
})
.attr('fill', d => {
if (d.id === selectedNodeId) return '#22c55e';
return d.type === 'company' ? '#4a90d9' : '#f39c12';
})
.attr('stroke', d => d.id === selectedNodeId ? '#fff' : '#1e293b')
.attr('stroke-width', d => d.id === selectedNodeId ? 2 : 1);
// Labels
node.append('text')
.attr('class', 'modal-node-label' + (modalShowLabels ? ' show-labels' : ''))
.attr('dx', 10)
.attr('dy', 3)
.text(d => d.name.length > 20 ? d.name.substring(0, 20) + '...' : d.name);
// Tooltip & hover
const tooltip = d3.select('#modalTooltip');
node.on('mouseover', function(event, d) {
// Highlight label on hover
d3.select(this).select('.modal-node-label').classed('hover-visible', true);
let html = `<h4>${d.name}</h4>`;
if (d.type === 'company') {
if (d.category) html += `<p>Kategoria: ${d.category}</p>`;
if (d.city) html += `<p>Miasto: ${d.city}</p>`;
const connected = links.filter(l => {
const sId = typeof l.source === 'object' ? l.source.id : l.source;
const tId = typeof l.target === 'object' ? l.target.id : l.target;
return sId === d.id || tId === d.id;
});
if (connected.length > 0) {
html += `<p style="margin-top: 8px; border-top: 1px solid #475569; padding-top: 8px;"><strong>Powiązania:</strong></p>`;
connected.slice(0, 8).forEach(l => {
const sId = typeof l.source === 'object' ? l.source.id : l.source;
const person = sId === d.id ?
nodes.find(n => n.id === (typeof l.target === 'object' ? l.target.id : l.target)) :
nodes.find(n => n.id === sId);
if (person && person.type === 'person') {
html += `<span class="role-badge ${l.category}">${l.role}</span>${person.name}<br>`;
}
});
if (connected.length > 8) html += `<p style="color: #64748b;">...i ${connected.length - 8} więcej</p>`;
}
} else {
const connected = links.filter(l => {
const sId = typeof l.source === 'object' ? l.source.id : l.source;
const tId = typeof l.target === 'object' ? l.target.id : l.target;
return sId === d.id || tId === d.id;
});
html += `<p>Powiązany z ${connected.length} firmami</p>`;
if (connected.length > 0) {
connected.slice(0, 5).forEach(l => {
const tId = typeof l.target === 'object' ? l.target.id : l.target;
const company = nodes.find(n => n.id === tId);
if (company && company.type === 'company') {
html += `<span class="role-badge ${l.category}">${l.role}</span>${company.name}<br>`;
}
});
if (connected.length > 5) html += `<p style="color: #64748b;">...i ${connected.length - 5} więcej</p>`;
}
}
tooltip.html(html)
.style('display', 'block')
.style('left', (event.clientX + 15) + 'px')
.style('top', (event.clientY - 10) + 'px');
})
.on('mousemove', (event) => {
tooltip
.style('left', (event.clientX + 15) + 'px')
.style('top', (event.clientY - 10) + 'px');
})
.on('mouseout', function() {
d3.select(this).select('.modal-node-label').classed('hover-visible', false);
tooltip.style('display', 'none');
})
.on('click', (event, d) => {
if (d.type === 'company' && d.slug) {
closeConnectionsMap();
window.location.href = `/company/${d.slug}`;
} else {
// Select this node to filter
selectNode(d.id);
}
});
modalSimulation.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('transform', d => `translate(${d.x},${d.y})`);
});
// Auto-fit
setTimeout(modalFitToScreen, 1200);
}
function modalDrag(simulation) {
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
return d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended);
}
function modalResetZoom() {
if (!modalSvg) return;
modalSvg.transition()
.duration(750)
.call(modalZoom.transform, d3.zoomIdentity);
}
function modalFitToScreen() {
if (!modalFilteredData.nodes || modalFilteredData.nodes.length === 0 || !modalSvg) return;
const container = document.querySelector('.connections-modal-body');
const width = container.clientWidth;
const height = container.clientHeight;
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
modalFilteredData.nodes.forEach(n => {
if (n.x !== undefined && n.y !== undefined) {
minX = Math.min(minX, n.x);
maxX = Math.max(maxX, n.x);
minY = Math.min(minY, n.y);
maxY = Math.max(maxY, n.y);
}
});
if (minX === Infinity) return;
const padding = 60;
minX -= padding;
maxX += padding;
minY -= padding;
maxY += padding;
const graphWidth = maxX - minX;
const graphHeight = maxY - minY;
const scale = Math.min(width / graphWidth, height / graphHeight, 2.5);
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
modalSvg.transition()
.duration(750)
.call(modalZoom.transform, d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(scale)
.translate(-centerX, -centerY));
}
function toggleModalLabels() {
modalShowLabels = document.getElementById('filterLabels').checked;
d3.selectAll('.modal-node-label').classed('show-labels', modalShowLabels);
}
// Close search results when clicking outside
document.addEventListener('click', function(e) {
const searchDiv = document.querySelector('.filter-search');
const resultsDiv = document.getElementById('filterSearchResults');
if (searchDiv && resultsDiv && !searchDiv.contains(e.target)) {
resultsDiv.classList.remove('show');
}
});
// Handle window resize
window.addEventListener('resize', () => {
const modal = document.getElementById('connectionsModal');
if (modal && modal.style.display === 'flex' && modalFilteredData.nodes.length > 0) {
initModalGraph(modalFilteredData.nodes, modalFilteredData.links);
}
});
</script>