(() => { 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 = "" + dayLabel + ""; 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"); }); }); }); } })();