This commit is contained in:
2026-09-02 14:19:09 +02:00
commit 476b1cd28a
11 changed files with 23464 additions and 0 deletions
+374
View File
@@ -0,0 +1,374 @@
<title>Lagebild Europa</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,500;6..72,600&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap">
<style>
:root{
--paper:#e9ecf1; --surface:#ffffff; --sunk:#dfe4ec;
--ink:#151a23; --muted:#5a6474; --faint:#8d97a6;
--line:#ccd3dd; --hair:#dde2ea;
--accent:#2f4d7a; --accent-soft:#dbe3f1;
--conflict:#a53f31; --coop:#1c6c58; --neutralbar:#98a3b3;
--focus:#2f4d7a;
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--paper:#11151c; --surface:#181d26; --sunk:#1f2631;
--ink:#e4e8ef; --muted:#8e99ab; --faint:#6d7889;
--line:#28303c; --hair:#222a35;
--accent:#84a8e0; --accent-soft:#22304a;
--conflict:#e08272; --coop:#54b596; --neutralbar:#5d6875;
--focus:#84a8e0;
}
}
:root[data-theme="dark"]{
--paper:#11151c; --surface:#181d26; --sunk:#1f2631;
--ink:#e4e8ef; --muted:#8e99ab; --faint:#6d7889;
--line:#28303c; --hair:#222a35;
--accent:#84a8e0; --accent-soft:#22304a;
--conflict:#e08272; --coop:#54b596; --neutralbar:#5d6875;
--focus:#84a8e0;
}
*{box-sizing:border-box}
body{
margin:0; background:var(--paper); color:var(--ink);
font-family:"IBM Plex Sans",system-ui,-apple-system,"Segoe UI",sans-serif;
font-size:15px; line-height:1.5;
}
.wrap{max-width:1180px; margin:0 auto; padding:0 24px 72px}
/* ---------- Kopf ---------- */
header{padding:44px 0 0}
.eyebrow{
font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:11px;
letter-spacing:.13em; text-transform:uppercase; color:var(--faint);
display:flex; gap:14px; flex-wrap:wrap; align-items:baseline;
}
h1{
font-family:Newsreader,Georgia,serif; font-weight:500;
font-size:clamp(34px,5vw,52px); line-height:1.05; margin:10px 0 0;
letter-spacing:-.015em; text-wrap:balance;
}
.standfirst{
color:var(--muted); max-width:62ch; margin:12px 0 0; font-size:15.5px;
}
/* ---------- Trichter ---------- */
.funnel{
display:grid; grid-template-columns:repeat(4,1fr); gap:1px;
background:var(--line); border:1px solid var(--line);
margin:30px 0 0;
}
.step{background:var(--surface); padding:14px 16px 15px}
.step .n{
font-family:"IBM Plex Mono",monospace; font-size:23px; font-weight:500;
font-variant-numeric:tabular-nums; letter-spacing:-.02em; display:block;
}
.step .k{font-size:12px; color:var(--muted); margin-top:2px}
.step:last-child{background:var(--accent-soft)}
.step:last-child .n{color:var(--accent)}
/* ---------- Steuerung ---------- */
.controls{
display:flex; gap:10px; flex-wrap:wrap; align-items:center;
margin:26px 0 0; padding-bottom:14px; border-bottom:2px solid var(--ink);
}
.controls label{
font-family:"IBM Plex Mono",monospace; font-size:11px; letter-spacing:.1em;
text-transform:uppercase; color:var(--faint);
}
select{
font:inherit; font-size:13.5px; color:var(--ink); background:var(--surface);
border:1px solid var(--line); border-radius:2px; padding:5px 9px;
}
select:focus-visible, .expand:focus-visible, a:focus-visible{
outline:2px solid var(--focus); outline-offset:2px;
}
.count{margin-left:auto; font-family:"IBM Plex Mono",monospace; font-size:12px; color:var(--muted)}
/* ---------- Layout ---------- */
.cols{display:grid; grid-template-columns:1fr 250px; gap:44px; margin-top:4px; align-items:start}
@media(max-width:900px){ .cols{grid-template-columns:1fr; gap:36px} }
/* ---------- Story ---------- */
.story{
display:grid; grid-template-columns:38px 1fr; gap:16px;
padding:20px 0; border-bottom:1px solid var(--hair);
}
.rank{
font-family:"IBM Plex Mono",monospace; font-size:12px; color:var(--faint);
font-variant-numeric:tabular-nums; padding-top:5px;
}
.head{font-family:Newsreader,Georgia,serif; font-size:21px; line-height:1.28;
font-weight:500; margin:0; text-wrap:balance; letter-spacing:-.005em}
.head a{color:inherit; text-decoration:none; background-image:linear-gradient(var(--line),var(--line));
background-size:100% 1px; background-position:0 100%; background-repeat:no-repeat}
.head a:hover{background-image:linear-gradient(var(--accent),var(--accent))}
.meta{
display:flex; flex-wrap:wrap; gap:6px 12px; align-items:center;
margin-top:8px; font-size:12.5px; color:var(--muted);
}
.place{color:var(--ink)}
.mono{font-family:"IBM Plex Mono",monospace; font-variant-numeric:tabular-nums}
.dot{color:var(--faint)}
/* Goldstein-Instrument: divergierende Skala -10..+10 um die Mitte */
.scale{display:flex; align-items:center; gap:7px; margin-top:11px}
.track{
position:relative; width:132px; height:7px; background:var(--sunk); flex:0 0 auto;
}
.track::before{
content:""; position:absolute; left:50%; top:-2px; bottom:-2px; width:1px;
background:var(--faint); opacity:.55;
}
.fill{position:absolute; top:0; bottom:0}
.scale .val{font-family:"IBM Plex Mono",monospace; font-size:11.5px; font-variant-numeric:tabular-nums}
.tone{font-family:"IBM Plex Mono",monospace; font-size:11.5px; color:var(--muted)}
/* Quellen */
.sources{display:flex; flex-wrap:wrap; gap:6px; margin-top:10px}
.src{
font-family:"IBM Plex Mono",monospace; font-size:11px; color:var(--muted);
border:1px solid var(--line); padding:2px 6px; text-decoration:none;
}
.src:hover{border-color:var(--accent); color:var(--accent)}
/* CAMEO-Behauptungen */
.claims{margin-top:12px; border-left:2px solid var(--line); padding-left:13px;
display:flex; flex-direction:column; gap:5px}
.more{display:flex; flex-direction:column; gap:5px}
.claim{display:grid; grid-template-columns:46px 1fr; gap:10px; font-size:12.5px; align-items:baseline}
.code{font-family:"IBM Plex Mono",monospace; font-size:11.5px; color:var(--faint)}
.claim b{font-weight:500}
.actors{color:var(--muted)}
.q3,.q4{color:var(--conflict)}
.q1,.q2{color:var(--coop)}
.expand{
margin-top:9px; font:inherit; font-size:12px; color:var(--accent);
background:none; border:none; padding:0; cursor:pointer;
font-family:"IBM Plex Mono",monospace; letter-spacing:.02em;
}
.expand:hover{text-decoration:underline}
/* ---------- Rail ---------- */
.rail h2{
font-family:"IBM Plex Mono",monospace; font-size:11px; letter-spacing:.12em;
text-transform:uppercase; color:var(--faint); font-weight:500;
margin:0 0 12px; padding-bottom:8px; border-bottom:1px solid var(--line);
}
.rail section + section{margin-top:32px}
.bar{display:grid; grid-template-columns:1fr auto; gap:8px; align-items:center;
font-size:12.5px; padding:3px 0}
.bar .lab{position:relative; padding-left:0}
.bar .lab span{position:relative; z-index:1}
.bar .lab::before{
content:""; position:absolute; left:-4px; top:-2px; bottom:-2px;
width:var(--w); background:var(--accent-soft);
}
.bar .v{font-family:"IBM Plex Mono",monospace; font-size:11.5px; color:var(--muted);
font-variant-numeric:tabular-nums}
.note{font-size:12px; color:var(--muted); line-height:1.55; margin-top:10px}
.status{
border:1px solid var(--line); border-left:3px solid var(--conflict);
background:var(--surface); padding:16px 18px; margin:24px 0 0; font-size:14px;
}
.status h2{font-family:"IBM Plex Sans",sans-serif; font-size:14.5px; margin:0 0 6px; font-weight:600}
.status p{margin:0 0 8px; color:var(--muted); max-width:60ch}
.status pre{
font-family:"IBM Plex Mono",monospace; font-size:12.5px; background:var(--sunk);
padding:9px 11px; margin:0; overflow-x:auto;
}
footer{margin-top:44px; padding-top:18px; border-top:1px solid var(--line);
font-size:12.5px; color:var(--muted); max-width:70ch}
footer code{font-family:"IBM Plex Mono",monospace; font-size:11.5px; background:var(--sunk); padding:1px 4px}
@media(prefers-reduced-motion:reduce){*{transition:none!important; animation:none!important}}
</style>
<div class="wrap">
<header>
<div class="eyebrow">
<span>GDELT 2.0 &middot; Event-Feed</span>
<span id="stamp"></span>
</div>
<h1>Lagebild Europa</h1>
<p class="standfirst">
Alle 15 Minuten veröffentlicht GDELT ein Paket maschinell kodierter Ereignisse.
Diese Seite verdichtet einen Tag davon auf das, was ein Mensch tatsächlich lesen kann —
und zeigt darunter, welche CAMEO-Behauptungen die Maschine aus jedem Artikel gezogen hat.
</p>
<div class="funnel" id="funnel"></div>
</header>
<div class="controls">
<label for="land">Land</label>
<select id="land"></select>
<label for="sort">Sortierung</label>
<select id="sort">
<option value="articles">Reichweite</option>
<option value="conflict">Konfliktivität</option>
<option value="time">Zuletzt gesehen</option>
</select>
<span class="count" id="count"></span>
</div>
<div class="status" id="status" hidden></div>
<div class="cols">
<main id="list"></main>
<aside class="rail">
<section>
<h2>Ereignisorte</h2>
<div id="countries"></div>
<p class="note">Gezählt über alle Event-Zeilen des Tages, nicht nur die gezeigten Stories.</p>
</section>
<section>
<h2>Häufigste CAMEO-Codes</h2>
<div id="codes"></div>
<p class="note">Der Feed ist stark diplomatie-lastig: Besuche, Erklärungen, Konsultationen dominieren die Kodierung.</p>
</section>
<section>
<h2>Goldstein-Skala</h2>
<p class="note" style="margin-top:0">
Jedem CAMEO-Code ist ein fester Wert von −10 bis +10 zugeordnet — der theoretische
Konfliktbeitrag des <em>Ereignistyps</em>, keine Messung des konkreten Vorfalls.
<br><br>
<span class="mono" style="color:var(--conflict)">−10,0</span> Militärgewalt &middot;
<span class="mono" style="color:var(--conflict)">−5,0</span> Festnahme &middot;
<span class="mono" style="color:var(--coop)">+1,9</span> Besuch &middot;
<span class="mono" style="color:var(--coop)">+7,4</span> Wirtschaftshilfe
</p>
</section>
</aside>
</div>
<footer id="foot"></footer>
</div>
<script>
let D;
const nf = new Intl.NumberFormat('de-DE');
const sign = v => (v > 0 ? '+' : '−') + Math.abs(v).toFixed(1).replace('.', ',');
/* Trichter: die vier Verdichtungsstufen mit echten Zahlen */
function boot(data){
D = data;
document.getElementById('funnel').innerHTML = [
[D.events_world, 'Event-Zeilen weltweit'],
[D.events_europe, 'davon in Europa'],
[D.articles_total, 'verschiedene Artikel'],
[D.stories.length, 'Stories auf dieser Seite'],
].map(([n, k]) => `<div class="step"><span class="n">${nf.format(n)}</span><span class="k">${k}</span></div>`).join('');
document.getElementById('stamp').textContent =
D.date.split('-').reverse().join('.') + ' · ' + D.slots + ' Pakete · Stand ' + D.generated.slice(11, 16) + ' UTC';
/* Rail */
const railBars = (obj, el) => {
const max = Math.max(...Object.values(obj));
el.innerHTML = Object.entries(obj).map(([k, v]) =>
`<div class="bar"><div class="lab" style="--w:${(v / max * 100).toFixed(1)}%"><span>${k}</span></div><div class="v">${nf.format(v)}</div></div>`
).join('');
};
railBars(D.countries, document.getElementById('countries'));
railBars(D.top_codes, document.getElementById('codes'));
/* Landfilter */
const sel = document.getElementById('land');
const lands = [...new Set(D.stories.map(s => s.country))].sort((a, b) => a.localeCompare(b, 'de'));
sel.innerHTML = '<option value="">alle</option>' + lands.map(l => `<option>${l}</option>`).join('');
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const hhmm = s => s.slice(8, 10) + ':' + s.slice(10, 12);
function claimHTML(e) {
const cls = 'q' + (['verbal kooperativ', 'material kooperativ', 'verbal konfliktiv', 'material konfliktiv'].indexOf(e.quad) + 1);
const pair = [e.actor1, e.actor2].filter(Boolean).join(' → ') || 'ohne benannte Akteure';
const g = e.goldstein === null ? '' : ` <span class="code">${sign(e.goldstein)}</span>`;
return `<div class="claim"><span class="code">${esc(e.code)}</span>
<span><b class="${cls}">${esc(e.label)}</b>${g}<br><span class="actors">${esc(pair)}</span></span></div>`;
}
function storyHTML(s, i) {
const g = s.goldstein, w = Math.min(Math.abs(g) / 10, 1) * 50;
const col = g < -0.2 ? 'var(--conflict)' : g > 0.2 ? 'var(--coop)' : 'var(--neutralbar)';
const fill = g >= 0
? `left:50%;width:${w}%;background:${col}`
: `right:50%;width:${w}%;background:${col}`;
const shown = s.events.slice(0, 3), rest = s.events.slice(3);
const srcs = s.sources.length > 1
? `<div class="sources">${s.sources.map(q => `<a class="src" href="${esc(q.url)}" target="_blank" rel="noopener">${esc(q.domain)}</a>`).join('')}</div>` : '';
return `<article class="story">
<div class="rank">${String(i + 1).padStart(2, '0')}</div>
<div>
<h3 class="head"><a href="${esc(s.url)}" target="_blank" rel="noopener">${esc(s.title)}</a></h3>
<div class="meta">
<span class="place">${esc(s.country)}</span><span class="dot">·</span>
<span>${esc(s.place)}</span><span class="dot">·</span>
<span class="mono">${hhmm(s.seen)}</span><span class="dot">·</span>
<span class="mono">${nf.format(s.articles)} Artikel</span><span class="dot">·</span>
<span class="mono">${s.event_rows} Event-Zeilen → ${s.events.length} Behauptungen</span>
${s.sources.length > 1 ? `<span class="dot">·</span><span class="mono">${s.sources.length} Quellen</span>` : `<span class="dot">·</span><span class="mono">${esc(s.sources[0].domain)}</span>`}
</div>
<div class="scale">
<span class="track"><span class="fill" style="${fill}"></span></span>
<span class="val" style="color:${col}">${sign(g)}</span>
<span class="tone">Ton ${sign(s.tone)}</span>
</div>
${srcs}
<div class="claims">${shown.map(claimHTML).join('')}
${rest.length ? `<div class="more" hidden>${rest.map(claimHTML).join('')}</div>` : ''}</div>
${rest.length ? `<button class="expand" aria-expanded="false">+ ${rest.length} weitere Behauptungen</button>` : ''}
</div>
</article>`;
}
function render() {
const land = sel.value, mode = document.getElementById('sort').value;
let rows = D.stories.filter(s => !land || s.country === land);
if (mode === 'conflict') rows = [...rows].sort((a, b) => a.goldstein - b.goldstein);
else if (mode === 'time') rows = [...rows].sort((a, b) => b.seen.localeCompare(a.seen));
document.getElementById('list').innerHTML = rows.map(storyHTML).join('');
document.getElementById('count').textContent =
rows.length + (rows.length === 1 ? ' Story' : ' Stories');
}
document.getElementById('list').addEventListener('click', ev => {
const btn = ev.target.closest('.expand');
if (!btn) return;
const box = btn.previousElementSibling.lastElementChild;
const open = box.hidden;
box.hidden = !open;
btn.setAttribute('aria-expanded', String(open));
btn.textContent = open ? '− weniger' : `+ ${box.children.length} weitere Behauptungen`;
});
sel.addEventListener('change', render);
document.getElementById('sort').addEventListener('change', render);
document.getElementById('foot').innerHTML =
`Quelle: GDELT Project, Event-Datenbank 2.0 (<code>export.CSV</code>) verbunden mit den Artikeltiteln
aus dem Global Knowledge Graph (<code>gkg.csv</code>, Feld <code>&lt;PAGE_TITLE&gt;</code>).
Ereignisort gefiltert über <code>ActionGeo_CountryCode</code> in FIPS-10-4-Kodierung.
Die Behauptungen sind maschinelle Extraktionen und teils fehlerhaft — Ortszuordnung und
Akteurserkennung liegen sichtbar daneben, wenn ein Artikel mehrere Schauplätze nennt.`;
render();
}
/* Daten kommen zur Laufzeit - die Seite selbst wird nie neu gebaut,
nur stories.json alle 15 Minuten ueberschrieben. */
fetch('stories.json', {cache: 'no-store'})
.then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)))
.then(boot)
.catch(err => {
const box = document.getElementById('status');
const local = location.protocol === 'file:';
box.hidden = false;
box.innerHTML = local
? `<h2>Die Seite braucht einen Webserver</h2>
<p>Über <code>file://</code> blockiert der Browser das Laden von
<code>stories.json</code>. Starte im Ordner <code>site/</code> einen Server
und öffne die angezeigte Adresse:</p>
<pre>cd site &amp;&amp; python3 -m http.server 8000</pre>`
: `<h2>stories.json konnte nicht geladen werden</h2>
<p>${err.message}. Erzeuge die Datei neu:</p>
<pre>python build_site.py --limit 60</pre>`;
});
</script>