From 3efc9efad99e90de3eab2fc1702ef8029538c318 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:17:09 +0900 Subject: ParkMap.astro: フキダシの項目名を文字アイコン化 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 水飲み場はあり/なしで表示し、水滴アイコンを塗りつぶしに変更 - じゃぶじゃぶ池の池アイコンを追加し、地図上にも表示 - 特殊遊具のアイコンを星に変更 - ベンチ・椅子をベンチの絵に変更 - 試合形式は独立行をやめ、ボール遊びに括弧書きで併記 - 防災用具から防火水槽・消火栓を除外 - 一覧表はスマホでカード表示にして横スクロールをなくす --- src/components/ParkMap.astro | 358 +++++++++++++++++++++++++++++++++----- src/components/ParkMapHelp.astro | 7 +- src/components/ParksFilters.astro | 7 +- src/components/ParksTable.astro | 81 ++++++++- 4 files changed, 399 insertions(+), 54 deletions(-) (limited to 'src/components') diff --git a/src/components/ParkMap.astro b/src/components/ParkMap.astro index 8e7f35c..b7ac130 100644 --- a/src/components/ParkMap.astro +++ b/src/components/ParkMap.astro @@ -119,6 +119,62 @@ font-size: 0.78rem; cursor: pointer; } + :global(.park-popup-row) { + display: flex; + gap: 0.35rem; + align-items: flex-start; + font-size: 0.78rem; + margin-top: 0.1rem; + } + :global(.park-popup-ico) { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 16px; + height: 16px; + border: 1px solid #c8e6c9; + border-radius: 4px; + background: #e8f5e9; + color: #2e7d32; + font-size: 10px; + font-weight: 700; + line-height: 1; + } + :global(.park-popup-ico svg) { + display: block; + width: 12px; + height: 12px; + } + :global(.park-popup-ico-water) { + border-color: #90caf9; + background: #e3f2fd; + color: #0277bd; + } + :global(.park-popup-ico-bench) { + border-color: #d7ccc8; + background: #efebe9; + color: #6d4c41; + } + :global(.park-popup-ico-pond) { + border-color: #81d4fa; + background: #e1f5fe; + color: #0277bd; + } + :global(.park-popup-meta) { + margin-top: 0.1rem; + color: #777; + font-size: 0.7rem; + } + :global(.park-popup-links a::after) { + content: none !important; + } + :global(.leaflet-popup-content) { + max-height: 50vh; + overflow: auto; + } + :global(.park-popup) { font-size: 0.82rem; line-height: 1.5; @@ -209,7 +265,9 @@ animation: park-selected-pulse 1.2s ease-in-out infinite; } :global(.leaflet-overlay-pane path.park-blink) { - animation: park-blink-pulse 2.6s ease-in-out infinite; + fill: #ffd54f; + stroke: #ff6d00; + animation: park-blink-pulse 2s ease-in-out infinite; } @keyframes park-selected-pulse { 0%, @@ -225,12 +283,12 @@ @keyframes park-blink-pulse { 0%, 100% { - fill-opacity: 0.6; + fill-opacity: 0.85; stroke-opacity: 1; } 50% { - fill-opacity: 0.08; - stroke-opacity: 0.3; + fill-opacity: 0.35; + stroke-opacity: 0.7; } } @media (prefers-reduced-motion: reduce) { @@ -428,8 +486,11 @@ border-radius: 4px; } .swatch-fac-water { - background: #0277bd; + box-sizing: border-box; + border: 1px solid #0277bd; border-radius: 50%; + background: #fff; + color: #0277bd; } :global(.park-fac-markers), :global(.park-fac-list) { @@ -458,9 +519,10 @@ border-radius: 5px; } :global(.park-fac-water) { - background: #0277bd; - border: 1px solid #01579b; + border: 1px solid #0277bd; border-radius: 50%; + background: #fff; + color: #0277bd; font-size: 12px; } :global(.park-fac-clock) { @@ -531,6 +593,12 @@ font-weight: 700; line-height: 1; } + /* アイコンのクリックで公園を選択できるようにする(地図のドラッグは邪魔しない)。 */ + :global(.park-fac-markers .park-fac), + :global(.park-play-markers .park-play) { + pointer-events: auto; + cursor: pointer; + } :global(.park-play-plane) { color: #c62828; } @@ -558,6 +626,12 @@ :global(.park-play-ball) { color: #1b5e20; } + :global(.park-play-special) { + color: #f9a825; + } + :global(.park-play-pond) { + color: #0277bd; + } :global(.park-play-rope) { color: #00838f; } @@ -971,24 +1045,86 @@ value.replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch] as string, ); + // 水飲み場アイコン(線画の水滴。ほかの遊具アイコンと同じ線画スタイル)。 + const WATER_DROP_ICON = + ''; const buildPopup = (ourName: string, osmName: string, owner = "", mapsQuery = "") => { - const rec = parkDataByName.get(ourName); + // 市外の近隣公園は kodaira-parks.json にないので、近隣データの属性を使う。 + const rec = parkDataByName.get(ourName) || nearbyInfoByName.get(ourName); const name = escapePopup(ourName || osmName || ""); const addr = escapePopup(rec?.["住所"] || ""); - const areaRaw = Number(rec?.["面積㎡"]); + // 近隣データは面積㎡が空で area_m2 に入っていることがある。 + const areaRaw = Number( + String(rec?.["面積㎡"] || "").trim() || rec?.["area_m2"] || 0, + ); const area = Number.isFinite(areaRaw) && areaRaw > 0 ? `${areaRaw.toLocaleString("ja-JP")}㎡` : ""; const type = escapePopup(rec?.["種別"] || ""); - const facs = [ - rec?.["トイレ"] ? "トイレ" : "", - rec?.["水飲み場"] ? "水飲み" : "", - rec?.["遊具"] ? "遊具" : "", - rec?.["シェルター等"] ? "東屋等" : "", - ] - .filter(Boolean) - .join("・"); + // 詳細行の項目名はアイコンで示し、文字量を抑える。 + const DISASTER_KEEP = /かまど|カマド|防災収納|レスキュー|防災器具|避難/; + const disaster = String(rec?.["防災用具"] || "") + .split(/[、,]/) + .map((s) => s.trim()) + .filter((s) => s && !s.includes("防火水槽") && !s.includes("消火栓")) + .filter((s) => DISASTER_KEEP.test(s)) + .join("、"); + const waterRaw = String(rec?.["水飲み場"] || "").trim(); + // 水飲み場は数量ではなく有無だけを示す。 + const water = waterRaw ? (/[??]/.test(waterRaw) ? "あり?" : "あり") : "なし"; + // ボール遊びは、試合形式の可否を括弧書きで添える。 + const ballText = String(rec?.["ボール遊び"] || "").trim(); + const matchText = String(rec?.["試合形式のボール遊び"] || "").trim(); + const ball = + ballText && matchText ? `${ballText}(試合形式${matchText})` : ballText; + // 項目名は1文字の文字アイコンで示す(水・ベンチ・池は絵アイコン)。 + const rowIcons: Record = { + 遊具: "遊", + トイレ: "便", + 水飲み場: WATER_DROP_ICON, + "ベンチ・椅子": PARK_ICONS.bench.svg, + テーブル: "台", + "シェルター等": "屋", + 草地広場: "草", + 多目的広場: "広", + 築山: "山", + じゃぶじゃぶ池: PARK_ICONS.pond.svg, + ボール遊び: "球", + ペット: "犬", + 時計: "時", + 防災用具: "災", + }; + const rows = [ + ["遊具", rec?.["遊具"]], + ["トイレ", rec?.["トイレ"]], + ["水飲み場", water], + ["ベンチ・椅子", rec?.["ベンチ・椅子"]], + ["テーブル", rec?.["テーブル"]], + ["シェルター等", rec?.["シェルター等"]], + ["草地広場", rec?.["草地広場"]], + ["多目的広場", rec?.["多目的広場"]], + ["築山", rec?.["築山"]], + ["じゃぶじゃぶ池", rec?.["じゃぶじゃぶ池"]], + ["ボール遊び", ball], + ["ペット", rec?.["ペット"]], + ["時計", rec?.["時計"]], + ["防災用具", disaster], + ].filter(([, value]) => value && String(value).trim()); + const detailHtml = rows + .map(([label, value]) => { + const labelText = String(label); + const icoClass = + labelText === "水飲み場" + ? "park-popup-ico park-popup-ico-water" + : labelText === "ベンチ・椅子" + ? "park-popup-ico park-popup-ico-bench" + : labelText === "じゃぶじゃぶ池" + ? "park-popup-ico park-popup-ico-pond" + : "park-popup-ico"; + return `
${rowIcons[labelText] || ""}${escapePopup(String(value))}
`; + }) + .join(""); const query = mapsQuery || (ourName ? `小平市${ourName}` : ""); const dest = rec?.["緯度"] && rec?.["経度"] @@ -998,7 +1134,7 @@ rec?.["地図URL"] || `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; const route = `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(dest)}&travelmode=walking`; - return `
${name}${owner ? `${escapePopup(owner)}` : ""}${addr || area || type ? `
${[addr, area, type].filter(Boolean).join(" ・ ")}
` : ""}${facs ? `
${facs}
` : ""}
`; + return `
${name}${owner ? `${escapePopup(owner)}` : ""}${addr || area || type ? `
${[addr, area, type].filter(Boolean).join(" ・ ")}
` : ""}${detailHtml}
`; }; const filterName = (name: string, scroll = false) => { @@ -1194,7 +1330,7 @@ lyr._isPoint = feature.geometry?.type === "Point"; if (name) nearbyEntries.push({ name, layer: lyr, city, key }); if (name) { - lyr.bindPopup(buildPopup(name, name, "", nearbyMapsQuery(name, city)), { + lyr.bindPopup(buildPopup(name, name, city ? `市外・${city}` : "", nearbyMapsQuery(name, city)), { maxWidth: 300, }); lyr.on("popupopen", () => { @@ -1219,6 +1355,43 @@ }, }).addTo(map); + // 遊具・設備アイコンをクリックしたときも、その公園を選択する。 + const openParkPopup = (name: string) => { + let layer: any = layersByName.get(name); + if (layer && typeof layer.getLayers === "function") { + layer = layer.getLayers()[0] || layer; + } + if (!layer) { + const entry = nearbyEntries.find((e) => e.name === name); + layer = entry?.layer || null; + } + if (!layer) return; + if (typeof layer.openPopup === "function") { + const latlng = layer.getBounds + ? layer.getBounds().getCenter() + : layer.getLatLng?.(); + layer.openPopup(latlng); + } + document.dispatchEvent( + new CustomEvent("park:select-row", { detail: { name } }), + ); + }; + // アイコンのマーカーに公園名を持たせ、クリックでその公園を選択できるようにする。 + // 地図側のクリック処理がフキダシを閉じてしまうので、伝播は止める。 + const markIconOwner = (marker: any, name: string) => { + const bind = () => { + const el = marker.getElement?.(); + if (!el || el.getAttribute("data-park") === name) return; + el.setAttribute("data-park", name); + L.DomEvent.on(el, "click", (e: any) => { + L.DomEvent.stopPropagation(e); + openParkPopup(name); + }); + }; + if (marker.getElement?.()) bind(); + else marker.on("add", bind); + }; + // 地域センター・公民館・図書館・市役所支所・文化施設。 // 市外の近隣施設はオレンジで表示する。 const publicFacilityLayer = L.layerGroup(); @@ -1307,7 +1480,7 @@ } if ((p["水飲み場"] || "").trim()) { icons.push( - '🚰', + '', ); } const clock = (p["時計"] || "").trim(); @@ -1344,6 +1517,7 @@ riseOnHover: false, interactive: false, }).addTo(facilityLayer); + markIconOwner(marker, name); const list = facilityMarkersByName.get(name) || []; list.push(marker); facilityMarkersByName.set(name, list); @@ -1351,17 +1525,73 @@ // 拡大時だけ表示する遊具アイコン。 // 公園ポリゴンの中心に、遊具の種類を絵アイコンで表示する。 + // スイング遊具・健康遊具の判定用(一覧表の facTokens と同じ名前で判定する) + const PLAY_SPRING_TOKENS = [ + "スプリング", + "スイング", + "リトルポップ", + "リンクミニ", + "ルッキンロッキン", + "リーフィー", + "チューリー", + "ロッキンパッピー", + "ロッキンパピー", + "ビー", + "ハッピー", + "ハニー", + "クップル", + "ウゴウゴ", + ]; + const PLAY_HEALTH_TOKENS = [ + "フィットコンビ", + "レグオーバー", + "アームウォーカー", + "ホップオーバー", + "ジャンプ・ステップ", + "立位体前屈", + "懸垂", + "垂直飛び", + "閉眼片足立ち", + "腕歩行", + "リラックスボード", + "パネルラダー", + "棒とびこし", + "脚のばし", + "ふみいたストレッチ", + ]; + const PLAY_STANDARD_TOKENS = [ + "ブランコ", + "ぶらんこ", + "滑台", + "滑り台", + "すべり台", + "砂場", + "鉄棒", + "複合遊具", + "ジャングル", + "ヒコーキ", + "飛行機", + "シーソー", + // 次の遊具は「特殊遊具」扱いにしない(別の分類・設備のため) + "ロープスイング", + "ロープウェイ", + "バスケ", + ...PLAY_SPRING_TOKENS, + ...PLAY_HEALTH_TOKENS, + ]; 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: PLAY_SPRING_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 }, + { tokens: [], key: "pond", title: PARK_ICONS.pond.title, cls: "park-play-pond", icon: PARK_ICONS.pond.svg }, + { tokens: [], key: "special", title: PARK_ICONS.special.title, cls: "park-play-special", icon: PARK_ICONS.special.svg }, ]; const playLegend = document.querySelector("[data-play-legend]"); if (playLegend) { @@ -1377,14 +1607,35 @@ const text = (p["遊具"] || "").trim(); const ballText = (p["ボール遊び"] || "").trim(); const hasBall = Boolean(ballText && ballText !== "禁止"); - if (!text && !hasBall) return; + const hasPond = Boolean((p["じゃぶじゃぶ池"] || "").trim()); + if (!text && !hasBall && !hasPond) return; + const segments = text + .split(/[、,]/) + .map((s) => s.trim()) + .filter(Boolean); + // ロープスイングはスイング遊具ではないので spring には含めない。 + const isSpring = segments.some( + (seg) => + !seg.includes("ロープスイング") && + PLAY_SPRING_TOKENS.some((token) => seg.includes(token)), + ); + // 標準的な遊具名を含まない区画は「特殊遊具」とみなす(一覧表と同じ判定)。 + const isSpecial = segments.some( + (seg) => !PLAY_STANDARD_TOKENS.some((token) => seg.includes(token)), + ); const seen = new Set(); const icons: string[] = []; for (const def of PLAY_DEFS) { const matched = def.key === "ball" ? hasBall - : def.tokens.some((token) => text.includes(token)); + : def.key === "pond" + ? hasPond + : def.key === "spring" + ? isSpring + : def.key === "special" + ? isSpecial + : def.tokens.some((token) => text.includes(token)); if (!matched) continue; if (seen.has(def.key)) continue; // ヒコーキジャングルはジャングルジムの一種なので、飛があればジは出さない。 @@ -1406,30 +1657,58 @@ riseOnHover: false, interactive: false, }).addTo(playLayer); + markIconOwner(marker, name); const list = playMarkersByName.get(name) || []; list.push(marker); playMarkersByName.set(name, list); }); - // 公園名のラベル(高ズーム時のみ) + // 公園名のラベル(高ズーム時のみ)。 + // 起動時に全件のマーカーを作ると重いので、画面内の公園だけを都度生成する。 const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch] as string, ); - const parkNameLayer = L.layerGroup(); - layersByName.forEach((lyr, name) => { + const parkCenters = Array.from(layersByName, ([name, lyr]) => { const center = centerOfLayer(lyr); - L.marker([center.lat, center.lng], { - icon: L.divIcon({ - className: "park-name-marker", - html: `${escapeHtml(name)}`, - iconSize: null, - iconAnchor: [0, 0], - }), - interactive: false, - keyboard: false, - }).addTo(parkNameLayer); + return { name, lat: center.lat, lng: center.lng }; }); + const parkNameLayer = L.layerGroup(); + const nameMarkers = new Map(); + const updateParkNames = () => { + if (map.getZoom() < 17) { + if (nameMarkers.size) { + nameMarkers.forEach((marker) => parkNameLayer.removeLayer(marker)); + nameMarkers.clear(); + } + if (map.hasLayer(parkNameLayer)) map.removeLayer(parkNameLayer); + return; + } + if (!map.hasLayer(parkNameLayer)) parkNameLayer.addTo(map); + const bounds = map.getBounds().pad(0.1); + const visible = new Set(); + for (const p of parkCenters) { + if (!bounds.contains([p.lat, p.lng])) continue; + visible.add(p.name); + if (nameMarkers.has(p.name)) continue; + const marker = L.marker([p.lat, p.lng], { + icon: L.divIcon({ + className: "park-name-marker", + html: `${escapeHtml(p.name)}`, + iconSize: null, + iconAnchor: [0, 0], + }), + interactive: false, + keyboard: false, + }).addTo(parkNameLayer); + nameMarkers.set(p.name, marker); + } + nameMarkers.forEach((marker, name) => { + if (visible.has(name)) return; + parkNameLayer.removeLayer(marker); + nameMarkers.delete(name); + }); + }; // フィルタ中は、該当しない公園の設備・遊具アイコンを隠す const applyIconFilter = (names: Set | null) => { @@ -1459,11 +1738,6 @@ } else if (map.hasLayer(playLayer)) { map.removeLayer(playLayer); } - if (map.getZoom() >= 17) { - if (!map.hasLayer(parkNameLayer)) parkNameLayer.addTo(map); - } else if (map.hasLayer(parkNameLayer)) { - map.removeLayer(parkNameLayer); - } // 公共施設の名前はもう一段ズームしたときだけ表示する(切替時のみ実行) const showFacilityLabels = map.getZoom() >= 15; if (showFacilityLabels !== facilityLabelsShown) { @@ -1476,6 +1750,8 @@ }; map.on("zoomend", updateFacilities); updateFacilities(); + map.on("moveend", updateParkNames); + updateParkNames(); // フィルタがないときは、すべての公園を点滅させる allLayers.forEach((lyr) => { diff --git a/src/components/ParkMapHelp.astro b/src/components/ParkMapHelp.astro index e45b87f..4402567 100644 --- a/src/components/ParkMapHelp.astro +++ b/src/components/ParkMapHelp.astro @@ -14,7 +14,7 @@ 市外の公共施設 小平市の境界 WCトイレ - 🚰水飲み場(拡大時) + 水飲み場(拡大時) 時計(拡大時) 消消火栓(拡大時) 槽防火水槽(拡大時) @@ -96,8 +96,11 @@ border-radius: 4px; } .swatch-fac-water { - background: #0277bd; + box-sizing: border-box; + border: 1px solid #0277bd; border-radius: 50%; + background: #fff; + color: #0277bd; } .swatch-fac-clock { background: #f9a825; diff --git a/src/components/ParksFilters.astro b/src/components/ParksFilters.astro index 4890741..5675eb9 100644 --- a/src/components/ParksFilters.astro +++ b/src/components/ParksFilters.astro @@ -59,7 +59,7 @@ const facilityTypes = [