Promote develop → main (2026-05-01 14:09 UTC) #8
@@ -2453,25 +2453,25 @@ a.dev-source-pill:hover {
|
||||
/* Achsen-Container */
|
||||
.ht-axis {
|
||||
position: relative;
|
||||
height: 150px;
|
||||
height: 110px;
|
||||
}
|
||||
|
||||
/* Stündliches Layout: höher wegen Datums-Markern oben */
|
||||
.ht-axis--hourly {
|
||||
height: 170px;
|
||||
height: 130px;
|
||||
}
|
||||
|
||||
/* Saeulen-Bereich (ueber der Linie) */
|
||||
.ht-bars {
|
||||
/* Punkte-Bereich (über der Linie) */
|
||||
.ht-points {
|
||||
position: absolute;
|
||||
left: 4%;
|
||||
right: 4%;
|
||||
top: 22px;
|
||||
bottom: 60px;
|
||||
top: 0;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.ht-axis--hourly .ht-bars {
|
||||
top: 42px;
|
||||
.ht-axis--hourly .ht-points {
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
/* Achsenlinie */
|
||||
@@ -2479,14 +2479,13 @@ a.dev-source-pill:hover {
|
||||
position: absolute;
|
||||
left: 2%;
|
||||
right: 2%;
|
||||
top: 100px;
|
||||
top: 60px;
|
||||
height: 2px;
|
||||
background: var(--border);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.ht-axis--hourly .ht-axis-line {
|
||||
top: 120px;
|
||||
top: 80px;
|
||||
}
|
||||
|
||||
/* Datums-Marker (vertikale Linie + Datum oben, nur bei Stunden-Granularität) */
|
||||
@@ -2640,12 +2639,12 @@ a.dev-source-pill:hover {
|
||||
position: absolute;
|
||||
left: 4%;
|
||||
right: 4%;
|
||||
top: 110px;
|
||||
top: 72px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.ht-axis--hourly .ht-axis-labels {
|
||||
top: 130px;
|
||||
top: 90px;
|
||||
}
|
||||
|
||||
.ht-axis-label {
|
||||
@@ -2665,206 +2664,6 @@ 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;
|
||||
|
||||
@@ -1135,26 +1135,27 @@ const App = {
|
||||
entries.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
|
||||
|
||||
const granularity = this._calcGranularity(entries, range);
|
||||
// Bei Filter "Lageberichte" zaehlen Snapshots; bei "Meldungen" nur Artikel; bei "Alle" beides.
|
||||
const buckets = this._buildBuckets(entries, granularity);
|
||||
let buckets = this._buildBuckets(entries, granularity);
|
||||
buckets = this._mergeCloseBuckets(buckets);
|
||||
|
||||
// Aktiven Index validieren
|
||||
if (this._activePointIndex !== null && this._activePointIndex >= buckets.length) {
|
||||
this._activePointIndex = null;
|
||||
}
|
||||
|
||||
// Achsen-Bereich (mit etwas Padding rechts/links, damit Bars nicht abgeschnitten werden)
|
||||
// Achsen-Bereich
|
||||
const rangeStart = buckets[0].timestamp;
|
||||
const rangeEnd = buckets[buckets.length - 1].timestamp;
|
||||
const articleMax = Math.max(1, ...buckets.map(b => b.entries.filter(e => e.kind === 'article').length));
|
||||
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, isHourly);
|
||||
const topActiveIdx = new Set(this._pickTopActiveBucketIdx(buckets, 3));
|
||||
const axisLabels = this._buildAxisLabels(buckets, granularity, true);
|
||||
|
||||
let html = `<div class="ht-axis${isHourly ? ' ht-axis--hourly' : ''}" data-granularity="${granularity}">`;
|
||||
// HTML aufbauen
|
||||
let html = `<div class="ht-axis${isHourly ? ' ht-axis--hourly' : ''}">`;
|
||||
|
||||
// Datums-Marker (Tageslinien, ausgeduennt)
|
||||
// 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 => {
|
||||
@@ -1165,78 +1166,26 @@ const App = {
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
// Lagebericht-Linien quer durch die Achse (klickbar, oeffnet Snapshot)
|
||||
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">';
|
||||
// Punkte
|
||||
html += '<div class="ht-points">';
|
||||
buckets.forEach((bucket, idx) => {
|
||||
const pos = this._bucketPositionPercent(bucket, rangeStart, rangeEnd, buckets.length);
|
||||
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;
|
||||
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 barClass = 'ht-bar';
|
||||
if (snapshotCount > 0) barClass += ' has-snapshot';
|
||||
if (articleCount === 0 && snapshotCount > 0) barClass += ' snapshot-only';
|
||||
if (isActive) barClass += ' active';
|
||||
let pointClass = 'ht-point';
|
||||
if (filterType === 'snapshots') {
|
||||
pointClass += ' ht-snapshot-point';
|
||||
} else if (hasSnapshots) {
|
||||
pointClass += ' ht-mixed-point';
|
||||
}
|
||||
if (this._activePointIndex === idx) pointClass += ' active';
|
||||
|
||||
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 (snapshotCount > 0) {
|
||||
html += `<div class="ht-bar-snap-cap" aria-hidden="true"></div>`;
|
||||
}
|
||||
// Themen-Label fuer aktivste Buckets
|
||||
if (keyword) {
|
||||
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)} · ${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>`;
|
||||
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>';
|
||||
@@ -1244,7 +1193,7 @@ const App = {
|
||||
// Achsenlinie
|
||||
html += '<div class="ht-axis-line"></div>';
|
||||
|
||||
// Achsen-Labels (ausgeduennt)
|
||||
// Achsen-Labels (ausgedünnt um Überlappung zu vermeiden)
|
||||
const thinned = this._thinLabels(axisLabels);
|
||||
html += '<div class="ht-axis-labels">';
|
||||
thinned.forEach(lbl => {
|
||||
@@ -1253,7 +1202,7 @@ const App = {
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
|
||||
// Detail-Panel (wenn eine Bar aktiv ist)
|
||||
// Detail-Panel (wenn ein Punkt aktiv ist)
|
||||
if (this._activePointIndex !== null && this._activePointIndex < buckets.length) {
|
||||
html += this._renderDetailPanel(buckets[this._activePointIndex]);
|
||||
}
|
||||
@@ -1261,36 +1210,13 @@ 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';
|
||||
if (range === '7d' || span <= 30 * DAY) return 'day';
|
||||
if (span <= 180 * DAY) return 'week';
|
||||
return 'month';
|
||||
return 'day';
|
||||
},
|
||||
|
||||
_buildBuckets(entries, granularity) {
|
||||
@@ -1303,19 +1229,6 @@ 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 });
|
||||
@@ -1329,56 +1242,33 @@ const App = {
|
||||
return Object.values(bucketMap).sort((a, b) => a.timestamp - b.timestamp);
|
||||
},
|
||||
|
||||
/** 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)');
|
||||
},
|
||||
_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-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 bestWord.charAt(0).toUpperCase() + bestWord.slice(1);
|
||||
},
|
||||
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]];
|
||||
|
||||
/** 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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
_bucketPositionPercent(bucket, rangeStart, rangeEnd, totalBuckets) {
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren