diff --git a/dist/samples/3d-hero-showcase/app/.eslintsrc.json b/dist/samples/3d-hero-showcase/app/.eslintsrc.json new file mode 100644 index 000000000..4c44dab04 --- /dev/null +++ b/dist/samples/3d-hero-showcase/app/.eslintsrc.json @@ -0,0 +1,13 @@ +{ + "extends": [ + "plugin:@typescript-eslint/recommended" + ], + "parser": "@typescript-eslint/parser", + "rules": { + "@typescript-eslint/ban-ts-comment": 0, + "@typescript-eslint/no-this-alias": 1, + "@typescript-eslint/no-empty-function": 1, + "@typescript-eslint/explicit-module-boundary-types": 1, + "@typescript-eslint/no-unused-vars": 1 + } +} diff --git a/dist/samples/3d-hero-showcase/app/README.md b/dist/samples/3d-hero-showcase/app/README.md new file mode 100644 index 000000000..389f62bc0 --- /dev/null +++ b/dist/samples/3d-hero-showcase/app/README.md @@ -0,0 +1,41 @@ +# Google Maps JavaScript Sample + +## 3d-hero-showcase + +Add a meaningful description for 3d-hero-showcase here... + +## Setup + +### Before starting run: + +`npm i` + +### Run an example on a local web server + +`cd samples/3d-hero-showcase` +`npm start` + +### Build an individual example + +`cd samples/3d-hero-showcase` +`npm run build` + +From 'samples': + +`npm run build --workspace=3d-hero-showcase/` + +### Build all of the examples. + +From 'samples': + +`npm run build-all` + +### Run lint to check for problems + +`cd samples/3d-hero-showcase` +`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/dist/samples/3d-hero-showcase/app/index.html b/dist/samples/3d-hero-showcase/app/index.html new file mode 100644 index 000000000..50cb6a31b --- /dev/null +++ b/dist/samples/3d-hero-showcase/app/index.html @@ -0,0 +1,189 @@ + + + + + + Amsterdam 3D Explorer + + + + + + + + + + + + + + +
+
+
+ + A + m + s + t + e + r + d + a + m + + 3D +
+

Rijksmuseum & attractions

+
+ +
+ +
+ + + + +
+ + +
+

Map Mode

+
+ + +
+
+ + +
+

Map Layers

+
+ + + + + + + + + +
+
+
+ + +
+ + +
+
+

Explore Amsterdam in 3D

+

+ Start the tour to fly around the Rijksmuseum, Vondelpark, + Prinsengracht, and De Gooyer Windmill. +

+ +
+
+ + + diff --git a/dist/samples/3d-hero-showcase/app/index.ts b/dist/samples/3d-hero-showcase/app/index.ts new file mode 100644 index 000000000..133d9494c --- /dev/null +++ b/dist/samples/3d-hero-showcase/app/index.ts @@ -0,0 +1,586 @@ +/* + * @license + * Copyright 2026 Google LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +interface TourStop { + name: string; + desc: string; + camera: { + center: { lat: number; lng: number; altitude: number }; + range: number; + tilt: number; + heading: number; + }; + stats: { + size: string; + highlight: string; + }; +} + +const TOUR_STOPS: TourStop[] = [ + { + name: 'Rijksmuseum', + desc: 'The national museum of the Netherlands. The footprint of this grand Gothic-Renaissance building is highlighted, and its 3D building mesh can be toggled on or off.', + camera: { + center: { lat: 52.36, lng: 4.8852, altitude: 40 }, + range: 280, + tilt: 45, + heading: 180, + }, + stats: { + size: '🖼 8,000+ objects', + highlight: "Rembrandt's Night Watch", + }, + }, + { + name: 'Vondelpark', + desc: "Amsterdam's largest and most famous public park. A flat translucent green polygon outlines a section of its lush lawns and tranquil lakes.", + camera: { + center: { lat: 52.358, lng: 4.8685, altitude: 50 }, + range: 600, + tilt: 45, + heading: 250, + }, + stats: { + size: '🌳 120 acres', + highlight: 'Open Air Theatre', + }, + }, + { + name: 'Prinsengracht Canal', + desc: "One of Amsterdam's three main belt canals. Traced in Googly orange is a flat polyline showing the canal tour path floating at water level.", + camera: { + center: { lat: 52.3637, lng: 4.8855, altitude: 30 }, + range: 300, + tilt: 45, + heading: 330, + }, + stats: { + size: '⛵ 3.2 km length', + highlight: 'Historic Houseboats', + }, + }, + { + name: 'De Gooyer Windmill', + desc: 'The tallest wooden mill in the Netherlands. We load a dynamic 3D model of the windmill which rotates in real-time, standing next to the canal.', + camera: { + center: { lat: 52.3667, lng: 4.9263, altitude: 30 }, + range: 180, + tilt: 45, + heading: 135, + }, + stats: { + size: '⚙ 26 meters tall', + highlight: 'Built in 1725', + }, + }, +]; + +// Reference to map elements +let map: google.maps.maps3d.Map3DElement; +let windmillModel: google.maps.maps3d.Model3DElement; +let canalPolyline: google.maps.maps3d.Polyline3DElement; +let vondelparkPolygon: google.maps.maps3d.Polygon3DElement; +let museumPolygon: google.maps.maps3d.Polygon3DElement; +let museumFlattener: google.maps.maps3d.FlattenerElement; + +// Collections of pins +const standardMarkers: google.maps.maps3d.Marker3DInteractiveElement[] = []; +const standardPopovers: google.maps.maps3d.PopoverElement[] = []; + +// Tour State +let isTouring = false; +let currentStopIndex = -1; +let isAnimationCallbackActive = false; +let tourAnimationCleanup: (() => void) | null = null; + +async function init() { + const [ + { + Map3DElement, + Polyline3DElement, + Polygon3DElement, + Polygon3DInteractiveElement, + Model3DElement, + Marker3DInteractiveElement, + PopoverElement, + FlattenerElement, + }, + { PinElement }, + ] = await Promise.all([ + google.maps.importLibrary('maps3d'), + google.maps.importLibrary('marker'), + ]); + + // 1. Initialize the 3D Map (Centered on Rijksmuseum) + map = new Map3DElement({ + center: { lat: 52.36, lng: 4.8852, altitude: 800 }, + tilt: 40, + heading: 0, + range: 4000, + mode: 'SATELLITE', + }); + document.body.append(map); + + // 2. Create the layers + + // Polyline: Prinsengracht canal route (flat, orange) + canalPolyline = new Polyline3DElement({ + path: [ + { lat: 52.3622, lng: 4.89149 }, + { lat: 52.36276, lng: 4.88788 }, + { lat: 52.36614, lng: 4.88277 }, + { lat: 52.36673, lng: 4.88242 }, + { lat: 52.36673, lng: 4.88242 }, + ], + strokeColor: '#F37021', + strokeWidth: 6, + altitudeMode: 'CLAMP_TO_GROUND', + extruded: false, + drawsOccludedSegments: true, + }); + + // Polygon 1: Vondelpark lake/lawn zone (flat green) + vondelparkPolygon = new Polygon3DInteractiveElement({ + path: [ + { lat: 52.35639, lng: 4.85497 }, + { lat: 52.36108, lng: 4.87449 }, + { lat: 52.3593, lng: 4.87592 }, + { lat: 52.35511, lng: 4.86683 }, + { lat: 52.35457, lng: 4.85623 }, + ], + strokeColor: '#1e8e3e90', + strokeWidth: 3, + fillColor: '#1e8e3e40', + drawsOccludedSegments: false, + }); + + vondelparkPolygon.addEventListener('gmp-click', () => { + alert( + 'Welcome to Vondelpark! Enjoy the open lawns and winding pathways.' + ); + }); + + // Polygon 2: Rijksmuseum Building footprint (extruded) + museumPolygon = new Polygon3DElement({ + path: [ + { lat: 52.36029, lng: 4.88327, altitude: 25 }, + { lat: 52.36092, lng: 4.88502, altitude: 25 }, + { lat: 52.36011, lng: 4.8867, altitude: 25 }, + { lat: 52.35881, lng: 4.88627, altitude: 25 }, + { lat: 52.3592, lng: 4.88412, altitude: 25 }, + ], + strokeColor: '#4285F490', + strokeWidth: 3, + fillColor: '#4285F440', + altitudeMode: 'RELATIVE_TO_GROUND', + extruded: true, + }); + + // Rijksmuseum 3D flattener + museumFlattener = new FlattenerElement({ + path: [ + { lat: 52.36029, lng: 4.88327 }, + { lat: 52.36092, lng: 4.88502 }, + { lat: 52.36011, lng: 4.8867 }, + { lat: 52.35881, lng: 4.88627 }, + { lat: 52.3592, lng: 4.88412 }, + ], + }); + + // 3D Model: Windmill (placed at De Gooyer Windmill site) + windmillModel = new Model3DElement({ + src: 'https://maps-docs-team.web.app/assets/windmill.glb', + position: { lat: 52.3667, lng: 4.9263 }, + orientation: { heading: 0, tilt: 270, roll: 90 }, + scale: 0.15, + altitudeMode: 'CLAMP_TO_GROUND', + }); + + // 3. Create Standard Markers & Popovers with Custom HTML (at each Tour Stop) + const poiLocations = [ + { + id: 'rijksmuseum', + name: 'Rijksmuseum', + lat: 52.36, + lng: 4.8852, + alt: 35, + desc: 'The national museum of the Netherlands, home to masterpieces by Rembrandt and Vermeer.', + glyph: '🖼', + bg: '#4285F4', + highlight: "Rembrandt's Night Watch", + }, + { + id: 'vondelpark', + name: 'Vondelpark', + lat: 52.358, + lng: 4.8685, + alt: 20, + desc: "Amsterdam's historic public park, filled with cafes, ponds, and paths.", + glyph: '🌳', + bg: '#34A853', + highlight: 'Open Air Theatre', + }, + { + id: 'canal', + name: 'Prinsengracht Canal', + lat: 52.36409, + lng: 4.88584, + alt: 10, + desc: 'The longest of the main canal rings, known for its iconic houseboats.', + glyph: '⛵', + bg: '#FBBC05', + highlight: 'Historic Houseboats', + }, + { + id: 'degooyer', + name: 'De Gooyer Windmill', + lat: 52.3667, + lng: 4.9263, + alt: 30, + desc: 'A historic flour mill built in 1725, the tallest wooden mill in the country.', + glyph: '⚙', + bg: '#EA4335', + highlight: 'Built in 1725', + }, + ]; + + poiLocations.forEach((loc, index) => { + const pin = new PinElement({ + background: loc.bg, + glyph: loc.glyph, + borderColor: '#FFFFFF', + }); + + const interactiveMarker = new Marker3DInteractiveElement({ + position: { lat: loc.lat, lng: loc.lng, altitude: loc.alt }, + altitudeMode: 'RELATIVE_TO_GROUND', + extruded: true, + }); + interactiveMarker.append(pin); + + const popover = new PopoverElement({ + open: false, + positionAnchor: interactiveMarker, + }); + + const popoverContent = document.createElement('div'); + popoverContent.className = 'popover-custom-content'; + popoverContent.innerHTML = ` +
+ ${loc.glyph} +

${loc.name}

+
+

${loc.desc}

+
+
+ Highlight + ${loc.highlight} +
+
+ `; + popover.append(popoverContent); + + interactiveMarker.addEventListener('gmp-click', () => { + // Close others + standardPopovers.forEach((p, i) => { + if (i !== index) p.open = false; + }); + popover.open = !popover.open; + }); + + standardMarkers.push(interactiveMarker); + standardPopovers.push(popover); + }); + + // Sync initial states + syncLayers(); + syncRijksmuseumMesh(); + + // 5. Connect UI Event Listeners + setupUIListeners(); +} + +function syncLayers() { + const showMarkers = ( + document.getElementById('toggle-markers') as HTMLInputElement + ).checked; + const showPolyline = ( + document.getElementById('toggle-polyline') as HTMLInputElement + ).checked; + const showPolygons = ( + document.getElementById('toggle-polygons') as HTMLInputElement + ).checked; + const showModel = ( + document.getElementById('toggle-model') as HTMLInputElement + ).checked; + + // Standard Markers & Popovers + standardMarkers.forEach((marker, index) => { + if (showMarkers) { + map.append(marker); + map.append(standardPopovers[index]); + } else { + marker.remove(); + standardPopovers[index].remove(); + } + }); + + // Polyline + if (showPolyline) { + map.append(canalPolyline); + } else { + canalPolyline.remove(); + } + + // Polygons + if (showPolygons) { + map.append(vondelparkPolygon); + map.append(museumPolygon); + } else { + vondelparkPolygon.remove(); + museumPolygon.remove(); + } + + // 3D Model + if (showModel) { + map.append(windmillModel); + } else { + windmillModel.remove(); + } +} + +function syncRijksmuseumMesh() { + const showMesh = ( + document.getElementById('toggle-salesforce-mesh') as HTMLInputElement + ).checked; + if (showMesh) { + museumFlattener.remove(); + } else { + map.append(museumFlattener); + } +} + +function setupUIListeners() { + // Welcome dismiss + const welcome = document.getElementById('welcome-banner') as HTMLDivElement; + document + .getElementById('btn-welcome-dismiss') + ?.addEventListener('click', () => { + welcome.classList.add('hidden'); + }); + + // Tour buttons + const btnStart = document.getElementById( + 'btn-start-tour' + ) as HTMLButtonElement; + const btnStop = document.getElementById( + 'btn-stop-tour' + ) as HTMLButtonElement; + const tourNav = document.getElementById('btn-prev-stop') + ?.parentElement as HTMLDivElement; + + btnStart.addEventListener('click', () => { + isTouring = true; + btnStart.classList.add('hidden'); + btnStop.classList.remove('hidden'); + tourNav.classList.remove('hidden'); + welcome.classList.add('hidden'); + jumpToStop(0); + }); + + btnStop.addEventListener('click', () => { + endTour(); + }); + + document.getElementById('btn-prev-stop')?.addEventListener('click', () => { + if (currentStopIndex > 0) { + jumpToStop(currentStopIndex - 1); + } + }); + + document.getElementById('btn-next-stop')?.addEventListener('click', () => { + if (currentStopIndex < TOUR_STOPS.length - 1) { + jumpToStop(currentStopIndex + 1); + } + }); + + // Layer Toggle Listeners + document + .getElementById('toggle-markers') + ?.addEventListener('change', syncLayers); + + document + .getElementById('toggle-polyline') + ?.addEventListener('change', syncLayers); + document + .getElementById('toggle-polygons') + ?.addEventListener('change', syncLayers); + document + .getElementById('toggle-salesforce-mesh') + ?.addEventListener('change', syncRijksmuseumMesh); + document + .getElementById('toggle-model') + ?.addEventListener('change', syncLayers); + + // Map Mode Selectors + const modeSat = document.getElementById( + 'mode-satellite' + ) as HTMLButtonElement; + const modeHyb = document.getElementById('mode-hybrid') as HTMLButtonElement; + + modeSat.addEventListener('click', () => { + map.mode = 'SATELLITE'; + modeSat.classList.add('active'); + modeHyb.classList.remove('active'); + }); + + modeHyb.addEventListener('click', () => { + map.mode = 'HYBRID'; + modeHyb.classList.add('active'); + modeSat.classList.remove('active'); + }); + + // Listener for camera interrupt + map.addEventListener('gmp-click', () => { + if (isTouring) { + console.log( + 'Tour camera animation interrupted by user interaction.' + ); + } + }); +} + +function jumpToStop(index: number) { + if (tourAnimationCleanup) { + tourAnimationCleanup(); + } + + currentStopIndex = index; + isAnimationCallbackActive = true; + + // Update Tour Nav UI + const prevBtn = document.getElementById( + 'btn-prev-stop' + ) as HTMLButtonElement; + const nextBtn = document.getElementById( + 'btn-next-stop' + ) as HTMLButtonElement; + const progressText = document.getElementById( + 'tour-progress' + ) as HTMLSpanElement; + + prevBtn.disabled = index === 0; + nextBtn.disabled = index === TOUR_STOPS.length - 1; + progressText.textContent = `Stop ${String(index + 1)} of ${String(TOUR_STOPS.length)}`; + + // Open active stop popover and close all others + standardPopovers.forEach((p, i) => { + p.open = i === index; + }); + + const stop = TOUR_STOPS[index]; + const flightStartTime = Date.now(); + + // Perform camera FlyTo Animation (18s duration for cinematic smoothness) + map.flyCameraTo({ + endCamera: stop.camera, + durationMillis: 18000, + }); + + const listener = () => { + const elapsed = Date.now() - flightStartTime; + if ( + isAnimationCallbackActive && + currentStopIndex === index && + elapsed >= 17000 + ) { + if (tourAnimationCleanup === cleanup) { + tourAnimationCleanup = null; + } + map.removeEventListener('gmp-animationend', listener); + flyAroundStop(index); + } + }; + + const cleanup = () => { + map.removeEventListener('gmp-animationend', listener); + }; + tourAnimationCleanup = cleanup; + + map.addEventListener('gmp-animationend', listener); +} + +function flyAroundStop(index: number) { + isAnimationCallbackActive = false; // Prevent loop trigger + const stop = TOUR_STOPS[index]; + const spinStartTime = Date.now(); + + // Slower, smoother rotation (30s duration) + map.flyCameraAround({ + camera: stop.camera, + durationMillis: 30000, + repeatCount: 1, + }); + + const listener = () => { + const elapsed = Date.now() - spinStartTime; + if (isTouring && currentStopIndex === index && elapsed >= 29000) { + if (tourAnimationCleanup === cleanup) { + tourAnimationCleanup = null; + } + map.removeEventListener('gmp-animationend', listener); + // Wait 3 seconds at current view, then fly to the next stop + window.setTimeout(() => { + if (isTouring && currentStopIndex === index) { + if (index < TOUR_STOPS.length - 1) { + jumpToStop(index + 1); + } else { + endTour(); + } + } + }, 3000); + } + }; + + const cleanup = () => { + map.removeEventListener('gmp-animationend', listener); + }; + tourAnimationCleanup = cleanup; + + map.addEventListener('gmp-animationend', listener); +} + +function endTour() { + isTouring = false; + currentStopIndex = -1; + isAnimationCallbackActive = false; + map.stopCameraAnimation(); + + if (tourAnimationCleanup) { + tourAnimationCleanup(); + tourAnimationCleanup = null; + } + + // Close all popovers + standardPopovers.forEach((p) => (p.open = false)); + + const btnStart = document.getElementById( + 'btn-start-tour' + ) as HTMLButtonElement; + const btnStop = document.getElementById( + 'btn-stop-tour' + ) as HTMLButtonElement; + const tourNav = document.getElementById('btn-prev-stop') + ?.parentElement as HTMLDivElement; + + btnStart.classList.remove('hidden'); + btnStop.classList.add('hidden'); + tourNav.classList.add('hidden'); +} + +window.addEventListener('load', () => { + void init(); +}); diff --git a/dist/samples/3d-hero-showcase/app/package.json b/dist/samples/3d-hero-showcase/app/package.json new file mode 100644 index 000000000..6e9cc1ad7 --- /dev/null +++ b/dist/samples/3d-hero-showcase/app/package.json @@ -0,0 +1,11 @@ +{ + "name": "@js-api-samples/3d-hero-showcase", + "version": "1.0.0", + "scripts": { + "build": "bash ../build-single.sh", + "test": "tsc && npm run build:vite --workspace=.", + "start": "tsc && vite build --base './' && vite", + "build:vite": "vite build --base './'", + "preview": "vite preview" + } +} diff --git a/dist/samples/3d-hero-showcase/app/style.css b/dist/samples/3d-hero-showcase/app/style.css new file mode 100644 index 000000000..285232b39 --- /dev/null +++ b/dist/samples/3d-hero-showcase/app/style.css @@ -0,0 +1,649 @@ +/* + * @license + * Copyright 2026 Google LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* [START maps_3d_hero_showcase] */ +:root { + --bg-color: #ffffff; + --border-color: #dadce0; + --text-primary: #202124; + --text-secondary: #5f6368; + --google-blue: #1a73e8; + --google-blue-hover: #1557b0; + --google-blue-light: #e8f0fe; + --google-red: #d93025; + --google-red-hover: #b31412; + --google-green: #1e8e3e; + --google-yellow: #f9ab00; + --light-grey: #f8f9fa; + --card-shadow: + 0 1px 3px rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15); + --font-sans: 'Google Sans', 'Outfit', 'Roboto', sans-serif; +} + +html, +body { + height: 100%; + margin: 0; + padding: 0; + font-family: var(--font-sans); + background-color: #f1f3f4; + overflow: hidden; + color: var(--text-primary); +} + +/* Base Map */ +gmp-map-3d { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + z-index: 1; +} + +/* Googly Floating Cards */ +.google-card { + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 20px; + box-shadow: var(--card-shadow); + z-index: 10; + display: flex; + flex-direction: column; + box-sizing: border-box; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + overflow: hidden; +} + +/* Control Panel Sidebar */ +#control-panel { + position: absolute; + top: 16px; + left: 16px; + width: 330px; + max-height: calc(100% - 32px); + padding: 18px; +} + +.card-header { + padding-bottom: 12px; + border-bottom: 1px solid #e8eaed; + margin-bottom: 14px; +} + +.logo-area { + display: flex; + align-items: center; + gap: 8px; +} + +.logo-text { + font-size: 21px; + font-weight: 700; + letter-spacing: -0.5px; +} + +.badge-3d { + background: var(--google-blue-light); + color: var(--google-blue); + font-size: 10px; + font-weight: 700; + padding: 2px 6px; + border-radius: 6px; +} + +/* Google Brand Colors */ +.g-blue { + color: var(--google-blue); +} +.g-red { + color: var(--google-red); +} +.g-yellow { + color: var(--google-yellow); +} +.g-green { + color: var(--google-green); +} + +.subtitle { + font-size: 12px; + color: var(--text-secondary); + margin: 4px 0 0 0; + font-weight: 500; +} + +.scrollable-content { + flex: 1; + overflow-y: auto; + padding-right: 4px; +} + +/* Scrollbar styles */ +.scrollable-content::-webkit-scrollbar { + width: 4px; +} +.scrollable-content::-webkit-scrollbar-track { + background: transparent; +} +.scrollable-content::-webkit-scrollbar-thumb { + background: #dadce0; + border-radius: 10px; +} + +.action-block { + margin-bottom: 16px; +} + +.option-block { + border-top: 1px solid #e8eaed; + padding-top: 14px; + margin-bottom: 16px; +} + +.option-block h3 { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--text-secondary); + margin-top: 0; + margin-bottom: 10px; + font-weight: 700; +} + +/* Google style Buttons */ +.google-btn { + font-family: var(--font-sans); + font-size: 13.5px; + font-weight: 500; + border-radius: 20px; + padding: 9px 18px; + border: 1px solid var(--border-color); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: #ffffff; + color: var(--google-blue); + transition: + background-color 0.2s, + border-color 0.2s, + box-shadow 0.2s; + width: 100%; +} + +.google-btn:hover { + background-color: var(--light-grey); + border-color: #dadce0; + box-shadow: 0 1px 2px rgba(60, 64, 67, 0.3); +} + +.primary-btn { + background: var(--google-blue); + color: white; + border: none; +} + +.primary-btn:hover { + background: var(--google-blue-hover); + box-shadow: + 0 1px 3px rgba(60, 64, 67, 0.3), + 0 1px 2px rgba(0, 0, 0, 0.15); +} + +.danger-btn { + background: var(--google-red); + color: white; + border: none; +} + +.danger-btn:hover { + background: var(--google-red-hover); + box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3); +} + +.secondary-btn { + background: #ffffff; + color: var(--text-primary); +} + +.secondary-btn:hover { + background: var(--light-grey); +} + +.text-btn { + background: transparent; + border: none; + color: var(--google-blue); + font-weight: 700; + width: auto; + padding: 6px 12px; +} + +.text-btn:hover { + background: var(--google-blue-light); + box-shadow: none; +} + +.small-btn { + padding: 5px 12px; + font-size: 11.5px; + border-radius: 12px; + width: auto; +} + +/* Tour Nav pills */ +.tour-nav { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 10px; + background: var(--light-grey); + padding: 6px 10px; + border-radius: 14px; + border: 1px solid var(--border-color); +} + +.nav-arrow { + background: transparent; + border: none; + cursor: pointer; + font-size: 11px; + color: var(--text-secondary); + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.nav-arrow:hover:not(:disabled) { + background: #dadce0; + color: var(--text-primary); +} + +.nav-arrow:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.progress-text { + font-size: 12.5px; + font-weight: 500; + color: var(--text-primary); +} + +/* Switch Rows */ +.toggle-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.switch-row { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; +} + +.switch-label { + font-size: 13.5px; + font-weight: 500; + color: var(--text-primary); + display: flex; + align-items: center; +} + +.switch-label .emoji { + margin-right: 8px; + font-size: 15px; +} + +/* Lever switches */ +.switch-control { + position: relative; + width: 32px; + height: 14px; +} + +.switch-control input { + opacity: 0; + width: 0; + height: 0; +} + +.lever { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #bdc1c6; + transition: 0.15s; + border-radius: 14px; +} + +.lever:before { + position: absolute; + content: ''; + height: 20px; + width: 20px; + left: -4px; + bottom: -3px; + background-color: #ffffff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); + transition: 0.15s; + border-radius: 50%; +} + +input:checked + .lever { + background-color: #8ab4f8; +} + +input:checked + .lever:before { + transform: translateX(18px); + background-color: var(--google-blue); +} + +/* Segmented view controls */ +.segmented-control { + display: flex; + background: var(--light-grey); + padding: 2px; + border-radius: 12px; + border: 1px solid var(--border-color); +} + +.segment-btn { + flex: 1; + background: transparent; + border: none; + color: var(--text-secondary); + padding: 6px 10px; + border-radius: 10px; + font-size: 12.5px; + font-weight: 500; + cursor: pointer; + font-family: var(--font-sans); + transition: all 0.15s; +} + +.segment-btn.active { + background: #ffffff; + color: var(--google-blue); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); + font-weight: 700; +} + +.card-footer { + border-top: 1px solid #e8eaed; + padding-top: 8px; + margin-top: 12px; + font-size: 10.5px; + color: var(--text-secondary); + text-align: center; + font-weight: 500; +} + +/* Bottom Info Drawer */ +.info-drawer { + position: absolute; + bottom: 20px; + left: calc(50% + 165px); /* Adjusted center offset for Googly panel */ + transform: translateX(-50%); + width: 550px; + padding: 16px 20px; + max-width: calc(100% - 390px); +} + +.drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +#info-title { + font-size: 18px; + font-weight: 700; + margin: 0; + color: var(--text-primary); + letter-spacing: -0.3px; +} + +#info-desc { + font-size: 13.5px; + color: var(--text-secondary); + margin: 0 0 12px 0; + line-height: 1.45; +} + +.metrics-row { + display: flex; + gap: 20px; + background: var(--light-grey); + border-radius: 12px; + padding: 8px 16px; + border: 1px solid var(--border-color); +} + +.metric-item { + flex: 1; + display: flex; + flex-direction: column; +} + +.metric-lbl { + font-size: 10px; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: 2px; + font-weight: 700; + letter-spacing: 0.5px; +} + +.metric-val { + font-size: 13.5px; + font-weight: 700; + color: var(--text-primary); +} + +.highlight-val { + color: var(--google-blue); +} + +/* Welcome Overlay */ +.welcome-toast { + position: absolute; + top: 16px; + right: 16px; + width: 280px; + padding: 14px; +} + +.toast-content h3 { + margin-top: 0; + margin-bottom: 4px; + font-size: 14px; + font-weight: 700; +} + +.toast-content p { + font-size: 12.5px; + color: var(--text-secondary); + margin: 0 0 10px 0; + line-height: 1.4; +} + +.toast-content button { + float: right; +} + +/* Helpers */ +.hidden, +.google-card.hidden, +.google-btn.hidden, +.tour-nav.hidden { + display: none; +} + +.google-card.hidden { + opacity: 0; + pointer-events: none; + transform: translate(-50%, 15px); +} + +/* Googly Custom Marker Badge */ +.custom-badge { + display: flex; + align-items: center; + gap: 8px; + background: #ffffff; + border: 1px solid var(--border-color); + border-radius: 20px; + padding: 4px 10px 4px 4px; + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 12px; + font-weight: 700; + box-shadow: + 0 2px 6px rgba(60, 64, 67, 0.15), + 0 1px 2px rgba(60, 64, 67, 0.3); + white-space: nowrap; + user-select: none; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + transform-origin: bottom center; +} + +.custom-badge:hover { + transform: scale(1.05); + box-shadow: + 0 4px 12px rgba(60, 64, 67, 0.2), + 0 1px 3px rgba(60, 64, 67, 0.35); + border-color: #bdc1c6; +} + +.badge-icon { + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--google-blue-light); + color: var(--google-blue); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; +} + +.badge-details { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.badge-title { + font-size: 9px; + color: var(--text-secondary); + font-weight: 500; + line-height: 1; + margin-bottom: 1px; +} + +.badge-value { + font-size: 11.5px; + font-weight: 700; + color: var(--text-primary); + line-height: 1; +} + +/* Responsive */ +@media (max-width: 900px) { + #control-panel { + top: 10px; + left: 10px; + width: calc(100% - 20px); + max-height: 45%; + } +} + +/* Popover Custom Styling */ +.popover-custom-content { + width: 260px; + color: var(--text-primary); + font-family: var(--font-sans); + background: #ffffff; + padding: 2px; +} + +.popover-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + border-bottom: 1px solid #e8eaed; + padding-bottom: 6px; +} + +.popover-emoji { + font-size: 18px; +} + +.popover-header h4 { + font-size: 15px; + margin: 0; + font-weight: 700; + color: var(--text-primary); +} + +.popover-desc { + font-size: 12.5px; + color: var(--text-secondary); + margin: 0 0 10px 0; + line-height: 1.4; +} + +.popover-stats { + display: flex; + gap: 12px; + background: var(--light-grey); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 6px 10px; +} + +.popover-stat-item { + flex: 1; + display: flex; + flex-direction: column; +} + +.popover-stat-lbl { + font-size: 8px; + text-transform: uppercase; + color: var(--text-secondary); + font-weight: 700; + letter-spacing: 0.3px; + margin-bottom: 2px; +} + +.popover-stat-val { + font-size: 11.5px; + font-weight: 700; + color: var(--text-primary); + line-height: 1.1; +} + +.popover-stat-val.highlight-val { + color: var(--google-blue); +} +/* [END maps_3d_hero_showcase] */ diff --git a/dist/samples/3d-hero-showcase/app/tsconfig.json b/dist/samples/3d-hero-showcase/app/tsconfig.json new file mode 100644 index 000000000..976bcc6ef --- /dev/null +++ b/dist/samples/3d-hero-showcase/app/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["./*.ts"] +} diff --git a/dist/samples/3d-hero-showcase/dist/assets/index-CpUaCuEf.css b/dist/samples/3d-hero-showcase/dist/assets/index-CpUaCuEf.css new file mode 100644 index 000000000..31a414d96 --- /dev/null +++ b/dist/samples/3d-hero-showcase/dist/assets/index-CpUaCuEf.css @@ -0,0 +1 @@ +:root{--bg-color:#fff;--border-color:#dadce0;--text-primary:#202124;--text-secondary:#5f6368;--google-blue:#1a73e8;--google-blue-hover:#1557b0;--google-blue-light:#e8f0fe;--google-red:#d93025;--google-red-hover:#b31412;--google-green:#1e8e3e;--google-yellow:#f9ab00;--light-grey:#f8f9fa;--card-shadow:0 1px 3px #3c40434d, 0 4px 8px 3px #3c404326;--font-sans:"Google Sans", "Outfit", "Roboto", sans-serif}html,body{height:100%;font-family:var(--font-sans);color:var(--text-primary);background-color:#f1f3f4;margin:0;padding:0;overflow:hidden}gmp-map-3d{z-index:1;width:100%;height:100%;position:absolute;top:0;left:0}.google-card{background:var(--bg-color);border:1px solid var(--border-color);box-shadow:var(--card-shadow);z-index:10;box-sizing:border-box;border-radius:20px;flex-direction:column;transition:all .3s cubic-bezier(.4,0,.2,1);display:flex;overflow:hidden}#control-panel{width:330px;max-height:calc(100% - 32px);padding:18px;position:absolute;top:16px;left:16px}.card-header{border-bottom:1px solid #e8eaed;margin-bottom:14px;padding-bottom:12px}.logo-area{align-items:center;gap:8px;display:flex}.logo-text{letter-spacing:-.5px;font-size:21px;font-weight:700}.badge-3d{background:var(--google-blue-light);color:var(--google-blue);border-radius:6px;padding:2px 6px;font-size:10px;font-weight:700}.g-blue{color:var(--google-blue)}.g-red{color:var(--google-red)}.g-yellow{color:var(--google-yellow)}.g-green{color:var(--google-green)}.subtitle{color:var(--text-secondary);margin:4px 0 0;font-size:12px;font-weight:500}.scrollable-content{flex:1;padding-right:4px;overflow-y:auto}.scrollable-content::-webkit-scrollbar{width:4px}.scrollable-content::-webkit-scrollbar-track{background:0 0}.scrollable-content::-webkit-scrollbar-thumb{background:#dadce0;border-radius:10px}.action-block{margin-bottom:16px}.option-block{border-top:1px solid #e8eaed;margin-bottom:16px;padding-top:14px}.option-block h3{text-transform:uppercase;letter-spacing:.8px;color:var(--text-secondary);margin-top:0;margin-bottom:10px;font-size:11px;font-weight:700}.google-btn{font-family:var(--font-sans);border:1px solid var(--border-color);cursor:pointer;color:var(--google-blue);background:#fff;border-radius:20px;justify-content:center;align-items:center;gap:8px;width:100%;padding:9px 18px;font-size:13.5px;font-weight:500;transition:background-color .2s,border-color .2s,box-shadow .2s;display:flex}.google-btn:hover{background-color:var(--light-grey);border-color:#dadce0;box-shadow:0 1px 2px #3c40434d}.primary-btn{background:var(--google-blue);color:#fff;border:none}.primary-btn:hover{background:var(--google-blue-hover);box-shadow:0 1px 3px #3c40434d,0 1px 2px #00000026}.danger-btn{background:var(--google-red);color:#fff;border:none}.danger-btn:hover{background:var(--google-red-hover);box-shadow:0 1px 3px #3c40434d}.secondary-btn{color:var(--text-primary);background:#fff}.secondary-btn:hover{background:var(--light-grey)}.text-btn{color:var(--google-blue);background:0 0;border:none;width:auto;padding:6px 12px;font-weight:700}.text-btn:hover{background:var(--google-blue-light);box-shadow:none}.small-btn{border-radius:12px;width:auto;padding:5px 12px;font-size:11.5px}.tour-nav{background:var(--light-grey);border:1px solid var(--border-color);border-radius:14px;justify-content:space-between;align-items:center;margin-top:10px;padding:6px 10px;display:flex}.nav-arrow{cursor:pointer;color:var(--text-secondary);background:0 0;border:none;border-radius:50%;justify-content:center;align-items:center;width:28px;height:28px;font-size:11px;display:flex}.nav-arrow:hover:not(:disabled){color:var(--text-primary);background:#dadce0}.nav-arrow:disabled{opacity:.3;cursor:not-allowed}.progress-text{color:var(--text-primary);font-size:12.5px;font-weight:500}.toggle-list{flex-direction:column;gap:10px;display:flex}.switch-row{cursor:pointer;justify-content:space-between;align-items:center;display:flex}.switch-label{color:var(--text-primary);align-items:center;font-size:13.5px;font-weight:500;display:flex}.switch-label .emoji{margin-right:8px;font-size:15px}.switch-control{width:32px;height:14px;position:relative}.switch-control input{opacity:0;width:0;height:0}.lever{cursor:pointer;background-color:#bdc1c6;border-radius:14px;transition:all .15s;position:absolute;inset:0}.lever:before{content:"";background-color:#fff;border-radius:50%;width:20px;height:20px;transition:all .15s;position:absolute;bottom:-3px;left:-4px;box-shadow:0 1px 3px #0006}input:checked+.lever{background-color:#8ab4f8}input:checked+.lever:before{background-color:var(--google-blue);transform:translate(18px)}.segmented-control{background:var(--light-grey);border:1px solid var(--border-color);border-radius:12px;padding:2px;display:flex}.segment-btn{color:var(--text-secondary);cursor:pointer;font-size:12.5px;font-weight:500;font-family:var(--font-sans);background:0 0;border:none;border-radius:10px;flex:1;padding:6px 10px;transition:all .15s}.segment-btn.active{color:var(--google-blue);background:#fff;font-weight:700;box-shadow:0 1px 3px #0000001f}.card-footer{color:var(--text-secondary);text-align:center;border-top:1px solid #e8eaed;margin-top:12px;padding-top:8px;font-size:10.5px;font-weight:500}.info-drawer{width:550px;max-width:calc(100% - 390px);padding:16px 20px;position:absolute;bottom:20px;left:calc(50% + 165px);transform:translate(-50%)}.drawer-header{justify-content:space-between;align-items:center;margin-bottom:8px;display:flex}#info-title{color:var(--text-primary);letter-spacing:-.3px;margin:0;font-size:18px;font-weight:700}#info-desc{color:var(--text-secondary);margin:0 0 12px;font-size:13.5px;line-height:1.45}.metrics-row{background:var(--light-grey);border:1px solid var(--border-color);border-radius:12px;gap:20px;padding:8px 16px;display:flex}.metric-item{flex-direction:column;flex:1;display:flex}.metric-lbl{text-transform:uppercase;color:var(--text-secondary);letter-spacing:.5px;margin-bottom:2px;font-size:10px;font-weight:700}.metric-val{color:var(--text-primary);font-size:13.5px;font-weight:700}.highlight-val{color:var(--google-blue)}.welcome-toast{width:280px;padding:14px;position:absolute;top:16px;right:16px}.toast-content h3{margin-top:0;margin-bottom:4px;font-size:14px;font-weight:700}.toast-content p{color:var(--text-secondary);margin:0 0 10px;font-size:12.5px;line-height:1.4}.toast-content button{float:right}.hidden,.google-card.hidden,.google-btn.hidden,.tour-nav.hidden{display:none}.google-card.hidden{opacity:0;pointer-events:none;transform:translate(-50%,15px)}.custom-badge{border:1px solid var(--border-color);color:var(--text-primary);font-family:var(--font-sans);white-space:nowrap;-webkit-user-select:none;user-select:none;cursor:pointer;transform-origin:bottom;background:#fff;border-radius:20px;align-items:center;gap:8px;padding:4px 10px 4px 4px;font-size:12px;font-weight:700;transition:transform .2s,box-shadow .2s;display:flex;box-shadow:0 2px 6px #3c404326,0 1px 2px #3c40434d}.custom-badge:hover{border-color:#bdc1c6;transform:scale(1.05);box-shadow:0 4px 12px #3c404333,0 1px 3px #3c404359}.badge-icon{background:var(--google-blue-light);width:24px;height:24px;color:var(--google-blue);border-radius:50%;justify-content:center;align-items:center;font-size:13px;display:flex}.badge-details{flex-direction:column;align-items:flex-start;display:flex}.badge-title{color:var(--text-secondary);margin-bottom:1px;font-size:9px;font-weight:500;line-height:1}.badge-value{color:var(--text-primary);font-size:11.5px;font-weight:700;line-height:1}@media (width<=900px){#control-panel{width:calc(100% - 20px);max-height:45%;top:10px;left:10px}}.popover-custom-content{width:260px;color:var(--text-primary);font-family:var(--font-sans);background:#fff;padding:2px}.popover-header{border-bottom:1px solid #e8eaed;align-items:center;gap:8px;margin-bottom:8px;padding-bottom:6px;display:flex}.popover-emoji{font-size:18px}.popover-header h4{color:var(--text-primary);margin:0;font-size:15px;font-weight:700}.popover-desc{color:var(--text-secondary);margin:0 0 10px;font-size:12.5px;line-height:1.4}.popover-stats{background:var(--light-grey);border:1px solid var(--border-color);border-radius:8px;gap:12px;padding:6px 10px;display:flex}.popover-stat-item{flex-direction:column;flex:1;display:flex}.popover-stat-lbl{text-transform:uppercase;color:var(--text-secondary);letter-spacing:.3px;margin-bottom:2px;font-size:8px;font-weight:700}.popover-stat-val{color:var(--text-primary);font-size:11.5px;font-weight:700;line-height:1.1}.popover-stat-val.highlight-val{color:var(--google-blue)} diff --git a/dist/samples/3d-hero-showcase/dist/assets/index-E0EpP32H.js b/dist/samples/3d-hero-showcase/dist/assets/index-E0EpP32H.js new file mode 100644 index 000000000..60efc426b --- /dev/null +++ b/dist/samples/3d-hero-showcase/dist/assets/index-E0EpP32H.js @@ -0,0 +1,13 @@ +(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var e=[{name:`Rijksmuseum`,desc:`The national museum of the Netherlands. The footprint of this grand Gothic-Renaissance building is highlighted, and its 3D building mesh can be toggled on or off.`,camera:{center:{lat:52.36,lng:4.8852,altitude:40},range:280,tilt:45,heading:180},stats:{size:`🖼 8,000+ objects`,highlight:`Rembrandt's Night Watch`}},{name:`Vondelpark`,desc:`Amsterdam's largest and most famous public park. A flat translucent green polygon outlines a section of its lush lawns and tranquil lakes.`,camera:{center:{lat:52.358,lng:4.8685,altitude:50},range:600,tilt:45,heading:250},stats:{size:`🌳 120 acres`,highlight:`Open Air Theatre`}},{name:`Prinsengracht Canal`,desc:`One of Amsterdam's three main belt canals. Traced in Googly orange is a flat polyline showing the canal tour path floating at water level.`,camera:{center:{lat:52.3637,lng:4.8855,altitude:30},range:300,tilt:45,heading:330},stats:{size:`⛵ 3.2 km length`,highlight:`Historic Houseboats`}},{name:`De Gooyer Windmill`,desc:`The tallest wooden mill in the Netherlands. We load a dynamic 3D model of the windmill which rotates in real-time, standing next to the canal.`,camera:{center:{lat:52.3667,lng:4.9263,altitude:30},range:180,tilt:45,heading:135},stats:{size:`⚙ 26 meters tall`,highlight:`Built in 1725`}}],t,n,r,i,a,o,s=[],c=[],l=!1,u=-1,d=!1,f=null;async function p(){let[{Map3DElement:e,Polyline3DElement:l,Polygon3DElement:u,Polygon3DInteractiveElement:d,Model3DElement:f,Marker3DInteractiveElement:p,PopoverElement:_,FlattenerElement:v},{PinElement:y}]=await Promise.all([google.maps.importLibrary(`maps3d`),google.maps.importLibrary(`marker`)]);t=new e({center:{lat:52.36,lng:4.8852,altitude:800},tilt:40,heading:0,range:4e3,mode:`SATELLITE`}),document.body.append(t),r=new l({path:[{lat:52.3622,lng:4.89149},{lat:52.36276,lng:4.88788},{lat:52.36614,lng:4.88277},{lat:52.36673,lng:4.88242},{lat:52.36673,lng:4.88242}],strokeColor:`#F37021`,strokeWidth:6,altitudeMode:`CLAMP_TO_GROUND`,extruded:!1,drawsOccludedSegments:!0}),i=new d({path:[{lat:52.35639,lng:4.85497},{lat:52.36108,lng:4.87449},{lat:52.3593,lng:4.87592},{lat:52.35511,lng:4.86683},{lat:52.35457,lng:4.85623}],strokeColor:`#1e8e3e90`,strokeWidth:3,fillColor:`#1e8e3e40`,drawsOccludedSegments:!1}),i.addEventListener(`gmp-click`,()=>{alert(`Welcome to Vondelpark! Enjoy the open lawns and winding pathways.`)}),a=new u({path:[{lat:52.36029,lng:4.88327,altitude:25},{lat:52.36092,lng:4.88502,altitude:25},{lat:52.36011,lng:4.8867,altitude:25},{lat:52.35881,lng:4.88627,altitude:25},{lat:52.3592,lng:4.88412,altitude:25}],strokeColor:`#4285F490`,strokeWidth:3,fillColor:`#4285F440`,altitudeMode:`RELATIVE_TO_GROUND`,extruded:!0}),o=new v({path:[{lat:52.36029,lng:4.88327},{lat:52.36092,lng:4.88502},{lat:52.36011,lng:4.8867},{lat:52.35881,lng:4.88627},{lat:52.3592,lng:4.88412}]}),n=new f({src:`https://maps-docs-team.web.app/assets/windmill.glb`,position:{lat:52.3667,lng:4.9263},orientation:{heading:0,tilt:270,roll:90},scale:.15,altitudeMode:`CLAMP_TO_GROUND`}),[{id:`rijksmuseum`,name:`Rijksmuseum`,lat:52.36,lng:4.8852,alt:35,desc:`The national museum of the Netherlands, home to masterpieces by Rembrandt and Vermeer.`,glyph:`🖼`,bg:`#4285F4`,highlight:`Rembrandt's Night Watch`},{id:`vondelpark`,name:`Vondelpark`,lat:52.358,lng:4.8685,alt:20,desc:`Amsterdam's historic public park, filled with cafes, ponds, and paths.`,glyph:`🌳`,bg:`#34A853`,highlight:`Open Air Theatre`},{id:`canal`,name:`Prinsengracht Canal`,lat:52.36409,lng:4.88584,alt:10,desc:`The longest of the main canal rings, known for its iconic houseboats.`,glyph:`⛵`,bg:`#FBBC05`,highlight:`Historic Houseboats`},{id:`degooyer`,name:`De Gooyer Windmill`,lat:52.3667,lng:4.9263,alt:30,desc:`A historic flour mill built in 1725, the tallest wooden mill in the country.`,glyph:`⚙`,bg:`#EA4335`,highlight:`Built in 1725`}].forEach((e,t)=>{let n=new y({background:e.bg,glyph:e.glyph,borderColor:`#FFFFFF`}),r=new p({position:{lat:e.lat,lng:e.lng,altitude:e.alt},altitudeMode:`RELATIVE_TO_GROUND`,extruded:!0});r.append(n);let i=new _({open:!1,positionAnchor:r}),a=document.createElement(`div`);a.className=`popover-custom-content`,a.innerHTML=` +
+ ${e.glyph} +

${e.name}

+
+

${e.desc}

+
+
+ Highlight + ${e.highlight} +
+
+ `,i.append(a),r.addEventListener(`gmp-click`,()=>{c.forEach((e,n)=>{n!==t&&(e.open=!1)}),i.open=!i.open}),s.push(r),c.push(i)}),m(),h(),g()}function m(){let e=document.getElementById(`toggle-markers`).checked,o=document.getElementById(`toggle-polyline`).checked,l=document.getElementById(`toggle-polygons`).checked,u=document.getElementById(`toggle-model`).checked;s.forEach((n,r)=>{e?(t.append(n),t.append(c[r])):(n.remove(),c[r].remove())}),o?t.append(r):r.remove(),l?(t.append(i),t.append(a)):(i.remove(),a.remove()),u?t.append(n):n.remove()}function h(){document.getElementById(`toggle-salesforce-mesh`).checked?o.remove():t.append(o)}function g(){let n=document.getElementById(`welcome-banner`);document.getElementById(`btn-welcome-dismiss`)?.addEventListener(`click`,()=>{n.classList.add(`hidden`)});let r=document.getElementById(`btn-start-tour`),i=document.getElementById(`btn-stop-tour`),a=document.getElementById(`btn-prev-stop`)?.parentElement;r.addEventListener(`click`,()=>{l=!0,r.classList.add(`hidden`),i.classList.remove(`hidden`),a.classList.remove(`hidden`),n.classList.add(`hidden`),_(0)}),i.addEventListener(`click`,()=>{y()}),document.getElementById(`btn-prev-stop`)?.addEventListener(`click`,()=>{u>0&&_(u-1)}),document.getElementById(`btn-next-stop`)?.addEventListener(`click`,()=>{u{t.mode=`SATELLITE`,o.classList.add(`active`),s.classList.remove(`active`)}),s.addEventListener(`click`,()=>{t.mode=`HYBRID`,s.classList.add(`active`),o.classList.remove(`active`)}),t.addEventListener(`gmp-click`,()=>{l&&console.log(`Tour camera animation interrupted by user interaction.`)})}function _(n){f&&f(),u=n,d=!0;let r=document.getElementById(`btn-prev-stop`),i=document.getElementById(`btn-next-stop`),a=document.getElementById(`tour-progress`);r.disabled=n===0,i.disabled=n===e.length-1,a.textContent=`Stop ${String(n+1)} of ${String(e.length)}`,c.forEach((e,t)=>{e.open=t===n});let o=e[n],s=Date.now();t.flyCameraTo({endCamera:o.camera,durationMillis:18e3});let l=()=>{let e=Date.now()-s;d&&u===n&&e>=17e3&&(f===p&&(f=null),t.removeEventListener(`gmp-animationend`,l),v(n))},p=()=>{t.removeEventListener(`gmp-animationend`,l)};f=p,t.addEventListener(`gmp-animationend`,l)}function v(n){d=!1;let r=e[n],i=Date.now();t.flyCameraAround({camera:r.camera,durationMillis:3e4,repeatCount:1});let a=()=>{let r=Date.now()-i;l&&u===n&&r>=29e3&&(f===o&&(f=null),t.removeEventListener(`gmp-animationend`,a),window.setTimeout(()=>{l&&u===n&&(n{t.removeEventListener(`gmp-animationend`,a)};f=o,t.addEventListener(`gmp-animationend`,a)}function y(){l=!1,u=-1,d=!1,t.stopCameraAnimation(),f&&=(f(),null),c.forEach(e=>e.open=!1);let e=document.getElementById(`btn-start-tour`),n=document.getElementById(`btn-stop-tour`),r=document.getElementById(`btn-prev-stop`)?.parentElement;e.classList.remove(`hidden`),n.classList.add(`hidden`),r.classList.add(`hidden`)}window.addEventListener(`load`,()=>{p()}); \ No newline at end of file diff --git a/dist/samples/3d-hero-showcase/dist/index.html b/dist/samples/3d-hero-showcase/dist/index.html new file mode 100644 index 000000000..5f2624765 --- /dev/null +++ b/dist/samples/3d-hero-showcase/dist/index.html @@ -0,0 +1,189 @@ + + + + + + Amsterdam 3D Explorer + + + + + + + + + + + + + + +
+
+
+ + A + m + s + t + e + r + d + a + m + + 3D +
+

Rijksmuseum & attractions

+
+ +
+ +
+ + + + +
+ + +
+

Map Mode

+
+ + +
+
+ + +
+

Map Layers

+
+ + + + + + + + + +
+
+
+ + +
+ + +
+
+

Explore Amsterdam in 3D

+

+ Start the tour to fly around the Rijksmuseum, Vondelpark, + Prinsengracht, and De Gooyer Windmill. +

+ +
+
+ + + diff --git a/dist/samples/3d-hero-showcase/docs/index.html b/dist/samples/3d-hero-showcase/docs/index.html new file mode 100644 index 000000000..50cb6a31b --- /dev/null +++ b/dist/samples/3d-hero-showcase/docs/index.html @@ -0,0 +1,189 @@ + + + + + + Amsterdam 3D Explorer + + + + + + + + + + + + + + +
+
+
+ + A + m + s + t + e + r + d + a + m + + 3D +
+

Rijksmuseum & attractions

+
+ +
+ +
+ + + + +
+ + +
+

Map Mode

+
+ + +
+
+ + +
+

Map Layers

+
+ + + + + + + + + +
+
+
+ + +
+ + +
+
+

Explore Amsterdam in 3D

+

+ Start the tour to fly around the Rijksmuseum, Vondelpark, + Prinsengracht, and De Gooyer Windmill. +

+ +
+
+ + + diff --git a/dist/samples/3d-hero-showcase/docs/index.js b/dist/samples/3d-hero-showcase/docs/index.js new file mode 100644 index 000000000..3c083b2d8 --- /dev/null +++ b/dist/samples/3d-hero-showcase/docs/index.js @@ -0,0 +1,539 @@ +'use strict'; + +const TOUR_STOPS = [ + { + name: 'Rijksmuseum', + desc: 'The national museum of the Netherlands. The footprint of this grand Gothic-Renaissance building is highlighted, and its 3D building mesh can be toggled on or off.', + camera: { + center: { lat: 52.36, lng: 4.8852, altitude: 40 }, + range: 280, + tilt: 45, + heading: 180, + }, + stats: { + size: '🖼 8,000+ objects', + highlight: "Rembrandt's Night Watch", + }, + }, + { + name: 'Vondelpark', + desc: "Amsterdam's largest and most famous public park. A flat translucent green polygon outlines a section of its lush lawns and tranquil lakes.", + camera: { + center: { lat: 52.358, lng: 4.8685, altitude: 50 }, + range: 600, + tilt: 45, + heading: 250, + }, + stats: { + size: '🌳 120 acres', + highlight: 'Open Air Theatre', + }, + }, + { + name: 'Prinsengracht Canal', + desc: "One of Amsterdam's three main belt canals. Traced in Googly orange is a flat polyline showing the canal tour path floating at water level.", + camera: { + center: { lat: 52.3637, lng: 4.8855, altitude: 30 }, + range: 300, + tilt: 45, + heading: 330, + }, + stats: { + size: '⛵ 3.2 km length', + highlight: 'Historic Houseboats', + }, + }, + { + name: 'De Gooyer Windmill', + desc: 'The tallest wooden mill in the Netherlands. We load a dynamic 3D model of the windmill which rotates in real-time, standing next to the canal.', + camera: { + center: { lat: 52.3667, lng: 4.9263, altitude: 30 }, + range: 180, + tilt: 45, + heading: 135, + }, + stats: { + size: '⚙ 26 meters tall', + highlight: 'Built in 1725', + }, + }, +]; + +// Reference to map elements +let map; +let windmillModel; +let canalPolyline; +let vondelparkPolygon; +let museumPolygon; +let museumFlattener; + +// Collections of pins +const standardMarkers = []; +const standardPopovers = []; + +// Tour State +let isTouring = false; +let currentStopIndex = -1; +let isAnimationCallbackActive = false; +let tourAnimationCleanup = null; + +async function init() { + const [ + { + Map3DElement, + Polyline3DElement, + Polygon3DElement, + Polygon3DInteractiveElement, + Model3DElement, + Marker3DInteractiveElement, + PopoverElement, + FlattenerElement, + }, + { PinElement }, + ] = await Promise.all([ + google.maps.importLibrary('maps3d'), + google.maps.importLibrary('marker'), + ]); + + // 1. Initialize the 3D Map (Centered on Rijksmuseum) + map = new Map3DElement({ + center: { lat: 52.36, lng: 4.8852, altitude: 800 }, + tilt: 40, + heading: 0, + range: 4000, + mode: 'SATELLITE', + }); + document.body.append(map); + + // 2. Create the layers + + // Polyline: Prinsengracht canal route (flat, orange) + canalPolyline = new Polyline3DElement({ + path: [ + { lat: 52.3622, lng: 4.89149 }, + { lat: 52.36276, lng: 4.88788 }, + { lat: 52.36614, lng: 4.88277 }, + { lat: 52.36673, lng: 4.88242 }, + { lat: 52.36673, lng: 4.88242 }, + ], + strokeColor: '#F37021', + strokeWidth: 6, + altitudeMode: 'CLAMP_TO_GROUND', + extruded: false, + drawsOccludedSegments: true, + }); + + // Polygon 1: Vondelpark lake/lawn zone (flat green) + vondelparkPolygon = new Polygon3DInteractiveElement({ + path: [ + { lat: 52.35639, lng: 4.85497 }, + { lat: 52.36108, lng: 4.87449 }, + { lat: 52.3593, lng: 4.87592 }, + { lat: 52.35511, lng: 4.86683 }, + { lat: 52.35457, lng: 4.85623 }, + ], + strokeColor: '#1e8e3e90', + strokeWidth: 3, + fillColor: '#1e8e3e40', + drawsOccludedSegments: false, + }); + + vondelparkPolygon.addEventListener('gmp-click', () => { + alert( + 'Welcome to Vondelpark! Enjoy the open lawns and winding pathways.' + ); + }); + + // Polygon 2: Rijksmuseum Building footprint (extruded) + museumPolygon = new Polygon3DElement({ + path: [ + { lat: 52.36029, lng: 4.88327, altitude: 25 }, + { lat: 52.36092, lng: 4.88502, altitude: 25 }, + { lat: 52.36011, lng: 4.8867, altitude: 25 }, + { lat: 52.35881, lng: 4.88627, altitude: 25 }, + { lat: 52.3592, lng: 4.88412, altitude: 25 }, + ], + strokeColor: '#4285F490', + strokeWidth: 3, + fillColor: '#4285F440', + altitudeMode: 'RELATIVE_TO_GROUND', + extruded: true, + }); + + // Rijksmuseum 3D flattener + museumFlattener = new FlattenerElement({ + path: [ + { lat: 52.36029, lng: 4.88327 }, + { lat: 52.36092, lng: 4.88502 }, + { lat: 52.36011, lng: 4.8867 }, + { lat: 52.35881, lng: 4.88627 }, + { lat: 52.3592, lng: 4.88412 }, + ], + }); + + // 3D Model: Windmill (placed at De Gooyer Windmill site) + windmillModel = new Model3DElement({ + src: 'https://maps-docs-team.web.app/assets/windmill.glb', + position: { lat: 52.3667, lng: 4.9263 }, + orientation: { heading: 0, tilt: 270, roll: 90 }, + scale: 0.15, + altitudeMode: 'CLAMP_TO_GROUND', + }); + + // 3. Create Standard Markers & Popovers with Custom HTML (at each Tour Stop) + const poiLocations = [ + { + id: 'rijksmuseum', + name: 'Rijksmuseum', + lat: 52.36, + lng: 4.8852, + alt: 35, + desc: 'The national museum of the Netherlands, home to masterpieces by Rembrandt and Vermeer.', + glyph: '🖼', + bg: '#4285F4', + highlight: "Rembrandt's Night Watch", + }, + { + id: 'vondelpark', + name: 'Vondelpark', + lat: 52.358, + lng: 4.8685, + alt: 20, + desc: "Amsterdam's historic public park, filled with cafes, ponds, and paths.", + glyph: '🌳', + bg: '#34A853', + highlight: 'Open Air Theatre', + }, + { + id: 'canal', + name: 'Prinsengracht Canal', + lat: 52.36409, + lng: 4.88584, + alt: 10, + desc: 'The longest of the main canal rings, known for its iconic houseboats.', + glyph: '⛵', + bg: '#FBBC05', + highlight: 'Historic Houseboats', + }, + { + id: 'degooyer', + name: 'De Gooyer Windmill', + lat: 52.3667, + lng: 4.9263, + alt: 30, + desc: 'A historic flour mill built in 1725, the tallest wooden mill in the country.', + glyph: '⚙', + bg: '#EA4335', + highlight: 'Built in 1725', + }, + ]; + + poiLocations.forEach((loc, index) => { + const pin = new PinElement({ + background: loc.bg, + glyph: loc.glyph, + borderColor: '#FFFFFF', + }); + + const interactiveMarker = new Marker3DInteractiveElement({ + position: { lat: loc.lat, lng: loc.lng, altitude: loc.alt }, + altitudeMode: 'RELATIVE_TO_GROUND', + extruded: true, + }); + interactiveMarker.append(pin); + + const popover = new PopoverElement({ + open: false, + positionAnchor: interactiveMarker, + }); + + const popoverContent = document.createElement('div'); + popoverContent.className = 'popover-custom-content'; + popoverContent.innerHTML = ` +
+ ${loc.glyph} +

${loc.name}

+
+

${loc.desc}

+
+
+ Highlight + ${loc.highlight} +
+
+ `; + popover.append(popoverContent); + + interactiveMarker.addEventListener('gmp-click', () => { + // Close others + standardPopovers.forEach((p, i) => { + if (i !== index) p.open = false; + }); + popover.open = !popover.open; + }); + + standardMarkers.push(interactiveMarker); + standardPopovers.push(popover); + }); + + // Sync initial states + syncLayers(); + syncRijksmuseumMesh(); + + // 5. Connect UI Event Listeners + setupUIListeners(); +} + +function syncLayers() { + const showMarkers = document.getElementById('toggle-markers').checked; + const showPolyline = document.getElementById('toggle-polyline').checked; + const showPolygons = document.getElementById('toggle-polygons').checked; + const showModel = document.getElementById('toggle-model').checked; + + // Standard Markers & Popovers + standardMarkers.forEach((marker, index) => { + if (showMarkers) { + map.append(marker); + map.append(standardPopovers[index]); + } else { + marker.remove(); + standardPopovers[index].remove(); + } + }); + + // Polyline + if (showPolyline) { + map.append(canalPolyline); + } else { + canalPolyline.remove(); + } + + // Polygons + if (showPolygons) { + map.append(vondelparkPolygon); + map.append(museumPolygon); + } else { + vondelparkPolygon.remove(); + museumPolygon.remove(); + } + + // 3D Model + if (showModel) { + map.append(windmillModel); + } else { + windmillModel.remove(); + } +} + +function syncRijksmuseumMesh() { + const showMesh = document.getElementById('toggle-salesforce-mesh').checked; + if (showMesh) { + museumFlattener.remove(); + } else { + map.append(museumFlattener); + } +} + +function setupUIListeners() { + // Welcome dismiss + const welcome = document.getElementById('welcome-banner'); + document + .getElementById('btn-welcome-dismiss') + ?.addEventListener('click', () => { + welcome.classList.add('hidden'); + }); + + // Tour buttons + const btnStart = document.getElementById('btn-start-tour'); + const btnStop = document.getElementById('btn-stop-tour'); + const tourNav = document.getElementById('btn-prev-stop')?.parentElement; + + btnStart.addEventListener('click', () => { + isTouring = true; + btnStart.classList.add('hidden'); + btnStop.classList.remove('hidden'); + tourNav.classList.remove('hidden'); + welcome.classList.add('hidden'); + jumpToStop(0); + }); + + btnStop.addEventListener('click', () => { + endTour(); + }); + + document.getElementById('btn-prev-stop')?.addEventListener('click', () => { + if (currentStopIndex > 0) { + jumpToStop(currentStopIndex - 1); + } + }); + + document.getElementById('btn-next-stop')?.addEventListener('click', () => { + if (currentStopIndex < TOUR_STOPS.length - 1) { + jumpToStop(currentStopIndex + 1); + } + }); + + // Layer Toggle Listeners + document + .getElementById('toggle-markers') + ?.addEventListener('change', syncLayers); + + document + .getElementById('toggle-polyline') + ?.addEventListener('change', syncLayers); + document + .getElementById('toggle-polygons') + ?.addEventListener('change', syncLayers); + document + .getElementById('toggle-salesforce-mesh') + ?.addEventListener('change', syncRijksmuseumMesh); + document + .getElementById('toggle-model') + ?.addEventListener('change', syncLayers); + + // Map Mode Selectors + const modeSat = document.getElementById('mode-satellite'); + const modeHyb = document.getElementById('mode-hybrid'); + + modeSat.addEventListener('click', () => { + map.mode = 'SATELLITE'; + modeSat.classList.add('active'); + modeHyb.classList.remove('active'); + }); + + modeHyb.addEventListener('click', () => { + map.mode = 'HYBRID'; + modeHyb.classList.add('active'); + modeSat.classList.remove('active'); + }); + + // Listener for camera interrupt + map.addEventListener('gmp-click', () => { + if (isTouring) { + console.log( + 'Tour camera animation interrupted by user interaction.' + ); + } + }); +} + +function jumpToStop(index) { + if (tourAnimationCleanup) { + tourAnimationCleanup(); + } + + currentStopIndex = index; + isAnimationCallbackActive = true; + + // Update Tour Nav UI + const prevBtn = document.getElementById('btn-prev-stop'); + const nextBtn = document.getElementById('btn-next-stop'); + const progressText = document.getElementById('tour-progress'); + + prevBtn.disabled = index === 0; + nextBtn.disabled = index === TOUR_STOPS.length - 1; + progressText.textContent = `Stop ${String(index + 1)} of ${String(TOUR_STOPS.length)}`; + + // Open active stop popover and close all others + standardPopovers.forEach((p, i) => { + p.open = i === index; + }); + + const stop = TOUR_STOPS[index]; + const flightStartTime = Date.now(); + + // Perform camera FlyTo Animation (18s duration for cinematic smoothness) + map.flyCameraTo({ + endCamera: stop.camera, + durationMillis: 18000, + }); + + const listener = () => { + const elapsed = Date.now() - flightStartTime; + if ( + isAnimationCallbackActive && + currentStopIndex === index && + elapsed >= 17000 + ) { + if (tourAnimationCleanup === cleanup) { + tourAnimationCleanup = null; + } + map.removeEventListener('gmp-animationend', listener); + flyAroundStop(index); + } + }; + + const cleanup = () => { + map.removeEventListener('gmp-animationend', listener); + }; + tourAnimationCleanup = cleanup; + + map.addEventListener('gmp-animationend', listener); +} + +function flyAroundStop(index) { + isAnimationCallbackActive = false; // Prevent loop trigger + const stop = TOUR_STOPS[index]; + const spinStartTime = Date.now(); + + // Slower, smoother rotation (30s duration) + map.flyCameraAround({ + camera: stop.camera, + durationMillis: 30000, + repeatCount: 1, + }); + + const listener = () => { + const elapsed = Date.now() - spinStartTime; + if (isTouring && currentStopIndex === index && elapsed >= 29000) { + if (tourAnimationCleanup === cleanup) { + tourAnimationCleanup = null; + } + map.removeEventListener('gmp-animationend', listener); + // Wait 3 seconds at current view, then fly to the next stop + window.setTimeout(() => { + if (isTouring && currentStopIndex === index) { + if (index < TOUR_STOPS.length - 1) { + jumpToStop(index + 1); + } else { + endTour(); + } + } + }, 3000); + } + }; + + const cleanup = () => { + map.removeEventListener('gmp-animationend', listener); + }; + tourAnimationCleanup = cleanup; + + map.addEventListener('gmp-animationend', listener); +} + +function endTour() { + isTouring = false; + currentStopIndex = -1; + isAnimationCallbackActive = false; + map.stopCameraAnimation(); + + if (tourAnimationCleanup) { + tourAnimationCleanup(); + tourAnimationCleanup = null; + } + + // Close all popovers + standardPopovers.forEach((p) => (p.open = false)); + + const btnStart = document.getElementById('btn-start-tour'); + const btnStop = document.getElementById('btn-stop-tour'); + const tourNav = document.getElementById('btn-prev-stop')?.parentElement; + + btnStart.classList.remove('hidden'); + btnStop.classList.add('hidden'); + tourNav.classList.add('hidden'); +} + +window.addEventListener('load', () => { + void init(); +}); diff --git a/dist/samples/3d-hero-showcase/docs/index.ts b/dist/samples/3d-hero-showcase/docs/index.ts new file mode 100644 index 000000000..133d9494c --- /dev/null +++ b/dist/samples/3d-hero-showcase/docs/index.ts @@ -0,0 +1,586 @@ +/* + * @license + * Copyright 2026 Google LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +interface TourStop { + name: string; + desc: string; + camera: { + center: { lat: number; lng: number; altitude: number }; + range: number; + tilt: number; + heading: number; + }; + stats: { + size: string; + highlight: string; + }; +} + +const TOUR_STOPS: TourStop[] = [ + { + name: 'Rijksmuseum', + desc: 'The national museum of the Netherlands. The footprint of this grand Gothic-Renaissance building is highlighted, and its 3D building mesh can be toggled on or off.', + camera: { + center: { lat: 52.36, lng: 4.8852, altitude: 40 }, + range: 280, + tilt: 45, + heading: 180, + }, + stats: { + size: '🖼 8,000+ objects', + highlight: "Rembrandt's Night Watch", + }, + }, + { + name: 'Vondelpark', + desc: "Amsterdam's largest and most famous public park. A flat translucent green polygon outlines a section of its lush lawns and tranquil lakes.", + camera: { + center: { lat: 52.358, lng: 4.8685, altitude: 50 }, + range: 600, + tilt: 45, + heading: 250, + }, + stats: { + size: '🌳 120 acres', + highlight: 'Open Air Theatre', + }, + }, + { + name: 'Prinsengracht Canal', + desc: "One of Amsterdam's three main belt canals. Traced in Googly orange is a flat polyline showing the canal tour path floating at water level.", + camera: { + center: { lat: 52.3637, lng: 4.8855, altitude: 30 }, + range: 300, + tilt: 45, + heading: 330, + }, + stats: { + size: '⛵ 3.2 km length', + highlight: 'Historic Houseboats', + }, + }, + { + name: 'De Gooyer Windmill', + desc: 'The tallest wooden mill in the Netherlands. We load a dynamic 3D model of the windmill which rotates in real-time, standing next to the canal.', + camera: { + center: { lat: 52.3667, lng: 4.9263, altitude: 30 }, + range: 180, + tilt: 45, + heading: 135, + }, + stats: { + size: '⚙ 26 meters tall', + highlight: 'Built in 1725', + }, + }, +]; + +// Reference to map elements +let map: google.maps.maps3d.Map3DElement; +let windmillModel: google.maps.maps3d.Model3DElement; +let canalPolyline: google.maps.maps3d.Polyline3DElement; +let vondelparkPolygon: google.maps.maps3d.Polygon3DElement; +let museumPolygon: google.maps.maps3d.Polygon3DElement; +let museumFlattener: google.maps.maps3d.FlattenerElement; + +// Collections of pins +const standardMarkers: google.maps.maps3d.Marker3DInteractiveElement[] = []; +const standardPopovers: google.maps.maps3d.PopoverElement[] = []; + +// Tour State +let isTouring = false; +let currentStopIndex = -1; +let isAnimationCallbackActive = false; +let tourAnimationCleanup: (() => void) | null = null; + +async function init() { + const [ + { + Map3DElement, + Polyline3DElement, + Polygon3DElement, + Polygon3DInteractiveElement, + Model3DElement, + Marker3DInteractiveElement, + PopoverElement, + FlattenerElement, + }, + { PinElement }, + ] = await Promise.all([ + google.maps.importLibrary('maps3d'), + google.maps.importLibrary('marker'), + ]); + + // 1. Initialize the 3D Map (Centered on Rijksmuseum) + map = new Map3DElement({ + center: { lat: 52.36, lng: 4.8852, altitude: 800 }, + tilt: 40, + heading: 0, + range: 4000, + mode: 'SATELLITE', + }); + document.body.append(map); + + // 2. Create the layers + + // Polyline: Prinsengracht canal route (flat, orange) + canalPolyline = new Polyline3DElement({ + path: [ + { lat: 52.3622, lng: 4.89149 }, + { lat: 52.36276, lng: 4.88788 }, + { lat: 52.36614, lng: 4.88277 }, + { lat: 52.36673, lng: 4.88242 }, + { lat: 52.36673, lng: 4.88242 }, + ], + strokeColor: '#F37021', + strokeWidth: 6, + altitudeMode: 'CLAMP_TO_GROUND', + extruded: false, + drawsOccludedSegments: true, + }); + + // Polygon 1: Vondelpark lake/lawn zone (flat green) + vondelparkPolygon = new Polygon3DInteractiveElement({ + path: [ + { lat: 52.35639, lng: 4.85497 }, + { lat: 52.36108, lng: 4.87449 }, + { lat: 52.3593, lng: 4.87592 }, + { lat: 52.35511, lng: 4.86683 }, + { lat: 52.35457, lng: 4.85623 }, + ], + strokeColor: '#1e8e3e90', + strokeWidth: 3, + fillColor: '#1e8e3e40', + drawsOccludedSegments: false, + }); + + vondelparkPolygon.addEventListener('gmp-click', () => { + alert( + 'Welcome to Vondelpark! Enjoy the open lawns and winding pathways.' + ); + }); + + // Polygon 2: Rijksmuseum Building footprint (extruded) + museumPolygon = new Polygon3DElement({ + path: [ + { lat: 52.36029, lng: 4.88327, altitude: 25 }, + { lat: 52.36092, lng: 4.88502, altitude: 25 }, + { lat: 52.36011, lng: 4.8867, altitude: 25 }, + { lat: 52.35881, lng: 4.88627, altitude: 25 }, + { lat: 52.3592, lng: 4.88412, altitude: 25 }, + ], + strokeColor: '#4285F490', + strokeWidth: 3, + fillColor: '#4285F440', + altitudeMode: 'RELATIVE_TO_GROUND', + extruded: true, + }); + + // Rijksmuseum 3D flattener + museumFlattener = new FlattenerElement({ + path: [ + { lat: 52.36029, lng: 4.88327 }, + { lat: 52.36092, lng: 4.88502 }, + { lat: 52.36011, lng: 4.8867 }, + { lat: 52.35881, lng: 4.88627 }, + { lat: 52.3592, lng: 4.88412 }, + ], + }); + + // 3D Model: Windmill (placed at De Gooyer Windmill site) + windmillModel = new Model3DElement({ + src: 'https://maps-docs-team.web.app/assets/windmill.glb', + position: { lat: 52.3667, lng: 4.9263 }, + orientation: { heading: 0, tilt: 270, roll: 90 }, + scale: 0.15, + altitudeMode: 'CLAMP_TO_GROUND', + }); + + // 3. Create Standard Markers & Popovers with Custom HTML (at each Tour Stop) + const poiLocations = [ + { + id: 'rijksmuseum', + name: 'Rijksmuseum', + lat: 52.36, + lng: 4.8852, + alt: 35, + desc: 'The national museum of the Netherlands, home to masterpieces by Rembrandt and Vermeer.', + glyph: '🖼', + bg: '#4285F4', + highlight: "Rembrandt's Night Watch", + }, + { + id: 'vondelpark', + name: 'Vondelpark', + lat: 52.358, + lng: 4.8685, + alt: 20, + desc: "Amsterdam's historic public park, filled with cafes, ponds, and paths.", + glyph: '🌳', + bg: '#34A853', + highlight: 'Open Air Theatre', + }, + { + id: 'canal', + name: 'Prinsengracht Canal', + lat: 52.36409, + lng: 4.88584, + alt: 10, + desc: 'The longest of the main canal rings, known for its iconic houseboats.', + glyph: '⛵', + bg: '#FBBC05', + highlight: 'Historic Houseboats', + }, + { + id: 'degooyer', + name: 'De Gooyer Windmill', + lat: 52.3667, + lng: 4.9263, + alt: 30, + desc: 'A historic flour mill built in 1725, the tallest wooden mill in the country.', + glyph: '⚙', + bg: '#EA4335', + highlight: 'Built in 1725', + }, + ]; + + poiLocations.forEach((loc, index) => { + const pin = new PinElement({ + background: loc.bg, + glyph: loc.glyph, + borderColor: '#FFFFFF', + }); + + const interactiveMarker = new Marker3DInteractiveElement({ + position: { lat: loc.lat, lng: loc.lng, altitude: loc.alt }, + altitudeMode: 'RELATIVE_TO_GROUND', + extruded: true, + }); + interactiveMarker.append(pin); + + const popover = new PopoverElement({ + open: false, + positionAnchor: interactiveMarker, + }); + + const popoverContent = document.createElement('div'); + popoverContent.className = 'popover-custom-content'; + popoverContent.innerHTML = ` +
+ ${loc.glyph} +

${loc.name}

+
+

${loc.desc}

+
+
+ Highlight + ${loc.highlight} +
+
+ `; + popover.append(popoverContent); + + interactiveMarker.addEventListener('gmp-click', () => { + // Close others + standardPopovers.forEach((p, i) => { + if (i !== index) p.open = false; + }); + popover.open = !popover.open; + }); + + standardMarkers.push(interactiveMarker); + standardPopovers.push(popover); + }); + + // Sync initial states + syncLayers(); + syncRijksmuseumMesh(); + + // 5. Connect UI Event Listeners + setupUIListeners(); +} + +function syncLayers() { + const showMarkers = ( + document.getElementById('toggle-markers') as HTMLInputElement + ).checked; + const showPolyline = ( + document.getElementById('toggle-polyline') as HTMLInputElement + ).checked; + const showPolygons = ( + document.getElementById('toggle-polygons') as HTMLInputElement + ).checked; + const showModel = ( + document.getElementById('toggle-model') as HTMLInputElement + ).checked; + + // Standard Markers & Popovers + standardMarkers.forEach((marker, index) => { + if (showMarkers) { + map.append(marker); + map.append(standardPopovers[index]); + } else { + marker.remove(); + standardPopovers[index].remove(); + } + }); + + // Polyline + if (showPolyline) { + map.append(canalPolyline); + } else { + canalPolyline.remove(); + } + + // Polygons + if (showPolygons) { + map.append(vondelparkPolygon); + map.append(museumPolygon); + } else { + vondelparkPolygon.remove(); + museumPolygon.remove(); + } + + // 3D Model + if (showModel) { + map.append(windmillModel); + } else { + windmillModel.remove(); + } +} + +function syncRijksmuseumMesh() { + const showMesh = ( + document.getElementById('toggle-salesforce-mesh') as HTMLInputElement + ).checked; + if (showMesh) { + museumFlattener.remove(); + } else { + map.append(museumFlattener); + } +} + +function setupUIListeners() { + // Welcome dismiss + const welcome = document.getElementById('welcome-banner') as HTMLDivElement; + document + .getElementById('btn-welcome-dismiss') + ?.addEventListener('click', () => { + welcome.classList.add('hidden'); + }); + + // Tour buttons + const btnStart = document.getElementById( + 'btn-start-tour' + ) as HTMLButtonElement; + const btnStop = document.getElementById( + 'btn-stop-tour' + ) as HTMLButtonElement; + const tourNav = document.getElementById('btn-prev-stop') + ?.parentElement as HTMLDivElement; + + btnStart.addEventListener('click', () => { + isTouring = true; + btnStart.classList.add('hidden'); + btnStop.classList.remove('hidden'); + tourNav.classList.remove('hidden'); + welcome.classList.add('hidden'); + jumpToStop(0); + }); + + btnStop.addEventListener('click', () => { + endTour(); + }); + + document.getElementById('btn-prev-stop')?.addEventListener('click', () => { + if (currentStopIndex > 0) { + jumpToStop(currentStopIndex - 1); + } + }); + + document.getElementById('btn-next-stop')?.addEventListener('click', () => { + if (currentStopIndex < TOUR_STOPS.length - 1) { + jumpToStop(currentStopIndex + 1); + } + }); + + // Layer Toggle Listeners + document + .getElementById('toggle-markers') + ?.addEventListener('change', syncLayers); + + document + .getElementById('toggle-polyline') + ?.addEventListener('change', syncLayers); + document + .getElementById('toggle-polygons') + ?.addEventListener('change', syncLayers); + document + .getElementById('toggle-salesforce-mesh') + ?.addEventListener('change', syncRijksmuseumMesh); + document + .getElementById('toggle-model') + ?.addEventListener('change', syncLayers); + + // Map Mode Selectors + const modeSat = document.getElementById( + 'mode-satellite' + ) as HTMLButtonElement; + const modeHyb = document.getElementById('mode-hybrid') as HTMLButtonElement; + + modeSat.addEventListener('click', () => { + map.mode = 'SATELLITE'; + modeSat.classList.add('active'); + modeHyb.classList.remove('active'); + }); + + modeHyb.addEventListener('click', () => { + map.mode = 'HYBRID'; + modeHyb.classList.add('active'); + modeSat.classList.remove('active'); + }); + + // Listener for camera interrupt + map.addEventListener('gmp-click', () => { + if (isTouring) { + console.log( + 'Tour camera animation interrupted by user interaction.' + ); + } + }); +} + +function jumpToStop(index: number) { + if (tourAnimationCleanup) { + tourAnimationCleanup(); + } + + currentStopIndex = index; + isAnimationCallbackActive = true; + + // Update Tour Nav UI + const prevBtn = document.getElementById( + 'btn-prev-stop' + ) as HTMLButtonElement; + const nextBtn = document.getElementById( + 'btn-next-stop' + ) as HTMLButtonElement; + const progressText = document.getElementById( + 'tour-progress' + ) as HTMLSpanElement; + + prevBtn.disabled = index === 0; + nextBtn.disabled = index === TOUR_STOPS.length - 1; + progressText.textContent = `Stop ${String(index + 1)} of ${String(TOUR_STOPS.length)}`; + + // Open active stop popover and close all others + standardPopovers.forEach((p, i) => { + p.open = i === index; + }); + + const stop = TOUR_STOPS[index]; + const flightStartTime = Date.now(); + + // Perform camera FlyTo Animation (18s duration for cinematic smoothness) + map.flyCameraTo({ + endCamera: stop.camera, + durationMillis: 18000, + }); + + const listener = () => { + const elapsed = Date.now() - flightStartTime; + if ( + isAnimationCallbackActive && + currentStopIndex === index && + elapsed >= 17000 + ) { + if (tourAnimationCleanup === cleanup) { + tourAnimationCleanup = null; + } + map.removeEventListener('gmp-animationend', listener); + flyAroundStop(index); + } + }; + + const cleanup = () => { + map.removeEventListener('gmp-animationend', listener); + }; + tourAnimationCleanup = cleanup; + + map.addEventListener('gmp-animationend', listener); +} + +function flyAroundStop(index: number) { + isAnimationCallbackActive = false; // Prevent loop trigger + const stop = TOUR_STOPS[index]; + const spinStartTime = Date.now(); + + // Slower, smoother rotation (30s duration) + map.flyCameraAround({ + camera: stop.camera, + durationMillis: 30000, + repeatCount: 1, + }); + + const listener = () => { + const elapsed = Date.now() - spinStartTime; + if (isTouring && currentStopIndex === index && elapsed >= 29000) { + if (tourAnimationCleanup === cleanup) { + tourAnimationCleanup = null; + } + map.removeEventListener('gmp-animationend', listener); + // Wait 3 seconds at current view, then fly to the next stop + window.setTimeout(() => { + if (isTouring && currentStopIndex === index) { + if (index < TOUR_STOPS.length - 1) { + jumpToStop(index + 1); + } else { + endTour(); + } + } + }, 3000); + } + }; + + const cleanup = () => { + map.removeEventListener('gmp-animationend', listener); + }; + tourAnimationCleanup = cleanup; + + map.addEventListener('gmp-animationend', listener); +} + +function endTour() { + isTouring = false; + currentStopIndex = -1; + isAnimationCallbackActive = false; + map.stopCameraAnimation(); + + if (tourAnimationCleanup) { + tourAnimationCleanup(); + tourAnimationCleanup = null; + } + + // Close all popovers + standardPopovers.forEach((p) => (p.open = false)); + + const btnStart = document.getElementById( + 'btn-start-tour' + ) as HTMLButtonElement; + const btnStop = document.getElementById( + 'btn-stop-tour' + ) as HTMLButtonElement; + const tourNav = document.getElementById('btn-prev-stop') + ?.parentElement as HTMLDivElement; + + btnStart.classList.remove('hidden'); + btnStop.classList.add('hidden'); + tourNav.classList.add('hidden'); +} + +window.addEventListener('load', () => { + void init(); +}); diff --git a/dist/samples/3d-hero-showcase/docs/style.css b/dist/samples/3d-hero-showcase/docs/style.css new file mode 100644 index 000000000..285232b39 --- /dev/null +++ b/dist/samples/3d-hero-showcase/docs/style.css @@ -0,0 +1,649 @@ +/* + * @license + * Copyright 2026 Google LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/* [START maps_3d_hero_showcase] */ +:root { + --bg-color: #ffffff; + --border-color: #dadce0; + --text-primary: #202124; + --text-secondary: #5f6368; + --google-blue: #1a73e8; + --google-blue-hover: #1557b0; + --google-blue-light: #e8f0fe; + --google-red: #d93025; + --google-red-hover: #b31412; + --google-green: #1e8e3e; + --google-yellow: #f9ab00; + --light-grey: #f8f9fa; + --card-shadow: + 0 1px 3px rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15); + --font-sans: 'Google Sans', 'Outfit', 'Roboto', sans-serif; +} + +html, +body { + height: 100%; + margin: 0; + padding: 0; + font-family: var(--font-sans); + background-color: #f1f3f4; + overflow: hidden; + color: var(--text-primary); +} + +/* Base Map */ +gmp-map-3d { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + z-index: 1; +} + +/* Googly Floating Cards */ +.google-card { + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 20px; + box-shadow: var(--card-shadow); + z-index: 10; + display: flex; + flex-direction: column; + box-sizing: border-box; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + overflow: hidden; +} + +/* Control Panel Sidebar */ +#control-panel { + position: absolute; + top: 16px; + left: 16px; + width: 330px; + max-height: calc(100% - 32px); + padding: 18px; +} + +.card-header { + padding-bottom: 12px; + border-bottom: 1px solid #e8eaed; + margin-bottom: 14px; +} + +.logo-area { + display: flex; + align-items: center; + gap: 8px; +} + +.logo-text { + font-size: 21px; + font-weight: 700; + letter-spacing: -0.5px; +} + +.badge-3d { + background: var(--google-blue-light); + color: var(--google-blue); + font-size: 10px; + font-weight: 700; + padding: 2px 6px; + border-radius: 6px; +} + +/* Google Brand Colors */ +.g-blue { + color: var(--google-blue); +} +.g-red { + color: var(--google-red); +} +.g-yellow { + color: var(--google-yellow); +} +.g-green { + color: var(--google-green); +} + +.subtitle { + font-size: 12px; + color: var(--text-secondary); + margin: 4px 0 0 0; + font-weight: 500; +} + +.scrollable-content { + flex: 1; + overflow-y: auto; + padding-right: 4px; +} + +/* Scrollbar styles */ +.scrollable-content::-webkit-scrollbar { + width: 4px; +} +.scrollable-content::-webkit-scrollbar-track { + background: transparent; +} +.scrollable-content::-webkit-scrollbar-thumb { + background: #dadce0; + border-radius: 10px; +} + +.action-block { + margin-bottom: 16px; +} + +.option-block { + border-top: 1px solid #e8eaed; + padding-top: 14px; + margin-bottom: 16px; +} + +.option-block h3 { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--text-secondary); + margin-top: 0; + margin-bottom: 10px; + font-weight: 700; +} + +/* Google style Buttons */ +.google-btn { + font-family: var(--font-sans); + font-size: 13.5px; + font-weight: 500; + border-radius: 20px; + padding: 9px 18px; + border: 1px solid var(--border-color); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: #ffffff; + color: var(--google-blue); + transition: + background-color 0.2s, + border-color 0.2s, + box-shadow 0.2s; + width: 100%; +} + +.google-btn:hover { + background-color: var(--light-grey); + border-color: #dadce0; + box-shadow: 0 1px 2px rgba(60, 64, 67, 0.3); +} + +.primary-btn { + background: var(--google-blue); + color: white; + border: none; +} + +.primary-btn:hover { + background: var(--google-blue-hover); + box-shadow: + 0 1px 3px rgba(60, 64, 67, 0.3), + 0 1px 2px rgba(0, 0, 0, 0.15); +} + +.danger-btn { + background: var(--google-red); + color: white; + border: none; +} + +.danger-btn:hover { + background: var(--google-red-hover); + box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3); +} + +.secondary-btn { + background: #ffffff; + color: var(--text-primary); +} + +.secondary-btn:hover { + background: var(--light-grey); +} + +.text-btn { + background: transparent; + border: none; + color: var(--google-blue); + font-weight: 700; + width: auto; + padding: 6px 12px; +} + +.text-btn:hover { + background: var(--google-blue-light); + box-shadow: none; +} + +.small-btn { + padding: 5px 12px; + font-size: 11.5px; + border-radius: 12px; + width: auto; +} + +/* Tour Nav pills */ +.tour-nav { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 10px; + background: var(--light-grey); + padding: 6px 10px; + border-radius: 14px; + border: 1px solid var(--border-color); +} + +.nav-arrow { + background: transparent; + border: none; + cursor: pointer; + font-size: 11px; + color: var(--text-secondary); + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.nav-arrow:hover:not(:disabled) { + background: #dadce0; + color: var(--text-primary); +} + +.nav-arrow:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.progress-text { + font-size: 12.5px; + font-weight: 500; + color: var(--text-primary); +} + +/* Switch Rows */ +.toggle-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.switch-row { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; +} + +.switch-label { + font-size: 13.5px; + font-weight: 500; + color: var(--text-primary); + display: flex; + align-items: center; +} + +.switch-label .emoji { + margin-right: 8px; + font-size: 15px; +} + +/* Lever switches */ +.switch-control { + position: relative; + width: 32px; + height: 14px; +} + +.switch-control input { + opacity: 0; + width: 0; + height: 0; +} + +.lever { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #bdc1c6; + transition: 0.15s; + border-radius: 14px; +} + +.lever:before { + position: absolute; + content: ''; + height: 20px; + width: 20px; + left: -4px; + bottom: -3px; + background-color: #ffffff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); + transition: 0.15s; + border-radius: 50%; +} + +input:checked + .lever { + background-color: #8ab4f8; +} + +input:checked + .lever:before { + transform: translateX(18px); + background-color: var(--google-blue); +} + +/* Segmented view controls */ +.segmented-control { + display: flex; + background: var(--light-grey); + padding: 2px; + border-radius: 12px; + border: 1px solid var(--border-color); +} + +.segment-btn { + flex: 1; + background: transparent; + border: none; + color: var(--text-secondary); + padding: 6px 10px; + border-radius: 10px; + font-size: 12.5px; + font-weight: 500; + cursor: pointer; + font-family: var(--font-sans); + transition: all 0.15s; +} + +.segment-btn.active { + background: #ffffff; + color: var(--google-blue); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); + font-weight: 700; +} + +.card-footer { + border-top: 1px solid #e8eaed; + padding-top: 8px; + margin-top: 12px; + font-size: 10.5px; + color: var(--text-secondary); + text-align: center; + font-weight: 500; +} + +/* Bottom Info Drawer */ +.info-drawer { + position: absolute; + bottom: 20px; + left: calc(50% + 165px); /* Adjusted center offset for Googly panel */ + transform: translateX(-50%); + width: 550px; + padding: 16px 20px; + max-width: calc(100% - 390px); +} + +.drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +#info-title { + font-size: 18px; + font-weight: 700; + margin: 0; + color: var(--text-primary); + letter-spacing: -0.3px; +} + +#info-desc { + font-size: 13.5px; + color: var(--text-secondary); + margin: 0 0 12px 0; + line-height: 1.45; +} + +.metrics-row { + display: flex; + gap: 20px; + background: var(--light-grey); + border-radius: 12px; + padding: 8px 16px; + border: 1px solid var(--border-color); +} + +.metric-item { + flex: 1; + display: flex; + flex-direction: column; +} + +.metric-lbl { + font-size: 10px; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: 2px; + font-weight: 700; + letter-spacing: 0.5px; +} + +.metric-val { + font-size: 13.5px; + font-weight: 700; + color: var(--text-primary); +} + +.highlight-val { + color: var(--google-blue); +} + +/* Welcome Overlay */ +.welcome-toast { + position: absolute; + top: 16px; + right: 16px; + width: 280px; + padding: 14px; +} + +.toast-content h3 { + margin-top: 0; + margin-bottom: 4px; + font-size: 14px; + font-weight: 700; +} + +.toast-content p { + font-size: 12.5px; + color: var(--text-secondary); + margin: 0 0 10px 0; + line-height: 1.4; +} + +.toast-content button { + float: right; +} + +/* Helpers */ +.hidden, +.google-card.hidden, +.google-btn.hidden, +.tour-nav.hidden { + display: none; +} + +.google-card.hidden { + opacity: 0; + pointer-events: none; + transform: translate(-50%, 15px); +} + +/* Googly Custom Marker Badge */ +.custom-badge { + display: flex; + align-items: center; + gap: 8px; + background: #ffffff; + border: 1px solid var(--border-color); + border-radius: 20px; + padding: 4px 10px 4px 4px; + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 12px; + font-weight: 700; + box-shadow: + 0 2px 6px rgba(60, 64, 67, 0.15), + 0 1px 2px rgba(60, 64, 67, 0.3); + white-space: nowrap; + user-select: none; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + transform-origin: bottom center; +} + +.custom-badge:hover { + transform: scale(1.05); + box-shadow: + 0 4px 12px rgba(60, 64, 67, 0.2), + 0 1px 3px rgba(60, 64, 67, 0.35); + border-color: #bdc1c6; +} + +.badge-icon { + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--google-blue-light); + color: var(--google-blue); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; +} + +.badge-details { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.badge-title { + font-size: 9px; + color: var(--text-secondary); + font-weight: 500; + line-height: 1; + margin-bottom: 1px; +} + +.badge-value { + font-size: 11.5px; + font-weight: 700; + color: var(--text-primary); + line-height: 1; +} + +/* Responsive */ +@media (max-width: 900px) { + #control-panel { + top: 10px; + left: 10px; + width: calc(100% - 20px); + max-height: 45%; + } +} + +/* Popover Custom Styling */ +.popover-custom-content { + width: 260px; + color: var(--text-primary); + font-family: var(--font-sans); + background: #ffffff; + padding: 2px; +} + +.popover-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + border-bottom: 1px solid #e8eaed; + padding-bottom: 6px; +} + +.popover-emoji { + font-size: 18px; +} + +.popover-header h4 { + font-size: 15px; + margin: 0; + font-weight: 700; + color: var(--text-primary); +} + +.popover-desc { + font-size: 12.5px; + color: var(--text-secondary); + margin: 0 0 10px 0; + line-height: 1.4; +} + +.popover-stats { + display: flex; + gap: 12px; + background: var(--light-grey); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 6px 10px; +} + +.popover-stat-item { + flex: 1; + display: flex; + flex-direction: column; +} + +.popover-stat-lbl { + font-size: 8px; + text-transform: uppercase; + color: var(--text-secondary); + font-weight: 700; + letter-spacing: 0.3px; + margin-bottom: 2px; +} + +.popover-stat-val { + font-size: 11.5px; + font-weight: 700; + color: var(--text-primary); + line-height: 1.1; +} + +.popover-stat-val.highlight-val { + color: var(--google-blue); +} +/* [END maps_3d_hero_showcase] */ diff --git a/dist/samples/3d-hero-showcase/jsfiddle/demo.css b/dist/samples/3d-hero-showcase/jsfiddle/demo.css new file mode 100644 index 000000000..a0fdf3d70 --- /dev/null +++ b/dist/samples/3d-hero-showcase/jsfiddle/demo.css @@ -0,0 +1,647 @@ +/* + * @license + * Copyright 2026 Google LLC. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +:root { + --bg-color: #ffffff; + --border-color: #dadce0; + --text-primary: #202124; + --text-secondary: #5f6368; + --google-blue: #1a73e8; + --google-blue-hover: #1557b0; + --google-blue-light: #e8f0fe; + --google-red: #d93025; + --google-red-hover: #b31412; + --google-green: #1e8e3e; + --google-yellow: #f9ab00; + --light-grey: #f8f9fa; + --card-shadow: + 0 1px 3px rgba(60, 64, 67, 0.3), 0 4px 8px 3px rgba(60, 64, 67, 0.15); + --font-sans: 'Google Sans', 'Outfit', 'Roboto', sans-serif; +} + +html, +body { + height: 100%; + margin: 0; + padding: 0; + font-family: var(--font-sans); + background-color: #f1f3f4; + overflow: hidden; + color: var(--text-primary); +} + +/* Base Map */ +gmp-map-3d { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + z-index: 1; +} + +/* Googly Floating Cards */ +.google-card { + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 20px; + box-shadow: var(--card-shadow); + z-index: 10; + display: flex; + flex-direction: column; + box-sizing: border-box; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + overflow: hidden; +} + +/* Control Panel Sidebar */ +#control-panel { + position: absolute; + top: 16px; + left: 16px; + width: 330px; + max-height: calc(100% - 32px); + padding: 18px; +} + +.card-header { + padding-bottom: 12px; + border-bottom: 1px solid #e8eaed; + margin-bottom: 14px; +} + +.logo-area { + display: flex; + align-items: center; + gap: 8px; +} + +.logo-text { + font-size: 21px; + font-weight: 700; + letter-spacing: -0.5px; +} + +.badge-3d { + background: var(--google-blue-light); + color: var(--google-blue); + font-size: 10px; + font-weight: 700; + padding: 2px 6px; + border-radius: 6px; +} + +/* Google Brand Colors */ +.g-blue { + color: var(--google-blue); +} +.g-red { + color: var(--google-red); +} +.g-yellow { + color: var(--google-yellow); +} +.g-green { + color: var(--google-green); +} + +.subtitle { + font-size: 12px; + color: var(--text-secondary); + margin: 4px 0 0 0; + font-weight: 500; +} + +.scrollable-content { + flex: 1; + overflow-y: auto; + padding-right: 4px; +} + +/* Scrollbar styles */ +.scrollable-content::-webkit-scrollbar { + width: 4px; +} +.scrollable-content::-webkit-scrollbar-track { + background: transparent; +} +.scrollable-content::-webkit-scrollbar-thumb { + background: #dadce0; + border-radius: 10px; +} + +.action-block { + margin-bottom: 16px; +} + +.option-block { + border-top: 1px solid #e8eaed; + padding-top: 14px; + margin-bottom: 16px; +} + +.option-block h3 { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--text-secondary); + margin-top: 0; + margin-bottom: 10px; + font-weight: 700; +} + +/* Google style Buttons */ +.google-btn { + font-family: var(--font-sans); + font-size: 13.5px; + font-weight: 500; + border-radius: 20px; + padding: 9px 18px; + border: 1px solid var(--border-color); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: #ffffff; + color: var(--google-blue); + transition: + background-color 0.2s, + border-color 0.2s, + box-shadow 0.2s; + width: 100%; +} + +.google-btn:hover { + background-color: var(--light-grey); + border-color: #dadce0; + box-shadow: 0 1px 2px rgba(60, 64, 67, 0.3); +} + +.primary-btn { + background: var(--google-blue); + color: white; + border: none; +} + +.primary-btn:hover { + background: var(--google-blue-hover); + box-shadow: + 0 1px 3px rgba(60, 64, 67, 0.3), + 0 1px 2px rgba(0, 0, 0, 0.15); +} + +.danger-btn { + background: var(--google-red); + color: white; + border: none; +} + +.danger-btn:hover { + background: var(--google-red-hover); + box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3); +} + +.secondary-btn { + background: #ffffff; + color: var(--text-primary); +} + +.secondary-btn:hover { + background: var(--light-grey); +} + +.text-btn { + background: transparent; + border: none; + color: var(--google-blue); + font-weight: 700; + width: auto; + padding: 6px 12px; +} + +.text-btn:hover { + background: var(--google-blue-light); + box-shadow: none; +} + +.small-btn { + padding: 5px 12px; + font-size: 11.5px; + border-radius: 12px; + width: auto; +} + +/* Tour Nav pills */ +.tour-nav { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 10px; + background: var(--light-grey); + padding: 6px 10px; + border-radius: 14px; + border: 1px solid var(--border-color); +} + +.nav-arrow { + background: transparent; + border: none; + cursor: pointer; + font-size: 11px; + color: var(--text-secondary); + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.nav-arrow:hover:not(:disabled) { + background: #dadce0; + color: var(--text-primary); +} + +.nav-arrow:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.progress-text { + font-size: 12.5px; + font-weight: 500; + color: var(--text-primary); +} + +/* Switch Rows */ +.toggle-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.switch-row { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; +} + +.switch-label { + font-size: 13.5px; + font-weight: 500; + color: var(--text-primary); + display: flex; + align-items: center; +} + +.switch-label .emoji { + margin-right: 8px; + font-size: 15px; +} + +/* Lever switches */ +.switch-control { + position: relative; + width: 32px; + height: 14px; +} + +.switch-control input { + opacity: 0; + width: 0; + height: 0; +} + +.lever { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #bdc1c6; + transition: 0.15s; + border-radius: 14px; +} + +.lever:before { + position: absolute; + content: ''; + height: 20px; + width: 20px; + left: -4px; + bottom: -3px; + background-color: #ffffff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); + transition: 0.15s; + border-radius: 50%; +} + +input:checked + .lever { + background-color: #8ab4f8; +} + +input:checked + .lever:before { + transform: translateX(18px); + background-color: var(--google-blue); +} + +/* Segmented view controls */ +.segmented-control { + display: flex; + background: var(--light-grey); + padding: 2px; + border-radius: 12px; + border: 1px solid var(--border-color); +} + +.segment-btn { + flex: 1; + background: transparent; + border: none; + color: var(--text-secondary); + padding: 6px 10px; + border-radius: 10px; + font-size: 12.5px; + font-weight: 500; + cursor: pointer; + font-family: var(--font-sans); + transition: all 0.15s; +} + +.segment-btn.active { + background: #ffffff; + color: var(--google-blue); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); + font-weight: 700; +} + +.card-footer { + border-top: 1px solid #e8eaed; + padding-top: 8px; + margin-top: 12px; + font-size: 10.5px; + color: var(--text-secondary); + text-align: center; + font-weight: 500; +} + +/* Bottom Info Drawer */ +.info-drawer { + position: absolute; + bottom: 20px; + left: calc(50% + 165px); /* Adjusted center offset for Googly panel */ + transform: translateX(-50%); + width: 550px; + padding: 16px 20px; + max-width: calc(100% - 390px); +} + +.drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +#info-title { + font-size: 18px; + font-weight: 700; + margin: 0; + color: var(--text-primary); + letter-spacing: -0.3px; +} + +#info-desc { + font-size: 13.5px; + color: var(--text-secondary); + margin: 0 0 12px 0; + line-height: 1.45; +} + +.metrics-row { + display: flex; + gap: 20px; + background: var(--light-grey); + border-radius: 12px; + padding: 8px 16px; + border: 1px solid var(--border-color); +} + +.metric-item { + flex: 1; + display: flex; + flex-direction: column; +} + +.metric-lbl { + font-size: 10px; + text-transform: uppercase; + color: var(--text-secondary); + margin-bottom: 2px; + font-weight: 700; + letter-spacing: 0.5px; +} + +.metric-val { + font-size: 13.5px; + font-weight: 700; + color: var(--text-primary); +} + +.highlight-val { + color: var(--google-blue); +} + +/* Welcome Overlay */ +.welcome-toast { + position: absolute; + top: 16px; + right: 16px; + width: 280px; + padding: 14px; +} + +.toast-content h3 { + margin-top: 0; + margin-bottom: 4px; + font-size: 14px; + font-weight: 700; +} + +.toast-content p { + font-size: 12.5px; + color: var(--text-secondary); + margin: 0 0 10px 0; + line-height: 1.4; +} + +.toast-content button { + float: right; +} + +/* Helpers */ +.hidden, +.google-card.hidden, +.google-btn.hidden, +.tour-nav.hidden { + display: none; +} + +.google-card.hidden { + opacity: 0; + pointer-events: none; + transform: translate(-50%, 15px); +} + +/* Googly Custom Marker Badge */ +.custom-badge { + display: flex; + align-items: center; + gap: 8px; + background: #ffffff; + border: 1px solid var(--border-color); + border-radius: 20px; + padding: 4px 10px 4px 4px; + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 12px; + font-weight: 700; + box-shadow: + 0 2px 6px rgba(60, 64, 67, 0.15), + 0 1px 2px rgba(60, 64, 67, 0.3); + white-space: nowrap; + user-select: none; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + transform-origin: bottom center; +} + +.custom-badge:hover { + transform: scale(1.05); + box-shadow: + 0 4px 12px rgba(60, 64, 67, 0.2), + 0 1px 3px rgba(60, 64, 67, 0.35); + border-color: #bdc1c6; +} + +.badge-icon { + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--google-blue-light); + color: var(--google-blue); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; +} + +.badge-details { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.badge-title { + font-size: 9px; + color: var(--text-secondary); + font-weight: 500; + line-height: 1; + margin-bottom: 1px; +} + +.badge-value { + font-size: 11.5px; + font-weight: 700; + color: var(--text-primary); + line-height: 1; +} + +/* Responsive */ +@media (max-width: 900px) { + #control-panel { + top: 10px; + left: 10px; + width: calc(100% - 20px); + max-height: 45%; + } +} + +/* Popover Custom Styling */ +.popover-custom-content { + width: 260px; + color: var(--text-primary); + font-family: var(--font-sans); + background: #ffffff; + padding: 2px; +} + +.popover-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + border-bottom: 1px solid #e8eaed; + padding-bottom: 6px; +} + +.popover-emoji { + font-size: 18px; +} + +.popover-header h4 { + font-size: 15px; + margin: 0; + font-weight: 700; + color: var(--text-primary); +} + +.popover-desc { + font-size: 12.5px; + color: var(--text-secondary); + margin: 0 0 10px 0; + line-height: 1.4; +} + +.popover-stats { + display: flex; + gap: 12px; + background: var(--light-grey); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 6px 10px; +} + +.popover-stat-item { + flex: 1; + display: flex; + flex-direction: column; +} + +.popover-stat-lbl { + font-size: 8px; + text-transform: uppercase; + color: var(--text-secondary); + font-weight: 700; + letter-spacing: 0.3px; + margin-bottom: 2px; +} + +.popover-stat-val { + font-size: 11.5px; + font-weight: 700; + color: var(--text-primary); + line-height: 1.1; +} + +.popover-stat-val.highlight-val { + color: var(--google-blue); +} diff --git a/dist/samples/3d-hero-showcase/jsfiddle/demo.details b/dist/samples/3d-hero-showcase/jsfiddle/demo.details new file mode 100644 index 000000000..13280ef74 --- /dev/null +++ b/dist/samples/3d-hero-showcase/jsfiddle/demo.details @@ -0,0 +1,7 @@ +name: 3d-hero-showcase +authors: + - Geo Developer IX Documentation Team +tags: + - google maps +load_type: h +description: Sample code supporting Google Maps Platform JavaScript API documentation. diff --git a/dist/samples/3d-hero-showcase/jsfiddle/demo.html b/dist/samples/3d-hero-showcase/jsfiddle/demo.html new file mode 100644 index 000000000..a31abbead --- /dev/null +++ b/dist/samples/3d-hero-showcase/jsfiddle/demo.html @@ -0,0 +1,188 @@ + + + + + + Amsterdam 3D Explorer + + + + + + + + + + + + + + +
+
+
+ + A + m + s + t + e + r + d + a + m + + 3D +
+

Rijksmuseum & attractions

+
+ +
+ +
+ + + + +
+ + +
+

Map Mode

+
+ + +
+
+ + +
+

Map Layers

+
+ + + + + + + + + +
+
+
+ + +
+ + +
+
+

Explore Amsterdam in 3D

+

+ Start the tour to fly around the Rijksmuseum, Vondelpark, + Prinsengracht, and De Gooyer Windmill. +

+ +
+
+ + diff --git a/dist/samples/3d-hero-showcase/jsfiddle/demo.js b/dist/samples/3d-hero-showcase/jsfiddle/demo.js new file mode 100644 index 000000000..3c083b2d8 --- /dev/null +++ b/dist/samples/3d-hero-showcase/jsfiddle/demo.js @@ -0,0 +1,539 @@ +'use strict'; + +const TOUR_STOPS = [ + { + name: 'Rijksmuseum', + desc: 'The national museum of the Netherlands. The footprint of this grand Gothic-Renaissance building is highlighted, and its 3D building mesh can be toggled on or off.', + camera: { + center: { lat: 52.36, lng: 4.8852, altitude: 40 }, + range: 280, + tilt: 45, + heading: 180, + }, + stats: { + size: '🖼 8,000+ objects', + highlight: "Rembrandt's Night Watch", + }, + }, + { + name: 'Vondelpark', + desc: "Amsterdam's largest and most famous public park. A flat translucent green polygon outlines a section of its lush lawns and tranquil lakes.", + camera: { + center: { lat: 52.358, lng: 4.8685, altitude: 50 }, + range: 600, + tilt: 45, + heading: 250, + }, + stats: { + size: '🌳 120 acres', + highlight: 'Open Air Theatre', + }, + }, + { + name: 'Prinsengracht Canal', + desc: "One of Amsterdam's three main belt canals. Traced in Googly orange is a flat polyline showing the canal tour path floating at water level.", + camera: { + center: { lat: 52.3637, lng: 4.8855, altitude: 30 }, + range: 300, + tilt: 45, + heading: 330, + }, + stats: { + size: '⛵ 3.2 km length', + highlight: 'Historic Houseboats', + }, + }, + { + name: 'De Gooyer Windmill', + desc: 'The tallest wooden mill in the Netherlands. We load a dynamic 3D model of the windmill which rotates in real-time, standing next to the canal.', + camera: { + center: { lat: 52.3667, lng: 4.9263, altitude: 30 }, + range: 180, + tilt: 45, + heading: 135, + }, + stats: { + size: '⚙ 26 meters tall', + highlight: 'Built in 1725', + }, + }, +]; + +// Reference to map elements +let map; +let windmillModel; +let canalPolyline; +let vondelparkPolygon; +let museumPolygon; +let museumFlattener; + +// Collections of pins +const standardMarkers = []; +const standardPopovers = []; + +// Tour State +let isTouring = false; +let currentStopIndex = -1; +let isAnimationCallbackActive = false; +let tourAnimationCleanup = null; + +async function init() { + const [ + { + Map3DElement, + Polyline3DElement, + Polygon3DElement, + Polygon3DInteractiveElement, + Model3DElement, + Marker3DInteractiveElement, + PopoverElement, + FlattenerElement, + }, + { PinElement }, + ] = await Promise.all([ + google.maps.importLibrary('maps3d'), + google.maps.importLibrary('marker'), + ]); + + // 1. Initialize the 3D Map (Centered on Rijksmuseum) + map = new Map3DElement({ + center: { lat: 52.36, lng: 4.8852, altitude: 800 }, + tilt: 40, + heading: 0, + range: 4000, + mode: 'SATELLITE', + }); + document.body.append(map); + + // 2. Create the layers + + // Polyline: Prinsengracht canal route (flat, orange) + canalPolyline = new Polyline3DElement({ + path: [ + { lat: 52.3622, lng: 4.89149 }, + { lat: 52.36276, lng: 4.88788 }, + { lat: 52.36614, lng: 4.88277 }, + { lat: 52.36673, lng: 4.88242 }, + { lat: 52.36673, lng: 4.88242 }, + ], + strokeColor: '#F37021', + strokeWidth: 6, + altitudeMode: 'CLAMP_TO_GROUND', + extruded: false, + drawsOccludedSegments: true, + }); + + // Polygon 1: Vondelpark lake/lawn zone (flat green) + vondelparkPolygon = new Polygon3DInteractiveElement({ + path: [ + { lat: 52.35639, lng: 4.85497 }, + { lat: 52.36108, lng: 4.87449 }, + { lat: 52.3593, lng: 4.87592 }, + { lat: 52.35511, lng: 4.86683 }, + { lat: 52.35457, lng: 4.85623 }, + ], + strokeColor: '#1e8e3e90', + strokeWidth: 3, + fillColor: '#1e8e3e40', + drawsOccludedSegments: false, + }); + + vondelparkPolygon.addEventListener('gmp-click', () => { + alert( + 'Welcome to Vondelpark! Enjoy the open lawns and winding pathways.' + ); + }); + + // Polygon 2: Rijksmuseum Building footprint (extruded) + museumPolygon = new Polygon3DElement({ + path: [ + { lat: 52.36029, lng: 4.88327, altitude: 25 }, + { lat: 52.36092, lng: 4.88502, altitude: 25 }, + { lat: 52.36011, lng: 4.8867, altitude: 25 }, + { lat: 52.35881, lng: 4.88627, altitude: 25 }, + { lat: 52.3592, lng: 4.88412, altitude: 25 }, + ], + strokeColor: '#4285F490', + strokeWidth: 3, + fillColor: '#4285F440', + altitudeMode: 'RELATIVE_TO_GROUND', + extruded: true, + }); + + // Rijksmuseum 3D flattener + museumFlattener = new FlattenerElement({ + path: [ + { lat: 52.36029, lng: 4.88327 }, + { lat: 52.36092, lng: 4.88502 }, + { lat: 52.36011, lng: 4.8867 }, + { lat: 52.35881, lng: 4.88627 }, + { lat: 52.3592, lng: 4.88412 }, + ], + }); + + // 3D Model: Windmill (placed at De Gooyer Windmill site) + windmillModel = new Model3DElement({ + src: 'https://maps-docs-team.web.app/assets/windmill.glb', + position: { lat: 52.3667, lng: 4.9263 }, + orientation: { heading: 0, tilt: 270, roll: 90 }, + scale: 0.15, + altitudeMode: 'CLAMP_TO_GROUND', + }); + + // 3. Create Standard Markers & Popovers with Custom HTML (at each Tour Stop) + const poiLocations = [ + { + id: 'rijksmuseum', + name: 'Rijksmuseum', + lat: 52.36, + lng: 4.8852, + alt: 35, + desc: 'The national museum of the Netherlands, home to masterpieces by Rembrandt and Vermeer.', + glyph: '🖼', + bg: '#4285F4', + highlight: "Rembrandt's Night Watch", + }, + { + id: 'vondelpark', + name: 'Vondelpark', + lat: 52.358, + lng: 4.8685, + alt: 20, + desc: "Amsterdam's historic public park, filled with cafes, ponds, and paths.", + glyph: '🌳', + bg: '#34A853', + highlight: 'Open Air Theatre', + }, + { + id: 'canal', + name: 'Prinsengracht Canal', + lat: 52.36409, + lng: 4.88584, + alt: 10, + desc: 'The longest of the main canal rings, known for its iconic houseboats.', + glyph: '⛵', + bg: '#FBBC05', + highlight: 'Historic Houseboats', + }, + { + id: 'degooyer', + name: 'De Gooyer Windmill', + lat: 52.3667, + lng: 4.9263, + alt: 30, + desc: 'A historic flour mill built in 1725, the tallest wooden mill in the country.', + glyph: '⚙', + bg: '#EA4335', + highlight: 'Built in 1725', + }, + ]; + + poiLocations.forEach((loc, index) => { + const pin = new PinElement({ + background: loc.bg, + glyph: loc.glyph, + borderColor: '#FFFFFF', + }); + + const interactiveMarker = new Marker3DInteractiveElement({ + position: { lat: loc.lat, lng: loc.lng, altitude: loc.alt }, + altitudeMode: 'RELATIVE_TO_GROUND', + extruded: true, + }); + interactiveMarker.append(pin); + + const popover = new PopoverElement({ + open: false, + positionAnchor: interactiveMarker, + }); + + const popoverContent = document.createElement('div'); + popoverContent.className = 'popover-custom-content'; + popoverContent.innerHTML = ` +
+ ${loc.glyph} +

${loc.name}

+
+

${loc.desc}

+
+
+ Highlight + ${loc.highlight} +
+
+ `; + popover.append(popoverContent); + + interactiveMarker.addEventListener('gmp-click', () => { + // Close others + standardPopovers.forEach((p, i) => { + if (i !== index) p.open = false; + }); + popover.open = !popover.open; + }); + + standardMarkers.push(interactiveMarker); + standardPopovers.push(popover); + }); + + // Sync initial states + syncLayers(); + syncRijksmuseumMesh(); + + // 5. Connect UI Event Listeners + setupUIListeners(); +} + +function syncLayers() { + const showMarkers = document.getElementById('toggle-markers').checked; + const showPolyline = document.getElementById('toggle-polyline').checked; + const showPolygons = document.getElementById('toggle-polygons').checked; + const showModel = document.getElementById('toggle-model').checked; + + // Standard Markers & Popovers + standardMarkers.forEach((marker, index) => { + if (showMarkers) { + map.append(marker); + map.append(standardPopovers[index]); + } else { + marker.remove(); + standardPopovers[index].remove(); + } + }); + + // Polyline + if (showPolyline) { + map.append(canalPolyline); + } else { + canalPolyline.remove(); + } + + // Polygons + if (showPolygons) { + map.append(vondelparkPolygon); + map.append(museumPolygon); + } else { + vondelparkPolygon.remove(); + museumPolygon.remove(); + } + + // 3D Model + if (showModel) { + map.append(windmillModel); + } else { + windmillModel.remove(); + } +} + +function syncRijksmuseumMesh() { + const showMesh = document.getElementById('toggle-salesforce-mesh').checked; + if (showMesh) { + museumFlattener.remove(); + } else { + map.append(museumFlattener); + } +} + +function setupUIListeners() { + // Welcome dismiss + const welcome = document.getElementById('welcome-banner'); + document + .getElementById('btn-welcome-dismiss') + ?.addEventListener('click', () => { + welcome.classList.add('hidden'); + }); + + // Tour buttons + const btnStart = document.getElementById('btn-start-tour'); + const btnStop = document.getElementById('btn-stop-tour'); + const tourNav = document.getElementById('btn-prev-stop')?.parentElement; + + btnStart.addEventListener('click', () => { + isTouring = true; + btnStart.classList.add('hidden'); + btnStop.classList.remove('hidden'); + tourNav.classList.remove('hidden'); + welcome.classList.add('hidden'); + jumpToStop(0); + }); + + btnStop.addEventListener('click', () => { + endTour(); + }); + + document.getElementById('btn-prev-stop')?.addEventListener('click', () => { + if (currentStopIndex > 0) { + jumpToStop(currentStopIndex - 1); + } + }); + + document.getElementById('btn-next-stop')?.addEventListener('click', () => { + if (currentStopIndex < TOUR_STOPS.length - 1) { + jumpToStop(currentStopIndex + 1); + } + }); + + // Layer Toggle Listeners + document + .getElementById('toggle-markers') + ?.addEventListener('change', syncLayers); + + document + .getElementById('toggle-polyline') + ?.addEventListener('change', syncLayers); + document + .getElementById('toggle-polygons') + ?.addEventListener('change', syncLayers); + document + .getElementById('toggle-salesforce-mesh') + ?.addEventListener('change', syncRijksmuseumMesh); + document + .getElementById('toggle-model') + ?.addEventListener('change', syncLayers); + + // Map Mode Selectors + const modeSat = document.getElementById('mode-satellite'); + const modeHyb = document.getElementById('mode-hybrid'); + + modeSat.addEventListener('click', () => { + map.mode = 'SATELLITE'; + modeSat.classList.add('active'); + modeHyb.classList.remove('active'); + }); + + modeHyb.addEventListener('click', () => { + map.mode = 'HYBRID'; + modeHyb.classList.add('active'); + modeSat.classList.remove('active'); + }); + + // Listener for camera interrupt + map.addEventListener('gmp-click', () => { + if (isTouring) { + console.log( + 'Tour camera animation interrupted by user interaction.' + ); + } + }); +} + +function jumpToStop(index) { + if (tourAnimationCleanup) { + tourAnimationCleanup(); + } + + currentStopIndex = index; + isAnimationCallbackActive = true; + + // Update Tour Nav UI + const prevBtn = document.getElementById('btn-prev-stop'); + const nextBtn = document.getElementById('btn-next-stop'); + const progressText = document.getElementById('tour-progress'); + + prevBtn.disabled = index === 0; + nextBtn.disabled = index === TOUR_STOPS.length - 1; + progressText.textContent = `Stop ${String(index + 1)} of ${String(TOUR_STOPS.length)}`; + + // Open active stop popover and close all others + standardPopovers.forEach((p, i) => { + p.open = i === index; + }); + + const stop = TOUR_STOPS[index]; + const flightStartTime = Date.now(); + + // Perform camera FlyTo Animation (18s duration for cinematic smoothness) + map.flyCameraTo({ + endCamera: stop.camera, + durationMillis: 18000, + }); + + const listener = () => { + const elapsed = Date.now() - flightStartTime; + if ( + isAnimationCallbackActive && + currentStopIndex === index && + elapsed >= 17000 + ) { + if (tourAnimationCleanup === cleanup) { + tourAnimationCleanup = null; + } + map.removeEventListener('gmp-animationend', listener); + flyAroundStop(index); + } + }; + + const cleanup = () => { + map.removeEventListener('gmp-animationend', listener); + }; + tourAnimationCleanup = cleanup; + + map.addEventListener('gmp-animationend', listener); +} + +function flyAroundStop(index) { + isAnimationCallbackActive = false; // Prevent loop trigger + const stop = TOUR_STOPS[index]; + const spinStartTime = Date.now(); + + // Slower, smoother rotation (30s duration) + map.flyCameraAround({ + camera: stop.camera, + durationMillis: 30000, + repeatCount: 1, + }); + + const listener = () => { + const elapsed = Date.now() - spinStartTime; + if (isTouring && currentStopIndex === index && elapsed >= 29000) { + if (tourAnimationCleanup === cleanup) { + tourAnimationCleanup = null; + } + map.removeEventListener('gmp-animationend', listener); + // Wait 3 seconds at current view, then fly to the next stop + window.setTimeout(() => { + if (isTouring && currentStopIndex === index) { + if (index < TOUR_STOPS.length - 1) { + jumpToStop(index + 1); + } else { + endTour(); + } + } + }, 3000); + } + }; + + const cleanup = () => { + map.removeEventListener('gmp-animationend', listener); + }; + tourAnimationCleanup = cleanup; + + map.addEventListener('gmp-animationend', listener); +} + +function endTour() { + isTouring = false; + currentStopIndex = -1; + isAnimationCallbackActive = false; + map.stopCameraAnimation(); + + if (tourAnimationCleanup) { + tourAnimationCleanup(); + tourAnimationCleanup = null; + } + + // Close all popovers + standardPopovers.forEach((p) => (p.open = false)); + + const btnStart = document.getElementById('btn-start-tour'); + const btnStop = document.getElementById('btn-stop-tour'); + const tourNav = document.getElementById('btn-prev-stop')?.parentElement; + + btnStart.classList.remove('hidden'); + btnStop.classList.add('hidden'); + tourNav.classList.add('hidden'); +} + +window.addEventListener('load', () => { + void init(); +});