Load the zoom view in strips so it opens instantly

The full-page captures (up to 1.3 MB each) are now cut into strips by
scripts/ganzseiten-kacheln.mjs. The first strip is ~60 KB and is fetched
as soon as a device scrolls into view; further strips load just before
they are scrolled to. Strip sizes are in the file names, so the dialog
has its full height immediately without layout jumps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lara 2026-10-06 12:19:46 +02:00
parent f000d64cca
commit ad92f1f512
46 changed files with 213 additions and 50 deletions

View file

@ -173,8 +173,8 @@ module.exports = [
{ key: 'image', label: 'Bildschirmfoto', type: 'image' }, { key: 'image', label: 'Bildschirmfoto', type: 'image' },
{ key: 'mobileImage', label: 'Handy-Ansicht (Hochformat, leer = kein Handy)', type: 'image' }, { key: 'mobileImage', label: 'Handy-Ansicht (Hochformat, leer = kein Handy)', type: 'image' },
{ key: 'video', label: 'Video im Laptop (Pfad zu einer .mp4, leer = Standbild)', type: 'text' }, { key: 'video', label: 'Video im Laptop (Pfad zu einer .mp4, leer = Standbild)', type: 'text' },
{ key: 'fullImage', label: 'Ganze Seite, Computer (Pfad, öffnet sich beim Anklicken; leer = nicht vergrößerbar)', type: 'text' }, { key: 'fullImage', label: 'Ganze Seite, Computer (Ordner mit Streifen, z. B. /mappe/ganz/name/; leer = nicht vergrößerbar)', type: 'text' },
{ key: 'fullMobileImage', label: 'Ganze Seite, Handy (Pfad, öffnet sich beim Anklicken)', type: 'text' }, { key: 'fullMobileImage', label: 'Ganze Seite, Handy (Ordner mit Streifen, z. B. /mappe/ganz/name-mobil/)', type: 'text' },
{ key: 'kategorie', label: 'Kategorie', type: 'text' }, { key: 'kategorie', label: 'Kategorie', type: 'text' },
{ key: 'titel', label: 'Titel / Name', type: 'text' }, { key: 'titel', label: 'Titel / Name', type: 'text' },
{ key: 'aufgabe', label: 'Die Aufgabe (leer = nur Text zeigen)', type: 'textarea' }, { key: 'aufgabe', label: 'Die Aufgabe (leer = nur Text zeigen)', type: 'textarea' },

View file

@ -187,8 +187,8 @@
"image": "/mappe/elektro-rabe.webp", "image": "/mappe/elektro-rabe.webp",
"mobileImage": "/mappe/elektro-rabe-mobil.webp", "mobileImage": "/mappe/elektro-rabe-mobil.webp",
"video": "/mappe/elektro-rabe.mp4", "video": "/mappe/elektro-rabe.mp4",
"fullImage": "/mappe/elektro-rabe-ganz.webp", "fullImage": "/mappe/ganz/elektro-rabe/",
"fullMobileImage": "/mappe/elektro-rabe-mobil-ganz.webp" "fullMobileImage": "/mappe/ganz/elektro-rabe-mobil/"
}, },
{ {
"kategorie": "Konzeptarbeit · Tanzstudio", "kategorie": "Konzeptarbeit · Tanzstudio",
@ -199,8 +199,8 @@
"image": "/mappe/maison-verte.webp", "image": "/mappe/maison-verte.webp",
"mobileImage": "/mappe/maison-verte-mobil.webp", "mobileImage": "/mappe/maison-verte-mobil.webp",
"video": "/mappe/maison-verte.mp4", "video": "/mappe/maison-verte.mp4",
"fullImage": "/mappe/maison-verte-ganz.webp", "fullImage": "/mappe/ganz/maison-verte/",
"fullMobileImage": "/mappe/maison-verte-mobil-ganz.jpg" "fullMobileImage": "/mappe/ganz/maison-verte-mobil/"
}, },
{ {
"kategorie": "Konzeptarbeit · Kosmetikstudio", "kategorie": "Konzeptarbeit · Kosmetikstudio",
@ -211,8 +211,8 @@
"image": "/mappe/atelier-rosenhain.webp", "image": "/mappe/atelier-rosenhain.webp",
"mobileImage": "/mappe/atelier-rosenhain-mobil.webp", "mobileImage": "/mappe/atelier-rosenhain-mobil.webp",
"video": "", "video": "",
"fullImage": "/mappe/atelier-rosenhain-ganz.webp", "fullImage": "/mappe/ganz/atelier-rosenhain/",
"fullMobileImage": "/mappe/atelier-rosenhain-mobil-ganz.webp" "fullMobileImage": "/mappe/ganz/atelier-rosenhain-mobil/"
} }
] ]
}, },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -0,0 +1,43 @@
// Schneidet die Ganzseitenaufnahmen der Mappe in Streifen, damit die
// Großansicht sofort etwas zeigt und der Rest beim Scrollen nachlädt.
// Aufruf: node scripts/ganzseiten-kacheln.mjs <Ordner mit den PNGs>
// Erwartet <name>.png (Computer, 1440 px breit) und <name>-mobil.png (Handy, 780 px).
// Ergebnis: public/mappe/ganz/<name>[-mobil]/NNN-BREITExHOEHE.webp
// Die Maße stehen im Dateinamen, so braucht der Build kein Bildwerkzeug.
import sharp from 'sharp';
import { mkdirSync, readdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const quelle = process.argv[2];
if (!quelle) throw new Error('Ordner mit den PNGs angeben');
const ziel = new URL('../public/mappe/ganz/', import.meta.url);
// Erster Streifen klein (schnell sichtbar), danach größere
const STREIFEN = { desktop: [900, 1800], mobil: [1700, 3200] };
for (const datei of readdirSync(quelle).filter((f) => f.endsWith('.png') && !f.startsWith('v-'))) {
const name = datei.replace(/\.png$/, '');
const art = name.endsWith('-mobil') ? 'mobil' : 'desktop';
const pfad = join(quelle, datei);
const { width, height } = await sharp(pfad, { limitInputPixels: false }).metadata();
const ordner = new URL(`${name}/`, ziel);
rmSync(ordner, { recursive: true, force: true });
mkdirSync(ordner, { recursive: true });
let top = 0;
let i = 0;
let summe = 0;
while (top < height) {
const h = Math.min(i === 0 ? STREIFEN[art][0] : STREIFEN[art][1], height - top);
const out = new URL(`${String(i).padStart(3, '0')}-${width}x${h}.webp`, ordner);
const info = await sharp(pfad, { limitInputPixels: false })
.extract({ left: 0, top, width, height: h })
.webp({ quality: 80 })
.toFile(fileURLToPath(out));
summe += info.size;
top += h;
i++;
}
console.log(`${name}: ${i} Streifen, ${Math.round(summe / 1024)} KB`);
}

View file

@ -6,10 +6,12 @@
* Mit `screenZoom`/`phoneZoom` wird das Gerät anklickbar und öffnet die ganze * Mit `screenZoom`/`phoneZoom` wird das Gerät anklickbar und öffnet die ganze
* Seite in der Großansicht (<Zoom>, einmal pro Seite eingebunden). * Seite in der Großansicht (<Zoom>, einmal pro Seite eingebunden).
*/ */
import type { Streifen } from '../lib/ganzseiten';
export interface ZoomSource { export interface ZoomSource {
title: string; title: string;
desktop?: string; desktop?: Streifen[];
mobile?: string; mobile?: Streifen[];
} }
interface Props { interface Props {
@ -27,8 +29,8 @@ const loading = eager ? 'eager' : 'lazy';
const zoomAttrs = (z: ZoomSource, mode: 'desktop' | 'mobile') => ({ const zoomAttrs = (z: ZoomSource, mode: 'desktop' | 'mobile') => ({
'data-zoom': mode, 'data-zoom': mode,
'data-zoom-title': z.title, 'data-zoom-title': z.title,
'data-zoom-desktop': z.desktop || undefined, 'data-zoom-desktop': z.desktop ? JSON.stringify(z.desktop) : undefined,
'data-zoom-mobile': z.mobile || undefined, 'data-zoom-mobile': z.mobile ? JSON.stringify(z.mobile) : undefined,
'aria-label': `${z.title} ${mode === 'desktop' ? 'am Computer' : 'auf dem Handy'} vergrößert ansehen`, 'aria-label': `${z.title} ${mode === 'desktop' ? 'am Computer' : 'auf dem Handy'} vergrößert ansehen`,
}); });
const laptopZoom = screenZoom && (screenZoom.desktop || screenZoom.mobile) ? screenZoom : undefined; const laptopZoom = screenZoom && (screenZoom.desktop || screenZoom.mobile) ? screenZoom : undefined;

View file

@ -25,12 +25,12 @@
<div class="zoom-browser"> <div class="zoom-browser">
<div class="zoom-browser-top" aria-hidden="true"><span></span><span></span><span></span></div> <div class="zoom-browser-top" aria-hidden="true"><span></span><span></span><span></span></div>
<div class="zoom-scroll" tabindex="0" aria-label="Seite am Computer, zum Durchscrollen"> <div class="zoom-scroll" tabindex="0" aria-label="Seite am Computer, zum Durchscrollen">
<img alt="" decoding="async" data-slot="desktop" /> <div class="zoom-pages" data-slot="desktop"></div>
</div> </div>
</div> </div>
<div class="zoom-phone"> <div class="zoom-phone">
<div class="zoom-scroll" tabindex="0" aria-label="Seite auf dem Handy, zum Durchscrollen"> <div class="zoom-scroll" tabindex="0" aria-label="Seite auf dem Handy, zum Durchscrollen">
<img alt="" decoding="async" data-slot="mobile" /> <div class="zoom-pages" data-slot="mobile"></div>
</div> </div>
</div> </div>
</div> </div>
@ -39,48 +39,145 @@
</dialog> </dialog>
<script> <script>
/*
* Die Aufnahmen liegen als Streifen vor (scripts/ganzseiten-kacheln.mjs).
* Schnell wird es durch drei Dinge:
* 1. Streifen haben feste Maße, die Seite steht sofort in voller Höhe.
* 2. Nur sichtbare Streifen laden, der Rest kurz bevor man hinscrollt.
* 3. Erste Streifen werden vorab geholt, sobald ein Gerät in Sichtweite
* kommt, und beim Zeigen oder Antippen zusätzlich der zweite.
*/
type Streifen = { src: string; w: number; h: number };
type Mode = 'desktop' | 'mobile';
const parse = (raw?: string): Streifen[] => {
try {
return raw ? (JSON.parse(raw) as Streifen[]) : [];
} catch {
return [];
}
};
const geholt = new Set<string>();
const vorab = (liste: Streifen[], anzahl: number) => {
liste.slice(0, anzahl).forEach(({ src }) => {
if (geholt.has(src)) return;
geholt.add(src);
const img = new Image();
img.decoding = 'async';
img.src = src;
});
};
const vorabFuer = (trigger: HTMLElement, anzahl: number) => {
const mode = trigger.dataset.zoom as Mode;
vorab(parse(mode === 'mobile' ? trigger.dataset.zoomMobile : trigger.dataset.zoomDesktop), anzahl);
};
const dialog = document.querySelector<HTMLDialogElement>('dialog.zoom'); const dialog = document.querySelector<HTMLDialogElement>('dialog.zoom');
if (dialog && typeof dialog.showModal === 'function') { if (dialog && typeof dialog.showModal === 'function') {
const title = dialog.querySelector<HTMLElement>('.zoom-title')!; const title = dialog.querySelector<HTMLElement>('.zoom-title')!;
const stage = dialog.querySelector<HTMLElement>('.zoom-stage')!; const stage = dialog.querySelector<HTMLElement>('.zoom-stage')!;
const switcher = dialog.querySelector<HTMLElement>('.zoom-switch')!; const switcher = dialog.querySelector<HTMLElement>('.zoom-switch')!;
const imgs = { const pages = {
desktop: dialog.querySelector<HTMLImageElement>('img[data-slot="desktop"]')!, desktop: dialog.querySelector<HTMLElement>('.zoom-pages[data-slot="desktop"]')!,
mobile: dialog.querySelector<HTMLImageElement>('img[data-slot="mobile"]')!, mobile: dialog.querySelector<HTMLElement>('.zoom-pages[data-slot="mobile"]')!,
};
let sources: Record<Mode, Streifen[]> = { desktop: [], mobile: [] };
const observers: Partial<Record<Mode, IntersectionObserver>> = {};
const laden = (img: HTMLImageElement) => {
if (img.dataset.src) {
img.src = img.dataset.src;
delete img.dataset.src;
}
};
const aufbauen = (mode: Mode) => {
const box = pages[mode];
const key = sources[mode].map((s) => s.src).join('|');
if (box.dataset.key === key) return;
box.dataset.key = key;
observers[mode]?.disconnect();
box.replaceChildren();
const scroller = box.closest<HTMLElement>('.zoom-scroll')!;
// Streifen erst laden, wenn sie in die Nähe des sichtbaren Bereichs kommen
const io =
'IntersectionObserver' in window
? new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (!e.isIntersecting) return;
laden(e.target as HTMLImageElement);
io?.unobserve(e.target);
});
},
{ root: scroller, rootMargin: '1500px 0px' },
)
: undefined;
observers[mode] = io;
sources[mode].forEach((s, i) => {
const img = document.createElement('img');
img.alt = '';
img.decoding = 'async';
if (s.w && s.h) {
img.width = s.w;
img.height = s.h;
}
if (i < 2 || !io) {
if (i === 0) img.setAttribute('fetchpriority', 'high');
img.src = s.src;
} else {
img.dataset.src = s.src;
io.observe(img);
}
box.append(img);
});
}; };
type Mode = keyof typeof imgs;
let sources: Record<Mode, string> = { desktop: '', mobile: '' };
const show = (mode: Mode) => { const show = (mode: Mode) => {
stage.dataset.mode = mode; stage.dataset.mode = mode;
switcher.querySelectorAll<HTMLButtonElement>('button').forEach((b) => { switcher.querySelectorAll<HTMLButtonElement>('button').forEach((b) => {
b.setAttribute('aria-pressed', String(b.dataset.mode === mode)); b.setAttribute('aria-pressed', String(b.dataset.mode === mode));
}); });
const img = imgs[mode]; aufbauen(mode);
if (img.getAttribute('src') !== sources[mode]) { pages[mode].closest('.zoom-scroll')!.scrollTop = 0;
img.classList.remove('is-loaded'); // die andere Ansicht schon mal anwärmen
img.src = sources[mode]; vorab(sources[mode === 'desktop' ? 'mobile' : 'desktop'], 1);
}
img.closest('.zoom-scroll')!.scrollTop = 0;
}; };
Object.values(imgs).forEach((img) => { // Vorab holen: erster Streifen, sobald ein Gerät in Sichtweite ist
img.addEventListener('load', () => img.classList.add('is-loaded')); const trigger = document.querySelectorAll<HTMLElement>('[data-zoom]');
if ('IntersectionObserver' in window) {
const nah = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (!e.isIntersecting) return;
vorabFuer(e.target as HTMLElement, 1);
nah.unobserve(e.target);
});
},
{ rootMargin: '300px 0px' },
);
trigger.forEach((t) => nah.observe(t));
}
// Absicht erkennen: Maus drauf, Finger drauf, Tastaturfokus, dann den zweiten Streifen dazu
trigger.forEach((t) => {
const absicht = () => vorabFuer(t, 2);
t.addEventListener('pointerenter', absicht, { passive: true });
t.addEventListener('touchstart', absicht, { passive: true });
t.addEventListener('focus', absicht);
}); });
document.addEventListener('click', (event) => { document.addEventListener('click', (event) => {
const trigger = (event.target as Element).closest<HTMLElement>('[data-zoom]'); const t = (event.target as Element).closest<HTMLElement>('[data-zoom]');
if (!trigger) return; if (!t) return;
sources = { sources = { desktop: parse(t.dataset.zoomDesktop), mobile: parse(t.dataset.zoomMobile) };
desktop: trigger.dataset.zoomDesktop || '', title.textContent = t.dataset.zoomTitle || '';
mobile: trigger.dataset.zoomMobile || '', switcher.hidden = !(sources.desktop.length && sources.mobile.length);
}; const wanted = t.dataset.zoom as Mode;
title.textContent = trigger.dataset.zoomTitle || '';
switcher.hidden = !(sources.desktop && sources.mobile);
const wanted = trigger.dataset.zoom as Mode;
show(sources[wanted] ? wanted : sources.desktop ? 'desktop' : 'mobile');
dialog.showModal(); dialog.showModal();
document.documentElement.classList.add('zoom-open'); document.documentElement.classList.add('zoom-open');
show(sources[wanted].length ? wanted : sources.desktop.length ? 'desktop' : 'mobile');
}); });
switcher.addEventListener('click', (event) => { switcher.addEventListener('click', (event) => {
@ -257,16 +354,13 @@
background: #fff; background: #fff;
} }
.zoom-scroll img { /* Streifen liegen lückenlos untereinander; bis sie da sind, zeigt die
feste Höhe eine helle Fläche statt eines Sprungs */
.zoom-pages :global(img) {
display: block; display: block;
width: 100%; width: 100%;
height: auto; height: auto;
opacity: 0; background: #f1efe9;
transition: opacity 0.25s;
}
.zoom-scroll img.is-loaded {
opacity: 1;
} }
.zoom-hint { .zoom-hint {
@ -302,9 +396,4 @@
} }
} }
@media (prefers-reduced-motion: reduce) {
.zoom-scroll img {
transition: none;
}
}
</style> </style>

28
src/lib/ganzseiten.ts Normal file
View file

@ -0,0 +1,28 @@
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
export interface Streifen {
src: string;
w: number;
h: number;
}
/**
* Liest die Streifen einer Ganzseitenaufnahme aus public/<ordner> (beim Build).
* Dateinamen: NNN-BREITExHOEHE.webp, erzeugt von scripts/ganzseiten-kacheln.mjs.
* Ein einzelner Bildpfad statt eines Ordners geht auch (ein Streifen ohne Maße).
*/
export function streifen(ordner?: string): Streifen[] | undefined {
if (!ordner) return undefined;
if (!ordner.endsWith('/')) return [{ src: ordner, w: 0, h: 0 }];
try {
const liste = readdirSync(join(process.cwd(), 'public', ordner))
.map((f) => f.match(/^\d+-(\d+)x(\d+)\.(webp|jpg|png)$/) && { f, m: f.match(/^\d+-(\d+)x(\d+)/)! })
.filter((x): x is { f: string; m: RegExpMatchArray } => Boolean(x))
.sort((a, b) => a.f.localeCompare(b.f))
.map(({ f, m }) => ({ src: ordner + f, w: Number(m[1]), h: Number(m[2]) }));
return liste.length ? liste : undefined;
} catch {
return undefined;
}
}

View file

@ -4,6 +4,7 @@ import Devices from '../components/Devices.astro';
import Zoom from '../components/Zoom.astro'; import Zoom from '../components/Zoom.astro';
import Kicker from '../components/Kicker.astro'; import Kicker from '../components/Kicker.astro';
import { site, rich } from '../lib/content'; import { site, rich } from '../lib/content';
import { streifen } from '../lib/ganzseiten';
const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, kontakt } = site; const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, kontakt } = site;
@ -11,7 +12,7 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
type MappeItem = (typeof mappe.items)[number]; type MappeItem = (typeof mappe.items)[number];
const zoomFor = (item?: MappeItem) => const zoomFor = (item?: MappeItem) =>
item && !item.nameHidden && (item.fullImage || item.fullMobileImage) item && !item.nameHidden && (item.fullImage || item.fullMobileImage)
? { title: item.titel, desktop: item.fullImage, mobile: item.fullMobileImage } ? { title: item.titel, desktop: streifen(item.fullImage), mobile: streifen(item.fullMobileImage) }
: undefined; : undefined;
const findItem = (src: string) => const findItem = (src: string) =>
mappe.items.find((i) => src && (i.image === src || i.mobileImage === src)); mappe.items.find((i) => src && (i.image === src || i.mobileImage === src));