feat: add configurable people maps
Add independent map profiles, offline city lookup, bundled world artwork and collision-free portrait placement with optional person details. Deploy with updated requirements, migrations 0029-0031 and collectstatic.
This commit is contained in:
+4
-2
@@ -50,6 +50,8 @@ private_uploads/
|
|||||||
staticfiles/
|
staticfiles/
|
||||||
*.DS_Store
|
*.DS_Store
|
||||||
test_media_releases/
|
test_media_releases/
|
||||||
docs/
|
docs/*
|
||||||
scripts/
|
!docs/people-maps.md
|
||||||
|
scripts/*
|
||||||
|
!scripts/verify_people_map.py
|
||||||
videos/
|
videos/
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.http import FileResponse, Http404, HttpResponseRedirect
|
from django.http import FileResponse, Http404, HttpResponseRedirect
|
||||||
from django.urls import path, reverse
|
from django.urls import path, reverse
|
||||||
|
from django.templatetags.static import static
|
||||||
|
from django.utils.html import format_html_join
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
|
|
||||||
from apps.core.admin_video import video_admin_preview
|
from apps.core.admin_video import video_admin_preview
|
||||||
from apps.core.video import VIDEO_ADMIN_FIELDSET
|
from apps.core.video import VIDEO_ADMIN_FIELDSET
|
||||||
|
|
||||||
|
from .forms import MapMembershipAdminForm
|
||||||
from .models import (
|
from .models import (
|
||||||
AboutSection,
|
AboutSection,
|
||||||
AboutSectionItem,
|
AboutSectionItem,
|
||||||
@@ -20,6 +23,10 @@ from .models import (
|
|||||||
HomepageSection,
|
HomepageSection,
|
||||||
HomepageSectionItem,
|
HomepageSectionItem,
|
||||||
PageVideo,
|
PageVideo,
|
||||||
|
PeopleMap,
|
||||||
|
MapPerson,
|
||||||
|
MapMembership,
|
||||||
|
MapPlacement,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -319,3 +326,49 @@ class PageVideoAdmin(admin.ModelAdmin):
|
|||||||
@admin.display(description="Preview")
|
@admin.display(description="Preview")
|
||||||
def video_preview(self, obj):
|
def video_preview(self, obj):
|
||||||
return video_admin_preview(obj)
|
return video_admin_preview(obj)
|
||||||
|
|
||||||
|
|
||||||
|
class MapMembershipInline(admin.StackedInline):
|
||||||
|
model = MapMembership
|
||||||
|
form = MapMembershipAdminForm
|
||||||
|
extra = 0
|
||||||
|
autocomplete_fields = ['person']
|
||||||
|
|
||||||
|
|
||||||
|
class MapPlacementInline(admin.TabularInline):
|
||||||
|
model = MapPlacement
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PeopleMap)
|
||||||
|
class PeopleMapAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['title', 'style', 'background_style', 'is_active']
|
||||||
|
list_filter = ['style', 'background_style', 'is_active']
|
||||||
|
search_fields = ['title']
|
||||||
|
inlines = [MapMembershipInline, MapPlacementInline]
|
||||||
|
readonly_fields = ['background_gallery']
|
||||||
|
fields = ['title', 'description', 'style', 'background_style', 'background_gallery', 'background_image', 'show_people_details', 'is_active']
|
||||||
|
|
||||||
|
@admin.display(description="Included world maps")
|
||||||
|
def background_gallery(self, obj):
|
||||||
|
return format_html_join('',
|
||||||
|
'<figure style="display:inline-block;width:240px;margin:8px;vertical-align:top">'
|
||||||
|
'<img src="{}" alt="{}" style="width:240px;height:120px;object-fit:contain;background:#0c182a;border-radius:8px" />'
|
||||||
|
'<figcaption style="margin-top:8px">{}</figcaption></figure>',
|
||||||
|
((static(PeopleMap(background_style=key).background_static_path), label, label)
|
||||||
|
for key, label in PeopleMap.BACKGROUND_CHOICES))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(MapPerson)
|
||||||
|
class MapPersonAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['name', 'role', 'institution']
|
||||||
|
search_fields = ['name', 'institution', 'role']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(MapPlacement)
|
||||||
|
class MapPlacementAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['people_map', 'page_path', 'position', 'order', 'is_active']
|
||||||
|
search_fields = ['page_path', 'people_map__title']
|
||||||
|
autocomplete_fields = ['people_map']
|
||||||
|
list_filter = ['position', 'is_active']
|
||||||
|
|||||||
+14
-1
@@ -2,7 +2,7 @@ from django import forms
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from .contact_uploads import validate_contact_attachments
|
from .contact_uploads import validate_contact_attachments
|
||||||
from .models import ContactSubmission
|
from .models import ContactSubmission, MapMembership
|
||||||
|
|
||||||
|
|
||||||
class ContactForm(forms.ModelForm):
|
class ContactForm(forms.ModelForm):
|
||||||
@@ -39,3 +39,16 @@ class ContactForm(forms.ModelForm):
|
|||||||
self.add_error("attachments", exc)
|
self.add_error("attachments", exc)
|
||||||
cleaned_data["attachments"] = []
|
cleaned_data["attachments"] = []
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class MapMembershipAdminForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = MapMembership
|
||||||
|
fields = "__all__"
|
||||||
|
help_texts = {
|
||||||
|
"location_method": "City mode calculates coordinates on save. Choose Coordinates to set latitude and longitude yourself.",
|
||||||
|
"country": "For city lookup, enter an English country name or ISO code, e.g. Canada / CA. Optional location label in Coordinates mode.",
|
||||||
|
"city": "Required in city mode. Unknown, small or ambiguous towns can be placed using Coordinates mode instead.",
|
||||||
|
"latitude": "Coordinates mode: -90 to 90. In city mode this value is calculated from the country and city on save.",
|
||||||
|
"longitude": "Coordinates mode: -180 to 180. In city mode this value is calculated from the country and city on save.",
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Offline city resolution. No requests or personal data leave the server."""
|
||||||
|
from functools import lru_cache
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(value):
|
||||||
|
return ''.join(c for c in unicodedata.normalize('NFKD', value.casefold().strip()) if not unicodedata.combining(c))
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def gazetteer():
|
||||||
|
import geonamescache
|
||||||
|
return geonamescache.GeonamesCache()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_city(country, city):
|
||||||
|
data = gazetteer()
|
||||||
|
country_key = normalize(country)
|
||||||
|
codes = [c['iso'] for c in data.get_countries().values() if country_key in (normalize(c['name']), c['iso'].lower(), c['iso3'].lower())]
|
||||||
|
if not codes:
|
||||||
|
raise ValidationError({'country': 'Enter a country name or ISO code, e.g. Canada or CA.'})
|
||||||
|
city_key = normalize(city)
|
||||||
|
country_cities = [c for c in data.get_cities().values() if c['countrycode'] in codes]
|
||||||
|
# Prefer the city's canonical name over another town's historical aliases.
|
||||||
|
# E.g. Tehran is also listed as an alias of Tiran in GeoNames.
|
||||||
|
matches = [c for c in country_cities if normalize(c['name']) == city_key]
|
||||||
|
if not matches:
|
||||||
|
matches = [c for c in country_cities if city_key in {
|
||||||
|
normalize(alias) for alias in c.get('alternatenames', [])
|
||||||
|
}]
|
||||||
|
if len(matches) != 1:
|
||||||
|
raise ValidationError({'city': 'City not found or ambiguous. Use coordinates for small towns or cities with the same name.'})
|
||||||
|
return matches[0]['latitude'], matches[0]['longitude']
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Generated by Django 5.2.16 on 2026-11-02 19:44
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pages', '0028_alter_blogpostpartner_options_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MapPerson',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=200)),
|
||||||
|
('qualifications', models.CharField(blank=True, max_length=100)),
|
||||||
|
('role', models.CharField(blank=True, max_length=200)),
|
||||||
|
('institution', models.CharField(blank=True, max_length=300)),
|
||||||
|
('biography', models.TextField(blank=True)),
|
||||||
|
('photo', models.ImageField(blank=True, upload_to='maps/people/')),
|
||||||
|
('profile_url', models.URLField(blank=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'Map people',
|
||||||
|
'ordering': ['name', 'pk'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PeopleMap',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=200)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('style', models.CharField(choices=[('aurora', 'Aurora — dark ocean'), ('paper', 'Atlas — light'), ('classic', 'Classic — original blue / turquoise palette')], default='aurora', max_length=20)),
|
||||||
|
('background_image', models.ImageField(blank=True, help_text='Optional original map artwork. Must use an equirectangular world projection.', upload_to='maps/')),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MapPlacement',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('page_path', models.CharField(help_text='Local page path, e.g. /about/, /projects/project-slug/, or any custom page. No domain or query string.', max_length=400)),
|
||||||
|
('position', models.CharField(choices=[('top', 'Before page content'), ('bottom', 'After page content')], default='bottom', max_length=10)),
|
||||||
|
('order', models.PositiveIntegerField(default=0)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('people_map', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='placements', to='pages.peoplemap')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['order', 'pk'],
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('people_map', 'page_path', 'position'), name='unique_map_placement')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MapMembership',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('location_method', models.CharField(choices=[('city', 'Country and city'), ('coordinates', 'Coordinates')], default='city', max_length=12)),
|
||||||
|
('country', models.CharField(blank=True, max_length=100)),
|
||||||
|
('city', models.CharField(blank=True, max_length=100)),
|
||||||
|
('latitude', models.FloatField(blank=True, null=True)),
|
||||||
|
('longitude', models.FloatField(blank=True, null=True)),
|
||||||
|
('order', models.PositiveIntegerField(default=0)),
|
||||||
|
('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='map_memberships', to='pages.mapperson')),
|
||||||
|
('people_map', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='pages.peoplemap')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['order', 'pk'],
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('people_map', 'person'), name='unique_map_person')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.16 on 2026-11-02 20:14
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pages', '0029_mapperson_peoplemap_mapplacement_mapmembership'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='peoplemap',
|
||||||
|
name='background_style',
|
||||||
|
field=models.CharField(choices=[('classic', 'Classic continents — blue silhouette'), ('atlas', 'World atlas — country borders and coordinate grid'), ('midnight', 'Midnight — dotted continents'), ('ocean', 'Ocean — luminous continents and meridians')], default='classic', help_text='Built-in artwork shipped with the application, also available on the server. An uploaded image overrides this choice.', max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.16 on 2026-11-02 20:21
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('pages', '0030_peoplemap_background_style'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='peoplemap',
|
||||||
|
name='show_people_details',
|
||||||
|
field=models.BooleanField(default=True, help_text='Disable to display only the map, without the people information cards underneath.', verbose_name='Show people details below the map'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -581,3 +581,113 @@ class CustomPageSectionItem(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.section} › {self.title or self.icon or '(item)'}"
|
return f"{self.section} › {self.title or self.icon or '(item)'}"
|
||||||
|
|
||||||
|
|
||||||
|
class PeopleMap(models.Model):
|
||||||
|
show_people_details = models.BooleanField(
|
||||||
|
"Show people details below the map",
|
||||||
|
default=True,
|
||||||
|
help_text="Disable to display only the map, without the people information cards underneath.",
|
||||||
|
)
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
style = models.CharField(max_length=20, default="aurora", choices=[
|
||||||
|
("aurora", "Aurora — dark ocean"), ("paper", "Atlas — light"),
|
||||||
|
("classic", "Classic — original blue / turquoise palette"),
|
||||||
|
])
|
||||||
|
BACKGROUND_CHOICES = [
|
||||||
|
("classic", "Classic continents — blue silhouette"),
|
||||||
|
("atlas", "World atlas — country borders and coordinate grid"),
|
||||||
|
("midnight", "Midnight — dotted continents"),
|
||||||
|
("ocean", "Ocean — luminous continents and meridians"),
|
||||||
|
]
|
||||||
|
background_style = models.CharField(max_length=20, choices=BACKGROUND_CHOICES, default="classic",
|
||||||
|
help_text="Built-in artwork shipped with the application, also available on the server. An uploaded image overrides this choice.")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def background_static_path(self):
|
||||||
|
return {
|
||||||
|
"classic": "images/world-map.svg",
|
||||||
|
"atlas": "images/maps/world-atlas.svg",
|
||||||
|
"midnight": "images/maps/world-midnight.svg",
|
||||||
|
"ocean": "images/maps/world-ocean.svg",
|
||||||
|
}.get(self.background_style, "images/world-map.svg")
|
||||||
|
|
||||||
|
background_image = models.ImageField(upload_to="maps/", blank=True,
|
||||||
|
help_text="Optional original map artwork. Must use an equirectangular world projection.")
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class MapPerson(models.Model):
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
qualifications = models.CharField(max_length=100, blank=True)
|
||||||
|
role = models.CharField(max_length=200, blank=True)
|
||||||
|
institution = models.CharField(max_length=300, blank=True)
|
||||||
|
biography = models.TextField(blank=True)
|
||||||
|
photo = models.ImageField(upload_to="maps/people/", blank=True)
|
||||||
|
profile_url = models.URLField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["name", "pk"]
|
||||||
|
verbose_name_plural = "Map people"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class MapMembership(models.Model):
|
||||||
|
people_map = models.ForeignKey(PeopleMap, on_delete=models.CASCADE, related_name="memberships")
|
||||||
|
person = models.ForeignKey(MapPerson, on_delete=models.CASCADE, related_name="map_memberships")
|
||||||
|
location_method = models.CharField(max_length=12, default="city", choices=[("city", "Country and city"), ("coordinates", "Coordinates")])
|
||||||
|
country = models.CharField(max_length=100, blank=True)
|
||||||
|
city = models.CharField(max_length=100, blank=True)
|
||||||
|
latitude = models.FloatField(blank=True, null=True)
|
||||||
|
longitude = models.FloatField(blank=True, null=True)
|
||||||
|
order = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["order", "pk"]
|
||||||
|
constraints = [models.UniqueConstraint(fields=["people_map", "person"], name="unique_map_person")]
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
if self.location_method == "city":
|
||||||
|
from .maps import resolve_city
|
||||||
|
if not self.city or not self.country:
|
||||||
|
raise ValidationError('Country and city are required for city locations.')
|
||||||
|
self.latitude, self.longitude = resolve_city(self.country, self.city)
|
||||||
|
import math
|
||||||
|
errors = {}
|
||||||
|
for field, limit in (("latitude", 90), ("longitude", 180)):
|
||||||
|
value = getattr(self, field)
|
||||||
|
if value is None or not math.isfinite(value) or not -limit <= value <= limit:
|
||||||
|
errors[field] = f'Enter a coordinate between {-limit} and {limit}.'
|
||||||
|
if errors:
|
||||||
|
raise ValidationError(errors)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
self.full_clean()
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class MapPlacement(models.Model):
|
||||||
|
people_map = models.ForeignKey(PeopleMap, on_delete=models.CASCADE, related_name="placements")
|
||||||
|
page_path = models.CharField(max_length=400, help_text="Local page path, e.g. /about/, /projects/project-slug/, or any custom page. No domain or query string.")
|
||||||
|
position = models.CharField(max_length=10, choices=[("top", "Before page content"), ("bottom", "After page content")], default="bottom")
|
||||||
|
order = models.PositiveIntegerField(default=0)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["order", "pk"]
|
||||||
|
constraints = [models.UniqueConstraint(fields=["people_map", "page_path", "position"], name="unique_map_placement")]
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
if not self.page_path.startswith('/') or self.page_path.startswith('//') or any(c in self.page_path for c in ('?', '#', '://')):
|
||||||
|
raise ValidationError({'page_path': 'Use a local path beginning with /, without a domain, query or fragment.'})
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.people_map} → {self.page_path}'
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from django import template
|
||||||
|
from apps.pages.models import MapPlacement
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.inclusion_tag('partials/_people_maps.html', takes_context=True)
|
||||||
|
def people_maps(context, position):
|
||||||
|
request = context.get('request')
|
||||||
|
if request is None:
|
||||||
|
return {'placements': []}
|
||||||
|
cache_key = '_people_maps_for_path'
|
||||||
|
if not hasattr(request, cache_key):
|
||||||
|
placements = list(MapPlacement.objects.filter(
|
||||||
|
page_path=request.path, is_active=True, people_map__is_active=True,
|
||||||
|
).select_related('people_map').prefetch_related('people_map__memberships__person'))
|
||||||
|
setattr(request, cache_key, placements)
|
||||||
|
return {'placements': [p for p in getattr(request, cache_key) if p.position == position]}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.template import Context, Template
|
||||||
|
from django.test import RequestFactory, TestCase
|
||||||
|
|
||||||
|
from apps.pages.maps import resolve_city
|
||||||
|
from apps.pages.models import MapMembership, MapPerson, MapPlacement, PeopleMap
|
||||||
|
|
||||||
|
|
||||||
|
class PeopleMapTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.world = PeopleMap.objects.create(title='Our global network')
|
||||||
|
self.person = MapPerson.objects.create(name='<script>Test</script>', institution='Institute')
|
||||||
|
|
||||||
|
def test_offline_city_and_country_resolution(self):
|
||||||
|
lat, lon = resolve_city('CA', 'Vancouver')
|
||||||
|
self.assertAlmostEqual(lat, 49.24966, places=2)
|
||||||
|
self.assertAlmostEqual(lon, -123.11934, places=2)
|
||||||
|
self.assertEqual(resolve_city('Canada', 'Vancouver'), (lat, lon))
|
||||||
|
|
||||||
|
def test_invalid_or_ambiguous_city_is_rejected(self):
|
||||||
|
for country, city in [('Canada', 'Not a real city'), ('Unknown country', 'Paris'), ('US', 'Springfield')]:
|
||||||
|
with self.subTest(country=country, city=city), self.assertRaises(ValidationError):
|
||||||
|
resolve_city(country, city)
|
||||||
|
|
||||||
|
def test_membership_saves_resolved_coordinates(self):
|
||||||
|
member = MapMembership.objects.create(people_map=self.world, person=self.person, country='CA', city='Vancouver')
|
||||||
|
self.assertLess(member.longitude, -120)
|
||||||
|
member.city = 'Toronto'
|
||||||
|
member.save()
|
||||||
|
member.refresh_from_db()
|
||||||
|
self.assertGreater(member.longitude, -80)
|
||||||
|
|
||||||
|
def test_explicit_coordinates_and_invalid_ranges(self):
|
||||||
|
member = MapMembership(people_map=self.world, person=self.person, location_method='coordinates', latitude=0, longitude=0)
|
||||||
|
member.full_clean()
|
||||||
|
for latitude, longitude in [(91, 0), (0, -181), (None, 0), (float('nan'), 0), (0, float('inf'))]:
|
||||||
|
member.latitude, member.longitude = latitude, longitude
|
||||||
|
with self.subTest(latitude=latitude, longitude=longitude), self.assertRaises(ValidationError):
|
||||||
|
member.full_clean()
|
||||||
|
|
||||||
|
def test_different_maps_can_have_different_members(self):
|
||||||
|
second = PeopleMap.objects.create(title='Other map')
|
||||||
|
MapMembership.objects.create(people_map=self.world, person=self.person, location_method='coordinates', latitude=0, longitude=0)
|
||||||
|
self.assertEqual(second.memberships.count(), 0)
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
MapMembership.objects.create(people_map=self.world, person=self.person, location_method='coordinates', latitude=1, longitude=1)
|
||||||
|
|
||||||
|
def test_placement_path_validation(self):
|
||||||
|
for path in ['https://example.com/', '//example.com/', '/about/?a=1', '/about/#x']:
|
||||||
|
with self.subTest(path=path), self.assertRaises(ValidationError):
|
||||||
|
MapPlacement(people_map=self.world, page_path=path).full_clean()
|
||||||
|
|
||||||
|
def render_maps(self, path, position='bottom'):
|
||||||
|
request = RequestFactory().get(path)
|
||||||
|
return Template('{% load people_maps %}{% people_maps position %}').render(Context({'request': request, 'position': position}))
|
||||||
|
|
||||||
|
def test_placement_filters_and_escaped_person_data(self):
|
||||||
|
MapPlacement.objects.create(people_map=self.world, page_path='/about/')
|
||||||
|
MapMembership.objects.create(people_map=self.world, person=self.person, location_method='coordinates', latitude=0, longitude=0)
|
||||||
|
html = self.render_maps('/about/')
|
||||||
|
self.assertIn('Our global network', html)
|
||||||
|
self.assertIn('<script>', html)
|
||||||
|
self.assertNotIn('<script>Test', html)
|
||||||
|
self.assertNotIn('Our global network', self.render_maps('/contact/'))
|
||||||
|
self.assertNotIn('Our global network', self.render_maps('/about/', 'top'))
|
||||||
|
self.world.is_active = False
|
||||||
|
self.world.save()
|
||||||
|
self.assertNotIn('Our global network', self.render_maps('/about/'))
|
||||||
|
|
||||||
|
def test_placement_supports_any_project_or_page_path(self):
|
||||||
|
for path in ['/', '/about/', '/my-page/', '/projects/alpha/', '/projects/alpha/beta/']:
|
||||||
|
MapPlacement.objects.create(people_map=self.world, page_path=path, position='top')
|
||||||
|
self.assertIn('Our global network', self.render_maps(path, 'top'))
|
||||||
|
|
||||||
|
def test_admin_map_inline_resolves_city(self):
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.urls import reverse
|
||||||
|
user = get_user_model().objects.create_superuser(username='mapadmin', password='test-password', email='map@example.com')
|
||||||
|
self.client.force_login(user)
|
||||||
|
url = reverse('admin:pages_peoplemap_change', args=[self.world.pk])
|
||||||
|
self.assertEqual(self.client.get(url).status_code, 200)
|
||||||
|
response = self.client.post(url, {
|
||||||
|
'title': self.world.title, 'style': 'classic', 'background_style': 'atlas', 'is_active': 'on',
|
||||||
|
'memberships-TOTAL_FORMS': '1', 'memberships-INITIAL_FORMS': '0',
|
||||||
|
'memberships-MIN_NUM_FORMS': '0', 'memberships-MAX_NUM_FORMS': '1000',
|
||||||
|
'memberships-0-person': str(self.person.pk), 'memberships-0-location_method': 'city',
|
||||||
|
'memberships-0-country': 'CA', 'memberships-0-city': 'Vancouver', 'memberships-0-order': '0',
|
||||||
|
'placements-TOTAL_FORMS': '1', 'placements-INITIAL_FORMS': '0',
|
||||||
|
'placements-MIN_NUM_FORMS': '0', 'placements-MAX_NUM_FORMS': '1000',
|
||||||
|
'placements-0-page_path': '/about/', 'placements-0-position': 'bottom',
|
||||||
|
'placements-0-order': '0', 'placements-0-is_active': 'on', '_save': 'Save',
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
member = self.world.memberships.get()
|
||||||
|
self.assertAlmostEqual(member.latitude, 49.25, places=1)
|
||||||
|
self.assertIn('Our global network', self.client.get('/about/').content.decode())
|
||||||
|
|
||||||
|
def test_all_builtin_backgrounds_are_bundled_and_rendered(self):
|
||||||
|
from django.contrib.staticfiles import finders
|
||||||
|
MapPlacement.objects.create(people_map=self.world, page_path='/about/')
|
||||||
|
for key, label in PeopleMap.BACKGROUND_CHOICES:
|
||||||
|
self.world.background_style = key
|
||||||
|
self.world.full_clean()
|
||||||
|
self.world.save()
|
||||||
|
self.assertTrue(finders.find(self.world.background_static_path), label)
|
||||||
|
self.assertIn(self.world.background_static_path, self.render_maps('/about/'))
|
||||||
|
|
||||||
|
def test_uploaded_background_overrides_preset(self):
|
||||||
|
MapPlacement.objects.create(people_map=self.world, page_path='/about/')
|
||||||
|
self.world.background_image = 'maps/custom-world.png'
|
||||||
|
self.world.background_style = 'atlas'
|
||||||
|
self.world.save()
|
||||||
|
html = self.render_maps('/about/')
|
||||||
|
self.assertIn('/media/maps/custom-world.png', html)
|
||||||
|
self.assertNotIn('src="/static/images/maps/world-atlas.svg"', html)
|
||||||
|
|
||||||
|
def test_people_details_can_be_hidden_without_hiding_map_or_leaving_dead_links(self):
|
||||||
|
placement = MapPlacement.objects.create(people_map=self.world, page_path='/about/')
|
||||||
|
member = MapMembership.objects.create(people_map=self.world, person=self.person,
|
||||||
|
location_method='coordinates', latitude=0, longitude=0)
|
||||||
|
target = f'map-{placement.pk}-person-{member.pk}'
|
||||||
|
html = self.render_maps('/about/')
|
||||||
|
self.assertIn('people-map-directory', html)
|
||||||
|
self.assertIn(f'href="#{target}"', html)
|
||||||
|
self.world.show_people_details = False
|
||||||
|
self.world.save()
|
||||||
|
html = self.render_maps('/about/')
|
||||||
|
self.assertNotIn('people-map-directory', html)
|
||||||
|
self.assertNotIn(target, html)
|
||||||
|
self.assertNotIn('Institute', html)
|
||||||
|
self.assertIn('people-map-pin', html)
|
||||||
|
self.assertIn('data-lat=', html)
|
||||||
|
self.assertIn('<script>Test</script>', html)
|
||||||
|
|
||||||
|
def test_canonical_city_name_takes_priority_over_another_towns_alias(self):
|
||||||
|
lat, lon = resolve_city('IR', 'Tehran')
|
||||||
|
self.assertAlmostEqual(lat, 35.69, places=1)
|
||||||
|
self.assertAlmostEqual(lon, 51.42, places=1)
|
||||||
|
|
||||||
|
def test_city_alias_remains_supported(self):
|
||||||
|
self.assertEqual(resolve_city('GB', 'Londres'), resolve_city('GB', 'London'))
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# People maps
|
||||||
|
|
||||||
|
People maps are independent of login accounts. One person can appear on several
|
||||||
|
maps, with a different location on each map. Existing HTML sections are untouched.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Install `requirements.txt`, run `python manage.py migrate`, and collect static files
|
||||||
|
using your normal deployment settings. Migrations 0029–0031 are generated by Django. They create the map tables, add
|
||||||
|
the built-in background selector and add the optional people directory setting.
|
||||||
|
They do not change existing pages or import live content.
|
||||||
|
No production database changes were made during implementation.
|
||||||
|
|
||||||
|
## Admin workflow
|
||||||
|
|
||||||
|
1. Under **Pages → Map people**, create people with a name, photo, qualifications,
|
||||||
|
role, institution, biography and optional profile URL.
|
||||||
|
2. Under **Pages → People maps**, create a map. Choose a built-in background using **Background style**: Classic silhouette, Atlas with country borders, Midnight dotted continents, or Ocean gradient. Thumbnail previews appear below the selector. All four SVG maps are bundled in static files and ship to the server through `collectstatic`; no upload or external map service is required. Choose Aurora (dark), Atlas (light),
|
||||||
|
or Classic (the original black / blue / turquoise palette). Classic reproduces
|
||||||
|
the palette and portrait treatment, rather than the exact hand-drawn silhouette
|
||||||
|
in the reference screenshot. Optionally upload your own world artwork; its
|
||||||
|
projection must be equirectangular (longitude -180…180, latitude 90…-90).
|
||||||
|
3. Add memberships on the map edit screen. Select an existing person using the
|
||||||
|
searchable selector, or create one with the adjacent add button.
|
||||||
|
4. Choose **Country and city** or **Coordinates** for each membership. City mode
|
||||||
|
accepts an English country name or ISO code and a recognized city name or alias.
|
||||||
|
Coordinates are resolved and stored on save. The local GeoNames dataset covers
|
||||||
|
cities of at least 15,000 inhabitants. Canonical city names take priority over another town's aliases. Unknown or ambiguous cities require the
|
||||||
|
coordinates method; there is no silent guess. Coordinates mode accepts decimal
|
||||||
|
latitude (-90…90) and longitude (-180…180), including zero and negative values.
|
||||||
|
5. In the map's **Map placements** inline, enter the exact local page path, such as
|
||||||
|
`/about/`, `/`, `/projects/example/`, `/projects/example/module/`, or a custom
|
||||||
|
page's path. Select before or after content, display order, and active status.
|
||||||
|
Add several placements to reuse the same map. Separate maps can have different
|
||||||
|
members. Placements can also be managed under **Pages → Map placements**.
|
||||||
|
|
||||||
|
Paths are exact and case-sensitive, including trailing slashes. Update a placement
|
||||||
|
when changing a page's URL. Page publication and project access rules still apply:
|
||||||
|
this feature adds no new public page routes. Disabling a map hides all its placements;
|
||||||
|
disabling one placement hides only that occurrence. To replace an existing HTML
|
||||||
|
map, first configure the new map and placement, then separately disable the old
|
||||||
|
HTML section when ready. No existing section is automatically replaced.
|
||||||
|
|
||||||
|
Admins can turn **Show people details below the map** off on each map to hide the directory cards. It is on by default. Portraits remain visible on the map; when details are hidden, portraits do not link to hidden cards.
|
||||||
|
|
||||||
|
## Display behavior
|
||||||
|
|
||||||
|
The map uses local SVG artwork, CSS and JavaScript, without remote map tiles,
|
||||||
|
tracking, API keys or geocoding requests. Portraits and labels are placed in the
|
||||||
|
nearest available rectangle on deterministic expanding rings. Leaders connect each
|
||||||
|
displaced portrait to the actual geographic anchor. If the map becomes very dense,
|
||||||
|
its canvas grows vertically to preserve non-overlap. Minimum canvas width is 900px;
|
||||||
|
phones scroll the map horizontally. Every person also has a readable directory card
|
||||||
|
below the map, including without JavaScript. Selecting a portrait jumps to its card.
|
||||||
|
Different placements generate unique anchor IDs.
|
||||||
|
|
||||||
|
Natural Earth land data is public domain:
|
||||||
|
https://www.naturalearthdata.com/about/terms-of-use/
|
||||||
|
City resolution uses the `geonamescache` package and its GeoNames data:
|
||||||
|
https://www.geonames.org/ (CC BY).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```sh
|
||||||
|
venv/bin/python manage.py test apps.pages.tests apps.products.tests --settings=config.settings.preview --noinput
|
||||||
|
venv/bin/python manage.py makemigrations --check --dry-run --settings=config.settings.preview
|
||||||
|
venv/bin/python scripts/verify_people_map.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The optional browser verification needs Playwright and installed Google Chrome.
|
||||||
|
It uses synthetic preview data, renders the actual template and assets without
|
||||||
|
writing database records, tests 14 people and 60 people at the same location at
|
||||||
|
desktop and phone widths, checks bounding boxes for collisions, tests portrait
|
||||||
|
navigation and page overflow, and saves screenshots to `artifacts/people-map/`.
|
||||||
@@ -5,3 +5,4 @@ whitenoise[brotli]>=6.7.0
|
|||||||
Pillow>=10.4.0
|
Pillow>=10.4.0
|
||||||
python-dotenv>=1.0.1
|
python-dotenv>=1.0.1
|
||||||
markdown>=3.6
|
markdown>=3.6
|
||||||
|
geonamescache>=3.0.2,<4.0
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace as Obj
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
os.environ['DJANGO_SETTINGS_MODULE']='config.settings.preview'
|
||||||
|
import django
|
||||||
|
django.setup()
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
root=Path(__file__).resolve().parent.parent
|
||||||
|
def preview(count=14):
|
||||||
|
places=[(49.28,-123.12,'Vancouver','Canada'),(49.28,-123.12,'Vancouver','Canada'),(49.28,-123.12,'Vancouver','Canada'),(40.71,-74,'New York','United States'),(42.37,-72.52,'Amherst','United States'),(38.54,-121.74,'Davis','United States'),(46.94,7.45,'Bern','Switzerland'),(35.69,51.39,'Tehran','Iran'),(35.69,51.39,'Tehran','Iran'),(23.58,58.4,'Muscat','Oman'),(37.28,49.58,'Rasht','Iran'),(23.13,113.26,'Guangzhou','China'),(-33.86,151.2,'Sydney','Australia'),(3.14,101.68,'Kuala Lumpur','Malaysia')]
|
||||||
|
names=['Arman Rahmim','Mohammad R. Salmanpour','Steven Lewis','Nicolas Karakatsanis','Joyita Dutta','Emilie Roncali','Isaac Shiri','Ali Fathi Jouzdani','Arman Gorji','Yassine Bouchareb','Shahram Taeb','Lijun Lu','Collaborator in Sydney','Collaborator in Kuala Lumpur']
|
||||||
|
members=[]
|
||||||
|
for i in range(count):
|
||||||
|
lat,lon,city,country=places[i%len(places)] if count<20 else (49.28,-123.12,'Vancouver','Canada')
|
||||||
|
person=Obj(name=names[i%len(names)],qualifications='PhD',role='Collaborator',institution='Research Institute',biography='',photo=None,profile_url='')
|
||||||
|
members.append(Obj(pk=i+1,person=person,latitude=lat,longitude=lon,city=city,country=country))
|
||||||
|
world=Obj(title='Care knows no borders.',description='A connected community bringing computation closer to care, across disciplines and across the world.',show_people_details=True,style='aurora',background_style='classic',background_static_path='images/world-map.svg',background_image=None,memberships=Obj(all=members))
|
||||||
|
html=render_to_string('partials/_people_maps.html',{'placements':[Obj(pk=1,people_map=world)]})
|
||||||
|
html=html.replace('/static/images/world-map.svg', (root/'static/images/world-map.svg').as_uri())
|
||||||
|
return '<!doctype html><meta charset="utf-8"><style>body{margin:0;background:#070c16;padding:32px;font-family:Arial}.container{max-width:1280px;margin:auto}*{box-sizing:border-box}'+(root/'static/css/people-map.css').read_text()+'</style>'+html+'<script>'+(root/'static/js/people-map.js').read_text()+'</script>'
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser=p.chromium.launch(channel='chrome')
|
||||||
|
page=browser.new_page(viewport={'width':1440,'height':1100})
|
||||||
|
errors=[]
|
||||||
|
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||||
|
for count in [14,60]:
|
||||||
|
path=Path('/tmp/com2care-map-preview.html'); path.write_text(preview(count))
|
||||||
|
page.goto(path.as_uri());page.wait_for_timeout(500)
|
||||||
|
def verify():
|
||||||
|
return page.evaluate('''() => {const pins=[...document.querySelectorAll('.people-map-pin')].map(p=>p.getBoundingClientRect());return pins.flatMap((a,i)=>pins.slice(i+1).filter(b=>a.left<b.right&&a.right>b.left&&a.top<b.bottom&&a.bottom>b.top)).length}''')
|
||||||
|
assert verify()==0, f'overlap {count}'
|
||||||
|
if count==14: page.screenshot(path=str(root/'artifacts/people-map/desktop.png'),full_page=True)
|
||||||
|
page.set_viewport_size({'width':390,'height':844});page.wait_for_timeout(300)
|
||||||
|
assert verify()==0, f'mobile overlap {count}'
|
||||||
|
assert page.evaluate('document.documentElement.scrollWidth <= window.innerWidth'), 'page overflow'
|
||||||
|
page.locator('.people-map-pin').first.click()
|
||||||
|
assert page.evaluate('location.hash')=='#map-1-person-1'
|
||||||
|
if count==14:page.screenshot(path=str(root/'artifacts/people-map/mobile.png'),full_page=True)
|
||||||
|
page.set_viewport_size({'width':1440,'height':1100})
|
||||||
|
print(f'{count} pins: desktop/mobile no collisions; anchor navigation passes')
|
||||||
|
assert not errors, errors
|
||||||
|
browser.close()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
.people-map{--map-ocean:#0c182a;--map-land:#7697bc;--map-accent:#54ded0;--map-ink:#edf5ff;background:var(--map-ocean);color:var(--map-ink);border:1px solid #7e9bb333;border-radius:24px;overflow:hidden;box-shadow:0 20px 60px #0002}
|
||||||
|
.people-map--paper{--map-ocean:#f2f6f6;--map-land:#769a9a;--map-accent:#08786d;--map-ink:#153c42}
|
||||||
|
.people-map--classic{--map-ocean:#000;--map-land:#8197c6;--map-accent:#24c6bb;--map-ink:#fff;border-radius:0}
|
||||||
|
.people-map-heading{padding:36px 36px 12px}.people-map-heading h2{color:var(--map-ink);margin:10px 0;font-size:clamp(1.6rem,3vw,2.5rem)}.people-map-heading p{color:var(--map-ink);opacity:.75;max-width:65ch}.people-map-eyebrow{color:var(--map-accent);font-size:.7rem;letter-spacing:.16em;font-weight:700}
|
||||||
|
.people-map-scroll{overflow-x:auto;padding:12px 0;scrollbar-color:var(--map-accent) var(--map-ocean)}.people-map-scroll:focus-visible{outline:2px solid var(--map-accent);outline-offset:-3px}
|
||||||
|
.people-map-canvas{position:relative;min-width:900px;min-height:450px;background:radial-gradient(ellipse at 45% 40%,#5fa9bb15,transparent 65%)}
|
||||||
|
.people-map-land{position:absolute;top:0;left:0;width:100%;height:auto;aspect-ratio:2/1;object-fit:fill;opacity:.7}.people-map--classic .people-map-land{opacity:1}.people-map-lines{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;color:var(--map-accent)}.people-map-lines line{stroke:currentColor;stroke-width:1;opacity:.55}.people-map-lines circle{fill:currentColor}
|
||||||
|
.people-map-pin{visibility:hidden;position:absolute;width:112px;height:98px;display:flex;align-items:center;flex-direction:column;text-align:center;color:var(--map-ink)!important;text-decoration:none!important;font:600 11px/1.25 Inter,Arial,sans-serif;z-index:1}.people-map-pin img,.people-map-initial{width:56px;height:56px;flex-shrink:0;border-radius:50%;object-fit:cover;border:3px solid var(--map-accent);background:var(--map-ocean);box-shadow:0 4px 16px #0005}.people-map-initial{display:grid;place-items:center;font-size:24px}.people-map-label{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;max-width:112px;margin-top:5px;padding:2px 4px;border-radius:4px;background:var(--map-ocean)}.people-map-pin:focus-visible{outline:2px solid var(--map-accent);border-radius:8px}.people-map-pin:hover img{box-shadow:0 0 18px var(--map-accent)}
|
||||||
|
.people-map-directory{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));gap:1px;background:#8095aa33;border-top:1px solid #8095aa33}.people-map-person{background:var(--map-ocean);padding:22px;display:flex;gap:14px;scroll-margin-top:100px}.people-map-person:target{box-shadow:inset 0 0 0 2px var(--map-accent)}.people-map-person>img{width:48px;height:48px;border-radius:50%;object-fit:cover}.people-map-person h3{color:var(--map-ink);font-size:1rem;margin:0 0 6px}.people-map-person p{font-size:.85rem;margin:3px 0;overflow-wrap:anywhere}.people-map-person small{opacity:.65}.people-map-person a{color:var(--map-accent);font-size:.85rem}.people-map--classic .people-map-label{color:var(--map-accent)}
|
||||||
|
|
||||||
|
.people-map--paper .people-map-land{filter:saturate(.35);opacity:.6}
|
||||||
|
|
||||||
|
.people-map .people-map-land--preset{opacity:1;filter:none}
|
||||||
|
|
||||||
|
.people-map-heading,.people-map-person{overflow-wrap:anywhere}
|
||||||
|
.people-map-person>div{min-width:0}
|
||||||
|
.people-map-person>img{flex-shrink:0}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 195 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 77 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 77 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 76 KiB |
@@ -0,0 +1,62 @@
|
|||||||
|
/* Equirectangular coordinates; all layout measurements are in CSS pixels. */
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
function layout(canvas) {
|
||||||
|
const pins = [...canvas.querySelectorAll('[data-lat]')];
|
||||||
|
const width = canvas.clientWidth;
|
||||||
|
const baseHeight = width / 2;
|
||||||
|
const placed = [];
|
||||||
|
const lines = canvas.querySelector('svg');
|
||||||
|
lines.replaceChildren();
|
||||||
|
// A fixed minimum width preserves readable labels on phones. The scroll
|
||||||
|
// container exposes the entire map without shrinking portraits or hiding people.
|
||||||
|
let height = baseHeight;
|
||||||
|
pins.forEach(pin => {
|
||||||
|
const anchor = {
|
||||||
|
x: (Number(pin.dataset.lon) + 180) / 360 * width,
|
||||||
|
y: (90 - Number(pin.dataset.lat)) / 180 * baseHeight,
|
||||||
|
};
|
||||||
|
const w = pin.offsetWidth, h = pin.offsetHeight;
|
||||||
|
let point;
|
||||||
|
// Deterministic expanding rings. Test complete label+portrait rectangles,
|
||||||
|
// and grow the canvas for arbitrarily dense maps instead of accepting overlap.
|
||||||
|
for (let ring = 0; !point; ring++) {
|
||||||
|
const radius = ring * 12;
|
||||||
|
const count = Math.max(1, Math.ceil(2 * Math.PI * radius / 12));
|
||||||
|
for (let step = 0; step < count; step++) {
|
||||||
|
const angle = step / count * 2 * Math.PI;
|
||||||
|
const x = Math.max(w / 2 + 8, Math.min(width - w / 2 - 8, anchor.x + Math.cos(angle) * radius));
|
||||||
|
const y = Math.max(h / 2 + 8, anchor.y + Math.sin(angle) * radius);
|
||||||
|
if (!placed.some(p => Math.abs(p.x - x) < (p.w + w) / 2 + 8 && Math.abs(p.y - y) < (p.h + h) / 2 + 8)) {
|
||||||
|
point = {x, y, w, h}; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
placed.push(point);
|
||||||
|
height = Math.max(height, point.y + h / 2 + 12);
|
||||||
|
pin.style.left = `${point.x - w / 2}px`;
|
||||||
|
pin.style.top = `${point.y - h / 2}px`;
|
||||||
|
pin.style.visibility = "visible";
|
||||||
|
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||||
|
line.setAttribute('x1', anchor.x); line.setAttribute('y1', anchor.y);
|
||||||
|
line.setAttribute('x2', point.x); line.setAttribute('y2', point.y - h / 2 + 28);
|
||||||
|
lines.append(line);
|
||||||
|
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||||
|
dot.setAttribute('cx', anchor.x); dot.setAttribute('cy', anchor.y); dot.setAttribute('r', 3);
|
||||||
|
lines.append(dot);
|
||||||
|
});
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
lines.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||||
|
}
|
||||||
|
const maps = [...document.querySelectorAll('[data-people-map]')];
|
||||||
|
for (const canvas of maps) {
|
||||||
|
layout(canvas);
|
||||||
|
let previousWidth = canvas.clientWidth;
|
||||||
|
new ResizeObserver(() => {
|
||||||
|
if (previousWidth !== canvas.clientWidth) {
|
||||||
|
previousWidth = canvas.clientWidth; layout(canvas);
|
||||||
|
}
|
||||||
|
}).observe(canvas);
|
||||||
|
}
|
||||||
|
document.fonts.ready.then(() => maps.forEach(layout));
|
||||||
|
})();
|
||||||
+5
-1
@@ -1,4 +1,4 @@
|
|||||||
{% load static %}
|
{% load static people_maps %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="{% static 'css/main.css' %}" />
|
<link rel="stylesheet" href="{% static 'css/main.css' %}" />
|
||||||
<style>:root { {{ site_branding.theme_style }} }</style>
|
<style>:root { {{ site_branding.theme_style }} }</style>
|
||||||
|
<link rel="stylesheet" href="{% static 'css/people-map.css' %}" />
|
||||||
{% block extra_css %}{% endblock %}
|
{% block extra_css %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -29,12 +30,15 @@
|
|||||||
{% include "partials/_navbar.html" %}
|
{% include "partials/_navbar.html" %}
|
||||||
|
|
||||||
<main id="main-content">
|
<main id="main-content">
|
||||||
|
{% people_maps 'top' %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
{% people_maps 'bottom' %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{% include "partials/_footer.html" %}
|
{% include "partials/_footer.html" %}
|
||||||
|
|
||||||
<script src="{% static 'js/main.js' %}"></script>
|
<script src="{% static 'js/main.js' %}"></script>
|
||||||
|
<script src="{% static 'js/people-map.js' %}" defer></script>
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% load static %}
|
||||||
|
{% for placement in placements %}
|
||||||
|
<section class="section"><div class="container">
|
||||||
|
<article class="people-map people-map--{{ placement.people_map.style }}">
|
||||||
|
<header class="people-map-heading"><span class="people-map-eyebrow">CONNECTED ACROSS THE WORLD</span><h2>{{ placement.people_map.title }}</h2><p>{{ placement.people_map.description }}</p></header>
|
||||||
|
<div class="people-map-scroll" tabindex="0" aria-label="World map. Scroll horizontally on small screens.">
|
||||||
|
<div class="people-map-canvas" data-people-map>
|
||||||
|
<img class="people-map-land{% if placement.people_map.background_style != 'classic' %} people-map-land--preset{% endif %}" src="{% if placement.people_map.background_image %}{{ placement.people_map.background_image.url }}{% else %}{% static placement.people_map.background_static_path %}{% endif %}" alt="World map" />
|
||||||
|
<svg class="people-map-lines" aria-hidden="true"></svg>
|
||||||
|
{% for member in placement.people_map.memberships.all %}
|
||||||
|
<{% if placement.people_map.show_people_details %}a{% else %}span{% endif %} class="people-map-pin" {% if placement.people_map.show_people_details %}href="#map-{{ placement.pk }}-person-{{ member.pk }}"{% endif %} data-lat="{{ member.latitude|stringformat:'.6f' }}" data-lon="{{ member.longitude|stringformat:'.6f' }}" aria-label="{{ member.person.name }} — {{ member.city }} {{ member.country }}">
|
||||||
|
{% if member.person.photo %}<img src="{{ member.person.photo.url }}" alt="" loading="lazy" />{% else %}<span class="people-map-initial">{{ member.person.name|first }}</span>{% endif %}
|
||||||
|
<span class="people-map-label">{{ member.person.name }}</span>
|
||||||
|
</{% if placement.people_map.show_people_details %}a{% else %}span{% endif %}>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if placement.people_map.show_people_details %}
|
||||||
|
<div class="people-map-directory">
|
||||||
|
{% for member in placement.people_map.memberships.all %}
|
||||||
|
<article class="people-map-person" id="map-{{ placement.pk }}-person-{{ member.pk }}" tabindex="-1">
|
||||||
|
{% if member.person.photo %}<img src="{{ member.person.photo.url }}" alt="{{ member.person.name }}" loading="lazy" />{% endif %}
|
||||||
|
<div><h3>{{ member.person.name }}{% if member.person.qualifications %}, {{ member.person.qualifications }}{% endif %}</h3><p>{{ member.person.role }}</p><p>{{ member.person.institution }}</p><small>{{ member.city }}{% if member.city and member.country %}, {% endif %}{{ member.country }}</small>
|
||||||
|
{% if member.person.biography %}<p>{{ member.person.biography|linebreaksbr }}</p>{% endif %}
|
||||||
|
{% if member.person.profile_url %}<a href="{{ member.person.profile_url }}" rel="noopener noreferrer">View profile ↗</a>{% endif %}</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article></div></section>
|
||||||
|
{% endfor %}
|
||||||
Reference in New Issue
Block a user