diff --git a/_config/config.yml b/_config/config.yml index 33aafb9..b668eae 100644 --- a/_config/config.yml +++ b/_config/config.yml @@ -1,3 +1,8 @@ +--- +Name: elemental-locations +After: + - '*' +--- Dynamic\Locations\Model\Location: extensions: - Dynamic\Elements\Locations\Extension\LocationDataExtension diff --git a/dist/css/map.css b/dist/css/map.css index 23a7502..1d9801a 100644 --- a/dist/css/map.css +++ b/dist/css/map.css @@ -1,9 +1,20 @@ -#map { +.locations-map { height: 700px; } -#panel { +/* Styling for an info pane that slides out from the left. +* Hidden by default. */ +.locations-panel { + display: none; + position: absolute; + left: 0; + top: 0; + z-index: 1000; + background-color: #fff; + width: 300px; height: 700px; + padding-top: 20px; + overflow-y: auto; } /* Styling for Autocomplete search bar */ @@ -50,20 +61,6 @@ .hidden { display: none; } - - /* Styling for an info pane that slides out from the left. - * Hidden by default. */ - #panel { - display: none; - position: absolute; - left: 0; - top: 0; - z-index: 1000; - background-color: #fff; - width: 300px; - height: 100%; - overflow-y: auto; - } .open { width: 250px; diff --git a/dist/js/map.js b/dist/js/map.js index 5a13423..906c8a7 100644 --- a/dist/js/map.js +++ b/dist/js/map.js @@ -1,181 +1,81 @@ -/* - * Copyright 2017 Google Inc. All rights reserved. - * - * - * 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 - * - * http://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. - */ +// Escapes HTML characters in a template literal string, to prevent XSS. +// See https://www.owasp.org/index.php/XSS_%28Cross_Site_Scripting%29_Prevention_Cheat_Sheet#RULE_.231_-_HTML_Escape_Before_Inserting_Untrusted_Data_into_HTML_Element_Content +function sanitizeHTML(strings, ...values) { + const entities = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' }; + return strings.reduce((result, string, i) => { + const value = values[i - 1]; + const escapedValue = String(value).replace(/[&<>'"]/g, (char) => entities[char]); + return result + escapedValue + string; + }); +} + +function initMap() { + const mapDivs = document.querySelectorAll('div[id^="map-"]'); + + mapDivs.forEach((mapDiv) => { + const mapId = mapDiv.id; + const json_link = mapDiv.getAttribute('data-link'); + const panelId = mapId.replace('map-', 'panel-'); // Assuming panel ID is related to map ID + const key = mapDiv.getAttribute('data-key'); + const unit = mapDiv.getAttribute('data-format'); -// Style credit: https://snazzymaps.com/style/1/pale-dawn -const mapStyle = [{ - 'featureType': 'administrative', - 'elementType': 'all', - 'stylers': [{ - 'visibility': 'on', - }, - { - 'lightness': 33, - }, - ], - }, - { - 'featureType': 'landscape', - 'elementType': 'all', - 'stylers': [{ - 'color': '#f2e5d4', - }], - }, - { - 'featureType': 'poi.park', - 'elementType': 'geometry', - 'stylers': [{ - 'color': '#c5dac6', - }], - }, - { - 'featureType': 'poi.park', - 'elementType': 'labels', - 'stylers': [{ - 'visibility': 'on', - }, - { - 'lightness': 20, - }, - ], - }, - { - 'featureType': 'road', - 'elementType': 'all', - 'stylers': [{ - 'lightness': 20, - }], - }, - { - 'featureType': 'road.highway', - 'elementType': 'geometry', - 'stylers': [{ - 'color': '#c5c6c6', - }], - }, - { - 'featureType': 'road.arterial', - 'elementType': 'geometry', - 'stylers': [{ - 'color': '#e4d7c6', - }], - }, - { - 'featureType': 'road.local', - 'elementType': 'geometry', - 'stylers': [{ - 'color': '#fbfaf7', - }], - }, - { - 'featureType': 'water', - 'elementType': 'all', - 'stylers': [{ - 'visibility': 'on', - }, - { - 'color': '#acbcc9', - }, - ], - }, - ]; - - // Escapes HTML characters in a template literal string, to prevent XSS. - // See https://www.owasp.org/index.php/XSS_%28Cross_Site_Scripting%29_Prevention_Cheat_Sheet#RULE_.231_-_HTML_Escape_Before_Inserting_Untrusted_Data_into_HTML_Element_Content - function sanitizeHTML(strings) { - const entities = {'&': '&', '<': '<', '>': '>', '"': '"', '\'': '''}; - let result = strings[0]; - for (let i = 1; i < arguments.length; i++) { - result += String(arguments[i]).replace(/[&<>'"]/g, (char) => { - return entities[char]; - }); - result += strings[i]; - } - return result; - } - - /** - * Initialize the Google Map. - */ - function initMap() { // Create the map. - const map = new google.maps.Map(document.getElementById('map'), { + const map = new google.maps.Map(document.getElementById(mapId), { zoom: 7, // Initial zoom, this will change based on locations - center: {lat: 52.632469, lng: -1.689423}, // Initial center, this will also change - styles: mapStyle, + center: { lat: 43.7376857, lng: -87.7226079 }, // Initial center, this will also change + //styles: mapStyle, }); - + // Create a LatLngBounds object to calculate the map's bounds const bounds = new google.maps.LatLngBounds(); - + // Load the stores GeoJSON onto the map. - map.data.loadGeoJson('$link', {idPropertyName: 'storeid'}, function(features) { - // Once the GeoJSON is loaded, iterate over each feature - features.forEach(function(feature) { - // Get the feature's geometry (its location) + map.data.loadGeoJson(json_link, { idPropertyName: 'storeid' }, function (features) { + features.forEach(function (feature) { const geometry = feature.getGeometry(); - - // If the geometry is a point, extend the bounds to include this point if (geometry.getType() === 'Point') { const coordinates = geometry.get(); bounds.extend(coordinates); // Extend the bounds to include this location } }); - - // Fit the map's viewport to the bounds of the locations map.fitBounds(bounds); }); - + // Define the custom marker icons, using the store's "category". const apiKey = '$key'; const infoWindow = new google.maps.InfoWindow(); - - // Show the information for a store when its marker is clicked. + map.data.addListener('click', (event) => { const category = event.feature.getProperty('category'); const name = event.feature.getProperty('name'); - const description = event.feature.getProperty('description'); + const description = event.feature.getProperty('description') || ' '; const hours = event.feature.getProperty('hours') || 'Hours not available'; const phone = event.feature.getProperty('phone') || 'Phone not available'; const position = event.feature.getGeometry().get(); - + const content = sanitizeHTML` - -
+

${name}

${description}

+

Category: ${category}

Open: ${hours}
Phone: ${phone}

-

+

- `; - + `; + infoWindow.setContent(content); infoWindow.setPosition(position); - infoWindow.setOptions({pixelOffset: new google.maps.Size(0, -30)}); + infoWindow.setOptions({ pixelOffset: new google.maps.Size(0, -30) }); infoWindow.open(map); }); - + // Build and add the search bar const card = document.createElement('div'); const titleBar = document.createElement('div'); const title = document.createElement('div'); const container = document.createElement('div'); const input = document.createElement('input'); - const options = { - types: ['address'], - // componentRestrictions: {country: 'gb'}, - }; - + const options = { types: ['address'] }; + card.setAttribute('id', 'pac-card'); title.setAttribute('id', 'title'); title.textContent = 'Find the nearest store'; @@ -188,79 +88,90 @@ const mapStyle = [{ card.appendChild(titleBar); card.appendChild(container); map.controls[google.maps.ControlPosition.TOP_RIGHT].push(card); - - // Make the search bar into a Places Autocomplete search bar and select - // which detail fields should be returned about the place that - // the user selects from the suggestions. + const autocomplete = new google.maps.places.Autocomplete(input, options); - - autocomplete.setFields( - ['address_components', 'geometry', 'name']); - - // Set the origin point when the user selects an address - const originMarker = new google.maps.Marker({map: map}); + autocomplete.setFields(['address_components', 'geometry', 'name']); + + const originMarker = new google.maps.Marker({ map: map }); originMarker.setVisible(false); let originLocation = map.getCenter(); - + autocomplete.addListener('place_changed', async () => { - originMarker.setVisible(false); + if (originMarker) { + originMarker.setMap(null); // Remove the previous marker if relevant + } + originLocation = map.getCenter(); const place = autocomplete.getPlace(); - + if (!place.geometry) { - // User entered the name of a Place that was not suggested and - // pressed the Enter key, or the Place Details request failed. - window.alert('No address available for input: \'' + place.name + '\''); + window.alert(`No address available for input: '${place.name}'`); return; } - + // Recenter the map to the selected address originLocation = place.geometry.location; map.setCenter(originLocation); - map.setZoom(9); - console.log(place); - - originMarker.setPosition(originLocation); - originMarker.setVisible(true); - + // Use the selected address as the origin to calculate distances - // to each of the store locations const rankedStores = await calculateDistances(map.data, originLocation); - showStoresList(map.data, rankedStores); - + + // Filter stores by max radius of 60 miles (96,560 meters) + const maxRadiusMeters = 96560; + const filteredStores = rankedStores.filter(store => store.distanceVal <= maxRadiusMeters); + + // Show the filtered stores in the relevant panel + showStoresList(map.data, filteredStores, panelId); + + // Create a new LatLngBounds object to adjust the map bounds + const bounds = new google.maps.LatLngBounds(); + + // Extend the bounds to include the user's searched location + bounds.extend(originLocation); + + // Extend the bounds to include each store location within the max radius + filteredStores.forEach((store) => { + const storeFeature = map.data.getFeatureById(store.storeid); + const storeLocation = storeFeature.getGeometry().get(); + bounds.extend(storeLocation); + }); + + // Adjust the map to fit all markers within the bounds + map.fitBounds(bounds); + return; }); - } - - /** - * Use Distance Matrix API to calculate distance from origin to each store. - * @param {google.maps.Data} data The geospatial data object layer for the map - * @param {google.maps.LatLng} origin Geographical coordinates in latitude - * and longitude - * @return {Promise} n Promise fulfilled by an array of objects with - * a distanceText, distanceVal, and storeid property, sorted ascending - * by distanceVal. - */ - async function calculateDistances(data, origin) { - const stores = []; - const destinations = []; - - // Build parallel arrays for the store IDs and destinations - data.forEach((store) => { - const storeNum = store.getProperty('storeid'); - const storeLoc = store.getGeometry().get(); - - stores.push(storeNum); - destinations.push(storeLoc); - }); - - // Retrieve the distances of each store from the origin - // The returned list will be in the same order as the destinations list - const service = new google.maps.DistanceMatrixService(); - const getDistanceMatrix = - (service, parameters) => new Promise((resolve, reject) => { + }); +} + +/** + * Use Distance Matrix API to calculate distance from origin to each store. + * @param {google.maps.Data} data The geospatial data object layer for the map + * @param {google.maps.LatLng} origin Geographical coordinates in latitude + * and longitude + * @return {Promise} A promise fulfilled by an array of objects with + * a distanceText, distanceVal, and storeid property, sorted ascending + * by distanceVal. + */ +async function calculateDistances(data, origin) { + const stores = []; + const destinations = []; + + // Build parallel arrays for the store IDs and destinations + data.forEach((store) => { + const storeNum = store.getProperty('storeid'); + const storeLoc = store.getGeometry().get(); + + stores.push(storeNum); + destinations.push(storeLoc); + }); + + // Retrieve the distances of each store from the origin + // The returned list will be in the same order as the destinations list + const service = new google.maps.DistanceMatrixService(); + const getDistanceMatrix = (service, parameters) => new Promise((resolve, reject) => { service.getDistanceMatrix(parameters, (response, status) => { - if (status != google.maps.DistanceMatrixStatus.OK) { + if (status !== google.maps.DistanceMatrixStatus.OK) { reject(response); } else { const distances = []; @@ -268,7 +179,7 @@ const mapStyle = [{ for (let j = 0; j < results.length; j++) { const element = results[j]; - + // Check if distance is available before accessing it if (element.distance) { const distanceText = element.distance.text; @@ -288,60 +199,52 @@ const mapStyle = [{ } }); }); - + const distancesList = await getDistanceMatrix(service, { origins: [origin], destinations: destinations, travelMode: 'DRIVING', - unitSystem: google.maps.UnitSystem.$MeasurementUnit, + unitSystem: google.maps.UnitSystem.$unit, }); - - distancesList.sort((first, second) => { - return first.distanceVal - second.distanceVal; - }); - - return distancesList; + + distancesList.sort((first, second) => { + return first.distanceVal - second.distanceVal; + }); + + return distancesList; +} + +// Function to show the list of stores in the specified panel +function showStoresList(data, stores, panelId) { + if (stores.length == 0) { + console.log('No stores found'); + return; } - - /** - * Build the content of the side panel from the sorted list of stores - * and display it. - * @param {google.maps.Data} data The geospatial data object layer for the map - * @param {object[]} stores An array of objects with a distanceText, - * distanceVal, and storeid property. - */ - function showStoresList(data, stores) { - if (stores.length == 0) { - console.log('No stores found'); - return; - } - // Reference the existing panel in the HTML - let panel = document.getElementById('panel'); + let panel = document.getElementById(panelId); - // Ensure the panel is visible + if (panel) { panel.style.display = 'block'; - // Clear any previous content while (panel.lastChild) { - panel.removeChild(panel.lastChild); + panel.removeChild(panel.lastChild); } - // Populate the panel with the list of stores stores.forEach((store) => { - const name = document.createElement('p'); - name.classList.add('place'); - const currentStore = data.getFeatureById(store.storeid); - name.textContent = currentStore.getProperty('name'); - panel.appendChild(name); + const name = document.createElement('p'); + name.classList.add('place'); + const currentStore = data.getFeatureById(store.storeid); + name.textContent = currentStore.getProperty('name'); + panel.appendChild(name); - const distanceText = document.createElement('p'); - distanceText.classList.add('distanceText'); - distanceText.textContent = store.distanceText; - panel.appendChild(distanceText); + const distanceText = document.createElement('p'); + distanceText.classList.add('distanceText'); + distanceText.textContent = store.distanceText; + panel.appendChild(distanceText); }); - // Add 'open' class to the panel (optional for custom styling or animations) panel.classList.add('open'); - - } \ No newline at end of file + } else { + console.log(`Panel with ID ${panelId} not found`); + } +} \ No newline at end of file diff --git a/src/Control/ElementLocationsController.php b/src/Control/ElementLocationsController.php index 9184768..033936b 100644 --- a/src/Control/ElementLocationsController.php +++ b/src/Control/ElementLocationsController.php @@ -9,8 +9,6 @@ use SilverStripe\View\Requirements; use SilverStripe\Control\Controller; use SilverStripe\Core\Config\Config; -use SilverStripe\Control\HTTPRequest; -use Dynamic\SilverStripeGeocoder\GoogleGeocoder; use DNADesign\Elemental\Controllers\ElementController; /** @@ -39,18 +37,12 @@ protected function init() { parent::init(); - $key = $this->getKey(); - $link = $this->Link('json'); - - Requirements::javascriptTemplate( - 'dynamic/silverstripe-elemental-locations: dist/js/map.js', - [ - 'key' => $key, - 'link' => $link, - 'format' => $this->data()->MeasurementUnit, - ] + Requirements::javascript( + 'dynamic/silverstripe-elemental-locations: dist/js/map.js' ); + $key = $this->data()->getKey(); + Requirements::javascript( '//maps.googleapis.com/maps/api/js?key=' . $key . '&libraries=places&callback=initMap&solution_channel=GMP_codelabs_simplestorelocator_v1_a', [ @@ -60,6 +52,9 @@ protected function init() ); } + /** + * @return string + */ public function json() { $this->getResponse()->addHeader("Content-Type", "application/json"); @@ -70,14 +65,6 @@ public function json() return $data->renderWith('Dynamic/Elements/Locations/Data/JSON'); } - /** - * @return string - */ - public function getKey() - { - return Config::inst()->get(GoogleGeocoder::class, 'map_api_key'); - } - /** * @return ArrayList|DataList */ @@ -108,27 +95,4 @@ public function setLocations() return $this; } - - /** - * @param string $action - * - * @return string - */ - public function Link($action = null): string - { - $id = $this->element->ID; - $segment = Controller::join_links('element', $id, $action); - $page = Director::get_current_page(); - - if ($page && !($page instanceof ElementController)) { - return $page->Link($segment); - } - - if ($controller = $this->getParentController()) { - return $controller->Link($segment); - } - - return $segment; - } - } diff --git a/src/Element/ElementLocations.php b/src/Element/ElementLocations.php index 85adfde..4101279 100644 --- a/src/Element/ElementLocations.php +++ b/src/Element/ElementLocations.php @@ -5,7 +5,7 @@ use SilverStripe\ORM\ArrayList; use SilverStripe\Forms\FieldList; use SilverStripe\TagField\TagField; -use SilverStripe\View\Requirements; +use SilverStripe\Control\Controller; use SilverStripe\Core\Config\Config; use Dynamic\Locations\Model\Location; use SilverStripe\ORM\FieldType\DBField; @@ -79,6 +79,25 @@ public function getCMSFields(): FieldList return parent::getCMSFields(); } + /** + * @return string + */ + public function getKey() + { + return Config::inst()->get(GoogleGeocoder::class, 'map_api_key'); + } + + /** + * @return string + */ + public function getJSONLink() + { + $controller = Controller::curr(); + $segment = Controller::join_links('element', $this->ID, 'json'); + + return $controller->Link($segment); + } + /** * return ArrayList */ @@ -97,18 +116,38 @@ public function getLocationsList() return $locations; } + /** + * create a list of assigned categories + */ + public function getCategoryList() + { + if ($this->Categories()->count()) { + return implode(', ', $this->Categories()->column('Title')); + } + + return ''; + } + /** * @return string */ public function getSummary(): string { - $count = $this->getLocationsList()->count(); - $label = _t( - Location::class . '.PLURALS', - '1 Location|{count} Locations', - [ 'count' => $count ] - ); - return DBField::create_field('HTMLText', $label)->Summary(20); + $categories = $this->getCategoryList(); + $count = $this->Categories()->count(); + if ($count > 0) { + $label = _t( + ElementLocations::class . '.CategoriesLabel', + $categories + ); + } else { + $label = _t( + ElementLocations::class . '.AllLocationsLabel', + 'Showing all locations' + ); + } + //Debug::dump($label); + return DBField::create_field('HTMLText', $label)->Summary(30); } /** diff --git a/templates/Dynamic/Elements/Locations/Data/JSON.ss b/templates/Dynamic/Elements/Locations/Data/JSON.ss index 011a46f..3c92484 100644 --- a/templates/Dynamic/Elements/Locations/Data/JSON.ss +++ b/templates/Dynamic/Elements/Locations/Data/JSON.ss @@ -12,7 +12,7 @@ }, "type": "Feature", "properties": { - <% if $Categories %>"category": "$Categories.First.Title.XML",<% end_if %> + <% if $Categories %>"category": "$CategoryList.XML",<% end_if %> <% if $Hours %>"hours": "10am - 6pm",<% end_if %> <% if $Content %>"description": "$Content.XML",<% end_if %> <% if $Title %>"name": "$Title.XML",<% end_if %> diff --git a/templates/Dynamic/Elements/Locations/Elements/ElementLocations.ss b/templates/Dynamic/Elements/Locations/Elements/ElementLocations.ss index 1f5152f..ded1acf 100644 --- a/templates/Dynamic/Elements/Locations/Elements/ElementLocations.ss +++ b/templates/Dynamic/Elements/Locations/Elements/ElementLocations.ss @@ -7,13 +7,13 @@
-