Files

405 lines
17 KiB
JavaScript
Raw Permalink 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.
(() => {
const canvas = document.getElementById("particles");
if (canvas) {
const ctx = canvas.getContext("2d");
let particles = [];
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
class Particle {
constructor() {
this.x = Math.random() * canvas.width;
this.y = Math.random() * canvas.height;
this.size = Math.random() * 4 + 1;
this.speedX = Math.random() * 0.5 - 0.25;
this.speedY = Math.random() * 0.5 + 0.2;
}
update() {
this.x += this.speedX;
this.y += this.speedY;
if (this.y > canvas.height) this.y = 0;
}
draw() {
ctx.fillStyle = "#FFDD00";
ctx.globalAlpha = 0.6;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
}
}
function initParticles() {
particles = [];
for (let i = 0; i < 80; i += 1) {
particles.push(new Particle());
}
}
function animateParticles() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const particle of particles) {
particle.update();
particle.draw();
}
requestAnimationFrame(animateParticles);
}
resizeCanvas();
initParticles();
animateParticles();
window.addEventListener("resize", () => {
resizeCanvas();
initParticles();
});
}
const searchInput = document.getElementById("itinerary-search");
/**
* 每個 .region-panel(例如 #taipei、#global)都是獨立的地區區塊,各自的
* member-tabs/地圖/地點卡片互不影響。新增行程時只要在對應 .region-panel
* 裡加一顆 .tab-btn(帶 data-trip-name / data-map-embed / data-map-open
* 沒有地圖就不要寫 data-map-embed),以及對應的 .polaroid-card,不需要動這支
* 檔案。「全部行程」的地圖會依當時有設定地圖的 tab 自動組出來;完全沒有
* 設定地圖的行程,地圖區塊會自動收起來,只留卡片列表。
*/
function initRegionController(panel) {
const tabs = Array.from(panel.querySelectorAll(":scope > .member-tabs .tab-btn"));
const cards = Array.from(panel.querySelectorAll(".polaroid-card"));
const itineraryList = panel.querySelector(".itinerary-list");
const detailPanel = panel.querySelector(".detail-panel");
const detailImage = panel.querySelector(".detail-photo img");
const detailDate = panel.querySelector(".detail-content .date");
const detailTitle = panel.querySelector(".detail-content h3");
const detailDesc = panel.querySelector(".detail-content p");
const detailQuote = panel.querySelector(".detail-content blockquote");
const detailTag = panel.querySelector(".detail-content .tag");
const detailLink = panel.querySelector(".detail-content .detail-link");
const mapSticky = panel.querySelector(".map-sticky");
const mapEmbedSingle = mapSticky ? mapSticky.querySelector(".map-embed-single") : null;
const memberMapFrame = mapEmbedSingle ? mapEmbedSingle.querySelector(".map-my-maps-frame") : null;
const mapEmbedEmpty = mapSticky ? mapSticky.querySelector(".map-embed-empty") : null;
const mapEmbedAll = mapSticky ? mapSticky.querySelector(".map-embed-all") : null;
const openMapLink = mapSticky ? mapSticky.querySelector(".map-link") : null;
const openMapLinksDual = mapSticky ? mapSticky.querySelector(".map-links-dual") : null;
const dayGroups = new Map();
/* 舊資料裡卡片用 data-member="kogo"tab 用 data-member="ko";新地區請直接讓兩邊字串一致,不需要這個對照 */
function normalizeMember(member) {
if (member === "kogo") return "ko";
return member;
}
const tripConfig = {};
tabs.forEach((tab) => {
const member = normalizeMember(tab.dataset.member || "");
if (!member || member === "all") return;
tripConfig[member] = {
tripName: tab.dataset.tripName || tab.textContent.trim(),
memberLabel: tab.dataset.memberLabel || tab.textContent.trim(),
sortOrder: Number.isFinite(Number(tab.dataset.sortOrder)) ? Number(tab.dataset.sortOrder) : 99,
mapEmbed: tab.dataset.mapEmbed || "",
mapOpen: tab.dataset.mapOpen || "",
};
});
const initialTab = tabs.find((tab) => tab.classList.contains("active")) || tabs[0];
let currentMember = initialTab ? normalizeMember(initialTab.dataset.member || "all") : "all";
let currentKeyword = "";
function getMemberLabel(member) {
return (tripConfig[member] && tripConfig[member].memberLabel) || "未分類";
}
function getDayToken(rawDate) {
const match = String(rawDate || "").match(/Day\s*(\d+)/i);
if (!match) return String(rawDate || "未分類");
return "Day " + match[1];
}
function getGroupLabel(card) {
const date = getDayToken(card.dataset.date || "未分類");
const member = normalizeMember(card.dataset.member || "");
const prefix = (tripConfig[member] && tripConfig[member].tripName) || getMemberLabel(member);
return prefix + "_" + date;
}
function tripsWithMaps() {
return Object.keys(tripConfig)
.filter((member) => tripConfig[member].mapEmbed)
.sort((a, b) => tripConfig[a].sortOrder - tripConfig[b].sortOrder);
}
function buildMapPairs(members) {
if (!mapEmbedAll) return;
mapEmbedAll.innerHTML = "";
members.forEach((member) => {
const trip = tripConfig[member];
const pair = document.createElement("div");
pair.className = "map-embed-pair";
const label = document.createElement("p");
label.className = "map-embed-label";
label.textContent = trip.tripName;
const iframe = document.createElement("iframe");
iframe.className = "map-my-maps-frame";
iframe.loading = "lazy";
iframe.referrerPolicy = "no-referrer-when-downgrade";
iframe.title = trip.tripName + " 踩點地圖";
iframe.src = trip.mapEmbed;
pair.appendChild(label);
pair.appendChild(iframe);
mapEmbedAll.appendChild(pair);
});
}
function buildOpenLinksDual(members) {
if (!openMapLinksDual) return;
openMapLinksDual.innerHTML = "";
members.forEach((member) => {
const trip = tripConfig[member];
if (!trip.mapOpen) return;
const a = document.createElement("a");
a.className = "map-link";
a.target = "_blank";
a.href = trip.mapOpen;
a.textContent = "開啟「" + trip.tripName + "」My Maps";
openMapLinksDual.appendChild(a);
});
}
function updateMapByMember(member) {
if (!mapSticky) return;
if (!Object.keys(tripConfig).length) {
mapSticky.hidden = true;
return;
}
mapSticky.hidden = false;
if (member === "all") {
const withMaps = tripsWithMaps();
if (mapEmbedSingle) mapEmbedSingle.hidden = true;
if (openMapLink) openMapLink.hidden = true;
if (mapEmbedAll) {
buildMapPairs(withMaps);
mapEmbedAll.hidden = withMaps.length === 0;
}
if (openMapLinksDual) {
buildOpenLinksDual(withMaps);
openMapLinksDual.hidden = withMaps.length === 0;
}
if (mapEmbedEmpty) mapEmbedEmpty.hidden = withMaps.length > 0;
return;
}
const trip = tripConfig[member];
const hasMap = !!(trip && trip.mapEmbed);
if (mapEmbedAll) mapEmbedAll.hidden = true;
if (openMapLinksDual) openMapLinksDual.hidden = true;
if (mapEmbedSingle) mapEmbedSingle.hidden = !hasMap;
if (memberMapFrame && hasMap) memberMapFrame.src = trip.mapEmbed;
if (openMapLink) {
openMapLink.hidden = !hasMap;
if (hasMap) openMapLink.href = trip.mapOpen || "#";
}
if (mapEmbedEmpty) mapEmbedEmpty.hidden = hasMap;
}
function getCardSearchText(card) {
return [
card.dataset.date || "",
card.dataset.title || "",
card.dataset.desc || "",
card.dataset.tag || "",
].join(" ").toLowerCase();
}
function createDayGroup(dayLabel) {
const group = document.createElement("section");
group.className = "day-group";
group.dataset.day = dayLabel;
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "day-toggle";
toggle.setAttribute("aria-expanded", "false");
toggle.innerHTML = "<span>" + dayLabel + "</span><i class='fa-solid fa-chevron-down'></i>";
const body = document.createElement("div");
body.className = "day-cards";
body.style.display = "none";
group.appendChild(toggle);
group.appendChild(body);
itineraryList.appendChild(group);
dayGroups.set(dayLabel, { group, toggle, body });
return dayGroups.get(dayLabel);
}
function setDayExpanded(dayGroup, shouldExpand) {
dayGroup.toggle.setAttribute("aria-expanded", shouldExpand ? "true" : "false");
dayGroup.body.style.display = shouldExpand ? "grid" : "none";
dayGroup.group.classList.toggle("expanded", shouldExpand);
}
function initializeDayGroups() {
if (!itineraryList || !cards.length) return;
const sortedCards = [...cards].sort((a, b) => {
const memberA = normalizeMember(a.dataset.member || "");
const memberB = normalizeMember(b.dataset.member || "");
const sortA = (tripConfig[memberA] && tripConfig[memberA].sortOrder) ?? 99;
const sortB = (tripConfig[memberB] && tripConfig[memberB].sortOrder) ?? 99;
if (sortA !== sortB) return sortA - sortB;
const dayA = Number((getDayToken(a.dataset.date).match(/\d+/) || [999])[0]);
const dayB = Number((getDayToken(b.dataset.date).match(/\d+/) || [999])[0]);
return dayA - dayB;
});
sortedCards.forEach((card) => {
const dayLabel = getGroupLabel(card);
const dayGroup = dayGroups.get(dayLabel) || createDayGroup(dayLabel);
dayGroup.body.appendChild(card);
});
}
function updateDayGroupVisibility() {
dayGroups.forEach(({ group, body, toggle }) => {
const visibleCards = Array.from(body.querySelectorAll(".polaroid-card")).filter((card) => card.style.display !== "none");
group.style.display = visibleCards.length ? "" : "none";
if (!visibleCards.length) {
setDayExpanded({ group, toggle, body }, false);
}
});
}
function ensureExpandedForCard(card) {
const dayGroup = dayGroups.get(getGroupLabel(card));
if (!dayGroup) return;
setDayExpanded(dayGroup, true);
}
function setActiveCard(card, options = {}) {
const { expandDay = true } = options;
cards.forEach((item) => item.classList.remove("active"));
card.classList.add("active");
if (expandDay) {
ensureExpandedForCard(card);
}
updateMapByMember(currentMember === "all" ? "all" : currentMember);
if (!detailPanel) return;
if (detailDate) detailDate.textContent = card.dataset.date || "";
if (detailTitle) detailTitle.textContent = card.dataset.title || "";
if (detailDesc) detailDesc.textContent = card.dataset.desc || "";
if (detailQuote) detailQuote.textContent = "「" + (card.dataset.quote || "") + "」";
if (detailTag) detailTag.textContent = card.dataset.tag || "";
if (detailLink) detailLink.href = card.dataset.link || "#";
const cardImg = card.querySelector("img");
if (detailImage && cardImg) {
detailImage.src = cardImg.src;
detailImage.alt = "節目截圖 - " + (card.dataset.title || "行程地點");
}
}
function applyFilter() {
let firstVisible = null;
cards.forEach((card) => {
const cardMember = normalizeMember(card.dataset.member || "");
const matchedMember = currentMember === "all" || cardMember === currentMember;
const matchedKeyword = !currentKeyword || getCardSearchText(card).includes(currentKeyword);
const matched = matchedMember && matchedKeyword;
card.style.display = matched ? "" : "none";
if (matched && !firstVisible) firstVisible = card;
});
updateDayGroupVisibility();
const activeVisibleCard = cards.find((card) => card.classList.contains("active") && card.style.display !== "none");
const fallbackCard = activeVisibleCard || firstVisible;
if (fallbackCard) {
setActiveCard(fallbackCard, { expandDay: false });
} else {
updateMapByMember(currentMember === "all" ? "all" : currentMember);
}
}
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
tabs.forEach((item) => {
item.classList.remove("active");
item.setAttribute("aria-pressed", "false");
});
tab.classList.add("active");
tab.setAttribute("aria-pressed", "true");
currentMember = normalizeMember(tab.dataset.member || "all");
applyFilter();
});
});
if (itineraryList) {
itineraryList.addEventListener("click", (event) => {
const toggle = event.target.closest(".day-toggle");
if (!toggle) return;
const group = toggle.closest(".day-group");
if (!group) return;
const dayGroup = dayGroups.get(group.dataset.day);
if (!dayGroup) return;
const isExpanded = dayGroup.toggle.getAttribute("aria-expanded") === "true";
setDayExpanded(dayGroup, !isExpanded);
});
}
cards.forEach((card) => {
card.addEventListener("click", () => {
setActiveCard(card, { expandDay: true });
if (window.innerWidth < 920 && detailPanel) {
detailPanel.scrollIntoView({ behavior: "smooth", block: "start" });
}
});
});
initializeDayGroups();
applyFilter();
return {
setKeyword(keyword) {
currentKeyword = keyword;
applyFilter();
},
};
}
const regionPanels = Array.from(document.querySelectorAll(".region-panel"));
const controllers = regionPanels.map((panel) => initRegionController(panel));
if (searchInput) {
searchInput.addEventListener("input", (event) => {
const keyword = event.target.value.trim().toLowerCase();
controllers.forEach((controller) => controller.setKeyword(keyword));
});
}
const regionTabs = Array.from(document.querySelectorAll("[data-region-tabs] .region-tab-btn"));
if (regionTabs.length && regionPanels.length) {
regionTabs.forEach((tab) => {
tab.addEventListener("click", () => {
const region = tab.dataset.region;
regionPanels.forEach((panel) => {
panel.hidden = panel.dataset.regionPanel !== region;
});
regionTabs.forEach((item) => {
const isActive = item === tab;
item.classList.toggle("active", isActive);
item.setAttribute("aria-pressed", isActive ? "true" : "false");
});
});
});
}
})();