diff options
| -rw-r--r-- | app/README.md | 45 | ||||
| -rw-r--r-- | app/index.html | 149 | ||||
| -rw-r--r-- | app/service-worker.js | 2 |
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', |
