diff --git a/breccia_mapper/settings.py b/breccia_mapper/settings.py index d2424c7..d32a0b2 100644 --- a/breccia_mapper/settings.py +++ b/breccia_mapper/settings.py @@ -94,6 +94,10 @@ The most likely required settings are: SECRET_KEY, DEBUG, ALLOWED_HOSTS, DATABAS - EMAIL_USE_SSL default: True if EMAIL_PORT == 465 else False Use SSL to communicate with SMTP server? Usually on port 465 + +- GOOGLE_MAPS_API_KEY + default: None + Google Maps API key to display maps of people's locations """ import collections @@ -113,6 +117,7 @@ SETTINGS_EXPORT = [ 'DEBUG', 'PROJECT_LONG_NAME', 'PROJECT_SHORT_NAME', + 'GOOGLE_MAPS_API_KEY', ] PROJECT_LONG_NAME = config('PROJECT_LONG_NAME', default='Project Long Name') @@ -393,3 +398,7 @@ except ImportError as exc: CUSTOMISATION_NAME = None TEMPLATE_NAME_INDEX = 'index.html' TEMPLATE_WELCOME_EMAIL_NAME = 'welcome-email' + +# Upstream API keys + +GOOGLE_MAPS_API_KEY = config('GOOGLE_MAPS_API_KEY', default=None) diff --git a/breccia_mapper/templates/base.html b/breccia_mapper/templates/base.html index 1b93a01..4aac740 100644 --- a/breccia_mapper/templates/base.html +++ b/breccia_mapper/templates/base.html @@ -74,6 +74,10 @@ Activities + + diff --git a/people/forms.py b/people/forms.py index 80c6fad..199d6b2 100644 --- a/people/forms.py +++ b/people/forms.py @@ -78,6 +78,8 @@ class PersonAnswerSetForm(forms.ModelForm, DynamicAnswerSetBase): 'job_title', 'disciplines', 'themes', + 'latitude', + 'longitude', ] widgets = { 'nationality': Select2Widget(), diff --git a/people/migrations/0028_person_location_fields.py b/people/migrations/0028_person_location_fields.py new file mode 100644 index 0000000..860c34a --- /dev/null +++ b/people/migrations/0028_person_location_fields.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.10 on 2020-12-15 13:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('people', '0027_multiple_choice_questions'), + ] + + operations = [ + migrations.AddField( + model_name='personanswerset', + name='latitude', + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name='personanswerset', + name='longitude', + field=models.FloatField(blank=True, null=True), + ), + ] diff --git a/people/models/person.py b/people/models/person.py index e8fff6c..3009a29 100644 --- a/people/models/person.py +++ b/people/models/person.py @@ -177,5 +177,11 @@ class PersonAnswerSet(AnswerSet): #: Project themes within this person works themes = models.ManyToManyField(Theme, related_name='people', blank=True) + #: Latitude for displaying locaiton on a map + latitude = models.FloatField(blank=True, null=True) + + #: Longitude for displaying locaiton on a map + longitude = models.FloatField(blank=True, null=True) + def get_absolute_url(self): return self.person.get_absolute_url() diff --git a/people/models/relationship.py b/people/models/relationship.py index 83346ea..f6cdbe0 100644 --- a/people/models/relationship.py +++ b/people/models/relationship.py @@ -52,7 +52,7 @@ class Relationship(models.Model): class Meta: constraints = [ - models.UniqueConstraint(fields=['source', 'person'], + models.UniqueConstraint(fields=['source', 'target'], name='unique_relationship'), ] diff --git a/people/static/js/location_picker.js b/people/static/js/location_picker.js new file mode 100644 index 0000000..8b9ffa2 --- /dev/null +++ b/people/static/js/location_picker.js @@ -0,0 +1,103 @@ +const marker_fill_alpha = 1.0; +const marker_edge_colour = 'white'; +const marker_fill_colour = 'gray'; + +// Size of the arrow markers used on the map +const marker_scale = 9; +// Offset for the place type icon (multiplier for marker scale) +const marker_label_offset = 0.27 * marker_scale; +// Width and transparency for the edges of the markers +const marker_edge_alpha = 1.0; +const marker_edge_width = 1.0; + +let marker = null; + +/** + * Position a map marker at the clicked location and update lat/long form fields. + * @param {Event} event - Click event from a Google Map. + */ +function selectLocation(event) { + if (marker === null) { + // Generate a new marker + marker = new google.maps.Marker({ + position: event.latLng, + map: map, + icon: { + path: google.maps.SymbolPath.BACKWARD_CLOSED_ARROW, + strokeColor: marker_edge_colour, + strokeWeight: marker_edge_width, + strokeOpacity: marker_edge_alpha, + fillColor: marker_fill_colour, + fillOpacity: marker_fill_alpha, + scale: marker_scale, + labelOrigin: new google.maps.Point(0, -marker_label_offset) + }, + }); + } else { + marker.setPosition(event.latLng); + } + + const pos = marker.getPosition(); + document.getElementById('id_latitude').value = pos.lat(); + document.getElementById('id_longitude').value = pos.lng(); +} + +/** + * Initialise Google Maps element after library is loaded. + */ +function initMap() { + const centre_latlng = new google.maps.LatLng(settings.centre_lat | 0, settings.centre_lng | 0); + const map = new google.maps.Map( + document.getElementById('map'), { zoom: settings.zoom, center: centre_latlng }); + + const search_input = document.getElementById('location-search') + const search_box = new google.maps.places.SearchBox(search_input) + map.controls[google.maps.ControlPosition.TOP_LEFT].push(search_input) + + map.addListener('bounds_changed', () => { + search_box.setBounds(map.getBounds()) + }) + + let markers = [] + + search_box.addListener('places_changed', () => { + const places = search_box.getPlaces() + + if (places.length === 0) return + + for (const marker of markers) marker.setMap(null) + markers = [] + + const bounds = new google.maps.LatLngBounds() + for (const place of places) { + if (!place.geometry) { + console.error('Place contains no geometry') + continue + } + + const icon = { + size: new google.maps.Size(71, 71), + origin: new google.maps.Point(0, 0), + anchor: new google.maps.Point(17, 34), + scaledSize: new google.maps.Size(25, 25), + } + + markers.push( + new google.maps.Marker({ + map, icon, title: place.name, position: place.geometry.location + }) + ) + + if (place.geometry.viewport) { + bounds.union(place.geometry.viewport) + } else { + bounds.extend(place.geometry.location) + } + + } + + map.fitBounds(bounds) + }) + + map.addListener('click', selectLocation) +} diff --git a/people/static/js/map.js b/people/static/js/map.js new file mode 100644 index 0000000..154a16d --- /dev/null +++ b/people/static/js/map.js @@ -0,0 +1,93 @@ +const marker_fill_alpha = 1.0; +const marker_edge_colour = 'white'; +const marker_fill_colour = 'gray'; + +// Size of the arrow markers used on the map +const marker_scale = 9; +// Offset for the place type icon (multiplier for marker scale) +const marker_label_offset = 0.27 * marker_scale; +// Width and transparency for the edges of the markers +const marker_edge_alpha = 1.0; +const marker_edge_width = 1.0; + +let map = null + +// The function called when Google Maps starts up +function initMap() { + // const centre_latlng = new google.maps.LatLng(settings.centre_lat, settings.centre_lng); + // The map, centered at Soton + map = new google.maps.Map( + // document.getElementById('map'), { zoom: settings.zoom, center: centre_latlng }); + document.getElementById('map')); + + const bounds = new google.maps.LatLngBounds() + const markers_data = JSON.parse( + document.getElementById('map-markers').textContent + ).filter(data => data.lat !== null && data.lng !== null); + + // For each data entry in the json... + for (const pin_data of markers_data) { + // Get the lat-long position from the data + const lat_lng = new google.maps.LatLng(pin_data.lat, pin_data.lng); + console.log(lat_lng) + + // Generate a new marker + const marker = new google.maps.Marker({ + position: lat_lng, + map: map, + icon: { + path: google.maps.SymbolPath.BACKWARD_CLOSED_ARROW, + strokeColor: marker_edge_colour, + strokeWeight: marker_edge_width, + strokeOpacity: marker_edge_alpha, + fillColor: marker_fill_colour, + fillOpacity: marker_fill_alpha, + scale: marker_scale, + labelOrigin: new google.maps.Point(0, -marker_label_offset) + }, + }); + + console.log(marker) + + bounds.extend(marker.position) + + // Build the info window content to tell the user the last time it was visited. + marker.info = new google.maps.InfoWindow({ + content: "
" + + "

" + pin_data.name.replace(''', "'") + "

" + + "
" + }); + + // We bind a listener to the current marker so that if it's clicked, it checks for an open info window, + // closes it, then opens the info window attached to it specifically. Then sets that as the last window. + google.maps.event.addListener(marker, 'click', function () { + if (last_info) { + last_info.close(); + } + last_info = this.info; + this.info.open(map, this); + }) + } + + map.fitBounds(bounds) + const max_zoom = 10 + if (map.getZoom() > max_zoom) { + map.setZoom(max_zoom) + } + + + // Set the last info window to null + var last_info = null; + + setTimeout(setMaxZoom, 100) +} + +/** + * Zoom to set level if map is zoomed in more than this. + */ +function setMaxZoom() { + const max_zoom = 10 + if (map.getZoom() > max_zoom) { + map.setZoom(max_zoom) + } +} diff --git a/people/templates/people/person/detail.html b/people/templates/people/person/detail.html index 367bac5..b071b83 100644 --- a/people/templates/people/person/detail.html +++ b/people/templates/people/person/detail.html @@ -1,5 +1,15 @@ {% extends 'base.html' %} +{% block extra_head %} + {{ map_markers|json_script:'map-markers' }} + + {% load staticfiles %} + + + +{% endblock %} + {% block content %}