Files
ndw-website/js/highlighted-events.js
T
2026-06-14 12:33:36 +02:00

474 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
// === 1) Date / day-of-year (for deterministic highlight pick) ===
const today = new Date();
const formattedDate = today.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
function getDayOfYear(date) {
const start = new Date(date.getFullYear(), 0, 0);
return Math.floor((date - start) / 86400000);
}
const dayOfYear = getDayOfYear(today);
// === 2) DOM refs for the highlight block (hero) ===
const elDate = document.getElementById("event-date");
const elTitle = document.getElementById("event-title");
const elBody = document.getElementById("event-body");
const elImg = document.getElementById("event-image");
const elImgWrap = document.getElementById("event-image-wrap");
const elSpeaker = document.getElementById("event-speaker");
const elLanguage = document.getElementById("event-language");
const elInstitute = document.getElementById("event-institute");
if (elDate) elDate.textContent = formattedDate;
// === 3) DOM refs for halls and talk lists ===
const hallHeadingEls = {
"1": document.getElementById("hall-1-heading"),
"2": document.getElementById("hall-2-heading"),
"3": document.getElementById("hall-3-heading"),
};
const hallTalksEls = {
"1": document.getElementById("hall-1-talks"),
"2": document.getElementById("hall-2-talks"),
"3": document.getElementById("hall-3-talks"),
};
// === 4) Load schedule JSON ===
fetch("text/schedule.json", { cache: "no-cache" })
.then((resp) => {
if (!resp.ok) throw new Error("could not load schedule.json");
return resp.json();
})
.then((data) => {
const halls = data.halls || [];
const talks = data.talks || [];
// 4a) Set hall headings
halls.forEach((hall) => {
if (hallHeadingEls[hall.id]) {
hallHeadingEls[hall.id].textContent =
decodeUnicode(hall.label || ("Hörsaal " + hall.id));
}
});
// 4b) Group talks by hall id
const groupedByHall = {};
talks.forEach((talk) => {
if (!groupedByHall[talk.hall]) groupedByHall[talk.hall] = [];
groupedByHall[talk.hall].push(talk);
});
// 4c) Desktop: gemeinsame Zeitliste + Platzhalter, Mobile: wie bisher
const timesOrder = uniqueTimesInOrder(talks); // in Reihenfolge aus JSON
Object.keys(hallTalksEls).forEach((hallId) => {
const container = hallTalksEls[hallId];
if (!container) return;
const hallTalks = groupedByHall[hallId] || [];
// Index nach Zeit für schnellen Zugriff
const byTime = new Map(hallTalks.map(t => [String(t.time || "").trim(), t]));
// Desktop nebeneinander -> gleiche Zeilen je Zeit; sonst originale Liste
const desktop = window.matchMedia("(min-width: 992px)").matches;
let rows = [];
if (desktop) {
rows = timesOrder.map((time, i) => {
const talk = byTime.get(time) || null;
return renderTalkRow(talk, hallId, i, time);
});
} else {
rows = hallTalks.map((t, i) => renderTalkRow(t, hallId, i, t.time || ""));
}
container.innerHTML = rows.join("");
wireImageLoadReflow(container);
});
// 4d) Enable toggle animation
wireTalkToggles();
// 4e) Zeilenhöhen anpassen (nur Desktop)
equalizeRowHeightsOnce();
// 4e) Fill the highlight block deterministically
// Only talks that have BOTH a non-empty description and a real image
const highlightCandidates = (talks || []).filter((t) => {
const hasDesc = (t.desc || "").trim().length > 0;
const hasImg = isImageUrl((t.image || "").trim());
return hasDesc && hasImg;
});
if (highlightCandidates.length > 0) {
const idx = dayOfYear % highlightCandidates.length;
const chosen = highlightCandidates[idx];
const highlightTitle = decodeUnicode(chosen.title || "Untitled");
const highlightBody = chosen.desc || highlightTitle;
const highlightImage = chosen.image || "";
const highlightAlt =
decodeUnicode(chosen.imageAlt || highlightTitle || "Image");
if (elTitle) elTitle.textContent = highlightTitle;
if (elBody) elBody.innerHTML = formatHtmlWithBreaks(highlightBody);
if (elImg) {
elImg.alt = highlightAlt;
if (isImageUrl(highlightImage)) {
elImg.src = highlightImage;
elImg.style.display = "";
} else {
elImg.removeAttribute("src");
elImg.style.display = "none";
}
}
if (elSpeaker) {
const profTitle = chosen.profTitle || "";
const lecturer = chosen.lecturer || "";
const instLabel = chosen.instituteLabel || "";
const instUrl = chosen.instituteUrl || "";
const name =
(profTitle ? profTitle.trim() + " " : "") + (lecturer || "");
const inst = instUrl
? `<a class="talk-institute" href="${escapeAttr(
instUrl
)}" target="_blank" rel="noopener">${escapeText(
instLabel || instUrl
)}</a>`
: instLabel
? `<span class="talk-institute">${escapeText(instLabel)}</span>`
: "";
const sep = name && inst ? ", " : "";
elSpeaker.innerHTML = `<span data-i18n="speaker">Referent:</span> ${escapeText(
name.trim()
)}${sep}${inst}`;
}
if (elLanguage && (chosen.language || "")) {
elLanguage.innerHTML = `<span data-i18n="language">Sprache:</span> ${escapeText(
chosen.language
)}`;
}
if (elInstitute) {
const instLabel = chosen.instituteLabel || "";
const instUrl = chosen.instituteUrl || "";
if (elInstitute.tagName === "A" && instUrl) {
elInstitute.textContent = decodeUnicode(instLabel || instUrl);
elInstitute.setAttribute("href", instUrl);
} else {
elInstitute.textContent = decodeUnicode(instLabel || "");
}
}
} else {
// No suitable highlight -> clear/hide gracefully
if (elTitle) elTitle.textContent = " ";
if (elBody) elBody.textContent = " ";
if (elImg) {
elImg.removeAttribute("src");
elImg.alt = "";
elImg.style.display = "none";
}
if (elSpeaker) elSpeaker.textContent = "";
if (elLanguage) elLanguage.textContent = "";
if (elInstitute) elInstitute.textContent = "";
}
})
.catch((err) => {
console.error("schedule load error:", err);
if (elTitle) elTitle.textContent = "Highlight could not be loaded";
if (elBody) elBody.textContent = "Please try again later.";
if (elImg) {
elImg.alt = "No image available";
elImg.style.display = "none";
}
Object.values(hallTalksEls).forEach((container) => {
if (container) {
container.innerHTML = `
<div class="talk-block">
<p class="time-slot">--:--</p>
<button class="talk-toggle" type="button" disabled>
<span class="talk-title-text" style="opacity:.6">Error loading program.</span>
</button>
</div>
`;
}
});
});
// === Helpers ===
// Escape for text node contexts
function escapeText(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// Escape for attribute values
function escapeAttr(str) {
return escapeText(str).replace(/'/g, "&#39;");
}
// Decode literals like "U+00AE" or "u+1F4A9" to real Unicode
function decodeUnicode(str) {
if (!str) return "";
return String(str).replace(/U\+([0-9A-F]{4,6})/gi, (_, hex) =>
String.fromCodePoint(parseInt(hex, 16))
);
}
// Produce HTML: decode "U+XXXX", escape HTML, then turn newlines into <br>
function formatHtmlWithBreaks(str) {
const decoded = decodeUnicode(str).replace(/\r\n?/g, "\n");
const escaped = escapeText(decoded);
return escaped.replace(/\n/g, "<br>");
}
// Accept only real image files; PDFs etc. are rejected for highlight/images
function isImageUrl(url) {
if (!url) return false;
return /\.(png|jpe?g|webp|gif|avif|svg)$/i.test(url);
}
// Build an i18n-ready "Speaker" line.
function buildLecturerLine({
profTitle = "",
lecturer = "",
instLabel = "",
instUrl = "",
}) {
if (!profTitle && !lecturer && !instLabel) return "";
const name =
(profTitle ? escapeText(decodeUnicode(profTitle.trim())) + " " : "") +
escapeText(decodeUnicode(lecturer || "").trim());
const inst = instUrl
? `<a class="talk-institute" href="${escapeAttr(
instUrl
)}" target="_blank" rel="noopener">${escapeText(
decodeUnicode(instLabel || instUrl)
)}</a>`
: instLabel
? `<span class="talk-institute">${escapeText(decodeUnicode(instLabel))}</span>`
: "";
const sep = name && inst ? ", " : "";
return `<p class="talk-lecturer"><span data-i18n="speaker">Referent:</span> ${name}${sep}${inst}</p>`;
}
// Build an i18n-ready "Language" line.
function buildLanguageLine(langValue = "") {
if (!langValue) return "";
return `<p class="talk-language"><span data-i18n="language">Sprache:</span> ${escapeText(
decodeUnicode(langValue)
)}</p>`;
}
// Toggle animation and a11y attributes for each talk block
function wireTalkToggles() {
const toggles = document.querySelectorAll(".talk-toggle");
toggles.forEach((btn) => {
btn.addEventListener("click", () => {
const panelId = btn.getAttribute("aria-controls");
const panel = document.getElementById(panelId);
if (!panel) return;
const expanded = btn.getAttribute("aria-expanded") === "true";
const nowExpanded = !expanded;
// a11y attributes
btn.setAttribute("aria-expanded", String(nowExpanded));
panel.setAttribute("aria-hidden", String(!nowExpanded));
// animate max-height + opacity
if (nowExpanded) {
// open just this panel
panel.classList.add("is-open");
panel.style.maxHeight = panel.scrollHeight + "px";
panel.style.opacity = "1";
} else {
// close just this panel
panel.style.maxHeight = panel.scrollHeight + "px"; // start from current height
panel.getBoundingClientRect(); // force reflow
panel.classList.remove("is-open");
panel.style.maxHeight = "0";
panel.style.opacity = "0";
}
// IMPORTANT:
// Do NOT re-equalize heights here. We want other columns to stay unchanged.
});
});
}
// Liefert die Zeitpunkte in der Reihenfolge ihres ersten Auftretens im JSON
function uniqueTimesInOrder(talks) {
const seen = new Set();
const out = [];
talks.forEach(t => {
const tt = String(t.time || "").trim();
if (tt && !seen.has(tt)) { seen.add(tt); out.push(tt); }
});
return out;
}
// Eine Zeile (Talk oder leerer Platzhalter für eine Zeit)
function renderTalkRow(talk, hallId, i, timeLabel) {
const safeTime = timeLabel || (talk ? talk.time || "" : "");
if (!talk) {
// leerer Platzhalter sorgt für fluchtende Zeiten und gleiche Spaltenhöhe
return `
<div class="talk-block is-placeholder">
<p class="time-slot">${escapeText(safeTime)}</p>
<button class="talk-toggle" type="button" disabled aria-disabled="true">
<span class="talk-title-text" style="opacity:.55">—</span>
</button>
</div>
`;
}
const safeTitle = decodeUnicode(talk.title || "");
const rawDesc = talk.desc || "";
const safeDesc = rawDesc.trim() !== "" ? rawDesc : "";
const imgUrl = (talk.image || "").trim();
const safeImg = isImageUrl(imgUrl) ? imgUrl : "";
const safeAlt = talk.imageAlt || safeTitle || "Talk image";
const profTitle = talk.profTitle || "";
const lecturer = talk.lecturer || "";
const instLabel = talk.instituteLabel || "";
const instUrl = talk.instituteUrl || "";
const language = talk.language || "";
const panelId = `talk-details-${hallId}-${i}`;
const lecturerLine = buildLecturerLine({ profTitle, lecturer, instLabel, instUrl });
const languageLine = buildLanguageLine(language);
const detailsInner = `
<div class="talk-details-inner">
${safeDesc ? `<p class="talk-desc">${formatHtmlWithBreaks(safeDesc)}</p>` : ""}
${lecturerLine}
${languageLine}
${safeImg ? `<img class="talk-image" src="${escapeAttr(safeImg)}" alt="${escapeAttr(decodeUnicode(safeAlt))}">` : ""}
</div>
`;
const hasDetails = safeDesc || lecturerLine || languageLine || safeImg;
const toggleButton = hasDetails
? `<button class="talk-toggle" type="button" aria-expanded="false" aria-controls="${panelId}">
<span class="talk-title-text">${escapeText(safeTitle)}</span>
</button>`
: `<div class="talk-toggle" aria-disabled="true">
<span class="talk-title-text">${escapeText(safeTitle)}</span>
</div>`;
return `<div class="talk-block">
<p class="time-slot">${escapeText(safeTime)}</p>
${toggleButton}
${hasDetails ? `
<div class="talk-details" id="${panelId}" aria-hidden="true" style="max-height:0; opacity:0;">
${detailsInner}
</div>` : ""}</div>`;
}
/**
* Equalize row heights ONCE, right after initial render while all panels are collapsed.
* We set min-height so that opening one panel later will not change the other columns.
*/
function equalizeRowHeightsOnce() {
const desktop = window.matchMedia("(min-width: 992px)").matches;
const columns = ["1", "2", "3"]
.map((id) => document.getElementById(`hall-${id}-talks`))
.filter(Boolean);
// Reset any previous min-heights (needed if you re-render programmatically)
columns.forEach((col) => {
col.querySelectorAll(".talk-block").forEach((b) => (b.style.minHeight = ""));
});
if (!desktop || columns.length === 0) return;
const colRows = columns.map((col) =>
Array.from(col.querySelectorAll(".talk-block"))
);
const maxRows = Math.max(...colRows.map((r) => r.length));
// For each logical time-row, measure the tallest collapsed block and apply as min-height
for (let i = 0; i < maxRows; i++) {
const rowElems = colRows.map((r) => r[i]).filter(Boolean);
const maxH = Math.max(...rowElems.map((el) => el.offsetHeight));
rowElems.forEach((el) => (el.style.minHeight = maxH + "px"));
}
}
// kleines Debounce
function debounce(fn, ms) {
let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn.apply(this, args), ms); };
}
// Nach dem Auf-/Zuklappen neu ausrichten
const _origWire = wireTalkToggles;
wireTalkToggles = function () {
const toggles = document.querySelectorAll(".talk-toggle");
toggles.forEach((btn) => {
btn.addEventListener("click", () => {
const panelId = btn.getAttribute("aria-controls");
const panel = document.getElementById(panelId);
if (!panel) return;
const expanded = btn.getAttribute("aria-expanded") === "true";
const nowExpanded = !expanded;
btn.setAttribute("aria-expanded", String(nowExpanded));
panel.setAttribute("aria-hidden", String(!nowExpanded));
if (nowExpanded) {
panel.classList.add("is-open");
panel.style.maxHeight = panel.scrollHeight + "px";
panel.style.opacity = "1";
} else {
panel.style.maxHeight = panel.scrollHeight + "px";
panel.getBoundingClientRect();
panel.classList.remove("is-open");
panel.style.maxHeight = "0";
panel.style.opacity = "0";
}
// nach Ende der Transition neu messen
setTimeout(equalizeRowHeights, 250);
});
});
};
// Run once after you injected the HTML
function wireImageLoadReflow(root) {
const scope = root || document;
scope.querySelectorAll(".talk-image").forEach((img) => {
if (img.dataset._reflowBound) return; // avoid double-binding
img.dataset._reflowBound = "1";
img.addEventListener("load", () => {
const panel = img.closest(".talk-details");
if (panel && panel.classList.contains("is-open")) {
// Recompute max-height to fit the now-loaded image
panel.style.maxHeight = panel.scrollHeight + "px";
}
}, { passive: true });
});
}
})();