summaryrefslogtreecommitdiff
path: root/app/index.html
diff options
context:
space:
mode:
authorMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-09 13:59:59 +0200
committerMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-22 19:18:04 +0200
commit26c96e92cda9441ed3fe5e7b1b5849290bfef14c (patch)
tree432ea1645c8368715c235efd557f88c0ea475094 /app/index.html
parent26c9c4569d94b0bd458d4efec40ec51dad3c302a (diff)
Viel mehr Sprachen
Diffstat (limited to 'app/index.html')
-rw-r--r--app/index.html332
1 files changed, 308 insertions, 24 deletions
diff --git a/app/index.html b/app/index.html
index 5208c65..c9022b0 100644
--- a/app/index.html
+++ b/app/index.html
@@ -132,6 +132,31 @@ input[type=text]:focus-visible,input[type=number]:focus-visible{outline-offset:-
background:repeating-conic-gradient(var(--face) 0% 25%, var(--face-lt) 0% 50%) 0 0/2px 2px}
.seg.btnseg{margin-left:auto;align-self:flex-end;padding-bottom:2px}
.seg.btnseg button{font-size:11px;padding:3px 9px;min-height:22px}
+
+/* ---------- Sprachauswahl (Panel) ---------- *
+ Der Anker traegt position:relative, damit das absolut gesetzte Panel
+ an IHM haengt und nicht am Viewport; z-index ueber den Fenstern, weil
+ es sonst unter der naechsten Kachel verschwindet. */
+/* Linksbuendig, nicht rechts: die Kopfzeile wird von links gelesen, und eine
+ rechts ans Ende geschobene Leiste verschwindet neben der Unterzeile. */
+.langwrap{position:relative}
+.langbtn{appearance:none;background:var(--face);color:var(--ink);
+ font:11px var(--ui);padding:3px 10px;min-height:22px;cursor:pointer;white-space:nowrap;
+ border:1px solid var(--black);
+ box-shadow:inset 1px 1px 0 var(--white), inset -1px -1px 0 var(--gray)}
+.langbtn[aria-expanded=true]{font-weight:700;
+ box-shadow:inset 1px 1px 0 var(--gray), inset -1px -1px 0 var(--white)}
+/* Das Panel haengt an derselben Kante wie der Knopf — links. */
+.langpop{position:absolute;left:0;top:calc(100% + 3px);z-index:50;
+ background:var(--face);border:1px solid var(--black);padding:7px 8px 6px;
+ box-shadow:inset 1px 1px 0 var(--white), inset -1px -1px 0 var(--gray), 2px 2px 0 rgba(0,0,0,.35);
+ max-width:min(92vw,420px)}
+.langpop[hidden]{display:none}
+.langgrp+.langgrp{margin-top:6px}
+.langgrp-h{font:italic 10px var(--ui);letter-spacing:.4px;opacity:.75;margin-bottom:2px}
+.langpop .seg.btnseg{margin-left:0;align-self:auto;padding-bottom:0}
+.langlegend{font:10px var(--ui);opacity:.6;margin-top:7px;
+ border-top:1px solid var(--gray);padding-top:4px}
#cards{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}
@media(max-width:640px){#cards{grid-template-columns:1fr}}
/* Gekachelt haengt die Enge nicht mehr am Schirm, sondern am Fenster.
@@ -457,9 +482,24 @@ a{color:var(--navy)}
</div>
<script src="i18n.de.js"></script>
+<script src="i18n.de-x-amt.js"></script>
+<script src="i18n.de-x-sales.js"></script>
<script src="i18n.en.js"></script>
<script src="i18n.la.js"></script>
<script src="i18n.ja.js"></script>
+<script src="i18n.nl.js"></script>
+<script src="i18n.fr.js"></script>
+<script src="i18n.tr.js"></script>
+<script src="i18n.uk.js"></script>
+<script src="i18n.eo.js"></script>
+<script src="i18n.eu.js"></script>
+<script src="i18n.nds.js"></script>
+<script src="i18n.bar.js"></script>
+<script src="i18n.got.js"></script>
+<script src="i18n.goh.js"></script>
+<script src="i18n.gmh.js"></script>
+<script src="i18n.grc.js"></script>
+<script src="i18n.sa.js"></script>
<script>
/* =====================================================================
TEIL 0 — SPRACHEN
@@ -474,21 +514,95 @@ a{color:var(--navy)}
Sprache. Deshalb bekommen die Funktionen ROHWERTE (Zahlen) und formatieren
selbst ueber f, statt fertige Textstuecke einzusetzen.
===================================================================== */
-var LANGS = ['de','en','la','ja'], LANGKEY = 'altersvorsorge.lang';
-var DICT = { de: window.I18N_DE || {}, en: window.I18N_EN || {},
- la: window.I18N_LA || {}, ja: window.I18N_JA || {} };
+/* Sprachcodes nach ISO 639-1, wo es einen gibt, sonst 639-3. Dreibuchstabig
+ sind grc (Altgriechisch — el waere Neugriechisch und damit schlicht falsch),
+ nds (Niederdeutsch) und bar (Bairisch); keine dieser drei Sprachen hat einen
+ zweibuchstabigen Code. */
+var LANGS = ['de','de-x-amt','de-x-sales','en','nl','nds','bar','fr','eu','eo','tr','uk',
+ 'got','goh','gmh','la','grc','sa','ja'];
+var LANGKEY = 'altersvorsorge.lang';
+var DICT = { de: window.I18N_DE || {}, en: window.I18N_EN || {},
+ nl: window.I18N_NL || {}, nds: window.I18N_NDS || {},
+ bar:window.I18N_BAR || {}, fr: window.I18N_FR || {},
+ eu: window.I18N_EU || {}, eo: window.I18N_EO || {},
+ tr: window.I18N_TR || {}, uk: window.I18N_UK || {},
+ got:window.I18N_GOT || {}, goh: window.I18N_GOH || {},
+ gmh:window.I18N_GMH || {}, la: window.I18N_LA || {},
+ grc:window.I18N_GRC || {}, sa: window.I18N_SA || {},
+ ja: window.I18N_JA || {},
+ 'de-x-amt': window.I18N_DE_X_AMT || {},
+ 'de-x-sales': window.I18N_DE_X_SALES || {} };
var FMT = {}; /* wird in applyLocale() gefuellt */
+/* Autonyme fuer die Schaltflaechen. Ein Kuerzel im Knopf, der volle Name im
+ title — und zwar in der jeweiligen Sprache selbst, nicht uebersetzt: wer
+ Ukrainisch sucht, erkennt „Українська“ auch dann, wenn die Oberflaeche
+ gerade auf Baskisch steht. Das spart nebenbei 14×14 Woerterbucheintraege. */
+var AUTONYM = {
+ de:'Deutsch', en:'English', nl:'Nederlands', nds:'Plattdüütsch',
+ bar:'Boarisch', fr:'Français', eu:'Euskara', eo:'Esperanto',
+ tr:'Türkçe', uk:'Українська',
+ /* Die drei historischen Stufen tragen ihr jeweils zeitgenoessisches
+ Selbstbezeichnungswort: gutiska razda ist in Wulfilas Bibel belegt,
+ diutisc bei Notker, diutsch im klassischen Mittelhochdeutschen. */
+ got:'Gutiska razda', goh:'Diutisc', gmh:'Diutsch',
+ la:'Latina', grc:'Ἀρχαία Ἑλληνική', sa:'संस्कृतम्', ja:'日本語',
+ /* Registervarianten des Deutschen (BCP 47 § 2.2.7, privater Untertag):
+ keine Sprachen, sondern Stilebenen — das Autonym nennt daher den
+ Registernamen, nicht "Deutsch" ein zweites Mal. */
+ 'de-x-amt': 'Amtsdeutsch', 'de-x-sales': 'Vertriebsdeutsch'
+};
+
+/* Teiluebersetzte Sprachen: Oberflaeche, Feldnamen, Tabellenkoepfe und
+ Diagrammlegenden liegen vor, die langen juristischen Erlaeuterungen
+ fallen ueber tRaw() auf das Deutsche zurueck. Seit der zweiten Fassung
+ sind alle vormals teiluebersetzten Sprachen vollstaendig (459 von 459
+ Schluesseln); diese Tabelle ist daher aktuell leer, bleibt aber stehen,
+ weil pruefe.js sie erwartet und weil rechtslaeufige Sprachen (siehe
+ VORGEMERKT unten) hier voraussichtlich zuerst als Teilfassung landen. */
+var TEILWEISE = { };
+
+/* =====================================================================
+ VORGEMERKT — noch nicht gebaut. Beruehrt nicht die Woerterbuecher,
+ sondern diesen Abschnitt und das Stilblatt; deshalb steht es hier.
+
+ RECHTSLAEUFIGE SPRACHEN: he, fa, ar.
+ Die Uebersetzung ist der billige Teil. Zu tun waere:
+ - applyStatic() setzt bisher nur documentElement.lang; es braucht
+ zusaetzlich dir = RTL[LANG] ? 'rtl' : 'ltr' und eine Tabelle
+ var RTL = { he:1, fa:1, ar:1 };
+ - im Stilblatt margin-left/right und padding-left/right durch die
+ logischen Eigenschaften (margin-inline-start/-end) ersetzen, sonst
+ kippt die Kachelung. Die .seg-Leisten und .titlebar sind betroffen.
+ - Teil 8 zeichnet auf Canvas: dort gibt es keinen Bidi-Algorithmus.
+ Achsenbeschriftung, Legenden und der Wasserfall muessten gespiegelt
+ werden (ctx.setTransform mit negativem x plus Textanker rechts).
+ Das ist der eigentliche Aufwand, nicht die Sprache.
+ - fa und ar: LOCALES auf 'fa-u-nu-latn' bzw. 'ar-u-nu-latn' setzen,
+ sonst liefert ICU ostarabische Ziffern (۱۲۳۴) neben einem €.
+
+ Die Registervarianten des Deutschen (de-x-amt, de-x-sales) sind seit
+ der zweiten Fassung gebaut: siehe LANGS, DICT, AUTONYM oben sowie
+ pickLang() unten. LOCALES/GRUPPEN brauchten keine neuen Eintraege,
+ weil die bestehenden ||-Fallbacks ('de-DE' bzw. '.') fuer beide schon
+ das Richtige tun.
+ ===================================================================== */
+
/* Vorrang: ?lang= in der Adresse (teilbar) > gemerkte Wahl > Browsersprache.
- Latein wird nie automatisch gewählt — ein Browser, der la meldet, meint es
- fast sicher nicht als Anzeigesprache. */
+ Die toten und konstruierten Sprachen werden nie automatisch gewaehlt — ein
+ Browser, der la meldet, meint es fast sicher nicht als Anzeigesprache. */
function pickLang(){
- var q = /[?&]lang=([a-z]{2})/i.exec(location.search);
+ /* Grundcode 2-3 Buchstaben, danach beliebig viele BCP-47-Privatuntertags
+ (-x-amt, -x-sales, ...), je 1-8 alphanumerische Zeichen. */
+ var q = /[?&]lang=([a-z]{2,3}(?:-x-[a-z0-9]{1,8})*)/i.exec(location.search);
if (q && LANGS.indexOf(q[1].toLowerCase()) >= 0) return q[1].toLowerCase();
try { var s = localStorage.getItem(LANGKEY); if (LANGS.indexOf(s) >= 0) return s; } catch(e){}
var n = ((navigator.languages && navigator.languages[0]) || navigator.language || 'de').toLowerCase();
- if (/^ja/.test(n)) return 'ja';
- if (/^en/.test(n)) return 'en';
+ /* Laengster Praefix zuerst, damit nds vor nl und de nicht verdeckt wird. */
+ var auto = ['nds','bar','ja','nl','fr','eu','eo','tr','uk','en'];
+ for (var i = 0; i < auto.length; i++){
+ if (n === auto[i] || n.indexOf(auto[i] + '-') === 0) return auto[i];
+ }
return 'de';
}
var LANG = pickLang();
@@ -1325,8 +1439,18 @@ function encodeState() {
bekommt die kontinentale Schreibweise — römische Ziffern wären ein Witz auf
Kosten der Lesbarkeit, und die Trennzeichenkonvention ist ohnehin jünger
als jede lateinische Quelle. Japanisch gruppiert wie das Englische. */
-var LOCALES = { de:'de-DE', en:'en-GB', la:'de-DE', ja:'ja-JP' };
-var GRUPPEN = { de:'.', en:',', la:'.', ja:',' };
+var LOCALES = { de:'de-DE', en:'en-GB', nl:'nl-NL', nds:'de-DE', bar:'de-DE',
+ fr:'fr-FR', eu:'eu-ES', eo:'de-DE', tr:'tr-TR', uk:'uk-UA',
+ got:'de-DE', goh:'de-DE', gmh:'de-DE',
+ la:'de-DE', grc:'de-DE', sa:'de-DE', ja:'ja-JP' };
+/* Nur fuer den mehrdeutigen Eingabefall „1,234“ relevant, siehe parseNum().
+ Sprachen mit schmalem Leerraum als Tausendertrenner (fr, uk) bekommen
+ U+202F: dann matcht weder Punkt noch Komma, und beide werden als
+ Dezimalzeichen gelesen — genau richtig. */
+var GRUPPEN = { de:'.', en:',', nl:'.', nds:'.', bar:'.',
+ fr:' ', eu:'.', eo:'.', tr:'.', uk:' ',
+ got:'.', goh:'.', gmh:'.',
+ la:'.', grc:'.', sa:'.', ja:',' };
var LOCALE = 'de-DE', nf0 = null, nf2 = null, nfp = null;
function applyLocale(){
LOCALE = LOCALES[LANG] || 'de-DE';
@@ -2644,27 +2768,187 @@ function applyStatic(){
WINCHROME.forEach(function(fn){ fn(); });
}
+/* =====================================================================
+ SPRACHAUSWAHL
+
+ Neunzehn Knoepfe nebeneinander sind keine Auswahl mehr, sondern eine
+ Wand. Also: ein Knopf oeffnet ein Panel, das Panel gliedert nach
+ Sprachverwandtschaft. Die Gliederung ist genealogisch und flach —
+ eine Partition der Menge LANGS, keine Baumnavigation; ein Baum mit
+ 19 Blaettern hat mehr Knoten als Blaetter und kostet nur Klicks.
+
+ WARUM DIE GRUPPENNAMEN LATEINISCH SIND. Sie muessten sonst in jede
+ der 19 Sprachen uebersetzt werden — sechs Gruppen mal 19 Sprachen
+ sind 114 zusaetzliche Woerterbucheintraege, davon etliche in
+ Gotisch, Sanskrit und Althochdeutsch, fuer die es die Termini
+ „Indogermanistik" schlicht nicht gibt. Dasselbe Argument, das oben
+ die Autonyme begruendet: eine sprachneutrale Konvention statt n²
+ Eintraege. Die wissenschaftliche Nomenklatur ist im Lateinischen
+ ohnehin zu Hause (Linné lässt gruessen), und wer „Germanica" liest,
+ erkennt seine Gruppe in jeder der 19 Oberflaechensprachen wieder.
+
+ Das Kreuz † markiert historische Sprachstufen ohne heutige
+ Sprechergemeinschaft — got, goh, gmh, la, grc, sa.
+ ===================================================================== */
+var FAMILIEN = [
+ /* Registervarianten sind keine eigenen Sprachen, sondern Stilebenen
+ derselben (BCP 47 § 2.2.7). Sie stehen deshalb zusammen und zuerst,
+ nicht verteilt im germanischen Zweig. */
+ { tax:'Theodisca — registra', langs:['de','de-x-amt','de-x-sales'] },
+ { tax:'Germanica', langs:['nds','nl','en','bar','gmh','goh','got'] },
+ { tax:'Italica', langs:['fr','la'] },
+ { tax:'Indoeuropaea cetera', langs:['uk','grc','sa'] },
+ { tax:'Extra Indoeuropaeam', langs:['eu','tr','ja'] },
+ { tax:'Ficta', langs:['eo'] }
+];
+/* Zweigangabe fuer die Sammelgruppen, damit „cetera" nicht nur ein
+ Restposten ist: sie steht im title des jeweiligen Knopfes. */
+var ZWEIG = { uk:'Slavica', grc:'Hellenica', sa:'Indoaryana',
+ eu:'lingua sola (isolata)', tr:'Turcica', ja:'Iaponica' };
+var HISTORISCH = { got:1, goh:1, gmh:1, la:1, grc:1, sa:1 };
+
+/* Konsistenzpruefung zur Ladezeit: die Familien muessen eine Partition
+ von LANGS sein — keine Sprache doppelt, keine vergessen. Eine neue
+ Sprache in LANGS ohne Eintrag hier faellt sonst lautlos aus dem
+ Panel heraus, und genau das ist der Fehler, den niemand bemerkt. */
+(function(){
+ var flach = FAMILIEN.reduce(function(a,g){ return a.concat(g.langs); }, []);
+ var fehlt = LANGS.filter(function(l){ return flach.indexOf(l) < 0; });
+ var zuviel = flach.filter(function(l){ return LANGS.indexOf(l) < 0; });
+ var doppelt = flach.filter(function(l,i){ return flach.indexOf(l) !== i; });
+ if (fehlt.length || zuviel.length || doppelt.length){
+ console.warn('FAMILIEN ist keine Partition von LANGS —',
+ 'fehlt:', fehlt, 'unbekannt:', zuviel, 'doppelt:', doppelt);
+ }
+})();
+
+var langPopOffen = false;
+
+function langKnopf(l){
+ /* Registervarianten (de-x-amt, de-x-sales) sind zu lang fuer den
+ Knopf ("DE-X-AMT"); der Teil nach dem letzten Bindestrich reicht
+ zur Unterscheidung, weil lang="..." und AUTONYM ohnehin den vollen
+ Code bzw. Namen tragen. */
+ var kurz = l.indexOf('-') >= 0 ? l.slice(l.lastIndexOf('-') + 1) : l;
+ var b = el('button', null, kurz.toUpperCase() + (HISTORISCH[l] ? ' †' : ''));
+ b.type = 'button';
+ /* Der Name steht im Autonym da, nicht uebersetzt — siehe AUTONYM. */
+ b.title = AUTONYM[l]
+ + (ZWEIG[l] ? ' — ' + ZWEIG[l] : '')
+ + (TEILWEISE[l] ? ' — ' + t('ui.lang.partial') : '');
+ b.setAttribute('lang', l);
+ b.setAttribute('aria-label', AUTONYM[l]);
+ b.setAttribute('aria-pressed', LANG === l);
+ return b;
+}
+
function buildLangBar(){
var host = $('#langrow');
if (!host) return;
host.innerHTML = '';
- host.appendChild(el('span','sub', t('ui.lang') + ': '));
- var seg = el('div','seg btnseg');
- seg.style.marginLeft = '0';
- seg.setAttribute('role','group');
- seg.setAttribute('aria-label', t('ui.lang.aria'));
- LANGS.forEach(function(l){
- var b = el('button', null, l.toUpperCase());
- b.type = 'button';
- b.title = t('ui.lang.' + l);
- b.setAttribute('lang', l);
- b.setAttribute('aria-pressed', LANG === l);
- b.onclick = function(){ setLang(l); };
- seg.appendChild(b);
+ langPopOffen = false;
+
+ var wrap = el('div','langwrap');
+
+ var knopf = el('button','langbtn', t('ui.lang') + ': ' + AUTONYM[LANG] + ' ▾');
+ knopf.type = 'button';
+ knopf.id = 'langbtn';
+ knopf.setAttribute('lang', LANG);
+ /* aria-haspopup="dialog" passt zum role="dialog" des Panels; aria-expanded
+ traegt den Zustand. Beides zusammen ist das Muster, das Screenreader als
+ Aufklappmenue ansagen (WAI-ARIA 1.2 § 5.2.6). */
+ knopf.setAttribute('aria-haspopup','dialog');
+ knopf.setAttribute('aria-expanded','false');
+ knopf.setAttribute('aria-controls','langpop');
+
+ var pop = el('div','langpop');
+ pop.id = 'langpop';
+ pop.hidden = true;
+ pop.setAttribute('role','dialog');
+ pop.setAttribute('aria-modal','false');
+ pop.setAttribute('aria-label', t('ui.lang.aria'));
+
+ FAMILIEN.forEach(function(g){
+ var box = el('div','langgrp');
+ box.setAttribute('role','group');
+ box.setAttribute('aria-label', g.tax);
+ box.appendChild(el('div','langgrp-h', g.tax));
+ var seg = el('div','seg btnseg');
+ seg.style.marginLeft = '0';
+ g.langs.forEach(function(l){
+ var b = langKnopf(l);
+ b.onclick = function(){ langPopSchliessen(false); setLang(l); };
+ seg.appendChild(b);
+ });
+ box.appendChild(seg);
+ pop.appendChild(box);
});
- host.appendChild(seg);
+
+ /* Legende zum Kreuz. Sprachneutral gehalten (Zeichen + lateinischer
+ Terminus), aus demselben Grund wie die Gruppennamen. */
+ var leg = el('div','langlegend', '† linguae mortuae');
+ pop.appendChild(leg);
+
+ knopf.onclick = function(){ langPopOffen ? langPopSchliessen(true) : langPopOeffnen(); };
+
+ wrap.appendChild(knopf);
+ wrap.appendChild(pop);
+ host.appendChild(wrap);
+
+ if (TEILWEISE[LANG]){
+ var n = el('div','sub');
+ n.style.cssText = 'flex-basis:100%;margin-top:3px';
+ n.innerHTML = t('ui.lang.partialNote');
+ host.appendChild(n);
+ }
}
+function langPopOeffnen(){
+ var pop = $('#langpop'), knopf = $('#langbtn');
+ if (!pop || !knopf) return;
+ pop.hidden = false;
+ knopf.setAttribute('aria-expanded','true');
+ langPopOffen = true;
+ /* Der Fokus wandert auf die gerade aktive Sprache — das ist die Stelle,
+ von der aus der Nutzer denkt, nicht der Anfang der Liste. */
+ var akt = pop.querySelector('button[aria-pressed=true]') || pop.querySelector('button');
+ if (akt) akt.focus();
+}
+
+function langPopSchliessen(fokusZurueck){
+ var pop = $('#langpop'), knopf = $('#langbtn');
+ if (!pop || !knopf) return;
+ pop.hidden = true;
+ knopf.setAttribute('aria-expanded','false');
+ langPopOffen = false;
+ if (fokusZurueck) knopf.focus();
+}
+
+/* Die beiden Weltzustandsbeobachter werden EINMAL angemeldet, nicht bei
+ jedem buildLangBar() — sonst haetten wir nach n Sprachwechseln n
+ Handler, die alle dasselbe tun (ein klassisches Leck). */
+document.addEventListener('keydown', function(e){
+ if (!langPopOffen) return;
+ if (e.key === 'Escape'){ e.preventDefault(); langPopSchliessen(true); return; }
+ if (e.key !== 'Tab') return;
+ /* Fokusfalle: das Panel ist ein Dialog, also zyklisch. Die Liste der
+ fokussierbaren Elemente ist hier trivial — es sind ausschliesslich
+ Knoepfe, keine versteckten, keine tabindex=-1. */
+ var pop = $('#langpop');
+ if (!pop) return;
+ var f = [].slice.call(pop.querySelectorAll('button'));
+ if (!f.length) return;
+ var i = f.indexOf(document.activeElement);
+ e.preventDefault();
+ var j = e.shiftKey ? (i <= 0 ? f.length - 1 : i - 1) : (i < 0 || i === f.length - 1 ? 0 : i + 1);
+ f[j].focus();
+}, true);
+document.addEventListener('pointerdown', function(e){
+ if (!langPopOffen) return;
+ var wrap = e.target && e.target.closest ? e.target.closest('.langwrap') : null;
+ if (!wrap) langPopSchliessen(false);
+}, true);
+
function setLang(l){
if (l === LANG || LANGS.indexOf(l) < 0) return;
LANG = l;