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:
mohamad
2026-11-03 04:41:30 +08:00
parent 6fd236f518
commit d4ec85add4
22 changed files with 725 additions and 4 deletions
+4 -2
View File
@@ -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/
+53
View File
@@ -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
View File
@@ -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.",
}
+35
View File
@@ -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'),
),
]
+110
View File
@@ -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}'
View File
+18
View File
@@ -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]}
+141
View File
@@ -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('&lt;script&gt;', 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('&lt;script&gt;Test&lt;/script&gt;', 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'))
+74
View File
@@ -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/`.
+1
View File
@@ -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
+45
View File
@@ -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()
+17
View File
@@ -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

+62
View File
@@ -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
View File
@@ -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>
+31
View File
@@ -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 %}