PageSourceSearch

https://medientage.at/assets/js/speaker-core.js?v=20260904-03

js medientage.at collected 2026-10-04 11:22:17 UTC 27,425 bytes, 656 lines download raw bytes

1/*
2 * OMT speaker core — shared vita modal logic
3 *
4 * Used by the homepage (index.html / main.js) AND the speaker gallery
5 * (speaker.html / speaker-gallery.js). Single source for the speaker vita
6 * modal: data fetching, parsing, gallery rail, desktop feature view and the
7 * mobile bottom navigation.
8 *
9 * Requires the modal markup (#omt-speaker-modal) to be in the DOM.
10 * Exposes: window.OMTSpeakerModal
11 */
12(function () {
13    'use strict';
14
15    // ── State ────────────────────────────────────────────────────
16    var profiles = {};
17    var order = [];
18    var currentId = '';
19    var lastTrigger = null;
20    var initialized = false;
21
22    // ── DOM references (populated in init()) ─────────────────────
23    var modal, dialog, closeBtn, rail, gallery, tooltip, galleryPrev, galleryNext, mobilePrev, mobileNext;
24
25    function part(selector) {
26        return modal ? modal.querySelector(selector) : null;
27    }
28
29    // ── Parsing ──────────────────────────────────────────────────
30
31    // Prefixes "© " unless the value already carries the mark.
32    function withCopyrightMark(value) {
33        var v = (value || '').trim();
34        if (v === '') return '';
35        return v.indexOf('©') !== -1 ? v : '© ' + v;
36    }
37
38    // Prefixes "Credits: " unless the value already labels itself. Editors type
39    // "Credit:", "Credits –", "Foto:" and so on; those keep their own wording
40    // instead of ending up doubled.
41    var CREDIT_LABEL_RE = /^(credits?|foto|photo|bild|fotos)\b\s*[:–-]?/i;
42
43    function withCreditLabel(value) {
44        var v = (value || '').trim();
45        if (v === '') return '';
46        return CREDIT_LABEL_RE.test(v) ? v : 'Credits: ' + v;
47    }
48
49    /* Surname sorting — the rail and the speaker page must show the same order,
50     * so it lives here, not in the page script. Surname = everything after the
51     * first given name, keeping multi-word names ("Hulshoff Pol",
52     * "Spengler-Ahrens") intact. Academic titles are stripped first. */
53    var TITLE_RE = /^(Prof\. Dr\. |Dr\. |Prof\. )/;
54
55    function normalizeName(str) {
56        str = (str || '').toLowerCase();
57        try {
58            return str.normalize('NFD').replace(/[̀-ͯ]/g, '');
59        } catch (e) {
60            return str;
61        }
62    }
63
64    function surnameKey(name) {
65        var clean = (name || '').replace(TITLE_RE, '').trim();
66        var space = clean.indexOf(' ');
67        var surname = space === -1 ? clean : clean.slice(space + 1);
68        return normalizeName(surname + ' ' + clean);
69    }
70
71    function sortBySurname(list, byId) {
72        return list.slice().sort(function (a, b) {
73            var pa = byId[a], pb = byId[b];
74            if (!pa || !pb) return 0;
75            return surnameKey(pa.name).localeCompare(surnameKey(pb.name), 'de');
76        });
77    }
78
79    // Picks the first present image reference from a list of [node, key] pairs and
80    // returns it together with its srcset sibling and its aspect ratio. Covers both
81    // the legacy (media.card) and the new (media.portrait.url) schema.
82    function firstImage(candidates) {
83        for (var i = 0; i < candidates.length; i++) {
84            var node = candidates[i][0];
85            var key = candidates[i][1];
86            if (node && node[key]) {
87                return {
88                    url: node[key],
89                    srcset: node[key + 'Srcset'] || '',
90                    aspect: (node.width && node.height) ? node.width / node.height : 0
91                };
92            }
93        }
94        return { url: '', srcset: '', aspect: 0 };
95    }
96
97    /*
98     * Builds the sizes attribute for one image.
99     *
100     * Not simply the width of the box: every surface here crops with object-fit:
101     * cover, and a landscape photo in a square tile is scaled by its HEIGHT — only
102     * the middle of it is visible. A 3:2 source in a 242 px tile is therefore drawn
103     * 363 px wide, and asking for 242 px hands the browser a candidate it then has
104     * to blow up again. Which is worse than the problem we set out to fix.
105     *
106     * Hence per image: required width = box width × max(1, source ratio / box ratio).
107     * Portrait sources are unaffected (factor 1), landscape ones pull the next
108     * candidate up. Without dimensions in the payload the factor stays 1.
109     *
110     * Spec entries: [media condition or null, value, unit, box ratio at that width].
111     * The box ratio may also be a [min, max] pair — that is a box which follows the
112     * image within those bounds (the feature portrait, see portraitRatio).
113     */
114    function sizesFor(profile, spec) {
115        var ar = profile.imageAspect || 0;
116
117        return spec.map(function (entry) {
118            var box = Array.isArray(entry[3]) ? clampRatio(ar, entry[3]) : entry[3];
119            var factor = (ar && box) ? Math.max(1, ar / box) : 1;
120            var value = entry[1] * factor;
121            var out = entry[2] === 'px'
122                ? Math.round(value) + 'px'
123                : Math.round(value * 10) / 10 + 'vw';
124            return entry[0] ? entry[0] + ' ' + out : out;
125        }).join(', ');
126    }
127
128    /*
129     * The photo caption for a card: copyright notice and credit in one line.
130     *
131     * Both used to sit on the image — the notice as a chip in the corner. In a
132     * three-column grid on a phone that chip lands on a chin: the tiles are ~175 px
133     * wide, the notice runs up to 59 characters and wraps to two lines. There is no
134     * safe corner either, the crop keeps the head top-centred. Below the image it
135     * reads as a caption, covers nothing, and still sits with its photo.
136     */
137    function photoCaption(profile) {
138        var parts = [];
139        if (profile.copyright) parts.push(profile.copyright);
140        if (profile.credit && profile.credit !== profile.copyright) parts.push(profile.credit);
141        return parts.join(' · ');
142    }
143
144    // Sets src together with the candidate widths. Without sizes the browser assumes
145    // 100vw and always takes the largest candidate, which would undo the whole point.
146    function applyImage(img, profile, spec) {
147        if (!img || !profile) return;
148        img.src = profile.image;
149        if (profile.imageSrcset) {
150            img.srcset = profile.imageSrcset;
151            img.sizes = sizesFor(profile, spec);
152        } else {
153            img.removeAttribute('srcset');
154            img.removeAttribute('sizes');
155        }
156    }
157
158    /*
159     * How far the feature portrait may follow its own aspect ratio, per breakpoint.
160     * Mirrors the clamp() in modal.css — both ends have to agree, otherwise the
161     * chosen candidate width no longer matches the box the image is drawn in.
162     */
163    var PORTRAIT_RATIO_DESKTOP = [0.75, 1.4];
164    var PORTRAIT_RATIO_MOBILE = [0.8, 1.6];
165
166    function clampRatio(ar, bounds) {
167        if (!ar) return bounds[0];
168        return Math.min(bounds[1], Math.max(bounds[0], ar));
169    }
170
171    // Box geometry per surface, kept here so all render sites stay in sync with the
172    // CSS. Modal portrait: 0.34fr of the dialog on desktop, full width below.
173    var SIZES_PORTRAIT = [
174        ['(min-width: 821px)', 352, 'px', PORTRAIT_RATIO_DESKTOP],
175        [null, 92, 'vw', PORTRAIT_RATIO_MOBILE]
176    ];
177    var SIZES_THUMB = [
178        [null, 80, 'px', 1]
179    ];
180
181    function parse(data) {
182        var out = {};
183        var ord = [];
184        var speakers = (data && Array.isArray(data.speakers)) ? data.speakers : [];
185
186        speakers.forEach(function (speaker) {
187            var slug = speaker.slug;
188            if (!slug) return;
189
190            ord.push(slug);
191
192            var media = speaker.media || {};
193            var card = media.card || {};
194            var portrait = media.portrait || {};
195            // Image fallback chain: card crop → card → portrait crop → portrait.
196            // Paths are already rewritten to local assets/... server-side by the
197            // proxy media mirror, which also writes the …Srcset siblings — those let
198            // the browser load the width it actually renders instead of reducing a
199            // 1600 px master to a 242 px tile with a cheap filter.
200            var picked = firstImage([
201                [card.crop, 'sourceUrl'],
202                [card, 'url'],
203                [portrait.crop, 'sourceUrl'],
204                [portrait, 'url']
205            ]);
206            var imageUrl = picked.url;
207
208            out[slug] = {
209                slug: slug,
210                name: speaker.displayName || '',
211                role: speaker.roleSummary || '',
212                title: speaker.title || '',
213                topic: speaker.topic || '',
214                label: speaker.label || 'Speaker-Vita',
215                note: speaker.note || '',
216                image: imageUrl,
217                imageSrcset: picked.srcset,
218                imageAspect: picked.aspect,
219                imageAlt: card.alt || portrait.alt || speaker.displayName || '',
220                credit: withCreditLabel(card.credit || portrait.credit),
221                copyright: withCopyrightMark(portrait.copyrightNotice || card.copyrightNotice),
222                // summary & cardTeaser are server-sanitized HTML — render via innerHTML.
223                summary: speaker.summary || '',
224                highlights: (speaker.highlights || []).map(function (h) { return h.label; }),
225                // bioParagraphs/bio are being phased out; bioHtml (server-sanitized) wins.
226                bio: speaker.bioParagraphs || [],
227                bioHtml: speaker.bioHtml || '',
228                isFeatured: speaker.isFeatured === true,
229                organization: speaker.organization || '',
230                cardTeaser: speaker.cardTeaser || null
231            };
232        });
233
234        // The API order is arbitrary — sort once here so every consumer (rail,
235        // speaker page, prev/next) shares one order.
236        return { profiles: out, order: sortBySurname(ord, out) };
237    }
238
239    // ── Fill the feature view ────────────────────────────────────
240    function fillModal(profile) {
241        var image = part('[data-speaker-image]');
242        var credit = part('[data-speaker-credit]');
243        var topic = part('[data-speaker-topic]');
244        var label = part('[data-speaker-label]');
245        var name = part('[data-speaker-name]');
246        var role = part('[data-speaker-role]');
247        var summary = part('[data-speaker-summary]');
248        var highlights = part('[data-speaker-highlights]');
249        var bio = part('[data-speaker-bio]');
250
251        if (image) {
252            applyImage(image, profile, SIZES_PORTRAIT);
253            image.alt = profile.imageAlt || profile.name;
254
255            /*
256             * The feature portrait box follows the photo instead of forcing every
257             * photo into one shape. Press portraits arrive between 3:2 and 2:3, and
258             * a fixed box cuts the difference off the face — on mobile the 16:10 box
259             * took half the height off a 4:5 headshot, which left a forehead. The
260             * clamp in modal.css keeps the box from becoming a band or a tower; the
261             * ratio is set before the image loads, so nothing jumps.
262             */
263            var box = image.closest('.speaker-modal__media');
264            if (box) {
265                if (profile.imageAspect) {
266                    box.style.setProperty('--portrait-ratio', profile.imageAspect.toFixed(4));
267                } else {
268                    box.style.removeProperty('--portrait-ratio');
269                }
270            }
271        }
272        if (credit) credit.textContent = profile.copyright || '';
273
274        // credit → line under the portrait; hidden when EPM has no value, so no
275        // empty row is left behind when switching speakers.
276        var imageCredit = part('[data-speaker-image-credit]');
277        if (imageCredit) {
278            imageCredit.textContent = profile.credit || '';
279            imageCredit.hidden = !profile.credit;
280        }
281
282        if (topic) topic.textContent = profile.topic || '';
283        if (label) label.textContent = profile.label || 'Speaker-Vita';
284        if (name) name.textContent = profile.name || '';
285        if (role) role.textContent = profile.role || '';
286        if (summary) summary.innerHTML = profile.summary || '';
287
288        if (highlights) {
289            highlights.innerHTML = '';
290            (profile.highlights || []).forEach(function (item) {
291                var li = document.createElement('li');
292                li.textContent = item;
293                highlights.appendChild(li);
294            });
295        }
296
297        if (bio) {
298            if (profile.bioHtml) {
299                // Already reduced to a safe allowlist server-side.
300                bio.innerHTML = profile.bioHtml;
301            } else {
302                // Fallback: render plaintext paragraphs escaped.
303                bio.innerHTML = '';
304                (profile.bio || []).forEach(function (paragraph) {
305                    var p = document.createElement('p');
306                    p.textContent = paragraph;
307                    bio.appendChild(p);
308                });
309            }
310        }
311    }
312
313    // ── Gallery rail (filmstrip at the bottom) ───────────────────
314    function setActiveButton(id) {
315        if (!gallery) return;
316        gallery.querySelectorAll('[data-speaker-gallery-open]').forEach(function (button) {
317            var isActive = button.getAttribute('data-speaker-gallery-open') === id;
318            button.classList.toggle('is-active', isActive);
319            button.setAttribute('aria-current', isActive ? 'true' : 'false');
320        });
321    }
322
323    /*
324     * smooth === false jumps without animation. The rail carries
325     * scroll-behavior: smooth in CSS, and that also governs scrollLeft and
326     * scrollTo({ behavior: 'auto' }) — so the jump has to be made with the
327     * property switched off for the moment, rather than by asking for a
328     * behavior the older engines would reject.
329     */
330    function scrollGalleryToActive(smooth) {
331        if (!gallery) return;
332        var active = gallery.querySelector('[data-speaker-gallery-open].is-active');
333        if (!active) return;
334        var target = active.offsetLeft - (gallery.clientWidth - active.offsetWidth) / 2;
335        target = Math.max(0, Math.min(target, gallery.scrollWidth - gallery.clientWidth));
336
337        if (smooth) {
338            gallery.scrollTo({ left: target, behavior: 'smooth' });
339            return;
340        }
341        gallery.style.scrollBehavior = 'auto';
342        gallery.scrollLeft = target;
343        gallery.style.scrollBehavior = '';
344    }
345
346    // Advances the gallery by exactly one tile; scroll-snap settles the rest.
347    function nudgeGallery(direction) {
348        if (!gallery) return;
349        var tiles = gallery.querySelectorAll('.speaker-modal__gallery-btn');
350        if (!tiles.length) return;
351
352        var galleryRect = gallery.getBoundingClientRect();
353        var center = galleryRect.left + galleryRect.width / 2;
354        var target = null;
355
356        for (var i = 0; i < tiles.length; i++) {
357            var rect = tiles[i].getBoundingClientRect();
358            var tileCenter = rect.left + rect.width / 2;
359            if (direction > 0 && tileCenter > center + 2) { target = tiles[i]; break; }
360            if (direction < 0 && tileCenter < center - 2) { target = tiles[i]; }
361        }
362        if (!target) target = direction > 0 ? tiles[tiles.length - 1] : tiles[0];
363
364        var left = target.offsetLeft - (gallery.clientWidth - target.offsetWidth) / 2;
365        left = Math.max(0, Math.min(left, gallery.scrollWidth - gallery.clientWidth));
366        gallery.scrollTo({ left: left, behavior: 'smooth' });
367    }
368
369    function shortName(profile) {
370        return profile.name.replace(/^Prof\. Dr\. |^Dr\. |^Prof\. /, '');
371    }
372
373    function adjacentId(id, direction) {
374        var index = order.indexOf(id);
375        if (index === -1) index = 0;
376        return order[(index + direction + order.length) % order.length];
377    }
378
379    function updateMobileNav(id) {
380        var prevId = adjacentId(id, -1);
381        var nextId = adjacentId(id, 1);
382        var prevProfile = profiles[prevId];
383        var nextProfile = profiles[nextId];
384
385        if (mobilePrev && prevProfile) {
386            mobilePrev.setAttribute('data-speaker-target', prevId);
387            mobilePrev.setAttribute('aria-label', 'Vorherige Vita: ' + prevProfile.name);
388            var prevImg = mobilePrev.querySelector('[data-speaker-mobile-prev-image]');
389            var prevName = mobilePrev.querySelector('[data-speaker-mobile-prev-name]');
390            if (prevImg) {
391                applyImage(prevImg, prevProfile, SIZES_THUMB);
392                prevImg.alt = '';
393            }
394            if (prevName) prevName.textContent = shortName(prevProfile);
395        }
396
397        if (mobileNext && nextProfile) {
398            mobileNext.setAttribute('data-speaker-target', nextId);
399            mobileNext.setAttribute('aria-label', 'Nächste Vita: ' + nextProfile.name);
400            var nextImg = mobileNext.querySelector('[data-speaker-mobile-next-image]');
401            var nextName = mobileNext.querySelector('[data-speaker-mobile-next-name]');
402            if (nextImg) {
403                applyImage(nextImg, nextProfile, SIZES_THUMB);
404                nextImg.alt = '';
405            }
406            if (nextName) nextName.textContent = shortName(nextProfile);
407        }
408    }
409
410    function updateGalleryEdges() {
411        if (!rail || !gallery) return;
412        var canScroll = gallery.scrollWidth - gallery.clientWidth > 4;
413        var hasPrev = canScroll && gallery.scrollLeft > 4;
414        var hasNext = canScroll && gallery.scrollLeft + gallery.clientWidth < gallery.scrollWidth - 4;
415
416        rail.classList.toggle('has-prev', hasPrev);
417        rail.classList.toggle('has-next', hasNext);
418    }
419
420    function showTooltip(button) {
421        if (!rail || !tooltip || !button) return;
422        var note = button.getAttribute('data-note');
423        if (!note) return;
424
425        var railRect = rail.getBoundingClientRect();
426        var buttonRect = button.getBoundingClientRect();
427        var center = buttonRect.left + buttonRect.width / 2 - railRect.left;
428
429        tooltip.textContent = note;
430        tooltip.style.setProperty('--speaker-tooltip-x', center + 'px');
431        tooltip.hidden = false;
432        tooltip.classList.add('is-visible');
433    }
434
435    function hideTooltip() {
436        if (!tooltip) return;
437        tooltip.classList.remove('is-visible');
438        tooltip.hidden = true;
439    }
440
441    function buildGallery() {
442        if (!gallery) return;
443        gallery.innerHTML = '';
444
445        order.forEach(function (id) {
446            var profile = profiles[id];
447            if (!profile) return;
448
449            var button = document.createElement('button');
450            var image = document.createElement('img');
451            var name = document.createElement('span');
452
453            button.type = 'button';
454            button.className = 'speaker-modal__gallery-btn';
455            button.setAttribute('data-speaker-modal-open', id);
456            button.setAttribute('data-speaker-gallery-open', id);
457            button.setAttribute('data-note', profile.note || profile.topic || profile.name);
458            button.setAttribute('aria-label', 'Vita von ' + profile.name + ' anzeigen');
459            button.addEventListener('mouseenter', function () { showTooltip(button); });
460            button.addEventListener('focus', function () { showTooltip(button); });
461            button.addEventListener('mouseleave', hideTooltip);
462            button.addEventListener('blur', hideTooltip);
463
464            image.className = 'speaker-modal__gallery-img';
465            applyImage(image, profile, SIZES_THUMB);
466            image.alt = '';
467            image.loading = 'lazy';
468
469            name.className = 'speaker-modal__gallery-name';
470            name.textContent = shortName(profile);
471
472            button.appendChild(image);
473            button.appendChild(name);
474            gallery.appendChild(button);
475        });
476    }
477
478    // ── Open / close ─────────────────────────────────────────────
479    function open(id, trigger) {
480        var profile = profiles[id];
481        if (!modal || !profile) return;
482
483        var wasOpen = modal.classList.contains('open');
484        lastTrigger = trigger || document.activeElement;
485        currentId = id;
486        fillModal(profile);
487        setActiveButton(id);
488        updateMobileNav(id);
489        modal.classList.add('open');
490        modal.setAttribute('aria-hidden', 'false');
491
492        // Every vita starts at its own beginning — otherwise the next speaker
493        // opens somewhere in the middle of the previous one's bio.
494        if (dialog) dialog.scrollTop = 0;
495
496        /*
497         * Position the rail in the same task, not in the next frame: reading
498         * offsetLeft forces the layout the class change just invalidated, so the
499         * metrics are already correct here. A rail that was display:none a moment
500         * ago and is scrolled across in the following frame can be handed to the
501         * compositor before it has been painted — which is how a reopened modal
502         * ended up with an empty filmstrip.
503         */
504        scrollGalleryToActive(wasOpen);
505        updateGalleryEdges();
506        document.body.style.overflow = 'hidden';
507        if (closeBtn) closeBtn.focus();
508    }
509
510    function close() {
511        if (!modal || !modal.classList.contains('open')) return;
512        modal.classList.remove('open');
513        modal.setAttribute('aria-hidden', 'true');
514        document.body.style.overflow = '';
515        if (lastTrigger && typeof lastTrigger.focus === 'function') {
516            lastTrigger.focus();
517        }
518    }
519
520    // ── Init: cache DOM + bind events (idempotent) ───────────────
521    function init() {
522        if (initialized) return true;
523
524        modal = document.getElementById('omt-speaker-modal');
525        if (!modal) return false;
526
527        // The overlay itself does not scroll — the dialog inside it does
528        // (.speaker-modal, overflow-y: auto). Resetting the overlay would be a
529        // no-op and leave the next vita standing mid-page.
530        dialog = modal.querySelector('.speaker-modal');
531        closeBtn = modal.querySelector('[data-speaker-modal-close]');
532        rail = modal.querySelector('.speaker-modal__rail');
533        gallery = modal.querySelector('[data-speaker-gallery]');
534        tooltip = modal.querySelector('[data-speaker-gallery-tooltip]');
535        galleryPrev = modal.querySelector('[data-speaker-gallery-prev]');
536        galleryNext = modal.querySelector('[data-speaker-gallery-next]');
537        mobilePrev = modal.querySelector('[data-speaker-mobile-prev]');
538        mobileNext = modal.querySelector('[data-speaker-mobile-next]');
539
540        if (gallery) {
541            gallery.addEventListener('scroll', function () {
542                updateGalleryEdges();
543                hideTooltip();
544            }, { passive: true });
545        }
546
547        window.addEventListener('resize', updateGalleryEdges);
548
549        if (galleryPrev) {
550            galleryPrev.addEventListener('click', function () {
551                hideTooltip();
552                nudgeGallery(-1);
553            });
554        }
555        if (galleryNext) {
556            galleryNext.addEventListener('click', function () {
557                hideTooltip();
558                nudgeGallery(1);
559            });
560        }
561
562        [mobilePrev, mobileNext].forEach(function (button) {
563            if (!button) return;
564            button.addEventListener('click', function () {
565                open(button.getAttribute('data-speaker-target'), button);
566            });
567        });
568
569        // Delegated listeners catch both static and dynamically rendered triggers.
570        document.addEventListener('click', function (e) {
571            var trigger = e.target.closest('[data-speaker-modal-open]');
572            if (!trigger) return;
573            open(trigger.getAttribute('data-speaker-modal-open'), trigger);
574        });
575
576        document.addEventListener('keydown', function (e) {
577            if (e.key !== 'Enter' && e.key !== ' ') return;
578            var trigger = e.target.closest('[data-speaker-modal-open]');
579            if (!trigger) return;
580            e.preventDefault();
581            open(trigger.getAttribute('data-speaker-modal-open'), trigger);
582        });
583
584        if (closeBtn) {
585            closeBtn.addEventListener('click', close);
586        }
587
588        modal.addEventListener('click', function (event) {
589            if (event.target === modal) close();
590        });
591
592        document.addEventListener('keydown', function (event) {
593            if (event.key === 'Escape' && modal.classList.contains('open')) {
594                close();
595            }
596        });
597
598        initialized = true;
599        return true;
600    }
601
602    // ── Set data / change order ──────────────────────────────────
603    function setData(newProfiles, newOrder) {
604        profiles = newProfiles || {};
605        order = (newOrder || []).slice();
606        currentId = order[0] || '';
607        buildGallery();
608        updateMobileNav(currentId);
609        updateGalleryEdges();
610    }
611
612    // Re-sort (e.g. alphabetically) without refetching the data.
613    function setOrder(newOrder) {
614        order = (newOrder || []).slice();
615        buildGallery();
616        updateMobileNav(currentId || order[0] || '');
617        updateGalleryEdges();
618    }
619
620    // ── Load: /api/speakers → parse → setData ────────────────────
621    // cache: 'no-cache' → the browser revalidates instead of serving its own
622    // copy for the response's max-age. Without it the server TTL (5 min) and the
623    // browser cache (5 min) would stack to 10 min stale data. Same as the
624    // program fetch in main.js.
625    function load() {
626        return fetch('api/speakers', { cache: 'no-cache' })
627            .then(function (res) {
628                if (!res.ok) throw new Error('speakers ' + res.status);
629                return res.json();
630            })
631            .then(function (data) {
632                var parsed = parse(data);
633                setData(parsed.profiles, parsed.order);
634                return parsed;
635            });
636    }
637
638    // ── Public API ───────────────────────────────────────────────
639    window.OMTSpeakerModal = {
640        init: init,
641        load: load,
642        parse: parse,
643        applyImage: applyImage,
644        photoCaption: photoCaption,
645        sizesFor: sizesFor,
646        sortBySurname: sortBySurname,
647        setData: setData,
648        setOrder: setOrder,
649        open: open,
650        close: close,
651        getProfile: function (slug) { return profiles[slug]; },
652        getProfiles: function () { return profiles; },
653        getOrder: function () { return order.slice(); },
654        has: function (slug) { return Object.prototype.hasOwnProperty.call(profiles, slug); }
655    };
656})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.