diff --git a/AGENTS.md b/AGENTS.md index dd072601..bd6fff8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -315,7 +315,9 @@ node scripts/geocode-places.mjs --check # exit 1 if anything is unplaced ``` Coordinates live in `config/places.json` so the site and CI never call a -geocoder. The script skips anything not in the format above, and checks the +geocoder. A name that is the same campus as another — "University of Chicago" and +"The University of Chicago" — is written `{ "sameAs": "" }`, not a +second copy of the coordinates; `tests/places.test.js` fails on a repeat. The script skips anything not in the format above, and checks the `matched` string it wrote — Nominatim answers "Durham, UK" with the county, not the city. Correct a wrong pin by hand; the script never overwrites one. diff --git a/config/places.json b/config/places.json index 6fe572b2..75c6d38a 100644 --- a/config/places.json +++ b/config/places.json @@ -1,5 +1,5 @@ { - "$comment": "Coordinates for every place the site pins — collaborator institutions and presentation locations alike — resolved once with scripts/geocode-places.mjs and committed so the site and CI never call a geocoder. `matched` is what the geocoder thought it found — check it before trusting a pin. Safe to correct by hand: the script only fills in what is missing.", + "$comment": "Coordinates for every place the site pins — collaborator institutions and presentation locations alike — resolved once with scripts/geocode-places.mjs and committed so the site and CI never call a geocoder. `matched` is what the geocoder thought it found — check it before trusting a pin. Safe to correct by hand: the script only fills in what is missing. A name that is the same campus as another is `{ \"sameAs\": \"\" }` rather than a second copy of the coordinates; read places through src/lib/places.js. Keep one entry per campus: tests/places.test.js fails on two entries with the same coordinates.", "source": "https://nominatim.openstreetmap.org (ODbL)", "places": { "A Coruña, Spain": { @@ -38,9 +38,7 @@ "matched": "California Institute of Technology, Moore Walk, Pasadena Council District 7, Pasadena, Los Angeles County, California, 91125, United States" }, "Caltech": { - "lat": 34.137, - "lon": -118.1253, - "matched": "California Institute of Technology, Broad Walk, Pasadena Council District 7, Pasadena, Los Angeles County, California, 91106, United States" + "sameAs": "California Institute of Technology" }, "Caltech/JPL": { "lat": 34.2015, @@ -58,9 +56,7 @@ "matched": "hand-placed: MaRS Centre, 661 University Ave, Toronto" }, "Canadian Institute for Theoretical Astrophysics": { - "lat": 43.66, - "lon": -79.3971, - "matched": "hand-placed: 60 St George St, University of Toronto" + "sameAs": "University of Toronto" }, "Cardiff University": { "lat": 51.5067, @@ -73,9 +69,7 @@ "matched": "hand-placed: 1530 P St NW, Washington DC — Nominatim has no entry" }, "Carnegie Observatories": { - "lat": 34.1563, - "lon": -118.1334, - "matched": "The Carnegie Observatories, Earlham Street, Bungalow Heaven, Pasadena Council District 5, Pasadena, Los Angeles County, California, 91188, United States" + "sameAs": "Observatories of the Carnegie Institution of Washington" }, "Case Western Reserve University": { "lat": 41.5014, @@ -88,9 +82,7 @@ "matched": "60 Garden Street, Cambridge, MA, USA (placed by hand)" }, "Center for Computational Astrophysics": { - "lat": 40.7397, - "lon": -73.9903, - "matched": "hand-placed: part of the Flatiron Institute, 162 Fifth Ave, New York" + "sameAs": "Flatiron Institute" }, "Centre de Recherche Astrophysique de Lyon": { "lat": 45.6936, @@ -103,9 +95,7 @@ "matched": "Centro de Estudios de Física del Cosmos de Aragón, VF-TE-02, Arcos de las Salinas, Gúdar-Javalambre, Teruel, Aragón, España" }, "CESR": { - "lat": 43.5626, - "lon": 1.4759, - "matched": "Toulouse, France (placed by hand) — same place as Institut de Recherche en Astrophysique et Planétologie, its successor" + "sameAs": "Institut de Recherche en Astrophysique et Planétologie" }, "Chengdu Experimental Foreign Languages School": { "lat": 30.6587, @@ -213,9 +203,7 @@ "matched": "Jesi, Ancona, Marche, 60035, Italia" }, "Københavns Universitet": { - "lat": 55.6802, - "lon": 12.5723, - "matched": "Københavns Universitet, Copenhagen (geocoded) — same place as University of Copenhagen" + "sameAs": "University of Copenhagen" }, "Laboratoire d'Astrophysique de Marseille": { "lat": 43.3436, @@ -398,9 +386,7 @@ "matched": "City of Rochester, Monroe County, New York, United States" }, "Ruprecht Karls Universität Heidelberg": { - "lat": 49.4186, - "lon": 8.6692, - "matched": "Universität Heidelberg, Handschuhsheimer Landstraße, Handschuhsheim, Heidelberg, Baden-Württemberg, 69121, Deutschland" + "sameAs": "Ruprecht-Karls-Universität Heidelberg" }, "Ruprecht-Karls-Universität Heidelberg": { "lat": 49.4186, @@ -408,9 +394,7 @@ "matched": "Universität Heidelberg, Handschuhsheimer Landstraße, Handschuhsheim, Heidelberg, Baden-Württemberg, 69121, Deutschland" }, "Rutgers The State University of New Jersey": { - "lat": 40.4969, - "lon": -74.449, - "matched": "Rutgers University, New Brunswick, NJ (geocoded)" + "sameAs": "Rutgers University" }, "Rutgers University": { "lat": 40.5204, @@ -433,14 +417,10 @@ "matched": "Seattle, King County, Washington, United States" }, "Simons Foundation": { - "lat": 40.7392, - "lon": -73.9909, - "matched": "hand-placed: 160 Fifth Ave, New York — Nominatim has no entry" + "sameAs": "Flatiron Institute" }, "Smithsonian Astrophysical Observatory": { - "lat": 42.3814, - "lon": -71.1281, - "matched": "60 Garden Street, Cambridge, MA, USA (placed by hand) — same place as Center for Astrophysics Harvard & Smithsonian" + "sameAs": "Center for Astrophysics Harvard & Smithsonian" }, "Smithsonian Institution": { "lat": 38.8888, @@ -528,9 +508,7 @@ "matched": "Orto Botanico dell'Università degli Studi di Cagliari, Stampaxi/Stampace, Casteddu/Cagliari, Sardigna/Sardegna, Italia" }, "Università di Bologna": { - "lat": 44.4983, - "lon": 11.3545, - "matched": "Università di Bologna, Via dei Bibiena, Ghetto ebraico, Irnerio, Santo Stefano, Bologna, Emilia-Romagna, 40126, Italia" + "sameAs": "University of Bologna" }, "Universität Bern": { "lat": 46.951, @@ -593,9 +571,7 @@ "matched": "University of Cambridge, 82-88, Hills Road, Petersfield, Cambridge, Cambridgeshire, Cambridgeshire and Peterborough, England, CB2 1NF, United Kingdom" }, "University of Chicago": { - "lat": 41.7881, - "lon": -87.6048, - "matched": "University of Chicago, 5841, South Maryland Avenue, Hyde Park, Chicago, Hyde Park Township, Cook County, Illinois, 60637, United States" + "sameAs": "The University of Chicago" }, "University of Colorado": { "lat": 40.0076, @@ -708,9 +684,7 @@ "matched": "Université Paris Cité – Campus Saint-Germain-des-Prés, 45, Rue des Saints-Pères, Quartier Saint-Thomas-d'Aquin, Paris 7e Arrondissement, Paris, Île-de-France, France métropolitaine, 75006, France" }, "Universtity of Wisconsin": { - "lat": 43.0766, - "lon": -89.4125, - "matched": "hand-placed: University of Wisconsin-Madison. The name is misspelt on the ORCID record it came from, and is kept as written so the collector still matches it." + "sameAs": "University of Wisconsin Madison" }, "Valencia, Spain": { "lat": 39.4697, diff --git a/scripts/geocode-places.mjs b/scripts/geocode-places.mjs index 1f3c1d41..753d213a 100644 --- a/scripts/geocode-places.mjs +++ b/scripts/geocode-places.mjs @@ -3,7 +3,8 @@ // // Both live in one file because they are the same kind of fact — somewhere on // Earth the site draws a dot for — and one committed answer per place means the -// two maps cannot disagree about where Cambridge is. +// two maps cannot disagree about where Cambridge is. A name that is the same +// campus as another is `{ sameAs }` it, so each coordinate is written once. // // Issue #22: geocoding at build time would make CI depend on a third-party // API, which this repo avoids everywhere else. So this is run by hand and the @@ -94,7 +95,10 @@ fs.writeFileSync(OUT, `${JSON.stringify({ + 'presentation locations alike — resolved once with scripts/geocode-places.mjs and ' + 'committed so the site and CI never call a geocoder. `matched` is what the geocoder ' + 'thought it found — check it before trusting a pin. Safe to correct by hand: the ' - + 'script only fills in what is missing.', + + 'script only fills in what is missing. ' + + 'A name that is the same campus as another is `{ "sameAs": "" }` rather than a ' + + 'second copy of the coordinates; read places through src/lib/places.js. Keep one entry per ' + + 'campus: tests/places.test.js fails on two entries with the same coordinates.', source: 'https://nominatim.openstreetmap.org (ODbL)', places: Object.fromEntries(Object.entries(places).sort(([a], [b]) => a.localeCompare(b))), }, null, 2)}\n`); diff --git a/src/lib/collabmap.js b/src/lib/collabmap.js index 6315f56b..d651e584 100644 --- a/src/lib/collabmap.js +++ b/src/lib/collabmap.js @@ -10,7 +10,7 @@ // lives in worldmap.js, shared with the conference map. import collaborators from '/config/collaborators.json'; -import places from '/config/places.json'; +import { locate } from './places.js'; import { map, project, toXY, spread, hueFor, KM_PER_UNIT, MAX_DRIFT_MILES, MAX_PIN_DRIFT } from './worldmap.js'; import { byType, venueUrl, links, assists, primaryLink } from './data.js'; @@ -83,7 +83,7 @@ export function collaboratorMap() { for (const person of collaborators.people) { const posts = person.affiliations - .filter((a) => a.start && a.organization && places.places[a.organization]) + .filter((a) => a.start && a.organization && locate(a.organization)) // Newest first: what someone is doing now is the more useful fact, and it // is the order every other dated list on this site uses. The trajectory // is a line through the same points either way. @@ -92,7 +92,7 @@ export function collaboratorMap() { const mine = papers.get(person.orcid) ?? []; const pins = posts.map((post) => { - const { lat, lon } = places.places[post.organization]; + const { lat, lon } = locate(post.organization); const [x, y] = toXY(lon, lat); return { organization: post.organization, diff --git a/src/lib/confmap.js b/src/lib/confmap.js index bbacdc83..55f76d3a 100644 --- a/src/lib/confmap.js +++ b/src/lib/confmap.js @@ -11,7 +11,7 @@ // Geometry is resolved here rather than in the component, so the page renders // plain numbers and the maths is testable without a DOM. -import places from '/config/places.json'; +import { locate } from './places.js'; import { map, toXY, spread, hueFor } from './worldmap.js'; import { byType, dateLabel, links } from './data.js'; @@ -32,7 +32,7 @@ export const ONLINE = 'Online'; export const ONLINE_AT = { lat: -70, lon: 0 }; /** Where a location string is on Earth, or nothing if it is not settled. */ -const coords = (loc) => (loc === ONLINE ? ONLINE_AT : places.places[loc]); +const coords = (loc) => (loc === ONLINE ? ONLINE_AT : locate(loc)); /** How the CV's `kind` reads in a sentence about one talk. */ const KIND = { diff --git a/src/lib/places.js b/src/lib/places.js new file mode 100644 index 00000000..9d541e1b --- /dev/null +++ b/src/lib/places.js @@ -0,0 +1,14 @@ +// Where a named place is on Earth. +// +// `config/places.json` holds each coordinate once. A name that is the same +// campus as another — "University of Chicago" and "The University of Chicago", +// SAO and the CfA — is written `{ "sameAs": "" }` instead of +// repeating coordinates, so the two can never drift apart. + +import data from '/config/places.json'; + +/** `{ lat, lon }` for a place name, or undefined when it is not placed. */ +export function locate(name) { + const p = data.places[name]; + return p?.sameAs ? data.places[p.sameAs] : p; +} diff --git a/tests/places.test.js b/tests/places.test.js new file mode 100644 index 00000000..4038bfba --- /dev/null +++ b/tests/places.test.js @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import data from '../config/places.json'; +import { locate } from '../src/lib/places.js'; + +const entries = Object.entries(data.places); + +describe('config/places.json', () => { + it('points every sameAs at a place with coordinates of its own', () => { + for (const [name, p] of entries.filter(([, e]) => e.sameAs)) { + const target = data.places[p.sameAs]; + expect(target, `${name} -> ${p.sameAs}`).toBeDefined(); + expect(target.sameAs, `${name} -> ${p.sameAs} is a chain`).toBeUndefined(); + expect(typeof target.lat).toBe('number'); + } + }); + + it('writes each coordinate once: the same campus is a sameAs, not a copy', () => { + const seen = new Map(); + for (const [name, p] of entries.filter(([, e]) => !e.sameAs)) { + const key = `${p.lat},${p.lon}`; + expect(seen.get(key), `${name} repeats the coordinates of ${seen.get(key)}`).toBeUndefined(); + seen.set(key, name); + } + }); + + it('locate follows a sameAs and is undefined for an unplaced name', () => { + expect(locate('Smithsonian Astrophysical Observatory')) + .toBe(data.places['Center for Astrophysics Harvard & Smithsonian']); + expect(locate('Nowhere in particular')).toBeUndefined(); + }); + + it('keeps the Smithsonian Institution in Washington, apart from the observatory', () => { + expect(locate('Smithsonian Institution').lat).not.toBe(locate('Smithsonian Astrophysical Observatory').lat); + }); +});