PageSourceSearch

https://freiburg.run/main-16ca9e79d6ab883d.js

js freiburg.run collected 2026-09-24 12:59:33 UTC 40,860 bytes, 1,232 lines download raw bytes

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: '&copy; <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: '&copy; <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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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: '&copy; <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.