diff --git a/assets/app.js b/assets/app.js new file mode 100644 index 0000000..d92a8b5 --- /dev/null +++ b/assets/app.js @@ -0,0 +1,321 @@ +const DATA_PRIMARY = "kaohsiung_parking_lots_2025-12-25_schema_placephoto_fallback.json"; +const DATA_FALLBACK = "kaohsiung_parking_lots_2025-12-25_curated.json"; + +const state = { + rawItems: [], + filteredItems: [], + totalCount: 0, + dataSource: "", +}; + +const dom = { + listContainer: document.getElementById("listContainer"), + resultCount: document.getElementById("resultCount"), + searchInput: document.getElementById("searchInput"), + vehicleFilter: document.getElementById("vehicleFilter"), + diffPricing: document.getElementById("diffPricing"), + hasCap: document.getElementById("hasCap"), + sortFilter: document.getElementById("sortFilter"), + loadStatus: document.getElementById("loadStatus"), +}; + +function updateStatus(message, type = "") { + dom.loadStatus.textContent = message; + dom.loadStatus.className = `load-status ${type}`.trim(); +} + +async function loadData() { + updateStatus("載入資料中...", "loading"); + try { + const response = await fetch(DATA_PRIMARY); + if (!response.ok) { + throw new Error(`Primary data load failed: ${response.status}`); + } + const data = await response.json(); + state.dataSource = DATA_PRIMARY; + return data; + } catch (error) { + console.warn(error); + updateStatus("主要資料讀取失敗,改用備援資料。", "warning"); + const response = await fetch(DATA_FALLBACK); + if (!response.ok) { + throw new Error(`Fallback data load failed: ${response.status}`); + } + state.dataSource = DATA_FALLBACK; + return response.json(); + } +} + +function normalizeItem(item) { + const pricing = item.pricing || {}; + const google = item.google || {}; + const thumbnail = item.thumbnail || {}; + const placePhoto = thumbnail.place_photo || {}; + const streetView = thumbnail.street_view || {}; + const placePhotoTemplates = placePhoto.templates || thumbnail.templates || item.templates || {}; + + const address = item.address || item.address_text || ""; + const districtMatch = address.match(/.{2}區/); + const district = item.district || (districtMatch ? districtMatch[0] : "其他"); + + const vehicleText = item.vehicle_types || item.vehicleType || ""; + const hasCar = /汽車/.test(vehicleText); + const hasMoto = /機車/.test(vehicleText); + + return { + id: item.id || `${item.parking_name || ""}-${address}`, + parkingName: item.parking_name || item.name || "未命名停車場", + address, + district, + vehicleText, + hasCar, + hasMoto, + pricingWeekday: pricing.weekday || pricing.weekday_fee || "", + pricingWeekend: pricing.weekend || pricing.weekend_fee || "", + googleRating: google.rating, + googleReviewCount: google.review_count, + googleMapsUrl: google.maps_url || google.url || "", + thumbnail: { + url: placePhoto.url || "", + photoReference: placePhoto.photo_reference || "", + photoResourceName: placePhoto.photo_resource_name || "", + classicTemplate: placePhotoTemplates.classic_photoreference_url_template || "", + newTemplate: placePhotoTemplates.new_photo_resource_url_template || "", + streetViewUrl: streetView.url || "", + }, + }; +} + +function getThumbnailUrl(thumbnail) { + if (thumbnail.url) { + return thumbnail.url; + } + if (thumbnail.photoReference && thumbnail.classicTemplate) { + return thumbnail.classicTemplate.replace("PHOTO_REFERENCE", thumbnail.photoReference); + } + if (thumbnail.photoResourceName && thumbnail.newTemplate) { + return thumbnail.newTemplate.replace("PHOTO_RESOURCE_NAME", thumbnail.photoResourceName); + } + if (thumbnail.streetViewUrl) { + return thumbnail.streetViewUrl; + } + return ""; +} + +function applyFilters() { + const keyword = dom.searchInput.value.trim(); + const keywordLower = keyword.toLowerCase(); + const vehicle = dom.vehicleFilter.value; + const diffPricing = dom.diffPricing.checked; + const hasCap = dom.hasCap.checked; + const sort = dom.sortFilter.value; + + let filtered = state.rawItems.filter((item) => { + const haystack = `${item.parkingName} ${item.address}`.toLowerCase(); + const keywordMatch = keywordLower ? haystack.includes(keywordLower) : true; + + let vehicleMatch = true; + if (vehicle === "car") { + vehicleMatch = item.hasCar; + } else if (vehicle === "moto") { + vehicleMatch = item.hasMoto; + } else if (vehicle === "both") { + vehicleMatch = item.hasCar && item.hasMoto; + } + + const diffMatch = diffPricing + ? item.pricingWeekday && item.pricingWeekend && item.pricingWeekday !== item.pricingWeekend + : true; + + const capMatch = hasCap + ? /月租|上限|最高/.test(`${item.pricingWeekday}${item.pricingWeekend}`) + : true; + + return keywordMatch && vehicleMatch && diffMatch && capMatch; + }); + + if (sort === "name") { + filtered.sort((a, b) => a.parkingName.localeCompare(b.parkingName, "zh-Hant")); + } else if (sort === "weekday") { + filtered.sort((a, b) => getPriceNumber(a.pricingWeekday) - getPriceNumber(b.pricingWeekday)); + } + + state.filteredItems = filtered; + renderList(); +} + +function getPriceNumber(priceText) { + const match = priceText.match(/\d+(?:\.\d+)?/); + if (!match) { + return Number.POSITIVE_INFINITY; + } + return Number(match[0]); +} + +function renderList() { + dom.listContainer.innerHTML = ""; + dom.resultCount.textContent = `共 ${state.totalCount} 筆,符合條件 ${state.filteredItems.length} 筆`; + + if (state.filteredItems.length === 0) { + dom.listContainer.innerHTML = `
沒有符合條件的停車場資料。
`; + return; + } + + const grouped = state.filteredItems.reduce((acc, item) => { + const key = item.district || "其他"; + if (!acc[key]) { + acc[key] = []; + } + acc[key].push(item); + return acc; + }, {}); + + const sortedDistricts = Object.keys(grouped).sort((a, b) => a.localeCompare(b, "zh-Hant")); + + sortedDistricts.forEach((district) => { + const section = document.createElement("section"); + section.className = "district-section"; + + const title = document.createElement("h3"); + title.className = "district-title"; + title.textContent = district; + + const grid = document.createElement("div"); + grid.className = "card-grid"; + + grouped[district].forEach((item) => { + grid.appendChild(renderCard(item)); + }); + + section.appendChild(title); + section.appendChild(grid); + dom.listContainer.appendChild(section); + }); +} + +function renderCard(item) { + const card = document.createElement("article"); + card.className = "card"; + + const imageWrap = document.createElement("div"); + imageWrap.className = "card-image"; + const imageUrl = getThumbnailUrl(item.thumbnail); + if (imageUrl) { + const img = document.createElement("img"); + img.src = imageUrl; + img.alt = `${item.parkingName} 縮圖`; + img.loading = "lazy"; + imageWrap.appendChild(img); + } else { + imageWrap.textContent = "No Image"; + } + + const body = document.createElement("div"); + body.className = "card-body"; + + const title = document.createElement("h3"); + title.textContent = item.parkingName; + + const meta = document.createElement("div"); + meta.className = "meta"; + meta.innerHTML = ` +
地址:${item.address || "未提供"}
+
平日:${item.pricingWeekday || "未提供"}
+
假日:${item.pricingWeekend || "未提供"}
+
Google:${renderGoogleRating(item)}
+ `; + + const tags = document.createElement("div"); + tags.className = "tags"; + if (item.hasCar) { + tags.appendChild(createTag("汽車")); + } + if (item.hasMoto) { + tags.appendChild(createTag("機車")); + } + if (!item.hasCar && !item.hasMoto && item.vehicleText) { + tags.appendChild(createTag(item.vehicleText)); + } + + const actions = document.createElement("div"); + actions.className = "card-actions"; + + const copyButton = document.createElement("button"); + copyButton.className = "button"; + copyButton.textContent = "複製地址"; + copyButton.addEventListener("click", async () => { + if (!item.address) return; + try { + await navigator.clipboard.writeText(item.address); + copyButton.textContent = "已複製"; + setTimeout(() => { + copyButton.textContent = "複製地址"; + }, 1200); + } catch (error) { + console.warn("Copy failed", error); + } + }); + + const mapLink = document.createElement("a"); + mapLink.className = "button primary"; + mapLink.href = item.googleMapsUrl || `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(item.parkingName + " " + item.address)}`; + mapLink.target = "_blank"; + mapLink.rel = "noopener noreferrer"; + mapLink.textContent = "在 Google 地圖開啟"; + + actions.appendChild(copyButton); + actions.appendChild(mapLink); + + body.appendChild(title); + body.appendChild(tags); + body.appendChild(meta); + body.appendChild(actions); + + card.appendChild(imageWrap); + card.appendChild(body); + + return card; +} + +function renderGoogleRating(item) { + if (item.googleRating) { + const countText = item.googleReviewCount ? `(${item.googleReviewCount})` : ""; + return `⭐ ${item.googleRating}${countText}`; + } + return "尚未載入 Google 評分"; +} + +function createTag(text) { + const tag = document.createElement("span"); + tag.className = "tag"; + tag.textContent = text; + return tag; +} + +function bindEvents() { + [dom.searchInput, dom.vehicleFilter, dom.sortFilter].forEach((el) => { + el.addEventListener("input", applyFilters); + el.addEventListener("change", applyFilters); + }); + + dom.diffPricing.addEventListener("change", applyFilters); + dom.hasCap.addEventListener("change", applyFilters); +} + +async function init() { + try { + const data = await loadData(); + const list = Array.isArray(data) ? data : data.items || []; + state.rawItems = list.map(normalizeItem); + state.totalCount = state.rawItems.length; + updateStatus(`資料載入完成(${state.dataSource})。`, "success"); + bindEvents(); + applyFilters(); + } catch (error) { + console.error(error); + updateStatus("資料載入失敗,請稍後再試。", "error"); + dom.listContainer.innerHTML = `
目前無法載入資料。
`; + } +} + +init(); diff --git a/assets/styles.css b/assets/styles.css new file mode 100644 index 0000000..7fd3c7b --- /dev/null +++ b/assets/styles.css @@ -0,0 +1,281 @@ +:root { + color-scheme: light; + --bg: #f5f7fb; + --panel: #ffffff; + --accent: #2563eb; + --text: #1f2933; + --muted: #6b7280; + --border: #e5e7eb; + --shadow: 0 16px 40px rgba(15, 23, 42, 0.08); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Noto Sans TC", "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); +} + +.container { + width: min(1200px, 92vw); + margin: 0 auto; +} + +.site-header { + background: linear-gradient(120deg, #0ea5e9, #2563eb); + color: #fff; + padding: 48px 0 40px; +} + +.site-header h1 { + margin: 8px 0; + font-size: clamp(2rem, 4vw, 2.8rem); +} + +.site-kicker { + letter-spacing: 0.2em; + text-transform: uppercase; + opacity: 0.75; + font-size: 0.85rem; +} + +.site-subtitle { + margin: 0; + font-size: 1rem; + opacity: 0.85; +} + +.load-status { + margin-top: 12px; + font-size: 0.95rem; + opacity: 0.9; +} + +main { + padding: 32px 0 48px; +} + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 16px; + box-shadow: var(--shadow); + padding: 24px; +} + +.filters { + margin-bottom: 24px; +} + +.filters-header { + display: flex; + justify-content: space-between; + align-items: baseline; + flex-wrap: wrap; + gap: 12px; +} + +.filters-header h2 { + margin: 0; + font-size: 1.25rem; +} + +.result-count { + color: var(--muted); +} + +.filters-grid { + margin-top: 20px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; +} + +.field { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 0.95rem; +} + +.field input, +.field select { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border); + font-size: 0.95rem; +} + +.checkbox-group { + display: grid; + gap: 6px; + color: var(--muted); +} + +.list-container { + display: grid; + gap: 28px; +} + +.district-section { + display: grid; + gap: 16px; +} + +.district-title { + font-size: 1.2rem; + margin: 0; + padding-bottom: 8px; + border-bottom: 2px solid var(--border); +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 18px; +} + +.card { + background: #fff; + border-radius: 16px; + overflow: hidden; + border: 1px solid var(--border); + display: flex; + flex-direction: column; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05); +} + +.card-image { + height: 170px; + background: #e2e8f0; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + font-weight: 600; + font-size: 0.95rem; +} + +.card-image img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.card-body { + padding: 16px; + display: grid; + gap: 10px; +} + +.card-body h3 { + margin: 0; + font-size: 1.05rem; +} + +.meta { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 0.9rem; +} + +.meta strong { + color: var(--text); + font-weight: 600; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.tag { + background: #eff6ff; + color: #1d4ed8; + border-radius: 999px; + padding: 4px 10px; + font-size: 0.75rem; + font-weight: 600; +} + +.card-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 6px; +} + +.button { + border: 1px solid var(--border); + padding: 8px 12px; + border-radius: 10px; + background: #fff; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.button.primary { + background: var(--accent); + color: #fff; + border-color: transparent; +} + +.button:hover { + transform: translateY(-1px); + box-shadow: 0 6px 14px rgba(37, 99, 235, 0.15); +} + +.empty-state { + text-align: center; + color: var(--muted); + padding: 40px 0; +} + +.site-footer { + background: #111827; + color: #d1d5db; + padding: 32px 0; +} + +.footer-content { + display: flex; + justify-content: space-between; + gap: 24px; + flex-wrap: wrap; +} + +.site-footer a { + color: #93c5fd; + text-decoration: none; +} + +.site-footer a:hover { + text-decoration: underline; +} + +.footer-note { + max-width: 480px; + font-size: 0.85rem; + color: #9ca3af; +} + +@media (max-width: 900px) { + .filters-header { + flex-direction: column; + align-items: flex-start; + } +} + +@media (max-width: 700px) { + .card-grid { + grid-template-columns: 1fr; + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..d46765b --- /dev/null +++ b/index.html @@ -0,0 +1,77 @@ + + + + + + 高雄市停車場資訊 + + + + + +
+
+
+

篩選條件

+

共 0 筆,符合條件 0 筆

+
+
+ + +
+ 收費類型 +
+ + +
+
+ +
+
+ +
+
+
+
+ + + + + +