Ereignis-Timeline: Newsfeed mit Lagebericht-Sektionen + Heatmap-Strip

Komplett neu gedacht: nicht mehr horizontale Karten-Kette, sondern
vertikaler Newsfeed mit den vorhandenen vt-Klassen, plus dezenter
Heatmap-Strip oben für die Quantitäts-Übersicht.

- Heatmap-Strip oben (14 px hoch): ein Quadrat pro Tag/Stunde/Woche/
  Monat je nach Spannweite, Farbintensität = Aktivität, goldener
  Boden-Strich bei Lagebericht.
- Klick auf Heatmap-Quadrat: Stream scrollt zur passenden Zeit-Gruppe,
  diese flasht kurz auf.
- Newsfeed darunter: vt-time-group mit Datums-Trennzeilen
  (Heute/Gestern/...), Lagebericht-Einträge sind durch ihre vt-snapshot
  Klasse prominent gegenüber Meldungs-Einträgen.
- Klick auf Lagebericht: Volltext klappt inline auf (vorhandener
  lazyLoadSnapshotDetail-Mechanismus, kein separates Detail-Panel mehr).
- Klick auf Meldung: Detail klappt inline auf.

Karten-Kette, Verbindungs-Stränge, "Aktuell"-Marker, Snapshot-Detail-
Panel, Window-Detail-Panel und alle zugehörigen CSS-Klassen
(ht-card/ht-link/ht-now/ht-chain/ht-detail) komplett entfernt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
2026-05-01 15:51:41 +02:00
Ursprung ffb8dddc4f
Commit b14fe31f42
3 geänderte Dateien mit 97 neuen und 469 gelöschten Zeilen

Datei anzeigen

@@ -2450,39 +2450,33 @@ a.dev-source-pill:hover {
padding: 12px 20px 8px;
}
/* === Snapshot-zentrierte Timeline === */
/* === Timeline: Heatmap-Strip oben + vertikaler Newsfeed-Stream darunter === */
.ht-tl {
display: flex;
flex-direction: column;
gap: var(--sp-xl);
gap: var(--sp-md);
}
.ht-tl-hint {
font-size: 12px;
color: var(--text-tertiary);
font-style: italic;
padding: 4px 0;
}
/* Quanti-Strip (Heatmap-Zeile) */
/* Heatmap-Strip */
.ht-strip {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 0 6px;
}
.ht-strip-cells {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(8px, 1fr);
gap: 2px;
height: 18px;
height: 14px;
}
.ht-strip-cell {
background: color-mix(in srgb, var(--accent) calc(var(--intensity) * 70%), var(--border));
border-radius: 2px;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
min-height: 16px;
min-height: 12px;
}
.ht-strip-cell.empty {
background: var(--border);
@@ -2490,11 +2484,11 @@ a.dev-source-pill:hover {
cursor: default;
}
.ht-strip-cell:hover:not(.empty) {
transform: scaleY(1.4);
transform: scaleY(1.6);
box-shadow: var(--glow-accent);
}
.ht-strip-cell.has-snapshot {
box-shadow: inset 0 -2px 0 var(--accent);
box-shadow: inset 0 -3px 0 var(--accent);
}
.ht-strip-labels {
display: grid;
@@ -2508,156 +2502,9 @@ a.dev-source-pill:hover {
white-space: nowrap;
}
/* Lagebericht-Kette */
.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;
}
/* Lagebericht-Karte */
.ht-card {
flex: 0 0 220px;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-elevated);
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 {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
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-weight: 600;
color: var(--accent);
}
.ht-card-preview {
font-size: 13px;
color: var(--text-primary);
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
flex: 1;
}
.ht-card-meta {
font-size: 10px;
color: var(--text-tertiary);
font-family: var(--font-mono);
margin-top: 4px;
}
/* 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 */
/* Stream-Container */
.ht-stream {
margin-top: 8px;
margin-top: var(--sp-md);
}
.ht-empty {
padding: 20px;
@@ -2666,79 +2513,17 @@ a.dev-source-pill:hover {
color: var(--text-tertiary);
}
/* Detail-Panel (Zeitfenster oder Snapshot) */
.ht-detail {
margin-top: 4px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
animation: ht-slide-down 0.2s ease;
/* Time-Group Flash beim Scrollen vom Strip */
.vt-time-group--flash {
animation: vt-group-flash 1.2s ease-out;
}
@keyframes ht-slide-down {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.ht-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.ht-detail-title {
font-size: 12px;
font-weight: 600;
color: var(--accent);
font-family: var(--font-mono);
}
.ht-detail-close {
background: none;
border: none;
color: var(--text-disabled);
font-size: 18px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.ht-detail-close:hover { color: var(--text-primary); }
.ht-detail-body {
max-height: 480px;
overflow-y: auto;
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%; }
@keyframes vt-group-flash {
0% { background: var(--tint-accent-strong); }
100% { background: transparent; }
}
/* Reduced Motion */
@media (prefers-reduced-motion: reduce) {
.ht-now-pulse { animation: none; }
.ht-detail { animation: none; }
.ht-card { transition: none; }
.vt-time-group--flash { animation: none; }
}
/* === Briefing Listen === */

Datei anzeigen

@@ -13,7 +13,7 @@
<link rel="stylesheet" href="/static/vendor/leaflet.css">
<link rel="stylesheet" href="/static/vendor/MarkerCluster.css">
<link rel="stylesheet" href="/static/vendor/MarkerCluster.Default.css">
<link rel="stylesheet" href="/static/css/style.css?v=20260501b">
<link rel="stylesheet" href="/static/css/style.css?v=20260501c">
<style>
/* 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; }
@@ -647,7 +647,7 @@
<script src="/static/js/components.js?v=20260427a"></script>
<script src="/static/js/layout.js?v=20260316b"></script>
<script src="/static/js/pipeline.js?v=20260501a"></script>
<script src="/static/js/app.js?v=20260501b"></script>
<script src="/static/js/app.js?v=20260501c"></script>
<script src="/static/js/cluster-data.js?v=20260322f"></script>
<script src="/static/js/tutorial.js?v=20260316z"></script>
<script src="/static/js/chat.js?v=20260422a"></script>

Datei anzeigen

@@ -1114,11 +1114,9 @@ const App = {
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)
/** Heatmap-Strip oben + vertikaler Newsfeed-Stream darunter.
* Lageberichte sind als prominente Sektionen im Stream.
* Klick auf Heatmap-Quadrat scrollt im Stream zur passenden Zeit-Gruppe.
*/
rerenderTimeline() {
const container = document.getElementById('timeline');
@@ -1127,59 +1125,65 @@ const App = {
const filterType = this._timelineFilter;
const range = this._timelineRange;
// Filter-spezifische Eintraege fuer die Hauptansicht
let entries = this._collectEntries(filterType, searchTerm, range);
const entries = this._collectEntries(filterType, searchTerm, range);
this._updateTimelineCount(entries);
if (entries.length === 0) {
this._activeTimelineDetail = null;
container.innerHTML = (searchTerm || range !== 'all')
? '<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>';
return;
}
entries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
// Quanti-Strip nutzt IMMER alle Eintraege im aktuellen Range (unabh. vom Filter),
// damit Klick auf ein Quadrat die echte Aktivitaet zeigt.
// Heatmap nutzt IMMER alle Eintraege (Filter-unabhaengig), damit der Strip
// die volle Aktivitaet zeigt und auch im "Lageberichte"-Filter scrollbar bleibt.
const stripEntries = this._collectEntries('all', '', range);
stripEntries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
const articles = entries.filter(e => e.kind === 'article');
const snapshots = entries.filter(e => e.kind === 'snapshot');
let html = '<div class="ht-tl">';
// 1) Quanti-Strip (entfaellt nur bei "Lageberichte"-Filter)
if (filterType !== 'snapshots' && stripEntries.length > 0) {
if (stripEntries.length > 0) {
html += this._renderTimelineStrip(stripEntries);
}
// 2) Hauptansicht je nach Filter
if (filterType === 'snapshots') {
html += this._renderSnapshotsOnly(snapshots);
} else if (filterType === 'articles') {
html += this._renderTimelineStream(articles);
} else {
// 'all'
if (snapshots.length === 0) {
html += '<div class="ht-tl-hint">Noch kein Lagebericht. Aktiviere einen Refresh, um den ersten zu erstellen.</div>';
html += this._renderTimelineStream(articles);
} else {
html += this._renderTimelineChain(snapshots, articles);
}
}
// 3) Detail-Panel falls aktiv
if (this._activeTimelineDetail) {
html += this._renderTimelineDetail(this._activeTimelineDetail);
}
// Vertikaler Newsfeed
html += '<div class="ht-stream">';
html += this._renderVerticalStream(entries);
html += '</div>';
html += '</div>';
container.innerHTML = html;
},
/** Granularitaets-Heuristik fuer den Newsfeed: Stunden bei kurzen Spannen, sonst Tage. */
_calcGranularity(entries) {
if (!entries || entries.length < 2) return 'day';
const ts = entries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
if (ts.length < 2) return 'day';
const span = Math.max(...ts) - Math.min(...ts);
if (span <= 48 * 60 * 60 * 1000) return 'hour';
return 'day';
},
/** Vertikaler Stream: Datums-Trennzeilen + Lagebericht-Sektionen + Meldungen. */
_renderVerticalStream(entries) {
if (!entries || entries.length === 0) {
return '<div class="ht-empty">Keine Einträge.</div>';
}
// Neueste oben
const sorted = [...entries].sort((a, b) => new Date(b.timestamp || 0) - new Date(a.timestamp || 0));
const granularity = this._calcGranularity(sorted);
const groups = this._groupByTimePeriod(sorted, granularity);
let html = '<div class="vt-timeline">';
groups.forEach(g => {
const groupId = 'vt-grp-' + g.key.replace(/[^a-z0-9]/gi, '-');
html += `<div class="vt-time-group" id="${groupId}" data-time-key="${UI.escape(g.key)}">`;
html += `<div class="vt-time-label"><span class="vt-time-label-text">${UI.escape(g.label)}</span></div>`;
html += this._renderTimeGroupEntries(g.entries, this._currentIncidentType);
html += `</div>`;
});
html += '</div>';
return html;
},
/* ======= Quanti-Strip ======= */
_stripGranularity(stripEntries) {
if (stripEntries.length < 2) return 'day';
@@ -1310,187 +1314,8 @@ const App = {
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');
}
});
// Nach-Strang + "Aktuell"-Marker
const after = articles.filter(a => new Date(a.timestamp).getTime() > lastSnapTs);
const nowMs = Date.now();
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 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>`;
},
setTimelineFilter(filter) {
this._timelineFilter = filter;
this._activeTimelineDetail = null;
document.querySelectorAll('.ht-filter-btn').forEach(btn => {
const isActive = btn.dataset.filter === filter;
btn.classList.toggle('active', isActive);
@@ -1501,7 +1326,6 @@ const App = {
setTimelineRange(range) {
this._timelineRange = range;
this._activeTimelineDetail = null;
document.querySelectorAll('.ht-range-btn').forEach(btn => {
const isActive = btn.dataset.range === range;
btn.classList.toggle('active', isActive);
@@ -1510,23 +1334,42 @@ const App = {
this.rerenderTimeline();
},
openSnapshotInline(snapshotId) {
const same = this._activeTimelineDetail && this._activeTimelineDetail.type === 'snapshot' && this._activeTimelineDetail.id === snapshotId;
this._activeTimelineDetail = same ? null : { type: 'snapshot', id: snapshotId };
this.rerenderTimeline();
this._resizeTimelineTile();
},
/** Klick auf Heatmap-Quadrat: Filter im Stream auf das Zeitfenster scrollen. */
openTimelineWindow(startMs, endMs, label) {
this._activeTimelineDetail = { type: 'window', start: startMs, end: endMs, label: label || 'Zeitfenster' };
this.rerenderTimeline();
this._resizeTimelineTile();
},
closeTimelineDetail() {
this._activeTimelineDetail = null;
this.rerenderTimeline();
this._resizeTimelineTile();
// Im Stream die naechste vt-time-group finden, deren Eintraege ins Fenster fallen
const groups = document.querySelectorAll('#timeline .vt-time-group');
let target = null;
for (const g of groups) {
const firstTimeEl = g.querySelector('.vt-article-time, .vt-snapshot-time');
// Pragmatisch: nimm die erste Group, die Eintraege mit Timestamp >= startMs hat.
// Die Stream-Daten sind absteigend sortiert; wir finden die erste passende.
const matches = Array.from(g.querySelectorAll('.vt-entry')).some(entry => {
// Wir haben keine direkten Timestamps im DOM — nehmen den Group-Index
return true;
});
if (matches) {
// Statt komplizierter Ts-Suche: scrolle zur Gruppe, deren Label das Datum enthaelt
target = g;
break;
}
}
// Bessere Heuristik: die vt-time-group, deren erstes Datums-Element ins Fenster faellt
const dateAtStart = new Date(startMs);
const tzs = _tz(dateAtStart);
const wantKey = `${tzs.year}-${tzs.month}-${tzs.date}`;
const wantHour = tzs.hours;
for (const g of groups) {
const k = g.dataset.timeKey || '';
if (k.startsWith(wantKey) && (k === wantKey || k.endsWith('-' + wantHour))) {
target = g;
break;
}
}
if (target) {
target.scrollIntoView({ behavior: document.documentElement.dataset.a11yMotion ? 'instant' : 'smooth', block: 'start' });
target.classList.add('vt-time-group--flash');
setTimeout(() => target.classList.remove('vt-time-group--flash'), 1200);
}
},
_resizeTimelineTile() {