diff --git a/package-lock.json b/package-lock.json index d440a729d..2b11785f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1423,6 +1423,10 @@ "resolved": "samples/polyline-simple", "link": true }, + "node_modules/@js-api-samples/polyline-utility": { + "resolved": "samples/polyline-utility", + "link": true + }, "node_modules/@js-api-samples/react-places-ui-kit-search-nearby": { "resolved": "samples/react-ui-kit-search-nearby", "link": true @@ -6946,6 +6950,10 @@ "name": "@js-api-samples/polyline-simple", "version": "1.0.0" }, + "samples/polyline-utility": { + "name": "@js-api-samples/polyline-utility", + "version": "1.0.0" + }, "samples/react-ui-kit-place-details": { "name": "@js-api-samples/react-ui-kit-place-details", "version": "1.0.0" diff --git a/samples/polyline-utility/README.md b/samples/polyline-utility/README.md new file mode 100644 index 000000000..92c7ecc2a --- /dev/null +++ b/samples/polyline-utility/README.md @@ -0,0 +1,41 @@ +# Google Maps JavaScript Sample + +## polyline-utility + +This sample demonstrates how to encode and decode polylines using the [Google Maps GeometryLibrary](https://developers.google.com/maps/documentation/javascript/reference/geometry). It also shows how to convert polylines to and from GeoJSON. + +## Setup + +### Before starting run: + +`npm i` + +### Run an example on a local web server + +`cd samples/polyline-utility` +`npm start` + +### Build an individual example + +`cd samples/polyline-utility` +`npm run build` + +From 'samples': + +`npm run build --workspace=polyline-utility/` + +### Build all of the examples. + +From 'samples': + +`npm run build-all` + +### Run lint to check for problems + +`cd samples/polyline-utility` +`npx eslint index.ts` + +## Feedback + +For feedback related to this sample, please open a new issue on +[GitHub](https://github.com/googlemaps-samples/js-api-samples/issues). diff --git a/samples/polyline-utility/index.html b/samples/polyline-utility/index.html new file mode 100644 index 000000000..a2c204cef --- /dev/null +++ b/samples/polyline-utility/index.html @@ -0,0 +1,86 @@ + + + + + + Polyline Encoder / Decoder Utility + + + + + + + +
+
+
+

Polyline Utility

+

Click on the map to draw, or decode existing paths.

+
+ +
+ +
+
+ +
+ + +
+ + +
+ +
+ +
+ + + +
+ +
+
+ + +
+
    +
    +
    +
    +
    + +
    +
    + + + diff --git a/samples/polyline-utility/index.ts b/samples/polyline-utility/index.ts new file mode 100644 index 000000000..a5645d713 --- /dev/null +++ b/samples/polyline-utility/index.ts @@ -0,0 +1,311 @@ +// [START maps_polyline_utility] + +async function init(): Promise { + const [ + { Polyline }, + { AdvancedMarkerElement }, + { LatLng, LatLngBounds, event }, + { PlaceAutocompleteElement }, + geometryLibrary, + ] = await Promise.all([ + google.maps.importLibrary('maps'), + google.maps.importLibrary('marker'), + google.maps.importLibrary('core'), + google.maps.importLibrary('places'), + google.maps.importLibrary('geometry'), + ]); + + let markers: google.maps.marker.AdvancedMarkerElement[] = []; + + const mapElement = document.querySelector('gmp-map')!; + const map = mapElement.innerMap; + map.setOptions({ draggableCursor: 'crosshair' }); + + // Setup Polyline + const polyline = new Polyline({ + map, + strokeColor: '#1a73e8', + strokeOpacity: 0.8, + strokeWeight: 4, + }); + + // Map Click to Add Point + map.addListener('click', (e: google.maps.MapMouseEvent) => { + if (e.latLng) { + addPoint(e.latLng); + } + }); + + // Setup Places Autocomplete + const pacContainer = document.getElementById('pac-container')!; + const autocomplete = new PlaceAutocompleteElement(); + pacContainer.appendChild(autocomplete); + + autocomplete.addEventListener( + 'gmp-select', + async ({ + placePrediction, + }: google.maps.places.PlacePredictionSelectEvent) => { + const place = placePrediction.toPlace(); + try { + await place.fetchFields({ + fields: ['location', 'viewport', 'displayName'], + }); + } catch (e: unknown) { + alert(`Failed to fetch place details: ${String(e)}`); + return; + } + + if (place.viewport) { + map.fitBounds(place.viewport); + } else if (place.location) { + map.setCenter(place.location); + map.setZoom(17); + } else { + const name = place.displayName ?? ''; + alert(`No details available for input: '${name}'`); + } + } + ); + + // Buttons and Textareas + document + .getElementById('decode-encoded-btn')! + .addEventListener('click', decodeEncodedPolyline); + document + .getElementById('decode-geojson-btn')! + .addEventListener('click', decodeGeoJson); + document + .getElementById('clear-all-btn')! + .addEventListener('click', clearAll); + + // Panel Resizer + const resizer = document.getElementById('drag-resizer')!; + const sidePanel = document.getElementById('side-panel')!; + let isResizing = false; + + resizer.addEventListener('mousedown', () => { + isResizing = true; + document.body.classList.add('is-resizing'); + resizer.classList.add('dragging'); + }); + + document.addEventListener('mousemove', (e: MouseEvent) => { + if (!isResizing) return; + sidePanel.style.width = `${String(e.clientX)}px`; + }); + + document.addEventListener('mouseup', () => { + if (isResizing) { + isResizing = false; + document.body.classList.remove('is-resizing'); + resizer.classList.remove('dragging'); + } + }); + + function addPoint(latLng: google.maps.LatLng) { + const path = polyline.getPath(); + path.push(latLng); + + const marker = new AdvancedMarkerElement({ + map, + position: latLng, + gmpDraggable: true, + }); + + marker.addListener('dragend', () => { + rebuildPathFromMarkers(); + }); + + markers.push(marker); + updateOutputs(); + } + + function rebuildPathFromMarkers() { + const newPath = markers + .map((m) => m.position) + .filter( + (pos): pos is google.maps.LatLngLiteral | google.maps.LatLng => + pos != null + ); + polyline.setPath(newPath); + updateOutputs(); + } + + function removePoint(index: number) { + if (index >= 0 && index < markers.length) { + event.clearInstanceListeners(markers[index]); + markers[index].map = null; + markers.splice(index, 1); + rebuildPathFromMarkers(); + } + } + + function clearAll() { + markers.forEach((m) => { + event.clearInstanceListeners(m); + m.map = null; + }); + markers = []; + polyline.getPath().clear(); + updateOutputs(); + } + + function updateOutputs() { + const path = polyline.getPath(); + const arr = path.getArray(); + + // Encoded Polyline + const encodedTextarea = document.getElementById( + 'encoded-polyline' + ) as HTMLTextAreaElement; + if (arr.length > 0) { + encodedTextarea.value = geometryLibrary.encoding.encodePath(path); + } else { + encodedTextarea.value = ''; + } + + // GeoJSON + const geojsonTextarea = document.getElementById( + 'geojson-polyline' + ) as HTMLTextAreaElement; + if (arr.length > 0) { + const coords = arr.map((ll) => { + const { lat, lng } = ll.toJSON(); + return [lng, lat]; + }); + geojsonTextarea.value = JSON.stringify(coords); + } else { + geojsonTextarea.value = ''; + } + + // List + const list = document.getElementById('locations-list')!; + list.replaceChildren(); + document.getElementById('location-count')!.textContent = + arr.length.toString(); + + arr.forEach((ll, index) => { + const { lat, lng } = ll.toJSON(); + + const li = document.createElement('li'); + li.textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)}`; + + const delBtn = document.createElement('button'); + delBtn.className = 'delete-point-btn'; + delBtn.textContent = '×'; + delBtn.title = 'Remove point'; + delBtn.onclick = () => { + removePoint(index); + }; + + li.appendChild(delBtn); + list.appendChild(li); + }); + } + + function decodeEncodedPolyline() { + let encoded = ( + document.getElementById('encoded-polyline') as HTMLTextAreaElement + ).value.trim(); + const unescape = ( + document.getElementById('enableunescape') as HTMLInputElement + ).checked; + + if (!encoded) return; + if (unescape) { + encoded = encoded + .replace(/\\\\\\\\/g, '\\\\') + .replace(/\\\\"/g, '"'); + } + + try { + const decodedPath = geometryLibrary.encoding.decodePath(encoded); + applyDecodedPath(decodedPath); + } catch { + alert('Failed to decode polyline string.'); + } + } + + function decodeGeoJson() { + const geojsonStr = ( + document.getElementById('geojson-polyline') as HTMLTextAreaElement + ).value.trim(); + if (!geojsonStr) return; + + try { + // We treat the parsed output as `unknown` to strictly validate it. + const parsed: unknown = JSON.parse(geojsonStr); + let coordsArray: unknown[] | undefined; + + // Check if it's a Feature or LineString object + if (parsed && typeof parsed === 'object') { + const obj = parsed as Record; + if (obj.type === 'Feature') { + const geometry = obj.geometry as + Record | undefined; + if ( + geometry?.type === 'LineString' && + Array.isArray(geometry.coordinates) + ) { + coordsArray = geometry.coordinates as unknown[]; + } + } else if ( + obj.type === 'LineString' && + Array.isArray(obj.coordinates) + ) { + coordsArray = obj.coordinates as unknown[]; + } else if (Array.isArray(parsed)) { + coordsArray = parsed; + } + } + + if (!coordsArray || !Array.isArray(coordsArray)) { + throw new Error('Invalid GeoJSON'); + } + + const decodedPath = coordsArray.map((coord: unknown) => { + if (Array.isArray(coord) && coord.length >= 2) { + const lng = Number(coord[0]); + const lat = Number(coord[1]); + if (!isNaN(lat) && !isNaN(lng)) { + return new LatLng(lat, lng); + } + } + throw new Error('Invalid coordinates'); + }); + + applyDecodedPath(decodedPath); + } catch { + alert( + 'Failed to decode GeoJSON. Ensure it is a valid array of [lng, lat] coordinates or a LineString object.' + ); + } + } + + function applyDecodedPath(decodedPath: google.maps.LatLng[]) { + if (decodedPath.length === 0) return; + + if (markers.length > 0) { + const replaceConfirm = confirm( + 'Are you sure you want to replace the current polyline?' + ); + if (!replaceConfirm) return; + } + + clearAll(); + + const bounds = new LatLngBounds(); + decodedPath.forEach((ll) => { + addPoint(ll); + bounds.extend(ll); + }); + + if (!bounds.isEmpty()) { + map.fitBounds(bounds); + } + } +} + +void init(); +// [END maps_polyline_utility] diff --git a/samples/polyline-utility/package.json b/samples/polyline-utility/package.json new file mode 100644 index 000000000..8b8dd9844 --- /dev/null +++ b/samples/polyline-utility/package.json @@ -0,0 +1,12 @@ +{ + "name": "@js-api-samples/polyline-utility", + "version": "1.0.0", + "scripts": { + "build": "bash ../build-single.sh", + "test": "tsc && npm run build:vite --workspace=.", + "start": "tsc && vite build --config ../../vite.config.js --base './' && vite --config ../../vite.config.js", + "build:vite": "vite build --config ../../vite.config.js --base './'", + "preview": "vite preview --config ../../vite.config.js" + }, + "author": "Google LLC" +} diff --git a/samples/polyline-utility/style.css b/samples/polyline-utility/style.css new file mode 100644 index 000000000..f39fe9745 --- /dev/null +++ b/samples/polyline-utility/style.css @@ -0,0 +1,200 @@ +/* + * @license + * Copyright 2026 Google LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* [START maps_polyline_utility] */ +html, +body { + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; + font-family: 'Inter', Roboto, sans-serif; + background-color: #f8f9fa; + color: #202124; +} + +.container { + display: flex; + height: 100%; + width: 100%; + overflow: hidden; +} + +.panel { + width: 400px; + min-width: 200px; + max-width: 80vw; + background: white; + padding: 24px; + box-shadow: 2px 0 12px rgba(0, 0, 0, 0.1); + overflow-y: auto; + overflow-x: hidden; + z-index: 10; + display: flex; + flex-direction: column; + gap: 24px; +} + +.header h2 { + margin: 0 0 8px 0; + font-size: 22px; + color: #1a73e8; +} + +.header p { + margin: 0; + font-size: 14px; + color: #5f6368; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +label { + font-size: 14px; + font-weight: 500; + color: #3c4043; +} + +input[type='text'], +textarea { + padding: 10px 12px; + border: 1px solid #dadce0; + border-radius: 6px; + font-size: 14px; + font-family: inherit; + transition: border-color 0.2s; + box-sizing: border-box; +} + +textarea { + height: 80px; + resize: vertical; + font-family: 'Courier New', Courier, monospace; +} + +input[type='text']:focus, +textarea:focus { + outline: none; + border-color: #1a73e8; +} + +.checkbox-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; +} +.checkbox-row input { + margin: 0; +} + +button { + cursor: pointer; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + transition: background-color 0.2s; +} + +.primary-btn { + background-color: #1a73e8; + color: white; + padding: 10px; +} + +.primary-btn:hover { + background-color: #1557b0; +} + +.list-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.text-btn { + background: none; + color: #d93025; + padding: 0; + font-size: 13px; +} +.text-btn:hover { + text-decoration: underline; +} + +#locations-list { + list-style: none; + margin: 0; + padding: 0; + border: 1px solid #dadce0; + border-radius: 6px; + max-height: 200px; + overflow-y: auto; +} + +#locations-list li { + padding: 8px 12px; + font-size: 13px; + border-bottom: 1px solid #f1f3f4; + display: flex; + justify-content: space-between; + align-items: center; +} +#locations-list li:last-child { + border-bottom: none; +} +#locations-list li:hover { + background-color: #f8f9fa; +} + +.delete-point-btn { + background: none; + color: #5f6368; + padding: 2px 6px; + font-size: 16px; + line-height: 1; +} +.delete-point-btn:hover { + color: #d93025; +} + +.map-container { + flex-grow: 1; + position: relative; +} + +gmp-map { + width: 100%; + height: 100%; +} + +.resizer { + width: 6px; + background-color: #dadce0; + cursor: col-resize; + transition: background-color 0.2s; + flex-shrink: 0; +} + +.resizer:hover, +.resizer.dragging { + background-color: #1a73e8; +} + +body.is-resizing { + cursor: col-resize; + user-select: none; +} + +body.is-resizing .map-container { + pointer-events: none; +} +/* [END maps_polyline_utility] */ diff --git a/samples/polyline-utility/tsconfig.json b/samples/polyline-utility/tsconfig.json new file mode 100644 index 000000000..976bcc6ef --- /dev/null +++ b/samples/polyline-utility/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["./*.ts"] +}