From 316018acf1471059ca357243a8504db2f28aab12 Mon Sep 17 00:00:00 2001 From: lmorreale3 <82850864+lmorreale3@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:17:55 -0400 Subject: [PATCH 1/8] Add files via upload --- manuscript/manuscript_cloud.py | 114 +++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 manuscript/manuscript_cloud.py diff --git a/manuscript/manuscript_cloud.py b/manuscript/manuscript_cloud.py new file mode 100644 index 0000000..90009bd --- /dev/null +++ b/manuscript/manuscript_cloud.py @@ -0,0 +1,114 @@ +"""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), + "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), + "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() + ] From 1fdbee6f134b6796e9959559ca5aff669dc62fae Mon Sep 17 00:00:00 2001 From: lmorreale3 <82850864+lmorreale3@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:20:31 -0400 Subject: [PATCH 2/8] Add files via upload --- manuscript/manuscript-cloud.css | 77 ++++++++++++++++++++++++++++++++ manuscript/manuscript_cloud.html | 25 +++++++++++ 2 files changed, 102 insertions(+) create mode 100644 manuscript/manuscript-cloud.css create mode 100644 manuscript/manuscript_cloud.html diff --git a/manuscript/manuscript-cloud.css b/manuscript/manuscript-cloud.css new file mode 100644 index 0000000..a374066 --- /dev/null +++ b/manuscript/manuscript-cloud.css @@ -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; } diff --git a/manuscript/manuscript_cloud.html b/manuscript/manuscript_cloud.html new file mode 100644 index 0000000..1fc8d96 --- /dev/null +++ b/manuscript/manuscript_cloud.html @@ -0,0 +1,25 @@ +{% comment %} + Word cloud of manuscript holding cities. Needs `cloud_places` in context + (see manuscript/manuscript_cloud.py), plus static/css/manuscript-cloud.css, + d3 and static/js/manuscript-cloud.js loaded by the page. +{% endcomment %} +{% if cloud_places %} +
+

Where the manuscripts are

+

+
+
+ +
+
+
+ Larger name = more manuscripts. Names keep their compass order (north, south, east, west), not true distances. + Italy & Vatican + Rest of Europe + North America + Japan +
+
+
+{{ cloud_places|json_script:"ms-cloud-data" }} +{% endif %} From ace53aa8898597e1bf44d523ac8ee2d327ed98b4 Mon Sep 17 00:00:00 2001 From: lmorreale3 <82850864+lmorreale3@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:22:47 -0400 Subject: [PATCH 3/8] Implement manuscript cloud visualization Add manuscript word cloud visualization for cities --- static/js/manuscript-cloud.js | 216 ++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 static/js/manuscript-cloud.js diff --git a/static/js/manuscript-cloud.js b/static/js/manuscript-cloud.js new file mode 100644 index 0000000..68bb7fc --- /dev/null +++ b/static/js/manuscript-cloud.js @@ -0,0 +1,216 @@ +/* + * Manuscript word cloud for the Manuscripts page. + * + * Each holding city's name is sized by how many manuscripts it holds and + * placed so that cities keep their compass relation to one another + * (Florence above Rome, Washington to the west of both, Tenri to the east). + * Distances are compressed, not true. Clicking a name lists that city's + * manuscripts by library below the cloud. + * + * Expects: d3 v7 loaded globally, a + + {% endblock scripts %} From 1a88bddcbb7e43da9988ccb73a855f623d05797e Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Wed, 28 Oct 2026 14:17:33 -0400 Subject: [PATCH 6/8] Move files to the expected directories --- {manuscript => static/css}/manuscript-cloud.css | 0 {manuscript => templates/partials}/manuscript_cloud.html | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename {manuscript => static/css}/manuscript-cloud.css (100%) rename {manuscript => templates/partials}/manuscript_cloud.html (100%) diff --git a/manuscript/manuscript-cloud.css b/static/css/manuscript-cloud.css similarity index 100% rename from manuscript/manuscript-cloud.css rename to static/css/manuscript-cloud.css diff --git a/manuscript/manuscript_cloud.html b/templates/partials/manuscript_cloud.html similarity index 100% rename from manuscript/manuscript_cloud.html rename to templates/partials/manuscript_cloud.html From 0de54b38cbb20107a43d2d10f5ee7fa14a65aa92 Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Wed, 28 Oct 2026 14:19:15 -0400 Subject: [PATCH 7/8] Fix indentation errors --- manuscript/manuscript_cloud.py | 4 +++- manuscript/views.py | 4 ++-- templates/manuscripts.html | 19 ++++++++++--------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/manuscript/manuscript_cloud.py b/manuscript/manuscript_cloud.py index 90009bd..f129357 100644 --- a/manuscript/manuscript_cloud.py +++ b/manuscript/manuscript_cloud.py @@ -93,7 +93,9 @@ def build_cloud_places(manuscripts, library_id_map): label, country, lat, lon = coords else: if key not in UNLOCATED: - logger.info("Manuscript cloud: no coordinates for city %r", library.get("city")) + 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, diff --git a/manuscript/views.py b/manuscript/views.py index 371f0a7..0196010 100644 --- a/manuscript/views.py +++ b/manuscript/views.py @@ -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, @@ -29,7 +30,6 @@ StanzaTranslated, line_code_to_numeric, ) - from manuscript.manuscript_cloud import build_cloud_places from manuscript.serializers import SingleManuscriptSerializer, ToponymSerializer from pages.models import AboutPage, ManuscriptsIntroduction, SitePage from textannotation.models import CrossReference, EditorialNote, TextualVariant @@ -600,7 +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), + "cloud_places": build_cloud_places(manuscripts, library_id_map), }, ) diff --git a/templates/manuscripts.html b/templates/manuscripts.html index e21775c..2795f66 100644 --- a/templates/manuscripts.html +++ b/templates/manuscripts.html @@ -26,7 +26,8 @@

{% endif %} - {% include "partials/manuscript_cloud.html" %} + {% include "partials/manuscript_cloud.html" %} +
Filter: @@ -72,13 +73,13 @@

Index of Manuscripts

{% endblock content %} - {% block extra_css %} - - - {% endblock extra_css %} +{% block extra_css %} + + +{% endblock extra_css %} - {% block scripts %} - - - {% endblock scripts %} +{% block scripts %} + + +{% endblock scripts %} From 90ca0157d9af915c58b96fa566537d70c3ff604a Mon Sep 17 00:00:00 2001 From: Ben Silverman Date: Wed, 28 Oct 2026 14:19:58 -0400 Subject: [PATCH 8/8] Fix bugs: missing cities, count bug, double-start --- manuscript/manuscript_cloud.py | 2 ++ static/js/manuscript-cloud.js | 10 +++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/manuscript/manuscript_cloud.py b/manuscript/manuscript_cloud.py index f129357..88ce8b8 100644 --- a/manuscript/manuscript_cloud.py +++ b/manuscript/manuscript_cloud.py @@ -47,6 +47,7 @@ "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), @@ -56,6 +57,7 @@ "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), diff --git a/static/js/manuscript-cloud.js b/static/js/manuscript-cloud.js index 68bb7fc..1b405d9 100644 --- a/static/js/manuscript-cloud.js +++ b/static/js/manuscript-cloud.js @@ -31,12 +31,13 @@ p.count = p.inst.reduce((s, x) => s + x.mss.length, 0); }); const located = PLACES.filter((p) => p.lat != null); - const MAXC = Math.max(2, d3.max(PLACES, (p) => p.count)); + // Size only by the cities on the cloud; the "not mapped" group isn't drawn + const MAXC = Math.max(2, d3.max(located, (p) => p.count) || 0); let selected = located.slice().sort((a, b) => b.count - a.count)[0] || PLACES[0]; const summary = root.querySelector(".ms-cloud__summary"); if (summary) { - const total = PLACES.reduce((s, p) => s + p.count, 0); + const total = located.reduce((s, p) => s + p.count, 0); summary.textContent = `${total} manuscripts in ${located.length} cities. Select a city to list its manuscripts.`; } @@ -200,7 +201,10 @@ render(); } - let lastW = 0, lastH = 0; + // Start from the current size so the observer's first callback doesn't + // repeat the initial layout below + const r0 = frame.getBoundingClientRect(); + let lastW = r0.width, lastH = r0.height; new ResizeObserver(() => { const r = frame.getBoundingClientRect(); if (Math.abs(r.width - lastW) < 1 && Math.abs(r.height - lastH) < 1) return;