Promote develop → main (2026-05-01 14:09 UTC) #8

Zusammengeführt
IntelSight_Admin hat 13 Commits von develop nach main 2026-05-01 16:09:55 +02:00 zusammengeführt
2 geänderte Dateien mit 375 neuen und 64 gelöschten Zeilen
Nur Änderungen aus Commit 370bb94b26 werden angezeigt - Alle Commits anzeigen

Datei anzeigen

@@ -2453,25 +2453,25 @@ a.dev-source-pill:hover {
/* Achsen-Container */ /* Achsen-Container */
.ht-axis { .ht-axis {
position: relative; position: relative;
height: 110px; height: 150px;
} }
/* Stündliches Layout: höher wegen Datums-Markern oben */ /* Stündliches Layout: höher wegen Datums-Markern oben */
.ht-axis--hourly { .ht-axis--hourly {
height: 130px; height: 170px;
} }
/* Punkte-Bereich (über der Linie) */ /* Saeulen-Bereich (ueber der Linie) */
.ht-points { .ht-bars {
position: absolute; position: absolute;
left: 4%; left: 4%;
right: 4%; right: 4%;
top: 0; top: 22px;
height: 56px; bottom: 60px;
} }
.ht-axis--hourly .ht-points { .ht-axis--hourly .ht-bars {
top: 20px; top: 42px;
} }
/* Achsenlinie */ /* Achsenlinie */
@@ -2479,13 +2479,14 @@ a.dev-source-pill:hover {
position: absolute; position: absolute;
left: 2%; left: 2%;
right: 2%; right: 2%;
top: 60px; top: 100px;
height: 2px; height: 2px;
background: var(--border); background: var(--border);
z-index: 3;
} }
.ht-axis--hourly .ht-axis-line { .ht-axis--hourly .ht-axis-line {
top: 80px; top: 120px;
} }
/* Datums-Marker (vertikale Linie + Datum oben, nur bei Stunden-Granularität) */ /* Datums-Marker (vertikale Linie + Datum oben, nur bei Stunden-Granularität) */
@@ -2639,12 +2640,12 @@ a.dev-source-pill:hover {
position: absolute; position: absolute;
left: 4%; left: 4%;
right: 4%; right: 4%;
top: 72px; top: 110px;
height: 20px; height: 20px;
} }
.ht-axis--hourly .ht-axis-labels { .ht-axis--hourly .ht-axis-labels {
top: 90px; top: 130px;
} }
.ht-axis-label { .ht-axis-label {
@@ -2664,6 +2665,206 @@ a.dev-source-pill:hover {
color: var(--text-tertiary); 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 */ /* Detail-Panel */
.ht-detail-panel { .ht-detail-panel {
margin-top: 8px; margin-top: 8px;

Datei anzeigen

@@ -1135,27 +1135,26 @@ const App = {
entries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0)); entries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
const granularity = this._calcGranularity(entries, range); const granularity = this._calcGranularity(entries, range);
let buckets = this._buildBuckets(entries, granularity); // Bei Filter "Lageberichte" zaehlen Snapshots; bei "Meldungen" nur Artikel; bei "Alle" beides.
buckets = this._mergeCloseBuckets(buckets); const buckets = this._buildBuckets(entries, granularity);
// Aktiven Index validieren // Aktiven Index validieren
if (this._activePointIndex !== null && this._activePointIndex >= buckets.length) { if (this._activePointIndex !== null && this._activePointIndex >= buckets.length) {
this._activePointIndex = null; this._activePointIndex = null;
} }
// Achsen-Bereich // Achsen-Bereich (mit etwas Padding rechts/links, damit Bars nicht abgeschnitten werden)
const rangeStart = buckets[0].timestamp; const rangeStart = buckets[0].timestamp;
const rangeEnd = buckets[buckets.length - 1].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 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 = `<div class="ht-axis${isHourly ? ' ht-axis--hourly' : ''}" data-granularity="${granularity}">`;
let html = `<div class="ht-axis${isHourly ? ' ht-axis--hourly' : ''}">`;
// Datums-Marker (immer anzeigen, ausgedünnt) // Datums-Marker (Tageslinien, ausgeduennt)
const dayMarkers = this._thinLabels(this._buildDayMarkers(buckets, rangeStart, rangeEnd), 10); const dayMarkers = this._thinLabels(this._buildDayMarkers(buckets, rangeStart, rangeEnd), 10);
html += '<div class="ht-day-markers">'; html += '<div class="ht-day-markers">';
dayMarkers.forEach(m => { dayMarkers.forEach(m => {
@@ -1166,26 +1165,78 @@ const App = {
}); });
html += '</div>'; html += '</div>';
// Punkte // Lagebericht-Linien quer durch die Achse (klickbar, oeffnet Snapshot)
html += '<div class="ht-points">'; html += '<div class="ht-snapshot-lines">';
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 += `<div class="ht-snapshot-line" style="left:${pos}%;" data-snapshot-id="${snap.data.id}" onclick="App.openSnapshotFromTimeline(${snap.data.id}, event)" title="Lagebericht ${UI.escape(dateStr)}">`;
html += `<div class="ht-snapshot-line-cap" aria-hidden="true">
<svg viewBox="0 0 12 12" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 2h6v8l-3-2-3 2z"/></svg>
</div>`;
html += `</div>`;
});
html += '</div>';
// "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 += `<div class="ht-today-line" style="left:${todayPos}%;" aria-hidden="true">
<div class="ht-today-label">Heute</div>
</div>`;
}
// Saeulen pro Bucket
html += '<div class="ht-bars">';
buckets.forEach((bucket, idx) => { buckets.forEach((bucket, idx) => {
const pos = this._bucketPositionPercent(bucket, rangeStart, rangeEnd, buckets.length); const pos = this._bucketPositionPercent(bucket, rangeStart, rangeEnd, buckets.length);
const size = this._calcPointSize(bucket.entries.length, maxCount); const articleCount = bucket.entries.filter(e => e.kind === 'article').length;
const hasSnapshots = bucket.entries.some(e => e.kind === 'snapshot'); const snapshotCount = bucket.entries.filter(e => e.kind === 'snapshot').length;
const hasArticles = bucket.entries.some(e => e.kind === 'article'); // 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'; let barClass = 'ht-bar';
if (filterType === 'snapshots') { if (snapshotCount > 0) barClass += ' has-snapshot';
pointClass += ' ht-snapshot-point'; if (articleCount === 0 && snapshotCount > 0) barClass += ' snapshot-only';
} else if (hasSnapshots) { if (isActive) barClass += ' active';
pointClass += ' ht-mixed-point';
html += `<div class="${barClass}" style="left:${pos}%;" onclick="App.openTimelineDetail(${idx})" data-idx="${idx}">`;
// Saeule selbst (gefuellt zur Hoehe)
if (articleCount > 0) {
html += `<div class="ht-bar-fill" style="height:${heightPct}%;"></div>`;
} else {
html += `<div class="ht-bar-fill ht-bar-fill--empty"></div>`;
} }
if (this._activePointIndex === idx) pointClass += ' active'; if (snapshotCount > 0) {
html += `<div class="ht-bar-snap-cap" aria-hidden="true"></div>`;
const tooltip = `${bucket.label}: ${bucket.entries.length} Eintr${bucket.entries.length === 1 ? 'ag' : 'äge'}`; }
// Themen-Label fuer aktivste Buckets
html += `<div class="${pointClass}" style="left:${pos}%;width:${size}px;height:${size}px;" onclick="App.openTimelineDetail(${idx})" data-idx="${idx}">`; if (keyword) {
html += `<div class="ht-tooltip">${UI.escape(tooltip)}</div>`; html += `<div class="ht-bucket-label">${UI.escape(keyword)}</div>`;
}
// Hover-Vorschaukarte
html += `<div class="ht-hover-card">
<div class="ht-hover-card-head">${UI.escape(bucket.label)} &middot; ${articleCount + snapshotCount} Eintr${(articleCount + snapshotCount) === 1 ? 'ag' : 'äge'}</div>`;
if (top3.length > 0) {
html += '<ul class="ht-hover-card-list">';
top3.forEach(h => { html += `<li>${UI.escape(h.length > 80 ? h.slice(0, 78) + '…' : h)}</li>`; });
html += '</ul>';
if (remaining > 0) html += `<div class="ht-hover-card-more">+${remaining} weitere</div>`;
}
if (snapshotCount > 0) {
html += `<div class="ht-hover-card-snap">+ ${snapshotCount} Lagebericht${snapshotCount === 1 ? '' : 'e'}</div>`;
}
html += `</div>`;
html += `</div>`; html += `</div>`;
}); });
html += '</div>'; html += '</div>';
@@ -1193,7 +1244,7 @@ const App = {
// Achsenlinie // Achsenlinie
html += '<div class="ht-axis-line"></div>'; html += '<div class="ht-axis-line"></div>';
// Achsen-Labels (ausgedünnt um Überlappung zu vermeiden) // Achsen-Labels (ausgeduennt)
const thinned = this._thinLabels(axisLabels); const thinned = this._thinLabels(axisLabels);
html += '<div class="ht-axis-labels">'; html += '<div class="ht-axis-labels">';
thinned.forEach(lbl => { thinned.forEach(lbl => {
@@ -1202,7 +1253,7 @@ const App = {
html += '</div>'; html += '</div>';
html += '</div>'; html += '</div>';
// Detail-Panel (wenn ein Punkt aktiv ist) // Detail-Panel (wenn eine Bar aktiv ist)
if (this._activePointIndex !== null && this._activePointIndex < buckets.length) { if (this._activePointIndex !== null && this._activePointIndex < buckets.length) {
html += this._renderDetailPanel(buckets[this._activePointIndex]); html += this._renderDetailPanel(buckets[this._activePointIndex]);
} }
@@ -1210,13 +1261,36 @@ const App = {
container.innerHTML = html; 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) { _calcGranularity(entries, range) {
if (entries.length < 2) return 'day'; if (entries.length < 2) return 'day';
const timestamps = entries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0); const timestamps = entries.map(e => new Date(e.timestamp || 0).getTime()).filter(t => t > 0);
if (timestamps.length < 2) return 'day'; if (timestamps.length < 2) return 'day';
const span = Math.max(...timestamps) - Math.min(...timestamps); const span = Math.max(...timestamps) - Math.min(...timestamps);
const DAY = 24 * 60 * 60 * 1000;
if (range === '24h' || span <= 48 * 60 * 60 * 1000) return 'hour'; 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) { _buildBuckets(entries, granularity) {
@@ -1229,6 +1303,19 @@ const App = {
key = `${b.year}-${b.month + 1}-${b.date}-${b.hours}`; 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'; 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(); 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 { } else {
key = `${b.year}-${b.month + 1}-${b.date}`; key = `${b.year}-${b.month + 1}-${b.date}`;
label = d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', timeZone: TIMEZONE }); 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); return Object.values(bucketMap).sort((a, b) => a.timestamp - b.timestamp);
}, },
_mergeCloseBuckets(buckets) { /** Top-3 Headlines nach relevance_score (Fallback: Reihenfolge). */
if (buckets.length < 2) return buckets; _topRelevantHeadlines(bucket, n = 3) {
const rangeStart = buckets[0].timestamp; const articles = bucket.entries
const rangeEnd = buckets[buckets.length - 1].timestamp; .filter(e => e.kind === 'article')
if (rangeEnd <= rangeStart) return buckets; .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'); /** Top-Keyword aus den Headlines des Buckets, mit deutscher Stopwort-Filterung. */
const axisWidth = (container ? container.offsetWidth : 800) * 0.92; _extractBucketKeyword(bucket) {
const maxCount = Math.max(...buckets.map(b => b.entries.length)); const STOP = this._timelineStopwords || (this._timelineStopwords = new Set([
const result = [buckets[0]]; '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',
for (let i = 1; i < buckets.length; i++) { 'the','and','for','with','from','that','this','have','has','was','were','been','will','would','could','should',
const prev = result[result.length - 1]; ]));
const curr = buckets[i]; const counts = {};
bucket.entries.forEach(e => {
const distPx = ((curr.timestamp - prev.timestamp) / (rangeEnd - rangeStart)) * axisWidth; const headline = (e.kind === 'article' ? (e.data.headline_de || e.data.headline) : '') || '';
const prevSize = Math.min(32, this._calcPointSize(prev.entries.length, maxCount)); const words = headline.toLowerCase().match(/[a-zäöüß]{4,}/gi) || [];
const currSize = Math.min(32, this._calcPointSize(curr.entries.length, maxCount)); words.forEach(w => {
const minDistPx = (prevSize + currSize) / 2 + 6; if (STOP.has(w)) return;
counts[w] = (counts[w] || 0) + 1;
if (distPx < minDistPx) { });
prev.entries = prev.entries.concat(curr.entries); });
} else { let bestWord = null;
result.push(curr); 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) { _bucketPositionPercent(bucket, rangeStart, rangeEnd, totalBuckets) {