summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-07 18:48:36 +0200
committerMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-22 19:18:04 +0200
commit47219611aad9061b04cbbe12c9bbbaf933431241 (patch)
treeefe9b22dbfae37f3d44d8551e7fbbd5997b0388b /app
parent03ed88c55eace54b88ec71cbfb1da483dbccbf60 (diff)
Mehrspaltiges Layout auf breiten Bildschirmen
Diffstat (limited to 'app')
-rw-r--r--app/README.md45
-rw-r--r--app/index.html149
-rw-r--r--app/service-worker.js2
3 files changed, 188 insertions, 8 deletions
diff --git a/app/README.md b/app/README.md
index 5f35f7c..3a87f05 100644
--- a/app/README.md
+++ b/app/README.md
@@ -62,6 +62,7 @@ selbst zu aktualisieren.
| 10 | Render-Schleife |
| 11 | Installation und Offline-Betrieb |
| 12 | Fensterrahmen: Titelleisten und Minimieren |
+| 13 | Kachelung: ein, zwei oder drei Fensterspalten |
Der Rechenkern in Teil 1 ist **die einzige Stelle, an der gerechnet wird.** Er ist zeichengenau
aus `build_bav.py` übertragen; Änderungen an der Rechtslage gehören in beide Dateien.
@@ -196,6 +197,50 @@ Zwei Änderungen:
Dazu: `:focus-visible` ist jetzt definiert (vorher gab es gar keine sichtbare Tastaturführung
auf den Segment-Umschaltern), und `prefers-reduced-motion` schaltet Übergänge ab.
+> **Berichtigung.** Der `IntersectionObserver` beobachtete `#result`. Dieses Element gibt es
+> nicht; der Abschnitt heißt `#w-result`. `observe(null)` wirft nach WebIDL einen `TypeError`
+> (`observe(Element target)`), und die Ausnahme riss alles mit, was im Skript danach kam:
+> die Leiste selbst, die Registrierung des Service Workers und den Installationshinweis.
+> Behoben; die drei Dinge funktionieren erstmals.
+
+## 6b. Bedienung auf breiten Schirmen
+
+Ein Fenster wird nicht breiter als rund 570 px. Darüber hinaus wird keine Zeile lesbarer — die
+Typografie mag 45 bis 75 Zeichen —, also wandert der gewonnene Platz in eine weitere Spalte
+statt in längere Zeilen:
+
+| Breite | Spalten | Aufteilung |
+|---|---|---|
+| < 1080 px | 1 | Quelltextreihenfolge, unverändert |
+| ≥ 1080 px | 2 | Eingaben ‖ Ausgaben |
+| ≥ 1560 px | 3 | Eingaben ‖ Ergebnis + Diagramm ‖ Herleitung |
+
+Die Aufteilung folgt der Wirkungsrichtung: was man anfasst, steht links; was daraus folgt,
+rechts daneben und damit **gleichzeitig im Bild**. Das ist der eigentliche Gewinn — Ursache und
+Wirkung ohne Scrollen, wofür auf dem Telefon die mitlaufende Leiste einspringt.
+
+Drei Entwurfsentscheidungen, jede mit ihrem Grund:
+
+1. **Die Fenster werden wirklich umgehängt** (`LAY.apply`), nicht bloß per CSS umsortiert.
+ Der Quelltext bleibt einspaltig und damit in der für schmale Geräte richtigen Reihenfolge.
+ `display:contents` auf vorgebauten Spalten hätte genau diese Reihenfolge zerstört, und eine
+ reine Grid-Zuweisung (`grid-column`/`grid-row`) hätte in jeder Zeile eine Lücke in Höhe des
+ jeweils höchsten Fensters gelassen. Echte Blockcontainer packen dicht.
+2. **`position:sticky` braucht einen Blockcontainer.** Der Grid-Bereich eines Elements ist genau
+ eine Zeile hoch; ein darin klebendes Element hätte keinen Weg, sich irgendwohin zu kleben.
+ Deshalb `.col` als Zwischenebene — und deshalb bleibt das Ergebnisfenster in seiner Spalte
+ oben stehen, während links die Regler wandern. Die mitlaufende Leiste blendet sich dabei von
+ selbst aus, ohne Sonderfall im Code: ihr Beobachter sieht das angeheftete Fenster ja
+ unverwandt im Bild.
+3. **Die Enge hängt nicht mehr am Schirm, sondern am Fenster.** Ein 560 px breites Fenster auf
+ einem 1600-px-Schirm braucht dieselben Regeln wie ein Telefon. Das ist der Fall, für den es
+ `@container` gibt; die alten `@media (max-width:640px)`-Regeln bleiben als Rückfall stehen.
+
+Ein angeheftetes Fenster nimmt dauerhaft Platz weg. Über 60 % der Schirmhöhe wäre das ein
+schlechter Tausch, und ein Fenster höher als der Schirm ließe sein unteres Ende gar nicht mehr
+erreichen. In dem Fall (`body.nosticky`, gesetzt nach Messung in `LAY.fit`) scrollt es normal
+mit, und die mitlaufende Leiste übernimmt wieder — die dafür ja gebaut ist.
+
Der Rechenweg baut 98 Tabellenzeilen. Solange kein Abschnitt aufgeklappt ist, wird der
Neuaufbau jetzt bis 300 ms nach der letzten Eingabe verschoben und beim Aufklappen nachgeholt.
diff --git a/app/index.html b/app/index.html
index 408cc8b..25614e8 100644
--- a/app/index.html
+++ b/app/index.html
@@ -131,6 +131,9 @@ input[type=text]:focus-visible,input[type=number]:focus-visible{outline-offset:-
.seg.btnseg button{font-size:11px;padding:3px 9px;min-height:22px}
#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.
+ Also fragt die Regel das Fenster (siehe .win{container-type} unten). */
+@container (max-width:430px){#cards{grid-template-columns:1fr}}
.opt{background:var(--face);padding:7px 9px 8px;position:relative;
border:1px solid;border-color:var(--white) var(--gray) var(--gray) var(--white)}
.opt.lead{border-color:var(--gray) var(--white) var(--white) var(--gray);background:var(--face-lt)}
@@ -139,6 +142,7 @@ input[type=text]:focus-visible,input[type=number]:focus-visible{outline-offset:-
border:1px solid var(--black)}
.opt .big{font-size:20px;font-weight:700;font-family:var(--mono);letter-spacing:-.02em;margin:3px 0 1px}
@media(max-width:640px){.opt .big{font-size:18px}}
+@container (max-width:430px){.opt .big{font-size:18px}}
.opt .sec{font-size:11px;font-family:var(--mono)}
.delta{font-size:11px;font-weight:700;font-family:var(--mono);display:inline-block;
padding:0 5px;background:var(--face);margin-top:2px;
@@ -311,6 +315,41 @@ kbd{font:11px var(--mono);background:var(--face);padding:0 4px;
border:1px solid;border-color:var(--white) var(--gray) var(--gray) var(--white)}
a{color:var(--navy)}
@media (prefers-reduced-motion: reduce){ *{transition:none !important;scroll-behavior:auto !important} }
+
+/* ---------- Kachelung: „Fenster ▸ Nebeneinander“ ----------
+ Der Programm-Manager konnte seine Fenster nebeneinander legen; genau das
+ passiert hier, sobald Platz da ist. Ein Fenster wird nicht breiter als
+ rund 570 px — darueber wird keine Zeile mehr lesbar (die Typografie mag
+ 45 bis 75 Zeichen), also wandert der gewonnene Platz in eine zweite und
+ ab 1560 px in eine dritte Spalte statt in laengere Zeilen.
+
+ Die Spalten sind echte Blockcontainer (.col) und keine Grid-Bereiche.
+ Das hat zwei Gruende: nur so packt jede Spalte ihre verschieden hohen
+ Fenster lueckenlos, und nur so hat position:sticky ueberhaupt einen
+ Weg zum Verschieben — der Grid-Bereich eines Elements ist genau eine
+ Zeile hoch, ein darin klebendes Element koennte sich nirgendwohin
+ kleben. Welches Fenster in welche Spalte kommt, entscheidet LAY
+ (Teil 13); im Quelltext steht weiter die einspaltige Reihenfolge. */
+.col{min-width:0}
+.win{container-type:inline-size}
+
+@media(min-width:1080px){
+ .wrap{max-width:1180px;display:grid;align-items:start;
+ grid-template-columns:repeat(2,minmax(0,1fr));column-gap:10px}
+ /* Kopf und Fuss ueber die volle Breite, ihr Text aber nicht: eine
+ 1700 px lange Zeile liest niemand. */
+ .wrap>header,.wrap>footer{grid-column:1/-1}
+ .wrap>header .sub,.wrap>footer p{max-width:82ch}
+ /* Das Ergebnisfenster bleibt oben stehen, waehrend links die Regler
+ wandern. Damit ist die mitlaufende Leiste hier entbehrlich — sie
+ blendet sich von selbst aus, weil ihr Beobachter das Fenster ja
+ unverwandt im Bild sieht. */
+ body.tiled #w-result{position:sticky;top:8px;z-index:3}
+ body.nosticky #w-result{position:static}
+}
+@media(min-width:1560px){
+ .wrap{max-width:1740px;grid-template-columns:repeat(3,minmax(0,1fr))}
+}
</style>
</head>
<body>
@@ -352,7 +391,7 @@ a{color:var(--navy)}
</section>
<!-- ======================= KERNANGABEN ======================= -->
-<section class="win">
+<section class="win" id="w-input">
<h2>Ihre Angaben</h2>
<p class="sub" style="margin-bottom:6px">Die Zahlen rechnen sich bei jeder Änderung sofort neu.
Mit <span class="qm" style="cursor:default">?</span> erklärt sich jedes Feld selbst.</p>
@@ -360,7 +399,7 @@ a{color:var(--navy)}
</section>
<!-- ======================= DIAGRAMME ======================= -->
-<section class="win" data-title="Diagramme">
+<section class="win" id="w-chart" data-title="Diagramme">
<div class="tabs" id="tabs" role="tablist"></div>
<div id="chartwrap">
<canvas id="chart" height="260"></canvas>
@@ -371,21 +410,21 @@ a{color:var(--navy)}
</section>
<!-- ======================= WEITERE ANGABEN ======================= -->
-<section class="win">
+<section class="win" id="w-extra">
<h2 style="margin-bottom:6px">Weitere Angaben</h2>
<p class="sub" style="margin-bottom:2px">Alles hier hat sinnvolle Voreinstellungen. Nur anfassen, wenn es auf Sie zutrifft.</p>
<div id="groups"></div>
</section>
<!-- ======================= RECHENWEG ======================= -->
-<section class="win">
+<section class="win" id="w-calc">
<h2 style="margin-bottom:6px">Rechenweg</h2>
<p class="sub" style="margin-bottom:2px">Für alle, die es nachvollziehen wollen.</p>
<div id="details"></div>
</section>
<!-- ======================= WEITERGEBEN ======================= -->
-<section class="win">
+<section class="win" id="w-share">
<h2>Speichern und weitergeben</h2>
<p class="sub">Ihre Eingaben liegen ausschließlich in diesem Browser (<kbd>localStorage</kbd>). Nichts wird übertragen,
es gibt keinen Server und keine Tracker.</p>
@@ -1600,7 +1639,11 @@ function renderSticky(){
}).join('');
}
function initSticky(){
- var bar = $('#stickybar'), res = $('#result');
+ /* Das Ergebnisfenster heisst #w-result. Der frueher hier stehende
+ Selektor #result traf nichts, observe(null) warf, und die Ausnahme
+ riss alles mit, was danach kam — Leiste, Service Worker und
+ Installationshinweis inklusive. */
+ var bar = $('#stickybar'), res = $('#w-result');
$('#sb-up').onclick = function(){ res.scrollIntoView({ behavior:'smooth', block:'start' }); };
if (!('IntersectionObserver' in window)) return; // ohne Beobachter bleibt sie aus
new IntersectionObserver(function(es){
@@ -2300,6 +2343,7 @@ function refresh(){
renderVerdict();
renderDemo();
renderSticky();
+ if (LAY) LAY.fit(); // Kartenzahl aendert die Fensterhoehe
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(function(){ drawChart(); scheduleDetails(); rafId = null; });
saveState();
@@ -2358,7 +2402,10 @@ $('#reset').onclick = function(){
/* Es gibt nur ein Farbschema. Silber auf Petrol ist hier nicht Dekoration,
sondern die Sache selbst — eine Dunkelvariante gäbe es nicht zu skinnen. */
var rt = null;
-window.addEventListener('resize', function(){ clearTimeout(rt); rt = setTimeout(drawChart, 120); });
+window.addEventListener('resize', function(){
+ clearTimeout(rt);
+ rt = setTimeout(function(){ LAY.sync(); drawChart(); }, 120);
+});
/* =====================================================================
TEIL 12 — FENSTERRAHMEN
@@ -2403,6 +2450,7 @@ function dressWindows(){
S.open[key] = !r;
if (save) saveState();
if (!r) drawChart(); // eingeklapptes Canvas hat Breite 0
+ if (LAY) LAY.fit(); // Einklappen aendert die Fensterhoehe
};
mn.onclick = function(){ setRolled(!sec.classList.contains('rolled'), true); };
/* Doppelklick auf die Titelleiste klappt ein - wie unter Windows */
@@ -2411,9 +2459,96 @@ function dressWindows(){
});
}
+/* =====================================================================
+ TEIL 13 — KACHELUNG
+ Der Programm-Manager legte seine Fenster auf Wunsch nebeneinander.
+ Hier geschieht das von selbst, sobald der Schirm es hergibt:
+
+ unter 1080 px eine Spalte (Quelltextreihenfolge, unveraendert)
+ ab 1080 px zwei Spalten Eingaben | Ausgaben
+ ab 1560 px drei Spalten Eingaben | Ergebnis+Bild | Herleitung
+
+ Die Aufteilung folgt der Wirkungsrichtung: was man anfasst, steht
+ links; was daraus folgt, rechts daneben und damit gleichzeitig im
+ Bild. Das ist der eigentliche Gewinn — Ursache und Wirkung ohne
+ Scrollen, wofuer auf dem Telefon die mitlaufende Leiste einspringt.
+
+ Die Fenster werden dafuer wirklich umgehaengt statt bloss per CSS
+ umsortiert. Der Quelltext bleibt einspaltig und damit in der fuer
+ schmale Geraete richtigen Reihenfolge; display:contents haette diese
+ Reihenfolge zerstoert, und reine Grid-Zuweisung haette in jeder Zeile
+ Luecken in Hoehe des jeweils hoechsten Fensters gelassen.
+ ===================================================================== */
+var LAY = (function(){
+ var wrap = document.querySelector('.wrap'), foot = wrap.querySelector('footer');
+ /* einspaltige Reihenfolge = Quelltextreihenfolge */
+ var ORDER = ['w-result','w-input','w-chart','w-extra','w-calc','w-share'];
+ var PLAN = {
+ 2: [['w-input','w-extra','w-share'], ['w-result','w-chart','w-calc']],
+ 3: [['w-input','w-extra'], ['w-result','w-chart'], ['w-calc','w-share']]
+ };
+ var cur = 0;
+
+ /* Die Schwellen stehen doppelt — hier und im Stilblatt. Das ist der
+ Preis dafuer, dass die Struktur im DOM liegt und die Optik im CSS;
+ matchMedia liest wenigstens dieselbe Quelle wie der Umbruch. */
+ function want(){
+ return matchMedia('(min-width:1560px)').matches ? 3
+ : matchMedia('(min-width:1080px)').matches ? 2 : 1;
+ }
+
+ /* Ein angeheftetes Fenster nimmt dauerhaft Platz weg. Ueber 60 % der
+ Schirmhoehe waere das ein schlechter Tausch: darunter stuenden nur
+ noch Reste des Diagramms, und ein Fenster hoeher als der Schirm
+ liesse sein unteres Ende gar nicht mehr erreichen. In dem Fall
+ scrollt es normal mit — dann uebernimmt wieder die mitlaufende
+ Leiste, die dafuer ja gebaut ist. */
+ var STICKY_ANTEIL = 0.6;
+ function fitSticky(){
+ var r = document.getElementById('w-result');
+ if (!r) return;
+ document.body.classList.toggle('nosticky',
+ cur < 2 || r.getBoundingClientRect().height > window.innerHeight * STICKY_ANTEIL);
+ }
+
+ function apply(n){
+ if (n === cur) return false;
+ cur = n;
+ var w = {};
+ ORDER.forEach(function(id){ w[id] = document.getElementById(id); });
+ /* Erst die Fenster festhalten, dann die alten Spalten entfernen —
+ die Referenzen ueberleben das, appendChild haengt sie danach um. */
+ [].slice.call(wrap.querySelectorAll('.col')).forEach(function(c){ c.remove(); });
+ if (n === 1){
+ ORDER.forEach(function(id){ if (w[id]) wrap.insertBefore(w[id], foot); });
+ } else {
+ PLAN[n].forEach(function(ids){
+ var c = el('div','col');
+ ids.forEach(function(id){ if (w[id]) c.appendChild(w[id]); });
+ wrap.insertBefore(c, foot);
+ });
+ }
+ document.body.classList.toggle('tiled', n > 1);
+ return true;
+ }
+
+ return {
+ cols: function(){ return cur; },
+ fit: fitSticky,
+ sync: function(){
+ var geaendert = apply(want());
+ fitSticky();
+ /* Das Canvas hat nach dem Umhaengen eine andere Breite. */
+ if (geaendert && typeof R !== 'undefined' && R) drawChart();
+ return geaendert;
+ }
+ };
+})();
+
/* --- Start --- */
loadState();
dressWindows();
+LAY.sync();
buildMetricBar(); buildTabs(); buildInputs();
R = AVModell.rechne(S.I, S.P);
refresh();
diff --git a/app/service-worker.js b/app/service-worker.js
index b68ba71..0e294c7 100644
--- a/app/service-worker.js
+++ b/app/service-worker.js
@@ -9,7 +9,7 @@
* Beim Ändern der App die Version hochzählen — sonst sieht ein bereits
* installierter Client die Änderung erst nach zwei Aufrufen.
*/
-const VERSION = 'av-v10';
+const VERSION = 'av-v11';
const ASSETS = [
'./',
'./index.html',