Promote develop → main (2026-05-01 14:09 UTC) #8
@@ -2450,39 +2450,33 @@ a.dev-source-pill:hover {
|
|||||||
padding: 12px 20px 8px;
|
padding: 12px 20px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Snapshot-zentrierte Timeline === */
|
/* === Timeline: Heatmap-Strip oben + vertikaler Newsfeed-Stream darunter === */
|
||||||
.ht-tl {
|
.ht-tl {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--sp-xl);
|
gap: var(--sp-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ht-tl-hint {
|
/* Heatmap-Strip */
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-style: italic;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Quanti-Strip (Heatmap-Zeile) */
|
|
||||||
.ht-strip {
|
.ht-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
padding: 4px 0 6px;
|
||||||
}
|
}
|
||||||
.ht-strip-cells {
|
.ht-strip-cells {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-flow: column;
|
grid-auto-flow: column;
|
||||||
grid-auto-columns: minmax(8px, 1fr);
|
grid-auto-columns: minmax(8px, 1fr);
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
height: 18px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
.ht-strip-cell {
|
.ht-strip-cell {
|
||||||
background: color-mix(in srgb, var(--accent) calc(var(--intensity) * 70%), var(--border));
|
background: color-mix(in srgb, var(--accent) calc(var(--intensity) * 70%), var(--border));
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
min-height: 16px;
|
min-height: 12px;
|
||||||
}
|
}
|
||||||
.ht-strip-cell.empty {
|
.ht-strip-cell.empty {
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
@@ -2490,11 +2484,11 @@ a.dev-source-pill:hover {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.ht-strip-cell:hover:not(.empty) {
|
.ht-strip-cell:hover:not(.empty) {
|
||||||
transform: scaleY(1.4);
|
transform: scaleY(1.6);
|
||||||
box-shadow: var(--glow-accent);
|
box-shadow: var(--glow-accent);
|
||||||
}
|
}
|
||||||
.ht-strip-cell.has-snapshot {
|
.ht-strip-cell.has-snapshot {
|
||||||
box-shadow: inset 0 -2px 0 var(--accent);
|
box-shadow: inset 0 -3px 0 var(--accent);
|
||||||
}
|
}
|
||||||
.ht-strip-labels {
|
.ht-strip-labels {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -2508,156 +2502,9 @@ a.dev-source-pill:hover {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Lagebericht-Kette */
|
/* Stream-Container */
|
||||||
.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 */
|
|
||||||
.ht-stream {
|
.ht-stream {
|
||||||
margin-top: 8px;
|
margin-top: var(--sp-md);
|
||||||
}
|
}
|
||||||
.ht-empty {
|
.ht-empty {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@@ -2666,79 +2513,17 @@ a.dev-source-pill:hover {
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Detail-Panel (Zeitfenster oder Snapshot) */
|
/* Time-Group Flash beim Scrollen vom Strip */
|
||||||
.ht-detail {
|
.vt-time-group--flash {
|
||||||
margin-top: 4px;
|
animation: vt-group-flash 1.2s ease-out;
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
animation: ht-slide-down 0.2s ease;
|
|
||||||
}
|
}
|
||||||
@keyframes ht-slide-down {
|
@keyframes vt-group-flash {
|
||||||
from { opacity: 0; transform: translateY(-8px); }
|
0% { background: var(--tint-accent-strong); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
100% { background: transparent; }
|
||||||
}
|
|
||||||
.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%; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reduced Motion */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.ht-now-pulse { animation: none; }
|
.vt-time-group--flash { animation: none; }
|
||||||
.ht-detail { animation: none; }
|
|
||||||
.ht-card { transition: none; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Briefing Listen === */
|
/* === Briefing Listen === */
|
||||||
|
|||||||
@@ -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=20260501b">
|
<link rel="stylesheet" href="/static/css/style.css?v=20260501c">
|
||||||
<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=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/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>
|
||||||
|
|||||||
@@ -1114,11 +1114,9 @@ const App = {
|
|||||||
this._timelineSearchTimer = setTimeout(() => this.rerenderTimeline(), 250);
|
this._timelineSearchTimer = setTimeout(() => this.rerenderTimeline(), 250);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Snapshot-zentrierte Timeline:
|
/** Heatmap-Strip oben + vertikaler Newsfeed-Stream darunter.
|
||||||
* - Quanti-Strip oben (Heatmap-Reihe ueber Tage/Stunden)
|
* Lageberichte sind als prominente Sektionen im Stream.
|
||||||
* - Lagebericht-Karten als horizontale Stationen
|
* Klick auf Heatmap-Quadrat scrollt im Stream zur passenden Zeit-Gruppe.
|
||||||
* - 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');
|
||||||
@@ -1127,59 +1125,65 @@ const App = {
|
|||||||
const filterType = this._timelineFilter;
|
const filterType = this._timelineFilter;
|
||||||
const range = this._timelineRange;
|
const range = this._timelineRange;
|
||||||
|
|
||||||
// Filter-spezifische Eintraege fuer die Hauptansicht
|
const 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._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>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
|
// Heatmap nutzt IMMER alle Eintraege (Filter-unabhaengig), damit der Strip
|
||||||
|
// die volle Aktivitaet zeigt und auch im "Lageberichte"-Filter scrollbar bleibt.
|
||||||
// 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);
|
const stripEntries = this._collectEntries('all', '', range);
|
||||||
stripEntries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
|
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">';
|
let html = '<div class="ht-tl">';
|
||||||
|
if (stripEntries.length > 0) {
|
||||||
// 1) Quanti-Strip (entfaellt nur bei "Lageberichte"-Filter)
|
|
||||||
if (filterType !== 'snapshots' && stripEntries.length > 0) {
|
|
||||||
html += this._renderTimelineStrip(stripEntries);
|
html += this._renderTimelineStrip(stripEntries);
|
||||||
}
|
}
|
||||||
|
// Vertikaler Newsfeed
|
||||||
// 2) Hauptansicht je nach Filter
|
html += '<div class="ht-stream">';
|
||||||
if (filterType === 'snapshots') {
|
html += this._renderVerticalStream(entries);
|
||||||
html += this._renderSnapshotsOnly(snapshots);
|
html += '</div>';
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
container.innerHTML = html;
|
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 ======= */
|
/* ======= Quanti-Strip ======= */
|
||||||
_stripGranularity(stripEntries) {
|
_stripGranularity(stripEntries) {
|
||||||
if (stripEntries.length < 2) return 'day';
|
if (stripEntries.length < 2) return 'day';
|
||||||
@@ -1310,187 +1314,8 @@ const App = {
|
|||||||
return html;
|
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">×</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) {
|
setTimelineFilter(filter) {
|
||||||
this._timelineFilter = filter;
|
this._timelineFilter = filter;
|
||||||
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);
|
||||||
@@ -1501,7 +1326,6 @@ const App = {
|
|||||||
|
|
||||||
setTimelineRange(range) {
|
setTimelineRange(range) {
|
||||||
this._timelineRange = range;
|
this._timelineRange = range;
|
||||||
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);
|
||||||
@@ -1510,23 +1334,42 @@ const App = {
|
|||||||
this.rerenderTimeline();
|
this.rerenderTimeline();
|
||||||
},
|
},
|
||||||
|
|
||||||
openSnapshotInline(snapshotId) {
|
/** Klick auf Heatmap-Quadrat: Filter im Stream auf das Zeitfenster scrollen. */
|
||||||
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) {
|
openTimelineWindow(startMs, endMs, label) {
|
||||||
this._activeTimelineDetail = { type: 'window', start: startMs, end: endMs, label: label || 'Zeitfenster' };
|
// Im Stream die naechste vt-time-group finden, deren Eintraege ins Fenster fallen
|
||||||
this.rerenderTimeline();
|
const groups = document.querySelectorAll('#timeline .vt-time-group');
|
||||||
this._resizeTimelineTile();
|
let target = null;
|
||||||
},
|
for (const g of groups) {
|
||||||
|
const firstTimeEl = g.querySelector('.vt-article-time, .vt-snapshot-time');
|
||||||
closeTimelineDetail() {
|
// Pragmatisch: nimm die erste Group, die Eintraege mit Timestamp >= startMs hat.
|
||||||
this._activeTimelineDetail = null;
|
// Die Stream-Daten sind absteigend sortiert; wir finden die erste passende.
|
||||||
this.rerenderTimeline();
|
const matches = Array.from(g.querySelectorAll('.vt-entry')).some(entry => {
|
||||||
this._resizeTimelineTile();
|
// 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() {
|
_resizeTimelineTile() {
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren