405 lines
17 KiB
JavaScript
405 lines
17 KiB
JavaScript
(() => {
|
||
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");
|
||
});
|
||
});
|
||
});
|
||
}
|
||
})();
|