feat(zopk): Add admin-only knowledge display on homepage and /zopk
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

4 new features visible only to admin (role=='admin'):
- Homepage: "Czy wiesz, że?" widget with 3 random high-confidence facts
- /zopk: Knowledge stats, top entities, key numeric facts, fact type distribution
- /zopk: Dated facts timeline (CSS-only vertical timeline)
- /zopk: D3.js entity co-occurrence graph with slider control

No migrations needed - read-only SELECT queries only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-02-09 16:30:34 +01:00
parent df8736297d
commit 30bb960a03
4 changed files with 716 additions and 3 deletions

View File

@ -113,6 +113,17 @@ def index():
).first() is not None
user_can_attend = next_event.can_user_attend(current_user)
# ZOPK Knowledge facts — admin only widget
zopk_facts = []
if current_user.role == 'admin':
try:
from database import ZOPKKnowledgeFact, ZOPKNews
zopk_facts = db.query(ZOPKKnowledgeFact).join(ZOPKNews).filter(
ZOPKKnowledgeFact.confidence_score >= 0.5
).order_by(func.random()).limit(3).all()
except Exception:
pass
# Sprawdź czy użytkownik ma deklarację członkowską w toku
pending_application = None
if not current_user.is_norda_member and not current_user.company_id:
@ -131,7 +142,8 @@ def index():
next_event=next_event,
user_registered=user_registered,
user_can_attend=user_can_attend,
pending_application=pending_application
pending_application=pending_application,
zopk_facts=zopk_facts
)
finally:
db.close()

View File

@ -8,6 +8,8 @@ Contains public-facing routes for ZOPK (Zielony Okręg Przemysłowy Kaszubia).
from datetime import datetime, timedelta
from flask import abort, render_template, request
from flask_login import current_user
from sqlalchemy import func
from database import (
SessionLocal,
@ -16,7 +18,10 @@ from database import (
ZOPKNews,
ZOPKResource,
ZOPKMilestone,
ZOPKCompanyLink
ZOPKCompanyLink,
ZOPKKnowledgeFact,
ZOPKKnowledgeEntity,
ZOPKKnowledgeChunk
)
from . import bp
@ -95,6 +100,34 @@ def zopk_index():
'total_stakeholders': db.query(ZOPKStakeholder).filter(ZOPKStakeholder.is_active == True).count()
}
# Knowledge data — admin only
knowledge_data = None
if current_user.is_authenticated and current_user.role == 'admin':
knowledge_data = {
'total_facts': db.query(func.count(ZOPKKnowledgeFact.id)).scalar(),
'total_entities': db.query(func.count(ZOPKKnowledgeEntity.id)).filter(
ZOPKKnowledgeEntity.merged_into_id.is_(None)
).scalar(),
'total_chunks': db.query(func.count(ZOPKKnowledgeChunk.id)).filter(
ZOPKKnowledgeChunk.embedding.isnot(None)
).scalar(),
'fact_types': db.query(
ZOPKKnowledgeFact.fact_type, func.count()
).group_by(ZOPKKnowledgeFact.fact_type).all(),
'top_entities': db.query(ZOPKKnowledgeEntity).filter(
ZOPKKnowledgeEntity.merged_into_id.is_(None),
ZOPKKnowledgeEntity.mentions_count >= 3
).order_by(ZOPKKnowledgeEntity.mentions_count.desc()).limit(15).all(),
'key_investments': db.query(ZOPKKnowledgeFact).filter(
ZOPKKnowledgeFact.numeric_value.isnot(None),
ZOPKKnowledgeFact.confidence_score >= 0.5
).order_by(ZOPKKnowledgeFact.numeric_value.desc()).limit(5).all(),
'dated_facts': db.query(ZOPKKnowledgeFact).join(ZOPKNews).filter(
ZOPKKnowledgeFact.date_value.isnot(None),
ZOPKKnowledgeFact.confidence_score >= 0.4
).order_by(ZOPKKnowledgeFact.date_value.desc()).limit(20).all(),
}
return render_template('zopk/index.html',
projects=projects,
stakeholders=stakeholders,
@ -102,7 +135,8 @@ def zopk_index():
resources=resources,
stats=stats,
news_stats=news_stats,
milestones=milestones
milestones=milestones,
knowledge_data=knowledge_data
)
finally:

View File

@ -952,6 +952,38 @@
</div>
</div>
<!-- ZOPK Knowledge Widget — admin only -->
{% if current_user.is_authenticated and current_user.role == 'admin' and zopk_facts %}
<div style="background: linear-gradient(135deg, #059669 0%, #047857 50%, #065f46 100%); border-radius: var(--radius-lg); padding: var(--spacing-lg); margin-bottom: var(--spacing-xl); color: white;" data-animate="fadeIn">
<div style="display: flex; align-items: center; gap: var(--spacing-sm); margin-bottom: var(--spacing-md);">
<span style="font-size: 1.5rem;">💡</span>
<h3 style="margin: 0; font-size: var(--font-size-lg); font-weight: 700;">Czy wiesz, że... <span style="font-weight: 400; font-size: var(--font-size-sm); opacity: 0.8;">(Baza Wiedzy ZOPK)</span></h3>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: var(--spacing-md);">
{% for fact in zopk_facts %}
<div style="background: rgba(255,255,255,0.12); border-radius: var(--radius); padding: var(--spacing-md);">
<span style="display: inline-block; padding: 1px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; margin-bottom: var(--spacing-xs);
{% if fact.fact_type == 'investment' %}background: rgba(16,185,129,0.3);
{% elif fact.fact_type == 'event' %}background: rgba(59,130,246,0.3);
{% elif fact.fact_type == 'decision' %}background: rgba(245,158,11,0.3);
{% elif fact.fact_type == 'milestone' %}background: rgba(139,92,246,0.3);
{% else %}background: rgba(255,255,255,0.2);{% endif %}">
{{ fact.fact_type or 'fakt' }}
</span>
<p style="font-size: var(--font-size-sm); line-height: 1.5; margin: 0; opacity: 0.95;">
{{ fact.full_text[:200] }}{% if fact.full_text|length > 200 %}...{% endif %}
</p>
{% if fact.source_news %}
<div style="font-size: 11px; margin-top: var(--spacing-xs); opacity: 0.7;">
{{ fact.source_news.source_name or fact.source_news.source_domain }} &bull; {{ fact.source_news.published_at.strftime('%d.%m.%Y') if fact.source_news.published_at else '' }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Search Section -->
<div class="search-section" data-animate="fadeIn">
<form action="{{ url_for('search') }}" method="GET" class="search-bar">

View File

@ -568,6 +568,386 @@
font-size: var(--font-size-base);
}
}
/* ====== Knowledge Section (admin only) ====== */
.knowledge-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.knowledge-stat-box {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow);
text-align: center;
}
.knowledge-stat-box .stat-number {
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--primary);
}
.knowledge-stat-box .stat-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-top: var(--spacing-xs);
}
.knowledge-entities-list {
list-style: none;
padding: 0;
margin: 0;
}
.knowledge-entities-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-sm) 0;
border-bottom: 1px solid var(--border);
font-size: var(--font-size-sm);
}
.knowledge-entities-list li:last-child {
border-bottom: none;
}
.entity-type-badge {
display: inline-block;
padding: 1px 6px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
}
.entity-type-badge.company { background: #dbeafe; color: #1e40af; }
.entity-type-badge.person { background: #fce7f3; color: #9d174d; }
.entity-type-badge.place { background: #dcfce7; color: #166534; }
.entity-type-badge.organization { background: #fef3c7; color: #92400e; }
.entity-type-badge.project { background: #ede9fe; color: #5b21b6; }
.entity-mentions {
background: var(--background);
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.fact-types-bar {
display: flex;
height: 24px;
border-radius: var(--radius);
overflow: hidden;
margin-top: var(--spacing-md);
}
.fact-type-segment {
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 600;
color: white;
transition: flex 0.3s ease;
min-width: 24px;
}
.fact-type-segment.statistics { background: #3b82f6; }
.fact-type-segment.investment { background: #10b981; }
.fact-type-segment.event { background: #f59e0b; }
.fact-type-segment.decision { background: #ef4444; }
.fact-type-segment.milestone { background: #8b5cf6; }
.fact-type-segment.other { background: #6b7280; }
.fact-types-legend {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-md);
margin-top: var(--spacing-sm);
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.fact-types-legend span::before {
content: '';
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
margin-right: 4px;
vertical-align: middle;
}
.fact-types-legend .ft-statistics::before { background: #3b82f6; }
.fact-types-legend .ft-investment::before { background: #10b981; }
.fact-types-legend .ft-event::before { background: #f59e0b; }
.fact-types-legend .ft-decision::before { background: #ef4444; }
.fact-types-legend .ft-milestone::before { background: #8b5cf6; }
.fact-types-legend .ft-other::before { background: #6b7280; }
.investment-card {
background: var(--surface);
padding: var(--spacing-md);
border-radius: var(--radius);
border-left: 3px solid #10b981;
margin-bottom: var(--spacing-sm);
}
.investment-value {
font-size: var(--font-size-lg);
font-weight: 700;
color: #059669;
}
.investment-desc {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-top: var(--spacing-xs);
}
/* ====== Knowledge Timeline (admin only) ====== */
.knowledge-timeline {
position: relative;
padding: 0 0 0 120px;
margin-bottom: var(--spacing-2xl);
}
.knowledge-timeline::before {
content: '';
position: absolute;
left: 110px;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(180deg, #10b981 0%, #3b82f6 50%, #8b5cf6 100%);
border-radius: 2px;
}
.kt-item {
position: relative;
padding-bottom: var(--spacing-lg);
}
.kt-item:last-child { padding-bottom: 0; }
.kt-date {
position: absolute;
left: -120px;
top: 4px;
width: 100px;
text-align: right;
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-secondary);
}
.kt-dot {
position: absolute;
left: -16px;
top: 6px;
width: 12px;
height: 12px;
border-radius: 50%;
border: 3px solid white;
box-shadow: 0 0 0 2px #059669;
}
.kt-dot.investment { background: #10b981; box-shadow: 0 0 0 2px #10b981; }
.kt-dot.event { background: #3b82f6; box-shadow: 0 0 0 2px #3b82f6; }
.kt-dot.decision { background: #f59e0b; box-shadow: 0 0 0 2px #f59e0b; }
.kt-dot.milestone { background: #8b5cf6; box-shadow: 0 0 0 2px #8b5cf6; }
.kt-dot.statistics { background: #6b7280; box-shadow: 0 0 0 2px #6b7280; }
.kt-card {
background: var(--surface);
padding: var(--spacing-md);
border-radius: var(--radius);
box-shadow: var(--shadow);
margin-left: var(--spacing-md);
}
.kt-card .fact-type-tag {
display: inline-block;
padding: 1px 8px;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: 500;
margin-bottom: var(--spacing-xs);
}
.kt-card .fact-type-tag.investment { background: #dcfce7; color: #166534; }
.kt-card .fact-type-tag.event { background: #dbeafe; color: #1e40af; }
.kt-card .fact-type-tag.decision { background: #fef3c7; color: #92400e; }
.kt-card .fact-type-tag.milestone { background: #ede9fe; color: #5b21b6; }
.kt-card .fact-type-tag.statistics { background: #f3f4f6; color: #374151; }
.kt-card p {
font-size: var(--font-size-sm);
color: var(--text-primary);
line-height: 1.5;
margin: 0;
}
.kt-source {
font-size: var(--font-size-xs);
color: var(--text-muted);
margin-top: var(--spacing-xs);
}
.kt-source a {
color: var(--primary);
text-decoration: none;
}
.kt-source a:hover { text-decoration: underline; }
/* ====== Knowledge Graph (admin only) ====== */
.knowledge-graph-container {
position: relative;
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
margin-bottom: var(--spacing-2xl);
}
.kg-controls {
position: absolute;
top: var(--spacing-md);
left: 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);
display: flex;
align-items: center;
gap: var(--spacing-md);
font-size: var(--font-size-sm);
}
.kg-controls label { color: var(--text-secondary); }
.kg-controls input[type="range"] {
width: 100px;
vertical-align: middle;
}
.kg-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);
}
.kg-stats strong { color: var(--primary); }
#kg-svg {
width: 100%;
height: 500px;
cursor: grab;
}
#kg-svg:active { cursor: grabbing; }
.kg-node circle {
stroke: #fff;
stroke-width: 2px;
}
.kg-node:hover circle {
stroke-width: 4px;
filter: brightness(1.1);
}
.kg-node text {
font-size: 9px;
fill: var(--text-primary);
pointer-events: none;
text-anchor: middle;
}
.kg-link {
stroke: #999;
stroke-opacity: 0.4;
}
.kg-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);
}
.kg-legend-item {
display: flex;
align-items: center;
gap: 4px;
}
.kg-legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.kg-tooltip {
position: absolute;
background: rgba(0,0,0,0.85);
color: white;
padding: 6px 10px;
border-radius: var(--radius);
font-size: var(--font-size-xs);
pointer-events: none;
z-index: 100;
display: none;
}
@media (max-width: 768px) {
.knowledge-grid {
grid-template-columns: 1fr;
}
.knowledge-timeline {
padding-left: 20px;
}
.knowledge-timeline::before {
left: 6px;
}
.kt-date {
position: static;
width: auto;
text-align: left;
margin-bottom: var(--spacing-xs);
}
.kt-dot {
left: -20px;
}
.kt-card {
margin-left: 0;
}
}
</style>
{% endblock %}
@ -598,6 +978,124 @@
</div>
</div>
<!-- Knowledge Section — admin only -->
{% if current_user.is_authenticated and current_user.role == 'admin' and knowledge_data %}
<section>
<div class="section-header">
<h2>Wiedza o regionie (baza wiedzy AI)</h2>
</div>
<!-- Stats row -->
<div class="knowledge-grid">
<div class="knowledge-stat-box">
<div class="stat-number">{{ knowledge_data.total_facts }}</div>
<div class="stat-label">Wyekstrahowane fakty</div>
</div>
<div class="knowledge-stat-box">
<div class="stat-number">{{ knowledge_data.total_entities }}</div>
<div class="stat-label">Rozpoznane encje</div>
</div>
<div class="knowledge-stat-box">
<div class="stat-number">{{ knowledge_data.total_chunks }}</div>
<div class="stat-label">Chunki z embeddingami</div>
</div>
</div>
<!-- Fact types bar -->
{% if knowledge_data.fact_types %}
{% set total_ft = knowledge_data.fact_types|sum(attribute='1') %}
{% if total_ft > 0 %}
<div class="fact-types-bar">
{% for ft_name, ft_count in knowledge_data.fact_types %}
<div class="fact-type-segment {{ ft_name or 'other' }}" style="flex: {{ ft_count }}" title="{{ ft_name or 'inne' }}: {{ ft_count }}">
{% if ft_count * 100 / total_ft > 8 %}{{ ft_count }}{% endif %}
</div>
{% endfor %}
</div>
<div class="fact-types-legend">
{% for ft_name, ft_count in knowledge_data.fact_types %}
<span class="ft-{{ ft_name or 'other' }}">{{ ft_name or 'inne' }} ({{ ft_count }})</span>
{% endfor %}
</div>
{% endif %}
{% endif %}
<!-- Two columns: entities + investments -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-lg); margin-top: var(--spacing-xl);">
<!-- Top entities -->
<div>
<h3 style="font-size: var(--font-size-lg); margin-bottom: var(--spacing-md);">Najczęściej wymieniane encje</h3>
<ul class="knowledge-entities-list">
{% for entity in knowledge_data.top_entities %}
<li>
<span>
<span class="entity-type-badge {{ entity.entity_type or 'other' }}">{{ entity.entity_type or '?' }}</span>
{{ entity.canonical_name or entity.name }}
</span>
<span class="entity-mentions">{{ entity.mentions_count }}x</span>
</li>
{% endfor %}
</ul>
</div>
<!-- Key investments / numeric facts -->
<div>
<h3 style="font-size: var(--font-size-lg); margin-bottom: var(--spacing-md);">Kluczowe dane liczbowe</h3>
{% for fact in knowledge_data.key_investments %}
<div class="investment-card">
<div class="investment-value">
{% if fact.numeric_value >= 1000000000 %}
{{ "%.1f"|format(fact.numeric_value / 1000000000) }} mld {{ fact.numeric_unit or '' }}
{% elif fact.numeric_value >= 1000000 %}
{{ "%.1f"|format(fact.numeric_value / 1000000) }} mln {{ fact.numeric_unit or '' }}
{% elif fact.numeric_value >= 1000 %}
{{ "%.0f"|format(fact.numeric_value / 1000) }} tys. {{ fact.numeric_unit or '' }}
{% else %}
{{ "%.0f"|format(fact.numeric_value) }} {{ fact.numeric_unit or '' }}
{% endif %}
</div>
<div class="investment-desc">{{ fact.full_text[:200] }}</div>
</div>
{% endfor %}
</div>
</div>
</section>
<!-- Knowledge Timeline — admin only -->
{% if knowledge_data.dated_facts %}
<section>
<div class="section-header">
<h2>Oś czasu faktów</h2>
</div>
<div class="knowledge-timeline">
{% for fact in knowledge_data.dated_facts %}
<div class="kt-item">
<div class="kt-date">{{ fact.date_value.strftime('%d.%m.%Y') }}</div>
<div class="kt-dot {{ fact.fact_type or 'statistics' }}"></div>
<div class="kt-card">
<span class="fact-type-tag {{ fact.fact_type or 'statistics' }}">
{% if fact.fact_type == 'investment' %}Inwestycja
{% elif fact.fact_type == 'event' %}Wydarzenie
{% elif fact.fact_type == 'decision' %}Decyzja
{% elif fact.fact_type == 'milestone' %}Kamień milowy
{% else %}{{ fact.fact_type or 'Fakt' }}
{% endif %}
</span>
<p>{{ fact.full_text[:300] }}</p>
{% if fact.source_news %}
<div class="kt-source">
Źródło: <a href="{{ fact.source_news.url }}" target="_blank" rel="noopener">{{ fact.source_news.source_name or fact.source_news.source_domain }}</a>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% endif %}
<!-- Projects Section -->
<section>
<div class="section-header">
@ -845,4 +1343,141 @@
</div>
</section>
{% endif %}
<!-- Knowledge Graph — admin only -->
{% if current_user.is_authenticated and current_user.role == 'admin' and knowledge_data %}
<section>
<div class="section-header">
<h2>Graf współwystępowania encji</h2>
</div>
<div class="knowledge-graph-container">
<div class="kg-controls">
<label>Min. współwystąpień:</label>
<input type="range" id="kgMinCooccur" min="2" max="10" value="2"
oninput="document.getElementById('kgCooccurVal').textContent=this.value"
onchange="loadKnowledgeGraph()">
<span id="kgCooccurVal">2</span>
</div>
<div class="kg-stats" id="kgStats">Ładowanie...</div>
<svg id="kg-svg"></svg>
<div class="kg-legend">
<div class="kg-legend-item"><div class="kg-legend-dot" style="background:#3b82f6"></div><span>Firmy</span></div>
<div class="kg-legend-item"><div class="kg-legend-dot" style="background:#ec4899"></div><span>Osoby</span></div>
<div class="kg-legend-item"><div class="kg-legend-dot" style="background:#10b981"></div><span>Miejsca</span></div>
<div class="kg-legend-item"><div class="kg-legend-dot" style="background:#f59e0b"></div><span>Organizacje</span></div>
<div class="kg-legend-item"><div class="kg-legend-dot" style="background:#8b5cf6"></div><span>Projekty</span></div>
</div>
</div>
<div class="kg-tooltip" id="kgTooltip"></div>
</section>
{% endif %}
{% endblock %}
{% block extra_js %}
{% if current_user.is_authenticated and current_user.role == 'admin' and knowledge_data %}
// Knowledge Graph — D3.js
(function() {
if (typeof d3 === 'undefined') {
window.addEventListener('load', function() { if (typeof d3 !== 'undefined') initKG(); });
} else {
initKG();
}
var kgSim, kgSvg, kgG, kgZoom;
function initKG() {
kgSvg = d3.select('#kg-svg');
if (!kgSvg.node()) return;
kgZoom = d3.zoom()
.scaleExtent([0.1, 4])
.on('zoom', function(event) { kgG.attr('transform', event.transform); });
kgSvg.call(kgZoom);
kgG = kgSvg.append('g');
kgG.append('g').attr('class', 'kg-links');
kgG.append('g').attr('class', 'kg-nodes');
loadKnowledgeGraph();
}
window.loadKnowledgeGraph = async function() {
var minC = document.getElementById('kgMinCooccur').value;
try {
var resp = await fetch('/admin/zopk-api/knowledge/graph/data?min_cooccurrence=' + minC + '&limit=150');
var data = await resp.json();
if (data.success) {
renderKG(data.nodes, data.links, data.stats);
}
} catch(e) {
console.error('KG load error:', e);
}
};
var typeColors = {
company: '#3b82f6', person: '#ec4899', place: '#10b981',
organization: '#f59e0b', Organizacja: '#f59e0b',
project: '#8b5cf6', Projekt: '#8b5cf6',
Lokalizacja: '#10b981', event: '#ef4444'
};
function nodeColor(type) { return typeColors[type] || '#6b7280'; }
function renderKG(nodes, links, stats) {
var width = kgSvg.node().getBoundingClientRect().width;
var height = 500;
if (kgSim) kgSim.stop();
kgG.select('.kg-links').selectAll('*').remove();
kgG.select('.kg-nodes').selectAll('*').remove();
document.getElementById('kgStats').innerHTML =
'<strong>' + stats.total_nodes + '</strong> encji &bull; <strong>' + stats.total_links + '</strong> połączeń';
var link = kgG.select('.kg-links').selectAll('line')
.data(links).enter().append('line')
.attr('class', 'kg-link')
.attr('stroke-width', function(d) { return Math.sqrt(d.value); });
var node = kgG.select('.kg-nodes').selectAll('g')
.data(nodes).enter().append('g')
.attr('class', 'kg-node')
.call(d3.drag()
.on('start', function(ev, d) { if (!ev.active) kgSim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
.on('drag', function(ev, d) { d.fx = ev.x; d.fy = ev.y; })
.on('end', function(ev, d) { if (!ev.active) kgSim.alphaTarget(0); d.fx = null; d.fy = null; }))
.on('mouseover', function(ev, d) {
var tt = document.getElementById('kgTooltip');
tt.innerHTML = '<strong>' + d.name + '</strong><br>Typ: ' + d.type + ' | Wzmianki: ' + d.mentions;
tt.style.display = 'block';
tt.style.left = (ev.pageX + 10) + 'px';
tt.style.top = (ev.pageY + 10) + 'px';
})
.on('mouseout', function() { document.getElementById('kgTooltip').style.display = 'none'; });
node.append('circle')
.attr('r', function(d) { return Math.max(6, Math.min(24, Math.sqrt(d.mentions) * 2)); })
.attr('fill', function(d) { return nodeColor(d.type); });
node.filter(function(d) { return d.mentions >= 8; })
.append('text')
.attr('dy', function(d) { return Math.max(6, Math.min(24, Math.sqrt(d.mentions) * 2)) + 10; })
.text(function(d) { return d.name.length > 18 ? d.name.slice(0, 18) + '...' : d.name; });
kgSim = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(function(d) { return d.id; }).distance(80).strength(function(d) { return Math.min(1, d.value / 10); }))
.force('charge', d3.forceManyBody().strength(-150))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(function(d) { return Math.max(6, Math.sqrt(d.mentions) * 2) + 4; }))
.on('tick', function() {
link.attr('x1', function(d) { return d.source.x; })
.attr('y1', function(d) { return d.source.y; })
.attr('x2', function(d) { return d.target.x; })
.attr('y2', function(d) { return d.target.y; });
node.attr('transform', function(d) { return 'translate(' + d.x + ',' + d.y + ')'; });
});
}
})();
{% endif %}
{% endblock %}