Neue Marker Unterarm
This commit is contained in:
232
doc/multilingual.md
Normal file
232
doc/multilingual.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Mehrsprachigkeit (DE/EN) – Vorschlag
|
||||
|
||||
## Ausgangslage
|
||||
|
||||
Die App ist heute komplett deutschsprachig, und zwar an drei verschiedenen Stellen, die unterschiedlich behandelt werden müssen:
|
||||
|
||||
1. **Statisches HTML-Markup** – Labels, Überschriften, `placeholder`/`title`-Attribute direkt in
|
||||
`public/*.html` (`index.html`, `homing.html`, `calibration*.html`, …).
|
||||
Beispiel: `<h2>Aktionen</h2>`, `<button title="Erst Homing ausführen">`.
|
||||
2. **Dynamisch erzeugte Strings in Client-JS** – Template-Literals in `public/client.js`,
|
||||
`public/homing.js`, `public/calibration.js` etc., z. B.
|
||||
`txt.textContent = text || \`Schritt ${step} / ${total}\`;` oder
|
||||
`result.innerHTML = '<span>⚠ Bitte Set auswählen, das verschoben werden soll.</span>'`.
|
||||
3. **Server-seitige Strings**, die als JSON an den Client durchgereicht und dort angezeigt werden,
|
||||
z. B. `res.status(400).json({ error: '"y" und "z" müssen Zahlen sein.' })` in
|
||||
[server/server.js](../server/server.js).
|
||||
|
||||
Es gibt kein Build-Tool (kein Webpack/Vite/React) – alles ist Vanilla HTML/JS, das Express
|
||||
als statische Dateien ausliefert ([server/server.js](../server/server.js)). Jede Lösung sollte
|
||||
also **ohne Build-Schritt** funktionieren und sich inkrementell einführen lassen (nicht „big bang“,
|
||||
sondern Seite für Seite).
|
||||
|
||||
## Zielbild
|
||||
|
||||
- Sprache wird primär aus `navigator.language` (Browser-Einstellung) bestimmt, mit manuellem
|
||||
Override (Dropdown/Flag-Icon in der Topbar) und Persistenz in `localStorage`.
|
||||
- Fallback-Sprache ist Deutsch (aktueller Stand), zweite Sprache Englisch.
|
||||
- Übersetzungen liegen in einfachen JSON-Dateien, kein zusätzliches npm-Paket nötig
|
||||
(es reicht ein paar Dutzend Zeilen eigener Lade-/Ersetzungslogik).
|
||||
- Server-Fehlermeldungen werden nicht als fertiger Text geschickt, sondern als
|
||||
**Fehlercode**, den der Client anhand der aktuellen Sprache übersetzt.
|
||||
|
||||
## 1. Struktur der Sprachdateien
|
||||
|
||||
```
|
||||
public/
|
||||
i18n/
|
||||
de.json
|
||||
en.json
|
||||
i18n.js <- kleine Lade-/Helper-Bibliothek, von jeder Seite eingebunden
|
||||
```
|
||||
|
||||
`de.json` (Auszug, Keys gruppiert nach Seite/Bereich, „flach mit Punktnotation“ ist für diese
|
||||
Größenordnung einfacher zu pflegen als tief verschachteltes JSON):
|
||||
|
||||
```json
|
||||
{
|
||||
"common.back": "← Zurück",
|
||||
"common.error": "Fehler",
|
||||
"index.actions.title": "Aktionen",
|
||||
"index.actions.runHoming": "📷 Foto & Homing berechnen",
|
||||
"index.actions.sendToRobot": "✅ An Roboter senden",
|
||||
"index.actions.sendDisabledTitle": "Erst Homing ausführen",
|
||||
"index.status.idle": "○ Warte",
|
||||
"index.status.running": "● Läuft …",
|
||||
"calibration.move.selectSetWarning": "⚠ Bitte Set auswählen, das verschoben werden soll.",
|
||||
"calibration.move.sameSetWarning": "⚠ \"Bleibt\" und \"verschoben\" dürfen nicht dasselbe Set sein.",
|
||||
"server.error.yzMustBeNumbers": "\"y\" und \"z\" müssen Zahlen sein.",
|
||||
"server.error.invalidAxisPayload": "Ungültige Nutzlast: axis.dir und axis.referencePoint erwartet"
|
||||
}
|
||||
```
|
||||
|
||||
`en.json` hat exakt dieselben Keys mit englischen Werten. Das ist die einzige Stelle, an der eine
|
||||
fehlende Übersetzung sofort auffällt: Key in `de.json` ohne Pendant in `en.json` → Fallback auf
|
||||
Deutsch (siehe unten), kein Crash.
|
||||
|
||||
**Warum nicht ein JSON pro HTML-Seite?** Weil viele Strings (Status-Badges, Fehlermeldungen,
|
||||
Buttons wie „Zurück“) seitenübergreifend wiederverwendet werden. Eine flache `common.*`-Gruppe plus
|
||||
eine Gruppe pro Seite (`index.*`, `homing.*`, `calibration.*`) hält das überschaubar, ohne dass man
|
||||
Strings doppelt pflegt.
|
||||
|
||||
## 2. Statisches HTML markieren
|
||||
|
||||
Jedes übersetzbare Element bekommt ein `data-i18n`-Attribut mit dem Key. Für Attribute (z. B.
|
||||
`title`, `placeholder`) gibt es ein zusätzliches `data-i18n-attr`:
|
||||
|
||||
```html
|
||||
<h2 data-i18n="index.actions.title">Aktionen</h2>
|
||||
|
||||
<button id="btn-homing-send" disabled
|
||||
data-i18n="index.actions.sendToRobot"
|
||||
data-i18n-attr-title="index.actions.sendDisabledTitle"
|
||||
title="Erst Homing ausführen">
|
||||
✅ An Roboter senden
|
||||
</button>
|
||||
```
|
||||
|
||||
Wichtig: Der **deutsche Text bleibt im HTML stehen** (als Fallback/Default und damit die Seite ohne
|
||||
JS oder bei Ladefehler nicht leer ist). `i18n.js` ersetzt den Inhalt nur, wenn die Zielsprache nicht
|
||||
Deutsch ist bzw. wenn ein Override gesetzt wurde. Das macht die Migration risikoarm: Man kann Seite
|
||||
für Seite Attribute ergänzen, ohne dass etwas kaputtgeht, falls eine Seite noch nicht migriert ist.
|
||||
|
||||
## 3. `i18n.js` – minimale Laufzeit-Bibliothek
|
||||
|
||||
Kernfunktionen, ca. 60–80 Zeilen, kein Tooling nötig:
|
||||
|
||||
```js
|
||||
// public/i18n.js
|
||||
const SUPPORTED = ['de', 'en'];
|
||||
const FALLBACK = 'de';
|
||||
|
||||
function detectLang() {
|
||||
const stored = localStorage.getItem('lang');
|
||||
if (stored && SUPPORTED.includes(stored)) return stored;
|
||||
const nav = (navigator.language || FALLBACK).slice(0, 2).toLowerCase();
|
||||
return SUPPORTED.includes(nav) ? nav : FALLBACK;
|
||||
}
|
||||
|
||||
let dict = {};
|
||||
let fallbackDict = {};
|
||||
|
||||
export async function initI18n() {
|
||||
const lang = detectLang();
|
||||
[dict, fallbackDict] = await Promise.all([
|
||||
fetch(`/i18n/${lang}.json`).then(r => r.json()),
|
||||
lang === FALLBACK ? Promise.resolve({}) : fetch(`/i18n/${FALLBACK}.json`).then(r => r.json()),
|
||||
]);
|
||||
document.documentElement.lang = lang;
|
||||
applyToDom();
|
||||
return lang;
|
||||
}
|
||||
|
||||
export function t(key, vars) {
|
||||
let str = dict[key] ?? fallbackDict[key] ?? key; // Key selbst als letzter Fallback -> sichtbar im UI statt "undefined"
|
||||
if (vars) for (const [k, v] of Object.entries(vars)) str = str.replaceAll(`{${k}}`, v);
|
||||
return str;
|
||||
}
|
||||
|
||||
function applyToDom(root = document) {
|
||||
root.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.dataset.i18n); });
|
||||
root.querySelectorAll('[data-i18n-attr-title]').forEach(el => { el.title = t(el.dataset.i18nAttrTitle); });
|
||||
root.querySelectorAll('[data-i18n-attr-placeholder]').forEach(el => { el.placeholder = t(el.dataset.i18nAttrPlaceholder); });
|
||||
}
|
||||
|
||||
export async function setLang(lang) {
|
||||
localStorage.setItem('lang', lang);
|
||||
location.reload(); // einfach & robust, kein dynamisches Re-Rendering nötig bei dieser App-Größe
|
||||
}
|
||||
```
|
||||
|
||||
Einbindung pro Seite (vor dem bestehenden Seiten-Script):
|
||||
|
||||
```html
|
||||
<script type="module" src="/i18n.js"></script>
|
||||
<script type="module">
|
||||
import { initI18n } from '/i18n.js';
|
||||
await initI18n();
|
||||
</script>
|
||||
<script src="/client.js"></script>
|
||||
```
|
||||
|
||||
Da `client.js`/`homing.js`/`calibration.js` aktuell keine ES-Module sind, reicht es, `initI18n()`
|
||||
in einem kleinen Inline-`<script type="module">` vor dem normalen `<script>`-Tag auszuführen –
|
||||
kein Umbau der bestehenden Dateien zu Modulen nötig (DOM ist zu diesem Zeitpunkt geparst, weil
|
||||
Module standardmäßig wie `defer` laufen).
|
||||
|
||||
## 4. Dynamische Strings in Client-JS
|
||||
|
||||
Für Strings, die per JS erzeugt werden (Status-Badges, Fehlermeldungen, generierte Tabellen),
|
||||
wird `t()` global verfügbar gemacht (z. B. `window.t = t;` am Ende von `i18n.js`) und an den
|
||||
Stellen, an denen heute literal Deutsch steht, durch einen Key-Aufruf ersetzt:
|
||||
|
||||
```js
|
||||
// vorher (public/homing.js)
|
||||
if (txt) txt.textContent = text || `Schritt ${step} / ${total}`;
|
||||
|
||||
// nachher
|
||||
if (txt) txt.textContent = text || t('homing.progress.step', { step, total });
|
||||
// "homing.progress.step": "Schritt {step} / {total}" / "Step {step} of {total}"
|
||||
```
|
||||
|
||||
```js
|
||||
// vorher (public/calibration.js)
|
||||
result.innerHTML = '<span style="color:#f87171">⚠ Bitte Set auswählen, das verschoben werden soll.</span>';
|
||||
|
||||
// nachher
|
||||
result.innerHTML = `<span style="color:#f87171">⚠ ${t('calibration.move.selectSetWarning')}</span>`;
|
||||
```
|
||||
|
||||
Das ist die aufwändigste Stelle (laut grep aktuell **deutlich über 1000 Treffer** für deutsche
|
||||
Wortfragmente über alle `public/*.js`-Dateien), aber rein mechanisch und gut inkrementell machbar:
|
||||
eine Datei nach der anderen, jeweils committed und getestet.
|
||||
|
||||
## 5. Server-seitige Strings (Fehlermeldungen)
|
||||
|
||||
`server/server.js` schickt aktuell fertige deutsche Sätze im JSON zurück, z. B.
|
||||
|
||||
```js
|
||||
return res.status(400).json({ error: '"y" und "z" müssen Zahlen sein.' });
|
||||
```
|
||||
|
||||
Diese Strings landen direkt im UI (`infoEl.textContent = data.error` o. ä.) – der Server kann aber
|
||||
die Browsersprache des Clients nicht zuverlässig kennen (und sollte sie auch nicht raten müssen).
|
||||
Saubere Lösung: Server schickt einen **Code**, Client übersetzt:
|
||||
|
||||
```js
|
||||
// server/server.js
|
||||
return res.status(400).json({ errorCode: 'yzMustBeNumbers' });
|
||||
```
|
||||
|
||||
```js
|
||||
// Client, z. B. client.js / homing.js
|
||||
const msg = data.errorCode ? t(`server.error.${data.errorCode}`) : (data.error ?? t('common.unknownError'));
|
||||
```
|
||||
|
||||
Migration ist optional und kann zuletzt erfolgen – bis dahin einfach `error` (deutsch) weiter
|
||||
durchreichen und im Client mit `data.error ?? t(...)` abfangen, damit nichts bricht, während man
|
||||
Stück für Stück auf `errorCode` umstellt.
|
||||
|
||||
## 6. Sprachumschalter in der UI
|
||||
|
||||
Kleines Dropdown/Flag-Toggle in der Topbar (`calib-topbar` existiert schon in mehreren Seiten),
|
||||
das `setLang('en')` / `setLang('de')` aufruft. Reicht als erster Schritt; ein automatisches
|
||||
Re-Rendering ohne Reload ist bei dieser Seitenanzahl unnötiger Aufwand.
|
||||
|
||||
## 7. Reihenfolge der Umsetzung (Vorschlag)
|
||||
|
||||
1. `public/i18n/de.json` aus den **aktuellen** deutschen Strings extrahieren (1:1, keine
|
||||
Textänderung) + `i18n.js` Lib bauen. Keine sichtbare Änderung im Verhalten.
|
||||
2. `public/i18n/en.json` befüllen (Übersetzung).
|
||||
3. Seite für Seite umstellen, anzufangen bei `index.html` + `client.js` (am meisten genutzt),
|
||||
danach `homing.html`/`homing.js`, danach `calibration*.html`/`calibration.js`.
|
||||
4. Sprachumschalter in der Topbar ergänzen.
|
||||
5. Server-Fehlermeldungen optional zuletzt auf `errorCode` umstellen.
|
||||
|
||||
## Warum keine Library wie i18next?
|
||||
|
||||
Für die Größe dieser App (kein Build-Schritt, ~5 HTML-Seiten, kein Framework) wäre i18next/
|
||||
FormatJS deutlich mehr Gewicht (zusätzliche Dependency, Build-Integration, ICU-Syntax) als Nutzen.
|
||||
Die ~80 Zeilen oben decken Pluralisierung zwar nicht ab, aber dafür gibt es in der App aktuell
|
||||
keinen Bedarf (keine zählerabhängigen Sätze wie „1 Marker“ vs. „3 Marker“ – falls das später
|
||||
gebraucht wird, reicht eine simple `count === 1 ? key + '.one' : key + '.many'`-Konvention).
|
||||
@@ -332,13 +332,13 @@
|
||||
}
|
||||
],
|
||||
"markers": [
|
||||
{"id": 244, "name": "aruco_244", "position": [125, 0, 0], "normal": [1, 0, 0], "size": 25, "spin": 0},
|
||||
{"id": 116, "name": "aruco_116", "position": [125, 0, 0], "normal": [1, 0, 0], "size": 25, "spin": 0},
|
||||
{"id": 245, "name": "aruco_245", "position": [90, 0, -35], "normal": [0, 0, -1], "size": 25, "spin": 0},
|
||||
{"id": 226, "name": "aruco_246", "position": [90, 0, 35], "normal": [0, 0, 1], "size": 25},
|
||||
{"id": 247, "name": "aruco_247", "position": [52.5, 0, 35], "normal": [0, 0, 1], "size": 25},
|
||||
{"id": 129, "name": "aruco_129", "position": [90, 0, 35], "normal": [0, 0, 1], "size": 25},
|
||||
{"id": 132, "name": "aruco_132", "position": [52.5, 0, 35], "normal": [0, 0, 1], "size": 25},
|
||||
{"id": 248, "name": "aruco_248", "position": [52.5, 0, -35], "normal": [0, 0, -1], "size": 25},
|
||||
{"id": 232, "name": "aruco_232", "position": [90, 24.75, -24.75], "normal": [0, 1, -1], "size": 25},
|
||||
{"id": 231, "name": "aruco_231", "position": [90, 24.75, 24.75], "normal": [0, 1, 1], "size": 25}
|
||||
{"id": 121, "name": "aruco_121", "position": [90, 24.75, 24.75], "normal": [0, 1, 1], "size": 25}
|
||||
]
|
||||
},
|
||||
"Arm2": {
|
||||
@@ -363,6 +363,10 @@
|
||||
}
|
||||
],
|
||||
"markers": [
|
||||
{"id": 148, "name": "aruco_148", "position": [-35, -219, 0], "normal": [-1,0,0], "size": 25},
|
||||
{"id": 144, "name": "aruco_144", "position": [-35, -112, 0], "normal": [-1,0,0], "size": 25},
|
||||
{"id": 146, "name": "aruco_146", "position": [-24.75, -112, 24.75], "normal": [-1,0,1], "size": 25}
|
||||
{"id": 143, "name": "aruco_143", "position": [-24.75, -182, 24.75], "normal": [-1,0,1], "size": 25}
|
||||
]
|
||||
},
|
||||
"Hand": {
|
||||
|
||||
Reference in New Issue
Block a user