From aef902b693244d74134d576220085fc8687a128a Mon Sep 17 00:00:00 2001 From: isamu Date: Sun, 25 Jan 2026 07:14:09 +0900 Subject: [PATCH 1/4] fix: add loading=async to Google Maps script URL - Add loading=async parameter to improve performance - Document pending API migrations in CLAUDE.md (PlacesService, Marker) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 15 +++++++++++++++ src/vue/View.vue | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index beb5683..983901a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,21 @@ interface MapToolData { - `src/vue/View.vue`: Main map display component - `src/vue/Preview.vue`: Sidebar preview component +### TODO: Google Maps API Migration + +The following deprecated APIs need to be migrated: + +1. **PlacesService → Place class** + - `google.maps.places.PlacesService` is deprecated (March 2025) + - Migrate to `google.maps.places.Place` + - See: https://developers.google.com/maps/documentation/javascript/places-migration-overview + +2. **Marker → AdvancedMarkerElement** + - `google.maps.Marker` is deprecated (February 2024) + - Migrate to `google.maps.marker.AdvancedMarkerElement` + - Requires `marker` library in API URL + - See: https://developers.google.com/maps/documentation/javascript/advanced-markers/migration + ## Updating This Document **IMPORTANT**: When making spec changes or improvements to this plugin through discussion with Claude: diff --git a/src/vue/View.vue b/src/vue/View.vue index 3cb54df..3d94365 100644 --- a/src/vue/View.vue +++ b/src/vue/View.vue @@ -286,7 +286,7 @@ const loadGoogleMapsScript = (): Promise => { } const script = document.createElement("script"); - script.src = `https://maps.googleapis.com/maps/api/js?key=${props.googleMapKey}&libraries=places`; + script.src = `https://maps.googleapis.com/maps/api/js?key=${props.googleMapKey}&libraries=places&loading=async`; script.async = true; script.defer = true; script.onload = () => resolve(); From 74443340f6b92ba349b37aaf29248f2282920196 Mon Sep 17 00:00:00 2001 From: isamu Date: Sun, 25 Jan 2026 07:18:07 +0900 Subject: [PATCH 2/4] feat: migrate to modern Google Maps API - Replace deprecated Marker with AdvancedMarkerElement - Replace deprecated PlacesService.textSearch with Place.searchByText - Add loading=async to script URL - Load marker and places libraries dynamically with importLibrary() - Add required mapId for AdvancedMarkerElement - Update CLAUDE.md to document current API usage Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 18 ++--- src/vue/View.vue | 188 +++++++++++++++++++++++++---------------------- 2 files changed, 109 insertions(+), 97 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 983901a..6e6b0c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,20 +39,16 @@ interface MapToolData { - `src/vue/View.vue`: Main map display component - `src/vue/Preview.vue`: Sidebar preview component -### TODO: Google Maps API Migration +### Google Maps API Usage -The following deprecated APIs need to be migrated: +This plugin uses modern Google Maps JavaScript API: -1. **PlacesService → Place class** - - `google.maps.places.PlacesService` is deprecated (March 2025) - - Migrate to `google.maps.places.Place` - - See: https://developers.google.com/maps/documentation/javascript/places-migration-overview +- **AdvancedMarkerElement**: For map markers (requires `marker` library and `mapId`) +- **Place.searchByText**: For place search functionality (new Places API) +- **Geocoder**: For address-to-coordinates conversion +- **DirectionsService**: For route calculation -2. **Marker → AdvancedMarkerElement** - - `google.maps.Marker` is deprecated (February 2024) - - Migrate to `google.maps.marker.AdvancedMarkerElement` - - Requires `marker` library in API URL - - See: https://developers.google.com/maps/documentation/javascript/advanced-markers/migration +Libraries loaded: `places,marker` with `loading=async` ## Updating This Document diff --git a/src/vue/View.vue b/src/vue/View.vue index 3d94365..c190bb7 100644 --- a/src/vue/View.vue +++ b/src/vue/View.vue @@ -155,12 +155,15 @@ const props = defineProps<{ // State const mapContainer = ref(null); const map = ref(null); -const markers = ref>(new Map()); -const placesService = ref(null); +const markers = ref>(new Map()); const directionsService = ref(null); const directionsRenderer = ref(null); const geocoder = ref(null); +// Library references (loaded dynamically) +let AdvancedMarkerElement: typeof google.maps.marker.AdvancedMarkerElement; +let Place: typeof google.maps.places.Place; + const isLoading = ref(false); const errorMessage = ref(null); const places = ref([]); @@ -226,16 +229,14 @@ const formatLocation = ( const getMarkersData = (): MarkerData[] => { const result: MarkerData[] = []; markers.value.forEach((marker, id) => { - const position = marker.getPosition(); + const position = marker.position; if (position) { + const lat = typeof position.lat === "function" ? position.lat() : position.lat; + const lng = typeof position.lng === "function" ? position.lng() : position.lng; result.push({ id, - position: { lat: position.lat(), lng: position.lng() }, - title: marker.getTitle() || undefined, - label: - typeof marker.getLabel() === "string" - ? marker.getLabel() as string - : (marker.getLabel() as google.maps.MarkerLabel | null)?.text, + position: { lat, lng }, + title: marker.title || undefined, }); } }); @@ -286,7 +287,7 @@ const loadGoogleMapsScript = (): Promise => { } const script = document.createElement("script"); - script.src = `https://maps.googleapis.com/maps/api/js?key=${props.googleMapKey}&libraries=places&loading=async`; + script.src = `https://maps.googleapis.com/maps/api/js?key=${props.googleMapKey}&libraries=places,marker&loading=async`; script.async = true; script.defer = true; script.onload = () => resolve(); @@ -301,6 +302,12 @@ const initializeMap = async () => { try { await loadGoogleMapsScript(); + // Load libraries dynamically + const markerLib = await google.maps.importLibrary("marker") as google.maps.MarkerLibrary; + const placesLib = await google.maps.importLibrary("places") as google.maps.PlacesLibrary; + AdvancedMarkerElement = markerLib.AdvancedMarkerElement; + Place = placesLib.Place; + const defaultCenter = { lat: 35.6812, lng: 139.7671 }; // Tokyo Station map.value = new google.maps.Map(mapContainer.value, { center: defaultCenter, @@ -308,10 +315,10 @@ const initializeMap = async () => { mapTypeControl: true, streetViewControl: true, fullscreenControl: true, + mapId: "DEMO_MAP_ID", // Required for AdvancedMarkerElement }); geocoder.value = new google.maps.Geocoder(); - placesService.value = new google.maps.places.PlacesService(map.value); directionsService.value = new google.maps.DirectionsService(); directionsRenderer.value = new google.maps.DirectionsRenderer(); directionsRenderer.value.setMap(map.value); @@ -370,13 +377,22 @@ const addMapMarker = ( id: string, title?: string, label?: string -): google.maps.Marker => { - const marker = new google.maps.Marker({ +): google.maps.marker.AdvancedMarkerElement => { + // Create label element if needed + let content: HTMLElement | undefined; + if (label) { + const labelDiv = document.createElement("div"); + labelDiv.className = "marker-label"; + labelDiv.style.cssText = "background: #4285f4; color: white; padding: 4px 8px; border-radius: 4px; font-weight: bold; font-size: 12px;"; + labelDiv.textContent = label; + content = labelDiv; + } + + const marker = new AdvancedMarkerElement({ position, map: map.value, title, - label: label ? { text: label, color: "white" } : undefined, - animation: google.maps.Animation.DROP, + content, }); markers.value.set(id, marker); @@ -385,7 +401,7 @@ const addMapMarker = ( const clearAllMarkers = () => { markers.value.forEach((marker) => { - marker.setMap(null); + marker.map = null; }); markers.value.clear(); }; @@ -484,87 +500,87 @@ const handleAction = async (data: MapToolData) => { }; const searchPlaces = async (query?: string, placeType?: string) => { - if (!placesService.value || !map.value) return; + if (!map.value || !Place) return; const center = map.value.getCenter(); if (!center) return; - const request: google.maps.places.TextSearchRequest = { - location: center, - radius: 5000, - query: query || placeType || "", - }; - - if (placeType && !query) { - (request as google.maps.places.TextSearchRequest & { type?: string }).type = placeType; - } + try { + // Use new Place.searchByText API + const request: google.maps.places.SearchByTextRequest = { + textQuery: query || placeType || "", + locationBias: { + center: { lat: center.lat(), lng: center.lng() }, + radius: 5000, + }, + fields: ["id", "displayName", "formattedAddress", "location", "rating", "userRatingCount", "types", "regularOpeningHours"], + maxResultCount: 10, + }; + + if (placeType && !query) { + request.includedType = placeType; + } - return new Promise((resolve) => { - placesService.value!.textSearch( - request, - ( - results: google.maps.places.PlaceResult[] | null, - status: google.maps.places.PlacesServiceStatus - ) => { - if (status === google.maps.places.PlacesServiceStatus.OK && results) { - // Clear previous place markers - markers.value.forEach((marker, id) => { - if (id.startsWith("place_")) { - marker.setMap(null); - markers.value.delete(id); - } - }); - - const placeResults: PlaceResult[] = results.slice(0, 10).map( - (place: google.maps.places.PlaceResult, index: number) => { - const location = place.geometry?.location; - const coords: LatLng = location - ? { lat: location.lat(), lng: location.lng() } - : { lat: 0, lng: 0 }; - - // Add marker for each place - if (location) { - addMapMarker( - coords, - `place_${place.place_id}`, - place.name, - String(index + 1) - ); - } - - return { - placeId: place.place_id || "", - name: place.name || "", - address: place.formatted_address || "", - location: coords, - rating: place.rating, - userRatingsTotal: place.user_ratings_total, - types: place.types, - openNow: place.opening_hours?.isOpen?.(), - }; - } - ); + const { places: searchResults } = await Place.searchByText(request); - places.value = placeResults; - route.value = null; + if (searchResults && searchResults.length > 0) { + // Clear previous place markers + markers.value.forEach((marker, id) => { + if (id.startsWith("place_")) { + marker.map = null; + markers.value.delete(id); + } + }); - // Fit bounds to show all markers - if (placeResults.length > 0) { - const bounds = new google.maps.LatLngBounds(); - placeResults.forEach((p) => { - bounds.extend(p.location); - }); - map.value?.fitBounds(bounds); + const placeResults: PlaceResult[] = searchResults.map( + (place: google.maps.places.Place, index: number) => { + const location = place.location; + const coords: LatLng = location + ? { lat: location.lat(), lng: location.lng() } + : { lat: 0, lng: 0 }; + + // Add marker for each place + if (location) { + addMapMarker( + coords, + `place_${place.id}`, + place.displayName || undefined, + String(index + 1) + ); } - emitResult(true, undefined, { places: placeResults }); - } else { - emitResult(false, "No places found"); + return { + placeId: place.id || "", + name: place.displayName || "", + address: place.formattedAddress || "", + location: coords, + rating: place.rating ?? undefined, + userRatingsTotal: place.userRatingCount ?? undefined, + types: place.types, + openNow: place.regularOpeningHours?.periods ? true : undefined, + }; } - resolve(); + ); + + places.value = placeResults; + route.value = null; + + // Fit bounds to show all markers + if (placeResults.length > 0) { + const bounds = new google.maps.LatLngBounds(); + placeResults.forEach((p) => { + bounds.extend(p.location); + }); + map.value?.fitBounds(bounds); } - ); - }); + + emitResult(true, undefined, { places: placeResults }); + } else { + emitResult(false, "No places found"); + } + } catch { + emitResult(false, "No places found"); + } }; const getDirections = async ( From ff44992c6f8e1f5c07ea9b0469dab1eaf3861e96 Mon Sep 17 00:00:00 2001 From: isamu Date: Sun, 25 Jan 2026 07:28:41 +0900 Subject: [PATCH 3/4] fix: improve code quality and security - Remove v-html usage (XSS risk) - use stripHtml() instead - Add 10s timeout to Google Maps script polling - Fix Map mutation during iteration in searchPlaces - Fix incorrect openNow logic (isOpen() is async) - Add production Map ID documentation comment Co-Authored-By: Claude Opus 4.5 --- src/vue/View.vue | 76 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 15 deletions(-) diff --git a/src/vue/View.vue b/src/vue/View.vue index c190bb7..5c2883e 100644 --- a/src/vue/View.vue +++ b/src/vue/View.vue @@ -121,7 +121,7 @@ :key="index" class="p-2 bg-gray-50 rounded text-sm" > -
+
{{ stripHtml(step.instruction) }}
{{ step.distance }} · {{ step.duration }}
@@ -226,6 +226,11 @@ const formatLocation = ( return `${location.lat}, ${location.lng}`; }; +const stripHtml = (html: string): string => { + const doc = new DOMParser().parseFromString(html, "text/html"); + return doc.body.textContent || ""; +}; + const getMarkersData = (): MarkerData[] => { const result: MarkerData[] = []; markers.value.forEach((marker, id) => { @@ -270,7 +275,12 @@ const emitResult = ( // Map initialization const loadGoogleMapsScript = (): Promise => { return new Promise((resolve, reject) => { - if (typeof google !== "undefined" && google.maps) { + // Check if already fully loaded with importLibrary available + if ( + typeof google !== "undefined" && + google.maps && + typeof google.maps.importLibrary === "function" + ) { resolve(); return; } @@ -278,19 +288,34 @@ const loadGoogleMapsScript = (): Promise => { const existingScript = document.querySelector( 'script[src*="maps.googleapis.com"]' ); + + const TIMEOUT_MS = 10000; + const POLL_INTERVAL_MS = 50; + + const waitForImportLibrary = (startTime: number) => { + if ( + typeof google !== "undefined" && + google.maps && + typeof google.maps.importLibrary === "function" + ) { + resolve(); + } else if (Date.now() - startTime > TIMEOUT_MS) { + reject(new Error("Timeout waiting for Google Maps to load")); + } else { + setTimeout(() => waitForImportLibrary(startTime), POLL_INTERVAL_MS); + } + }; + if (existingScript) { - existingScript.addEventListener("load", () => resolve()); - existingScript.addEventListener("error", () => - reject(new Error("Failed to load Google Maps")) - ); + waitForImportLibrary(Date.now()); return; } const script = document.createElement("script"); - script.src = `https://maps.googleapis.com/maps/api/js?key=${props.googleMapKey}&libraries=places,marker&loading=async`; + script.src = `https://maps.googleapis.com/maps/api/js?key=${props.googleMapKey}&loading=async`; script.async = true; script.defer = true; - script.onload = () => resolve(); + script.onload = () => waitForImportLibrary(Date.now()); script.onerror = () => reject(new Error("Failed to load Google Maps")); document.head.appendChild(script); }); @@ -299,12 +324,19 @@ const loadGoogleMapsScript = (): Promise => { const initializeMap = async () => { if (!mapContainer.value || !props.googleMapKey) return; + // Skip if map is already initialized + if (map.value) return; + try { await loadGoogleMapsScript(); // Load libraries dynamically - const markerLib = await google.maps.importLibrary("marker") as google.maps.MarkerLibrary; - const placesLib = await google.maps.importLibrary("places") as google.maps.PlacesLibrary; + const markerLib = (await google.maps.importLibrary( + "marker" + )) as google.maps.MarkerLibrary; + const placesLib = (await google.maps.importLibrary( + "places" + )) as google.maps.PlacesLibrary; AdvancedMarkerElement = markerLib.AdvancedMarkerElement; Place = placesLib.Place; @@ -315,7 +347,9 @@ const initializeMap = async () => { mapTypeControl: true, streetViewControl: true, fullscreenControl: true, - mapId: "DEMO_MAP_ID", // Required for AdvancedMarkerElement + // DEMO_MAP_ID is Google's demo ID. For production, create your own at: + // https://console.cloud.google.com/google/maps-apis/studio/maps + mapId: "DEMO_MAP_ID", }); geocoder.value = new google.maps.Geocoder(); @@ -524,9 +558,13 @@ const searchPlaces = async (query?: string, placeType?: string) => { const { places: searchResults } = await Place.searchByText(request); if (searchResults && searchResults.length > 0) { - // Clear previous place markers - markers.value.forEach((marker, id) => { - if (id.startsWith("place_")) { + // Clear previous place markers (collect IDs first to avoid mutation during iteration) + const placeMarkerIds = [...markers.value.keys()].filter((id) => + id.startsWith("place_") + ); + placeMarkerIds.forEach((id) => { + const marker = markers.value.get(id); + if (marker) { marker.map = null; markers.value.delete(id); } @@ -557,7 +595,8 @@ const searchPlaces = async (query?: string, placeType?: string) => { rating: place.rating ?? undefined, userRatingsTotal: place.userRatingCount ?? undefined, types: place.types, - openNow: place.regularOpeningHours?.periods ? true : undefined, + // Note: isOpen() requires async call, omitting for now + openNow: undefined, }; } ); @@ -659,11 +698,18 @@ const zoomOut = () => { } }; +// Track last processed data to avoid re-processing +let lastProcessedData: string | null = null; + // Watch for result changes watch( () => props.selectedResult?.data, async (newData) => { if (newData && map.value) { + // Skip if same data was already processed + const dataKey = JSON.stringify(newData); + if (dataKey === lastProcessedData) return; + lastProcessedData = dataKey; await handleAction(newData); } } From 80bdb71dcfcfadd0f5927ce5cf57c31c54fc2103 Mon Sep 17 00:00:00 2001 From: isamu Date: Sun, 25 Jan 2026 07:31:28 +0900 Subject: [PATCH 4/4] feat: add click-to-navigate for direction steps - Add startLocation/endLocation to DirectionStep type - Make direction steps clickable with hover effect - Navigate to step location and zoom to 18 on click Co-Authored-By: Claude Opus 4.5 --- src/core/types.ts | 2 ++ src/vue/View.vue | 38 +++++++++++++++++++++++++++++++------- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/core/types.ts b/src/core/types.ts index c3274f8..f4ad913 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -143,6 +143,8 @@ export interface DirectionStep { distance: string; duration: string; travelMode: string; + startLocation?: LatLng; + endLocation?: LatLng; } // Route information from Directions API diff --git a/src/vue/View.vue b/src/vue/View.vue index 5c2883e..7b5f490 100644 --- a/src/vue/View.vue +++ b/src/vue/View.vue @@ -119,7 +119,8 @@
{{ stripHtml(step.instruction) }}
@@ -143,6 +144,7 @@ import type { MarkerData, PlaceResult, DirectionRoute, + DirectionStep, LatLng, } from "../core/types"; @@ -653,12 +655,25 @@ const getDirections = async ( duration: leg.duration?.text || "", startAddress: leg.start_address || "", endAddress: leg.end_address || "", - steps: leg.steps?.map((step: google.maps.DirectionsStep) => ({ - instruction: step.instructions || "", - distance: step.distance?.text || "", - duration: step.duration?.text || "", - travelMode: step.travel_mode || "", - })) || [], + steps: + leg.steps?.map((step: google.maps.DirectionsStep) => ({ + instruction: step.instructions || "", + distance: step.distance?.text || "", + duration: step.duration?.text || "", + travelMode: step.travel_mode || "", + startLocation: step.start_location + ? { + lat: step.start_location.lat(), + lng: step.start_location.lng(), + } + : undefined, + endLocation: step.end_location + ? { + lat: step.end_location.lat(), + lng: step.end_location.lng(), + } + : undefined, + })) || [], polyline: result.routes[0].overview_polyline || "", }; @@ -682,6 +697,15 @@ const selectPlace = (place: PlaceResult) => { map.value.setZoom(17); }; +const selectStep = (step: DirectionStep) => { + if (!map.value) return; + const location = step.startLocation || step.endLocation; + if (location) { + map.value.setCenter(location); + map.value.setZoom(18); + } +}; + const zoomIn = () => { if (!map.value) return; const current = map.value.getZoom() || 15;