From a81bb98c971bd9470bbba0c415b6c111f5146ad3 Mon Sep 17 00:00:00 2001
From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>
Date: Sun, 8 Nov 2026 00:48:32 +0900
Subject: 公園マップ: フィルタを分類・拡充し、地図の表示不具合を修正
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ParksFilters.astro: フィルタを「遊具/広場・休憩/設備/利用ルール」に
分類し、パーゴラ・ボール遊び禁止の表示なし・犬禁止の表示なしを追加。
冒頭にβ版の注意書きを追加
ParkMap.astro: 全画面時のズーム調整と、小数ズーム時のタイル読込の
不具合を修正
ParksTable.astro: 絞り込みトークンを追加し、確認済みの表示を反映
park-editor: 確認チェックの自動同期と文字化けを修正
kodaira-parks.json: 水飲み場表記の統一と確認済みを反映
park-icons.ts: パーゴラのアイコンを追加
parks.mdx: 説明文を更新
---
src/components/ParkMap.astro | 152 ++++++++++++++++++++++++++++++++-----------
1 file changed, 115 insertions(+), 37 deletions(-)
(limited to 'src/components/ParkMap.astro')
diff --git a/src/components/ParkMap.astro b/src/components/ParkMap.astro
index 3a208db..b9aa641 100644
--- a/src/components/ParkMap.astro
+++ b/src/components/ParkMap.astro
@@ -24,6 +24,9 @@
width: 100%;
border: 1px solid var(--sl-color-gray-5);
border-radius: 8px;
+ /* タイルが未読込のとき、ダークテーマの背景色(青みのある濃色)が
+ 地図の領域に見えないよう、地図らしい下地色を敷いておく。 */
+ background: #e8e6e1;
}
.park-map:fullscreen,
.park-map:-webkit-full-screen {
@@ -97,6 +100,9 @@
font-size: 0.7rem;
font-weight: 700;
}
+ :global(.park-map-filter-status[hidden]) {
+ display: none;
+ }
:global(.park-map-filter-footer) {
flex: none;
display: flex;
@@ -167,6 +173,11 @@
border-color: #e0e0e0;
background: #fff;
color: #6d4c41;
+ padding: 0;
+ }
+ :global(.park-popup-ico-pet svg) {
+ width: 100%;
+ height: 100%;
}
:global(.park-popup-ico-clock) {
border-color: #ffe082;
@@ -179,12 +190,6 @@
color: #fff;
font-size: 8px;
}
- /* ボール遊びは可で試合形式だけ禁止のときに「試」バッジを並べる */
- :global(.park-popup-ico-ball) {
- width: auto;
- gap: 2px;
- padding: 0 2px;
- }
:global(.park-popup-mini) {
position: relative;
display: inline-flex;
@@ -264,6 +269,17 @@
color: #0b57d0;
font-weight: 700;
}
+ :global(.google-ico) {
+ display: inline-flex;
+ flex: none;
+ width: 15px;
+ height: 15px;
+ }
+ :global(.google-ico svg) {
+ display: block;
+ width: 100%;
+ height: 100%;
+ }
:global(.park-name-marker) {
background: none;
@@ -851,7 +867,7 @@
dragging: !isTouch,
touchZoom: true,
scrollWheelZoom: true,
- zoomSnap: 1,
+ zoomSnap: 0.1,
zoomDelta: 1,
zoomAnimation: false,
fadeAnimation: false,
@@ -923,7 +939,7 @@
// 帰属表示の「Leaflet」旗アイコンを消す(OSMの表示は残す)。
map.attributionControl.setPrefix(false);
- const tileLayer = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
+ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution:
'© OpenStreetMap contributors',
@@ -955,7 +971,7 @@
if (filterCaret) filterCaret.textContent = "▾";
});
filterFooter.appendChild(filterClose);
- filterPanel.append(filterToggle, filterBody, filterFooter);
+ filterPanel.append(filterToggle, filterBody);
L.DomEvent.disableClickPropagation(filterPanel);
L.DomEvent.disableScrollPropagation(filterPanel);
filterToggle.addEventListener("click", () => {
@@ -974,6 +990,7 @@
next: controls.nextSibling,
};
filterBody.append(controls);
+ filterBody.append(filterFooter);
map.getContainer().appendChild(filterPanel);
filterPanel.hidden = false;
filterBody.hidden = false;
@@ -999,6 +1016,30 @@
const target = map.getContainer();
const isCssMax = () => target.classList.contains("is-maximized");
+ // 全画面の切替前のサイズ・ズーム。Leaflet はコンテナが大きくなっても
+ // ズームを自動では変えないため、切替後も同じ範囲が画面に収まるよう、
+ // 画面の大きさの比に応じてズームを合わせ直す。
+ let preSize: { x: number; y: number } | null = null;
+ let preZoom = 0;
+ let lastSize: { x: number; y: number } | null = null;
+ const captureNow = () => {
+ preSize = map.getSize();
+ preZoom = map.getZoom();
+ };
+ const scaleZoomToFit = () => {
+ if (!preSize) return;
+ const now = map.getSize();
+ // 面積比の平方根(縦横の拡大率の相乗平均)で拡大・縮小する。
+ // 入るときと出るときで同じ倍率になり、行って戻ると元のズームに戻る。
+ const ratio = Math.sqrt((now.x * now.y) / (preSize.x * preSize.y));
+ if (!Number.isFinite(ratio) || ratio <= 0 || Math.abs(ratio - 1) < 0.02) return;
+ const z = preZoom + Math.log2(ratio);
+ map.setView(
+ map.getCenter(),
+ Math.max(map.getMinZoom(), Math.min(map.getMaxZoom(), z)),
+ { animate: false },
+ );
+ };
const update = () => {
const active = document.fullscreenElement === target || isCssMax();
button.innerHTML = active ? "✕" : "⛶";
@@ -1010,8 +1051,24 @@
if (active) map.dragging.enable();
else map.dragging.disable();
}
- window.setTimeout(() => map.invalidateSize(), 60);
- window.setTimeout(() => map.invalidateSize(), 350);
+ // Esc などボタン操作以外で切り替わったときのために、直前の状態を控える。
+ if (!preSize) {
+ preSize = lastSize || map.getSize();
+ preZoom = map.getZoom();
+ }
+ // 端末によってサイズが変わるタイミングが違うので、2回に分けて合わせる。
+ window.setTimeout(() => {
+ map.invalidateSize();
+ scaleZoomToFit();
+ }, 60);
+ window.setTimeout(() => {
+ map.invalidateSize();
+ scaleZoomToFit();
+ }, 350);
+ window.setTimeout(() => {
+ lastSize = map.getSize();
+ preSize = null;
+ }, 600);
};
let maxHome: { parent: Node; next: Node | null } | null = null;
const enterCssMax = () => {
@@ -1035,6 +1092,8 @@
L.DomEvent.on(button, "click", (ev: Event) => {
L.DomEvent.stop(ev);
+ // 切替後のズーム合わせ用に、切替前のサイズ・ズームを控えておく。
+ captureNow();
if (document.fullscreenElement === target) {
document.exitFullscreen?.().catch(() => exitCssMax());
return;
@@ -1127,24 +1186,35 @@
.filter((s) => s && !s.includes("防火水槽") && !s.includes("消火栓"))
.filter((s) => DISASTER_KEEP.test(s))
.join("、");
- const waterRaw = String(rec?.["水飲み場"] || "").trim();
- // 水飲み場は数量ではなく有無だけを、分かるように表示する。
- const water = waterRaw
- ? /[??]/.test(waterRaw)
- ? "水飲み場あり?"
- : "水飲み場あり"
- : "水飲み場なし";
+ // 水飲み場は「水飲み場x1」のように、手洗いも含めて数を示す。
+ const waterText = (value: string) => {
+ const text = String(value || "").trim();
+ if (!text) return "水飲み場なし";
+ if (/[??]/.test(text)) return "水飲み場?";
+ let total = 0;
+ for (const part of text.split(/[、,]/)) {
+ const nums = [...part.matchAll(/[x×](\d+)/g)].map((m) => Number(m[1]));
+ if (nums.length) total += nums.reduce((a, b) => a * b, 1);
+ }
+ return total > 0 ? `水飲み場x${total}` : "水飲み場";
+ };
+ const water = waterText(String(rec?.["水飲み場"] || ""));
// ○/〇 などの表記は「〜あり」にして意味が分かるようにする。
const presence = (label: string, value: unknown) => {
const v = String(value ?? "").trim();
if (!v) return "";
return /^[○〇◯あり有1]+$/.test(v) ? `${label}あり` : v;
};
- // ボール遊びは、試合形式の可否を括弧書きで添える。
+ // ボール遊び禁止なら試合形式の記載は省く。試合形式だけ禁止ならそれだけを示す。
const ballText = String(rec?.["ボール遊び"] || "").trim();
const matchText = String(rec?.["試合形式のボール遊び"] || "").trim();
- const ball =
- ballText && matchText ? `${ballText}(試合形式${matchText})` : ballText;
+ const ballBanned = /禁止/.test(ballText);
+ const matchBanned = /禁止/.test(matchText);
+ const ball = ballBanned
+ ? "ボール遊び禁止"
+ : matchBanned
+ ? "試合形式禁止"
+ : ballText;
// 項目名は1文字の文字アイコンで示す(水・ベンチ・池は絵アイコン)。
const rowIcons: Record = {
遊具: "遊",
@@ -1191,12 +1261,9 @@
const labelText = String(label);
const valueText = String(value);
const isBall = labelText === "ボール遊び";
- // ボール遊び全体が禁止ならボールに×、試合形式だけ禁止なら「試」に×を付ける。
+ // ボール遊び全体が禁止のときだけボールに×を付ける。
const banned = isBall ? /禁止/.test(ballText) : /禁止/.test(valueText);
- let iconHtml = rowIcons[labelText] || "";
- if (isBall && !banned && /禁止/.test(matchText)) {
- iconHtml += '';
- }
+ const iconHtml = rowIcons[labelText] || "";
const icoClass = `park-popup-ico${ROW_MOD[labelText] || ""}${banned ? " is-banned" : ""}${isBall ? " park-popup-ico-ball" : ""}`;
return ``;
})
@@ -1210,7 +1277,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 ``;
+ return ``;
};
const filterName = (name: string, scroll = false) => {
@@ -1908,14 +1975,14 @@
const nearbyHit = nearbyEntries.find((entry) => entry.key === key);
if (nearbyHit) {
const group = L.featureGroup([nearbyHit.layer]);
- map.fitBounds(group.getBounds(), { padding: [40, 40], maxZoom: 17 });
+ map.fitBounds(group.getBounds(), { padding: [40, 40], maxZoom: map.getMaxZoom() });
nearbyHit.layer.openPopup?.();
return true;
}
}
const target = layersByName.get(name);
if (target) {
- map.fitBounds(target.getBounds(), { padding: [40, 40], maxZoom: 17 });
+ map.fitBounds(target.getBounds(), { padding: [40, 40], maxZoom: map.getMaxZoom() });
const targetLayer =
typeof target.getLayers === "function" ? target.getLayers()[0] : target;
targetLayer?.openPopup?.();
@@ -1926,7 +1993,7 @@
.map((entry) => entry.layer);
if (nearbyTargets.length) {
const group = L.featureGroup(nearbyTargets);
- map.fitBounds(group.getBounds(), { padding: [40, 40], maxZoom: 17 });
+ map.fitBounds(group.getBounds(), { padding: [40, 40], maxZoom: map.getMaxZoom() });
nearbyTargets[0].openPopup?.();
return true;
}
@@ -1950,30 +2017,39 @@
// フィルタの対象(複数ならその範囲)に地図をフォーカスする。
let filterFocusTimer = 0;
- const focusOnParks = (names: Set | null) => {
+ const focusOnParks = (names: Set | null, nearbyNames: Set | null) => {
const bounds = L.latLngBounds([]);
const add = (lyr: any) => {
const b = lyr?.getBounds?.();
if (b && b.isValid()) bounds.extend(b);
};
+ const addPoint = (ll: any) => {
+ if (ll) bounds.extend(ll);
+ };
if (names) {
allLayers.forEach((lyr) => {
if (names.has(lyr._parkName || "")) add(lyr);
});
nearbyEntries.forEach(({ name, layer }) => {
- if (names.has(name)) add(layer);
+ if (nearbyNames && nearbyNames.has(name)) add(layer);
+ });
+ facilityMarkers.forEach(({ name, layer }) => {
+ if (names.has(name)) addPoint(layer.getLatLng?.());
});
} else {
allLayers.forEach(add);
+ facilityMarkers.forEach(({ layer }) => addPoint(layer.getLatLng?.()));
}
if (!bounds.isValid()) return;
- map.fitBounds(bounds, { padding: [40, 40], maxZoom: 16, animate: true });
+ map.fitBounds(bounds, { padding: [40, 40], maxZoom: map.getMaxZoom(), animate: true });
};
// 選択中の公園を強調するためのクラス(グロー表示)
document.addEventListener("park:filter", (e: Event) => {
const detail = (e as CustomEvent).detail || {};
const names = new Set(detail.names || []);
+ // 市外の近い公園は小平市内と同名でも別の公園なので、別セットで扱う。
+ const nearbyNames = new Set(detail.nearbyNames ?? detail.names ?? []);
const active = Boolean(detail.active);
if (filterStatus) filterStatus.hidden = !active;
const fromMap = Boolean(detail.fromMap);
@@ -1997,7 +2073,7 @@
}
});
nearbyEntries.forEach(({ name, layer: lyr }) => {
- const selected = Boolean(name && names.has(name));
+ const selected = Boolean(name && nearbyNames.has(name));
lyr.setStyle(selected ? NEARBY_SELECTED_STYLE : nearbyBaseStyle(lyr));
markSelected(lyr, selected);
if (selected) lyr.bringToFront?.();
@@ -2037,7 +2113,7 @@
markBlink(lyr, true);
return;
}
- if (name && names.has(name)) {
+ if (name && nearbyNames.has(name)) {
lyr.setStyle(NEARBY_SELECTED_STYLE);
markSelected(lyr, true);
lyr.bringToFront?.();
@@ -2056,7 +2132,7 @@
// フィルタの対象にフォーカスする(キーワード入力は少し待ってから)。
if (detail.focus) {
- const run = () => focusOnParks(active ? names : null);
+ const run = () => focusOnParks(active ? names : null, active ? nearbyNames : null);
window.clearTimeout(filterFocusTimer);
if (detail.focus === "later") {
filterFocusTimer = window.setTimeout(run, 800);
@@ -2223,7 +2299,9 @@
// レイアウト確定後にサイズを再計算し、タイルを再配置する。
const refresh = () => {
map.invalidateSize();
- tileLayer.redraw();
+ // redraw() は使わない。Leaflet 1.9 の redraw() は小数ズーム時に
+ // _tileZoom を小数(例: 12.9)にしてしまい、タイルが読めなくなる。
+ // invalidateSize と fitAll だけでタイルは更新される。
fitAll();
};
requestAnimationFrame(refresh);
--
cgit v1.3.1