nordabiz/templates/connections_map.html
Maciej Pienczyn cebe52f303 refactor: Rebranding i aktualizacja modelu AI
- Zmiana nazwy: "Norda Biznes Hub" → "Norda Biznes Partner"
- Aktualizacja modelu AI: Gemini 2.0 Flash → Gemini 3 Flash
- Zachowano historyczne odniesienia w timeline i dokumentacji

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

590 lines
15 KiB
HTML

{% extends "base.html" %}
{% block title %}Mapa Powiazań - Norda Biznes Partner{% 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 %}