- Add company logo display in search results cards - Make logo clickable (links to company profile) - Temporarily hide "Aktualności i wydarzenia" section on company profiles - Add scripts for KRS PDF download/parsing and CEIDG API Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
590 lines
15 KiB
HTML
590 lines
15 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Mapa Powiazań - Norda Biznes Hub{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.connections-container {
|
|
width: 100%;
|
|
height: calc(100vh - 200px);
|
|
min-height: 600px;
|
|
background: #1a1a2e;
|
|
border-radius: 12px;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
#connections-graph {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.node-company {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.node-person {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.link {
|
|
stroke: #4a90d9;
|
|
stroke-opacity: 0.4;
|
|
}
|
|
|
|
.link.zarzad {
|
|
stroke: #e74c3c;
|
|
}
|
|
|
|
.link.wspolnik {
|
|
stroke: #2ecc71;
|
|
}
|
|
|
|
.link.prokurent {
|
|
stroke: #f39c12;
|
|
}
|
|
|
|
.link.wlasciciel_jdg {
|
|
stroke: #9b59b6;
|
|
}
|
|
|
|
.node-label {
|
|
font-size: 10px;
|
|
fill: #fff;
|
|
pointer-events: none;
|
|
text-shadow: 0 1px 2px rgba(0,0,0,0.8);
|
|
}
|
|
|
|
.tooltip {
|
|
position: absolute;
|
|
padding: 12px 16px;
|
|
background: rgba(26, 26, 46, 0.95);
|
|
border: 1px solid #4a90d9;
|
|
border-radius: 8px;
|
|
color: #fff;
|
|
font-size: 13px;
|
|
pointer-events: none;
|
|
z-index: 1000;
|
|
max-width: 300px;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
}
|
|
|
|
.tooltip h4 {
|
|
margin: 0 0 8px 0;
|
|
color: #4a90d9;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.tooltip p {
|
|
margin: 4px 0;
|
|
}
|
|
|
|
.tooltip .role-badge {
|
|
display: inline-block;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
font-size: 11px;
|
|
margin: 2px;
|
|
}
|
|
|
|
.tooltip .role-badge.zarzad {
|
|
background: #e74c3c;
|
|
}
|
|
|
|
.tooltip .role-badge.wspolnik {
|
|
background: #2ecc71;
|
|
}
|
|
|
|
.tooltip .role-badge.prokurent {
|
|
background: #f39c12;
|
|
}
|
|
|
|
.tooltip .role-badge.wlasciciel_jdg {
|
|
background: #9b59b6;
|
|
}
|
|
|
|
.controls {
|
|
position: absolute;
|
|
top: 16px;
|
|
right: 16px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
z-index: 100;
|
|
}
|
|
|
|
.control-btn {
|
|
padding: 8px 16px;
|
|
background: rgba(74, 144, 217, 0.9);
|
|
border: none;
|
|
border-radius: 6px;
|
|
color: #fff;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.control-btn:hover {
|
|
background: rgba(74, 144, 217, 1);
|
|
}
|
|
|
|
.legend {
|
|
position: absolute;
|
|
bottom: 16px;
|
|
left: 16px;
|
|
background: rgba(26, 26, 46, 0.9);
|
|
padding: 12px 16px;
|
|
border-radius: 8px;
|
|
z-index: 100;
|
|
}
|
|
|
|
.legend h4 {
|
|
margin: 0 0 8px 0;
|
|
color: #fff;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin: 4px 0;
|
|
font-size: 12px;
|
|
color: #ccc;
|
|
}
|
|
|
|
.legend-color {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.legend-line {
|
|
width: 24px;
|
|
height: 3px;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.stats-panel {
|
|
position: absolute;
|
|
top: 16px;
|
|
left: 16px;
|
|
background: rgba(26, 26, 46, 0.9);
|
|
padding: 12px 16px;
|
|
border-radius: 8px;
|
|
z-index: 100;
|
|
}
|
|
|
|
.stats-panel h4 {
|
|
margin: 0 0 8px 0;
|
|
color: #4a90d9;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.stats-panel .stat {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
margin: 4px 0;
|
|
font-size: 13px;
|
|
color: #ccc;
|
|
}
|
|
|
|
.stats-panel .stat-value {
|
|
color: #fff;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.page-header {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.page-header h1 {
|
|
font-size: 28px;
|
|
margin: 0;
|
|
}
|
|
|
|
.page-header p {
|
|
color: #888;
|
|
margin: 8px 0 0 0;
|
|
}
|
|
|
|
.loading-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(26, 26, 46, 0.9);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.loading-text {
|
|
color: #fff;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.source-info {
|
|
margin-top: 16px;
|
|
font-size: 12px;
|
|
color: #666;
|
|
}
|
|
|
|
.source-info a {
|
|
color: #4a90d9;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="page-header">
|
|
<h1>Mapa Powiazań Norda Biznes</h1>
|
|
<p>Wizualizacja powiązań między firmami członkowskimi a osobami (zarząd, wspólnicy, prokurenci)</p>
|
|
</div>
|
|
|
|
<div class="connections-container" id="container">
|
|
<div class="loading-overlay" id="loading">
|
|
<div class="loading-text">Ładowanie danych...</div>
|
|
</div>
|
|
|
|
<svg id="connections-graph"></svg>
|
|
|
|
<div class="stats-panel" id="stats">
|
|
<h4>Statystyki</h4>
|
|
<div class="stat">
|
|
<span>Firmy:</span>
|
|
<span class="stat-value" id="stat-companies">-</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span>Osoby:</span>
|
|
<span class="stat-value" id="stat-people">-</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span>Powiązania:</span>
|
|
<span class="stat-value" id="stat-connections">-</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="controls">
|
|
<button class="control-btn" onclick="fitToScreen()" style="background: rgba(34, 197, 94, 0.9);">Dopasuj widok</button>
|
|
<button class="control-btn" onclick="resetZoom()">Reset widoku</button>
|
|
<button class="control-btn" onclick="toggleLabels()">Pokaż/ukryj etykiety</button>
|
|
</div>
|
|
|
|
<div class="legend">
|
|
<h4>Legenda</h4>
|
|
<div class="legend-item">
|
|
<div class="legend-color" style="background: #4a90d9;"></div>
|
|
<span>Firma</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color" style="background: #f39c12;"></div>
|
|
<span>Osoba</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-line" style="background: #e74c3c;"></div>
|
|
<span>Zarząd</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-line" style="background: #2ecc71;"></div>
|
|
<span>Wspólnik</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-line" style="background: #f39c12;"></div>
|
|
<span>Prokurent</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-line" style="background: #9b59b6;"></div>
|
|
<span>Właściciel JDG</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tooltip" id="tooltip" style="display: none;"></div>
|
|
</div>
|
|
|
|
<div class="source-info">
|
|
Źródło danych: <a href="https://ekrs.ms.gov.pl" target="_blank">ekrs.ms.gov.pl</a> (KRS),
|
|
<a href="https://dane.biznes.gov.pl" target="_blank">dane.biznes.gov.pl</a> (CEIDG)
|
|
</div>
|
|
|
|
<script src="{{ url_for('static', filename='js/vendor/d3.v7.min.js') }}"></script>
|
|
<script>
|
|
// D3.js Force-Directed Graph for Company-Person Connections
|
|
let simulation, svg, g, zoom, showLabels = true;
|
|
let graphData = { nodes: [], links: [] };
|
|
|
|
// Wait for D3 to be available
|
|
function waitForD3(callback) {
|
|
if (typeof d3 !== 'undefined') {
|
|
callback();
|
|
} else {
|
|
setTimeout(() => waitForD3(callback), 100);
|
|
}
|
|
}
|
|
|
|
async function loadData() {
|
|
try {
|
|
const response = await fetch('/api/connections');
|
|
const data = await response.json();
|
|
|
|
if (!data.success) {
|
|
throw new Error('Failed to load data');
|
|
}
|
|
|
|
graphData = data;
|
|
|
|
// Update stats
|
|
document.getElementById('stat-companies').textContent = data.stats.companies;
|
|
document.getElementById('stat-people').textContent = data.stats.people;
|
|
document.getElementById('stat-connections').textContent = data.stats.connections;
|
|
|
|
// Hide loading
|
|
document.getElementById('loading').style.display = 'none';
|
|
|
|
// Initialize graph
|
|
initGraph(data.nodes, data.links);
|
|
|
|
} catch (error) {
|
|
console.error('Error loading data:', error);
|
|
document.querySelector('#loading .loading-text').textContent = 'Błąd ładowania danych';
|
|
}
|
|
}
|
|
|
|
function initGraph(nodes, links) {
|
|
const container = document.getElementById('container');
|
|
const width = container.clientWidth;
|
|
const height = container.clientHeight;
|
|
|
|
svg = d3.select('#connections-graph')
|
|
.attr('width', width)
|
|
.attr('height', height);
|
|
|
|
// Clear previous content
|
|
svg.selectAll('*').remove();
|
|
|
|
// Add zoom behavior
|
|
zoom = d3.zoom()
|
|
.scaleExtent([0.1, 4])
|
|
.on('zoom', (event) => {
|
|
g.attr('transform', event.transform);
|
|
});
|
|
|
|
svg.call(zoom);
|
|
|
|
// Create container for graph elements
|
|
g = svg.append('g');
|
|
|
|
// Create simulation - optimized for large graph
|
|
simulation = d3.forceSimulation(nodes)
|
|
.force('link', d3.forceLink(links).id(d => d.id).distance(80).strength(0.5))
|
|
.force('charge', d3.forceManyBody().strength(-150).distanceMax(300))
|
|
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
.force('collision', d3.forceCollide().radius(20))
|
|
.force('x', d3.forceX(width / 2).strength(0.05))
|
|
.force('y', d3.forceY(height / 2).strength(0.05));
|
|
|
|
// Auto-fit after initial layout (2 seconds)
|
|
setTimeout(() => {
|
|
fitToScreen();
|
|
}, 2000);
|
|
|
|
// Create links
|
|
const link = g.append('g')
|
|
.selectAll('line')
|
|
.data(links)
|
|
.join('line')
|
|
.attr('class', d => `link ${d.category}`)
|
|
.attr('stroke-width', 2);
|
|
|
|
// Create nodes
|
|
const node = g.append('g')
|
|
.selectAll('g')
|
|
.data(nodes)
|
|
.join('g')
|
|
.attr('class', d => d.type === 'company' ? 'node-company' : 'node-person')
|
|
.call(drag(simulation));
|
|
|
|
// Add circles to nodes
|
|
node.append('circle')
|
|
.attr('r', d => {
|
|
if (d.type === 'company') return 12;
|
|
return 6 + (d.company_count || 1) * 2;
|
|
})
|
|
.attr('fill', d => d.type === 'company' ? '#4a90d9' : '#f39c12')
|
|
.attr('stroke', '#fff')
|
|
.attr('stroke-width', 1.5);
|
|
|
|
// Add labels
|
|
node.append('text')
|
|
.attr('class', 'node-label')
|
|
.attr('dx', 15)
|
|
.attr('dy', 4)
|
|
.text(d => {
|
|
if (d.type === 'company') return d.name.length > 20 ? d.name.substring(0, 20) + '...' : d.name;
|
|
return d.name;
|
|
});
|
|
|
|
// Add tooltip interaction
|
|
const tooltip = d3.select('#tooltip');
|
|
|
|
node.on('mouseover', (event, d) => {
|
|
let html = `<h4>${d.name}</h4>`;
|
|
|
|
if (d.type === 'company') {
|
|
html += `<p>Kategoria: ${d.category}</p>`;
|
|
if (d.city) html += `<p>Miasto: ${d.city}</p>`;
|
|
|
|
// Find connected people
|
|
const connected = links.filter(l => l.target.id === d.id || l.source.id === d.id);
|
|
if (connected.length > 0) {
|
|
html += `<p>Powiązania:</p>`;
|
|
connected.forEach(l => {
|
|
const person = l.source.id === d.id ? l.target : l.source;
|
|
if (person.type === 'person') {
|
|
html += `<span class="role-badge ${l.category}">${l.role}</span> ${person.name}<br>`;
|
|
}
|
|
});
|
|
}
|
|
} else {
|
|
html += `<p>Powiązany z ${d.company_count} firmami</p>`;
|
|
|
|
// Find connected companies
|
|
const connected = links.filter(l => l.source.id === d.id || l.target.id === d.id);
|
|
if (connected.length > 0) {
|
|
connected.forEach(l => {
|
|
const company = l.source.id === d.id ? l.target : l.source;
|
|
if (company.type === 'company') {
|
|
html += `<span class="role-badge ${l.category}">${l.role}</span> ${company.name}<br>`;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
tooltip.html(html)
|
|
.style('display', 'block')
|
|
.style('left', (event.pageX + 15) + 'px')
|
|
.style('top', (event.pageY - 10) + 'px');
|
|
})
|
|
.on('mousemove', (event) => {
|
|
tooltip
|
|
.style('left', (event.pageX + 15) + 'px')
|
|
.style('top', (event.pageY - 10) + 'px');
|
|
})
|
|
.on('mouseout', () => {
|
|
tooltip.style('display', 'none');
|
|
})
|
|
.on('click', (event, d) => {
|
|
if (d.type === 'company' && d.slug) {
|
|
window.location.href = `/company/${d.slug}`;
|
|
}
|
|
});
|
|
|
|
// Update positions on tick
|
|
simulation.on('tick', () => {
|
|
link
|
|
.attr('x1', d => d.source.x)
|
|
.attr('y1', d => d.source.y)
|
|
.attr('x2', d => d.target.x)
|
|
.attr('y2', d => d.target.y);
|
|
|
|
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
|
});
|
|
}
|
|
|
|
function drag(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 resetZoom() {
|
|
svg.transition()
|
|
.duration(750)
|
|
.call(zoom.transform, d3.zoomIdentity);
|
|
}
|
|
|
|
function fitToScreen() {
|
|
if (!graphData.nodes || graphData.nodes.length === 0) return;
|
|
|
|
const container = document.getElementById('container');
|
|
const width = container.clientWidth;
|
|
const height = container.clientHeight;
|
|
|
|
// Calculate bounding box of all nodes
|
|
let minX = Infinity, maxX = -Infinity;
|
|
let minY = Infinity, maxY = -Infinity;
|
|
|
|
graphData.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;
|
|
|
|
// Add padding
|
|
const padding = 50;
|
|
minX -= padding;
|
|
maxX += padding;
|
|
minY -= padding;
|
|
maxY += padding;
|
|
|
|
// Calculate scale and translation
|
|
const graphWidth = maxX - minX;
|
|
const graphHeight = maxY - minY;
|
|
const scale = Math.min(width / graphWidth, height / graphHeight, 1.5);
|
|
const centerX = (minX + maxX) / 2;
|
|
const centerY = (minY + maxY) / 2;
|
|
|
|
// Apply transform
|
|
svg.transition()
|
|
.duration(750)
|
|
.call(zoom.transform, d3.zoomIdentity
|
|
.translate(width / 2, height / 2)
|
|
.scale(scale)
|
|
.translate(-centerX, -centerY));
|
|
}
|
|
|
|
function toggleLabels() {
|
|
showLabels = !showLabels;
|
|
d3.selectAll('.node-label').style('display', showLabels ? 'block' : 'none');
|
|
}
|
|
|
|
// Handle window resize
|
|
window.addEventListener('resize', () => {
|
|
if (graphData.nodes.length > 0) {
|
|
initGraph(graphData.nodes, graphData.links);
|
|
}
|
|
});
|
|
|
|
// Load data on page load - wait for D3 first
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
waitForD3(loadData);
|
|
});
|
|
</script>
|
|
{% endblock %}
|