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
+110
View File
@@ -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}'