Ereignis-Timeline: Snapshot-zentriertes Konzept
Komplette Neufassung der horizontalen Timeline. Lageberichte sind die natürlichen Anker einer OSINT-Lage; Artikel werden um sie herum gruppiert. Aufbau: - Quanti-Strip oben: schmale Heatmap-Reihe (ein Quadrat pro Stunde/Tag/ Woche/Monat je nach Spannweite), Farbintensität = Aktivität. Quadrate mit Lagebericht haben goldene Unterkante. Klick auf Quadrat öffnet Detail-Panel mit allen Meldungen des Zeitfensters. - Lagebericht-Kette darunter: jede Karte zeigt Datum, Vorschau-Text aus dem Snapshot, Anzahl Meldungen + Fakten. Karten sind durch Stränge verbunden, die "X Meldungen"-Pille tragen — Klick auf Strang öffnet Liste der Meldungen zwischen den beiden Lageberichten. - "Aktuell"-Marker am rechten Ende mit pulsierendem Pin. Filter: - Alle: Strip + Kette - Meldungen: Strip + vertikaler Stream - Lageberichte: nur Karten ohne Strip/Stränge Edge-Case: Lagen ohne Lagebericht zeigen Strip + Stream als Fallback. Mobile (<900px): Kette stapelt vertikal, Strip bleibt horizontal. Alte Bar-Achse, Punkte, Bucket-Merge, Day-Markers etc. komplett entfernt — die alte Achse war für sporadische OSINT-Aktivität das falsche Pattern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -433,7 +433,7 @@ const App = {
|
||||
_editingSourceId: null,
|
||||
_timelineFilter: 'all',
|
||||
_timelineRange: 'all',
|
||||
_activePointIndex: null,
|
||||
_activeTimelineDetail: null,
|
||||
_timelineSearchTimer: null,
|
||||
_pendingComplete: null,
|
||||
_pendingCompleteTimer: null,
|
||||
@@ -1038,7 +1038,7 @@ const App = {
|
||||
}
|
||||
this._timelineFilter = 'all';
|
||||
this._timelineRange = 'all';
|
||||
this._activePointIndex = null;
|
||||
this._activeTimelineDetail = null;
|
||||
const _tsEl = document.getElementById('timeline-search'); if (_tsEl) _tsEl.value = '';
|
||||
document.querySelectorAll('.ht-filter-btn').forEach(btn => {
|
||||
const isActive = btn.dataset.filter === 'all';
|
||||
@@ -1114,6 +1114,12 @@ 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)
|
||||
*/
|
||||
rerenderTimeline() {
|
||||
const container = document.getElementById('timeline');
|
||||
if (!container) return;
|
||||
@@ -1121,11 +1127,12 @@ const App = {
|
||||
const filterType = this._timelineFilter;
|
||||
const range = this._timelineRange;
|
||||
|
||||
// Filter-spezifische Eintraege fuer die Hauptansicht
|
||||
let entries = this._collectEntries(filterType, searchTerm, range);
|
||||
this._updateTimelineCount(entries);
|
||||
|
||||
if (entries.length === 0) {
|
||||
this._activePointIndex = null;
|
||||
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>';
|
||||
@@ -1134,261 +1141,356 @@ const App = {
|
||||
|
||||
entries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
|
||||
|
||||
const granularity = this._calcGranularity(entries, range);
|
||||
let buckets = this._buildBuckets(entries, granularity);
|
||||
buckets = this._mergeCloseBuckets(buckets);
|
||||
// Quanti-Strip nutzt IMMER alle Eintraege im aktuellen Range (unabh. vom Filter),
|
||||
// damit Klick auf ein Quadrat die echte Aktivitaet zeigt.
|
||||
const stripEntries = this._collectEntries('all', '', range);
|
||||
stripEntries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
|
||||
|
||||
// Aktiven Index validieren
|
||||
if (this._activePointIndex !== null && this._activePointIndex >= buckets.length) {
|
||||
this._activePointIndex = null;
|
||||
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) {
|
||||
html += this._renderTimelineStrip(stripEntries);
|
||||
}
|
||||
|
||||
// Achsen-Bereich
|
||||
const rangeStart = buckets[0].timestamp;
|
||||
const rangeEnd = buckets[buckets.length - 1].timestamp;
|
||||
const maxCount = Math.max(...buckets.map(b => b.entries.length));
|
||||
|
||||
// Stunden- vs. Tages-Granularität
|
||||
const isHourly = granularity === 'hour';
|
||||
const axisLabels = this._buildAxisLabels(buckets, granularity, true);
|
||||
|
||||
// HTML aufbauen
|
||||
let html = `<div class="ht-axis${isHourly ? ' ht-axis--hourly' : ''}">`;
|
||||
|
||||
// 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';
|
||||
// 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);
|
||||
}
|
||||
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;
|
||||
},
|
||||
|
||||
_calcGranularity(entries, range) {
|
||||
if (entries.length < 2) return 'day';
|
||||
const timestamps = entries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
|
||||
if (timestamps.length < 2) return 'day';
|
||||
const span = Math.max(...timestamps) - Math.min(...timestamps);
|
||||
if (range === '24h' || span <= 48 * 60 * 60 * 1000) return 'hour';
|
||||
return 'day';
|
||||
/* ======= Quanti-Strip ======= */
|
||||
_stripGranularity(stripEntries) {
|
||||
if (stripEntries.length < 2) return 'day';
|
||||
const ts = stripEntries.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);
|
||||
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) {
|
||||
const bucketMap = {};
|
||||
entries.forEach(e => {
|
||||
const d = new Date(e.timestamp || 0);
|
||||
const b = _tz(d);
|
||||
let key, label, 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);
|
||||
},
|
||||
_buildStripBuckets(stripEntries, granularity) {
|
||||
if (stripEntries.length === 0) return [];
|
||||
const ts = stripEntries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
|
||||
if (ts.length === 0) return [];
|
||||
const minTs = Math.min(...ts);
|
||||
const maxTs = Math.max(...ts);
|
||||
|
||||
_mergeCloseBuckets(buckets) {
|
||||
if (buckets.length < 2) return buckets;
|
||||
const rangeStart = buckets[0].timestamp;
|
||||
const rangeEnd = buckets[buckets.length - 1].timestamp;
|
||||
if (rangeEnd <= rangeStart) return buckets;
|
||||
|
||||
const container = document.getElementById('timeline');
|
||||
const axisWidth = (container ? container.offsetWidth : 800) * 0.92;
|
||||
const maxCount = Math.max(...buckets.map(b => b.entries.length));
|
||||
const result = [buckets[0]];
|
||||
|
||||
for (let i = 1; i < buckets.length; i++) {
|
||||
const prev = result[result.length - 1];
|
||||
const curr = buckets[i];
|
||||
|
||||
const distPx = ((curr.timestamp - prev.timestamp) / (rangeEnd - rangeStart)) * axisWidth;
|
||||
const prevSize = Math.min(32, this._calcPointSize(prev.entries.length, maxCount));
|
||||
const currSize = Math.min(32, this._calcPointSize(curr.entries.length, maxCount));
|
||||
const minDistPx = (prevSize + currSize) / 2 + 6;
|
||||
|
||||
if (distPx < minDistPx) {
|
||||
prev.entries = prev.entries.concat(curr.entries);
|
||||
} else {
|
||||
result.push(curr);
|
||||
}
|
||||
// Bucket-Start fuer minTs ermitteln
|
||||
const minDate = new Date(minTs);
|
||||
const tzMin = _tz(minDate);
|
||||
let firstStart;
|
||||
let stepMs;
|
||||
if (granularity === 'hour') {
|
||||
firstStart = new Date(tzMin.year, tzMin.month, tzMin.date, tzMin.hours).getTime();
|
||||
stepMs = 3600000;
|
||||
} else if (granularity === 'day') {
|
||||
firstStart = new Date(tzMin.year, tzMin.month, tzMin.date).getTime();
|
||||
stepMs = 86400000;
|
||||
} else if (granularity === 'week') {
|
||||
const dow = (minDate.getDay() + 6) % 7; // 0=Mo
|
||||
firstStart = new Date(tzMin.year, tzMin.month, tzMin.date - dow).getTime();
|
||||
stepMs = 7 * 86400000;
|
||||
} else {
|
||||
firstStart = new Date(tzMin.year, tzMin.month, 1).getTime();
|
||||
stepMs = null; // dynamisch (Monatsgrenzen)
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
_bucketPositionPercent(bucket, rangeStart, rangeEnd, totalBuckets) {
|
||||
if (totalBuckets === 1) return 50;
|
||||
if (rangeEnd === rangeStart) return 50;
|
||||
return ((bucket.timestamp - rangeStart) / (rangeEnd - rangeStart)) * 100;
|
||||
},
|
||||
|
||||
_calcPointSize(count, maxCount) {
|
||||
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;
|
||||
const buckets = [];
|
||||
const fmt = (t) => {
|
||||
const d = new Date(t);
|
||||
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 });
|
||||
return d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric', timeZone: TIMEZONE });
|
||||
};
|
||||
|
||||
if (buckets.length <= maxLabels) {
|
||||
buckets.forEach(b => {
|
||||
labels.push({ text: getLabelText(b), pos: this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length) });
|
||||
});
|
||||
if (granularity === 'month') {
|
||||
let d = new Date(firstStart);
|
||||
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 {
|
||||
const step = (buckets.length - 1) / (maxLabels - 1);
|
||||
for (let i = 0; i < maxLabels; i++) {
|
||||
const idx = Math.round(i * step);
|
||||
const b = buckets[idx];
|
||||
labels.push({ text: getLabelText(b), pos: this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length) });
|
||||
for (let t = firstStart; t <= maxTs && buckets.length < 240; t += stepMs) {
|
||||
buckets.push({ start: t, end: t + stepMs, label: fmt(t), articles: 0, snapshots: 0 });
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
},
|
||||
|
||||
_thinLabels(labels, minGapPercent) {
|
||||
if (!labels || labels.length <= 1) return labels;
|
||||
const gap = minGapPercent || 8;
|
||||
const result = [labels[0]];
|
||||
for (let i = 1; i < labels.length; i++) {
|
||||
if (labels[i].pos - result[result.length - 1].pos >= gap) {
|
||||
result.push(labels[i]);
|
||||
}
|
||||
}
|
||||
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 });
|
||||
// Eintraege zaehlen
|
||||
stripEntries.forEach(e => {
|
||||
const ets = new Date(e.timestamp || 0).getTime();
|
||||
// Linear-Suche, da Buckets sortiert; bei vielen Buckets ggf. Binary
|
||||
for (let i = 0; i < buckets.length; i++) {
|
||||
if (ets >= buckets[i].start && ets < buckets[i].end) {
|
||||
if (e.kind === 'article') buckets[i].articles++;
|
||||
else if (e.kind === 'snapshot') buckets[i].snapshots++;
|
||||
break;
|
||||
}
|
||||
const pos = this._bucketPositionPercent(b, rangeStart, rangeEnd, buckets.length);
|
||||
markers.push({ text: label, pos });
|
||||
}
|
||||
});
|
||||
return markers;
|
||||
|
||||
return buckets;
|
||||
},
|
||||
|
||||
_renderDetailPanel(bucket) {
|
||||
const type = this._currentIncidentType;
|
||||
const sorted = [...bucket.entries].sort((a, b) => {
|
||||
if (a.kind === 'snapshot' && b.kind !== 'snapshot') return -1;
|
||||
if (a.kind !== 'snapshot' && b.kind === 'snapshot') return 1;
|
||||
return new Date(b.timestamp || 0) - new Date(a.timestamp || 0);
|
||||
});
|
||||
_renderTimelineStrip(stripEntries) {
|
||||
const granularity = this._stripGranularity(stripEntries);
|
||||
const buckets = this._buildStripBuckets(stripEntries, granularity);
|
||||
if (buckets.length === 0) return '';
|
||||
|
||||
let entriesHtml = '';
|
||||
sorted.forEach(e => {
|
||||
if (e.kind === 'snapshot') {
|
||||
entriesHtml += this._renderSnapshotEntry(e.data);
|
||||
} else {
|
||||
entriesHtml += this._renderArticleEntry(e.data, type, 0);
|
||||
const maxCount = Math.max(1, ...buckets.map(b => b.articles));
|
||||
|
||||
let html = '<div class="ht-strip">';
|
||||
html += '<div class="ht-strip-cells">';
|
||||
buckets.forEach(b => {
|
||||
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">
|
||||
<div class="ht-detail-header">
|
||||
<span class="ht-detail-title">${UI.escape(bucket.label)} (${bucket.entries.length} Eintr${bucket.entries.length === 1 ? 'ag' : 'äge'})</span>
|
||||
<button class="ht-detail-close" onclick="App.closeTimelineDetail()">×</button>
|
||||
// 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-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">×</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">×</button>
|
||||
</div>
|
||||
<div class="ht-detail-body">${body}</div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
setTimelineFilter(filter) {
|
||||
this._timelineFilter = filter;
|
||||
this._activePointIndex = null;
|
||||
this._activeTimelineDetail = null;
|
||||
document.querySelectorAll('.ht-filter-btn').forEach(btn => {
|
||||
const isActive = btn.dataset.filter === filter;
|
||||
btn.classList.toggle('active', isActive);
|
||||
@@ -1399,7 +1501,7 @@ const App = {
|
||||
|
||||
setTimelineRange(range) {
|
||||
this._timelineRange = range;
|
||||
this._activePointIndex = null;
|
||||
this._activeTimelineDetail = null;
|
||||
document.querySelectorAll('.ht-range-btn').forEach(btn => {
|
||||
const isActive = btn.dataset.range === range;
|
||||
btn.classList.toggle('active', isActive);
|
||||
@@ -1408,18 +1510,21 @@ const App = {
|
||||
this.rerenderTimeline();
|
||||
},
|
||||
|
||||
openTimelineDetail(bucketIndex) {
|
||||
if (this._activePointIndex === bucketIndex) {
|
||||
this._activePointIndex = null;
|
||||
} else {
|
||||
this._activePointIndex = bucketIndex;
|
||||
}
|
||||
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();
|
||||
},
|
||||
|
||||
openTimelineWindow(startMs, endMs, label) {
|
||||
this._activeTimelineDetail = { type: 'window', start: startMs, end: endMs, label: label || 'Zeitfenster' };
|
||||
this.rerenderTimeline();
|
||||
this._resizeTimelineTile();
|
||||
},
|
||||
|
||||
closeTimelineDetail() {
|
||||
this._activePointIndex = null;
|
||||
this._activeTimelineDetail = null;
|
||||
this.rerenderTimeline();
|
||||
this._resizeTimelineTile();
|
||||
},
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren