nordabiz/templates/admin/zopk_knowledge_graph.html
Maciej Pienczyn 8f44c11af9 perf: remove d3.js + connections modal from base.html (-225KB per page)
d3.v7.min.js (225KB) and connections_modal.html (~1100 lines) were
loaded on every page but only used by owner-only "Mapa Powiązań" tool.

- Removed d3.js and connections_modal include from base.html
- Added {% block extra_scripts %} for pages that need external JS
- admin/zopk_knowledge_graph.html loads d3 via extra_scripts block
- connections_map.html already had its own d3 import
- "Mapa Powiązań" link now points to /connections page instead of modal
- zopk/index.html d3 code was disabled ({% if false %}) — no change needed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:23:06 +02:00

514 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}Graf Relacji Encji - ZOPK Baza Wiedzy{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', filename='js/vendor/d3.v7.min.js') }}" defer></script>
{% endblock %}
{% block extra_css %}
<style>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
}
.page-header h1 {
font-size: var(--font-size-2xl);
color: var(--text-primary);
}
.breadcrumb {
display: flex;
gap: var(--spacing-xs);
color: var(--text-secondary);
font-size: var(--font-size-sm);
margin-bottom: var(--spacing-lg);
}
.breadcrumb a {
color: var(--primary);
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
.graph-container {
position: relative;
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
}
.graph-controls {
position: absolute;
top: var(--spacing-md);
left: var(--spacing-md);
z-index: 10;
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.control-group {
background: rgba(255,255,255,0.95);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.control-group label {
display: block;
font-size: var(--font-size-xs);
color: var(--text-secondary);
margin-bottom: 4px;
}
.control-group select,
.control-group input {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
}
.graph-stats {
position: absolute;
top: var(--spacing-md);
right: var(--spacing-md);
z-index: 10;
background: rgba(255,255,255,0.95);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
box-shadow: var(--shadow);
font-size: var(--font-size-sm);
}
.graph-stats strong {
color: var(--primary);
}
#graph-svg {
width: 100%;
height: 700px;
cursor: grab;
}
#graph-svg:active {
cursor: grabbing;
}
.node {
cursor: pointer;
}
.node circle {
stroke: #fff;
stroke-width: 2px;
transition: all 0.2s ease;
}
.node:hover circle {
stroke-width: 4px;
filter: brightness(1.1);
}
.node text {
font-size: 10px;
fill: var(--text-primary);
pointer-events: none;
text-anchor: middle;
dominant-baseline: middle;
}
.link {
stroke: #999;
stroke-opacity: 0.4;
}
.link:hover {
stroke-opacity: 0.8;
}
/* Node colors by type */
.node-company { fill: #3b82f6; }
.node-person { fill: #ec4899; }
.node-place { fill: #10b981; }
.node-organization { fill: #f59e0b; }
.node-project { fill: #8b5cf6; }
.node-event { fill: #ef4444; }
.node-other { fill: #6b7280; }
/* Tooltip */
.tooltip {
position: absolute;
background: rgba(0,0,0,0.8);
color: white;
padding: 8px 12px;
border-radius: var(--radius);
font-size: var(--font-size-sm);
pointer-events: none;
z-index: 100;
max-width: 250px;
}
.tooltip h4 {
margin: 0 0 4px 0;
font-size: var(--font-size-base);
}
.tooltip p {
margin: 0;
font-size: var(--font-size-xs);
opacity: 0.8;
}
/* Legend */
.legend {
position: absolute;
bottom: var(--spacing-md);
left: var(--spacing-md);
background: rgba(255,255,255,0.95);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius);
box-shadow: var(--shadow);
display: flex;
flex-wrap: wrap;
gap: var(--spacing-md);
font-size: var(--font-size-xs);
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255,255,255,0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 20;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.btn {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: 6px 12px;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
cursor: pointer;
transition: var(--transition);
text-decoration: none;
border: none;
background: var(--primary);
color: white;
}
.btn:hover {
background: var(--primary-dark);
}
</style>
{% endblock %}
{% block content %}
<div class="container">
<div class="breadcrumb">
<a href="{{ url_for('admin.admin_zopk') }}">Panel ZOPK</a>
<span></span>
<a href="{{ url_for('admin.admin_zopk_knowledge_dashboard') }}">Baza Wiedzy</a>
<span></span>
<span>Graf Relacji</span>
</div>
<div class="page-header">
<h1>🕸️ Graf Współwystępowania Encji</h1>
</div>
<div class="graph-container">
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-spinner"></div>
</div>
<div class="graph-controls">
<div class="control-group">
<label>Typ encji:</label>
<select id="entityType" onchange="loadGraph()">
<option value="">Wszystkie</option>
<option value="company">Firmy</option>
<option value="person">Osoby</option>
<option value="place">Miejsca</option>
<option value="organization">Organizacje</option>
<option value="project">Projekty</option>
</select>
</div>
<div class="control-group">
<label>Min. współwystąpień:</label>
<input type="range" id="minCooccurrence" min="2" max="10" value="3"
oninput="document.getElementById('cooccurValue').textContent = this.value"
onchange="loadGraph()">
<span id="cooccurValue">3</span>
</div>
<div class="control-group">
<button class="btn" onclick="resetZoom()">🔄 Reset widoku</button>
</div>
</div>
<div class="graph-stats" id="graphStats">
Ładowanie...
</div>
<svg id="graph-svg"></svg>
<div class="legend">
<div class="legend-item">
<div class="legend-dot node-company"></div>
<span>Firmy</span>
</div>
<div class="legend-item">
<div class="legend-dot node-person"></div>
<span>Osoby</span>
</div>
<div class="legend-item">
<div class="legend-dot node-place"></div>
<span>Miejsca</span>
</div>
<div class="legend-item">
<div class="legend-dot node-organization"></div>
<span>Organizacje</span>
</div>
<div class="legend-item">
<div class="legend-dot node-project"></div>
<span>Projekty</span>
</div>
</div>
</div>
</div>
<!-- Tooltip -->
<div class="tooltip" id="tooltip" style="display: none;"></div>
{% endblock %}
{% block extra_js %}
// D3 is loaded by base.html AFTER this block, so we need to wait
// Use window.onload to ensure D3 script from base.html is fully loaded
window.addEventListener('load', function() {
if (typeof d3 !== 'undefined') {
initGraph();
} else {
console.error('D3.js not loaded!');
}
});
let simulation, svg, g, link, node, zoom;
let currentNodes = [];
let currentLinks = [];
function initGraph() {
svg = d3.select('#graph-svg');
const width = svg.node().getBoundingClientRect().width;
const height = 700;
// Create zoom behavior
zoom = d3.zoom()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
g.attr('transform', event.transform);
});
svg.call(zoom);
// Create container group
g = svg.append('g');
// Create link and node groups
g.append('g').attr('class', 'links');
g.append('g').attr('class', 'nodes');
loadGraph();
}
async function loadGraph() {
const overlay = document.getElementById('loadingOverlay');
overlay.style.display = 'flex';
const entityType = document.getElementById('entityType').value;
const minCooccurrence = document.getElementById('minCooccurrence').value;
try {
const url = `/api/zopk/knowledge/graph/data?entity_type=${entityType}&min_cooccurrence=${minCooccurrence}&limit=200`;
const response = await fetch(url);
const data = await response.json();
if (data.success) {
currentNodes = data.nodes;
currentLinks = data.links;
updateGraph();
document.getElementById('graphStats').innerHTML =
`<strong>${data.stats.total_nodes}</strong> encji • <strong>${data.stats.total_links}</strong> połączeń`;
} else {
console.error('Error loading graph:', data.error);
}
} catch (error) {
console.error('Error:', error);
} finally {
overlay.style.display = 'none';
}
}
function updateGraph() {
const width = svg.node().getBoundingClientRect().width;
const height = 700;
// Stop existing simulation
if (simulation) simulation.stop();
// Clear existing elements
g.select('.links').selectAll('*').remove();
g.select('.nodes').selectAll('*').remove();
// Create links
link = g.select('.links')
.selectAll('line')
.data(currentLinks)
.enter()
.append('line')
.attr('class', 'link')
.attr('stroke-width', d => Math.sqrt(d.value));
// Create nodes
node = g.select('.nodes')
.selectAll('g')
.data(currentNodes)
.enter()
.append('g')
.attr('class', 'node')
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended))
.on('mouseover', showTooltip)
.on('mouseout', hideTooltip);
// Add circles
node.append('circle')
.attr('r', d => Math.max(8, Math.min(30, Math.sqrt(d.mentions) * 2)))
.attr('class', d => `node-${getNodeType(d.type)}`);
// Add labels for larger nodes
node.filter(d => d.mentions >= 10)
.append('text')
.attr('dy', d => Math.max(8, Math.min(30, Math.sqrt(d.mentions) * 2)) + 12)
.text(d => d.name.length > 15 ? d.name.slice(0, 15) + '...' : d.name);
// Create simulation
simulation = d3.forceSimulation(currentNodes)
.force('link', d3.forceLink(currentLinks)
.id(d => d.id)
.distance(100)
.strength(d => Math.min(1, d.value / 10)))
.force('charge', d3.forceManyBody().strength(-200))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(d => Math.max(8, Math.sqrt(d.mentions) * 2) + 5))
.on('tick', ticked);
}
function ticked() {
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 dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
function showTooltip(event, d) {
const tooltip = document.getElementById('tooltip');
tooltip.innerHTML = `
<h4>${d.name}</h4>
<p>Typ: ${d.type}</p>
<p>Wzmianki: ${d.mentions}</p>
${d.verified ? '<p>✅ Zweryfikowano</p>' : ''}
`;
tooltip.style.display = 'block';
tooltip.style.left = (event.pageX + 10) + 'px';
tooltip.style.top = (event.pageY + 10) + 'px';
}
function hideTooltip() {
document.getElementById('tooltip').style.display = 'none';
}
function getNodeType(type) {
const typeMap = {
'company': 'company',
'person': 'person',
'place': 'place',
'Lokalizacja': 'place',
'organization': 'organization',
'Organizacja': 'organization',
'project': 'project',
'Projekt': 'project',
'event': 'event'
};
return typeMap[type] || 'other';
}
function resetZoom() {
svg.transition().duration(500).call(
zoom.transform,
d3.zoomIdentity
);
}
{% endblock %}