- API: Count unique company_ids instead of all roles - Tooltip: Show "X firmami (Y ról)" to distinguish companies from roles - Bogdan Łaga has 6 unique companies with 9 roles (was showing 9 companies) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1102 lines
34 KiB
HTML
1102 lines
34 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 {
|
|
// Get all connections (roles)
|
|
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;
|
|
});
|
|
|
|
// Count UNIQUE companies (not roles)
|
|
const uniqueCompanyIds = new Set();
|
|
connected.forEach(l => {
|
|
const tId = typeof l.target === 'object' ? l.target.id : l.target;
|
|
uniqueCompanyIds.add(tId);
|
|
});
|
|
const uniqueCompanyCount = uniqueCompanyIds.size;
|
|
|
|
html += `<p>Powiązany z ${uniqueCompanyCount} firmami (${connected.length} ról)</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>
|