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 @@
+
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