diff --git a/composer.json b/composer.json index 079838f..1e59fbd 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,9 @@ "extra": { "branch-alias": { "dev-master": "1.x-dev" - } + }, + "expose": [ + "dist" + ] } } diff --git a/dist/css/map.css b/dist/css/map.css new file mode 100644 index 0000000..5311516 --- /dev/null +++ b/dist/css/map.css @@ -0,0 +1,87 @@ +#map { + height: 700px; +} + +#panel { + height: 700px; +} + +/* Styling for Autocomplete search bar */ +#pac-card { + background-color: #fff; + border-radius: 2px 0 0 2px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); + box-sizing: border-box; + font-family: Roboto; + margin: 10px 10px 0 0; + -moz-box-sizing: border-box; + outline: none; + } + + #pac-container { + padding-top: 12px; + padding-bottom: 12px; + margin-right: 12px; + } + + #pac-input { + background-color: #fff; + font-family: Roboto; + font-size: 15px; + font-weight: 300; + margin-left: 12px; + padding: 0 11px 0 13px; + text-overflow: ellipsis; + width: 400px; + } + + #pac-input:focus { + border-color: #4d90fe; + } + + #title { + color: #fff; + background-color: #acbcc9; + font-size: 18px; + font-weight: 400; + padding: 6px 12px; + } + + .hidden { + display: none; + } + + /* Styling for an info pane that slides out from the left. + * Hidden by default. */ + #panel { + height: 700px; + width: null; + background-color: white; + /* position: fixed; */ + z-index: 1; + overflow-x: hidden; + transition: all .2s ease-out; + } + + .open { + width: 250px; + } + + .place { + font-family: 'open sans', arial, sans-serif; + font-size: 1.2em; + font-weight: 500; + margin-block-end: 0px; + padding-left: 18px; + padding-right: 18px; + } + + .distanceText { + color: silver; + font-family: 'open sans', arial, sans-serif; + font-size: 1em; + font-weight: 400; + margin-block-start: 0.25em; + padding-left: 18px; + padding-right: 18px; + } \ No newline at end of file diff --git a/dist/js/map.js b/dist/js/map.js new file mode 100644 index 0000000..df090b8 --- /dev/null +++ b/dist/js/map.js @@ -0,0 +1,339 @@ +/* + * 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. + */ + +// 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'), { + zoom: 7, + center: {lat: 52.632469, lng: -1.689423}, + styles: mapStyle, + }); + + // Load the stores GeoJSON onto the map. + map.data.loadGeoJson('$link', {idPropertyName: 'storeid'}); + + // Define the custom marker icons, using the store's "category". + // map.data.setStyle((feature) => { + // return { + // icon: { + // url: `img/icon_${feature.getProperty('category')}.png`, + // scaledSize: new google.maps.Size(64, 64), + // }, + // }; + // }); + + 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 hours = event.feature.getProperty('hours'); + const phone = event.feature.getProperty('phone'); + const position = event.feature.getGeometry().get(); + const content = sanitizeHTML` + +
+

${name}

${description}

+

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

+

+
+ `; + + infoWindow.setContent(content); + infoWindow.setPosition(position); + 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'}, + }; + + card.setAttribute('id', 'pac-card'); + title.setAttribute('id', 'title'); + title.textContent = 'Find the nearest store'; + titleBar.appendChild(title); + container.setAttribute('id', 'pac-container'); + input.setAttribute('id', 'pac-input'); + input.setAttribute('type', 'text'); + input.setAttribute('placeholder', 'Enter an address'); + container.appendChild(input); + 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}); + originMarker.setVisible(false); + let originLocation = map.getCenter(); + + autocomplete.addListener('place_changed', async () => { + originMarker.setVisible(false); + 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 + '\''); + 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); + + 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) => { + service.getDistanceMatrix(parameters, (response, status) => { + if (status != google.maps.DistanceMatrixStatus.OK) { + reject(response); + } else { + const distances = []; + const results = response.rows[0].elements; + for (let j = 0; j < results.length; j++) { + const element = results[j]; + const distanceText = element.distance.text; + const distanceVal = element.distance.value; + const distanceObject = { + storeid: stores[j], + distanceText: distanceText, + distanceVal: distanceVal, + }; + distances.push(distanceObject); + } + + resolve(distances); + } + }); + }); + + const distancesList = await getDistanceMatrix(service, { + origins: [origin], + destinations: destinations, + travelMode: 'DRIVING', + unitSystem: google.maps.UnitSystem.METRIC, + }); + + distancesList.sort((first, second) => { + return first.distanceVal - second.distanceVal; + }); + + return distancesList; + } + + /** + * 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('empty stores'); + return; + } + + let panel = document.createElement('div'); + // If the panel already exists, use it. Else, create it and add to the page. + if (document.getElementById('panel')) { + panel = document.getElementById('panel'); + // If panel is already open, close it + if (panel.classList.contains('open')) { + panel.classList.remove('open'); + } + } else { + panel.setAttribute('id', 'panel'); + const body = document.body; + body.insertBefore(panel, body.childNodes[0]); + } + + + // Clear the previous details + while (panel.lastChild) { + panel.removeChild(panel.lastChild); + } + + stores.forEach((store) => { + // Add store details with text formatting + 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); + }); + + // Open the panel + panel.classList.add('open'); + + return; + } \ No newline at end of file diff --git a/src/Control/ElementLocationsController.php b/src/Control/ElementLocationsController.php new file mode 100644 index 0000000..716d7a8 --- /dev/null +++ b/src/Control/ElementLocationsController.php @@ -0,0 +1,170 @@ +getKey(); + $link = $this->Link('json'); + + Requirements::javascriptTemplate( + 'dynamic/silverstripe-elemental-locations: dist/js/map.js', + [ + 'key' => $key, + 'link' => $link, + ] + ); + + Requirements::javascript( + '//maps.googleapis.com/maps/api/js?key=' . $key . '&libraries=places&callback=initMap&solution_channel=GMP_codelabs_simplestorelocator_v1_a', + [ + 'async' => true, + 'defer' => true, + ] + ); + } + + public function json() + { + $this->getResponse()->addHeader("Content-Type", "application/json"); + $data = new ArrayData([ + //"Locations" => $this->getLocations(), + ]); + + return $data->renderWith('Dynamic/Elements/Locations/Data/geoJSON'); + } + + /** + * @return string + */ + public function getKey() + { + return Config::inst()->get(GoogleGeocoder::class, 'map_api_key'); + } + + /** + * @return ArrayList|DataList + */ + public function getLocations() + { + if (!$this->locations) { + $this->setLocations($this->request); + } + + return $this->locations; + } + + /** + * @param HTTPRequest|null $request + * + * @return $this + */ + public function setLocations(HTTPRequest $request = null) + { + + if ($request === null) { + $request = $this->request; + } + + if ($this->Categories()->exists()) { + foreach ($this->Categories() as $category) { + $filter['Categories.ID'][] = $category->ID; + } + } + + $this->extend('updateLocatorFilter', $filter, $request); + + $filterAny = $this->config()->get('base_filter_any'); + $this->extend('updateLocatorFilterAny', $filterAny, $request); + + $exclude = $this->config()->get('base_exclude'); + $this->extend('updateLocatorExclude', $exclude, $request); + + $class = $this->data()->ClassName; + $locations = $class::get_locations($filter, $filterAny, $exclude); + $locations = DataToArrayListHelper::to_array_list($locations); + + //allow for adjusting list post possible distance calculation + $this->extend('updateLocationList', $locations); + + if ($locations->canSortBy('Distance')) { + $locations = $locations->sort('Distance'); + } + + if ($this->getShowRadius()) { + $radiusVar = $this->data()->config()->get('radius_var'); + + if ($radius = (int)$request->getVar($radiusVar)) { + $locations = $locations->filterByCallback(function ($location) use (&$radius) { + return $location->Distance <= $radius; + }); + } + } + + //allow for returning list to be set as + $this->extend('updateListType', $locations); + + $limit = $this->getLimit(); + if ($limit > 0) { + $locations = $locations->limit($limit); + } + + $this->locations = $locations; + + 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 58d0bf7..b5fd553 100644 --- a/src/Element/ElementLocations.php +++ b/src/Element/ElementLocations.php @@ -4,11 +4,15 @@ use SilverStripe\ORM\ArrayList; use SilverStripe\Forms\FieldList; +use SilverStripe\TagField\TagField; +use SilverStripe\View\Requirements; +use SilverStripe\Core\Config\Config; use Dynamic\Locations\Model\Location; use SilverStripe\ORM\FieldType\DBField; use DNADesign\Elemental\Models\BaseElement; use Dynamic\Locations\Model\LocationCategory; -use SilverStripe\TagField\TagField; +use Dynamic\SilverStripeGeocoder\GoogleGeocoder; +use Dynamic\Elements\Locations\Control\ElementLocationsController; /** * Class \Dynamic\Elements\Locations\Elements\ElementLocations @@ -29,6 +33,12 @@ class ElementLocations extends BaseElement */ private static string $icon = 'font-icon-globe'; + /** + * @var string + * @config + */ + private static $controller_class = ElementLocationsController::class; + /** * @var array * @config diff --git a/templates/Dynamic/Elements/Locations/Data/JSON.ss b/templates/Dynamic/Elements/Locations/Data/JSON.ss new file mode 100644 index 0000000..e69de29 diff --git a/templates/Dynamic/Elements/Locations/Data/geoJSON.ss b/templates/Dynamic/Elements/Locations/Data/geoJSON.ss new file mode 100644 index 0000000..77b2201 --- /dev/null +++ b/templates/Dynamic/Elements/Locations/Data/geoJSON.ss @@ -0,0 +1,227 @@ +{ + "type": "FeatureCollection", + "features": [{ + "geometry": { + "type": "Point", + "coordinates": [-0.1428115, + 51.5125168 + ] + }, + "type": "Feature", + "properties": { + "category": "patisserie", + "hours": "10am - 6pm", + "description": "Modern twists on classic pastries. We're part of a larger chain of patisseries and cafes.", + "name": "Josie's Patisserie Mayfair", + "phone": "+44 20 1234 5678", + "storeid": "01" + } + }, + { + "geometry": { + "type": "Point", + "coordinates": [-2.579623, + 51.452251 + ] + }, + "type": "Feature", + "properties": { + "category": "patisserie", + "hours": "10am - 6pm", + "description": "Come and try our award-winning cakes and pastries. We're part of a larger chain of patisseries and cafes.", + "name": "Josie's Patisserie Bristol", + "phone": "+44 117 121 2121", + "storeid": "02" + } + }, + { + "geometry": { + "type": "Point", + "coordinates": [ + 1.273459, + 52.638072 + ] + }, + "type": "Feature", + "properties": { + "category": "patisserie", + "hours": "10am - 6pm", + "description": "Whatever the occasion, whether it's a birthday or a wedding, Josie's Patisserie has the perfect treat for you. We're part of a larger chain of patisseries and cafes.", + "name": "Josie's Patisserie Norwich", + "phone": "+44 1603 123456", + "storeid": "03" + } + }, + { + "geometry": { + "type": "Point", + "coordinates": [-1.9912838, + 50.8000418 + ] + }, + "type": "Feature", + "properties": { + "category": "patisserie", + "hours": "10am - 6pm", + "description": "A gourmet patisserie that will delight your senses. We're part of a larger chain of patisseries and cafes.", + "name": "Josie's Patisserie Wimborne", + "phone": "+44 1202 343434", + "storeid": "04" + } + }, + { + "geometry": { + "type": "Point", + "coordinates": [-2.985933, + 53.408899 + ] + }, + "type": "Feature", + "properties": { + "category": "patisserie", + "hours": "10am - 6pm", + "description": "Spoil yourself or someone special with our classic pastries. We're part of a larger chain of patisseries and cafes.", + "name": "Josie's Patisserie Liverpool", + "phone": "+44 151 444 4444", + "storeid": "05" + } + }, + { + "geometry": { + "type": "Point", + "coordinates": [-1.689423, + 52.632469 + ] + }, + "type": "Feature", + "properties": { + "category": "patisserie", + "hours": "10am - 6pm", + "description": "Come and feast your eyes and tastebuds on our delicious pastries and cakes. We're part of a larger chain of patisseries and cafes.", + "name": "Josie's Patisserie Tamworth", + "phone": "+44 5555 55555", + "storeid": "06" + } + }, + { + "geometry": { + "type": "Point", + "coordinates": [-3.155305, + 51.479756 + ] + }, + "type": "Feature", + "properties": { + "category": "patisserie", + "hours": "10am - 6pm", + "description": "Josie's Patisserie is family-owned, and our delectable pastries, cakes, and great coffee are renowed. We're part of a larger chain of patisseries and cafes.", + "name": "Josie's Patisserie Cardiff", + "phone": "+44 29 6666 6666", + "storeid": "07" + } + }, + { + "geometry": { + "type": "Point", + "coordinates": [-0.725019, + 52.668891 + ] + }, + "type": "Feature", + "properties": { + "category": "cafe", + "hours": "8am - 9:30pm", + "description": "Oakham's favorite spot for fresh coffee and delicious cakes. We're part of a larger chain of patisseries and cafes.", + "name": "Josie's Cafe Oakham", + "phone": "+44 7777 777777", + "storeid": "08" + } + }, + { + "geometry": { + "type": "Point", + "coordinates": [-2.477653, + 53.735405 + ] + }, + "type": "Feature", + "properties": { + "category": "cafe", + "hours": "8am - 9:30pm", + "description": "Enjoy freshly brewed coffe, and home baked cakes in our homely cafe. We're part of a larger chain of patisseries and cafes.", + "name": "Josie's Cafe Blackburn", + "phone": "+44 8888 88888", + "storeid": "09" + } + }, + { + "geometry": { + "type": "Point", + "coordinates": [-0.211363, + 51.108966 + ] + }, + "type": "Feature", + "properties": { + "category": "cafe", + "hours": "8am - 9:30pm", + "description": "A delicious array of pastries with many flavours, and fresh coffee in an snug cafe. We're part of a larger chain of patisseries and cafes.", + "name": "Josie's Cafe Crawley", + "phone": "+44 1010 101010", + "storeid": "10" + } + }, + { + "geometry": { + "type": "Point", + "coordinates": [-0.123559, + 50.832679 + ] + }, + "type": "Feature", + "properties": { + "category": "cafe", + "hours": "8am - 9:30pm", + "description": "Grab a freshly brewed coffee, a decadent cake and relax in our idyllic cafe. We're part of a larger chain of patisseries and cafes.", + "name": "Josie's Cafe Brighton", + "phone": "+44 1313 131313", + "storeid": "11" + } + }, + { + "geometry": { + "type": "Point", + "coordinates": [-3.319575, + 52.517827 + ] + }, + "type": "Feature", + "properties": { + "category": "cafe", + "hours": "8am - 9:30pm", + "description": "Come in and unwind at this idyllic cafe with fresh coffee and home made cakes. We're part of a larger chain of patisseries and cafes.", + "name": "Josie's Cafe Newtown", + "phone": "+44 1414 141414", + "storeid": "12" + } + }, + { + "geometry": { + "type": "Point", + "coordinates": [ + 1.158167, + 52.071634 + ] + }, + "type": "Feature", + "properties": { + "category": "cafe", + "hours": "8am - 9:30pm", + "description": "Fresh coffee and delicious cakes in an snug cafe. We're part of a larger chain of patisseries and cafes.", + "name": "Josie's Cafe Ipswich", + "phone": "+44 1717 17171", + "storeid": "13" + } + } + ] +} \ No newline at end of file diff --git a/templates/Dynamic/Elements/Locations/Elements/ElementLocations.ss b/templates/Dynamic/Elements/Locations/Elements/ElementLocations.ss index dbd042d..299c7f3 100644 --- a/templates/Dynamic/Elements/Locations/Elements/ElementLocations.ss +++ b/templates/Dynamic/Elements/Locations/Elements/ElementLocations.ss @@ -1,2 +1,14 @@ <% if $Title && $ShowTitle %>

$Title

<% end_if %> -<% if $Content %>
$Content
<% end_if %> \ No newline at end of file +<% if $Content %>
$Content
<% end_if %> + +<% require css('dynamic/silverstripe-elemental-locations: dist/css/map.css') %> + + +
+
+
+
+
+
+
+