Promote develop → main (2026-05-01 14:09 UTC) #8

Zusammengeführt
IntelSight_Admin hat 13 Commits von develop nach main 2026-05-01 16:09:55 +02:00 zusammengeführt
3 geänderte Dateien mit 563 neuen und 435 gelöschten Zeilen
Nur Änderungen aus Commit ffb8dddc4f werden angezeigt - Alle Commits anzeigen

Datei anzeigen

@@ -2450,213 +2450,215 @@ a.dev-source-pill:hover {
padding: 12px 20px 8px; padding: 12px 20px 8px;
} }
/* Achsen-Container */ /* === Snapshot-zentrierte Timeline === */
.ht-axis { .ht-tl {
position: relative; display: flex;
height: 110px; flex-direction: column;
gap: var(--sp-xl);
} }
/* Stündliches Layout: höher wegen Datums-Markern oben */ .ht-tl-hint {
.ht-axis--hourly { font-size: 12px;
height: 130px; color: var(--text-tertiary);
font-style: italic;
padding: 4px 0;
} }
/* Punkte-Bereich (über der Linie) */ /* Quanti-Strip (Heatmap-Zeile) */
.ht-points { .ht-strip {
position: absolute; display: flex;
left: 4%; flex-direction: column;
right: 4%; gap: 4px;
top: 0;
height: 56px;
} }
.ht-strip-cells {
.ht-axis--hourly .ht-points { display: grid;
top: 20px; grid-auto-flow: column;
grid-auto-columns: minmax(8px, 1fr);
gap: 2px;
height: 18px;
} }
.ht-strip-cell {
/* Achsenlinie */ background: color-mix(in srgb, var(--accent) calc(var(--intensity) * 70%), var(--border));
.ht-axis-line { border-radius: 2px;
position: absolute; cursor: pointer;
left: 2%; transition: transform 0.15s ease, box-shadow 0.15s ease;
right: 2%; min-height: 16px;
top: 60px; }
height: 2px; .ht-strip-cell.empty {
background: var(--border); background: var(--border);
opacity: 0.4;
cursor: default;
}
.ht-strip-cell:hover:not(.empty) {
transform: scaleY(1.4);
box-shadow: var(--glow-accent);
}
.ht-strip-cell.has-snapshot {
box-shadow: inset 0 -2px 0 var(--accent);
}
.ht-strip-labels {
display: grid;
gap: 2px;
font-size: 9px;
font-family: var(--font-mono);
color: var(--text-tertiary);
}
.ht-strip-label {
text-align: left;
white-space: nowrap;
} }
.ht-axis--hourly .ht-axis-line { /* Lagebericht-Kette */
top: 80px; .ht-chain-scroll {
overflow-x: auto;
overflow-y: visible;
padding: 4px 0 8px;
}
.ht-chain {
display: flex;
align-items: stretch;
gap: 0;
min-width: max-content;
} }
/* Datums-Marker (vertikale Linie + Datum oben, nur bei Stunden-Granularität) */ /* Lagebericht-Karte */
.ht-day-markers { .ht-card {
position: absolute; flex: 0 0 220px;
left: 4%; padding: 12px;
right: 4%; border: 1px solid var(--border);
top: 0; border-radius: var(--radius-lg);
bottom: 0; background: var(--bg-elevated);
pointer-events: none; cursor: pointer;
display: flex;
flex-direction: column;
gap: 4px;
transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
outline: none;
position: relative;
} }
.ht-card::before {
.ht-day-marker { content: '';
position: absolute;
top: 0;
}
.ht-day-marker-label {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
transform: translateX(-50%); right: 0;
font-size: 10px; height: 3px;
background: var(--accent);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.ht-card:hover {
border-color: var(--accent);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.ht-card:focus-visible { box-shadow: 0 0 0 3px var(--tint-accent-strong); }
.ht-card.active {
border-color: var(--accent);
box-shadow: var(--glow-accent-strong);
}
.ht-card-icon {
color: var(--accent);
margin-bottom: 2px;
}
.ht-card-date {
font-size: 11px;
font-family: var(--font-mono); font-family: var(--font-mono);
font-weight: 600; font-weight: 600;
color: var(--accent); color: var(--accent);
white-space: nowrap;
} }
.ht-card-preview {
.ht-day-marker-line { font-size: 13px;
position: absolute;
top: 14px;
height: 66px;
width: 1px;
left: 0;
background: var(--accent);
opacity: 0.2;
}
/* Punkt (Basis) */
.ht-point {
position: absolute;
bottom: 0;
transform: translateX(-50%);
border-radius: 50%;
background: var(--text-disabled);
border: 2px solid var(--bg-card);
cursor: pointer;
transition: all 0.2s ease;
z-index: 2;
}
.ht-point:hover {
box-shadow: var(--glow-accent);
z-index: 4;
}
.ht-point.active {
box-shadow: var(--glow-accent-strong);
z-index: 4;
}
/* Dimmen: nicht-aktive Punkte verblassen wenn ein Punkt aktiv ist */
.ht-points:has(.ht-point.active) .ht-point:not(.active) {
opacity: 0.3;
transition: opacity 0.3s ease;
}
/* Pfeil über dem aktiven Punkt */
.ht-point.active::after {
content: '▼';
position: absolute;
bottom: calc(100% + 2px);
left: 50%;
transform: translateX(-50%);
font-size: 10px;
color: var(--accent);
pointer-events: none;
line-height: 1;
}
/* Snapshot-Punkt (Raute) */
.ht-point.ht-snapshot-point {
border-radius: 2px;
transform: translateX(-50%) rotate(45deg);
background: var(--accent);
box-shadow: var(--glow-accent);
}
.ht-point.ht-snapshot-point .ht-tooltip,
.ht-point.ht-snapshot-point .ht-point-count {
transform: rotate(-45deg);
}
.ht-point.ht-snapshot-point .ht-tooltip {
transform: rotate(-45deg) translateX(-50%);
transform-origin: bottom left;
}
/* Gemischter Punkt (Gold-Kreis) */
.ht-point.ht-mixed-point {
background: var(--accent);
border: 2px solid var(--bg-card);
}
/* Tooltip (über dem Punkt) */
.ht-tooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: var(--bg-secondary);
color: var(--text-primary); color: var(--text-primary);
font-size: 11px; line-height: 1.4;
padding: 3px 8px; overflow: hidden;
border-radius: var(--radius); display: -webkit-box;
white-space: nowrap; -webkit-line-clamp: 3;
pointer-events: none; -webkit-box-orient: vertical;
opacity: 0; flex: 1;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0.15s ease;
border: 1px solid var(--border);
z-index: 10;
} }
.ht-card-meta {
.ht-point:hover .ht-tooltip {
opacity: 1;
visibility: visible;
}
/* Zahl unter dem Punkt */
.ht-point-count {
position: absolute;
top: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
font-size: 10px; font-size: 10px;
font-family: var(--font-mono);
color: var(--text-disabled);
white-space: nowrap;
pointer-events: none;
}
.ht-point.active .ht-point-count,
.ht-point:hover .ht-point-count {
color: var(--accent);
}
/* Achsen-Labels (unter der Linie) */
.ht-axis-labels {
position: absolute;
left: 4%;
right: 4%;
top: 72px;
height: 20px;
}
.ht-axis--hourly .ht-axis-labels {
top: 90px;
}
.ht-axis-label {
position: absolute;
transform: translateX(-50%);
font-size: 10px;
font-family: var(--font-mono);
color: var(--text-tertiary); color: var(--text-tertiary);
white-space: nowrap; font-family: var(--font-mono);
margin-top: 4px;
} }
/* Leerer Zustand */ /* Verbindungs-Strang zwischen Karten */
.ht-link {
flex: 0 1 auto;
min-width: 80px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 8px;
cursor: pointer;
outline: none;
color: var(--text-secondary);
transition: color 0.15s ease;
}
.ht-link:hover { color: var(--accent); }
.ht-link:focus-visible { color: var(--accent); }
.ht-link-line {
flex: 1;
height: 2px;
background: var(--border);
border-radius: 1px;
min-width: 12px;
transition: background 0.15s ease;
}
.ht-link:hover .ht-link-line { background: var(--accent); }
.ht-link-count {
font-size: 11px;
font-family: var(--font-mono);
font-weight: 600;
white-space: nowrap;
background: var(--bg-card);
padding: 2px 8px;
border-radius: 10px;
border: 1px solid var(--border);
transition: border-color 0.15s ease;
}
.ht-link:hover .ht-link-count { border-color: var(--accent); color: var(--accent); }
.ht-link--quiet .ht-link-line { background: var(--border); opacity: 0.5; }
/* "Aktuell"-Marker am Ende */
.ht-now {
flex: 0 0 auto;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 6px;
padding: 0 12px;
min-width: 80px;
}
.ht-now-pulse {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 0 var(--accent);
animation: ht-pulse 2s ease-out infinite;
}
@keyframes ht-pulse {
0% { box-shadow: 0 0 0 0 rgba(150, 121, 26, 0.4); }
70% { box-shadow: 0 0 0 12px rgba(150, 121, 26, 0); }
100% { box-shadow: 0 0 0 0 rgba(150, 121, 26, 0); }
}
.ht-now-label {
font-size: 10px;
font-family: var(--font-mono);
font-weight: 700;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Stream / Empty */
.ht-stream {
margin-top: 8px;
}
.ht-empty { .ht-empty {
padding: 20px; padding: 20px;
text-align: center; text-align: center;
@@ -2664,20 +2666,18 @@ a.dev-source-pill:hover {
color: var(--text-tertiary); color: var(--text-tertiary);
} }
/* Detail-Panel */ /* Detail-Panel (Zeitfenster oder Snapshot) */
.ht-detail-panel { .ht-detail {
margin-top: 8px; margin-top: 4px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--bg-secondary); background: var(--bg-secondary);
animation: ht-slide-down 0.2s ease; animation: ht-slide-down 0.2s ease;
} }
@keyframes ht-slide-down { @keyframes ht-slide-down {
from { opacity: 0; transform: translateY(-8px); } from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
.ht-detail-header { .ht-detail-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -2685,14 +2685,12 @@ a.dev-source-pill:hover {
padding: 8px 12px; padding: 8px 12px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.ht-detail-title { .ht-detail-title {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--accent); color: var(--accent);
font-family: var(--font-mono); font-family: var(--font-mono);
} }
.ht-detail-close { .ht-detail-close {
background: none; background: none;
border: none; border: none;
@@ -2702,21 +2700,46 @@ a.dev-source-pill:hover {
padding: 0 4px; padding: 0 4px;
line-height: 1; line-height: 1;
} }
.ht-detail-close:hover { color: var(--text-primary); }
.ht-detail-close:hover { .ht-detail-body {
color: var(--text-primary); max-height: 480px;
}
.ht-detail-content {
max-height: 350px;
overflow-y: auto; overflow-y: auto;
padding: 4px 12px; padding: 8px 14px;
}
.ht-detail-body::-webkit-scrollbar { width: 6px; }
.ht-detail-body::-webkit-scrollbar-track { background: var(--bg-primary); border-radius: 3px; }
.ht-detail-body::-webkit-scrollbar-thumb { background: var(--text-disabled); border-radius: 3px; }
.ht-detail-body::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }
/* Mobile: Kette vertikal stapeln, Strip bleibt horizontal */
@media (max-width: 900px) {
.ht-chain {
flex-direction: column;
align-items: stretch;
min-width: auto;
}
.ht-card { flex: 0 0 auto; width: 100%; }
.ht-link {
min-width: auto;
width: 100%;
flex-direction: column;
gap: 4px;
padding: 8px 0;
}
.ht-link-line {
width: 2px;
height: 14px;
flex: 0 0 auto;
}
.ht-now { width: 100%; }
} }
.ht-detail-content::-webkit-scrollbar { width: 6px; } /* Reduced Motion */
.ht-detail-content::-webkit-scrollbar-track { background: var(--bg-primary); border-radius: 3px; } @media (prefers-reduced-motion: reduce) {
.ht-detail-content::-webkit-scrollbar-thumb { background: var(--text-disabled); border-radius: 3px; } .ht-now-pulse { animation: none; }
.ht-detail-content::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); } .ht-detail { animation: none; }
.ht-card { transition: none; }
}
/* === Briefing Listen === */ /* === Briefing Listen === */
.briefing-content ul { .briefing-content ul {

Datei anzeigen

@@ -13,7 +13,7 @@
<link rel="stylesheet" href="/static/vendor/leaflet.css"> <link rel="stylesheet" href="/static/vendor/leaflet.css">
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css"> <link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css"> <link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css">
<link rel="stylesheet" href="/static/css/style.css?v=20260316k"> <link rel="stylesheet" href="/static/css/style.css?v=20260501b">
<style> <style>
/* Export Modal Radio */ /* Export Modal Radio */
.export-radio { display:flex; align-items:center; gap:10px; padding:8px 12px; cursor:pointer; border-radius:var(--radius-sm); transition:background 0.15s; border:1px solid transparent; margin-bottom:4px; } .export-radio { display:flex; align-items:center; gap:10px; padding:8px 12px; cursor:pointer; border-radius:var(--radius-sm); transition:background 0.15s; border:1px solid transparent; margin-bottom:4px; }
@@ -647,7 +647,7 @@
<script src="/static/js/components.js?v=20260427a"></script> <script src="/static/js/components.js?v=20260427a"></script>
<script src="/static/js/layout.js?v=20260316b"></script> <script src="/static/js/layout.js?v=20260316b"></script>
<script src="/static/js/pipeline.js?v=20260501a"></script> <script src="/static/js/pipeline.js?v=20260501a"></script>
<script src="/static/js/app.js?v=20260427c"></script> <script src="/static/js/app.js?v=20260501b"></script>
<script src="/static/js/cluster-data.js?v=20260322f"></script> <script src="/static/js/cluster-data.js?v=20260322f"></script>
<script src="/static/js/tutorial.js?v=20260316z"></script> <script src="/static/js/tutorial.js?v=20260316z"></script>
<script src="/static/js/chat.js?v=20260422a"></script> <script src="/static/js/chat.js?v=20260422a"></script>

Datei anzeigen

@@ -433,7 +433,7 @@ const App = {
_editingSourceId: null, _editingSourceId: null,
_timelineFilter: 'all', _timelineFilter: 'all',
_timelineRange: 'all', _timelineRange: 'all',
_activePointIndex: null, _activeTimelineDetail: null,
_timelineSearchTimer: null, _timelineSearchTimer: null,
_pendingComplete: null, _pendingComplete: null,
_pendingCompleteTimer: null, _pendingCompleteTimer: null,
@@ -1038,7 +1038,7 @@ const App = {
} }
this._timelineFilter = 'all'; this._timelineFilter = 'all';
this._timelineRange = 'all'; this._timelineRange = 'all';
this._activePointIndex = null; this._activeTimelineDetail = null;
const _tsEl = document.getElementById('timeline-search'); if (_tsEl) _tsEl.value = ''; const _tsEl = document.getElementById('timeline-search'); if (_tsEl) _tsEl.value = '';
document.querySelectorAll('.ht-filter-btn').forEach(btn => { document.querySelectorAll('.ht-filter-btn').forEach(btn => {
const isActive = btn.dataset.filter === 'all'; const isActive = btn.dataset.filter === 'all';
@@ -1114,6 +1114,12 @@ const App = {
this._timelineSearchTimer = setTimeout(() => this.rerenderTimeline(), 250); this._timelineSearchTimer = setTimeout(() => this.rerenderTimeline(), 250);
}, },
/** Snapshot-zentrierte Timeline:
* - Quanti-Strip oben (Heatmap-Reihe ueber Tage/Stunden)
* - Lagebericht-Karten als horizontale Stationen
* - Verbindungs-Straenge mit "X Meldungen" zwischen den Karten
* - Detail-Panel unten je nach Klick (Snapshot oder Zeitfenster-Liste)
*/
rerenderTimeline() { rerenderTimeline() {
const container = document.getElementById('timeline'); const container = document.getElementById('timeline');
if (!container) return; if (!container) return;
@@ -1121,11 +1127,12 @@ const App = {
const filterType = this._timelineFilter; const filterType = this._timelineFilter;
const range = this._timelineRange; const range = this._timelineRange;
// Filter-spezifische Eintraege fuer die Hauptansicht
let entries = this._collectEntries(filterType, searchTerm, range); let entries = this._collectEntries(filterType, searchTerm, range);
this._updateTimelineCount(entries); this._updateTimelineCount(entries);
if (entries.length === 0) { if (entries.length === 0) {
this._activePointIndex = null; this._activeTimelineDetail = null;
container.innerHTML = (searchTerm || range !== 'all') container.innerHTML = (searchTerm || range !== 'all')
? '<div class="ht-empty">Keine Einträge im gewählten Zeitraum.</div>' ? '<div class="ht-empty">Keine Einträge im gewählten Zeitraum.</div>'
: '<div class="ht-empty">Noch keine Meldungen. Starte eine Recherche mit "Aktualisieren".</div>'; : '<div class="ht-empty">Noch keine Meldungen. Starte eine Recherche mit "Aktualisieren".</div>';
@@ -1134,261 +1141,356 @@ const App = {
entries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0)); entries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
const granularity = this._calcGranularity(entries, range); // Quanti-Strip nutzt IMMER alle Eintraege im aktuellen Range (unabh. vom Filter),
let buckets = this._buildBuckets(entries, granularity); // damit Klick auf ein Quadrat die echte Aktivitaet zeigt.
buckets = this._mergeCloseBuckets(buckets); const stripEntries = this._collectEntries('all', '', range);
stripEntries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
// Aktiven Index validieren const articles = entries.filter(e => e.kind === 'article');
if (this._activePointIndex !== null && this._activePointIndex >= buckets.length) { const snapshots = entries.filter(e => e.kind === 'snapshot');
this._activePointIndex = null;
let html = '<div class="ht-tl">';
// 1) Quanti-Strip (entfaellt nur bei "Lageberichte"-Filter)
if (filterType !== 'snapshots' && stripEntries.length > 0) {
html += this._renderTimelineStrip(stripEntries);
} }
// Achsen-Bereich // 2) Hauptansicht je nach Filter
const rangeStart = buckets[0].timestamp; if (filterType === 'snapshots') {
const rangeEnd = buckets[buckets.length - 1].timestamp; html += this._renderSnapshotsOnly(snapshots);
const maxCount = Math.max(...buckets.map(b => b.entries.length)); } else if (filterType === 'articles') {
html += this._renderTimelineStream(articles);
// Stunden- vs. Tages-Granularität } else {
const isHourly = granularity === 'hour'; // 'all'
const axisLabels = this._buildAxisLabels(buckets, granularity, true); if (snapshots.length === 0) {
html += '<div class="ht-tl-hint">Noch kein Lagebericht. Aktiviere einen Refresh, um den ersten zu erstellen.</div>';
// HTML aufbauen html += this._renderTimelineStream(articles);
let html = `<div class="ht-axis${isHourly ? ' ht-axis--hourly' : ''}">`; } else {
html += this._renderTimelineChain(snapshots, articles);
// Datums-Marker (immer anzeigen, ausgedünnt)
const dayMarkers = this._thinLabels(this._buildDayMarkers(buckets, rangeStart, rangeEnd), 10);
html += '<div class="ht-day-markers">';
dayMarkers.forEach(m => {
html += `<div class="ht-day-marker" style="left:${m.pos}%;">`;
html += `<div class="ht-day-marker-label">${UI.escape(m.text)}</div>`;
html += `<div class="ht-day-marker-line"></div>`;
html += `</div>`;
});
html += '</div>';
// Punkte
html += '<div class="ht-points">';
buckets.forEach((bucket, idx) => {
const pos = this._bucketPositionPercent(bucket, rangeStart, rangeEnd, buckets.length);
const size = this._calcPointSize(bucket.entries.length, maxCount);
const hasSnapshots = bucket.entries.some(e => e.kind === 'snapshot');
const hasArticles = bucket.entries.some(e => e.kind === 'article');
let pointClass = 'ht-point';
if (filterType === 'snapshots') {
pointClass += ' ht-snapshot-point';
} else if (hasSnapshots) {
pointClass += ' ht-mixed-point';
} }
if (this._activePointIndex === idx) pointClass += ' active';
const tooltip = `${bucket.label}: ${bucket.entries.length} Eintr${bucket.entries.length === 1 ? 'ag' : 'äge'}`;
html += `<div class="${pointClass}" style="left:${pos}%;width:${size}px;height:${size}px;" onclick="App.openTimelineDetail(${idx})" data-idx="${idx}">`;
html += `<div class="ht-tooltip">${UI.escape(tooltip)}</div>`;
html += `</div>`;
});
html += '</div>';
// Achsenlinie
html += '<div class="ht-axis-line"></div>';
// Achsen-Labels (ausgedünnt um Überlappung zu vermeiden)
const thinned = this._thinLabels(axisLabels);
html += '<div class="ht-axis-labels">';
thinned.forEach(lbl => {
html += `<div class="ht-axis-label" style="left:${lbl.pos}%;">${UI.escape(lbl.text)}</div>`;
});
html += '</div>';
html += '</div>';
// Detail-Panel (wenn ein Punkt aktiv ist)
if (this._activePointIndex !== null && this._activePointIndex < buckets.length) {
html += this._renderDetailPanel(buckets[this._activePointIndex]);
} }
// 3) Detail-Panel falls aktiv
if (this._activeTimelineDetail) {
html += this._renderTimelineDetail(this._activeTimelineDetail);
}
html += '</div>';
container.innerHTML = html; container.innerHTML = html;
}, },
_calcGranularity(entries, range) { /* ======= Quanti-Strip ======= */
if (entries.length < 2) return 'day'; _stripGranularity(stripEntries) {
const timestamps = entries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0); if (stripEntries.length < 2) return 'day';
if (timestamps.length < 2) return 'day'; const ts = stripEntries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
const span = Math.max(...timestamps) - Math.min(...timestamps); if (ts.length < 2) return 'day';
if (range === '24h' || span <= 48 * 60 * 60 * 1000) return 'hour'; const span = Math.max(...ts) - Math.min(...ts);
return 'day'; const DAY = 86400000;
if (span <= 2 * DAY) return 'hour';
if (span <= 60 * DAY) return 'day';
if (span <= 365 * DAY) return 'week';
return 'month';
}, },
_buildBuckets(entries, granularity) { _buildStripBuckets(stripEntries, granularity) {
const bucketMap = {}; if (stripEntries.length === 0) return [];
entries.forEach(e => { const ts = stripEntries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
const d = new Date(e.timestamp || 0); if (ts.length === 0) return [];
const b = _tz(d); const minTs = Math.min(...ts);
let key, label, ts; const maxTs = Math.max(...ts);
if (granularity === 'hour') {
key = `${b.year}-${b.month + 1}-${b.date}-${b.hours}`;
label = d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE }) + ', ' + b.hours.toString().padStart(2, '0') + ':00';
ts = new Date(b.year, b.month, b.date, b.hours).getTime();
} else {
key = `${b.year}-${b.month + 1}-${b.date}`;
label = d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', timeZone: TIMEZONE });
ts = new Date(b.year, b.month, b.date, 12).getTime();
}
if (!bucketMap[key]) {
bucketMap[key] = { key, label, timestamp: ts, entries: [] };
}
bucketMap[key].entries.push(e);
});
return Object.values(bucketMap).sort((a, b) => a.timestamp - b.timestamp);
},
_mergeCloseBuckets(buckets) { // Bucket-Start fuer minTs ermitteln
if (buckets.length < 2) return buckets; const minDate = new Date(minTs);
const rangeStart = buckets[0].timestamp; const tzMin = _tz(minDate);
const rangeEnd = buckets[buckets.length - 1].timestamp; let firstStart;
if (rangeEnd <= rangeStart) return buckets; let stepMs;
if (granularity === 'hour') {
const container = document.getElementById('timeline'); firstStart = new Date(tzMin.year, tzMin.month, tzMin.date, tzMin.hours).getTime();
const axisWidth = (container ? container.offsetWidth : 800) * 0.92; stepMs = 3600000;
const maxCount = Math.max(...buckets.map(b => b.entries.length)); } else if (granularity === 'day') {
const result = [buckets[0]]; firstStart = new Date(tzMin.year, tzMin.month, tzMin.date).getTime();
stepMs = 86400000;
for (let i = 1; i < buckets.length; i++) { } else if (granularity === 'week') {
const prev = result[result.length - 1]; const dow = (minDate.getDay() + 6) % 7; // 0=Mo
const curr = buckets[i]; firstStart = new Date(tzMin.year, tzMin.month, tzMin.date - dow).getTime();
stepMs = 7 * 86400000;
const distPx = ((curr.timestamp - prev.timestamp) / (rangeEnd - rangeStart)) * axisWidth; } else {
const prevSize = Math.min(32, this._calcPointSize(prev.entries.length, maxCount)); firstStart = new Date(tzMin.year, tzMin.month, 1).getTime();
const currSize = Math.min(32, this._calcPointSize(curr.entries.length, maxCount)); stepMs = null; // dynamisch (Monatsgrenzen)
const minDistPx = (prevSize + currSize) / 2 + 6;
if (distPx < minDistPx) {
prev.entries = prev.entries.concat(curr.entries);
} else {
result.push(curr);
}
} }
return result;
},
_bucketPositionPercent(bucket, rangeStart, rangeEnd, totalBuckets) { const buckets = [];
if (totalBuckets === 1) return 50; const fmt = (t) => {
if (rangeEnd === rangeStart) return 50; const d = new Date(t);
return ((bucket.timestamp - rangeStart) / (rangeEnd - rangeStart)) * 100; if (granularity === 'hour') return d.toLocaleString('de-DE', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
}, if (granularity === 'day') return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', timeZone: TIMEZONE });
if (granularity === 'week') return 'Woche ab ' + d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
_calcPointSize(count, maxCount) { return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric', timeZone: TIMEZONE });
if (maxCount <= 1) return 16;
const minSize = 12;
const maxSize = 32;
const logScale = Math.log(count + 1) / Math.log(maxCount + 1);
return Math.round(minSize + logScale * (maxSize - minSize));
},
_buildAxisLabels(buckets, granularity, timeOnly) {
if (buckets.length === 0) return [];
const maxLabels = 8;
const labels = [];
const rangeStart = buckets[0].timestamp;
const rangeEnd = buckets[buckets.length - 1].timestamp;
const getLabelText = (b) => {
if (timeOnly) {
// Bei Tages-Granularität: Uhrzeit des ersten Eintrags nehmen
const ts = (granularity === 'day' && b.entries && b.entries.length > 0)
? new Date(b.entries[0].timestamp || b.timestamp)
: new Date(b.timestamp);
const tp = _tz(ts);
return tp.hours.toString().padStart(2, '0') + ':' + tp.minutes.toString().padStart(2, '0');
}
return b.label;
}; };
if (buckets.length <= maxLabels) { if (granularity === 'month') {
buckets.forEach(b => { let d = new Date(firstStart);
labels.push({ text: getLabelText(b), pos: this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length) }); while (d.getTime() <= maxTs && buckets.length < 240) {
}); const start = d.getTime();
const next = new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime();
buckets.push({ start, end: next, label: fmt(start), articles: 0, snapshots: 0 });
d = new Date(next);
}
} else { } else {
const step = (buckets.length - 1) / (maxLabels - 1); for (let t = firstStart; t <= maxTs && buckets.length < 240; t += stepMs) {
for (let i = 0; i < maxLabels; i++) { buckets.push({ start: t, end: t + stepMs, label: fmt(t), articles: 0, snapshots: 0 });
const idx = Math.round(i * step);
const b = buckets[idx];
labels.push({ text: getLabelText(b), pos: this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length) });
} }
} }
return labels;
},
_thinLabels(labels, minGapPercent) { // Eintraege zaehlen
if (!labels || labels.length <= 1) return labels; stripEntries.forEach(e => {
const gap = minGapPercent || 8; const ets = new Date(e.timestamp || 0).getTime();
const result = [labels[0]]; // Linear-Suche, da Buckets sortiert; bei vielen Buckets ggf. Binary
for (let i = 1; i < labels.length; i++) { for (let i = 0; i < buckets.length; i++) {
if (labels[i].pos - result[result.length - 1].pos >= gap) { if (ets >= buckets[i].start && ets < buckets[i].end) {
result.push(labels[i]); if (e.kind === 'article') buckets[i].articles++;
} else if (e.kind === 'snapshot') buckets[i].snapshots++;
} break;
return result;
},
_buildDayMarkers(buckets, rangeStart, rangeEnd) {
const seen = {};
const markers = [];
buckets.forEach(b => {
const d = new Date(b.timestamp);
const bp = _tz(d);
const dayKey = `${bp.year}-${bp.month}-${bp.date}`;
if (!seen[dayKey]) {
seen[dayKey] = true;
const np = _tz(new Date());
const todayKey = `${np.year}-${np.month}-${np.date}`;
const yp = _tz(new Date(Date.now() - 86400000));
const yesterdayKey = `${yp.year}-${yp.month}-${yp.date}`;
let label;
const dateStr = d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
if (dayKey === todayKey) {
label = 'Heute, ' + dateStr;
} else if (dayKey === yesterdayKey) {
label = 'Gestern, ' + dateStr;
} else {
label = d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', timeZone: TIMEZONE });
} }
const pos = this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length);
markers.push({ text: label, pos });
} }
}); });
return markers;
return buckets;
}, },
_renderDetailPanel(bucket) { _renderTimelineStrip(stripEntries) {
const type = this._currentIncidentType; const granularity = this._stripGranularity(stripEntries);
const sorted = [...bucket.entries].sort((a, b) => { const buckets = this._buildStripBuckets(stripEntries, granularity);
if (a.kind === 'snapshot' && b.kind !== 'snapshot') return -1; if (buckets.length === 0) return '';
if (a.kind !== 'snapshot' && b.kind === 'snapshot') return 1;
return new Date(b.timestamp || 0) - new Date(a.timestamp || 0);
});
let entriesHtml = ''; const maxCount = Math.max(1, ...buckets.map(b => b.articles));
sorted.forEach(e => {
if (e.kind === 'snapshot') { let html = '<div class="ht-strip">';
entriesHtml += this._renderSnapshotEntry(e.data); html += '<div class="ht-strip-cells">';
} else { buckets.forEach(b => {
entriesHtml += this._renderArticleEntry(e.data, type, 0); const intensity = b.articles > 0 ? Math.min(1, b.articles / maxCount) : 0;
const cls = ['ht-strip-cell'];
if (b.snapshots > 0) cls.push('has-snapshot');
if (b.articles === 0 && b.snapshots === 0) cls.push('empty');
const tip = `${b.label}: ${b.articles} Meldung${b.articles === 1 ? '' : 'en'}` +
(b.snapshots > 0 ? ` + ${b.snapshots} Lagebericht${b.snapshots === 1 ? '' : 'e'}` : '');
html += `<div class="${cls.join(' ')}" style="--intensity:${intensity.toFixed(3)};" title="${UI.escape(tip)}" onclick="App.openTimelineWindow(${b.start}, ${b.end}, ${UI.escape(JSON.stringify(b.label))})"></div>`;
});
html += '</div>';
// Wenige Datums-Labels unter dem Strip
const labelCount = Math.min(buckets.length, 6);
const stride = Math.max(1, Math.floor(buckets.length / labelCount));
const labelTexts = [];
for (let i = 0; i < buckets.length; i += stride) {
const b = buckets[i];
const d = new Date(b.start);
let txt;
if (granularity === 'hour') txt = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE });
else if (granularity === 'day') txt = d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
else if (granularity === 'week') txt = 'KW ' + d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE });
else txt = d.toLocaleDateString('de-DE', { month: 'short', year: '2-digit', timeZone: TIMEZONE });
labelTexts.push({ text: txt, idx: i });
}
if (labelTexts.length) {
html += '<div class="ht-strip-labels" style="grid-template-columns: repeat(' + buckets.length + ', 1fr);">';
const seen = new Set(labelTexts.map(l => l.idx));
for (let i = 0; i < buckets.length; i++) {
if (seen.has(i)) {
const t = labelTexts.find(l => l.idx === i).text;
html += `<div class="ht-strip-label">${UI.escape(t)}</div>`;
} else {
html += '<div></div>';
}
}
html += '</div>';
}
html += '</div>';
return html;
},
/* ======= Lagebericht-Kette (Snapshots als Stationen) ======= */
_renderTimelineChain(snapshots, articles) {
// Aelteste links, neueste rechts
snapshots.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
articles.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
let html = '<div class="ht-chain-scroll"><div class="ht-chain">';
const firstSnapTs = new Date(snapshots[0].timestamp).getTime();
const lastSnapTs = new Date(snapshots[snapshots.length - 1].timestamp).getTime();
// Vor-Strang (Meldungen vor erstem Lagebericht)
const before = articles.filter(a => new Date(a.timestamp).getTime() < firstSnapTs);
if (before.length > 0) {
const earliestArt = new Date(before[0].timestamp).getTime();
html += this._renderChainLink(earliestArt, firstSnapTs, before.length, 'Vor dem ersten Lagebericht');
}
// Lagebericht-Karten + Straenge dazwischen
snapshots.forEach((snap, i) => {
html += this._renderChainCard(snap);
if (i < snapshots.length - 1) {
const ts1 = new Date(snap.timestamp).getTime();
const ts2 = new Date(snapshots[i + 1].timestamp).getTime();
const between = articles.filter(a => {
const t = new Date(a.timestamp).getTime();
return t >= ts1 && t < ts2;
});
html += this._renderChainLink(ts1, ts2, between.length, 'Zwischen den Lageberichten');
} }
}); });
return `<div class="ht-detail-panel"> // Nach-Strang + "Aktuell"-Marker
<div class="ht-detail-header"> const after = articles.filter(a => new Date(a.timestamp).getTime() > lastSnapTs);
<span class="ht-detail-title">${UI.escape(bucket.label)} (${bucket.entries.length} Eintr${bucket.entries.length === 1 ? 'ag' : 'äge'})</span> const nowMs = Date.now();
<button class="ht-detail-close" onclick="App.closeTimelineDetail()">&times;</button> if (after.length > 0) {
html += this._renderChainLink(lastSnapTs, nowMs, after.length, 'Seit dem letzten Lagebericht');
}
html += this._renderChainNow();
html += '</div></div>';
return html;
},
_renderChainCard(snap) {
const data = snap.data || {};
const dateStr = new Date(snap.timestamp).toLocaleString('de-DE', {
day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE,
});
let preview = (data.summary_preview || '').replace(/\s+/g, ' ').trim();
if (preview.length > 140) preview = preview.slice(0, 138) + '…';
const isActive = this._activeTimelineDetail && this._activeTimelineDetail.type === 'snapshot' && this._activeTimelineDetail.id === data.id;
return `<div class="ht-card ${isActive ? 'active' : ''}" tabindex="0" onclick="App.openSnapshotInline(${data.id})" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();App.openSnapshotInline(${data.id});}">
<div class="ht-card-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/><path d="M8 13h8M8 17h6"/></svg>
</div> </div>
<div class="ht-detail-content">${entriesHtml}</div> <div class="ht-card-date">${UI.escape(dateStr)}</div>
<div class="ht-card-preview">${UI.escape(preview || 'Lagebericht')}</div>
<div class="ht-card-meta">${data.article_count || 0} Meldungen · ${data.fact_check_count || 0} Fakten</div>
</div>`;
},
_renderChainLink(startMs, endMs, count, label) {
const text = count === 0 ? 'keine Meldungen' : `${count} Meldung${count === 1 ? '' : 'en'}`;
const labelArg = JSON.stringify(label);
return `<div class="ht-link" tabindex="0" onclick="App.openTimelineWindow(${startMs}, ${endMs}, ${UI.escape(labelArg)})" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();App.openTimelineWindow(${startMs}, ${endMs}, ${UI.escape(labelArg)});}">
<div class="ht-link-line"></div>
<div class="ht-link-count">${UI.escape(text)}</div>
<div class="ht-link-line"></div>
</div>`;
},
_renderChainNow() {
return `<div class="ht-now" aria-label="Aktueller Stand">
<div class="ht-now-pulse"></div>
<div class="ht-now-label">aktuell</div>
</div>`;
},
/* ======= Stream (Fallback / Filter "Meldungen") ======= */
_renderTimelineStream(articles) {
if (articles.length === 0) {
return '<div class="ht-empty">Keine Meldungen im gewählten Zeitraum.</div>';
}
// Reuse: existierender vertikaler Verlauf
return '<div class="ht-stream">' + this._buildFullVerticalTimeline('articles', '') + '</div>';
},
_renderSnapshotsOnly(snapshots) {
if (snapshots.length === 0) {
return '<div class="ht-empty">Noch keine Lageberichte vorhanden.</div>';
}
snapshots.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
let html = '<div class="ht-chain-scroll"><div class="ht-chain ht-chain--snapshots-only">';
snapshots.forEach((snap, i) => {
html += this._renderChainCard(snap);
if (i < snapshots.length - 1) {
html += '<div class="ht-link ht-link--quiet"><div class="ht-link-line"></div></div>';
}
});
html += this._renderChainNow();
html += '</div></div>';
return html;
},
/* ======= Detail-Panel ======= */
_renderTimelineDetail(detail) {
if (detail.type === 'snapshot') {
return this._renderSnapshotDetailPanel(detail.id);
}
return this._renderWindowDetailPanel(detail);
},
_renderSnapshotDetailPanel(snapshotId) {
const cached = this._snapshotFullCache && this._snapshotFullCache.get(snapshotId);
const meta = (this._currentSnapshots || []).find(s => s.id === snapshotId);
if (!meta) return '';
const dateStr = new Date(meta.created_at).toLocaleString('de-DE', {
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: TIMEZONE,
});
const title = `Lagebericht vom ${dateStr}`;
let body;
if (cached && cached.summary) {
const safeSummary = (typeof window.marked !== 'undefined' && marked.parse)
? marked.parse(cached.summary)
: '<pre>' + UI.escape(cached.summary) + '</pre>';
body = `<div class="ht-detail-body summary-text">${safeSummary}</div>`;
} else {
body = '<div class="ht-detail-body"><em>Lade Lagebericht...</em></div>';
// Lazy-Load anstossen
if (this._currentIncidentId && API.getSnapshot) {
API.getSnapshot(this._currentIncidentId, snapshotId).then(full => {
if (!this._snapshotFullCache) this._snapshotFullCache = new Map();
this._snapshotFullCache.set(snapshotId, full);
if (this._activeTimelineDetail && this._activeTimelineDetail.type === 'snapshot' && this._activeTimelineDetail.id === snapshotId) {
this.rerenderTimeline();
}
}).catch(err => console.warn('snapshot-detail:', err));
}
}
return `<div class="ht-detail">
<div class="ht-detail-header">
<span class="ht-detail-title">${UI.escape(title)}</span>
<button class="ht-detail-close" onclick="App.closeTimelineDetail()" aria-label="Schließen">&times;</button>
</div>
${body}
</div>`;
},
_renderWindowDetailPanel(detail) {
const start = detail.start;
const end = detail.end;
const label = detail.label || 'Zeitfenster';
const type = this._currentIncidentType;
const list = (this._currentArticles || []).filter(a => {
const ts = new Date((type === 'research' && a.published_at) ? a.published_at : a.collected_at).getTime();
return ts >= start && ts < end;
}).sort((a, b) => {
const ta = new Date((type === 'research' && a.published_at) ? a.published_at : a.collected_at).getTime();
const tb = new Date((type === 'research' && b.published_at) ? b.published_at : b.collected_at).getTime();
return tb - ta;
});
let body;
if (list.length === 0) {
body = '<div class="ht-empty">Keine Meldungen in diesem Zeitfenster.</div>';
} else {
body = list.map(a => this._renderArticleEntry(a, type, 0)).join('');
}
const titleText = `${label} · ${list.length} Meldung${list.length === 1 ? '' : 'en'}`;
return `<div class="ht-detail">
<div class="ht-detail-header">
<span class="ht-detail-title">${UI.escape(titleText)}</span>
<button class="ht-detail-close" onclick="App.closeTimelineDetail()" aria-label="Schließen">&times;</button>
</div>
<div class="ht-detail-body">${body}</div>
</div>`; </div>`;
}, },
setTimelineFilter(filter) { setTimelineFilter(filter) {
this._timelineFilter = filter; this._timelineFilter = filter;
this._activePointIndex = null; this._activeTimelineDetail = null;
document.querySelectorAll('.ht-filter-btn').forEach(btn => { document.querySelectorAll('.ht-filter-btn').forEach(btn => {
const isActive = btn.dataset.filter === filter; const isActive = btn.dataset.filter === filter;
btn.classList.toggle('active', isActive); btn.classList.toggle('active', isActive);
@@ -1399,7 +1501,7 @@ const App = {
setTimelineRange(range) { setTimelineRange(range) {
this._timelineRange = range; this._timelineRange = range;
this._activePointIndex = null; this._activeTimelineDetail = null;
document.querySelectorAll('.ht-range-btn').forEach(btn => { document.querySelectorAll('.ht-range-btn').forEach(btn => {
const isActive = btn.dataset.range === range; const isActive = btn.dataset.range === range;
btn.classList.toggle('active', isActive); btn.classList.toggle('active', isActive);
@@ -1408,18 +1510,21 @@ const App = {
this.rerenderTimeline(); this.rerenderTimeline();
}, },
openTimelineDetail(bucketIndex) { openSnapshotInline(snapshotId) {
if (this._activePointIndex === bucketIndex) { const same = this._activeTimelineDetail && this._activeTimelineDetail.type === 'snapshot' && this._activeTimelineDetail.id === snapshotId;
this._activePointIndex = null; this._activeTimelineDetail = same ? null : { type: 'snapshot', id: snapshotId };
} else { this.rerenderTimeline();
this._activePointIndex = bucketIndex; this._resizeTimelineTile();
} },
openTimelineWindow(startMs, endMs, label) {
this._activeTimelineDetail = { type: 'window', start: startMs, end: endMs, label: label || 'Zeitfenster' };
this.rerenderTimeline(); this.rerenderTimeline();
this._resizeTimelineTile(); this._resizeTimelineTile();
}, },
closeTimelineDetail() { closeTimelineDetail() {
this._activePointIndex = null; this._activeTimelineDetail = null;
this.rerenderTimeline(); this.rerenderTimeline();
this._resizeTimelineTile(); this._resizeTimelineTile();
}, },