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;