Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions manuscript/manuscript_cloud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Data for the manuscript-location word cloud on the Manuscripts page.

Library records only store a city name, so coordinates for each holding city
live here. The cloud uses them only to keep cities in the right compass
order (north/south/east/west); a city that isn't listed still appears in the
panel under "Location not mapped" and is logged so it can be added.
"""

import logging
import re
import unicodedata

from django.urls import reverse

logger = logging.getLogger(__name__)

# key: normalized city name -> (display name, country, lat, lon)
CITY_COORDS = {
"amsterdam": ("Amsterdam", "Netherlands", 52.372, 4.894),
"basel": ("Basel", "Switzerland", 47.559, 7.588),
"bergamo": ("Bergamo", "Italy", 45.698, 9.677),
"bologna": ("Bologna", "Italy", 44.494, 11.343),
"boston": ("Boston", "USA", 42.350, -71.078),
"brussels": ("Brussels", "Belgium", 50.850, 4.352),
"cambridge ma": ("Cambridge, MA", "USA", 42.374, -71.116),
"chicago": ("Chicago", "USA", 41.878, -87.630),
"cortona": ("Cortona", "Italy", 43.275, 11.985),
"florence": ("Florence", "Italy", 43.773, 11.256),
"forli": ("Forlì", "Italy", 44.222, 12.041),
"genoa": ("Genoa", "Italy", 44.405, 8.946),
"gubbio": ("Gubbio", "Italy", 43.352, 12.577),
"helsinki": ("Helsinki", "Finland", 60.170, 24.938),
"ithaca ny": ("Ithaca, NY", "USA", 42.448, -76.484),
"krakow": ("Krakow", "Poland", 50.065, 19.945),
"warsaw": ("Warsaw", "Poland", 52.230, 21.011),
"lawrence ks": ("Lawrence, KS", "USA", 38.958, -95.247),
"london": ("London", "UK", 51.520, -0.127),
"lucca": ("Lucca", "Italy", 43.843, 10.505),
"milan": ("Milan", "Italy", 45.464, 9.190),
"modena": ("Modena", "Italy", 44.647, 10.925),
"naples": ("Naples", "Italy", 40.852, 14.268),
"new haven": ("New Haven", "USA", 41.311, -72.926),
"new york": ("New York", "USA", 40.753, -73.982),
"oxford": ("Oxford", "UK", 51.754, -1.254),
"padua": ("Padua", "Italy", 45.406, 11.877),
"paris": ("Paris", "France", 48.857, 2.352),
"parma": ("Parma", "Italy", 44.801, 10.328),
"pavia": ("Pavia", "Italy", 45.185, 9.159),
"perugia": ("Perugia", "Italy", 43.112, 12.389),
"poppi": ("Poppi", "Italy", 43.721, 11.763),
"pesaro": ("Pesaro", "Italy", 43.910, 12.913),
"prato": ("Prato", "Italy", 43.878, 11.102),
"ravenna": ("Ravenna", "Italy", 44.418, 12.204),
"rome": ("Rome", "Italy", 41.900, 12.483),
"siena": ("Siena", "Italy", 43.318, 11.331),
"tenri": ("Tenri, Japan", "Japan", 34.597, 135.837),
"tenri japan": ("Tenri, Japan", "Japan", 34.597, 135.837),
"treviso": ("Treviso", "Italy", 45.666, 12.245),
"turin": ("Turin", "Italy", 45.070, 7.687),
"vaduz": ("Vaduz", "Liechtenstein", 47.141, 9.521),
"vatican city": ("Vatican City", "Vatican City", 41.904, 12.454),
"venice": ("Venice", "Italy", 45.434, 12.338),
"vicenza": ("Vicenza", "Italy", 45.546, 11.535),
"vienna": ("Vienna", "Austria", 48.206, 16.367),
"volterra": ("Volterra", "Italy", 43.402, 10.861),
"washington dc": ("Washington DC", "USA", 38.889, -77.005),
}

# City values that mean "we don't know where it is"
UNLOCATED = {"", "unknown", "unverified", "irrelevant"}


def normalize_city(name):
"""'Cambridge, MA' / 'Cambridge MA' / 'Forlì' -> 'cambridge ma' / 'forli'"""
name = unicodedata.normalize("NFKD", name or "")
name = "".join(c for c in name if not unicodedata.combining(c))
name = re.sub(r"[^\w\s]", " ", name.lower())
return re.sub(r"\s+", " ", name).strip()


def build_cloud_places(manuscripts, library_id_map):
"""Group manuscripts by holding city, then by library.

`manuscripts` and `library_id_map` are the structures already built in
`views.manuscripts`. Returns a list of dicts ready for `json_script`.
"""
places = {}
for ms in manuscripts:
if not ms.get("siglum"):
continue
library = library_id_map.get(ms.get("library")) or {}
key = normalize_city(library.get("city"))
coords = CITY_COORDS.get(key)
if coords:
label, country, lat, lon = coords
else:
if key not in UNLOCATED:
logger.info(
"Manuscript cloud: no coordinates for city %r", library.get("city")
)
label, country, lat, lon = "Location not mapped", "", None, None
place = places.setdefault(
label,
{"city": label, "country": country, "lat": lat, "lon": lon, "inst": {}},
)
inst_name = library.get("library") or "Unknown library"
place["inst"].setdefault(inst_name, []).append(
[
ms.get("shelfmark") or "no shelfmark",
ms["siglum"],
reverse("manuscript", args=[ms["siglum"]]),
]
)

return [
{**p, "inst": [{"name": k, "mss": v} for k, v in p["inst"].items()]}
for p in places.values()
]
2 changes: 2 additions & 0 deletions manuscript/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from rest_framework import viewsets

from gallery.models import GalleryIndexPage
from manuscript.manuscript_cloud import build_cloud_places
from manuscript.models import (
Library,
Location,
Expand Down Expand Up @@ -599,6 +600,7 @@ def manuscripts(request: HttpRequest):
"manuscripts": sorted(manuscripts, key=lambda m: m["shelfmark_fmt"]),
"sigla_index": sorted(sigla, key=lambda s: s["siglum"]),
"snippet": ManuscriptsIntroduction.objects.first(),
"cloud_places": build_cloud_places(manuscripts, library_id_map),
},
)

Expand Down
77 changes: 77 additions & 0 deletions static/css/manuscript-cloud.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/* Manuscript word cloud (templates/partials/manuscript_cloud.html).
Everything is scoped under .ms-cloud so it can't affect the rest of the site. */
.ms-cloud {
--msc-bg: #eef1f4;
--msc-ink: #1c2631;
--msc-muted: #5a6876;
--msc-line: #d9e0e6;
--msc-hover: #e2e8ee;
--msc-italy: #b91c1c; /* lasfera-red */
--msc-italy-soft: #fbe5e5;
--msc-blue: #2f5d8a;
--msc-gold: #9a6a12;
--msc-font: "Barlow Semi Condensed", "Arial Narrow", "Helvetica Neue", Arial, sans-serif;
--msc-mono: ui-monospace, Menlo, Consolas, monospace;
margin: 0 0 2.5rem;
color: var(--msc-ink);
}
.ms-cloud__summary { color: var(--msc-muted); margin: 0 0 0.75rem; }

/* 16:9 landscape frame, kept within the window height; scrolls sideways on phones */
.ms-cloud__stage { overflow-x: auto; border-radius: 8px; }
.ms-cloud__frame {
position: relative; width: 100%; min-width: 720px; aspect-ratio: 16 / 9;
max-height: calc(100vh - 120px); min-height: 405px;
background: var(--msc-bg); border-radius: 8px; overflow: hidden;
}
.ms-cloud__frame svg { display: block; width: 100%; height: 100%; }

.ms-word { cursor: pointer; outline: none; }
.ms-word text { font-family: var(--msc-font); font-weight: 600; fill: var(--msc-c); }
.ms-word__hl { fill: transparent; }
.ms-word:hover .ms-word__hl, .ms-word:focus-visible .ms-word__hl { fill: var(--msc-hover); }
.ms-word:focus-visible .ms-word__hl { stroke: var(--msc-ink); stroke-width: 1.5; }
.ms-word.is-selected .ms-word__hl { fill: var(--msc-italy-soft); }
.ms-word--it { --msc-c: var(--msc-italy); }
.ms-word--eu { --msc-c: var(--msc-ink); }
.ms-word--na { --msc-c: var(--msc-blue); }
.ms-word--jp { --msc-c: var(--msc-gold); }

.ms-cloud__legend {
display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: center;
font-size: 0.8rem; color: var(--msc-muted); margin: 0.5rem 0 1.25rem;
}
.ms-cloud__legend i {
display: inline-block; width: 10px; height: 10px; border-radius: 2px;
margin-right: 4px; vertical-align: -1px;
}

.ms-cloud__detail { border-top: 1px solid var(--msc-line); padding-top: 1rem; }
.ms-cloud__eyebrow {
font: 500 0.7rem var(--msc-mono); letter-spacing: 0.08em; text-transform: uppercase;
color: var(--msc-muted); margin: 0;
}
.ms-cloud__place { font-size: 1.6rem; font-weight: 700; line-height: 1.2; margin: 0.1rem 0; }
.ms-cloud__meta { color: var(--msc-muted); margin: 0 0 1rem; }
.ms-cloud__libs {
display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr));
gap: 0.5rem 2rem; align-items: start;
}
.ms-cloud__lib h6 {
font-size: 1rem; line-height: 1.3;
display: flex; justify-content: space-between; gap: 12px; font-weight: 600;
border-bottom: 1px solid var(--msc-line); padding-bottom: 4px; margin: 0 0 4px;
}
.ms-cloud__lib h6 span { font: 0.75rem var(--msc-mono); color: var(--msc-muted); padding-top: 3px; }
.ms-cloud__lib ul { list-style: none; margin: 0 0 1rem; padding: 0; }
.ms-cloud__lib a {
display: grid; grid-template-columns: 70px minmax(0, 1fr); gap: 8px; align-items: baseline;
padding: 3px 6px; border-radius: 4px; text-decoration: none; color: var(--msc-ink);
}
.ms-cloud__lib a:hover { background: var(--msc-hover); text-decoration: underline; }
.ms-cloud__sig { font: 500 0.8rem var(--msc-mono); color: var(--msc-italy); }
.ms-cloud__unmapped {
margin-top: 0.5rem; font-size: 0.85rem; color: var(--msc-muted);
text-decoration: underline; background: none; border: 0; cursor: pointer; padding: 0;
}
.ms-cloud button:focus-visible, .ms-cloud a:focus-visible { outline: 2px solid var(--msc-italy); outline-offset: 2px; }
Loading
Loading