diff --git a/.changeset/quiet-ambient-places.md b/.changeset/quiet-ambient-places.md new file mode 100644 index 000000000..c28ced277 --- /dev/null +++ b/.changeset/quiet-ambient-places.md @@ -0,0 +1,9 @@ +--- +"@openmapx/core": minor +--- + +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. + +Keep source-fresh retained GERS snapshot identities resolvable after publication replacement. 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/apps/api/openapi.json b/apps/api/openapi.json index 265049686..66070c8ae 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", @@ -1917,6 +2026,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/__tests__/places.test.ts b/apps/api/src/routes/__tests__/places.test.ts index 08ac3ce21..2145fa226 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; @@ -1504,3 +1509,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/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 c18911aec..b2e2ae3c1 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"; @@ -84,6 +85,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" }); diff --git a/apps/api/src/routes/places.ts b/apps/api/src/routes/places.ts index baab1bf46..57453e512 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/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..b6f1d79f5 --- /dev/null +++ b/apps/web/src/components/admin/services/AmbientPlacesMaintenance.test.tsx @@ -0,0 +1,85 @@ +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", + policyVersion: 2, + 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(); + expect(screen.getByText("Policy 2")).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..02a1ce16d --- /dev/null +++ b/apps/web/src/components/admin/services/AmbientPlacesMaintenance.tsx @@ -0,0 +1,185 @@ +"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 && ( + + + {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 5afce1353..dca2a7110 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, @@ -47,6 +48,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/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/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/localizeTextField.test.ts b/apps/web/src/components/map/localizeTextField.test.ts index a69300add..8ee736022 100644 --- a/apps/web/src/components/map/localizeTextField.test.ts +++ b/apps/web/src/components/map/localizeTextField.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { localizeTextField } from "./localizeTextField"; -const GERMAN = ["coalesce", ["get", "name:de"], ["get", "name"]]; +const GERMAN = ["coalesce", ["get", "name:de"], ["get", "name_de"], ["get", "name"]]; describe("localizeTextField", () => { it("turns a legacy name token into the localized name", () => { @@ -35,7 +35,7 @@ describe("localizeTextField", () => { it("switches language when run again, without nesting", () => { const english = localizeTextField(GERMAN, "en"); - expect(english).toEqual(["coalesce", ["get", "name:en"], ["get", "name"]]); + expect(english).toEqual(["coalesce", ["get", "name:en"], ["get", "name_en"], ["get", "name"]]); expect(localizeTextField(english, "en")).toEqual(english); }); diff --git a/apps/web/src/components/map/localizeTextField.ts b/apps/web/src/components/map/localizeTextField.ts index 4473e9586..d4be2a81b 100644 --- a/apps/web/src/components/map/localizeTextField.ts +++ b/apps/web/src/components/map/localizeTextField.ts @@ -12,12 +12,21 @@ function isNameRead(node: unknown): boolean { /** * Points every name a label reads at the map's language, falling back to the - * plain `name`. Only the name lookups change, so a label that also shows an + * supplied colon or underscore language properties, then plain `name`. + * Only the name lookups change, so a label that also shows an * elevation, or names some features and not others, keeps doing that. * Running it again on its own output gives the same expression. */ export function localizeTextField(textField: TextField, locale: string): TextField { - const localized = ["coalesce", ["get", `name:${locale}`], ["get", "name"]]; + const languages = [...new Set([locale, locale.split("-")[0]])]; + const localized = [ + "coalesce", + ...languages.flatMap((language) => [ + ["get", `name:${language}`], + ["get", `name_${language}`], + ]), + ["get", "name"], + ]; if (typeof textField === "string") return textField.includes("{name") ? localized : textField; if (!Array.isArray(textField)) return textField; if (isNameRead(textField)) return localized; diff --git a/apps/web/src/components/map/mapStylePoiTarget.test.ts b/apps/web/src/components/map/mapStylePoiTarget.test.ts index ae71c623a..53f57a45a 100644 --- a/apps/web/src/components/map/mapStylePoiTarget.test.ts +++ b/apps/web/src/components/map/mapStylePoiTarget.test.ts @@ -34,6 +34,17 @@ function makeHitMap(featuresByLayer: Record) { } describe("mapStylePoiTarget", () => { + it("keeps underscore translations consistent between the label and clicked place", () => { + const map = makeHitMap({ + "poi-label": [ + pointFeature({ name: "Kölner Dom", name_en: "Cologne Cathedral" }, { id: 45320222 }), + ], + }); + expect(findStylePoiAtPoint(map, point, ["poi-label"], new Set(), "en")).toMatchObject({ + name: "Cologne Cathedral", + featureId: "45320222", + }); + }); it("discovers only basemap POI symbol layers", () => { const map = { getStyle: () => ({ diff --git a/apps/web/src/components/map/mapStylePoiTarget.ts b/apps/web/src/components/map/mapStylePoiTarget.ts index 0b5d9d663..631f8ac9e 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]; @@ -39,12 +42,18 @@ export function getStylePoiLayerIds(map: maplibregl.Map): string[] { /** * The name a basemap POI label shows: the map localises every `name` label to - * `name:` with the plain `name` as fallback, so the selected place's + * supplied colon/underscore language fields with plain `name` as fallback, so the selected place's * pin and panel take the same name the map printed beside the icon. */ function displayedName(properties: MapGeoJSONFeature["properties"], locale?: string) { - const localized = locale ? properties?.[`name:${locale}`] : undefined; - if (typeof localized === "string" && localized.length > 0) return localized; + if (locale) { + for (const language of new Set([locale, locale.split("-")[0]])) { + for (const key of [`name:${language}`, `name_${language}`]) { + const localized = properties?.[key]; + if (typeof localized === "string" && localized.length > 0) return localized; + } + } + } const name = properties?.name; return typeof name === "string" && name.length > 0 ? name : null; } @@ -90,7 +99,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..805e60336 --- /dev/null +++ b/apps/web/src/integration-api/map/ambientPlaces.ts @@ -0,0 +1,9 @@ +/** Canonical owned-basemap identity and the current category/reach selection for ambient overlays. */ +export { + ambientBasemapKey, + 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 new file mode 100644 index 000000000..d4a1b2a2d --- /dev/null +++ b/docs/docs/developer/ambient-places-acceptance.json @@ -0,0 +1,1923 @@ +{ + "version": 1, + "date": "2026-10-08", + "scope": "One real OSM-only Rhine region in disposable PostGIS; optional Overture validated with regression fixtures", + "workingTreeFollowup": "Four final-review defects fixed: production translation property forms, enabled-provider canonical GERS details, source-fresh retained snapshot identity, and category/DOM click priority. Six-candidate landmark placement bounds rendering work; tested codeFiles identify exact 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": "817452b3-5447-43e5-aab9-30cb894455a7", + "sourceFingerprint": "sha256:c18cc77ed3493aa186bd7659fee4a9b028fbc479c2a8a90f219a3b2d33824d75", + "placeCount": 14232, + "termCount": 16052 + }, + "indexedMs": 15265.852042000002, + "publishedMs": 654.9927500000013, + "manifest": { + "version": 1, + "policyVersion": 2, + "generation": "59337707-c217-4398-966c-3c21ac8237b1", + "publishedAt": "2026-10-08T21:42:58.742Z", + "region": { + "name": "Rhine regional acceptance", + "bounds": [6.58, 50.89, 7.07, 51.31] + }, + "placeCount": 13454, + "enabled": true, + "sources": { + "osm": { + "region": "europe/germany", + "epoch": "817452b3-5447-43e5-aab9-30cb894455a7", + "publishedAt": "2026-10-08T21:42:58.436Z", + "count": 13454 + }, + "overture": null + } + }, + "landmarks": [ + { + "id": "osm:way/28562993", + "name": "Quirinus-M\u00fcnster", + "category": "place_of_worship", + "rank": 2559, + "min_zoom": 14, + "tenant": false + }, + { + "id": "osm:way/4532022", + "name": "K\u00f6lner 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": 32.84899999999999, + "warm": { + "count": 30, + "p50Ms": 3.6406670000000076, + "p95Ms": 4.53449999999998, + "samplesMs": [ + 6.084041999999954, 3.4137499999999363, 3.4806670000000395, 3.701832999999965, + 2.9012500000000045, 3.9146670000000086, 4.269166999999925, 3.500458000000094, + 4.052290999999968, 3.2850409999999783, 3.4926249999999754, 4.258250000000089, + 3.904333999999949, 3.8790420000000267, 3.0233749999999873, 3.8118330000000924, + 4.245207999999934, 3.185374999999908, 3.924041999999986, 3.867416999999932, + 3.1982499999999163, 3.502458000000047, 3.6406670000000076, 4.330332999999996, + 3.498207999999977, 3.133874999999989, 4.53449999999998, 3.7719170000000304, + 3.095916000000102, 3.4141250000000127 + ] + }, + "concurrentEight": { + "count": 40, + "p95Ms": 47.610041000000024, + "samplesMs": [ + 4.317875000000072, 45.293499999999995, 45.302875000000085, 45.330999999999904, + 47.263374999999996, 47.610041000000024, 48.57779100000005, 52.89637499999992, + 8.213542000000075, 8.454499999999939, 8.267417000000023, 8.399125000000026, + 8.892208000000096, 9.031833000000006, 9.028582999999912, 9.182707999999934, + 7.140209000000027, 7.2488329999999905, 7.298457999999982, 7.274167000000034, + 7.304208000000017, 8.263458000000014, 8.294834000000037, 8.331041000000027, + 13.155124999999998, 13.334625000000074, 13.20291700000007, 13.239667000000054, + 13.24595899999997, 13.332916999999952, 13.274250000000052, 13.410540999999967, + 8.206583000000023, 8.21345800000006, 8.285541999999964, 9.213666999999987, + 9.258208999999965, 9.281083999999964, 9.313208000000031, 9.313540999999987 + ] + }, + "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": 32.84899999999999, + "features": 38, + "bytes": 3120 + }, + { + "z": 14, + "x": 8496, + "y": 5470, + "ms": 5.416583000000003, + "features": 29, + "bytes": 2391 + }, + { + "z": 15, + "x": 16993, + "y": 10941, + "ms": 4.048165999999981, + "features": 11, + "bytes": 1055 + }, + { + "z": 16, + "x": 33986, + "y": 21882, + "ms": 3.4017499999999927, + "features": 27, + "bytes": 2250 + }, + { + "z": 17, + "x": 67972, + "y": 43764, + "ms": 3.380457999999976, + "features": 5, + "bytes": 559 + }, + { + "z": 18, + "x": 135944, + "y": 87529, + "ms": 3.1499579999999696, + "features": 1, + "bytes": 205 + }, + { + "z": 13, + "x": 4254, + "y": 2744, + "ms": 5.70070800000002, + "features": 83, + "bytes": 6881 + }, + { + "z": 14, + "x": 8508, + "y": 5489, + "ms": 4.58379199999996, + "features": 49, + "bytes": 4233 + }, + { + "z": 15, + "x": 17017, + "y": 10978, + "ms": 4.433374999999955, + "features": 32, + "bytes": 2785 + }, + { + "z": 16, + "x": 34034, + "y": 21957, + "ms": 4.7673750000000155, + "features": 38, + "bytes": 3450 + }, + { + "z": 17, + "x": 68069, + "y": 43914, + "ms": 6.498665999999957, + "features": 8, + "bytes": 882 + }, + { + "z": 18, + "x": 136139, + "y": 87828, + "ms": 5.672874999999976, + "features": 2, + "bytes": 358 + }, + { + "z": 13, + "x": 4250, + "y": 2741, + "ms": 4.493583999999942, + "features": 0, + "bytes": 0 + }, + { + "z": 14, + "x": 8500, + "y": 5482, + "ms": 3.6132920000000013, + "features": 0, + "bytes": 0 + }, + { + "z": 15, + "x": 17001, + "y": 10965, + "ms": 5.11566700000003, + "features": 0, + "bytes": 0 + }, + { + "z": 16, + "x": 34002, + "y": 21931, + "ms": 3.6046250000000555, + "features": 0, + "bytes": 0 + }, + { + "z": 17, + "x": 68004, + "y": 43862, + "ms": 3.0414160000000265, + "features": 0, + "bytes": 0 + }, + { + "z": 18, + "x": 136009, + "y": 87725, + "ms": 4.722875000000045, + "features": 0, + "bytes": 0 + }, + { + "z": 13, + "x": 4245, + "y": 2731, + "ms": 2.8757910000000493, + "features": 10, + "bytes": 997 + }, + { + "z": 14, + "x": 8491, + "y": 5462, + "ms": 3.883624999999938, + "features": 9, + "bytes": 879 + }, + { + "z": 15, + "x": 16983, + "y": 10925, + "ms": 5.170374999999922, + "features": 10, + "bytes": 978 + }, + { + "z": 16, + "x": 33966, + "y": 21851, + "ms": 5.000709000000029, + "features": 12, + "bytes": 858 + }, + { + "z": 17, + "x": 67933, + "y": 43702, + "ms": 4.267665999999963, + "features": 0, + "bytes": 0 + }, + { + "z": 18, + "x": 135867, + "y": 87405, + "ms": 3.2137919999998985, + "features": 0, + "bytes": 0 + }, + { + "z": 13, + "x": 4256, + "y": 2746, + "ms": 4.040292000000022, + "features": 7, + "bytes": 719 + }, + { + "z": 14, + "x": 8513, + "y": 5492, + "ms": 3.330833000000098, + "features": 3, + "bytes": 351 + }, + { + "z": 15, + "x": 17027, + "y": 10985, + "ms": 5.098374999999919, + "features": 2, + "bytes": 311 + }, + { + "z": 16, + "x": 34054, + "y": 21970, + "ms": 3.163917000000083, + "features": 4, + "bytes": 486 + }, + { + "z": 17, + "x": 68108, + "y": 43940, + "ms": 3.791541999999936, + "features": 4, + "bytes": 486 + }, + { + "z": 18, + "x": 136216, + "y": 87881, + "ms": 2.9594170000000304, + "features": 0, + "bytes": 0 + } + ], + "generation": "59337707-c217-4398-966c-3c21ac8237b1" + }, + "frames": { + "method": "Five foreground 5s sweeps/state, two MapLibre easeTo 2.5s legs; nearest-rank pooled p95 per state, individual sweep p95 retained.", + "desktop1280x800": { + "context": "Isolated headful system Chrome, English, DPR1; T3 preview explicitly unavailable.", + "off": { + "runs": 5, + "viewport": [1280, 800], + "devicePixelRatio": 1, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "p95Ms": 9.199999999999818, + "intervalCount": 3001, + "over50Percent": 0, + "individualP95Ms": [ + 9.199999999999818, 9.200000000000728, 9.099999999998545, 9.099999999998545, + 9.30000000000291 + ] + }, + "on": { + "runs": 5, + "viewport": [1280, 800], + "devicePixelRatio": 1, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "p95Ms": 9.200000000004366, + "intervalCount": 2998, + "over50Percent": 0, + "individualP95Ms": [ + 9.200000000000728, 9.200000000000728, 9, 9.299999999995634, 9.299999999995634 + ] + } + }, + "phoneCss430x932": { + "context": "Same headful Chrome, 430x932 CSS viewport, English, DPR1.", + "off": { + "runs": 5, + "viewport": [430, 932], + "devicePixelRatio": 1, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "p95Ms": 9.19999999999709, + "intervalCount": 3005, + "over50Percent": 0, + "individualP95Ms": [ + 9.30000000000291, 9.19999999999709, 9.10000000000582, 9.10000000000582, 9 + ] + }, + "on": { + "runs": 5, + "viewport": [430, 932], + "devicePixelRatio": 1, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "p95Ms": 9.19999999999709, + "intervalCount": 3003, + "over50Percent": 0, + "individualP95Ms": [ + 9.30000000000291, 9.19999999999709, 9, 9.10000000000582, 8.89999999999418 + ] + } + }, + "iosSimulatorIsolated": { + "context": "Native iPhone18Pro iOS27 Safari, German, DPR3; preview stream paused, other owned QA WebGL map closed, no concurrent builds/tests.", + "off": { + "runs": 5, + "viewport": [402, 714], + "devicePixelRatio": 3, + "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", + "p95Ms": 33, + "intervalCount": 1097, + "over50Percent": 0.09115770282588878, + "individualP95Ms": [31, 34, 31, 33, 31] + }, + "on": { + "runs": 5, + "viewport": [402, 714], + "devicePixelRatio": 3, + "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", + "p95Ms": 33, + "intervalCount": 1070, + "over50Percent": 0, + "individualP95Ms": [34, 34, 33, 34, 31] + } + }, + "qualification": "Local simulator/host result only. Individual sweep results and previous streamed/stress/load misses are retained; no physical-device thermal/battery or all-host-load guarantee." + }, + "fixedCameraViews": [ + { + "scene": "Neuss", + "zoom": 14, + "viewport": [430, 932], + "roadLabelsBefore": 10, + "roadLabelsAfter": 10, + "basemapPoisBefore": 13, + "basemapPoisAfter": 13, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 29, + "ambientLabels": 3, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Neuss", + "zoom": 15, + "viewport": [430, 932], + "roadLabelsBefore": 34, + "roadLabelsAfter": 34, + "basemapPoisBefore": 11, + "basemapPoisAfter": 11, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 33, + "ambientLabels": 4, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Neuss", + "zoom": 16, + "viewport": [430, 932], + "roadLabelsBefore": 28, + "roadLabelsAfter": 28, + "basemapPoisBefore": 10, + "basemapPoisAfter": 10, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 46, + "ambientLabels": 11, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Neuss", + "zoom": 17, + "viewport": [430, 932], + "roadLabelsBefore": 15, + "roadLabelsAfter": 15, + "basemapPoisBefore": 7, + "basemapPoisAfter": 7, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 9, + "ambientLabels": 5, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Neuss", + "zoom": 18, + "viewport": [430, 932], + "roadLabelsBefore": 4, + "roadLabelsAfter": 4, + "basemapPoisBefore": 15, + "basemapPoisAfter": 15, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 3, + "ambientLabels": 2, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Cologne", + "zoom": 14, + "viewport": [430, 932], + "roadLabelsBefore": 15, + "roadLabelsAfter": 15, + "basemapPoisBefore": 14, + "basemapPoisAfter": 14, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 55, + "ambientLabels": 15, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Cologne", + "zoom": 15, + "viewport": [430, 932], + "roadLabelsBefore": 26, + "roadLabelsAfter": 26, + "basemapPoisBefore": 15, + "basemapPoisAfter": 15, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 59, + "ambientLabels": 7, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Cologne", + "zoom": 16, + "viewport": [430, 932], + "roadLabelsBefore": 26, + "roadLabelsAfter": 26, + "basemapPoisBefore": 5, + "basemapPoisAfter": 5, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 52, + "ambientLabels": 17, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Cologne", + "zoom": 17, + "viewport": [430, 932], + "roadLabelsBefore": 10, + "roadLabelsAfter": 10, + "basemapPoisBefore": 13, + "basemapPoisAfter": 13, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 13, + "ambientLabels": 3, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Cologne", + "zoom": 18, + "viewport": [430, 932], + "roadLabelsBefore": 6, + "roadLabelsAfter": 6, + "basemapPoisBefore": 6, + "basemapPoisAfter": 6, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 1, + "ambientLabels": 1, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Zons", + "zoom": 14, + "viewport": [430, 932], + "roadLabelsBefore": 0, + "roadLabelsAfter": 0, + "basemapPoisBefore": 9, + "basemapPoisAfter": 9, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 3, + "ambientLabels": 1, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Zons", + "zoom": 15, + "viewport": [430, 932], + "roadLabelsBefore": 15, + "roadLabelsAfter": 15, + "basemapPoisBefore": 7, + "basemapPoisAfter": 7, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 11, + "ambientLabels": 4, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Zons", + "zoom": 16, + "viewport": [430, 932], + "roadLabelsBefore": 12, + "roadLabelsAfter": 12, + "basemapPoisBefore": 11, + "basemapPoisAfter": 11, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 9, + "ambientLabels": 6, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Zons", + "zoom": 17, + "viewport": [430, 932], + "roadLabelsBefore": 8, + "roadLabelsAfter": 8, + "basemapPoisBefore": 14, + "basemapPoisAfter": 14, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 3, + "ambientLabels": 2, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Zons", + "zoom": 18, + "viewport": [430, 932], + "roadLabelsBefore": 3, + "roadLabelsAfter": 3, + "basemapPoisBefore": 4, + "basemapPoisAfter": 4, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 1, + "ambientLabels": 2, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Rural Rhine empty", + "zoom": 14, + "viewport": [430, 932], + "roadLabelsBefore": 2, + "roadLabelsAfter": 2, + "basemapPoisBefore": 1, + "basemapPoisAfter": 1, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Rural Rhine empty", + "zoom": 15, + "viewport": [430, 932], + "roadLabelsBefore": 1, + "roadLabelsAfter": 1, + "basemapPoisBefore": 0, + "basemapPoisAfter": 0, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Rural Rhine empty", + "zoom": 16, + "viewport": [430, 932], + "roadLabelsBefore": 1, + "roadLabelsAfter": 1, + "basemapPoisBefore": 1, + "basemapPoisAfter": 1, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Rural Rhine empty", + "zoom": 17, + "viewport": [430, 932], + "roadLabelsBefore": 0, + "roadLabelsAfter": 0, + "basemapPoisBefore": 0, + "basemapPoisAfter": 0, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Rural Rhine empty", + "zoom": 18, + "viewport": [430, 932], + "roadLabelsBefore": 0, + "roadLabelsAfter": 0, + "basemapPoisBefore": 0, + "basemapPoisAfter": 0, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Berlin", + "zoom": 14, + "viewport": [430, 932], + "roadLabelsBefore": 11, + "roadLabelsAfter": 11, + "basemapPoisBefore": 22, + "basemapPoisAfter": 22, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Berlin", + "zoom": 15, + "viewport": [430, 932], + "roadLabelsBefore": 15, + "roadLabelsAfter": 15, + "basemapPoisBefore": 18, + "basemapPoisAfter": 18, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Berlin", + "zoom": 16, + "viewport": [430, 932], + "roadLabelsBefore": 17, + "roadLabelsAfter": 17, + "basemapPoisBefore": 9, + "basemapPoisAfter": 9, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Berlin", + "zoom": 17, + "viewport": [430, 932], + "roadLabelsBefore": 7, + "roadLabelsAfter": 7, + "basemapPoisBefore": 18, + "basemapPoisAfter": 18, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Berlin", + "zoom": 18, + "viewport": [430, 932], + "roadLabelsBefore": 5, + "roadLabelsAfter": 5, + "basemapPoisBefore": 8, + "basemapPoisAfter": 8, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Aachen", + "zoom": 14, + "viewport": [430, 932], + "roadLabelsBefore": 18, + "roadLabelsAfter": 18, + "basemapPoisBefore": 12, + "basemapPoisAfter": 12, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Aachen", + "zoom": 15, + "viewport": [430, 932], + "roadLabelsBefore": 30, + "roadLabelsAfter": 30, + "basemapPoisBefore": 10, + "basemapPoisAfter": 10, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Aachen", + "zoom": 16, + "viewport": [430, 932], + "roadLabelsBefore": 28, + "roadLabelsAfter": 28, + "basemapPoisBefore": 11, + "basemapPoisAfter": 11, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Aachen", + "zoom": 17, + "viewport": [430, 932], + "roadLabelsBefore": 17, + "roadLabelsAfter": 17, + "basemapPoisBefore": 12, + "basemapPoisAfter": 12, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Aachen", + "zoom": 18, + "viewport": [430, 932], + "roadLabelsBefore": 9, + "roadLabelsAfter": 9, + "basemapPoisBefore": 13, + "basemapPoisAfter": 13, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Monschau", + "zoom": 14, + "viewport": [430, 932], + "roadLabelsBefore": 5, + "roadLabelsAfter": 5, + "basemapPoisBefore": 10, + "basemapPoisAfter": 10, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Monschau", + "zoom": 15, + "viewport": [430, 932], + "roadLabelsBefore": 16, + "roadLabelsAfter": 16, + "basemapPoisBefore": 8, + "basemapPoisAfter": 8, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Monschau", + "zoom": 16, + "viewport": [430, 932], + "roadLabelsBefore": 19, + "roadLabelsAfter": 19, + "basemapPoisBefore": 12, + "basemapPoisAfter": 12, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Monschau", + "zoom": 17, + "viewport": [430, 932], + "roadLabelsBefore": 14, + "roadLabelsAfter": 14, + "basemapPoisBefore": 15, + "basemapPoisAfter": 15, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Monschau", + "zoom": 18, + "viewport": [430, 932], + "roadLabelsBefore": 9, + "roadLabelsAfter": 9, + "basemapPoisBefore": 11, + "basemapPoisAfter": 11, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 0, + "ambientLabels": 0, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Neuss dark English", + "zoom": 14, + "viewport": [430, 932], + "roadLabelsBefore": 10, + "roadLabelsAfter": 10, + "basemapPoisBefore": 13, + "basemapPoisAfter": 13, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 29, + "ambientLabels": 3, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Neuss dark English", + "zoom": 15, + "viewport": [430, 932], + "roadLabelsBefore": 34, + "roadLabelsAfter": 34, + "basemapPoisBefore": 11, + "basemapPoisAfter": 11, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 33, + "ambientLabels": 4, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Neuss dark English", + "zoom": 16, + "viewport": [430, 932], + "roadLabelsBefore": 28, + "roadLabelsAfter": 28, + "basemapPoisBefore": 10, + "basemapPoisAfter": 10, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 46, + "ambientLabels": 11, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Neuss dark English", + "zoom": 17, + "viewport": [430, 932], + "roadLabelsBefore": 15, + "roadLabelsAfter": 15, + "basemapPoisBefore": 7, + "basemapPoisAfter": 7, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 9, + "ambientLabels": 5, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Neuss dark English", + "zoom": 18, + "viewport": [430, 932], + "roadLabelsBefore": 4, + "roadLabelsAfter": 4, + "basemapPoisBefore": 15, + "basemapPoisAfter": 15, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 3, + "ambientLabels": 2, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Cologne dark German", + "zoom": 14, + "viewport": [430, 932], + "roadLabelsBefore": 15, + "roadLabelsAfter": 15, + "basemapPoisBefore": 14, + "basemapPoisAfter": 14, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 55, + "ambientLabels": 15, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Cologne dark German", + "zoom": 15, + "viewport": [430, 932], + "roadLabelsBefore": 26, + "roadLabelsAfter": 26, + "basemapPoisBefore": 15, + "basemapPoisAfter": 15, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 59, + "ambientLabels": 7, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Cologne dark German", + "zoom": 16, + "viewport": [430, 932], + "roadLabelsBefore": 26, + "roadLabelsAfter": 26, + "basemapPoisBefore": 5, + "basemapPoisAfter": 5, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 52, + "ambientLabels": 17, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Cologne dark German", + "zoom": 17, + "viewport": [430, 932], + "roadLabelsBefore": 10, + "roadLabelsAfter": 10, + "basemapPoisBefore": 13, + "basemapPoisAfter": 13, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 13, + "ambientLabels": 3, + "duplicateAmbientLabelIds": 0 + }, + { + "scene": "Cologne dark German", + "zoom": 18, + "viewport": [430, 932], + "roadLabelsBefore": 6, + "roadLabelsAfter": 6, + "basemapPoisBefore": 6, + "basemapPoisAfter": 6, + "sameRoadNamesAndBasemapIdentities": true, + "ambientPoints": 1, + "ambientLabels": 1, + "duplicateAmbientLabelIds": 0 + } + ], + "interaction": { + "englishSuppliedDistinctNames": [ + { + "id": "osm:node/1236400589", + "name": "Martin-Luther Haus", + "name_en": "Martin Luther Haus" + } + ], + "selected": { + "primaryScheme": "osm", + "ids": { + "osm": "way/28562993" + }, + "name": "Quirinus-M\u00fcnster", + "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_en"], ["get", "name"]], + "german": ["coalesce", ["get", "name:de"], ["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\u00fcnster", + "address": "", + "coordinates": [6.6933392733335495, 51.19904645716551], + "category": "place_of_worship", + "names": {}, + "id": "osm:way/28562993" + }, + "ambientLabels": 0 + } + }, + "screenshots": [ + { + "file": "before-desktop.png", + "sha256": "3c383a6e80b6b1560fb1b917188b18a974fd4d368b810d47613f2b93544f468d", + "bytes": 864031, + "generation": "59337707-c217-4398-966c-3c21ac8237b1" + }, + { + "file": "after-desktop.png", + "sha256": "7dbcc11511cc3358696a73b2bf4c582f4bcc4272f07b2ca9b717fb47a1568ab1", + "bytes": 900166, + "generation": "59337707-c217-4398-966c-3c21ac8237b1" + }, + { + "file": "ios-before.png", + "sha256": "c0d2d9b2705ebcf8e2eb84671e4368ae06268908ceaa04e8c4fa168b5e7dd009", + "bytes": 307679, + "generation": "59337707-c217-4398-966c-3c21ac8237b1" + }, + { + "file": "ios-after.png", + "sha256": "a476206bce85771e9f2fdeae38dfced718cd4e8766ea56a7a3ae0b849aa9a2ad", + "bytes": 321627, + "generation": "59337707-c217-4398-966c-3c21ac8237b1" + }, + { + "file": "admin.png", + "sha256": "7537eeb480fd3bf75f07f4970149b0513e7f2f00764228a472713134c185a5d3", + "bytes": 81670, + "generation": "59337707-c217-4398-966c-3c21ac8237b1" + } + ], + "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": "8be73dd916da2400cd608f88febda12d24db9ff991a6500912868ae7b9b26243" + }, + { + "file": "services/data-manager/src/jobs/search-index/build.ts", + "sha256": "766b92e7e71c3ec7a0cb65c2df04368a2aa33e53dabdaf38a4dd90294435588e" + }, + { + "file": "apps/web/src/components/map/localizeTextField.ts", + "sha256": "ce0cf960ad810b3b6194835e70063433de9b97cee28cdb72497c56fc036ac97a" + }, + { + "file": "apps/web/src/components/map/mapStylePoiTarget.ts", + "sha256": "90b535d0399147127e0eaebb8cf76f1a5e9082b43c92c73f4e4762e4a47d7f25" + }, + { + "file": "integrations/poi-overture/index.ts", + "sha256": "6b5ec71a1dce14efacdd7535da776a5431aa430305aff6c657ae3c513be0538c" + }, + { + "file": "packages/core/src/server/ambient-places.ts", + "sha256": "7194127e9ca894be5497151c89283a227477dc86421a19165909dd7817977438" + } + ], + "framePublication": { + "index": { + "region": "europe/germany", + "epoch": "817452b3-5447-43e5-aab9-30cb894455a7", + "sourceFingerprint": "sha256:c18cc77ed3493aa186bd7659fee4a9b028fbc479c2a8a90f219a3b2d33824d75", + "placeCount": 14232, + "termCount": 16052 + }, + "indexedMs": 15265.852042000002, + "publishedMs": 654.9927500000013, + "manifest": { + "version": 1, + "policyVersion": 2, + "generation": "59337707-c217-4398-966c-3c21ac8237b1", + "publishedAt": "2026-10-08T21:42:58.742Z", + "region": { + "name": "Rhine regional acceptance", + "bounds": [6.58, 50.89, 7.07, 51.31] + }, + "placeCount": 13454, + "enabled": true, + "sources": { + "osm": { + "region": "europe/germany", + "epoch": "817452b3-5447-43e5-aab9-30cb894455a7", + "publishedAt": "2026-10-08T21:42:58.436Z", + "count": 13454 + }, + "overture": null + } + }, + "landmarks": [ + { + "id": "osm:way/28562993", + "name": "Quirinus-M\u00fcnster", + "category": "place_of_worship", + "rank": 2559, + "min_zoom": 14, + "tenant": false + }, + { + "id": "osm:way/4532022", + "name": "K\u00f6lner Dom", + "category": "place_of_worship", + "rank": 2559, + "min_zoom": 14, + "tenant": false + } + ] + }, + "codeBase": "7a0d0e0ba8849c796487d294632a16dbe851e9e8", + "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": "59337707-c217-4398-966c-3c21ac8237b1", + "frameGeneration": "59337707-c217-4398-966c-3c21ac8237b1", + "generationQualification": "Final post-review static matrix, desktop/iOS/admin images, quiet tile reads and all three frame phases use the same generation and checksummed source. Prior samples are retained separately with their original generation and qualification.", + "browserQualification": "Initial QA used T3 preview. After preview_open explicitly reported unavailable, final post-review frames/static matrix/desktop images used isolated headful system Chrome; iOS images/frames used native Safari via T3 device tools. No headless frame measurements are claimed.", + "previousFrameEvidence": { + "generation": "b900c5b5-69bb-47c1-8526-b703bac4745a", + "codeBase": "769270286129de5ea229dab4d0c09b18c7e49670", + "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" + } + } + } + }, + "basemapIdentityFields": ["featureId", "source", "sourceLayer", "layer", "coordinates", "name"], + "framePlacementDiagnostic": { + "generation": "59337707-c217-4398-966c-3c21ac8237b1", + "landmarkCandidates": 8, + "codeFiles": [ + { + "file": "packages/core/src/ambient-places.ts", + "sha256": "af99d4ddbbc7f700942df8c9fec437f6f0f583d17904410af42048291080e9dc" + }, + { + "file": "integrations/overlay-ambient-places/map-layer.tsx", + "sha256": "dee63c71e25e1c457eca07d5d6bebc2f36d9ff9984af207adbbf7d47b6d5e964" + }, + { + "file": "services/data-manager/src/jobs/search-index/build.ts", + "sha256": "766b92e7e71c3ec7a0cb65c2df04368a2aa33e53dabdaf38a4dd90294435588e" + }, + { + "file": "apps/web/src/components/map/localizeTextField.ts", + "sha256": "ce0cf960ad810b3b6194835e70063433de9b97cee28cdb72497c56fc036ac97a" + }, + { + "file": "apps/web/src/components/map/mapStylePoiTarget.ts", + "sha256": "90b535d0399147127e0eaebb8cf76f1a5e9082b43c92c73f4e4762e4a47d7f25" + }, + { + "file": "integrations/poi-overture/index.ts", + "sha256": "6b5ec71a1dce14efacdd7535da776a5431aa430305aff6c657ae3c513be0538c" + }, + { + "file": "packages/core/src/server/ambient-places.ts", + "sha256": "7194127e9ca894be5497151c89283a227477dc86421a19165909dd7817977438" + } + ], + "frames": { + "method": "Five foreground 5s sweeps/state, two MapLibre easeTo 2.5s legs; nearest-rank pooled p95 per state, individual sweep p95 retained.", + "desktop1280x800": { + "context": "Isolated headful system Chrome, English, DPR1; T3 preview explicitly unavailable.", + "off": { + "runs": 5, + "viewport": [1280, 800], + "devicePixelRatio": 1, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "p95Ms": 10, + "intervalCount": 3000, + "over50Percent": 0, + "individualP95Ms": [ + 10.099999999999454, 9.899999999999636, 9.899999999997817, 10, 9.900000000001455 + ] + }, + "on": { + "runs": 5, + "viewport": [1280, 800], + "devicePixelRatio": 1, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "p95Ms": 10, + "intervalCount": 3000, + "over50Percent": 0, + "individualP95Ms": [ + 10.100000000000364, 9.80000000000291, 9.900000000001455, 9.900000000001455, 10 + ] + } + }, + "phoneCss430x932": { + "context": "Same headful Chrome, 430x932 CSS viewport, English, DPR1.", + "off": { + "runs": 5, + "viewport": [430, 932], + "devicePixelRatio": 1, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "p95Ms": 9.900000000008731, + "intervalCount": 3005, + "over50Percent": 0, + "individualP95Ms": [ + 9.900000000001455, 9.900000000008731, 10, 9.89999999999418, 9.89999999999418 + ] + }, + "on": { + "runs": 5, + "viewport": [430, 932], + "devicePixelRatio": 1, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36", + "p95Ms": 10, + "intervalCount": 3003, + "over50Percent": 0, + "individualP95Ms": [10, 10, 10, 10, 9.89999999999418] + } + }, + "iosSimulatorIsolated": { + "context": "Native iPhone18Pro iOS27 Safari, German, DPR3; preview stream paused, other owned QA WebGL map closed, no concurrent builds/tests.", + "off": { + "runs": 5, + "viewport": [402, 714], + "devicePixelRatio": 3, + "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", + "p95Ms": 32, + "intervalCount": 1099, + "over50Percent": 0.09099181073703366, + "individualP95Ms": [31, 33, 31, 35, 32] + }, + "on": { + "runs": 5, + "viewport": [402, 714], + "devicePixelRatio": 3, + "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", + "p95Ms": 34, + "intervalCount": 1075, + "over50Percent": 0.09302325581395349, + "individualP95Ms": [31, 35, 31, 35, 33] + } + }, + "qualification": "Local simulator/host result only. Individual sweep results and previous streamed/stress/load misses are retained; no physical-device thermal/battery or all-host-load guarantee." + }, + "qualification": "Post-review eight-candidate isolated iOS on p95 34 ms missed unchanged 33.4 ms target. Retained before reducing candidate placement work; dense Cologne basemap baseline is near limit." + }, + "rawCaptureEvidence": [ + { + "file": "matrix-final.json", + "bytes": 313396, + "sha256": "70226ea760874f3f2e7f4246e7c1ac7caffea5c18a4618b9a260e1863d75aad8", + "location": "External QA capture; not shipped in git" + }, + { + "file": "identity-final.json", + "bytes": 2286, + "sha256": "05ca6cad2704e2d6cb574e2f0b11e2e9eb18ab8eff34405191294f4ba49923de", + "location": "External QA capture; not shipped in git" + }, + { + "file": "frames.jsonl", + "bytes": 212226, + "sha256": "41b0f0d37dce97cc3e0554180e1e2029ed91f61f20fde36efafe615faa560971", + "location": "External QA capture; not shipped in git" + }, + { + "file": "frames-post-review-eight-anchors.jsonl", + "bytes": 195208, + "sha256": "6b8a56a728a8deda4be84be0b21e2682d2b347016c4c60dacadc82707d9f54b5", + "location": "External QA capture; not shipped in git" + }, + { + "file": "anchor-diagnostic.json", + "bytes": 3261, + "sha256": "f7aea17c62103b0fc6b625d01a13874144ac4eef8adf586da04ec8b3e5444694", + "location": "External QA capture; not shipped in git" + } + ], + "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", + "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 new file mode 100644 index 000000000..553654e4c --- /dev/null +++ b/docs/docs/developer/ambient-places-publication.md @@ -0,0 +1,404 @@ +--- +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 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 + +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 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`) 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. 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. + +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. +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 +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 +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 +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. 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, only when no explicit identity is supplied, a unique compatible-category/name +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 +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 source-fresh retained ambient snapshots, +preferring the active generation and then the newest retained match, +retaining canonical OSM/GERS identity and supplied names. Enabled-provider GERS +details also apply accepted same-release links and retain the OSM primary plus +GERS alias while supplying richer Overture metadata. Its existing detail +cache key includes that generation. Discovery disable or replacement does not revoke snapshot +identities in still-retained, source-fresh generations already exposed in cached +tiles. Removed or source-stale snapshots cannot resolve through this fallback. 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 + +- 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; the shared + style localization pass accepts colon and underscore language fields; at most 120 + Unicode characters. No inferred opening hours, ratings or popularity. +- 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 + 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. + +## 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: + +| 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 (using the prototype native-geometry storage before the final +EWKB discovery safeguard): + +| 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 | + +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 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: + +```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). + +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 +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. +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–9, 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. Six 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. +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 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 | +| ---------------------------------------- | -------------: | ---------: | +| Publication after indexing | 60 s | 0.655 s | +| First repository tile read | 1,000 ms | 32.85 ms | +| Warm sparse/dense p95, 30 reads | 100 ms | 4.53 ms | +| Eight concurrent reads p95, 40 reads | 250 ms | 47.61 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 lists and basemap POI identities (source, source layer, feature ID, rendered +layer, coordinates and name); 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`; overlapping category and DOM markers retain +click priority. Selected-label suppression survives both paint-only +theme recreation and full style replacement. English/German labels retain supplied colon or underscore language properties +after the real map localizer runs, then fall back conservatively 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 | +| ---------------------------------------- | -------------: | ------------: | --------: | +| Foreground Chrome, 1280 × 800 | 9.2 ms | 9.2 ms | 0% | +| Same Chrome, phone CSS 430 × 932 | 9.2 ms | 9.2 ms | 0% | +| iPhone 18 Pro simulator Safari, isolated | 33 ms | 33 ms | 0% | + +The final isolated run pauses the device preview stream and removes our second +QA WebGL map. Six collision-placement candidates retain the actual Quirinus name +where four/five fail, while bounding work below the original eight-candidate +policy. The preceding same-generation eight-candidate run measured 32/34 ms off/on +and missed the on target; its samples and code hashes remain in the artifact. +Earlier streamed 33/34 ms and shared-load 35/36 ms samples remain diagnostic +evidence. No unrelated host process was stopped. + +The unchanged target is pooled p95 ≤33.4 ms and intervals >50 ms ≤5%. +The final six-candidate isolated simulator run meets it; individual on sweeps +still reach 34 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. + +## 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/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/developer/map-comparison-baseline.md b/docs/docs/developer/map-comparison-baseline.md index 129243d98..913ffe002 100644 --- a/docs/docs/developer/map-comparison-baseline.md +++ b/docs/docs/developer/map-comparison-baseline.md @@ -204,3 +204,33 @@ 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–9 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. All 45 paired views compare source/source-layer/feature-ID, rendered-layer, +coordinate/name identities as well as road-name lists. 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. +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/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..49f24da2e 100644 --- a/docs/docs/features/overture-places.md +++ b/docs/docs/features/overture-places.md @@ -47,6 +47,54 @@ 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. +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, +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. +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. 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 +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. 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/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-07-ambient-places-design.md b/docs/superpowers/specs/2026-10-07-ambient-places-design.md new file mode 100644 index 000000000..761ecbf00 --- /dev/null +++ b/docs/superpowers/specs/2026-10-07-ambient-places-design.md @@ -0,0 +1,155 @@ +# 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. +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. +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 +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. + +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 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. + +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/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/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/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..c2809e7d1 --- /dev/null +++ b/integrations/overlay-ambient-places/manifest.json @@ -0,0 +1,168 @@ +{ + "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": "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", + "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..e6c206308 --- /dev/null +++ b/integrations/overlay-ambient-places/map-layer.test.tsx @@ -0,0 +1,540 @@ +import { readFileSync } from "node:fs"; +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; paint_symbol: Record }; + createExpression: ( + value: unknown, + rootKey: string, + specification: unknown, + ) => { + result: string; + value: { + evaluate: ( + globals: { zoom: number }, + feature: { type: string; properties: Record; geometry: never[] }, + state?: unknown, + canonical?: unknown, + images?: string[], + ) => unknown; + }; + }; +}; + +const test = vi.hoisted(() => ({ + group: null as unknown, + styleVersion: 0, + attribution: vi.fn(), + map: { + getLayer: vi.fn(() => true), + getSource: vi.fn(() => true), + getZoom: () => 16, + querySourceFeatures: vi.fn((): MapGeoJSONFeature[] => []), + queryRenderedFeatures: vi.fn((): MapGeoJSONFeature[] => []), + 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>(), + 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, styleVersion: test.styleVersion }) }; +}); +vi.mock("@/integration-api/map/useMapLayerGroup", () => ({ + useMapLayerGroup: (group: unknown) => { + test.group = group; + }, +})); +vi.mock("@/integration-api/overlay/useIntegrationAttribution", () => ({ + useIntegrationAttribution: test.attribution, + useIntegrationSourceAttributions: test.attribution, +})); +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 type { Map as MapLibreMap } from "maplibre-gl"; +import { getAmbientIdentity } from "@/components/map/ambientPlaceIdentity"; +import { localizeTextField } from "@/components/map/localizeTextField"; +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; + test.styleVersion = 0; + vi.clearAllMocks(); + test.map.querySourceFeatures.mockReturnValue([]); + test.map.queryRenderedFeatures.mockReturnValue([]); + test.map.getStyle.mockReturnValue({ layers: [] }); + usePlaceStore.setState({ selectedPlace: null }); +}); +afterEach(() => { + cleanup(); + 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", + 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 }[]; + }; + let textField = descriptor.layers.find((layer) => layer.type === "symbol")?.layout?.[ + "text-field" + ]; + for (const [locale, expected] of [ + ["en", "Cologne Cathedral"], + ["de", "Dom zu Köln"], + ["en", "Cologne Cathedral"], + ]) { + textField = localizeTextField(textField, locale); + const expression = createExpression( + textField, + "layers.ambient-places-labels.layout.text-field", + latest.layout_symbol["text-field"], + ); + expect(expression.result).toBe("success"); + expect( + String( + expression.value.evaluate( + { zoom: 16 }, + { + type: "Point", + properties: { + name: "Kölner Dom", + name_en: "Cologne Cathedral", + name_de: "Dom zu Köln", + }, + geometry: [], + }, + ), + ), + ).toBe(expected); + expect( + String( + expression.value.evaluate( + { zoom: 16 }, + { type: "Point", properties: { name: "Source name" }, geometry: [] }, + ), + ), + ).toBe("Source name"); + } + }); + it.each(["category marker", "DOM pin"])( + "keeps an overlapping %s selection instead of choosing a different ambient point", + async (owner) => { + const ambient = { + type: "Feature", + layer: { id: "ambient-places-labels" }, + geometry: { type: "Point", coordinates: [6.08, 50.77] }, + properties: { + id: "osm:node/1", + name: "Ambient A", + category: "cafe", + rank: 2000, + min_zoom: 15, + tenant: false, + sources: "osm", + }, + } as MapGeoJSONFeature; + const category = { + ...ambient, + layer: { id: "category-results-layer" }, + properties: { id: "osm:node/2", name: "Category B" }, + } as MapGeoJSONFeature; + test.map.querySourceFeatures.mockReturnValue([ambient]); + test.map.queryRenderedFeatures.mockImplementation((...args: unknown[]) => { + const options = args[1] as { layers?: string[] } | undefined; + return (owner === "category marker" ? [category, ambient] : [ambient]).filter((feature) => + options?.layers?.includes(feature.layer.id), + ); + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => ({ manifest }) })), + ); + render(); + await waitFor(() => expect(test.group).not.toBeNull()); + const selected = { + primaryScheme: "osm", + ids: { osm: "node/2" }, + id: "osm:node/2", + name: "Category B", + address: "", + coordinates: [6.08, 50.77], + } as const; + usePlaceStore.setState({ + selectedPlace: { ...selected, coordinates: [...selected.coordinates] }, + }); + const click = test.map.on.mock.calls.find(([event]) => event === "click")?.[1]; + const pin = document.createElement("div"); + pin.dataset.openmapxPinMarker = "true"; + act(() => + click?.({ + point: { x: 1, y: 2 }, + ...(owner === "DOM pin" ? { originalEvent: { target: pin } } : {}), + }), + ); + expect(usePlaceStore.getState().selectedPlace?.id).toBe("osm:node/2"); + }, + ); + 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'); + const descriptor = test.group as { + layers: { id: string; type: string; layout?: Record }[]; + }; + const expression = createExpression( + descriptor.layers.find((layer) => layer.id === "ambient-places-landmarks")?.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: [] }, + ) as { values: unknown[] } + ).values; + // Landmarks use a separate symbol partition with six candidate positions. + expect(positions(2559)).toHaveLength(12); + + 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( + "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.mockImplementation((...args: unknown[]) => { + const options = args[1] as { layers?: string[] } | undefined; + return options?.layers?.includes("ambient-places-labels") ? [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("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", + 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..31cff28d8 --- /dev/null +++ b/integrations/overlay-ambient-places/map-layer.tsx @@ -0,0 +1,290 @@ +"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 { + ExpressionSpecification, + FilterSpecification, + MapGeoJSONFeature, + MapMouseEvent, +} from "maplibre-gl"; +import { useLocale } from "next-intl"; +import { useEffect, useMemo } from "react"; +import { + ambientBasemapKey, + ambientPlaceStyle, + 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, 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"; +import integrationManifest from "./manifest.json"; +import { useAmbientPlacesStore } from "./store"; + +export const AMBIENT_SOURCE = "ambient-places-source"; +export const AMBIENT_LANDMARK_LAYER = "ambient-places-landmarks"; +export const AMBIENT_LABEL_LAYER = "ambient-places-labels"; +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[] = []; +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, styleVersion } = 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); + useIntegrationSourceAttributions( + "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 { + 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: 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" as const, + order: landmark ? 36 : 35, + filter: landmark ? LANDMARK : ["!", LANDMARK], + layout: { + ...style.layout, + "text-field": ["coalesce", ["get", `name_${locale.split("-")[0]}`], ["get", "name"]], + "symbol-sort-key": ["-", ["get", "rank"]], + }, + paint: style.paint, + }; + }), + }; + }, [visible, manifest, env.apiUrl, locale, dark, mapRef, mapReady, styleVersion]); + useMapLayerGroup(group); + + // 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; + 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], + // 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}` + : 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: ExpressionSpecification = [ + "all", + ["<=", ["get", "min_zoom"], Math.floor(map.getZoom())], + ["!", ["in", ["get", "id"], ["literal", [...hidden].sort()]]], + ]; + // 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) { + 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; + const target = event.originalEvent?.target; + if (target instanceof Element && target.closest("[data-openmapx-pin-marker]")) return; + const live = LAYERS.filter((id) => map.getLayer(id)); + if (!live.length) return; + // Basemap clicks already defer to ambient features. Keep that direction + // while category/data-source markers and DOM pins retain higher priority. + const basemapLayers = getStylePoiLayerIds(map); + const priorityLayers = [...INTERACTIVE_LAYER_IDS].filter( + (id) => !LAYERS.includes(id) && !basemapLayers.includes(id) && map.getLayer(id), + ); + if ( + priorityLayers.length && + map.queryRenderedFeatures(event.point, { layers: priorityLayers }).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 = () => { + 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, dark, styleVersion]); + 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..a77d99392 --- /dev/null +++ b/integrations/overlay-ambient-places/strings/de.json @@ -0,0 +1,61 @@ +{ + "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" + }, + "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 new file mode 100644 index 000000000..0365c73c3 --- /dev/null +++ b/integrations/overlay-ambient-places/strings/en.json @@ -0,0 +1,61 @@ +{ + "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" + }, + "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/integrations/overlay-ambient-places/theme-suppression.test.tsx b/integrations/overlay-ambient-places/theme-suppression.test.tsx new file mode 100644 index 000000000..b85085e1d --- /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", () => ({ + useIntegrationSourceAttributions: vi.fn(), +})); +vi.mock("@/lib/useExploreReachResults", () => { + const result = { filtered: [] }; + return { useExploreReachResults: () => result }; +}); +vi.mock("next-intl", () => ({ useLocale: () => "en" })); + +import { + AMBIENT_LABEL_LAYER, + AMBIENT_LANDMARK_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_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(() => { + 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(); + }); +}); 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/__tests__/resolver.test.ts b/integrations/poi-overture/__tests__/resolver.test.ts index 9e4f2e7a7..283e848cb 100644 --- a/integrations/poi-overture/__tests__/resolver.test.ts +++ b/integrations/poi-overture/__tests__/resolver.test.ts @@ -81,6 +81,22 @@ describe("overture place resolver", () => { expect(place).toBeNull(); }); + it("keeps accepted OSM primary identity and GERS metadata in enabled-provider details", async () => { + const db = makeFakeDb({ ...FIXTURE_ROW, canonical_id: "osm:node/9007199254740993" }); + const ctx = createMockIntegrationContext({ db }); + const { setup } = await import("../index.js"); + const { getPlaceResolver } = await import("@openmapx/place-ids"); + setup(ctx); + const place = await getPlaceResolver("overture")?.(GERS_ID, { lang: "de-DE" }); + expect(place).toMatchObject({ + id: "osm:node/9007199254740993", + primaryScheme: "osm", + ids: { osm: "node/9007199254740993", overture: GERS_ID }, + name: "Testcafé Berlin", + website: "https://example.test/location", + }); + }); + it("does not register the resolver when ctx.db is undefined", async () => { const ctx = createMockIntegrationContext(); diff --git a/integrations/poi-overture/index.ts b/integrations/poi-overture/index.ts index 3186528a4..2c92b31fa 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 AND link.release = places.release) AS canonical_id, gers_id, name, ST_X(geom) AS longitude, @@ -219,6 +223,9 @@ async function fetchOverturePlaceByGers( ): Promise { const rows = await db.execute( `SELECT + (SELECT 'osm:' || link.osm_type || '/' || link.osm_id::TEXT + FROM overture_places.poi_conflation_link link + WHERE link.gers_id = places.gers_id AND link.release = places.release) AS canonical_id, gers_id, name, ST_X(geom) AS longitude, @@ -261,9 +268,10 @@ function overtureRowToPlace(row: OvertureRow, lang?: string) { alternates: row.taxonomy_alternates, }); + const osmId = row.canonical_id?.match(/^osm:((?:node|way|relation)\/\d+)$/)?.[1]; return createPlace({ - primaryScheme: "overture", - ids: { overture: row.gers_id }, + primaryScheme: osmId ? "osm" : "overture", + ids: { overture: row.gers_id, ...(osmId ? { osm: osmId } : {}) }, name: localized.name, address: address.address ?? "", city: address.city, diff --git a/package.json b/package.json index c578d04fa..f29725b46 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 apps/api/src/db/__tests__/drop-poi-ingest.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__/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", + "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 apps/api/src/db/__tests__/drop-poi-ingest.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__/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__/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/package.json b/packages/core/package.json index 2cd04706a..28dc02ded 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -137,6 +137,14 @@ "./coverage": { "types": "./src/coverage/index.ts", "default": "./src/coverage/index.ts" + }, + "./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": { @@ -153,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 new file mode 100644 index 000000000..e057d8b09 --- /dev/null +++ b/packages/core/src/ambient-places.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "vitest"; +import { + ambientCategory, + 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("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" }, + { 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("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("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); + 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("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 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 = { + 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); + }); +}); + +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 new file mode 100644 index 000000000..642fb5a7b --- /dev/null +++ b/packages/core/src/ambient-places.ts @@ -0,0 +1,405 @@ +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 = 2; +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 + ); +} +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 + : landmark + ? 2.5 + : everyday.has(c) || /^shop[:/]/.test(category) + ? 2 + : 1; + return { + rank: + tier * 1000 + + Math.round(Math.max(0, Math.min(1, Number.isFinite(importance) ? importance : 0)) * 99), + minZoom: tenant ? 18 : tier === 3 ? 13 : (landmark ?? (tier === 2 ? 15 : 16)), + }; +} +export function ambientPlaceFromOsm(row: AmbientOsmRow): AmbientPlace | null { + 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 || + !row.category?.trim() || + !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 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; + } + return { + id: `osm:${row.osm_type}/${row.osm_id}`, + name, + names, + coordinates: [row.lng, row.lat], + category: ambientCategory(category), + ...policy(category, row.importance, tenant, landmark), + 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, + excludedOsm: ReadonlySet = new Set(), +): 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), + }; + }); + 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 { + 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) { + const exact = byId.get(l.osmId); + if (exact) matches.set(l.key, exact); + continue; + } + const candidates = (byName.get(normalize(l.name)) ?? []).filter( + (p) => + l.category && + ambientCategory(l.category) === p.category && + // 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]); + } + 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", + }; +} diff --git a/packages/core/src/server/ambient-places.ts b/packages/core/src/server/ambient-places.ts new file mode 100644 index 000000000..6b0871b3f --- /dev/null +++ b/packages/core/src/server/ambient-places.ts @@ -0,0 +1,128 @@ +import type postgres from "postgres"; +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 ( + /^[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; + return { ...row.manifest, enabled: row.enabled && freshPublication(row.manifest) }; +} + +/** Resolve a source-fresh published GERS alias, preferring active then newest. + * Discovery disable does not revoke already published tiles or their identities. + * Still-retained generations own their identities until removed or source-stale. + */ +export async function readAmbientPlaceByGers( + sql: postgres.Sql, + gers: string, +): Promise<{ generation: string; place: AmbientPlace } | null> { + if (!gers || gers.length > 128) return null; + return sql.begin("isolation level repeatable read", async (tx) => { + await tx.unsafe(`SET LOCAL statement_timeout='2000ms'`); + const [exists] = await tx.unsafe<{ exists: boolean }[]>( + `SELECT to_regclass('ambient_places.state') IS NOT NULL AS exists`, + ); + if (!exists.exists) return null; + const candidates = await tx.unsafe<{ generation: string; manifest: AmbientManifest }[]>( + `SELECT g.id AS generation,g.manifest FROM ambient_places.generations g + CROSS JOIN ambient_places.state s WHERE s.singleton=1 + ORDER BY (g.id=s.active) DESC,g.published_at DESC,g.id + LIMIT ${AMBIENT_LIMITS.generations}`, + ); + const generations = candidates + .filter((candidate) => freshPublication(candidate.manifest)) + .map((candidate) => candidate.generation); + if (!generations.length) return null; + const [row] = await tx.unsafe< + (Record & { generation: string; lng: number; lat: number })[] + >( + `SELECT generation,id,gers_id,name,name_de,name_en,category,rank,min_zoom,tenant,sources, + 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=ANY($1::UUID[]) AND gers_id=$2 + ORDER BY array_position($1::UUID[],generation),id COLLATE "C" LIMIT 1`, + [generations, gers], + ); + const place = row ? ambientPlaceFromTile(row, [row.lng, row.lat]) : null; + return place ? { generation: row.generation, place } : null; + }); +} +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,ST_GeomFromEWKB(geom) AS geom + FROM ambient_places.features + 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) + +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/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index 16f32db9a..893ec5578 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -423,7 +423,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", @@ -4369,5 +4370,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 2f1971cb9..4b97b8418 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -423,7 +423,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", @@ -4369,5 +4370,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 5bcd8f62f..1d97bcab9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1102,6 +1102,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.8(@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': @@ -2191,6 +2216,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) @@ -2614,12 +2642,21 @@ importers: specifier: ^8.10.2 version: 8.10.2 devDependencies: + '@mapbox/vector-tile': + specifier: 3.0.0 + version: 3.0.0 + '@openmapx/integration-framework': + specifier: workspace:^ + version: link:../../packages/integration-framework '@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 @@ -9319,6 +9356,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-postgres.test.ts b/services/data-manager/__tests__/ambient-places/api-postgres.test.ts new file mode 100644 index 000000000..f379e6c4f --- /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 "../helpers/postgis-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 new file mode 100644 index 000000000..ac2b41401 --- /dev/null +++ b/services/data-manager/__tests__/ambient-places/api.test.ts @@ -0,0 +1,40 @@ +import Fastify from "fastify"; +import type postgres from "postgres"; +import { describe, expect, it, vi } from "vitest"; + +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 () => {}, +})); + +import { registerAmbientPlacesApi } from "../../src/jobs/ambient-places/api.js"; + +describe("ambient publication job lifecycle", () => { + 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 () => { + 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/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/extractor-postgres.test.ts b/services/data-manager/__tests__/ambient-places/extractor-postgres.test.ts new file mode 100644 index 000000000..b8950b3b2 --- /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 "../helpers/postgis-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 }); + } + }, 120_000); + }, +); 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)" + } + } + } + ] +} 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..66e180987 --- /dev/null +++ b/services/data-manager/__tests__/ambient-places/publish-postgres.test.ts @@ -0,0 +1,363 @@ +import { VectorTile } from "@mapbox/vector-tile"; +import { AMBIENT_LIMITS } from "@openmapx/core/ambient-places"; +import { + readAmbientManifest, + readAmbientPlaceByGers, + readAmbientTile, +} from "@openmapx/core/ambient-places-server"; +import { createMockIntegrationContext } from "@openmapx/integration-framework/testing"; +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 "../helpers/postgis-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("keeps exact GERS identity from readable retained generations after replacement and rollback", async () => { + const pg = await startPostgis(); + try { + await pg.sql.unsafe( + buildSearchIndexSchemaDDL("osm_search") + buildSearchIndexIndexesDDL("osm_search"), + ); + await pg.sql.unsafe(buildSchemaDDL("overture_places")); + 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','retained','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); + INSERT INTO overture_places.places(gers_id,name,geom,basic_category,confidence,operating_status,release) VALUES('gers-retained','Retained cafe',ST_SetSRID(ST_MakePoint(6.081,50.7701),4326),'cafe',0.8,'open','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','aachen',1,now(),'completed','complete')`); + 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 overture_places.places SET geom=ST_SetSRID(ST_MakePoint(13,52),4326)`, + ); + await buildAmbientPlaces(pg.sql, region); + await buildAmbientPlaces(pg.sql, region); + // Still-stored, source-fresh tiles remain readable even after lease + // expiry, until a later publication actually garbage-collects them. + await pg.sql.unsafe( + `UPDATE ambient_places.generations SET cache_lease_until=now()-interval '1 day' WHERE id=$1`, + [a.generation], + ); + await setAmbientEnabled(pg.sql, false); + expect(await readAmbientTile(pg.sql, a.generation, tile.z, tile.x, tile.y)).toEqual(bytes); + expect(await readAmbientPlaceByGers(pg.sql, "gers-retained")).toMatchObject({ + generation: a.generation, + place: { id: "overture:gers-retained", name: "Retained cafe" }, + }); + await rollbackAmbientPlaces(pg.sql); + expect((await readAmbientPlaceByGers(pg.sql, "gers-retained"))?.generation).toBe( + a.generation, + ); + // Keep A retained through the following new publication; expired, + // unreferenced snapshots are correctly removed by its garbage collection. + await pg.sql.unsafe( + `UPDATE ambient_places.generations SET cache_lease_until=now()+interval '7 days' WHERE id=$1`, + [a.generation], + ); + await pg.sql.unsafe(`UPDATE overture_places.places SET geom=ST_SetSRID(ST_MakePoint(6.081,50.7701),4326); + INSERT INTO overture_places.poi_conflation_link(osm_type,osm_id,gers_id,source_confidence,match_confidence,distance_m,method,evidence,release) VALUES('node',1,'gers-retained',0.8,1,0,'fixture','{}','2026-10-01.0')`); + const current = await buildAmbientPlaces(pg.sql, region); + expect(await readAmbientPlaceByGers(pg.sql, "gers-retained")).toMatchObject({ + generation: current.generation, + place: { id: "osm:node/1" }, + }); + await pg.sql.unsafe( + `UPDATE ambient_places.generations SET manifest=jsonb_set(manifest,'{sources,osm,publishedAt}',to_jsonb((now()-interval '91 days')::TEXT)) WHERE id=$1`, + [current.generation], + ); + expect((await readAmbientPlaceByGers(pg.sql, "gers-retained"))?.generation).toBe( + a.generation, + ); + await pg.sql.unsafe( + `UPDATE ambient_places.generations SET manifest=jsonb_set(manifest,'{sources,osm,publishedAt}',to_jsonb((now()-interval '91 days')::TEXT)) WHERE id=$1`, + [a.generation], + ); + expect(await readAmbientPlaceByGers(pg.sql, "gers-retained")).toBeNull(); + } finally { + await pg.stop(); + } + }, 120_000); + 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; + 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 }, + }); + 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"); + 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(); + + // Execute the registered enabled-provider resolver against the real + // accepted link, not a conversion fixture with a prefilled canonical ID. + const { setup } = await import("../../../../integrations/poi-overture/index.js"); + const { getPlaceResolver } = await import("../../../../packages/place-ids/src/index.js"); + setup( + createMockIntegrationContext({ + db: { + execute: async (query: string, params?: unknown[]) => + (await pg.sql.unsafe(query, params as never[])) as unknown as T, + }, + }), + ); + expect(await getPlaceResolver("overture")?.("gers-a", { lang: "en" })).toMatchObject({ + id: "osm:node/9007199254740993", + primaryScheme: "osm", + ids: { osm: "node/9007199254740993", 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); + 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()`, + ); + 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("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 { + 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 f8b72d282..81de7ec18 100644 --- a/services/data-manager/package.json +++ b/services/data-manager/package.json @@ -30,8 +30,11 @@ "undici": "^8.10.2" }, "devDependencies": { + "@mapbox/vector-tile": "3.0.0", + "@openmapx/integration-framework": "workspace:^", "@testcontainers/postgresql": "^12.1.0", "@types/node": "^26.6.2", + "pbf": "5.1.2", "typescript": "^6.0.3", "vitest": "^5.0.1" } 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..74a3b3c7b --- /dev/null +++ b/services/data-manager/src/jobs/ambient-places/api.ts @@ -0,0 +1,116 @@ +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, AmbientPublicationBusyError } from "./schema.js"; + +/** Mounted inside data-manager's bearer-token protected application. */ +export function registerAmbientPlacesApi(app: FastifyInstance, sql: postgres.Sql): void { + 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: Boolean(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: Boolean(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" }); + // 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 admission; + } catch (error) { + if (error instanceof AmbientPublicationBusyError) + return reply.code(409).send({ error: error.message }); + throw error; + } + 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..d4b46f641 --- /dev/null +++ b/services/data-manager/src/jobs/ambient-places/build.ts @@ -0,0 +1,288 @@ +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, 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 AmbientPublicationBusyError(); +} +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, + onClaim?: () => void | Promise, +): Promise { + const region = validateAmbientRegion(input); + await ensureAmbientSchema(sql); + 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`, + ); + 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 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); + // 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 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 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, + }, + }; + 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_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)], + ); + } + 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); + } + throw error; + } +} +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.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 new file mode 100644 index 000000000..066bcd386 --- /dev/null +++ b/services/data-manager/src/jobs/ambient-places/schema.ts @@ -0,0 +1,53 @@ +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(), + 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, + category TEXT NOT NULL, rank INTEGER NOT NULL, min_zoom SMALLINT NOT NULL, + tenant BOOLEAN NOT NULL, sources TEXT NOT NULL, geom BYTEA NOT NULL, + PRIMARY KEY(generation,id) +); +-- 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), + active UUID REFERENCES ambient_places.generations(id), previous UUID REFERENCES ambient_places.generations(id), + enabled BOOLEAN NOT NULL DEFAULT true, + 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>(); +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'`); + 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); + initialization.set(sql, ready); + ready.catch(() => initialization.delete(sql)); + } + await ready; +} 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`,