This page is fully self-contained: no external network request is possible (see the Content-Security-Policy above). A live mode twin of this same post can load map tiles and remote SPARQL endpoints.

Everything the series has queried so far has been symbolic: IRIs, literals, language tags, class membership. GeoSPARQL adds a second kind of value to the graph — geometry — and a set of SPARQL functions that answer spatial questions about it: is this point inside that polygon, how far apart are these two places, what is the bounding box of this shape. The geometry lives in the data as an ordinary typed literal; the questions are ordinary SPARQL expressions. So a GeoSPARQL query runs through the same fn.query path every other post in this series uses — no new API surface, just new functions the engine recognises inside a FILTER or a projection.

This post covers Factoidal's GeoSPARQL support: pure F* with no floating-point arithmetic anywhere on its decided path. That last part is the reason the post exists: a spatial engine built on floating-point has to decide, for every "is this point on the edge" question, how big an epsilon counts as "on". Factoidal decides those cases with exact rational arithmetic, so a point that lies exactly on a polygon's edge is classified as boundary — not nudged inside or outside by rounding. The last cell below demonstrates exactly that.

Geometry literals: geo:wktLiteral and WKT#

A geometry enters an RDF graph as a literal typed geo:wktLiteral (http://www.opengis.net/ont/geosparql#wktLiteral), whose lexical form is WKT (Well-Known Text) — the OGC's plain-text geometry notation. A couple of shapes in WKT:

"POINT(-0.1278 51.5074)"^^geo:wktLiteral
"POLYGON((-0.5 51.3, 0.3 51.3, 0.3 51.7, -0.5 51.7, -0.5 51.3))"^^geo:wktLiteral

POINT(x y) is a single coordinate pair; the WKT convention is x = longitude, y = latitude, so a London point reads POINT(-0.1278 51.5074). POLYGON((...)) is a closed ring of coordinates (first and last vertex coincide), with the doubled parentheses leaving room for interior rings (holes). Factoidal's WKT parser is itself F* — Parser.WKT.fst — and parses coordinates to exact scaled-decimal rationals, not to floating-point doubles: 51.5074 is carried as the integer 515074 with a scale of 4, so no precision is lost at the door.

Here is the dataset the topological cells below run against — three UK city points and two toy "constituency-ish" bounding-box polygons, hand-picked so exactly one city sits inside each box and Manchester sits inside neither. It is named once, below, and every cell in this section that needs it references GEO_TTL by name instead of repeating it:

GEO_TTL = `
  @prefix ex:  <http://example.org/> .
  @prefix geo: <http://www.opengis.net/ont/geosparql#> .

  ex:London a ex:City ; ex:name "London" ;
    ex:hasGeom "POINT(-0.1278 51.5074)"^^geo:wktLiteral .
  ex:Manchester a ex:City ; ex:name "Manchester" ;
    ex:hasGeom "POINT(-2.2426 53.4808)"^^geo:wktLiteral .
  ex:Edinburgh a ex:City ; ex:name "Edinburgh" ;
    ex:hasGeom "POINT(-3.1883 55.9533)"^^geo:wktLiteral .

  ex:GreaterLondonArea a ex:Area ; ex:name "Greater London (toy box)" ;
    ex:hasGeom "POLYGON((-0.5 51.3, 0.3 51.3, 0.3 51.7, -0.5 51.7, -0.5 51.3))"^^geo:wktLiteral .
  ex:ScotlandArea a ex:Area ; ex:name "Scotland (toy box)" ;
    ex:hasGeom "POLYGON((-8 54.5, -1 54.5, -1 60.9, -8 60.9, -8 54.5))"^^geo:wktLiteral .
`
const dataset = await fn.parse(GEO_TTL);
return dataset.size;

Fifteen triples: each of the five subjects carries a type, a name, and a geo:wktLiteral geometry. Nothing here is special-cased for geometry at parse time — a geo:wktLiteral is just a typed literal like any other until a geof: function asks about it.

Topological predicates: the Simple Features family#

The spatial questions come from the OGC Simple Features access model, exposed as SPARQL functions under the http://www.opengis.net/def/function/geosparql/ namespace (prefix geof:). Each takes two geometries and returns a boolean:

Because they return booleans, they sit naturally inside a FILTER. "Which city sits inside which area box" is geof:sfWithin over the cross-product of cities and areas:

const dataset = await fn.parse(GEO_TTL);
const rows = await fn.query(dataset, `# Which city sits inside which area box: cross product of cities and
# areas, kept only where the city's point is sfWithin the area's polygon.
  PREFIX ex:   <http://example.org/>
  PREFIX geof: <http://www.opengis.net/def/function/geosparql/>
  SELECT ?cityName ?areaName WHERE {
    ?city a ex:City ; ex:name ?cityName ; ex:hasGeom ?cgeom .
    ?area a ex:Area ; ex:name ?areaName ; ex:hasGeom ?ageom .
    # keep the pair only if the city point lies within the area polygon
    FILTER(geof:sfWithin(?cgeom, ?ageom))
  } ORDER BY ?cityName`);
return pretty(rows);

Two pairs survive the filter: Edinburgh's point is inside the Scotland box, London's is inside the Greater London box. Manchester is inside neither, so it produces no row. Swapping the argument order and using geof:sfContains — "which area box contains London" — is the same relation read the other way and would return the Greater London box.

sfWithin at borough scale: a live choropleth#

The three-city, two-toy-box example above proves the mechanism; this one scales it up to something that reads as a real place. The dataset is the real 33 London boroughs (32 boroughs + the City of London) and the River Thames, vendored as GeoJSON under docs/web/hub/assets/geo/ (ONS Open Geography boundaries, OGL v3; a Natural Earth river line, public domain — see that directory's PROVENANCE.md for the exact services, licences, and the Douglas-Peucker simplification that keeps the total under 70 KB), plus sixteen well-known London landmarks written directly into this cell as WKT points. The cell converts every borough polygon and every landmark point to a geo:wktLiteral, fn.parses the result, and runs one SPARQL query:

# Which landmark points are sfWithin which borough polygon.
SELECT ?boroughName ?landmarkName WHERE {
  ?borough   a ex:Borough  ; ex:name ?boroughName  ; ex:hasGeom ?bgeom .
  ?landmark  a ex:Landmark ; ex:name ?landmarkName ; ex:hasGeom ?lgeom .
  # keep the pair only if the landmark point lies within the borough polygon
  FILTER(geof:sfWithin(?lgeom, ?bgeom))
}

— "which landmark points are sfWithin which borough polygon", the same point/polygon sfWithin relation the three-city example used, just run over 33 × 16 candidate pairs instead of 3 × 2. Every borough's fill color is that query's row count for that borough (0 landmarks = the palette's lightest shade, 3+ = its darkest — the scale landmarkColor() below defines); clicking a borough opens a popup listing exactly the ?landmarkName rows this query returned for it, plus a second, equally live fact — whether geof:sfIntersects decided the Thames' LineString crosses that borough's polygon — read from a second small query. Nothing about a borough's appearance or popup content is computed by re-deriving geometry in JavaScript; both come straight out of query results. sfWithin and sfIntersects are both fully decided (never refused) for every pair this cell asks about — point/polygon sfWithin is decided unconditionally, and multi-polygon component decomposition (several boroughs are OGC MultiPolygons, not simple Polygons) falls back to None only when no component settles the question, which never happens for a point that is genuinely inside or outside every part — see RDF.Geo.Topology.fst's decided-vs-refused table, also summarized in the "Scope" section below.

The map itself has three layers, same-origin only:

  1. Boroughs (L.geoJSON, the vendored polygon file) — fill color from the choropleth query above, a thin outline, a click handler that builds the popup from that borough's rows.
  2. The Thames (L.geoJSON, the vendored line file) — a plain blue line, drawn for orientation ("this reads as London"), not itself a query result.
  3. Landmarks (L.circleMarker, one per named point) — small dark dots with a name tooltip; the input the choropleth query ran over, not its output.

No raster tile layer, no CDN — the strict page's Content-Security-Policy (visible in this page's <head>, task #105) sets img-src/connect-src to 'self' only, so a tile request literally cannot succeed here, and every marker uses L.circleMarker/ L.geoJSON rather than Leaflet's default L.Icon (whose PNG assets are deliberately not vendored — see third_party/leaflet/PROVENANCE.md). A fullscreen control (top-right, native Fullscreen API, no plugin) makes the choropleth easier to read on a small viewport. On this page's live-mode twin — same cell source, data-hub-mode="live" on <body> instead of "strict" — a fourth, non-CDN-restricted layer becomes available: a real OpenStreetMap raster basemap underneath the vector layers above, toggleable via a layer control, with its own required attribution line. The strict page you're reading now never adds it.

// Sixteen well-known London landmarks, hand-picked to land in several
// different boroughs (Westminster and Greenwich get two each; Richmond
// upon Thames gets three) so the choropleth below actually has a
// range of counts to show, not just "0 or 1". These are the cell's
// INPUT data, same status as the three cities/two toy boxes in the
// examples above -- ordinary asserted facts, not a query result. A
// landmark deliberately placed near a borough boundary can land on
// either side of it after this post's Douglas-Peucker simplification
// of the borough polygons (see assets/geo/PROVENANCE.md) -- that is
// the engine reporting exactly what the (simplified) vendored geometry
// says, not a bug; none of the sixteen below are boundary-adjacent
// enough for that to matter.
const LANDMARKS = [
  ["Big Ben", -0.1246, 51.5007],
  ["Buckingham Palace", -0.1419, 51.5014],
  ["Canary Wharf", -0.0235, 51.5054],
  ["The Shard", -0.0865, 51.5045],
  ["Greenwich Observatory", -0.0005, 51.4769],
  ["The O2", 0.0032, 51.5030],
  ["Wembley Stadium", -0.2795, 51.5560],
  ["Emirates Stadium", -0.1084, 51.5549],
  ["Kew Gardens", -0.2955, 51.4787],
  ["Hampton Court Palace", -0.3367, 51.4035],
  ["Twickenham Stadium", -0.3419, 51.4560],
  ["Alexandra Palace", -0.1310, 51.5941],
  ["Heathrow Airport", -0.4543, 51.4700],
  ["Kensington Palace", -0.1877, 51.5052],
  ["Battersea Power Station", -0.1439, 51.4816],
  ["Camden Market", -0.1461, 51.5416],
];

// GeoJSON -> WKT, one direction only (no round trip needed -- Leaflet
// renders the fetched GeoJSON objects directly; WKT is only for the
// SPARQL literals this cell constructs). Matches Parser.WKT.fst's
// nesting exactly: a ring is "(x y, x y, ...)"; a polygon body is
// "(ring, hole, ...)"; MULTIPOLYGON wraps a comma list of polygon
// bodies in one more pair of parens.
function ringToWkt(ring) {
  return "(" + ring.map(([lon, lat]) => lon + " " + lat).join(", ") + ")";
}
function polygonCoordsToWkt(coordinates) {
  return "(" + coordinates.map(ringToWkt).join(", ") + ")";
}
function geomToWkt(geom) {
  if (geom.type === "Polygon") return "POLYGON" + polygonCoordsToWkt(geom.coordinates);
  if (geom.type === "MultiPolygon")
    return "MULTIPOLYGON(" + geom.coordinates.map(polygonCoordsToWkt).join(", ") + ")";
  if (geom.type === "LineString")
    return "LINESTRING(" + geom.coordinates.map(([lon, lat]) => lon + " " + lat).join(", ") + ")";
  throw new Error("geomToWkt: unsupported geometry type " + geom.type);
}

// Choropleth color scale: landmark COUNT -> fill color, four discrete
// steps (0..3+). The ONLY input is the query row count computed below
// -- no geometry math here, just a lookup table.
const CHOROPLETH_SCALE = ["#eef3ef", "#a8cdb4", "#5a9c79", "#1b4332"];
function landmarkColor(count) {
  return CHOROPLETH_SCALE[Math.min(count, CHOROPLETH_SCALE.length - 1)];
}

// Same-origin base path for this cell's two fetch()es, independent of
// whether this notebook is running on the strict page (.../web/hub/21-.../)
// or its live-mode twin (.../web/hub-live/21-.../) -- both serve the
// SAME vendored files from .../web/hub/assets/geo/. `location` is a
// browser global (see reactive-cells.mjs's header on globals resolving
// through ordinary JS scope); the Node pinning test stubs `fetch`
// directly and never reads the computed URL, so the `typeof location
// === "undefined"` branch only matters there.
const geoAssetsBase =
  typeof location === "undefined"
    ? "../assets/geo/"
    : (/^(.*\/web\/)hub(?:-live)?\/[^/]+\/?$/.exec(location.pathname) || [, "../"])[1] + "hub/assets/geo/";

try {
  const [boroughsGeoJSON, thamesGeoJSON] = await Promise.all([
    fetch(geoAssetsBase + "london-boroughs.geojson").then((r) => r.json()),
    fetch(geoAssetsBase + "thames.geojson").then((r) => r.json()),
  ]);

  const boroughTriples = boroughsGeoJSON.features
    .map((f, i) => {
      const id = "ex:borough" + i;
      const wkt = geomToWkt(f.geometry).replace(/"/g, "");
      return `${id} a ex:Borough ; ex:name "${f.properties.name}" ; ex:hasGeom "${wkt}"^^geo:wktLiteral .`;
    })
    .join("\n  ");
  const landmarkTriples = LANDMARKS.map(([name, lon, lat], i) => {
    return `ex:landmark${i} a ex:Landmark ; ex:name "${name}" ; ex:hasGeom "POINT(${lon} ${lat})"^^geo:wktLiteral .`;
  }).join("\n  ");
  const thamesWkt = geomToWkt(thamesGeoJSON.features[0].geometry).replace(/"/g, "");

  const CHOROPLETH_TTL = `
  @prefix ex:  <http://example.org/> .
  @prefix geo: <http://www.opengis.net/ont/geosparql#> .
  ex:Thames a ex:River ; ex:hasGeom "${thamesWkt}"^^geo:wktLiteral .
  ${boroughTriples}
  ${landmarkTriples}
`;
  const dataset = await fn.parse(CHOROPLETH_TTL);

  // The ONE query this whole choropleth reads from: every
  // (boroughName, landmarkName) pair the engine decided sfWithin. A
  // borough with zero rows here just never appears as a key below --
  // groupBy defaults its count to 0, not a re-derivation of anything.
  const withinRows = await fn.query(dataset, `# Which landmark points are sfWithin which borough polygon, over the
# real 33-borough dataset -- the query that drives the choropleth fill.
    PREFIX ex:   <http://example.org/>
    PREFIX geof: <http://www.opengis.net/def/function/geosparql/>
    SELECT ?boroughName ?landmarkName WHERE {
      ?borough  a ex:Borough  ; ex:name ?boroughName  ; ex:hasGeom ?bgeom .
      ?landmark a ex:Landmark ; ex:name ?landmarkName ; ex:hasGeom ?lgeom .
      # keep the pair only if the landmark point lies within the borough polygon
      FILTER(geof:sfWithin(?lgeom, ?bgeom))
    } ORDER BY ?boroughName ?landmarkName`);
  const landmarksByBorough = new Map();
  for (const row of withinRows) {
    const b = row.get("boroughName").value;
    const l = row.get("landmarkName").value;
    if (!landmarksByBorough.has(b)) landmarksByBorough.set(b, []);
    landmarksByBorough.get(b).push(l);
  }

  // A second, independent decided predicate over the SAME dataset --
  // linestring/multipolygon sfIntersects, decomposed component-wise
  // exactly like point/multipolygon sfWithin above. Folded into the
  // popup as a bonus fact, not into the fill color.
  const thamesRows = await fn.query(dataset, `# Which boroughs the Thames LineString sfIntersects -- feeds the
# per-borough popup's second, independent fact.
    PREFIX ex:   <http://example.org/>
    PREFIX geof: <http://www.opengis.net/def/function/geosparql/>
    SELECT ?boroughName WHERE {
      ex:Thames ex:hasGeom ?rgeom .
      ?borough a ex:Borough ; ex:name ?boroughName ; ex:hasGeom ?bgeom .
      # keep the borough only if it shares a point with the Thames line
      FILTER(geof:sfIntersects(?rgeom, ?bgeom))
    }`);
  const thamesBoroughs = new Set(thamesRows.map((r) => r.get("boroughName").value));

  const container = document.createElement("div");
  container.className = "hub-leaflet-map";

  const map = L.map(container, { zoomControl: true, attributionControl: false });
  // Fixed center+zoom before any layer is added -- a detached
  // container has zero size, and fitBounds against it poisons the
  // view with NaN. Greater London's rough centroid/zoom, refined by
  // the real fitBounds once layers mount (post-attach frame below).
  map.setView([51.5, -0.12], 10);

  L.geoJSON(boroughsGeoJSON, {
    style: (feature) => {
      const count = (landmarksByBorough.get(feature.properties.name) || []).length;
      return { color: "#1a1e23", weight: 1, fillColor: landmarkColor(count), fillOpacity: 0.75 };
    },
    onEachFeature: (feature, layer) => {
      const name = feature.properties.name;
      const names = landmarksByBorough.get(name) || [];
      const crossed = thamesBoroughs.has(name);
      const list = names.length
        ? "<ul>" + names.map((n) => `<li>${n}</li>`).join("") + "</ul>"
        : "<p>(no landmark from this list)</p>";
      layer.bindPopup(
        `<strong>${name}</strong><br>` +
        `geof:sfWithin landmarks (${names.length}): ${list}` +
        `geof:sfIntersects the Thames: <strong>${crossed}</strong>`
      );
    },
  }).addTo(map);

  L.geoJSON(thamesGeoJSON, {
    style: { color: "#1a6fa8", weight: 3, opacity: 0.85 },
  }).addTo(map);

  for (const [name, lon, lat] of LANDMARKS) {
    L.circleMarker([lat, lon], {
      radius: 4,
      color: "#10130f",
      weight: 1,
      fillColor: "#f4b942",
      fillOpacity: 1,
    })
      .bindTooltip(name)
      .addTo(map);
  }

  // Fullscreen control (task #105): no plugin -- the native Fullscreen
  // API on the map's own container, with the still-common webkit
  // prefix as a fallback. invalidateSize() on the change event is
  // required: Leaflet computed its panes against the pre-fullscreen
  // box, and a size that changed outside Leaflet's own resize
  // observer (the browser doing it, not a CSS transition Leaflet
  // triggered) does not repaint on its own.
  const FullscreenControl = L.Control.extend({
    options: { position: "topright" },
    onAdd() {
      const el = L.DomUtil.create("div", "leaflet-bar leaflet-control leaflet-control-hub-fullscreen");
      const a = L.DomUtil.create("a", "", el);
      a.href = "#";
      a.title = "Toggle fullscreen";
      a.setAttribute("role", "button");
      a.setAttribute("aria-label", "Toggle fullscreen");
      a.textContent = "⤢";
      L.DomEvent.on(a, "click", (e) => {
        L.DomEvent.stopPropagation(e);
        L.DomEvent.preventDefault(e);
        const el2 = map.getContainer();
        const isFs = document.fullscreenElement || document.webkitFullscreenElement;
        if (!isFs) {
          (el2.requestFullscreen || el2.webkitRequestFullscreen)?.call(el2);
        } else {
          (document.exitFullscreen || document.webkitExitFullscreen)?.call(document);
        }
      });
      return el;
    },
  });
  map.addControl(new FullscreenControl());
  const onFullscreenChange = () => {
    map.invalidateSize();
    const isFs = document.fullscreenElement || document.webkitFullscreenElement;
    map.getContainer().classList.toggle("hub-leaflet-map-fs-active", !!isFs);
  };
  document.addEventListener("fullscreenchange", onFullscreenChange);
  document.addEventListener("webkitfullscreenchange", onFullscreenChange);

  // Live mode ONLY (task #105): data-hub-mode="live" is set on <body>
  // by docs/_includes/hub.njk exclusively for pages generated by
  // web/hub-live.11ty.js. Same cell source either way -- this branch
  // is the ONE place the notebook's behavior differs by capability,
  // not by content. The strict page this section's prose describes
  // never executes this branch, so it never attempts a network
  // request -- consistent with the page's own CSP (img-src 'self'
  // only in strict mode, which would block this fetch anyway).
  if (typeof document !== "undefined" && document.body?.getAttribute("data-hub-mode") === "live") {
    const osm = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
      maxZoom: 19,
      attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
    });
    L.control.layers({ "OpenStreetMap (live)": osm }, {}, { position: "bottomleft" }).addTo(map);
    // Test hook only (tests/web-demos/hub_post21_geo_check.sh) -- proves
    // the tile layer OBJECT was created without asserting any tile was
    // actually fetched (CI has no network guarantee, per that script's
    // own header).
    if (typeof window !== "undefined") window.__hubLiveTileLayer = osm;
  }

  // Leaflet needs its container sized AND attached to the document
  // before it can lay out panes or compute a bounds-fitting zoom; the
  // runtime attaches this cell's returned node only after this
  // function returns, so both the size refresh and the real
  // fitBounds run on a later frame, once the container has a size.
  const settle = () => {
    map.invalidateSize();
    map.fitBounds(L.geoJSON(boroughsGeoJSON).getBounds(), { padding: [16, 16] });
  };
  if (typeof requestAnimationFrame === "function") requestAnimationFrame(settle);
  else setTimeout(settle, 0);

  return container;
} catch (err) {
  return html`<div>map unavailable: ${String((err && err.message) || err)}</div>`;
}

Every fill color on this choropleth came out of the same FILTER(geof:sfWithin(?lgeom, ?bgeom)) query the prose above named: Westminster and Greenwich are among the darkest boroughs shown (two landmarks each from this post's fixed list), Richmond upon Thames is darker still (three), and every borough with none of the sixteen landmarks stays the palette's lightest shade — read that directly off the map, not off a hardcoded claim here, since which exact boroughs land where depends on the vendored, independently-simplified polygon each landmark point happens to fall inside (see assets/geo/ PROVENANCE.md). Click any borough for its own popup, built the same way: the ?landmarkName rows this query returned for that one borough, plus the separate sfIntersects-against-the-Thames fact.

geof:sfDisjoint is the complement of sfIntersects: two geometries that share nothing. "Which cities lie outside the Greater London box":

const dataset = await fn.parse(GEO_TTL);
const rows = await fn.query(dataset, `# Which cities lie outside the Greater London box: kept only where the
# city's point shares no point at all with the area polygon.
  PREFIX ex:   <http://example.org/>
  PREFIX geof: <http://www.opengis.net/def/function/geosparql/>
  SELECT ?cityName WHERE {
    ex:GreaterLondonArea ex:hasGeom ?ageom .
    ?city a ex:City ; ex:name ?cityName ; ex:hasGeom ?cgeom .
    # keep the city only if it shares no point with the area polygon
    FILTER(geof:sfDisjoint(?cgeom, ?ageom))
  } ORDER BY ?cityName`);
return pretty(rows);

Edinburgh and Manchester — the two cities the earlier sfWithin query did not pair with the Greater London box. London is missing here, exactly because it is not disjoint from the box it sits inside.

geof:distance: exact squared distance, one disclosed square root#

geof:distance returns a number, not a boolean, so it goes in the SELECT projection rather than a FILTER. It is defined for point-to-point only. The arithmetic is worth spelling out because it is where the "exact rational" story meets its one exception: Factoidal computes the squared distance (x₁ − x₂)² + (y₁ − y₂)² exactly — sums and products of exact rationals, no rounding — and then takes a single, disclosed, under-approximating integer square root (RDF.Geo.Functions.fst's geo_sqrt_approx, a pure-F* binary-search isqrt, floored, never an assume val) to produce the xsd:double result. Every other value on the decided path is exact; the square root is the one documented floor(sqrt(...)) step, because a Euclidean distance is generally irrational and has to land in a double eventually.

Ordering the cities by distance from London puts London itself first, at exactly zero:

const dataset = await fn.parse(GEO_TTL);
const rows = await fn.query(dataset, `# Cities ordered by distance from London, computed as an exact squared
# distance with one disclosed floored square root.
  PREFIX ex:   <http://example.org/>
  PREFIX geof: <http://www.opengis.net/def/function/geosparql/>
  SELECT ?cityName (geof:distance(?g, ?londonGeom) AS ?d) WHERE {
    ex:London ex:hasGeom ?londonGeom .
    ?city a ex:City ; ex:name ?cityName ; ex:hasGeom ?g .
  } ORDER BY ?d`);
return pretty(rows);

London is 0.00000000000000000 — the squared distance from a point to itself is exactly zero, and the square root of exact zero is exactly zero, so there is no floating-point drift on the self-distance case. Manchester (about 2.89 degrees away) and Edinburgh (about 5.40) follow in order. The units are raw coordinate degrees here, not metres — the engine does no CRS-aware geodesic reprojection, a scope boundary the last section names explicitly.

geof:envelope: the bounding box, exactly#

geof:envelope returns a geometry rather than a boolean or a number: the axis-aligned bounding box of its argument, as a rectangular POLYGON. There is no approximation anywhere in it — a bounding box is just the min/max of the input coordinates, all exact:

const ENVELOPE_TTL = `
  @prefix ex:  <http://example.org/> .
  @prefix geo: <http://www.opengis.net/ont/geosparql#> .
  ex:London a ex:City ; ex:name "London" ;
    ex:hasGeom "POINT(-0.1278 51.5074)"^^geo:wktLiteral .
  ex:GreaterLondonArea a ex:Area ; ex:name "Greater London (toy box)" ;
    ex:hasGeom "POLYGON((-0.5 51.3, 0.3 51.3, 0.3 51.7, -0.5 51.7, -0.5 51.3))"^^geo:wktLiteral .
`;
const dataset = await fn.parse(ENVELOPE_TTL);
const rows = await fn.query(dataset, `# Axis-aligned bounding box of each named geometry, as a POLYGON.
  PREFIX ex:   <http://example.org/>
  PREFIX geof: <http://www.opengis.net/def/function/geosparql/>
  SELECT ?name (geof:envelope(?g) AS ?env) WHERE {
    ?s ex:name ?name ; ex:hasGeom ?g .
  } ORDER BY ?name`);
return pretty(rows);

The Greater London box is already axis-aligned, so its envelope is itself, coordinate for coordinate. The London point envelopes to a degenerate rectangle — all four corners collapsed onto the single point — which is the correct bounding box of a zero-extent geometry. Note the result literal comes back typed geo:wktLiteral, so an envelope result can feed straight back into another geof: call.

The exact-boundary case#

This is the case a floating-point spatial engine has to make a judgment call about, and the one Factoidal decides exactly. Take the Greater London box and three points: one lying exactly on its bottom edge (POINT(0 51.3), where 51.3 is the box's south edge), one strictly inside, and one clearly outside. Run all three against sfWithin, sfIntersects, and sfTouches at once:

const BOX_TTL = `
  @prefix ex:  <http://example.org/> .
  @prefix geo: <http://www.opengis.net/ont/geosparql#> .

  ex:box      ex:hasGeom "POLYGON((-0.5 51.3, 0.3 51.3, 0.3 51.7, -0.5 51.7, -0.5 51.3))"^^geo:wktLiteral .
  ex:onEdge   ex:label "exactly on the edge (y = 51.3)" ; ex:hasGeom "POINT(0 51.3)"^^geo:wktLiteral .
  ex:interior ex:label "strictly inside"               ; ex:hasGeom "POINT(0 51.5)"^^geo:wktLiteral .
  ex:outside  ex:label "clearly outside"               ; ex:hasGeom "POINT(0 52.0)"^^geo:wktLiteral .
`;
const dataset = await fn.parse(BOX_TTL);
async function rel(pred, node) {
  return fn.query(dataset, `# Does the named point stand in the given Simple Features relation to the box.
    PREFIX ex:   <http://example.org/>
    PREFIX geof: <http://www.opengis.net/def/function/geosparql/>
    ASK { ex:box ex:hasGeom ?b . ${node} ex:hasGeom ?p . FILTER(geof:${pred}(?p, ?b)) }`);
}
const cases = [
  ["ex:onEdge",   "exactly on the edge (y = 51.3)"],
  ["ex:interior", "strictly inside"],
  ["ex:outside",  "clearly outside"],
];
const out = [];
for (const [node, label] of cases) {
  out.push({
    point: label,
    sfWithin:     await rel("sfWithin", node),
    sfIntersects: await rel("sfIntersects", node),
    sfTouches:    await rel("sfTouches", node),
  });
}
return pretty(out);

Read the top row. The point exactly on the edge is not within the polygon (sfWithin false — Simple Features "within" requires the interior, and the boundary is not the interior), it intersects the polygon (sfIntersects true — they share that boundary point), and it touches the polygon (sfTouches true — they meet only on the boundary). All three answers are correct simultaneously, and they are correct because the engine classified the point as lying on the boundary rather than guessing interior-or-exterior. The interior point is within and intersects but does not touch; the outside point fails all three. Under the hood this is a ray-casting point-in-polygon test rewritten to use an exact orientation predicate (sums and products of exact rationals, no division), so a point exactly on a polygon edge is classified as boundary and not nudged either way — a property of the arithmetic, not a tuning parameter (see RDF.Geo.Topology.fst).

Scope: what is decided, what is refused#

The scope is deliberately narrow. Every predicate returns "decided exactly" or "refused" per geometry-kind pair — a refused pair maps to a SPARQL error, never to a guessed boolean. The RDF.Geo.Topology.fst module header carries the full decided-vs-refused table; the shape of it:

The design covers the exact-arithmetic core for the pairs it can prove and refuses loudly everywhere else, rather than covering the whole Simple Features matrix approximately. Widening the decided set — sfCrosses/sfOverlaps for mixed-dimension pairs, a full DE-9IM classifier, CRS-aware geodesic distance — would extend it, on the same no-floating-point-on-the-decided-path discipline the boundary cell above demonstrates.

What's next#

That closes the vocabulary tour the series set out to give. The full map of published posts, each with its central vocabulary and pinning test file, is in the series plan; the performance hub covers the runtime-vs-runtime side of the same engine.

Every live cell above is pinned in tests/hub/post21_test.mjs — the exact same source, executed against the real npm/factoidal typed API instead of the in-browser fn adapter.