Promote develop → main (2026-05-01 14:09 UTC) #8
@@ -2490,6 +2490,67 @@ a.dev-source-pill:hover {
|
|||||||
.ht-strip-cell.has-snapshot {
|
.ht-strip-cell.has-snapshot {
|
||||||
box-shadow: inset 0 -3px 0 var(--accent);
|
box-shadow: inset 0 -3px 0 var(--accent);
|
||||||
}
|
}
|
||||||
|
.ht-strip-cell.active {
|
||||||
|
background: var(--accent);
|
||||||
|
transform: scaleY(1.6);
|
||||||
|
box-shadow: var(--glow-accent-strong), inset 0 -3px 0 var(--accent);
|
||||||
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.ht-strip-cell.active::after {
|
||||||
|
content: '▼';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -10px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--accent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.ht-strip:has(.ht-strip-cell.active) .ht-strip-cell:not(.active):not(.empty) {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Banner: aktiver Strip-Filter */
|
||||||
|
.ht-strip-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-md);
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--tint-accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.ht-strip-banner-icon {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.ht-strip-banner-text {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.ht-strip-banner-text strong {
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.ht-strip-banner-close {
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.ht-strip-banner-close:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-card);
|
||||||
|
}
|
||||||
.ht-strip-labels {
|
.ht-strip-labels {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
|||||||
@@ -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=20260501c">
|
<link rel="stylesheet" href="/static/css/style.css?v=20260501d">
|
||||||
<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=20260501c"></script>
|
<script src="/static/js/app.js?v=20260501d"></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>
|
||||||
|
|||||||
@@ -433,7 +433,7 @@ const App = {
|
|||||||
_editingSourceId: null,
|
_editingSourceId: null,
|
||||||
_timelineFilter: 'all',
|
_timelineFilter: 'all',
|
||||||
_timelineRange: 'all',
|
_timelineRange: 'all',
|
||||||
_activeTimelineDetail: null,
|
_activeStripWindow: 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._activeTimelineDetail = null;
|
this._activeStripWindow = 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';
|
||||||
@@ -1115,8 +1115,7 @@ const App = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** Heatmap-Strip oben + vertikaler Newsfeed-Stream darunter.
|
/** Heatmap-Strip oben + vertikaler Newsfeed-Stream darunter.
|
||||||
* Lageberichte sind als prominente Sektionen im Stream.
|
* Klick auf Heatmap-Balken: Stream filtert auf das Zeitfenster (aktive Balken hervorgehoben).
|
||||||
* Klick auf Heatmap-Quadrat scrollt im Stream zur passenden Zeit-Gruppe.
|
|
||||||
*/
|
*/
|
||||||
rerenderTimeline() {
|
rerenderTimeline() {
|
||||||
const container = document.getElementById('timeline');
|
const container = document.getElementById('timeline');
|
||||||
@@ -1125,28 +1124,46 @@ const App = {
|
|||||||
const filterType = this._timelineFilter;
|
const filterType = this._timelineFilter;
|
||||||
const range = this._timelineRange;
|
const range = this._timelineRange;
|
||||||
|
|
||||||
const entries = this._collectEntries(filterType, searchTerm, range);
|
let entries = this._collectEntries(filterType, searchTerm, range);
|
||||||
this._updateTimelineCount(entries);
|
this._updateTimelineCount(entries);
|
||||||
|
|
||||||
if (entries.length === 0) {
|
// Strip nutzt IMMER alle Eintraege im Range (unabhaengig von Filter/Search/Strip-Window)
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
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));
|
||||||
|
|
||||||
|
// Wenn ein Heatmap-Balken aktiv ist: Stream zusaetzlich auf dieses Zeitfenster filtern
|
||||||
|
const win = this._activeStripWindow;
|
||||||
|
if (win && entries.length > 0) {
|
||||||
|
entries = entries.filter(e => {
|
||||||
|
const ts = new Date(e.timestamp || 0).getTime();
|
||||||
|
return ts >= win.start && ts < win.end;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let html = '<div class="ht-tl">';
|
let html = '<div class="ht-tl">';
|
||||||
if (stripEntries.length > 0) {
|
if (stripEntries.length > 0) {
|
||||||
html += this._renderTimelineStrip(stripEntries);
|
html += this._renderTimelineStrip(stripEntries);
|
||||||
}
|
}
|
||||||
// Vertikaler Newsfeed
|
|
||||||
|
// Banner mit aktivem Filter
|
||||||
|
if (win) {
|
||||||
|
html += `<div class="ht-strip-banner">
|
||||||
|
<span class="ht-strip-banner-icon" aria-hidden="true">▼</span>
|
||||||
|
<span class="ht-strip-banner-text">Gefiltert auf <strong>${UI.escape(win.label)}</strong> · ${entries.length} Eintr${entries.length === 1 ? 'ag' : 'äge'}</span>
|
||||||
|
<button class="ht-strip-banner-close" onclick="App.clearStripWindow()" aria-label="Filter aufheben">Filter aufheben</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
html += '<div class="ht-stream">';
|
html += '<div class="ht-stream">';
|
||||||
html += this._renderVerticalStream(entries);
|
if (entries.length === 0) {
|
||||||
|
html += win
|
||||||
|
? '<div class="ht-empty">Keine Einträge in diesem Zeitfenster.</div>'
|
||||||
|
: (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>';
|
||||||
|
} else {
|
||||||
|
html += this._renderVerticalStream(entries);
|
||||||
|
}
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
@@ -1269,6 +1286,7 @@ const App = {
|
|||||||
if (buckets.length === 0) return '';
|
if (buckets.length === 0) return '';
|
||||||
|
|
||||||
const maxCount = Math.max(1, ...buckets.map(b => b.articles));
|
const maxCount = Math.max(1, ...buckets.map(b => b.articles));
|
||||||
|
const win = this._activeStripWindow;
|
||||||
|
|
||||||
let html = '<div class="ht-strip">';
|
let html = '<div class="ht-strip">';
|
||||||
html += '<div class="ht-strip-cells">';
|
html += '<div class="ht-strip-cells">';
|
||||||
@@ -1277,6 +1295,7 @@ const App = {
|
|||||||
const cls = ['ht-strip-cell'];
|
const cls = ['ht-strip-cell'];
|
||||||
if (b.snapshots > 0) cls.push('has-snapshot');
|
if (b.snapshots > 0) cls.push('has-snapshot');
|
||||||
if (b.articles === 0 && b.snapshots === 0) cls.push('empty');
|
if (b.articles === 0 && b.snapshots === 0) cls.push('empty');
|
||||||
|
if (win && win.start === b.start && win.end === b.end) cls.push('active');
|
||||||
const tip = `${b.label}: ${b.articles} Meldung${b.articles === 1 ? '' : 'en'}` +
|
const tip = `${b.label}: ${b.articles} Meldung${b.articles === 1 ? '' : 'en'}` +
|
||||||
(b.snapshots > 0 ? ` + ${b.snapshots} Lagebericht${b.snapshots === 1 ? '' : 'e'}` : '');
|
(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 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>`;
|
||||||
@@ -1316,6 +1335,7 @@ const App = {
|
|||||||
|
|
||||||
setTimelineFilter(filter) {
|
setTimelineFilter(filter) {
|
||||||
this._timelineFilter = filter;
|
this._timelineFilter = filter;
|
||||||
|
this._activeStripWindow = 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);
|
||||||
@@ -1326,6 +1346,7 @@ const App = {
|
|||||||
|
|
||||||
setTimelineRange(range) {
|
setTimelineRange(range) {
|
||||||
this._timelineRange = range;
|
this._timelineRange = range;
|
||||||
|
this._activeStripWindow = 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);
|
||||||
@@ -1334,42 +1355,23 @@ const App = {
|
|||||||
this.rerenderTimeline();
|
this.rerenderTimeline();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Klick auf Heatmap-Quadrat: Filter im Stream auf das Zeitfenster scrollen. */
|
/** Klick auf Heatmap-Balken: Stream auf dieses Zeitfenster filtern.
|
||||||
|
* Zweiter Klick auf denselben Balken hebt den Filter auf.
|
||||||
|
*/
|
||||||
openTimelineWindow(startMs, endMs, label) {
|
openTimelineWindow(startMs, endMs, label) {
|
||||||
// Im Stream die naechste vt-time-group finden, deren Eintraege ins Fenster fallen
|
const win = this._activeStripWindow;
|
||||||
const groups = document.querySelectorAll('#timeline .vt-time-group');
|
if (win && win.start === startMs && win.end === endMs) {
|
||||||
let target = null;
|
this._activeStripWindow = null;
|
||||||
for (const g of groups) {
|
} else {
|
||||||
const firstTimeEl = g.querySelector('.vt-article-time, .vt-snapshot-time');
|
this._activeStripWindow = { start: startMs, end: endMs, label: label || '' };
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
this.rerenderTimeline();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Strip-Filter aufheben (z.B. via Banner-Button). */
|
||||||
|
clearStripWindow() {
|
||||||
|
this._activeStripWindow = null;
|
||||||
|
this.rerenderTimeline();
|
||||||
},
|
},
|
||||||
|
|
||||||
_resizeTimelineTile() {
|
_resizeTimelineTile() {
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren