diff --git a/.gitignore b/.gitignore index b902e5a..821b926 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,8 @@ private_uploads/ staticfiles/ *.DS_Store test_media_releases/ -docs/ -scripts/ +docs/* +!docs/people-maps.md +scripts/* +!scripts/verify_people_map.py videos/ diff --git a/apps/pages/admin.py b/apps/pages/admin.py index 7d4c801..81fd674 100644 --- a/apps/pages/admin.py +++ b/apps/pages/admin.py @@ -1,11 +1,14 @@ from django.contrib import admin from django.http import FileResponse, Http404, HttpResponseRedirect 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 apps.core.admin_video import video_admin_preview from apps.core.video import VIDEO_ADMIN_FIELDSET +from .forms import MapMembershipAdminForm from .models import ( AboutSection, AboutSectionItem, @@ -20,6 +23,10 @@ from .models import ( HomepageSection, HomepageSectionItem, PageVideo, + PeopleMap, + MapPerson, + MapMembership, + MapPlacement, ) @@ -319,3 +326,49 @@ class PageVideoAdmin(admin.ModelAdmin): @admin.display(description="Preview") def video_preview(self, 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('', + '
' + '{}' + '
{}
', + ((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'] diff --git a/apps/pages/forms.py b/apps/pages/forms.py index 03bb005..a007bdb 100644 --- a/apps/pages/forms.py +++ b/apps/pages/forms.py @@ -2,7 +2,7 @@ from django import forms from django.core.exceptions import ValidationError from .contact_uploads import validate_contact_attachments -from .models import ContactSubmission +from .models import ContactSubmission, MapMembership class ContactForm(forms.ModelForm): @@ -39,3 +39,16 @@ class ContactForm(forms.ModelForm): self.add_error("attachments", exc) cleaned_data["attachments"] = [] 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.", + } diff --git a/apps/pages/maps.py b/apps/pages/maps.py new file mode 100644 index 0000000..154749b --- /dev/null +++ b/apps/pages/maps.py @@ -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'] diff --git a/apps/pages/migrations/0029_mapperson_peoplemap_mapplacement_mapmembership.py b/apps/pages/migrations/0029_mapperson_peoplemap_mapplacement_mapmembership.py new file mode 100644 index 0000000..3733b66 --- /dev/null +++ b/apps/pages/migrations/0029_mapperson_peoplemap_mapplacement_mapmembership.py @@ -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')], + }, + ), + ] diff --git a/apps/pages/migrations/0030_peoplemap_background_style.py b/apps/pages/migrations/0030_peoplemap_background_style.py new file mode 100644 index 0000000..13ff3e4 --- /dev/null +++ b/apps/pages/migrations/0030_peoplemap_background_style.py @@ -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), + ), + ] diff --git a/apps/pages/migrations/0031_peoplemap_show_people_details.py b/apps/pages/migrations/0031_peoplemap_show_people_details.py new file mode 100644 index 0000000..6c694e3 --- /dev/null +++ b/apps/pages/migrations/0031_peoplemap_show_people_details.py @@ -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'), + ), + ] diff --git a/apps/pages/models.py b/apps/pages/models.py index 4f4bcdd..13f3980 100644 --- a/apps/pages/models.py +++ b/apps/pages/models.py @@ -581,3 +581,113 @@ class CustomPageSectionItem(models.Model): def __str__(self): 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}' diff --git a/apps/pages/templatetags/__init__.py b/apps/pages/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/pages/templatetags/people_maps.py b/apps/pages/templatetags/people_maps.py new file mode 100644 index 0000000..0243ee8 --- /dev/null +++ b/apps/pages/templatetags/people_maps.py @@ -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]} diff --git a/apps/pages/tests/test_maps.py b/apps/pages/tests/test_maps.py new file mode 100644 index 0000000..4e91f27 --- /dev/null +++ b/apps/pages/tests/test_maps.py @@ -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='', 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('' +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.leftb.left&&a.topb.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() diff --git a/static/css/people-map.css b/static/css/people-map.css new file mode 100644 index 0000000..575326a --- /dev/null +++ b/static/css/people-map.css @@ -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} diff --git a/static/images/maps/world-atlas.svg b/static/images/maps/world-atlas.svg new file mode 100644 index 0000000..8cf2f77 --- /dev/null +++ b/static/images/maps/world-atlas.svg @@ -0,0 +1 @@ +World atlas with country boundaries \ No newline at end of file diff --git a/static/images/maps/world-midnight.svg b/static/images/maps/world-midnight.svg new file mode 100644 index 0000000..be7886d --- /dev/null +++ b/static/images/maps/world-midnight.svg @@ -0,0 +1 @@ +Midnight dotted world \ No newline at end of file diff --git a/static/images/maps/world-ocean.svg b/static/images/maps/world-ocean.svg new file mode 100644 index 0000000..d0ed362 --- /dev/null +++ b/static/images/maps/world-ocean.svg @@ -0,0 +1 @@ +Ocean gradient world with meridians \ No newline at end of file diff --git a/static/images/world-map.svg b/static/images/world-map.svg new file mode 100644 index 0000000..d60cee6 --- /dev/null +++ b/static/images/world-map.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/js/people-map.js b/static/js/people-map.js new file mode 100644 index 0000000..6535470 --- /dev/null +++ b/static/js/people-map.js @@ -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)); +})(); diff --git a/templates/base.html b/templates/base.html index edab20b..11237f7 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,4 +1,4 @@ -{% load static %} +{% load static people_maps %} @@ -22,6 +22,7 @@ + {% block extra_css %}{% endblock %} @@ -29,12 +30,15 @@ {% include "partials/_navbar.html" %}
+ {% people_maps 'top' %} {% block content %}{% endblock %} + {% people_maps 'bottom' %}
{% include "partials/_footer.html" %} + {% block extra_js %}{% endblock %} diff --git a/templates/partials/_people_maps.html b/templates/partials/_people_maps.html new file mode 100644 index 0000000..534d864 --- /dev/null +++ b/templates/partials/_people_maps.html @@ -0,0 +1,31 @@ +{% load static %} +{% for placement in placements %} +
+
+
CONNECTED ACROSS THE WORLD

{{ placement.people_map.title }}

{{ placement.people_map.description }}

+
+
+ World map + + {% 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 %}{% else %}{{ member.person.name|first }}{% endif %} + {{ member.person.name }} + + {% endfor %} +
+
+ {% if placement.people_map.show_people_details %} +
+ {% for member in placement.people_map.memberships.all %} +
+ {% if member.person.photo %}{{ member.person.name }}{% endif %} +

{{ member.person.name }}{% if member.person.qualifications %}, {{ member.person.qualifications }}{% endif %}

{{ member.person.role }}

{{ member.person.institution }}

{{ member.city }}{% if member.city and member.country %}, {% endif %}{{ member.country }} + {% if member.person.biography %}

{{ member.person.biography|linebreaksbr }}

{% endif %} + {% if member.person.profile_url %}View profile ↗{% endif %}
+
+ {% endfor %} +
+ {% endif %} +
+{% endfor %}