375 lines
16 KiB
HTML
375 lines
16 KiB
HTML
<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 · 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 ·
|
||
<span class="mono" style="color:var(--conflict)">−5,0</span> Festnahme ·
|
||
<span class="mono" style="color:var(--coop)">+1,9</span> Besuch ·
|
||
<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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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><PAGE_TITLE></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 && 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>
|