Files
gdelt-bot/site/index.html
T
2026-09-02 14:19:09 +02:00

375 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>