From 370bb94b265d046a4c14774e0c775af472114f2c Mon Sep 17 00:00:00 2001 From: UserIsMH Date: Fri, 1 May 2026 15:04:43 +0200 Subject: [PATCH] =?UTF-8?q?Ereignis-Timeline:=20S=C3=A4ulen,=20Lagebericht?= =?UTF-8?q?-Linien,=20Themen-Labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Punkte ersetzt durch schmale Säulen (Bar-Chart), Höhe = Anzahl Artikel im Bucket relativ zum Maximum. Aktivität ist sofort als Verlauf lesbar. - Granularität: hour < 48h, day < 30T, week < 180T, sonst month. Bucket-Merge (verfälscht das Datum) entfernt, stattdessen sauberer Granularitätswechsel. - Lagebericht-Linien quer durch die Achse als dezente goldene Vertikalen mit kleinem Stempel-Symbol oben. Klick öffnet das Bucket-Detail mit dem zugehörigen Snapshot. - Heute-Linie mit Label, wenn der heutige Zeitpunkt im sichtbaren Bereich liegt. - Themen-Label über den Top-3 aktivsten Buckets: clientseitig per Wort-Häufigkeit aus Headlines, mit deutscher Stopwortliste. Zeigt nur, wenn ein Wort mindestens zweimal vorkommt. - Hover über eine Säule: Mini-Karte mit den 3 relevantesten Headlines des Buckets (sortiert nach relevance_score), plus "+N weitere" und Lagebericht-Hinweis bei gemischten Buckets. - Snapshot-Bars bekommen oben einen goldenen Cap als Marker. - Reduced-motion respektiert. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/static/css/style.css | 225 ++++++++++++++++++++++++++++++++++++--- src/static/js/app.js | 214 ++++++++++++++++++++++++++++--------- 2 files changed, 375 insertions(+), 64 deletions(-) diff --git a/src/static/css/style.css b/src/static/css/style.css index d944697..d38956a 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -2453,25 +2453,25 @@ a.dev-source-pill:hover { /* Achsen-Container */ .ht-axis { position: relative; - height: 110px; + height: 150px; } /* Stündliches Layout: höher wegen Datums-Markern oben */ .ht-axis--hourly { - height: 130px; + height: 170px; } -/* Punkte-Bereich (über der Linie) */ -.ht-points { +/* Saeulen-Bereich (ueber der Linie) */ +.ht-bars { position: absolute; left: 4%; right: 4%; - top: 0; - height: 56px; + top: 22px; + bottom: 60px; } -.ht-axis--hourly .ht-points { - top: 20px; +.ht-axis--hourly .ht-bars { + top: 42px; } /* Achsenlinie */ @@ -2479,13 +2479,14 @@ a.dev-source-pill:hover { position: absolute; left: 2%; right: 2%; - top: 60px; + top: 100px; height: 2px; background: var(--border); + z-index: 3; } .ht-axis--hourly .ht-axis-line { - top: 80px; + top: 120px; } /* Datums-Marker (vertikale Linie + Datum oben, nur bei Stunden-Granularität) */ @@ -2639,12 +2640,12 @@ a.dev-source-pill:hover { position: absolute; left: 4%; right: 4%; - top: 72px; + top: 110px; height: 20px; } .ht-axis--hourly .ht-axis-labels { - top: 90px; + top: 130px; } .ht-axis-label { @@ -2664,6 +2665,206 @@ a.dev-source-pill:hover { color: var(--text-tertiary); } +/* === Säulen-Variante (neuer Stil) === */ +.ht-bar { + position: absolute; + bottom: 0; + width: 12px; + margin-left: -6px; + height: 100%; + cursor: pointer; + z-index: 5; + transition: transform 0.15s ease; +} +.ht-bar:hover { transform: scaleX(1.15); } +.ht-bar-fill { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: var(--text-disabled); + border-radius: 2px 2px 0 0; + transition: background 0.2s ease, height 0.2s ease; +} +.ht-bar:hover .ht-bar-fill, +.ht-bar.active .ht-bar-fill { background: var(--accent); } +.ht-bar-fill--empty { + height: 4px; + background: var(--border); +} +.ht-bar-snap-cap { + position: absolute; + top: -8px; + left: -2px; + right: -2px; + height: 6px; + background: var(--accent); + border-radius: 2px; + box-shadow: var(--glow-accent); +} +.ht-bars:has(.ht-bar.active) .ht-bar:not(.active) { opacity: 0.45; } + +/* Aktiver Bar: dezenter Marker oben */ +.ht-bar.active::before { + content: ''; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid var(--accent); + margin-bottom: 4px; +} + +/* Themen-Label ueber aktiven Buckets */ +.ht-bucket-label { + position: absolute; + bottom: calc(100% + 14px); + left: 50%; + transform: translateX(-50%); + font-size: 10px; + font-family: var(--font-mono); + font-weight: 600; + color: var(--accent); + background: var(--bg-card); + padding: 1px 6px; + border-radius: 8px; + border: 1px solid var(--tint-accent-strong); + white-space: nowrap; + pointer-events: none; + opacity: 0.85; +} + +/* Hover-Vorschaukarte */ +.ht-hover-card { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + min-width: 240px; + max-width: 320px; + background: var(--bg-card); + border: 1px solid var(--accent); + border-radius: var(--radius); + box-shadow: var(--shadow-md); + padding: 8px 10px; + opacity: 0; + visibility: hidden; + transition: opacity 0.15s ease, visibility 0.15s ease; + pointer-events: none; + z-index: 20; + text-align: left; + white-space: normal; +} +.ht-bar:hover .ht-hover-card { opacity: 1; visibility: visible; } +.ht-hover-card-head { + font-size: 11px; + font-family: var(--font-mono); + color: var(--accent); + font-weight: 600; + margin-bottom: 4px; +} +.ht-hover-card-list { + list-style: none; + margin: 0; + padding: 0; + font-size: 12px; + color: var(--text-primary); + line-height: 1.4; +} +.ht-hover-card-list li { + padding: 2px 0; + border-top: 1px dashed var(--border); +} +.ht-hover-card-list li:first-child { border-top: none; } +.ht-hover-card-more { + margin-top: 4px; + font-size: 11px; + color: var(--text-tertiary); + font-style: italic; +} +.ht-hover-card-snap { + margin-top: 4px; + font-size: 11px; + color: var(--accent); + font-weight: 500; +} + +/* Lagebericht-Linien quer durch die Achse */ +.ht-snapshot-lines { + position: absolute; + left: 4%; + right: 4%; + top: 0; + bottom: 38px; + pointer-events: none; + z-index: 2; +} +.ht-snapshot-line { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + margin-left: -1px; + background: var(--accent); + opacity: 0.35; + cursor: pointer; + pointer-events: auto; + transition: opacity 0.15s ease; +} +.ht-snapshot-line:hover { opacity: 0.85; } +.ht-snapshot-line-cap { + position: absolute; + top: -2px; + left: 50%; + transform: translateX(-50%); + width: 14px; + height: 14px; + background: var(--bg-card); + border: 1px solid var(--accent); + border-radius: 50%; + color: var(--accent); + display: flex; + align-items: center; + justify-content: center; +} + +/* "Heute"-Linie */ +.ht-today-line { + position: absolute; + left: 4%; + top: 0; + bottom: 38px; + width: 1px; + margin-left: -1px; + background: var(--accent); + opacity: 0.5; + border-left: 1px dashed var(--accent); + pointer-events: none; + z-index: 4; +} +.ht-today-label { + position: absolute; + top: 0; + left: 4px; + font-size: 9px; + font-family: var(--font-mono); + font-weight: 700; + color: var(--accent); + background: var(--bg-card); + padding: 0 4px; + border-radius: 2px; +} + +@media (prefers-reduced-motion: reduce) { + .ht-bar { transition: none; } + .ht-bar-fill { transition: none; } + .ht-detail-panel { animation: none; } +} + /* Detail-Panel */ .ht-detail-panel { margin-top: 8px; diff --git a/src/static/js/app.js b/src/static/js/app.js index 79f6a02..bae85f8 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -1135,27 +1135,26 @@ 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); + // Bei Filter "Lageberichte" zaehlen Snapshots; bei "Meldungen" nur Artikel; bei "Alle" beides. + const buckets = this._buildBuckets(entries, granularity); // Aktiven Index validieren if (this._activePointIndex !== null && this._activePointIndex >= buckets.length) { this._activePointIndex = null; } - // Achsen-Bereich + // Achsen-Bereich (mit etwas Padding rechts/links, damit Bars nicht abgeschnitten werden) const rangeStart = buckets[0].timestamp; const rangeEnd = buckets[buckets.length - 1].timestamp; - const maxCount = Math.max(...buckets.map(b => b.entries.length)); + const articleMax = Math.max(1, ...buckets.map(b => b.entries.filter(e => e.kind === 'article').length)); - // Stunden- vs. Tages-Granularität const isHourly = granularity === 'hour'; - const axisLabels = this._buildAxisLabels(buckets, granularity, true); + const axisLabels = this._buildAxisLabels(buckets, granularity, isHourly); + const topActiveIdx = new Set(this._pickTopActiveBucketIdx(buckets, 3)); - // HTML aufbauen - let html = `
`; + let html = `
`; - // Datums-Marker (immer anzeigen, ausgedünnt) + // Datums-Marker (Tageslinien, ausgeduennt) const dayMarkers = this._thinLabels(this._buildDayMarkers(buckets, rangeStart, rangeEnd), 10); html += '
'; dayMarkers.forEach(m => { @@ -1166,26 +1165,78 @@ const App = { }); html += '
'; - // Punkte - html += '
'; + // Lagebericht-Linien quer durch die Achse (klickbar, oeffnet Snapshot) + html += '
'; + const snapshots = entries.filter(e => e.kind === 'snapshot'); + snapshots.forEach(snap => { + const ts = new Date(snap.timestamp || 0).getTime(); + if (rangeEnd === rangeStart) return; + const pos = ((ts - rangeStart) / (rangeEnd - rangeStart)) * 100; + if (pos < 0 || pos > 100) return; + const dateStr = new Date(snap.timestamp).toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE }); + html += `
`; + html += ``; + html += `
`; + }); + html += '
'; + + // "Heute"-Linie wenn heute im sichtbaren Bereich liegt + const now = Date.now(); + if (rangeEnd > rangeStart && now >= rangeStart && now <= rangeEnd) { + const todayPos = ((now - rangeStart) / (rangeEnd - rangeStart)) * 100; + html += ``; + } + + // Saeulen pro Bucket + html += '
'; 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'); + const articleCount = bucket.entries.filter(e => e.kind === 'article').length; + const snapshotCount = bucket.entries.filter(e => e.kind === 'snapshot').length; + // Bar-Hoehe: Anteil articleCount/articleMax, mindestens 6%, maximal 100% + const heightPct = articleCount === 0 ? 0 : Math.max(6, Math.round((articleCount / articleMax) * 100)); + const isActive = this._activePointIndex === idx; + const showLabel = topActiveIdx.has(idx) && articleCount >= 2; + const keyword = showLabel ? this._extractBucketKeyword(bucket) : null; + const top3 = this._topRelevantHeadlines(bucket, 3); + const remaining = bucket.entries.filter(e => e.kind === 'article').length - top3.length; - let pointClass = 'ht-point'; - if (filterType === 'snapshots') { - pointClass += ' ht-snapshot-point'; - } else if (hasSnapshots) { - pointClass += ' ht-mixed-point'; + let barClass = 'ht-bar'; + if (snapshotCount > 0) barClass += ' has-snapshot'; + if (articleCount === 0 && snapshotCount > 0) barClass += ' snapshot-only'; + if (isActive) barClass += ' active'; + + html += `
`; + // Saeule selbst (gefuellt zur Hoehe) + if (articleCount > 0) { + html += `
`; + } else { + html += `
`; } - if (this._activePointIndex === idx) pointClass += ' active'; - - const tooltip = `${bucket.label}: ${bucket.entries.length} Eintr${bucket.entries.length === 1 ? 'ag' : 'äge'}`; - - html += `
`; - html += `
${UI.escape(tooltip)}
`; + if (snapshotCount > 0) { + html += ``; + } + // Themen-Label fuer aktivste Buckets + if (keyword) { + html += `
${UI.escape(keyword)}
`; + } + // Hover-Vorschaukarte + html += `
+
${UI.escape(bucket.label)} · ${articleCount + snapshotCount} Eintr${(articleCount + snapshotCount) === 1 ? 'ag' : 'äge'}
`; + if (top3.length > 0) { + html += '
    '; + top3.forEach(h => { html += `
  • ${UI.escape(h.length > 80 ? h.slice(0, 78) + '…' : h)}
  • `; }); + html += '
'; + if (remaining > 0) html += `
+${remaining} weitere
`; + } + if (snapshotCount > 0) { + html += `
+ ${snapshotCount} Lagebericht${snapshotCount === 1 ? '' : 'e'}
`; + } + html += `
`; html += `
`; }); html += '
'; @@ -1193,7 +1244,7 @@ const App = { // Achsenlinie html += '
'; - // Achsen-Labels (ausgedünnt um Überlappung zu vermeiden) + // Achsen-Labels (ausgeduennt) const thinned = this._thinLabels(axisLabels); html += '
'; thinned.forEach(lbl => { @@ -1202,7 +1253,7 @@ const App = { html += '
'; html += '
'; - // Detail-Panel (wenn ein Punkt aktiv ist) + // Detail-Panel (wenn eine Bar aktiv ist) if (this._activePointIndex !== null && this._activePointIndex < buckets.length) { html += this._renderDetailPanel(buckets[this._activePointIndex]); } @@ -1210,13 +1261,36 @@ const App = { container.innerHTML = html; }, + /** Lagebericht direkt aus der Timeline-Snapshot-Linie oeffnen. + * Findet den passenden Bucket und expandiert dessen Detail-Panel. + */ + openSnapshotFromTimeline(snapshotId, evt) { + if (evt && evt.stopPropagation) evt.stopPropagation(); + // Bucket finden, der diesen Snapshot enthaelt + const filterType = this._timelineFilter; + const range = this._timelineRange; + const searchTerm = (document.getElementById('timeline-search')?.value || '').toLowerCase(); + let entries = this._collectEntries(filterType, searchTerm, range); + entries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0)); + const granularity = this._calcGranularity(entries, range); + const buckets = this._buildBuckets(entries, granularity); + const idx = buckets.findIndex(b => b.entries.some(e => e.kind === 'snapshot' && e.data.id === snapshotId)); + if (idx === -1) return; + this._activePointIndex = idx; + this.rerenderTimeline(); + this._resizeTimelineTile(); + }, + _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); + const DAY = 24 * 60 * 60 * 1000; if (range === '24h' || span <= 48 * 60 * 60 * 1000) return 'hour'; - return 'day'; + if (range === '7d' || span <= 30 * DAY) return 'day'; + if (span <= 180 * DAY) return 'week'; + return 'month'; }, _buildBuckets(entries, granularity) { @@ -1229,6 +1303,19 @@ const App = { 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 if (granularity === 'week') { + // ISO-Wochen-Start (Montag) + const weekStart = new Date(b.year, b.month, b.date); + const dow = (weekStart.getDay() + 6) % 7; // 0=Mo, 6=So + weekStart.setDate(weekStart.getDate() - dow); + const ws = _tz(weekStart); + key = `w-${ws.year}-${ws.month + 1}-${ws.date}`; + label = 'KW ' + weekStart.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', timeZone: TIMEZONE }); + ts = new Date(ws.year, ws.month, ws.date, 12).getTime(); + } else if (granularity === 'month') { + key = `m-${b.year}-${b.month + 1}`; + label = d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric', timeZone: TIMEZONE }); + ts = new Date(b.year, b.month, 15).getTime(); } else { key = `${b.year}-${b.month + 1}-${b.date}`; label = d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', timeZone: TIMEZONE }); @@ -1242,33 +1329,56 @@ const App = { return Object.values(bucketMap).sort((a, b) => a.timestamp - b.timestamp); }, - _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; + /** Top-3 Headlines nach relevance_score (Fallback: Reihenfolge). */ + _topRelevantHeadlines(bucket, n = 3) { + const articles = bucket.entries + .filter(e => e.kind === 'article') + .map(e => e.data) + .sort((a, b) => (b.relevance_score || 0) - (a.relevance_score || 0)); + return articles.slice(0, n).map(a => a.headline_de || a.headline || '(ohne Titel)'); + }, - 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); + /** Top-Keyword aus den Headlines des Buckets, mit deutscher Stopwort-Filterung. */ + _extractBucketKeyword(bucket) { + const STOP = this._timelineStopwords || (this._timelineStopwords = new Set([ + 'aber','alle','allem','allen','aller','alles','als','also','am','an','andere','anderem','anderen','ander','anders','auch','auf','aus','bei','beim','bin','bist','bis','bist','da','damit','dann','das','dass','dem','den','der','des','die','dies','diese','diesem','diesen','dieser','dieses','doch','dort','du','durch','ein','eine','einem','einen','einer','eines','er','es','etwa','etwas','euch','euer','eure','fuer','für','gegen','gibt','habe','haben','hat','hatte','hatten','hier','hin','hinter','ich','ihm','ihn','ihnen','ihr','ihre','ihrem','ihren','ihrer','ihres','im','in','indem','ins','ist','jede','jedem','jeden','jeder','jedes','jene','jenem','jenen','jener','jenes','jetzt','kann','kein','keine','keinem','keinen','keiner','keines','koennen','können','machen','man','manche','manchem','manchen','mancher','manches','mein','meine','meinem','meinen','meiner','meines','mit','muss','muessen','müssen','nach','nicht','nichts','noch','nun','nur','ob','oder','ohne','sehr','sein','seine','seinem','seinen','seiner','seines','seit','sich','sind','so','solche','solchem','solchen','solcher','solches','sollte','sondern','sonst','ueber','über','um','und','uns','unser','unsere','unter','viel','vom','von','vor','war','waren','was','weiter','welche','welchem','welchen','welcher','welches','wenn','werde','werden','wie','wieder','wir','wird','wirst','wo','wollen','wuerde','würde','zu','zum','zur','zwischen', + 'mehr','schon','beim','dem','dafür','dafuer','damit','jedoch','sowie','laut','rund','etwa','erst','dazu','dabei','ueber','daran','sowieso','seitdem','besonders','immer','heute','gestern','morgen','jahr','jahre','jahren','tag','tage','tagen','woche','wochen','monat','monate','jetzt', + 'the','and','for','with','from','that','this','have','has','was','were','been','will','would','could','should', + ])); + const counts = {}; + bucket.entries.forEach(e => { + const headline = (e.kind === 'article' ? (e.data.headline_de || e.data.headline) : '') || ''; + const words = headline.toLowerCase().match(/[a-zäöüß]{4,}/gi) || []; + words.forEach(w => { + if (STOP.has(w)) return; + counts[w] = (counts[w] || 0) + 1; + }); + }); + let bestWord = null; + let bestCount = 0; + Object.entries(counts).forEach(([w, c]) => { + if (c > bestCount) { + bestCount = c; + bestWord = w; } + }); + if (!bestWord || bestCount < 2) return null; // braucht mindestens 2 Vorkommen + // Erstes Vorkommen mit Original-Casing zurueckgeben (statt all-lowercase) + for (const e of bucket.entries) { + const headline = (e.kind === 'article' ? (e.data.headline_de || e.data.headline) : '') || ''; + const m = headline.match(new RegExp('\\b(' + bestWord + ')\\b', 'i')); + if (m) return m[1]; } - return result; + return bestWord.charAt(0).toUpperCase() + bestWord.slice(1); + }, + + /** Indices der Top-N aktivsten Buckets (fuer Themen-Labels). */ + _pickTopActiveBucketIdx(buckets, n = 3) { + return buckets + .map((b, i) => ({ i, c: b.entries.filter(e => e.kind === 'article').length })) + .sort((a, b) => b.c - a.c) + .slice(0, n) + .map(x => x.i); }, _bucketPositionPercent(bucket, rangeStart, rangeEnd, totalBuckets) {