1const on_load = function(f) { 2 if (document.body === null) { 3 document.addEventListener('DOMContentLoaded', () => {f()}, false); 4 } else { 5 f(); 6 } 7} 8 9const umami_track_event = function (name, data) { 10 if (window.umami !== undefined) { 11 window.umami.track(name, data); 12 } 13}; 14 15const parseGeo = function (s) { 16 const match2coords = (m) => { 17 const lat = parseFloat(m.groups.lat); 18 const lng = parseFloat(m.groups.lng); 19 return [lat, lng]; 20 }; 21 22 const re1 = /\s*N\s*(?<lat>\d+\.\d+)\s+E\s*(?<lng>\d+\.\d+)\s*$/gm; 23 const match1 = re1.exec(s); 24 if (match1 !== null) { 25 return match2coords(match1); 26 } 27 28 const re2 = /\s*(?<lat>\d+\.\d+)\s*,\s*(?<lng>\d+\.\d+)\s*$/gm; 29 const match2 = re2.exec(s); 30 if (match2 !== null) { 31 return match2coords(match2); 32 } 33 34 return null; 35}; 36 37const onEach = (selector, callback) => { 38 document.querySelectorAll(selector).forEach(callback); 39}; 40 41const on = (selector, event, callback) => { 42 document.querySelectorAll(selector).forEach(el => el.addEventListener(event, callback)); 43}; 44 45const decodeUnsignedIntegers = function (encoded) { 46 const numbers = []; 47 let index = 0; 48 const len = encoded.length; 49 while (index < len) { 50 let num = 0; 51 let shift = 0; 52 while (true) { 53 const b = encoded.charCodeAt(index++) - 63; 54 num |= (b & 0x1f) << shift; 55 if ((b & 0x20) === 0) break; 56 shift += 5; 57 } 58 numbers.push(num); 59 } 60 return numbers; 61}; 62 63const decodeSignedIntegers = function (encoded) { 64 return decodeUnsignedIntegers(encoded).map(num => (num & 1) ? ~(num >> 1) : (num >> 1)); 65}; 66 67const decodeFloats = function (encoded) { 68 const factor = 1e5; 69 return decodeSignedIntegers(encoded).map(num => num / factor); 70}; 71 72const decodeDeltas = function (encoded) { 73 const dimension = 2; 74 const lastNumbers = []; 75 const numbers = decodeFloats(encoded); 76 for (let i = 0, len = numbers.length; i < len;) { 77 for (let d = 0; d < dimension; ++d, ++i) { 78 numbers[i] = Math.round((lastNumbers[d] = numbers[i] + (lastNumbers[d] || 0)) * 100000) / 100000; 79 } 80 } 81 return numbers; 82}; 83 84const parsePolyline = function (encoded) { 85 if (encoded === undefined || encoded.trim() === "") { 86 return null; 87 } 88 const dimension = 2; 89 const flatPoints = decodeDeltas(encoded); 90 const points = []; 91 for (let i = 0, len = flatPoints.length; i + (dimension -1) < len;) { 92 const point = []; 93 for (let dim = 0; dim < dimension; ++dim) { 94 point.push(flatPoints[i++]); 95 } 96 points.push(point); 97 } 98 return points; 99}; 100 101const loadMap = function (id) { 102 const mapEl = document.getElementById(id); 103 const cityName = mapEl.dataset.cityName || "Freiburg"; 104 const cityLat = parseFloat(mapEl.dataset.cityLat) || 47.996090; 105 const cityLon = parseFloat(mapEl.dataset.cityLon) || 7.849400; 106 107 const center = [cityLat, cityLon]; 108 const map = L.map(id, {gestureHandling: true}).setView(center, 15); 109 110 L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { 111 attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' 112 }).addTo(map); 113 114 L.circle(center, { 115 color: '#3e8ed0', 116 fill: false, 117 weight: 1, 118 radius: 25000 119 }).addTo(map).bindPopup(`${cityName}, 25km`); 120 L.circle(center, { 121 color: '#3e8ed0', 122 fill: false, 123 weight: 1, 124 radius: 50000 125 }).addTo(map).bindPopup(`${cityName}, 50km`); 126 127 const blueIcon = load_marker(""); 128 const greyIcon = load_marker("grey"); 129 const greenIcon = load_marker("green"); 130 const redIcon = load_marker("red"); 131 132 const markers = []; 133 document.querySelectorAll(".event").forEach(el => { 134 const geo = parseGeo(el.dataset.geo); 135 if (geo !== null) { 136 let icon = null; 137 let zOffset = 0; 138 switch (el.dataset.type) { 139 case "Lauftreff": 140 zOffset = 1000; 141 icon = redIcon; 142 break; 143 case "vergangene Veranstaltung": 144 zOffset = -1000; 145 icon = greyIcon; 146 break; 147 case "Veranstaltung": 148 default: 149 zOffset = 1000; 150 icon = blueIcon; 151 break; 152 } 153 154 const m = L.marker(geo, {icon: icon, zIndexOffset: zOffset}); 155 markers.push(m); 156 m.addTo(map); 157 if (el.dataset.time !== undefined) { 158 m.bindPopup(`<a href="/${el.dataset.slug}">${el.dataset.name}</a><br>(${el.dataset.type})<br>${el.dataset.time}<br>${el.dataset.location}`); 159 } else { 160 m.bindPopup(`<a href="/${el.dataset.slug}">${el.dataset.name}</a><br>(${el.dataset.type})<br>${el.dataset.location}`); 161 } 162 } 163 }); 164 165 const items = [{ 166 label: "Veranstaltung", 167 type: "image", 168 url: "/images/marker-icon.png", 169 },{ 170 label: "vergangene Veranstaltung", 171 type: "image", 172 url: "/images/marker-grey-icon.png", 173 },{ 174 label: "Lauftreff", 175 type: "image", 176 url: "/images/marker-red-icon.png", 177 }]; 178 items.push( 179 { 180 label: `25km um ${cityName}`, 181 type: "image", 182 url: "/images/circle-small.png" 183 }, { 184 label: `50km um ${cityName}`, 185 type: "image", 186 url: "/images/circle-big.png" 187 } 188 ); 189 const legend = L.control.Legend({ 190 title: "Legende", 191 position: "bottomleft", 192 collapsed: true,
193 symbolWidth: 30, 194 opacity: 1, 195 column: 1, 196 legends: items 197 }); 198 legend.addTo(map); 199 200 const group = new L.featureGroup(markers); 201 map.fitBounds(group.getBounds(), {padding: L.point(40, 40)}); 202}; 203 204const loadParkrunMap = function (id, encodedTrack) { 205 const map = L.map(id, {gestureHandling: true}).setView([48.000548, 7.804842], 15); 206 207 L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { 208 attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' 209 }).addTo(map); 210 211 const course = L.polyline(parsePolyline(encodedTrack)); 212 course.addTo(map); 213 214 const blueIcon = load_marker(""); 215 const greyIcon = load_marker("grey"); 216 217 const parking = L.marker([48.000993,7.808887], {icon: greyIcon}); 218 parking.addTo(map); 219 parking.bindPopup("Parkplatz"); 220 221 const tram = L.marker([47.999420,7.810088], {icon: greyIcon}); 222 tram.addTo(map); 223 tram.bindPopup("StraÃenbahn (Linie 3, Rohrgraben)"); 224 225 const meetingpoint = L.marker([48.001294,7.806489], {icon: blueIcon}); 226 meetingpoint.addTo(map); 227 meetingpoint.bindPopup("Treffpunkt / Zielbereich"); 228}; 229 230const load_marker = function (color) { 231 let url = "/images/marker-icon.png"; 232 let url2x = "/images/marker-icon-2x.png"; 233 if (color !== "") { 234 url = "/images/marker-" + color + "-icon.png"; 235 url2x = "/images/marker-" + color + "-icon-2x.png"; 236 } 237 const options = { 238 iconAnchor: [12, 41], 239 iconRetinaUrl: url2x, 240 iconSize: [25, 41], 241 iconUrl: url, 242 popupAnchor: [1, -34], 243 shadowSize: [41, 41], 244 shadowUrl: "/images/marker-shadow.png", 245 tooltipAnchor: [16, -28], 246 }; 247 return L.icon(options); 248} 249 250const similarDistances = function(d1, d2, factor) { 251 return ((d1 * (1.0-factor)) <= d2) && (d2 <= (d1 * (1.0+factor))); 252} 253 254const filter = (s, hiddenTags) => { 255 let shown = 0; 256 let hidden = 0; 257 let hiddenTag = 0; 258 const info = document.querySelector("#filter-info"); 259 const needle = s.toLowerCase().trim(); 260 261 // hide all collapsible boxes when a search is active 262 if (needle !== "") { 263 document.querySelectorAll(".collapsible").forEach(box => { 264 box.classList.add("is-hidden"); 265 }); 266 } else { 267 document.querySelectorAll(".collapsible").forEach(box => { 268 box.classList.remove("is-hidden"); 269 }); 270 } 271 272 // check if needle is a number (e.g. "10", "10.5", "10,5") and if so, use it as a distance filter 273 let needleDistance = -1; 274 if (needle !== "") { 275 const re = /^(\d+[.,]?\d*)$/i; 276 const match = needle.match(re); 277 if (match) { 278 needleDistance = parseFloat(match[1].replace(',', '.')); 279 } 280 } 281 282 const items = new Array(); 283 document.querySelectorAll(".event, .event-separator").forEach(el => { 284 const sep = el.previousSibling; 285 if (sep === null) { 286 items.push(null); 287 } 288 items.push(el); 289 }); 290 291 let lastSep = null; 292 items.forEach(el => { 293 if (el === null) { 294 lastSep = null; 295 } else if (el.classList.contains("event-separator")) { 296 if (lastSep !== null) { 297 lastSep.classList.add("is-hidden"); 298 } 299 lastSep = el; 300 } else { 301 // hide by tag 302 if (hiddenTags.size != 0) { 303 let found = false; 304 el.querySelectorAll("[data-tag]").forEach(tagEl => { 305 if (tagEl.dataset.tag !== undefined) { 306 if (hiddenTags.has(tagEl.dataset.tag)) { 307 found = true; 308 return; 309 } 310 } 311 }); 312 if (found) { 313 hiddenTag++; 314 el.classList.add("is-hidden"); 315 return; 316 } 317 } 318 319 // hide by search 320 if (needle != "") { 321 if (needleDistance > -1) { 322 let distances = []; 323 if (el.dataset.distancesParsed !== undefined) { 324 distances = JSON.parse(el.dataset.distancesParsed); 325 } else { 326 let distancesStr = el.dataset.distances; 327 // parse string into array "[5 10 42.3]" -> [5, 10, 42.3] 328 if (distancesStr !== undefined && distancesStr.trim() !== "") { 329 distancesStr = distancesStr.trim(); 330 if (distancesStr.startsWith("[") && distancesStr.endsWith("]")) { 331 distancesStr = distancesStr.substring(1, distancesStr.length - 1); 332 distancesStr.split(" ").forEach(d => { 333 const dist = parseFloat(d); 334 if (!isNaN(dist)) { 335 distances.push(dist); 336 } 337 }); 338 } 339 } 340 // store parsed distances in dataset for future use 341 el.dataset.distancesParsed = JSON.stringify(distances); 342 } 343 344 let distanceMatch = false;
345 for (let d of distances) { 346 if (similarDistances(d, needleDistance, 0.1)) { 347 distanceMatch = true; 348 break; 349 } 350 } 351 352 if (!distanceMatch) { 353 hidden++; 354 el.classList.add("is-hidden"); 355 return; 356 } 357 } else { 358 const name = el.dataset.name.toLowerCase(); 359 const location = el.dataset.location.toLowerCase(); 360 if (!name.includes(needle) && !location.includes(needle)) { 361 hidden++; 362 el.classList.add("is-hidden"); 363 return; 364 } 365 } 366 } 367 368 // shown 369 shown++; 370 el.classList.remove("is-hidden"); 371 if (lastSep !== null) { 372 lastSep.classList.remove("is-hidden"); 373 } 374 lastSep = null; 375 } 376 }); 377 378 if (lastSep !== null) { 379 lastSep.classList.add("is-hidden"); 380 } 381 382 if (hidden != 0 || hiddenTag != 0) { 383 let hiddenStr = "" 384 if (hidden != 0) { 385 hiddenStr = `, ${hidden} ${hidden!=1 ? "Einträge" : "Eintrag"} über Filter versteckt`; 386 } 387 let hiddenTagStr = "" 388 if (hiddenTag != 0) { 389 hiddenTagStr = `, ${hiddenTag} ${hiddenTag!=1 ? "Einträge" : "Eintrag"} über <a href="/tags.html">Kategorien</a> versteckt`; 390 } 391 let filterStr = ""; 392 if (needleDistance >= 0) { 393 filterStr = `Filter nach Distanz: ${needleDistance} km ±10%; `; 394 } else if (needle !== "") { 395 // sanitize needle for HTML output (e.g. if it contains "<" or ">", escape it) 396 const sanitizedNeedle = needle.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); 397 filterStr = `Filter nach Name/Ort: "${sanitizedNeedle}"; `; 398 } 399 info.innerHTML = `${filterStr}${shown} ${shown!=1 ? "Einträge" : "Eintrag"} angezeigt${hiddenStr}${hiddenTagStr}`; 400 info.classList.remove("is-hidden"); 401 } else { 402 info.innerHTML = `${shown} ${shown!=1 ? "Einträge" : "Eintrag"}`; 403 info.classList.remove("is-hidden"); 404 } 405}; 406 407function getLocalStorage() { 408 let storage; 409 try { 410 storage = window["localStorage"]; 411 const x = "__storage_test__"; 412 storage.setItem(x, x); 413 storage.removeItem(x); 414 return storage; 415 } catch (e) { 416 return null; 417 } 418} 419 420function createEl(tag, id, classes) { 421 const el = document.createElement(tag); 422 if (id !== undefined && id !== null && id !== "") { 423 el.id = id; 424 } 425 if (classes !== undefined && classes !== null && classes !== "") { 426 classes.split(" ").forEach(c => { 427 el.classList.add(c); 428 }); 429 } 430 return el; 431} 432 433const WATCHLIST_STORAGE_KEY = "watchlist.v1"; 434const WATCHLIST_MAX_ITEMS = 100; 435 436const todayDateKey = function() { 437 const now = new Date(); 438 const yyyy = `${now.getFullYear()}`; 439 const mm = `${now.getMonth() + 1}`.padStart(2, "0"); 440 const dd = `${now.getDate()}`.padStart(2, "0"); 441 return `${yyyy}-${mm}-${dd}`; 442}; 443 444const normalizeDateKey = function(s) { 445 if (typeof s !== "string") { 446 return ""; 447 } 448 const cleaned = s.trim(); 449 if (!/^\d{4}-\d{2}-\d{2}$/.test(cleaned)) { 450 return ""; 451 } 452 return cleaned; 453}; 454 455const normalizeWatchlistItem = function(item) { 456 if (item === null || typeof item !== "object") { 457 return null; 458 } 459 460 const id = (item.id || "").toString().trim(); 461 const slug = (item.slug || "").toString().trim(); 462 if (id === "" || slug === "") { 463 return null; 464 } 465 466 const addedAtNum = Number(item.addedAt); 467 468 // Explicit category, or fall back to the slug prefix (e.g. "group/â¦" â "group") 469 let category = (item.category || "").toString().trim(); 470 if (category === "") { 471 const slashIdx = id.indexOf("/"); 472 if (slashIdx > 0) { 473 category = id.substring(0, slashIdx); 474 } 475 } 476 if (category !== "event" && category !== "group") { 477 category = "event";
478 } 479 480 return { 481 id: id, 482 slug: slug, 483 category: category, 484 url: (item.url || "").toString().trim(), 485 name: (item.name || "").toString().trim(), 486 time: (item.time || "").toString().trim(), 487 timeFrom: normalizeDateKey(item.timeFrom), 488 timeTo: normalizeDateKey(item.timeTo), 489 location: (item.location || "").toString().trim(), 490 addedAt: Number.isFinite(addedAtNum) ? addedAtNum : Date.now(), 491 }; 492}; 493 494const watchlistItemScore = function(item) { 495 let score = 0; 496 if (item.name !== "") score += 1; 497 if (item.time !== "") score += 1; 498 if (item.timeFrom !== "") score += 1; 499 if (item.timeTo !== "") score += 1; 500 if (item.location !== "") score += 1; 501 if (item.url !== "") score += 1; 502 return score; 503}; 504 505const dedupeWatchlist = function(items) { 506 const byId = new Map(); 507 items.forEach(item => { 508 if (!byId.has(item.id)) { 509 byId.set(item.id, item); 510 return; 511 } 512 const existing = byId.get(item.id); 513 if (watchlistItemScore(item) > watchlistItemScore(existing)) { 514 byId.set(item.id, item); 515 } 516 }); 517 return Array.from(byId.values()); 518}; 519 520const sortWatchlist = function(items) { 521 const sorted = [...items]; 522 sorted.sort((a, b) => { 523 const aDate = a.timeFrom || a.timeTo; 524 const bDate = b.timeFrom || b.timeTo; 525 if (aDate === "" && bDate === "") { 526 return a.name.localeCompare(b.name, "de", {sensitivity: "base"}); 527 } 528 if (aDate === "") return 1; 529 if (bDate === "") return -1; 530 if (aDate < bDate) return -1; 531 if (aDate > bDate) return 1; 532 return a.name.localeCompare(b.name, "de", {sensitivity: "base"}); 533 }); 534 return sorted; 535}; 536 537const prunePastWatchlistItems = function(items, todayKey) { 538 return items.filter(item => { 539 if (item.timeTo === "") { 540 return true; 541 } 542 return item.timeTo >= todayKey; 543 }); 544}; 545 546const getWatchlist = function(storage) { 547 if (storage === null) { 548 return []; 549 } 550 let raw; 551 try { 552 raw = storage.getItem(WATCHLIST_STORAGE_KEY); 553 } catch (error) { 554 return []; 555 } 556 if (raw === null || raw.trim() === "") { 557 return []; 558 } 559 try { 560 const parsed = JSON.parse(raw); 561 if (!Array.isArray(parsed)) { 562 return []; 563 } 564 const items = parsed 565 .map(normalizeWatchlistItem) 566 .filter(item => item !== null); 567 return dedupeWatchlist(items); 568 } catch (error) { 569 return []; 570 } 571}; 572 573const saveWatchlist = function(storage, items) { 574 if (storage === null) { 575 return false; 576 } 577 try { 578 storage.setItem(WATCHLIST_STORAGE_KEY, JSON.stringify(items)); 579 return true; 580 } catch (error) { 581 console.error("Saving watchlist failed", error); 582 return false; 583 } 584}; 585 586const initWatchlist = function(storage) { 587 const toggles = Array.from(document.querySelectorAll("[data-watchlist-toggle]")); 588 const modal = document.getElementById("watchlist-modal"); 589 if (toggles.length === 0 && modal === null) { 590 return; 591 } 592 593 const listEl = document.getElementById("watchlist-list"); 594 const emptyEl = document.getElementById("watchlist-empty"); 595 const warningEl = document.getElementById("watchlist-storage-warning"); 596 const todayKey = todayDateKey(); 597 598 if (storage === null) { 599 toggles.forEach(toggle => { 600 toggle.disabled = true; 601 toggle.classList.add("is-disabled"); 602 }); 603 if (warningEl !== null) { 604 warningEl.classList.remove("is-hidden"); 605 } 606 return; 607 } 608 609 let watchlist = sortWatchlist(prunePastWatchlistItems(getWatchlist(storage), todayKey)); 610 saveWatchlist(storage, watchlist); 611 612 const watchlistById = function() { 613 const ids = new Set(); 614 watchlist.forEach(item => ids.add(item.id)); 615 return ids; 616 }; 617 618 const syncToggleButtons = function() { 619 const ids = watchlistById();
620 toggles.forEach(toggle => { 621 const id = (toggle.dataset.watchlistId || "").trim(); 622 const selected = id !== "" && ids.has(id); 623 toggle.setAttribute("aria-pressed", selected ? "true" : "false"); 624 toggle.classList.toggle("is-watchlisted", selected); 625 }); 626 }; 627 628 const makeWatchlistItem = function(item) { 629 const li = createEl("li", null, "watchlist-item"); 630 const textWrap = createEl("div", null, "watchlist-item-text"); 631 632 const link = createEl("a", null, "has-text-weight-bold"); 633 if (item.url !== "") { 634 link.href = item.url; 635 } else { 636 link.href = `/${item.slug}`; 637 } 638 link.textContent = item.name !== "" ? item.name : item.slug; 639 link.addEventListener("click", () => { 640 umami_track_event("watchlist-open-item", {id: item.id, slug: item.slug}); 641 }); 642 textWrap.appendChild(link); 643 644 const meta = []; 645 if (item.time !== "") { 646 meta.push(item.time); 647 } 648 if (item.location !== "") { 649 meta.push(item.location); 650 } 651 if (meta.length > 0) { 652 const metaEl = createEl("div", null, "is-size-7 has-text-grey"); 653 metaEl.textContent = meta.join(" | "); 654 textWrap.appendChild(metaEl); 655 } 656 657 const removeBtn = createEl("button", null, "button is-small is-light is-danger"); 658 removeBtn.type = "button"; 659 removeBtn.textContent = "Entfernen"; 660 removeBtn.dataset.watchlistRemove = item.id; 661 662 li.appendChild(textWrap); 663 li.appendChild(removeBtn); 664 return li; 665 }; 666 667 const renderWatchlist = function() { 668 if (listEl === null || emptyEl === null) { 669 return; 670 } 671 672 listEl.innerHTML = ""; 673 if (watchlist.length === 0) { 674 emptyEl.classList.remove("is-hidden"); 675 return; 676 } 677 emptyEl.classList.add("is-hidden"); 678 679 const sections = [ 680 {key: "event", label: "Veranstaltungen"}, 681 {key: "group", label: "Lauftreffs"}, 682 ]; 683 684 sections.forEach(({key, label}) => { 685 const items = watchlist.filter(i => i.category === key); 686 if (items.length === 0) { 687 return; 688 } 689 690 const heading = createEl("p", null, "watchlist-category-heading"); 691 heading.textContent = label; 692 listEl.appendChild(heading); 693 694 const ul = createEl("ul", null, "watchlist-items"); 695 items.forEach(item => ul.appendChild(makeWatchlistItem(item))); 696 listEl.appendChild(ul); 697 }); 698 }; 699 700 const refreshWatchlist = function(save) { 701 watchlist = dedupeWatchlist(watchlist); 702 watchlist = prunePastWatchlistItems(watchlist, todayDateKey()); 703 watchlist = sortWatchlist(watchlist); 704 if (watchlist.length > WATCHLIST_MAX_ITEMS) { 705 watchlist = watchlist.slice(0, WATCHLIST_MAX_ITEMS); 706 } 707 if (save) { 708 saveWatchlist(storage, watchlist); 709 } 710 syncToggleButtons(); 711 renderWatchlist(); 712 713 document.querySelectorAll(".watchlist-count").forEach(el => { 714 if (watchlist.length > 0) { 715 el.textContent = watchlist.length; 716 el.classList.remove("is-hidden"); 717 } else { 718 el.classList.add("is-hidden"); 719 } 720 }); 721 }; 722 723 const isElementVisible = function(el) { 724 if (!(el instanceof HTMLElement)) { 725 return false; 726 } 727 if (el.getClientRects().length === 0) { 728 return false; 729 } 730 const style = window.getComputedStyle(el); 731 return style.display !== "none" && style.visibility !== "hidden"; 732 }; 733 734 const findVisibleWatchlistTrigger = function(excludeEl) { 735 const candidates = Array.from(document.querySelectorAll("[data-target='watchlist-modal']")); 736 for (const candidate of candidates) { 737 if (candidate === excludeEl) { 738 continue; 739 } 740 if (isElementVisible(candidate)) { 741 return candidate; 742 } 743 } 744 return null; 745 }; 746 747 const animateWatchlistAdd = function(sourceToggle) { 748 if (!(sourceToggle instanceof HTMLElement)) { 749 return; 750 } 751 if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) { 752 return; 753 } 754 755 const target = findVisibleWatchlistTrigger(sourceToggle); 756 if (!(target instanceof HTMLElement)) { 757 return; 758 } 759 760 const startRect = sourceToggle.getBoundingClientRect(); 761 const targetRect = target.getBoundingClientRect(); 762 if (startRect.width === 0 || startRect.height === 0 || targetRect.width === 0 || targetRect.height === 0) { 763 return; 764 } 765 766 const startX = startRect.left + startRect.width / 2; 767 const startY = startRect.top + startRect.height / 2; 768 const endX = targetRect.left + targetRect.width / 2; 769 const endY = targetRect.top + targetRect.height / 2; 770 771 const flyer = createEl("div", null, "watchlist-flyer"); 772 const star = document.createElement("span"); 773 star.classList.add("star-icon"); 774 flyer.appendChild(star); 775 flyer.classList.add("watchlist-flyer"); 776 flyer.style.left = `${startX - 16}px`; 777 flyer.style.top = `${startY - 16}px`; 778 document.body.appendChild(flyer); 779 780 const dx = endX - startX; 781 const dy = endY - startY; 782 const travel = flyer.animate([ 783 {transform: "translate(0, 0)", opacity: 0.95, offset: 0}, 784 {transform: `translate(${dx * 0.7}px, ${dy * 0.7}px)`, opacity: 0.9, offset: 0.65}, 785 {transform: `translate(${dx}px, ${dy}px)`, opacity: 0.1, offset: 1}, 786 ], { 787 duration: 1000, 788 easing: "cubic-bezier(0.22, 1, 0.36, 1)", 789 fill: "forwards", 790 }); 791 792 const cleanup = () => { 793 flyer.remove(); 794 }; 795 travel.addEventListener("finish", cleanup, {once: true}); 796 travel.addEventListener("cancel", cleanup, {once: true}); 797 }; 798
799 toggles.forEach(toggle => { 800 toggle.addEventListener("click", () => { 801 const id = (toggle.dataset.watchlistId || "").trim(); 802 if (id === "") { 803 return; 804 } 805 806 const existing = watchlist.some(item => item.id === id); 807 if (existing) { 808 watchlist = watchlist.filter(item => item.id !== id); 809 umami_track_event("watchlist-remove", {id: id}); 810 refreshWatchlist(true); 811 return; 812 } 813 814 watchlist.push(normalizeWatchlistItem({ 815 id: id, 816 slug: (toggle.dataset.slug || "").trim(), 817 category: (toggle.dataset.watchlistCategory || "").trim(), 818 url: (toggle.dataset.url || "").trim(), 819 name: (toggle.dataset.name || "").trim(), 820 time: (toggle.dataset.time || "").trim(), 821 timeFrom: (toggle.dataset.timeFrom || "").trim(), 822 timeTo: (toggle.dataset.timeTo || "").trim(), 823 location: (toggle.dataset.location || "").trim(), 824 addedAt: Date.now(), 825 })); 826 watchlist = watchlist.filter(item => item !== null); 827 umami_track_event("watchlist-add", {id: id}); 828 refreshWatchlist(true); 829 830 animateWatchlistAdd(toggle); 831 }); 832 }); 833 834 if (listEl !== null) { 835 listEl.addEventListener("click", (event) => { 836 const target = event.target; 837 if (!(target instanceof HTMLElement)) { 838 return; 839 } 840 const id = target.dataset.watchlistRemove; 841 if (id === undefined || id.trim() === "") { 842 return; 843 } 844 845 watchlist = watchlist.filter(item => item.id !== id); 846 umami_track_event("watchlist-remove", {id: id}); 847 refreshWatchlist(true); 848 }); 849 } 850 851 onEach('.modal-trigger', ($trigger) => { 852 if ($trigger.dataset.target === "watchlist-modal") { 853 $trigger.addEventListener('click', () => { 854 umami_track_event('watchlist-open', {url: document.location.href}); 855 }); 856 } 857 }); 858 859 refreshWatchlist(false); 860}; 861 862const main = () => { 863 // TAG FILTER, LOCAL STORAGE 864 const storage = getLocalStorage(); 865 const hiddenTags = new Set(); 866 if (storage !== null) { 867 let tags = storage.getItem("hiddenTags"); 868 if (tags !== null) { 869 tags.split(",").forEach(tag => { 870 tag = tag.trim(); 871 if (tag !== "") { 872 hiddenTags.add(tag); 873 } 874 }); 875 } 876 } 877 const tagTable = document.querySelector("#tag-table"); 878 if (tagTable !== null) { 879 tagTable.querySelectorAll("[data-tag]").forEach(el => { 880 if (storage !== null) { 881 const tag = el.dataset.tag; 882 el.checked = hiddenTags.has(tag); 883 el.addEventListener('change', (event) => { 884 if (event.currentTarget.checked) { 885 hiddenTags.add(tag); 886 } else { 887 hiddenTags.delete(tag); 888 } 889 const tags = Array.from(hiddenTags).join(","); 890 storage.setItem("hiddenTags", tags); 891 }); 892 } else { 893 el.disabled = true; 894 } 895 }); 896 } 897 898 // FILTER 899 const filterInput = document.querySelector("#filter-input"); 900 if (filterInput !== null) { 901 filterInput.addEventListener('input', (e) => { 902 filter(e.target.value, hiddenTags); 903 }); 904 document.querySelector("#filter-button-cancel").addEventListener('click', (e) => { 905 filterInput.value = ""; 906 filter("", hiddenTags); 907 }); 908 filter("", hiddenTags); 909 } 910 const filterToggle = document.querySelector("#toggle-filter"); 911 const sectionFilter = document.querySelector("#section-filter"); 912 if (filterToggle !== null && sectionFilter !== null) { 913 filterToggle.addEventListener('click', () => { 914 sectionFilter.classList.toggle('is-hidden'); 915 }); 916 } 917 918 // WATCHLIST 919 initWatchlist(storage); 920 921 // SHARE BUTTONS 922 onEach("[data-share]", shareButton => { 923 const shareData = { 924 title: shareButton.dataset.name, 925 url: shareButton.dataset.url + "?utm_source=share_button", 926 }; 927 928 if (navigator.canShare === undefined || !navigator.canShare(shareData)) { 929 shareButton.classList.add("is-hidden"); 930 return; 931 } 932 933 shareButton.addEventListener('click', async (e) => { 934 e.preventDefault(); 935 umami_track_event('share-click', {url: shareData.url}); 936 try { 937 await navigator.share(shareData); 938 } catch (error) { 939 console.error("Error sharing:", error); 940 } 941 }); 942 }); 943 944 // CALENDARS 945 onEach(".calendar-button", btn => { 946 btn.addEventListener('click', (e) => { 947 e.preventDefault(); 948 const modal = document.getElementById("calendar-modal"); 949 modal.querySelector(".event-name").innerText = btn.dataset.name; 950 const googlecal = modal.querySelector(".calendar-google"); 951 googlecal.setAttribute("href", btn.dataset.googlecal); 952 googlecal.setAttribute("data-umami-event", "calendar-click"); 953 const ics = modal.querySelector(".calendar-ics"); 954 ics.setAttribute("href", btn.dataset.calendar); 955 ics.setAttribute("download", btn.dataset.calendarfile); 956 ics.setAttribute("data-umami-event", "calendar-click"); 957 modal.classList.add("is-active"); 958 umami_track_event('calendar-click', {event: btn.dataset.name}); 959 }); 960 }); 961 962 // MAPS 963 const mapShowBtn = document.querySelector("#map-show-btn"); 964 const mapHideBtn = document.querySelector("#map-hide-btn"); 965 if (mapShowBtn !== null && mapHideBtn !== null) { 966 mapShowBtn.addEventListener('click', () => { 967 mapShowBtn.classList.add("is-hidden"); 968 mapHideBtn.classList.remove("is-hidden"); 969 const container = document.querySelector("#map-container"); 970 const mapDiv = createEl("div", "small-map", ""); 971 container.appendChild(mapDiv); 972 if (container.dataset.type === "parkrun") { 973 loadParkrunMap("small-map", container.dataset.track); 974 } else { 975 loadMap("small-map"); 976 } 977 }); 978 mapHideBtn.addEventListener('click', () => { 979 mapShowBtn.classList.remove("is-hidden"); 980 mapHideBtn.classList.add("is-hidden"); 981 document.querySelector("#small-map").remove(); 982 }); 983 984 } 985 986 // iterate over all elements with class "event-map" and load a map for each 987 988 document.querySelectorAll(".event-map").forEach(eventMap => { 989 console.log(eventMap); 990 const geo = parseGeo(eventMap.dataset.geo); 991 const track = parsePolyline(eventMap.dataset.track); 992 993 if (geo !== null) { 994 const map = L.map(eventMap, {gestureHandling: true}
994).setView(geo, 15); 995 996 L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { 997 attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' 998 }).addTo(map); 999 1000 const marker = L.marker(geo, {icon: load_marker("")}); 1001 marker.addTo(map); 1002 marker.bindPopup(eventMap.dataset.name); 1003 if (track !== null) { 1004 const polyline = L.polyline(track, {color: '#3273dc'}).addTo(map); 1005 map.fitBounds(polyline.getBounds()); 1006 } 1007 } 1008 }); 1009 1010 // NAVBAR 1011 const burgersByTarget = new Map(); 1012 const collectBurger = (burger, target) => { 1013 if (!burgersByTarget.has(target)) { 1014 burgersByTarget.set(target, []); 1015 } 1016 burgersByTarget.get(target).push(burger); 1017 } 1018 onEach('.navbar-burger', el => { 1019 collectBurger(el, el.dataset.target); 1020 }); 1021 onEach('.navbar-burger-menu', el => { 1022 collectBurger(el, el.dataset.target); 1023 }); 1024 burgersByTarget.forEach((burgers, target) => { 1025 burgers.forEach(el => { 1026 el.addEventListener('click', () => { 1027 if (!el.classList.contains('is-active')) { 1028 umami_track_event('menu-open', {url: document.location.href}); 1029 } 1030 burgers.forEach(be => { 1031 be.classList.toggle('is-active'); 1032 }); 1033 document.getElementById(target).classList.toggle('is-active'); 1034 }); 1035 }); 1036 }); 1037 1038 // MODALS 1039 function openModal($el) { 1040 $el.classList.add('is-active'); 1041 } 1042 function closeModal($el) { 1043 $el.classList.remove('is-active'); 1044 } 1045 function closeAllModals() { 1046 (document.querySelectorAll('.modal') || []).forEach(($modal) => { 1047 closeModal($modal); 1048 }); 1049 } 1050 1051 // Add a click event on buttons to open a specific modal 1052 onEach('.modal-trigger', ($trigger) => { 1053 const modal = $trigger.dataset.target; 1054 const $target = document.getElementById(modal); 1055 1056 $trigger.addEventListener('click', (event) => { 1057 if ($trigger.tagName === "A") { 1058 event.preventDefault(); 1059 } 1060 if ($target === null) { 1061 return; 1062 } 1063 openModal($target); 1064 }); 1065 }); 1066 1067 // Add a click event on various child elements to close the parent modal 1068 onEach('.modal-background, .modal-close, .modal-card-head .delete, .modal-card-foot .button, .modal-card-body .close', ($close) => { 1069 const $target = $close.closest('.modal'); 1070 1071 $close.addEventListener('click', () => { 1072 closeModal($target); 1073 }); 1074 }); 1075 1076 // Add a keyboard event to close all modals 1077 document.addEventListener('keydown', (e) => { 1078 if (e.key === "Escape") { 1079 closeAllModals(); 1080 } 1081 }); 1082 1083 // DROPDOWNS 1084 const $clickableDropdowns = document.querySelectorAll( 1085 ".dropdown:not(.is-hoverable)", 1086 ); 1087 1088 if ($clickableDropdowns.length > 0) { 1089 $clickableDropdowns.forEach(($dropdown) => { 1090 const $button = $dropdown.querySelector("button"); 1091 if (!$button) { 1092 return; 1093 } 1094 $button.addEventListener("click", (event) => { 1095 event.stopPropagation(); 1096 $dropdown.classList.toggle("is-active"); 1097 }); 1098 }); 1099 1100 document.addEventListener("click", () => { 1101 closeDropdowns(); 1102 }); 1103 } 1104 1105 function closeDropdowns() { 1106 $clickableDropdowns.forEach(($el) => { 1107 $el.classList.remove("is-active"); 1108 }); 1109 } 1110 1111 // UMAMI 1112 onEach("a[target=_blank]", (a) => { 1113 if (a.getAttribute("data-umami-event") === null) { 1114 a.setAttribute('data-umami-event', 'outbound-link-click'); 1115 } 1116 a.setAttribute('data-umami-event-url', a.href); 1117 }); 1118 if (location.hash === '#disable-umami') { 1119 localStorage.setItem('umami.disabled', 'true'); 1120 alert('Umami is now DISABLED in this browser.'); 1121 } 1122 if (location.hash === '#enable-umami') { 1123 localStorage.removeItem('umami.disabled'); 1124 alert('Umami is now ENABLED in this browser.'); 1125 } 1126 1127 // NOTIFICATIONS 1128 function isEmbedList() { 1129 return document.getElementById("embed-list") !== null; 1130 } 1131 function notificationGuard(id) { 1132 // don't show notifications if an element with id "embed-list" exists 1133 if (isEmbedList()) { 1134 console.log("Embed list detected, skipping notification."); 1135 return true; 1136 } 1137 1138 // check if the notification has already been shown 1139 try { 1140 if (typeof localStorage !== "undefined") { 1141 const lastNotificationShown = localStorage.getItem("last-notification-shown"); 1142 if (lastNotificationShown !== null) { 1143 if (lastNotificationShown === id) { 1144 console.log("Notification already shown, skipping."); 1145 return true; 1146 } 1147 } 1148 localStorage.setItem("last-notification-shown", id); 1149 } 1150 } catch (e) { 1151 console.error("LocalStorage not available, cannot store notification state.", e); 1152 } 1153 1154 // if localStorage is not available, assume notification has not been shown 1155 return false;
1156 } 1157 1158 function triggerNotificationOnce() { 1159 const notificationDataEl = document.getElementById("notification-data"); 1160 if (notificationDataEl === null) { 1161 return; 1162 } 1163 1164 let messages; 1165 try { 1166 messages = JSON.parse(notificationDataEl.getAttribute("data-messages")); 1167 } catch (e) { 1168 console.error("Failed to parse notification messages.", e); 1169 return; 1170 } 1171 1172 if (!Array.isArray(messages) || messages.length === 0) { 1173 return; 1174 } 1175 1176 // find the first message with an active start date (start <= today) 1177 const today = new Date(); 1178 today.setHours(0, 0, 0, 0); 1179 const activeMessage = messages.find(m => { 1180 if (m.start) { 1181 const start = new Date(m.start); 1182 if (!isNaN(start.getTime())) { 1183 start.setHours(0, 0, 0, 0); 1184 if (today < start) { 1185 return false; 1186 } 1187 } 1188 } 1189 return true; 1190 }); 1191 1192 if (activeMessage && !notificationGuard(`${activeMessage.id}`)) { 1193 setTimeout(() => { 1194 showNotification(activeMessage); 1195 }, 2000); 1196 } 1197 } 1198 1199 function showNotification(notification) { 1200 if (!notification || !notification.content || !notification.class) { 1201 console.error("Invalid notification object."); 1202 return; 1203 } 1204 1205 const existing = document.getElementById("notificationDiv"); 1206 if (existing) { 1207 existing.remove(); 1208 } 1209 1210 const container = createEl("div", "notificationDiv", "container"); 1211 document.body.appendChild(container); 1212 1213 const div = createEl("div", null, "notification is-radiusless " + notification.class); 1214 container.appendChild(div); 1215 1216 const closeButton = createEl("button", null, "delete"); 1217 closeButton.onclick = () => container.remove(); 1218 div.appendChild(closeButton); 1219 1220 const contentDiv = createEl("div"); 1221 contentDiv.innerHTML = notification.content; 1222 div.appendChild(contentDiv); 1223 1224 setTimeout(() => { 1225 container.classList.add("active"); 1226 }, 10); 1227 } 1228 1229 window.addEventListener("DOMContentLoaded", triggerNotificationOnce); 1230}; 1231 1232on_load(main);
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.