diff --git a/places_insights/places-insights-demo/README.md b/places_insights/places-insights-demo/README.md new file mode 100644 index 0000000..307884e --- /dev/null +++ b/places_insights/places-insights-demo/README.md @@ -0,0 +1,132 @@ +# Places Insights Demo Application + +This is a client-side web application that demonstrates how to query and visualize Google Places Insights data. It allows users to define geographic search areas, apply a variety of filters, and see aggregated results from Google BigQuery displayed on a Google Map. + +The application is built entirely with HTML, CSS, and JavaScript, using the Google Maps Platform JavaScript API for mapping, Deck.gl for H3 heatmap visualizations, and Google Identity Services for client-side OAuth 2.0 authentication. + +--- + +## Prerequisites + +Before you can run this application, you must have a Google Cloud project with billing enabled and ensure the following are set up: + +### 1. Places Insights Subscription + +This demo queries the Places Insights datasets in BigQuery. You must subscribe to these datasets in the Google Cloud Marketplace for the countries you wish to query. + +* **Action:** Visit the [Places Insights product page](https://developers.google.com/maps/documentation/placesinsights) and follow the instructions to subscribe to the datasets for your project. + +### 2. Enabled APIs + +Ensure the following APIs are enabled in your Google Cloud project: + +* **BigQuery API** (for running the queries) +* **Maps JavaScript API** (for displaying the map) +* **Geocoding API** (for centering the map on countries/regions) +* **Routes API** (for the "Route Search" feature) +* **Places API (New)** (for the Place Autocomplete web components and Place Details) +* **Places UI Kit API** (Required for the `` component used in the H3 Function demo) + +### 3. IAM Permissions + +The Google account you authorize with must have the appropriate IAM permissions in your Cloud project to run BigQuery jobs. At a minimum, this typically includes: + +* `BigQuery Job User` +* `BigQuery Resource Viewer` + +--- + +## Setup + +Follow these steps to configure and run the application locally. + +### 1. Create an OAuth 2.0 Client ID + +This application uses client-side OAuth 2.0 to authorize users to run BigQuery queries on their own behalf. + +1. In the Google Cloud Console, navigate to **APIs & Services > Credentials**. +2. Click **+ CREATE CREDENTIALS** and select **OAuth client ID**. +3. For "Application type", select **Web application**. +4. Give it a name (e.g., "Places Insights Demo"). +5. Under **Authorized JavaScript origins**, click **+ ADD URI**. +6. Enter the origin for your local development server. For most servers, this is `http://localhost:8000`. +7. Click **CREATE**. +8. Copy the **Your Client ID** value. You will need this in the next step. + +### 2. Create a Google Maps Platform API Key + +1. On the same **Credentials** page, click **+ CREATE CREDENTIALS** and select **API key**. +2. Copy the generated API key. +3. **Important:** For security in a production environment, you should restrict this key to your website's domain and ensure it only has access to the APIs listed in the Prerequisites section. + +### 3. Configure the Application + +1. In the project directory, find the file named `config.js.template` (or create a new `config.js` file). +2. **Rename** this file to `config.js`. +3. Open `config.js` and fill in the placeholder values with your project-specific credentials: + + ```javascript + window.APP_CONFIG = { + // Your Google Cloud Project ID + GCP_PROJECT_ID: 'YOUR_GCP_PROJECT_ID', + + // The OAuth 2.0 Client ID you created in Step 1 + OAUTH_CLIENT_ID: 'YOUR_OAUTH_CLIENT_ID.apps.googleusercontent.com', + + // The Google Maps Platform API Key you created in Step 2 + MAPS_API_KEY: 'YOUR_MAPS_API_KEY', + + /** + * Defines which dataset to query. + * Options: 'FULL' or 'SAMPLE' + * - 'FULL': Queries the full country datasets (e.g., places_insights___us.places). + * - 'SAMPLE': Queries the city sample datasets (e.g., places_insights___us___sample.places_sample). + */ + DATASET: 'FULL', + }; + ``` + +4. **Dataset Selection:** Set the `DATASET` parameter to `'SAMPLE'` if you are using the [sample datasets](https://developers.google.com/maps/documentation/placesinsights/cloud-setup#sample_data), or `'FULL'` if you have subscribed to the [full datasets](https://developers.google.com/maps/documentation/placesinsights/cloud-setup#full_data). +5. **Security Note:** The `config.js` file **must not be committed to version control**. + +### 4. Run a Local Server + +Because of browser security policies related to OAuth 2.0, you cannot run this application by opening the `index.html` file directly. You must serve it from a local web server. + +1. Open a terminal in the project's root directory. +2. If you have Python 3 installed, you can run a simple server with the command: + ```sh + python3 -m http.server 8000 + ``` +3. Open your web browser and navigate to `http://localhost:8000`. + +--- + +## How to Use the Application + +For a detailed walkthrough, click the **Help** button in the application's sidebar. + +### Quick Start + +1. **Select a Location:** Choose a country or city you have subscribed to in Places Insights. +2. **Authorize:** Sign in with your Google account to enable querying. +3. **Choose a Demo Type:** + * **Circle Search:** Click on the map to define a search radius. + * **Polygon Search:** Draw a custom shape on the map or paste a WKT string. + * **Region Search:** Search by administrative names like "London" or "California". You can add multiple regions to search at once. + * **Route Search:** Select an origin and destination to search along a calculated driving route. + * **Places Count Per H3 (Function):** Uses server-side BigQuery functions for high-performance density mapping. This mode supports **low counts (0-4)** and **sample place markers**. +4. **Apply Filters:** Narrow your search using the collapsible filter sections: + * **Place Types:** Select types and optionally check **"Match Primary Type Only"** for stricter filtering. + * **Attributes:** Filter by Rating, Business Status (Operational/Closed), Price, etc. + * **Opening Hours:** Filter by day and time (Standard modes only). + * **Brands:** Filter by brand name or category (US Standard mode only). +5. **Visualize:** + * Leave **Show H3 Density Map** unchecked for simple aggregate counts (Standard modes only). + * Check the box to visualize the results as a color-coded heatmap of hexagonal cells. +6. **Run Search:** Click the "Run Search" button to execute the query and see the results on the map. + +### Interactive Features (H3 Function Mode) +When running the **Places Count Per H3** demo: +1. **Click a Hexagon:** Click on any colored H3 cell on the map. This will load up to 20 sample markers for places within that cell. +2. **View Details:** Click on any of the yellow markers to open a **Place Details Card** containing rich information (photos, reviews, opening hours) powered by the Places UI Kit. \ No newline at end of file diff --git a/places_insights/places-insights-demo/auth.js b/places_insights/places-insights-demo/auth.js new file mode 100644 index 0000000..27a1553 --- /dev/null +++ b/places_insights/places-insights-demo/auth.js @@ -0,0 +1,84 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// --- AUTHENTICATION --- + +/** + * Initializes the Google Identity Services client. This is called once on page load. + */ +function initializeIdentityServices() { + if (!OAUTH_CLIENT_ID || !GCP_PROJECT_ID) { + updateStatus('Configuration missing. Please set up config.js.', 'error'); + return; + } + if (typeof google === 'undefined' || !google.accounts) { + updateStatus('Google Identity Services failed to load.', 'error'); + console.error('GSI client library not loaded or initialized.'); + return; + } + try { + // Initialize the token client for handling OAuth 2.0 flow. + tokenClient = google.accounts.oauth2.initTokenClient({ + client_id: OAUTH_CLIENT_ID, + scope: BQ_SCOPES, + callback: () => {}, // Callback is handled by the promise in ensureAccessToken. + }); + } catch (error) { + updateStatus('Could not initialize Google Identity Services.', 'error'); + console.error(error); + } +} + +/** + * Handles clicks on the main authorization button (sign-in or sign-out). + */ +function handleAuthClick() { + if (userSignedIn) { + // If the user is signed in, revoke the token and sign out. + if (accessToken) { + google.accounts.oauth2.revoke(accessToken, () => {}); + } + accessToken = null; + resetSignedInUi(); + } else { + // If the user is signed out, start the sign-in process. + ensureAccessToken({ prompt: 'consent' }); + } +} + +/** + * Gets a valid access token, prompting the user for consent if necessary. + * @param {object} options - Options for the token request (e.g., { prompt: 'consent' }). + * @returns {Promise} A promise that resolves with the access token. + */ +function ensureAccessToken(options = { prompt: '' }) { + if (accessToken) return Promise.resolve(accessToken); + + return new Promise((resolve, reject) => { + if (!tokenClient) { + return reject(new Error('Identity client not initialized.')); + } + // Define the callback for the token request. + tokenClient.callback = (tokenResponse) => { + if (tokenResponse.error) { + resetSignedInUi(); + return reject(new Error(tokenResponse.error_description || 'Authorization failed.')); + } + accessToken = tokenResponse.access_token; + setSignedInUi(); + resolve(accessToken); + }; + tokenClient.requestAccessToken(options); + }); +} \ No newline at end of file diff --git a/places_insights/places-insights-demo/config.js.template b/places_insights/places-insights-demo/config.js.template new file mode 100644 index 0000000..b90b8e7 --- /dev/null +++ b/places_insights/places-insights-demo/config.js.template @@ -0,0 +1,33 @@ +// This file contains the client-side configuration for connecting to Google Cloud. +// RENAME this file to "config.js" and fill in your actual credentials. +// IMPORTANT: config.js is ignored by Git to keep your credentials private. + +window.APP_CONFIG = { + /** + * Your Google Cloud Project ID. + * This is the project where BigQuery API calls will be billed. + */ + GCP_PROJECT_ID: 'YOUR_GCP_PROJECT_ID', + + /** + * Your OAuth 2.0 Client ID for a Web Application. + * This ID is used to authenticate users and get their consent to run queries on their behalf. + * Ensure your app's origin (e.g., http://localhost:8000) is in the "Authorized JavaScript origins". + */ + OAUTH_CLIENT_ID: 'YOUR_OAUTH_CLIENT_ID.apps.googleusercontent.com', + + /** + * Your Google Maps Platform API Key. + * This is used to display the map and call the Routes API. + * Ensure it has "Maps JavaScript API", "Geocoding API", and "Routes API" enabled. + */ + MAPS_API_KEY: 'YOUR_MAPS_API_KEY', + + /** + * Defines which dataset to query. + * Options: 'FULL' or 'SAMPLE' + * - 'FULL': Queries the full country datasets (e.g., places_insights___us.places). + * - 'SAMPLE': Queries the city sample datasets (e.g., places_insights___us___sample.places_sample). + */ + DATASET: 'FULL', +}; \ No newline at end of file diff --git a/places_insights/places-insights-demo/display.js b/places_insights/places-insights-demo/display.js new file mode 100644 index 0000000..69d2af2 --- /dev/null +++ b/places_insights/places-insights-demo/display.js @@ -0,0 +1,252 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// --- DISPLAY & DATA HELPERS --- + +/** + * Displays the result of a simple aggregate query in a Google Maps InfoWindow. + * This function is now capable of handling multiple rows for brand queries. + * @param {object} bqResult The JSON response from the BigQuery API. + * @param {google.maps.LatLng} center The location to anchor the InfoWindow. + */ +function displayResultsOnMap(bqResult, center) { + if (infoWindow) infoWindow.close(); + + // Handle cases where the query returns no rows (e.g., count < 5). + if (!bqResult.rows || bqResult.rows.length === 0) { + infoWindow = new google.maps.InfoWindow({ + content: "

Query returned no results.
(Note: Aggregations may require a minimum count to appear).

" + }); + infoWindow.setPosition(center); + infoWindow.open(map); + return; + } + + const schema = bqResult.schema.fields; + + // Build an HTML string from all rows in the result. + let content = '
'; + + bqResult.rows.forEach((row, rowIndex) => { + if (rowIndex > 0) { + content += '
'; + } + const rowData = row.f; + schema.forEach((field, index) => { + const name = field.name.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); + content += `${name}: ${rowData[index].v}
`; + }); + }); + + content += '
'; + + infoWindow = new google.maps.InfoWindow({ content }); + infoWindow.setPosition(center); + infoWindow.open(map); +} + +/** + * Displays the results of an H3 density query as a deck.gl heatmap layer. + * This version processes parallel arrays of h3 indices and counts from a single-row ARRAY_AGG result. + * @param {object} bqResult The JSON response from the BigQuery API. + */ +function displayH3Results(bqResult) { + const tooltip = document.getElementById('tooltip'); + + // With ARRAY_AGG, we expect exactly one row. + if (!bqResult.rows || bqResult.rows.length === 0) { + updateStatus("Query returned no results. Try a larger search area or different filters.", 'info'); + return; + } + + const rowData = bqResult.rows[0].f; + const indices = rowData[0].v; // Array of h3_index objects: [{v: '...'}, {v: '...'}] + const counts = rowData[1].v; // Array of count objects: [{v: '...'}, {v: '...'}] + + // If the first index is null or the array is empty, it means the ARRAY_AGG was empty. + if (!indices || indices.length === 0 || indices[0].v === null) { + updateStatus("Query returned no results. Try a larger search area or different filters.", 'info'); + return; + } + + const h3Data = []; + for (let i = 0; i < indices.length; i++) { + h3Data.push({ + h3_index: indices[i].v, + count: parseInt(counts[i].v, 10), + }); + } + + const maxCount = Math.max(...h3Data.map(d => d.count)); + + // Create a new deck.gl GeoJsonLayer for the H3 cells. + const layer = new deck.GeoJsonLayer({ + id: 'h3-layer', + data: h3Data.map(d => { + const boundary = h3.cellToBoundary(d.h3_index); + const coordinates = boundary.map(p => [p[1], p[0]]); // Swap to [lng, lat] + coordinates.push(coordinates[0]); // Close the polygon ring + + return { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [coordinates] + }, + properties: { count: d.count } + }; + }), + wrapLongitude: true, + pickable: true, + stroked: true, + filled: true, + lineWidthMinPixels: 1, + getFillColor: d => colorScale(d.properties.count, maxCount), + getLineColor: [255, 255, 255, 100], + onHover: info => { + isHoveringH3 = !!info.object; // Track hover state to prevent map click conflicts + + if (info.object) { + tooltip.style.display = 'block'; + tooltip.style.left = `${info.x}px`; + tooltip.style.top = `${info.y}px`; + tooltip.innerHTML = `Count: ${info.object.properties.count}`; + } else { + tooltip.style.display = 'none'; + } + } + }); + + deckglOverlay.setProps({ layers: [layer] }); +} + + +/** + * Parses a WKT Polygon string (e.g., "POLYGON((lng lat, ...))") into a GeoJSON coordinates array. + * Uses Regex to robustly handle optional spaces after POLYGON. + * @param {string} wkt The WKT string. + * @returns {Array} Array of [lng, lat] pairs. + */ +function parseWktPolygon(wkt) { + if (!wkt) return []; + + // Match POLYGON((...)) or POLYGON ((...)) case-insensitive + const match = wkt.match(/POLYGON\s*\(\((.*)\)\)/i); + + if (!match || !match[1]) { + console.error("Failed to parse WKT:", wkt); + return []; + } + + try { + const content = match[1]; + return [content.split(',').map(pair => { + const [lng, lat] = pair.trim().split(/\s+/); // Split by any whitespace + return [parseFloat(lng), parseFloat(lat)]; + })]; + } catch (e) { + console.error("Failed to parse WKT coordinates:", wkt, e); + return []; + } +} + +/** + * Displays results from the PLACES_COUNT_PER_H3 function. + * Uses the server-side 'geography' geometry directly. + * @param {object} bqResult + */ +function displayH3FunctionResults(bqResult) { + const tooltip = document.getElementById('tooltip'); + + if (!bqResult.rows || bqResult.rows.length === 0) { + updateStatus("Query returned no results.", 'info'); + return; + } + + // Map rows to GeoJSON features + const features = bqResult.rows.map(row => { + const cols = row.f; + // Schema assumed: h3_cell_index (0), geography (1), count (2), place_ids (3) + const wkt = cols[1].v; + const count = parseInt(cols[2].v, 10); + + // Parse place_ids. BigQuery returns arrays as {v: [{v: 'id1'}, {v: 'id2'}]} + let placeIds = []; + if (cols[3] && cols[3].v) { + placeIds = cols[3].v.map(item => item.v); + } + + return { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: parseWktPolygon(wkt) + }, + properties: { + count: count, + place_ids: placeIds + } + }; + }); + + const maxCount = Math.max(...features.map(f => f.properties.count)); + + const layer = new deck.GeoJsonLayer({ + id: 'h3-func-layer', + data: features, + pickable: true, + stroked: true, + filled: true, + lineWidthMinPixels: 1, + getFillColor: d => colorScale(d.properties.count, maxCount), + getLineColor: [255, 255, 255, 150], + onHover: info => { + isHoveringH3 = !!info.object; // Track hover state + + if (info.object) { + tooltip.style.display = 'block'; + tooltip.style.left = `${info.x}px`; + tooltip.style.top = `${info.y}px`; + let html = `Count: ${info.object.properties.count}`; + if (info.object.properties.place_ids && info.object.properties.place_ids.length > 0) { + html += `
Click to show sample places`; + } + tooltip.innerHTML = html; + } else { + tooltip.style.display = 'none'; + } + }, + onClick: info => { + if (info.object && info.object.properties.place_ids) { + loadPlaceMarkers(info.object.properties.place_ids); + } + } + }); + + deckglOverlay.setProps({ layers: [layer] }); +} + +/** + * Calculates a color for a heatmap cell based on its value relative to the max value. + * @param {number} value The count for the current cell. + * @param {number} max The maximum count in the dataset. + * @returns {Array} An RGBA color array. + */ +function colorScale(value, max) { + const percentage = Math.sqrt(value / max); // Use sqrt for better visual distribution of colors. + const r = 255; + const g = 255 - (200 * percentage); + const b = 0; + return [r, g, b, 180]; // Yellow to Red, with some transparency +} \ No newline at end of file diff --git a/places_insights/places-insights-demo/help.js b/places_insights/places-insights-demo/help.js new file mode 100644 index 0000000..1b8faae --- /dev/null +++ b/places_insights/places-insights-demo/help.js @@ -0,0 +1,153 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Generates the HTML content for the User Guide modal dynamically based on the configuration. + * @returns {string} HTML string for the guide. + */ +function generateGuideHtml() { + const isSample = DATASET === 'SAMPLE'; + const locationType = isSample ? 'City' : 'Country'; + const locationTypeLower = locationType.toLowerCase(); + + // Example dataset name for explanation + const exampleLocation = isSample ? 'London, United Kingdom' : 'United Kingdom'; + const exampleDataset = isSample ? 'places_insights___gb___sample' : 'places_insights___gb'; + + return ` +

1. Introduction

+

+ Welcome to the Places Insights Demo! This interactive web application is designed to help you explore and visualize Google's rich geospatial data without needing to write any code. Using a simple interface, you can define search areas on a map, apply powerful filters, and get aggregated insights directly from Google BigQuery, displayed visually on Google Maps. +

+

+ This guide will walk you through all the features of the application, from getting started to running advanced queries. +

+ +

2. Getting Started

+

+ Before you can run a query, there are two initial steps you must complete. +

+

Step 1: Select a ${locationType}

+

+ When you first load the application, you will be greeted by a modal window prompting you to select a ${locationTypeLower}. +

+
    +
  • Why is this important? The ${locationTypeLower} you choose determines which BigQuery dataset your queries will run against (e.g., selecting "${exampleLocation}" targets the ${exampleDataset} dataset).
  • +
  • Action: Choose a ${locationTypeLower} from the dropdown menu and click Show Map.
  • +
+ +

Step 2: Authorize with Google

+

+ Once the map loads, you will see a control sidebar on the left. To run queries, you must grant the application permission to use your Google account. +

+
    +
  • Why is this important? This is a purely client-side application. It runs queries in BigQuery on your behalf, and the costs associated with those queries are billed to your Google Cloud project. Authorization is required to securely link your actions in the browser to your BigQuery account.
  • +
  • Action: Click the green Authorize with Google button and follow the prompts in the Google sign-in window. Once successful, the button will change to Sign Out and the status message will turn green.
  • +
+ +

3. Defining Your Search Area (Demo Types)

+

+ The "Demo Type" dropdown is the primary way to define the geographic area for your search. +

+

A. Circle Search

+

+ This is the simplest search method, ideal for analyzing the area around a specific point. +

+
    +
  1. Select Circle Search from the "Demo Type" dropdown.
  2. +
  3. Set a Radius (meters) in the input box.
  4. +
  5. Click anywhere on the map. A blue circle will appear, defining your search area. Clicking a new location will move the circle.
  6. +
+ +

B. Polygon Search

+

+ This mode allows you to define a custom, multi-sided search area. You can do this in two ways: +

+
    +
  • By Drawing: +
      +
    1. Select Polygon Search from the "Demo Type" dropdown.
    2. +
    3. Click the Start Drawing button. Your cursor will turn into a crosshair.
    4. +
    5. Click on the map to place the corners (vertices) of your polygon.
    6. +
    7. When you are done, click the Finish Drawing button. An editable blue polygon will appear. You can drag the corners or edges to refine the shape.
    8. +
    9. Click Clear Polygon to start over.
    10. +
    +
  • +
  • By Pasting WKT: +
      +
    1. If you have a polygon in Well-Known Text (WKT) format (e.g., POLYGON((lng lat, ...))), you can paste it directly into the Polygon (WKT) text area. The map will automatically draw the shape.
    2. +
    3. Conversely, as you draw or edit a polygon on the map, its WKT representation will automatically appear in the text area.
    4. +
    +
  • +
+ +

C. Region Search

+

+ This powerful mode allows you to search by administrative names like cities, states, or postal codes instead of drawing on the map. +

+
    +
  1. Select Region Search from the "Demo Type" dropdown.
  2. +
  3. Choose a Region Type from the dropdown. This list is dynamically populated based on the selected ${locationTypeLower}.
  4. +
  5. Enter a name in the Region Name(s) input box (e.g., "London").
  6. +
  7. (Optional) Add Multiple Regions: To search across several regions at once, click the + button after typing each name.
  8. +
+ +

D. Route Search

+

+ This mode is designed for analyzing a corridor along a driving route. +

+
    +
  1. Select Route Search from the "Demo Type" dropdown.
  2. +
  3. In the Origin input, start typing an address or place name and select a location from the autocomplete suggestions.
  4. +
  5. Do the same for the Destination input.
  6. +
  7. Set a Radius (meters) to define the buffer around the route.
  8. +
+ +

E. Places Count Per H3 (Function)

+

+ This advanced mode uses BigQuery's server-side functions to generate high-performance density maps. +

+
    +
  • Low Counts: Unlike standard aggregation, this mode can return counts lower than 5 (including 0).
  • +
  • Sample Places: This mode returns sample Place IDs. Click on any hexagon on the map to load markers for up to 20 sample places in that cell. Clicking a marker will reveal full place details.
  • +
  • Limitations: Brand filters and Opening Hours are not supported in this mode.
  • +
+ +

4. Refining Your Search with Filters

+

+ The filter sections are collapsible; click on any filter title to expand it. +

+
    +
  • Included Place Types: Start typing a place category (e.g., restaurant, park) and click a suggestion to add it as a tag.
  • +
  • Match Primary Type Only: Check this box to search strictly for places where the selected type is their primary classification (e.g., finding a "Restaurant" that is primarily a restaurant, not a hotel with a restaurant).
  • +
  • Business Status: Filter places by their operational status (Operational, Closed Temporarily, Closed Permanently, or Any). Default is Operational.
  • +
  • Attribute Filters: Set min/max ratings or select checkboxes for amenities (e.g., "Offers Delivery").
  • +
  • Opening Hours: Select a Day of Week and time window (Not available in H3 Function mode).
  • +
  • Brand Filters (US Only): Filter by Brand Category or Brand Name (Not available in H3 Function mode).
  • +
+ +

5. Choosing Your Visualization

+
    +
  • Simple Count (Default): Results are displayed as numbers in a pop-up window.
  • +
  • H3 Density Map: Check the Show H3 Density Map box to see a heatmap. Use the H3 Resolution slider to change the cell size. (This is always enabled in H3 Function mode).
  • +
+ +

6. Running a Query and Managing the App

+
    +
  • Run Search: Click the blue Run Search button to execute the query.
  • +
  • View/Copy Query: After running a query, click View Query to see the SQL code. You can copy this SQL to run it directly in the BigQuery console.
  • +
  • Change ${locationType}: Click Change ${locationType} to restart with a different dataset.
  • +
+ `; +} \ No newline at end of file diff --git a/places_insights/places-insights-demo/index.html b/places_insights/places-insights-demo/index.html new file mode 100644 index 0000000..928a213 --- /dev/null +++ b/places_insights/places-insights-demo/index.html @@ -0,0 +1,259 @@ + + + + + + Places Insights Demo + + + + +
+ +
+ + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/places_insights/places-insights-demo/main.js b/places_insights/places-insights-demo/main.js new file mode 100644 index 0000000..cb2f396 --- /dev/null +++ b/places_insights/places-insights-demo/main.js @@ -0,0 +1,371 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// --- MAIN APPLICATION ENTRY POINT --- + +/** + * Hides the "View Query" button and clears the last executed query state. + * This is called whenever a search parameter is changed. + */ +function invalidateQueryState() { + document.getElementById('view-query-btn').classList.add('hidden'); + lastExecutedQuery = null; +} + +/** + * Populates the country/city selection dropdown based on the configuration mode. + */ +function populateLocationSelect() { + const select = document.getElementById("country-select"); + const title = document.getElementById("selector-title"); + const changeBtn = document.getElementById("change-country-btn"); + + // Clear existing options (keep the default disabled one) + while (select.options.length > 1) { + select.remove(1); + } + + if (DATASET === 'SAMPLE') { + title.textContent = "Select a City for the Demo"; + if (changeBtn) changeBtn.textContent = "Change City"; + + Object.keys(SAMPLE_LOCATIONS).sort().forEach(location => { + const option = document.createElement("option"); + option.value = location; + option.textContent = location; + select.appendChild(option); + }); + } else { + // FULL dataset mode + title.textContent = "Select a Country for the Demo"; + if (changeBtn) changeBtn.textContent = "Change Country"; + + Object.keys(COUNTRY_CODES).sort().forEach(location => { + const option = document.createElement("option"); + option.value = location; + option.textContent = location; + select.appendChild(option); + }); + } +} + +/** + * Handles the initial start of the demo after a location is selected from the modal. + */ +function handleStartDemo() { + selectedCountryName = document.getElementById("country-select").value; + if (selectedCountryName) { + document.getElementById("country-selector-modal").classList.add("hidden"); + document.getElementById("sidebar").classList.remove("hidden"); + + const brandFilters = document.getElementById('brand-filters'); + // Logic for Brand Filters visibility + let isUS = false; + if (DATASET === 'SAMPLE') { + isUS = SAMPLE_LOCATIONS[selectedCountryName] === 'us'; + } else { + isUS = selectedCountryName === 'United States'; + } + + if (isUS) { + brandFilters.classList.remove('hidden'); + } else { + brandFilters.classList.add('hidden'); + } + + populateRegionTypes(selectedCountryName); + startDemo(selectedCountryName); + } else { + alert("Please select a location to begin."); + } +} + +/** + * Handles clicks on the "Change Country/City" button. + */ +function handleChangeCountryClick() { + document.getElementById('country-selector-modal').classList.remove('hidden'); + document.getElementById('sidebar').classList.add('hidden'); + resetSidebarUI(); + clearAllOverlays(true); + invalidateQueryState(); +} + +/** + * Handles clicks on the "+" button to add a region to the list. + */ +function handleAddRegionClick() { + const regionInput = document.getElementById('region-name-input'); + const regionList = document.getElementById('selected-regions-list'); + const regionName = regionInput.value.trim(); + + if (regionName) { + addTag(toTitleCase(regionName), regionList); // Apply Title Case here + regionInput.value = ''; + regionInput.focus(); + invalidateQueryState(); + } +} + +/** + * Handles clicks on the "+" button to add a brand to the list. + */ +function handleAddBrandClick() { + const brandInput = document.getElementById('brand-name-input'); + const brandList = document.getElementById('selected-brands-list'); + const brandName = brandInput.value.trim(); + + if (brandName) { + addTag(brandName, brandList); // Keep original case for brands + brandInput.value = ''; + brandInput.focus(); + invalidateQueryState(); + } +} + +/** + * Generates the help HTML via help.js and displays the modal. + */ +function showHelpModal() { + const guideModal = document.getElementById('guide-modal'); + const guideContent = document.getElementById('guide-content'); + + // Use the function from help.js to generate fresh HTML based on current config + try { + guideContent.innerHTML = generateGuideHtml(); + } catch (error) { + console.error(error); + guideContent.innerHTML = '

Error: Could not load the user guide.

'; + } + + guideModal.classList.remove('hidden'); +} + +/** + * Hides the specified modal. + * @param {string} modalId The ID of the modal to hide. + */ +function hideModal(modalId) { + document.getElementById(modalId).classList.add('hidden'); +} + +/** + * Displays the last executed query in a modal. + */ +function showQueryModal() { + if (lastExecutedQuery) { + document.getElementById('query-content').textContent = lastExecutedQuery; + document.getElementById('query-modal').classList.remove('hidden'); + } else { + alert("No valid query has been executed yet."); + } +} + +/** + * Copies the displayed SQL query to the clipboard. + */ +function handleCopyQueryClick() { + const queryText = document.getElementById('query-content').textContent; + const copyButton = document.getElementById('copy-query-btn'); + + navigator.clipboard.writeText(queryText).then(() => { + copyButton.textContent = 'Copied!'; + setTimeout(() => { + copyButton.textContent = 'Copy SQL'; + }, 2000); // Revert text after 2 seconds + }).catch(err => { + console.error('Failed to copy text: ', err); + alert('Failed to copy query to clipboard.'); + }); +} + + +/** + * Populates the Region Type dropdown based on the selected country's configuration. + */ +function populateRegionTypes(locationName) { + const select = document.getElementById('region-type-select'); + select.innerHTML = ''; + + let countryCode; + if (DATASET === 'SAMPLE') { + countryCode = SAMPLE_LOCATIONS[locationName]; + } else { + countryCode = COUNTRY_CODES[locationName]; + } + + const regionFields = REGION_FIELD_CONFIG[countryCode]; + + if (regionFields && regionFields.length > 0) { + const defaultOption = document.createElement('option'); + defaultOption.value = ''; + defaultOption.textContent = '-- Select a region type --'; + defaultOption.disabled = true; + defaultOption.selected = true; + select.appendChild(defaultOption); + + regionFields.forEach(field => { + const option = document.createElement('option'); + option.value = `${field.field}|${field.type}`; + option.textContent = field.label; + select.appendChild(option); + }); + } else { + const disabledOption = document.createElement('option'); + disabledOption.textContent = 'Region search not available'; + disabledOption.disabled = true; + select.appendChild(disabledOption); + } +} + +/** + * Initializes the Place Autocomplete (New) web components for route search. + */ +async function initializeRouteSearch() { + const { PlaceAutocompleteElement } = await google.maps.importLibrary("places"); + + const originContainer = document.getElementById('origin-input-container'); + const destinationContainer = document.getElementById('destination-input-container'); + + const originAutocomplete = new PlaceAutocompleteElement(); + const destinationAutocomplete = new PlaceAutocompleteElement(); + + originContainer.appendChild(originAutocomplete); + destinationContainer.appendChild(destinationAutocomplete); + + originAutocomplete.addEventListener('gmp-select', async ({ placePrediction }) => { + invalidateQueryState(); + const place = placePrediction.toPlace(); + await place.fetchFields({ fields: ['location'] }); + originPlace = place; + }); + + destinationAutocomplete.addEventListener('gmp-select', async ({ placePrediction }) => { + invalidateQueryState(); + const place = placePrediction.toPlace(); + await place.fetchFields({ fields: ['location'] }); + destinationPlace = place; + }); +} + + +/** + * Sets up the accordion functionality for collapsible fieldsets. + */ +function initializeAccordion() { + const fieldsets = document.querySelectorAll('.collapsible-fieldset'); + fieldsets.forEach(fieldset => { + const legend = fieldset.querySelector('legend'); + legend.addEventListener('click', () => { + const wasOpen = fieldset.classList.contains('is-open'); + + fieldsets.forEach(fs => fs.classList.remove('is-open')); + + if (!wasOpen) { + fieldset.classList.add('is-open'); + } + }); + }); +} + + +/** + * This function runs once the entire page, including all external scripts, has finished loading. + */ +window.onload = () => { + // Populate the selector based on config + populateLocationSelect(); + + const elements = { + sidebar: document.getElementById('sidebar'), + authButton: document.getElementById('auth-button'), + runQueryBtn: document.getElementById('run-query-btn'), + viewQueryBtn: document.getElementById('view-query-btn'), + copyQueryBtn: document.getElementById('copy-query-btn'), + demoTypeSelect: document.getElementById('demo-type-select'), + startButton: document.getElementById("start-demo-btn"), + changeCountryBtn: document.getElementById('change-country-btn'), + addRegionBtn: document.getElementById('add-region-btn'), + addBrandBtn: document.getElementById('add-brand-btn'), + showHelpBtn: document.getElementById('show-help-btn'), + guideModal: document.getElementById('guide-modal'), + queryModal: document.getElementById('query-modal'), + closeHelpBtn: document.querySelector('#guide-modal .close-modal-btn'), + closeQueryBtn: document.querySelector('#query-modal .close-modal-btn'), + h3Toggle: document.getElementById('h3-density-toggle'), + h3Controls: document.getElementById('h3-resolution-controls'), + h3Slider: document.getElementById('h3-resolution-slider'), + h3Value: document.getElementById('h3-resolution-value'), + wktInput: document.getElementById('wkt-input'), + clearPolygonBtn: document.getElementById('clear-polygon-btn'), + startDrawingBtn: document.getElementById('start-drawing-btn'), + finishDrawingBtn: document.getElementById('finish-drawing-btn'), + dayOfWeekSelect: document.getElementById('day-of-week-select'), + startTimeInput: document.getElementById('start-time-input'), + endTimeInput: document.getElementById('end-time-input') + }; + + // Central invalidation listener for any change within the sidebar. + elements.sidebar.addEventListener('input', invalidateQueryState); + + elements.authButton.addEventListener('click', handleAuthClick); + elements.runQueryBtn.addEventListener('click', runQuery); + elements.viewQueryBtn.addEventListener('click', showQueryModal); + elements.copyQueryBtn.addEventListener('click', handleCopyQueryClick); + elements.startButton.addEventListener("click", handleStartDemo); + elements.changeCountryBtn.addEventListener('click', handleChangeCountryClick); + elements.addRegionBtn.addEventListener('click', handleAddRegionClick); + elements.addBrandBtn.addEventListener('click', handleAddBrandClick); + elements.showHelpBtn.addEventListener('click', showHelpModal); + elements.closeHelpBtn.addEventListener('click', () => hideModal('guide-modal')); + elements.closeQueryBtn.addEventListener('click', () => hideModal('query-modal')); + elements.guideModal.addEventListener('click', (e) => { + if (e.target === elements.guideModal) hideModal('guide-modal'); + }); + elements.queryModal.addEventListener('click', (e) => { + if (e.target === elements.queryModal) hideModal('query-modal'); + }); + elements.demoTypeSelect.addEventListener('change', handleDemoTypeChange); + + elements.h3Toggle.addEventListener('change', (e) => { + elements.h3Controls.classList.toggle('hidden', !e.target.checked); + }); + elements.h3Slider.addEventListener('input', (e) => { + elements.h3Value.textContent = e.target.value; + }); + + elements.clearPolygonBtn.addEventListener('click', clearPolygon); + elements.startDrawingBtn.addEventListener('click', startDrawing); + elements.finishDrawingBtn.addEventListener('click', finishDrawing); + + // New: Event listener for opening hours filter + elements.dayOfWeekSelect.addEventListener('change', (e) => { + const daySelected = e.target.value !== ''; + elements.startTimeInput.disabled = !daySelected; + elements.endTimeInput.disabled = !daySelected; + if (!daySelected) { + elements.startTimeInput.value = ''; + elements.endTimeInput.value = ''; + } + }); + + initializeIdentityServices(); + initializeAutocomplete(document.getElementById('place-type-input')); + initializeRouteSearch(); + initializeAccordion(); + + // Set initial state for time inputs + elements.startTimeInput.disabled = true; + elements.endTimeInput.disabled = true; +}; \ No newline at end of file diff --git a/places_insights/places-insights-demo/map.js b/places_insights/places-insights-demo/map.js new file mode 100644 index 0000000..cfbafcd --- /dev/null +++ b/places_insights/places-insights-demo/map.js @@ -0,0 +1,322 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// --- MAP & DRAWING LOGIC --- + +// New state for sample markers +let sampleMarkers = []; +let detailsInfoWindow = null; + +/** + * Main entry point for map initialization after country selection. + */ +async function startDemo(countryName) { + try { + await initMap(countryName); + } catch (error) { + console.error("Error starting the demo:", error); + alert("Map initialization failed. Check console for details."); + document.getElementById("country-selector-modal").classList.remove("hidden"); + document.getElementById("sidebar").classList.add("hidden"); + } +} + +/** + * Initializes the Google Map, deck.gl overlay, and main click listener. + */ +async function initMap(countryName) { + const { Map, Circle, InfoWindow, Polyline, Polygon } = await google.maps.importLibrary("maps"); + const { Geocoder } = await google.maps.importLibrary("geocoding"); + const geocoder = new Geocoder(); + const { results } = await geocoder.geocode({ address: countryName }); + + if (results.length > 0) { + map = new Map(document.getElementById("map"), { + mapId: "DEMO_MAP_ID", + mapTypeControl: false, + streetViewControl: false + }); + map.fitBounds(results[0].geometry.viewport); + + deckglOverlay = new deck.GoogleMapsOverlay({}); + deckglOverlay.setMap(map); + map.addListener('click', handleMapClick); + } else { + throw new Error("Geocoding failed for the selected country."); + } +} + +/** + * Handles the user changing the demo type (Circle vs. Polygon vs. Region vs. Route). + */ +function handleDemoTypeChange(e) { + const demoType = e.target.value; + const isH3Function = demoType === 'h3-function'; + + // 1. Reset UI to the selected mode (clears inputs, sets correct visibility) + resetSidebarUI(demoType); + + // 2. Apply Special Rules for H3 Function + if (isH3Function) { + // Force H3 Toggle ON and disabled + const h3Toggle = document.getElementById('h3-density-toggle'); + h3Toggle.checked = true; + h3Toggle.disabled = true; + + // Show Slider with Max 8 + document.getElementById('h3-resolution-controls').classList.remove('hidden'); + const h3Slider = document.getElementById('h3-resolution-slider'); + h3Slider.max = '8'; + if (parseInt(h3Slider.value) > 8) { + h3Slider.value = '8'; + document.getElementById('h3-resolution-value').textContent = '8'; + } + + // Always Hide Brand Filters for Function mode + document.getElementById('brand-filters').classList.add('hidden'); + + // Hide Opening Hours for Function mode + document.getElementById('opening-hours-filters').classList.add('hidden'); + + } else { + // 3. Restore Standard State (if not handled by resetSidebarUI defaults) + // Ensure Country-specific Brand Filters are visible if applicable (US) + const brandFilters = document.getElementById('brand-filters'); + if (selectedCountryName === 'United States') { + brandFilters.classList.remove('hidden'); + } + + // Show Opening Hours + document.getElementById('opening-hours-filters').classList.remove('hidden'); + } + + map.setOptions({ draggableCursor: 'grab' }); + clearAllOverlays(true); + invalidateQueryState(); +} + +/** + * Central handler for all clicks on the map. + */ +function handleMapClick(e) { + // If we are hovering over an H3 cell (and likely clicking it), ignore the map click + // This prevents the map background click from clearing the H3 overlay + if (isHoveringH3) return; + + const demoType = document.getElementById('demo-type-select').value; + // Allow circle placement for both standard Circle Search AND the new H3 Function + if (demoType === 'circle-search' || demoType === 'h3-function') { + invalidateQueryState(); + clearAllOverlays(true); + searchCenter = e.latLng; + const radius = parseInt(document.getElementById('radius-input').value, 10); + searchCircle = new google.maps.Circle({ + strokeColor: "#4285F4", strokeOpacity: 0.8, strokeWeight: 2, + fillColor: "transparent", map, center: searchCenter, radius: radius, + }); + } else if (demoType === 'polygon-search' && isDrawing) { + invalidateQueryState(); + polygonVertices.push(e.latLng); + tempPolyline.setPath(polygonVertices); + } +} + +/** + * Puts the application into "drawing mode". + */ +function startDrawing() { + invalidateQueryState(); + clearPolygon(); + isDrawing = true; + polygonVertices = []; + map.setOptions({ draggableCursor: 'crosshair' }); + tempPolyline = new google.maps.Polyline({ map: map, strokeColor: "#0000FF", strokeWeight: 2 }); + + document.getElementById('start-drawing-btn').classList.add('hidden'); + document.getElementById('finish-drawing-btn').classList.remove('hidden'); + document.getElementById('polygon-instructions').textContent = "Click points on the map. Click 'Finish' when done."; +} + +/** + * Exits "drawing mode" and finalizes the polygon shape. + */ +function finishDrawing() { + if (!isDrawing) return; + isDrawing = false; + map.setOptions({ draggableCursor: 'grab' }); + + if (tempPolyline) tempPolyline.setMap(null); + tempPolyline = null; + + document.getElementById('start-drawing-btn').classList.remove('hidden'); + document.getElementById('finish-drawing-btn').classList.add('hidden'); + document.getElementById('polygon-instructions').textContent = "Define a search area by drawing or pasting WKT."; + + if (polygonVertices.length < 3) { + polygonVertices = []; + return; + } + + searchPolygon = new google.maps.Polygon({ + paths: polygonVertices, editable: true, draggable: true, fillColor: '#5599FF', + fillOpacity: 0.3, strokeColor: '#0000FF', strokeWeight: 2, map: map, + }); + + updateWktFromPolygon(searchPolygon); + + searchPolygon.getPaths().forEach(path => { + google.maps.event.addListener(path, 'set_at', () => { + updateWktFromPolygon(searchPolygon); + invalidateQueryState(); + }); + google.maps.event.addListener(path, 'insert_at', () => { + updateWktFromPolygon(searchPolygon); + invalidateQueryState(); + }); + }); + invalidateQueryState(); +} + +/** + * Clears all visual overlays from the map. + * @param {boolean} fullReset If true, also nullifies state variables for search geometry. + */ +function clearAllOverlays(fullReset = false) { + if (isDrawing) finishDrawing(); + if (searchCircle) searchCircle.setMap(null); + if (searchPolygon) searchPolygon.setMap(null); + if (routePolyline) routePolyline.setMap(null); + if (infoWindow) infoWindow.close(); + if (deckglOverlay) deckglOverlay.setProps({ layers: [] }); + + clearSampleMarkers(); + + if (fullReset) { + searchCenter = null; + searchCircle = null; + searchPolygon = null; + routePolyline = null; + originPlace = null; + destinationPlace = null; + } +} + +/** + * Clears only the polygon and its associated state. + */ +function clearPolygon() { + invalidateQueryState(); + if (isDrawing) finishDrawing(); + if (searchPolygon) searchPolygon.setMap(null); + searchPolygon = null; + document.getElementById('wkt-input').value = ''; + document.getElementById('wkt-input').classList.remove('invalid'); +} + +/** + * Clears all sample place markers from the map. + */ +function clearSampleMarkers() { + sampleMarkers.forEach(m => m.map = null); + sampleMarkers = []; + if (detailsInfoWindow) { + detailsInfoWindow.close(); + } +} + +/** + * Fetches details for a list of place IDs and puts markers on the map. + * @param {string[]} placeIds List of Place IDs to show. + */ +async function loadPlaceMarkers(placeIds) { + clearSampleMarkers(); + + if (!placeIds || placeIds.length === 0) return; + + // Limit to top 20 to ensure performance/rate limits + const limit = 20; + const subset = placeIds.slice(0, limit); + + updateStatus(`Fetching locations for ${subset.length} places...`); + + try { + const { Place } = await google.maps.importLibrary("places"); + const { AdvancedMarkerElement, PinElement } = await google.maps.importLibrary("marker"); + // Import to register web components for InfoWindow + await google.maps.importLibrary("places"); + + if (!detailsInfoWindow) { + detailsInfoWindow = new google.maps.InfoWindow(); + } + + const bounds = new google.maps.LatLngBounds(); + + for (const id of subset) { + // Individual try/catch so one failure doesn't stop the loop + try { + const place = new Place({ id: id }); + await place.fetchFields({ fields: ['location'] }); + + if (!place.location) continue; + + const pin = new PinElement({ + scale: 0.8, + background: "#FBBC04", + borderColor: "#137333", + glyphColor: "white" + }); + + const marker = new AdvancedMarkerElement({ + map: map, + position: place.location, + content: pin.element, + title: "Click for details" + }); + + // Click Listener: Create Web Component in InfoWindow + marker.addListener('click', () => { + const container = document.createElement('div'); + container.className = 'info-window-component-container'; + + const details = document.createElement('gmp-place-details-compact'); + details.setAttribute('orientation', 'vertical'); + + const request = document.createElement('gmp-place-details-place-request'); + request.setAttribute('place', id); + + const allContent = document.createElement('gmp-place-all-content'); + + details.appendChild(request); + details.appendChild(allContent); + container.appendChild(details); + + detailsInfoWindow.setContent(container); + detailsInfoWindow.open(map, marker); + }); + + sampleMarkers.push(marker); + bounds.extend(place.location); + + } catch (e) { + console.warn(`Failed to fetch place ${id}`, e); + } + } + + updateStatus(`Showing ${sampleMarkers.length} sample places. Click a marker for details.`, 'success'); + + } catch (error) { + console.error("Error loading markers:", error); + updateStatus("Error loading sample markers.", 'error'); + } +} \ No newline at end of file diff --git a/places_insights/places-insights-demo/place_types.js b/places_insights/places-insights-demo/place_types.js new file mode 100644 index 0000000..334081a --- /dev/null +++ b/places_insights/places-insights-demo/place_types.js @@ -0,0 +1,86 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// This file contains a comprehensive list of place types from the documentation +// to be used by the application's autocomplete feature. + +const PLACE_TYPES = [ + 'accounting', 'acai_shop', 'administrative_area_level_1', 'administrative_area_level_2', 'administrative_area_level_3', 'administrative_area_level_4', 'administrative_area_level_5', + 'adult_club', 'advertising_agency', 'afghan_restaurant', 'african_restaurant', 'airport', 'airport_gate', 'airport_lounge', 'airport_terminal', 'airpost', 'airprt_runway', + 'american_restaurant', 'amphitheater', 'amusement_center', 'amusement_park', 'animal_and_plant_health_inspection_service', 'animal_shelter', 'aquarium', 'archipelago', + 'art_gallery', 'art_school', 'art_studio', 'ashram', 'asian_restaurant', 'assisted_living_facility', 'athletic_field', 'atm', 'attraction', 'auditorium', 'australian_restaurant', + 'austrian_restaurant', 'auto_detailing_service', 'auto_parts_store', 'auto_repair_shop', 'badminton_court', 'bagel_shop', 'bakery', 'ballet_school', 'bank', 'banquet_hall', + 'bar', 'barbecue_restaurant', 'barber_shop', 'baseball_field', 'basketball_court', 'beach', 'beauty_salon', 'bed_and_breakfast', 'belgian_restaurant', 'beverages', 'bicycle_store', + 'billiards_hall', 'bistro', 'boat_club', 'boat_dealer', 'boat_launch', 'boat_rental', 'boat_tour_agency', 'boating_and_sailing', 'book_store', 'botanical_garden', 'bowling_alley', + 'brazilian_restaurant', 'breakfast_restaurant', 'brewery', 'british_restaurant', 'brunch_restaurant', 'buddhist_temple', 'buffet_restaurant', 'bungalow', 'burrito_restaurant', 'bus_charter', + 'bus_station', 'bus_stop', 'business_park', 'butcher_shop', 'cafe', 'cafeteria', 'cajun_restaurant', 'cake_shop', 'californian_restaurant', 'cambodian_restaurant', 'campground', + 'camping_cabin', 'canadian_restaurant', 'candy_store', 'cannabis_store', 'cape', 'car_dealer', 'car_rental', 'car_repair', 'car_wash', 'caribbean_restaurant', 'carpenter', + 'casino', 'catering_service', 'cemetery', 'chalet', 'charity', 'charter_school', 'check_cashing_service', 'child_care_agency', 'childrens_club', 'childrens_museum', 'chilean_restaurant', + 'chinese_restaurant', 'chiropractor', 'chocolate_shop', 'christmas_market', 'church', 'city_hall', 'civic_center', 'classical_music_venue', 'clinic', 'clothing_store', + 'club', 'cocktail_bar', 'coffee_shop', 'college', 'colombian_restaurant', 'comedy_club', 'community_center', 'community_garden', 'community_hall', 'computer_repair_service', + 'concert_hall', 'condominium_complex', 'confectionery', 'conference_center', 'convenience_store', 'convention_center', 'cooperative', 'corporate_office', 'cosmetics_store', 'cottage', + 'country', 'country_club', 'courthouse', 'couture_store', 'coworking_space', 'creek', 'creole_restaurant', 'creperie', 'cricket_ground', 'cultural_center', 'currency_exchange', 'curtain_store', + 'cycling_park', 'czech_restaurant', 'dance_hall', 'dance_school', 'day_care_center', 'deli', 'dental_clinic', 'dentist', 'department_store', 'dessert_restaurant', 'dessert_shop', + 'dim_sum_restaurant', 'diner', 'disc_golf_course', 'distillery', 'dive_shop', 'dog_park', 'dominican_restaurant', 'donut_shop', 'door_and_window_store', 'drama_school', 'driving_school', + 'drugstore', 'dry_cleaner', 'dump_truck_dealer', 'dumpling_restaurant', 'dutch_restaurant', 'eclectic_restaurant', 'ecuadorian_restaurant', 'educational_center', 'egyptian_restaurant', + 'electric_vehicle_charging_station', 'electrical_equipment_supplier', 'electrician', 'electronics_store', 'elementary_school', 'embassy', 'emergency_room', 'english_restaurant', 'equestrian_club', + 'eritrean_restaurant', 'ethiopian_restaurant', 'event_venue', 'extended_stay_hotel', 'family_restaurant', 'farm', 'farmers_market', 'fast_food_restaurant', 'ferris_wheel', 'ferry_terminal', + 'festival', 'filipino_restaurant', 'fine_dining_restaurant', 'finnish_restaurant', 'fire_station', 'fish_and_chips_restaurant', 'fish_store', 'fishing_charter', 'fishing_pond', 'fitness_center', + 'flea_market', 'florist', 'food_and_drink', 'food_court', 'food_delivery', 'food_pantry', 'food_producer', 'food_truck', 'football_field', 'foot_care', 'forest', 'fraternal_organization', + 'french_restaurant', 'frozen_yogurt_shop', 'fruit_and_vegetable_store', 'funeral_home', 'furniture_store', 'fusion_restaurant', 'game_store', 'garden', 'gas_station', 'gastropub', + 'general_contractor', 'geological_feature', 'german_restaurant', 'ghanaian_restaurant', 'gift_shop', 'glass_and_mirror_shop', 'go_kart_track', 'golf_course', 'gourmet_grocery_store', + 'government_office', 'greek_restaurant', 'grocery_store', 'guatemalan_restaurant', 'guest_house', 'gym', 'gymnastics_center', 'hair_care', 'hair_salon', 'haitian_restaurant', + 'halal_restaurant', 'hamburger_restaurant', 'handball_court', 'hardware_store', 'hawaiian_restaurant', 'health_and_wellness', 'health_food_store', 'health_spa', 'heliport', 'high_school', + 'hiking_area', 'hindu_temple', 'historical_landmark', 'historical_place', 'historical_society', 'hockey_rink', 'home_goods_store', 'home_improvement_store', 'honduran_restaurant', + 'horse_riding_school', 'hospital', 'hostel', 'hot_dog_stand', 'hot_pot_restaurant', 'hotel', 'house', 'household_goods_store', 'housing_complex', 'hungarian_restaurant', 'ice_cream_shop', + 'ice_skating_rink', 'indian_restaurant', 'indonesian_restaurant', 'indoor_cycling', 'indoor_playground', 'industrial_park', 'inn', 'insurance_agency', 'internet_cafe', 'intersection', + 'investment_firm', 'irish_pub', 'irish_restaurant', 'island', 'israeli_restaurant', 'italian_restaurant', 'jamaican_restaurant', 'japanese_restaurant', 'jazz_club', 'jewelry_store', 'jewish_restaurant', + 'judo_school', 'juice_shop', 'karaoke', 'kebab_shop', 'kindergarten', 'korean_restaurant', 'kosher_restaurant', 'labor_union', 'lake', 'landmark', 'language_school', 'laotian_restaurant', + 'latin_american_restaurant', 'laundromat', 'laundry', 'law_firm', 'lawyer', 'leather_goods_store', 'lebanese_restaurant', 'legal_services', 'leisure_center', 'library', 'light_rail_station', + 'liquor_store', 'loan_agency', 'locality', 'locksmith', 'lodging', 'lounge', 'luggage_store', 'lunch_restaurant', 'madagascan_restaurant', 'mail_box', 'mailing_service', 'malaysian_restaurant', + 'marina', 'market', 'marketplace', 'martial_arts_school', 'masonic_temple', 'massage_therapist', 'meal_delivery', 'meal_takeaway', 'medical_clinic', 'medical_lab', 'medical_supply_store', + 'meditation_center', 'mediterranean_restaurant', 'meeting_room', 'memorial_park', 'mental_health_clinic', 'mexican_restaurant', 'middle_eastern_restaurant', 'military_base', + 'miniature_golf_course', 'mobile_home_park', 'modern_art_museum', 'modern_european_restaurant', 'mongolian_restaurant', 'monument', 'moroccan_restaurant', 'mosque', 'motel', 'motorcycle_dealer', + 'motorcycle_rental', 'motorcycle_repair_shop', 'mountain', 'mountain_peak', 'movie_rental', 'movie_theater', 'moving_company', 'museum', 'music_school', 'music_venue', 'nail_salon', + 'national_forest', 'national_park', 'natural_feature', 'nepalese_restaurant', 'new_zealand_restaurant', 'nicaraguan_restaurant', 'night_club', 'nigerian_restaurant', 'non_profit_organization', + 'noodle_restaurant', 'nordic_restaurant', 'north_african_restaurant', 'north_indian_restaurant', 'norwegian_restaurant', 'nursing_home', 'observation_deck', 'observatory', 'off_roading_area', + 'office_supply_store', 'opera_house', 'optician', 'optometrist', 'orthodox_church', 'orthopedic_surgeon', 'orthopedist', 'osteopath', 'outlet_store', 'package_delivery_service', + 'paella_restaurant', 'pakistani_restaurant', 'pan_asian_restaurant', 'panamanian_restaurant', 'park', 'park_and_ride', 'parking', 'parking_garage', 'parkway', 'party_planner', 'passport_office', + 'pastry_shop', 'patio', 'pawn_shop', 'pediatrician', 'peninsula', 'pension', 'performing_arts_theater', 'persian_restaurant', 'peruvian_restaurant', 'pet_store', 'pharmacy', 'philharmonic_hall', + 'phone_repair_shop', 'photographer', 'physical_therapist', 'physician', 'physiotherapist', 'picnic_ground', 'pier', 'pilates_studio', 'pizza_restaurant', 'pizza_takeaway', 'place_of_worship', + 'planetarium', 'plant_nursery', 'plumber', 'podiatrist', 'point_of_interest', 'police_station', 'polish_restaurant', 'political_organization', 'pond', 'pool_hall', 'port', 'portuguese_restaurant', + 'post_office', 'poultry_store', 'prefecture', 'preschool', 'primary_school', 'private_guest_room', 'private_school', 'private_tutor', 'produce_market', 'professional_organization', 'psychiatrist', + 'psychologist', 'psychotherapist', 'pub', 'public_bath', 'public_bathroom', 'public_housing', 'public_school', 'public_transportation', 'puerto_rican_restaurant', 'pumping_station', 'punjabi_restaurant', + 'quarry', 'race_track', 'racquetball_court', 'ramen_restaurant', 'ranch', 'rapid_transit_station', 'real_estate_agency', 'recreation_center', 'recycling_center', 'redevelopment_agency', + 'reggae_night_club', 'regional_park', 'rehabilitation_center', 'religious_organization', 'rental_agency', 'repair_service', 'research_institute', 'reservable', 'reservoir', 'resort', 'resort_hotel', + 'restaurant', 'rest_stop', 'retirement_home', 'river', 'road', 'rock_climbing_gym', 'roller_coaster', 'roller_skating_rink', 'romanian_restaurant', 'roofing_contractor', 'rooming_house', + 'route', 'rv_park', 'sailing_club', 'salad_shop', 'salon', 'salsa_club', 'salvadoran_restaurant', 'sandwich_shop', 'sauna', 'savannah', 'school', 'school_district_office', 'science_museum', + 'scottish_restaurant', 'scuba_diving_center', 'sculpture', 'seafood_restaurant', 'secondary_school', 'self_storage_facility', 'senior_center', 'serbian_restaurant', 'service_station', + 'sex_therapist', 'shabu_shabu_restaurant', 'shawarma_restaurant', 'shipping_and_mailing_service', 'shoe_store', 'shopping_mall', 'shooting_range', 'shrine', 'sichuan_restaurant', 'sicilian_restaurant', + 'singaporean_restaurant', 'skate_park', 'skateboard_shop', 'skating_rink', 'ski_resort', 'skin_care_clinic', 'skydiving_center', 'slovak_restaurant', 'slovenian_restaurant', 'smoothie_shop', + 'snack_bar', 'soccer_field', 'social_club', 'social_security_office', 'social_service_organization', 'softball_field', 'soul_food_restaurant', 'soup_restaurant', 'south_african_restaurant', + 'south_indian_restaurant', 'south_pacific_restaurant', 'south_american_restaurant', 'southeast_asian_restaurant', 'southwestern_restaurant', 'souvenir_store', 'spa', 'spanish_restaurant', + 'special_education_school', 'sports_bar', 'sports_club', 'sports_complex', 'sporting_goods_store', 'squash_court', 'stadium', 'stable', 'state_park', 'steak_house', 'storage', 'store', + 'street', 'strip_club', 'student_housing', 'studio', 'subway_station', 'sushi_restaurant', 'swamp', 'swedish_restaurant', 'swimming_pool', 'swim_club', 'swiss_restaurant', 'synagogue', + 'syrian_restaurant', 'taco_restaurant', 'taiwanese_restaurant', 'takeout', 'tanning_salon', 'tapas_restaurant', 'tasting_room', 'tattoo_shop', 'tax_assessor', 'tax_consultant', 'tax_department', + 'taxi_stand', 'tea_house', 'tea_room', 'technical_school', 'telecommunications_service_provider', 'telemarketing_service', 'temple', 'tennis_court', 'tennis_club', 'teppanyaki_restaurant', + 'thai_restaurant', 'theatre', 'theme_park', 'therapist', 'tibetan_restaurant', 'ticket_outlet', 'tiki_bar', 'tire_shop', 'title_company', 'tobacco_shop', 'tongolese_restaurant', 'tourist_attraction', + 'tour_agency', 'townhouse_complex', 'town_hall', 'trade_school', 'traditional_music_venue', 'trail', 'trailer_dealer', 'train_station', 'transit_depot', 'transit_station', 'translation_service', + 'transportation', 'travel_agency', 'truck_stop', 'trinidadian_restaurant', 'turkish_restaurant', 'tuscan_restaurant', 'udon_restaurant', 'ukrainian_restaurant', 'unagi_restaurant', 'university', + 'uruguayan_restaurant', 'used_car_dealer', 'uyghur_restaurant', 'uzbek_restaurant', 'variety_store', 'vegan_restaurant', 'vegetarian_restaurant', 'vehicle_inspection', 'venetian_restaurant', + 'venezuelan_restaurant', 'veterans_affairs', 'veterinarian', 'video_arcade', 'video_game_store', 'video_production_service', 'vietnamese_restaurant', 'villa', 'village_hall', 'vineyard', + 'visitor_center', 'volcano', 'volleyball_court', 'warehouse_store', 'waste_management_service', 'watch_repair_service', 'waterfall', 'water_park', 'water_skiing_club', 'water_sports_equipment_rental', + 'waxing_salon', 'wedding_planner', 'wedding_venue', 'welsh_restaurant', 'western_restaurant', 'wharf', 'wholesale_club', 'wholesaler', 'wifi_hotspot', 'wildlife_park', 'wildlife_refuge', 'wine_bar', + 'winery', 'womens_health_clinic', 'yakitori_restaurant', 'yoga_studio', 'youth_organization', 'zoo' +]; \ No newline at end of file diff --git a/places_insights/places-insights-demo/query.js b/places_insights/places-insights-demo/query.js new file mode 100644 index 0000000..9410cfd --- /dev/null +++ b/places_insights/places-insights-demo/query.js @@ -0,0 +1,427 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// --- BIGQUERY QUERY LOGIC --- + +// Note: fetchRouteAsWkt is imported from routes.js + +// --- SEARCH PARAMETER HELPERS --- + +function getCircleSearchParams() { + if (!searchCenter) { + return { success: false, message: "Click a location on the map to set the search center." }; + } + const radius = parseInt(document.getElementById('radius-input').value, 10); + const filter = `ST_DWITHIN(ST_GEOGPOINT(${searchCenter.lng()}, ${searchCenter.lat()}), places.point, ${radius})`; + return { success: true, filter, center: searchCenter, searchAreaVar: '' }; +} + +function getPolygonSearchParams() { + if (!searchPolygon) { + return { success: false, message: "Draw or paste a polygon to define the search area." }; + } + const wkt = document.getElementById('wkt-input').value; + const searchAreaVar = `DECLARE search_area GEOGRAPHY; SET search_area = ST_GEOGFROMTEXT("""${wkt}""");`; + const filter = 'ST_CONTAINS(search_area, places.point)'; + return { success: true, filter, center: searchPolygon.getPath().getAt(0), searchAreaVar }; +} + +async function getRegionSearchParams() { + const regionInputValue = document.getElementById('region-type-select').value; + const regionNameInput = toTitleCase(document.getElementById('region-name-input').value.trim()); + const regionTags = [...document.querySelectorAll('#selected-regions-list span')].map(s => s.textContent); + + if (!regionInputValue || (!regionNameInput && regionTags.length === 0)) { + return { success: false, message: "Select a Region Type and enter at least one Region Name." }; + } + + const uniqueRegionNames = [...new Set([...regionTags, regionNameInput].filter(Boolean))]; + const [field, dataType] = regionInputValue.split('|'); + const filter = buildRegionFilter(field, dataType, uniqueRegionNames); + + updateStatus('Geocoding regions...'); + const { Geocoder } = await google.maps.importLibrary("geocoding"); + const bounds = new google.maps.LatLngBounds(); + const geocodePromises = uniqueRegionNames.map(name => new Geocoder().geocode({ address: `${name}, ${selectedCountryName}` })); + + const resultsArray = await Promise.all(geocodePromises); + resultsArray.forEach(({ results }) => { + if (results.length > 0) bounds.union(results[0].geometry.viewport); + }); + + if (bounds.isEmpty()) { + throw new Error(`Could not geocode any of the specified regions.`); + } + map.fitBounds(bounds); + + return { + success: true, filter, center: bounds.getCenter(), searchAreaVar: '', + isMultiRegion: uniqueRegionNames.length > 1, regionType: field, regionDataType: dataType + }; +} + +async function getRouteSearchParams() { + if (!originPlace || !destinationPlace) { + return { success: false, message: "Select both an origin and a destination for the route." }; + } + updateStatus('Calculating route...'); + // Call helper from routes.js + const routeData = await fetchRouteAsWkt(originPlace, destinationPlace); + const radius = parseInt(document.getElementById('route-radius-input').value, 10); + const searchAreaVar = `DECLARE route GEOGRAPHY; SET route = ST_GEOGFROMTEXT("""${routeData.wktString}""");`; + const filter = `ST_DWITHIN(route, places.point, ${radius})`; + return { success: true, filter, center: routeData.bounds.getCenter(), searchAreaVar }; +} + + +/** + * The main function to execute a query. It's called when the "Run Search" button is clicked. + */ +async function runQuery() { + const runQueryBtn = document.getElementById('run-query-btn'); + runQueryBtn.disabled = true; + runQueryBtn.textContent = 'Running...'; + updateStatus('Validating inputs...'); + + try { + const demoType = document.getElementById('demo-type-select').value; + + let countryCode; + if (DATASET === 'SAMPLE') { + countryCode = SAMPLE_LOCATIONS[selectedCountryName]; + } else { + countryCode = COUNTRY_CODES[selectedCountryName]; + } + + let sqlQuery; + + // Clean up any existing sample markers from H3 interaction + if (typeof clearSampleMarkers === 'function') { + clearSampleMarkers(); + } + + // 1. Branch for H3 Function (Special Case) + if (demoType === 'h3-function') { + if (!searchCenter) { + throw new Error("Click a location on the map to set the search center."); + } + updateStatus('Checking authorization...'); + const token = await ensureAccessToken(); + + updateStatus('Building function query...'); + sqlQuery = buildH3FunctionQuery(countryCode); + lastExecutedQuery = sqlQuery; + + if (infoWindow) infoWindow.close(); + if (deckglOverlay) deckglOverlay.setProps({ layers: [] }); + + updateStatus('Executing function...'); + const response = await fetch(`https://bigquery.googleapis.com/bigquery/v2/projects/${GCP_PROJECT_ID}/queries`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ query: sqlQuery, useLegacySql: false, maxResults: 100000 }) + }); + + const result = await response.json(); + if (!response.ok) throw new Error(result.error?.message || 'API request failed.'); + + document.getElementById('view-query-btn').classList.remove('hidden'); + if (searchCircle) searchCircle.setMap(null); // Hide circle so heatmap is visible + + displayH3FunctionResults(result); + updateStatus('Query successful.', 'success'); + + } else { + // 2. Standard SQL Logic (Circle, Polygon, Region, Route) + + // Get Geometry Parameters + let searchParams; + switch (demoType) { + case 'circle-search': searchParams = getCircleSearchParams(); break; + case 'polygon-search': searchParams = getPolygonSearchParams(); break; + case 'region-search': searchParams = await getRegionSearchParams(); break; + case 'route-search': searchParams = await getRouteSearchParams(); break; + default: throw new Error("Invalid demo type selected."); + } + + if (!searchParams.success) { + updateStatus(searchParams.message, 'error'); + return; + } + + // Clear previous results and Authorize + if (infoWindow) infoWindow.close(); + if (deckglOverlay) deckglOverlay.setProps({ layers: [] }); + updateStatus('Checking authorization...'); + const token = await ensureAccessToken(); + + // Gather all other filters + updateStatus('Building query...'); + const allFilters = [searchParams.filter]; + + // Place Types Logic (Primary vs Included) + const placeTypes = [...document.querySelectorAll('#selected-types-list span')].map(s => s.textContent); + // Use new Checkbox + const usePrimaryType = document.getElementById('primary-type-checkbox').checked; + + if (placeTypes.length > 0) { + if (usePrimaryType) { + // Primary Type filter + const typeList = placeTypes.map(t => `'${t}'`).join(', '); + allFilters.push(`places.primary_type IN (${typeList})`); + } else { + // Included Type (Standard) filter + allFilters.push(`(${placeTypes.map(t => `'${t}' IN UNNEST(places.types)`).join(' OR ')})`); + } + } + + const attributes = [...document.querySelectorAll('.attribute-filter:checked')].map(cb => cb.name); + if (attributes.length > 0) allFilters.push(...buildAttributeFilter(attributes)); + const ratingFilter = buildRatingFilter(parseFloat(document.getElementById('min-rating-input').value), parseFloat(document.getElementById('max-rating-input').value)); + if (ratingFilter) allFilters.push(ratingFilter); + + const bizStatus = document.getElementById('business-status-select').value; + if (bizStatus) allFilters.push(`places.business_status = '${bizStatus}'`); + + // Brand Filters (only applicable here, not in H3 Function) + const brandNames = [...document.querySelectorAll('#selected-brands-list span')].map(s => s.textContent); + if (brandNames.length > 0) allFilters.push(buildBrandFilter(brandNames)); + + // Brand Category is removed as we no longer have brands.json data + + const openingDay = document.getElementById('day-of-week-select').value; + const hoursFilter = buildOpeningHoursFilter(openingDay, document.getElementById('start-time-input').value, document.getElementById('end-time-input').value); + if (hoursFilter.whereClause) allFilters.push(hoursFilter.whereClause); + + // Assemble FROM clause dynamically based on dataset type + let tableName; + if (DATASET === 'SAMPLE') { + tableName = `places_insights___${countryCode}___sample.places_sample`; + } else { + tableName = `places_insights___${countryCode}.places`; + } + + let fromClause = `FROM \`${tableName}\` places`; + + if (openingDay) fromClause += ` ${hoursFilter.unnestClause}`; + + // Brands Join Logic + const isBrandQuery = brandNames.length > 0; + if (isBrandQuery) { + let brandsTable; + if (DATASET === 'SAMPLE') { + brandsTable = 'places_insights___us___sample.brands'; + } else { + brandsTable = 'places_insights___us.brands'; + } + + fromClause += `, UNNEST(places.brand_ids) AS brand_id LEFT JOIN \`${brandsTable}\` brands ON brand_id = brands.id`; + } + + const whereClause = allFilters.length > 0 ? `WHERE ${allFilters.join(' AND ')}` : ''; + + // Build the final SQL Query + const useH3 = document.getElementById('h3-density-toggle').checked; + + if (useH3) { + const h3Res = parseInt(document.getElementById('h3-resolution-slider').value, 10); + sqlQuery = buildH3DensityQuery(searchParams.searchAreaVar, fromClause, whereClause, h3Res); + } else { + sqlQuery = buildAggregateQuery(searchParams.searchAreaVar, fromClause, whereClause, placeTypes, isBrandQuery, searchParams.isMultiRegion, searchParams.regionType, searchParams.regionDataType); + } + lastExecutedQuery = sqlQuery; + + // Execute Query and Display Results + updateStatus('Executing query...'); + const response = await fetch(`https://bigquery.googleapis.com/bigquery/v2/projects/${GCP_PROJECT_ID}/queries`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ query: sqlQuery, useLegacySql: false, maxResults: 100000 }) + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error?.message || 'API request failed.'); + + document.getElementById('view-query-btn').classList.remove('hidden'); + + if (useH3) { + if (searchCircle) searchCircle.setMap(null); + if (searchPolygon) searchPolygon.setMap(null); + displayH3Results(result); + } else { + if (searchCircle) searchCircle.setMap(map); + if (searchPolygon) searchPolygon.setMap(map); + displayResultsOnMap(result, searchParams.center); + } + + let successMessage = 'Query successful.'; + if (!useH3 && result.rows && Number(result.totalRows) > result.rows.length) { + successMessage += ` Warning: Displaying ${result.rows.length.toLocaleString()} of ${Number(result.totalRows).toLocaleString()} total rows.`; + } + updateStatus(successMessage, 'success'); + } + + } catch (error) { + console.error('Query failed:', error); + updateStatus(`Error: ${error.message}`, 'error'); + } finally { + runQueryBtn.disabled = false; + runQueryBtn.textContent = 'Run Search'; + } +} + + +// --- FILTER BUILDERS --- + +function buildBrandFilter(brandNames) { + if (!brandNames || brandNames.length === 0) return ''; + const sanitizedNames = brandNames.map(name => `"${name.replace(/"/g, '\\"')}"`).join(', '); + return `brands.name IN (${sanitizedNames})`; +} + +// Brand Category Filter removed + +function buildOpeningHoursFilter(day, startTime, endTime) { + if (!day || (!startTime && !endTime)) return { unnestClause: '', whereClause: '' }; + const unnestClause = `, UNNEST(places.regular_opening_hours.${day}) AS opening_period`; + let conditions = []; + if (startTime) conditions.push(`opening_period.start_time <= TIME '${startTime}:00'`); + if (endTime) conditions.push(`opening_period.end_time >= TIME '${endTime}:00'`); + return { unnestClause, whereClause: conditions.join(' AND ') }; +} + +function buildAttributeFilter(attributes) { + if (!attributes || attributes.length === 0) return []; + return attributes.map(attr => `places.${attr} = TRUE`); +} + +function buildRatingFilter(min, max) { + const hasMin = !isNaN(min); + const hasMax = !isNaN(max); + if (hasMin && hasMax) return `places.rating BETWEEN ${min} AND ${max}`; + if (hasMin) return `places.rating >= ${min}`; + if (hasMax) return `places.rating <= ${max}`; + return ''; +} + +function buildRegionFilter(regionType, regionDataType, regionNames) { + if (!regionType || !regionNames || regionNames.length === 0) return ''; + const sanitizedNames = regionNames.map(name => `'${name.replace(/'/g, "\\'")}'`).join(', '); + if (regionDataType === 'STRING') { + return `places.${regionType} IN (${sanitizedNames})`; + } + return `EXISTS (SELECT 1 FROM UNNEST(places.${regionType}) AS name WHERE name IN (${sanitizedNames}))`; +} + +// --- UNIFIED QUERY BUILDERS --- + +function buildAggregateQuery(searchAreaVar, fromClause, whereClause, types, isBrandQuery, isMultiRegion, regionType, regionDataType) { + if (isMultiRegion && regionDataType === 'STRING') { + return `${searchAreaVar} SELECT WITH AGGREGATION_THRESHOLD places.${regionType} AS region_name, COUNT(*) AS count ${fromClause} ${whereClause} GROUP BY region_name ORDER BY count DESC`; + } + if (isBrandQuery) { + return `${searchAreaVar} SELECT WITH AGGREGATION_THRESHOLD brands.name, COUNT(places.id) AS count ${fromClause} ${whereClause} GROUP BY brands.name ORDER BY count DESC`; + } + if (types.length <= 1) { + return `${searchAreaVar} SELECT WITH AGGREGATION_THRESHOLD COUNT(*) AS total_count ${fromClause} ${whereClause}`; + } + const select = types.map(t => `COUNTIF('${t}' IN UNNEST(places.types)) AS ${t.replace(/ /g, '_')}_count`).join(',\n '); + return `${searchAreaVar} SELECT WITH AGGREGATION_THRESHOLD ${select}, COUNT(*) AS total_count ${fromClause} ${whereClause}`; +} + +function buildH3DensityQuery(searchAreaVar, fromClause, whereClause, resolution) { + // This is the inner query that performs the main aggregation. + const innerQuery = ` + SELECT WITH AGGREGATION_THRESHOLD + \`carto-os.carto.H3_FROMGEOGPOINT\`(places.point, ${resolution}) AS h3_index, + COUNT(*) AS place_count + ${fromClause} + ${whereClause} + GROUP BY h3_index + `; + + // This outer query wraps the inner one to aggregate results into arrays. + return `${searchAreaVar} + SELECT + ARRAY_AGG(h3_index) as indices, + ARRAY_AGG(place_count) as counts + FROM (${innerQuery}) + WHERE h3_index IS NOT NULL + `; +} + +// --- NEW FUNCTION QUERY BUILDER --- + +function buildH3FunctionQuery(countryCode) { + const radius = parseInt(document.getElementById('radius-input').value, 10); + const h3Resolution = parseInt(document.getElementById('h3-resolution-slider').value, 10); + + // Construct JSON_OBJECT fields + let jsonParts = []; + + // Geography (Point + Radius) + jsonParts.push(`'geography', ST_GEOGPOINT(${searchCenter.lng()}, ${searchCenter.lat()})`); + jsonParts.push(`'geography_radius', ${radius}`); + jsonParts.push(`'h3_resolution', ${h3Resolution}`); + + // Standard Filters + // 1. Business Status + const bizStatus = document.getElementById('business-status-select').value; + if (bizStatus) { + jsonParts.push(`'business_status', ['${bizStatus}']`); + } + + // 2. Place Types (Toggle logic) + const placeTypes = [...document.querySelectorAll('#selected-types-list span')].map(s => s.textContent); + // Use new Checkbox + const usePrimaryType = document.getElementById('primary-type-checkbox').checked; + + if (placeTypes.length > 0) { + const formattedTypes = placeTypes.map(t => `"${t}"`).join(', '); + if (usePrimaryType) { + jsonParts.push(`'primary_type', [${formattedTypes}]`); + } else { + jsonParts.push(`'types', [${formattedTypes}]`); + } + } + + // 3. Ratings + const minRating = parseFloat(document.getElementById('min-rating-input').value); + const maxRating = parseFloat(document.getElementById('max-rating-input').value); + if (!isNaN(minRating)) jsonParts.push(`'min_rating', ${minRating}`); + if (!isNaN(maxRating)) jsonParts.push(`'max_rating', ${maxRating}`); + + // 4. Boolean Attributes + const attributes = [...document.querySelectorAll('.attribute-filter:checked')].map(cb => cb.name); + attributes.forEach(attr => { + jsonParts.push(`'${attr}', TRUE`); + }); + + // Note: Brand filters are strictly excluded here. + + // Dynamic table name based on dataset configuration + let tableName; + if (DATASET === 'SAMPLE') { + tableName = `places_insights___${countryCode}___sample`; + } else { + tableName = `places_insights___${countryCode}`; + } + + return ` + SELECT * FROM \`${tableName}.PLACES_COUNT_PER_H3\`( + JSON_OBJECT( + ${jsonParts.join(',\n ')} + ) + ) + `; +} \ No newline at end of file diff --git a/places_insights/places-insights-demo/routes.js b/places_insights/places-insights-demo/routes.js new file mode 100644 index 0000000..7ebfa23 --- /dev/null +++ b/places_insights/places-insights-demo/routes.js @@ -0,0 +1,83 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// --- ROUTES API LOGIC --- + +/** + * Calls the Routes API to get a route between an origin and destination, + * draws it on the map, and returns the route as a WKT LINESTRING. + * @param {google.maps.places.Place} origin The origin Place object. + * @param {google.maps.places.Place} destination The destination Place object. + * @returns {Promise<{wktString: string, bounds: google.maps.LatLngBounds}>} + */ +async function fetchRouteAsWkt(origin, destination) { + const API_KEY = MAPS_API_KEY; + const URL = 'https://routes.googleapis.com/directions/v2:computeRoutes'; + + const originLatLng = origin.location.toJSON(); + const destinationLatLng = destination.location.toJSON(); + + const requestBody = { + origin: { location: { latLng: { latitude: originLatLng.lat, longitude: originLatLng.lng }}}, + destination: { location: { latLng: { latitude: destinationLatLng.lat, longitude: destinationLatLng.lng }}}, + travelMode: 'DRIVE', + routingPreference: 'TRAFFIC_AWARE', + polylineEncoding: 'GEO_JSON_LINESTRING', + computeAlternativeRoutes: false, + }; + + const response = await fetch(URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': API_KEY, + 'X-Goog-FieldMask': 'routes.polyline.geoJsonLinestring,routes.viewport', + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorBody = await response.json(); + console.error('Error from Routes API:', errorBody); + throw new Error(`Routes API request failed: ${errorBody.error?.message || response.status}`); + } + + const data = await response.json(); + if (!data.routes || data.routes.length === 0) { + throw new Error('No routes found between the selected origin and destination.'); + } + + const route = data.routes[0]; + const coordinates = route.polyline.geoJsonLinestring.coordinates; + const wktCoordinatePairs = coordinates.map(coord => `${coord[0]} ${coord[1]}`); + const wktString = `LINESTRING(${wktCoordinatePairs.join(', ')})`; + + if (routePolyline) routePolyline.setMap(null); + const path = coordinates.map(coord => ({ lng: coord[0], lat: coord[1] })); + routePolyline = new google.maps.Polyline({ + path: path, + strokeColor: '#4285F4', + strokeOpacity: 0.8, + strokeWeight: 6, + map: map, + }); + + const viewport = route.viewport; + const lowPoint = { lat: viewport.low.latitude, lng: viewport.low.longitude }; + const highPoint = { lat: viewport.high.latitude, lng: viewport.high.longitude }; + const bounds = new google.maps.LatLngBounds(lowPoint, highPoint); + map.fitBounds(bounds); + + return { wktString, bounds }; +} \ No newline at end of file diff --git a/places_insights/places-insights-demo/state.js b/places_insights/places-insights-demo/state.js new file mode 100644 index 0000000..0b52597 --- /dev/null +++ b/places_insights/places-insights-demo/state.js @@ -0,0 +1,174 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// --- CONFIGURATION CONSTANTS --- +// These are loaded from config.js and treated as constants throughout the app. +const config = window.APP_CONFIG || {}; +const GCP_PROJECT_ID = config.GCP_PROJECT_ID || ''; +const OAUTH_CLIENT_ID = config.OAUTH_CLIENT_ID || ''; +const MAPS_API_KEY = config.MAPS_API_KEY || ''; +const DATASET = config.DATASET || 'FULL'; +const BQ_SCOPES = 'https://www.googleapis.com/auth/bigquery'; + +// --- APPLICATION STATE --- +// Holds the name of the country/city selected in the initial modal. +let selectedCountryName = ''; + +// Maps full country names to their two-letter codes for BigQuery table names (FULL Dataset). +const COUNTRY_CODES = { + 'Australia': 'au', 'Brazil': 'br', 'Canada': 'ca', 'France': 'fr', 'Germany': 'de', + 'India': 'in', 'Indonesia': 'id', 'Italy': 'it', 'Japan': 'jp', 'Mexico': 'mx', + 'Spain': 'es', 'Switzerland': 'ch', 'United Kingdom': 'gb', 'United States': 'us' +}; + +// Maps sample city locations to their country codes (SAMPLE Dataset). +const SAMPLE_LOCATIONS = { + 'Sydney, Australia': 'au', + 'Sao Paulo, Brazil': 'br', + 'Toronto, Canada': 'ca', + 'Paris, France': 'fr', + 'Berlin, Germany': 'de', + 'Mumbai, India': 'in', + 'Jakarta, Indonesia': 'id', + 'Rome, Italy': 'it', + 'Tokyo, Japan': 'jp', + 'Mexico City, Mexico': 'mx', + 'Madrid, Spain': 'es', + 'Zurich, Switzerland': 'ch', + 'London, United Kingdom': 'gb', + 'New York City, United States': 'us' +}; + +// Holds the data from brands.json, loaded at startup. +let BRANDS_DATA = []; +// Configuration for country-specific region search fields, now with explicit types. +const REGION_FIELD_CONFIG = { + 'au': [ + { label: 'State / Territory', field: 'administrative_area_level_1_name', type: 'STRING' }, + { label: 'City / Locality', field: 'locality_names', type: 'ARRAY' }, + { label: 'Postal Code', field: 'postal_code_names', type: 'ARRAY' } + ], + 'br': [ + { label: 'State', field: 'administrative_area_level_1_name', type: 'STRING' }, + { label: 'City / Municipality', field: 'administrative_area_level_2_name', type: 'STRING' }, + { label: 'Locality', field: 'locality_names', type: 'ARRAY' }, + { label: 'Postal Code', field: 'postal_code_names', type: 'ARRAY' }, + { label: 'Neighborhood', field: 'sublocality_level_1_names', type: 'ARRAY' } + ], + 'ca': [ + { label: 'Province / Territory', field: 'administrative_area_level_1_name', type: 'STRING' }, + { label: 'City / Locality', field: 'locality_names', type: 'ARRAY' }, + { label: 'Neighborhood', field: 'neighborhood_names', type: 'ARRAY' }, + { label: 'Postal Code', field: 'postal_code_names', type: 'ARRAY' } + ], + 'de': [ + { label: 'State', field: 'administrative_area_level_1_name', type: 'STRING' }, + { label: 'District', field: 'administrative_area_level_3_name', type: 'STRING' }, + { label: 'City / Locality', field: 'locality_names', type: 'ARRAY' }, + { label: 'Postal Code', field: 'postal_code_names', type: 'ARRAY' }, + { label: 'Sublocality / Borough', field: 'sublocality_level_1_names', type: 'ARRAY' } + ], + 'es': [ + { label: 'Autonomous Community', field: 'administrative_area_level_1_name', type: 'STRING' }, + { label: 'Province', field: 'administrative_area_level_2_name', type: 'STRING' }, + { label: 'City / Locality', field: 'locality_names', type: 'ARRAY' }, + { label: 'Neighborhood', field: 'neighborhood_names', type: 'ARRAY' }, + { label: 'Postal Code', field: 'postal_code_names', type: 'ARRAY' } + ], + 'fr': [ + { label: 'Region', field: 'administrative_area_level_1_name', type: 'STRING' }, + { label: 'Department', field: 'administrative_area_level_2_name', type: 'STRING' }, + { label: 'City / Locality', field: 'locality_names', type: 'ARRAY' }, + { label: 'Postal Code', field: 'postal_code_names', type: 'ARRAY' }, + { label: 'Sublocality', field: 'sublocality_level_1_names', type: 'ARRAY' } + ], + 'gb': [ + { label: 'Country', field: 'administrative_area_level_1_name', type: 'STRING' }, + { label: 'City / Locality', field: 'locality_names', type: 'ARRAY' }, + { label: 'Postal Town', field: 'postal_town_names', type: 'ARRAY' }, + { label: 'Postal Code', field: 'postal_code_names', type: 'ARRAY' } + ], + 'in': [ + { label: 'State', field: 'administrative_area_level_1_name', type: 'STRING' }, + { label: 'District', field: 'administrative_area_level_3_name', type: 'STRING' }, + { label: 'City / Locality', field: 'locality_names', type: 'ARRAY' }, + { label: 'Postal Code (PIN)', field: 'postal_code_names', type: 'ARRAY' }, + { label: 'Sublocality', field: 'sublocality_level_1_names', type: 'ARRAY' } + ], + 'id': [ + { label: 'Province', field: 'administrative_area_level_1_name', type: 'STRING' }, + { label: 'Regency / City', field: 'administrative_area_level_2_name', type: 'STRING' }, + { label: 'District', field: 'administrative_area_level_3_name', type: 'STRING' }, + { label: 'Village / Kelurahan', field: 'administrative_area_level_4_name', type: 'STRING' }, + { label: 'City / Locality', field: 'locality_names', type: 'ARRAY' }, + { label: 'Postal Code', field: 'postal_code_names', type: 'ARRAY' } + ], + 'it': [ + { label: 'Region', field: 'administrative_area_level_1_name', type: 'STRING' }, + { label: 'Province', field: 'administrative_area_level_2_name', type: 'STRING' }, + { label: 'Municipality (Comune)', field: 'administrative_area_level_3_name', type: 'STRING' }, + { label: 'Postal Code', field: 'postal_code_names', type: 'ARRAY' } + ], + 'jp': [ + { label: 'Prefecture', field: 'administrative_area_level_1_name', type: 'STRING' }, + { label: 'City / Locality', field: 'locality_names', type: 'ARRAY' }, + { label: 'Postal Code', field: 'postal_code_names', type: 'ARRAY' }, + { label: 'Sublocality', field: 'sublocality_level_1_names', type: 'ARRAY' } + ], + 'mx': [ + { label: 'State', field: 'administrative_area_level_1_name', type: 'STRING' }, + { label: 'Municipality', field: 'administrative_area_level_2_name', type: 'STRING' }, + { label: 'City / Locality', field: 'locality_names', type: 'ARRAY' }, + { label: 'Postal Code', field: 'postal_code_names', type: 'ARRAY' } + ], + 'ch': [ + { label: 'Canton', field: 'administrative_area_level_1_name', type: 'STRING' }, + { label: 'District', field: 'administrative_area_level_2_name', type: 'STRING' }, + { label: 'Municipality / Locality', field: 'locality_names', type: 'ARRAY' }, + { label: 'Postal Code', field: 'postal_code_names', type: 'ARRAY' } + ], + 'us': [ + { label: 'State', field: 'administrative_area_level_1_name', type: 'STRING' }, + { label: 'County', field: 'administrative_area_level_2_name', type: 'STRING' }, + { label: 'City / Locality', field: 'locality_names', type: 'ARRAY' }, + { label: 'Neighborhood', field: 'neighborhood_names', type: 'ARRAY' }, + { label: 'Postal Code', field: 'postal_code_names', type: 'ARRAY' } + ] +}; + + +// --- MAP & OVERLAY STATE --- +// References to Google Maps and deck.gl objects. +let map, searchCenter, searchCircle, searchPolygon, infoWindow, deckglOverlay; + +// Route Search State +let originPlace = null, destinationPlace = null, routePolyline = null; + +// --- DRAWING STATE --- +// Manages the custom polygon drawing process. +let isDrawing = false; +let polygonVertices = []; +let tempPolyline; + +// --- AUTHENTICATION STATE --- +// Manages the user's sign-in status and access token. +let tokenClient, accessToken = null, userSignedIn = false; + +// --- UI STATE --- +// Caches the content of guide.html to avoid re-fetching. +let guideContentHtml = null; +// Caches the last successfully executed query. +let lastExecutedQuery = null; +// Tracks if mouse is hovering over an H3 cell to prevent map click conflicts +let isHoveringH3 = false; \ No newline at end of file diff --git a/places_insights/places-insights-demo/style.css b/places_insights/places-insights-demo/style.css new file mode 100644 index 0000000..2d64046 --- /dev/null +++ b/places_insights/places-insights-demo/style.css @@ -0,0 +1,520 @@ +/* Basic styles */ +html, +body { + height: 100%; + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +#map { + height: 100%; +} + +/* Modal styles */ +#country-selector-modal, +#guide-modal, +#query-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.7); + z-index: 2000; + display: flex; + justify-content: center; + align-items: center; +} + +#country-selector-modal.hidden, +#guide-modal.hidden, +#query-modal.hidden { + display: none; +} + +.modal-content { + background-color: white; + padding: 25px 35px; + border-radius: 8px; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); + text-align: center; + width: 90%; + max-width: 450px; + position: relative; +} + +.modal-content h2 { + font-size: 24px; + margin-top: 0; +} + +.modal-content p { + margin-bottom: 25px; + color: #555; + font-size: 16px; +} + +#country-select { + width: 100%; + padding: 12px; + margin-bottom: 20px; + font-size: 16px; + border-radius: 4px; + border: 1px solid #ccc; + box-sizing: border-box; +} + +#start-demo-btn { + width: 100%; + padding: 12px; + font-size: 16px; + font-weight: bold; + background-color: #4285F4; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.3s; +} + +#start-demo-btn:hover { + background-color: #357ae8; +} + +/* Help & Query Modal Specifics */ +#guide-modal .modal-content, +#query-modal .modal-content { + text-align: left; + max-width: 700px; + max-height: 80vh; + overflow-y: auto; +} + +.close-modal-btn { + position: absolute; + top: 10px; + right: 20px; + font-size: 28px; + font-weight: bold; + color: #aaa; + cursor: pointer; + line-height: 1; +} + +.close-modal-btn:hover { + color: #333; +} + +#guide-content h2, +#guide-content h3 { + margin-top: 1.5em; + margin-bottom: 0.5em; +} + +#guide-content p, +#guide-content ul, +#guide-content ol { + line-height: 1.6; +} + +#query-content { + background-color: #f1f3f4; + border-radius: 4px; + padding: 15px; + white-space: pre-wrap; + word-wrap: break-word; + font-family: monospace; + font-size: 13px; +} + +.modal-actions { + margin-top: 15px; + text-align: right; +} + + +/* Sidebar styles */ +#sidebar { + position: absolute; + top: 10px; + left: 10px; + width: 320px; + max-height: calc(100% - 20px); + overflow-y: auto; + background-color: white; + padding: 15px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + z-index: 1000; +} + +.sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; +} + +.sidebar-header h1 { + font-size: 20px; + margin: 0; +} + +.sidebar-header-buttons { + display: flex; + gap: 8px; +} + +.sidebar-header button { + font-size: 14px; + padding: 6px 10px; +} + +#sidebar hr { + border: none; + border-top: 1px solid #eee; + margin: 15px 0; +} + +.control-group { + margin-bottom: 15px; +} + +.control-group label { + display: block; + font-size: 14px; + font-weight: 500; + margin-bottom: 5px; +} + +.control-group input, +.control-group select, +.control-group textarea { + width: 100%; + padding: 8px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 4px; + box-sizing: border-box; +} + +.control-group textarea { + font-family: monospace; + resize: vertical; +} + +.control-group textarea.invalid { + border-color: #ea4335; +} + +.info-text { + font-size: 14px; + color: #555; + margin-top: 0; +} + +.advanced-options label, +.filter-group label { + display: flex; + align-items: center; + cursor: pointer; + font-weight: normal; +} + +.advanced-options input, +.filter-group input { + width: auto; + margin-right: 8px; +} + +.drawing-buttons { + display: flex; + gap: 8px; + margin-bottom: 15px; +} + +.drawing-buttons button { + flex: 1; +} + +.input-with-button { + display: flex; + gap: 8px; +} + +.input-with-button input { + flex: 1; +} + +.input-with-button button { + flex-shrink: 0; + padding-left: 12px; + padding-right: 12px; + font-weight: bold; +} + +/* Styles for Place Autocomplete (New) Web Component */ +#origin-input-container, +#destination-input-container { + min-height: 36px; +} + +gmp-place-autocomplete::part(input) { + width: 100%; + padding: 8px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 4px; + box-sizing: border-box; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + + +/* Attribute Filters Section */ +.attribute-filters { + border: 1px solid #ddd; + border-radius: 4px; + padding: 0 10px; + margin-bottom: 15px; +} + +.attribute-filters legend { + font-weight: 500; + padding: 0 5px; +} + +.filter-group { + margin-bottom: 8px; +} + +.rating-inputs, +.time-inputs { + display: flex; + gap: 10px; + margin-bottom: 10px; +} + +.rating-inputs .control-group, +.time-inputs .control-group { + flex: 1; + margin-bottom: 0; +} + +/* Collapsible Accordion Styles */ +.collapsible-fieldset > legend { + cursor: pointer; + position: relative; + width: 100%; + box-sizing: border-box; + padding: 10px 5px; + margin-left: -5px; +} + +.collapsible-fieldset > legend::after { + content: '+'; + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + font-weight: bold; +} + +.collapsible-fieldset.is-open > legend::after { + content: '−'; +} + +.collapsible-content { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-out; + padding: 0; +} + +.collapsible-fieldset.is-open > .collapsible-content { + max-height: 600px; /* A large enough value */ + padding: 10px 0; +} + +.collapsible-fieldset:not(.is-open) { + padding-top: 0; + padding-bottom: 0; + border-width: 1px 0; +} + +.collapsible-fieldset:not(.is-open):first-of-type { + border-top-width: 1px; +} + +.collapsible-fieldset:not(.is-open) > legend { + margin-bottom: 0; +} + + +/* Auth container & Buttons */ +.auth-container { + display: flex; + flex-direction: column; + gap: 10px; + align-items: stretch; +} + +#status { + margin: 0; + font-weight: bold; + text-align: center; + font-size: 13px; +} + +#status.error { + color: #ea4335; +} + +#status.success { + color: #34a853; +} + +.main-buttons { + display: flex; + gap: 8px; + margin-top: 15px; +} + +.main-buttons #run-query-btn { + flex-grow: 1; +} + +button { + padding: 10px 15px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + transition: background-color 0.2s; +} + +button:disabled { + background-color: #ccc; + cursor: not-allowed; +} + +button.secondary-button { + background-color: #f1f3f4; + color: #5f6368; + font-size: 14px; + padding: 8px; +} + +button.secondary-button:hover:not(:disabled) { + background-color: #e8eaed; +} + +#run-query-btn { + background-color: #4285F4; + color: white; +} + +#run-query-btn:hover:not(:disabled) { + background-color: #357ae8; +} + +#auth-button { + background-color: #34a853; + color: white; +} + +#auth-button:hover { + background-color: #2c8e44; +} + +/* Autocomplete styles */ +.autocomplete-container { + position: relative; +} + +#autocomplete-suggestions, +#brand-autocomplete-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: white; + border: 1px solid #ccc; + border-top: none; + border-radius: 0 0 4px 4px; + z-index: 1001; + max-height: 200px; + overflow-y: auto; +} + +.suggestion-item { + padding: 8px; + cursor: pointer; +} + +.suggestion-item:hover { + background-color: #f0f0f0; +} + +/* Selected list styles */ +#selected-types-list, +#selected-brands-list, +#selected-regions-list { + list-style: none; + padding: 0; + margin: 10px 0; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.selected-type-tag { + display: flex; + align-items: center; + background-color: #e0e0e0; + padding: 5px 10px; + border-radius: 15px; + font-size: 14px; +} + +.remove-tag-btn { + background: none; + border: none; + color: #555; + cursor: pointer; + margin-left: 8px; + font-size: 16px; + padding: 0; +} + +.remove-tag-btn:hover { + color: black; +} + +/* Tooltip for deck.gl */ +#tooltip { + position: absolute; + z-index: 1001; + pointer-events: none; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 8px; + border-radius: 4px; + font-size: 12px; +} + +.hidden { + display: none; +} + +/* InfoWindow container for Place Details Component */ +.info-window-component-container { + width: 300px; + min-height: 200px; + display: block; + overflow: hidden; +} + +.info-window-component-container gmp-place-details-compact { + width: 100%; +} \ No newline at end of file diff --git a/places_insights/places-insights-demo/ui.js b/places_insights/places-insights-demo/ui.js new file mode 100644 index 0000000..587e85f --- /dev/null +++ b/places_insights/places-insights-demo/ui.js @@ -0,0 +1,275 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// --- UI HELPERS --- + +/** + * Converts a string to Title Case. + * @param {string} str The string to convert. + * @returns {string} The Title Cased string. + */ +function toTitleCase(str) { + if (!str) return ''; + return str.toLowerCase().split(' ').map(word => { + return word.charAt(0).toUpperCase() + word.slice(1); + }).join(' '); +} + +/** + * Resets the sidebar UI to a specific mode's default state. + * Clears input values and toggles the appropriate control sections. + * @param {string} targetMode The demo mode to switch to (e.g., 'circle-search'). Defaults to 'circle-search'. + */ +function resetSidebarUI(targetMode = 'circle-search') { + const select = document.getElementById('demo-type-select'); + if (select.value !== targetMode) { + select.value = targetMode; + } + + // Map modes to their control container IDs + const modeToControls = { + 'circle-search': 'circle-search-controls', + 'h3-function': 'circle-search-controls', // H3 function uses circle inputs + 'polygon-search': 'polygon-search-controls', + 'region-search': 'region-search-controls', + 'route-search': 'route-search-controls' + }; + + const activeControlId = modeToControls[targetMode]; + + // Hide all control sections first + ['circle-search-controls', 'polygon-search-controls', 'region-search-controls', 'route-search-controls'] + .forEach(id => { + const el = document.getElementById(id); + if (id === activeControlId) { + el.classList.remove('hidden'); + } else { + el.classList.add('hidden'); + } + }); + + // Reset Input Values + // Set default radius to 5000m for H3 Function, 1000m for others + if (targetMode === 'h3-function') { + document.getElementById('radius-input').value = '5000'; + } else { + document.getElementById('radius-input').value = '1000'; + } + + document.getElementById('wkt-input').value = ''; + document.getElementById('wkt-input').classList.remove('invalid'); + document.getElementById('region-name-input').value = ''; + document.getElementById('selected-regions-list').innerHTML = ''; + document.getElementById('route-radius-input').value = '100'; + + // Reset Filters + document.getElementById('place-type-input').value = ''; + document.getElementById('selected-types-list').innerHTML = ''; + // Reset Type Checkbox + document.getElementById('primary-type-checkbox').checked = false; + + document.getElementById('min-rating-input').value = ''; + document.getElementById('max-rating-input').value = ''; + document.getElementById('business-status-select').value = 'OPERATIONAL'; + document.querySelectorAll('.attribute-filter').forEach(cb => cb.checked = false); + + // Reset Time + const daySelect = document.getElementById('day-of-week-select'); + const startInput = document.getElementById('start-time-input'); + const endInput = document.getElementById('end-time-input'); + daySelect.value = ''; + startInput.value = ''; + endInput.value = ''; + startInput.disabled = true; + endInput.disabled = true; + + // Reset Brands + document.getElementById('brand-name-input').value = ''; + document.getElementById('selected-brands-list').innerHTML = ''; + + // Reset H3 Controls (Default State) + // Specific overrides for 'h3-function' are handled in map.js after this call. + const h3Toggle = document.getElementById('h3-density-toggle'); + h3Toggle.checked = false; + h3Toggle.disabled = false; + + document.getElementById('h3-resolution-controls').classList.add('hidden'); + const h3Slider = document.getElementById('h3-resolution-slider'); + h3Slider.max = '12'; + h3Slider.value = '8'; + document.getElementById('h3-resolution-value').textContent = '8'; + + // Close Accordions + document.querySelectorAll('.collapsible-fieldset').forEach(fs => fs.classList.remove('is-open')); +} + + +/** + * A generic function to add a removable "tag" to a list. + */ +function addTag(text, listElement) { + if ([...listElement.querySelectorAll('span')].some(el => el.textContent === text)) return; + + const tag = document.createElement('li'); + tag.className = 'selected-type-tag'; + const textSpan = document.createElement('span'); + textSpan.textContent = text; + const removeBtn = document.createElement('button'); + removeBtn.className = 'remove-tag-btn'; + removeBtn.innerHTML = '×'; + removeBtn.onclick = () => { + tag.remove(); + invalidateQueryState(); // Invalidate when a tag is removed + }; + + tag.appendChild(textSpan); + tag.appendChild(removeBtn); + listElement.appendChild(tag); +} + +/** + * Initializes the place type autocomplete functionality. + */ +function initializeAutocomplete(inputElement) { + const suggestionsContainer = document.getElementById('autocomplete-suggestions'); + const selectedTypesList = document.getElementById('selected-types-list'); + + inputElement.addEventListener('input', () => { + const query = inputElement.value.toLowerCase(); + suggestionsContainer.innerHTML = ''; + if (!query) return; + + const filteredTypes = PLACE_TYPES + .filter(t => t.toLowerCase().includes(query)) + .sort((a, b) => { + const aL = a.toLowerCase(), bL = b.toLowerCase(); + const sA = (aL === query) ? 1 : (aL.startsWith(query) ? 2 : 3); + const sB = (bL === query) ? 1 : (bL.startsWith(query) ? 2 : 3); + if (sA !== sB) return sA - sB; + return a.localeCompare(b); + }).slice(0, 10); + + filteredTypes.forEach(type => { + const item = document.createElement('div'); + item.className = 'suggestion-item'; + item.textContent = type; + item.addEventListener('click', () => { + addTag(type, selectedTypesList); + inputElement.value = ''; + suggestionsContainer.innerHTML = ''; + invalidateQueryState(); // Invalidate on add + }); + suggestionsContainer.appendChild(item); + }); + }); + + document.addEventListener('click', e => { + if (!e.target.closest('.autocomplete-container')) { + suggestionsContainer.innerHTML = ''; + } + }); +} + +/** + * Converts a google.maps.Polygon object to a WKT string and updates the textarea. + */ +function updateWktFromPolygon(polygon) { + const path = polygon.getPath().getArray(); + const wktInput = document.getElementById('wkt-input'); + if (path.length < 3) { + wktInput.value = ''; + return; + } + let wkt = "POLYGON(("; + wkt += path.map(p => `${p.lng()} ${p.lat()}`).join(', '); + wkt += `, ${path[0].lng()} ${path[0].lat()}`; + wkt += "))"; + wktInput.value = wkt; + wktInput.classList.remove('invalid'); +} + +/** + * Handles user input in the WKT textarea, parsing it and drawing a polygon on the map. + */ +function handleWktInputChange(e) { + const wktString = e.target.value; + const wktInput = e.target; + try { + const coords = wktString.match(/\(\((.*)\)\)/)[1].split(',').map(c => { + const parts = c.trim().split(' '); + return { lng: parseFloat(parts[0]), lat: parseFloat(parts[1]) }; + }); + + if (coords.length < 4 || isNaN(coords[0].lat)) throw new Error("Invalid coordinate format"); + + clearAllOverlays(); + + searchPolygon = new google.maps.Polygon({ + paths: coords, + editable: true, draggable: true, fillColor: '#5599FF', fillOpacity: 0.3, + strokeColor: '#0000FF', strokeWeight: 2, map: map + }); + + searchPolygon.getPaths().forEach(path => { + google.maps.event.addListener(path, 'set_at', () => { + updateWktFromPolygon(searchPolygon); + invalidateQueryState(); + }); + google.maps.event.addListener(path, 'insert_at', () => { + updateWktFromPolygon(searchPolygon); + invalidateQueryState(); + }); + }); + + wktInput.classList.remove('invalid'); + } catch (err) { + wktInput.classList.add('invalid'); + } +} + +// --- STATUS & AUTH UI HELPERS --- + +/** + * Updates the status message in the sidebar. + * @param {string} message The text to display. + * @param {string} type 'info', 'success', or 'error'. + */ +function updateStatus(message, type = 'info') { + const statusDisplay = document.getElementById('status'); + if (statusDisplay) { + statusDisplay.textContent = message; + statusDisplay.className = type; + } +} + +/** + * Updates the UI to a "signed-in" state. + */ +function setSignedInUi() { + userSignedIn = true; + document.getElementById('auth-button').textContent = 'Sign Out'; + document.getElementById('run-query-btn').disabled = false; + updateStatus('Authorized successfully.', 'success'); +} + +/** + * Updates the UI to a "signed-out" state. + */ +function resetSignedInUi() { + userSignedIn = false; + document.getElementById('auth-button').textContent = 'Authorize with Google'; + document.getElementById('run-query-btn').disabled = true; + accessToken = null; + updateStatus('Please authorize to run a query.'); +} \ No newline at end of file