Admin: Weltküche-Herkunftsübersicht (QA-Seite)
Neue admin-geschützte Seite admin-weltkueche.html: spiegelt die Spiel-Logik (Heimat + Handels-Gravitation Menge÷Entfernung + Markt-Tags) und zeigt pro Gericht × Zutat die vom Spiel akzeptierten Herkunftsländer (🟢 richtig) und die Distraktoren (🔴). Filter nach Kategorie + Suche + „nur Auffällige". Auffällig-Heuristik: Heimat-Zweig-Zutat mit „richtigem" Land > 1500 km (surft echte Ausreißer, ohne Welt-Zutaten wie Pfeffer falsch zu flaggen) — aktuell 5 Kandidaten. In Admin-Nav + Backend-Werkzeuge verlinkt. Verifiziert: 30 Gerichte, Kartoffel/Eier → [AT, DE] (nicht Frankreich). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,7 @@
|
||||
<a href="admin-levels.html" class="btn-sm">🎮 Level</a>
|
||||
<a href="admin-styleguide.html" class="btn-sm">🎨 Styleguide</a>
|
||||
<a href="admin-accounts.html" class="btn-sm">👤 Admins</a>
|
||||
<a href="admin-weltkueche.html" class="btn-sm">🍽️ Weltküche-Herkunft</a>
|
||||
<a href="admin.html" class="btn-sm">Logout</a>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +56,7 @@
|
||||
<a class="btn btn-outline" href="sims/tourismustal/editor.php">🏠 Tourismustal-Sprite-Editor</a>
|
||||
<a class="btn btn-outline" href="stadt-editor">🏙️ Stadt-Raumplanung-Editor</a>
|
||||
<a class="btn btn-outline" href="foto-upload">📷 Schulbuch-Bildupload</a>
|
||||
<a class="btn btn-outline" href="admin-weltkueche.html">🍽️ Weltküche-Herkunftsübersicht (QA)</a>
|
||||
</div>
|
||||
<div style="font-size:.66rem;color:#8a8a8a;margin-top:.55rem">Alle nur mit Admin-Login erreichbar. Schulbuch-Upload zusätzlich per PIN (0815). Heli-Audio-Werkzeuge liegen nur lokal (<code>.LocalDeveloperTools</code>) und sind nicht auf dem Server.</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GeoGraSim — Weltküche-Herkunftsübersicht (Admin)</title>
|
||||
<link rel="icon" type="image/png" href="favicon-96x96.png" sizes="96x96"><link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="stylesheet" href="assets/fonts/inter.css">
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',sans-serif;background:#f7f6f3;min-height:100vh;color:#2a2a2a}
|
||||
.topbar{background:#fff;padding:.6rem 1.2rem;display:flex;align-items:center;gap:.8rem;border-bottom:1px solid rgba(0,0,0,.06);position:sticky;top:0;z-index:20}
|
||||
.topbar h1{font-size:.95rem;font-weight:800;color:#4a7c8a;flex:1;display:flex;align-items:center;gap:.5rem}
|
||||
.topbar h1 img{height:28px}
|
||||
.btn-sm{padding:.3rem .6rem;border:1px solid rgba(0,0,0,.08);border-radius:6px;background:#fff;font-size:.68rem;font-weight:600;cursor:pointer;color:#4a4a4a;text-decoration:none}
|
||||
.btn-sm:hover{background:#dae8ec;color:#4a7c8a}
|
||||
.wrap{max-width:1100px;margin:1rem auto;padding:0 1rem}
|
||||
.lead{font-size:.82rem;color:#6a6a6a;margin-bottom:.8rem;line-height:1.5}
|
||||
.lead b{color:#3a3a3a}
|
||||
.filters{display:flex;gap:.4rem;flex-wrap:wrap;margin-bottom:1rem;align-items:center}
|
||||
.filters input{padding:.4rem .6rem;border:1.5px solid rgba(0,0,0,.12);border-radius:7px;font-size:.8rem;font-family:inherit;min-width:220px}
|
||||
.chip{padding:.3rem .7rem;border-radius:99px;border:1.5px solid rgba(0,0,0,.12);background:#fff;font-size:.72rem;font-weight:600;cursor:pointer;color:#4a4a4a}
|
||||
.chip.on{background:#4a7c8a;color:#fff;border-color:#4a7c8a}
|
||||
.dish{background:#fff;border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,.04);border:1px solid rgba(0,0,0,.05);margin-bottom:1rem;overflow:hidden}
|
||||
.dish-h{padding:.7rem 1rem;background:#f0f5f6;border-bottom:1px solid rgba(0,0,0,.05);display:flex;align-items:center;gap:.6rem;flex-wrap:wrap}
|
||||
.dish-h .nm{font-size:1rem;font-weight:800;color:#2a4a52}
|
||||
.dish-h .heimat{font-size:.72rem;color:#6a6a6a}
|
||||
.dish-h .cat{font-size:.6rem;text-transform:uppercase;letter-spacing:.05em;background:#e8e4d8;color:#7a6540;padding:.1rem .5rem;border-radius:99px;font-weight:700}
|
||||
table{width:100%;border-collapse:collapse;font-size:.78rem}
|
||||
th{text-align:left;padding:.45rem .7rem;font-size:.62rem;text-transform:uppercase;letter-spacing:.04em;color:#8a8a8a;background:#fafaf7;border-bottom:1px solid rgba(0,0,0,.05)}
|
||||
td{padding:.5rem .7rem;border-bottom:1px solid rgba(0,0,0,.04);vertical-align:top}
|
||||
tr:last-child td{border-bottom:none}
|
||||
.ing-nm{font-weight:700;color:#3a3a3a;white-space:nowrap}
|
||||
.ing-nm small{display:block;color:#9a9a9a;font-weight:400;font-size:.66rem}
|
||||
.cty{display:inline-flex;align-items:center;gap:.25rem;padding:.12rem .45rem;border-radius:99px;font-size:.72rem;font-weight:600;margin:.1rem .2rem .1rem 0;white-space:nowrap}
|
||||
.cty.ok{background:#dceadd;color:#2f6b3a;border:1px solid #b6d8bd}
|
||||
.cty.dist{background:#f6e2dc;color:#a3472f;border:1px solid #e6c3b4}
|
||||
.cty.other{background:#eee;color:#7a7a7a;border:1px solid #ddd}
|
||||
.cty .g{opacity:.6;font-weight:400;font-size:.64rem}
|
||||
.warn{background:#fff5e6;border-left:3px solid #e0a020;padding:.35rem .6rem;border-radius:5px;font-size:.72rem;color:#8a6510;margin-top:.35rem}
|
||||
.colhead{font-size:.6rem;color:#8a8a8a;text-transform:uppercase;letter-spacing:.04em;margin-bottom:.2rem}
|
||||
.legend{font-size:.72rem;color:#6a6a6a;margin:.5rem 0 1rem;display:flex;gap:1rem;flex-wrap:wrap}
|
||||
#status{padding:2rem;text-align:center;color:#8a8a8a}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<h1><img src="assets/img/geograsim_t.svg" alt="GeoGraSim"> Weltküche — Herkunftsübersicht (Admin)</h1>
|
||||
<a href="admin-licenses.html" class="btn-sm">← Admin</a>
|
||||
<a href="admin.html" class="btn-sm">Logout</a>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<p class="lead">QA-Werkzeug: spiegelt die <b>Spiel-Logik</b> (Heimat + Handels-Gravitation Menge÷Entfernung, Markt-Tags). Pro Gericht & Zutat siehst du, welche Länder das Spiel als <b>richtig</b> akzeptiert und welche als <b>Distraktoren</b> im Topf sind. Ziel: kein „Warum Frankreich?", keine „Bananen aus Deutschland".</p>
|
||||
<div class="legend">
|
||||
<span><span class="cty ok">🟢 richtig</span> akzeptierte Herkunft</span>
|
||||
<span><span class="cty dist">🔴 Distraktor</span> falsch (fern/klimafremd/kein Lieferant)</span>
|
||||
<span><span class="cty other">⚪ nicht angeboten</span> Produzent, aber nicht im Spiel</span>
|
||||
<span><b>g</b> = Relevanz (Menge÷Entfernung zur Heimat)</span>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<input id="q" placeholder="Suche Gericht oder Zutat…" oninput="render()">
|
||||
<span class="chip on" data-cat="" onclick="setCat(this,'')">Alle</span>
|
||||
<span class="chip" data-cat="regional-at" onclick="setCat(this,'regional-at')">AT-regional</span>
|
||||
<span class="chip" data-cat="dach" onclick="setCat(this,'dach')">DACH</span>
|
||||
<span class="chip" data-cat="europa" onclick="setCat(this,'europa')">Europa</span>
|
||||
<span class="chip" data-cat="exotisch" onclick="setCat(this,'exotisch')">Exotisch</span>
|
||||
<label style="font-size:.72rem;color:#6a6a6a;margin-left:.5rem"><input type="checkbox" id="onlyWarn" onchange="render()"> nur Auffällige ⚠</label>
|
||||
</div>
|
||||
<div id="status">Lade Daten…</div>
|
||||
<div id="list"></div>
|
||||
</div>
|
||||
<script>
|
||||
var BASE = location.pathname.replace(/\/admin-weltkueche\.html$/, '');
|
||||
var D = 'sims/weltkueche/assets/data/';
|
||||
var C = {}, PM = {}, DISHES = [];
|
||||
var _cat = '';
|
||||
|
||||
// ---- Spiel-Logik gespiegelt (siehe game.html buildTaskFor) ----
|
||||
function mrank(m){ return m === 'eu' ? 0 : (m === 'world-far' || m === 'local') ? 2 : 1; }
|
||||
function wkDist(a, b){
|
||||
var A = C[a], B = C[b];
|
||||
if (!A || !B || A.lat == null || B.lat == null) return 3000;
|
||||
var R = 6371, toR = Math.PI/180;
|
||||
var dLat = (B.lat-A.lat)*toR, dLng = (B.lng-A.lng)*toR;
|
||||
var s = Math.pow(Math.sin(dLat/2),2) + Math.cos(A.lat*toR)*Math.cos(B.lat*toR)*Math.pow(Math.sin(dLng/2),2);
|
||||
return Math.max(60, 2*R*Math.asin(Math.min(1, Math.sqrt(s))));
|
||||
}
|
||||
// Klassifiziert alle Produzenten einer Zutat für ein Gericht.
|
||||
// Gibt { correct:[keys], distractors:[keys], branch } zurück (Pools, ohne Zufalls-Ziehung).
|
||||
function classify(ingKey, heimat){
|
||||
var ing = PM[ingKey]; if (!ing) return null;
|
||||
var producers = ing.producers || {};
|
||||
var keys = Object.keys(producers).filter(function(c){ return C[c]; });
|
||||
// Heimat-Zweig: Heimat erzeugt die Zutat selbst
|
||||
if (heimat && C[heimat] && producers[heimat]) {
|
||||
var grav = function(c){ return (producers[c].t || 0) / wkDist(heimat, c); };
|
||||
var realistic = keys.filter(function(c){ return c !== heimat && mrank(producers[c].market) < 2; })
|
||||
.sort(function(a,b){ return (mrank(producers[a].market)-mrank(producers[b].market)) || (grav(b)-grav(a)); });
|
||||
var correct = [heimat]; if (realistic[0]) correct.push(realistic[0]);
|
||||
if (correct.length < 2){ var any = keys.filter(function(c){ return c!==heimat && correct.indexOf(c)<0; })
|
||||
.sort(function(a,b){ return (producers[b].t||0)-(producers[a].t||0); }); if(any[0]) correct.push(any[0]); }
|
||||
var farP = keys.filter(function(c){ return correct.indexOf(c)<0 && mrank(producers[c].market)===2; });
|
||||
var climate = (ing.climate_possible||[]).filter(function(c){ return C[c] && correct.indexOf(c)<0; });
|
||||
return { correct: correct, distractors: farP.concat(climate), branch:'heimat', grav: grav };
|
||||
}
|
||||
// Normal-Zweig (exotisch / Heimat kein Produzent)
|
||||
var TIER = { eu:0, global:1, 'world-far':2 };
|
||||
var pool = keys.filter(function(c){ var m = producers[c].market || 'global'; return m==='eu'||m==='global'||m==='world-far'; })
|
||||
.sort(function(a,b){ var ma=producers[a].market||'global', mb=producers[b].market||'global';
|
||||
return ((TIER[ma]!=null?TIER[ma]:2)-(TIER[mb]!=null?TIER[mb]:2)) || ((producers[b].t||0)-(producers[a].t||0)); });
|
||||
var correct2 = pool.slice(0, Math.min(2, pool.length));
|
||||
var climate2 = (ing.climate_possible||[]).filter(function(c){ return C[c] && correct2.indexOf(c)<0; });
|
||||
var localFar = keys.filter(function(c){ var m=producers[c].market; return (m==='local'||m==='world-far') && correct2.indexOf(c)<0; });
|
||||
return { correct: correct2, distractors: localFar.concat(climate2), branch:'welt', grav: function(c){ return producers[c].t||0; } };
|
||||
}
|
||||
|
||||
function fmtT(t){ if(!t) return ''; if(t>=1e6) return (t/1e6).toFixed(t>=1e7?0:1)+' Mio t'; if(t>=1e3) return Math.round(t/1e3)+' Tsd t'; return t+' t'; }
|
||||
function ctyChip(key, cls, ing, heimat, showG){
|
||||
var c = C[key]; if(!c) return '';
|
||||
var g = '';
|
||||
if (showG && heimat && C[heimat] && key!==heimat){
|
||||
var t = (PM[ing].producers[key]||{}).t||0; var d = Math.round(wkDist(heimat,key));
|
||||
g = ' <span class="g">'+Math.round(t/Math.max(1,d))+'</span>';
|
||||
}
|
||||
return '<span class="cty '+cls+'" title="'+(c.n)+(PM[ing].producers[key]?(' · '+fmtT(PM[ing].producers[key].t)+' · Markt: '+(PM[ing].producers[key].market||'global')):'')+(heimat&&key!==heimat?(' · '+Math.round(wkDist(heimat,key))+' km'):'')+'">'+c.f+' '+c.n+g+'</span>';
|
||||
}
|
||||
|
||||
function render(){
|
||||
var q = (document.getElementById('q').value||'').toLowerCase().trim();
|
||||
var onlyWarn = document.getElementById('onlyWarn').checked;
|
||||
var host = document.getElementById('list'); var html = '';
|
||||
var shown = 0;
|
||||
DISHES.forEach(function(d){
|
||||
if (_cat && d.category !== _cat) return;
|
||||
var heimat = d.heimat || null;
|
||||
var ings = (d.ings||[]).filter(function(k){ return PM[k]; });
|
||||
// Filter Suche
|
||||
var matchDish = !q || (d.name||'').toLowerCase().indexOf(q)>=0 || ings.some(function(k){ return (PM[k].nm||k).toLowerCase().indexOf(q)>=0; });
|
||||
if (!matchDish) return;
|
||||
var rows = ''; var dishHasWarn = false;
|
||||
ings.forEach(function(k){
|
||||
if (q && (d.name||'').toLowerCase().indexOf(q)<0 && (PM[k].nm||k).toLowerCase().indexOf(q)<0) return;
|
||||
var cl = classify(k, heimat); if(!cl) return;
|
||||
// Auffällig NUR im Heimat-Zweig: die Zutat ist lokal erzeugbar (Heimat baut
|
||||
// sie an), trotzdem ist ein „richtiges" Land > 1500 km weg → echter Ausreißer.
|
||||
// Welt-Zutaten (Pfeffer aus Vietnam etc.) sind zu Recht fern → keine Warnung.
|
||||
var warn = '';
|
||||
if (heimat && C[heimat] && cl.branch === 'heimat'){
|
||||
var farCorrect = cl.correct.filter(function(c){ return c!==heimat && wkDist(heimat,c) > 1500; });
|
||||
if (farCorrect.length){ warn = '<div class="warn">⚠ „richtig" trotz > 1500 km von '+C[heimat].n+': '+farCorrect.map(function(c){return C[c].n+' ('+Math.round(wkDist(heimat,c))+' km)';}).join(', ')+'</div>'; dishHasWarn=true; }
|
||||
}
|
||||
if (onlyWarn && !warn) return;
|
||||
var okChips = cl.correct.map(function(c){ return ctyChip(c,'ok',k,heimat,true); }).join('');
|
||||
var distChips = cl.distractors.slice(0,6).map(function(c){ return ctyChip(c,'dist',k,heimat,false); }).join('');
|
||||
rows += '<tr><td class="ing-nm">'+(PM[k].ic||'')+' '+(PM[k].nm||k)+'<small>'+(cl.branch==='heimat'?'Heimat-Regel':'Welt-Zutat')+'</small></td>'
|
||||
+ '<td><div class="colhead">richtig</div>'+(okChips||'—')+warn+'</td>'
|
||||
+ '<td><div class="colhead">Distraktoren (Pool)</div>'+(distChips||'—')+'</td></tr>';
|
||||
});
|
||||
if (!rows) return;
|
||||
if (onlyWarn && !dishHasWarn) return;
|
||||
shown++;
|
||||
html += '<div class="dish"><div class="dish-h"><span class="nm">'+(d.name||d.id)+'</span>'
|
||||
+ (heimat&&C[heimat]?'<span class="heimat">Heimat: '+C[heimat].f+' '+C[heimat].n+'</span>':'<span class="heimat">ohne Heimat (Welt-Gericht)</span>')
|
||||
+ (d.category?'<span class="cat">'+d.category+'</span>':'')+'</div>'
|
||||
+ '<table><thead><tr><th style="width:26%">Zutat</th><th style="width:37%">🟢 Richtig</th><th style="width:37%">🔴 Distraktoren</th></tr></thead><tbody>'+rows+'</tbody></table></div>';
|
||||
});
|
||||
document.getElementById('status').style.display = 'none';
|
||||
host.innerHTML = html || '<div id="status" style="display:block">Keine Treffer.</div>';
|
||||
}
|
||||
function setCat(el, cat){ _cat = cat; document.querySelectorAll('.chip').forEach(function(c){ c.classList.toggle('on', c===el); }); render(); }
|
||||
|
||||
// Auth + Daten laden
|
||||
fetch(BASE + '/api/admin', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body:JSON.stringify({action:'status'})})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(r){
|
||||
if (!r.authenticated){ location.href = 'admin.html'; return; }
|
||||
return Promise.all([
|
||||
fetch(D+'countries-full.json').then(function(r){return r.json();}),
|
||||
fetch(D+'production-matrix.json').then(function(r){return r.json();}),
|
||||
fetch(D+'dishes.json').then(function(r){return r.json();}),
|
||||
]).then(function(a){
|
||||
C = a[0]; PM = a[1];
|
||||
DISHES = Object.keys(a[2]).filter(function(k){return k[0]!=='_';}).map(function(id){ return Object.assign({id:id}, a[2][id]); });
|
||||
render();
|
||||
});
|
||||
})
|
||||
.catch(function(e){ document.getElementById('status').textContent = 'Fehler: '+e.message; });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user