diff --git a/package-lock.json b/package-lock.json index 31bc367..f475505 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,10 @@ "license": "MIT", "dependencies": { "leaflet": "^1.9.4", + "leaflet-smooth-zoom": "github:mutsuyuki/Leaflet.SmoothWheelZoom", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-leaflet": "^5.0.0", "react-router-dom": "^7.14.0" }, "devDependencies": { @@ -141,6 +143,17 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", @@ -969,6 +982,11 @@ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "license": "BSD-2-Clause" }, + "node_modules/leaflet-smooth-zoom": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/mutsuyuki/Leaflet.SmoothWheelZoom.git#a68e52f46315f93ed560b8314cb7235df947ea32", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1359,6 +1377,20 @@ "react": "^19.2.4" } }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/react-router": { "version": "7.14.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", diff --git a/package.json b/package.json index 2d2d20d..f74bbec 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,10 @@ }, "dependencies": { "leaflet": "^1.9.4", + "leaflet-smooth-zoom": "github:mutsuyuki/Leaflet.SmoothWheelZoom", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-leaflet": "^5.0.0", "react-router-dom": "^7.14.0" } } diff --git a/src/geolocation.ts b/src/geolocation.ts index da30a0b..b08727d 100644 --- a/src/geolocation.ts +++ b/src/geolocation.ts @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import L from "leaflet"; // Numeric tolerance for detecting singular matrices / collinear points @@ -164,7 +165,7 @@ function solveNormalEquations(A: number[][], b: number[]): number[] | null { // Supports 4+ reference points for better accuracy function createPolynomialTransformer( points: ReferencePoint[], -): ((lat: number, lng: number) => L.LatLngExpression | null) | null { +): ((lat: number, lng: number) => L.LatLngTuple | null) | null { if (points.length < 4) { console.warn( "Less than 4 reference points. Falling back to affine transformation.", @@ -211,7 +212,7 @@ function createPolynomialTransformer( return null; } - return (lat: number, lng: number): L.LatLngExpression | null => { + return (lat: number, lng: number): L.LatLngTuple | null => { const xCoeff0 = xCoeffs[0]; const xCoeff1 = xCoeffs[1]; const xCoeff2 = xCoeffs[2]; @@ -264,7 +265,7 @@ function createPolynomialTransformer( // Function to calculate affine transformation coefficients from 3 points function createAffineTransformer( points: ReferencePoint[], -): ((lat: number, lng: number) => L.LatLngExpression | null) | null { +): ((lat: number, lng: number) => L.LatLngTuple | null) | null { if (points.length < 3) { console.error("At least 3 reference points are required"); return null; @@ -321,7 +322,7 @@ function createAffineTransformer( p3.y * (p1.lat * p2.lng - p2.lat * p1.lng)) / det; - return (lat: number, lng: number): L.LatLngExpression => { + return (lat: number, lng: number): L.LatLngTuple => { const x = A * lat + B * lng + C; const y = D * lat + E * lng + F; return [y, x]; @@ -336,175 +337,104 @@ export interface GeolocationOptions { debugPosition?: [number, number]; } -export function setupGeolocation( - map: L.Map, +export function useGeolocation( imgWidth: number, imgHeight: number, - options?: GeolocationOptions, + { debugPosition }: GeolocationOptions, ) { - let userMarker: L.Marker | null = null; - let userCircle: L.Circle | null = null; - let watchId: number | null = null; - let hasAlerted = false; - - // デバッグ用固定位置がある場合はそれを使用 - if (options?.debugPosition) { - const [imgY, imgX] = options.debugPosition; - placeOrUpdateMarker(map, [imgY, imgX], 0, () => { - userMarker = null; - userCircle = null; - }); - return { - cleanup: () => {}, - }; - } + const [position, setPosition] = useState<[number, number] | null>(null); + const [accuracy, setAccuracy] = useState(0); - // Get location information - watchId = navigator.geolocation.watchPosition( - (position: GeolocationPosition) => { - const userLat: number = position.coords.latitude; - const userLng: number = position.coords.longitude; - const accuracy = position.coords.accuracy; - - // Convert latitude/longitude to image coordinates - if (!convertLatLngToImageXY) { - console.error("Coordinate transformer is not available"); - if (!hasAlerted) { - alert("座標変換システムの初期化に失敗しました。"); - hasAlerted = true; - } - return; - } + useEffect(() => { + let hasAlerted = false; - const imageXY = convertLatLngToImageXY(userLat, userLng); + // デバッグ用固定位置がある場合はそれを使用 + if (debugPosition) { + setPosition(debugPosition); + setAccuracy(0); + return; + } - if (!imageXY || !Array.isArray(imageXY) || imageXY.length < 2) { - console.error("Failed to convert coordinates"); - if (!hasAlerted) { - alert("座標変換に失敗しました。"); - hasAlerted = true; + if (!navigator.geolocation) { + console.error("Geolocation is not supported"); + return; + } + + const watchId = navigator.geolocation.watchPosition( + (position: GeolocationPosition) => { + const userLat: number = position.coords.latitude; + const userLng: number = position.coords.longitude; + const accuracy = position.coords.accuracy; + + // Convert latitude/longitude to image coordinates + if (!convertLatLngToImageXY) { + console.error("Coordinate transformer is not available"); + if (!hasAlerted) { + alert("座標変換システムの初期化に失敗しました。"); + hasAlerted = true; + } + return; } - return; - } - const imgY = imageXY[0]; - const imgX = imageXY[1]; + const imageXY = convertLatLngToImageXY(userLat, userLng); + if (!imageXY) { + console.error("Failed to convert coordinates"); + if (!hasAlerted) { + alert("座標変換に失敗しました。"); + hasAlerted = true; + } + return; + } - if (typeof imgY !== "number" || typeof imgX !== "number") { - console.error("Invalid coordinate values"); - return; - } + const imgY = imageXY[0]; + const imgX = imageXY[1]; - // Check if coordinates are outside the map bounds - if (imgX < 0 || imgX > imgWidth || imgY < 0 || imgY > imgHeight) { - console.warn("User location is outside the map bounds", { - lat: userLat, - lng: userLng, - imageXY: { x: imgX, y: imgY }, - }); - if (!hasAlerted) { - alert("現在地がマップの範囲外です。"); - hasAlerted = true; + if (typeof imgY !== "number" || typeof imgX !== "number") { + console.error("Invalid coordinate values"); + return; } - return; - } - placeOrUpdateMarker(map, [imgY, imgX], accuracy, () => { - userMarker = null; - userCircle = null; - }); + // Check if coordinates are outside the map bounds + if (imgX < 0 || imgX > imgWidth || imgY < 0 || imgY > imgHeight) { + console.warn("User location is outside the map bounds", { + lat: userLat, + lng: userLng, + imageXY: { x: imgX, y: imgY }, + }); + if (!hasAlerted) { + alert("現在地がマップの範囲外です。"); + hasAlerted = true; + } + return; + } - // Show error warning only when inside map bounds and error is large - if (accuracy > ERROR_THRESHOLD_METERS && !hasAlerted) { - alert( - `現在地は正確ではない可能性があります(誤差:約${Math.round(accuracy)}m)`, + // Show error warning only when inside map bounds and error is large + if (accuracy > ERROR_THRESHOLD_METERS && !hasAlerted) { + alert( + `現在地は正確ではない可能性があります(誤差:約${Math.round(accuracy)}m)`, + ); + hasAlerted = true; + } + // Log the obtained values + console.log( + "gps:", + { lat: userLat, lng: userLng, error_m: accuracy }, + "imageXY:", + { y: Number(imgY.toFixed(2)), x: Number(imgX.toFixed(2)) }, ); - hasAlerted = true; - } - - // Log the obtained values - console.log( - "gps:", - { lat: userLat, lng: userLng, error_m: accuracy }, - "imageXY:", - { y: Number(imgY.toFixed(2)), x: Number(imgX.toFixed(2)) }, - ); - }, - (error: GeolocationPositionError) => { - console.error("Failed to get location information", error); - // ユーザーが位置情報の共有を拒否した場合(PERMISSION_DENIED)はアラートを表示しない - if (error.code !== error.PERMISSION_DENIED && !hasAlerted) { - alert("位置情報の取得に失敗しました。"); - hasAlerted = true; - } - }, - { - enableHighAccuracy: true, - maximumAge: 5_000, - timeout: 10_000, - }, - ); - - return { - cleanup: () => { - if (watchId !== null) { - navigator.geolocation.clearWatch(watchId); - } - }, - }; -} - -function placeOrUpdateMarker( - map: L.Map, - latlng: L.LatLngExpression, - accuracy: number, - onClear: () => void, -) { - // Remove existing marker/circle - const existingMarker = (map as any)._userMarker as L.Marker | undefined; - const existingCircle = (map as any)._userCircle as L.Circle | undefined; - if (existingMarker) { - map.removeLayer(existingMarker); - } - if (existingCircle) { - map.removeLayer(existingCircle); - } + }, + (err) => { + console.error("Failed to get location information", err); + if (err.code !== err.PERMISSION_DENIED && !hasAlerted) { + alert("位置情報の取得に失敗しました。"); + hasAlerted = true; + } + }, + { enableHighAccuracy: true, maximumAge: 5000, timeout: 10000 }, + ); - // 精度円 (accuracy radius in meters, but since this is a custom CRS map, - // we use a visual radius in pixels) - const radius = Math.max(accuracy, 10); - const circle = L.circle(latlng, { - radius, - color: "#3b82f6", - fillColor: "#3b82f6", - fillOpacity: 0.2, - weight: 2, - }).addTo(map); - - // 中心点のマーカー(小さなドット) - const dotIcon = L.divIcon({ - className: "user-location-dot", - html: `
`, - iconSize: [16, 16], - iconAnchor: [8, 8], - }); + return () => navigator.geolocation.clearWatch(watchId); + }, [debugPosition, imgWidth, imgHeight]); - const marker = L.marker(latlng, { - icon: dotIcon, - zIndexOffset: 1000, - }) - .addTo(map) - .bindPopup("Current Location") - .openPopup(); - - // Store references on map for cleanup - (map as any)._userMarker = marker; - (map as any)._userCircle = circle; + return { position, accuracy }; } diff --git a/src/markers.ts b/src/markers.ts index 2190e9f..e41c9f2 100644 --- a/src/markers.ts +++ b/src/markers.ts @@ -1,259 +1,188 @@ import L from "leaflet"; -import * as im from "./assets"; -interface MarkerState { - markers: L.Marker[]; - visible: boolean; +export interface MarkerData { + position: [number, number]; + popup?: string; } -export function setupWaterServerMarkers(map: L.Map): MarkerState { - const hiddenWSMarkers: L.Marker[] = [ - L.marker([1560, 1560]).bindPopup("Komaba Library 1F"), - L.marker([1351, 2680]).bindPopup("Comipla 1F"), - L.marker([1351, 2650]).bindPopup("Comipla 2F"), - L.marker([1494, 2823]).bindPopup("Campus Plaza A Building 1F"), - L.marker([1529, 2688]).bindPopup("2nd Gymnasium 1F"), - L.marker([1567, 2689]).bindPopup("2nd Gymnasium 2F"), - L.marker([1668, 2991]).bindPopup("1st Gymnasium 2F"), - L.marker([1390, 2065]).bindPopup("Building 8 1F"), - L.marker([1645, 2200]).bindPopup("21 KOMCEE West B1F"), - L.marker([1026, 2812]).bindPopup("Building 5 1F"), - L.marker([1160, 1318]).bindPopup("Building 13 1F"), - L.marker([1500, 860]).bindPopup("Building 15 1F"), - L.marker([1340, 2788]).bindPopup("Co-op Purchasing Department"), - L.marker([505, 2826]).bindPopup( - "Mathematical Science Research Building 1F", - ), - ]; - - return { markers: hiddenWSMarkers, visible: false }; -} - -export function setupVendingMachineMarkers(map: L.Map): MarkerState { - const orangeIcon = L.icon({ - iconUrl: - "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-orange.png", - iconSize: [25, 41], - iconAnchor: [12.5, 41], - }); - - const hiddenVMMarkers: L.Marker[] = [ - L.marker([1097, 1504], { icon: orangeIcon }), - L.marker([1096, 1625], { icon: orangeIcon }), - L.marker([1762, 2443], { icon: orangeIcon }), - L.marker([1686, 2536], { icon: orangeIcon }), - ]; - - return { markers: hiddenVMMarkers, visible: false }; +export const waterServerMarkers: MarkerData[] = [ + { position: [1560, 1560], popup: "Komaba Library 1F" }, + { position: [1351, 2680], popup: "Comipla 1F" }, + { position: [1351, 2650], popup: "Comipla 2F" }, + { position: [1494, 2823], popup: "Campus Plaza A Building 1F" }, + { position: [1529, 2688], popup: "2nd Gymnasium 1F" }, + { position: [1567, 2689], popup: "2nd Gymnasium 2F" }, + { position: [1668, 2991], popup: "1st Gymnasium 2F" }, + { position: [1390, 2065], popup: "Building 8 1F" }, + { position: [1645, 2200], popup: "21 KOMCEE West B1F" }, + { position: [1026, 2812], popup: "Building 5 1F" }, + { position: [1160, 1318], popup: "Building 13 1F" }, + { position: [1500, 860], popup: "Building 15 1F" }, + { position: [1340, 2788], popup: "Co-op Purchasing Department" }, + { + position: [505, 2826], + popup: "Mathematical Science Research Building 1F", + }, +]; + +export const orangeIcon = L.icon({ + iconUrl: + "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-orange.png", + iconSize: [25, 41], + iconAnchor: [12.5, 41], +}); + +export const vendingMachineMarkers: MarkerData[] = [ + { position: [1097, 1504] }, + { position: [1096, 1625] }, + { position: [1762, 2443] }, + { position: [1686, 2536] }, +]; + +export interface PolygonData { + id: string; + name: string; + positions: [number, number][]; + showDetailButton: boolean; } -export function toggleMarkers( - map: L.Map, - markers: L.Marker[], - visible: boolean, -): boolean { - if (visible) { - markers.forEach((m) => map.removeLayer(m)); - } else { - markers.forEach((m) => m.addTo(map)); - } - return !visible; -} - -const buttonStyle = ` - display: inline-block; - margin-top: 8px; - padding: 8px 16px; - background-color: #3b82f6; - color: white; - border: none; - border-radius: 6px; - font-size: 14px; - font-weight: 600; - cursor: pointer; - text-decoration: none; - transition: background-color 0.2s; -`; - -const buttonHoverStyle = ` - background-color: #2563eb; -`; - -export function setupBuildingPolygons(map: L.Map) { - const createBuildingPopup = ( - buildingId: string, - name: string, - showButton: boolean = true, - ) => { - const buttonHtml = showButton - ? ` - 詳細 - ` - : ""; - const shareUrl = `${window.location.origin}/?building=${buildingId}`; - const shareButtonHtml = ` - - `; - return ` -
-

${name}

- ${shareButtonHtml} - ${buttonHtml} -
- `; - }; - - const library = L.polygon( - [ +export const buildingPolygons: PolygonData[] = [ + { + id: "library", + name: "図書館", + positions: [ [1024, 2687], [1019, 2904], [858, 2904], [854, 2692], ], - { - color: "transparent", - fillOpacity: 0, - }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("library", "図書館")); - - L.polygon( - [ + showDetailButton: true, + }, + { + id: "building1", + name: "1号館", + positions: [ [1193, 1727], [935, 1727], [935, 2082], [1193, 2082], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("building1", "1号館")); - - L.polygon( - [ + showDetailButton: true, + }, + { + id: "building101", + name: "101号館", + positions: [ [1181, 2177], [1181, 2423], [1115, 2423], [1115, 2177], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("building101", "101号館", false)); - - L.polygon( - [ + showDetailButton: false, + }, + { + id: "building2", + name: "2号館", + positions: [ [964, 1017], [879, 1017], [879, 1173], [964, 1173], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("building2", "2号館", false)); - - L.polygon( - [ + showDetailButton: false, + }, + { + id: "building5", + name: "5号館", + positions: [ [1612, 1352], [1506, 1353], [1505, 1625], [1613, 1624], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("building5", "5号館")); - - L.polygon( - [ + showDetailButton: true, + }, + { + id: "building7", + name: "7号館", + positions: [ [1461, 1557], [1342, 1558], [1341, 1733], [1461, 1732], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("building7", "7号館")); - - L.polygon( - [ + showDetailButton: true, + }, + { + id: "building8", + name: "8号館", + positions: [ [1438, 1941], [1438, 2205], [1349, 2205], [1349, 1941], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("building8", "8号館")); - - L.polygon( - [ + showDetailButton: true, + }, + { + id: "building9", + name: "9号館", + positions: [ [1618, 1839], [1618, 2109], [1521, 2109], [1521, 1839], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("building9", "9号館", false)); - - L.polygon( - [ + showDetailButton: false, + }, + { + id: "building10", + name: "10号館", + positions: [ [1473, 1753], [1473, 1895], [1328, 1895], [1328, 1753], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("building10", "10号館")); - - L.polygon( - [ + showDetailButton: true, + }, + { + id: "building11", + name: "11号館", + positions: [ [1194, 1510], [1194, 1624], [1015, 1624], [1015, 1510], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("building11", "11号館")); - - L.polygon( - [ + showDetailButton: true, + }, + { + id: "building12", + name: "12号館", + positions: [ [1086, 1210], [1086, 1325], [948, 1325], [948, 1210], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("building12", "12号館")); - - L.polygon( - [ + showDetailButton: true, + }, + { + id: "building13", + name: "13号館", + positions: [ [1217, 1209], [1217, 1420], [1121, 1420], [1121, 1209], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("building13", "13号館")); - - L.polygon( - [ + showDetailButton: true, + }, + { + id: "building14", + name: "14号館", + positions: [ [1222, 992], [1215, 1155], [1126, 1155], @@ -261,25 +190,23 @@ export function setupBuildingPolygons(map: L.Map) { [1018, 1057], [1018, 989], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("building14", "14号館", false)); - - L.polygon( - [ + showDetailButton: false, + }, + { + id: "building15", + name: "15号館", + positions: [ [1474, 719], [1474, 913], [1344, 913], [1344, 719], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("building15", "15号館")); - - L.polygon( - [ + showDetailButton: true, + }, + { + id: "building16", + name: "16号館", + positions: [ [1475, 913], [1540, 913], [1540, 1069], @@ -289,25 +216,23 @@ export function setupBuildingPolygons(map: L.Map) { [1540, 801], [1475, 801], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("building16", "16号館")); - - L.polygon( - [ + showDetailButton: true, + }, + { + id: "building17", + name: "17号館", + positions: [ [1589, 1136], [1487, 1136], [1486, 1308], [1588, 1308], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("building17", "17号館")); - - L.polygon( - [ + showDetailButton: true, + }, + { + id: "building18", + name: "18号館", + positions: [ [1763, 1718], [1763, 1913], [1665, 1913], @@ -315,37 +240,34 @@ export function setupBuildingPolygons(map: L.Map) { [1523, 1818], [1519, 1720], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("building18", "18号館")); - - L.polygon( - [ + showDetailButton: true, + }, + { + id: "building19", + name: "19号館", + positions: [ [2125, 2578], [2125, 2679], [1954, 2679], [1954, 2578], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("building19", "19号館", false)); - - L.polygon( - [ + showDetailButton: false, + }, + { + id: "komcee_west", + name: "21KOMCEE West", + positions: [ [1750, 2144], [1750, 2309], [1537, 2309], [1537, 2144], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("komcee_west", "21KOMCEE West")); - - L.polygon( - [ + showDetailButton: true, + }, + { + id: "komcee_east", + name: "21KOMCEE East", + positions: [ [1715, 2342], [1715, 2443], [1352, 2443], @@ -353,13 +275,12 @@ export function setupBuildingPolygons(map: L.Map) { [1451, 2243], [1451, 2342], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("komcee_east", "21KOMCEE East")); - - L.polygon( - [ + showDetailButton: true, + }, + { + id: "building900", + name: "900番講堂", + positions: [ [860, 1373], [763, 1373], [763, 1493], @@ -369,25 +290,23 @@ export function setupBuildingPolygons(map: L.Map) { [877, 1494], [860, 1494], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("building900", "900番講堂")); - - L.polygon( - [ + showDetailButton: true, + }, + { + id: "info_edu", + name: "情報教育棟", + positions: [ [482, 1434], [381, 1434], [381, 1772], [482, 1772], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("info_edu", "情報教育棟")); - - L.polygon( - [ + showDetailButton: true, + }, + { + id: "administration", + name: "アドミニストレーション棟", + positions: [ [723, 2071], [723, 2304], [647, 2310], @@ -395,148 +314,117 @@ export function setupBuildingPolygons(map: L.Map) { [565, 2418], [565, 2071], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup( - createBuildingPopup("administration", "アドミニストレーション棟"), - ); - - L.polygon( - [ + showDetailButton: true, + }, + { + id: "comipla_north", + name: "コミプラ北館", + positions: [ [1395, 2593], [1395, 2979], [1305, 2979], [1305, 2593], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("comipla_north", "コミプラ北館")); - - // 第二体育館 (Second Gymnasium) - L.polygon( - [ + showDetailButton: true, + }, + { + id: "second_gymnasium", + name: "第二体育館", + positions: [ [1478, 2602], [1482, 2770], [1780, 2774], [1774, 2600], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("second_gymnasium", "第二体育館")); - - // 第一体育館 (First Gymnasium) - L.polygon( - [ + showDetailButton: true, + }, + { + id: "first_gymnasium", + name: "第一体育館", + positions: [ [1798, 2924], [1632, 2922], [1632, 3052], [1804, 3054], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("first_gymnasium", "第一体育館")); - - // キャンパスプラザA棟 (Campus Plaza A) - L.polygon( - [ + showDetailButton: true, + }, + { + id: "campus_plaza_a", + name: "キャンパスプラザA棟", + positions: [ [1556, 2784], [1458, 2794], [1444, 2858], [1558, 2858], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("campus_plaza_a", "キャンパスプラザA棟")); - - // キャンパスプラザB棟 (Campus Plaza B) - L.polygon( - [ + showDetailButton: true, + }, + { + id: "campus_plaza_b", + name: "キャンパスプラザB棟", + positions: [ [1452, 2908], [1446, 2978], [1564, 2978], [1558, 2908], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("campus_plaza_b", "キャンパスプラザB棟")); - - // 駒場博物館 (Komaba Museum) - L.polygon( - [ + showDetailButton: true, + }, + { + id: "komaba_museum", + name: "駒場博物館", + positions: [ [878, 2242], [754, 2241], [760, 2430], [880, 2431], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("komaba_museum", "駒場博物館")); - - // アドバンスト・リサーチ・ラボラトリー (Advanced Research Laboratory) - L.polygon( - [ + showDetailButton: true, + }, + { + id: "advanced_research_lab", + name: "アドバンスト・リサーチ・ラボラトリー", + positions: [ [1825, 1682], [1890, 1678], [1897, 1536], [1826, 1540], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup( - createBuildingPopup( - "advanced_research_lab", - "アドバンスト・リサーチ・ラボラトリー", - false, - ), - ); - - // 駒場国際教育研究棟 (Komaba International Education & Research Building) - L.polygon( - [ + showDetailButton: false, + }, + { + id: "komaba_international_edu", + name: "駒場国際教育研究棟", + positions: [ [1449, 1096], [1355, 1101], [1357, 1399], [1444, 1401], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup( - createBuildingPopup("komaba_international_edu", "駒場国際教育研究棟"), - ); - - // 駒場保健センター (Komaba Health Center) - L.polygon( - [ + showDetailButton: true, + }, + { + id: "komaba_health_center", + name: "駒場保健センター", + positions: [ [575, 1484], [524, 1484], [524, 1593], [577, 1591], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("komaba_health_center", "駒場保健センター")); - - // 生協食堂 (Co-op Cafeteria) - L.polygon( - [ + showDetailButton: true, + }, + { + id: "coop_cafeteria", + name: "生協食堂", + positions: [ [1250, 2495], [1013, 2498], [1017, 2637], [1086, 2660], [1250, 2665], ], - { color: "transparent", fillOpacity: 0 }, - ) - .addTo(map) - .bindPopup(createBuildingPopup("coop_cafeteria", "生協食堂")); -} + showDetailButton: true, + }, +]; diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index fd57ea8..0d681b0 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -1,65 +1,56 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import L from "leaflet"; +import { + MapContainer, + ImageOverlay, + Marker, + Popup, + Polygon, + Circle, + useMap, +} from "react-leaflet"; +import "leaflet/dist/leaflet.css"; import { useSearchParams } from "react-router-dom"; import { Komabamap } from "../assets"; import UtcFavicon from "../assets/utc-favicon.svg"; -import { setupGeolocation } from "../geolocation"; +import { useGeolocation } from "../geolocation"; import { - setupBuildingPolygons, - setupWaterServerMarkers, - setupVendingMachineMarkers, - toggleMarkers, + buildingPolygons, + waterServerMarkers, + vendingMachineMarkers, + orangeIcon, } from "../markers"; import { searchItems, type SearchableItem } from "../search"; import { getBuildingCenter } from "../buildings"; +import "leaflet-smooth-zoom/SmoothWheelZoom.js"; const imgWidth = 4000; const imgHeight = 2800; +const bounds: L.LatLngBoundsExpression = [ + [0, 0], + [imgHeight, imgWidth], +]; -export function MapPage() { - const mapRef = useRef(null); - const containerRef = useRef(null); - const wsMarkersRef = useRef([]); - const vmMarkersRef = useRef([]); - const [wsMarkersVisible, setWsMarkersVisible] = useState(false); - const [vmMarkersVisible, setVmMarkersVisible] = useState(false); - const [sidebarOpen, setSidebarOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [searchResults, setSearchResults] = useState([]); - const [selectedItem, setSelectedItem] = useState(null); - const [searchParams] = useSearchParams(); - const initialParamsProcessed = useRef(false); +// Component to handle map interactions like flyTo +function MapController({ + selectedItem, + initialParams, +}: { + selectedItem: SearchableItem | null; + initialParams: { + buildingId: string | null; + lat: string | null; + lng: string | null; + zoom: string | null; + }; +}) { + const map = useMap(); + const initialProcessed = useRef(false); useEffect(() => { - if (!containerRef.current) return; - const map = L.map(containerRef.current, { crs: L.CRS.Simple, minZoom: -3 }); - const bounds: L.LatLngBoundsExpression = [ - [0, 0], - [imgHeight, imgWidth], - ]; - L.imageOverlay(Komabamap, bounds).addTo(map); - map.setView([imgHeight / 2, imgWidth / 2], -1); - mapRef.current = map; - - // デバッグ用: 環境変数で現在地を固定座標に設定可能 - // 例: VITE_DEBUG_POSITION=1400,2000 - const debugPos = import.meta.env.VITE_DEBUG_POSITION; - const geoOptions = debugPos - ? { debugPosition: debugPos.split(",").map(Number) as [number, number] } - : undefined; - - const geo = setupGeolocation(map, imgWidth, imgHeight, geoOptions); - const wsMarkers = setupWaterServerMarkers(map); - wsMarkersRef.current = wsMarkers.markers; - const vmMarkers = setupVendingMachineMarkers(map); - vmMarkersRef.current = vmMarkers.markers; - setupBuildingPolygons(map); - - // Process URL parameters for sharing location - const buildingId = searchParams.get("building"); - const lat = searchParams.get("lat"); - const lng = searchParams.get("lng"); - const zoom = searchParams.get("zoom"); + if (initialProcessed.current) return; + + const { buildingId, lat, lng, zoom } = initialParams; if (buildingId) { const center = getBuildingCenter(buildingId); @@ -94,86 +85,135 @@ export function MapPage() { .openOn(map); } - initialParamsProcessed.current = true; - - return () => { - geo.cleanup(); - map.remove(); - mapRef.current = null; - }; - }, []); - - const handleWsMarkerToggle = () => { - if (mapRef.current) { - const newVisible = toggleMarkers( - mapRef.current, - wsMarkersRef.current, - wsMarkersVisible, - ); - setWsMarkersVisible(newVisible); - } - }; - const handleVmMarkerToggle = () => { - if (mapRef.current) { - const newVisible = toggleMarkers( - mapRef.current, - vmMarkersRef.current, - vmMarkersVisible, - ); - setVmMarkersVisible(newVisible); - } - }; + initialProcessed.current = true; + }, [map, initialParams]); - const handleSearch = (query: string) => { - setSearchQuery(query); - const results = searchItems(query); - setSearchResults(results); - }; - - const handleSelectItem = (item: SearchableItem) => { - setSelectedItem(item); - if (mapRef.current) { + useEffect(() => { + if (selectedItem) { // Fly to the selected item's location - mapRef.current.flyTo([item.lat, item.lng], 0, { + map.flyTo([selectedItem.lat, selectedItem.lng], 0, { duration: 1.5, }); // If it's a building with a detail page, show a popup with link - if (item.type === "building" && item.buildingId) { - const shareUrl = `${window.location.origin}/?building=${item.buildingId}`; + if (selectedItem.type === "building" && selectedItem.buildingId) { + const shareUrl = `${window.location.origin}/?building=${selectedItem.buildingId}`; L.popup() - .setLatLng([item.lat, item.lng]) + .setLatLng([selectedItem.lat, selectedItem.lng]) .setContent( `
-

${item.name}

+

${selectedItem.name}


- 詳細 + 詳細
`, ) - .openOn(mapRef.current); + .openOn(map); } else { // Show popup for water servers and vending machines L.popup() - .setLatLng([item.lat, item.lng]) + .setLatLng([selectedItem.lat, selectedItem.lng]) .setContent( - `${item.name}${item.description ? `
${item.description}` : ""}`, + `${selectedItem.name}${selectedItem.description ? `
${selectedItem.description}` : ""}`, ) - .openOn(mapRef.current); + .openOn(map); } } - // Reset search box to initial state + }, [selectedItem, map]); + + return null; +} + +function UserLocation() { + // デバッグ用: 環境変数で現在地を固定座標に設定可能 + // 例: VITE_DEBUG_POSITION=1400,2000 + const debugPos = useMemo( + () => + import.meta.env.VITE_DEBUG_POSITION?.split(",").map(Number) as [ + number, + number, + ], + [], + ); + + const geoOptions = debugPos ? { debugPosition: debugPos } : {}; + + const { position, accuracy } = useGeolocation( + imgWidth, + imgHeight, + geoOptions, + ); + + if (!position) return null; + + const dotIcon = L.divIcon({ + className: "user-location-dot", + html: `
`, + iconSize: [16, 16], + iconAnchor: [8, 8], + }); + + return ( + <> + + Current Location + + {/*精度円 (accuracy radius in meters, but since this is a custom CRS map, we use a visual radius in pixels)*/} + + + ); +} + +export function MapPage() { + const [wsMarkersVisible, setWsMarkersVisible] = useState(false); + const [vmMarkersVisible, setVmMarkersVisible] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [selectedItem, setSelectedItem] = useState(null); + const [searchParams] = useSearchParams(); + + // Process URL parameters for sharing location + const initialParams = { + buildingId: searchParams.get("building"), + lat: searchParams.get("lat"), + lng: searchParams.get("lng"), + zoom: searchParams.get("zoom"), + }; + + const handleSearch = (query: string) => { + setSearchQuery(query); + const results = searchItems(query); + setSearchResults(results); + }; + + const handleSelectItem = (item: SearchableItem) => { + setSelectedItem(item); setSearchQuery(""); setSearchResults([]); - // Close sidebar after selection setSidebarOpen(false); }; - // サイドバー内部ボタンのラベルを状態依存で切替 const wsLabel = wsMarkersVisible ? "ウォーターサーバーを非表示" : "ウォーターサーバーを表示"; @@ -181,8 +221,106 @@ export function MapPage() { return (
- {/* Map背景 */} -
+ + + + + + {buildingPolygons.map((poly) => ( + + +
+

+ {poly.name} +

+ + {poly.showDetailButton && ( + + 詳細 + + )} +
+
+
+ ))} + + {wsMarkersVisible && + waterServerMarkers.map((marker, idx) => ( + + {marker.popup && {marker.popup}} + + ))} + + {vmMarkersVisible && + vendingMachineMarkers.map((marker, idx) => ( + + {marker.popup && {marker.popup}} + + ))} +
{/* 検索ボックス(常時表示) */}
@@ -345,7 +483,7 @@ export function MapPage() {