Einheitliche Sim-Kopfzeile (Entwurf B) als Komponente + Lizenz-Datenmodell

- ggs-header.css/.js: schwebende Pillen-Kopfzeile aus vorhandenem
  .ggs-header-Markup (Kreis-Logo/Titel/Level als eine Pille, eigene
  SVG-Icons, Tempo-Panel links + Zustandsbutton, Musik-Panel unten,
  30% transparente Untermenues, Glossar target=_blank, Lehrplan raus).
  Enhance-Ansatz: Spiel-Logik (IDs/Klassen, data-speed) bleibt unberuehrt.
- Eingebaut in Klima 2D/3D, Fluss, Logistik (Auto-Tempo), Energiemanager,
  Kofferdetektiv 3D.
- admin-licenses.html: Datenmodell-Sektion (licenses-Tabelle, Lebenszyklus
  Generiert->Eingeloest->Zugewiesen, Beziehungen, Jahr->Schuljahr->Code).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 14:06:41 +02:00
parent 362aaba883
commit 502f271f36
9 changed files with 601 additions and 6 deletions
+215
View File
@@ -0,0 +1,215 @@
/* ============================================================
GGS-HEADER — Einheitliche Sim-Kopfzeile (Entwurf B, „schwebend")
------------------------------------------------------------
Wird von ggs-header.js aktiviert (Klasse .ggs-header--float auf
<header class="ggs-header">). Überführt das vorhandene
Design-System-Markup in die schwebende Pillen-Optik:
Kreis-Logo · Titel · Level = EINE Pille, rechts schwebende
Icon-Buttons, Tempo-/Musik-Panels als Untermenü (30 % transparent).
Styling ist auf .ggs-header--float gescoped → betrifft nur
Sims, die die Komponente laden.
============================================================ */
/* Werte 1:1 aus admin-styleguide.html (Entwurf B, von Thomas bestätigt) */
.ggs-header--float {
background: transparent !important;
border-bottom: none !important;
height: auto !important;
min-height: 50px;
gap: .5rem !important;
padding: .55rem .7rem !important;
position: relative;
z-index: 1000;
flex-wrap: nowrap;
}
/* --- Marken-Pille: Kreis-Logo · Titel · Level ---------------- */
.ggs-header--float .ggs-hpille {
display: flex;
align-items: center;
gap: .5rem;
height: 38px;
padding: 0 .8rem 0 .3rem;
background: rgba(255, 255, 255, .96);
border-radius: 19px;
box-shadow: 0 2px 9px rgba(0, 0, 0, .2);
flex: none;
}
.ggs-header--float .ggs-header-logo {
width: 30px;
height: 30px;
border-radius: 50%;
background: #1f4e5a;
flex: none;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
gap: 0;
}
.ggs-header--float .ggs-header-logo-icon {
width: 100% !important;
height: 100% !important;
object-fit: cover;
animation: none !important;
}
.ggs-header--float .ggs-header-logo-text { display: none !important; }
.ggs-header--float .ggs-header-separator { display: none !important; }
.ggs-header--float .ggs-header-module {
font-weight: 800;
color: #1f4e5a;
font-size: .92rem;
white-space: nowrap;
line-height: 1;
}
.ggs-header--float .ggs-header-badge {
font-size: .6rem;
font-weight: 800;
color: #fff;
background: #5a8a5e;
border-radius: 9px;
padding: .2rem .5rem;
line-height: 1;
white-space: nowrap;
}
/* Live-Tick-Punkt (klima) darf in der Pille mitlaufen */
.ggs-header--float .ggs-hpille .kw-tick-dot { flex: none; }
/* --- Schwebende Icon-Buttons (rechts + Home links) ----------- */
.ggs-header--float .ggs-btn-ghost,
.ggs-header--float .ggs-spd,
.ggs-header--float .ggs-mtrigger {
width: 38px;
height: 38px;
border-radius: 12px;
border: none;
background: rgba(255, 255, 255, .96);
box-shadow: 0 2px 9px rgba(0, 0, 0, .2);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #1f4e5a;
padding: 0;
flex: none;
text-decoration: none;
transition: background .15s, transform .1s;
}
.ggs-header--float .ggs-btn-ghost:hover,
.ggs-header--float .ggs-spd:hover,
.ggs-header--float .ggs-mtrigger:hover {
background: #fff;
color: #16323a;
border-radius: 12px;
transform: translateY(-1px);
}
.ggs-header--float .ggs-btn-ghost .ic,
.ggs-header--float .ggs-mtrigger .ic {
width: 20px;
height: 20px;
}
.ggs-header--float .ggs-header-spacer { flex: 1; }
.ggs-header--float .ggs-header-actions {
display: flex;
align-items: center;
gap: .5rem;
flex: none;
}
/* --- Tempo-Zustandsbutton (zeigt IMMER den aktuellen Speed) --- */
.ggs-header--float .ggs-tempo { position: relative; display: inline-flex; }
.ggs-header--float .ggs-spd {
flex-direction: column;
line-height: 1;
font-weight: 800;
}
.ggs-header--float .ggs-spd b { font-size: .82rem; }
.ggs-header--float .ggs-spd small {
font-size: .5rem;
font-weight: 700;
margin-top: 1px;
color: #4a7c8a;
letter-spacing: .02em;
}
.ggs-header--float .ggs-spd .ic { width: 18px; height: 18px; color: inherit; }
.ggs-header--float .ggs-spd.act { background: #4a7c8a; color: #fff; }
.ggs-header--float .ggs-spd.act small { color: #dbe9ee; }
/* --- Untermenü-Panels (Tempo links, Musik unten) ------------ */
.ggs-header--float .ggs-pop {
display: none;
align-items: center;
gap: .25rem;
height: 38px;
padding: 0 .3rem;
background: rgba(255, 255, 255, .7);
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
border-radius: 12px;
box-shadow: 0 2px 9px rgba(0, 0, 0, .14);
border: 1px solid rgba(255, 255, 255, .5);
position: absolute;
z-index: 1001;
}
.ggs-header--float .ggs-tempo.open .ggs-pop--tempo,
.ggs-header--float .ggs-music-wrap.open .ggs-pop--music { display: inline-flex; }
/* Tempo: öffnet LINKS vom Button, bleibt offen */
.ggs-header--float .ggs-pop--tempo {
right: calc(100% + 6px);
top: 50%;
transform: translateY(-50%);
}
/* Musik: klappt nach UNTEN, schließt nach Song-Auswahl */
.ggs-header--float .ggs-music-wrap { position: relative; display: inline-flex; }
.ggs-header--float .ggs-pop--music {
top: calc(100% + 6px);
right: 0;
gap: .5rem;
padding: 0 .55rem;
height: 38px;
}
/* --- Tempo-Segmente im Panel (nutzt bestehende .ggs-speed) --- */
.ggs-header--float .ggs-pop--tempo .ggs-speed {
display: inline-flex;
gap: .25rem;
border: none;
background: transparent;
box-shadow: none;
border-radius: 0;
overflow: visible;
}
.ggs-header--float .ggs-pop--tempo .ggs-speed-btn {
height: 30px;
min-width: 36px;
padding: 0 .6rem;
border: none !important;
background: transparent;
border-radius: 8px;
font-weight: 800;
font-size: .82rem;
color: #4a4a4a;
display: inline-flex;
align-items: center;
justify-content: center;
}
.ggs-header--float .ggs-pop--tempo .ggs-speed-btn:hover { background: rgba(74, 124, 138, .15); color: #1f4e5a; }
.ggs-header--float .ggs-pop--tempo .ggs-speed-btn.active { background: #4a7c8a; color: #fff; }
/* --- Musik-Widget im Panel (bestehendes #ggs-music) --------- */
.ggs-header--float .ggs-pop--music .ggs-music {
display: inline-flex;
align-items: center;
gap: .5rem;
background: transparent;
box-shadow: none;
border: none;
padding: 0;
height: auto;
}
.ggs-header--float .ggs-pop--music .ggs-music[hidden] { display: inline-flex !important; }
/* Schmaler auf iPad-Landscape (Platz sparen) */
@media (max-width: 1100px) {
.ggs-header--float .ggs-header-actions { gap: .4rem; }
}
+274
View File
@@ -0,0 +1,274 @@
/* ============================================================
GGS-HEADER — Einheitliche Sim-Kopfzeile (Entwurf B)
------------------------------------------------------------
Überführt das vorhandene <header class="ggs-header">-Markup
(Design-System) in die schwebende Pillen-Optik OHNE die
Spiel-Logik anzufassen: bestehende IDs/Klassen (#btn-sound,
#btn-reset, [data-speed], #level-badge, #ggs-music …) bleiben
erhalten, die Handler der Sim greifen weiter.
Nutzung: einfach ggs-header.css + ggs-header.js einbinden.
Auto-Init auf DOMContentLoaded für jedes .ggs-header.
Optionen über data-Attribute am <header>:
data-ggs-home Home-Ziel (Default: vorh. Home-Link / "/schueler")
data-ggs-glossar Glossar-URL (Default: "/glossar")
data-ggs-sim Sim-ID (für /glossar?module=<id>)
data-ggs-no-glossar Glossar-Button weglassen
============================================================ */
(function () {
'use strict';
var SPRITE_ID = 'ggs-header-sprite';
var ICONS = {
home: '<path d="M4 11.4 12 4l8 7.4"/><path d="M6 10.3V19a1 1 0 0 0 1 1h3v-5h4v5h3a1 1 0 0 0 1-1v-8.7"/>',
tempo: '<path d="M4.6 16.5a7.5 7.5 0 0 1 14.8 0"/><path d="M12 16 15.2 12"/><circle cx="12" cy="16.3" r="1.3" fill="currentColor" stroke="none"/>',
music: '<path d="M9 17V6.4l9-1.8V15"/><circle cx="6.5" cy="17" r="2.4"/><circle cx="15.5" cy="15" r="2.4"/>',
sound: '<path d="M4 9.5h3.2L11 6v12l-3.8-3.5H4z"/><path d="M14.6 9.6a4 4 0 0 1 0 4.8"/><path d="M17 7.4a7 7 0 0 1 0 9.2"/>',
'sound-off': '<path d="M4 9.5h3.2L11 6v12l-3.8-3.5H4z"/><path d="M15.5 10l4 4"/><path d="M19.5 10l-4 4"/>',
restart: '<path d="M19 12a7 7 0 1 1-2-4.9"/><path d="M19 4.5V9h-4.5"/>',
help: '<circle cx="12" cy="12" r="8.4"/><path d="M9.7 9.3a2.4 2.4 0 0 1 4.6.8c0 1.6-2.3 1.8-2.3 3.2"/><circle cx="12" cy="16.4" r=".7" fill="currentColor" stroke="none"/>',
glossar: '<path d="M12 6.6C10 5.2 7 5.2 5 6.2v11c2-1 5-1 7 .4"/><path d="M12 6.6c2-1.4 5-1.4 7-.4v11c-2-1-5-1-7 .4"/><path d="M12 6.6v11.4"/>',
pause: '<rect x="6" y="5" width="4" height="14" rx="1.2" fill="currentColor"/><rect x="14" y="5" width="4" height="14" rx="1.2" fill="currentColor"/>',
caret: '<path d="M7 10l5 5 5-5"/>'
};
/* SVG-Sprite einmalig ins Dokument legen */
function injectSprite() {
if (document.getElementById(SPRITE_ID)) return;
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('id', SPRITE_ID);
svg.setAttribute('width', '0');
svg.setAttribute('height', '0');
svg.setAttribute('aria-hidden', 'true');
svg.style.position = 'absolute';
var defs = '';
for (var key in ICONS) {
var stroked = (key === 'pause');
defs += '<symbol id="ggic-' + key + '" viewBox="0 0 24 24"' +
(stroked ? '' : ' fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"') +
'>' + ICONS[key] + '</symbol>';
}
svg.innerHTML = '<defs>' + defs + '</defs>';
document.body.insertBefore(svg, document.body.firstChild);
}
function icon(name, cls) {
return '<svg class="ic ' + (cls || '') + '" aria-hidden="true"><use href="#ggic-' + name + '"/></svg>';
}
function setIcon(el, name, title) {
if (!el) return;
el.innerHTML = icon(name);
if (title) el.setAttribute('title', title);
}
/* Ersten passenden Button per ID-Liste finden (bespoke IDs abdecken) */
function byIds(scope, ids) {
for (var i = 0; i < ids.length; i++) {
var el = scope.querySelector('#' + ids[i]);
if (el) return el;
}
return null;
}
var SOUND_IDS = ['btn-sound', 'btn-mute', 'btnVoiceToggle', 'btnAudioToggle', 'btnAudio', 'btn-ton', 'btn-audio'];
var RESET_IDS = ['btn-reset', 'btnReset', 'btn-restart', 'btn-neustart'];
var HELP_IDS = ['btn-info', 'btnHelp', 'btn-help', 'btn-anleitung', 'btn-hilfe'];
/* Glyph-Buttons (Ton), die die Sim per textContent umschaltet:
Icon nach jeder Änderung nachziehen, Mute-Zustand als Icon spiegeln. */
function watchGlyphBtn(btn, mapFn, title) {
if (!btn) return;
if (title) btn.setAttribute('title', title);
var apply = function () {
if (btn.querySelector('svg.ic')) return; // schon Icon → nichts tun
setIcon(btn, mapFn(btn.textContent || ''));
};
apply();
new MutationObserver(apply).observe(btn, { childList: true, characterData: true, subtree: true });
}
/* ------------------------------------------------------------ */
function enhance(header, opts) {
if (!header || header.hasAttribute('data-ggs-enhanced')) return;
opts = opts || {};
injectSprite();
header.setAttribute('data-ggs-enhanced', '1');
header.classList.add('ggs-header--float');
var q = function (sel) { return header.querySelector(sel); };
var homeHref = opts.home || header.getAttribute('data-ggs-home') || '/schueler';
var simId = opts.sim || header.getAttribute('data-ggs-sim') || '';
var glossarHref = opts.glossar || header.getAttribute('data-ggs-glossar') ||
('/glossar' + (simId ? '?module=' + encodeURIComponent(simId) : ''));
var actions = q('.ggs-header-actions');
/* --- 1) Marken-Pille: Logo · Titel · Level ---------------- */
var logo = q('.ggs-header-logo');
var pille = document.createElement('div');
pille.className = 'ggs-hpille';
if (logo) {
logo.parentNode.insertBefore(pille, logo);
pille.appendChild(logo);
} else {
header.insertBefore(pille, header.firstChild);
}
var mod = q('.ggs-header-module'); if (mod) pille.appendChild(mod);
var badges = header.querySelectorAll('.ggs-header-badge');
for (var i = 0; i < badges.length; i++) pille.appendChild(badges[i]);
var tick = header.querySelector('.kw-tick-dot'); if (tick) pille.appendChild(tick);
/* --- 2) Home-Button links neben die Pille ----------------- */
var homeBtn = null;
var acts = header.querySelectorAll('.ggs-header-actions a, .ggs-header-actions button');
for (var h = 0; h < acts.length; h++) {
var a = acts[h];
var href = a.getAttribute('href') || '';
if (a.id === 'btn-info' || a.id === 'btn-sound' || a.id === 'btn-reset' || a.id === 'ggs-lehrplan-link') continue;
if (/schueler|cockpit|\/$/.test(href) || (a.textContent || '').indexOf('🏠') >= 0) { homeBtn = a; break; }
}
if (!homeBtn) {
homeBtn = document.createElement('a');
if (actions) actions.appendChild(homeBtn);
}
homeBtn.className = 'ggs-btn-ghost';
if (!homeBtn.getAttribute('href')) homeBtn.setAttribute('href', homeHref);
setIcon(homeBtn, 'home', 'Startseite');
header.insertBefore(homeBtn, pille.nextSibling);
/* Spacer sicherstellen (zwischen Home-Gruppe und Aktionen) */
var spacer = q('.ggs-header-spacer');
if (!spacer) {
spacer = document.createElement('div');
spacer.className = 'ggs-header-spacer';
}
header.insertBefore(spacer, actions);
/* --- 3) Aktions-Icons setzen (bespoke IDs via Listen) ----- */
var helpBtn = byIds(header, HELP_IDS);
var soundBtn = byIds(header, SOUND_IDS);
var resetBtn = byIds(header, RESET_IDS);
setIcon(helpBtn, 'help', 'Hilfe / Anleitung');
watchGlyphBtn(soundBtn, function (t) { return /🔇|🔈|🔕|off|aus|mute|stumm/i.test(t) ? 'sound-off' : 'sound'; }, 'Ton an/aus');
setIcon(resetBtn, 'restart', 'Neu starten');
var lehrplan = q('#ggs-lehrplan-link'); if (lehrplan) lehrplan.style.display = 'none';
/* --- 4) Tempo-Panel (nur wenn .ggs-speed vorhanden) ------- */
var speed = q('.ggs-speed');
var tempoWrap = null, spdBtn = null;
if (speed) {
tempoWrap = document.createElement('div');
tempoWrap.className = 'ggs-tempo';
spdBtn = document.createElement('button');
spdBtn.className = 'ggs-spd';
spdBtn.type = 'button';
spdBtn.setAttribute('title', 'Tempo');
var pop = document.createElement('div');
pop.className = 'ggs-pop ggs-pop--tempo';
speed.parentNode.insertBefore(tempoWrap, speed);
pop.appendChild(speed);
tempoWrap.appendChild(pop);
tempoWrap.appendChild(spdBtn);
var syncSpeed = function () {
var active = speed.querySelector('.ggs-speed-btn.active') || speed.querySelector('.ggs-speed-btn[data-speed="1"]');
if (!active) return;
var sp = active.getAttribute('data-speed'); // klassische Speed-Buttons
var txt = (active.textContent || '').replace(/\s+/g, ' ').trim();
var isAuto = /auto/i.test(active.getAttribute('title') || '') || active.hasAttribute('data-auto') || active.getAttribute('data-mode') === 'auto';
var isPause = sp === '0' || active.getAttribute('data-mode') === 'pause' || /^\s*(pause|⏸)/i.test(txt);
if (isPause) {
spdBtn.innerHTML = icon('pause');
} else if (isAuto) {
// Auto-Label (z. B. „Auto 1×") steht schon voll im Text → nicht doppeln
spdBtn.innerHTML = /auto/i.test(txt) ? '<b>' + txt + '</b>' : '<b>' + (txt || 'Auto') + '</b><small>Auto</small>';
} else {
spdBtn.innerHTML = '<b>' + (txt || (sp + '×')) + '</b>';
}
spdBtn.classList.toggle('act', isPause || isAuto || (sp !== null && sp !== '1'));
};
syncSpeed();
// Klick auf ein Segment: Sim-Handler läuft weiter, Label spiegeln, Panel bleibt offen
var spBtns = speed.querySelectorAll('.ggs-speed-btn');
for (var s = 0; s < spBtns.length; s++) {
spBtns[s].addEventListener('click', function () { setTimeout(syncSpeed, 0); });
}
// Ein Observer über den ganzen Speed-Block: fängt Klassen-Flip UND Text-Änderung (Auto 1×↔8×)
var mo = new MutationObserver(syncSpeed);
mo.observe(speed, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['class'] });
spdBtn.addEventListener('click', function (e) {
e.stopPropagation();
tempoWrap.classList.toggle('open');
});
}
/* --- 5) Musik-Panel (nur wenn #ggs-music vorhanden) ------- */
var music = q('#ggs-music');
var musicWrap = null;
if (music) {
musicWrap = document.createElement('div');
musicWrap.className = 'ggs-music-wrap';
var mtrigger = document.createElement('button');
mtrigger.className = 'ggs-mtrigger';
mtrigger.type = 'button';
mtrigger.setAttribute('title', 'Musik');
mtrigger.innerHTML = icon('music');
var mpop = document.createElement('div');
mpop.className = 'ggs-pop ggs-pop--music';
music.parentNode.insertBefore(musicWrap, music);
music.removeAttribute('hidden');
mpop.appendChild(music);
musicWrap.appendChild(mtrigger);
musicWrap.appendChild(mpop);
mtrigger.addEventListener('click', function (e) {
e.stopPropagation();
musicWrap.classList.toggle('open');
});
// Nach Song-Auswahl Panel schließen
var sel = music.querySelector('#ggs-music-sel, select');
if (sel) sel.addEventListener('change', function () { musicWrap.classList.remove('open'); });
}
/* --- 6) Glossar-Button (target=_blank) -------------------- */
if (!opts.noGlossar && !header.hasAttribute('data-ggs-no-glossar') && actions) {
var gl = document.createElement('a');
gl.className = 'ggs-btn-ghost ggs-glossar';
gl.setAttribute('href', glossarHref);
gl.setAttribute('target', '_blank');
gl.setAttribute('rel', 'noopener');
setIcon(gl, 'glossar', 'Glossar (neuer Tab)');
actions.appendChild(gl);
}
/* --- 7) Aktionen in Mockup-Reihenfolge sortieren ---------- */
if (actions) {
var order = [tempoWrap, musicWrap, soundBtn, resetBtn, helpBtn, q('.ggs-glossar')];
for (var o = 0; o < order.length; o++) if (order[o]) actions.appendChild(order[o]);
}
/* --- 8) Panels bei Außen-Klick schließen ------------------ */
document.addEventListener('click', function (e) {
if (musicWrap && !musicWrap.contains(e.target)) musicWrap.classList.remove('open');
// Tempo bleibt bewusst offen (Toggle) — nur Klick auf den Button schließt
});
return header;
}
function autoInit() {
var headers = document.querySelectorAll('.ggs-header:not([data-ggs-enhanced])');
for (var i = 0; i < headers.length; i++) {
if (headers[i].getAttribute('data-ggs-no-float') === '1') continue;
try { enhance(headers[i]); }
catch (err) { if (window.console) console.warn('[ggs-header] enhance fehlgeschlagen:', err); }
}
}
window.GGSHeader = { enhance: enhance, autoInit: autoInit, icon: icon };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', autoInit);
} else {
autoInit();
}
})();