aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/components
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-25 12:24:24 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-25 12:24:24 +0900
commita01062f43d36112c00d5c007ac9d488c14d8fb0d (patch)
treeb99ddac9928e02b9cd07d372fa42842a348bfb61 /src/components
parenta71727538f65ab4f10f5239550be82590f9d47d1 (diff)
parks: アイコン・フィルタ・編集画面を更新し、公開を一時停止
- 遊具アイコンをSVGRepo素材に差し替え、施設種別をアイコン表示 - フィルタに種類・遊具・ピクニックを追加し、適用時の地図移動を停止 - 現在地ボタンを追跡トグルに変更し、初回以外は地図を動かさない - 編集画面を市内公園・周辺公園・公共施設の4タブに拡張 - 公園ページをドラフト化し、サイドバーとPWAの公開を停止
Diffstat (limited to 'src/components')
-rw-r--r--src/components/FacilitiesTable.astro70
-rw-r--r--src/components/ParkMap.astro446
-rw-r--r--src/components/ParkMapHelp.astro111
-rw-r--r--src/components/ParksFilters.astro184
-rw-r--r--src/components/ParksTable.astro260
5 files changed, 851 insertions, 220 deletions
diff --git a/src/components/FacilitiesTable.astro b/src/components/FacilitiesTable.astro
index 2974581..797bb1a 100644
--- a/src/components/FacilitiesTable.astro
+++ b/src/components/FacilitiesTable.astro
@@ -1,7 +1,9 @@
---
-// 地域センター・公民館・図書館・市役所支所・文化施設の一覧。
+// 地域センター・公民館・児童館・図書館・市役所支所・文化施設の一覧。
+// 小平市の施設に加えて、市境500m以内の市外施設(図書館・公民館・児童館)も表示する。
// トイレは市の公共施設として基本的に利用できる。給水は公式ページに明記が少ないため「未確認」が多い。
import facilitiesData from "@/data/kodaira-facilities.json";
+import nearbyFacilitiesData from "@/data/kodaira-nearby-facilities.json";
type Facility = {
name: string;
@@ -13,12 +15,32 @@ type Facility = {
water: string;
note: string;
source: string;
+ nearby?: boolean;
+ city?: string;
+ distance_m?: number;
};
-const facilities = facilitiesData as Facility[];
-const types = ["地域センター", "公民館", "図書館", "市役所支所", "文化施設"];
-const mapsUrl = (name: string) =>
- `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`小平市${name}`)}`;
+const facilities = [
+ ...(facilitiesData as Facility[]),
+ ...(nearbyFacilitiesData as Facility[]),
+];
+const types = [
+ "地域センター",
+ "公民館",
+ "児童館",
+ "図書館",
+ "市役所支所",
+ "文化施設",
+];
+const mapsUrl = (f: Facility) => {
+ // 市外の施設は「小平市」を付けない。名前が市名を含む場合は二重にしない。
+ const query = f.nearby
+ ? f.city && !f.name.includes(f.city)
+ ? `${f.city}${f.name}`
+ : f.name
+ : `小平市${f.name}`;
+ return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
+};
---
<div class="facilities" data-fac-root>
@@ -60,7 +82,7 @@ const mapsUrl = (name: string) =>
data-type={f.type}
data-toilet={f.toilet || ""}
data-water={f.water || ""}
- data-search={`${f.name} ${f.type} ${f.address} ${f.note}`.toLowerCase()}
+ data-search={`${f.name} ${f.type} ${f.address} ${f.city || ""} ${f.note}`.toLowerCase()}
>
<td>
{f.source ? (
@@ -68,10 +90,13 @@ const mapsUrl = (name: string) =>
) : (
f.name
)}
+ {f.nearby ? (
+ <span class="fac-nearby" title="小平市外の施設">市外</span>
+ ) : null}
</td>
<td>{f.type}</td>
<td>
- <a href={mapsUrl(f.name)} target="_blank" rel="noopener">
+ <a href={mapsUrl(f)} target="_blank" rel="noopener">
{f.address || "—"}
</a>
</td>
@@ -96,9 +121,12 @@ const mapsUrl = (name: string) =>
</table>
</div>
- <p class="fac-note">
- トイレは市の公共施設として基本的に利用できます(○)。給水は公式ページに明記が少ないため「未確認」が多いです。最新の情報は各施設の公式ページで確認してください。地図の位置は住所・名称からの概略です。
- </p>
+ <details class="fac-help">
+ <summary>設備情報について</summary>
+ <p class="fac-note">
+ 小平市の施設は、公共施設のためトイレを利用できます(○)。市外の近隣施設は情報がないため「未確認」としています。給水は公式ページに明記が少ないため「未確認」が多いです。最新の情報は各施設の公式ページで確認してください。市外施設の情報はOpenStreetMapによるもので、地図の位置は住所・名称からの概略です。
+ </p>
+ </details>
</div>
<script>
@@ -178,9 +206,29 @@ const mapsUrl = (name: string) =>
.fac-status.is-no {
color: #b71c1c;
}
- .fac-note {
+ .fac-nearby {
+ display: inline-block;
+ margin-left: 0.35rem;
+ padding: 0.02rem 0.35rem;
+ border: 1px solid currentColor;
+ border-radius: 999px;
+ color: #6d4c41;
+ font-size: 0.7rem;
+ font-weight: 600;
+ line-height: 1.5;
+ vertical-align: 0.08em;
+ white-space: nowrap;
+ }
+ .fac-help {
margin-top: 0.5rem;
color: var(--sl-color-gray-2);
font-size: 0.82em;
}
+ .fac-help summary {
+ cursor: pointer;
+ color: var(--sl-color-text-accent);
+ }
+ .fac-note {
+ margin: 0.35rem 0 0;
+ }
</style>
diff --git a/src/components/ParkMap.astro b/src/components/ParkMap.astro
index 1c75bee..e4f226b 100644
--- a/src/components/ParkMap.astro
+++ b/src/components/ParkMap.astro
@@ -10,22 +10,6 @@
role="region"
aria-label="小平市の公園の位置(OpenStreetMap)"
></div>
- <div class="park-map-legend" aria-hidden="true">
- <span><i class="swatch swatch-matched"></i>一覧と一致</span>
- <span><i class="swatch swatch-unmatched"></i>一覧にない公園・緑地</span>
- <span><i class="swatch swatch-nearby"></i>市外の近い公園(500m以内)</span>
- <span><i class="swatch swatch-facility"></i>公共施設</span>
- <span><i class="swatch swatch-boundary"></i>小平市の境界</span>
- <span><i class="swatch-fac swatch-fac-toilet">WC</i>トイレ</span>
- <span><i class="swatch-fac swatch-fac-water"><svg viewBox="0 0 24 24" width="10" height="10" aria-hidden="true"><path fill="currentColor" d="M12 2.7c-3.4 4.2-6 8.1-6 11.3a6 6 0 0 0 12 0c0-3.2-2.6-7.1-6-11.3z" /></svg></i>水飲み場(拡大時)</span>
- </div>
- <p class="park-map-note">
- 公園の範囲を赤で表示しています。地図を拡大すると見やすくなります。PCでは地図上でスクロールするとズームできます。市外の近い公園と公共施設は別レイヤーで表示し、右上のレイヤ切替でON/OFFできます。トイレと水飲み場のアイコンは、地図を拡大すると表示されます。OpenStreetMapに公園として登録されている範囲を表示しているため、すべての公園を網羅しているわけではありません。現在地は端末内でのみ利用し、サーバーには送信しません。地図データ © <a
- href="https://www.openstreetmap.org/copyright"
- target="_blank"
- rel="noopener">OpenStreetMap</a
- > contributors
- </p>
<p class="park-map-locate-status" data-locate-status hidden></p>
</div>
@@ -94,6 +78,10 @@
background: #8e24aa;
border-radius: 50%;
}
+ .swatch-facility-nearby {
+ background: #fb8c00;
+ border-radius: 50%;
+ }
.swatch-boundary {
height: 0;
border-top: 4px solid #6a1b9a;
@@ -106,18 +94,46 @@
font-size: 0.8rem;
}
:global(.park-locate) {
- width: 34px;
- height: 34px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ padding: 0;
border: 0;
+ border-radius: 50%;
background: #fff;
color: #1e88e5;
- font-size: 20px;
- line-height: 1;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
cursor: pointer;
}
:global(.park-locate:hover) {
background: #f0f7ff;
}
+ :global(.park-locate.is-tracking) {
+ background: #1e88e5;
+ color: #fff;
+ animation: park-locate-pulse 1.8s infinite;
+ }
+ :global(.park-locate.is-tracking:hover) {
+ background: #1565c0;
+ }
+ @keyframes park-locate-pulse {
+ 0% {
+ box-shadow: 0 0 0 0 rgba(30, 136, 229, 0.45);
+ }
+ 70% {
+ box-shadow: 0 0 0 9px rgba(30, 136, 229, 0);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 rgba(30, 136, 229, 0);
+ }
+ }
+ :global(.park-locate svg) {
+ display: block;
+ width: 20px;
+ height: 20px;
+ }
.park-map-note {
margin-top: 0.4rem;
color: var(--sl-color-gray-2);
@@ -218,6 +234,122 @@
:global(.park-fac svg) {
display: block;
}
+ :global(.park-facility-marker) {
+ background: none;
+ border: 0;
+ }
+ :global(.park-facility-emoji) {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ width: 22px;
+ height: 22px;
+ border: 2px solid #8e24aa;
+ border-radius: 50%;
+ background: #fff;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+ font-size: 13px;
+ line-height: 1;
+ }
+ :global(.park-facility-marker.is-nearby .park-facility-emoji) {
+ border-color: #fb8c00;
+ }
+ :global(.park-play-markers),
+ :global(.park-play-list) {
+ pointer-events: none;
+ }
+ :global(.park-play-list) {
+ display: inline-flex;
+ gap: 2px;
+ transform: translate(-50%, -50%) translateY(-24px);
+ }
+ :global(.park-play) {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border: 1px solid currentColor;
+ border-radius: 50%;
+ background: #fff;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
+ font-size: 9px;
+ font-weight: 700;
+ line-height: 1;
+ }
+ :global(.park-play-plane) {
+ color: #c62828;
+ }
+ :global(.park-play-swing) {
+ color: #ef6c00;
+ }
+ :global(.park-play-seesaw) {
+ color: #6d4c41;
+ }
+ :global(.park-play-spring) {
+ color: #d81b60;
+ }
+ :global(.park-play-slide) {
+ color: #2e7d32;
+ }
+ :global(.park-play-jungle) {
+ color: #6a1b9a;
+ }
+ :global(.park-play-sand) {
+ color: #3e2723;
+ }
+ :global(.park-play-sand svg *) {
+ vector-effect: non-scaling-stroke;
+ }
+ :global(.park-play-complex) {
+ color: #0277bd;
+ }
+ :global(.park-play-ball) {
+ color: #1b5e20;
+ }
+ :global(.park-play-rope) {
+ color: #00838f;
+ }
+ :global(.park-locate-marker) {
+ border: 0;
+ background: none;
+ }
+ :global(.park-locate-arrow) {
+ display: block;
+ transform-origin: 50% 50%;
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.35));
+ }
+ .park-map-help {
+ margin-top: 0.4rem;
+ }
+ .park-map-help summary {
+ cursor: pointer;
+ color: var(--sl-color-text-accent);
+ font-size: 0.85rem;
+ }
+ .park-play-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem 0.8rem;
+ margin: 0.5rem 0 0;
+ color: var(--sl-color-gray-2);
+ font-size: 0.78rem;
+ }
+ .park-play-legend-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ }
+ :global(.park-play svg) {
+ display: block;
+ width: 100%;
+ height: 100%;
+ }
+ :global(.park-play-swing svg) {
+ width: 70%;
+ height: 70%;
+ }
/* StarlightのMarkdown余白ルールがLeafletの各ペインに margin-top:1rem を
付けて地図を下へずらすため、地図内のペインは余白なしにする。 */
.park-map :global(.leaflet-pane) {
@@ -252,6 +384,8 @@
import boundary from "@/data/kodaira-boundary.json";
import nearbyData from "@/data/kodaira-nearby-parks.json";
import facilities from "@/data/kodaira-facilities.json";
+ import nearbyFacilities from "@/data/kodaira-nearby-facilities.json";
+ import { PARK_ICONS } from "@/data/park-icons";
import parks from "@/data/kodaira-parks.json";
const MATCHED_STYLE = {
@@ -285,6 +419,13 @@
fillColor: "#42a5f5",
fillOpacity: 0.25,
};
+ const NEARBY_POINT_STYLE = {
+ radius: 7,
+ color: "#0d47a1",
+ weight: 2,
+ fillColor: "#42a5f5",
+ fillOpacity: 0.85,
+ };
const NEARBY_SELECTED_STYLE = {
color: "#0d47a1",
weight: 3,
@@ -365,7 +506,9 @@
if (name) nearbyInfoByName.set(name, p);
});
const nearbyMapsQuery = (name: string, city = "") =>
- name ? (city ? `${city}${name}` : name) : "";
+ name ? (city && !name.includes(city) ? `${city}${name}` : name) : "";
+ const nearbyBaseStyle = (lyr: any) =>
+ lyr._isPoint ? NEARBY_POINT_STYLE : NEARBY_STYLE;
const parkDataByName = new Map<string, any>();
(parks as any[]).forEach((p) => parkDataByName.set(p["公園名"], p));
const ownerByName = new Map<string, string>();
@@ -536,12 +679,17 @@
// 市外の近い公園(500m以内)は別レイヤーにする。
// 小平市内の公園と同じく、クリックで一覧を絞り込み、左下に情報を表示する。
const nearbyLayer = L.geoJSON(nearbyData as any, {
- style: NEARBY_STYLE,
+ // 範囲が取得できない公園は点(丸)で表示する。
+ pointToLayer: (_feature: any, latlng: any) =>
+ L.circleMarker(latlng, NEARBY_POINT_STYLE),
+ style: (feature: any) =>
+ feature?.geometry?.type === "Point" ? NEARBY_POINT_STYLE : NEARBY_STYLE,
onEachFeature: (feature: any, lyr: any) => {
const p = feature.properties || {};
const name = String(p.name || "").trim();
const city = String(p.city || "").trim();
const key = `${p.osm_type || ""}/${p.osm_id || ""}`;
+ lyr._isPoint = feature.geometry?.type === "Point";
if (name) nearbyEntries.push({ name, layer: lyr, city, key });
lyr.on("click", (ev: any) => {
if (ev?.originalEvent) L.DomEvent.stopPropagation(ev.originalEvent);
@@ -552,35 +700,51 @@
},
}).addTo(map);
- // 地域センター・公民館・図書館・市役所支所。
+ // 地域センター・公民館・図書館・市役所支所・文化施設。
+ // 市外の近隣施設はオレンジで表示する。
const publicFacilityLayer = L.layerGroup();
- (facilities as any[]).forEach((f) => {
- if (f.lat == null || f.lon == null) return;
- L.circleMarker([f.lat, f.lon], {
- radius: 6,
- color: "#4a148c",
- weight: 1.5,
- fillColor: "#8e24aa",
- fillOpacity: 0.85,
- })
- .bindPopup(
- `<strong>${f.name}</strong><br>${f.type}<br>${f.hours || ""}${f.closed ? `<br>休: ${f.closed}` : ""}<br>トイレ: ${f.toilet || "未確認"} / 給水: ${f.water || "未確認"}`,
- )
- .addTo(publicFacilityLayer);
- });
+ const facilityMarkers: { name: string; layer: any }[] = [];
+ const FACILITY_ICON: Record<string, string> = {
+ 地域センター: "📍",
+ 公民館: "🏛",
+ 児童館: "🧒",
+ 図書館: "📚",
+ 市役所支所: "🏢",
+ 文化施設: "🎭",
+ };
+ const setFacilityOpacity = (layer: any, show: boolean) => {
+ if (typeof layer.setOpacity === "function") {
+ layer.setOpacity(show ? 1 : 0.2);
+ } else if (typeof layer.setStyle === "function") {
+ layer.setStyle({
+ opacity: show ? 1 : 0.15,
+ fillOpacity: show ? 0.85 : 0.1,
+ });
+ }
+ };
+ const addFacilities = (list: any[], nearby: boolean) => {
+ list.forEach((f) => {
+ if (f.lat == null || f.lon == null) return;
+ const icon = FACILITY_ICON[f.type] || "";
+ const marker = L.marker([f.lat, f.lon], {
+ icon: L.divIcon({
+ className: `park-facility-marker${nearby ? " is-nearby" : ""}`,
+ html: `<span class="park-facility-emoji">${icon || "🏢"}</span>`,
+ iconSize: [22, 22],
+ iconAnchor: [11, 11],
+ }),
+ })
+ .bindPopup(
+ `<strong>${icon} ${f.name}</strong>${nearby ? "(市外)" : ""}<br>${f.type}<br>${f.hours || ""}${f.closed ? `<br>休: ${f.closed}` : ""}<br>トイレ: ${f.toilet || "未確認"} / 給水: ${f.water || "未確認"}`,
+ )
+ .addTo(publicFacilityLayer);
+ facilityMarkers.push({ name: String(f.name || ""), layer: marker });
+ });
+ };
+ addFacilities(facilities as any[], false);
+ addFacilities(nearbyFacilities as any[], true);
publicFacilityLayer.addTo(map);
- L.control
- .layers(
- null,
- {
- "市外の近い公園(500m以内)": nearbyLayer,
- "公共施設": publicFacilityLayer,
- },
- { collapsed: false, position: "topright" },
- )
- .addTo(map);
-
// 拡大時だけ表示する設備アイコン(トイレ・水飲み場)
// 一覧と一致した公園のポリゴン内にだけ表示する。
const facilityLayer = L.layerGroup();
@@ -612,12 +776,76 @@
}).addTo(facilityLayer);
});
+ // 拡大時だけ表示する遊具アイコン。
+ // 公園ポリゴンの中心に、遊具の種類を絵アイコンで表示する。
+ const PLAY_DEFS = [
+ { tokens: ["ヒコーキ", "飛行機"], key: "airplane", title: PARK_ICONS.airplane.title, cls: "park-play-plane", icon: PARK_ICONS.airplane.svg },
+ { tokens: ["ジャングル"], key: "climbing", title: PARK_ICONS.climbing.title, cls: "park-play-jungle", icon: PARK_ICONS.climbing.svg },
+ { tokens: ["ブランコ"], key: "swing", title: PARK_ICONS.swing.title, cls: "park-play-swing", icon: PARK_ICONS.swing.svg },
+ { tokens: ["シーソー"], key: "seesaw", title: PARK_ICONS.teeter.title, cls: "park-play-seesaw", icon: PARK_ICONS.teeter.svg },
+ { tokens: ["スプリング"], key: "spring", title: PARK_ICONS.spring.title, cls: "park-play-spring", icon: PARK_ICONS.spring.svg },
+ { tokens: ["滑り台", "滑台"], key: "slide", title: PARK_ICONS.slide.title, cls: "park-play-slide", icon: PARK_ICONS.slide.svg },
+ { tokens: ["ロープウェイ"], key: "flyingfox", title: PARK_ICONS.flyingfox.title, cls: "park-play-rope", icon: PARK_ICONS.flyingfox.svg },
+ { tokens: ["砂場"], key: "sand", title: PARK_ICONS.sand.title, cls: "park-play-sand", icon: PARK_ICONS.sand.svg },
+ { tokens: ["複合遊具"], key: "playground", title: PARK_ICONS.playground.title, cls: "park-play-complex", icon: PARK_ICONS.playground.svg },
+ { tokens: [], key: "ball", title: PARK_ICONS.ball.title, cls: "park-play-ball", icon: PARK_ICONS.ball.svg },
+ ];
+ const playLegend = document.querySelector<HTMLElement>("[data-play-legend]");
+ if (playLegend) {
+ playLegend.innerHTML = PLAY_DEFS.map(
+ (def) =>
+ `<span class="park-play-legend-item"><span class="park-play ${def.cls}" aria-hidden="true">${def.icon}</span>${def.title}</span>`,
+ ).join("");
+ }
+ const playLayer = L.layerGroup();
+ layersByName.forEach((lyr, name) => {
+ const p = parkDataByName.get(name);
+ if (!p) return;
+ const text = (p["遊具"] || "").trim();
+ const ballText = (p["ボール遊び"] || "").trim();
+ const hasBall = Boolean(ballText && ballText !== "禁止");
+ if (!text && !hasBall) return;
+ const seen = new Set<string>();
+ const icons: string[] = [];
+ for (const def of PLAY_DEFS) {
+ const matched =
+ def.key === "ball"
+ ? hasBall
+ : def.tokens.some((token) => text.includes(token));
+ if (!matched) continue;
+ if (seen.has(def.key)) continue;
+ // ヒコーキジャングルはジャングルジムの一種なので、飛があればジは出さない。
+ if (def.key === "climbing" && seen.has("airplane")) continue;
+ seen.add(def.key);
+ icons.push(
+ `<span class="park-play ${def.cls}" title="${def.title}" aria-label="${def.title}">${def.icon}</span>`,
+ );
+ }
+ if (!icons.length) return;
+ const center = lyr.getBounds().getCenter();
+ L.marker([center.lat, center.lng], {
+ icon: L.divIcon({
+ className: "park-play-markers",
+ html: `<span class="park-play-list">${icons.join("")}</span>`,
+ iconSize: null,
+ iconAnchor: [0, 0],
+ }),
+ riseOnHover: false,
+ interactive: false,
+ }).addTo(playLayer);
+ });
+
const updateFacilities = () => {
if (map.getZoom() >= 16) {
if (!map.hasLayer(facilityLayer)) facilityLayer.addTo(map);
} else if (map.hasLayer(facilityLayer)) {
map.removeLayer(facilityLayer);
}
+ if (map.getZoom() >= 17) {
+ if (!map.hasLayer(playLayer)) playLayer.addTo(map);
+ } else if (map.hasLayer(playLayer)) {
+ map.removeLayer(playLayer);
+ }
};
map.on("zoomend", updateFacilities);
updateFacilities();
@@ -646,7 +874,8 @@
if (key) {
const nearbyHit = nearbyEntries.find((entry) => entry.key === key);
if (nearbyHit) {
- map.fitBounds(nearbyHit.layer.getBounds(), { padding: [40, 40], maxZoom: 17 });
+ const group = L.featureGroup([nearbyHit.layer]);
+ map.fitBounds(group.getBounds(), { padding: [40, 40], maxZoom: 17 });
showSelected(
name,
name,
@@ -702,7 +931,6 @@
const names = new Set<string>(detail.names || []);
const active = Boolean(detail.active);
const fromMap = Boolean(detail.fromMap);
- const visible: any[] = [];
if (!fromMap && selectedBar) selectedBar.hidden = true;
@@ -718,7 +946,12 @@
}
});
nearbyEntries.forEach(({ name, layer: lyr }) => {
- lyr.setStyle(name && names.has(name) ? NEARBY_SELECTED_STYLE : NEARBY_STYLE);
+ lyr.setStyle(
+ name && names.has(name) ? NEARBY_SELECTED_STYLE : nearbyBaseStyle(lyr),
+ );
+ });
+ facilityMarkers.forEach(({ layer }) => {
+ setFacilityOpacity(layer, true);
});
return;
}
@@ -731,7 +964,6 @@
}
if (name && names.has(name)) {
lyr.setStyle(MATCHED_STYLE);
- visible.push(lyr);
} else {
lyr.setStyle(DIM_STYLE);
}
@@ -739,26 +971,23 @@
nearbyEntries.forEach(({ name, layer: lyr }) => {
if (!active) {
- lyr.setStyle(NEARBY_STYLE);
+ lyr.setStyle(nearbyBaseStyle(lyr));
return;
}
if (name && names.has(name)) {
- lyr.setStyle(NEARBY_STYLE);
- visible.push(lyr);
+ lyr.setStyle(nearbyBaseStyle(lyr));
} else {
lyr.setStyle(NEARBY_DIM_STYLE);
}
});
- if (active && visible.length && visible.length < 30) {
- const bounds = L.featureGroup(visible).getBounds();
- if (bounds.isValid()) {
- map.fitBounds(bounds, { padding: [40, 40], maxZoom: 16 });
- }
- }
+ // 公共施設のマーカーも絞り込みに合わせて表示する。
+ facilityMarkers.forEach(({ name, layer }) => {
+ setFacilityOpacity(layer, !active || names.has(name));
+ });
});
- // 現在地表示(スマホでは自動で取得を試みる)
+ // 現在地表示(ボタン操作時のみ取得)
const statusEl = document.querySelector<HTMLElement>("[data-locate-status]");
const setStatus = (msg: string) => {
if (!statusEl) return;
@@ -769,30 +998,72 @@
let locationMarker: any = null;
let accuracyCircle: any = null;
let watchId: number | null = null;
+ let tracking = false;
+ let firstFix = true;
+ let locateButton: HTMLButtonElement | null = null;
+
+ const LOCATE_ICON =
+ '<span aria-hidden="true"><svg viewBox="0 0 24 24" width="20" height="20"><path d="M12 2.2 20 21.5 12 16.8 4 21.5Z" fill="#1e88e5" stroke="#ffffff" stroke-width="2" stroke-linejoin="round"/></svg></span>';
+ const TRACKING_ICON =
+ '<span aria-hidden="true"><svg viewBox="0 0 24 24" width="20" height="20"><circle cx="12" cy="12" r="3.5" fill="currentColor"/><circle cx="12" cy="12" r="7" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>';
+
+ const updateLocateButton = () => {
+ if (!locateButton) return;
+ locateButton.classList.toggle("is-tracking", tracking);
+ locateButton.setAttribute("aria-pressed", tracking ? "true" : "false");
+ const label = tracking ? "現在地の追跡を停止" : "現在地を表示";
+ locateButton.title = label;
+ locateButton.setAttribute("aria-label", label);
+ locateButton.innerHTML = tracking ? TRACKING_ICON : LOCATE_ICON;
+ };
+
+ const stopTracking = () => {
+ if (watchId !== null) {
+ navigator.geolocation.clearWatch(watchId);
+ watchId = null;
+ }
+ tracking = false;
+ updateLocateButton();
+ };
- const locate = (center = false) => {
+ const locate = () => {
+ if (tracking) {
+ stopTracking();
+ setStatus("現在地の追跡を停止しました。");
+ return;
+ }
if (!navigator.geolocation) {
setStatus("このブラウザでは現在地を取得できません。");
return;
}
setStatus("現在地を取得しています…");
+ firstFix = true;
const onSuccess = (pos: GeolocationPosition) => {
const { latitude, longitude, accuracy } = pos.coords;
if (!locationMarker) {
- locationMarker = L.circleMarker([latitude, longitude], {
- radius: 7,
- color: "#ffffff",
- weight: 2,
- fillColor: "#1e88e5",
- fillOpacity: 1,
- }).bindPopup("現在地", { autoPan: false });
- locationMarker.on("add", () => {
- locationMarker.getElement()?.setAttribute("data-locate", "true");
+ const icon = L.divIcon({
+ className: "park-locate-marker",
+ html: '<span class="park-locate-arrow"><svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path d="M12 2.2 20 21.5 12 16.8 4 21.5Z" fill="#1e88e5" stroke="#ffffff" stroke-width="2" stroke-linejoin="round"/></svg></span>',
+ iconSize: [24, 24],
+ iconAnchor: [12, 12],
});
- locationMarker.addTo(map);
+ locationMarker = L.marker([latitude, longitude], {
+ icon,
+ interactive: false,
+ keyboard: false,
+ }).addTo(map);
} else {
locationMarker.setLatLng([latitude, longitude]);
}
+ const heading =
+ typeof pos.coords.heading === "number" &&
+ Number.isFinite(pos.coords.heading)
+ ? pos.coords.heading
+ : 0;
+ const arrow = locationMarker
+ .getElement()
+ ?.querySelector<HTMLElement>(".park-locate-arrow");
+ if (arrow) arrow.style.transform = `rotate(${heading}deg)`;
if (!accuracyCircle) {
accuracyCircle = L.circle([latitude, longitude], {
radius: Math.min(accuracy, 1000),
@@ -807,13 +1078,21 @@
.setLatLng([latitude, longitude])
.setRadius(Math.min(accuracy, 1000));
}
- setStatus(`現在地を表示しています(精度 約${Math.round(accuracy)}m)。`);
- if (center) map.setView([latitude, longitude], Math.max(map.getZoom(), 15));
+ if (firstFix) {
+ map.setView([latitude, longitude], Math.max(map.getZoom(), 15));
+ firstFix = false;
+ }
+ setStatus(
+ `現在地を追跡中です(精度 約${Math.round(accuracy)}m)。もう一度押すと停止します。`,
+ );
};
const onError = () => {
setStatus("現在地を取得できませんでした。位置情報の利用を許可してください。");
+ stopTracking();
};
if (watchId !== null) navigator.geolocation.clearWatch(watchId);
+ tracking = true;
+ updateLocateButton();
watchId = navigator.geolocation.watchPosition(onSuccess, onError, {
enableHighAccuracy: true,
timeout: 10000,
@@ -823,23 +1102,22 @@
const LocateControl = L.Control.extend({
onAdd: () => {
- const btn = L.DomUtil.create("button", "leaflet-bar park-locate");
+ const btn = L.DomUtil.create("button", "park-locate") as HTMLButtonElement;
btn.setAttribute("type", "button");
- btn.title = "現在地を表示";
- btn.setAttribute("aria-label", "現在地を表示");
- btn.innerHTML = '<span aria-hidden="true">◎</span>';
+ locateButton = btn;
L.DomEvent.disableClickPropagation(btn);
- btn.addEventListener("click", () => locate(true));
+ btn.addEventListener("click", () => locate());
+ updateLocateButton();
return btn;
},
});
new LocateControl({ position: "topright" }).addTo(map);
- if (window.matchMedia("(max-width: 640px)").matches) {
- locate(true);
- }
+ // 位置情報の許可確認は、ボタン操作(ユーザー操作)のときだけ出す。
+ // ページ読み込み時に自動で watchPosition を呼ぶと、ブラウザによっては
+ // 許可ダイアログが出ずに失敗することがあるため。
window.addEventListener("pagehide", () => {
- if (watchId !== null) navigator.geolocation.clearWatch(watchId);
+ stopTracking();
});
// レイアウト確定後にサイズを再計算し、タイルを再配置する。
diff --git a/src/components/ParkMapHelp.astro b/src/components/ParkMapHelp.astro
new file mode 100644
index 0000000..26d9917
--- /dev/null
+++ b/src/components/ParkMapHelp.astro
@@ -0,0 +1,111 @@
+---
+// 地図の見方・凡例。地図と一覧表を近づけるため、一覧表の下に表示する。
+// 遊具アイコンの凡例は ParkMap.astro のスクリプトが [data-play-legend] に流し込む。
+---
+
+<details class="park-map-help">
+ <summary>地図の見方・凡例</summary>
+ <div class="park-map-legend" aria-hidden="true">
+ <span><i class="swatch swatch-matched"></i>一覧と一致</span>
+ <span><i class="swatch swatch-unmatched"></i>一覧にない公園・緑地</span>
+ <span><i class="swatch swatch-nearby"></i>市外の近い公園(500m以内)</span>
+ <span><i class="swatch swatch-facility"></i>公共施設</span>
+ <span><i class="swatch swatch-facility-nearby"></i>市外の公共施設</span>
+ <span><i class="swatch swatch-boundary"></i>小平市の境界</span>
+ <span><i class="swatch-fac swatch-fac-toilet">WC</i>トイレ</span>
+ <span><i class="swatch-fac swatch-fac-water"><svg viewBox="0 0 24 24" width="10" height="10" aria-hidden="true"><path fill="currentColor" d="M12 2.7c-3.4 4.2-6 8.1-6 11.3a6 6 0 0 0 12 0c0-3.2-2.6-7.1-6-11.3z" /></svg></i>水飲み場(拡大時)</span>
+ </div>
+ <div class="park-play-legend" data-play-legend></div>
+ <p class="park-map-note">
+ 公園の範囲を赤、市外の近い公園を青、公共施設を紫(市外はオレンジ)で表示しています。地図を拡大すると見やすくなります。PCでは地図上でスクロールするとズームできます。市外の近い公園のうち、範囲が取得できないものは丸で表示しています。トイレと水飲み場のアイコンは拡大すると表示されます。さらに拡大すると、遊具アイコン(上の凡例)が表示されます。OpenStreetMapに公園として登録されている範囲を表示しているため、すべての公園を網羅しているわけではありません。現在地は端末内でのみ利用し、サーバーには送信しません。地図データ © <a
+ href="https://www.openstreetmap.org/copyright"
+ target="_blank"
+ rel="noopener">OpenStreetMap</a
+ > contributors
+ </p>
+</details>
+
+<style>
+ .park-map-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem 1rem;
+ align-items: center;
+ margin-top: 0.5rem;
+ color: var(--sl-color-gray-2);
+ font-size: 0.8rem;
+ }
+ .park-map-legend span {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ }
+ .swatch {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border-radius: 2px;
+ }
+ .swatch-matched {
+ background: #e53935;
+ }
+ .swatch-unmatched {
+ background: #ffcdd2;
+ }
+ .swatch-nearby {
+ background: #42a5f5;
+ }
+ .swatch-facility {
+ background: #8e24aa;
+ border-radius: 50%;
+ }
+ .swatch-facility-nearby {
+ background: #fb8c00;
+ border-radius: 50%;
+ }
+ .swatch-boundary {
+ height: 0;
+ border-top: 4px solid #6a1b9a;
+ border-radius: 0;
+ background: transparent;
+ }
+ .swatch-fac {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ margin-right: 0.3rem;
+ color: #fff;
+ font-style: normal;
+ font-size: 7px;
+ font-weight: 700;
+ line-height: 1;
+ }
+ .swatch-fac-toilet {
+ background: #455a64;
+ border-radius: 4px;
+ }
+ .swatch-fac-water {
+ background: #0277bd;
+ border-radius: 50%;
+ }
+ .park-map-note {
+ margin-top: 0.4rem;
+ color: var(--sl-color-gray-2);
+ font-size: 0.8rem;
+ }
+ .park-play-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem 0.8rem;
+ margin: 0.5rem 0 0;
+ color: var(--sl-color-gray-2);
+ font-size: 0.78rem;
+ }
+ .park-play-legend-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ }
+</style>
diff --git a/src/components/ParksFilters.astro b/src/components/ParksFilters.astro
new file mode 100644
index 0000000..bf65d6e
--- /dev/null
+++ b/src/components/ParksFilters.astro
@@ -0,0 +1,184 @@
+---
+// 公園・公共施設の一覧を絞り込むコントロール。地図の上に表示する。
+// 実際の絞り込みは ParksTable.astro のスクリプトが行う。
+import { parks, towns, total } from "@/data/parks-rows";
+import { PARK_ICONS } from "@/data/park-icons";
+
+const facilityTypes = [
+ "図書館",
+ "公民館",
+ "児童館",
+ "地域センター",
+ "市役所支所",
+ "文化施設",
+].filter((type) =>
+ parks.some((p) => p["施設"] === "1" && p["種別"] === type),
+);
+---
+
+<div class="parks-controls" data-parks-filters>
+ <label>
+ <span>種類</span>
+ <select data-parks-kind>
+ <option value="">すべて</option>
+ <option value="park">公園</option>
+ {facilityTypes.map((type) => (
+ <option value={type}>{type}</option>
+ ))}
+ </select>
+ </label>
+ <label>
+ <span>町名</span>
+ <select data-parks-town>
+ <option value="">すべての町名</option>
+ {towns.map((town) => (
+ <option value={town}>{town}</option>
+ ))}
+ </select>
+ </label>
+ <label>
+ <span>キーワード</span>
+ <input type="search" data-parks-search placeholder="名称・住所・設備など" />
+ </label>
+ <fieldset class="parks-facilities">
+ <legend>設備・遊具で絞る</legend>
+ <label><input type="checkbox" data-fac-filter value="toilet" />トイレ</label>
+ <label><input type="checkbox" data-fac-filter value="water" />水飲み場・給水</label>
+ <label>
+ <input type="checkbox" data-fac-filter value="picnic" />
+ <span class="filter-icon" set:html={PARK_ICONS.picnic.svg} />ピクニック・広場
+ </label>
+ <label><input type="checkbox" data-fac-filter value="toy" />遊具あり</label>
+ <label>
+ <input type="checkbox" data-fac-filter value="sand" />
+ <span class="filter-icon" set:html={PARK_ICONS.sand.svg} />砂場
+ </label>
+ <label>
+ <input type="checkbox" data-fac-filter value="swing" />
+ <span class="filter-icon" set:html={PARK_ICONS.swing.svg} />ブランコ
+ </label>
+ <label>
+ <input type="checkbox" data-fac-filter value="seesaw" />
+ <span class="filter-icon" set:html={PARK_ICONS.teeter.svg} />シーソー
+ </label>
+ <label>
+ <input type="checkbox" data-fac-filter value="spring" />
+ <span class="filter-icon" set:html={PARK_ICONS.spring.svg} />スプリング遊具
+ </label>
+ <label>
+ <input type="checkbox" data-fac-filter value="jungle" />
+ <span class="filter-icon" set:html={PARK_ICONS.climbing.svg} />ジャングルジム
+ </label>
+ <label>
+ <input type="checkbox" data-fac-filter value="plane" />
+ <span class="filter-icon" set:html={PARK_ICONS.airplane.svg} />ヒコーキ
+ </label>
+ <label>
+ <input type="checkbox" data-fac-filter value="slide" />
+ <span class="filter-icon" set:html={PARK_ICONS.slide.svg} />滑り台
+ </label>
+ <label>
+ <input type="checkbox" data-fac-filter value="complex" />
+ <span class="filter-icon" set:html={PARK_ICONS.playground.svg} />複合遊具
+ </label>
+ <label>
+ <input type="checkbox" data-fac-filter value="rope" />
+ <span class="filter-icon" set:html={PARK_ICONS.flyingfox.svg} />ロープウェイ
+ </label>
+ <label>
+ <input type="checkbox" data-fac-filter value="ball" />
+ <span class="filter-icon" set:html={PARK_ICONS.ball.svg} />ボール遊び
+ </label>
+ </fieldset>
+ <span class="parks-count" data-parks-count aria-live="polite">{total}件</span>
+</div>
+
+<p class="parks-disclaimer">
+ 情報には誤りが含まれる場合があります。最新の情報は各施設の公式ページでご確認ください。
+</p>
+
+<style>
+ .parks-controls {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem 0.9rem;
+ align-items: center;
+ margin: 0 0 0.55rem;
+ }
+ .parks-controls label {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ min-height: 2.1rem;
+ font-size: 0.9rem;
+ line-height: 1.2;
+ }
+ .parks-controls label > span {
+ line-height: 1.2;
+ }
+ .parks-controls select,
+ .parks-controls input[type="search"] {
+ height: 2.1rem;
+ box-sizing: border-box;
+ padding: 0.25rem 0.5rem;
+ border: 1px solid var(--sl-color-hairline, #ccc);
+ border-radius: 0.3rem;
+ background: var(--sl-color-bg, #fff);
+ color: var(--sl-color-text, inherit);
+ font: inherit;
+ line-height: 1.2;
+ }
+ .parks-facilities {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.1rem 0.6rem;
+ align-items: center;
+ margin: 0;
+ padding: 0.2rem 0.5rem;
+ border: 1px solid var(--sl-color-hairline, #ccc);
+ border-radius: 0.3rem;
+ }
+ .parks-facilities legend {
+ padding: 0 0.2rem;
+ font-size: 0.78em;
+ color: inherit;
+ opacity: 0.7;
+ }
+ .parks-facilities label {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.2rem;
+ min-height: 1.5rem;
+ font-size: 0.8rem;
+ line-height: 1.2;
+ }
+ .parks-facilities input[type="checkbox"] {
+ width: 0.9em;
+ height: 0.9em;
+ margin: 0;
+ }
+ .filter-icon {
+ display: inline-flex;
+ width: 18px;
+ height: 18px;
+ flex: none;
+ }
+ .filter-icon :global(svg) {
+ display: block;
+ width: 100%;
+ height: 100%;
+ }
+ :global(.filter-icon svg *) {
+ vector-effect: non-scaling-stroke;
+ }
+ .parks-count {
+ margin-left: auto;
+ font-size: 0.9rem;
+ opacity: 0.75;
+ }
+ .parks-disclaimer {
+ margin: 0 0 0.55rem;
+ color: var(--sl-color-gray-2);
+ font-size: 0.78rem;
+ }
+</style>
diff --git a/src/components/ParksTable.astro b/src/components/ParksTable.astro
index bea8ff2..65b8412 100644
--- a/src/components/ParksTable.astro
+++ b/src/components/ParksTable.astro
@@ -1,78 +1,9 @@
---
-// 小平市の公園一覧(/kodaira-data/parks/)を表示する。
-// データは src/data/kodaira-parks.json(市立332件+市立以外)。
+// 小平市の公園・周辺の公園・公共施設の一覧(/kodaira-data/parks/)を表示する。
+// データは src/data/parks-rows.ts で組み立てる。
// DOM を抑えるため、設備は1セルにまとめ、アイコンは CSS の擬似要素で描く。
// 絞り込み・並べ替え・カード表示は利用者の端末で実行する。
-import parksData from "@/data/kodaira-parks.json";
-import osmData from "@/data/parks-geojson";
-import nearbyData from "@/data/kodaira-nearby-parks.json";
-
-type Park = Record<string, string>;
-
-function nearbyCentroid(feature: any): [number, number] {
- const g = feature.geometry;
- const ring = g.type === "Polygon" ? g.coordinates[0] : g.coordinates[0][0];
- if (!ring?.length) return [0, 0];
- let lat = 0;
- let lon = 0;
- for (const [x, y] of ring) {
- lon += x;
- lat += y;
- }
- return [lat / ring.length, lon / ring.length];
-}
-
-// 市境500m以内の市外公園。小平市の公園と同じ表に「市外」バッジ付きで並べる。
-const nearbyRows: Park[] = ((nearbyData as any).features ?? []).map((f: any) => {
- const p = f.properties || {};
- const [lat, lon] = nearbyCentroid(f);
- const area = Math.round(Number(p.area_m2) || 0);
- const name = String(p.name || "").trim();
- // 市外の公園も公園名で開く。市名が分かる場合は市名を付ける。
- const mapsQuery = name
- ? p.city
- ? `${p.city}${name}`
- : name
- : `${lat},${lon}`;
- return {
- "公園名": name,
- "住所": p.city ? `${p.city}(市外)` : "市外",
- "町名": "市外",
- "面積㎡": area ? String(area) : "",
- "500㎡以上": "",
- "種別": "市外",
- "南西部指定管理": "",
- "トイレ": "",
- "水飲み場": "",
- "ベンチ・椅子": "",
- "テーブル": "",
- "パーゴラ": "",
- "東屋": "",
- "防災用具": "",
- "ボール遊び": "",
- "遊具": "",
- "駐車場": "",
- "その他設備": "",
- "緯度": String(lat),
- "経度": String(lon),
- "備考": `市外・小平市境から約${Math.round(Number(p.distance_m) || 0)}m`,
- "設置者": "市外",
- "状態": "",
- "地図キー": `${p.osm_type || ""}/${p.osm_id || ""}`,
- "地図URL": `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(mapsQuery)}`,
- };
-});
-
-const parks = [...(parksData as Park[]), ...nearbyRows];
-
-// OpenStreetMapにポリゴンがあり、地図から位置を表示できる公園名。
-// 市外の近隣公園も地図に表示しているので、同じように地図で見られる。
-const mappedNames = new Set<string>([
- ...(osmData as any).features.map((f: any) => f.properties?.our_name).filter(Boolean),
- ...((nearbyData as any).features ?? [])
- .map((f: any) => String(f.properties?.name || "").trim())
- .filter(Boolean),
-]);
+import { parks, mappedNames, type Park } from "@/data/parks-rows";
const EMPTY = "—";
@@ -87,6 +18,12 @@ const TYPE_LABEL: Record<string, string> = {
団地内公園: "団地内公園",
児童遊園: "児童遊園",
市外: "市外の近隣公園",
+ 地域センター: "地域センター",
+ 公民館: "公民館",
+ 児童館: "児童館",
+ 図書館: "図書館",
+ 市役所支所: "市役所支所",
+ 文化施設: "文化施設",
};
const TYPE_CLASS: Record<string, string> = {
街区: "parks-type--block",
@@ -98,6 +35,30 @@ const TYPE_CLASS: Record<string, string> = {
団地内公園: "parks-type--danchi",
児童遊園: "parks-type--jidou",
市外: "parks-type--green",
+ 地域センター: "parks-type--facility",
+ 公民館: "parks-type--facility",
+ 児童館: "parks-type--facility",
+ 図書館: "parks-type--facility",
+ 市役所支所: "parks-type--facility",
+ 文化施設: "parks-type--facility",
+};
+// 種別は列幅を小さくするため、漢字1文字の記号で表示する。
+const TYPE_MARK: Record<string, string> = {
+ 街区: "街",
+ 近隣: "近",
+ 運動: "運",
+ 広域: "広",
+ 広場: "場",
+ 緑地: "緑",
+ 団地内公園: "団",
+ 児童遊園: "児",
+ 市外: "外",
+ 地域センター: "📍",
+ 公民館: "🏛",
+ 児童館: "🧒",
+ 図書館: "📚",
+ 市役所支所: "🏢",
+ 文化施設: "🎭",
};
// 市立以外の公園に付けるバッジ(設置者が空欄なら市立として扱う)。
@@ -125,6 +86,9 @@ function facilityBadges(p: Park): Badge[] {
if (p["水飲み場"]) {
badges.push({ kind: "water", label: "水飲み場", title: `水飲み場: ${p["水飲み場"]}` });
}
+ if (p["給水"]) {
+ badges.push({ kind: "water", label: "給水", title: `給水: ${p["給水"]}` });
+ }
if (p["遊具"]) {
badges.push({ kind: "toy", label: "遊具", title: `遊具: ${p["遊具"]}` });
}
@@ -150,12 +114,34 @@ function facTokens(p: Park): string {
const tokens: string[] = [];
if (p["トイレ"]) tokens.push("toilet");
if (p["水飲み場"]) tokens.push("water");
+ if (p["給水"]) tokens.push("water");
if (p["遊具"]) tokens.push("toy");
+ const play = p["遊具"] || "";
+ if (play.includes("砂場")) tokens.push("sand");
+ if (play.includes("ブランコ")) tokens.push("swing");
+ if (play.includes("シーソー")) tokens.push("seesaw");
+ if (play.includes("スプリング")) tokens.push("spring");
+ if (play.includes("ジャングル")) tokens.push("jungle");
+ if (play.includes("ヒコーキ") || play.includes("飛行機")) tokens.push("plane");
+ if (play.includes("滑り台") || play.includes("滑台")) tokens.push("slide");
+ if (play.includes("複合遊具")) tokens.push("complex");
+ if (play.includes("ロープウェイ")) tokens.push("rope");
if (p["パーゴラ"]) tokens.push("pergola");
if (p["東屋"]) tokens.push("azumaya");
const ball = p["ボール遊び"];
if (ball && ball !== "禁止") tokens.push("ball");
if (p["南西部指定管理"]) tokens.push("cm");
+ // ピクニック広場・芝生広場・テーブルのある公園。
+ const picnicText = `${p["公園名"] || ""} ${p["種別"] || ""} ${p["その他設備"] || ""} ${p["備考"] || ""}`;
+ if (
+ (p["テーブル"] || "").trim() ||
+ p["種別"] === "広場" ||
+ picnicText.includes("芝生") ||
+ picnicText.includes("ピクニック") ||
+ picnicText.includes("広場")
+ ) {
+ tokens.push("picnic");
+ }
return tokens.join(" ");
}
@@ -228,56 +214,32 @@ function searchIndex(p: Park): string {
p["状態"],
p["トイレ"],
p["水飲み場"],
+ p["給水"],
p["ボール遊び"],
...DETAIL_FIELDS.map((f) => p[f.key]),
]
.join(" ")
.toLowerCase();
}
-
-const towns = [...new Set(parks.map((p) => p["町名"]).filter(Boolean))].sort((a, b) =>
- a.localeCompare(b, "ja"),
-);
-const total = parks.length;
---
<div class="parks" data-parks>
- <div class="parks-controls">
- <label>
- <span>町名</span>
- <select data-parks-town>
- <option value="">すべての町名</option>
- {towns.map((town) => (
- <option value={town}>{town}</option>
- ))}
- </select>
- </label>
- <label>
- <span>キーワード</span>
- <input type="search" data-parks-search placeholder="公園名・住所・遊具など" />
- </label>
- <fieldset class="parks-facilities">
- <legend>設備・管理で絞る</legend>
- <label><input type="checkbox" data-fac-filter value="toilet" />トイレ</label>
- <label><input type="checkbox" data-fac-filter value="water" />水飲み場</label>
- <label><input type="checkbox" data-fac-filter value="toy" />遊具</label>
- <label><input type="checkbox" data-fac-filter value="pergola" />パーゴラ</label>
- <label><input type="checkbox" data-fac-filter value="azumaya" />東屋</label>
- <label><input type="checkbox" data-fac-filter value="ball" />ボール遊び</label>
- <label><input type="checkbox" data-fac-filter value="cm" />指定管理</label>
- </fieldset>
- <span class="parks-count" data-parks-count aria-live="polite">{total}件</span>
- </div>
- <p class="parks-hint">設備の情報は、市の資料などに基づきます。設備で絞ると、この情報がある公園だけが対象になります。</p>
- <p class="parks-none" data-parks-none hidden>条件に合う公園が見つかりませんでした。</p>
+ <p class="parks-none" data-parks-none hidden>条件に合う公園・施設が見つかりませんでした。</p>
<div class="parks-scroll">
<table>
+ <colgroup>
+ <col class="col-name" />
+ <col class="col-address" />
+ <col class="col-area" />
+ <col class="col-type" />
+ <col class="col-facility" />
+ </colgroup>
<thead>
<tr>
<th scope="col" aria-sort="none">
<button type="button" class="parks-sort" data-sort="name">
- 公園名<span class="parks-sort-mark" aria-hidden="true"></span>
+ 名称<span class="parks-sort-mark" aria-hidden="true"></span>
</button>
</th>
<th scope="col">住所</th>
@@ -293,13 +255,14 @@ const total = parks.length;
<tbody>
{parks.map((park) => (
<tr
+ data-kind={park["施設"] === "1" ? park["種別"] : "park"}
data-town={park["町名"]}
data-area={park["面積㎡"]}
data-name={park["公園名"]}
data-fac={facTokens(park)}
data-search={searchIndex(park)}
>
- <td class="is-name" data-label="公園名">
+ <td class="is-name" data-label="名称">
{mappedNames.has(park["公園名"]) ? (
<button
type="button"
@@ -308,6 +271,14 @@ const total = parks.length;
data-focus-key={park["地図キー"] || ""}
title="地図でこの公園の位置を表示"
>{park["公園名"]}</button>
+ ) : park["公式URL"] ? (
+ <a
+ class="park-name-link"
+ href={park["公式URL"]}
+ target="_blank"
+ rel="noopener"
+ title="公式ページを開く"
+ >{park["公園名"]}</a>
) : (
park["公園名"]
)}
@@ -344,7 +315,7 @@ const total = parks.length;
title={TYPE_LABEL[park["種別"]]}
aria-label={TYPE_LABEL[park["種別"]]}
>
- {park["種別"]}
+ <span aria-hidden="true">{TYPE_MARK[park["種別"]] || park["種別"].slice(0, 1)}</span>
</span>
) : (
EMPTY
@@ -424,16 +395,35 @@ const total = parks.length;
opacity: 1;
}
.parks-scroll {
- overflow-x: auto;
+ max-height: 70vh;
+ overflow: auto;
+ overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
border: 1px solid var(--sl-color-hairline, #ddd);
border-radius: 0.3rem;
}
.parks table {
width: 100%;
+ min-width: 760px;
+ table-layout: fixed;
border-collapse: collapse;
font-size: 0.85rem;
}
+ .parks col.col-name {
+ width: 24%;
+ }
+ .parks col.col-address {
+ width: 18%;
+ }
+ .parks col.col-area {
+ width: 11%;
+ }
+ .parks col.col-type {
+ width: 7%;
+ }
+ .parks col.col-facility {
+ width: 40%;
+ }
.parks th,
.parks td {
border-bottom: 1px solid var(--sl-color-hairline, #ddd);
@@ -481,6 +471,9 @@ const total = parks.length;
}
.parks .is-name {
font-weight: 600;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ line-height: 1.5;
}
.parks-owner {
display: inline-block;
@@ -521,6 +514,8 @@ const total = parks.length;
white-space: nowrap;
}
.park-name-link {
+ display: inline-block;
+ max-width: 100%;
padding: 0;
border: 0;
background: none;
@@ -532,6 +527,8 @@ const total = parks.length;
text-decoration: underline;
text-decoration-color: var(--sl-color-gray-4);
text-underline-offset: 2px;
+ white-space: normal;
+ overflow-wrap: anywhere;
}
.park-name-link:hover {
color: var(--sl-color-accent);
@@ -579,16 +576,15 @@ const total = parks.length;
.parks-type {
display: inline-flex;
align-items: center;
- gap: 0.3em;
- font-size: 0.9em;
- }
- .parks-type::before {
- content: "";
- width: 0.7em;
- height: 0.7em;
- border-radius: 50%;
- background: var(--type-color, #888);
- flex: none;
+ justify-content: center;
+ width: 1.5em;
+ height: 1.5em;
+ border-radius: 0.35em;
+ background: color-mix(in srgb, var(--type-color, #888) 18%, transparent);
+ color: var(--type-color, #888);
+ font-size: 0.8em;
+ font-weight: 700;
+ line-height: 1;
}
.parks-type--block {
--type-color: #2e9e4f;
@@ -614,6 +610,10 @@ const total = parks.length;
.parks-type--jidou {
--type-color: #d81b60;
}
+ .parks-type--facility {
+ --type-color: #00838f;
+ font-size: 1em;
+ }
.parks .is-facility {
white-space: normal;
min-width: 12rem;
@@ -674,12 +674,15 @@ const total = parks.length;
<script>
document.querySelectorAll("[data-parks]").forEach((root) => {
- const select = root.querySelector<HTMLSelectElement>("[data-parks-town]");
- const search = root.querySelector<HTMLInputElement>("[data-parks-search]");
- const count = root.querySelector("[data-parks-count]");
+ const select = document.querySelector<HTMLSelectElement>("[data-parks-town]");
+ const kindSelect = document.querySelector<HTMLSelectElement>("[data-parks-kind]");
+ const search = document.querySelector<HTMLInputElement>("[data-parks-search]");
+ const count = document.querySelector("[data-parks-count]");
const none = root.querySelector<HTMLElement>("[data-parks-none]");
const tbody = root.querySelector("tbody");
- const checks = Array.from(root.querySelectorAll<HTMLInputElement>("[data-fac-filter]"));
+ const checks = Array.from(
+ document.querySelectorAll<HTMLInputElement>("[data-fac-filter]"),
+ );
const sortButtons = Array.from(root.querySelectorAll<HTMLButtonElement>("[data-sort]"));
if (!tbody) return;
@@ -703,17 +706,19 @@ const total = parks.length;
let selectedName = "";
const applyFilter = () => {
+ const kind = kindSelect ? kindSelect.value : "";
const town = select ? select.value : "";
const query = search ? search.value.trim().toLowerCase() : "";
const wanted = checks.filter((c) => c.checked).map((c) => c.value);
let shown = 0;
allRows().forEach((row) => {
const facs = (row.dataset.fac || "").split(" ");
+ const okKind = !kind || row.dataset.kind === kind;
const okTown = !town || row.dataset.town === town;
const okText = !query || (row.dataset.search || "").includes(query);
const okFac = wanted.every((token) => facs.includes(token));
const okSelected = !selectedName || row.dataset.name === selectedName;
- const ok = okTown && okText && okFac && okSelected;
+ const ok = okKind && okTown && okText && okFac && okSelected;
row.classList.toggle("is-hidden", !ok);
if (ok) shown += 1;
});
@@ -729,7 +734,7 @@ const total = parks.length;
new CustomEvent("park:filter", {
detail: {
names: visibleNames,
- active: Boolean(town || query || wanted.length || selectedName),
+ active: Boolean(kind || town || query || wanted.length || selectedName),
fromMap,
},
}),
@@ -765,6 +770,10 @@ const total = parks.length;
clearSelected();
applyFilter();
});
+ kindSelect?.addEventListener("change", () => {
+ clearSelected();
+ applyFilter();
+ });
search?.addEventListener("input", () => {
clearSelected();
applyFilter();
@@ -784,6 +793,7 @@ const total = parks.length;
selectedName = name;
if (search) search.value = name;
if (select) select.value = "";
+ if (kindSelect) kindSelect.value = "";
checks.forEach((c) => {
c.checked = false;
});