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
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>
510 lines
13 KiB
HTML
510 lines
13 KiB
HTML
{% 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 %}
|