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.