From aba616f61d9b1731427f98b852d45a89136ad7bd Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 7 Oct 2026 23:09:26 +0200 Subject: [PATCH 01/13] feat(places): define regional ambient policy and canonical identity --- .changeset/quiet-ambient-places.md | 5 + .../plans/2026-10-07-ambient-places.md | 100 ++++++ .../specs/2026-10-07-ambient-places-design.md | 151 ++++++++ .../poi-overture/__tests__/provider.test.ts | 15 + integrations/poi-overture/index.ts | 6 +- packages/core/package.json | 4 + packages/core/src/ambient-places.test.ts | 109 ++++++ packages/core/src/ambient-places.ts | 335 ++++++++++++++++++ 8 files changed, 724 insertions(+), 1 deletion(-) create mode 100644 .changeset/quiet-ambient-places.md create mode 100644 docs/superpowers/plans/2026-10-07-ambient-places.md create mode 100644 docs/superpowers/specs/2026-10-07-ambient-places-design.md create mode 100644 packages/core/src/ambient-places.test.ts create mode 100644 packages/core/src/ambient-places.ts diff --git a/.changeset/quiet-ambient-places.md b/.changeset/quiet-ambient-places.md new file mode 100644 index 000000000..6f09617df --- /dev/null +++ b/.changeset/quiet-ambient-places.md @@ -0,0 +1,5 @@ +--- +"@openmapx/core": minor +--- + +Add regional ambient-place policy, generation metadata and canonical tile identity contracts. diff --git a/docs/superpowers/plans/2026-10-07-ambient-places.md b/docs/superpowers/plans/2026-10-07-ambient-places.md new file mode 100644 index 000000000..4ebabeecf --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-ambient-places.md @@ -0,0 +1,100 @@ +# Regional Ambient Places Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver #399 as a bounded, reversible regional ambient-place overlay with shared place identities. + +**Architecture:** Dedicated immutable PostGIS generations separate source updates from map reads. Data-manager publishes atomically; API serves bounded MVT and admin proxy operations. A built-in overlay owns rendering, attribution and conservative duplicate suppression. + +**Tech Stack:** TypeScript, postgres.js/PostGIS 3.6, Fastify 5, React 19, Next 16, MapLibre, Vitest, existing MUI admin patterns. + +**Spec:** `docs/superpowers/specs/2026-10-07-ambient-places-design.md` + +## Global Constraints + +- Work only in the attached isolated worktree; feature PR, no merge/deploy or approval checkpoints. +- No OpenConditions, adapters, feeds, mobility contracts, planet imports, direct ATP ingestion or geocoder rewrite. +- One bbox in `[5.8,47.2,15.1,55.1]`, width/height ≤0.5 degrees; initial Aachen `[5.9,50.65,6.3,50.95]`. +- At most 100,000 input rows per source and output places; 500-row writes; eight generations; seven-day cache lease; 90-day maximum source age. +- MVT zoom 13–18; 256 features, 128 KiB, 2-second SQL timeout, eight concurrent requests per API process. +- Keep IDs as strings; confidence ≥0.5 and known-open Overture; tenants zoom 18; labels ≤120 characters; basemap proximity match ≤8 metres and unique. +- Update existing docs and admin; screenshot assets outside repo; PR assigned Medformatik with existing appropriate labels, immediately linked in T3. + +## Review Focus + +- Large OSM bigint IDs stay exact across SQL, tile, tap and category/search identity (Task 1/2). +- Missing or rebuilding Overture cannot silently publish a mismatched partial generation (Task 2). +- Failed/concurrent publication and retention pressure preserve active/previous and old tile URLs (Task 2). +- Ambiguous same-name branches and non-ground tenants never inherit an incorrect basemap identity (Task 4). +- Late manifest responses, disable and style replacement never resurrect layers or stale canonical mappings (Task 4). + +--- + +### Task 1: Shared publication policy and canonical identity + +**Files:** Create `packages/core/src/ambient-places.ts`, `packages/core/src/ambient-places.test.ts`; modify `packages/core/package.json`; modify `integrations/poi-overture/index.ts` and provider tests; create a changeset. + +**Interfaces:** Produces `AmbientManifest`, `AmbientPlace`, `AmbientRegion`, `validateAmbientRegion(value)`, `ambientPlaceFromOsm(row)`, `ambientPlaceFromOverture(row)`, `mergeAmbientPlaces(osm, overture, links)`, `ambientPlaceToCategoryPlace(feature, locale)`, `matchAmbientBasemap(places, labels)` and `ambientIdentityKeys(place)` in `@openmapx/core/ambient-places`. Labels expose canonical IDs and GERS; shared matching uses 8m unique compatible-name rules. + +- [ ] Write behavior tests for Aachen/invalid bounds, 120-character localized fallback, closure/confidence rules, importance tiers, tenants, exact `osm:node/9007199254740993`, accepted-link fusion and ambiguous duplicate matching. Assert search-provider linked Overture returns OSM ID and retains GERS. +- [ ] Run `pnpm exec vitest run packages/core/src/ambient-places.test.ts integrations/poi-overture/__tests__/provider.test.ts`; expected: failing new policy/identity tests. +- [ ] Implement shared pure contract/policy and export subpath; change only Overture POI identity SQL to use accepted links to extant OSM records. No geocoder changes. +- [ ] Run the same command; expected: all pass. Add changeset for the new core subpath. +- [ ] Commit `feat(places): define regional ambient policy and canonical identity`. + +### Task 2: Atomic bounded PostGIS publisher and MVT repository + +**Files:** Create `packages/core/src/server/ambient-places.ts` and export `@openmapx/core/ambient-places-server`; create `services/data-manager/src/jobs/ambient-places/{schema,build,api}.ts`; modify `services/data-manager/src/api.ts`; create `services/data-manager/__tests__/ambient-places/publish-postgres.test.ts`. + +**Interfaces:** Consumes Task 1 contracts. Produces `readAmbientManifest(sql)`, `readAmbientTile(sql, generation,z,x,y)`, `buildAmbientPlaces(sql, region)`, `setAmbientEnabled(sql, enabled)`, `rollbackAmbientPlaces(sql)`, `registerAmbientPlacesApi(app, sql)`. SQL schema owns immutable generation/feature tables and singleton pointer. `GET /ambient-places/status`, `POST /ambient-places/build`, `POST /ambient-places/enabled`, `POST /ambient-places/rollback` are protected by existing data-manager auth. + +- [ ] Write real PostGIS tests with OSM/Overture fixtures for bounds, links, exact bigint, ranking cap, tile content, OSM-only metadata, unfinished/old source refusal, build failure preservation, old-generation byte equality, rollback, concurrent publishers and retention lease. +- [ ] Run `OPENMAPX_RUN_DATABASE_TESTS=1 pnpm exec vitest run services/data-manager/__tests__/ambient-places/publish-postgres.test.ts`; expected: new repository/publisher tests fail before implementation. +- [ ] Implement lazy schema setup under advisory lock, repeatable-read publisher, input/output limits, generation pruning, candidate validation and atomic pointer. Reuse existing source schemas; isolate all new tables. Serve deterministic indexed MVT with safe integer XYZ and statement timeout. Register authenticated asynchronous build/status controls. +- [ ] Run the same command; expected: all pass, real MVT round trips. Record dense/sparse/repeated tile and build timings in a temporary benchmark output for Task 5. +- [ ] Commit `feat(places): publish immutable regional MVT generations`. + +### Task 3: Public serving and authenticated operator API + +**Files:** Create `apps/api/src/routes/ambient-places.ts` and `apps/api/src/routes/ambient-places.test.ts`; modify `apps/api/src/routes/index.ts`, generated `apps/api/openapi.json`. + +**Interfaces:** Consumes Task 2 repository. Produces public manifest/tile routes and `/api/admin/ambient-places/{status,build,enabled,rollback}` proxy. Route factory injectable SQL/read/proxy seams follow existing Fastify tests. Admin requests use `requireAdmin`; mutation audit and validated data-manager origin. + +- [ ] Write API injection tests for absent manifest, invalid UUID/XYZ/region, immutable/no-store cache, over-byte/concurrency limits, tile SQL failure and public rejection of admin actions. +- [ ] Run `pnpm exec vitest run apps/api/src/routes/ambient-places.test.ts`; expected: new route tests fail. +- [ ] Implement routes with auth classifications, request schemas, error budgets and bounded validated proxy. Register in core route registry; regenerate OpenAPI using existing script. +- [ ] Run the same command plus API surface checks; expected: pass and generated contract matches routes. +- [ ] Commit `feat(api): serve bounded ambient tiles and operator controls`. + +### Task 4: Map overlay, duplicate ownership and admin workflow + +**Files:** Create `integrations/overlay-ambient-places/{manifest.json,package.json,store.ts,map-layer.tsx,legend.tsx,preview.svg,strings/en.json,strings/de.json,map-layer.test.tsx}`; create `apps/web/src/components/map/ambientPlaceIdentity.ts` and tests; modify `mapStylePoiTarget.ts`, `MapStylePoiClickHandler.tsx` and tests; create `apps/web/src/components/admin/services/AmbientPlacesMaintenance.tsx` and tests; modify `DataWorkflowsPage.tsx`; update workspace lockfile/integration inventory. + +**Interfaces:** Consumes Task 1/3. Map-instance WeakMap stores canonical basemap places; `StylePoiTarget.canonicalPlace` uses existing `categoryPlaceToPlace`. Overlay store defaults visible while respecting user changes; legend exposes source generation metadata. Admin controls call Task 3 endpoints. + +- [ ] Write tests for generation replacement, stale async response, disabled/no data, style re-registration, handler teardown, canonical tap/category suppression, ambiguous branches/non-ground tenants and per-map isolation; admin successful build/rollback and error state. +- [ ] Run focused tests; expected: new feature behaviors fail. +- [ ] Implement bounded vector source/layer group, 60s abortable manifest refresh, identity-only category/selection exclusion, conservative basemap lookup and canonical hover/tap. Reuse attribution and overlay registry, localized en/de copy. Add admin controls and count/inventory metadata. +- [ ] Run focused tests; expected: all pass. Perform native T3 browser before/after, map pan/zoom/style/tap/toggle, multilingual and admin QA using disposable fixture backend when necessary. +- [ ] Commit `feat(web): show ranked ambient places and publication controls`. + +### Task 5: Documentation, full verification and PR delivery + +**Files:** Modify `docs/docs/features/overture-places.md`, `docs/docs/features/map-layers.md`; create `docs/docs/development/ambient-places-publication.md` (adjust to existing docs development location if needed); PR body and screenshots outside repo. + +**Interfaces:** Consumes all tasks and measured evidence; produces current operator guidance and a verified feature PR. + +- [ ] Update existing docs with prerequisites, policies, attribution, source ages and disable/rollback; record PMTiles/Martin comparison and measured fixture budgets with limitations. +- [ ] Run `pnpm lint`, `pnpm check-types`, `pnpm test --maxWorkers=4`, relevant API/web/data-manager builds and the real PostGIS task suite; expected: all pass. Fix actual failures with regression tests where appropriate. +- [ ] Commit `docs(places): document regional publication and validation`. +- [ ] Generate final review package and dispatch one fresh strongest-model reviewer per executing-plans skill. Fix Critical/Important findings with red→green regressions; record rulings/deferred minors; rerun affected checks and full suite if code changes. +- [ ] Push through temporary HTTPS GitHub credentials; create PR closing #399 with validation and before/after evidence. Immediately T3-link it; assign Medformatik, apply existing relevant labels, verify metadata and thread PR list. Do not merge/deploy. + +## Plan self-review + +All issue acceptance criteria map to Tasks 1–5. Shared contract signatures and +limits match the spec. The five failure classes each have an owning test task. +The PMTiles comparison is a measured serving decision, not new offline scope. +Source publication timestamps are explicitly distinguished from real-world +freshness. Admin mutation auth and source license credit are required, not +follow-up work. Native execution is already authorized; no approval gates. diff --git a/docs/superpowers/specs/2026-10-07-ambient-places-design.md b/docs/superpowers/specs/2026-10-07-ambient-places-design.md new file mode 100644 index 000000000..775ce4eda --- /dev/null +++ b/docs/superpowers/specs/2026-10-07-ambient-places-design.md @@ -0,0 +1,151 @@ +# Regional ambient places (#399) + +## Outcome and scope + +Ordinary map browsing shows useful named places from the existing OSM search +index and optional Overture snapshot, without starting a category search. +The initial operator preset is Aachen: `[5.9, 50.65, 6.3, 50.95]`. An operator +may choose one other bounded German region. This is regional publication, not +new ingestion. OpenConditions, adapters, feeds, mobility contracts, planet +imports, direct ATP ingestion and geocoding behavior are outside the change. + +Use the attached isolated worktree and a feature PR. Preserve all other +checkouts. Implement and self-review without approval checkpoints; finish with +verification, current documentation and before/after PR screenshots. Do not +merge or deploy. Keep screenshot files outside the repository. + +## Storage and publication + +Publish an immutable generation in dedicated `ambient_places` PostGIS tables. +A repeatable-read transaction reads the existing OSM index, Overture records +and accepted OSM/GERS links together. It builds a separate candidate, validates +it, then atomically changes the singleton active pointer and previous pointer. +A failed build cannot replace the last good generation. A transaction-scoped +advisory lock rejects concurrent publishers and pointer mutations. Rollback +swaps active and previous; disable stops public discovery without deleting data. + +The region must be a finite, ordered bbox inside Germany's bounding rectangle +`[5.8,47.2,15.1,55.1]`, no more than 0.5 degrees wide or high. Input queries are +spatially indexed, limited to 100,001 rows each; more than 100,000 from either +source rejects publication. Candidate output is limited to 100,000 places, +written in batches of 500. No empty generation may replace a working layer. +Keep at most eight committed generations. Prune only unreferenced generations +older than seven days; if retention prevents freeing space, refuse the build. +Transactions have a 120-second statement timeout. Operator rebuilds run as +one asynchronous in-process job, with database locking as the cross-process +authority. A restart cannot leave a permanent job lock. + +Require a ready OSM epoch with a publication timestamp. If Overture exists, +require completed conflation for its current release. Missing Overture is an +explicit OSM-only coverage state, not an error. Reject source publications +older than 90 days; an active generation older than 90 days is not discovered. +Manifest reports source region, epoch/release, publication time, counts, +missing-source state, policy version and generation. These are snapshot ages, +not assertions about when the real-world place was last verified. Never expose +local source paths, fingerprints containing paths, or operator error details in +the public manifest. + +## Place policy and identity + +Use only existing accepted links, never a new proximity conflation during +publication. Linked pairs use `osm:/` and carry GERS; OSM wins location, +name and category while Overture fills missing localized names. Unlinked +Overture places use `overture:`. Preserve IDs as strings, including OSM +bigints. Overture search results with an accepted link use the same OSM primary +ID even when the OSM record was not returned by that search; retain GERS for +enrichment. Tile selection calls the existing `categoryPlaceToPlace` path so +search, place cards and saved places share the same identity contract. + +Exclude unnamed/invalid coordinates, closed/disused/abandoned OSM objects, +private access, permanently or temporarily closed Overture objects, unknown +operating status, and Overture confidence below 0.5 or missing. An open OSM +record remains usable if linked Overture is excluded. Non-ground floor/level +OSM tenants require zoom 18 and must not be matched to a basemap label by +proximity. Unknown floor is displayed conservatively and makes no entrance or +ground-level claim. No guessed brand replacement or transliteration. Carry +trimmed original name plus German and English names where actually supplied; +fall back from UI language to original name. Cap each label at 120 characters. + +Rank deterministically using OSM importance and category utility, never claimed +popularity. Health, transport and civic landmarks appear at zoom 13; everyday +shops and services at 15; other named places at 16; tenants at 18. Tie-break by +canonical ID. Tile properties include canonical ID, GERS, names, category, +rank, minimum zoom, tenant flag and source flags. Generation provenance belongs +to the manifest and source metadata; place cards use existing resolvers. + +## Serving decision and budgets + +Compare regional PMTiles against the existing Martin/PostGIS setup. Select +PostGIS MVT for this first online release: existing data and spatial indexes, +no new ingestion/archive tools, atomic generation pointer, bounded requests, +and simple rollback. Use the same PostGIS MVT functions Martin uses behind a +small API route, because generation validation, byte/concurrency bounds and +public/private metadata separation need an explicit contract. Martin remains +available for existing layers. PMTiles is attractive for immutable CDN/offline +archives but requires archive generation, coverage planning and update downloads; +it is deferred to #403 rather than silently promised here. + +Public routes: `/api/ambient-places/manifest` and +`/api/ambient-places/tiles/:generation/:z/:x/:y.mvt`. Manifest is no-store. Tile +URLs contain immutable UUID generations and use seven-day immutable cache. +Validate UUID and integer XYZ (zoom 13–18). A tile contains at most 256 ranked +features and 128 KiB, with a 2-second SQL timeout and at most eight concurrent +requests per API process. Over-budget requests fail without cacheable content; +missing/disabled/no-data manifests allow normal basemap browsing to continue. +Old generation URLs survive publication and rollback during the cache lease. +No query-time per-place enrichment, source calls, or unbounded regional scans. +Measure real PostGIS dense/sparse tiles, repeated tile requests and build time; +record fixture size and hardware/runtime limits honestly in the PR/docs. + +## Map behavior + +A built-in map overlay is on by default and independently switchable through +the existing layer selector. It requests only the advertised bounds at zoom +13–18. Refresh the manifest every 60 seconds, abort obsolete requests, retain a +last-known generation only while its age is acceptable, and release sources, +layers and event handlers on disable/unmount. Rebuild on style changes through +`useMapLayerGroup`. Use small point symbols and collision-controlled text under +basemap symbols so road/transit names retain priority. Use localized labels, +short halo text and deterministic symbol-sort-key. No DOM marker per place. + +Suppress ambient features already shown by category results or selected place, +using canonical IDs and GERS. For owned basemap labels, match explicit OSM refs +first; otherwise allow only one unique, same-name, compatible-category point +within 8 metres, never known non-ground tenants. Hide the ambient copy and carry +its canonical place into the basemap tap/hover path. Ambiguous names/branches +stay separate. Scope mappings to each Map instance; remove them on teardown. +Do not change other overlays' filters or selection behavior. + +The existing source-attribution registry credits OSM and all supported Overture +contributors. A compact overlay legend reports region, snapshot time and +OSM-only versus combined coverage; absence/error is stated without blocking the +map. Source license metadata remains sourced from existing manifests. + +## Administration and documentation + +Add an ambient-place maintenance card under Admin → Data workflows. Show active +and previous generations, source versions/age, counts, missing-source state, +last build result, and enablement. Provide bounded region inputs, publish, +enable/disable and rollback actions. Use existing admin authentication and +validated data-manager proxy; audit mutations. Failed builds retain last good +state and are visible to the operator. Do not make publication reachable from +public routes. + +Update existing Overture Places and Map Layers pages with prerequisites, +regional limits, source policy, cache/rollback operation and attribution. Add a +focused performance/architecture record including the PMTiles comparison and +actual measurements. Update API surface and integration inventory as required. + +## Verification + +Unit tests cover label/closure/ranking policy, string IDs, bounds and conservative +dedup. Real disposable PostGIS tests prove spatial selection, accepted-link +identity, MVT decoding/content, deterministic feature budgets, immutable old +tiles, atomic failure, retention, rollback and concurrency. API injection tests +prove public validation/cache/error budgets and admin separation. Component and +map tests cover source switching, style reload, stale fetches, no-data/disabled, +selection and handlers. Live browser QA exercises dense and sparse fixtures, +locale, tile tap and details, pan/zoom, overlay toggle, basemap style change and +admin workflow. Screenshots are explicitly identified as fixture-backed if no +regional source data is locally available. Run repository lint, types, unit +suite and relevant builds; report environmental verification limits precisely. diff --git a/integrations/poi-overture/__tests__/provider.test.ts b/integrations/poi-overture/__tests__/provider.test.ts index ea2694e11..08f1da20f 100644 --- a/integrations/poi-overture/__tests__/provider.test.ts +++ b/integrations/poi-overture/__tests__/provider.test.ts @@ -228,3 +228,18 @@ describe("poi-overture health check", () => { expect(status.status).toBe("down"); }); }); + +describe("canonical accepted-link identity", () => { + it("uses the accepted OSM id even when only the Overture search row is returned", async () => { + const db = makeFakeDb([{ ...FIXTURE_ROWS[0], canonical_id: "osm:node/9007199254740993" }]); + const ctx = createMockIntegrationContext({ db }); + const { setup } = await import("../index.js"); + setup(ctx); + const provider = ctx.registered.poiSearch[0]; + const result = await toOutcome(provider.search("cafes", BBOX)); + expect(result.results[0]).toMatchObject({ + id: "osm:node/9007199254740993", + gersId: "gers-abc-001", + }); + }); +}); diff --git a/integrations/poi-overture/index.ts b/integrations/poi-overture/index.ts index 3186528a4..4b9ba15b7 100644 --- a/integrations/poi-overture/index.ts +++ b/integrations/poi-overture/index.ts @@ -16,6 +16,7 @@ import type { import { registerPlaceResolver } from "@openmapx/place-ids"; interface OvertureRow { + canonical_id?: string | null; gers_id: string; name: string; longitude: number; @@ -140,7 +141,7 @@ function overtureRowToPoiSearchResult( }); return { - id: `overture:${row.gers_id}`, + id: row.canonical_id ?? `overture:${row.gers_id}`, gersId: row.gers_id, name: localized.name, coordinates: [row.longitude, row.latitude], @@ -173,6 +174,9 @@ async function queryOverturePlaces( const { bbox, concepts, minConfidence } = opts; const sql = ` SELECT + (SELECT 'osm:' || link.osm_type || '/' || link.osm_id::TEXT + FROM overture_places.poi_conflation_link link + WHERE link.gers_id = places.gers_id) AS canonical_id, gers_id, name, ST_X(geom) AS longitude, diff --git a/packages/core/package.json b/packages/core/package.json index 2cd04706a..0c31a710c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -137,6 +137,10 @@ "./coverage": { "types": "./src/coverage/index.ts", "default": "./src/coverage/index.ts" + }, + "./ambient-places": { + "types": "./src/ambient-places.ts", + "default": "./src/ambient-places.ts" } }, "scripts": { diff --git a/packages/core/src/ambient-places.test.ts b/packages/core/src/ambient-places.test.ts new file mode 100644 index 000000000..cf7455c10 --- /dev/null +++ b/packages/core/src/ambient-places.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { + ambientPlaceFromOsm, + ambientPlaceFromOverture, + ambientPlaceToCategoryPlace, + matchAmbientBasemap, + mergeAmbientPlaces, + validateAmbientRegion, +} from "./ambient-places"; + +const osm = { + osm_type: "node", + osm_id: "9007199254740993", + name: "Klinik", + lng: 6.08, + lat: 50.77, + category: "amenity:hospital", + importance: 0.8, + tags: { "name:en": "Clinic" }, +}; +const overture = { + gers_id: "gers-a", + name: "Clinic", + longitude: 6.08, + latitude: 50.77, + basic_category: "hospital", + names: { common: { de: "Krankenhaus" } }, + confidence: 0.8, + operating_status: "open", +}; + +describe("ambient place policy", () => { + it("accepts a bounded Aachen region and rejects arbitrary/planet inputs", () => { + expect(validateAmbientRegion({ name: "Aachen", bounds: [5.9, 50.65, 6.3, 50.95] }).name).toBe( + "Aachen", + ); + for (const bounds of [ + [5, 50, 6, 51], + [6, 50, 7, 50.1], + [6, 50, 6, 50.1], + [6, NaN, 6.1, 50.1], + ]) { + expect(() => validateAmbientRegion({ name: "region", bounds })).toThrow(); + } + }); + it("keeps bigint identity and original/localized labels without guessing", () => { + const place = ambientPlaceFromOsm(osm)!; + expect(place.id).toBe("osm:node/9007199254740993"); + expect(ambientPlaceToCategoryPlace(place, "en").name).toBe("Clinic"); + expect(ambientPlaceToCategoryPlace(place, "fr").name).toBe("Klinik"); + expect(ambientPlaceFromOsm({ ...osm, name: "a".repeat(121) })!.name).toHaveLength(120); + }); + it("excludes closed/private OSM and uncertain/closed Overture", () => { + for (const tags of [ + { disused: "yes" }, + { access: "private" }, + { "abandoned:amenity": "hospital" }, + { "disused:shop": "bakery" }, + ] as Record[]) { + expect(ambientPlaceFromOsm({ ...osm, tags })).toBeNull(); + } + for (const patch of [ + { confidence: 0.49 }, + { confidence: null }, + { operating_status: "permanently_closed" }, + { operating_status: "temporarily_closed" }, + { operating_status: null }, + ]) { + expect(ambientPlaceFromOverture({ ...overture, ...patch })).toBeNull(); + } + expect(ambientPlaceFromOverture({ ...overture, confidence: 0.5 })).not.toBeNull(); + }); + it("ranks useful categories first and defers tenants to zoom 18", () => { + expect(ambientPlaceFromOsm(osm)!.minZoom).toBe(13); + expect(ambientPlaceFromOsm({ ...osm, category: "shop:bakery" })!.minZoom).toBe(15); + expect(ambientPlaceFromOsm({ ...osm, category: "tourism:artwork" })!.minZoom).toBe(16); + expect(ambientPlaceFromOsm({ ...osm, tags: { level: "1" } })!.minZoom).toBe(18); + }); + it("fuses only accepted links, preserves OSM location and survives excluded Overture", () => { + const a = ambientPlaceFromOsm(osm)!; + const b = ambientPlaceFromOverture(overture)!; + const fused = mergeAmbientPlaces([a], [b], new Map([[a.id, b.gersId!]])); + expect(fused).toHaveLength(1); + expect(fused[0]).toMatchObject({ + id: a.id, + gersId: "gers-a", + name: "Klinik", + coordinates: [6.08, 50.77], + sources: "osm,overture", + }); + expect(fused[0].names.de).toBe("Krankenhaus"); + expect(mergeAmbientPlaces([a], [b], new Map())).toHaveLength(2); + expect(mergeAmbientPlaces([a], [], new Map([[a.id, "gers-a"]]))[0].gersId).toBe("gers-a"); + }); + it("matches unique nearby compatible basemap labels and refuses branches and tenants", () => { + const a = ambientPlaceFromOsm(osm)!; + const label = { + key: "base/1", + name: "Klinik", + coordinates: [6.08, 50.77] as [number, number], + category: "hospital", + }; + expect(matchAmbientBasemap([a], [label]).get(label.key)?.id).toBe(a.id); + expect(matchAmbientBasemap([a, { ...a, id: "osm:node/2" }], [label]).size).toBe(0); + expect(matchAmbientBasemap([{ ...a, tenant: true }], [label]).size).toBe(0); + expect(matchAmbientBasemap([a], [{ ...label, category: "restaurant" }]).size).toBe(0); + expect(matchAmbientBasemap([a], [{ ...label, coordinates: [6.081, 50.77] }]).size).toBe(0); + }); +}); diff --git a/packages/core/src/ambient-places.ts b/packages/core/src/ambient-places.ts new file mode 100644 index 000000000..6b73a1f3b --- /dev/null +++ b/packages/core/src/ambient-places.ts @@ -0,0 +1,335 @@ +import type { CategoryPlace } from "./types/category"; +import type { Place } from "./types/place"; +import { haversineDistance } from "./utils/coordinates"; +import { overtureTaxonomyToOpenMapX } from "./utils/overtureCategoryMap"; + +export const AMBIENT_POLICY_VERSION = 1; +export const AMBIENT_MAX_AGE_MS = 90 * 86400_000; +export const AMBIENT_LIMITS = { + places: 100_000, + generations: 8, + tileFeatures: 256, + tileBytes: 128 * 1024, + minZoom: 13, + maxZoom: 18, + cacheSeconds: 7 * 86400, +} as const; +export interface AmbientRegion { + name: string; + bounds: [number, number, number, number]; +} +export interface AmbientPlace { + id: string; + gersId?: string; + name: string; + names: Record; + coordinates: [number, number]; + category: string; + rank: number; + minZoom: number; + tenant: boolean; + sources: "osm" | "overture" | "osm,overture"; +} +export interface AmbientManifest { + version: 1; + policyVersion: number; + generation: string; + publishedAt: string; + region: AmbientRegion; + placeCount: number; + enabled: boolean; + sources: { + osm: { region: string; epoch: string; publishedAt: string; count: number }; + overture: { region: string; release: string; publishedAt: string; count: number } | null; + }; +} +export interface AmbientOsmRow { + osm_type: string; + osm_id: string; + name: string; + lng: number; + lat: number; + category: string | null; + importance: number; + tags: Record; +} +export interface AmbientOvertureRow { + gers_id: string; + name: string; + longitude: number; + latitude: number; + basic_category: string | null; + taxonomy_primary?: string | null; + taxonomy_hierarchy?: string[] | null; + taxonomy_alternates?: string[] | null; + names?: { common?: Record | null } | null; + confidence: number | null; + operating_status: string | null; +} +export function validateAmbientRegion(value: unknown): AmbientRegion { + const region = value as AmbientRegion; + const b = region?.bounds; + if ( + typeof region?.name !== "string" || + !region.name.trim() || + region.name.length > 80 || + !Array.isArray(b) || + b.length !== 4 || + !b.every(Number.isFinite) || + b[0] < 5.8 || + b[1] < 47.2 || + b[2] > 15.1 || + b[3] > 55.1 || + b[2] <= b[0] || + b[3] <= b[1] || + b[2] - b[0] > 0.5 || + b[3] - b[1] > 0.5 + ) + throw new Error("Choose an ordered German region no wider or higher than 0.5 degrees"); + return { name: region.name.trim(), bounds: [...b] }; +} +const aliases: Record = { + hospitals: "hospital", + doctors: "doctor", + pharmacies: "pharmacy", + restaurants: "restaurant", + cafes: "cafe", + banks: "bank", + hotels: "hotel", + supermarkets: "supermarket", + bakeries: "bakery", + museums: "museum", + coffee_shop: "cafe", + railway_station: "station", + townhall: "townhall", +}; +export function ambientCategory(value: string): string { + const raw = value.split(":").at(-1)!; + return aliases[raw] ?? raw; +} +const essential = new Set([ + "hospital", + "doctor", + "pharmacy", + "station", + "aerodrome", + "townhall", + "library", + "police", + "fire_station", +]); +const everyday = new Set([ + "restaurant", + "cafe", + "bank", + "hotel", + "supermarket", + "bakery", + "fuel", + "gas_station", + "shop", + "dentist", + "post_office", +]); +function label(value: unknown): string { + return typeof value === "string" ? [...value.trim()].slice(0, 120).join("") : ""; +} +function point(lng: number, lat: number): boolean { + return ( + Number.isFinite(lng) && + Number.isFinite(lat) && + Math.abs(lng) <= 180 && + Math.abs(lat) <= 85.051129 + ); +} +function policy(category: string, importance: number, tenant: boolean) { + const c = ambientCategory(category); + const tier = essential.has(c) ? 3 : everyday.has(c) || category.startsWith("shop:") ? 2 : 1; + return { + rank: + tier * 100 + + Math.round(Math.max(0, Math.min(1, Number.isFinite(importance) ? importance : 0)) * 99), + minZoom: tenant ? 18 : tier === 3 ? 13 : tier === 2 ? 15 : 16, + }; +} +export function ambientPlaceFromOsm(row: AmbientOsmRow): AmbientPlace | null { + const tags = row.tags ?? {}; + const name = label(row.name); + if ( + !name || + !point(row.lng, row.lat) || + !/^(node|way|relation)$/.test(row.osm_type) || + !/^\d+$/.test(row.osm_id) || + tags.access === "private" || + ["disused", "abandoned", "demolished", "removed", "closed"].some( + (k) => tags[k] === "yes" || Object.keys(tags).some((t) => t.startsWith(`${k}:`)), + ) + ) + return null; + const tenant = [tags.level, tags.floor].some((v) => v !== undefined && v !== "0" && v !== "0.0"); + const names: Record = {}; + for (const lang of ["de", "en"]) { + const v = label(tags[`name:${lang}`]); + if (v) names[lang] = v; + } + const category = row.category ?? "place"; + return { + id: `osm:${row.osm_type}/${row.osm_id}`, + name, + names, + coordinates: [row.lng, row.lat], + category: ambientCategory(category), + ...policy(category, row.importance, tenant), + tenant, + sources: "osm", + }; +} +export function ambientPlaceFromOverture(row: AmbientOvertureRow): AmbientPlace | null { + const name = label(row.name); + if ( + !name || + !row.gers_id || + !point(row.longitude, row.latitude) || + row.operating_status !== "open" || + row.confidence === null || + !Number.isFinite(row.confidence) || + row.confidence < 0.5 + ) + return null; + const names: Record = {}; + for (const lang of ["de", "en"]) { + const v = label(row.names?.common?.[lang]); + if (v) names[lang] = v; + } + const category = ambientCategory( + overtureTaxonomyToOpenMapX({ + basicCategory: row.basic_category, + primary: row.taxonomy_primary, + hierarchy: row.taxonomy_hierarchy, + alternates: row.taxonomy_alternates, + }) ?? + row.basic_category ?? + "place", + ); + return { + id: `overture:${row.gers_id}`, + gersId: row.gers_id, + name, + names, + coordinates: [row.longitude, row.latitude], + category, + ...policy(category, 0, false), + tenant: false, + sources: "overture", + }; +} +export function mergeAmbientPlaces( + osm: AmbientPlace[], + overture: AmbientPlace[], + links: Map, +): AmbientPlace[] { + const byGers = new Map(overture.map((p) => [p.gersId, p])); + const consumed = new Set(); + const result = osm.map((p) => { + const gersId = links.get(p.id); + if (!gersId) return p; + consumed.add(gersId); + const o = byGers.get(gersId); + return { + ...p, + gersId, + names: { ...o?.names, ...p.names }, + sources: o ? ("osm,overture" as const) : ("osm" as const), + }; + }); + for (const p of overture) if (!consumed.has(p.gersId!)) result.push(p); + return result.sort((a, b) => b.rank - a.rank || a.id.localeCompare(b.id, "en")); +} +export function ambientPlaceToCategoryPlace(place: AmbientPlace, locale: string): CategoryPlace { + return { + id: place.id, + gersId: place.gersId, + name: place.names[locale.split("-")[0]] || place.name, + names: place.names, + coordinates: place.coordinates, + category: place.category, + }; +} +export function ambientIdentityKeys( + place: Pick | Pick, +): string[] { + const ids = "ids" in place ? place.ids : undefined; + const gers = "gersId" in place ? place.gersId : (ids?.overture ?? ids?.gers); + return [place.id, ...(gers ? [`overture:${gers}`] : []), ...(ids?.osm ? [`osm:${ids.osm}`] : [])]; +} +export interface AmbientBasemapLabel { + key: string; + name: string; + coordinates: [number, number]; + category?: string; + osmId?: string; +} +export function matchAmbientBasemap( + places: AmbientPlace[], + labels: AmbientBasemapLabel[], +): Map { + const matches = new Map(); + const normalize = (s: string) => s.trim().normalize("NFKC").toLocaleLowerCase("de"); + const byName = new Map(); + const byId = new Map(places.map((p) => [p.id, p])); + for (const p of places) + if (!p.tenant) + for (const name of new Set([p.name, ...Object.values(p.names)].map(normalize))) { + const bucket = byName.get(name) ?? []; + bucket.push(p); + byName.set(name, bucket); + } + for (const l of labels) { + if (l.osmId && byId.has(l.osmId)) { + matches.set(l.key, byId.get(l.osmId)!); + continue; + } + const candidates = (byName.get(normalize(l.name)) ?? []).filter( + (p) => + l.category && + ambientCategory(l.category) === p.category && + haversineDistance(p.coordinates, l.coordinates) <= 8, + ); + if (candidates.length === 1) matches.set(l.key, candidates[0]); + } + return matches; +} +/** Only schema-versioned, public properties are read from a vector feature. */ +export function ambientPlaceFromTile( + properties: Record, + coordinates: number[], +): AmbientPlace | null { + if ( + typeof properties.id !== "string" || + !/^(osm:(node|way|relation)\/\d+|overture:.+)$/.test(properties.id) || + typeof properties.name !== "string" || + !point(coordinates[0], coordinates[1]) + ) + return null; + return { + id: properties.id, + gersId: + typeof properties.gers_id === "string" && properties.gers_id ? properties.gers_id : undefined, + name: properties.name, + names: { + ...(properties.name_de ? { de: String(properties.name_de) } : {}), + ...(properties.name_en ? { en: String(properties.name_en) } : {}), + }, + coordinates: [coordinates[0], coordinates[1]], + category: String(properties.category ?? "place"), + rank: Number(properties.rank), + minZoom: Number(properties.min_zoom), + tenant: properties.tenant === true || properties.tenant === 1, + sources: + properties.sources === "osm,overture" + ? "osm,overture" + : properties.sources === "overture" + ? "overture" + : "osm", + }; +} From 97bcbf5de54f53d28849719e76c0a908a6873375 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 7 Oct 2026 23:55:09 +0200 Subject: [PATCH 02/13] feat(places): publish immutable regional MVT generations --- integrations/poi-overture/index.ts | 2 +- package.json | 2 +- packages/core/package.json | 13 +- packages/core/src/ambient-places.test.ts | 9 + packages/core/src/ambient-places.ts | 9 +- packages/core/src/server/ambient-places.ts | 80 +++++++ pnpm-lock.yaml | 10 + .../__tests__/ambient-places/api.test.ts | 38 +++ .../ambient-places/publish-postgres.test.ts | 189 +++++++++++++++ services/data-manager/package.json | 4 +- services/data-manager/src/api.ts | 2 + .../src/jobs/ambient-places/api.ts | 115 +++++++++ .../src/jobs/ambient-places/build.ts | 222 ++++++++++++++++++ .../src/jobs/ambient-places/schema.ts | 41 ++++ 14 files changed, 730 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/server/ambient-places.ts create mode 100644 services/data-manager/__tests__/ambient-places/api.test.ts create mode 100644 services/data-manager/__tests__/ambient-places/publish-postgres.test.ts create mode 100644 services/data-manager/src/jobs/ambient-places/api.ts create mode 100644 services/data-manager/src/jobs/ambient-places/build.ts create mode 100644 services/data-manager/src/jobs/ambient-places/schema.ts diff --git a/integrations/poi-overture/index.ts b/integrations/poi-overture/index.ts index 4b9ba15b7..68cf8d599 100644 --- a/integrations/poi-overture/index.ts +++ b/integrations/poi-overture/index.ts @@ -176,7 +176,7 @@ async function queryOverturePlaces( SELECT (SELECT 'osm:' || link.osm_type || '/' || link.osm_id::TEXT FROM overture_places.poi_conflation_link link - WHERE link.gers_id = places.gers_id) AS canonical_id, + WHERE link.gers_id = places.gers_id AND link.release = places.release) AS canonical_id, gers_id, name, ST_X(geom) AS longitude, diff --git a/package.json b/package.json index bf4dec7d0..e290531e7 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "check-duplicates": "jscpd --config .jscpd.json --cross-formats js-ts --no-tips apps packages integrations services scripts", "check-infrastructure": "bash scripts/check-infrastructure.sh", "test": "vitest run", - "test:database": "OPENMAPX_RUN_DATABASE_TESTS=1 vitest run --project node --maxWorkers=1 apps/api/src/privacy/*-postgres.test.ts apps/api/src/auth-postgres.test.ts apps/api/src/services/mobileAuthHandoff.test.ts apps/api/src/services/capability-bindings.test.ts apps/api/src/db/__tests__/extension-component-ownership-migration.test.ts services/data-manager/__tests__/offline-package-postgres-accounting.test.ts apps/api/src/db/__tests__/vehicles-parking-postgres.test.ts apps/api/src/db/__tests__/garage-isolation-postgres.test.ts apps/api/src/routes/__tests__/shares-postgres.test.ts apps/api/src/db/__tests__/user-erasure-postgres.test.ts packages/cli/__tests__/backup-restore-postgres.integration.test.ts services/data-manager/__tests__/poi-ingest/e2e-bnetza.test.ts services/data-manager/__tests__/poi-ingest/static-reuse-postgres.test.ts services/data-manager/__tests__/search-index/schema-postgres.test.ts services/data-manager/__tests__/search-index/build-postgres.test.ts services/data-manager/__tests__/notable-places/build-postgres.test.ts services/data-manager/__tests__/overture/schema-postgres.test.ts services/data-manager/__tests__/overture/runtime-postgres.test.ts services/data-manager/__tests__/coverage/poi-evidence-postgres.test.ts", + "test:database": "OPENMAPX_RUN_DATABASE_TESTS=1 vitest run --project node --maxWorkers=1 apps/api/src/privacy/*-postgres.test.ts apps/api/src/auth-postgres.test.ts apps/api/src/services/mobileAuthHandoff.test.ts apps/api/src/services/capability-bindings.test.ts apps/api/src/db/__tests__/extension-component-ownership-migration.test.ts services/data-manager/__tests__/offline-package-postgres-accounting.test.ts apps/api/src/db/__tests__/vehicles-parking-postgres.test.ts apps/api/src/db/__tests__/garage-isolation-postgres.test.ts apps/api/src/routes/__tests__/shares-postgres.test.ts apps/api/src/db/__tests__/user-erasure-postgres.test.ts packages/cli/__tests__/backup-restore-postgres.integration.test.ts services/data-manager/__tests__/poi-ingest/e2e-bnetza.test.ts services/data-manager/__tests__/poi-ingest/static-reuse-postgres.test.ts services/data-manager/__tests__/search-index/schema-postgres.test.ts services/data-manager/__tests__/search-index/build-postgres.test.ts services/data-manager/__tests__/notable-places/build-postgres.test.ts services/data-manager/__tests__/overture/schema-postgres.test.ts services/data-manager/__tests__/overture/runtime-postgres.test.ts services/data-manager/__tests__/coverage/poi-evidence-postgres.test.ts services/data-manager/__tests__/ambient-places/publish-postgres.test.ts", "test:watch": "vitest", "test:coverage": "OPENMAPX_SKIP_SLOW_CRYPTO=1 vitest run --coverage", "prepare": "husky", diff --git a/packages/core/package.json b/packages/core/package.json index 0c31a710c..28dc02ded 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -141,6 +141,10 @@ "./ambient-places": { "types": "./src/ambient-places.ts", "default": "./src/ambient-places.ts" + }, + "./ambient-places-server": { + "types": "./src/server/ambient-places.ts", + "default": "./src/server/ambient-places.ts" } }, "scripts": { @@ -157,14 +161,19 @@ "esbuild": "^0.28.2", "react-dom": "19.3.0", "typescript": "^6.0.3", - "vitest": "^5.0.1" + "vitest": "^5.0.1", + "postgres": "^3.4.9" }, "peerDependencies": { - "esbuild": "^0.28.0" + "esbuild": "^0.28.0", + "postgres": "^3.4.9" }, "peerDependenciesMeta": { "esbuild": { "optional": true + }, + "postgres": { + "optional": true } }, "dependencies": { diff --git a/packages/core/src/ambient-places.test.ts b/packages/core/src/ambient-places.test.ts index cf7455c10..cff107c0a 100644 --- a/packages/core/src/ambient-places.test.ts +++ b/packages/core/src/ambient-places.test.ts @@ -107,3 +107,12 @@ describe("ambient place policy", () => { expect(matchAmbientBasemap([a], [{ ...label, coordinates: [6.081, 50.77] }]).size).toBe(0); }); }); + +describe("accepted-link gap-fill identity", () => { + it("keeps canonical identity when only GERS is in the regional view, and suppresses a known closed OSM match", () => { + const p = ambientPlaceFromOverture(overture)!; + const links = new Map([["osm:node/9007199254740993", "gers-a"]]); + expect(mergeAmbientPlaces([], [p], links)[0].id).toBe("osm:node/9007199254740993"); + expect(mergeAmbientPlaces([], [p], links, new Set(["osm:node/9007199254740993"]))).toEqual([]); + }); +}); diff --git a/packages/core/src/ambient-places.ts b/packages/core/src/ambient-places.ts index 6b73a1f3b..c23ad6aee 100644 --- a/packages/core/src/ambient-places.ts +++ b/packages/core/src/ambient-places.ts @@ -227,6 +227,7 @@ export function mergeAmbientPlaces( osm: AmbientPlace[], overture: AmbientPlace[], links: Map, + excludedOsm: ReadonlySet = new Set(), ): AmbientPlace[] { const byGers = new Map(overture.map((p) => [p.gersId, p])); const consumed = new Set(); @@ -242,7 +243,13 @@ export function mergeAmbientPlaces( sources: o ? ("osm,overture" as const) : ("osm" as const), }; }); - for (const p of overture) if (!consumed.has(p.gersId!)) result.push(p); + const canonicalByGers = new Map([...links].map(([id, gers]) => [gers, id])); + for (const p of overture) { + if (!p.gersId || consumed.has(p.gersId)) continue; + const canonicalId = canonicalByGers.get(p.gersId); + if (canonicalId && excludedOsm.has(canonicalId)) continue; + result.push(canonicalId ? { ...p, id: canonicalId } : p); + } return result.sort((a, b) => b.rank - a.rank || a.id.localeCompare(b.id, "en")); } export function ambientPlaceToCategoryPlace(place: AmbientPlace, locale: string): CategoryPlace { diff --git a/packages/core/src/server/ambient-places.ts b/packages/core/src/server/ambient-places.ts new file mode 100644 index 000000000..5a585e35e --- /dev/null +++ b/packages/core/src/server/ambient-places.ts @@ -0,0 +1,80 @@ +import type postgres from "postgres"; +import { AMBIENT_LIMITS, AMBIENT_MAX_AGE_MS, type AmbientManifest } from "../ambient-places"; + +export function validAmbientTile(generation: string, z: number, x: number, y: number): boolean { + return ( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(generation) && + [z, x, y].every(Number.isSafeInteger) && + z >= AMBIENT_LIMITS.minZoom && + z <= AMBIENT_LIMITS.maxZoom && + x >= 0 && + y >= 0 && + x < 2 ** z && + y < 2 ** z + ); +} +export async function readAmbientManifest(sql: postgres.Sql): Promise { + const [exists] = await sql.unsafe<{ exists: boolean }[]>( + `SELECT to_regclass('ambient_places.state') IS NOT NULL AS exists`, + ); + if (!exists.exists) return null; + const [row] = await sql.unsafe<{ manifest: AmbientManifest; enabled: boolean }[]>( + `SELECT g.manifest,s.enabled FROM ambient_places.state s JOIN ambient_places.generations g ON g.id=s.active WHERE s.singleton=1`, + ); + if (!row) return null; + const dates = [ + row.manifest.publishedAt, + row.manifest.sources.osm.publishedAt, + ...(row.manifest.sources.overture ? [row.manifest.sources.overture.publishedAt] : []), + ]; + const fresh = dates.every( + (date) => + Number.isFinite(Date.parse(date)) && Date.now() - Date.parse(date) <= AMBIENT_MAX_AGE_MS, + ); + return { ...row.manifest, enabled: row.enabled && fresh }; +} +export async function readAmbientTile( + sql: postgres.Sql, + generation: string, + z: number, + x: number, + y: number, +): Promise { + if (!validAmbientTile(generation, z, x, y)) throw new Error("Invalid ambient tile coordinates"); + return sql.begin(async (tx) => { + await tx.unsafe(`SET LOCAL statement_timeout='2000ms'`); + const [exists] = await tx.unsafe<{ exists: boolean }[]>( + `SELECT to_regclass('ambient_places.generations') IS NOT NULL AS exists`, + ); + if (!exists.exists) return null; + const [known] = await tx.unsafe<{ exists: boolean }[]>( + `SELECT EXISTS(SELECT 1 FROM ambient_places.generations WHERE id=$1) AS exists`, + [generation], + ); + if (!known.exists) return null; + const [result] = await tx.unsafe<{ tile: Buffer }[]>( + ` + WITH candidates AS MATERIALIZED ( + SELECT id,gers_id,name,name_de,name_en,category,rank,min_zoom,tenant,sources,geom + FROM ambient_places.features + WHERE generation=$1 AND min_zoom<=$2 AND geom && ST_TileEnvelope($2,$3,$4,margin=>64.0/4096) + ORDER BY rank DESC,id COLLATE "C" LIMIT ${AMBIENT_LIMITS.tileFeatures} + ), budgeted AS ( + SELECT *, sum(octet_length(id)+coalesce(octet_length(gers_id),0)+octet_length(name) + +coalesce(octet_length(name_de),0)+coalesce(octet_length(name_en),0) + +octet_length(category)+octet_length(sources)+160) + OVER(ORDER BY rank DESC,id COLLATE "C" ROWS UNBOUNDED PRECEDING) AS bytes + FROM candidates + ), bounded AS ( + SELECT id,gers_id,name,name_de,name_en,category,rank,min_zoom,tenant,sources, + ST_AsMVTGeom(geom,ST_TileEnvelope($2,$3,$4),4096,64,true) AS geom + FROM budgeted WHERE bytes<=${AMBIENT_LIMITS.tileBytes - 1024} + ORDER BY rank DESC,id COLLATE "C" + ) SELECT ST_AsMVT(bounded,'ambient_places',4096,'geom') AS tile FROM bounded`, + [generation, z, x, y], + ); + if (result.tile.length > AMBIENT_LIMITS.tileBytes) + throw new Error("Ambient tile byte budget exceeded"); + return result.tile; + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e324d256e..5def348f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2221,6 +2221,9 @@ importers: esbuild: specifier: '>=0.28.1' version: 0.28.2 + postgres: + specifier: ^3.4.9 + version: 3.4.9 react-dom: specifier: 19.3.0 version: 19.3.0(react@19.3.0) @@ -2681,12 +2684,18 @@ importers: specifier: ^8.10.2 version: 8.10.2 devDependencies: + '@mapbox/vector-tile': + specifier: 3.0.0 + version: 3.0.0 '@testcontainers/postgresql': specifier: ^12.1.0 version: 12.1.0(supports-color@8.1.1) '@types/node': specifier: ^26.6.2 version: 26.6.3 + pbf: + specifier: 5.1.2 + version: 5.1.2 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -9454,6 +9463,7 @@ packages: pbf@5.1.2: resolution: {integrity: sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==} + hasBin: true perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} diff --git a/services/data-manager/__tests__/ambient-places/api.test.ts b/services/data-manager/__tests__/ambient-places/api.test.ts new file mode 100644 index 000000000..1608b8d1a --- /dev/null +++ b/services/data-manager/__tests__/ambient-places/api.test.ts @@ -0,0 +1,38 @@ +import Fastify from "fastify"; +import type postgres from "postgres"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/jobs/ambient-places/build.js", () => ({ + buildAmbientPlaces: async () => ({}), + rollbackAmbientPlaces: async () => {}, + setAmbientEnabled: async () => {}, +})); + +import { registerAmbientPlacesApi } from "../../src/jobs/ambient-places/api.js"; + +describe("ambient publication job lifecycle", () => { + it("allows retry after an initial job status write fails", async () => { + let fail = true; + const sql = { + begin: async (callback: (tx: { unsafe: () => Promise }) => Promise) => + callback({ unsafe: async () => [] }), + unsafe: async (query: string) => { + if (query.includes("last_build_started_at=now()") && fail) { + fail = false; + throw new Error("Temporary database outage"); + } + return [{ exists: false }]; + }, + } as unknown as postgres.Sql; + const app = Fastify(); + registerAmbientPlacesApi(app, sql); + const request = { + method: "POST" as const, + url: "/ambient-places/build", + payload: { name: "Aachen", bounds: [5.9, 50.65, 6.3, 50.95] }, + }; + expect((await app.inject(request)).statusCode).toBe(500); + expect((await app.inject(request)).statusCode).toBe(202); + await app.close(); + }); +}); diff --git a/services/data-manager/__tests__/ambient-places/publish-postgres.test.ts b/services/data-manager/__tests__/ambient-places/publish-postgres.test.ts new file mode 100644 index 000000000..c7825334e --- /dev/null +++ b/services/data-manager/__tests__/ambient-places/publish-postgres.test.ts @@ -0,0 +1,189 @@ +import { VectorTile } from "@mapbox/vector-tile"; +import { AMBIENT_LIMITS } from "@openmapx/core/ambient-places"; +import { readAmbientManifest, readAmbientTile } from "@openmapx/core/ambient-places-server"; +import { PbfReader } from "pbf"; +import { describe, expect, it } from "vitest"; +import { + buildAmbientPlaces, + rollbackAmbientPlaces, + setAmbientEnabled, +} from "../../src/jobs/ambient-places/build.js"; +import { buildSchemaDDL } from "../../src/jobs/overture/schema.js"; +import { + buildSearchIndexIndexesDDL, + buildSearchIndexSchemaDDL, +} from "../../src/jobs/search-index/schema.js"; +import { startPostgis } from "../poi-ingest/_testcontainer.js"; + +const region = { + name: "Aachen", + bounds: [5.9, 50.65, 6.3, 50.95] as [number, number, number, number], +}; +const tile = { + z: 16, + x: Math.floor(((6.08 + 180) / 360) * 2 ** 16), + y: Math.floor(((1 - Math.asinh(Math.tan((50.77 * Math.PI) / 180)) / Math.PI) / 2) * 2 ** 16), +}; + +describe.skipIf(process.env.OPENMAPX_RUN_DATABASE_TESTS !== "1")( + "ambient PostGIS publication", + () => { + it("publishes bounded canonical tiles atomically, preserves old bytes and rolls back", async () => { + const pg = await startPostgis(); + try { + expect(await readAmbientManifest(pg.sql)).toBeNull(); + await pg.sql.unsafe( + buildSearchIndexSchemaDDL("osm_search") + buildSearchIndexIndexesDDL("osm_search"), + ); + await pg.sql.unsafe(`INSERT INTO osm_search.index_state(singleton,region,source_path,source_fingerprint,current_fingerprint,epoch,status,started_at,published_at,updated_at) VALUES(1,'europe/germany','secret/path','secret','secret','osm-one','ready',now(),now(),now()); + INSERT INTO osm_search.places(osm_type,osm_id,name,lat,lng,category,tags,importance) VALUES + ('node',9007199254740993,'Klinik',50.77,6.08,'amenity:hospital','{"name:en":"Clinic"}',0.8), + ('node',2,'Outside',52,13,'amenity:hospital','{}',1), + ('node',3,'Closed',50.77,6.08,'shop:bakery','{"disused":"yes"}',1)`); + await pg.sql.unsafe(buildSchemaDDL("overture_places")); + const start = performance.now(); + const a = await buildAmbientPlaces(pg.sql, region); + const initialBuildMs = performance.now() - start; + expect(a).toMatchObject({ + placeCount: 1, + sources: { osm: { epoch: "osm-one", count: 1 }, overture: null }, + }); + const bytes = await readAmbientTile(pg.sql, a.generation, tile.z, tile.x, tile.y); + expect(bytes!.length).toBeGreaterThan(0); + expect(bytes!.toString("utf8")).toContain("osm:node/9007199254740993"); + expect(bytes!.length).toBeLessThan(AMBIENT_LIMITS.tileBytes); + expect(new VectorTile(new PbfReader(bytes!)).layers.ambient_places.length).toBe(1); + await pg.sql.unsafe(buildSchemaDDL("overture_places")); + await pg.sql.unsafe(`INSERT INTO overture_places.places(gers_id,name,geom,basic_category,confidence,operating_status,release) VALUES + ('gers-a','Clinic',ST_SetSRID(ST_MakePoint(6.08,50.77),4326),'hospital',0.8,'open','2026-10-01.0'), + ('gers-b','Cafe',ST_SetSRID(ST_MakePoint(6.081,50.7701),4326),'cafe',0.8,'open','2026-10-01.0'), + ('gers-closed','Closed',ST_SetSRID(ST_MakePoint(6.08,50.77),4326),'cafe',0.8,'permanently_closed','2026-10-01.0'); + INSERT INTO overture_places.conflation_state(singleton,release,region,place_count,places_published_at,status,phase) VALUES(1,'2026-10-01.0','europe/germany',3,now(),'completed','complete'); + INSERT INTO overture_places.poi_conflation_link(osm_type,osm_id,gers_id,source_confidence,match_confidence,distance_m,method,evidence,release) VALUES('node',9007199254740993,'gers-a',0.8,1,0,'fixture','{}','2026-10-01.0')`); + const b = await buildAmbientPlaces(pg.sql, region); + expect(b).toMatchObject({ + placeCount: 2, + sources: { overture: { release: "2026-10-01.0", count: 2 } }, + }); + expect(await readAmbientTile(pg.sql, a.generation, tile.z, tile.x, tile.y)).toEqual(bytes); + const current = await readAmbientTile(pg.sql, b.generation, tile.z, tile.x, tile.y); + expect(current!.toString("utf8")).toContain("gers-a"); + expect(current!.toString("utf8")).not.toContain("overture:gers-a"); + await rollbackAmbientPlaces(pg.sql); + expect((await readAmbientManifest(pg.sql))!.generation).toBe(a.generation); + await rollbackAmbientPlaces(pg.sql); + await setAmbientEnabled(pg.sql, false); + expect((await readAmbientManifest(pg.sql))!.enabled).toBe(false); + await setAmbientEnabled(pg.sql, true); + await pg.sql.unsafe( + `CREATE FUNCTION ambient_places.reject_fixture() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'fixture insertion failure'; END $$; CREATE TRIGGER reject_fixture BEFORE INSERT ON ambient_places.features FOR EACH ROW EXECUTE FUNCTION ambient_places.reject_fixture()`, + ); + await expect(buildAmbientPlaces(pg.sql, region)).rejects.toThrow( + /fixture insertion failure/, + ); + expect((await readAmbientManifest(pg.sql))!.generation).toBe(b.generation); + const [survivors] = await pg.sql.unsafe<{ count: number }[]>( + `SELECT count(*)::INT AS count FROM ambient_places.generations`, + ); + expect(survivors.count).toBe(2); + await pg.sql.unsafe( + `DROP TRIGGER reject_fixture ON ambient_places.features; DROP FUNCTION ambient_places.reject_fixture()`, + ); + await pg.sql.unsafe( + `UPDATE overture_places.places SET sources='[{"dataset":"unknown-fixture"}]' WHERE gers_id='gers-b'`, + ); + await expect(buildAmbientPlaces(pg.sql, region)).rejects.toThrow(/unsupported|unknown/i); + expect((await readAmbientManifest(pg.sql))!.generation).toBe(b.generation); + await pg.sql.unsafe(`UPDATE overture_places.places SET sources=NULL`); + await pg.sql.unsafe(`UPDATE overture_places.conflation_state SET status='running'`); + await expect(buildAmbientPlaces(pg.sql, region)).rejects.toThrow(/conflation/i); + expect((await readAmbientManifest(pg.sql))!.generation).toBe(b.generation); + await pg.sql.unsafe( + `UPDATE overture_places.conflation_state SET status='completed'; UPDATE osm_search.index_state SET published_at=now()-interval '91 days'`, + ); + await expect(buildAmbientPlaces(pg.sql, region)).rejects.toThrow(/old|stale/i); + await pg.sql.unsafe( + `UPDATE osm_search.index_state SET published_at=now(); UPDATE osm_search.places SET tags='{"disused":"yes"}'`, + ); + await pg.sql.unsafe( + `UPDATE overture_places.places SET operating_status='permanently_closed'`, + ); + await expect(buildAmbientPlaces(pg.sql, region)).rejects.toThrow(/empty/i); + expect((await readAmbientManifest(pg.sql))!.generation).toBe(b.generation); + process.stdout.write( + JSON.stringify({ + initialBuildMs, + buildAndChecksMs: Math.round(performance.now() - start), + sparseTileBytes: bytes!.length, + }), + ); + } finally { + await pg.stop(); + } + }, 120_000); + + it("bounds dense tiles, serializes writers and protects the generation cache lease", async () => { + const pg = await startPostgis(); + try { + await pg.sql.unsafe( + buildSearchIndexSchemaDDL("osm_search") + buildSearchIndexIndexesDDL("osm_search"), + ); + await pg.sql.unsafe(`INSERT INTO osm_search.index_state(singleton,region,source_path,source_fingerprint,current_fingerprint,epoch,status,started_at,published_at,updated_at) VALUES(1,'europe/germany','fixture','fixture','fixture','dense','ready',now(),now(),now()); + INSERT INTO osm_search.places(osm_type,osm_id,name,lat,lng,category,tags,importance) SELECT 'node',i,'Place '||i,50.77+i*0.0000001,6.08,'amenity:hospital','{}',0.8 FROM generate_series(1,1000) i`); + const a = await buildAmbientPlaces(pg.sql, region); + const times: number[] = []; + let bytes: Buffer | null = null; + for (let i = 0; i < 20; i++) { + const t = performance.now(); + bytes = await readAmbientTile(pg.sql, a.generation, tile.z, tile.x, tile.y); + times.push(performance.now() - t); + } + expect(bytes!.length).toBeLessThan(AMBIENT_LIMITS.tileBytes); + expect(new VectorTile(new PbfReader(bytes!)).layers.ambient_places.length).toBe(256); + expect(bytes!.toString("utf8")).toContain("Place 1"); + expect(bytes!.toString("utf8")).not.toContain("Place 999"); + await pg.sql.unsafe( + `UPDATE osm_search.places SET name=repeat('😀',115)||osm_id::TEXT,tags=jsonb_build_object('name:de',repeat('🌍',115)||osm_id::TEXT,'name:en',repeat('🏥',115)||osm_id::TEXT)`, + ); + const longLabels = await buildAmbientPlaces(pg.sql, region); + const longTile = await readAmbientTile( + pg.sql, + longLabels.generation, + tile.z, + tile.x, + tile.y, + ); + expect(longTile!.length).toBeLessThanOrEqual(AMBIENT_LIMITS.tileBytes); + expect(new VectorTile(new PbfReader(longTile!)).layers.ambient_places.length).toBeLessThan( + 256, + ); + const settled = await Promise.allSettled([ + buildAmbientPlaces(pg.sql, region), + buildAmbientPlaces(pg.sql, region), + ]); + expect(settled.filter((r) => r.status === "fulfilled")).toHaveLength(1); + expect(settled.filter((r) => r.status === "rejected")).toHaveLength(1); + const liveBeforeOverflow = (await readAmbientManifest(pg.sql))?.generation; + await pg.sql.unsafe( + `INSERT INTO osm_search.places(osm_type,osm_id,name,lat,lng,category,tags,importance) SELECT 'node',i,'Overflow '||i,50.77,6.08,'amenity:hospital','{}',0.8 FROM generate_series(1001,100001) i`, + ); + await expect(buildAmbientPlaces(pg.sql, region)).rejects.toThrow(/input exceeds/); + expect((await readAmbientManifest(pg.sql))?.generation).toBe(liveBeforeOverflow); + await pg.sql.unsafe(`DELETE FROM osm_search.places WHERE osm_id>1000`); + for (let i = 0; i < 5; i++) await buildAmbientPlaces(pg.sql, region); + await expect(buildAmbientPlaces(pg.sql, region)).rejects.toThrow(/retention/i); + expect(await readAmbientTile(pg.sql, a.generation, tile.z, tile.x, tile.y)).toEqual(bytes); + process.stdout.write( + JSON.stringify({ + densePlaces: 1000, + tileFeatures: 256, + tileBytes: bytes!.length, + p95Ms: times.sort((a, b) => a - b)[18], + }), + ); + } finally { + await pg.stop(); + } + }, 120_000); + }, +); diff --git a/services/data-manager/package.json b/services/data-manager/package.json index cef9ce436..7f7749a68 100644 --- a/services/data-manager/package.json +++ b/services/data-manager/package.json @@ -38,6 +38,8 @@ "@testcontainers/postgresql": "^12.1.0", "@types/node": "^26.6.2", "typescript": "^6.0.3", - "vitest": "^5.0.1" + "vitest": "^5.0.1", + "@mapbox/vector-tile": "3.0.0", + "pbf": "5.1.2" } } diff --git a/services/data-manager/src/api.ts b/services/data-manager/src/api.ts index cc4b1f4b8..e22f17f60 100644 --- a/services/data-manager/src/api.ts +++ b/services/data-manager/src/api.ts @@ -12,6 +12,7 @@ import type postgres from "postgres"; import { type CoverageApiOptions, registerCoverageEvidenceRoute } from "./coverage/api.js"; import type { CoverageSnapshotStore } from "./coverage/snapshot-store.js"; import { db, sql } from "./db/index.js"; +import { registerAmbientPlacesApi } from "./jobs/ambient-places/api.js"; import { convertPbfToBz2, convertPbfToBz2ForRegion } from "./jobs/convert-overpass.js"; import { downloadFonts } from "./jobs/download-fonts.js"; import { downloadOsm } from "./jobs/download-osm.js"; @@ -491,6 +492,7 @@ export function registerApi(app: FastifyInstance, opts: ApiOptions = {}): void { const singleFlight = opts.singleFlight ?? getSingleFlightController(); const store = new StateStore(dataDir); const searchIndexSql = opts.searchIndexSql ?? sql; + registerAmbientPlacesApi(app, searchIndexSql); const searchIndexRuntimeState = createSearchIndexRuntimeState(); const searchIndexOperationLock = createSearchIndexOperationLock(searchIndexSql); const notablePlaces = opts.notablePlaces ?? { diff --git a/services/data-manager/src/jobs/ambient-places/api.ts b/services/data-manager/src/jobs/ambient-places/api.ts new file mode 100644 index 000000000..a0781bdd4 --- /dev/null +++ b/services/data-manager/src/jobs/ambient-places/api.ts @@ -0,0 +1,115 @@ +import { type AmbientRegion, validateAmbientRegion } from "@openmapx/core/ambient-places"; +import { readAmbientManifest } from "@openmapx/core/ambient-places-server"; +import type { FastifyInstance } from "fastify"; +import type postgres from "postgres"; +import { buildAmbientPlaces, rollbackAmbientPlaces, setAmbientEnabled } from "./build.js"; +import { AMBIENT_WRITE_LOCK, ensureAmbientSchema } from "./schema.js"; + +/** Mounted inside data-manager's bearer-token protected application. */ +export function registerAmbientPlacesApi(app: FastifyInstance, sql: postgres.Sql): void { + let building = false; + app.get("/ambient-places/status", async () => { + const active = await readAmbientManifest(sql); + const [exists] = await sql.unsafe<{ exists: boolean }[]>( + `SELECT to_regclass('ambient_places.state') IS NOT NULL AS exists`, + ); + if (!exists.exists) return { active: null, previous: null, building, lastError: null }; + const [state] = await sql.unsafe< + { + previous: string | null; + last_build_error: string | null; + last_build_started_at: Date | null; + last_build_finished_at: Date | null; + }[] + >( + `SELECT previous,last_build_error,last_build_started_at,last_build_finished_at FROM ambient_places.state WHERE singleton=1`, + ); + const [writer] = await sql.unsafe<{ busy: boolean }[]>( + `SELECT EXISTS(SELECT 1 FROM pg_locks WHERE locktype='advisory' AND objid=$1 AND granted) AS busy`, + [AMBIENT_WRITE_LOCK], + ); + return { + active, + previous: state.previous, + building: building || writer.busy, + lastError: state.last_build_error, + startedAt: state.last_build_started_at, + finishedAt: state.last_build_finished_at, + }; + }); + app.post("/ambient-places/build", async (request, reply) => { + let region: AmbientRegion; + try { + region = validateAmbientRegion(request.body); + } catch (error) { + return reply.code(400).send({ error: (error as Error).message }); + } + if (building) return reply.code(409).send({ error: "An ambient build is already running" }); + await ensureAmbientSchema(sql); + building = true; + try { + await sql.unsafe( + `UPDATE ambient_places.state SET last_build_started_at=now(),last_build_finished_at=NULL,last_build_error=NULL WHERE singleton=1`, + ); + } catch (error) { + building = false; + throw error; + } + void buildAmbientPlaces(sql, region) + .then( + () => + sql.unsafe( + `UPDATE ambient_places.state SET last_build_finished_at=now(),last_build_error=NULL WHERE singleton=1`, + ), + (error) => { + app.log.warn( + { errorClass: error instanceof Error ? error.name : "Unknown" }, + "Ambient publication failed; active generation retained", + ); + return sql.unsafe( + `UPDATE ambient_places.state SET last_build_finished_at=now(),last_build_error=$1 WHERE singleton=1`, + [error instanceof Error ? error.message : "Publication failed"], + ); + }, + ) + .catch((error) => + app.log.error( + { errorClass: error instanceof Error ? error.name : "Unknown" }, + "Ambient job status could not be recorded", + ), + ) + .finally(() => { + building = false; + }); + return reply.code(202).send({ accepted: true }); + }); + app.post( + "/ambient-places/enabled", + { + schema: { + body: { + type: "object", + required: ["enabled"], + additionalProperties: false, + properties: { enabled: { type: "boolean" } }, + }, + }, + }, + async (request, reply) => { + try { + await setAmbientEnabled(sql, (request.body as { enabled: boolean }).enabled); + return { ok: true }; + } catch (error) { + return reply.code(409).send({ error: (error as Error).message }); + } + }, + ); + app.post("/ambient-places/rollback", async (_request, reply) => { + try { + await rollbackAmbientPlaces(sql); + return { ok: true }; + } catch (error) { + return reply.code(409).send({ error: (error as Error).message }); + } + }); +} diff --git a/services/data-manager/src/jobs/ambient-places/build.ts b/services/data-manager/src/jobs/ambient-places/build.ts new file mode 100644 index 000000000..1eed46755 --- /dev/null +++ b/services/data-manager/src/jobs/ambient-places/build.ts @@ -0,0 +1,222 @@ +import { randomUUID } from "node:crypto"; +import { assertSupportedOvertureContributors, type OvertureSourceItem } from "@openmapx/core"; +import { + AMBIENT_LIMITS, + AMBIENT_MAX_AGE_MS, + AMBIENT_POLICY_VERSION, + type AmbientManifest, + type AmbientOsmRow, + type AmbientOvertureRow, + type AmbientRegion, + ambientPlaceFromOsm, + ambientPlaceFromOverture, + mergeAmbientPlaces, + validateAmbientRegion, +} from "@openmapx/core/ambient-places"; +import type postgres from "postgres"; +import { AMBIENT_WRITE_LOCK, ensureAmbientSchema } from "./schema.js"; + +async function lock(tx: postgres.TransactionSql) { + const [row] = await tx.unsafe<{ locked: boolean }[]>( + `SELECT pg_try_advisory_xact_lock(${AMBIENT_WRITE_LOCK}) AS locked`, + ); + if (!row.locked) throw new Error("Another ambient publication or pointer change is running"); +} +function fresh(value: Date | string | null): string { + const date = value === null ? NaN : new Date(value).getTime(); + if ( + !Number.isFinite(date) || + Date.now() - date > AMBIENT_MAX_AGE_MS || + date > Date.now() + 60_000 + ) + throw new Error("Source publication is missing or too old (maximum 90 days)"); + return new Date(date).toISOString(); +} +async function tableExists(tx: postgres.TransactionSql, name: string): Promise { + const [row] = await tx.unsafe<{ exists: boolean }[]>( + `SELECT to_regclass($1) IS NOT NULL AS exists`, + [name], + ); + return row.exists; +} +export async function buildAmbientPlaces( + sql: postgres.Sql, + input: AmbientRegion, +): Promise { + const region = validateAmbientRegion(input); + await ensureAmbientSchema(sql); + return sql.begin("isolation level repeatable read", async (tx) => { + await lock(tx); + await tx.unsafe(`SET LOCAL statement_timeout='120000ms'`); + if (!(await tableExists(tx, "osm_search.index_state"))) + throw new Error("Build the regional OSM search index first"); + const [osmState] = await tx.unsafe< + { region: string; epoch: string; status: string; published_at: Date | null }[] + >(`SELECT region,epoch,status,published_at FROM osm_search.index_state WHERE singleton=1`); + if (!osmState || osmState.status !== "ready") throw new Error("OSM search index must be ready"); + const osmPublished = fresh(osmState.published_at); + const params = region.bounds; + const osmRows = await tx.unsafe( + `SELECT osm_type,osm_id::TEXT,name,lng,lat,category,tags,importance FROM osm_search.places WHERE geom && ST_MakeEnvelope($1,$2,$3,$4,4326)::geography ORDER BY osm_type,osm_id LIMIT ${AMBIENT_LIMITS.places + 1}`, + params, + ); + if (osmRows.length > AMBIENT_LIMITS.places) + throw new Error("Regional OSM input exceeds 100000 places; choose a smaller region"); + const osm = osmRows.flatMap((row) => { + const p = ambientPlaceFromOsm(row); + return p ? [p] : []; + }); + let overtureSource: AmbientManifest["sources"]["overture"] = null; + let overture: NonNullable>[] = []; + const links = new Map(); + if (await tableExists(tx, "overture_places.places")) { + if (!(await tableExists(tx, "overture_places.conflation_state"))) + throw new Error("Overture conflation state is missing"); + const [state] = await tx.unsafe< + { region: string; release: string; status: string; places_published_at: Date | null }[] + >( + `SELECT region,release,status,places_published_at FROM overture_places.conflation_state WHERE singleton=1`, + ); + if (!state) { + const [input] = await tx.unsafe<{ populated: boolean }[]>( + `SELECT EXISTS(SELECT 1 FROM overture_places.places LIMIT 1) AS populated`, + ); + if (input.populated) throw new Error("Overture conflation state is missing"); + } + if (state && state.status !== "completed") + throw new Error("Overture conflation must be completed before ambient publication"); + if (state) { + const publishedAt = fresh(state.places_published_at); + const [releases] = await tx.unsafe<{ mismatch: boolean }[]>( + `SELECT EXISTS(SELECT 1 FROM overture_places.places WHERE release<>$1 AND geom && ST_MakeEnvelope($2,$3,$4,$5,4326) LIMIT 1) AS mismatch`, + [state.release, ...params], + ); + if (releases.mismatch) + throw new Error("Overture conflation release does not match published places"); + const rows = await tx.unsafe< + (AmbientOvertureRow & { sources: OvertureSourceItem[] | null })[] + >( + `SELECT gers_id,name,ST_X(geom) AS longitude,ST_Y(geom) AS latitude,basic_category,taxonomy_primary,taxonomy_hierarchy,taxonomy_alternates,names,confidence,operating_status,sources FROM overture_places.places WHERE geom && ST_MakeEnvelope($1,$2,$3,$4,4326) ORDER BY gers_id LIMIT ${AMBIENT_LIMITS.places + 1}`, + params, + ); + if (rows.length > AMBIENT_LIMITS.places) + throw new Error("Regional Overture input exceeds 100000 places; choose a smaller region"); + overture = rows.flatMap((row) => { + const p = ambientPlaceFromOverture(row); + if (p) + assertSupportedOvertureContributors( + (row.sources ?? []).flatMap((source) => (source.dataset ? [source.dataset] : [])), + ); + return p ? [p] : []; + }); + const linkRows = await tx.unsafe<{ id: string; gers_id: string }[]>( + `SELECT 'osm:'||l.osm_type||'/'||l.osm_id::TEXT AS id,l.gers_id FROM overture_places.poi_conflation_link l JOIN osm_search.places p USING(osm_type,osm_id) WHERE p.geom && ST_MakeEnvelope($1,$2,$3,$4,4326)::geography AND l.release=$5 UNION SELECT 'osm:'||l.osm_type||'/'||l.osm_id::TEXT AS id,l.gers_id FROM overture_places.poi_conflation_link l JOIN overture_places.places o USING(gers_id) WHERE o.geom && ST_MakeEnvelope($1,$2,$3,$4,4326) AND l.release=$5 LIMIT ${AMBIENT_LIMITS.places + 1}`, + [...params, state.release], + ); + if (linkRows.length > AMBIENT_LIMITS.places) + throw new Error("Regional conflation input exceeds 100000 links"); + for (const row of linkRows) links.set(row.id, row.gers_id); + overtureSource = { + region: state.region, + release: state.release, + publishedAt, + count: overture.length, + }; + } + } + const validOsm = new Set(osm.map((p) => p.id)); + const excludedOsm = new Set( + osmRows + .filter((row) => !validOsm.has(`osm:${row.osm_type}/${row.osm_id}`)) + .map((row) => `osm:${row.osm_type}/${row.osm_id}`), + ); + const places = mergeAmbientPlaces(osm, overture, links, excludedOsm); + if (places.length === 0) throw new Error("Refusing to publish an empty ambient generation"); + if (places.length > AMBIENT_LIMITS.places) + throw new Error("Combined regional output exceeds 100000 places"); + await tx.unsafe( + `DELETE FROM ambient_places.generations g WHERE g.published_at( + `SELECT count(*)::INT AS count FROM ambient_places.generations`, + ); + if (capacity.count >= AMBIENT_LIMITS.generations) + throw new Error( + "Generation retention limit reached; preserve seven-day tile cache leases before rebuilding", + ); + const manifest: AmbientManifest = { + version: 1, + policyVersion: AMBIENT_POLICY_VERSION, + generation: randomUUID(), + publishedAt: new Date().toISOString(), + region, + placeCount: places.length, + enabled: true, + sources: { + osm: { + region: osmState.region, + epoch: osmState.epoch, + publishedAt: osmPublished, + count: osm.length, + }, + overture: overtureSource, + }, + }; + await tx.unsafe( + `INSERT INTO ambient_places.generations(id,manifest) VALUES($1,$2::TEXT::JSONB)`, + [manifest.generation, JSON.stringify(manifest)], + ); + for (let offset = 0; offset < places.length; offset += 500) { + const rows = places.slice(offset, offset + 500).map((p) => ({ + id: p.id, + gers_id: p.gersId ?? null, + name: p.name, + name_de: p.names.de ?? null, + name_en: p.names.en ?? null, + category: p.category, + rank: p.rank, + min_zoom: p.minZoom, + tenant: p.tenant, + sources: p.sources, + lng: p.coordinates[0], + lat: p.coordinates[1], + })); + await tx.unsafe( + `INSERT INTO ambient_places.features(generation,id,gers_id,name,name_de,name_en,category,rank,min_zoom,tenant,sources,geom) + SELECT $1::UUID,r.id,r.gers_id,r.name,r.name_de,r.name_en,r.category,r.rank,r.min_zoom,r.tenant,r.sources,ST_Transform(ST_SetSRID(ST_MakePoint(r.lng,r.lat),4326),3857) + FROM jsonb_to_recordset($2::TEXT::JSONB) AS r(id TEXT,gers_id TEXT,name TEXT,name_de TEXT,name_en TEXT,category TEXT,rank INT,min_zoom SMALLINT,tenant BOOLEAN,sources TEXT,lng DOUBLE PRECISION,lat DOUBLE PRECISION)`, + [manifest.generation, JSON.stringify(rows)], + ); + } + const [count] = await tx.unsafe<{ count: number }[]>( + `SELECT count(*)::INT AS count FROM ambient_places.features WHERE generation=$1`, + [manifest.generation], + ); + if (count.count !== manifest.placeCount) + throw new Error("Candidate feature count validation failed"); + await tx.unsafe(`UPDATE ambient_places.state SET previous=active,active=$1 WHERE singleton=1`, [ + manifest.generation, + ]); + return manifest; + }); +} +export async function setAmbientEnabled(sql: postgres.Sql, enabled: boolean): Promise { + await ensureAmbientSchema(sql); + await sql.begin(async (tx) => { + await lock(tx); + await tx.unsafe(`UPDATE ambient_places.state SET enabled=$1 WHERE singleton=1`, [enabled]); + }); +} +export async function rollbackAmbientPlaces(sql: postgres.Sql): Promise { + await ensureAmbientSchema(sql); + await sql.begin(async (tx) => { + await lock(tx); + const [state] = await tx.unsafe<{ previous: string | null }[]>( + `SELECT previous FROM ambient_places.state WHERE singleton=1`, + ); + if (!state.previous) throw new Error("No previous ambient generation available"); + await tx.unsafe( + `UPDATE ambient_places.state SET active=previous,previous=active WHERE singleton=1`, + ); + }); +} diff --git a/services/data-manager/src/jobs/ambient-places/schema.ts b/services/data-manager/src/jobs/ambient-places/schema.ts new file mode 100644 index 000000000..4afe4d683 --- /dev/null +++ b/services/data-manager/src/jobs/ambient-places/schema.ts @@ -0,0 +1,41 @@ +import type postgres from "postgres"; + +export const AMBIENT_WRITE_LOCK = 139399; +export const ambientSchemaDDL = ` +CREATE SCHEMA IF NOT EXISTS ambient_places; +CREATE TABLE IF NOT EXISTS ambient_places.generations ( + id UUID PRIMARY KEY, manifest JSONB NOT NULL, published_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE TABLE IF NOT EXISTS ambient_places.features ( + generation UUID NOT NULL REFERENCES ambient_places.generations(id) ON DELETE CASCADE, + id TEXT NOT NULL, gers_id TEXT, name TEXT NOT NULL, name_de TEXT, name_en TEXT, + category TEXT NOT NULL, rank INTEGER NOT NULL, min_zoom SMALLINT NOT NULL, + tenant BOOLEAN NOT NULL, sources TEXT NOT NULL, geom geometry(POINT,3857) NOT NULL, + PRIMARY KEY(generation,id) +); +CREATE INDEX IF NOT EXISTS ambient_features_geom ON ambient_places.features USING GIST(geom); +CREATE TABLE IF NOT EXISTS ambient_places.state ( + singleton SMALLINT PRIMARY KEY CHECK(singleton=1), + active UUID REFERENCES ambient_places.generations(id), previous UUID REFERENCES ambient_places.generations(id), + enabled BOOLEAN NOT NULL DEFAULT true, + last_build_started_at TIMESTAMPTZ, last_build_finished_at TIMESTAMPTZ, last_build_error TEXT +); +INSERT INTO ambient_places.state(singleton) VALUES(1) ON CONFLICT DO NOTHING; +`; +const initialization = new WeakMap>(); +export async function ensureAmbientSchema(sql: postgres.Sql): Promise { + let ready = initialization.get(sql); + if (!ready) { + ready = sql + .begin(async (tx) => { + await tx.unsafe( + `SET LOCAL statement_timeout='10000ms'; SELECT pg_advisory_xact_lock(${AMBIENT_WRITE_LOCK})`, + ); + await tx.unsafe(ambientSchemaDDL); + }) + .then(() => undefined); + initialization.set(sql, ready); + ready.catch(() => initialization.delete(sql)); + } + await ready; +} From 7ad3fd8771429c3ed5ac88531d44cadb7d721a77 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 7 Oct 2026 23:57:02 +0200 Subject: [PATCH 03/13] feat(api): serve bounded ambient tiles and operator controls --- apps/api/openapi.json | 171 ++++++++++++++++++ apps/api/src/routes/ambient-places.test.ts | 120 +++++++++++++ apps/api/src/routes/ambient-places.ts | 192 +++++++++++++++++++++ apps/api/src/routes/index.ts | 2 + 4 files changed, 485 insertions(+) create mode 100644 apps/api/src/routes/ambient-places.test.ts create mode 100644 apps/api/src/routes/ambient-places.ts diff --git a/apps/api/openapi.json b/apps/api/openapi.json index a82dff7d3..bde7b7890 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -10,6 +10,10 @@ "name": "admin", "description": "Operator-facing administration of integrations, services, settings and jobs." }, + { + "name": "ambient-places", + "description": "Routes under /api/ambient-places." + }, { "name": "attribution", "description": "Routes under /api/attribution." @@ -144,6 +148,111 @@ } ], "paths": { + "/api/admin/ambient-places/build": { + "post": { + "operationId": "postApiAdminAmbientPlacesBuild", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name", + "bounds" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "bounds": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "items": { + "type": "number" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Default Response" + } + }, + "tags": [ + "admin" + ], + "x-openmapx-auth": "admin" + } + }, + "/api/admin/ambient-places/enabled": { + "post": { + "operationId": "postApiAdminAmbientPlacesEnabled", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "enabled" + ], + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Default Response" + } + }, + "tags": [ + "admin" + ], + "x-openmapx-auth": "admin" + } + }, + "/api/admin/ambient-places/rollback": { + "post": { + "operationId": "postApiAdminAmbientPlacesRollback", + "responses": { + "200": { + "description": "Default Response" + } + }, + "tags": [ + "admin" + ], + "x-openmapx-auth": "admin" + } + }, + "/api/admin/ambient-places/status": { + "get": { + "operationId": "getApiAdminAmbientPlacesStatus", + "responses": { + "200": { + "description": "Default Response" + } + }, + "tags": [ + "admin" + ], + "x-openmapx-auth": "admin" + } + }, "/api/admin/audit": { "get": { "operationId": "getApiAdminAudit", @@ -1915,6 +2024,68 @@ "x-openmapx-auth": "admin" } }, + "/api/ambient-places/manifest": { + "get": { + "operationId": "getApiAmbientPlacesManifest", + "responses": { + "200": { + "description": "Default Response" + } + }, + "tags": [ + "ambient-places" + ], + "x-openmapx-auth": "public" + } + }, + "/api/ambient-places/tiles/{generation}/{z}/{x}/{y}.mvt": { + "get": { + "operationId": "getApiAmbientPlacesTilesGenerationZXYMvt", + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "generation", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "z", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "x", + "required": true + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "y", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response" + } + }, + "tags": [ + "ambient-places" + ], + "x-openmapx-auth": "public" + } + }, "/api/attribution": { "get": { "operationId": "getApiAttribution", diff --git a/apps/api/src/routes/ambient-places.test.ts b/apps/api/src/routes/ambient-places.test.ts new file mode 100644 index 000000000..aaa761397 --- /dev/null +++ b/apps/api/src/routes/ambient-places.test.ts @@ -0,0 +1,120 @@ +import Fastify from "fastify"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const session = vi.hoisted(() => ({ role: "user" })); +afterEach(() => { + session.role = "user"; + vi.unstubAllGlobals(); +}); +vi.mock("../db/index.js", () => ({ sql: {} })); +vi.mock("../auth.js", () => ({ + auth: { + api: { getSession: async () => ({ user: { role: session.role, id: "fixture-admin" } }) }, + }, +})); +vi.mock("../utils/rate-limit.js", () => ({ + systemMaintenanceLimit: { preHandler: () => async () => {} }, +})); +vi.mock("../utils/audit-log.js", () => ({ writeAuditLog: vi.fn() })); + +import { ambientPlacesRoute } from "./ambient-places"; + +const generation = "11111111-1111-4111-8111-111111111111"; +const path = `/ambient-places/tiles/${generation}/16/33874/22001.mvt`; + +describe("ambient HTTP budgets", () => { + it("uses no-store discovery and immutable generation URLs", async () => { + const app = Fastify(); + await app.register(ambientPlacesRoute, { + readManifest: async () => null, + readTile: async () => Buffer.from("tile"), + }); + const manifest = await app.inject("/ambient-places/manifest"); + expect(manifest.json()).toEqual({ manifest: null }); + expect(manifest.headers["cache-control"]).toBe("no-store"); + const tile = await app.inject(path); + expect(tile.statusCode).toBe(200); + expect(tile.headers["cache-control"]).toContain("immutable"); + expect(tile.headers["content-type"]).toContain("application/vnd.mapbox-vector-tile"); + await app.close(); + }); + it("rejects invalid generation and XYZ before querying", async () => { + const read = vi.fn(); + const app = Fastify(); + await app.register(ambientPlacesRoute, { readTile: read }); + for (const p of [ + path.replace(generation, "invalid"), + path.replace("/16/", "/12/"), + path.replace("/33874/", "/999999/"), + path.replace("/33874/", "/-1/"), + ]) + expect((await app.inject(p)).statusCode).toBe(400); + expect(read).not.toHaveBeenCalled(); + await app.close(); + }); + it("does not cache unknown, oversized or failed tiles and rejects public admin access", async () => { + const app = Fastify(); + const read = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(Buffer.alloc(131073)) + .mockRejectedValueOnce(new Error("private database detail")); + await app.register(ambientPlacesRoute, { readTile: read }); + expect((await app.inject(path)).statusCode).toBe(404); + for (let i = 0; i < 2; i++) { + const r = await app.inject(path); + expect(r.statusCode).toBe(503); + expect(r.headers["cache-control"]).toBe("no-store"); + expect(r.body).not.toContain("private database"); + } + expect( + (await app.inject({ method: "POST", url: "/admin/ambient-places/build", payload: {} })) + .statusCode, + ).toBe(403); + await app.close(); + }); + it("validates administrator publication bounds before sending a request to data-manager", async () => { + session.role = "admin"; + const proxy = vi.fn( + async () => new Response(JSON.stringify({ accepted: true }), { status: 202 }), + ); + vi.stubGlobal("fetch", proxy); + const app = Fastify(); + await app.register(ambientPlacesRoute, {}); + const bad = await app.inject({ + method: "POST", + url: "/admin/ambient-places/build", + payload: { name: "Planet", bounds: [-180, -90, 180, 90] }, + }); + expect(bad.statusCode).toBe(400); + expect(proxy).not.toHaveBeenCalled(); + const good = await app.inject({ + method: "POST", + url: "/admin/ambient-places/build", + payload: { name: "Aachen", bounds: [5.9, 50.65, 6.3, 50.95] }, + }); + expect(good.statusCode).toBe(202); + expect(proxy).toHaveBeenCalledTimes(1); + await app.close(); + }); + it("bounds concurrent tile reads and releases permits after failure", async () => { + const app = Fastify(); + let release!: () => void; + const gate = new Promise((r) => { + release = r; + }); + const read = vi.fn(async () => { + await gate; + throw new Error("offline"); + }); + await app.register(ambientPlacesRoute, { readTile: read }); + const requests = Array.from({ length: 9 }, () => app.inject(path)); + await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(8)); + release(); + const responses = await Promise.all(requests); + expect(responses.filter((r) => r.statusCode === 429)).toHaveLength(1); + expect((await app.inject(path)).statusCode).toBe(503); + expect(read).toHaveBeenCalledTimes(9); + await app.close(); + }); +}); diff --git a/apps/api/src/routes/ambient-places.ts b/apps/api/src/routes/ambient-places.ts new file mode 100644 index 000000000..71872a877 --- /dev/null +++ b/apps/api/src/routes/ambient-places.ts @@ -0,0 +1,192 @@ +import { readBoundedResponseText } from "@openmapx/core"; +import { + AMBIENT_LIMITS, + type AmbientManifest, + validateAmbientRegion, +} from "@openmapx/core/ambient-places"; +import { + readAmbientManifest, + readAmbientTile, + validAmbientTile, +} from "@openmapx/core/ambient-places-server"; +import { services } from "@openmapx/core/server"; +import { envString } from "@openmapx/core/server-env"; +import type { FastifyInstance } from "fastify"; +import { sql } from "../db/index.js"; +import { writeAuditLog } from "../utils/audit-log.js"; +import { systemMaintenanceLimit } from "../utils/rate-limit.js"; +import { getAdminSession, requireAdmin } from "../utils/require-admin.js"; +import { declareRouteAuth } from "../utils/route-auth.js"; + +interface AmbientRouteOptions { + readManifest?: () => Promise; + readTile?: (generation: string, z: number, x: number, y: number) => Promise; +} +async function proxy(method: "GET" | "POST", action: string, body?: unknown) { + const base = services.validateDataManagerBaseUrl( + envString("DATA_MANAGER_URL", "http://localhost:4000"), + ); + const response = await fetch(`${base}/ambient-places/${action}`, { + method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${envString("DATA_MANAGER_AUTH_TOKEN", "")}`, + }, + body: body === undefined ? undefined : JSON.stringify(body), + redirect: "error", + signal: AbortSignal.timeout(15_000), + }); + const text = await readBoundedResponseText(response, 256 * 1024, { + label: "ambient operator response", + }); + return { status: response.status, body: JSON.parse(text) }; +} +export async function ambientPlacesRoute( + app: FastifyInstance, + options: AmbientRouteOptions, +): Promise { + declareRouteAuth(app, "public"); + let pending = 0; + app.get("/ambient-places/manifest", async (request, reply) => { + reply.header("Cache-Control", "no-store"); + try { + return { manifest: await (options.readManifest ?? (() => readAmbientManifest(sql)))() }; + } catch (error) { + request.log.warn( + { errorClass: error instanceof Error ? error.name : "Unknown" }, + "Ambient discovery unavailable", + ); + return reply.code(503).send({ error: "Ambient places temporarily unavailable" }); + } + }); + app.get<{ Params: { generation: string; z: string; x: string; y: string } }>( + "/ambient-places/tiles/:generation/:z/:x/:y.mvt", + { + schema: { + params: { + type: "object", + required: ["generation", "z", "x", "y"], + properties: { + generation: { type: "string" }, + z: { type: "string" }, + x: { type: "string" }, + y: { type: "string" }, + }, + }, + }, + }, + async (request, reply) => { + reply.header("Cache-Control", "no-store"); + const { generation } = request.params; + const { z, x, y } = Object.fromEntries( + ["z", "x", "y"].map((k) => [k, Number(request.params[k as "z" | "x" | "y"])]), + ); + if ( + ![request.params.z, request.params.x, request.params.y].every((v) => /^\d+$/.test(v)) || + !validAmbientTile(generation, z, x, y) + ) + return reply.code(400).send({ error: "Invalid regional tile coordinates" }); + if (pending >= 8) + return reply + .code(429) + .header("Retry-After", "1") + .send({ error: "Ambient tile request budget exceeded" }); + pending++; + try { + const tile = await (options.readTile ?? ((g, z, x, y) => readAmbientTile(sql, g, z, x, y)))( + generation, + z, + x, + y, + ); + if (tile === null) return reply.code(404).send({ error: "Unknown ambient generation" }); + if (tile.length > AMBIENT_LIMITS.tileBytes) + throw new Error("Tile exceeds publication budget"); + return reply + .type("application/vnd.mapbox-vector-tile") + .header("Cache-Control", `public, max-age=${AMBIENT_LIMITS.cacheSeconds}, immutable`) + .send(tile); + } catch (error) { + request.log.warn( + { errorClass: error instanceof Error ? error.name : "Unknown" }, + "Ambient tile unavailable", + ); + return reply.code(503).send({ error: "Ambient tile temporarily unavailable" }); + } finally { + pending--; + } + }, + ); + await app.register(async (admin) => { + declareRouteAuth(admin, "admin"); + admin.addHook("onRequest", async (request) => { + request.adminSession = await requireAdmin(request); + }); + for (const action of ["status", "build", "enabled", "rollback"] as const) { + admin.route({ + method: action === "status" ? "GET" : "POST", + url: `/admin/ambient-places/${action}`, + schema: + action === "build" + ? { + body: { + type: "object", + required: ["name", "bounds"], + additionalProperties: false, + properties: { + name: { type: "string", minLength: 1, maxLength: 80 }, + bounds: { type: "array", minItems: 4, maxItems: 4, items: { type: "number" } }, + }, + }, + } + : action === "enabled" + ? { + body: { + type: "object", + required: ["enabled"], + additionalProperties: false, + properties: { enabled: { type: "boolean" } }, + }, + } + : undefined, + preHandler: action === "build" ? systemMaintenanceLimit.preHandler() : undefined, + handler: async (request, reply) => { + reply.header("Cache-Control", "no-store"); + if (action === "build") + try { + validateAmbientRegion(request.body); + } catch (error) { + return reply.code(400).send({ error: (error as Error).message }); + } + if ( + action === "enabled" && + typeof (request.body as { enabled?: unknown })?.enabled !== "boolean" + ) + return reply.code(400).send({ error: "enabled must be a boolean" }); + try { + const result = await proxy( + action === "status" ? "GET" : "POST", + action, + action === "status" ? undefined : request.body, + ); + if (action !== "status") + await writeAuditLog({ + actorId: getAdminSession(request).user.id, + action: `ambient-places.${action}`, + targetType: "ambient-places", + details: { status: result.status }, + request, + }); + return reply.code(result.status).send(result.body); + } catch (error) { + request.log.warn( + { errorClass: error instanceof Error ? error.name : "Unknown" }, + "Ambient operator service unavailable", + ); + return reply.code(503).send({ error: "Ambient publication service unavailable" }); + } + }, + }); + } + }); +} diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 2527d0dad..a78b55e2b 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -11,6 +11,7 @@ import { adminOperationsRoute } from "./admin-operations"; import { adminServicesRoute } from "./admin-services"; import { adminSettingsRoute } from "./admin-settings"; import { adminSystemRoute } from "./admin-system"; +import { ambientPlacesRoute } from "./ambient-places"; import { attributionRoute } from "./attribution"; import { authRoute } from "./auth"; import { capabilitiesRoute } from "./capabilities"; @@ -85,6 +86,7 @@ export async function registerCoreRoutes( await server.register(mobileAuthRoute, { prefix: "/api" }); await server.register(placesRoute, { prefix: "/api" }); + await server.register(ambientPlacesRoute, { prefix: "/api" }); await server.register(neighborhoodsRoute, { prefix: "/api" }); await server.register(offlinePackagesRoute, { prefix: "/api" }); From 5a51b56ba3d708121f27164252f53b4e20eb4dfa Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 7 Oct 2026 23:58:06 +0200 Subject: [PATCH 04/13] feat(web): show ranked ambient places and publication controls --- .../AmbientPlacesMaintenance.test.tsx | 83 ++++++ .../services/AmbientPlacesMaintenance.tsx | 184 ++++++++++++ .../admin/services/DataWorkflowsPage.tsx | 2 + .../map/MapStylePoiClickHandler.tsx | 2 + .../map/ambientPlaceIdentity.test.ts | 48 ++++ .../components/map/ambientPlaceIdentity.ts | 23 ++ .../useLayerSelectorConfig.test.tsx | 3 +- .../src/components/map/mapStylePoiTarget.ts | 5 +- .../src/integration-api/map/ambientPlaces.ts | 8 + .../overlay-ambient-places/legend.tsx | 38 +++ .../overlay-ambient-places/manifest.json | 156 ++++++++++ .../overlay-ambient-places/map-layer.test.tsx | 166 +++++++++++ .../overlay-ambient-places/map-layer.tsx | 269 ++++++++++++++++++ .../overlay-ambient-places/package.json | 21 ++ .../overlay-ambient-places/preview.svg | 1 + integrations/overlay-ambient-places/store.ts | 21 ++ .../overlay-ambient-places/strings/de.json | 56 ++++ .../overlay-ambient-places/strings/en.json | 56 ++++ packages/i18n/locales/de.json | 13 +- packages/i18n/locales/en.json | 13 +- pnpm-lock.yaml | 25 ++ 21 files changed, 1189 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/admin/services/AmbientPlacesMaintenance.test.tsx create mode 100644 apps/web/src/components/admin/services/AmbientPlacesMaintenance.tsx create mode 100644 apps/web/src/components/map/ambientPlaceIdentity.test.ts create mode 100644 apps/web/src/components/map/ambientPlaceIdentity.ts create mode 100644 apps/web/src/integration-api/map/ambientPlaces.ts create mode 100644 integrations/overlay-ambient-places/legend.tsx create mode 100644 integrations/overlay-ambient-places/manifest.json create mode 100644 integrations/overlay-ambient-places/map-layer.test.tsx create mode 100644 integrations/overlay-ambient-places/map-layer.tsx create mode 100644 integrations/overlay-ambient-places/package.json create mode 100644 integrations/overlay-ambient-places/preview.svg create mode 100644 integrations/overlay-ambient-places/store.ts create mode 100644 integrations/overlay-ambient-places/strings/de.json create mode 100644 integrations/overlay-ambient-places/strings/en.json diff --git a/apps/web/src/components/admin/services/AmbientPlacesMaintenance.test.tsx b/apps/web/src/components/admin/services/AmbientPlacesMaintenance.test.tsx new file mode 100644 index 000000000..9d8c9a958 --- /dev/null +++ b/apps/web/src/components/admin/services/AmbientPlacesMaintenance.test.tsx @@ -0,0 +1,83 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../shared/AdminToast", () => ({ useAdminToast: () => vi.fn() })); + +import { AmbientPlacesMaintenance } from "./AmbientPlacesMaintenance"; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); +function view() { + return render( + + + , + ); +} +describe("ambient operator workflow", () => { + it("shows source versions and publishes the bounded Aachen preset", async () => { + const fetch = vi.fn(async (_url: unknown, initValue?: unknown) => ({ + ok: true, + json: async () => + (initValue as RequestInit | undefined)?.method + ? { accepted: true } + : { + active: { + generation: "generation-one", + publishedAt: "2026-10-07T10:00:00Z", + placeCount: 42, + enabled: true, + region: { name: "Aachen", bounds: [5.9, 50.65, 6.3, 50.95] }, + sources: { + osm: { epoch: "osm-one", publishedAt: "2026-10-07", count: 42 }, + overture: null, + }, + }, + previous: null, + building: false, + lastError: null, + }, + })); + vi.stubGlobal("fetch", fetch); + view(); + await screen.findByText(/osm-one/); + expect(screen.getByText(/OSM only/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Publish map snapshot" })); + await waitFor(() => + expect(fetch).toHaveBeenCalledWith( + "http://fixture/api/admin/ambient-places/build", + expect.objectContaining({ + method: "POST", + credentials: "include", + body: JSON.stringify({ name: "Aachen", bounds: [5.9, 50.65, 6.3, 50.95] }), + }), + ), + ); + }); + it("shows a failed build and asks before rolling back the publication", async () => { + const fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + active: null, + previous: "previous-id", + building: false, + lastError: "Overture conflation must be completed", + }), + })); + vi.stubGlobal("fetch", fetch); + view(); + await screen.findByText(/Overture conflation must be completed/); + fireEvent.click(screen.getByRole("button", { name: "Roll back" })); + await screen.findByRole("dialog"); + expect(fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/components/admin/services/AmbientPlacesMaintenance.tsx b/apps/web/src/components/admin/services/AmbientPlacesMaintenance.tsx new file mode 100644 index 000000000..b2ad48b83 --- /dev/null +++ b/apps/web/src/components/admin/services/AmbientPlacesMaintenance.tsx @@ -0,0 +1,184 @@ +"use client"; + +import Alert from "@mui/material/Alert"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Chip from "@mui/material/Chip"; +import LinearProgress from "@mui/material/LinearProgress"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { type AmbientManifest, validateAmbientRegion } from "@openmapx/core/ambient-places"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useAdminToast } from "../shared/AdminToast"; +import { ConfirmDialog } from "../shared/ConfirmDialog"; + +interface AmbientStatus { + active: AmbientManifest | null; + previous: string | null; + building: boolean; + lastError: string | null; + startedAt?: string; + finishedAt?: string; +} + +export function AmbientPlacesMaintenance({ apiUrl }: { apiUrl: string }) { + const toast = useAdminToast(); + const client = useQueryClient(); + const [confirm, setConfirm] = useState(false); + const [name, setName] = useState("Aachen"); + const [bounds, setBounds] = useState([5.9, 50.65, 6.3, 50.95]); + const queryKey = ["admin", "ambient-places", "status"]; + async function request(action: string, body?: unknown) { + const response = await fetch(`${apiUrl}/api/admin/ambient-places/${action}`, { + credentials: "include", + ...(body === undefined + ? {} + : { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error ?? "Ambient publication service unavailable"); + return result; + } + const query = useQuery({ + queryKey, + queryFn: () => request("status"), + refetchInterval: (q) => (q.state.data?.building ? 2000 : 60_000), + }); + const operation = useMutation({ + mutationFn: ({ action, body }: { action: string; body: unknown }) => + request(action, action === "build" ? validateAmbientRegion(body) : body), + onSuccess: (_result, variables) => { + toast( + variables.action === "build" + ? "Regional publication queued" + : "Regional publication updated", + ); + setConfirm(false); + void client.invalidateQueries({ queryKey }); + }, + onError: (error: Error) => toast(error.message, "error"), + }); + const status = query.data; + const active = status?.active; + const busy = operation.isPending || Boolean(status?.building); + return ( + + + + Nearby places on the map + + Publish one small German region from the existing OSM search index and optional Overture + snapshot. Failed builds keep the last good map. + + + {(query.isLoading || busy) && } + {query.error && {query.error.message}} + {status?.lastError && {status.lastError}} + + + + + + {active && ( + + + {active.region.name} · published {new Date(active.publishedAt).toLocaleString()} + + + Generation {active.generation} + + + OSM {active.sources.osm.epoch} · {active.sources.osm.count.toLocaleString()} eligible + places · snapshot {active.sources.osm.publishedAt} + + {active.sources.overture && ( + + Overture {active.sources.overture.release} ·{" "} + {active.sources.overture.count.toLocaleString()} eligible places · snapshot{" "} + {active.sources.overture.publishedAt} + + )} + + Snapshot dates describe imported data, not real-world verification of each place. + + + )} + {status?.previous && ( + + Previous generation {status.previous} + + )} + + setName(e.target.value)} + size="small" + disabled={busy} + /> + {["West", "South", "East", "North"].map((label, i) => ( + + setBounds((b) => b.map((v, j) => (i === j ? Number(e.target.value) : v))) + } + size="small" + disabled={busy} + slotProps={{ htmlInput: { step: 0.01 } }} + /> + ))} + + + Maximum 0.5° × 0.5°, 100,000 places. Sources must be ready and no older than 90 days; + Overture requires completed conflation. Eight generations are retained with a seven-day + tile cache lease. + + + + + + + + + operation.mutate({ action: "rollback", body: {} })} + onCancel={() => setConfirm(false)} + /> + + ); +} diff --git a/apps/web/src/components/admin/services/DataWorkflowsPage.tsx b/apps/web/src/components/admin/services/DataWorkflowsPage.tsx index 205222bf9..9519b7d97 100644 --- a/apps/web/src/components/admin/services/DataWorkflowsPage.tsx +++ b/apps/web/src/components/admin/services/DataWorkflowsPage.tsx @@ -32,6 +32,7 @@ import { } from "../operations/adminOperationsApi"; import { AdminPageHeader } from "../shared/AdminPageHeader"; import { useAdminToast } from "../shared/AdminToast"; +import { AmbientPlacesMaintenance } from "./AmbientPlacesMaintenance"; import { NotablePlacesMaintenance } from "./NotablePlacesMaintenance"; import { OvertureMaintenance } from "./OvertureMaintenance"; import { SearchIndexMaintenance } from "./SearchIndexMaintenance"; @@ -716,6 +717,7 @@ export function DataWorkflowsPage() { + diff --git a/apps/web/src/components/map/MapStylePoiClickHandler.tsx b/apps/web/src/components/map/MapStylePoiClickHandler.tsx index af60b23c2..0882cf9d8 100644 --- a/apps/web/src/components/map/MapStylePoiClickHandler.tsx +++ b/apps/web/src/components/map/MapStylePoiClickHandler.tsx @@ -3,6 +3,7 @@ import useMediaQuery from "@mui/material/useMediaQuery"; import type { Place } from "@openmapx/core"; import { + categoryPlaceToPlace, createPlace, PANEL, useDirectionsStore, @@ -46,6 +47,7 @@ const HIDE_GRACE_MS = 150; * open exactly this place, so they share one place lookup. */ export function stylePoiPlace(target: StylePoiTarget): Place { + if (target.canonicalPlace) return categoryPlaceToPlace(target.canonicalPlace); return createPlace({ primaryScheme: "stylePoi", ids: { stylePoi: target.featureId }, diff --git a/apps/web/src/components/map/ambientPlaceIdentity.test.ts b/apps/web/src/components/map/ambientPlaceIdentity.test.ts new file mode 100644 index 000000000..65a289396 --- /dev/null +++ b/apps/web/src/components/map/ambientPlaceIdentity.test.ts @@ -0,0 +1,48 @@ +import type { MapGeoJSONFeature, Map as MapLibreMap } from "maplibre-gl"; +import { describe, expect, it } from "vitest"; +import { + ambientBasemapKey, + clearAmbientIdentities, + getAmbientIdentity, + setAmbientIdentities, +} from "./ambientPlaceIdentity"; +import { stylePoiPlace } from "./MapStylePoiClickHandler"; + +const feature = { + id: 42, + source: "basemap", + sourceLayer: "poi", + geometry: { type: "Point", coordinates: [6.08, 50.77] }, + properties: { name: "Klinik" }, +} as unknown as MapGeoJSONFeature; +const canonical = { + id: "osm:node/9007199254740993", + gersId: "gers-a", + name: "Klinik", + coordinates: [6.08, 50.77] as [number, number], + category: "hospital", +}; +describe("ambient basemap canonical identity", () => { + it("scopes mappings to a map and removes them on teardown", () => { + const a = {} as MapLibreMap; + const b = {} as MapLibreMap; + setAmbientIdentities(a, new globalThis.Map([[ambientBasemapKey(feature), canonical]])); + expect(getAmbientIdentity(a, feature)).toEqual(canonical); + expect(getAmbientIdentity(b, feature)).toBeUndefined(); + clearAmbientIdentities(a); + expect(getAmbientIdentity(a, feature)).toBeUndefined(); + }); + it("uses the same canonical id and GERS in a basemap tap as tile/category selection", () => { + const selected = stylePoiPlace({ + featureId: "42", + name: "Klinik", + coordinates: [6.08, 50.77], + canonicalPlace: canonical, + }); + expect(selected).toMatchObject({ + id: canonical.id, + primaryScheme: "osm", + ids: { osm: "node/9007199254740993", overture: "gers-a", gers: "gers-a" }, + }); + }); +}); diff --git a/apps/web/src/components/map/ambientPlaceIdentity.ts b/apps/web/src/components/map/ambientPlaceIdentity.ts new file mode 100644 index 000000000..9d88e2002 --- /dev/null +++ b/apps/web/src/components/map/ambientPlaceIdentity.ts @@ -0,0 +1,23 @@ +import type { CategoryPlace } from "@openmapx/core"; +import type { MapGeoJSONFeature, Map as MapLibreMap } from "maplibre-gl"; + +const identities = new WeakMap>(); +export function ambientBasemapKey(feature: MapGeoJSONFeature): string { + const point = feature.geometry.type === "Point" ? feature.geometry.coordinates.join(",") : ""; + return `${feature.source}/${feature.sourceLayer}/${feature.id ?? point}`; +} +export function setAmbientIdentities( + map: MapLibreMap, + places: globalThis.Map, +): void { + identities.set(map, places); +} +export function getAmbientIdentity( + map: MapLibreMap, + feature: MapGeoJSONFeature, +): CategoryPlace | undefined { + return identities.get(map)?.get(ambientBasemapKey(feature)); +} +export function clearAmbientIdentities(map: MapLibreMap): void { + identities.delete(map); +} diff --git a/apps/web/src/components/map/layer-selector/useLayerSelectorConfig.test.tsx b/apps/web/src/components/map/layer-selector/useLayerSelectorConfig.test.tsx index a1c09ada3..b1024d129 100644 --- a/apps/web/src/components/map/layer-selector/useLayerSelectorConfig.test.tsx +++ b/apps/web/src/components/map/layer-selector/useLayerSelectorConfig.test.tsx @@ -133,6 +133,7 @@ describe("purpose-based layer groups", () => { ]), ), ).toEqual({ + otherDetails: ["ambient-places"], transport: [ "cycling", "live-transit", @@ -156,7 +157,7 @@ describe("purpose-based layer groups", () => { "wildfires", ], }); - expect(result.current.mapDetails).toHaveLength(22); + expect(result.current.mapDetails).toHaveLength(23); }); it("keeps each selectable detail once and exposes unknown integrations", () => { diff --git a/apps/web/src/components/map/mapStylePoiTarget.ts b/apps/web/src/components/map/mapStylePoiTarget.ts index 0b5d9d663..645273357 100644 --- a/apps/web/src/components/map/mapStylePoiTarget.ts +++ b/apps/web/src/components/map/mapStylePoiTarget.ts @@ -1,6 +1,8 @@ +import type { CategoryPlace } from "@openmapx/core"; import { haversineDistance } from "@openmapx/core"; import type * as maplibregl from "maplibre-gl"; import type { FilterSpecification, MapGeoJSONFeature, StyleSpecification } from "maplibre-gl"; +import { getAmbientIdentity } from "./ambientPlaceIdentity"; import { STOP_LABEL_LAYER_ID } from "./stopLabelPoints"; const POI_SOURCE_LAYERS = new Set(["poi"]); @@ -17,6 +19,7 @@ const OWN_STYLE_POI_LAYER_IDS = new Set([ type StyleLayer = StyleSpecification["layers"][number]; export interface StylePoiTarget { + canonicalPlace?: CategoryPlace; featureId: string; name: string; coordinates: [number, number]; @@ -90,7 +93,7 @@ export function findStylePoiAtPoint( const features = map.queryRenderedFeatures(point, { layers: livePoiLayers }); for (const feature of features) { const target = targetFromFeature(feature, locale); - if (target) return target; + if (target) return { ...target, canonicalPlace: getAmbientIdentity(map, feature) }; } return null; } diff --git a/apps/web/src/integration-api/map/ambientPlaces.ts b/apps/web/src/integration-api/map/ambientPlaces.ts new file mode 100644 index 000000000..b69b58276 --- /dev/null +++ b/apps/web/src/integration-api/map/ambientPlaces.ts @@ -0,0 +1,8 @@ +/** Canonical owned-basemap identity and the current category/reach selection for ambient overlays. */ +export { + ambientBasemapKey, + clearAmbientIdentities, + setAmbientIdentities, +} from "@/components/map/ambientPlaceIdentity"; +export { getStylePoiLayerIds } from "@/components/map/mapStylePoiTarget"; +export { useExploreReachResults as useActivePlaceResults } from "@/lib/useExploreReachResults"; diff --git a/integrations/overlay-ambient-places/legend.tsx b/integrations/overlay-ambient-places/legend.tsx new file mode 100644 index 000000000..2bfea8466 --- /dev/null +++ b/integrations/overlay-ambient-places/legend.tsx @@ -0,0 +1,38 @@ +"use client"; +import Typography from "@mui/material/Typography"; +import { useOverlayVisibilitySetter } from "@openmapx/core"; +import { useLocale, useTranslations } from "next-intl"; +import { OverlayLegend } from "@/integration-api/overlay/OverlayLegend"; +import { useAmbientPlacesStore } from "./store"; +export function AmbientPlacesLegend() { + const t = useTranslations("ambientPlaces"); + const locale = useLocale(); + const state = useAmbientPlacesStore(); + const setVisible = useOverlayVisibilitySetter("ambient-places"); + if (!state.manifest && state.userRevision === 0) return null; + return ( + + + {state.manifest ? state.manifest.region.name : t("unavailable")} + + {state.manifest && ( + + {t("snapshot", { date: new Date(state.manifest.publishedAt).toLocaleDateString(locale) })}{" "} + · {state.manifest.sources.overture ? t("combined") : t("osmOnly")} + + )} + {state.manifest && !state.manifest.enabled && ( + {t("disabled")} + )} + {state.error && {t("error")}} + + ); +} diff --git a/integrations/overlay-ambient-places/manifest.json b/integrations/overlay-ambient-places/manifest.json new file mode 100644 index 000000000..1ac07fce3 --- /dev/null +++ b/integrations/overlay-ambient-places/manifest.json @@ -0,0 +1,156 @@ +{ + "id": "overlay-ambient-places", + "version": "1.0.0", + "author": "OpenMapX", + "license": "AGPL-3.0-or-later", + "documentation": "https://openmapx.dev/docs/features/overture-places", + "subjectData": { + "storesPersonalData": false, + "strategy": "not_personal", + "registrationIds": [], + "operatorInstructions": null + }, + "domains": ["map-overlay"], + "frontend": { + "mapLayer": true, + "legend": true, + "panel": false, + "layerSelector": { + "group": "map-details", + "labelKey": "ambientPlaces", + "icon": "place", + "quickSelector": true, + "preview": "preview.svg" + }, + "overlay": { + "minZoom": 13 + } + }, + "backend": { + "routes": false + }, + "configSchema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true + } + } + }, + "quality": "built-in", + "category": "Places", + "dataSources": [ + { + "sourceId": "overture", + "name": "Overture Maps", + "url": "https://overturemaps.org", + "license": "CDLA-Permissive-2.0", + "licenseUrl": "https://cdla.dev/permissive-2-0/", + "commercialUse": "yes", + "providerCountry": "US", + "providerPrivacyUrl": "https://overturemaps.org/privacy/", + "endUserExposure": "server-only" + }, + { + "sourceId": "foursquare", + "name": "Foursquare", + "url": "https://foursquare.com", + "license": "Apache-2.0", + "licenseUrl": "https://www.apache.org/licenses/LICENSE-2.0", + "attribution": "Data from Foursquare. Copyright 2024 Foursquare Labs, Inc. All rights reserved. Available under Apache 2.0. Foursquare data was transformed to the Overture schema. Changed: 2026-03-18.", + "commercialUse": "yes", + "providerCountry": "US", + "providerPrivacyUrl": "https://foursquare.com/legal/privacy-center/", + "endUserExposure": "server-only" + }, + { + "sourceId": "meta-places", + "name": "Meta Places", + "url": "https://www.meta.com/", + "license": "CDLA-Permissive-2.0", + "licenseUrl": "https://cdla.dev/permissive-2-0/", + "commercialUse": "yes", + "providerCountry": "US", + "providerPrivacyUrl": "https://www.facebook.com/privacy/policy/", + "endUserExposure": "server-only" + }, + { + "sourceId": "microsoft-places", + "name": "Microsoft Places", + "url": "https://www.microsoft.com/maps", + "license": "CDLA-Permissive-2.0", + "licenseUrl": "https://cdla.dev/permissive-2-0/", + "commercialUse": "yes", + "providerCountry": "US", + "providerPrivacyUrl": "https://privacy.microsoft.com/privacystatement", + "endUserExposure": "server-only" + }, + { + "sourceId": "pinmeto", + "name": "PinMeTo", + "url": "https://www.pinmeto.com/", + "license": "CDLA-Permissive-2.0", + "licenseUrl": "https://cdla.dev/permissive-2-0/", + "commercialUse": "yes", + "providerCountry": "SE", + "providerPrivacyUrl": "https://www.pinmeto.com/privacy-policy", + "endUserExposure": "server-only" + }, + { + "sourceId": "krick", + "name": "Krick", + "url": "https://www.krick.com/", + "license": "CDLA-Permissive-2.0", + "licenseUrl": "https://cdla.dev/permissive-2-0/", + "commercialUse": "yes", + "providerCountry": "DE", + "providerPrivacyUrl": "https://www.krick.com/datenschutz/", + "endUserExposure": "server-only" + }, + { + "sourceId": "renderseo", + "name": "RenderSEO", + "url": "https://www.renderseo.com/", + "license": "CDLA-Permissive-2.0", + "licenseUrl": "https://cdla.dev/permissive-2-0/", + "commercialUse": "yes", + "providerCountry": "US", + "providerPrivacyUrl": "https://www.renderseo.com/privacy-policy/", + "endUserExposure": "server-only" + }, + { + "sourceId": "dac", + "name": "DAC", + "url": "https://www.dacgroup.com/", + "license": "CDLA-Permissive-2.0", + "licenseUrl": "https://cdla.dev/permissive-2-0/", + "commercialUse": "yes", + "providerCountry": "CA", + "providerPrivacyUrl": "https://www.dacgroup.com/privacy-policy/", + "endUserExposure": "server-only" + }, + { + "sourceId": "brightquery", + "name": "BrightQuery", + "url": "https://brightquery.com/", + "license": "CDLA-Permissive-2.0", + "licenseUrl": "https://cdla.dev/permissive-2-0/", + "commercialUse": "yes", + "providerCountry": "US", + "providerPrivacyUrl": "https://brightquery.com/privacy/", + "endUserExposure": "server-only" + }, + { + "sourceId": "alltheplaces", + "name": "AllThePlaces", + "url": "https://www.alltheplaces.xyz/", + "license": "CC0-1.0", + "licenseUrl": "https://creativecommons.org/publicdomain/zero/1.0/", + "commercialUse": "yes", + "providerCountry": "US", + "providerPrivacyUrl": "https://www.alltheplaces.xyz/", + "endUserExposure": "server-only" + } + ] +} diff --git a/integrations/overlay-ambient-places/map-layer.test.tsx b/integrations/overlay-ambient-places/map-layer.test.tsx new file mode 100644 index 000000000..380d40cb2 --- /dev/null +++ b/integrations/overlay-ambient-places/map-layer.test.tsx @@ -0,0 +1,166 @@ +import { usePlaceStore } from "@openmapx/core"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import type { MapGeoJSONFeature } from "maplibre-gl"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const test = vi.hoisted(() => ({ + group: null as unknown, + map: { + getLayer: vi.fn(() => true), + getSource: vi.fn(() => true), + getZoom: () => 16, + querySourceFeatures: vi.fn((): MapGeoJSONFeature[] => []), + queryRenderedFeatures: vi.fn((): MapGeoJSONFeature[] => []), + getStyle: () => ({ layers: [] }), + setFilter: vi.fn(), + on: vi.fn<(event: string, handler: (event?: unknown) => void) => void>(), + off: vi.fn<(event: string, handler: (event?: unknown) => void) => void>(), + getCanvas: () => ({ style: { cursor: "" } }), + }, +})); +vi.mock("@/integration-api/map/MapContext", () => { + const context = { mapRef: { current: test.map }, mapReady: true, styleVersion: 0 }; + return { useMap: () => context }; +}); +vi.mock("@/integration-api/map/useMapLayerGroup", () => ({ + useMapLayerGroup: (group: unknown) => { + test.group = group; + }, +})); +vi.mock("@/integration-api/overlay/useIntegrationAttribution", () => ({ + useIntegrationAttribution: vi.fn<(event: string, handler: (event?: unknown) => void) => void>(), +})); +vi.mock("@/integration-api/runtime/EnvProvider", () => ({ + useEnv: () => ({ apiUrl: "http://fixture" }), +})); +vi.mock("@/lib/useExploreReachResults", () => { + const result = { filtered: [] }; + return { useExploreReachResults: () => result }; +}); +vi.mock("next-intl", () => ({ useLocale: () => "de" })); + +import { AmbientPlacesLayer } from "./map-layer"; +import { useAmbientPlacesStore } from "./store"; + +const manifest = { + version: 1, + policyVersion: 1, + generation: "11111111-1111-4111-8111-111111111111", + publishedAt: new Date().toISOString(), + region: { name: "Aachen", bounds: [5.9, 50.65, 6.3, 50.95] }, + placeCount: 1, + enabled: true, + sources: { + osm: { region: "Germany", epoch: "one", publishedAt: new Date().toISOString(), count: 1 }, + overture: null, + }, +}; +beforeEach(() => { + useAmbientPlacesStore.setState({ + panelOpen: true, + layerVisible: true, + manifest: null, + error: false, + loading: false, + }); + test.group = null; + vi.clearAllMocks(); + test.map.querySourceFeatures.mockReturnValue([]); + test.map.queryRenderedFeatures.mockReturnValue([]); + usePlaceStore.setState({ selectedPlace: null }); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); +describe("ambient generation lifecycle", () => { + it("uses a bounded generation source and cleans handlers/identity on disable", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => ({ manifest }) })), + ); + const view = render(); + await waitFor(() => expect(test.group).not.toBeNull()); + expect(JSON.stringify(test.group)).toContain(manifest.generation); + expect(JSON.stringify(test.group)).toContain('"maxzoom":18'); + act(() => useAmbientPlacesStore.setState({ layerVisible: false })); + await waitFor(() => expect(test.group).toBeNull()); + view.unmount(); + expect(test.map.off).toHaveBeenCalled(); + }); + it("ignores late discovery after disable and leaves no-data browsing intact", async () => { + let resolve!: (value: unknown) => void; + vi.stubGlobal( + "fetch", + vi.fn( + () => + new Promise((r) => { + resolve = r; + }), + ), + ); + render(); + act(() => useAmbientPlacesStore.setState({ layerVisible: false })); + await act(async () => resolve({ ok: true, json: async () => ({ manifest }) })); + expect(test.group).toBeNull(); + expect(useAmbientPlacesStore.getState().manifest).toBeNull(); + }); + it("opens the canonical tile identity, suppresses its selected marker and reapplies filters after style replacement", async () => { + const feature = { + type: "Feature", + geometry: { type: "Point", coordinates: [6.08, 50.77] }, + properties: { + id: "osm:node/9007199254740993", + gers_id: "gers-a", + name: "Clinic", + name_de: "Klinik", + category: "amenity:hospital", + rank: 800, + min_zoom: 13, + tenant: false, + sources: "osm,overture", + }, + } as MapGeoJSONFeature; + test.map.querySourceFeatures.mockReturnValue([feature]); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => ({ manifest }) })), + ); + render(); + await waitFor(() => expect(test.group).not.toBeNull()); + const handlers = () => + Object.fromEntries( + test.map.on.mock.calls.map(([event, handler]) => [event, handler]), + ) as Record void>; + test.map.queryRenderedFeatures.mockReturnValue([feature]); + act(() => handlers().click({ point: { x: 1, y: 2 } })); + expect(usePlaceStore.getState().selectedPlace).toMatchObject({ + id: "osm:node/9007199254740993", + ids: { gers: "gers-a", overture: "gers-a" }, + name: "Klinik", + }); + await waitFor(() => + expect(JSON.stringify(test.map.setFilter.mock.calls.at(-1))).toContain( + "osm:node/9007199254740993", + ), + ); + const prior = test.map.setFilter.mock.calls.length; + act(() => { + handlers()["style.load"](); + handlers().idle(); + }); + expect(test.map.setFilter.mock.calls.length).toBeGreaterThan(prior); + }); + it("removes a disabled publication without resurrecting the previous generation", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ manifest: { ...manifest, enabled: false } }), + })), + ); + render(); + await waitFor(() => expect(useAmbientPlacesStore.getState().loading).toBe(false)); + expect(test.group).toBeNull(); + }); +}); diff --git a/integrations/overlay-ambient-places/map-layer.tsx b/integrations/overlay-ambient-places/map-layer.tsx new file mode 100644 index 000000000..77679fa89 --- /dev/null +++ b/integrations/overlay-ambient-places/map-layer.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { useTheme } from "@mui/material/styles"; +import { categoryPlaceToPlace, PANEL, usePlaceStore, useSidebarStore } from "@openmapx/core"; +import { + AMBIENT_LIMITS, + AMBIENT_MAX_AGE_MS, + type AmbientManifest, + type AmbientPlace, + ambientIdentityKeys, + ambientPlaceFromTile, + ambientPlaceToCategoryPlace, + matchAmbientBasemap, +} from "@openmapx/core/ambient-places"; +import type { FilterSpecification, MapGeoJSONFeature, MapMouseEvent } from "maplibre-gl"; +import { useLocale } from "next-intl"; +import { useEffect, useMemo } from "react"; +import { + ambientBasemapKey, + clearAmbientIdentities, + getStylePoiLayerIds, + setAmbientIdentities, + useActivePlaceResults, +} from "@/integration-api/map/ambientPlaces"; +import { INTERACTIVE_LAYER_IDS } from "@/integration-api/map/interactiveLayers"; +import { useMap } from "@/integration-api/map/MapContext"; +import { getMapClickOwner } from "@/integration-api/map/mapClickOwnership"; +import type { MapLayerGroup } from "@/integration-api/map/mapLayerGroup"; +import { useMapLayerGroup } from "@/integration-api/map/useMapLayerGroup"; +import { useIntegrationAttribution } from "@/integration-api/overlay/useIntegrationAttribution"; +import { useEnv } from "@/integration-api/runtime/EnvProvider"; +import { useAmbientPlacesStore } from "./store"; + +export const AMBIENT_SOURCE = "ambient-places-source"; +export const AMBIENT_POINT_LAYER = "ambient-places-points"; +export const AMBIENT_LABEL_LAYER = "ambient-places-labels"; +const LAYERS = [AMBIENT_POINT_LAYER, AMBIENT_LABEL_LAYER]; +function fromFeature( + feature: Pick, +): AmbientPlace | null { + return feature.geometry.type === "Point" + ? ambientPlaceFromTile(feature.properties ?? {}, feature.geometry.coordinates) + : null; +} +function usable(manifest: AmbientManifest | null): boolean { + return Boolean( + manifest?.enabled && + [ + manifest.publishedAt, + manifest.sources.osm.publishedAt, + ...(manifest.sources.overture ? [manifest.sources.overture.publishedAt] : []), + ].every( + (date) => + Number.isFinite(Date.parse(date)) && Date.now() - Date.parse(date) <= AMBIENT_MAX_AGE_MS, + ), + ); +} +export function AmbientPlacesLayer() { + const dark = useTheme().palette.mode === "dark"; + const { mapRef, mapReady } = useMap(); + const env = useEnv(); + const locale = useLocale(); + const shown = useAmbientPlacesStore((s) => s.panelOpen && s.layerVisible); + const manifest = useAmbientPlacesStore((s) => s.manifest); + const publication = useAmbientPlacesStore((s) => s.setPublication); + const selected = usePlaceStore((s) => s.selectedPlace); + const { filtered } = useActivePlaceResults(locale); + const occupied = useMemo( + () => + new Set([ + ...(filtered ?? []).flatMap(ambientIdentityKeys), + ...(selected ? ambientIdentityKeys(selected) : []), + ]), + [filtered, selected], + ); + + useEffect(() => { + if (!shown) return; + let current = true; + let controller: AbortController | null = null; + const refresh = async () => { + controller?.abort(); + controller = new AbortController(); + const signal = controller.signal; + const existing = useAmbientPlacesStore.getState().manifest; + publication(existing, false, true); + try { + const response = await fetch(`${env.apiUrl}/api/ambient-places/manifest`, { + signal, + cache: "no-store", + }); + if (!response.ok) throw new Error("Publication unavailable"); + const body = (await response.json()) as { manifest: AmbientManifest | null }; + if ( + body.manifest && + (body.manifest.version !== 1 || + !body.manifest.generation || + !body.manifest.region?.bounds || + !body.manifest.sources?.osm) + ) + throw new Error("Unsupported publication"); + if (current && !signal.aborted) publication(body.manifest, false, false); + } catch { + if (current && !signal.aborted) + publication(usable(existing) ? existing : null, true, false); + } + }; + void refresh(); + const timer = setInterval(() => void refresh(), 60_000); + return () => { + current = false; + controller?.abort(); + clearInterval(timer); + }; + }, [shown, env.apiUrl, publication]); + + const visible = shown && usable(manifest); + useIntegrationAttribution("overlay-ambient-places", visible); + const group = useMemo(() => { + if (!visible || !manifest) return null; + return { + sources: { + [AMBIENT_SOURCE]: { + type: "vector", + tiles: [`${env.apiUrl}/api/ambient-places/tiles/${manifest.generation}/{z}/{x}/{y}.mvt`], + bounds: manifest.region.bounds, + minzoom: AMBIENT_LIMITS.minZoom, + maxzoom: AMBIENT_LIMITS.maxZoom, + }, + }, + layers: [ + { + id: AMBIENT_POINT_LAYER, + type: "circle", + source: AMBIENT_SOURCE, + "source-layer": "ambient_places", + minzoom: 13, + slot: "overlay-markers", + order: 35, + paint: { + "circle-radius": 3, + "circle-color": "#287e79", + "circle-stroke-color": "#ffffff", + "circle-stroke-width": 1, + }, + }, + { + id: AMBIENT_LABEL_LAYER, + type: "symbol", + source: AMBIENT_SOURCE, + "source-layer": "ambient_places", + minzoom: 13, + slot: "overlay-markers", + order: 36, + layout: { + "text-field": ["coalesce", ["get", `name_${locale.split("-")[0]}`], ["get", "name"]], + "text-font": ["Noto Sans Regular"], + "text-size": 11, + "text-anchor": "left", + "text-offset": [0.6, 0], + "symbol-sort-key": ["-", ["get", "rank"]], + "text-allow-overlap": false, + "text-ignore-placement": false, + "text-max-width": 12, + }, + paint: { + "text-color": dark ? "#a5dcd7" : "#17635e", + "text-halo-color": dark ? "#1c222b" : "#ffffff", + "text-halo-width": 1.5, + }, + }, + ], + }; + }, [visible, manifest, env.apiUrl, locale, dark]); + useMapLayerGroup(group); + + useEffect(() => { + const map = mapRef.current; + if (!visible || !manifest || !map || !mapReady) return; + let lastFilter = ""; + const reconcile = () => { + if (!map.getSource(AMBIENT_SOURCE)) return; + const unique = new Map(); + for (const feature of map.querySourceFeatures(AMBIENT_SOURCE, { + sourceLayer: "ambient_places", + })) { + const p = fromFeature(feature); + if (p) unique.set(p.id, p); + } + const places = [...unique.values()]; + const basemapLayers = getStylePoiLayerIds(map).filter((id) => map.getLayer(id)); + const features = basemapLayers.length + ? map.queryRenderedFeatures(undefined, { layers: basemapLayers }) + : []; + const labels = features.flatMap((feature) => { + if (feature.geometry.type !== "Point") return []; + const name = + feature.properties?.[`name:${locale.split("-")[0]}`] ?? feature.properties?.name; + if (typeof name !== "string") return []; + const osmType = feature.properties?.osm_type; + const osmId = feature.properties?.osm_id; + return [ + { + key: ambientBasemapKey(feature), + name, + coordinates: feature.geometry.coordinates as [number, number], + category: String(feature.properties?.subclass ?? feature.properties?.class ?? ""), + osmId: + typeof osmId === "string" && /^(node|way|relation)$/.test(osmType) + ? `osm:${osmType}/${osmId}` + : undefined, + }, + ]; + }); + const matched = matchAmbientBasemap(places, labels); + setAmbientIdentities( + map, + new Map([...matched].map(([key, p]) => [key, ambientPlaceToCategoryPlace(p, locale)])), + ); + const hidden = new Set([...matched.values()].map((p) => p.id)); + for (const p of places) + if (ambientIdentityKeys(p).some((id) => occupied.has(id))) hidden.add(p.id); + const filter: FilterSpecification = [ + "all", + ["<=", ["get", "min_zoom"], Math.floor(map.getZoom())], + ["!", ["in", ["get", "id"], ["literal", [...hidden].sort()]]], + ]; + const key = JSON.stringify(filter); + if (key === lastFilter) return; + lastFilter = key; + for (const id of LAYERS) if (map.getLayer(id)) map.setFilter(id, filter); + }; + const click = (event: MapMouseEvent) => { + if (getMapClickOwner(event)) return; + const live = LAYERS.filter((id) => map.getLayer(id)); + if (!live.length) return; + const feature = map.queryRenderedFeatures(event.point, { layers: live })[0]; + const place = feature ? fromFeature(feature) : null; + if (!place) return; + usePlaceStore + .getState() + .setSelectedPlace(categoryPlaceToPlace(ambientPlaceToCategoryPlace(place, locale))); + const sidebar = useSidebarStore.getState(); + if (!sidebar.activeSidebarId || sidebar.activeSidebarId === PANEL.PLACE) { + sidebar.closeDetail(); + sidebar.openSidebar(PANEL.PLACE); + } else sidebar.openDetail(PANEL.PLACE_CARD); + }; + const style = () => { + lastFilter = ""; + clearAmbientIdentities(map); + }; + for (const id of LAYERS) INTERACTIVE_LAYER_IDS.add(id); + map.on("idle", reconcile); + map.on("zoomend", reconcile); + map.on("style.load", style); + map.on("click", click); + reconcile(); + return () => { + map.off("idle", reconcile); + map.off("zoomend", reconcile); + map.off("style.load", style); + map.off("click", click); + clearAmbientIdentities(map); + for (const id of LAYERS) INTERACTIVE_LAYER_IDS.delete(id); + }; + }, [mapRef, mapReady, visible, manifest, locale, occupied]); + return null; +} diff --git a/integrations/overlay-ambient-places/package.json b/integrations/overlay-ambient-places/package.json new file mode 100644 index 000000000..3f24359f5 --- /dev/null +++ b/integrations/overlay-ambient-places/package.json @@ -0,0 +1,21 @@ +{ + "name": "@openmapx/integration-overlay-ambient-places", + "license": "AGPL-3.0-or-later", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "@openmapx/core": "workspace:^" + }, + "peerDependencies": { + "react": ">=19", + "next-intl": "^4.14.5", + "@mui/material": "^9.4.0" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "react": "19.3.0", + "vitest": "^5.0.1", + "@testing-library/react": "^16.3.3" + } +} diff --git a/integrations/overlay-ambient-places/preview.svg b/integrations/overlay-ambient-places/preview.svg new file mode 100644 index 000000000..690a27860 --- /dev/null +++ b/integrations/overlay-ambient-places/preview.svg @@ -0,0 +1 @@ +CaféApothekeBibliothek diff --git a/integrations/overlay-ambient-places/store.ts b/integrations/overlay-ambient-places/store.ts new file mode 100644 index 000000000..60bd24225 --- /dev/null +++ b/integrations/overlay-ambient-places/store.ts @@ -0,0 +1,21 @@ +import { createOverlayStore } from "@openmapx/core"; +import type { AmbientManifest } from "@openmapx/core/ambient-places"; + +export const useAmbientPlacesStore = createOverlayStore({ + overlayId: "ambient-places", + extra: { + panelOpen: true, + layerVisible: true, + manifest: null as AmbientManifest | null, + error: false, + loading: false, + }, + actions: (set) => ({ + setPublication: (manifest: AmbientManifest | null, error: boolean, loading: boolean) => + set({ manifest, error, loading }), + }), +}); +// A lazy-load placeholder with no user interaction must not override the normal- +// browsing default; a user's early toggle/deep link always wins. +if (useAmbientPlacesStore.getState().userRevision === 0) + useAmbientPlacesStore.setState({ panelOpen: true, layerVisible: true }); diff --git a/integrations/overlay-ambient-places/strings/de.json b/integrations/overlay-ambient-places/strings/de.json new file mode 100644 index 000000000..91c1bf01e --- /dev/null +++ b/integrations/overlay-ambient-places/strings/de.json @@ -0,0 +1,56 @@ +{ + "name": "Orte in der Nähe", + "description": "Benannte Orte aus einem veröffentlichten regionalen OSM- und Overture-Datensatz", + "dataSources": { + "overture": { + "purpose": "Benannte Orte aus einem veröffentlichten regionalen OSM- und Overture-Datensatz", + "dataSent": "Keine; selbst gehostete regionale Kartenkacheln", + "dataReceived": "Namen und Koordinaten von Orten" + }, + "foursquare": { + "purpose": "Benannte Orte aus einem veröffentlichten regionalen OSM- und Overture-Datensatz", + "dataSent": "Keine; selbst gehostete regionale Kartenkacheln", + "dataReceived": "Namen und Koordinaten von Orten" + }, + "meta-places": { + "purpose": "Benannte Orte aus einem veröffentlichten regionalen OSM- und Overture-Datensatz", + "dataSent": "Keine; selbst gehostete regionale Kartenkacheln", + "dataReceived": "Namen und Koordinaten von Orten" + }, + "microsoft-places": { + "purpose": "Benannte Orte aus einem veröffentlichten regionalen OSM- und Overture-Datensatz", + "dataSent": "Keine; selbst gehostete regionale Kartenkacheln", + "dataReceived": "Namen und Koordinaten von Orten" + }, + "pinmeto": { + "purpose": "Benannte Orte aus einem veröffentlichten regionalen OSM- und Overture-Datensatz", + "dataSent": "Keine; selbst gehostete regionale Kartenkacheln", + "dataReceived": "Namen und Koordinaten von Orten" + }, + "krick": { + "purpose": "Benannte Orte aus einem veröffentlichten regionalen OSM- und Overture-Datensatz", + "dataSent": "Keine; selbst gehostete regionale Kartenkacheln", + "dataReceived": "Namen und Koordinaten von Orten" + }, + "renderseo": { + "purpose": "Benannte Orte aus einem veröffentlichten regionalen OSM- und Overture-Datensatz", + "dataSent": "Keine; selbst gehostete regionale Kartenkacheln", + "dataReceived": "Namen und Koordinaten von Orten" + }, + "dac": { + "purpose": "Benannte Orte aus einem veröffentlichten regionalen OSM- und Overture-Datensatz", + "dataSent": "Keine; selbst gehostete regionale Kartenkacheln", + "dataReceived": "Namen und Koordinaten von Orten" + }, + "brightquery": { + "purpose": "Benannte Orte aus einem veröffentlichten regionalen OSM- und Overture-Datensatz", + "dataSent": "Keine; selbst gehostete regionale Kartenkacheln", + "dataReceived": "Namen und Koordinaten von Orten" + }, + "alltheplaces": { + "purpose": "Benannte Orte aus einem veröffentlichten regionalen OSM- und Overture-Datensatz", + "dataSent": "Keine; selbst gehostete regionale Kartenkacheln", + "dataReceived": "Namen und Koordinaten von Orten" + } + } +} diff --git a/integrations/overlay-ambient-places/strings/en.json b/integrations/overlay-ambient-places/strings/en.json new file mode 100644 index 000000000..8d86fcd81 --- /dev/null +++ b/integrations/overlay-ambient-places/strings/en.json @@ -0,0 +1,56 @@ +{ + "name": "Nearby places", + "description": "Named places from a published regional OSM and Overture snapshot", + "dataSources": { + "overture": { + "purpose": "Named places from a published regional OSM and Overture snapshot", + "dataSent": "None; hosted regional tiles", + "dataReceived": "Names and locations of places" + }, + "foursquare": { + "purpose": "Named places from a published regional OSM and Overture snapshot", + "dataSent": "None; hosted regional tiles", + "dataReceived": "Names and locations of places" + }, + "meta-places": { + "purpose": "Named places from a published regional OSM and Overture snapshot", + "dataSent": "None; hosted regional tiles", + "dataReceived": "Names and locations of places" + }, + "microsoft-places": { + "purpose": "Named places from a published regional OSM and Overture snapshot", + "dataSent": "None; hosted regional tiles", + "dataReceived": "Names and locations of places" + }, + "pinmeto": { + "purpose": "Named places from a published regional OSM and Overture snapshot", + "dataSent": "None; hosted regional tiles", + "dataReceived": "Names and locations of places" + }, + "krick": { + "purpose": "Named places from a published regional OSM and Overture snapshot", + "dataSent": "None; hosted regional tiles", + "dataReceived": "Names and locations of places" + }, + "renderseo": { + "purpose": "Named places from a published regional OSM and Overture snapshot", + "dataSent": "None; hosted regional tiles", + "dataReceived": "Names and locations of places" + }, + "dac": { + "purpose": "Named places from a published regional OSM and Overture snapshot", + "dataSent": "None; hosted regional tiles", + "dataReceived": "Names and locations of places" + }, + "brightquery": { + "purpose": "Named places from a published regional OSM and Overture snapshot", + "dataSent": "None; hosted regional tiles", + "dataReceived": "Names and locations of places" + }, + "alltheplaces": { + "purpose": "Named places from a published regional OSM and Overture snapshot", + "dataSent": "None; hosted regional tiles", + "dataReceived": "Names and locations of places" + } + } +} diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index 3adc74d40..c19f43d83 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -409,7 +409,8 @@ "nautical": "Seekarten", "sunTime": "Tageslicht & Zeitzonen", "schematicTransit": "Schematischer Liniennetzplan", - "closeMorePanel": "Erweitertes Menü schließen" + "closeMorePanel": "Erweitertes Menü schließen", + "ambientPlaces": "Orte in der Nähe" }, "schematicTransit": { "title": "Schematischer Liniennetzplan", @@ -4324,5 +4325,15 @@ "delay": "+{delay}", "coverageExplanation": "Die Fahrzeit berücksichtigt den aktuellen Verkehr.", "estimateExplanation": "Die zusätzliche Zeit wird geschätzt, indem diese Route mit und ohne aktuelle Verkehrsgeschwindigkeiten verglichen wird. Tatsächliche Verzögerungen können abweichen." + }, + "ambientPlaces": { + "title": "Orte in der Nähe", + "toggle": "Orte in der Nähe anzeigen", + "unavailable": "Kein regionaler Datensatz verfügbar", + "snapshot": "Datensatz vom {date}", + "combined": "OSM + Overture", + "osmOnly": "Nur OSM", + "disabled": "Dieser regionale Datensatz ist deaktiviert oder veraltet", + "error": "Aktualisierung nicht verfügbar; letzter verfügbarer Datensatz wird verwendet" } } diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index 6872d1060..ae50413eb 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -409,7 +409,8 @@ "nautical": "Nautical", "sunTime": "Daylight & time zones", "schematicTransit": "Schematic transit map", - "closeMorePanel": "Close more panel placeholder" + "closeMorePanel": "Close more panel placeholder", + "ambientPlaces": "Nearby places" }, "schematicTransit": { "title": "Schematic transit map", @@ -4324,5 +4325,15 @@ "delay": "+{delay}", "coverageExplanation": "Current traffic is included in this travel time.", "estimateExplanation": "The extra time is estimated by comparing this route with and without current traffic speeds. Actual delays can vary." + }, + "ambientPlaces": { + "title": "Nearby places", + "toggle": "Show nearby places", + "unavailable": "No regional snapshot is available", + "snapshot": "Snapshot {date}", + "combined": "OSM + Overture", + "osmOnly": "OSM only", + "disabled": "This regional snapshot is disabled or out of date", + "error": "Refresh unavailable; using the last available snapshot" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5def348f2..c5ef65b41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1120,6 +1120,31 @@ importers: specifier: ^4.6.5 version: 4.6.5 + integrations/overlay-ambient-places: + dependencies: + '@mui/material': + specifier: ^9.4.0 + version: 9.4.0(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@openmapx/core': + specifier: workspace:^ + version: link:../../packages/core + next-intl: + specifier: ^4.14.5 + version: 4.14.7(@swc/helpers@0.5.23)(@types/react@19.3.0)(next@16.3.6(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + devDependencies: + '@testing-library/react': + specifier: ^16.3.3 + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + react: + specifier: 19.3.0 + version: 19.3.0 + vitest: + specifier: ^5.0.1 + version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@5.0.1)(jsdom@30.1.1(@noble/hashes@2.4.0))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + integrations/overlay-cycling: dependencies: '@openmapx/core': From 937202fcbb26000ab0b0130175bbd7b2d0cb023a Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 8 Oct 2026 00:09:47 +0200 Subject: [PATCH 05/13] docs(places): document regional publication and validation --- README.md | 4 +- .../developer/ambient-places-publication.md | 166 ++++++++++++++++++ docs/docs/developer/api-surface.md | 2 +- docs/docs/features/map-layers.md | 15 ++ docs/docs/features/overture-places.md | 43 +++++ docs/docs/overview/introduction.md | 2 +- 6 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 docs/docs/developer/ambient-places-publication.md diff --git a/README.md b/README.md index 519a51e23..4f7855c73 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ OpenMapX is a complete mapping platform you run on your own infrastructure: sear - **Complete mapping platform** — geocoding, traffic-aware and EV routing, public-transit planning and navigation, live vehicles, street-level imagery, POI search, knowledge enrichment, reviews, crowd reports, weather, and dozens of overlays - **Two-layer plugin system** — _services_ (containers: Valhalla, Nominatim, MOTIS, …) and _integrations_ (app features: providers, overlays, data sources, tools). Both support community plugins from any Git URL -- **105 built-in integrations** and **29 built-in services** rendered into a generated `docker-compose.yml` +- **106 built-in integrations** and **29 built-in services** rendered into a generated `docker-compose.yml` - **Self-host the provider stack** — routing, geocoding, transit, tiles, search, and data pipelines have local/open implementations; the lightweight app, database, cache, proxy, and data-manager form the required core - **Open data** — OpenStreetMap, GTFS via Transitous, Wikidata, Wikipedia, Mapillary, NASA, NOAA, ECCC, DWD, MeteoAlarm, OpenAQ, USGS, NPS, and more - **Privacy-first** — no third-party analytics; most upstream calls are proxied through your API server, and unavoidable direct browser asset loads are explicitly documented @@ -64,7 +64,7 @@ graph TD Traefik --> Martin["martin :3002
PostGIS vector tiles"] subgraph "API Server" - IntHost["Integration Host
105 built-in + community"] + IntHost["Integration Host
106 built-in + community"] Orchestrators["Domain orchestrators
(geocoding, routing, transit, ...)"] SvcRegistry["Service Registry"] Bindings["Capability Bindings"] diff --git a/docs/docs/developer/ambient-places-publication.md b/docs/docs/developer/ambient-places-publication.md new file mode 100644 index 000000000..746d2621e --- /dev/null +++ b/docs/docs/developer/ambient-places-publication.md @@ -0,0 +1,166 @@ +--- +title: Ambient place publication +description: Bounded regional place generations, canonical identities and the MVT/PMTiles serving decision. +--- + +# Ambient place publication + +`overlay-ambient-places` shows destinations during ordinary map browsing. It consumes +one published German region; source ingestion remains in the existing OSM search +index and Overture workflows. The initial operator form targets Aachen. Nothing +imports planet data, calls AllThePlaces directly, or changes geocoding. + +## Operator workflow and public contract + +1. Build the regional OSM search index using the existing data workflow. Its state + must be `ready`, with a publication timestamp no older than 90 days. +2. Optionally complete Overture Places ingestion and conflation for the installed + release. An absent or empty, uninitialized Overture source permits OSM-only + publication; populated, unfinished, mismatched or stale sources fail closed. +3. In **Admin → Services → Data workflows → Nearby places**, choose a name and + bounding box. Bounds must fit Germany's rollout envelope + `[5.8,47.2,15.1,55.1]`, span at most 0.5 degrees in either direction and contain + at most 100,000 rows from either source. The combined result also cannot exceed + 100,000 places. +4. Publish and refresh the status card. It shows active/previous generation, + source region/epoch/release, dates, counts and errors. Build acceptance is + asynchronous (`202`); acceptance is not proof that publication succeeded. +5. Use **Disable** for discovery fallback, **Enable** to resume, or **Roll back** + to switch to the predecessor. Rollback can be repeated to switch back. + +The authenticated admin proxy routes are +`/api/admin/ambient-places/{status,build,enabled,rollback}`. Their data-manager +counterparts omit `/api/admin`. Mutations use the existing administrator guard, +audit log and validated, bearer-authenticated data-manager connection. Public +clients read `/api/ambient-places/manifest` (`no-store`), then +`/api/ambient-places/tiles////.mvt`. Errors are not cached; +valid tiles are immutable for seven days. Unknown generations return `404`. + +The public manifest contains only generation/policy version, publication time, +region bounds/name, counts and source release provenance. Source filesystem +paths/fingerprints, contributor record IDs and raw ingest state are not exposed. +Source publication time means local snapshot publication, not real-world +verification of every business. The UI identifies OSM-only versus combined +coverage. Filters, missing source coverage, ranking and collisions remain visible +limits; the layer is not a complete business directory. + +## Publication, retention and identity + +A dedicated `ambient_places` schema isolates snapshots from mutable source tables. +A repeatable-read transaction holds an advisory writer lock, reads bounded indexed +source candidates, applies the shared policy and writes batches of 500 places. +Generation and feature insertion, count validation and the active/previous pointer +swap commit together. Any failure rolls them all back. Concurrent writers fail +without exposing a partial generation. There is no dependency on a background +planet import or a live source-table query at map-render time. + +Generation URLs never change their bytes when sources refresh. At most eight +snapshots are retained. Unreferenced generations can be removed only after seven +days; active and previous are preserved. If all slots still have cache leases, +publication refuses another build. Disable changes discovery within the client's +one-minute refresh interval; it does not revoke previously downloaded tiles. +Clients keep a fresh last-good manifest on temporary discovery failure and remove +an explicitly disabled or over-age publication. No published region means ordinary +basemap browsing. + +Accepted OSM↔GERS links determine identity: `osm:/` remains primary and +GERS remains an alias. Overture-only places use `overture:`. Bigint OSM IDs +remain strings throughout SQL, tiles, search and details. OSM field values win; +Overture fills missing names and unmatched coverage. A known excluded OSM match +cannot be resurrected by its Overture counterpart. Tiles carry canonical ID, GERS, +localized names, category, rank, minimum zoom, tenant flag and source combination. + +The client suppresses ambient features already owned by a category/selection ID. +An owned-basemap label can acquire the canonical identity only through an explicit +OSM ID or a unique compatible-category/name match within eight metres. Ambiguous +branches and non-ground tenants are not matched by proximity. Mappings belong to +the map instance and are cleared on style replacement and overlay teardown. The +existing place-card conversion/resolver handles both tile and basemap clicks. + +## Policy and read budgets + +- OSM: named valid points, no explicit closed/disused/abandoned/demolished/removed + or private-access records. Non-ground level/floor records start at zoom 18. +- Overture: named valid points, known `open` status and finite confidence ≥0.5. + Missing values are omitted. Unsupported contributor datasets block publication. +- Labels: supplied German/English names, otherwise the source name; at most 120 + Unicode characters. No inferred opening hours, ratings or popularity. +- Ranking: essential destinations at zoom 13, everyday businesses at 15, other + places at 16; utility tier and OSM importance, then stable ID tie-breaking. +- MVT: zooms 13–18, at most 256 features, 128 KiB, indexed geometry intersection, + a 2-second SQL timeout and eight pending tile requests per API process. A + conservative UTF-8 budget removes the lowest-ranked tail before encoding; a + final byte guard prevents an oversized response. Invalid UUID/XYZ never reaches + a tile query. The vector source also declares publication bounds and zooms. + +The overlay uses the existing attribution registry with OSM, Overture and supported +contributors' license/copyright notices. It does not infer a single license for +the conflated dataset. Tiles are rendered locally; there are no request-time calls +to Overture or its contributors. + +## Serving comparison and decision + +Both alternatives use the same canonical policy and MVT content: + +| Choice | Publication/operations | Reads | Tradeoff | +| ----------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | +| Regional PMTiles | Precompute covered cells, verify archive, stage immutable file and switch discovery | Static range reads or Martin archive ZXY | Portable/offline-friendly; requires an archive builder, tile enumeration and file/object lifecycle | +| Existing Martin + PostGIS MVT | Publish immutable rows and serve a generation-specific SQL function | Martin cache in front of indexed PostGIS | Existing service; direct auto-published tables/functions need explicit generation routing and budgets | +| Implemented API + PostGIS MVT | Same immutable PostGIS representation with validated generation/XYZ and bounded reads | Existing API cache headers and PostGIS functions | Adds SQL work for uncached reads; keeps failure/concurrency/size guards in the established API boundary | + +For this first online region, the API/PostGIS path avoids adding a second storage +publication pipeline while preserving the same MVT representation Martin can +serve. Directly exposing the mutable source tables would not provide immutable +generation URLs or these limits. PMTiles is a good candidate for a later regional +export/offline package; this change does not promise that feature. + +A local disposable PostGIS 18 / PostGIS 3.6 fixture check on 2026-10-07 used 26 +explicitly synthetic Aachen places, 60 occupied cells over zooms 13–18 and +identical decoded MVT bytes. The same bounded query was served through the +repository-pinned Martin image and an archive converted/verified with the official +PMTiles CLI 1.31.2. The archive was 16,451 bytes; exporting its 60 source tiles took +556 ms. For one 215-byte zoom-18 tile, first-request and 20 repeated-request +measurements were: + +| Serving path | First request | Warm median | Warm p95 | +| -------------- | ------------: | ----------: | -------: | +| API/PostGIS | 77.40 ms | 10.04 ms | 20.14 ms | +| Martin/PostGIS | 19.27 ms | 3.31 ms | 4.27 ms | +| Martin/PMTiles | 5.55 ms | 1.42 ms | 2.87 ms | + +These are local fixture observations under concurrent development load, not +production capacity estimates. They do not measure a full regional archive, +remote range requests, CDN misses, a restarted cold database, or mobile FPS. The +20 Martin repeats benefit from its response cache. A separate 1,000-place dense +PostGIS fixture emitted 256 features / 14,805 bytes, with p95 SQL/repository reads +of 6.75 ms over 20 repeats. The single-place fixture published in 144 ms and +produced a 214-byte tile. Long multilingual UTF-8 labels verified the byte budget +by reducing the feature count below 256. Reproduce behavior with: + +```sh +OPENMAPX_RUN_DATABASE_TESTS=1 pnpm exec vitest run services/data-manager/__tests__/ambient-places/publish-postgres.test.ts +``` + +Official references: [PostGIS MVT](https://postgis.net/docs/ST_AsMVT.html), +[tile envelopes](https://postgis.net/docs/ST_TileEnvelope.html), +[Martin PostGIS functions](https://maplibre.org/martin/sources-pg-functions/), +[Martin configuration](https://maplibre.org/martin/config-file/), and +[PMTiles CLI conversion/verification](https://docs.protomaps.com/pmtiles/cli). + +A native-browser development check at 1280 × 800 used 1,006 synthetic places +around Aachen. At zoom 16, 908 rendered point features competed for 83 visible +labels; MapLibre collision placement reduced the labels without reducing the +underlying places. A short 1.8-second pan/zoom sweep with the overlay had a median +animation-frame interval of 8.3 ms, p95 of 9.2 ms, and one interval over 50 ms. +The same sweep with the overlay disabled measured 8.4 ms / 17.4 ms and two +intervals over 50 ms. This single local development sample checks responsiveness +and collision behavior; it is not a statistically controlled production or +mobile benchmark. An initial background-tab sample was discarded because browser +throttling made it unsuitable for frame measurement. + +Tests cover closure/confidence/tenant/label policy, accepted identities, ambiguous +matching, actual MVT decoding, input/dense-byte bounds, failed writes, contributor +refusal, stale/unfinished sources, concurrency, retention, immutable bytes, +rollback, API auth/cache/budgets and client generation/tap/style/teardown behavior. +Browser evidence uses clearly labeled disposable fixtures; it does not claim +verified production Aachen coverage. diff --git a/docs/docs/developer/api-surface.md b/docs/docs/developer/api-surface.md index c5c2cbe67..1231fb4e5 100644 --- a/docs/docs/developer/api-surface.md +++ b/docs/docs/developer/api-surface.md @@ -57,7 +57,7 @@ wildcard routes, so Fastify's own introspection sees `/api/integrations/:id` and nothing else. They are therefore read **statically**, by parsing the `registerRoute` call -sites with the TypeScript compiler API across the 105 built-in integrations. +sites with the TypeScript compiler API across the 106 built-in integrations. Running every integration's `setup()` against a stub context executes arbitrary integration code — including registry loads — inside a commit gate, so the static scan was preferred. Both approaches independently verify the declared diff --git a/docs/docs/features/map-layers.md b/docs/docs/features/map-layers.md index ed943e5c3..be9131bde 100644 --- a/docs/docs/features/map-layers.md +++ b/docs/docs/features/map-layers.md @@ -53,6 +53,21 @@ additional destinations that are not labeled on the map. See the [comparison baseline](../developer/map-comparison-baseline.md) for fixed German examples and how to assess cartography changes. +When an operator publishes a regional place snapshot, **Nearby places** adds +ranked named OSM and Overture destinations during ordinary browsing, without a +category search. Essential destinations start at zoom 13; everyday businesses +appear later, and indoor/non-ground tenants wait until zoom 18. Label collisions +still limit what is visible. These ranks express usefulness and source importance, +not ratings, popularity, or a promise that a business is currently open. + +The overlay avoids repeating a confidently matched owned-basemap label or an +already selected/category-result marker. Tapping a matched label opens the same +canonical place identity as search. The legend identifies the published region, +snapshot date and whether coverage is OSM-only or combined. Outside that region, +or before publication, normal map browsing continues with the basemap. You can +toggle **Nearby places** in Layers. See [Overture Places](./overture-places.md) +for source policies and operator setup. + ## How overlays work Every overlay is an [integration](../overview/how-it-works.md). A map-overlay diff --git a/docs/docs/features/overture-places.md b/docs/docs/features/overture-places.md index 39e69c5dc..5d9967c73 100644 --- a/docs/docs/features/overture-places.md +++ b/docs/docs/features/overture-places.md @@ -47,6 +47,49 @@ Candidates are selected nearest the map center, then by confidence and field completeness. The combined result is deterministically reranked, conservative same-business duplicates are removed, and the response is capped at 50 places. +## Nearby places during normal browsing + +The built-in `overlay-ambient-places` integration can show named places without +starting a category search. An administrator first builds the existing regional +OSM search index; an Overture snapshot is optional, but a populated Overture +source must have completed its link rebuild. In **Admin → Services → Data +workflows → Nearby places**, publish one German bounding box. The initial form +covers Aachen (`5.9,50.65,6.3,50.95`). The box may span at most 0.5 degrees in each +direction, with at most 100,000 input rows per source and 100,000 final places. +The initial rollout is deliberately regional; it does not import the planet. + +Named essential destinations start at zoom 13, everyday businesses at 15 and +other places at 16. Non-ground OSM tenants wait until 18. Explicitly closed, +disused, demolished or private OSM records are omitted. Overture records require +known `open` status and confidence of at least 0.5. Missing confidence/status +is omitted rather than guessed. Labels use a supplied German/English name and +fall back to the source name; names are capped at 120 Unicode characters. No +hours, ratings or popularity are inferred. Missing OSM fields may be filled by +an accepted Overture match; unmatched Overture records fill regional gaps. +Accepted OSM↔GERS links retain the OSM primary ID across tiles, search and place +cards, even when the OSM record is outside the ambient candidate set. + +The admin card reports generation, region, counts, source epoch/release, +publication time and build errors. A snapshot date describes the local data +publication, not a recent verification of every business. Source/publication +age above 90 days disables discovery. Coverage is partial: filters, missing +source data, ranking caps and label collisions can all omit places. + +Publishing stages a complete immutable generation and activates it in one +transaction. A failed or interrupted build retains the active generation. +**Disable** removes it from discovery; **Roll back** switches to the predecessor. +Both are available without waiting for a rebuild. Clients refresh discovery +every minute and retain a fresh last-known generation during a temporary API +failure. Already cached tile URLs remain valid for their seven-day cache lease; +disable is therefore a discovery change, not immediate revocation of cached data. +At most eight generations are retained; publication refuses to evict an +unexpired generation just to make room. + +The online path uses bounded PostGIS MVT reads behind the existing API. Regional +PMTiles remain a possible offline/export delivery option, not an offline feature +of this overlay. [Publication and serving decisions](../developer/ambient-places-publication.md) +explain the comparison, limits, tests and rollback model. + ## Releases and regional refreshes OpenMapX discovers the current release from Overture's official STAC catalog diff --git a/docs/docs/overview/introduction.md b/docs/docs/overview/introduction.md index 7ba423388..e7015ad04 100644 --- a/docs/docs/overview/introduction.md +++ b/docs/docs/overview/introduction.md @@ -28,7 +28,7 @@ which optional engines and data sources to run. - **A pluggable backend** — each daemon (the routing engine, the geocoder, the transit engine, the tile server, …) is described by a manifest and runs as a container. -- **A pluggable feature set** — 105 built-in _integrations_ span search, +- **A pluggable feature set** — 106 built-in _integrations_ span search, routing, transit, live mobility, places, overlays, weather, and more. Community extensions — integrations, services, or bundles of both — install from one unified **Extensions** store. From 186cf0b07909586342cb658effa14911937a029d Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 8 Oct 2026 00:39:08 +0200 Subject: [PATCH 06/13] fix(places): protect publication leases and canonical policy --- .../developer/ambient-places-publication.md | 24 +- docs/docs/features/overture-places.md | 13 +- .../attribution.test.tsx | 31 ++ .../overlay-ambient-places/manifest.json | 12 + .../overlay-ambient-places/strings/de.json | 5 + .../overlay-ambient-places/strings/en.json | 5 + package.json | 2 +- packages/core/src/ambient-places.test.ts | 12 + packages/core/src/ambient-places.ts | 5 +- .../ambient-places/api-postgres.test.ts | 86 +++++ .../__tests__/ambient-places/api.test.ts | 20 +- .../ambient-places/publish-postgres.test.ts | 74 ++++ .../src/jobs/ambient-places/api.ts | 73 ++-- .../src/jobs/ambient-places/build.ts | 354 +++++++++++------- .../src/jobs/ambient-places/schema.ts | 18 +- 15 files changed, 528 insertions(+), 206 deletions(-) create mode 100644 integrations/overlay-ambient-places/attribution.test.tsx create mode 100644 services/data-manager/__tests__/ambient-places/api-postgres.test.ts diff --git a/docs/docs/developer/ambient-places-publication.md b/docs/docs/developer/ambient-places-publication.md index 746d2621e..5a78a55ed 100644 --- a/docs/docs/developer/ambient-places-publication.md +++ b/docs/docs/developer/ambient-places-publication.md @@ -20,11 +20,15 @@ imports planet data, calls AllThePlaces directly, or changes geocoding. 3. In **Admin → Services → Data workflows → Nearby places**, choose a name and bounding box. Bounds must fit Germany's rollout envelope `[5.8,47.2,15.1,55.1]`, span at most 0.5 degrees in either direction and contain - at most 100,000 rows from either source. The combined result also cannot exceed + at most 100,000 OSM rows including linked boundary counterparts, and 100,000 + Overture rows. The combined result also cannot exceed 100,000 places. 4. Publish and refresh the status card. It shows active/previous generation, source region/epoch/release, dates, counts and errors. Build acceptance is - asynchronous (`202`); acceptance is not proof that publication succeeded. + asynchronous (`202`) after acquiring the publication writer lock; acceptance is + not proof that publication succeeded. Competing requests receive `409`. Status + writes share the writer lock and attempt identity, so a rejected request cannot + overwrite or abort the admitted publication. 5. Use **Disable** for discovery fallback, **Enable** to resume, or **Roll back** to switch to the predecessor. Rollback can be repeated to switch back. @@ -55,8 +59,11 @@ without exposing a partial generation. There is no dependency on a background planet import or a live source-table query at map-render time. Generation URLs never change their bytes when sources refresh. At most eight -snapshots are retained. Unreferenced generations can be removed only after seven -days; active and previous are preserved. If all slots still have cache leases, +snapshots are retained. Unreferenced generations can be removed only after their +cache lease expires. The lease renews when leaving active discovery or rolling +back, covering seven days plus the one-minute discovery refresh interval from +that transition, regardless of the original publication date. Active and previous +are preserved. If all slots still have cache leases, publication refuses another build. Disable changes discovery within the client's one-minute refresh interval; it does not revoke previously downloaded tiles. Clients keep a fresh last-good manifest on temporary discovery failure and remove @@ -67,12 +74,17 @@ Accepted OSM↔GERS links determine identity: `osm:/` remains primary GERS remains an alias. Overture-only places use `overture:`. Bigint OSM IDs remain strings throughout SQL, tiles, search and details. OSM field values win; Overture fills missing names and unmatched coverage. A known excluded OSM match -cannot be resurrected by its Overture counterpart. Tiles carry canonical ID, GERS, +cannot be resurrected by its Overture counterpart. Linked OSM counterparts are +read and evaluated even across the bbox boundary, within the same OSM input cap. +Authoritative OSM locations outside the chosen region omit the pair; they do not +become an Overture gap with lost closure or tenant policy. Tiles carry canonical ID, GERS, localized names, category, rank, minimum zoom, tenant flag and source combination. The client suppresses ambient features already owned by a category/selection ID. An owned-basemap label can acquire the canonical identity only through an explicit -OSM ID or a unique compatible-category/name match within eight metres. Ambiguous +OSM ID or, only when no explicit identity is supplied, a unique compatible-category/name +match within eight metres. An explicit different OSM identity is never replaced +by proximity. Ambiguous branches and non-ground tenants are not matched by proximity. Mappings belong to the map instance and are cleared on style replacement and overlay teardown. The existing place-card conversion/resolver handles both tile and basemap clicks. diff --git a/docs/docs/features/overture-places.md b/docs/docs/features/overture-places.md index 5d9967c73..49f24da2e 100644 --- a/docs/docs/features/overture-places.md +++ b/docs/docs/features/overture-places.md @@ -56,7 +56,10 @@ source must have completed its link rebuild. In **Admin → Services → Data workflows → Nearby places**, publish one German bounding box. The initial form covers Aachen (`5.9,50.65,6.3,50.95`). The box may span at most 0.5 degrees in each direction, with at most 100,000 input rows per source and 100,000 final places. -The initial rollout is deliberately regional; it does not import the planet. +Linked OSM counterparts across the boundary count toward the OSM input cap and +retain their closure/tenant policy and authoritative location. A pair whose OSM +location is outside the box is omitted. The initial rollout is deliberately +regional; it does not import the planet. Named essential destinations start at zoom 13, everyday businesses at 15 and other places at 16. Non-ground OSM tenants wait until 18. Explicitly closed, @@ -78,12 +81,14 @@ source data, ranking caps and label collisions can all omit places. Publishing stages a complete immutable generation and activates it in one transaction. A failed or interrupted build retains the active generation. **Disable** removes it from discovery; **Roll back** switches to the predecessor. -Both are available without waiting for a rebuild. Clients refresh discovery +Neither requires another rebuild. A competing mutation receives `409` until +publication finishes. Clients refresh discovery every minute and retain a fresh last-known generation during a temporary API failure. Already cached tile URLs remain valid for their seven-day cache lease; disable is therefore a discovery change, not immediate revocation of cached data. -At most eight generations are retained; publication refuses to evict an -unexpired generation just to make room. +At most eight generations are retained. Cache protection renews when an active +generation is replaced or rolled back, even if the original publication is old; +publication refuses to evict an unexpired generation just to make room. The online path uses bounded PostGIS MVT reads behind the existing API. Regional PMTiles remain a possible offline/export delivery option, not an offline feature diff --git a/integrations/overlay-ambient-places/attribution.test.tsx b/integrations/overlay-ambient-places/attribution.test.tsx new file mode 100644 index 000000000..b8312eced --- /dev/null +++ b/integrations/overlay-ambient-places/attribution.test.tsx @@ -0,0 +1,31 @@ +import { cleanup, renderHook } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { useMapAttributionStore } from "@/integration-api/overlay/mapAttributionStore"; +import { useIntegrationAttribution } from "@/integration-api/overlay/useIntegrationAttribution"; +import manifest from "./manifest.json"; + +vi.mock("@openmapx/integration-framework/react", () => ({ + useIntegrationRegistry: () => ({ get: () => manifest }), +})); +vi.mock("@/integration-api/map/MapContext", () => ({ + useMap: () => ({ mapRef: { current: null }, mapReady: false }), +})); +afterEach(() => { + cleanup(); + useMapAttributionStore.setState({ byLayer: {} }); +}); +it("credits ambient OSM data independently of basemap credits", () => { + const view = renderHook( + ({ active }) => useIntegrationAttribution("overlay-ambient-places", active), + { initialProps: { active: true } }, + ); + const credits = useMapAttributionStore + .getState() + .byLayer["integration:overlay-ambient-places"].join(" "); + expect(credits).toContain("OpenStreetMap contributors"); + expect(credits).toContain("https://www.openstreetmap.org/copyright"); + view.rerender({ active: false }); + expect( + useMapAttributionStore.getState().byLayer["integration:overlay-ambient-places"], + ).toBeUndefined(); +}); diff --git a/integrations/overlay-ambient-places/manifest.json b/integrations/overlay-ambient-places/manifest.json index 1ac07fce3..c2809e7d1 100644 --- a/integrations/overlay-ambient-places/manifest.json +++ b/integrations/overlay-ambient-places/manifest.json @@ -41,6 +41,18 @@ "quality": "built-in", "category": "Places", "dataSources": [ + { + "sourceId": "osm-ambient-places", + "name": "OpenStreetMap", + "url": "https://www.openstreetmap.org/", + "license": "ODbL", + "licenseUrl": "https://opendatacommons.org/licenses/odbl/", + "commercialUse": "yes", + "attribution": "\u00a9 OpenStreetMap contributors", + "providerCountry": "GB", + "providerPrivacyUrl": "https://wiki.osmfoundation.org/wiki/Privacy_Policy", + "endUserExposure": "server-only" + }, { "sourceId": "overture", "name": "Overture Maps", diff --git a/integrations/overlay-ambient-places/strings/de.json b/integrations/overlay-ambient-places/strings/de.json index 91c1bf01e..a77d99392 100644 --- a/integrations/overlay-ambient-places/strings/de.json +++ b/integrations/overlay-ambient-places/strings/de.json @@ -51,6 +51,11 @@ "purpose": "Benannte Orte aus einem veröffentlichten regionalen OSM- und Overture-Datensatz", "dataSent": "Keine; selbst gehostete regionale Kartenkacheln", "dataReceived": "Namen und Koordinaten von Orten" + }, + "osm-ambient-places": { + "purpose": "Benannte Orte aus einem veröffentlichten regionalen OSM- und Overture-Datensatz", + "dataSent": "Keine; selbst gehostete regionale Kartenkacheln", + "dataReceived": "Namen und Koordinaten von Orten" } } } diff --git a/integrations/overlay-ambient-places/strings/en.json b/integrations/overlay-ambient-places/strings/en.json index 8d86fcd81..0365c73c3 100644 --- a/integrations/overlay-ambient-places/strings/en.json +++ b/integrations/overlay-ambient-places/strings/en.json @@ -51,6 +51,11 @@ "purpose": "Named places from a published regional OSM and Overture snapshot", "dataSent": "None; hosted regional tiles", "dataReceived": "Names and locations of places" + }, + "osm-ambient-places": { + "purpose": "Named places from a published regional OSM and Overture snapshot", + "dataSent": "None; hosted regional tiles", + "dataReceived": "Names and locations of places" } } } diff --git a/package.json b/package.json index e290531e7..98c9189ee 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "check-duplicates": "jscpd --config .jscpd.json --cross-formats js-ts --no-tips apps packages integrations services scripts", "check-infrastructure": "bash scripts/check-infrastructure.sh", "test": "vitest run", - "test:database": "OPENMAPX_RUN_DATABASE_TESTS=1 vitest run --project node --maxWorkers=1 apps/api/src/privacy/*-postgres.test.ts apps/api/src/auth-postgres.test.ts apps/api/src/services/mobileAuthHandoff.test.ts apps/api/src/services/capability-bindings.test.ts apps/api/src/db/__tests__/extension-component-ownership-migration.test.ts services/data-manager/__tests__/offline-package-postgres-accounting.test.ts apps/api/src/db/__tests__/vehicles-parking-postgres.test.ts apps/api/src/db/__tests__/garage-isolation-postgres.test.ts apps/api/src/routes/__tests__/shares-postgres.test.ts apps/api/src/db/__tests__/user-erasure-postgres.test.ts packages/cli/__tests__/backup-restore-postgres.integration.test.ts services/data-manager/__tests__/poi-ingest/e2e-bnetza.test.ts services/data-manager/__tests__/poi-ingest/static-reuse-postgres.test.ts services/data-manager/__tests__/search-index/schema-postgres.test.ts services/data-manager/__tests__/search-index/build-postgres.test.ts services/data-manager/__tests__/notable-places/build-postgres.test.ts services/data-manager/__tests__/overture/schema-postgres.test.ts services/data-manager/__tests__/overture/runtime-postgres.test.ts services/data-manager/__tests__/coverage/poi-evidence-postgres.test.ts services/data-manager/__tests__/ambient-places/publish-postgres.test.ts", + "test:database": "OPENMAPX_RUN_DATABASE_TESTS=1 vitest run --project node --maxWorkers=1 apps/api/src/privacy/*-postgres.test.ts apps/api/src/auth-postgres.test.ts apps/api/src/services/mobileAuthHandoff.test.ts apps/api/src/services/capability-bindings.test.ts apps/api/src/db/__tests__/extension-component-ownership-migration.test.ts services/data-manager/__tests__/offline-package-postgres-accounting.test.ts apps/api/src/db/__tests__/vehicles-parking-postgres.test.ts apps/api/src/db/__tests__/garage-isolation-postgres.test.ts apps/api/src/routes/__tests__/shares-postgres.test.ts apps/api/src/db/__tests__/user-erasure-postgres.test.ts packages/cli/__tests__/backup-restore-postgres.integration.test.ts services/data-manager/__tests__/poi-ingest/e2e-bnetza.test.ts services/data-manager/__tests__/poi-ingest/static-reuse-postgres.test.ts services/data-manager/__tests__/search-index/schema-postgres.test.ts services/data-manager/__tests__/search-index/build-postgres.test.ts services/data-manager/__tests__/notable-places/build-postgres.test.ts services/data-manager/__tests__/overture/schema-postgres.test.ts services/data-manager/__tests__/overture/runtime-postgres.test.ts services/data-manager/__tests__/coverage/poi-evidence-postgres.test.ts services/data-manager/__tests__/ambient-places/publish-postgres.test.ts services/data-manager/__tests__/ambient-places/api-postgres.test.ts", "test:watch": "vitest", "test:coverage": "OPENMAPX_SKIP_SLOW_CRYPTO=1 vitest run --coverage", "prepare": "husky", diff --git a/packages/core/src/ambient-places.test.ts b/packages/core/src/ambient-places.test.ts index cff107c0a..988e8a7e3 100644 --- a/packages/core/src/ambient-places.test.ts +++ b/packages/core/src/ambient-places.test.ts @@ -92,6 +92,18 @@ describe("ambient place policy", () => { expect(mergeAmbientPlaces([a], [b], new Map())).toHaveLength(2); expect(mergeAmbientPlaces([a], [], new Map([[a.id, "gers-a"]]))[0].gersId).toBe("gers-a"); }); + it("never replaces an explicit different basemap OSM identity through proximity", () => { + const a = ambientPlaceFromOsm(osm)!; + const label = { + key: "base/other", + name: a.name, + category: a.category, + coordinates: a.coordinates, + osmId: "osm:node/2", + }; + expect(matchAmbientBasemap([a], [label]).size).toBe(0); + expect(matchAmbientBasemap([a], [{ ...label, osmId: a.id }]).get(label.key)?.id).toBe(a.id); + }); it("matches unique nearby compatible basemap labels and refuses branches and tenants", () => { const a = ambientPlaceFromOsm(osm)!; const label = { diff --git a/packages/core/src/ambient-places.ts b/packages/core/src/ambient-places.ts index c23ad6aee..6d58b848c 100644 --- a/packages/core/src/ambient-places.ts +++ b/packages/core/src/ambient-places.ts @@ -292,8 +292,9 @@ export function matchAmbientBasemap( byName.set(name, bucket); } for (const l of labels) { - if (l.osmId && byId.has(l.osmId)) { - matches.set(l.key, byId.get(l.osmId)!); + if (l.osmId) { + const exact = byId.get(l.osmId); + if (exact) matches.set(l.key, exact); continue; } const candidates = (byName.get(normalize(l.name)) ?? []).filter( diff --git a/services/data-manager/__tests__/ambient-places/api-postgres.test.ts b/services/data-manager/__tests__/ambient-places/api-postgres.test.ts new file mode 100644 index 000000000..15b618246 --- /dev/null +++ b/services/data-manager/__tests__/ambient-places/api-postgres.test.ts @@ -0,0 +1,86 @@ +import { readAmbientManifest } from "@openmapx/core/ambient-places-server"; +import Fastify from "fastify"; +import postgres from "postgres"; +import { describe, expect, it } from "vitest"; +import { registerAmbientPlacesApi } from "../../src/jobs/ambient-places/api.js"; +import { + buildSearchIndexIndexesDDL, + buildSearchIndexSchemaDDL, +} from "../../src/jobs/search-index/schema.js"; +import { startPostgis } from "../poi-ingest/_testcontainer.js"; + +const request = { + method: "POST" as const, + url: "/ambient-places/build", + payload: { name: "Aachen", bounds: [5.9, 50.65, 6.3, 50.95] }, +}; +describe.skipIf(process.env.OPENMAPX_RUN_DATABASE_TESTS !== "1")( + "ambient operator admission", + () => { + it("rejects competing API instances without aborting the winning repeatable-read build", async () => { + const pg = await startPostgis(); + const barrier = postgres(pg.connectionString, { max: 1 }); + const coldSql = postgres(pg.connectionString, { max: 2 }); + const cold = Fastify(); + registerAmbientPlacesApi(cold, coldSql); + const a = Fastify(); + const b = Fastify(); + registerAmbientPlacesApi(a, pg.sql); + registerAmbientPlacesApi(b, pg.sql); + try { + await pg.sql.unsafe( + buildSearchIndexSchemaDDL("osm_search") + buildSearchIndexIndexesDDL("osm_search"), + ); + await pg.sql.unsafe(`INSERT INTO osm_search.index_state(singleton,region,source_path,source_fingerprint,current_fingerprint,epoch,status,started_at,published_at,updated_at) VALUES(1,'aachen','fixture','fixture','fixture','admission','ready',now(),now(),now()); + INSERT INTO osm_search.places(osm_type,osm_id,name,lat,lng,category,tags,importance) VALUES('node',1,'Clinic',50.77,6.08,'amenity:hospital','{}',0.8)`); + // Initialize the dedicated schema without accepting a publication. + const { ensureAmbientSchema } = await import("../../src/jobs/ambient-places/schema.js"); + await ensureAmbientSchema(pg.sql); + await pg.sql.unsafe(`CREATE FUNCTION ambient_places.hold_fixture() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM pg_advisory_xact_lock(399401); RETURN NEW; END $$; + CREATE TRIGGER hold_fixture BEFORE INSERT ON ambient_places.features FOR EACH ROW EXECUTE FUNCTION ambient_places.hold_fixture()`); + await barrier.unsafe(`SELECT pg_advisory_lock(399401)`); + let competingStatus: number; + let sameProcessStatus: number; + let coldStatus: number; + try { + expect((await a.inject(request)).statusCode).toBe(202); + const deadline = Date.now() + 10_000; + while (true) { + const [lock] = await pg.sql.unsafe<{ blocked: boolean }[]>( + `SELECT EXISTS(SELECT 1 FROM pg_locks WHERE locktype='advisory' AND objid=399401 AND NOT granted) AS blocked`, + ); + if (lock.blocked) break; + if (Date.now() > deadline) + throw new Error("Publication did not reach the fixture barrier"); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + competingStatus = (await b.inject(request)).statusCode; + sameProcessStatus = (await a.inject(request)).statusCode; + coldStatus = (await cold.inject(request)).statusCode; + } finally { + await barrier.unsafe(`SELECT pg_advisory_unlock(399401)`); + } + const deadline = Date.now() + 10_000; + let status: { building: boolean; lastError: string | null }; + do { + status = (await a.inject({ method: "GET", url: "/ambient-places/status" })).json(); + if (!status.building) break; + if (Date.now() > deadline) throw new Error("Winning publication did not finish"); + await new Promise((resolve) => setTimeout(resolve, 10)); + } while (status.building); + expect(competingStatus).toBe(409); + expect(sameProcessStatus).toBe(409); + expect(coldStatus).toBe(409); + expect((await readAmbientManifest(pg.sql))?.placeCount).toBe(1); + expect(status.lastError).toBeNull(); + } finally { + await cold.close(); + await coldSql.end({ timeout: 2 }); + await barrier.end({ timeout: 2 }); + await a.close(); + await b.close(); + await pg.stop(); + } + }, 120_000); + }, +); diff --git a/services/data-manager/__tests__/ambient-places/api.test.ts b/services/data-manager/__tests__/ambient-places/api.test.ts index 1608b8d1a..ac2b41401 100644 --- a/services/data-manager/__tests__/ambient-places/api.test.ts +++ b/services/data-manager/__tests__/ambient-places/api.test.ts @@ -2,8 +2,10 @@ import Fastify from "fastify"; import type postgres from "postgres"; import { describe, expect, it, vi } from "vitest"; -vi.mock("../../src/jobs/ambient-places/build.js", () => ({ - buildAmbientPlaces: async () => ({}), +const mock = vi.hoisted(() => ({ build: vi.fn() })); +vi.mock("../../src/jobs/ambient-places/build.js", async (importOriginal) => ({ + ...(await importOriginal()), + buildAmbientPlaces: mock.build, rollbackAmbientPlaces: async () => {}, setAmbientEnabled: async () => {}, })); @@ -11,16 +13,16 @@ vi.mock("../../src/jobs/ambient-places/build.js", () => ({ import { registerAmbientPlacesApi } from "../../src/jobs/ambient-places/api.js"; describe("ambient publication job lifecycle", () => { - it("allows retry after an initial job status write fails", async () => { - let fail = true; + it("allows retry after publisher admission fails", async () => { + mock.build.mockRejectedValueOnce(new Error("Temporary database outage")); + mock.build.mockImplementationOnce(async (_sql, _region, claim: () => void) => { + claim(); + return {}; + }); const sql = { begin: async (callback: (tx: { unsafe: () => Promise }) => Promise) => callback({ unsafe: async () => [] }), - unsafe: async (query: string) => { - if (query.includes("last_build_started_at=now()") && fail) { - fail = false; - throw new Error("Temporary database outage"); - } + unsafe: async () => { return [{ exists: false }]; }, } as unknown as postgres.Sql; diff --git a/services/data-manager/__tests__/ambient-places/publish-postgres.test.ts b/services/data-manager/__tests__/ambient-places/publish-postgres.test.ts index c7825334e..bc7ed829e 100644 --- a/services/data-manager/__tests__/ambient-places/publish-postgres.test.ts +++ b/services/data-manager/__tests__/ambient-places/publish-postgres.test.ts @@ -122,6 +122,80 @@ describe.skipIf(process.env.OPENMAPX_RUN_DATABASE_TESTS !== "1")( } }, 120_000); + it("applies linked OSM policy and location across the regional boundary", async () => { + const pg = await startPostgis(); + try { + await pg.sql.unsafe( + buildSearchIndexSchemaDDL("osm_search") + buildSearchIndexIndexesDDL("osm_search"), + ); + await pg.sql.unsafe(`INSERT INTO osm_search.index_state(singleton,region,source_path,source_fingerprint,current_fingerprint,epoch,status,started_at,published_at,updated_at) VALUES(1,'aachen','fixture','fixture','fixture','boundary','ready',now(),now(),now()); + INSERT INTO osm_search.places(osm_type,osm_id,name,lat,lng,category,tags,importance) VALUES + ('node',1,'Closed clinic',50.77,5.8999,'amenity:hospital','{"disused":"yes"}',0.8), + ('node',2,'Private clinic',50.77,5.8999,'amenity:hospital','{"access":"private"}',0.8), + ('node',3,'Upper-floor clinic',50.77,5.8999,'amenity:hospital','{"level":"1"}',0.8)`); + await pg.sql.unsafe(buildSchemaDDL("overture_places")); + await pg.sql.unsafe(`INSERT INTO overture_places.conflation_state(singleton,release,region,place_count,places_published_at,status,phase) VALUES(1,'2026-10-01.0','aachen',4,now(),'completed','complete'); + INSERT INTO overture_places.places(gers_id,name,geom,basic_category,confidence,operating_status,release) SELECT 'boundary-'||i,'Clinic '||i,ST_SetSRID(ST_MakePoint(5.9001,50.77),4326),'hospital',0.8,'open','2026-10-01.0' FROM generate_series(1,4) i; + INSERT INTO overture_places.poi_conflation_link(osm_type,osm_id,gers_id,source_confidence,match_confidence,distance_m,method,evidence,release) SELECT 'node',i,'boundary-'||i,0.8,1,14,'fixture','{}','2026-10-01.0' FROM generate_series(1,3) i`); + const a = await buildAmbientPlaces(pg.sql, region); + const rows = await pg.sql.unsafe<{ id: string }[]>( + `SELECT id FROM ambient_places.features WHERE generation=$1 ORDER BY id`, + [a.generation], + ); + // Authoritative OSM locations are outside; closed/private counterparts + // cannot be resurrected and the tenant cannot become a ground-floor gap. + expect(rows.map((r) => r.id)).toEqual(["overture:boundary-4"]); + await pg.sql.unsafe(`UPDATE osm_search.places SET lng=5.9001 WHERE osm_id=3`); + const b = await buildAmbientPlaces(pg.sql, region); + const [tenant] = await pg.sql.unsafe<{ tenant: boolean; min_zoom: number; name: string }[]>( + `SELECT tenant,min_zoom,name FROM ambient_places.features WHERE generation=$1 AND id='osm:node/3'`, + [b.generation], + ); + expect(tenant).toMatchObject({ tenant: true, min_zoom: 18, name: "Upper-floor clinic" }); + expect(b.sources.osm.count).toBe(1); + } finally { + await pg.stop(); + } + }, 120_000); + + it("keeps recently retired old publications available through rapid builds and rollback", async () => { + const pg = await startPostgis(); + try { + await pg.sql.unsafe( + buildSearchIndexSchemaDDL("osm_search") + buildSearchIndexIndexesDDL("osm_search"), + ); + await pg.sql.unsafe(`INSERT INTO osm_search.index_state(singleton,region,source_path,source_fingerprint,current_fingerprint,epoch,status,started_at,published_at,updated_at) VALUES(1,'aachen','fixture','fixture','fixture','lease','ready',now(),now(),now()); + INSERT INTO osm_search.places(osm_type,osm_id,name,lat,lng,category,tags,importance) VALUES('node',1,'Clinic',50.77,6.08,'amenity:hospital','{}',0.8)`); + const a = await buildAmbientPlaces(pg.sql, region); + const bytes = await readAmbientTile(pg.sql, a.generation, tile.z, tile.x, tile.y); + await pg.sql.unsafe( + `UPDATE ambient_places.generations SET published_at=now()-interval '8 days',cache_lease_until=now()-interval '1 day' WHERE id=$1`, + [a.generation], + ); + await buildAmbientPlaces(pg.sql, region); + await buildAmbientPlaces(pg.sql, region); + await buildAmbientPlaces(pg.sql, region); + expect(await readAmbientTile(pg.sql, a.generation, tile.z, tile.x, tile.y)).toEqual(bytes); + const old = await buildAmbientPlaces(pg.sql, region); + await pg.sql.unsafe( + `UPDATE ambient_places.generations SET published_at=now()-interval '8 days',cache_lease_until=now()-interval '1 day' WHERE id=$1`, + [old.generation], + ); + await buildAmbientPlaces(pg.sql, region); + await rollbackAmbientPlaces(pg.sql); + expect((await readAmbientManifest(pg.sql))?.generation).toBe(old.generation); + await buildAmbientPlaces(pg.sql, region); + await buildAmbientPlaces(pg.sql, region); + // The eighth slot is still leased: a ninth build must preserve every URL. + await expect(buildAmbientPlaces(pg.sql, region)).rejects.toThrow(/retention/i); + expect(await readAmbientTile(pg.sql, old.generation, tile.z, tile.x, tile.y)).toEqual( + bytes, + ); + } finally { + await pg.stop(); + } + }, 120_000); + it("bounds dense tiles, serializes writers and protects the generation cache lease", async () => { const pg = await startPostgis(); try { diff --git a/services/data-manager/src/jobs/ambient-places/api.ts b/services/data-manager/src/jobs/ambient-places/api.ts index a0781bdd4..74a3b3c7b 100644 --- a/services/data-manager/src/jobs/ambient-places/api.ts +++ b/services/data-manager/src/jobs/ambient-places/api.ts @@ -1,19 +1,21 @@ +import { randomUUID } from "node:crypto"; import { type AmbientRegion, validateAmbientRegion } from "@openmapx/core/ambient-places"; import { readAmbientManifest } from "@openmapx/core/ambient-places-server"; import type { FastifyInstance } from "fastify"; import type postgres from "postgres"; import { buildAmbientPlaces, rollbackAmbientPlaces, setAmbientEnabled } from "./build.js"; -import { AMBIENT_WRITE_LOCK, ensureAmbientSchema } from "./schema.js"; +import { AMBIENT_WRITE_LOCK, AmbientPublicationBusyError } from "./schema.js"; /** Mounted inside data-manager's bearer-token protected application. */ export function registerAmbientPlacesApi(app: FastifyInstance, sql: postgres.Sql): void { - let building = false; + let building: string | null = null; app.get("/ambient-places/status", async () => { const active = await readAmbientManifest(sql); const [exists] = await sql.unsafe<{ exists: boolean }[]>( `SELECT to_regclass('ambient_places.state') IS NOT NULL AS exists`, ); - if (!exists.exists) return { active: null, previous: null, building, lastError: null }; + if (!exists.exists) + return { active: null, previous: null, building: Boolean(building), lastError: null }; const [state] = await sql.unsafe< { previous: string | null; @@ -31,7 +33,7 @@ export function registerAmbientPlacesApi(app: FastifyInstance, sql: postgres.Sql return { active, previous: state.previous, - building: building || writer.busy, + building: Boolean(building) || writer.busy, lastError: state.last_build_error, startedAt: state.last_build_started_at, finishedAt: state.last_build_finished_at, @@ -45,42 +47,41 @@ export function registerAmbientPlacesApi(app: FastifyInstance, sql: postgres.Sql return reply.code(400).send({ error: (error as Error).message }); } if (building) return reply.code(409).send({ error: "An ambient build is already running" }); - await ensureAmbientSchema(sql); - building = true; + // Claim the local slot before the first await. Cross-process admission is + // acknowledged only once the publisher has acquired the database lock. + const attempt = randomUUID(); + building = attempt; + let claimed = false; + let accept!: () => void; + let reject!: (error: unknown) => void; + const admission = new Promise((resolve, rejectPromise) => { + accept = resolve; + reject = rejectPromise; + }); + void buildAmbientPlaces(sql, region, () => { + claimed = true; + accept(); + }) + .catch((error) => { + if (!claimed) { + if (building === attempt) building = null; + reject(error); + } + app.log.warn( + { errorClass: error instanceof Error ? error.name : "Unknown" }, + "Ambient publication failed; active generation retained", + ); + }) + .finally(() => { + if (building === attempt) building = null; + }); try { - await sql.unsafe( - `UPDATE ambient_places.state SET last_build_started_at=now(),last_build_finished_at=NULL,last_build_error=NULL WHERE singleton=1`, - ); + await admission; } catch (error) { - building = false; + if (error instanceof AmbientPublicationBusyError) + return reply.code(409).send({ error: error.message }); throw error; } - void buildAmbientPlaces(sql, region) - .then( - () => - sql.unsafe( - `UPDATE ambient_places.state SET last_build_finished_at=now(),last_build_error=NULL WHERE singleton=1`, - ), - (error) => { - app.log.warn( - { errorClass: error instanceof Error ? error.name : "Unknown" }, - "Ambient publication failed; active generation retained", - ); - return sql.unsafe( - `UPDATE ambient_places.state SET last_build_finished_at=now(),last_build_error=$1 WHERE singleton=1`, - [error instanceof Error ? error.message : "Publication failed"], - ); - }, - ) - .catch((error) => - app.log.error( - { errorClass: error instanceof Error ? error.name : "Unknown" }, - "Ambient job status could not be recorded", - ), - ) - .finally(() => { - building = false; - }); return reply.code(202).send({ accepted: true }); }); app.post( diff --git a/services/data-manager/src/jobs/ambient-places/build.ts b/services/data-manager/src/jobs/ambient-places/build.ts index 1eed46755..dbf3f4b3c 100644 --- a/services/data-manager/src/jobs/ambient-places/build.ts +++ b/services/data-manager/src/jobs/ambient-places/build.ts @@ -14,13 +14,13 @@ import { validateAmbientRegion, } from "@openmapx/core/ambient-places"; import type postgres from "postgres"; -import { AMBIENT_WRITE_LOCK, ensureAmbientSchema } from "./schema.js"; +import { AMBIENT_WRITE_LOCK, AmbientPublicationBusyError, ensureAmbientSchema } from "./schema.js"; async function lock(tx: postgres.TransactionSql) { const [row] = await tx.unsafe<{ locked: boolean }[]>( `SELECT pg_try_advisory_xact_lock(${AMBIENT_WRITE_LOCK}) AS locked`, ); - if (!row.locked) throw new Error("Another ambient publication or pointer change is running"); + if (!row.locked) throw new AmbientPublicationBusyError(); } function fresh(value: Date | string | null): string { const date = value === null ? NaN : new Date(value).getTime(); @@ -42,163 +42,226 @@ async function tableExists(tx: postgres.TransactionSql, name: string): Promise void | Promise, ): Promise { const region = validateAmbientRegion(input); await ensureAmbientSchema(sql); - return sql.begin("isolation level repeatable read", async (tx) => { - await lock(tx); - await tx.unsafe(`SET LOCAL statement_timeout='120000ms'`); - if (!(await tableExists(tx, "osm_search.index_state"))) - throw new Error("Build the regional OSM search index first"); - const [osmState] = await tx.unsafe< - { region: string; epoch: string; status: string; published_at: Date | null }[] - >(`SELECT region,epoch,status,published_at FROM osm_search.index_state WHERE singleton=1`); - if (!osmState || osmState.status !== "ready") throw new Error("OSM search index must be ready"); - const osmPublished = fresh(osmState.published_at); - const params = region.bounds; - const osmRows = await tx.unsafe( - `SELECT osm_type,osm_id::TEXT,name,lng,lat,category,tags,importance FROM osm_search.places WHERE geom && ST_MakeEnvelope($1,$2,$3,$4,4326)::geography ORDER BY osm_type,osm_id LIMIT ${AMBIENT_LIMITS.places + 1}`, - params, - ); - if (osmRows.length > AMBIENT_LIMITS.places) - throw new Error("Regional OSM input exceeds 100000 places; choose a smaller region"); - const osm = osmRows.flatMap((row) => { - const p = ambientPlaceFromOsm(row); - return p ? [p] : []; - }); - let overtureSource: AmbientManifest["sources"]["overture"] = null; - let overture: NonNullable>[] = []; - const links = new Map(); - if (await tableExists(tx, "overture_places.places")) { - if (!(await tableExists(tx, "overture_places.conflation_state"))) - throw new Error("Overture conflation state is missing"); - const [state] = await tx.unsafe< - { region: string; release: string; status: string; places_published_at: Date | null }[] - >( - `SELECT region,release,status,places_published_at FROM overture_places.conflation_state WHERE singleton=1`, + const attempt = randomUUID(); + let startedAt: string | null = null; + try { + return await sql.begin("isolation level repeatable read", async (tx) => { + await lock(tx); + await tx.unsafe(`SET LOCAL statement_timeout='120000ms'`); + const [clock] = await tx.unsafe<{ started_at: string }[]>( + `SELECT clock_timestamp()::TEXT AS started_at`, ); - if (!state) { - const [input] = await tx.unsafe<{ populated: boolean }[]>( - `SELECT EXISTS(SELECT 1 FROM overture_places.places LIMIT 1) AS populated`, - ); - if (input.populated) throw new Error("Overture conflation state is missing"); - } - if (state && state.status !== "completed") - throw new Error("Overture conflation must be completed before ambient publication"); - if (state) { - const publishedAt = fresh(state.places_published_at); - const [releases] = await tx.unsafe<{ mismatch: boolean }[]>( - `SELECT EXISTS(SELECT 1 FROM overture_places.places WHERE release<>$1 AND geom && ST_MakeEnvelope($2,$3,$4,$5,4326) LIMIT 1) AS mismatch`, - [state.release, ...params], - ); - if (releases.mismatch) - throw new Error("Overture conflation release does not match published places"); - const rows = await tx.unsafe< - (AmbientOvertureRow & { sources: OvertureSourceItem[] | null })[] + startedAt = clock.started_at; + // Progress writes share the publication transaction and its writer lock. + // A rejected contender must never create a new MVCC version of this row. + await tx.unsafe( + `UPDATE ambient_places.state SET last_build_id=$1,last_build_started_at=$2,last_build_finished_at=NULL,last_build_error=NULL WHERE singleton=1`, + [attempt, startedAt], + ); + await onClaim?.(); + if (!(await tableExists(tx, "osm_search.index_state"))) + throw new Error("Build the regional OSM search index first"); + const [osmState] = await tx.unsafe< + { region: string; epoch: string; status: string; published_at: Date | null }[] + >(`SELECT region,epoch,status,published_at FROM osm_search.index_state WHERE singleton=1`); + if (!osmState || osmState.status !== "ready") + throw new Error("OSM search index must be ready"); + const osmPublished = fresh(osmState.published_at); + const params = region.bounds; + let osmRows: AmbientOsmRow[] = await tx.unsafe( + `SELECT osm_type,osm_id::TEXT,name,lng,lat,category,tags,importance FROM osm_search.places WHERE geom && ST_MakeEnvelope($1,$2,$3,$4,4326)::geography ORDER BY osm_type,osm_id LIMIT ${AMBIENT_LIMITS.places + 1}`, + params, + ); + if (osmRows.length > AMBIENT_LIMITS.places) + throw new Error("Regional OSM input exceeds 100000 places; choose a smaller region"); + let overtureSource: AmbientManifest["sources"]["overture"] = null; + let overture: NonNullable>[] = []; + const links = new Map(); + if (await tableExists(tx, "overture_places.places")) { + if (!(await tableExists(tx, "overture_places.conflation_state"))) + throw new Error("Overture conflation state is missing"); + const [state] = await tx.unsafe< + { region: string; release: string; status: string; places_published_at: Date | null }[] >( - `SELECT gers_id,name,ST_X(geom) AS longitude,ST_Y(geom) AS latitude,basic_category,taxonomy_primary,taxonomy_hierarchy,taxonomy_alternates,names,confidence,operating_status,sources FROM overture_places.places WHERE geom && ST_MakeEnvelope($1,$2,$3,$4,4326) ORDER BY gers_id LIMIT ${AMBIENT_LIMITS.places + 1}`, - params, + `SELECT region,release,status,places_published_at FROM overture_places.conflation_state WHERE singleton=1`, ); - if (rows.length > AMBIENT_LIMITS.places) - throw new Error("Regional Overture input exceeds 100000 places; choose a smaller region"); - overture = rows.flatMap((row) => { - const p = ambientPlaceFromOverture(row); - if (p) - assertSupportedOvertureContributors( - (row.sources ?? []).flatMap((source) => (source.dataset ? [source.dataset] : [])), + if (!state) { + const [input] = await tx.unsafe<{ populated: boolean }[]>( + `SELECT EXISTS(SELECT 1 FROM overture_places.places LIMIT 1) AS populated`, + ); + if (input.populated) throw new Error("Overture conflation state is missing"); + } + if (state && state.status !== "completed") + throw new Error("Overture conflation must be completed before ambient publication"); + if (state) { + const publishedAt = fresh(state.places_published_at); + const [releases] = await tx.unsafe<{ mismatch: boolean }[]>( + `SELECT EXISTS(SELECT 1 FROM overture_places.places WHERE release<>$1 AND geom && ST_MakeEnvelope($2,$3,$4,$5,4326) LIMIT 1) AS mismatch`, + [state.release, ...params], + ); + if (releases.mismatch) + throw new Error("Overture conflation release does not match published places"); + const rows = await tx.unsafe< + (AmbientOvertureRow & { sources: OvertureSourceItem[] | null })[] + >( + `SELECT gers_id,name,ST_X(geom) AS longitude,ST_Y(geom) AS latitude,basic_category,taxonomy_primary,taxonomy_hierarchy,taxonomy_alternates,names,confidence,operating_status,sources FROM overture_places.places WHERE geom && ST_MakeEnvelope($1,$2,$3,$4,4326) ORDER BY gers_id LIMIT ${AMBIENT_LIMITS.places + 1}`, + params, + ); + if (rows.length > AMBIENT_LIMITS.places) + throw new Error( + "Regional Overture input exceeds 100000 places; choose a smaller region", ); - return p ? [p] : []; - }); - const linkRows = await tx.unsafe<{ id: string; gers_id: string }[]>( - `SELECT 'osm:'||l.osm_type||'/'||l.osm_id::TEXT AS id,l.gers_id FROM overture_places.poi_conflation_link l JOIN osm_search.places p USING(osm_type,osm_id) WHERE p.geom && ST_MakeEnvelope($1,$2,$3,$4,4326)::geography AND l.release=$5 UNION SELECT 'osm:'||l.osm_type||'/'||l.osm_id::TEXT AS id,l.gers_id FROM overture_places.poi_conflation_link l JOIN overture_places.places o USING(gers_id) WHERE o.geom && ST_MakeEnvelope($1,$2,$3,$4,4326) AND l.release=$5 LIMIT ${AMBIENT_LIMITS.places + 1}`, - [...params, state.release], - ); - if (linkRows.length > AMBIENT_LIMITS.places) - throw new Error("Regional conflation input exceeds 100000 links"); - for (const row of linkRows) links.set(row.id, row.gers_id); - overtureSource = { - region: state.region, - release: state.release, - publishedAt, - count: overture.length, - }; + overture = rows.flatMap((row) => { + const p = ambientPlaceFromOverture(row); + if (p) + assertSupportedOvertureContributors( + (row.sources ?? []).flatMap((source) => (source.dataset ? [source.dataset] : [])), + ); + return p ? [p] : []; + }); + const linkRows = await tx.unsafe<{ id: string; gers_id: string }[]>( + `SELECT 'osm:'||l.osm_type||'/'||l.osm_id::TEXT AS id,l.gers_id FROM overture_places.poi_conflation_link l JOIN osm_search.places p USING(osm_type,osm_id) WHERE p.geom && ST_MakeEnvelope($1,$2,$3,$4,4326)::geography AND l.release=$5 UNION SELECT 'osm:'||l.osm_type||'/'||l.osm_id::TEXT AS id,l.gers_id FROM overture_places.poi_conflation_link l JOIN overture_places.places o USING(gers_id) WHERE o.geom && ST_MakeEnvelope($1,$2,$3,$4,4326) AND l.release=$5 LIMIT ${AMBIENT_LIMITS.places + 1}`, + [...params, state.release], + ); + if (linkRows.length > AMBIENT_LIMITS.places) + throw new Error("Regional conflation input exceeds 100000 links"); + for (const row of linkRows) links.set(row.id, row.gers_id); + // A linked OSM point can straddle the bbox boundary. Its policy and + // authoritative fields must still win over the regional Overture point. + const counterparts = await tx.unsafe( + `SELECT p.osm_type,p.osm_id::TEXT,p.name,p.lng,p.lat,p.category,p.tags,p.importance FROM osm_search.places p JOIN overture_places.poi_conflation_link l USING(osm_type,osm_id) JOIN overture_places.places o USING(gers_id) WHERE o.geom && ST_MakeEnvelope($1,$2,$3,$4,4326) AND l.release=$5 ORDER BY p.osm_type,p.osm_id LIMIT ${AMBIENT_LIMITS.places + 1}`, + [...params, state.release], + ); + const allOsm = new Map(osmRows.map((row) => [`${row.osm_type}/${row.osm_id}`, row])); + for (const row of counterparts) allOsm.set(`${row.osm_type}/${row.osm_id}`, row); + if (counterparts.length > AMBIENT_LIMITS.places || allOsm.size > AMBIENT_LIMITS.places) + throw new Error( + "Regional OSM and linked input exceeds 100000 places; choose a smaller region", + ); + osmRows = [...allOsm.values()]; + overtureSource = { + region: state.region, + release: state.release, + publishedAt, + count: overture.length, + }; + } } - } - const validOsm = new Set(osm.map((p) => p.id)); - const excludedOsm = new Set( - osmRows - .filter((row) => !validOsm.has(`osm:${row.osm_type}/${row.osm_id}`)) - .map((row) => `osm:${row.osm_type}/${row.osm_id}`), - ); - const places = mergeAmbientPlaces(osm, overture, links, excludedOsm); - if (places.length === 0) throw new Error("Refusing to publish an empty ambient generation"); - if (places.length > AMBIENT_LIMITS.places) - throw new Error("Combined regional output exceeds 100000 places"); - await tx.unsafe( - `DELETE FROM ambient_places.generations g WHERE g.published_at( - `SELECT count(*)::INT AS count FROM ambient_places.generations`, - ); - if (capacity.count >= AMBIENT_LIMITS.generations) - throw new Error( - "Generation retention limit reached; preserve seven-day tile cache leases before rebuilding", + const osm = osmRows.flatMap((row) => { + const p = ambientPlaceFromOsm(row); + return p ? [p] : []; + }); + const inside = (coordinates: [number, number]) => + coordinates[0] >= params[0] && + coordinates[0] <= params[2] && + coordinates[1] >= params[1] && + coordinates[1] <= params[3]; + const validOsm = new Set(osm.map((p) => p.id)); + const excludedOsm = new Set( + osmRows + .filter((row) => !validOsm.has(`osm:${row.osm_type}/${row.osm_id}`)) + .map((row) => `osm:${row.osm_type}/${row.osm_id}`), ); - const manifest: AmbientManifest = { - version: 1, - policyVersion: AMBIENT_POLICY_VERSION, - generation: randomUUID(), - publishedAt: new Date().toISOString(), - region, - placeCount: places.length, - enabled: true, - sources: { - osm: { - region: osmState.region, - epoch: osmState.epoch, - publishedAt: osmPublished, - count: osm.length, + const places = mergeAmbientPlaces(osm, overture, links, excludedOsm).filter((p) => + inside(p.coordinates), + ); + if (places.length === 0) throw new Error("Refusing to publish an empty ambient generation"); + if (places.length > AMBIENT_LIMITS.places) + throw new Error("Combined regional output exceeds 100000 places"); + await tx.unsafe( + `DELETE FROM ambient_places.generations g WHERE g.cache_lease_until( + `SELECT count(*)::INT AS count FROM ambient_places.generations`, + ); + if (capacity.count >= AMBIENT_LIMITS.generations) + throw new Error( + "Generation retention limit reached; preserve seven-day tile cache leases before rebuilding", + ); + const manifest: AmbientManifest = { + version: 1, + policyVersion: AMBIENT_POLICY_VERSION, + generation: randomUUID(), + publishedAt: new Date().toISOString(), + region, + placeCount: places.length, + enabled: true, + sources: { + osm: { + region: osmState.region, + epoch: osmState.epoch, + publishedAt: osmPublished, + count: osm.filter((p) => inside(p.coordinates)).length, + }, + overture: overtureSource, }, - overture: overtureSource, - }, - }; - await tx.unsafe( - `INSERT INTO ambient_places.generations(id,manifest) VALUES($1,$2::TEXT::JSONB)`, - [manifest.generation, JSON.stringify(manifest)], - ); - for (let offset = 0; offset < places.length; offset += 500) { - const rows = places.slice(offset, offset + 500).map((p) => ({ - id: p.id, - gers_id: p.gersId ?? null, - name: p.name, - name_de: p.names.de ?? null, - name_en: p.names.en ?? null, - category: p.category, - rank: p.rank, - min_zoom: p.minZoom, - tenant: p.tenant, - sources: p.sources, - lng: p.coordinates[0], - lat: p.coordinates[1], - })); + }; await tx.unsafe( - `INSERT INTO ambient_places.features(generation,id,gers_id,name,name_de,name_en,category,rank,min_zoom,tenant,sources,geom) + `INSERT INTO ambient_places.generations(id,manifest) VALUES($1,$2::TEXT::JSONB)`, + [manifest.generation, JSON.stringify(manifest)], + ); + for (let offset = 0; offset < places.length; offset += 500) { + const rows = places.slice(offset, offset + 500).map((p) => ({ + id: p.id, + gers_id: p.gersId ?? null, + name: p.name, + name_de: p.names.de ?? null, + name_en: p.names.en ?? null, + category: p.category, + rank: p.rank, + min_zoom: p.minZoom, + tenant: p.tenant, + sources: p.sources, + lng: p.coordinates[0], + lat: p.coordinates[1], + })); + await tx.unsafe( + `INSERT INTO ambient_places.features(generation,id,gers_id,name,name_de,name_en,category,rank,min_zoom,tenant,sources,geom) SELECT $1::UUID,r.id,r.gers_id,r.name,r.name_de,r.name_en,r.category,r.rank,r.min_zoom,r.tenant,r.sources,ST_Transform(ST_SetSRID(ST_MakePoint(r.lng,r.lat),4326),3857) FROM jsonb_to_recordset($2::TEXT::JSONB) AS r(id TEXT,gers_id TEXT,name TEXT,name_de TEXT,name_en TEXT,category TEXT,rank INT,min_zoom SMALLINT,tenant BOOLEAN,sources TEXT,lng DOUBLE PRECISION,lat DOUBLE PRECISION)`, - [manifest.generation, JSON.stringify(rows)], + [manifest.generation, JSON.stringify(rows)], + ); + } + const [count] = await tx.unsafe<{ count: number }[]>( + `SELECT count(*)::INT AS count FROM ambient_places.features WHERE generation=$1`, + [manifest.generation], ); + if (count.count !== manifest.placeCount) + throw new Error("Candidate feature count validation failed"); + // A client may have discovered the outgoing generation moments ago, + // regardless of its original publication date. Include the refresh interval. + await tx.unsafe( + `UPDATE ambient_places.generations SET cache_lease_until=greatest(cache_lease_until,now()+interval '7 days 1 minute') WHERE id=(SELECT active FROM ambient_places.state WHERE singleton=1)`, + ); + await tx.unsafe( + `UPDATE ambient_places.state SET previous=active,active=$1,last_build_finished_at=clock_timestamp() WHERE singleton=1`, + [manifest.generation], + ); + return manifest; + }); + } catch (error) { + if (startedAt !== null) { + // The candidate transaction has rolled back. Record its failure only if + // no newer writer owns the lock or has already completed another attempt. + await sql + .begin(async (tx) => { + const [writer] = await tx.unsafe<{ locked: boolean }[]>( + `SELECT pg_try_advisory_xact_lock(${AMBIENT_WRITE_LOCK}) AS locked`, + ); + if (!writer.locked) return; + await tx.unsafe( + `UPDATE ambient_places.state SET last_build_id=$1,last_build_started_at=$2,last_build_finished_at=clock_timestamp(),last_build_error=$3 WHERE singleton=1 AND (last_build_started_at IS NULL OR last_build_started_at<=$2::TIMESTAMPTZ OR last_build_id=$1)`, + [attempt, startedAt, error instanceof Error ? error.message : "Publication failed"], + ); + }) + .catch(() => undefined); } - const [count] = await tx.unsafe<{ count: number }[]>( - `SELECT count(*)::INT AS count FROM ambient_places.features WHERE generation=$1`, - [manifest.generation], - ); - if (count.count !== manifest.placeCount) - throw new Error("Candidate feature count validation failed"); - await tx.unsafe(`UPDATE ambient_places.state SET previous=active,active=$1 WHERE singleton=1`, [ - manifest.generation, - ]); - return manifest; - }); + throw error; + } } export async function setAmbientEnabled(sql: postgres.Sql, enabled: boolean): Promise { await ensureAmbientSchema(sql); @@ -215,6 +278,9 @@ export async function rollbackAmbientPlaces(sql: postgres.Sql): Promise { `SELECT previous FROM ambient_places.state WHERE singleton=1`, ); if (!state.previous) throw new Error("No previous ambient generation available"); + await tx.unsafe( + `UPDATE ambient_places.generations SET cache_lease_until=greatest(cache_lease_until,now()+interval '7 days 1 minute') WHERE id IN (SELECT active FROM ambient_places.state WHERE singleton=1 UNION SELECT previous FROM ambient_places.state WHERE singleton=1)`, + ); await tx.unsafe( `UPDATE ambient_places.state SET active=previous,previous=active WHERE singleton=1`, ); diff --git a/services/data-manager/src/jobs/ambient-places/schema.ts b/services/data-manager/src/jobs/ambient-places/schema.ts index 4afe4d683..15b6e9507 100644 --- a/services/data-manager/src/jobs/ambient-places/schema.ts +++ b/services/data-manager/src/jobs/ambient-places/schema.ts @@ -1,11 +1,18 @@ import type postgres from "postgres"; export const AMBIENT_WRITE_LOCK = 139399; +export class AmbientPublicationBusyError extends Error { + constructor() { + super("Another ambient publication or pointer change is running"); + } +} export const ambientSchemaDDL = ` CREATE SCHEMA IF NOT EXISTS ambient_places; CREATE TABLE IF NOT EXISTS ambient_places.generations ( - id UUID PRIMARY KEY, manifest JSONB NOT NULL, published_at TIMESTAMPTZ NOT NULL DEFAULT now() + id UUID PRIMARY KEY, manifest JSONB NOT NULL, published_at TIMESTAMPTZ NOT NULL DEFAULT now(), + cache_lease_until TIMESTAMPTZ NOT NULL DEFAULT now()+interval '7 days 1 minute' ); +ALTER TABLE ambient_places.generations ADD COLUMN IF NOT EXISTS cache_lease_until TIMESTAMPTZ NOT NULL DEFAULT now()+interval '7 days 1 minute'; CREATE TABLE IF NOT EXISTS ambient_places.features ( generation UUID NOT NULL REFERENCES ambient_places.generations(id) ON DELETE CASCADE, id TEXT NOT NULL, gers_id TEXT, name TEXT NOT NULL, name_de TEXT, name_en TEXT, @@ -18,8 +25,9 @@ CREATE TABLE IF NOT EXISTS ambient_places.state ( singleton SMALLINT PRIMARY KEY CHECK(singleton=1), active UUID REFERENCES ambient_places.generations(id), previous UUID REFERENCES ambient_places.generations(id), enabled BOOLEAN NOT NULL DEFAULT true, - last_build_started_at TIMESTAMPTZ, last_build_finished_at TIMESTAMPTZ, last_build_error TEXT + last_build_id UUID, last_build_started_at TIMESTAMPTZ, last_build_finished_at TIMESTAMPTZ, last_build_error TEXT ); +ALTER TABLE ambient_places.state ADD COLUMN IF NOT EXISTS last_build_id UUID; INSERT INTO ambient_places.state(singleton) VALUES(1) ON CONFLICT DO NOTHING; `; const initialization = new WeakMap>(); @@ -28,9 +36,11 @@ export async function ensureAmbientSchema(sql: postgres.Sql): Promise { if (!ready) { ready = sql .begin(async (tx) => { - await tx.unsafe( - `SET LOCAL statement_timeout='10000ms'; SELECT pg_advisory_xact_lock(${AMBIENT_WRITE_LOCK})`, + await tx.unsafe(`SET LOCAL statement_timeout='10000ms'`); + const [writer] = await tx.unsafe<{ locked: boolean }[]>( + `SELECT pg_try_advisory_xact_lock(${AMBIENT_WRITE_LOCK}) AS locked`, ); + if (!writer.locked) throw new AmbientPublicationBusyError(); await tx.unsafe(ambientSchemaDDL); }) .then(() => undefined); From 91f4e9e6da7827749b9d809de48caee9dbb54c34 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 8 Oct 2026 00:54:28 +0200 Subject: [PATCH 07/13] fix(places): resolve published identities without search provider --- apps/api/src/routes/__tests__/places.test.ts | 55 +++++++++++++++++++ apps/api/src/routes/places.ts | 32 ++++++++++- .../developer/ambient-places-publication.md | 11 +++- packages/core/src/server/ambient-places.ts | 54 ++++++++++++++---- .../ambient-places/publish-postgres.test.ts | 16 +++++- .../src/jobs/ambient-places/schema.ts | 1 + 6 files changed, 154 insertions(+), 15 deletions(-) diff --git a/apps/api/src/routes/__tests__/places.test.ts b/apps/api/src/routes/__tests__/places.test.ts index fc0b4ef95..4956ae9a4 100644 --- a/apps/api/src/routes/__tests__/places.test.ts +++ b/apps/api/src/routes/__tests__/places.test.ts @@ -81,6 +81,11 @@ vi.mock("../../utils/cache.js", () => ({ TTL: { places: { detail: 86400 }, photos: 3600 }, })); +const mockAmbientPlace = vi.fn().mockResolvedValue(null); +vi.mock("@openmapx/core/ambient-places-server", () => ({ + readAmbientPlaceByGers: (...args: unknown[]) => mockAmbientPlace(...args), +})); + // App setup let app: FastifyInstance; @@ -1449,3 +1454,53 @@ describe("pickMoreSpecificWebsite", () => { ).toBe("https://restaurant-mueller.de/"); }); }); + +describe("published ambient GERS details without the search provider", () => { + it("resolves the published canonical identity without nearest-place fallback", async () => { + mockAmbientPlace.mockResolvedValueOnce({ + generation: "published-generation", + place: { + id: "osm:node/9007199254740993", + gersId: "gers-a", + name: "Klinik", + names: { en: "Clinic" }, + coordinates: [6.08, 50.77], + category: "hospital", + rank: 380, + minZoom: 13, + tenant: false, + sources: "osm,overture", + }, + }); + const response = await app.inject({ + method: "GET", + url: "/places/overture:gers-a?lat=50.77&lng=6.08&name=Clinic&lang=en", + }); + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + id: "osm:node/9007199254740993", + name: "Clinic", + ids: { osm: "node/9007199254740993", overture: "gers-a" }, + }); + expect(mockLookupByNameAndCoords).not.toHaveBeenCalled(); + expect(mockLookupByCoords).not.toHaveBeenCalled(); + expect(mockHashKey).toHaveBeenCalledWith( + "cache:place", + expect.objectContaining({ ambientGeneration: "published-generation" }), + ); + }); + it("preserves existing fallback for an unpublished disabled-provider deep link", async () => { + mockAmbientPlace.mockResolvedValueOnce(null); + mockIsEnabledIntegrationScheme.mockReturnValue(false); + mockLookupByNameAndCoords.mockResolvedValueOnce(MOCK_PLACE); + mockGetPlaceKnowledge.mockResolvedValue({ externalIds: {} }); + mockBuildReviewLinks.mockReturnValue([]); + const response = await app.inject({ + method: "GET", + url: "/places/overture:not-published?lat=50.77&lng=6.08&name=Clinic", + }); + expect(response.statusCode).toBe(200); + expect(mockAmbientPlace).toHaveBeenCalled(); + expect(mockLookupByNameAndCoords).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/routes/places.ts b/apps/api/src/routes/places.ts index e8959c5ea..6675498ce 100644 --- a/apps/api/src/routes/places.ts +++ b/apps/api/src/routes/places.ts @@ -22,6 +22,8 @@ import { parseId, type ReviewProvider, } from "@openmapx/core"; +import { ambientPlaceToCategoryPlace } from "@openmapx/core/ambient-places"; +import { readAmbientPlaceByGers } from "@openmapx/core/ambient-places-server"; import { currentOpeningHoursInfo } from "@openmapx/core/server"; import { buildFacebookUrl, @@ -34,6 +36,7 @@ import { type PlaceResolverContext, } from "@openmapx/place-ids"; import type { FastifyPluginAsync } from "fastify"; +import { sql } from "../db/index.js"; import { getAllIntegrations, isEnabledIntegrationScheme } from "../integration-host.js"; import { getPlaceKnowledge } from "../services/knowledge/index"; import { recordCardEnrichment } from "../services/metrics/index"; @@ -752,9 +755,18 @@ export const placesRoute: FastifyPluginAsync = async (fastify) => { // The shared canonical identity contains every normalized input that can // affect resolution or enrichment. Hash the structured value so // unconstrained string fields cannot create separator collisions. - const cacheKey = hashKey("cache:place", placeRequest.identity); - try { + const parsed = parseId(rawId); + const published = + parsed?.scheme === "overture" && !getPlaceResolver("overture") + ? await readAmbientPlaceByGers(sql, parsed.value) + : null; + const cacheKey = hashKey( + "cache:place", + published + ? { ...placeRequest.identity, ambientGeneration: published.generation } + : placeRequest.identity, + ); const result = await withCache(cacheKey, TTL.places.detail, async () => { const parsedId = parseId(rawId); const latQ = placeRequest.identity.lat ?? Number.NaN; @@ -785,6 +797,22 @@ export const placesRoute: FastifyPluginAsync = async (fastify) => { enrichPlace(resolved, placeRequest.identity.lang ?? undefined), ); } + // The optional search provider may be disabled. An ambient + // publication still owns its GERS identity; never snap that record + // to a neighboring OSM object through coordinate fallback. + if (published) { + return enrichLimit(() => + enrichPlace( + categoryPlaceToPlace( + ambientPlaceToCategoryPlace( + published.place, + placeRequest.identity.lang ?? "en", + ), + ), + placeRequest.identity.lang ?? undefined, + ), + ); + } // No resolver registered for this scheme. The coord-fallback // below would happily snap to the nearest OSM POI — fine for // freeform UI schemes (saved labels, basemap POI clicks, diff --git a/docs/docs/developer/ambient-places-publication.md b/docs/docs/developer/ambient-places-publication.md index 5a78a55ed..3e7f49421 100644 --- a/docs/docs/developer/ambient-places-publication.md +++ b/docs/docs/developer/ambient-places-publication.md @@ -28,7 +28,9 @@ imports planet data, calls AllThePlaces directly, or changes geocoding. asynchronous (`202`) after acquiring the publication writer lock; acceptance is not proof that publication succeeded. Competing requests receive `409`. Status writes share the writer lock and attempt identity, so a rejected request cannot - overwrite or abort the admitted publication. + overwrite or abort the admitted publication. While `building` is true, + external status readers can still see the prior completed attempt's dates or + error until the candidate transaction commits. 5. Use **Disable** for discovery fallback, **Enable** to resume, or **Roll back** to switch to the predecessor. Rollback can be repeated to switch back. @@ -88,6 +90,13 @@ by proximity. Ambiguous branches and non-ground tenants are not matched by proximity. Mappings belong to the map instance and are cleared on style replacement and overlay teardown. The existing place-card conversion/resolver handles both tile and basemap clicks. +When the optional Overture search provider has no registered resolver, the place +API resolves a matching GERS alias from the fresh published ambient snapshot, +retaining canonical OSM/GERS identity and supplied names. Its existing detail +cache key includes that generation. Discovery disable does not revoke snapshot +identities already exposed in cached tiles. The richer live Overture metadata +continues to use the enabled provider; unpublished disabled-provider deep links +retain their existing coordinate fallback. ## Policy and read budgets diff --git a/packages/core/src/server/ambient-places.ts b/packages/core/src/server/ambient-places.ts index 5a585e35e..f61e864b3 100644 --- a/packages/core/src/server/ambient-places.ts +++ b/packages/core/src/server/ambient-places.ts @@ -1,5 +1,23 @@ import type postgres from "postgres"; -import { AMBIENT_LIMITS, AMBIENT_MAX_AGE_MS, type AmbientManifest } from "../ambient-places"; +import { + AMBIENT_LIMITS, + AMBIENT_MAX_AGE_MS, + type AmbientManifest, + type AmbientPlace, + ambientPlaceFromTile, +} from "../ambient-places"; + +function freshPublication(manifest: AmbientManifest): boolean { + const dates = [ + manifest.publishedAt, + manifest.sources.osm.publishedAt, + ...(manifest.sources.overture ? [manifest.sources.overture.publishedAt] : []), + ]; + return dates.every( + (date) => + Number.isFinite(Date.parse(date)) && Date.now() - Date.parse(date) <= AMBIENT_MAX_AGE_MS, + ); +} export function validAmbientTile(generation: string, z: number, x: number, y: number): boolean { return ( @@ -22,16 +40,30 @@ export async function readAmbientManifest(sql: postgres.Sql): Promise - Number.isFinite(Date.parse(date)) && Date.now() - Date.parse(date) <= AMBIENT_MAX_AGE_MS, - ); - return { ...row.manifest, enabled: row.enabled && fresh }; + return { ...row.manifest, enabled: row.enabled && freshPublication(row.manifest) }; +} + +/** Resolve only a published GERS alias, including an accepted canonical OSM ID. + * Discovery disable does not revoke already published tiles or their identities. + */ +export async function readAmbientPlaceByGers( + sql: postgres.Sql, + gers: string, +): Promise<{ generation: string; place: AmbientPlace } | null> { + if (!gers || gers.length > 128) return null; + const manifest = await readAmbientManifest(sql); + if (!manifest || !freshPublication(manifest)) return null; + return sql.begin(async (tx) => { + await tx.unsafe(`SET LOCAL statement_timeout='2000ms'`); + const [row] = await tx.unsafe<(Record & { lng: number; lat: number })[]>( + `SELECT id,gers_id,name,name_de,name_en,category,rank,min_zoom,tenant,sources, + ST_X(ST_Transform(geom,4326)) AS lng,ST_Y(ST_Transform(geom,4326)) AS lat + FROM ambient_places.features WHERE generation=$1 AND gers_id=$2 ORDER BY id COLLATE "C" LIMIT 1`, + [manifest.generation, gers], + ); + const place = row ? ambientPlaceFromTile(row, [row.lng, row.lat]) : null; + return place ? { generation: manifest.generation, place } : null; + }); } export async function readAmbientTile( sql: postgres.Sql, diff --git a/services/data-manager/__tests__/ambient-places/publish-postgres.test.ts b/services/data-manager/__tests__/ambient-places/publish-postgres.test.ts index bc7ed829e..4c8cfaf88 100644 --- a/services/data-manager/__tests__/ambient-places/publish-postgres.test.ts +++ b/services/data-manager/__tests__/ambient-places/publish-postgres.test.ts @@ -1,6 +1,10 @@ import { VectorTile } from "@mapbox/vector-tile"; import { AMBIENT_LIMITS } from "@openmapx/core/ambient-places"; -import { readAmbientManifest, readAmbientTile } from "@openmapx/core/ambient-places-server"; +import { + readAmbientManifest, + readAmbientPlaceByGers, + readAmbientTile, +} from "@openmapx/core/ambient-places-server"; import { PbfReader } from "pbf"; import { describe, expect, it } from "vitest"; import { @@ -69,11 +73,21 @@ describe.skipIf(process.env.OPENMAPX_RUN_DATABASE_TESTS !== "1")( const current = await readAmbientTile(pg.sql, b.generation, tile.z, tile.x, tile.y); expect(current!.toString("utf8")).toContain("gers-a"); expect(current!.toString("utf8")).not.toContain("overture:gers-a"); + expect(await readAmbientPlaceByGers(pg.sql, "gers-a")).toMatchObject({ + generation: b.generation, + place: { id: "osm:node/9007199254740993", gersId: "gers-a", name: "Klinik" }, + }); + expect(await readAmbientPlaceByGers(pg.sql, "gers-b")).toMatchObject({ + place: { id: "overture:gers-b", name: "Cafe" }, + }); + expect(await readAmbientPlaceByGers(pg.sql, "not-published")).toBeNull(); + await rollbackAmbientPlaces(pg.sql); expect((await readAmbientManifest(pg.sql))!.generation).toBe(a.generation); await rollbackAmbientPlaces(pg.sql); await setAmbientEnabled(pg.sql, false); expect((await readAmbientManifest(pg.sql))!.enabled).toBe(false); + expect((await readAmbientPlaceByGers(pg.sql, "gers-b"))?.place.id).toBe("overture:gers-b"); await setAmbientEnabled(pg.sql, true); await pg.sql.unsafe( `CREATE FUNCTION ambient_places.reject_fixture() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'fixture insertion failure'; END $$; CREATE TRIGGER reject_fixture BEFORE INSERT ON ambient_places.features FOR EACH ROW EXECUTE FUNCTION ambient_places.reject_fixture()`, diff --git a/services/data-manager/src/jobs/ambient-places/schema.ts b/services/data-manager/src/jobs/ambient-places/schema.ts index 15b6e9507..ab775a632 100644 --- a/services/data-manager/src/jobs/ambient-places/schema.ts +++ b/services/data-manager/src/jobs/ambient-places/schema.ts @@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS ambient_places.features ( PRIMARY KEY(generation,id) ); CREATE INDEX IF NOT EXISTS ambient_features_geom ON ambient_places.features USING GIST(geom); +CREATE INDEX IF NOT EXISTS ambient_features_gers ON ambient_places.features(generation,gers_id) WHERE gers_id IS NOT NULL; CREATE TABLE IF NOT EXISTS ambient_places.state ( singleton SMALLINT PRIMARY KEY CHECK(singleton=1), active UUID REFERENCES ambient_places.generations(id), previous UUID REFERENCES ambient_places.generations(id), From 38f4847c501c75a48e52f6a40ec30287c6164da8 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 8 Oct 2026 01:03:34 +0200 Subject: [PATCH 08/13] fix(places): keep generation store behind bounded tile API --- .../developer/ambient-places-publication.md | 22 ++++++++++++++++--- .../specs/2026-10-07-ambient-places-design.md | 6 ++++- packages/core/src/server/ambient-places.ts | 6 ++--- .../ambient-places/publish-postgres.test.ts | 4 ++++ .../src/jobs/ambient-places/build.ts | 2 +- .../src/jobs/ambient-places/schema.ts | 5 +++-- 6 files changed, 35 insertions(+), 10 deletions(-) diff --git a/docs/docs/developer/ambient-places-publication.md b/docs/docs/developer/ambient-places-publication.md index 3e7f49421..2be451896 100644 --- a/docs/docs/developer/ambient-places-publication.md +++ b/docs/docs/developer/ambient-places-publication.md @@ -60,6 +60,13 @@ swap commit together. Any failure rolls them all back. Concurrent writers fail without exposing a partial generation. There is no dependency on a background planet import or a live source-table query at map-render time. +Projected points are stored as EWKB bytes with a functional GiST index on +`ST_GeomFromEWKB(geom)`. This keeps the internal generation store out of Martin's +automatic spatial-table discovery: an unversioned table endpoint would otherwise +bypass the API's generation and read budgets. Tile queries decode the indexed +geometry before MVT encoding. Existing Martin sources and configuration are +unchanged. + Generation URLs never change their bytes when sources refresh. At most eight snapshots are retained. Unreferenced generations can be removed only after their cache lease expires. The lease renews when leaving active discovery or rolling @@ -141,7 +148,8 @@ identical decoded MVT bytes. The same bounded query was served through the repository-pinned Martin image and an archive converted/verified with the official PMTiles CLI 1.31.2. The archive was 16,451 bytes; exporting its 60 source tiles took 556 ms. For one 215-byte zoom-18 tile, first-request and 20 repeated-request -measurements were: +measurements were (using the prototype native-geometry storage before the final +EWKB discovery safeguard): | Serving path | First request | Warm median | Warm p95 | | -------------- | ------------: | ----------: | -------: | @@ -149,12 +157,20 @@ measurements were: | Martin/PostGIS | 19.27 ms | 3.31 ms | 4.27 ms | | Martin/PMTiles | 5.55 ms | 1.42 ms | 2.87 ms | +The final store uses the same projected points and MVT query with EWKB decoding +and its functional spatial index; the table above does not measure that final +storage representation. Its bounds, decoded tiles and details lookup are checked +by the real PostGIS regression suite. + These are local fixture observations under concurrent development load, not production capacity estimates. They do not measure a full regional archive, remote range requests, CDN misses, a restarted cold database, or mobile FPS. The 20 Martin repeats benefit from its response cache. A separate 1,000-place dense -PostGIS fixture emitted 256 features / 14,805 bytes, with p95 SQL/repository reads -of 6.75 ms over 20 repeats. The single-place fixture published in 144 ms and +PostGIS fixture emitted 256 features / 14,805 bytes, with prototype p95 +SQL/repository reads of 6.75 ms over 20 repeats. The same regression fixture with +final EWKB storage on 2026-10-08 measured 3.57 ms p95 and identical feature/byte +counts; differing local load prevents treating this as a speed comparison. +The prototype single-place fixture published in 144 ms and produced a 214-byte tile. Long multilingual UTF-8 labels verified the byte budget by reducing the feature count below 256. Reproduce behavior with: diff --git a/docs/superpowers/specs/2026-10-07-ambient-places-design.md b/docs/superpowers/specs/2026-10-07-ambient-places-design.md index 775ce4eda..0412d3bb5 100644 --- a/docs/superpowers/specs/2026-10-07-ambient-places-design.md +++ b/docs/superpowers/specs/2026-10-07-ambient-places-design.md @@ -17,6 +17,8 @@ merge or deploy. Keep screenshot files outside the repository. ## Storage and publication Publish an immutable generation in dedicated `ambient_places` PostGIS tables. +Store projected points as EWKB bytes with a functional spatial index so Martin's +automatic table discovery cannot expose an unversioned, unbounded alternate source. A repeatable-read transaction reads the existing OSM index, Overture records and accepted OSM/GERS links together. It builds a separate candidate, validates it, then atomically changes the singleton active pointer and previous pointer. @@ -30,7 +32,9 @@ spatially indexed, limited to 100,001 rows each; more than 100,000 from either source rejects publication. Candidate output is limited to 100,000 places, written in batches of 500. No empty generation may replace a working layer. Keep at most eight committed generations. Prune only unreferenced generations -older than seven days; if retention prevents freeing space, refuse the build. +after their seven-day plus one-minute retirement lease expires; renew the lease +when leaving active discovery or rolling back, independently of publication age. +If retention prevents freeing space, refuse the build. Transactions have a 120-second statement timeout. Operator rebuilds run as one asynchronous in-process job, with database locking as the cross-process authority. A restart cannot leave a permanent job lock. diff --git a/packages/core/src/server/ambient-places.ts b/packages/core/src/server/ambient-places.ts index f61e864b3..0f4a134ca 100644 --- a/packages/core/src/server/ambient-places.ts +++ b/packages/core/src/server/ambient-places.ts @@ -57,7 +57,7 @@ export async function readAmbientPlaceByGers( await tx.unsafe(`SET LOCAL statement_timeout='2000ms'`); const [row] = await tx.unsafe<(Record & { lng: number; lat: number })[]>( `SELECT id,gers_id,name,name_de,name_en,category,rank,min_zoom,tenant,sources, - ST_X(ST_Transform(geom,4326)) AS lng,ST_Y(ST_Transform(geom,4326)) AS lat + ST_X(ST_Transform(ST_GeomFromEWKB(geom),4326)) AS lng,ST_Y(ST_Transform(ST_GeomFromEWKB(geom),4326)) AS lat FROM ambient_places.features WHERE generation=$1 AND gers_id=$2 ORDER BY id COLLATE "C" LIMIT 1`, [manifest.generation, gers], ); @@ -87,9 +87,9 @@ export async function readAmbientTile( const [result] = await tx.unsafe<{ tile: Buffer }[]>( ` WITH candidates AS MATERIALIZED ( - SELECT id,gers_id,name,name_de,name_en,category,rank,min_zoom,tenant,sources,geom + SELECT id,gers_id,name,name_de,name_en,category,rank,min_zoom,tenant,sources,ST_GeomFromEWKB(geom) AS geom FROM ambient_places.features - WHERE generation=$1 AND min_zoom<=$2 AND geom && ST_TileEnvelope($2,$3,$4,margin=>64.0/4096) + WHERE generation=$1 AND min_zoom<=$2 AND ST_GeomFromEWKB(geom) && ST_TileEnvelope($2,$3,$4,margin=>64.0/4096) ORDER BY rank DESC,id COLLATE "C" LIMIT ${AMBIENT_LIMITS.tileFeatures} ), budgeted AS ( SELECT *, sum(octet_length(id)+coalesce(octet_length(gers_id),0)+octet_length(name) diff --git a/services/data-manager/__tests__/ambient-places/publish-postgres.test.ts b/services/data-manager/__tests__/ambient-places/publish-postgres.test.ts index 4c8cfaf88..26cc85532 100644 --- a/services/data-manager/__tests__/ambient-places/publish-postgres.test.ts +++ b/services/data-manager/__tests__/ambient-places/publish-postgres.test.ts @@ -48,6 +48,10 @@ describe.skipIf(process.env.OPENMAPX_RUN_DATABASE_TESTS !== "1")( const start = performance.now(); const a = await buildAmbientPlaces(pg.sql, region); const initialBuildMs = performance.now() - start; + const [discovery] = await pg.sql.unsafe<{ exposed: boolean }[]>( + `SELECT EXISTS(SELECT 1 FROM geometry_columns WHERE f_table_schema='ambient_places' AND f_table_name='features') AS exposed`, + ); + expect(discovery.exposed).toBe(false); expect(a).toMatchObject({ placeCount: 1, sources: { osm: { epoch: "osm-one", count: 1 }, overture: null }, diff --git a/services/data-manager/src/jobs/ambient-places/build.ts b/services/data-manager/src/jobs/ambient-places/build.ts index dbf3f4b3c..d4b46f641 100644 --- a/services/data-manager/src/jobs/ambient-places/build.ts +++ b/services/data-manager/src/jobs/ambient-places/build.ts @@ -221,7 +221,7 @@ export async function buildAmbientPlaces( })); await tx.unsafe( `INSERT INTO ambient_places.features(generation,id,gers_id,name,name_de,name_en,category,rank,min_zoom,tenant,sources,geom) - SELECT $1::UUID,r.id,r.gers_id,r.name,r.name_de,r.name_en,r.category,r.rank,r.min_zoom,r.tenant,r.sources,ST_Transform(ST_SetSRID(ST_MakePoint(r.lng,r.lat),4326),3857) + SELECT $1::UUID,r.id,r.gers_id,r.name,r.name_de,r.name_en,r.category,r.rank,r.min_zoom,r.tenant,r.sources,ST_AsEWKB(ST_Transform(ST_SetSRID(ST_MakePoint(r.lng,r.lat),4326),3857)) FROM jsonb_to_recordset($2::TEXT::JSONB) AS r(id TEXT,gers_id TEXT,name TEXT,name_de TEXT,name_en TEXT,category TEXT,rank INT,min_zoom SMALLINT,tenant BOOLEAN,sources TEXT,lng DOUBLE PRECISION,lat DOUBLE PRECISION)`, [manifest.generation, JSON.stringify(rows)], ); diff --git a/services/data-manager/src/jobs/ambient-places/schema.ts b/services/data-manager/src/jobs/ambient-places/schema.ts index ab775a632..066bcd386 100644 --- a/services/data-manager/src/jobs/ambient-places/schema.ts +++ b/services/data-manager/src/jobs/ambient-places/schema.ts @@ -17,10 +17,11 @@ CREATE TABLE IF NOT EXISTS ambient_places.features ( generation UUID NOT NULL REFERENCES ambient_places.generations(id) ON DELETE CASCADE, id TEXT NOT NULL, gers_id TEXT, name TEXT NOT NULL, name_de TEXT, name_en TEXT, category TEXT NOT NULL, rank INTEGER NOT NULL, min_zoom SMALLINT NOT NULL, - tenant BOOLEAN NOT NULL, sources TEXT NOT NULL, geom geometry(POINT,3857) NOT NULL, + tenant BOOLEAN NOT NULL, sources TEXT NOT NULL, geom BYTEA NOT NULL, PRIMARY KEY(generation,id) ); -CREATE INDEX IF NOT EXISTS ambient_features_geom ON ambient_places.features USING GIST(geom); +-- EWKB keeps this internal store out of Martin's automatic geometry-table discovery. +CREATE INDEX IF NOT EXISTS ambient_features_geom ON ambient_places.features USING GIST(ST_GeomFromEWKB(geom)); CREATE INDEX IF NOT EXISTS ambient_features_gers ON ambient_places.features(generation,gers_id) WHERE gers_id IS NOT NULL; CREATE TABLE IF NOT EXISTS ambient_places.state ( singleton SMALLINT PRIMARY KEY CHECK(singleton=1), From 01a67dfe5f052e66ac0e494c4c490d1de270c22e Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 8 Oct 2026 21:28:10 +0200 Subject: [PATCH 09/13] fix(places): rank source-backed regional landmarks --- .changeset/quiet-ambient-places.md | 2 + .../AmbientPlacesMaintenance.test.tsx | 2 + .../services/AmbientPlacesMaintenance.tsx | 1 + .../developer/ambient-places-publication.md | 13 +- .../2026-10-08-ambient-places-acceptance.md | 134 +++ ...-10-08-ambient-places-acceptance-design.md | 93 ++ packages/core/src/ambient-places.test.ts | 45 + packages/core/src/ambient-places.ts | 63 +- .../ambient-places/extractor-policy.test.ts | 61 ++ .../ambient-places/landmark-corpus.json | 940 ++++++++++++++++++ 10 files changed, 1343 insertions(+), 11 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-08-ambient-places-acceptance.md create mode 100644 docs/superpowers/specs/2026-10-08-ambient-places-acceptance-design.md create mode 100644 services/data-manager/__tests__/ambient-places/extractor-policy.test.ts create mode 100644 services/data-manager/__tests__/ambient-places/landmark-corpus.json diff --git a/.changeset/quiet-ambient-places.md b/.changeset/quiet-ambient-places.md index 6f09617df..ca8357a65 100644 --- a/.changeset/quiet-ambient-places.md +++ b/.changeset/quiet-ambient-places.md @@ -3,3 +3,5 @@ --- Add regional ambient-place policy, generation metadata and canonical tile identity contracts. + +Normalize actual indexed OSM category formats and rank corroborated cultural landmarks conservatively in ambient policy version 2. diff --git a/apps/web/src/components/admin/services/AmbientPlacesMaintenance.test.tsx b/apps/web/src/components/admin/services/AmbientPlacesMaintenance.test.tsx index 9d8c9a958..b6f1d79f5 100644 --- a/apps/web/src/components/admin/services/AmbientPlacesMaintenance.test.tsx +++ b/apps/web/src/components/admin/services/AmbientPlacesMaintenance.test.tsx @@ -33,6 +33,7 @@ describe("ambient operator workflow", () => { : { active: { generation: "generation-one", + policyVersion: 2, publishedAt: "2026-10-07T10:00:00Z", placeCount: 42, enabled: true, @@ -51,6 +52,7 @@ describe("ambient operator workflow", () => { view(); await screen.findByText(/osm-one/); expect(screen.getByText(/OSM only/)).toBeInTheDocument(); + expect(screen.getByText("Policy 2")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Publish map snapshot" })); await waitFor(() => expect(fetch).toHaveBeenCalledWith( diff --git a/apps/web/src/components/admin/services/AmbientPlacesMaintenance.tsx b/apps/web/src/components/admin/services/AmbientPlacesMaintenance.tsx index b2ad48b83..02a1ce16d 100644 --- a/apps/web/src/components/admin/services/AmbientPlacesMaintenance.tsx +++ b/apps/web/src/components/admin/services/AmbientPlacesMaintenance.tsx @@ -88,6 +88,7 @@ export function AmbientPlacesMaintenance({ apiUrl }: { apiUrl: string }) { label={active?.enabled ? "Visible" : "Disabled or out of date"} /> + {active && } {active && ( diff --git a/docs/docs/developer/ambient-places-publication.md b/docs/docs/developer/ambient-places-publication.md index 2be451896..ca2d4c1d6 100644 --- a/docs/docs/developer/ambient-places-publication.md +++ b/docs/docs/developer/ambient-places-publication.md @@ -113,8 +113,17 @@ retain their existing coordinate fallback. Missing values are omitted. Unsupported contributor datasets block publication. - Labels: supplied German/English names, otherwise the source name; at most 120 Unicode characters. No inferred opening hours, ratings or popularity. -- Ranking: essential destinations at zoom 13, everyday businesses at 15, other - places at 16; utility tier and OSM importance, then stable ID tie-breaking. +- Ranking (policy 2): essential destinations at zoom 13; corroborated cathedral/ + basilica or heritage levels 1–3 destinations at 14; other registered cultural + landmarks and everyday businesses at 15; other places at 16. Cultural landmarks + require a valid Wikidata/language-prefixed Wikipedia identity plus registered + heritage or explicit cathedral/basilica designation. Ordinary worship buildings + without that evidence stay at 16. Importance orders within utility tiers; + exact source ID breaks ties. Both indexed `amenity/hospital` and legacy + `amenity:hospital` categories normalize to the same tier. +- Tenant handling: interior/node/building-part non-ground records stay at 18. + A corroborated whole cultural-building footprint is a building destination; + its level metadata does not imply an interior tenant or an entrance claim. - MVT: zooms 13–18, at most 256 features, 128 KiB, indexed geometry intersection, a 2-second SQL timeout and eight pending tile requests per API process. A conservative UTF-8 budget removes the lowest-ranked tail before encoding; a diff --git a/docs/superpowers/plans/2026-10-08-ambient-places-acceptance.md b/docs/superpowers/plans/2026-10-08-ambient-places-acceptance.md new file mode 100644 index 000000000..34c0cc5f3 --- /dev/null +++ b/docs/superpowers/plans/2026-10-08-ambient-places-acceptance.md @@ -0,0 +1,134 @@ +# Ambient Place Acceptance Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the remaining #399 acceptance gaps in PR #436. +**Architecture:** Keep the existing immutable publication and overlay contracts. +Correct actual extractor category compatibility, add conservative auditable landmark +ranking and repair per-layer filter lifecycle. Verify with a real cropped regional +index and production-asset desktop/mobile QA. +**Tech Stack:** TypeScript, React/Next, MapLibre, PostGIS, Osmium, T3 preview/device. +**Spec:** docs/superpowers/specs/2026-10-08-ambient-places-acceptance-design.md + +## Global Constraints + +- One Rhine bbox [6.58,50.89,7.07,51.31]; all existing hard publication/read budgets. +- Policy version 2; essential z13, designated/high heritage landmarks z14, + other supported registered landmarks/businesses z15, other places z16, tenants z18. +- No OpenConditions/adapters/feeds/mobility changes, planet/direct ATP ingestion, + geocoder redesign, offline delivery, merge or deployment. +- Existing feature worktree and PR #436; no checkpoint approvals; external screenshots. +- Build <=60s; warm tile p95 <=100ms, concurrent-eight p95 <=250ms, first <=1000ms. +- Five foreground 5s sweeps/state; frame p95 <=33.4ms, >50ms <=5%; do not loosen targets. + +## Review Focus + +- Extracted slash-category rows receive the same tiers as colon aliases: Task 1. +- Ordinary/malformed/closed/private/tenant cultural features never gain unjustified + early prominence: Task 1. +- Identical filter keys after paint-only recreation still reinstall suppression: + Task 2. +- Real source geometries, exact IDs and publication provenance survive extraction + and canonical place selection: Task 3. +- Dense real-data scenes retain road/transit priority and meet declared read/frame + budgets with source/collision failures separated: Task 3. + +### Task 1: Source-compatible landmark policy and corpus + +**Files:** Modify `packages/core/src/ambient-places.ts`, +`packages/core/src/ambient-places.test.ts`; create +`services/data-manager/__tests__/ambient-places/extractor-policy.test.ts` and +`services/data-manager/__tests__/ambient-places/landmark-corpus.json`; +modify `apps/web/src/components/admin/services/AmbientPlacesMaintenance.tsx` and +its existing test; update `.changeset/quiet-ambient-places.md`. +**Interfaces:** consumes existing `featureToSearchPlace`/`AmbientOsmRow`; +produces policy version 2 and unchanged `AmbientPlace`/MVT properties. + +- [ ] Add extractor-to-policy regressions for `amenity/hospital`, `shop/bakery`, + real landmark corpus and ordinary/private/closed/tenant/invalid evidence controls. + Assert hospital z13, bakery z15, designated landmarks z14, ordinary worship z16, + non-ground z18, no false early tiers and identical colon/slash categories. +- [ ] Run `pnpm exec vitest run packages/core/src/ambient-places.test.ts services/data-manager/__tests__/ambient-places/extractor-policy.test.ts`. + Expected: new ranking/actual extractor tests fail on current code. +- [ ] Normalize slash/colon categories; implement source-backed landmark tier in + shared policy, bump policy version, expose current version in admin metadata + and cover it in the existing admin test. Retain all identity/closure guards. +- [ ] Run the above plus admin component test and real publication suite. + Expected: all pass; decoded exact IDs and feature/byte budgets still pass. +- [ ] Commit `fix(places): rank source-backed regional landmarks`. + +### Task 2: Resilient suppression on theme layer recreation + +**Files:** Modify `integrations/overlay-ambient-places/map-layer.tsx` and +`integrations/overlay-ambient-places/map-layer.test.tsx`. +**Interfaces:** consumes existing group hook and MapLibre filters; no shared hook +or other overlay behavior changes. + +- [ ] Add a theme rerender regression: a selected/owned canonical feature is + suppressed, real descriptor recreation drops the imperative label filter, the + full basemap reload never arrives, and idle must restore it. Assert no repeated + writes on subsequent unchanged idles, normal style reload and teardown. +- [ ] Run `pnpm exec vitest run integrations/overlay-ambient-places/map-layer.test.tsx`. + Expected: recreation regression fails before the fix. +- [ ] Repair filter/layer cache comparison and theme effect lifecycle, keeping + scoped identities, data-load handling and stable event cleanup. +- [ ] Repeat the file plus existing identity/tap and group-hook tests. + Expected: all pass with no shared hook changes. +- [ ] Commit `fix(web): retain ambient suppression across theme changes`. + +### Task 3: Real regional publication and controlled acceptance evidence + +**Files:** Update existing developer ambient/baseline docs; create +`docs/docs/developer/ambient-places-acceptance.json` (aggregate public evidence, +no images/source binaries/private URLs). Temporary QA servers/pages/scripts, +PBFs, metrics and captures remain under `/tmp/openmapx-399-acceptance` or ignored. +**Interfaces:** consumes unchanged extractor/index and publication/tiles; produces +checksummed source/QA evidence and passing targets from the spec. + +- [ ] Acquire dated metadata-stripped source PBFs, record SHA/timestamp, crop the + one region and build via existing extract/index into disposable PostGIS. + Expected: ready real OSM index with source-backed landmark rows; no production writes. +- [ ] Publish and measure at least 30 sparse/dense reads plus eight concurrent + requests; decode limits, check old bytes, failure fallback and rollback on the + same source. Expected: spec payload/publication/query targets pass. +- [ ] Build and serve production web assets through a temporary fixture route + mounting the real overlay, basemap, attribution and place-card components. + Repeat #393 cameras/zooms, real Neuss/Cologne/rural scenes, theme/locale/provider + configuration, identity taps and no-region controls. Expected: landmarks visible + at intended zooms, no explicit duplicates, road/transit priority preserved. +- [ ] Use T3 preview plus available iOS Safari simulator; run five foreground 5s + sweeps per state and record raw/aggregate frame data. Expected: declared frame + targets pass; exact device/runtime limits disclosed. +- [ ] View selected actual before/after/mobile images; record screenshot checksums + externally. Update aggregate evidence/docs and remove temporary QA product files. + Expected: reproducible source/settings/metrics, no screenshots in git. +- [ ] Run focused policy/overlay tests and real PostGIS publication/admission tests. + Expected: all pass. Commit `docs(places): verify real regional acceptance budgets`. + +### Task 4: Final review, gates and updated PR + +**Files:** Existing PR body/screenshots outside repository; relevant current docs. +**Interfaces:** consumes Tasks 1–3, delivers accurate complete PR #436 evidence. + +- [ ] Self-check every current #399 criterion and comment against evidence. + Expected: no code or regional QA criterion falsely claimed complete. +- [ ] Run root lint/types/full test (four workers), web/API/data-manager/docs + builds and real PostGIS task suite. Expected: all pass; failed targets are fixed. +- [ ] Dispatch one fresh strongest-model whole-branch reviewer with spec/plan, + issue text/comment and evidence, then fix important findings with RED→GREEN + tests and a green suite. Expected: no unresolved important findings. +- [ ] Commit, push with temporary HTTPS credentials and update PR #436 with real + evidence attachments. Immediately T3-link; verify head/assignee/existing labels, + uploaded image hashes and thread PR list. No merge/deploy. +- [ ] Run final real PostGIS completion suite. Expected: pass; copy decisions and + delete only this plan's ignored workspace; hand back with limitations accurately stated. + +## Plan self-review + +Each of the four previously disclosed gaps has an owning task. Actual source +category compatibility is tested at the extractor boundary. Ranking changes +neither geocoder retrieval nor ingestion contracts. All real data writes target a +disposable database, and both sides of each visual comparison use the same source +and publication. Physical-device thermal/battery certification is a disclosed +rollout limit, not a fabricated simulator result. User authorization overrides +skill checkpoint gates and preserves inline execution. diff --git a/docs/superpowers/specs/2026-10-08-ambient-places-acceptance-design.md b/docs/superpowers/specs/2026-10-08-ambient-places-acceptance-design.md new file mode 100644 index 000000000..21f9b995c --- /dev/null +++ b/docs/superpowers/specs/2026-10-08-ambient-places-acceptance-design.md @@ -0,0 +1,93 @@ +# Ambient place acceptance follow-up (#399 / PR #436) + +## Intent and boundaries + +Complete the remaining bounded-region acceptance work: source-backed regional +landmarks, actual regional QA, declared performance limits and resilient duplicate +suppression during theme/style changes. Work inline in the existing isolated +worktree; update PR #436 without approvals, merge or deployment. Preserve all +other checkouts. OpenConditions, adapters, feeds, mobility, planet/direct ATP +imports, geocoder redesign and offline delivery remain outside scope. + +## Source-backed prominence + +The existing search extractor produces slash categories such as +`amenity/hospital`; ambient policy must normalize both those and colon aliases. +Exercise extractor output directly so synthetic rows cannot hide this mismatch. +Policy version 2 retains the existing ID/tile contract and publication budgets. +Essential categories retain zoom 13, ordinary business categories zoom 15, other +places zoom 16 and non-ground tenants zoom 18. + +A cultural destination (place of worship, museum, castle, monument, +archaeological site or tourist attraction) gets regional landmark priority only +with two auditable signals: a valid Wikidata ID or language-prefixed Wikipedia +reference, plus positive heritage registration or a cathedral/basilica (`yes`, `minor`, `major`) designation. +Reject `heritage=no`, malformed identities and generic names as evidence. Heritage +levels 1–3 or cathedral/basilica designation start at zoom 14; other registered +heritage destinations start at zoom 15. Rank them above ordinary businesses and +below essential services. Closure/private access and tenant deferral always win. A corroborated, explicitly +mapped whole cultural building footprint (way/relation, not indoor/building-part) +is a building destination rather than an interior tenant; its level metadata +does not assert an entrance or defer the entire cathedral. Indoor/part/node +records remain conservatively deferred. +No named-building exceptions, popularity scores or worldwide church revelation. +Quirinus-Münster and Cologne Cathedral are source-backed corpus examples; ordinary +nearby worship buildings, malformed/no evidence, closure and tenants are controls. +Preserve supplied German/English labels and exact source IDs. + +## Style lifecycle + +The overlay must reinstall its current per-layer suppression filter when its +owned layer descriptor is recreated by a paint-only theme change, even if the +subsequent complete basemap fetch fails. Also repair a lost filter on idle without +unbounded repeated writes. Use the current map filter or layer identity, scoped +per map; retain existing cleanup and normal full-style reload behavior. A theme +regression must exercise the real group hook/recreation semantics. + +## Real data and reproducibility + +Use public metadata-stripped, dated Geofabrik regional PBFs, crop to one Rhine +bbox [6.58,50.89,7.07,51.31], then run the existing OSM extractor/index builder +into a disposable PostGIS instance. Record source URLs, snapshot dates, SHA-256, +crop bounds, tool/runtime versions, extraction and publication counts/times, +generation and policy version. No production database writes. OSM-only coverage +is explicit; existing tests cover optional Overture/conflated identities. + +Repeat fixed #393 cameras at zooms 14–18 with the same basemap source, candidate +publication and settings. Neuss and Cologne supply positive real regional scenes; +Berlin/Aachen/Monschau remain controls outside this publication. Add a rural Rhine +scene. Separate source eligibility, rendered labels, identity matching and label +collisions. Capture light/dark, locale, pan/zoom, owned basemap style replacement, +provider-disabled/no-region behavior and canonical tile/basemap place-card taps. +Screenshots and raw public source data stay outside the repository. Commit a small +source-backed policy corpus and machine-readable aggregate evidence with checksums; +attach selected real before/after and mobile evidence to the existing PR. + +## Declared acceptance budgets + +Apply unchanged 100,000 input/output, 256-feature, 128-KiB, zoom 13–18, 2-second +SQL and eight-pending-request hard guards. For this local regional pilot: +- Full regional publication completes within 60 seconds after indexing. +- Sparse/dense tile repository reads: warm p95 <=100 ms (at least 30 samples); + eight-concurrent p95 <=250 ms; first request <=1000 ms. +- Zero retained explicit-identity duplicates or incorrect landmark taps. +- Road/transit labels in fixed before/after views must not lose priority. +- Five foreground 5-second pan/zoom runs per overlay state, after tile settling: + p95 animation-frame interval <=33.4 ms, <=5% intervals >50 ms; report off/on + samples, viewport, DPR, browser/device, thermal/load limits and errors. +Measure production assets in desktop and phone CSS viewports, plus Safari on the +available iOS simulator. Simulator results do not certify physical-device battery, +thermal behavior or all mobile hardware; disclose that remaining deployment limit. +If a target fails, diagnose and fix the feature, then repeat the affected checks; +do not retroactively loosen thresholds to claim success. + +## Admin, docs and delivery + +Show publication policy version in existing admin metadata. Update current +ambient publication and comparison documentation with ranking evidence, real +source coverage, reproducible commands, target/result tables and deployment limits. +Run focused and real PostGIS tests, lint/types/full tests and affected production +builds. One fresh strongest-model review checks the follow-up plus full branch; +fix important findings in one test-backed pass. Push through temporary HTTPS +credentials, refresh PR evidence/body, verify assignee/labels/attachments, T3 link +and hand back with accurate completion evidence. No merge or deployment. diff --git a/packages/core/src/ambient-places.test.ts b/packages/core/src/ambient-places.test.ts index 988e8a7e3..a1228eb65 100644 --- a/packages/core/src/ambient-places.test.ts +++ b/packages/core/src/ambient-places.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + ambientCategory, ambientPlaceFromOsm, ambientPlaceFromOverture, ambientPlaceToCategoryPlace, @@ -70,6 +71,50 @@ describe("ambient place policy", () => { } expect(ambientPlaceFromOverture({ ...overture, confidence: 0.5 })).not.toBeNull(); }); + it("normalizes indexed slash categories and legacy colon categories identically", () => { + for (const prefix of ["amenity/", "amenity:"]) { + expect(ambientCategory(`${prefix}hospital`)).toBe("hospital"); + expect(ambientPlaceFromOsm({ ...osm, category: `${prefix}hospital` })!.minZoom).toBe(13); + } + expect(ambientPlaceFromOsm({ ...osm, category: "shop/bakery" })!.minZoom).toBe(15); + expect(ambientPlaceFromOsm({ ...osm, category: "shop/books" })!.minZoom).toBe(15); + }); + it("promotes only corroborated cultural landmarks and preserves exclusion/tenant policy", () => { + const base = { ...osm, category: "amenity/place_of_worship" }; + const regional = { wikidata: "Q156475", heritage: "4" }; + const designated = { wikidata: "Q156475", basilica: "yes" }; + expect(ambientPlaceFromOsm({ ...base, tags: regional })!.minZoom).toBe(15); + expect(ambientPlaceFromOsm({ ...base, tags: designated })!.minZoom).toBe(14); + expect( + ambientPlaceFromOsm({ ...base, tags: { heritage: "1", wikipedia: "de:Dom" } })!.minZoom, + ).toBe(14); + const controls: Record[] = [ + {}, + { wikidata: "Q156475" }, + { heritage: "4" }, + { wikidata: "Q156475", heritage: "no" }, + { wikidata: "bad", heritage: "4" }, + { wikipedia: "no-prefix", heritage: "4" }, + { wikidata: "Q156475", heritage: "0" }, + ]; + for (const tags of controls) { + expect(ambientPlaceFromOsm({ ...base, tags })!.minZoom).toBe(16); + } + expect(ambientPlaceFromOsm({ ...base, tags: { ...designated, level: "1" } })!.minZoom).toBe(18); + expect(ambientPlaceFromOsm({ ...base, tags: { ...designated, access: "private" } })).toBeNull(); + expect(ambientPlaceFromOsm({ ...base, tags: { ...designated, disused: "yes" } })).toBeNull(); + expect(ambientPlaceFromOsm({ ...base, category: "shop/books", tags: regional })!.minZoom).toBe( + 15, + ); + expect( + ambientPlaceFromOsm({ ...base, category: "amenity/bench", tags: regional })!.minZoom, + ).toBe(16); + const landmark = ambientPlaceFromOsm({ ...base, tags: designated })!; + expect(landmark.rank).toBeGreaterThan( + ambientPlaceFromOsm({ ...osm, category: "shop/bakery" })!.rank, + ); + expect(landmark.rank).toBeLessThan(ambientPlaceFromOsm(osm)!.rank); + }); it("ranks useful categories first and defers tenants to zoom 18", () => { expect(ambientPlaceFromOsm(osm)!.minZoom).toBe(13); expect(ambientPlaceFromOsm({ ...osm, category: "shop:bakery" })!.minZoom).toBe(15); diff --git a/packages/core/src/ambient-places.ts b/packages/core/src/ambient-places.ts index 6d58b848c..9b325c880 100644 --- a/packages/core/src/ambient-places.ts +++ b/packages/core/src/ambient-places.ts @@ -3,7 +3,7 @@ import type { Place } from "./types/place"; import { haversineDistance } from "./utils/coordinates"; import { overtureTaxonomyToOpenMapX } from "./utils/overtureCategoryMap"; -export const AMBIENT_POLICY_VERSION = 1; +export const AMBIENT_POLICY_VERSION = 2; export const AMBIENT_MAX_AGE_MS = 90 * 86400_000; export const AMBIENT_LIMITS = { places: 100_000, @@ -104,7 +104,7 @@ const aliases: Record = { townhall: "townhall", }; export function ambientCategory(value: string): string { - const raw = value.split(":").at(-1)!; + const raw = value.split(/[:/]/).at(-1)!; return aliases[raw] ?? raw; } const essential = new Set([ @@ -142,14 +142,48 @@ function point(lng: number, lat: number): boolean { Math.abs(lat) <= 85.051129 ); } -function policy(category: string, importance: number, tenant: boolean) { +const culturalDestinations = new Set([ + "place_of_worship", + "museum", + "gallery", + "castle", + "monument", + "archaeological_site", + "attraction", +]); +function landmarkZoom(category: string, tags: Record): 14 | 15 | null { + if (!culturalDestinations.has(ambientCategory(category))) return null; + const identified = + /^Q[1-9]\d*$/.test(tags.wikidata ?? "") || + /^[a-z]{2,3}(?:-[a-z]+)?:\S/.test(tags.wikipedia ?? ""); + if (!identified) return null; + const heritage = /^(?:[1-9]|10|yes)$/.test(tags.heritage ?? ""); + const designated = + tags.building === "cathedral" || + tags["church:type"] === "cathedral" || + ["yes", "minor", "major"].includes(tags.basilica); + if (!heritage && !designated) return null; + return designated || /^[1-3]$/.test(tags.heritage ?? "") ? 14 : 15; +} +function policy( + category: string, + importance: number, + tenant: boolean, + landmark: 14 | 15 | null = null, +) { const c = ambientCategory(category); - const tier = essential.has(c) ? 3 : everyday.has(c) || category.startsWith("shop:") ? 2 : 1; + const tier = essential.has(c) + ? 3 + : landmark + ? 2.5 + : everyday.has(c) || /^shop[:/]/.test(category) + ? 2 + : 1; return { rank: - tier * 100 + + tier * 1000 + Math.round(Math.max(0, Math.min(1, Number.isFinite(importance) ? importance : 0)) * 99), - minZoom: tenant ? 18 : tier === 3 ? 13 : tier === 2 ? 15 : 16, + minZoom: tenant ? 18 : tier === 3 ? 13 : (landmark ?? (tier === 2 ? 15 : 16)), }; } export function ambientPlaceFromOsm(row: AmbientOsmRow): AmbientPlace | null { @@ -166,20 +200,31 @@ export function ambientPlaceFromOsm(row: AmbientOsmRow): AmbientPlace | null { ) ) return null; - const tenant = [tags.level, tags.floor].some((v) => v !== undefined && v !== "0" && v !== "0.0"); + const category = row.category ?? "place"; + const landmark = landmarkZoom(category, tags); + // An explicitly mapped whole cultural building is a destination, not an + // interior tenant. Its level is not a promise about ground-floor entrances. + const wholeBuilding = + landmark !== null && + row.osm_type !== "node" && + ["cathedral", "church", "castle", "museum"].includes(tags.building) && + !tags.indoor && + !tags["building:part"]; + const tenant = + !wholeBuilding && + [tags.level, tags.floor].some((v) => v !== undefined && v !== "0" && v !== "0.0"); const names: Record = {}; for (const lang of ["de", "en"]) { const v = label(tags[`name:${lang}`]); if (v) names[lang] = v; } - const category = row.category ?? "place"; return { id: `osm:${row.osm_type}/${row.osm_id}`, name, names, coordinates: [row.lng, row.lat], category: ambientCategory(category), - ...policy(category, row.importance, tenant), + ...policy(category, row.importance, tenant, landmark), tenant, sources: "osm", }; diff --git a/services/data-manager/__tests__/ambient-places/extractor-policy.test.ts b/services/data-manager/__tests__/ambient-places/extractor-policy.test.ts new file mode 100644 index 000000000..ae5b9e2fb --- /dev/null +++ b/services/data-manager/__tests__/ambient-places/extractor-policy.test.ts @@ -0,0 +1,61 @@ +import { ambientPlaceFromOsm } from "@openmapx/core/ambient-places"; +import { describe, expect, it } from "vitest"; +import { + featureToSearchPlace, + type SearchGeoJsonFeature, +} from "../../src/jobs/search-index/extract.js"; +import corpus from "./landmark-corpus.json"; + +function evaluate(feature: SearchGeoJsonFeature) { + const row = featureToSearchPlace(feature)!; + expect(row).not.toBeNull(); + return ambientPlaceFromOsm({ + osm_type: row.osmType, + osm_id: row.osmId, + name: row.name, + lng: row.lng, + lat: row.lat, + category: row.category, + importance: row.importance, + tags: row.tags, + })!; +} +describe("real OSM extractor to ambient policy contract", () => { + it.each(corpus.cases)( + "keeps source-backed $feature.id at its intended zoom", + ({ feature, expectedMinZoom }) => { + if (expectedMinZoom === null) { + // This existing index is alias/code-focused; source presence alone is + // not publication coverage. Keep excluded name-only controls explicit. + expect(featureToSearchPlace(feature)).toBeNull(); + return; + } + const place = evaluate(feature); + expect(place.id).toBe(`osm:${feature.properties["@type"]}/${feature.properties["@id"]}`); + expect(place.minZoom).toBe(expectedMinZoom); + expect(place.name).toBe(feature.properties.name); + }, + ); + it("ranks actual slash categories from the extractor", () => { + for (const [tags, zoom] of [ + [{ amenity: "hospital" }, 13], + [{ shop: "bakery" }, 15], + ] as const) { + const place = evaluate({ + id: "n9007199254740993", + properties: { name: "Destination", short_name: "Alias", ...tags }, + geometry: { type: "Point", coordinates: [6.69, 51.2] }, + }); + expect(place.id).toBe("osm:node/9007199254740993"); + expect(place.minZoom).toBe(zoom); + } + }); + it("keeps an indoor/part tenant deferred even on a designated cultural building", () => { + const cathedral = corpus.cases.find((c) => c.feature.properties.name === "Kölner Dom")!.feature; + for (const tags of [{ indoor: "yes" }, { "building:part": "yes" }]) { + expect( + evaluate({ ...cathedral, properties: { ...cathedral.properties, ...tags } }).minZoom, + ).toBe(18); + } + }); +}); diff --git a/services/data-manager/__tests__/ambient-places/landmark-corpus.json b/services/data-manager/__tests__/ambient-places/landmark-corpus.json new file mode 100644 index 000000000..649448032 --- /dev/null +++ b/services/data-manager/__tests__/ambient-places/landmark-corpus.json @@ -0,0 +1,940 @@ +{ + "license": "ODbL-1.0; OpenStreetMap contributors", + "sourceDate": "2026-10-06", + "sources": [ + "https://download.geofabrik.de/europe/germany/nordrhein-westfalen/duesseldorf-regbez-261006.osm.pbf", + "https://download.geofabrik.de/europe/germany/nordrhein-westfalen/koeln-regbez-261006.osm.pbf" + ], + "cases": [ + { + "expectedMinZoom": 14, + "feature": { + "type": "Feature", + "id": "a57125986", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [6.6927837, 51.1990547], + [6.6928248, 51.1989561], + [6.6928491, 51.1988979], + [6.692866, 51.1988574], + [6.6931345, 51.1989014], + [6.6931455, 51.1988752], + [6.6931362, 51.1988736], + [6.6931433, 51.1988565], + [6.6931702, 51.1988609], + [6.6931683, 51.1988656], + [6.6932885, 51.198885], + [6.6932906, 51.1988801], + [6.6933132, 51.1988837], + [6.6933077, 51.1988971], + [6.6932982, 51.1988956], + [6.6932853, 51.1989272], + [6.6933251, 51.1989336], + [6.6933278, 51.1989269], + [6.6933424, 51.1989292], + [6.6933395, 51.1989362], + [6.6933886, 51.1989441], + [6.6933912, 51.1989378], + [6.6934674, 51.19895], + [6.6934731, 51.1989453], + [6.6935003, 51.1989341], + [6.6935317, 51.1989305], + [6.6935634, 51.198935], + [6.6935898, 51.1989463], + [6.6936071, 51.1989634], + [6.6936124, 51.1989806], + [6.6936189, 51.1989816], + [6.6936121, 51.1989987], + [6.693659, 51.199006], + [6.6936471, 51.199036], + [6.6936599, 51.199038], + [6.6936582, 51.1990424], + [6.6936743, 51.1990487], + [6.6936926, 51.1990654], + [6.6936993, 51.1990851], + [6.6936933, 51.1991049], + [6.6936762, 51.1991216], + [6.6936496, 51.1991335], + [6.6936201, 51.1991374], + [6.6936184, 51.1991417], + [6.6936066, 51.1991398], + [6.6935942, 51.1991708], + [6.6935475, 51.1991635], + [6.6935413, 51.1991792], + [6.6935318, 51.1991777], + [6.693524, 51.1991853], + [6.6934974, 51.1991968], + [6.6934664, 51.1992011], + [6.6934344, 51.1991972], + [6.6934074, 51.1991865], + [6.6933891, 51.1991699], + [6.693386, 51.1991619], + [6.6932945, 51.1991471], + [6.693297, 51.199141], + [6.6932562, 51.1991345], + [6.6932534, 51.1991413], + [6.6932358, 51.1991385], + [6.6932389, 51.1991308], + [6.693202, 51.1991249], + [6.6931886, 51.1991578], + [6.6931971, 51.1991592], + [6.6931916, 51.1991726], + [6.6931833, 51.1991713], + [6.693171, 51.1991696], + [6.693173, 51.1991648], + [6.6930458, 51.1991442], + [6.693044, 51.1991485], + [6.6930274, 51.1991458], + [6.6930318, 51.1991353], + [6.693041, 51.1991368], + [6.6930566, 51.1990994], + [6.6930013, 51.1990918], + [6.6929773, 51.1990877], + [6.6927837, 51.1990547] + ] + ] + ] + }, + "properties": { + "@type": "way", + "@id": "28562993", + "amenity": "place_of_worship", + "basilica": "minor", + "building": "church", + "heritage": "8", + "heritage:operator": "UntereDenkmalbehörde", + "historic": "church", + "name": "Quirinus-Münster", + "tourism": "attraction", + "wikidata": "Q896410", + "wikipedia": "de:Quirinus-Münster (Neuss)" + } + } + }, + { + "expectedMinZoom": 14, + "feature": { + "type": "Feature", + "id": "a9064044", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [6.9570708, 50.9415218], + [6.9570717, 50.9415089], + [6.9570835, 50.9415092], + [6.9570843, 50.9414983], + [6.9570728, 50.941498], + [6.9570736, 50.9414861], + [6.9570902, 50.9414865], + [6.9570899, 50.9414917], + [6.9571075, 50.9414921], + [6.957122, 50.9414925], + [6.9571247, 50.941431], + [6.9571123, 50.9414308], + [6.9571133, 50.9414179], + [6.9571265, 50.9414182], + [6.9571296, 50.9413686], + [6.9571056, 50.9413679], + [6.9571052, 50.9413737], + [6.9570887, 50.9413732], + [6.9570894, 50.9413618], + [6.9570985, 50.9413621], + [6.9570993, 50.9413494], + [6.9570906, 50.9413491], + [6.9570912, 50.9413413], + [6.957111, 50.9413417], + [6.9571107, 50.9413472], + [6.9571233, 50.9413475], + [6.9571275, 50.9412913], + [6.9571316, 50.9412343], + [6.9571195, 50.941234], + [6.9571191, 50.9412404], + [6.9571026, 50.94124], + [6.9571033, 50.9412291], + [6.9571123, 50.9412293], + [6.957113, 50.9412187], + [6.9571044, 50.9412185], + [6.9571053, 50.9412052], + [6.9571219, 50.9412057], + [6.9571216, 50.9412111], + [6.9571461, 50.9412118], + [6.9571502, 50.9411738], + [6.9571518, 50.9411597], + [6.9571373, 50.9411592], + [6.957138, 50.9411474], + [6.9571531, 50.9411477], + [6.9571571, 50.9411104], + [6.9571574, 50.941108], + [6.9571593, 50.9410906], + [6.9571316, 50.9410899], + [6.9571314, 50.9410942], + [6.9571148, 50.9410938], + [6.9571155, 50.9410844], + [6.9571246, 50.9410846], + [6.9571256, 50.9410704], + [6.9571168, 50.9410702], + [6.9571175, 50.9410583], + [6.9571342, 50.9410587], + [6.9571339, 50.9410632], + [6.957148, 50.9410634], + [6.9571475, 50.9410516], + [6.9571574, 50.9410517], + [6.957158, 50.9410407], + [6.9571475, 50.9410402], + [6.9571484, 50.9410305], + [6.9571542, 50.9410307], + [6.9571543, 50.9410278], + [6.9571588, 50.9410279], + [6.957159, 50.9410241], + [6.9571724, 50.9410243], + [6.9571724, 50.941027], + [6.9571702, 50.941027], + [6.9571703, 50.9410289], + [6.9571907, 50.9410289], + [6.9571908, 50.9410243], + [6.9572049, 50.9410243], + [6.9572044, 50.9410289], + [6.9572087, 50.9410286], + [6.9572085, 50.9410322], + [6.9572144, 50.9410321], + [6.9572141, 50.9410366], + [6.9572353, 50.9410365], + [6.9572349, 50.9410319], + [6.9572462, 50.9410322], + [6.9572463, 50.9410364], + [6.9572499, 50.9410364], + [6.9572498, 50.9410397], + [6.9572541, 50.9410397], + [6.9572537, 50.9410474], + [6.9572488, 50.9410474], + [6.9572483, 50.9410575], + [6.9572863, 50.9410587], + [6.9572862, 50.9410533], + [6.9572929, 50.9410532], + [6.9572931, 50.9410507], + [6.9572973, 50.9410507], + [6.9572975, 50.9410473], + [6.957305, 50.9410473], + [6.9573048, 50.9410504], + [6.9573103, 50.9410506], + [6.9573102, 50.9410542], + [6.9573154, 50.9410541], + [6.9573155, 50.9410587], + [6.9573467, 50.9410595], + [6.9573566, 50.9410596], + [6.9573922, 50.9410602], + [6.9573923, 50.9410576], + [6.9573972, 50.9410578], + [6.9573975, 50.941045], + [6.9573918, 50.941045], + [6.9573921, 50.9410362], + [6.9573976, 50.9410365], + [6.9573978, 50.9410331], + [6.9574016, 50.941033], + [6.9574014, 50.9410285], + [6.9574129, 50.9410286], + [6.9574132, 50.9410333], + [6.957434, 50.9410335], + [6.9574344, 50.9410289], + [6.9574452, 50.9410287], + [6.9574449, 50.9410337], + [6.9574497, 50.9410337], + [6.9574499, 50.9410364], + [6.9574565, 50.9410364], + [6.9574563, 50.9410448], + [6.9574497, 50.9410448], + [6.9574494, 50.9410584], + [6.9574547, 50.9410584], + [6.9574546, 50.9410636], + [6.9574652, 50.9410637], + [6.957465, 50.94107], + [6.957473, 50.9410701], + [6.9574729, 50.9410753], + [6.9574928, 50.9410755], + [6.957493, 50.9410695], + [6.9575075, 50.9410695], + [6.9575075, 50.941075], + [6.9575141, 50.9410775], + [6.957514, 50.9410846], + [6.9575101, 50.9410846], + [6.9575099, 50.9410916], + [6.9575463, 50.9410921], + [6.9575481, 50.9410605], + [6.9575795, 50.9410608], + [6.9575783, 50.9410923], + [6.9576574, 50.9410944], + [6.9576596, 50.9410628], + [6.9576739, 50.9410629], + [6.9576912, 50.9410631], + [6.9576895, 50.9410952], + [6.9577651, 50.941097], + [6.9577672, 50.9410638], + [6.9577986, 50.941064], + [6.9577971, 50.9410977], + [6.9578751, 50.9410993], + [6.9578771, 50.9410661], + [6.9579089, 50.9410664], + [6.9579065, 50.9410979], + [6.9579617, 50.9410998], + [6.9579619, 50.9410924], + [6.9579736, 50.9410847], + [6.9579837, 50.9410849], + [6.9579831, 50.941054], + [6.9579375, 50.9410534], + [6.9579386, 50.9410274], + [6.9579839, 50.9410281], + [6.9579859, 50.9409749], + [6.9579405, 50.9409731], + [6.9579407, 50.9409488], + [6.9579863, 50.9409491], + [6.9579878, 50.9409217], + [6.9580235, 50.9409217], + [6.9580226, 50.9409345], + [6.9580296, 50.9409394], + [6.9580293, 50.9409499], + [6.9580479, 50.9409589], + [6.9580481, 50.9409626], + [6.9580719, 50.9409629], + [6.9580722, 50.94096], + [6.9580926, 50.940951], + [6.9580965, 50.9409171], + [6.9581387, 50.9409185], + [6.9581386, 50.9409259], + [6.9581568, 50.9409266], + [6.9581566, 50.9409291], + [6.958162, 50.940929], + [6.9581618, 50.9409326], + [6.958157, 50.9409327], + [6.9581571, 50.9409369], + [6.9581821, 50.9409516], + [6.9581917, 50.9409573], + [6.9581916, 50.9409636], + [6.9582156, 50.9409639], + [6.9582164, 50.9409563], + [6.9582233, 50.9409563], + [6.9582292, 50.9409564], + [6.9582299, 50.9409642], + [6.9582524, 50.9409645], + [6.9582525, 50.9409584], + [6.9582634, 50.9409522], + [6.9582853, 50.9409397], + [6.9582853, 50.9409368], + [6.9582816, 50.9409367], + [6.9582819, 50.9409319], + [6.958286, 50.9409319], + [6.9582861, 50.9409282], + [6.9582958, 50.9409281], + [6.9582957, 50.9409293], + [6.9583061, 50.9409293], + [6.9583062, 50.94092], + [6.9583477, 50.940921], + [6.9583465, 50.9409386], + [6.9583524, 50.9409413], + [6.9583524, 50.9409528], + [6.9583698, 50.9409619], + [6.9583697, 50.9409655], + [6.9583924, 50.9409657], + [6.9583924, 50.9409629], + [6.9584113, 50.9409538], + [6.9584115, 50.9409421], + [6.9584177, 50.9409377], + [6.9584176, 50.9409222], + [6.9584392, 50.9409232], + [6.9584599, 50.9409233], + [6.9584594, 50.9409399], + [6.958458, 50.9409519], + [6.9584967, 50.940952], + [6.958495, 50.9409767], + [6.9584786, 50.9409757], + [6.9584763, 50.9409812], + [6.9584644, 50.9409897], + [6.9584554, 50.94099], + [6.9584545, 50.9410405], + [6.9584954, 50.9410408], + [6.9584948, 50.9410603], + [6.9584545, 50.9410602], + [6.9584544, 50.9410867], + [6.9584656, 50.9410868], + [6.9584743, 50.9410929], + [6.9584745, 50.9411021], + [6.958519, 50.9411022], + [6.9585204, 50.9410742], + [6.9585517, 50.941075], + [6.9585508, 50.9411029], + [6.9586269, 50.9411043], + [6.9586285, 50.9410755], + [6.9586594, 50.9410761], + [6.9586584, 50.9411048], + [6.9587314, 50.9411059], + [6.958733, 50.9410767], + [6.9587646, 50.9410773], + [6.9587634, 50.9411037], + [6.9588061, 50.9411034], + [6.9588054, 50.9411145], + [6.9588359, 50.9411154], + [6.9588439, 50.9411032], + [6.9588644, 50.9411091], + [6.9588552, 50.9411217], + [6.9588759, 50.9411347], + [6.9588803, 50.9411266], + [6.9589172, 50.9411334], + [6.9589145, 50.9411433], + [6.9589401, 50.9411357], + [6.9589448, 50.9411236], + [6.9589677, 50.9411263], + [6.9589637, 50.9411409], + [6.9589974, 50.9411554], + [6.9590146, 50.9411468], + [6.959028, 50.9411571], + [6.9590106, 50.941167], + [6.959016, 50.9411834], + [6.9590262, 50.9411788], + [6.9590477, 50.9411935], + [6.9590357, 50.9411999], + [6.9590664, 50.9412023], + [6.9590784, 50.9411935], + [6.9590961, 50.9412035], + [6.9590828, 50.9412114], + [6.9591016, 50.9412336], + [6.9591231, 50.9412324], + [6.9591233, 50.9412466], + [6.9591029, 50.9412479], + [6.9590952, 50.9412656], + [6.9591097, 50.9412637], + [6.959117, 50.9412833], + [6.9591049, 50.9412858], + [6.9591222, 50.9412995], + [6.9591442, 50.941294], + [6.9591516, 50.9413075], + [6.9591302, 50.941312], + [6.9591286, 50.9413377], + [6.9591497, 50.9413436], + [6.9591428, 50.941355], + [6.9591228, 50.9413498], + [6.9591037, 50.9413595], + [6.9591171, 50.941362], + [6.9591046, 50.9413856], + [6.9590931, 50.9413833], + [6.9590993, 50.9414012], + [6.9591182, 50.9414025], + [6.959115, 50.9414167], + [6.9590945, 50.9414151], + [6.9590737, 50.9414357], + [6.9590871, 50.9414464], + [6.9590707, 50.9414556], + [6.9590567, 50.9414453], + [6.9590244, 50.9414505], + [6.9590336, 50.9414562], + [6.9590123, 50.9414702], + [6.9590022, 50.9414647], + [6.9589946, 50.9414816], + [6.9590092, 50.9414901], + [6.9589942, 50.9415015], + [6.9589801, 50.9414932], + [6.9589463, 50.9415055], + [6.9589459, 50.9415182], + [6.9589234, 50.9415182], + [6.958924, 50.9415074], + [6.958895, 50.9415012], + [6.9588989, 50.9415086], + [6.9588642, 50.9415151], + [6.9588609, 50.941508], + [6.9588396, 50.94152], + [6.9588472, 50.9415315], + [6.9588253, 50.9415378], + [6.9588165, 50.9415259], + [6.9587865, 50.9415252], + [6.9587861, 50.9415363], + [6.9587562, 50.9415356], + [6.9587562, 50.9415389], + [6.9587547, 50.9415648], + [6.958744, 50.9415646], + [6.9587124, 50.9415642], + [6.9587132, 50.9415325], + [6.9586384, 50.9415312], + [6.9586365, 50.9415632], + [6.958605, 50.9415629], + [6.9586061, 50.9415305], + [6.9585321, 50.9415289], + [6.9585304, 50.9415625], + [6.9584996, 50.9415621], + [6.9585007, 50.9415338], + [6.9584576, 50.9415333], + [6.9584574, 50.9415407], + [6.9584469, 50.9415474], + [6.9584357, 50.9415473], + [6.9584349, 50.9415755], + [6.9584749, 50.9415762], + [6.9584755, 50.9416028], + [6.9584352, 50.9416027], + [6.9584355, 50.9416367], + [6.9584485, 50.9416369], + [6.958464, 50.9416471], + [6.9584641, 50.9416503], + [6.9584731, 50.9416505], + [6.9584729, 50.9416608], + [6.9584728, 50.9416671], + [6.9584721, 50.9416761], + [6.9584466, 50.941676], + [6.9584483, 50.9416886], + [6.9584454, 50.9416903], + [6.9584491, 50.9416929], + [6.9584431, 50.9416969], + [6.9584393, 50.9416946], + [6.958434, 50.9416944], + [6.9584342, 50.9416925], + [6.9584067, 50.9416921], + [6.9584068, 50.9416939], + [6.958401, 50.941694], + [6.9583988, 50.9416961], + [6.9583917, 50.9416927], + [6.9583943, 50.9416906], + [6.9583883, 50.941691], + [6.9583853, 50.9416765], + [6.9583676, 50.9416667], + [6.9583679, 50.9416597], + [6.9583561, 50.9416599], + [6.958343, 50.9416598], + [6.958343, 50.9416665], + [6.958324, 50.9416764], + [6.9583236, 50.9416874], + [6.9583189, 50.9416904], + [6.9583147, 50.9416904], + [6.9583178, 50.9416928], + [6.9583088, 50.9416955], + [6.9583071, 50.9416934], + [6.9583011, 50.9416935], + [6.9583012, 50.9416921], + [6.9582761, 50.9416923], + [6.9582764, 50.9416948], + [6.9582663, 50.9416954], + [6.9582596, 50.9416918], + [6.9582623, 50.9416899], + [6.9582506, 50.9416849], + [6.9582509, 50.9416807], + [6.9582243, 50.9416651], + [6.9582244, 50.9416585], + [6.9581955, 50.9416582], + [6.9581955, 50.9416631], + [6.9581899, 50.9416667], + [6.9581848, 50.9416667], + [6.9581804, 50.9416625], + [6.9581806, 50.9416582], + [6.9581725, 50.9416581], + [6.9581566, 50.9416579], + [6.9581562, 50.9416649], + [6.9581299, 50.9416793], + [6.9581307, 50.9416835], + [6.9581174, 50.9416865], + [6.958121, 50.9416888], + [6.9581147, 50.9416924], + [6.9581062, 50.9416923], + [6.9581063, 50.9416889], + [6.95808, 50.9416885], + [6.9580798, 50.9416919], + [6.9580684, 50.9416914], + [6.9580644, 50.9416881], + [6.958068, 50.9416852], + [6.9580596, 50.9416853], + [6.9580581, 50.9416716], + [6.9580396, 50.9416617], + [6.9580398, 50.9416554], + [6.9580273, 50.9416553], + [6.9580148, 50.9416552], + [6.958015, 50.9416609], + [6.9579967, 50.9416704], + [6.9579935, 50.9416846], + [6.9579867, 50.9416839], + [6.9579893, 50.9416862], + [6.9579825, 50.9416899], + [6.9579798, 50.9416882], + [6.9579748, 50.941688], + [6.957975, 50.9416863], + [6.9579484, 50.9416861], + [6.9579481, 50.9416887], + [6.9579401, 50.941689], + [6.9579348, 50.9416833], + [6.9579385, 50.941682], + [6.9579389, 50.9416688], + [6.9579005, 50.9416683], + [6.9579011, 50.9416525], + [6.9579014, 50.9416427], + [6.9579208, 50.941643], + [6.9579208, 50.9416397], + [6.9579364, 50.9416308], + [6.9579524, 50.9416314], + [6.9579532, 50.9415962], + [6.957904, 50.9415961], + [6.9579039, 50.9415689], + [6.9579553, 50.9415687], + [6.957956, 50.941543], + [6.9579443, 50.941543], + [6.9579354, 50.9415338], + [6.9579353, 50.9415266], + [6.9578809, 50.9415265], + [6.957879, 50.9415571], + [6.9578479, 50.9415565], + [6.9578491, 50.9415253], + [6.9577719, 50.9415237], + [6.9577704, 50.9415538], + [6.9577389, 50.9415538], + [6.9577402, 50.9415231], + [6.9576647, 50.9415212], + [6.957662, 50.9415528], + [6.9576305, 50.9415525], + [6.9576321, 50.9415207], + [6.9575546, 50.9415182], + [6.9575522, 50.9415515], + [6.9575214, 50.9415511], + [6.957522, 50.9415194], + [6.9574828, 50.9415187], + [6.9574845, 50.941526], + [6.95749, 50.941526], + [6.9574896, 50.9415332], + [6.9574847, 50.9415329], + [6.9574846, 50.9415349], + [6.9574806, 50.9415351], + [6.9574806, 50.9415382], + [6.9574682, 50.9415386], + [6.9574687, 50.9415345], + [6.9574487, 50.9415349], + [6.9574484, 50.9415387], + [6.9574403, 50.9415384], + [6.9574404, 50.9415455], + [6.9574297, 50.9415455], + [6.9574289, 50.9415492], + [6.9574238, 50.9415491], + [6.9574232, 50.9415627], + [6.9574287, 50.9415626], + [6.9574282, 50.9415717], + [6.957423, 50.9415714], + [6.9574228, 50.9415741], + [6.9574172, 50.941574], + [6.9574173, 50.9415787], + [6.9574055, 50.941578], + [6.9574059, 50.9415734], + [6.9573843, 50.9415729], + [6.9573839, 50.9415773], + [6.9573727, 50.9415771], + [6.9573733, 50.9415732], + [6.9573678, 50.9415731], + [6.9573683, 50.9415695], + [6.9573623, 50.9415696], + [6.9573621, 50.941563], + [6.9573696, 50.9415634], + [6.9573695, 50.9415496], + [6.9573637, 50.9415496], + [6.9573638, 50.9415284], + [6.9572819, 50.9415278], + [6.9572813, 50.9415336], + [6.9572645, 50.9415331], + [6.9572638, 50.9415274], + [6.9572152, 50.9415267], + [6.9572156, 50.9415423], + [6.957227, 50.9415426], + [6.9572262, 50.941555], + [6.9572079, 50.9415545], + [6.9572083, 50.9415495], + [6.9571843, 50.9415489], + [6.9571836, 50.9415592], + [6.9571728, 50.9415589], + [6.9571732, 50.9415523], + [6.9571543, 50.9415518], + [6.9571539, 50.9415585], + [6.9571358, 50.941558], + [6.9571365, 50.9415479], + [6.957148, 50.9415482], + [6.957149, 50.9415328], + [6.9571368, 50.9415325], + [6.9571372, 50.941527], + [6.9571258, 50.9415266], + [6.9571263, 50.9415182], + [6.9571208, 50.9415181], + [6.9571211, 50.9415131], + [6.9570902, 50.9415123], + [6.9570896, 50.9415223], + [6.9570708, 50.9415218] + ] + ] + ] + }, + "properties": { + "@type": "way", + "@id": "4532022", + "amenity": "place_of_worship", + "building": "cathedral", + "heritage": "1", + "heritage:operator": "whc", + "level": "1", + "name": "Kölner Dom", + "name:en": "Cologne Cathedral", + "tourism": "attraction", + "wikidata": "Q4176", + "wikipedia": "de:Kölner Dom", + "alt_name": "Hohe Domkirche Sankt Petrus" + } + } + }, + { + "expectedMinZoom": null, + "feature": { + "type": "Feature", + "id": "a175116286", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [6.6856916, 51.197592], + [6.685714, 51.1975785], + [6.6857144, 51.1975783], + [6.6857384, 51.1975637], + [6.6857631, 51.1975488], + [6.6858413, 51.1975016], + [6.685881, 51.1974777], + [6.6859231, 51.1974523], + [6.6859372, 51.1974616], + [6.6859556, 51.1974538], + [6.6859751, 51.1974532], + [6.6859923, 51.1974584], + [6.6860016, 51.1974657], + [6.6860059, 51.1974772], + [6.6860011, 51.1974885], + [6.6859906, 51.1974962], + [6.6860027, 51.1975041], + [6.6859802, 51.1975176], + [6.6860647, 51.1975725], + [6.6860765, 51.1975654], + [6.6861368, 51.1976046], + [6.6862013, 51.1976465], + [6.6861618, 51.1976703], + [6.6861934, 51.1976909], + [6.6861778, 51.1977004], + [6.6861952, 51.1977138], + [6.6862101, 51.1977341], + [6.6862068, 51.1977538], + [6.6861935, 51.197773], + [6.6861753, 51.1977839], + [6.686148, 51.1977896], + [6.6861041, 51.1977839], + [6.6860708, 51.197765], + [6.6860609, 51.1977709], + [6.6860287, 51.1977499], + [6.6859913, 51.1977725], + [6.6859275, 51.1977309], + [6.6858647, 51.1976901], + [6.6858807, 51.1976805], + [6.6857902, 51.1976216], + [6.685785, 51.1976247], + [6.6857627, 51.1976382], + [6.6856916, 51.197592] + ] + ] + ] + }, + "properties": { + "@type": "way", + "@id": "87558143", + "amenity": "place_of_worship", + "building": "church", + "name": "Christuskirche", + "wikidata": "Q15873914", + "wikipedia": "de:Liste der Baudenkmäler in Neuss (1/001–1/099)#Evang. Christuskirche" + } + } + }, + { + "expectedMinZoom": null, + "feature": { + "type": "Feature", + "id": "a181721794", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [6.8206185, 51.3094283], + [6.8208487, 51.3093431], + [6.8210234, 51.309514], + [6.8209057, 51.3097107], + [6.8206471, 51.3096513], + [6.8206185, 51.3094283] + ] + ] + ] + }, + "properties": { + "@type": "way", + "@id": "90860897", + "amenity": "place_of_worship", + "building": "church", + "name": "St. Marien" + } + } + }, + { + "expectedMinZoom": 15, + "feature": { + "type": "Feature", + "id": "a174851688", + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [6.6858402, 51.2034434], + [6.685849, 51.2034435], + [6.6858572, 51.2034368], + [6.6858504, 51.2034333], + [6.6858572, 51.203428], + [6.685849, 51.2034238], + [6.6858551, 51.203419], + [6.6858624, 51.2034228], + [6.685865, 51.2034228], + [6.6859081, 51.2033897], + [6.6858983, 51.2033847], + [6.6859179, 51.2033697], + [6.6859131, 51.2033672], + [6.6859162, 51.2033648], + [6.6858881, 51.2033501], + [6.6858977, 51.2033429], + [6.6859235, 51.2033564], + [6.6859505, 51.2033361], + [6.685979, 51.2033146], + [6.6859532, 51.2033011], + [6.6859649, 51.2032923], + [6.6859917, 51.2033062], + [6.6859952, 51.2033035], + [6.6860005, 51.2033062], + [6.6860193, 51.2032918], + [6.6860291, 51.2032968], + [6.6860722, 51.2032639], + [6.6860634, 51.2032594], + [6.6860707, 51.2032538], + [6.6860791, 51.2032582], + [6.6860863, 51.2032526], + [6.6860953, 51.2032572], + [6.6860882, 51.2032626], + [6.6861442, 51.2032914], + [6.6861578, 51.203281], + [6.6861756, 51.2032901], + [6.6861818, 51.2032854], + [6.6863026, 51.2033475], + [6.6863104, 51.2033422], + [6.6863188, 51.2033469], + [6.686312, 51.2033522], + [6.6863636, 51.2033787], + [6.6863708, 51.2033735], + [6.6863805, 51.2033783], + [6.6863728, 51.2033835], + [6.6864198, 51.2034076], + [6.6864295, 51.2034125], + [6.6864541, 51.2033935], + [6.6864443, 51.2033886], + [6.6864363, 51.2033844], + [6.6864438, 51.2033787], + [6.6864606, 51.2033873], + [6.6864629, 51.2033855], + [6.6864655, 51.2033869], + [6.686479, 51.2033765], + [6.6864902, 51.2033823], + [6.6864764, 51.2033928], + [6.6865551, 51.2034332], + [6.6865913, 51.2034518], + [6.6866056, 51.2034409], + [6.6866151, 51.2034457], + [6.6866019, 51.2034558], + [6.6866051, 51.2034575], + [6.6866025, 51.2034595], + [6.68664, 51.2034788], + [6.6867296, 51.2035254], + [6.686708, 51.2035418], + [6.6867534, 51.2035651], + [6.6866692, 51.2036295], + [6.6866696, 51.2036333], + [6.6866464, 51.2036378], + [6.6866481, 51.203664], + [6.6866654, 51.2036679], + [6.6866606, 51.2036755], + [6.6866433, 51.2036719], + [6.6866159, 51.2036923], + [6.686626, 51.2037036], + [6.6866147, 51.2037079], + [6.6866041, 51.2036967], + [6.6865629, 51.2037034], + [6.6865626, 51.2037167], + [6.6865553, 51.2037163], + [6.6864494, 51.2037976], + [6.6863161, 51.2037292], + [6.6862758, 51.2037085], + [6.6862727, 51.2037108], + [6.6862592, 51.2037212], + [6.6862484, 51.2037157], + [6.6862629, 51.2037046], + [6.6861479, 51.2036456], + [6.6861337, 51.2036565], + [6.6861247, 51.2036518], + [6.6861391, 51.2036408], + [6.6861359, 51.2036392], + [6.6861377, 51.2036378], + [6.6861203, 51.2036289], + [6.6861285, 51.2036227], + [6.6861452, 51.2036312], + [6.6861682, 51.2036136], + [6.686112, 51.2035848], + [6.6861045, 51.2035905], + [6.6860957, 51.203586], + [6.6861028, 51.2035806], + [6.6860509, 51.2035539], + [6.6860442, 51.203559], + [6.6860352, 51.2035543], + [6.686042, 51.2035491], + [6.6859905, 51.2035226], + [6.6859832, 51.2035282], + [6.6859739, 51.2035234], + [6.6859764, 51.2035215], + [6.6859242, 51.2034947], + [6.6859216, 51.2034966], + [6.6859123, 51.2034918], + [6.6859255, 51.2034817], + [6.6859103, 51.2034732], + [6.6859086, 51.2034743], + [6.6859081, 51.2034811], + [6.6859003, 51.2034812], + [6.6859001, 51.2034764], + [6.6858787, 51.2034765], + [6.6858789, 51.2034813], + [6.6858715, 51.2034814], + [6.6858714, 51.2034766], + [6.6858549, 51.2034675], + [6.6858405, 51.2034677], + [6.6858404, 51.2034632], + [6.6858498, 51.2034629], + [6.6858498, 51.2034482], + [6.6858404, 51.2034482], + [6.6858402, 51.2034434] + ] + ] + ] + }, + "properties": { + "@type": "way", + "@id": "87425844", + "amenity": "place_of_worship", + "building": "church", + "heritage": "8", + "heritage:operator": "Stadt Neuss", + "historic": "heritage", + "name": "St. Marien", + "wikidata": "Q1481049", + "wikipedia": "de:St. Marien (Neuss)" + } + } + } + ] +} From be3e5852fcfd48cf58a40630753b432618684ade Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 8 Oct 2026 21:37:25 +0200 Subject: [PATCH 10/13] fix(places): preserve policy tags through OSM index publication --- .../developer/ambient-places-publication.md | 8 ++- package.json | 2 +- packages/core/src/ambient-places.test.ts | 14 ++++ packages/core/src/ambient-places.ts | 17 ++++- .../ambient-places/extractor-postgres.test.ts | 72 +++++++++++++++++++ .../src/jobs/search-index/build.ts | 6 +- 6 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 services/data-manager/__tests__/ambient-places/extractor-postgres.test.ts diff --git a/docs/docs/developer/ambient-places-publication.md b/docs/docs/developer/ambient-places-publication.md index ca2d4c1d6..547e16427 100644 --- a/docs/docs/developer/ambient-places-publication.md +++ b/docs/docs/developer/ambient-places-publication.md @@ -7,7 +7,10 @@ description: Bounded regional place generations, canonical identities and the MV `overlay-ambient-places` shows destinations during ordinary map browsing. It consumes one published German region; source ingestion remains in the existing OSM search -index and Overture workflows. The initial operator form targets Aachen. Nothing +index and Overture workflows. The existing OSM extractor requires an alias, code +or generated acronym term: a source-present name-only object can therefore be +absent from this index and layer. This coverage limit is distinct from label +collision or ambient ranking. The initial operator form targets Aachen. Nothing imports planet data, calls AllThePlaces directly, or changes geocoding. ## Operator workflow and public contract @@ -53,6 +56,9 @@ limits; the layer is not a complete business directory. ## Publication, retention and identity A dedicated `ambient_places` schema isolates snapshots from mutable source tables. +The OSM index writer stores tag objects as JSONB; ambient policy also reads older +serialized-object rows, and rejects malformed/non-object policy tags so closure +and private-access checks cannot silently disappear. A repeatable-read transaction holds an advisory writer lock, reads bounded indexed source candidates, applies the shared policy and writes batches of 500 places. Generation and feature insertion, count validation and the active/previous pointer diff --git a/package.json b/package.json index 98c9189ee..8e9cef68b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "check-duplicates": "jscpd --config .jscpd.json --cross-formats js-ts --no-tips apps packages integrations services scripts", "check-infrastructure": "bash scripts/check-infrastructure.sh", "test": "vitest run", - "test:database": "OPENMAPX_RUN_DATABASE_TESTS=1 vitest run --project node --maxWorkers=1 apps/api/src/privacy/*-postgres.test.ts apps/api/src/auth-postgres.test.ts apps/api/src/services/mobileAuthHandoff.test.ts apps/api/src/services/capability-bindings.test.ts apps/api/src/db/__tests__/extension-component-ownership-migration.test.ts services/data-manager/__tests__/offline-package-postgres-accounting.test.ts apps/api/src/db/__tests__/vehicles-parking-postgres.test.ts apps/api/src/db/__tests__/garage-isolation-postgres.test.ts apps/api/src/routes/__tests__/shares-postgres.test.ts apps/api/src/db/__tests__/user-erasure-postgres.test.ts packages/cli/__tests__/backup-restore-postgres.integration.test.ts services/data-manager/__tests__/poi-ingest/e2e-bnetza.test.ts services/data-manager/__tests__/poi-ingest/static-reuse-postgres.test.ts services/data-manager/__tests__/search-index/schema-postgres.test.ts services/data-manager/__tests__/search-index/build-postgres.test.ts services/data-manager/__tests__/notable-places/build-postgres.test.ts services/data-manager/__tests__/overture/schema-postgres.test.ts services/data-manager/__tests__/overture/runtime-postgres.test.ts services/data-manager/__tests__/coverage/poi-evidence-postgres.test.ts services/data-manager/__tests__/ambient-places/publish-postgres.test.ts services/data-manager/__tests__/ambient-places/api-postgres.test.ts", + "test:database": "OPENMAPX_RUN_DATABASE_TESTS=1 vitest run --project node --maxWorkers=1 apps/api/src/privacy/*-postgres.test.ts apps/api/src/auth-postgres.test.ts apps/api/src/services/mobileAuthHandoff.test.ts apps/api/src/services/capability-bindings.test.ts apps/api/src/db/__tests__/extension-component-ownership-migration.test.ts services/data-manager/__tests__/offline-package-postgres-accounting.test.ts apps/api/src/db/__tests__/vehicles-parking-postgres.test.ts apps/api/src/db/__tests__/garage-isolation-postgres.test.ts apps/api/src/routes/__tests__/shares-postgres.test.ts apps/api/src/db/__tests__/user-erasure-postgres.test.ts packages/cli/__tests__/backup-restore-postgres.integration.test.ts services/data-manager/__tests__/poi-ingest/e2e-bnetza.test.ts services/data-manager/__tests__/poi-ingest/static-reuse-postgres.test.ts services/data-manager/__tests__/search-index/schema-postgres.test.ts services/data-manager/__tests__/search-index/build-postgres.test.ts services/data-manager/__tests__/notable-places/build-postgres.test.ts services/data-manager/__tests__/overture/schema-postgres.test.ts services/data-manager/__tests__/overture/runtime-postgres.test.ts services/data-manager/__tests__/coverage/poi-evidence-postgres.test.ts services/data-manager/__tests__/ambient-places/publish-postgres.test.ts services/data-manager/__tests__/ambient-places/api-postgres.test.ts services/data-manager/__tests__/ambient-places/extractor-postgres.test.ts", "test:watch": "vitest", "test:coverage": "OPENMAPX_SKIP_SLOW_CRYPTO=1 vitest run --coverage", "prepare": "husky", diff --git a/packages/core/src/ambient-places.test.ts b/packages/core/src/ambient-places.test.ts index a1228eb65..c694d286a 100644 --- a/packages/core/src/ambient-places.test.ts +++ b/packages/core/src/ambient-places.test.ts @@ -115,6 +115,20 @@ describe("ambient place policy", () => { ); expect(landmark.rank).toBeLessThan(ambientPlaceFromOsm(osm)!.rank); }); + it("reads legacy serialized tags and rejects malformed/non-object policy inputs", () => { + const legacy = { + ...osm, + category: "amenity/place_of_worship", + tags: JSON.stringify({ wikidata: "Q896410", basilica: "minor" }), + }; + expect(ambientPlaceFromOsm(legacy as never)!.minZoom).toBe(14); + expect( + ambientPlaceFromOsm({ ...legacy, tags: JSON.stringify({ access: "private" }) } as never), + ).toBeNull(); + for (const tags of ["not-json", "[]", "null", { access: true }]) { + expect(ambientPlaceFromOsm({ ...osm, tags } as never)).toBeNull(); + } + }); it("ranks useful categories first and defers tenants to zoom 18", () => { expect(ambientPlaceFromOsm(osm)!.minZoom).toBe(13); expect(ambientPlaceFromOsm({ ...osm, category: "shop:bakery" })!.minZoom).toBe(15); diff --git a/packages/core/src/ambient-places.ts b/packages/core/src/ambient-places.ts index 9b325c880..84cc8db95 100644 --- a/packages/core/src/ambient-places.ts +++ b/packages/core/src/ambient-places.ts @@ -187,7 +187,22 @@ function policy( }; } export function ambientPlaceFromOsm(row: AmbientOsmRow): AmbientPlace | null { - const tags = row.tags ?? {}; + let rawTags: unknown = row.tags ?? {}; + if (typeof rawTags === "string") { + try { + rawTags = JSON.parse(rawTags); + } catch { + return null; + } + } + if ( + !rawTags || + typeof rawTags !== "object" || + Array.isArray(rawTags) || + !Object.values(rawTags).every((value) => typeof value === "string") + ) + return null; + const tags = rawTags as Record; const name = label(row.name); if ( !name || diff --git a/services/data-manager/__tests__/ambient-places/extractor-postgres.test.ts b/services/data-manager/__tests__/ambient-places/extractor-postgres.test.ts new file mode 100644 index 000000000..66597f382 --- /dev/null +++ b/services/data-manager/__tests__/ambient-places/extractor-postgres.test.ts @@ -0,0 +1,72 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { buildAmbientPlaces } from "../../src/jobs/ambient-places/build.js"; +import { buildOsmSearchIndex } from "../../src/jobs/search-index/build.js"; +import { featureToSearchPlace } from "../../src/jobs/search-index/extract.js"; +import { StateStore } from "../../src/state.js"; +import { startPostgis } from "../poi-ingest/_testcontainer.js"; +import corpus from "./landmark-corpus.json"; + +describe.skipIf(process.env.OPENMAPX_RUN_DATABASE_TESTS !== "1")( + "OSM extraction/index/publication boundary", + () => { + it("stores object tags and publishes source-backed landmarks while excluding access/closure", async () => { + const pg = await startPostgis(); + const dir = mkdtempSync(join(tmpdir(), "ambient-index-")); + try { + const path = join(dir, "fixture.pbf"); + writeFileSync(path, "fixture"); + const store = new StateStore(dir); + store.upsert({ + type: "osm-pbf", + id: "europe/germany", + region: "europe/germany", + path, + sizeBytes: 7, + downloadedAt: new Date().toISOString(), + }); + const base = featureToSearchPlace(corpus.cases[0].feature)!; + const records = [ + base, + { ...base, osmId: "990001", tags: { ...base.tags, access: "private" } }, + { ...base, osmId: "990002", tags: { ...base.tags, disused: "yes" } }, + ]; + await buildOsmSearchIndex({ + region: "europe/germany", + dataDir: dir, + store, + sql: pg.sql, + runtimeState: { building: false, failure: null }, + dependencies: { + extract: async ({ onBatch }) => { + await onBatch(records); + return { emitted: 3, extracted: 3 }; + }, + }, + }); + const rows = await pg.sql.unsafe( + `SELECT jsonb_typeof(tags) AS kind,tags->>'basilica' AS basilica FROM osm_search.places`, + ); + expect(rows.every((r) => r.kind === "object")).toBe(true); + expect(rows[0].basilica).toBe("minor"); + const manifest = await buildAmbientPlaces(pg.sql, { + name: "Neuss", + bounds: [6.58, 50.89, 7.07, 51.31], + }); + expect(manifest.placeCount).toBe(1); + expect(manifest.policyVersion).toBe(2); + expect( + await pg.sql.unsafe( + `SELECT id,min_zoom FROM ambient_places.features WHERE generation=$1`, + [manifest.generation], + ), + ).toMatchObject([{ id: "osm:way/28562993", min_zoom: 14 }]); + } finally { + await pg.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }); + }, +); diff --git a/services/data-manager/src/jobs/search-index/build.ts b/services/data-manager/src/jobs/search-index/build.ts index 87edeb90e..2f33957fa 100644 --- a/services/data-manager/src/jobs/search-index/build.ts +++ b/services/data-manager/src/jobs/search-index/build.ts @@ -91,8 +91,10 @@ async function insertSearchPlaces( await sql.unsafe( `INSERT INTO osm_search__staging.places (osm_type, osm_id, name, lat, lng, category, tags, importance) - SELECT * FROM UNNEST($1::TEXT[], $2::BIGINT[], $3::TEXT[], $4::DOUBLE PRECISION[], - $5::DOUBLE PRECISION[], $6::TEXT[], $7::JSONB[], $8::DOUBLE PRECISION[]) + SELECT r.osm_type,r.osm_id,r.name,r.lat,r.lng,r.category,r.tags::JSONB,r.importance + FROM UNNEST($1::TEXT[], $2::BIGINT[], $3::TEXT[], $4::DOUBLE PRECISION[], + $5::DOUBLE PRECISION[], $6::TEXT[], $7::TEXT[], $8::DOUBLE PRECISION[]) + AS r(osm_type,osm_id,name,lat,lng,category,tags,importance) ON CONFLICT (osm_type, osm_id) DO UPDATE SET name = EXCLUDED.name, lat = EXCLUDED.lat, lng = EXCLUDED.lng, category = EXCLUDED.category, tags = EXCLUDED.tags, importance = EXCLUDED.importance`, From 769270286129de5ea229dab4d0c09b18c7e49670 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 8 Oct 2026 21:43:29 +0200 Subject: [PATCH 11/13] fix(web): retain ambient suppression across theme changes --- .../overlay-ambient-places/map-layer.test.tsx | 1 + .../overlay-ambient-places/map-layer.tsx | 13 ++- .../theme-suppression.test.tsx | 108 ++++++++++++++++++ 3 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 integrations/overlay-ambient-places/theme-suppression.test.tsx diff --git a/integrations/overlay-ambient-places/map-layer.test.tsx b/integrations/overlay-ambient-places/map-layer.test.tsx index 380d40cb2..07198789c 100644 --- a/integrations/overlay-ambient-places/map-layer.test.tsx +++ b/integrations/overlay-ambient-places/map-layer.test.tsx @@ -12,6 +12,7 @@ const test = vi.hoisted(() => ({ querySourceFeatures: vi.fn((): MapGeoJSONFeature[] => []), queryRenderedFeatures: vi.fn((): MapGeoJSONFeature[] => []), getStyle: () => ({ layers: [] }), + getFilter: vi.fn(() => undefined), setFilter: vi.fn(), on: vi.fn<(event: string, handler: (event?: unknown) => void) => void>(), off: vi.fn<(event: string, handler: (event?: unknown) => void) => void>(), diff --git a/integrations/overlay-ambient-places/map-layer.tsx b/integrations/overlay-ambient-places/map-layer.tsx index 77679fa89..800b56d86 100644 --- a/integrations/overlay-ambient-places/map-layer.tsx +++ b/integrations/overlay-ambient-places/map-layer.tsx @@ -174,10 +174,10 @@ export function AmbientPlacesLayer() { }, [visible, manifest, env.apiUrl, locale, dark]); useMapLayerGroup(group); + // biome-ignore lint/correctness/useExhaustiveDependencies: dark recreates label paint through the group hook; reinstall suppression immediately after that lifecycle. useEffect(() => { const map = mapRef.current; if (!visible || !manifest || !map || !mapReady) return; - let lastFilter = ""; const reconcile = () => { if (!map.getSource(AMBIENT_SOURCE)) return; const unique = new Map(); @@ -226,9 +226,11 @@ export function AmbientPlacesLayer() { ["!", ["in", ["get", "id"], ["literal", [...hidden].sort()]]], ]; const key = JSON.stringify(filter); - if (key === lastFilter) return; - lastFilter = key; - for (const id of LAYERS) if (map.getLayer(id)) map.setFilter(id, filter); + // Descriptor/theme reconciliation can recreate one layer without a + // style.load. Compare each live layer so its suppression is restored. + for (const id of LAYERS) + if (map.getLayer(id) && JSON.stringify(map.getFilter(id)) !== key) + map.setFilter(id, filter); }; const click = (event: MapMouseEvent) => { if (getMapClickOwner(event)) return; @@ -247,7 +249,6 @@ export function AmbientPlacesLayer() { } else sidebar.openDetail(PANEL.PLACE_CARD); }; const style = () => { - lastFilter = ""; clearAmbientIdentities(map); }; for (const id of LAYERS) INTERACTIVE_LAYER_IDS.add(id); @@ -264,6 +265,6 @@ export function AmbientPlacesLayer() { clearAmbientIdentities(map); for (const id of LAYERS) INTERACTIVE_LAYER_IDS.delete(id); }; - }, [mapRef, mapReady, visible, manifest, locale, occupied]); + }, [mapRef, mapReady, visible, manifest, locale, occupied, dark]); return null; } diff --git a/integrations/overlay-ambient-places/theme-suppression.test.tsx b/integrations/overlay-ambient-places/theme-suppression.test.tsx new file mode 100644 index 000000000..2a3c1772c --- /dev/null +++ b/integrations/overlay-ambient-places/theme-suppression.test.tsx @@ -0,0 +1,108 @@ +import { createTheme, ThemeProvider } from "@mui/material/styles"; +import { categoryPlaceToPlace, usePlaceStore } from "@openmapx/core"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; +import type { MapGeoJSONFeature } from "maplibre-gl"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createFakeMap } from "@/test"; + +const fake = createFakeMap({ styleLoaded: true, zoom: 16 }); +const mapRef = { current: fake.map }; +vi.mock("@/integration-api/map/MapContext", () => ({ + useMap: () => ({ mapRef, mapReady: true, styleVersion: 0 }), +})); +vi.mock("@/integration-api/runtime/EnvProvider", () => ({ + useEnv: () => ({ apiUrl: "http://fixture" }), +})); +vi.mock("@/integration-api/overlay/useIntegrationAttribution", () => ({ + useIntegrationAttribution: vi.fn(), +})); +vi.mock("@/lib/useExploreReachResults", () => { + const result = { filtered: [] }; + return { useExploreReachResults: () => result }; +}); +vi.mock("next-intl", () => ({ useLocale: () => "en" })); + +import { + AMBIENT_LABEL_LAYER, + AMBIENT_POINT_LAYER, + AMBIENT_SOURCE, + AmbientPlacesLayer, +} from "./map-layer"; +import { useAmbientPlacesStore } from "./store"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + usePlaceStore.setState({ selectedPlace: null }); +}); +describe("ambient suppression with the real group hook", () => { + it("restores filters after paint-only theme recreation with no subsequent style load", async () => { + const manifest = { + version: 1, + policyVersion: 2, + generation: "11111111-1111-4111-8111-111111111111", + publishedAt: new Date().toISOString(), + enabled: true, + placeCount: 1, + region: { name: "Neuss", bounds: [6.58, 50.89, 7.07, 51.31] }, + sources: { + osm: { region: "Germany", epoch: "one", publishedAt: new Date().toISOString(), count: 1 }, + overture: null, + }, + }; + const place = { + id: "osm:way/28562993", + name: "Quirinus-Münster", + coordinates: [6.6932, 51.199] as [number, number], + category: "place_of_worship", + }; + const feature = { + type: "Feature", + geometry: { type: "Point", coordinates: place.coordinates }, + properties: { ...place, rank: 2500, min_zoom: 14, tenant: false, sources: "osm" }, + } as MapGeoJSONFeature; + vi.spyOn(fake.map, "querySourceFeatures").mockReturnValue([feature]); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => ({ manifest }) })), + ); + useAmbientPlacesStore.setState({ panelOpen: true, layerVisible: true, manifest: null }); + usePlaceStore.setState({ selectedPlace: categoryPlaceToPlace(place) }); + const view = render( + + + , + ); + await waitFor(() => + expect(JSON.stringify(fake.map.getFilter(AMBIENT_LABEL_LAYER))).toContain(place.id), + ); + const added = fake.state.counts.addLayer.get(AMBIENT_LABEL_LAYER)!; + view.rerender( + + + , + ); + expect(fake.state.counts.addLayer.get(AMBIENT_LABEL_LAYER)).toBeGreaterThan(added); + act(() => fake.emit("idle")); + for (const layer of [AMBIENT_POINT_LAYER, AMBIENT_LABEL_LAYER]) + expect(JSON.stringify(fake.map.getFilter(layer))).toContain(place.id); + const writes = fake.state.counts.setFilter.get(AMBIENT_LABEL_LAYER); + act(() => { + fake.emit("idle"); + fake.emit("idle"); + }); + expect(fake.state.counts.setFilter.get(AMBIENT_LABEL_LAYER)).toBe(writes); + fake.map.removeLayer(AMBIENT_LABEL_LAYER); + fake.map.addLayer({ + id: AMBIENT_LABEL_LAYER, + type: "symbol", + source: AMBIENT_SOURCE, + "source-layer": "ambient_places", + }); + act(() => fake.emit("idle")); + expect(JSON.stringify(fake.map.getFilter(AMBIENT_LABEL_LAYER))).toContain(place.id); + view.unmount(); + expect(fake.map.getLayer(AMBIENT_LABEL_LAYER)).toBeUndefined(); + }); +}); From 7a0d0e0ba8849c796487d294632a16dbe851e9e8 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 8 Oct 2026 23:18:12 +0200 Subject: [PATCH 12/13] fix(places): complete real regional acceptance --- .../developer/ambient-places-acceptance.json | 1362 +++++++++++++++++ .../developer/ambient-places-publication.md | 134 +- .../docs/developer/map-comparison-baseline.md | 23 + .../specs/2026-10-07-ambient-places-design.md | 2 +- .../overlay-ambient-places/map-layer.test.tsx | 121 +- .../overlay-ambient-places/map-layer.tsx | 48 +- .../theme-suppression.test.tsx | 2 +- packages/core/src/ambient-places.test.ts | 33 + packages/core/src/ambient-places.ts | 4 +- .../ambient-places/extractor-postgres.test.ts | 2 +- 10 files changed, 1717 insertions(+), 14 deletions(-) create mode 100644 docs/docs/developer/ambient-places-acceptance.json diff --git a/docs/docs/developer/ambient-places-acceptance.json b/docs/docs/developer/ambient-places-acceptance.json new file mode 100644 index 000000000..0f5caa4c6 --- /dev/null +++ b/docs/docs/developer/ambient-places-acceptance.json @@ -0,0 +1,1362 @@ +{ + "version": 1, + "date": "2026-10-08", + "scope": "One real OSM-only Rhine region in disposable PostGIS; optional Overture validated with regression fixtures", + "workingTreeFollowup": "Landmark-only collision-safe anchors, actual-source credits, conservative worship identity matching, and categorized destination admission; codeFiles SHA-256 identifies the tested source.", + "sources": [ + { + "file": "duesseldorf.osm.pbf", + "bytes": 216424732, + "sha256": "125de47413c02befac0ca494985502427cd411b6876d2a3685dc7570cc38fb03", + "url": "https://download.geofabrik.de/europe/germany/nordrhein-westfalen/duesseldorf-regbez-261006.osm.pbf" + }, + { + "file": "koeln.osm.pbf", + "bytes": 224744183, + "sha256": "1549bed48c33cd2bb8d8c61c0c2c2a2c86f12170295e70d31d555377a33cc6b3", + "url": "https://download.geofabrik.de/europe/germany/nordrhein-westfalen/koeln-regbez-261006.osm.pbf" + }, + { + "file": "rhine.osm.pbf", + "bytes": 93809739, + "sha256": "c18cc77ed3493aa186bd7659fee4a9b028fbc479c2a8a90f219a3b2d33824d75" + } + ], + "latestSourceObjectTimestamp": "2026-10-06T20:09:52Z", + "publication": { + "index": { + "region": "europe/germany", + "epoch": "9262e2d9-8492-4d4a-b0ce-76bbeb104ef2", + "sourceFingerprint": "sha256:c18cc77ed3493aa186bd7659fee4a9b028fbc479c2a8a90f219a3b2d33824d75", + "placeCount": 14232, + "termCount": 16052 + }, + "indexedMs": 12792.523167, + "publishedMs": 490.0675420000007, + "manifest": { + "version": 1, + "policyVersion": 2, + "generation": "6fa7e3bb-b1b8-47a4-9b2c-2111859be634", + "publishedAt": "2026-10-08T20:47:15.322Z", + "region": { + "name": "Rhine regional acceptance", + "bounds": [6.58, 50.89, 7.07, 51.31] + }, + "placeCount": 13454, + "enabled": true, + "sources": { + "osm": { + "region": "europe/germany", + "epoch": "9262e2d9-8492-4d4a-b0ce-76bbeb104ef2", + "publishedAt": "2026-10-08T20:47:15.067Z", + "count": 13454 + }, + "overture": null + } + }, + "landmarks": [ + { + "id": "osm:way/28562993", + "name": "Quirinus-Münster", + "category": "place_of_worship", + "rank": 2559, + "min_zoom": 14, + "tenant": false + }, + { + "id": "osm:way/4532022", + "name": "Kölner Dom", + "category": "place_of_worship", + "rank": 2559, + "min_zoom": 14, + "tenant": false + } + ] + }, + "assets": { + "assets": { + "openmapx-streets.json": { + "sha256": "4103b56380c9f700cdc320f48fbb9fb7b716fa5006339af80818c9761971826d", + "bytes": 243266 + }, + "openmapx-dark.json": { + "sha256": "f24217e849e4256c7ced9749b1cdc0ed6feb0bd3d0e0a249ef43e66bff8acf22", + "bytes": 243351 + }, + "sprite.json": { + "sha256": "16c7ade94a4d116a1156ae3aa4a759ae82376416eade74c23b5e0bcabe28e439", + "bytes": 26378 + }, + "sprite.png": { + "sha256": "6a1a1c103f109f8e9fb86bd0b3599fa5ce870beb6dddcbd529ff8dfea362736a", + "bytes": 59510 + } + }, + "basemapMetadata": { + "tiles": ["https://openmapx.com/tiles/data/openmapx/{z}/{x}/{y}.pbf"], + "name": "OpenMapTiles", + "format": "pbf", + "basename": "tiles.mbtiles", + "id": "tiles", + "description": "A tileset showcasing all layers in OpenMapTiles. https://openmaptiles.org", + "attribution": "© OpenMapTiles © OpenStreetMap contributors", + "version": "3.16.0", + "type": "baselayer", + "bounds": [5.86442, 47.26543, 15.05078, 55.14777], + "center": [10.4576, 51.2066, 5], + "minzoom": 0, + "maxzoom": 14, + "planetiler:buildtime": "2026-03-28T14:40:55.764Z", + "planetiler:githash": "0e5588c4a6e8c29a270a33afe8df62027d889604", + "planetiler:osm:osmosisreplicationseq": "4927", + "planetiler:osm:osmosisreplicationtime": "2026-10-03T20:20:50Z", + "planetiler:osm:osmosisreplicationurl": "https://download.geofabrik.de/europe/germany-updates", + "planetiler:version": "0.10.2", + "compression": "gzip", + "tilejson": "3.0.0" + } + }, + "tileReads": { + "firstMs": 28.35450000000003, + "warm": { + "count": 30, + "p50Ms": 3.484375, + "p95Ms": 5.2009160000000065, + "samplesMs": [ + 4.577749999999924, 4.379583000000025, 3.8106250000000728, 3.3958330000000387, + 3.014499999999998, 3.187750000000051, 4.374208999999951, 3.149833000000058, + 4.644249999999943, 4.223458000000051, 3.5598340000000235, 4.83887500000003, 3.484375, + 3.6090409999999338, 2.899583000000007, 3.2896670000000086, 2.426584000000048, + 5.2009160000000065, 3.2372910000000275, 3.039957999999956, 3.4329999999999927, + 3.106708000000026, 2.429834000000028, 3.2476669999999785, 3.2955830000000788, + 3.5095839999999043, 3.495584000000008, 3.560416999999916, 5.353041999999959, + 3.7658330000000433 + ] + }, + "concurrentEight": { + "count": 40, + "p95Ms": 39.34204199999999, + "samplesMs": [ + 9.160499999999956, 37.05899999999997, 37.164416000000074, 38.00887499999999, + 39.31516599999998, 39.34204199999999, 39.628165999999965, 46.51333299999999, + 6.298000000000002, 6.31858299999999, 6.337750000000028, 6.44825000000003, 6.43162499999994, + 6.460290999999984, 6.873624999999947, 7.254791000000068, 10.623917000000006, + 10.670124999999985, 10.667291999999975, 10.674167000000011, 10.692166000000043, + 10.711457999999993, 10.729791999999975, 10.75425000000007, 6.520832999999925, + 7.183334000000059, 7.198417000000063, 7.2147919999999885, 7.222540999999978, + 7.268917000000101, 7.253499999999917, 7.400916999999936, 5.920417000000043, + 5.9570830000000115, 6.150999999999954, 6.257124999999974, 6.2649169999999685, + 6.280624999999986, 6.28937499999995, 6.347209000000021 + ] + }, + "maxFeatures": 83, + "maxBytes": 6881, + "duplicateIds": 0, + "immutableAndFallback": { + "identicalAfterPublish": true, + "rollbackRestored": true, + "disabled": true, + "oldAvailableDisabled": true, + "failedStage": true, + "failedKeptActive": true + }, + "raw": [ + { + "z": 13, + "x": 4248, + "y": 2735, + "ms": 28.35450000000003, + "features": 38, + "bytes": 3120 + }, + { + "z": 14, + "x": 8496, + "y": 5470, + "ms": 4.870207999999934, + "features": 29, + "bytes": 2391 + }, + { + "z": 15, + "x": 16993, + "y": 10941, + "ms": 3.8333330000000387, + "features": 11, + "bytes": 1055 + }, + { + "z": 16, + "x": 33986, + "y": 21882, + "ms": 2.965958999999998, + "features": 27, + "bytes": 2250 + }, + { + "z": 17, + "x": 67972, + "y": 43764, + "ms": 2.2329590000000508, + "features": 5, + "bytes": 559 + }, + { + "z": 18, + "x": 135944, + "y": 87529, + "ms": 2.72037499999999, + "features": 1, + "bytes": 205 + }, + { + "z": 13, + "x": 4254, + "y": 2744, + "ms": 5.2037499999999, + "features": 83, + "bytes": 6881 + }, + { + "z": 14, + "x": 8508, + "y": 5489, + "ms": 4.8072910000000775, + "features": 49, + "bytes": 4233 + }, + { + "z": 15, + "x": 17017, + "y": 10978, + "ms": 4.393457999999896, + "features": 32, + "bytes": 2785 + }, + { + "z": 16, + "x": 34034, + "y": 21957, + "ms": 4.250415999999973, + "features": 38, + "bytes": 3450 + }, + { + "z": 17, + "x": 68069, + "y": 43914, + "ms": 2.8047500000000127, + "features": 8, + "bytes": 882 + }, + { + "z": 18, + "x": 136139, + "y": 87828, + "ms": 2.5391670000000204, + "features": 2, + "bytes": 358 + }, + { + "z": 13, + "x": 4250, + "y": 2741, + "ms": 4.428375000000074, + "features": 0, + "bytes": 0 + }, + { + "z": 14, + "x": 8500, + "y": 5482, + "ms": 4.0877499999999145, + "features": 0, + "bytes": 0 + }, + { + "z": 15, + "x": 17001, + "y": 10965, + "ms": 3.6895410000000766, + "features": 0, + "bytes": 0 + }, + { + "z": 16, + "x": 34002, + "y": 21931, + "ms": 3.6579999999999018, + "features": 0, + "bytes": 0 + }, + { + "z": 17, + "x": 68004, + "y": 43862, + "ms": 3.625167000000033, + "features": 0, + "bytes": 0 + }, + { + "z": 18, + "x": 136009, + "y": 87725, + "ms": 3.637499999999932, + "features": 0, + "bytes": 0 + }, + { + "z": 13, + "x": 4245, + "y": 2731, + "ms": 5.620750000000044, + "features": 10, + "bytes": 997 + }, + { + "z": 14, + "x": 8491, + "y": 5462, + "ms": 3.19662500000004, + "features": 9, + "bytes": 879 + }, + { + "z": 15, + "x": 16983, + "y": 10925, + "ms": 3.4095839999999953, + "features": 10, + "bytes": 978 + }, + { + "z": 16, + "x": 33966, + "y": 21851, + "ms": 3.0982500000000073, + "features": 12, + "bytes": 858 + }, + { + "z": 17, + "x": 67933, + "y": 43702, + "ms": 3.2887499999999363, + "features": 0, + "bytes": 0 + }, + { + "z": 18, + "x": 135867, + "y": 87405, + "ms": 2.621208999999908, + "features": 0, + "bytes": 0 + }, + { + "z": 13, + "x": 4256, + "y": 2746, + "ms": 4.387749999999983, + "features": 7, + "bytes": 719 + }, + { + "z": 14, + "x": 8513, + "y": 5492, + "ms": 3.5732080000000224, + "features": 3, + "bytes": 351 + }, + { + "z": 15, + "x": 17027, + "y": 10985, + "ms": 3.6602920000000267, + "features": 2, + "bytes": 311 + }, + { + "z": 16, + "x": 34054, + "y": 21970, + "ms": 2.8039159999999583, + "features": 4, + "bytes": 486 + }, + { + "z": 17, + "x": 68108, + "y": 43940, + "ms": 4.060207999999989, + "features": 4, + "bytes": 486 + }, + { + "z": 18, + "x": 136216, + "y": 87881, + "ms": 3.705583000000047, + "features": 0, + "bytes": 0 + } + ], + "generation": "6fa7e3bb-b1b8-47a4-9b2c-2111859be634" + }, + "frames": { + "method": "Five visible 5-second sweeps per state; MapLibre easeTo two 2.5-second legs, z15 to z15.7 and back, alternating Neuss/Cologne; nearest-rank p95 pooled by state; unchanged target 33.4ms and over50 <=5%; no simultaneous builds/tests", + "desktop1280x800": { + "off": { + "sweeps": 5, + "viewport": [1280, 800], + "devicePixelRatio": 1, + "allForeground": true, + "intervalCount": 3003, + "pooledP95Ms": 9, + "over50Percent": 0, + "perSweepP95Ms": [9.099999999998545, 9, 9, 9, 8.900000000008731], + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Nightly)/0.0.46-nightly.20261008.2833 Chrome/152.0.7977.130 Electron/44.4.2 Safari/537.36" + }, + "on": { + "sweeps": 5, + "viewport": [1280, 800], + "devicePixelRatio": 1, + "allForeground": true, + "intervalCount": 3000, + "pooledP95Ms": 9, + "over50Percent": 0, + "perSweepP95Ms": [9.099999999998545, 9, 9.10000000000582, 8.89999999999418, 9], + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Nightly)/0.0.46-nightly.20261008.2833 Chrome/152.0.7977.130 Electron/44.4.2 Safari/537.36" + } + }, + "phoneCss430x932": { + "off": { + "sweeps": 5, + "viewport": [430, 932], + "devicePixelRatio": 1, + "allForeground": true, + "intervalCount": 3004, + "pooledP95Ms": 9.200000000011642, + "over50Percent": 0, + "perSweepP95Ms": [ + 9.099999999976717, 9.099999999976717, 9.299999999988358, 9.299999999988358, + 9.299999999988358 + ], + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Nightly)/0.0.46-nightly.20261008.2833 Chrome/152.0.7977.130 Electron/44.4.2 Safari/537.36" + }, + "on": { + "sweeps": 5, + "viewport": [430, 932], + "devicePixelRatio": 1, + "allForeground": true, + "intervalCount": 2998, + "pooledP95Ms": 9.299999999988358, + "over50Percent": 0, + "perSweepP95Ms": [ + 9.10000000000582, 9.299999999988358, 9.299999999988358, 9.299999999988358, + 9.199999999982538 + ], + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Nightly)/0.0.46-nightly.20261008.2833 Chrome/152.0.7977.130 Electron/44.4.2 Safari/537.36" + } + }, + "iosSimulatorStreamActive": { + "off": { + "sweeps": 5, + "viewport": [402, 714], + "devicePixelRatio": 3, + "allForeground": true, + "intervalCount": 1049, + "pooledP95Ms": 33, + "over50Percent": 0.19065776930409914, + "perSweepP95Ms": [33, 34, 32, 34, 33], + "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Mobile/15E148 Safari/604.1" + }, + "on": { + "sweeps": 5, + "viewport": [402, 714], + "devicePixelRatio": 3, + "allForeground": true, + "intervalCount": 1027, + "pooledP95Ms": 34, + "over50Percent": 0, + "perSweepP95Ms": [33, 35, 33, 34, 33], + "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Mobile/15E148 Safari/604.1" + } + }, + "device": "iPhone 18 Pro simulator, iOS 27, Safari 27; host macOS arm64, no physical-device thermal/battery claim", + "qualification": "Final isolated simulator pooled off/on 32/33ms passes the unchanged 33.4ms target. Individual on sweeps include 34/35ms. Streamed 33/34ms and paused-but-second-QA-WebGL-present 35/36ms misses retained. No unrelated host process stopped; no physical-device certificate. Phone iteration 0 off resampled after initial resize had not settled; raw invalid 1280x800 sample retained externally. One paused-shared last sweep briefly overlapped the CSS resample; that phase is diagnostic, not final acceptance.", + "iosSimulatorStreamPausedSharedGraphics": { + "off": { + "sweeps": 5, + "viewport": [402, 714], + "devicePixelRatio": 3, + "allForeground": true, + "intervalCount": 1055, + "pooledP95Ms": 35, + "over50Percent": 0.3791469194312796, + "perSweepP95Ms": [32, 35, 32, 38, 34], + "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Mobile/15E148 Safari/604.1" + }, + "on": { + "sweeps": 5, + "viewport": [402, 714], + "devicePixelRatio": 3, + "allForeground": true, + "intervalCount": 1032, + "pooledP95Ms": 36, + "over50Percent": 0.872093023255814, + "perSweepP95Ms": [35, 34, 34, 38, 36], + "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Mobile/15E148 Safari/604.1" + } + }, + "iosSimulatorIsolated": { + "off": { + "sweeps": 5, + "viewport": [402, 714], + "devicePixelRatio": 3, + "allForeground": true, + "intervalCount": 1128, + "pooledP95Ms": 32, + "over50Percent": 0.26595744680851063, + "perSweepP95Ms": [30, 34, 31, 32, 30], + "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Mobile/15E148 Safari/604.1" + }, + "on": { + "sweeps": 5, + "viewport": [402, 714], + "devicePixelRatio": 3, + "allForeground": true, + "intervalCount": 1104, + "pooledP95Ms": 33, + "over50Percent": 0.18115942028985507, + "perSweepP95Ms": [31, 34, 32, 35, 30], + "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Mobile/15E148 Safari/604.1" + } + } + }, + "fixedCameraViews": [ + { + "scene": "Neuss", + "zoom": 14, + "viewport": [430, 932], + "roadLabelsBefore": 10, + "roadLabelsAfter": 10, + "basemapPoisBefore": 13, + "basemapPoisAfter": 13, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 29, + "ambientLabels": 3, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Neuss", + "zoom": 15, + "viewport": [430, 932], + "roadLabelsBefore": 34, + "roadLabelsAfter": 34, + "basemapPoisBefore": 11, + "basemapPoisAfter": 11, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 33, + "ambientLabels": 4, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Neuss", + "zoom": 16, + "viewport": [430, 932], + "roadLabelsBefore": 28, + "roadLabelsAfter": 28, + "basemapPoisBefore": 10, + "basemapPoisAfter": 10, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 46, + "ambientLabels": 11, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Neuss", + "zoom": 17, + "viewport": [430, 932], + "roadLabelsBefore": 15, + "roadLabelsAfter": 15, + "basemapPoisBefore": 7, + "basemapPoisAfter": 7, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 9, + "ambientLabels": 5, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Neuss", + "zoom": 18, + "viewport": [430, 932], + "roadLabelsBefore": 4, + "roadLabelsAfter": 4, + "basemapPoisBefore": 15, + "basemapPoisAfter": 15, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 3, + "ambientLabels": 2, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Cologne", + "zoom": 14, + "viewport": [430, 932], + "roadLabelsBefore": 15, + "roadLabelsAfter": 15, + "basemapPoisBefore": 14, + "basemapPoisAfter": 14, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 55, + "ambientLabels": 16, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Cologne", + "zoom": 15, + "viewport": [430, 932], + "roadLabelsBefore": 26, + "roadLabelsAfter": 26, + "basemapPoisBefore": 15, + "basemapPoisAfter": 15, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 59, + "ambientLabels": 8, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Cologne", + "zoom": 16, + "viewport": [430, 932], + "roadLabelsBefore": 26, + "roadLabelsAfter": 26, + "basemapPoisBefore": 5, + "basemapPoisAfter": 5, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 52, + "ambientLabels": 17, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Cologne", + "zoom": 17, + "viewport": [430, 932], + "roadLabelsBefore": 10, + "roadLabelsAfter": 10, + "basemapPoisBefore": 13, + "basemapPoisAfter": 13, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 13, + "ambientLabels": 3, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Cologne", + "zoom": 18, + "viewport": [430, 932], + "roadLabelsBefore": 6, + "roadLabelsAfter": 6, + "basemapPoisBefore": 6, + "basemapPoisAfter": 6, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 1, + "ambientLabels": 1, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Zons", + "zoom": 14, + "viewport": [430, 932], + "roadLabelsBefore": 0, + "roadLabelsAfter": 0, + "basemapPoisBefore": 9, + "basemapPoisAfter": 9, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 3, + "ambientLabels": 1, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Zons", + "zoom": 15, + "viewport": [430, 932], + "roadLabelsBefore": 15, + "roadLabelsAfter": 15, + "basemapPoisBefore": 7, + "basemapPoisAfter": 7, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 11, + "ambientLabels": 4, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Zons", + "zoom": 16, + "viewport": [430, 932], + "roadLabelsBefore": 12, + "roadLabelsAfter": 12, + "basemapPoisBefore": 11, + "basemapPoisAfter": 11, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 9, + "ambientLabels": 6, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Zons", + "zoom": 17, + "viewport": [430, 932], + "roadLabelsBefore": 8, + "roadLabelsAfter": 8, + "basemapPoisBefore": 14, + "basemapPoisAfter": 14, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 3, + "ambientLabels": 2, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Zons", + "zoom": 18, + "viewport": [430, 932], + "roadLabelsBefore": 3, + "roadLabelsAfter": 3, + "basemapPoisBefore": 4, + "basemapPoisAfter": 4, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 1, + "ambientLabels": 2, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Rural Rhine empty", + "zoom": 14, + "viewport": [430, 932], + "roadLabelsBefore": 2, + "roadLabelsAfter": 2, + "basemapPoisBefore": 1, + "basemapPoisAfter": 1, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Rural Rhine empty", + "zoom": 15, + "viewport": [430, 932], + "roadLabelsBefore": 1, + "roadLabelsAfter": 1, + "basemapPoisBefore": 0, + "basemapPoisAfter": 0, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Rural Rhine empty", + "zoom": 16, + "viewport": [430, 932], + "roadLabelsBefore": 1, + "roadLabelsAfter": 1, + "basemapPoisBefore": 1, + "basemapPoisAfter": 1, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Rural Rhine empty", + "zoom": 17, + "viewport": [430, 932], + "roadLabelsBefore": 0, + "roadLabelsAfter": 0, + "basemapPoisBefore": 0, + "basemapPoisAfter": 0, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Rural Rhine empty", + "zoom": 18, + "viewport": [430, 932], + "roadLabelsBefore": 0, + "roadLabelsAfter": 0, + "basemapPoisBefore": 0, + "basemapPoisAfter": 0, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Berlin", + "zoom": 14, + "viewport": [430, 932], + "roadLabelsBefore": 11, + "roadLabelsAfter": 11, + "basemapPoisBefore": 22, + "basemapPoisAfter": 22, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Berlin", + "zoom": 15, + "viewport": [430, 932], + "roadLabelsBefore": 15, + "roadLabelsAfter": 15, + "basemapPoisBefore": 18, + "basemapPoisAfter": 18, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Berlin", + "zoom": 16, + "viewport": [430, 932], + "roadLabelsBefore": 17, + "roadLabelsAfter": 17, + "basemapPoisBefore": 9, + "basemapPoisAfter": 9, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Berlin", + "zoom": 17, + "viewport": [430, 932], + "roadLabelsBefore": 7, + "roadLabelsAfter": 7, + "basemapPoisBefore": 18, + "basemapPoisAfter": 18, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Berlin", + "zoom": 18, + "viewport": [430, 932], + "roadLabelsBefore": 5, + "roadLabelsAfter": 5, + "basemapPoisBefore": 8, + "basemapPoisAfter": 8, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Aachen", + "zoom": 14, + "viewport": [430, 932], + "roadLabelsBefore": 18, + "roadLabelsAfter": 18, + "basemapPoisBefore": 12, + "basemapPoisAfter": 12, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Aachen", + "zoom": 15, + "viewport": [430, 932], + "roadLabelsBefore": 30, + "roadLabelsAfter": 30, + "basemapPoisBefore": 10, + "basemapPoisAfter": 10, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Aachen", + "zoom": 16, + "viewport": [430, 932], + "roadLabelsBefore": 28, + "roadLabelsAfter": 28, + "basemapPoisBefore": 11, + "basemapPoisAfter": 11, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Aachen", + "zoom": 17, + "viewport": [430, 932], + "roadLabelsBefore": 17, + "roadLabelsAfter": 17, + "basemapPoisBefore": 12, + "basemapPoisAfter": 12, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Aachen", + "zoom": 18, + "viewport": [430, 932], + "roadLabelsBefore": 9, + "roadLabelsAfter": 9, + "basemapPoisBefore": 13, + "basemapPoisAfter": 13, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Monschau", + "zoom": 14, + "viewport": [430, 932], + "roadLabelsBefore": 5, + "roadLabelsAfter": 5, + "basemapPoisBefore": 10, + "basemapPoisAfter": 10, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Monschau", + "zoom": 15, + "viewport": [430, 932], + "roadLabelsBefore": 16, + "roadLabelsAfter": 16, + "basemapPoisBefore": 8, + "basemapPoisAfter": 8, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Monschau", + "zoom": 16, + "viewport": [430, 932], + "roadLabelsBefore": 19, + "roadLabelsAfter": 19, + "basemapPoisBefore": 12, + "basemapPoisAfter": 12, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Monschau", + "zoom": 17, + "viewport": [430, 932], + "roadLabelsBefore": 14, + "roadLabelsAfter": 14, + "basemapPoisBefore": 15, + "basemapPoisAfter": 15, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Monschau", + "zoom": 18, + "viewport": [430, 932], + "roadLabelsBefore": 9, + "roadLabelsAfter": 9, + "basemapPoisBefore": 11, + "basemapPoisAfter": 11, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Neuss dark English", + "zoom": 14, + "viewport": [430, 932], + "roadLabelsBefore": 10, + "roadLabelsAfter": 10, + "basemapPoisBefore": 13, + "basemapPoisAfter": 13, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 29, + "ambientLabels": 3, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Neuss dark English", + "zoom": 15, + "viewport": [430, 932], + "roadLabelsBefore": 34, + "roadLabelsAfter": 34, + "basemapPoisBefore": 11, + "basemapPoisAfter": 11, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 33, + "ambientLabels": 4, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Neuss dark English", + "zoom": 16, + "viewport": [430, 932], + "roadLabelsBefore": 28, + "roadLabelsAfter": 28, + "basemapPoisBefore": 10, + "basemapPoisAfter": 10, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 46, + "ambientLabels": 11, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Neuss dark English", + "zoom": 17, + "viewport": [430, 932], + "roadLabelsBefore": 15, + "roadLabelsAfter": 15, + "basemapPoisBefore": 7, + "basemapPoisAfter": 7, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 9, + "ambientLabels": 5, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Neuss dark English", + "zoom": 18, + "viewport": [430, 932], + "roadLabelsBefore": 4, + "roadLabelsAfter": 4, + "basemapPoisBefore": 15, + "basemapPoisAfter": 15, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 3, + "ambientLabels": 2, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Cologne dark German", + "zoom": 14, + "viewport": [430, 932], + "roadLabelsBefore": 15, + "roadLabelsAfter": 15, + "basemapPoisBefore": 14, + "basemapPoisAfter": 14, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 55, + "ambientLabels": 16, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Cologne dark German", + "zoom": 15, + "viewport": [430, 932], + "roadLabelsBefore": 26, + "roadLabelsAfter": 26, + "basemapPoisBefore": 15, + "basemapPoisAfter": 15, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 59, + "ambientLabels": 8, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Cologne dark German", + "zoom": 16, + "viewport": [430, 932], + "roadLabelsBefore": 26, + "roadLabelsAfter": 26, + "basemapPoisBefore": 5, + "basemapPoisAfter": 5, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 52, + "ambientLabels": 17, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Cologne dark German", + "zoom": 17, + "viewport": [430, 932], + "roadLabelsBefore": 10, + "roadLabelsAfter": 10, + "basemapPoisBefore": 13, + "basemapPoisAfter": 13, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 13, + "ambientLabels": 3, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Cologne dark German", + "zoom": 18, + "viewport": [430, 932], + "roadLabelsBefore": 6, + "roadLabelsAfter": 6, + "basemapPoisBefore": 6, + "basemapPoisAfter": 6, + "sameRoadAndBasemapIdentities": true, + "ambientPoints": 1, + "ambientLabels": 1, + "duplicateAmbientLabelIds": 0 + } + ], + "interaction": { + "selected": { + "primaryScheme": "osm", + "ids": { + "osm": "way/28562993" + }, + "name": "Quirinus-Münster", + "address": "", + "coordinates": [6.693338602781296, 51.19904687734484], + "category": "place_of_worship", + "names": {}, + "id": "osm:way/28562993" + }, + "paintThemeFilter": [ + "all", + ["<=", ["get", "min_zoom"], 16], + [ + "!", + [ + "in", + ["get", "id"], + [ + "literal", + [ + "osm:node/1236400589", + "osm:node/1538386886", + "osm:node/598497421", + "osm:way/28562993", + "osm:way/43629552", + "osm:way/87260409", + "osm:way/87411560" + ] + ] + ] + ] + ], + "english": ["coalesce", ["get", "name:en"], ["get", "name"]], + "german": ["coalesce", ["get", "name:de"], ["get", "name"]], + "styleReloadFilter": [ + "all", + ["<=", ["get", "min_zoom"], 16], + [ + "!", + [ + "in", + ["get", "id"], + [ + "literal", + [ + "osm:node/1236400589", + "osm:node/1538386886", + "osm:node/598497421", + "osm:way/28562993", + "osm:way/43629552", + "osm:way/87260409", + "osm:way/87411560" + ] + ] + ] + ] + ], + "selectedLabelVisible": false, + "tapCardPresent": true, + "basemapTap": { + "selected": { + "primaryScheme": "osm", + "ids": { + "osm": "way/28562993" + }, + "name": "Quirinus-Münster", + "address": "", + "coordinates": [6.6933392733335495, 51.19904645716551], + "category": "place_of_worship", + "names": {}, + "id": "osm:way/28562993" + }, + "ambientLabels": 0 + } + }, + "screenshots": [ + { + "file": "before-desktop.png", + "sha256": "b1c33b00802ad497b819c3823fe8718fcbc12ce9b23a1e6b904efb4673ca22fc", + "bytes": 864723, + "generation": "6fa7e3bb-b1b8-47a4-9b2c-2111859be634" + }, + { + "file": "after-desktop.png", + "sha256": "a6982f1de6f601ce97b0047919237e793775cd176a0399717dea96ca146eb359", + "bytes": 900832, + "generation": "6fa7e3bb-b1b8-47a4-9b2c-2111859be634" + }, + { + "file": "ios-before.png", + "sha256": "7e6a815253b8f7e4cd0138689d58d10b793061b1731dc7c1ec1cd26797f912a8", + "bytes": 308830, + "generation": "b900c5b5-69bb-47c1-8526-b703bac4745a" + }, + { + "file": "ios-after.png", + "sha256": "b957e0dfb9f022e138f9a9a1b3e85c6c2e9fef8c7ff3e1a79880536ada544ea9", + "bytes": 322292, + "generation": "b900c5b5-69bb-47c1-8526-b703bac4745a" + }, + { + "file": "admin.png", + "sha256": "553ba11c0abc21c84a68c2104122788330dd4af44017e23d6a94fd9498bc2ae5", + "bytes": 80715, + "generation": "6fa7e3bb-b1b8-47a4-9b2c-2111859be634" + } + ], + "limits": [ + "No production write, merge or deployment", + "Real source run OSM-only; optional Overture is regression-fixture evidence", + "Existing alias/code-focused index omits some named OSM features without terms", + "Source minZoom eligibility does not guarantee a collision-free rendered label", + "No cold restarted-database, remote CDN or full-region PMTiles capacity benchmark", + "Mobile simulator only; streamed and individual-sweep misses disclosed", + "Temporary QA mounts actual components; external enrichment disabled and admin rendering bypasses production outer authentication, which is covered separately by API tests", + "Uncategorized indexed road/building objects are excluded from ambient admission while remaining searchable" + ], + "database": [ + { + "postgres": "PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on aarch64-unknown-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit", + "postgis": "POSTGIS=\"3.6.4 94d984b\" [EXTENSION] PGSQL=\"180\" GEOS=\"3.14.1-CAPI-1.20.5\" (compiled against GEOS 3.13.1) PROJ=\"9.8.1 NETWORK_ENABLED=OFF URL_ENDPOINT=https://cdn.proj.org USER_WRITABLE_DIRECTORY=/var/lib/postgresql/.local/share/proj DATABASE_PATH=/usr/share/proj/proj.db\" (compiled against PROJ 9.6.0) LIBXML=\"2.9.14\" LIBJSON=\"0.18\" LIBPROTOBUF=\"1.5.1\" WAGYU=\"0.5.0 (Internal)\" TOPOLOGY" + } + ], + "sourceTools": { + "osmium": "1.15.0", + "libosmium": "2.19.0", + "crop": "complete-way bbox [6.58,50.89,7.07,51.31]" + }, + "codeFiles": [ + { + "file": "packages/core/src/ambient-places.ts", + "sha256": "af99d4ddbbc7f700942df8c9fec437f6f0f583d17904410af42048291080e9dc" + }, + { + "file": "integrations/overlay-ambient-places/map-layer.tsx", + "sha256": "942b01cc57f047ff781b25e74b0d045a033413a530aea3ead1a8877d2ece3e9b" + }, + { + "file": "services/data-manager/src/jobs/search-index/build.ts", + "sha256": "766b92e7e71c3ec7a0cb65c2df04368a2aa33e53dabdaf38a4dd90294435588e" + } + ], + "framePublication": { + "index": { + "region": "europe/germany", + "epoch": "c1f9509b-00f3-4e2c-ae82-a388f8cedc9d", + "sourceFingerprint": "sha256:c18cc77ed3493aa186bd7659fee4a9b028fbc479c2a8a90f219a3b2d33824d75", + "placeCount": 14232, + "termCount": 16052 + }, + "indexedMs": 23462.774084, + "publishedMs": 717.7764159999999, + "manifest": { + "version": 1, + "policyVersion": 2, + "generation": "b900c5b5-69bb-47c1-8526-b703bac4745a", + "publishedAt": "2026-10-08T20:29:21.592Z", + "region": { + "name": "Rhine regional acceptance", + "bounds": [6.58, 50.89, 7.07, 51.31] + }, + "placeCount": 13454, + "enabled": true, + "sources": { + "osm": { + "region": "europe/germany", + "epoch": "c1f9509b-00f3-4e2c-ae82-a388f8cedc9d", + "publishedAt": "2026-10-08T19:38:04.972Z", + "count": 13454 + }, + "overture": null + } + }, + "landmarks": [ + { + "id": "osm:way/28562993", + "name": "Quirinus-Münster", + "category": "place_of_worship", + "rank": 2559, + "min_zoom": 14, + "tenant": false + }, + { + "id": "osm:way/4532022", + "name": "Kölner Dom", + "category": "place_of_worship", + "rank": 2559, + "min_zoom": 14, + "tenant": false + } + ] + }, + "codeBase": "769270286129de5ea229dab4d0c09b18c7e49670", + "tileReadsBuildLoadDiagnostic": { + "context": "Concurrent full repository tests, type checks and production builds; retained failed warm budget, followed by one quiet repeat on the same generation.", + "generation": "6fa7e3bb-b1b8-47a4-9b2c-2111859be634", + "firstMs": 377.47570800000017, + "warm": { + "count": 30, + "p50Ms": 9.303375000000415, + "p95Ms": 127.88508399999955, + "samplesMs": [ + 5.975625000000036, 9.303375000000415, 4.770417000000634, 3.662042000000838, + 3.385833000000275, 4.433458999999857, 44.975374999999985, 104.38820900000064, + 67.89133399999992, 36.53754200000003, 26.340250000000196, 73.62662500000079, + 56.71424999999999, 8.914291000000048, 8.027584000000388, 5.420500000000175, + 7.1799580000006245, 4.8061250000000655, 7.0509579999998095, 6.721000000000458, + 5.0928329999997, 10.217458000000079, 4.401084000000083, 11.334458000000268, + 18.843832999999904, 62.54816600000049, 50.784583000000566, 11.986791999999696, + 315.44533299999966, 127.88508399999955 + ] + }, + "concurrentEight": { + "count": 40, + "p95Ms": 128.55979099999968, + "samplesMs": [ + 46.50658400000066, 124.08774999999969, 127.7908750000006, 127.81154199999946, + 127.85179199999948, 128.55979099999968, 129.32937500000025, 129.62737500000003, + 9.139207999999599, 9.205375000000458, 9.250334000000294, 9.23937500000011, + 9.244500000000698, 9.254375000000437, 9.272041000000172, 9.341792000000169, + 12.854166000000077, 12.964375000000473, 19.55370799999946, 19.617709000000104, + 19.53162499999962, 19.347915999999714, 19.356917000000067, 19.39995799999997, + 33.719459000000825, 33.748624999999265, 33.76666599999953, 34.25279099999989, + 34.272791999999754, 34.30595900000026, 36.406707999999526, 41.0654169999998, + 40.192667000000256, 41.55133299999943, 41.56562500000018, 41.605458, 41.828457999999955, + 43.796041000000514, 45.07579199999964, 45.058374999999614 + ] + } + }, + "fixedCameraGeneration": "6fa7e3bb-b1b8-47a4-9b2c-2111859be634", + "frameGeneration": "b900c5b5-69bb-47c1-8526-b703bac4745a", + "generationQualification": "Frames and native iOS captures use b900c5b5; final static desktop matrix, admin and quiet tile reads use restored 6fa7e3bb. Both index the same checksummed source with policy 2, 13,454 places and the identical tested product files.", + "browserQualification": "T3 preview used for initial static and frame QA. Its preview_open later explicitly reported unavailable; final paired static matrix and desktop/admin images use isolated headless system Chrome. No headless frame measurements are claimed." +} diff --git a/docs/docs/developer/ambient-places-publication.md b/docs/docs/developer/ambient-places-publication.md index 547e16427..61b699697 100644 --- a/docs/docs/developer/ambient-places-publication.md +++ b/docs/docs/developer/ambient-places-publication.md @@ -98,7 +98,7 @@ localized names, category, rank, minimum zoom, tenant flag and source combinatio The client suppresses ambient features already owned by a category/selection ID. An owned-basemap label can acquire the canonical identity only through an explicit OSM ID or, only when no explicit identity is supplied, a unique compatible-category/name -match within eight metres. An explicit different OSM identity is never replaced +match within ten metres. Worship matching uses its destination class, not the religion subclass. An explicit different OSM identity is never replaced by proximity. Ambiguous branches and non-ground tenants are not matched by proximity. Mappings belong to the map instance and are cleared on style replacement and overlay teardown. The @@ -214,5 +214,133 @@ Tests cover closure/confidence/tenant/label policy, accepted identities, ambiguo matching, actual MVT decoding, input/dense-byte bounds, failed writes, contributor refusal, stale/unfinished sources, concurrency, retention, immutable bytes, rollback, API auth/cache/budgets and client generation/tap/style/teardown behavior. -Browser evidence uses clearly labeled disposable fixtures; it does not claim -verified production Aachen coverage. +The earlier browser evidence used clearly labeled synthetic fixtures. The regional +acceptance run below uses a real OSM extract in a disposable database; it does not +claim production Aachen coverage. + +## Real regional acceptance: October 8, 2026 + +The follow-up uses dated, public Geofabrik Düsseldorf/Cologne October 6 PBFs, +merged and cropped to `[6.58,50.89,7.07,51.31]`. The existing extractor/index builder +produces 14,232 unique indexed places, then policy 2 publishes 13,454 places. +Uncategorized indexed roads/buildings remain searchable but are excluded from the +ambient destination layer; the real Industriestraße road-segment case is a regression +control. +The cropped source fingerprint is +`sha256:c18cc77ed3493aa186bd7659fee4a9b028fbc479c2a8a90f219a3b2d33824d75`. +The source snapshot's latest object timestamp is `2026-10-06T20:09:52Z`; +the publication timestamp is a separate pipeline timestamp. + +This run has **OSM-only coverage**. Optional Overture/confidence/conflation and +published-GERS detail resolution are covered by the PostGIS regression fixtures, +not claimed as a live Overture extract. Single-name source features without aliases +or codes can be absent from the existing alias/code-focused search index. The +committed landmark corpus distinguishes source presence from index eligibility; +this change preserves that existing ingestion/geocoder behavior. + +Actual Quirinus-Münster (`osm:way/28562993`) and Cologne Cathedral +(`osm:way/4532022`) publish at zoom 14 through corroborated source tags. +Names still compete for available space: eligibility does not guarantee a label +in every camera. Eight candidate anchors allow a corroborated landmark name to use nearby +whitespace while retaining collision avoidance and road/transit priority. Ordinary +destinations retain one label position to bound placement work and crowding. +The selected/category/basemap suppression filter is repaired per live layer after +paint-only theme recreation as well as full style replacement. OSM-only publication +credits use the actual OSM source; combined publications retain the supported +Overture contributor notices. + +The actual extractor-to-index-to-publication run also caught JSONB-array tag +serialization: serialized strings had been stored as JSON strings rather than +objects. The index writer now casts text values once to JSONB objects, and ambient +policy safely reads legacy serialized objects. Private/closed legacy tags therefore +remain enforceable without a global migration. + +The [aggregate acceptance evidence](https://github.com/OpenMapX/openmapx/blob/main/docs/docs/developer/ambient-places-acceptance.json) records +source hashes, runtime/settings, measured targets, fixed camera outcomes and +external screenshot checksums. It contains no screenshot binaries or credentials. +Selected real before/after and iOS screenshots are attached directly to PR #436. +Frames and native iOS images use generation `b900c5b5`; the final static matrix, +desktop/admin images and quiet tile reads use restored generation `6fa7e3bb`. +Both contain the same 13,454 places from the identical source hash, policy and +tested product files. Generation IDs and source epochs remain explicit in the +evidence. T3 preview handled initial static/frame QA; after `preview_open` +explicitly reported unavailable, isolated headless system Chrome completed the +static matrix and desktop/admin captures. It was not used for frame timings. + +| Regional check | Declared limit | Observed | +| ---------------------------------------- | -------------: | ---------: | +| Publication after indexing | 60 s | 0.490 s | +| First repository tile read | 1,000 ms | 28.35 ms | +| Warm sparse/dense p95, 30 reads | 100 ms | 5.20 ms | +| Eight concurrent reads p95, 40 reads | 250 ms | 39.34 ms | +| Representative tile features / bytes | 256 / 131,072 | 83 / 6,881 | +| Explicit IDs duplicated in decoded tiles | 0 | 0 | + +A concurrent build/test run reached warm p95 127.89 ms, exceeding the 100 ms +budget; those samples remain in the aggregate as a diagnostic. The table is the +subsequent quiet repeat on the same generation, without those jobs. + +These reads measure the final EWKB/functional-GiST store in disposable PostGIS +18/PostGIS 3.6. They do not measure a restarted cold database, remote network/CDN, +a full regional PMTiles archive, or deployment capacity. On the same real source, +replacement publication preserved old bytes; forced candidate failure retained +active discovery; rollback restored the old pointer; disable hid discovery while +already published tile bytes remained readable. + +Reproduce extraction/publication with the existing commands against a disposable +database, using the source SHA-256 and bbox above: + +```sh +osmium merge duesseldorf-regbez-261006.osm.pbf koeln-regbez-261006.osm.pbf -o merged.pbf +osmium extract -b 6.58,50.89,7.07,51.31 merged.pbf -o rhine.osm.pbf +# Register the cropped PBF in the data-manager StateStore, then run its existing +# buildOsmSearchIndex and buildAmbientPlaces jobs against the disposable database. +OPENMAPX_RUN_DATABASE_TESTS=1 pnpm exec vitest run services/data-manager/__tests__/ambient-places +``` + +Osmium's complete-way crop can include object geometries outside the bbox; +publication enforces the requested region bounds. The test-only corpus contains +selected public policy/name tags with ODbL credit, not contact or contributor data. +The source PBFs omit user/UID/changeset metadata. + +The fixed-camera matrix contains 45 off/on pairs at zooms 14–18: Neuss, Cologne, +sparse Zons, an empty rural cell, and no-region Aachen/Berlin/Monschau controls, +plus owned-dark English Neuss and German Cologne. Each pair has identical road +name and basemap POI identity lists; decoded tiles and visible ambient label lists +have no duplicate explicit IDs. A native pointer tap opens Quirinus-Münster with +canonical ID `osm:way/28562993`; selected-label suppression survives both paint-only +theme recreation and full style replacement. English/German fields conservatively +fall back to the source name. External enrichment is disabled in the QA fixture. +The actual admin component shows the real generation, policy 2, source coverage +and the intentionally induced failed-build notice while retaining the active map. +Its rendering fixture bypasses outer production authentication; API authorization +is verified separately by regression tests. + +Five foreground five-second camera sweeps per state alternate Neuss/Cologne at +zoom 15 → 15.7 → 15 using two 2.5-second `MapLibre.easeTo` legs. RAF observes frame +intervals without forcing a camera update on each frame. Nearest-rank p95 is +computed over the pooled intervals per state; individual sweep p95s are also +recorded in the aggregate evidence. No builds/tests ran concurrently. + +| Production-asset viewport | Off pooled p95 | On pooled p95 | On >50 ms | +| ---------------------------------------- | -------------: | ------------: | --------: | +| T3 desktop, 1280 × 800 | 9.0 ms | 9.0 ms | 0% | +| T3 phone CSS, 430 × 932 | 9.2 ms | 9.3 ms | 0% | +| iPhone 18 Pro simulator Safari, isolated | 32 ms | 33 ms | 0.181% | +| Same simulator, live stream active | 33 ms | 34 ms | 0% | + +The isolated run pauses the device preview stream and removes our second QA +WebGL map. A paused-stream run with that second map still present reached +35/36 ms off/on; it remains diagnostic evidence alongside the streamed results. +No unrelated host process was stopped. The phone-CSS off iteration 0 was repeated +after its initial viewport resize had not settled; the original sample is retained +externally. + +The unchanged target is pooled p95 ≤33.4 ms and intervals >50 ms ≤5%. +The isolated simulator run meets it; the streamed run narrowly misses the +p95 target, and individual isolated on sweeps reach 34–35 ms. This is a +qualified local simulator result, not a claim that every sweep or physical device +passes. An earlier forced-`jumpTo`-every-RAF stress workload missed mobile targets +in both states; those raw samples remain external and are not relabeled as normal +app camera behavior. Physical-device thermal/battery behavior and deployed +network/CDN latency remain rollout checks. diff --git a/docs/docs/developer/map-comparison-baseline.md b/docs/docs/developer/map-comparison-baseline.md index 129243d98..f51280142 100644 --- a/docs/docs/developer/map-comparison-baseline.md +++ b/docs/docs/developer/map-comparison-baseline.md @@ -204,3 +204,26 @@ For versioned search, identity, enrichment and navigation evidence alongside the visual cases, follow [Discovery evaluation](discovery-evaluation.md). Store new capture archives externally with a manifest and checksums; attach selected review images directly to the PR. + +## Regional ambient-place acceptance (#399) + +The October 8 acceptance run repeats the four fixed cameras at zooms 14–18 +against the same public basemap TileJSON and the same policy-2 regional publication. +Neuss lies inside the published Rhine bbox; Berlin, Aachen and Monschau are +outside-region controls. Cologne and a rural Rhine camera add dense/sparse +positive cases. This is a disposable real OSM extract, not a production deployment +or a recall audit of all German places. + +Source eligibility and rendered labels remain separate: Quirinus-Münster has +corroborated source-backed zoom-14 eligibility, while retained city/road labels can +still win a particular collision. The fixed zoom-16 Neuss view gains its name with +collision-safe landmark anchors. Ordinary labels keep one position. Known owned +basemap identities and selected/category destinations suppress their ambient +counterpart; distinct source IDs are not guessed to be the same business merely +because their names are similar. + +See [regional publication and measured budgets](ambient-places-publication.md) +and its aggregate JSON for source hashes, fixed settings, camera outcomes, +read/fallback/rollback evidence and desktop/Safari simulator frame samples. +Before/after screenshots are PR attachments outside git. Simulated Safari results +must not be presented as physical-device battery, thermal or fleet certification. diff --git a/docs/superpowers/specs/2026-10-07-ambient-places-design.md b/docs/superpowers/specs/2026-10-07-ambient-places-design.md index 0412d3bb5..761ecbf00 100644 --- a/docs/superpowers/specs/2026-10-07-ambient-places-design.md +++ b/docs/superpowers/specs/2026-10-07-ambient-places-design.md @@ -115,7 +115,7 @@ short halo text and deterministic symbol-sort-key. No DOM marker per place. Suppress ambient features already shown by category results or selected place, using canonical IDs and GERS. For owned basemap labels, match explicit OSM refs first; otherwise allow only one unique, same-name, compatible-category point -within 8 metres, never known non-ground tenants. Hide the ambient copy and carry +within 10 metres (follow-up real-source representatives differ by 8.28 m), never known non-ground tenants. Hide the ambient copy and carry its canonical place into the basemap tap/hover path. Ambiguous names/branches stay separate. Scope mappings to each Map instance; remove them on teardown. Do not change other overlays' filters or selection behavior. diff --git a/integrations/overlay-ambient-places/map-layer.test.tsx b/integrations/overlay-ambient-places/map-layer.test.tsx index 07198789c..853e2d8bc 100644 --- a/integrations/overlay-ambient-places/map-layer.test.tsx +++ b/integrations/overlay-ambient-places/map-layer.test.tsx @@ -1,17 +1,43 @@ +import { createRequire } from "node:module"; +import { resolve } from "node:path"; import { usePlaceStore } from "@openmapx/core"; import { act, cleanup, render, waitFor } from "@testing-library/react"; import type { MapGeoJSONFeature } from "maplibre-gl"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const webRequire = createRequire(resolve(process.cwd(), "apps/web/package.json")); +const rendererRequire = createRequire(webRequire.resolve("maplibre-gl/package.json")); +const { createExpression, latest } = rendererRequire("@maplibre/maplibre-gl-style-spec") as { + latest: { layout_symbol: Record }; + createExpression: ( + value: unknown, + rootKey: string, + specification: unknown, + ) => { + result: string; + value: { + evaluate: ( + globals: { zoom: number }, + feature: { type: string; properties: { rank: number }; geometry: never[] }, + ) => { values: unknown[] }; + }; + }; +}; + const test = vi.hoisted(() => ({ group: null as unknown, + attribution: vi.fn(), map: { getLayer: vi.fn(() => true), getSource: vi.fn(() => true), getZoom: () => 16, querySourceFeatures: vi.fn((): MapGeoJSONFeature[] => []), queryRenderedFeatures: vi.fn((): MapGeoJSONFeature[] => []), - getStyle: () => ({ layers: [] }), + getStyle: vi.fn( + (): { layers: { id: string; type: string; source?: string; "source-layer"?: string }[] } => ({ + layers: [], + }), + ), getFilter: vi.fn(() => undefined), setFilter: vi.fn(), on: vi.fn<(event: string, handler: (event?: unknown) => void) => void>(), @@ -29,7 +55,8 @@ vi.mock("@/integration-api/map/useMapLayerGroup", () => ({ }, })); vi.mock("@/integration-api/overlay/useIntegrationAttribution", () => ({ - useIntegrationAttribution: vi.fn<(event: string, handler: (event?: unknown) => void) => void>(), + useIntegrationAttribution: test.attribution, + useIntegrationSourceAttributions: test.attribution, })); vi.mock("@/integration-api/runtime/EnvProvider", () => ({ useEnv: () => ({ apiUrl: "http://fixture" }), @@ -40,6 +67,8 @@ vi.mock("@/lib/useExploreReachResults", () => { }); vi.mock("next-intl", () => ({ useLocale: () => "de" })); +import type { Map as MapLibreMap } from "maplibre-gl"; +import { getAmbientIdentity } from "@/components/map/ambientPlaceIdentity"; import { AmbientPlacesLayer } from "./map-layer"; import { useAmbientPlacesStore } from "./store"; @@ -68,6 +97,7 @@ beforeEach(() => { vi.clearAllMocks(); test.map.querySourceFeatures.mockReturnValue([]); test.map.queryRenderedFeatures.mockReturnValue([]); + test.map.getStyle.mockReturnValue({ layers: [] }); usePlaceStore.setState({ selectedPlace: null }); }); afterEach(() => { @@ -84,11 +114,58 @@ describe("ambient generation lifecycle", () => { await waitFor(() => expect(test.group).not.toBeNull()); expect(JSON.stringify(test.group)).toContain(manifest.generation); expect(JSON.stringify(test.group)).toContain('"maxzoom":18'); + const descriptor = test.group as { + layers: { type: string; layout?: Record }[]; + }; + const expression = createExpression( + descriptor.layers.find((layer) => layer.type === "symbol")?.layout?.[ + "text-variable-anchor-offset" + ], + "layers.ambient-places-labels.layout.text-variable-anchor-offset", + latest.layout_symbol["text-variable-anchor-offset"], + ); + expect(expression.result, JSON.stringify(expression.value)).toBe("success"); + if (expression.result !== "success") throw new Error("Invalid placement policy"); + const positions = (rank: number) => + expression.value.evaluate({ zoom: 16 }, { type: "Point", properties: { rank }, geometry: [] }) + .values; + expect(positions(2559)).toHaveLength(16); + expect(positions(2059)).toHaveLength(2); + expect(positions(3059)).toHaveLength(2); + expect(test.attribution).toHaveBeenLastCalledWith("overlay-ambient-places", [ + "osm-ambient-places", + ]); act(() => useAmbientPlacesStore.setState({ layerVisible: false })); await waitFor(() => expect(test.group).toBeNull()); view.unmount(); expect(test.map.off).toHaveBeenCalled(); }); + it("keeps supported contributor credits for a combined publication and clears them on hide", async () => { + const combined = { + ...manifest, + sources: { + ...manifest.sources, + overture: { + region: "Germany", + release: "2026-09-23.1", + publishedAt: new Date().toISOString(), + count: 1, + }, + }, + }; + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => ({ manifest: combined }) })), + ); + render(); + await waitFor(() => expect(test.group).not.toBeNull()); + const sources = test.attribution.mock.lastCall?.[1] as string[]; + expect(sources).toEqual( + expect.arrayContaining(["osm-ambient-places", "overture", "foursquare"]), + ); + act(() => useAmbientPlacesStore.setState({ layerVisible: false })); + expect(test.attribution).toHaveBeenLastCalledWith("overlay-ambient-places", []); + }); it("ignores late discovery after disable and leaves no-data browsing intact", async () => { let resolve!: (value: unknown) => void; vi.stubGlobal( @@ -152,6 +229,46 @@ describe("ambient generation lifecycle", () => { }); expect(test.map.setFilter.mock.calls.length).toBeGreaterThan(prior); }); + it("shares a real worship identity with a religion-subclass basemap tap and hides its ambient copy", async () => { + const place = { + type: "Feature", + geometry: { type: "Point", coordinates: [6.6933392733335495, 51.19904645716551] }, + properties: { + id: "osm:way/28562993", + name: "Quirinus-Münster", + category: "place_of_worship", + rank: 2559, + min_zoom: 14, + tenant: false, + sources: "osm", + }, + } as MapGeoJSONFeature; + const base = { + id: 285629932, + source: "openmaptiles", + sourceLayer: "poi", + layer: { id: "poi-level-3" }, + geometry: { type: "Point", coordinates: [6.693227291107178, 51.19902166658045] }, + properties: { name: "Quirinus-Münster", class: "place_of_worship", subclass: "christian" }, + } as MapGeoJSONFeature; + test.map.getStyle.mockReturnValue({ + layers: [ + { id: "poi-level-3", type: "symbol", source: "openmaptiles", "source-layer": "poi" }, + ], + }); + test.map.querySourceFeatures.mockReturnValue([place]); + test.map.queryRenderedFeatures.mockReturnValue([base]); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => ({ manifest }) })), + ); + render(); + await waitFor(() => expect(test.group).not.toBeNull()); + expect(getAmbientIdentity(test.map as unknown as MapLibreMap, base)?.id).toBe( + place.properties.id, + ); + expect(JSON.stringify(test.map.setFilter.mock.calls.at(-1))).toContain(place.properties.id); + }); it("removes a disabled publication without resurrecting the previous generation", async () => { vi.stubGlobal( "fetch", diff --git a/integrations/overlay-ambient-places/map-layer.tsx b/integrations/overlay-ambient-places/map-layer.tsx index 800b56d86..c5643e79d 100644 --- a/integrations/overlay-ambient-places/map-layer.tsx +++ b/integrations/overlay-ambient-places/map-layer.tsx @@ -27,14 +27,18 @@ import { useMap } from "@/integration-api/map/MapContext"; import { getMapClickOwner } from "@/integration-api/map/mapClickOwnership"; import type { MapLayerGroup } from "@/integration-api/map/mapLayerGroup"; import { useMapLayerGroup } from "@/integration-api/map/useMapLayerGroup"; -import { useIntegrationAttribution } from "@/integration-api/overlay/useIntegrationAttribution"; +import { useIntegrationSourceAttributions } from "@/integration-api/overlay/useIntegrationAttribution"; import { useEnv } from "@/integration-api/runtime/EnvProvider"; +import integrationManifest from "./manifest.json"; import { useAmbientPlacesStore } from "./store"; export const AMBIENT_SOURCE = "ambient-places-source"; export const AMBIENT_POINT_LAYER = "ambient-places-points"; export const AMBIENT_LABEL_LAYER = "ambient-places-labels"; const LAYERS = [AMBIENT_POINT_LAYER, AMBIENT_LABEL_LAYER]; +const OSM_SOURCES = ["osm-ambient-places"]; +const COMBINED_SOURCES = integrationManifest.dataSources.map((source) => source.sourceId); +const NO_SOURCES: string[] = []; function fromFeature( feature: Pick, ): AmbientPlace | null { @@ -115,7 +119,10 @@ export function AmbientPlacesLayer() { }, [shown, env.apiUrl, publication]); const visible = shown && usable(manifest); - useIntegrationAttribution("overlay-ambient-places", visible); + useIntegrationSourceAttributions( + "overlay-ambient-places", + visible ? (manifest?.sources.overture ? COMBINED_SOURCES : OSM_SOURCES) : NO_SOURCES, + ); const group = useMemo(() => { if (!visible || !manifest) return null; return { @@ -156,8 +163,34 @@ export function AmbientPlacesLayer() { "text-field": ["coalesce", ["get", `name_${locale.split("-")[0]}`], ["get", "name"]], "text-font": ["Noto Sans Regular"], "text-size": 11, - "text-anchor": "left", - "text-offset": [0.6, 0], + // Only corroborated landmarks spend the eight-position placement + // budget; ordinary destinations keep one restrained label position. + "text-variable-anchor-offset": [ + "case", + ["all", [">=", ["get", "rank"], 2500], ["<", ["get", "rank"], 2600]], + [ + "literal", + [ + "left", + [1, 0], + "right", + [-1, 0], + "top", + [0, 1], + "bottom", + [0, -1], + "top-left", + [Math.SQRT1_2, Math.SQRT1_2], + "top-right", + [-Math.SQRT1_2, Math.SQRT1_2], + "bottom-left", + [Math.SQRT1_2, -Math.SQRT1_2], + "bottom-right", + [-Math.SQRT1_2, -Math.SQRT1_2], + ], + ], + ["literal", ["left", [0.6, 0]]], + ], "symbol-sort-key": ["-", ["get", "rank"]], "text-allow-overlap": false, "text-ignore-placement": false, @@ -204,7 +237,12 @@ export function AmbientPlacesLayer() { key: ambientBasemapKey(feature), name, coordinates: feature.geometry.coordinates as [number, number], - category: String(feature.properties?.subclass ?? feature.properties?.class ?? ""), + // Worship subclasses describe religion, not the destination category. + category: String( + feature.properties?.class === "place_of_worship" + ? feature.properties.class + : (feature.properties?.subclass ?? feature.properties?.class ?? ""), + ), osmId: typeof osmId === "string" && /^(node|way|relation)$/.test(osmType) ? `osm:${osmType}/${osmId}` diff --git a/integrations/overlay-ambient-places/theme-suppression.test.tsx b/integrations/overlay-ambient-places/theme-suppression.test.tsx index 2a3c1772c..e943c37b5 100644 --- a/integrations/overlay-ambient-places/theme-suppression.test.tsx +++ b/integrations/overlay-ambient-places/theme-suppression.test.tsx @@ -14,7 +14,7 @@ vi.mock("@/integration-api/runtime/EnvProvider", () => ({ useEnv: () => ({ apiUrl: "http://fixture" }), })); vi.mock("@/integration-api/overlay/useIntegrationAttribution", () => ({ - useIntegrationAttribution: vi.fn(), + useIntegrationSourceAttributions: vi.fn(), })); vi.mock("@/lib/useExploreReachResults", () => { const result = { filtered: [] }; diff --git a/packages/core/src/ambient-places.test.ts b/packages/core/src/ambient-places.test.ts index c694d286a..e057d8b09 100644 --- a/packages/core/src/ambient-places.test.ts +++ b/packages/core/src/ambient-places.test.ts @@ -51,6 +51,19 @@ describe("ambient place policy", () => { expect(ambientPlaceToCategoryPlace(place, "fr").name).toBe("Klinik"); expect(ambientPlaceFromOsm({ ...osm, name: "a".repeat(121) })!.name).toHaveLength(120); }); + it("does not turn uncategorized indexed roads or buildings into ambient destinations", () => { + expect( + ambientPlaceFromOsm({ + ...osm, + category: null, + tags: { name: "Industriestraße", loc_name: "Hafenmole", highway: "unclassified" }, + }), + ).toBeNull(); + expect(ambientPlaceFromOsm({ ...osm, category: "", tags: { building: "yes" } })).toBeNull(); + expect( + ambientPlaceFromOsm({ ...osm, category: "amenity/library", tags: { building: "yes" } }), + ).not.toBeNull(); + }); it("excludes closed/private OSM and uncertain/closed Overture", () => { for (const tags of [ { disused: "yes" }, @@ -163,6 +176,26 @@ describe("ambient place policy", () => { expect(matchAmbientBasemap([a], [label]).size).toBe(0); expect(matchAmbientBasemap([a], [{ ...label, osmId: a.id }]).get(label.key)?.id).toBe(a.id); }); + it("matches alternate landmark representative points within ten metres conservatively", () => { + const a = { + ...ambientPlaceFromOsm(osm)!, + id: "osm:way/28562993", + name: "Quirinus-Münster", + names: {}, + category: "place_of_worship", + coordinates: [6.6933392733335495, 51.19904645716551] as [number, number], + }; + const label = { + key: "base/quirinus", + name: a.name, + category: "place_of_worship", + coordinates: [6.693227291107178, 51.19902166658045] as [number, number], + }; + expect(matchAmbientBasemap([a], [label]).get(label.key)?.id).toBe(a.id); + expect(matchAmbientBasemap([a, { ...a, id: "osm:way/2" }], [label]).size).toBe(0); + expect(matchAmbientBasemap([{ ...a, tenant: true }], [label]).size).toBe(0); + expect(matchAmbientBasemap([a], [{ ...label, coordinates: [6.6931, 51.199] }]).size).toBe(0); + }); it("matches unique nearby compatible basemap labels and refuses branches and tenants", () => { const a = ambientPlaceFromOsm(osm)!; const label = { diff --git a/packages/core/src/ambient-places.ts b/packages/core/src/ambient-places.ts index 84cc8db95..642fb5a7b 100644 --- a/packages/core/src/ambient-places.ts +++ b/packages/core/src/ambient-places.ts @@ -206,6 +206,7 @@ export function ambientPlaceFromOsm(row: AmbientOsmRow): AmbientPlace | null { const name = label(row.name); if ( !name || + !row.category?.trim() || !point(row.lng, row.lat) || !/^(node|way|relation)$/.test(row.osm_type) || !/^\d+$/.test(row.osm_id) || @@ -361,7 +362,8 @@ export function matchAmbientBasemap( (p) => l.category && ambientCategory(l.category) === p.category && - haversineDistance(p.coordinates, l.coordinates) <= 8, + // OSM outlines and vector-tile points can use slightly different representatives. + haversineDistance(p.coordinates, l.coordinates) <= 10, ); if (candidates.length === 1) matches.set(l.key, candidates[0]); } diff --git a/services/data-manager/__tests__/ambient-places/extractor-postgres.test.ts b/services/data-manager/__tests__/ambient-places/extractor-postgres.test.ts index 66597f382..399585d84 100644 --- a/services/data-manager/__tests__/ambient-places/extractor-postgres.test.ts +++ b/services/data-manager/__tests__/ambient-places/extractor-postgres.test.ts @@ -67,6 +67,6 @@ describe.skipIf(process.env.OPENMAPX_RUN_DATABASE_TESTS !== "1")( await pg.stop(); rmSync(dir, { recursive: true, force: true }); } - }); + }, 120_000); }, ); From 6a3c4fbf0a8e88be64cada011461af6e91c35453 Mon Sep 17 00:00:00 2001 From: Florian Date: Fri, 9 Oct 2026 07:42:14 +0200 Subject: [PATCH 13/13] fix(map): align ambient places with basemap cartography --- .../components/map/ambientPlaceStyle.test.ts | 48 +++ .../src/components/map/ambientPlaceStyle.ts | 191 +++++++++++ .../src/integration-api/map/ambientPlaces.ts | 1 + .../developer/ambient-places-acceptance.json | 303 +++++++++++++++++- .../developer/ambient-places-publication.md | 61 +++- .../docs/developer/map-comparison-baseline.md | 6 + .../overlay-ambient-places/map-layer.test.tsx | 158 ++++++++- .../overlay-ambient-places/map-layer.tsx | 113 +++---- .../theme-suppression.test.tsx | 4 +- 9 files changed, 795 insertions(+), 90 deletions(-) create mode 100644 apps/web/src/components/map/ambientPlaceStyle.test.ts create mode 100644 apps/web/src/components/map/ambientPlaceStyle.ts diff --git a/apps/web/src/components/map/ambientPlaceStyle.test.ts b/apps/web/src/components/map/ambientPlaceStyle.test.ts new file mode 100644 index 000000000..cd3d4862a --- /dev/null +++ b/apps/web/src/components/map/ambientPlaceStyle.test.ts @@ -0,0 +1,48 @@ +import { createRequire } from "node:module"; +import type { SymbolLayerSpecification } from "maplibre-gl"; +import { describe, expect, it } from "vitest"; +import { ambientPlaceStyle } from "./ambientPlaceStyle"; + +const require = createRequire(import.meta.resolve("maplibre-gl/package.json")); +const { createExpression } = require("@maplibre/maplibre-gl-style-spec"); + +describe("ambient cartography with a hosted style", () => { + it("adapts legacy sprite tokens to publication categories while keeping the provider's assets", () => { + const native: SymbolLayerSpecification = { + id: "hosted-poi-label", + type: "symbol", + source: "provider", + "source-layer": "poi", + layout: { + "text-field": "{name}", + "icon-image": "{class}_{subclass}", + "text-font": ["Provider Sans"], + "text-size": 13, + }, + paint: { "text-color": "#806040", "text-halo-color": "#fafafa" }, + }; + const result = ambientPlaceStyle([native], false, false); + expect(result.layout?.["text-font"]).toEqual(["Provider Sans"]); + expect(result.paint).toEqual(native.paint); + const expression = createExpression( + result.layout?.["icon-image"], + "layers.ambient.layout.icon-image", + ); + expect(expression.result).toBe("success"); + expect( + String( + expression.value.evaluate( + { zoom: 16 }, + { type: "Point", properties: { category: "station" } }, + ), + ), + ).toBe("railway_station"); + expect(JSON.stringify(result)).not.toContain("poi-railway"); + }); + it("keeps literals intact and uses a neutral label when no POI template exists", () => { + const result = ambientPlaceStyle([], false, true); + expect(result.layout?.["icon-image"]).toBeUndefined(); + expect(result.paint?.["text-color"]).toBe("#9aa0a6"); + expect(result.layout?.["icon-allow-overlap"]).toBe(false); + }); +}); diff --git a/apps/web/src/components/map/ambientPlaceStyle.ts b/apps/web/src/components/map/ambientPlaceStyle.ts new file mode 100644 index 000000000..2caa094a4 --- /dev/null +++ b/apps/web/src/components/map/ambientPlaceStyle.ts @@ -0,0 +1,191 @@ +import type { + ExpressionSpecification, + LayerSpecification, + SymbolLayerSpecification, +} from "maplibre-gl"; + +// Publication categories use OSM/Overture names. Rendering alone adapts these +// to OpenMapTiles classes; canonical identities and published data stay intact. +const classAliases: Record = { + doctor: "doctors", + supermarket: "grocery", + department_store: "grocery", + marketplace: "grocery", + clothes: "clothing_store", + alcohol: "alcohol_shop", + townhall: "town_hall", + community_centre: "town_hall", + courthouse: "town_hall", + post_office: "post", + parcel_locker: "post", + post_box: "post", + kindergarten: "school", + university: "college", + station: "railway", + tram_stop: "railway", + bus_stop: "bus", + bus_station: "bus", + aerodrome: "airport", + charging_station: "fuel", + gas_station: "fuel", + car_repair: "car", + hotel: "lodging", + hostel: "lodging", + motel: "lodging", + guest_house: "lodging", + gallery: "art_gallery", + archaeological_site: "monument", + ruins: "castle", + fitness_centre: "sports_centre", + food_court: "fast_food", + nightclub: "bar", + books: "library", +}; + +const shopSubclasses = [ + "convenience", + "kiosk", + "beauty", + "gift", + "jewelry", + "furniture", + "mall", + "florist", + "hardware", + "doityourself", + "shoes", + "mobile_phone", + "pet", + "ticket", + "travel_agency", + "garden_centre", + "electronics", + "computer", + "massage", + "tattoo", + "cosmetics", + "perfumery", + "tailor", + "accessories", + "motorcycle", + "locksmith", + "watches", + "paint", + "fabric", + "chemist", + "optician", + "hearing_aids", + "coffee", + "chocolate", + "confectionery", +]; +const categoryClass = [ + "match", + ["get", "category"], + shopSubclasses, + "shop", + ...Object.entries(classAliases).flat(), + ["get", "category"], +] as ExpressionSpecification; + +/** Remap only feature lookups, leaving literal labels and sprite names alone. */ +function adapt(value: unknown): unknown { + if (!Array.isArray(value) || value[0] === "literal") return value; + if (value[0] === "get" && value.length === 2) { + if (value[1] === "class") return categoryClass; + if (value[1] === "subclass") return ["get", "category"]; + } + return value.map(adapt); +} + +/** Legacy hosted styles use token strings rather than feature expressions. */ +function adaptSpriteTokens(value: string): string | ExpressionSpecification { + if (!/\{(?:class|subclass)\}/.test(value)) return value; + return [ + "concat", + ...value + .split(/(\{(?:class|subclass)\})/) + .filter(Boolean) + .map((part) => + part === "{class}" ? categoryClass : part === "{subclass}" ? ["get", "category"] : part, + ), + ] as ExpressionSpecification; +} + +/** Borrow the active style's assets and cartography, including hosted styles. */ +export function ambientPlaceStyle( + layers: readonly LayerSpecification[], + landmark: boolean, + dark: boolean, +): Pick { + const ordinary = + layers.find((layer) => layer.id === "poi-level-1" && layer.type === "symbol") ?? + layers.find( + (layer) => + layer.type === "symbol" && layer["source-layer"] === "poi" && layer.layout?.["text-field"], + ); + const template = landmark + ? (layers.find((layer) => layer.id === "poi-landmark" && layer.type === "symbol") ?? ordinary) + : ordinary; + const symbol = template?.type === "symbol" ? template : undefined; + const layout = symbol?.layout ?? {}; + return { + layout: { + // Keep unrelated basemap visibility, filters and sort keys out of this + // source. Placement and zoom eligibility remain publication-owned. + "icon-image": (typeof layout["icon-image"] === "string" + ? adaptSpriteTokens(layout["icon-image"]) + : adapt(layout["icon-image"])) as NonNullable< + SymbolLayerSpecification["layout"] + >["icon-image"], + "icon-size": adapt(layout["icon-size"] ?? 1) as NonNullable< + SymbolLayerSpecification["layout"] + >["icon-size"], + "icon-padding": layout["icon-padding"] ?? 2, + "text-font": layout["text-font"] ?? ["Noto Sans Regular"], + "text-size": layout["text-size"] ?? 12, + "text-max-width": landmark ? 6 : (layout["text-max-width"] ?? 9), + "text-padding": layout["text-padding"] ?? 2, + "text-anchor": "top", + "text-variable-anchor-offset": [ + "literal", + landmark + ? [ + "top", + [0, 1.3], + "bottom", + [0, -1.3], + "left", + [1.3, 0], + "right", + [-1.3, 0], + "top-left", + [0.92, 0.92], + "top-right", + [-0.92, 0.92], + ] + : ["top", [0, 1.05]], + ], + "icon-allow-overlap": false, + "icon-ignore-placement": false, + "text-allow-overlap": false, + "text-ignore-placement": false, + // Never show an unlabelled badge. A landmark name can still fit when + // its larger badge collides; the name must pass normal text placement. + "icon-optional": landmark, + "text-optional": false, + }, + paint: { + ...(symbol?.paint + ? Object.fromEntries( + Object.entries(symbol.paint).map(([key, value]) => [key, adapt(value)]), + ) + : { + "text-color": dark ? "#9aa0a6" : "#5f6368", + "text-halo-color": dark ? "rgba(11, 15, 20, 0.85)" : "rgba(255,255,255,0.8)", + "text-halo-width": 1, + "text-halo-blur": 0.5, + }), + }, + }; +} diff --git a/apps/web/src/integration-api/map/ambientPlaces.ts b/apps/web/src/integration-api/map/ambientPlaces.ts index b69b58276..805e60336 100644 --- a/apps/web/src/integration-api/map/ambientPlaces.ts +++ b/apps/web/src/integration-api/map/ambientPlaces.ts @@ -4,5 +4,6 @@ export { clearAmbientIdentities, setAmbientIdentities, } from "@/components/map/ambientPlaceIdentity"; +export { ambientPlaceStyle } from "@/components/map/ambientPlaceStyle"; export { getStylePoiLayerIds } from "@/components/map/mapStylePoiTarget"; export { useExploreReachResults as useActivePlaceResults } from "@/lib/useExploreReachResults"; diff --git a/docs/docs/developer/ambient-places-acceptance.json b/docs/docs/developer/ambient-places-acceptance.json index cdee43770..d4a1b2a2d 100644 --- a/docs/docs/developer/ambient-places-acceptance.json +++ b/docs/docs/developer/ambient-places-acceptance.json @@ -1618,5 +1618,306 @@ "landmarkPlacementCandidates": 6, "integrationBase": "78e099076", "integrationQualification": "Final delivery integrates origin/main at 78e099076. Recorded regional rendering/tile measurements used production assets before this sync (Next 16.3.6); the measured ambient/localization/provider/store code hashes are unchanged. Final integration is checked by clean Next 16.3.8 and API/data-manager/docs builds, types, regression/database and full suites. Captures are not a deployment or all-host-load guarantee.", - "finalReviewCaptureDate": "2026-10-09" + "finalReviewCaptureDate": "2026-10-09", + "visualCartographyFollowup": { + "date": "2026-10-09", + "parentRevision": "c40fb28de99447536e28ed85da8bca7f84ef03eb", + "scope": "Cartographic correction; same source hash and policy, fresh disposable generation, production rendering", + "publication": { + "index": { + "region": "europe/germany", + "epoch": "91ac6693-eda0-47fa-9d2e-0dffc9438b05", + "sourceFingerprint": "sha256:c18cc77ed3493aa186bd7659fee4a9b028fbc479c2a8a90f219a3b2d33824d75", + "placeCount": 14232, + "termCount": 16052 + }, + "indexedMs": 17182.270541, + "publishedMs": 498.3120000000017, + "manifest": { + "version": 1, + "policyVersion": 2, + "generation": "d90ce37e-f37a-49ba-8143-97a18674fbd5", + "publishedAt": "2026-10-09T05:17:29.475Z", + "region": { + "name": "Rhine regional acceptance", + "bounds": [ + 6.58, + 50.89, + 7.07, + 51.31 + ] + }, + "placeCount": 13454, + "enabled": true, + "sources": { + "osm": { + "region": "europe/germany", + "epoch": "91ac6693-eda0-47fa-9d2e-0dffc9438b05", + "publishedAt": "2026-10-09T05:17:29.232Z", + "count": 13454 + }, + "overture": null + } + }, + "landmarks": [ + { + "id": "osm:way/28562993", + "name": "Quirinus-Münster", + "category": "place_of_worship", + "rank": 2559, + "min_zoom": 14, + "tenant": false + }, + { + "id": "osm:way/4532022", + "name": "Kölner Dom", + "category": "place_of_worship", + "rank": 2559, + "min_zoom": 14, + "tenant": false + } + ] + }, + "runtime": { + "next": "16.3.8", + "maplibre": "6.10.0", + "desktop": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Nightly)/0.0.46-nightly.20261008.2833 Chrome/152.0.7977.130 Electron/44.4.2 Safari/537.36", + "nativeSafari": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/27.0 Mobile/15E148 Safari/604.1" + }, + "codeFiles": [ + { + "file": "integrations/overlay-ambient-places/map-layer.tsx", + "sha256": "03d719ac7d367c69a506a76246205fe2cd5a778b197357d534b2532c035d0333" + }, + { + "file": "apps/web/src/components/map/ambientPlaceStyle.ts", + "sha256": "4afa6ab840351260cc7eaef23b62ae5412a28dcf6943fc4a03bbf3c94c4bcdaf" + }, + { + "file": "apps/web/src/components/map/localizeTextField.ts", + "sha256": "ce0cf960ad810b3b6194835e70063433de9b97cee28cdb72497c56fc036ac97a" + }, + { + "file": "apps/web/public/styles/openmapx-streets.json", + "sha256": "4103b56380c9f700cdc320f48fbb9fb7b716fa5006339af80818c9761971826d" + }, + { + "file": "apps/web/public/styles/openmapx-dark.json", + "sha256": "f24217e849e4256c7ced9749b1cdc0ed6feb0bd3d0e0a249ef43e66bff8acf22" + } + ], + "screenshotComparison": "Ambient enabled on both sides: initial teal circles versus active-basemap POI cartography; identical publication/camera/style/locale per pair. QA controls are temporary.", + "screenshots": [ + { + "file": "before-desktop.png", + "sha256": "88dff8cd86b8ec0bae946dbb6ae58a4e712c337dba2443bd66e4cf33e4c28e15" + }, + { + "file": "after-desktop.png", + "sha256": "7aee74f585ed17b438962a9747c01b46dfcd81bbe22d0b8404c65364a58f0cd8" + }, + { + "file": "before-dark.png", + "sha256": "e10ddb8cfcb690590587f46f9512bc0a8c2d96a3b20f13bb8edc13626fdc5260" + }, + { + "file": "after-dark.png", + "sha256": "22db872a54bcce9718e5a9294b47b3f752c8afe0ee33ca10a238c0d41a6f9fd4" + }, + { + "file": "ios-before.png", + "sha256": "6e9937b48a125f854aee21c6f8ab8b7eabf79674acab71a8871b437f7a033bae" + }, + { + "file": "ios-after.png", + "sha256": "7842bea62800eb7ca62c236f97d1422d9cbe6aec2e995aa0e5e521cbc27d7cd5" + } + ], + "fixedCameraViews": { + "pairs": 45, + "viewport": [ + 430, + 932 + ], + "basemapIdentityMismatches": 0, + "roadListMismatches": 0, + "duplicateAmbientIds": 0, + "circleLayerPresent": false, + "cameras": [ + "Aachen", + "Berlin", + "Cologne", + "Cologne dark German", + "Monschau", + "Neuss", + "Neuss dark English", + "Rural Rhine", + "Zons" + ] + }, + "interaction": { + "generation": "d90ce37e-f37a-49ba-8143-97a18674fbd5", + "realPointerTap": "osm:way/28562993", + "detailsSelectedCanonicalId": "osm:way/28562993", + "selectedAmbientCopyVisible": false, + "darkStyleHalo": "rgba(11, 15, 20, 0.85)", + "darkStyleFont": [ + "Noto Sans Bold" + ], + "normalClickPriorityRegressions": true, + "realBasemapTap": "osm:way/28562993", + "basemapMatchedAmbientCopyVisible": false + }, + "placement": { + "ordinaryCandidates": 1, + "landmarkCandidates": 6, + "landmarkWrapEm": 6, + "ordinaryIconOptional": false, + "landmarkIconOptional": true, + "textOptional": false, + "forcedOverlap": false + }, + "frames": { + "method": "Five foreground 5-second easeTo sweeps per state, alternating Neuss/Cologne. No build/test jobs during measurements. T3 device stream paused and desktop WebGL page closed for native captures.", + "desktop1280x800": { + "off": { + "sweeps": 5, + "frames": 2986, + "pooledP95Ms": 10.1, + "over50Percent": 0.0, + "individualP95Ms": [ + 10.3, + 10.3, + 10.1, + 10, + 9.6 + ] + }, + "on": { + "sweeps": 5, + "frames": 2975, + "pooledP95Ms": 10.1, + "over50Percent": 0.0, + "individualP95Ms": [ + 10.1, + 10.3, + 10.1, + 10, + 9.8 + ] + } + }, + "iosInitial": { + "off": { + "sweeps": 5, + "frames": 1046, + "pooledP95Ms": 36, + "over50Percent": 1.052, + "individualP95Ms": [ + 32, + 34, + 32, + 54, + 32 + ] + }, + "on": { + "sweeps": 5, + "frames": 1075, + "pooledP95Ms": 34, + "over50Percent": 0.186, + "individualP95Ms": [ + 32, + 35, + 32, + 35, + 32 + ] + } + }, + "iosRepeat": { + "off": { + "sweeps": 5, + "frames": 1100, + "pooledP95Ms": 33, + "over50Percent": 0.273, + "individualP95Ms": [ + 31, + 35, + 31, + 33, + 30 + ] + }, + "on": { + "sweeps": 5, + "frames": 1082, + "pooledP95Ms": 33, + "over50Percent": 0.0, + "individualP95Ms": [ + 32, + 36, + 32, + 34, + 32 + ] + } + }, + "iosAllSamples": { + "off": { + "sweeps": 10, + "frames": 2146, + "pooledP95Ms": 34, + "over50Percent": 0.652, + "individualP95Ms": [ + 32, + 34, + 32, + 54, + 32, + 31, + 35, + 31, + 33, + 30 + ] + }, + "on": { + "sweeps": 10, + "frames": 2157, + "pooledP95Ms": 34, + "over50Percent": 0.093, + "individualP95Ms": [ + 32, + 35, + 32, + 35, + 32, + 32, + 36, + 32, + 34, + 32 + ] + } + }, + "qualification": "Initial native sample missed the unchanged 33.4 ms p95 target in both states. Repeat passed at 33/33 ms, on >50ms 0%. All initial/repeat samples retained; this is local simulator evidence, not physical-device certification.", + "p95TargetMs": 33.4 + }, + "rawCaptureEvidence": [ + { + "file": "frames.jsonl", + "sha256": "313abcbce2c61ead4e94732f613f1891717b7b5a5c78674dd266b6fbf1ce5eb1" + }, + { + "file": "matrix-final.json", + "sha256": "88d5dda2ea4ad732470cffab9381f87f9fe887b13bb50f1703eeb14105c2b0fd" + }, + { + "file": "interaction-final.json", + "sha256": "451c6aba0778f9bb8cb110102d14ddae67efe5250277c6abfef5110546338798" + } + ], + "codeQualification": "A post-capture lint rationale comment preserves the tested style-version dependency; rendering behavior is unchanged." + } } diff --git a/docs/docs/developer/ambient-places-publication.md b/docs/docs/developer/ambient-places-publication.md index 31f7096dd..553654e4c 100644 --- a/docs/docs/developer/ambient-places-publication.md +++ b/docs/docs/developer/ambient-places-publication.md @@ -146,6 +146,24 @@ contributors' license/copyright notices. It does not infer a single license for the conflated dataset. Tiles are rendered locally; there are no request-time calls to Overture or its contributors. +## Cartographic integration + +Ambient destinations borrow their category badges, label colors, fonts, sizes and +halos from the active basemap's POI layers. The owned light/dark styles therefore +share the same sprite assets and visual vocabulary; hosted styles keep their own +assets. Only the rendering adapter translates publication categories (for example +`doctor` to `doctors` or `supermarket` to `grocery`). It does not change canonical +IDs, ranking, source coverage or the published tile schema. + +Ordinary destinations use one label position below the badge. Corroborated +landmarks use the basemap's bold landmark typography, a compact six-em wrap width +and six collision-safe candidate positions. If a landmark badge cannot fit, its +name can still appear when text placement succeeds. An unlabelled badge is never +shown. Neither icons nor text force overlap or ignore placement. Existing +basemap road/transit labels retain priority, and selection/category/basemap +identity suppression applies independently to both symbol partitions. Basemap +style reloads refresh the borrowed cartography and suppression filters. + ## Serving comparison and decision Both alternatives use the same canonical policy and MVT content: @@ -204,7 +222,8 @@ Official references: [PostGIS MVT](https://postgis.net/docs/ST_AsMVT.html), [Martin configuration](https://maplibre.org/martin/config-file/), and [PMTiles CLI conversion/verification](https://docs.protomaps.com/pmtiles/cli). -A native-browser development check at 1280 × 800 used 1,006 synthetic places +An earlier native-browser development check of the initial dot/label renderer at +1280 × 800 used 1,006 synthetic places around Aachen. At zoom 16, 908 rendered point features competed for 83 visible labels; MapLibre collision placement reduced the labels without reducing the underlying places. A short 1.8-second pan/zoom sweep with the overlay had a median @@ -264,18 +283,17 @@ The [aggregate acceptance evidence](https://github.com/OpenMapX/openmapx/blob/ma source hashes, runtime/settings, measured targets, fixed camera outcomes and external screenshot checksums. It contains no screenshot binaries or credentials. Selected real before/after and iOS screenshots are attached directly to PR #436. -Final static views, images, tile reads and frame measurements use generation +The earlier static views, images, tile reads and frame measurements use generation `59337707-c217-4398-966c-3c21ac8237b1`, containing the same 13,454 places and checksummed source. Earlier generations and measurements remain explicitly identified in the artifact. T3 preview handled initial static/frame QA; after `preview_open` explicitly reported unavailable, isolated foreground system Chrome completed desktop/phone-CSS frames, the static matrix and desktop/admin images. Native iOS screenshots and frames use Safari through the T3 device tools. -No headless frame measurements are claimed. -The final branch integrates newer main and Next 16.3.8 after these measurements. -The measured feature files remain checksum-identical; clean final builds and -integration/regression/full-suite checks verify that integration. The artifact -retains the exact measured runtime rather than relabeling it as a deployment. +No headless frame measurements are claimed in that run. +The branch then integrated newer main and Next 16.3.8. Those historical captures +retain their exact source checksums and runtime. The cartographic follow-up below +identifies the subsequently changed renderer separately. | Regional check | Declared limit | Observed | | ---------------------------------------- | -------------: | ---------: | @@ -355,3 +373,32 @@ every sweep or physical device passes. An earlier forced-`jumpTo`-every-RAF stre workload missed mobile targets in both states; those raw samples remain external and are not relabeled as normal app camera behavior. Physical-device thermal/battery behavior and deployed network/CDN latency remain rollout checks. + +## October 9 cartographic correction + +The separate teal dots and sideways labels did not match the owned map style. +The correction above borrows the active style's POI badges and typography instead. +It republishes the same cropped source into disposable generation +`d90ce37e-f37a-49ba-8143-97a18674fbd5` (13,454 places, unchanged policy 2), and +renders the before/after styling states against that same generation and camera. +The captures use production Next 16.3.8 and MapLibre 6.10.0. T3's native Electron +preview supplies desktop/light/dark verification; Safari on the iPhone 18 Pro +simulator supplies mobile screenshots and its own frame samples. + +The repeated 45 camera pairs at phone CSS dimensions 430 × 932 preserve every +basemap POI identity and road-name list, with zero duplicate ambient IDs. The +zoom-16 Neuss camera retains Quirinus-Münster, including in the native simulator +view. An actual pointer tap on its placed name opens `osm:way/28562993` and hides +its ambient copy. Style replacement refreshes category colors, fonts and halos. +Before/after captures remain PR attachments outside git; their SHA-256, exact +code hashes and all frame samples are qualified separately in +`visualCartographyFollowup` in the aggregate artifact. Earlier dot-renderer +measurements above remain historical evidence. + +Five five-second desktop sweeps per state have pooled frame-interval p95 of +10.1/10.1 ms off/on. The initial native simulator run measured 36/34 ms and +missed the unchanged 33.4 ms target in both states. An isolated repeat measured +33/33 ms, with no on-state intervals above 50 ms. Across both runs the pooled +native p95 is 34/34 ms; individual sweeps and the initial off-state slowdown +remain in the artifact. These measurements establish a qualified local repeat, +not a guarantee for physical devices or competing host load. diff --git a/docs/docs/developer/map-comparison-baseline.md b/docs/docs/developer/map-comparison-baseline.md index 2cd5c1d7e..913ffe002 100644 --- a/docs/docs/developer/map-comparison-baseline.md +++ b/docs/docs/developer/map-comparison-baseline.md @@ -226,5 +226,11 @@ because their names are similar. See [regional publication and measured budgets](ambient-places-publication.md) and its aggregate JSON for source hashes, fixed settings, camera outcomes, read/fallback/rollback evidence and desktop/Safari simulator frame samples. +The October 9 cartographic correction borrows the active basemap's POI badges, +category colors, typography and halos, replacing the separate teal dot/label +renderer. Ordinary names sit below their badges; landmark names use compact +wrapping and may remain when only the badge collides. Icons and text retain +normal collision checks. The new matrix, runtime and screenshots are recorded +separately under `visualCartographyFollowup` in the aggregate artifact. Before/after screenshots are PR attachments outside git. Simulated Safari results must not be presented as physical-device battery, thermal or fleet certification. diff --git a/integrations/overlay-ambient-places/map-layer.test.tsx b/integrations/overlay-ambient-places/map-layer.test.tsx index 9c5c12a1c..e6c206308 100644 --- a/integrations/overlay-ambient-places/map-layer.test.tsx +++ b/integrations/overlay-ambient-places/map-layer.test.tsx @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { resolve } from "node:path"; import { usePlaceStore } from "@openmapx/core"; @@ -8,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const webRequire = createRequire(resolve(process.cwd(), "apps/web/package.json")); const rendererRequire = createRequire(webRequire.resolve("maplibre-gl/package.json")); const { createExpression, latest } = rendererRequire("@maplibre/maplibre-gl-style-spec") as { - latest: { layout_symbol: Record }; + latest: { layout_symbol: Record; paint_symbol: Record }; createExpression: ( value: unknown, rootKey: string, @@ -19,6 +20,9 @@ const { createExpression, latest } = rendererRequire("@maplibre/maplibre-gl-styl evaluate: ( globals: { zoom: number }, feature: { type: string; properties: Record; geometry: never[] }, + state?: unknown, + canonical?: unknown, + images?: string[], ) => unknown; }; }; @@ -26,6 +30,7 @@ const { createExpression, latest } = rendererRequire("@maplibre/maplibre-gl-styl const test = vi.hoisted(() => ({ group: null as unknown, + styleVersion: 0, attribution: vi.fn(), map: { getLayer: vi.fn(() => true), @@ -47,7 +52,7 @@ const test = vi.hoisted(() => ({ })); vi.mock("@/integration-api/map/MapContext", () => { const context = { mapRef: { current: test.map }, mapReady: true, styleVersion: 0 }; - return { useMap: () => context }; + return { useMap: () => ({ ...context, styleVersion: test.styleVersion }) }; }); vi.mock("@/integration-api/map/useMapLayerGroup", () => ({ useMapLayerGroup: (group: unknown) => { @@ -95,6 +100,7 @@ beforeEach(() => { loading: false, }); test.group = null; + test.styleVersion = 0; vi.clearAllMocks(); test.map.querySourceFeatures.mockReturnValue([]); test.map.queryRenderedFeatures.mockReturnValue([]); @@ -106,6 +112,139 @@ afterEach(() => { vi.unstubAllGlobals(); }); describe("ambient generation lifecycle", () => { + it.each(["openmapx-streets.json", "openmapx-dark.json"])( + "uses the active %s POI badges, typography and category colors without uncoupled dots", + async (file) => { + const style = JSON.parse( + readFileSync(resolve(process.cwd(), "apps/web/public/styles", file), "utf8"), + ); + const images = Object.keys( + JSON.parse( + readFileSync(resolve(process.cwd(), "apps/web/public/styles/sprite.json"), "utf8"), + ), + ); + const original = JSON.stringify(style); + test.map.getStyle.mockReturnValue(style); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => ({ manifest }) })), + ); + render(); + await waitFor(() => expect(test.group).not.toBeNull()); + const descriptor = test.group as { + layers: { + id: string; + type: string; + layout: Record; + paint: Record; + filter: unknown; + }[]; + }; + expect(descriptor.layers.every((layer) => layer.type === "symbol")).toBe(true); + const ordinary = descriptor.layers.find((layer) => layer.id === "ambient-places-labels"); + const landmark = descriptor.layers.find((layer) => layer.id === "ambient-places-landmarks"); + if (!ordinary || !landmark) throw new Error("Expected both ambient symbol partitions"); + const native = style.layers.find((layer: { id: string }) => layer.id === "poi-level-1"); + const nativeLandmark = style.layers.find( + (layer: { id: string }) => layer.id === "poi-landmark", + ); + for (const [layer, template] of [ + [ordinary, native], + [landmark, nativeLandmark], + ]) { + expect(layer.layout["text-font"]).toEqual(template.layout["text-font"]); + expect(layer.layout["text-size"]).toEqual(template.layout["text-size"]); + expect(layer.layout["text-max-width"]).toEqual( + layer === landmark ? 6 : template.layout["text-max-width"], + ); + expect(layer.paint["text-halo-color"]).toEqual(template.paint["text-halo-color"]); + expect(layer.paint["text-halo-width"]).toEqual(template.paint["text-halo-width"]); + expect(layer.layout["icon-allow-overlap"]).toBe(false); + expect(layer.layout["text-optional"]).toBe(false); + expect(layer.layout["icon-optional"]).toBe(layer === landmark); + } + const evaluate = ( + value: unknown, + key: string, + properties: Record, + paint = false, + ) => { + const expression = createExpression( + value, + key, + (paint ? latest.paint_symbol : latest.layout_symbol)[key], + ); + expect(expression.result, JSON.stringify(expression.value)).toBe("success"); + return expression.value.evaluate( + { zoom: 16 }, + { type: "Point", properties, geometry: [] }, + undefined, + undefined, + images, + ); + }; + for (const [category, poiClass, subclass] of [ + ["cafe", "cafe", "cafe"], + ["doctor", "doctors", "doctor"], + ["supermarket", "grocery", "supermarket"], + ["townhall", "town_hall", "townhall"], + ["station", "railway", "station"], + ["gallery", "art_gallery", "gallery"], + ["florist", "shop", "florist"], + ["place_of_worship", "place_of_worship", "place_of_worship"], + ["unmapped", "unmapped", "unmapped"], + ]) { + expect(evaluate(ordinary.layout["icon-image"], "icon-image", { category })).toEqual( + evaluate(native.layout["icon-image"], "icon-image", { class: poiClass, subclass }), + ); + expect(evaluate(ordinary.paint["text-color"], "text-color", { category }, true)).toEqual( + evaluate(native.paint["text-color"], "text-color", { class: poiClass, subclass }, true), + ); + } + for (const rank of [2059, 2499, 2500, 2559, 2599, 2600, 3059]) { + for (const layer of [ordinary, landmark]) { + const filter = [...test.map.setFilter.mock.calls] + .reverse() + .find(([id]) => id === layer.id)?.[1]; + const expression = createExpression(filter, `layers.${layer.id}.filter`, { + type: "boolean", + }); + expect(expression.result).toBe("success"); + const matches = expression.value.evaluate( + { zoom: 16 }, + { type: "Point", properties: { rank, min_zoom: 16, id: "osm:node/123" }, geometry: [] }, + ); + expect(matches).toBe( + layer === landmark ? rank >= 2500 && rank < 2600 : rank < 2500 || rank >= 2600, + ); + } + } + const offsets = evaluate( + ordinary.layout["text-variable-anchor-offset"], + "text-variable-anchor-offset", + { rank: 2059 }, + ) as { values: unknown[] }; + expect(offsets.values).toEqual(["top", [0, 1.05]]); + expect(JSON.stringify(style)).toBe(original); + }, + ); + it("refreshes cartography when the basemap style changes without a theme render", async () => { + const style = (file: string) => + JSON.parse(readFileSync(resolve(process.cwd(), "apps/web/public/styles", file), "utf8")); + test.map.getStyle.mockReturnValue(style("openmapx-streets.json")); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => ({ manifest }) })), + ); + const view = render(); + await waitFor(() => expect(test.group).not.toBeNull()); + const light = JSON.stringify(test.group); + test.map.getStyle.mockReturnValue(style("openmapx-dark.json")); + test.styleVersion = 1; + view.rerender(); + expect(JSON.stringify(test.group)).not.toBe(light); + expect(JSON.stringify(test.group)).toContain("rgba(11, 15, 20, 0.85)"); + }); it("keeps supplied ambient translations after production styledata localization", async () => { vi.stubGlobal( "fetch", @@ -114,7 +253,7 @@ describe("ambient generation lifecycle", () => { render(); await waitFor(() => expect(test.group).not.toBeNull()); const descriptor = test.group as { - layers: { type: string; layout?: Record }[]; + layers: { id: string; type: string; layout?: Record }[]; }; let textField = descriptor.layers.find((layer) => layer.type === "symbol")?.layout?.[ "text-field" @@ -162,7 +301,7 @@ describe("ambient generation lifecycle", () => { async (owner) => { const ambient = { type: "Feature", - layer: { id: "ambient-places-points" }, + layer: { id: "ambient-places-labels" }, geometry: { type: "Point", coordinates: [6.08, 50.77] }, properties: { id: "osm:node/1", @@ -225,10 +364,10 @@ describe("ambient generation lifecycle", () => { expect(JSON.stringify(test.group)).toContain(manifest.generation); expect(JSON.stringify(test.group)).toContain('"maxzoom":18'); const descriptor = test.group as { - layers: { type: string; layout?: Record }[]; + layers: { id: string; type: string; layout?: Record }[]; }; const expression = createExpression( - descriptor.layers.find((layer) => layer.type === "symbol")?.layout?.[ + descriptor.layers.find((layer) => layer.id === "ambient-places-landmarks")?.layout?.[ "text-variable-anchor-offset" ], "layers.ambient-places-labels.layout.text-variable-anchor-offset", @@ -243,10 +382,9 @@ describe("ambient generation lifecycle", () => { { type: "Point", properties: { rank }, geometry: [] }, ) as { values: unknown[] } ).values; - // The dense-city placement budget is six candidates for landmarks. + // Landmarks use a separate symbol partition with six candidate positions. expect(positions(2559)).toHaveLength(12); - expect(positions(2059)).toHaveLength(2); - expect(positions(3059)).toHaveLength(2); + expect(test.attribution).toHaveBeenLastCalledWith("overlay-ambient-places", [ "osm-ambient-places", ]); @@ -327,7 +465,7 @@ describe("ambient generation lifecycle", () => { ) as Record void>; test.map.queryRenderedFeatures.mockImplementation((...args: unknown[]) => { const options = args[1] as { layers?: string[] } | undefined; - return options?.layers?.includes("ambient-places-points") ? [feature] : []; + return options?.layers?.includes("ambient-places-labels") ? [feature] : []; }); act(() => handlers().click({ point: { x: 1, y: 2 } })); expect(usePlaceStore.getState().selectedPlace).toMatchObject({ diff --git a/integrations/overlay-ambient-places/map-layer.tsx b/integrations/overlay-ambient-places/map-layer.tsx index 7435e794f..31cff28d8 100644 --- a/integrations/overlay-ambient-places/map-layer.tsx +++ b/integrations/overlay-ambient-places/map-layer.tsx @@ -12,11 +12,17 @@ import { ambientPlaceToCategoryPlace, matchAmbientBasemap, } from "@openmapx/core/ambient-places"; -import type { FilterSpecification, MapGeoJSONFeature, MapMouseEvent } from "maplibre-gl"; +import type { + ExpressionSpecification, + FilterSpecification, + MapGeoJSONFeature, + MapMouseEvent, +} from "maplibre-gl"; import { useLocale } from "next-intl"; import { useEffect, useMemo } from "react"; import { ambientBasemapKey, + ambientPlaceStyle, clearAmbientIdentities, getStylePoiLayerIds, setAmbientIdentities, @@ -25,7 +31,7 @@ import { import { INTERACTIVE_LAYER_IDS } from "@/integration-api/map/interactiveLayers"; import { useMap } from "@/integration-api/map/MapContext"; import { getMapClickOwner } from "@/integration-api/map/mapClickOwnership"; -import type { MapLayerGroup } from "@/integration-api/map/mapLayerGroup"; +import type { MapLayerGroup, SlottedLayer } from "@/integration-api/map/mapLayerGroup"; import { useMapLayerGroup } from "@/integration-api/map/useMapLayerGroup"; import { useIntegrationSourceAttributions } from "@/integration-api/overlay/useIntegrationAttribution"; import { useEnv } from "@/integration-api/runtime/EnvProvider"; @@ -33,9 +39,14 @@ import integrationManifest from "./manifest.json"; import { useAmbientPlacesStore } from "./store"; export const AMBIENT_SOURCE = "ambient-places-source"; -export const AMBIENT_POINT_LAYER = "ambient-places-points"; +export const AMBIENT_LANDMARK_LAYER = "ambient-places-landmarks"; export const AMBIENT_LABEL_LAYER = "ambient-places-labels"; -const LAYERS = [AMBIENT_POINT_LAYER, AMBIENT_LABEL_LAYER]; +const LAYERS = [AMBIENT_LABEL_LAYER, AMBIENT_LANDMARK_LAYER]; +const LANDMARK: ExpressionSpecification = [ + "all", + [">=", ["get", "rank"], 2500], + ["<", ["get", "rank"], 2600], +]; const OSM_SOURCES = ["osm-ambient-places"]; const COMBINED_SOURCES = integrationManifest.dataSources.map((source) => source.sourceId); const NO_SOURCES: string[] = []; @@ -61,7 +72,7 @@ function usable(manifest: AmbientManifest | null): boolean { } export function AmbientPlacesLayer() { const dark = useTheme().palette.mode === "dark"; - const { mapRef, mapReady } = useMap(); + const { mapRef, mapReady, styleVersion } = useMap(); const env = useEnv(); const locale = useLocale(); const shown = useAmbientPlacesStore((s) => s.panelOpen && s.layerVisible); @@ -123,6 +134,7 @@ export function AmbientPlacesLayer() { "overlay-ambient-places", visible ? (manifest?.sources.overture ? COMBINED_SOURCES : OSM_SOURCES) : NO_SOURCES, ); + // biome-ignore lint/correctness/useExhaustiveDependencies: mapReady and styleVersion invalidate cartography read from the mutable mapRef.current style. const group = useMemo(() => { if (!visible || !manifest) return null; return { @@ -135,75 +147,31 @@ export function AmbientPlacesLayer() { maxzoom: AMBIENT_LIMITS.maxZoom, }, }, - layers: [ - { - id: AMBIENT_POINT_LAYER, - type: "circle", + layers: LAYERS.map((id) => { + const landmark = id === AMBIENT_LANDMARK_LAYER; + const style = ambientPlaceStyle(mapRef.current?.getStyle()?.layers ?? [], landmark, dark); + return { + id, + type: "symbol" as const, source: AMBIENT_SOURCE, "source-layer": "ambient_places", minzoom: 13, - slot: "overlay-markers", - order: 35, - paint: { - "circle-radius": 3, - "circle-color": "#287e79", - "circle-stroke-color": "#ffffff", - "circle-stroke-width": 1, - }, - }, - { - id: AMBIENT_LABEL_LAYER, - type: "symbol", - source: AMBIENT_SOURCE, - "source-layer": "ambient_places", - minzoom: 13, - slot: "overlay-markers", - order: 36, + slot: "overlay-markers" as const, + order: landmark ? 36 : 35, + filter: landmark ? LANDMARK : ["!", LANDMARK], layout: { + ...style.layout, "text-field": ["coalesce", ["get", `name_${locale.split("-")[0]}`], ["get", "name"]], - "text-font": ["Noto Sans Regular"], - "text-size": 11, - // Only corroborated landmarks spend the six-position placement - // budget; ordinary destinations keep one restrained label position. - "text-variable-anchor-offset": [ - "case", - ["all", [">=", ["get", "rank"], 2500], ["<", ["get", "rank"], 2600]], - [ - "literal", - [ - "left", - [1, 0], - "right", - [-1, 0], - "top", - [0, 1], - "bottom", - [0, -1], - "top-left", - [Math.SQRT1_2, Math.SQRT1_2], - "top-right", - [-Math.SQRT1_2, Math.SQRT1_2], - ], - ], - ["literal", ["left", [0.6, 0]]], - ], "symbol-sort-key": ["-", ["get", "rank"]], - "text-allow-overlap": false, - "text-ignore-placement": false, - "text-max-width": 12, }, - paint: { - "text-color": dark ? "#a5dcd7" : "#17635e", - "text-halo-color": dark ? "#1c222b" : "#ffffff", - "text-halo-width": 1.5, - }, - }, - ], + paint: style.paint, + }; + }), }; - }, [visible, manifest, env.apiUrl, locale, dark]); + }, [visible, manifest, env.apiUrl, locale, dark, mapRef, mapReady, styleVersion]); useMapLayerGroup(group); - // biome-ignore lint/correctness/useExhaustiveDependencies: dark recreates label paint through the group hook; reinstall suppression immediately after that lifecycle. + // biome-ignore lint/correctness/useExhaustiveDependencies: dark and styleVersion recreate cartography through the group hook; reinstall suppression immediately after that lifecycle. useEffect(() => { const map = mapRef.current; if (!visible || !manifest || !map || !mapReady) return; @@ -254,17 +222,22 @@ export function AmbientPlacesLayer() { const hidden = new Set([...matched.values()].map((p) => p.id)); for (const p of places) if (ambientIdentityKeys(p).some((id) => occupied.has(id))) hidden.add(p.id); - const filter: FilterSpecification = [ + const filter: ExpressionSpecification = [ "all", ["<=", ["get", "min_zoom"], Math.floor(map.getZoom())], ["!", ["in", ["get", "id"], ["literal", [...hidden].sort()]]], ]; - const key = JSON.stringify(filter); // Descriptor/theme reconciliation can recreate one layer without a // style.load. Compare each live layer so its suppression is restored. - for (const id of LAYERS) - if (map.getLayer(id) && JSON.stringify(map.getFilter(id)) !== key) - map.setFilter(id, filter); + for (const id of LAYERS) { + const partition: FilterSpecification = [ + "all", + filter, + id === AMBIENT_LANDMARK_LAYER ? LANDMARK : ["!", LANDMARK], + ]; + if (map.getLayer(id) && JSON.stringify(map.getFilter(id)) !== JSON.stringify(partition)) + map.setFilter(id, partition); + } }; const click = (event: MapMouseEvent) => { if (getMapClickOwner(event)) return; @@ -312,6 +285,6 @@ export function AmbientPlacesLayer() { clearAmbientIdentities(map); for (const id of LAYERS) INTERACTIVE_LAYER_IDS.delete(id); }; - }, [mapRef, mapReady, visible, manifest, locale, occupied, dark]); + }, [mapRef, mapReady, visible, manifest, locale, occupied, dark, styleVersion]); return null; } diff --git a/integrations/overlay-ambient-places/theme-suppression.test.tsx b/integrations/overlay-ambient-places/theme-suppression.test.tsx index e943c37b5..b85085e1d 100644 --- a/integrations/overlay-ambient-places/theme-suppression.test.tsx +++ b/integrations/overlay-ambient-places/theme-suppression.test.tsx @@ -24,7 +24,7 @@ vi.mock("next-intl", () => ({ useLocale: () => "en" })); import { AMBIENT_LABEL_LAYER, - AMBIENT_POINT_LAYER, + AMBIENT_LANDMARK_LAYER, AMBIENT_SOURCE, AmbientPlacesLayer, } from "./map-layer"; @@ -85,7 +85,7 @@ describe("ambient suppression with the real group hook", () => { ); expect(fake.state.counts.addLayer.get(AMBIENT_LABEL_LAYER)).toBeGreaterThan(added); act(() => fake.emit("idle")); - for (const layer of [AMBIENT_POINT_LAYER, AMBIENT_LABEL_LAYER]) + for (const layer of [AMBIENT_LANDMARK_LAYER, AMBIENT_LABEL_LAYER]) expect(JSON.stringify(fake.map.getFilter(layer))).toContain(place.id); const writes = fake.state.counts.setFilter.get(AMBIENT_LABEL_LAYER); act(() => {