A field can be a place, and a question can be about one

`type: location` puts a pin on a map and stores {lat, lon, zoom}; a
record that holds one renders as a still, on a desk and in any
resource card. `place:` on a question is the counterpart to `event:`:
event makes a page an occasion with a time, place makes it somewhere,
shows the map under the question, and opens any location field on the
page there instead of on the globe.

Every tile and every still is fetched by portal and served from this
origin (src/maps.rs, MapLibre vendored in public/), so a page with a
map on it still talks to nobody but us: no third-party request, no
consent banner, and the token never leaves the server. MAPBOX_TOKEN
unset turns the whole thing off, the way GARAGE_* does for uploads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-24 21:17:28 +02:00
co-authored by Claude Opus 5
parent 9ed8194484
commit 4fd60293fa
10 changed files with 834 additions and 4 deletions
+121
View File
@@ -84,6 +84,51 @@ pub struct Question {
/// task lands in the `portal_events` desk (see `announce.rs`).
#[serde(default)]
pub event: Option<EventConfig>,
/// Where this question is about, when it is about somewhere: a
/// still map under the question, drawn from this origin. `event`
/// makes a page an occasion with a time; this makes one a place,
/// and a `type: location` field on the page opens its map here
/// rather than on the whole world.
#[serde(default)]
pub place: Option<Place>,
}
/// A point a page is about. `zoom` is a map zoom level, 0 (the globe)
/// to 22 (a doorstep); 15 shows a street and its neighbours. `name` is
/// what to call it in words, for anyone who cannot see the map.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Place {
pub lat: f64,
pub lon: f64,
#[serde(default = "default_zoom")]
pub zoom: f64,
#[serde(default)]
pub name: Option<String>,
}
fn default_zoom() -> f64 {
15.0
}
impl Place {
/// `lat,lon,zoom`, the form a `type: location` field takes as its
/// opening view.
pub fn as_value(&self) -> String {
format!("{},{},{}", self.lat, self.lon, self.zoom)
}
pub fn validate(&self) -> Result<(), String> {
if !(-90.0..=90.0).contains(&self.lat) || !(-180.0..=180.0).contains(&self.lon) {
return Err(format!("lat {} / lon {} is not a point on Earth", self.lat, self.lon));
}
if !(0.0..=22.0).contains(&self.zoom) {
return Err(format!("zoom {} is outside 0 to 22", self.zoom));
}
if self.name.as_ref().is_some_and(|n| n.trim().is_empty()) {
return Err("place.name is empty; leave it out or say where this is".to_string());
}
Ok(())
}
}
/// A page's announcement window. `starts` is RFC 3339 with an offset
@@ -543,6 +588,12 @@ pub struct SiteConfig {
pub favicon: Option<String>,
#[serde(default)]
pub hero: HeroConfig,
/// Whether this instance can draw maps: set from `MAPBOX_TOKEN` on
/// the way to the browser (see `get_site`), never from content, so
/// a `type: location` field knows whether to offer a map or two
/// number inputs.
#[serde(skip)]
pub maps: bool,
/// Who this site's mail comes from. Absent means the site sends
/// nothing: a state's `mail:` and the invite mail are logged and
/// skipped, never half-sent (see `mail.rs`).
@@ -1038,6 +1089,76 @@ alternatives:
assert!(validate_questions(&questions, &schema_fixture()).is_ok());
}
/// A page with one `type: location` field, and whatever else the
/// test needs bolted onto the requirement.
fn question_with_location(extra_yaml: &str) -> std::collections::HashMap<String, Question> {
let question: Question = serde_yaml::from_str(&format!(
r#"
id: /t
name: Where is it?
alternatives:
- name: A
features:
- name: Where
requirements:
- name: place
label: Where
type: location
optional: true
{extra_yaml}
"#
))
.unwrap();
std::collections::HashMap::from([(question.id.clone(), question)])
}
#[test]
fn a_location_field_puts_its_own_value_there() {
let questions = question_with_location("");
validate_questions(&questions, &schema_fixture()).unwrap();
// It cannot also be loaded from somewhere.
let bound = question_with_location(
" bind:\n field: other\n param: p\n resource:\n source: { kind: url, url: \"https://x.example/{p}\" }",
);
let err = validate_questions(&bound, &schema_fixture()).unwrap_err();
assert!(err.to_string().contains("the visitor puts the pin"), "{err}");
}
#[test]
fn a_location_fields_opening_view_is_a_point_on_earth() {
let good = question_with_location(" value: \"59.8938,11.0332,15\"");
validate_questions(&good, &schema_fixture()).unwrap();
let no_zoom = question_with_location(" value: \"59.8938,11.0332\"");
validate_questions(&no_zoom, &schema_fixture()).unwrap();
for bad in ["\"59.8938\"", "\"91,11\"", "\"59,200\"", "\"here\"", "\"59,11,15,3\""] {
let questions = question_with_location(&format!(" value: {bad}"));
let err = validate_questions(&questions, &schema_fixture()).unwrap_err();
assert!(err.to_string().contains("opens the map"), "{bad}: {err}");
}
}
#[test]
fn a_page_about_a_place_says_where_and_how_close() {
let place = Place { lat: 59.8938, lon: 11.0332, zoom: 15.0, name: Some("Tomter".into()) };
place.validate().unwrap();
assert_eq!(place.as_value(), "59.8938,11.0332,15");
// The default is a street and its neighbours, not the globe.
let bare: Place = serde_yaml::from_str("lat: 59.8938\nlon: 11.0332").unwrap();
assert_eq!(bare.zoom, 15.0);
assert!(bare.name.is_none());
for bad in [
Place { lat: 91.0, lon: 11.0, zoom: 15.0, name: None },
Place { lat: 59.0, lon: 181.0, zoom: 15.0, name: None },
Place { lat: 59.0, lon: 11.0, zoom: 30.0, name: None },
Place { lat: 59.0, lon: 11.0, zoom: 15.0, name: Some(" ".into()) },
] {
assert!(bad.validate().is_err(), "{bad:?}");
}
}
#[test]
fn undeclared_from_state_is_rejected() {
let questions = question_with_transitions(" - { from: bogus, to: middle, label: X }");