nordabiz/templates/admin/zopk_knowledge_graph.html
Maciej Pienczyn 094379d95e
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
fix(templates): Add blueprint prefix to url_for calls across admin templates
After refactoring to blueprints, templates still used bare endpoint names
(e.g., url_for('admin_zopk')) instead of prefixed names (e.g.,
url_for('admin.admin_zopk')). While most worked via backward-compat aliases,
api_zopk_search_news was missing from the alias list causing 500 on /admin/zopk.

Fixed 19 template files and added missing alias for api_zopk_search_news.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 13:44:50 +01:00

510 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_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 %}