diff --git a/Dockerfile b/Dockerfile index 997ac04..c7e9bea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,11 @@ ENV VITE_MAP_RUNTIME_V2=$VITE_MAP_RUNTIME_V2 ENV VITE_DISCOVER_BOUNDARIES=$VITE_DISCOVER_BOUNDARIES COPY --from=base /app ./ RUN npm run build -w @mapos/web +# Compress once at build time instead of on every request on a busy host; nginx serves the +# `.gz` next to each file (`gzip_static`). Fonts and images are already compressed. +RUN find apps/web/dist -type f \( -name '*.js' -o -name '*.css' -o -name '*.html' -o -name '*.svg' \ + -o -name '*.json' -o -name '*.webmanifest' -o -name '*.txt' \) -size +1k \ + -exec sh -c 'for f; do gzip -9 -c "$f" > "$f.gz"; done' sh {} + FROM nginx:alpine AS web COPY --from=web-build /app/apps/web/dist /usr/share/nginx/html diff --git a/apps/api/src/data/offlineGazetteer.ts b/apps/api/src/data/offlineGazetteer.ts index 16fc083..bcb757c 100644 --- a/apps/api/src/data/offlineGazetteer.ts +++ b/apps/api/src/data/offlineGazetteer.ts @@ -135,7 +135,13 @@ function fold(value: string): string { } /** Entries whose name the query starts, contains, or is contained in — never a blind default. */ -export function searchOfflineGazetteer(query: string, limit = 5): OfflineGazetteerEntry[] { +/** Matches by name; with `near`, equally good matches are ordered by distance from it, the way + * the live geocoders rank the place on screen first. */ +export function searchOfflineGazetteer( + query: string, + limit = 5, + near?: [number, number] | null +): OfflineGazetteerEntry[] { const needle = fold(query); if (needle.length < 2) return []; const scored = OFFLINE_GAZETTEER.map((entry) => { @@ -150,8 +156,10 @@ export function searchOfflineGazetteer(query: string, limit = 5): OfflineGazette : 0; return { entry, score }; }).filter(({ score }) => score > 0); + const distance = (entry: OfflineGazetteerEntry) => + near ? Math.hypot(entry.longitude - near[0], entry.latitude - near[1]) : 0; return scored - .sort((a, b) => b.score - a.score) + .sort((a, b) => b.score - a.score || distance(a.entry) - distance(b.entry)) .slice(0, limit) .map(({ entry }) => entry); } diff --git a/apps/api/src/db/analyze.ts b/apps/api/src/db/analyze.ts new file mode 100644 index 0000000..01a5ba8 --- /dev/null +++ b/apps/api/src/db/analyze.ts @@ -0,0 +1,21 @@ +import { sql } from "./index.js"; +import { safeErrorLogFields } from "../utils/clientError.js"; + +const TABLE = /^[a-z_][a-z0-9_]*$/; + +/** + * Refreshes planner statistics for tables an import just rewrote. Autovacuum gets there + * eventually, but until it does the planner works from the empty table it last saw — it guessed + * 139 rows for a 242 000-row join and picked nested loops. Never fails the import: slow queries + * are better than a lost import. + */ +export async function analyzeTables(tables: readonly string[], run: typeof sql = sql) { + for (const table of tables) { + if (!TABLE.test(table)) throw new Error(`Not a table name: ${table}`); + try { + await run.unsafe(`ANALYZE ${table}`); + } catch (error) { + console.warn(`ANALYZE ${table} failed`, safeErrorLogFields(error)); + } + } +} diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts index 76d8355..6ed3633 100644 --- a/apps/api/src/db/index.ts +++ b/apps/api/src/db/index.ts @@ -35,7 +35,11 @@ import { const connectionString = process.env.DATABASE_URL ?? "postgres://mapos:mapos@localhost:5434/mapos"; -export const sql = postgres(connectionString); +/** JIT and a 4 MB sort budget cost the heavy catalogue joins more than they saved; set per + * connection as well, so a database whose server settings were never tuned still benefits. */ +export const sql = postgres(connectionString, { + connection: { jit: "off", work_mem: process.env.MAPOS_PG_WORK_MEM ?? "32MB" } +}); export const db = drizzle(sql, { schema }); const BASELINE_STATEMENTS = [ diff --git a/apps/api/src/geo/importGeoUnits.ts b/apps/api/src/geo/importGeoUnits.ts index 3a63f6f..3eca9a4 100644 --- a/apps/api/src/geo/importGeoUnits.ts +++ b/apps/api/src/geo/importGeoUnits.ts @@ -11,12 +11,16 @@ import { defaultGeoUnitSourceIds, GEO_UNIT_SOURCES } from "./geoUnitSources.js"; import { importAllGeoUnits } from "./geoUnitsImport.js"; import { safeErrorLogFields } from "../utils/clientError.js"; import { publishGeoUnitRelease } from "./geoUnitReleases.js"; +import { analyzeTables } from "../db/analyze.js"; + +const GEO_UNIT_TABLES = ["geo_units", "geo_unit_versions", "geo_unit_releases"]; if (process.argv[2] === "--publish-release") { const id = process.argv[3]; if (!id || !/^[a-f0-9-]{36}$/i.test(id)) throw new Error("A boundary release UUID is required"); await publishGeoUnitRelease(id); console.log(`Zveřejněno vydání hranic ${id}`); + await analyzeTables(GEO_UNIT_TABLES); process.exit(0); } @@ -39,6 +43,7 @@ try { (result.duplicates ? `, ${result.duplicates} duplicitních kódů` : "") ); } + await analyzeTables(GEO_UNIT_TABLES); process.exit(0); } catch (error) { console.error("Import území se nepodařil", safeErrorLogFields(error)); diff --git a/apps/api/src/geo/importLauCorrespondence.ts b/apps/api/src/geo/importLauCorrespondence.ts index ba51228..e0979d6 100644 --- a/apps/api/src/geo/importLauCorrespondence.ts +++ b/apps/api/src/geo/importLauCorrespondence.ts @@ -1,6 +1,7 @@ import { readFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; import { sql } from "../db/index.js"; +import { analyzeTables } from "../db/analyze.js"; const officialUrl = "https://ec.europa.eu/eurostat/documents/345175/501971/EU-27-LAU-2024-NUTS-2024.xlsx/12971f56-c035-dbab-4d9f-ff1dcc617bb3"; export function validateLauCorrespondence(data: unknown): { @@ -79,7 +80,10 @@ export async function importLauCorrespondence(path: string) { } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { importLauCorrespondence(process.argv[2] ?? "") - .then((result) => console.log(JSON.stringify(result))) + .then(async (result) => { + console.log(JSON.stringify(result)); + await analyzeTables(["geo_unit_correspondences"]); + }) .catch((e) => { console.error(e.message); process.exitCode = 1; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 767d3f3..4018424 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -31,6 +31,7 @@ import Fastify from "fastify"; import cors from "@fastify/cors"; import cookie from "@fastify/cookie"; import compress from "@fastify/compress"; +import { COMPRESSIBLE_TYPES } from "./utils/compressibleTypes.js"; import type { Bbox, ContentDraft, DataProvider, TripPlan } from "@mapos/layer-sdk"; import { registerUser, @@ -71,6 +72,8 @@ import { } from "./services/socialService.js"; import { mapyGeocode, mapySuggest } from "./services/mapyService.js"; import { + nearViewbox, + parseNearPoint, presentMapyGeocodeResult, presentNominatimGeocodeResult, type NominatimGeocodeItem @@ -296,7 +299,11 @@ export async function buildApp(options: BuildAppOptions = {}) { maxAge: 600 }); await app.register(cookie); - await app.register(compress, { global: true, threshold: 512 }); + await app.register(compress, { + global: true, + threshold: 512, + customTypes: COMPRESSIBLE_TYPES + }); app.addHook("onRequest", requireAllowedMutationOrigin(corsOrigins)); app.addHook( "preHandler", @@ -1554,48 +1561,55 @@ export async function buildApp(options: BuildAppOptions = {}) { return { url }; }); - app.get<{ Querystring: { q?: string; provider?: string; autocomplete?: string } }>( - "/geocode", - async (request, reply) => { - const q = (request.query.q ?? "").trim(); - if (q.length < 2) return { results: [] }; - - // Mapy geocoding is markedly better in CZ/SK; Nominatim stays the keyless default and the - // fallback whenever Mapy is unavailable, so search never goes dead. - if (request.query.provider !== "osm" && capabilities().mapy) { - try { - const items = - request.query.autocomplete === "true" - ? await mapySuggest({ query: q, limit: 5, type: "all" }) - : await mapyGeocode(q, "cs", 5); - if (items.length) { - return { - results: items.map(presentMapyGeocodeResult) - }; - } - } catch (err) { - app.log.warn(safeErrorLogFields(err), "mapy geocode failed, falling back to nominatim"); + app.get<{ + Querystring: { q?: string; provider?: string; autocomplete?: string; near?: string }; + }>("/geocode", async (request, reply) => { + const q = (request.query.q ?? "").trim(); + if (q.length < 2) return { results: [] }; + // The centre of the map on screen, so a search ranks places there first. A bias, never a + // filter: a place elsewhere is still found. + const near = parseNearPoint(request.query.near); + + // Mapy geocoding is markedly better in CZ/SK; Nominatim stays the keyless default and the + // fallback whenever Mapy is unavailable, so search never goes dead. + if (request.query.provider !== "osm" && capabilities().mapy) { + try { + const items = + request.query.autocomplete === "true" + ? await mapySuggest({ + query: q, + limit: 5, + type: "all", + ...(near ? { preferNear: near } : {}) + }) + : await mapyGeocode(q, "cs", 5, undefined, near ?? undefined); + if (items.length) { + return { + results: items.map(presentMapyGeocodeResult) + }; } + } catch (err) { + app.log.warn(safeErrorLogFields(err), "mapy geocode failed, falling back to nominatim"); } + } - if (request.query.autocomplete === "true") return { results: [] }; - try { - const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&addressdetails=1&limit=5&q=${encodeURIComponent(q)}`; - const data = await fetchJson(url, { - providerId: "nominatim", - ttlMs: 24 * 60 * 60_000, - timeoutMs: 8_000, - minIntervalMs: 1_100, - maxResponseBytes: 512 * 1024 - }); - return { - results: data.map(presentNominatimGeocodeResult) - }; - } catch { - return reply.code(502).send({ results: [] }); - } + if (request.query.autocomplete === "true") return { results: [] }; + try { + const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&addressdetails=1&limit=5&q=${encodeURIComponent(q)}${near ? `&viewbox=${nearViewbox(near)}&bounded=0` : ""}`; + const data = await fetchJson(url, { + providerId: "nominatim", + ttlMs: 24 * 60 * 60_000, + timeoutMs: 8_000, + minIntervalMs: 1_100, + maxResponseBytes: 512 * 1024 + }); + return { + results: data.map(presentNominatimGeocodeResult) + }; + } catch { + return reply.code(502).send({ results: [] }); } - ); + }); app.get<{ Querystring: { lat?: string; lng?: string } }>("/geocode/reverse", async (request) => { const lat = Number(request.query.lat); @@ -1712,6 +1726,13 @@ async function main() { const port = Number(process.env.PORT ?? 4033); await app.listen({ port, host: "0.0.0.0" }); console.log(`MapOS API listening on :${port}`); + // The first visitor after a restart used to wait ~8 s while the boundary manifest and the + // theme catalogue were computed cold. Compute them once now, in the background; a failure + // here only means the first real request does the work. + void (async () => { + for (const url of ["/v2/discover/boundaries", "/v2/themes?zoom=0"]) + await app.inject({ method: "GET", url }).catch(() => undefined); + })(); } // Keep imports side-effect free so route/schema tests can build the app without starting a diff --git a/apps/api/src/memory-server.ts b/apps/api/src/memory-server.ts index 565e51c..73fc6cc 100644 --- a/apps/api/src/memory-server.ts +++ b/apps/api/src/memory-server.ts @@ -114,6 +114,7 @@ import { memoryPlanShareRepository } from "./services/planShareMemoryRepository. import { memoryPlanDiscussionRepository } from "./services/planDiscussionMemoryRepository.js"; import { createMemoryAdjacentRouteProvider } from "./services/adjacentRouteProvider.js"; import { searchOfflineGazetteer } from "./data/offlineGazetteer.js"; +import { parseNearPoint } from "./services/geocodePresentation.js"; import { dataSourceProviders } from "./services/dataSources/index.js"; import { withOfflinePlaces } from "./services/ai/offlinePlaces.js"; import { EventService } from "./services/events/eventService.js"; @@ -1628,8 +1629,12 @@ export async function buildMemoryApp(options: MemoryAppOptions = {}) { app.get("/photos/resolve", async () => ({ url: null })); - app.get<{ Querystring: { q?: string } }>("/geocode", async (request) => ({ - results: searchOfflineGazetteer(request.query.q ?? "").map((entry) => ({ + app.get<{ Querystring: { q?: string; near?: string } }>("/geocode", async (request) => ({ + results: searchOfflineGazetteer( + request.query.q ?? "", + 5, + parseNearPoint(request.query.near) + ).map((entry) => ({ display_name: `${entry.name}, Česko`, lat: String(entry.latitude), lon: String(entry.longitude), diff --git a/apps/api/src/routes/discoverBoundaryRoutes.ts b/apps/api/src/routes/discoverBoundaryRoutes.ts index 70983e4..62bead1 100644 --- a/apps/api/src/routes/discoverBoundaryRoutes.ts +++ b/apps/api/src/routes/discoverBoundaryRoutes.ts @@ -35,12 +35,19 @@ export function registerDiscoverBoundaryRoutes( ); let metadata: { value: unknown; expires: number } | undefined; let metadataFlight: Promise | undefined; + // Coverage is a pure function of the published revision and the expensive half of this + // answer; only the cheap manifest lookup repeats every minute. + let revisionCoverage: { revision: string; coverage: unknown[] } | undefined; async function boundaryMetadata() { if (metadata && Date.now() < metadata.expires) return metadata.value; if (metadataFlight) return metadataFlight; metadataFlight = (async () => { const revision = await repository.manifest?.(); - const coverage = await repository.coverage(revision ?? undefined); + const coverage = + revision && revisionCoverage?.revision === revision + ? revisionCoverage.coverage + : await repository.coverage(revision ?? undefined); + if (revision) revisionCoverage = { revision, coverage }; const value = { coverage, ready: coverage.length > 0, diff --git a/apps/api/src/routes/themeRoutes.ts b/apps/api/src/routes/themeRoutes.ts index fda5c7f..5c0187e 100644 --- a/apps/api/src/routes/themeRoutes.ts +++ b/apps/api/src/routes/themeRoutes.ts @@ -113,6 +113,8 @@ export function registerThemeRoutes( ? {} : await catalogCoverage(bbox, (request.query as { zoom?: number }).zoom); const locale = localeFrom(request.query); + // The same for every reader; the client snaps its bbox, so a pan back hits the browser. + reply.header("Cache-Control", "public, max-age=300"); return { themes: listThemes().map((entry) => { const text = themeText(entry, locale); diff --git a/apps/api/src/services/geocodePresentation.test.ts b/apps/api/src/services/geocodePresentation.test.ts index d685411..914f6ba 100644 --- a/apps/api/src/services/geocodePresentation.test.ts +++ b/apps/api/src/services/geocodePresentation.test.ts @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { geocodeConfidence, + nearViewbox, + parseNearPoint, presentMapyGeocodeResult, presentNominatimGeocodeResult } from "./geocodePresentation.js"; @@ -52,3 +54,12 @@ test("confidence is explicitly ordinal instead of pretending to be a provider pr assert.equal(geocodeConfidence(2).level, "medium"); assert.equal(geocodeConfidence(20).level, "low"); }); + +test("the near-point search bias is coarse and ignores malformed input", () => { + assert.deepEqual(parseNearPoint("14.42076,50.08804"), [14.42, 50.09]); + assert.deepEqual(parseNearPoint(" -0.1276 , 51.5072 "), [-0.13, 51.51]); + for (const value of [undefined, "", "14.4", "a,b", "14,50,1", "200,50", "14,95", "1".repeat(80)]) + assert.equal(parseNearPoint(value), null, String(value)); + assert.equal(nearViewbox([14.42, 50.09]), "14.12,50.39,14.72,49.79"); + assert.equal(nearViewbox([179.9, 89.9]), "179.6,90,180,89.6"); +}); diff --git a/apps/api/src/services/geocodePresentation.ts b/apps/api/src/services/geocodePresentation.ts index 6c74570..1155c19 100644 --- a/apps/api/src/services/geocodePresentation.ts +++ b/apps/api/src/services/geocodePresentation.ts @@ -97,3 +97,31 @@ export function presentNominatimGeocodeResult( confidence: geocodeConfidence(index) }; } + +/** + * Reads the `near=lng,lat` search bias. Rounded to two decimals (about a kilometre): enough to + * rank the right town first, coarse enough that the provider never learns the exact view and + * repeated searches over one city share a cached answer. Anything malformed is simply no bias. + */ +export function parseNearPoint(value: string | undefined): [number, number] | null { + if (!value || value.length > 64) return null; + const parts = value.split(","); + if (parts.length !== 2) return null; + const [lng, lat] = parts.map((part) => Number(part.trim())) as [number, number]; + if (!Number.isFinite(lng) || !Number.isFinite(lat) || Math.abs(lng) > 180 || Math.abs(lat) > 90) + return null; + return [Math.round(lng * 100) / 100, Math.round(lat * 100) / 100]; +} + +/** A Nominatim `viewbox` (left,top,right,bottom) of roughly a city region around the point. */ +export function nearViewbox([lng, lat]: [number, number], span = 0.3): string { + const clamp = (value: number, limit: number) => Math.max(-limit, Math.min(limit, value)); + return [ + clamp(lng - span, 180), + clamp(lat + span, 90), + clamp(lng + span, 180), + clamp(lat - span, 90) + ] + .map((value) => Number(value.toFixed(2))) + .join(","); +} diff --git a/apps/api/src/services/mapyService.ts b/apps/api/src/services/mapyService.ts index b16e3f7..f43aafe 100644 --- a/apps/api/src/services/mapyService.ts +++ b/apps/api/src/services/mapyService.ts @@ -119,13 +119,25 @@ function normalizeItems(data: MapyItemsResponse): MapyGeocodeItem[] { })); } +/** `preferNear` ranks matches close to a point higher without excluding the rest — the same + * "Náměstí Míru" in the city on screen before the one across the country. */ export async function mapyGeocode( query: string, lang = "cs", limit = 8, - signal?: AbortSignal + signal?: AbortSignal, + preferNear?: [number, number] ): Promise { - const data = await mapyJson("/v1/geocode", { query, lang, limit }, signal); + const data = await mapyJson( + "/v1/geocode", + { + query, + lang, + limit, + preferNear: preferNear ? `${preferNear[0]},${preferNear[1]}` : undefined + }, + signal + ); return normalizeItems(data); } diff --git a/apps/api/src/themes/importStatSeries.ts b/apps/api/src/themes/importStatSeries.ts index 5157a4b..35bd12f 100644 --- a/apps/api/src/themes/importStatSeries.ts +++ b/apps/api/src/themes/importStatSeries.ts @@ -8,6 +8,7 @@ import { STAT_DATASETS } from "@mapos/adapter-sdk"; import { importAllStatDatasets } from "./statSeriesImport.js"; import { safeErrorLogFields } from "../utils/clientError.js"; +import { analyzeTables } from "../db/analyze.js"; const requested = process.argv.slice(2).filter((argument) => !argument.startsWith("-")); const unknown = requested.filter((id) => !STAT_DATASETS.some((dataset) => dataset.id === id)); @@ -36,6 +37,7 @@ try { : "bez období"; console.log(`${result.datasetId}: ${result.written} hodnot, ${range}`); } + await analyzeTables(["stat_series", "stat_datasets", "stat_import_runs", "theme_coverage"]); process.exit(process.exitCode ?? 0); } catch (error) { console.error("Import statistik se nepodařil", safeErrorLogFields(error)); diff --git a/apps/api/src/themes/themeExplorerService.ts b/apps/api/src/themes/themeExplorerService.ts index 127beeb..47c5cc3 100644 --- a/apps/api/src/themes/themeExplorerService.ts +++ b/apps/api/src/themes/themeExplorerService.ts @@ -1,22 +1,37 @@ import { selectDataset } from "./selectDataset.js"; import { sql } from "../db/index.js"; import { theme, THEMES } from "./themeRegistry.js"; -import { statDataset } from "@mapos/adapter-sdk"; +import { statCoverageRequest, statDataset } from "@mapos/adapter-sdk"; import { databaseQueries } from "./themeService.js"; import { createHash } from "node:crypto"; +import { TtlCache } from "../utils/ttlCache.js"; type Coverage = { available: boolean; coverageStatus: "none" | "partial" | "full" | "unknown"; geoLevels: string[]; }; -const catalogueCache = new Map }>(); +type Bbox = [number, number, number, number]; + +const THEME_DATASET_IDS = [...new Set(THEMES.flatMap((t) => t.sources.map((s) => s.datasetId)))]; +const catalogueCache = new TtlCache>({ + ttlMs: 15 * 60_000, + maxEntries: 256 +}); + /** One compact request for the drawer. No geometry or individual observations reach the client. * Coverage counts matching land territories, so coastlines don't turn complete data yellow. */ -export async function catalogCoverage(bbox: [number, number, number, number] | null, zoom = 0) { - const key = JSON.stringify([bbox, Math.floor(zoom), process.env.MAPOS_ALLOW_NONCOMMERCIAL_DATA]); - const cached = catalogueCache.get(key); - if (cached && cached.until > Date.now()) return cached.value; +export function catalogCoverage(bbox: Bbox | null, zoom = 0) { + const request = statCoverageRequest(bbox, zoom); + const key = JSON.stringify([ + request.bbox, + request.zoom, + process.env.MAPOS_ALLOW_NONCOMMERCIAL_DATA + ]); + return catalogueCache.getOrLoad(key, () => computeCoverage(request.bbox, request.zoom)); +} + +async function computeCoverage(bbox: Bbox | null, zoom: number) { const [w, s, e, n] = bbox ?? [-180, -85, 180, 85]; const rows = await sql`WITH geography AS MATERIALIZED ( SELECT level,code,edition FROM geo_units g @@ -28,11 +43,10 @@ export async function catalogCoverage(bbox: [number, number, number, number] | n SELECT ss.dataset_id,ss.geo_level,COUNT(DISTINCT ss.geo_code)::integer AS matched,MAX(t.total)::integer AS total FROM stat_series ss JOIN geography g ON g.level=ss.geo_level AND g.code=ss.geo_code AND (ss.boundary_edition IS NULL OR ss.boundary_edition=g.edition) - JOIN totals t ON t.level=ss.geo_level WHERE ss.value IS NOT NULL + JOIN totals t ON t.level=ss.geo_level + WHERE ss.value IS NOT NULL AND ss.dataset_id=ANY(${THEME_DATASET_IDS}) GROUP BY ss.dataset_id,ss.geo_level`; - const available = await databaseQueries.available!([ - ...new Set(THEMES.flatMap((t) => t.sources.map((s) => s.datasetId))) - ]); + const available = await databaseQueries.available!(THEME_DATASET_IDS); const result: Record = {}; for (const entry of THEMES) { const sources = entry.sources.filter((s) => available.includes(s.datasetId)); @@ -57,12 +71,27 @@ export async function catalogCoverage(bbox: [number, number, number, number] | n ] }; } - if (catalogueCache.size >= 64) catalogueCache.delete(catalogueCache.keys().next().value!); - catalogueCache.set(key, { until: Date.now() + 60000, value: result }); return result; } -export async function explorerInventory(id: string, excluded: string[] = [], period = "latest") { +/** Import runs change a few times a day; two minutes keeps a published import visible soon + * while a burst of detail requests for one theme runs its two queries once. */ +const inventoryCache = new TtlCache>>({ + ttlMs: 2 * 60_000, + maxEntries: 128 +}); + +export function explorerInventory(id: string, excluded: string[] = [], period = "latest") { + const key = JSON.stringify([ + id, + [...excluded].sort(), + period, + process.env.MAPOS_ALLOW_NONCOMMERCIAL_DATA + ]); + return inventoryCache.getOrLoad(key, () => loadExplorerInventory(id, excluded, period)); +} + +async function loadExplorerInventory(id: string, excluded: string[], period: string) { const ids = theme(id) ?.sources.map((s) => s.datasetId) diff --git a/apps/api/src/utils/compressibleTypes.test.ts b/apps/api/src/utils/compressibleTypes.test.ts new file mode 100644 index 0000000..a5a5781 --- /dev/null +++ b/apps/api/src/utils/compressibleTypes.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { COMPRESSIBLE_TYPES } from "./compressibleTypes.js"; + +test("vector tiles are compressed, event streams never are", () => { + for (const type of [ + "application/vnd.mapbox-vector-tile", + "application/x-protobuf", + "application/json; charset=utf-8", + "application/geo+json", + "text/html" + ]) + assert.ok(COMPRESSIBLE_TYPES.test(type), type); + for (const type of ["text/event-stream", "image/png", "image/webp"]) + assert.ok(!COMPRESSIBLE_TYPES.test(type), type); +}); diff --git a/apps/api/src/utils/compressibleTypes.ts b/apps/api/src/utils/compressibleTypes.ts new file mode 100644 index 0000000..789b53d --- /dev/null +++ b/apps/api/src/utils/compressibleTypes.ts @@ -0,0 +1,8 @@ +/** + * Response types the API gzips: `@fastify/compress`'s default list plus vector tiles. MVT is + * protobuf that gzips to about a third, and neither the plugin's list nor the edge proxy's + * treats it as compressible. Event streams stay uncompressed so each frame is flushed as it is + * written. + */ +export const COMPRESSIBLE_TYPES = + /^text\/(?!event-stream)|(?:\+|\/)json(?:;|$)|(?:\+|\/)text(?:;|$)|(?:\+|\/)xml(?:;|$)|octet-stream(?:;|$)|\/vnd\.mapbox-vector-tile(?:;|$)|\/x-protobuf(?:;|$)/u; diff --git a/apps/api/src/utils/ttlCache.test.ts b/apps/api/src/utils/ttlCache.test.ts new file mode 100644 index 0000000..e373554 --- /dev/null +++ b/apps/api/src/utils/ttlCache.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { TtlCache } from "./ttlCache.js"; + +test("concurrent reads of one key share a single load and later reads hit the cache", async () => { + let loads = 0; + let release!: (value: string) => void; + const cache = new TtlCache({ ttlMs: 1_000, maxEntries: 4 }); + const load = () => { + loads++; + return new Promise((resolve) => (release = resolve)); + }; + const first = cache.getOrLoad("a", load); + const second = cache.getOrLoad("a", load); + release("value"); + assert.deepEqual(await Promise.all([first, second]), ["value", "value"]); + assert.equal(await cache.getOrLoad("a", load), "value"); + assert.equal(loads, 1); +}); + +test("entries expire, failures are not cached and the bound drops the least recent key", async () => { + let now = 0; + const cache = new TtlCache({ ttlMs: 100, maxEntries: 2, now: () => now }); + await assert.rejects(cache.getOrLoad("x", () => Promise.reject(new Error("db down")))); + assert.equal(await cache.getOrLoad("x", async () => 1), 1, "a failure is retried"); + + cache.set("y", 2); + assert.equal(cache.get("x"), 1, "a hit refreshes recency"); + cache.set("z", 3); + assert.equal(cache.get("y"), undefined, "the least recently used key made room"); + assert.equal(cache.get("x"), 1); + + now = 150; + assert.equal(cache.get("x"), undefined, "expired"); + assert.equal(cache.size, 1); +}); diff --git a/apps/api/src/utils/ttlCache.ts b/apps/api/src/utils/ttlCache.ts new file mode 100644 index 0000000..d35259e --- /dev/null +++ b/apps/api/src/utils/ttlCache.ts @@ -0,0 +1,58 @@ +/** + * A small bounded cache for expensive, idempotent reads (catalogue coverage, inventories). + * + * Concurrent callers of one key share a single load instead of each running the same query, a + * failed load is not cached, and the oldest entry makes room once `maxEntries` is reached (a hit + * moves its key to the young end, so the bound behaves like an LRU). + */ +export class TtlCache { + private readonly entries = new Map(); + private readonly inflight = new Map>(); + private readonly now: () => number; + + constructor(private readonly options: { ttlMs: number; maxEntries: number; now?: () => number }) { + this.now = options.now ?? Date.now; + } + + get(key: string): V | undefined { + const hit = this.entries.get(key); + if (!hit) return undefined; + if (hit.until <= this.now()) { + this.entries.delete(key); + return undefined; + } + this.entries.delete(key); + this.entries.set(key, hit); + return hit.value; + } + + set(key: string, value: V, ttlMs = this.options.ttlMs) { + this.entries.delete(key); + while (this.entries.size >= this.options.maxEntries) + this.entries.delete(this.entries.keys().next().value!); + this.entries.set(key, { until: this.now() + ttlMs, value }); + } + + async getOrLoad(key: string, load: () => Promise): Promise { + const cached = this.get(key); + if (cached !== undefined) return cached; + const running = this.inflight.get(key); + if (running) return running; + const promise = load() + .then((value) => { + this.set(key, value); + return value; + }) + .finally(() => this.inflight.delete(key)); + this.inflight.set(key, promise); + return promise; + } + + clear() { + this.entries.clear(); + } + + get size() { + return this.entries.size; + } +} diff --git a/apps/web/package.json b/apps/web/package.json index d275708..9af3bfc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,7 +20,6 @@ "@mapos/adapter-sdk": "*", "@mapos/layer-sdk": "*", "@mapos/map-runtime": "*", - "@tanstack/react-query": "^5.80.7", "maplibre-gl": "^5.6.0", "pmtiles": "^4.5.0", "react": "^19.1.0", diff --git a/apps/web/src/assets/fonts/material-symbols-rounded-subset.woff2 b/apps/web/src/assets/fonts/material-symbols-rounded-subset.woff2 index 77a5bae..e7dabfb 100644 Binary files a/apps/web/src/assets/fonts/material-symbols-rounded-subset.woff2 and b/apps/web/src/assets/fonts/material-symbols-rounded-subset.woff2 differ diff --git a/apps/web/src/discover/areaControls.css b/apps/web/src/discover/areaControls.css index 217e896..03e2263 100644 --- a/apps/web/src/discover/areaControls.css +++ b/apps/web/src/discover/areaControls.css @@ -32,11 +32,6 @@ padding: 6px 8px; min-height: 36px; } -.area-actions { - display: flex; - flex-wrap: wrap; - gap: 6px; -} .area-controls details, .area-controls p { flex-basis: 100%; @@ -79,17 +74,3 @@ bottom: 96px; z-index: 30; } - -.area-layer-notices { - flex-basis: 100%; - max-height: 88px; - overflow: auto; - overscroll-behavior: contain; -} -.area-layer-notices .layer-notice { - box-shadow: none; - padding: 6px 0; - background: transparent; - border: 0; - font-size: 12px; -} diff --git a/apps/web/src/engine/LayerEngine.test.ts b/apps/web/src/engine/LayerEngine.test.ts index d9c9bdb..29a441d 100644 --- a/apps/web/src/engine/LayerEngine.test.ts +++ b/apps/web/src/engine/LayerEngine.test.ts @@ -947,3 +947,113 @@ test("progressive retries back off from the server hint and stay bounded", () => assert.equal(progressiveRetryDelay(2000, 20), 15000); assert.equal(progressiveRetryDelay(10, 1), 1000); }); + +function searchHereHarness( + t: { after(fn: () => void): void }, + viewportCost: "cheap" | "expensive", + preferences: { manualRefresh: boolean } = { manualRefresh: false } +) { + resetLayerRegistry(); + const events = new EventTarget(); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + addEventListener: events.addEventListener.bind(events), + removeEventListener: events.removeEventListener.bind(events) + } + }); + const requested: Bbox[] = []; + const pending: boolean[] = []; + registerLayer({ + kind: "pins", + viewportCost, + manifest: { + id: "area", + name: "Area", + icon: "x", + color: "#000000", + description: "Area", + category: "user" + }, + create: () => ({ + async update(bbox) { + requested.push(bbox); + return collection("a"); + }, + setData() {}, + setVisible() {}, + setOpacity() {}, + detach() {} + }) + }); + const store = { + view: { zoom: 14 }, + preferences, + activeLayers: { area: { visible: true, opacity: 1, filters: {} } }, + session: null, + activeTag: null, + countryCode: null, + enabledPoiSources: [], + setVisibleFeatures() {}, + setLayerLoading() {}, + setLayerNotice() {}, + setSearchHerePending(value: boolean) { + pending.push(value); + }, + markSourcesLoading() {}, + applySourceMeta() {} + } as unknown as MapStore; + const engine = new LayerEngine({} as never, "/api", store, new TaskRegistry()); + t.after(() => engine.destroy()); + return { engine, requested, pending: () => pending.at(-1) ?? false }; +} + +const PLZEN: Bbox = [13.36, 49.74, 13.39, 49.755]; +const PRAHA: Bbox = [14.41, 50.08, 14.44, 50.095]; + +test("a long move leaves an expensive layer waiting, and Search this area loads only it", async (t) => { + const { engine, requested, pending } = searchHereHarness(t, "expensive"); + engine.refresh(PLZEN, true); + await waitFor(() => requested.length === 1); + assert.equal(pending(), false); + + engine.refresh(PRAHA); + await wait(400); + assert.equal(requested.length, 1, "a jump across the country waits to be asked"); + assert.equal(pending(), true, "and says so"); + + engine.searchHere(PRAHA); + await waitFor(() => requested.length === 2); + assert.deepEqual(requested[1], snapBboxToTileGrid(PRAHA, 14)); + await waitFor(() => pending() === false); + + engine.searchHere(PRAHA); + await wait(50); + assert.equal(requested.length, 2, "pressing it again for a loaded view costs nothing"); +}); + +test("while an expensive layer loads after a short pan it is not reported as waiting", async (t) => { + const { engine, requested, pending } = searchHereHarness(t, "expensive"); + engine.refresh(PLZEN, true); + await waitFor(() => requested.length === 1); + // Half a screen east: past the fetched tiles, well within the automatic refresh distance. + engine.refresh([13.375, 49.74, 13.405, 49.755]); + assert.equal(pending(), false, "the debounce before an automatic load is not a question"); + await waitFor(() => requested.length === 2); + assert.equal(pending(), false); +}); + +test("with manual refresh the reader's own move waits, a programmatic one still loads", async (t) => { + const { engine, requested, pending } = searchHereHarness(t, "cheap", { manualRefresh: true }); + engine.refresh(PLZEN, true); + await waitFor(() => requested.length === 1); + + engine.refresh([13.4, 49.74, 13.43, 49.755], false, { userMoved: true }); + await wait(400); + assert.equal(requested.length, 1, "a drag loads nothing"); + assert.equal(pending(), true); + + engine.refresh(PRAHA); + await waitFor(() => requested.length === 2); + await waitFor(() => pending() === false); +}); diff --git a/apps/web/src/engine/LayerEngine.ts b/apps/web/src/engine/LayerEngine.ts index 4a80e9f..e11ddd5 100644 --- a/apps/web/src/engine/LayerEngine.ts +++ b/apps/web/src/engine/LayerEngine.ts @@ -403,8 +403,10 @@ export class LayerEngine { this.refresh(bbox); } - /** Invalidate obsolete work immediately, including the debounce interval. */ - refresh(bbox: Bbox, force = false) { + /** Invalidate obsolete work immediately, including the debounce interval. `userMoved` marks a + * refresh caused by the reader's own pan or zoom, which the manual-refresh preference defers + * until "Search this area". */ + refresh(bbox: Bbox, force = false, { userMoved = false }: { userMoved?: boolean } = {}) { if (this.destroyed) return; this.lastBbox = bbox; if (this.refreshTimer) clearTimeout(this.refreshTimer); @@ -433,10 +435,40 @@ export class LayerEngine { } this.refreshTimer = setTimeout(() => { this.refreshTimer = null; - this.doRefresh(bbox, false); + this.doRefresh(bbox, false, userMoved); }, DEFAULT_REFRESH_DEBOUNCE_MS); } + /** + * "Search this area": brings every visible layer up to date for this view — the ones waiting + * for the button, failed or partial ones, and any whose answer covers a different area — and + * leaves alone the ones already loaded or loading for it. Unlike a forced refresh it does not + * refetch fresh data or reattach tiles, so pressing it twice costs nothing. + */ + searchHere(bbox: Bbox) { + if (this.destroyed) return; + this.lastBbox = bbox; + this.syncLayers(this.store.activeLayers); + for (const [id, managed] of this.managed) { + if (!this.store.activeLayers[id]?.visible) continue; + const query = this.query(id, managed, bbox); + const state = this.states.get(id); + if (this.controllers.has(id) && state?.key === query.key) continue; + const upToDate = + state?.status === "ready" && + state.filterKey === query.filterKey && + state.acceptedAt != null && + Date.now() - state.acceptedAt < (state.ttl ?? query.ttl) && + (query.global || + query.tiled || + query.manual || + state.key === query.key || + Boolean(state.bbox && containsBbox(state.bbox, bbox))); + if (!upToDate) this.refreshLayer(id, bbox); + } + this.updatePending(); + } + private invalidate(id: string) { this.tileObservers.get(id)?.(); this.tileObservers.delete(id); @@ -457,7 +489,8 @@ export class LayerEngine { this.store.setLayerLoading(id, false); } - private doRefresh(bbox: Bbox, force: boolean) { + private doRefresh(bbox: Bbox, force: boolean, userMoved = false) { + const manual = userMoved && !force && Boolean(this.store.preferences?.manualRefresh); this.syncLayers(this.store.activeLayers); for (const [id, managed] of this.managed) { if ( @@ -477,6 +510,10 @@ export class LayerEngine { const query = this.query(id, managed, bbox); const state = this.states.get(id); if (this.controllers.has(id) && state?.key === query.key) continue; + // A partial answer for this very request already has its retry scheduled; asking again now + // would only duplicate the server's work. + if (!force && state?.status === "partial" && state.key === query.key && this.retries.has(id)) + continue; const sameFilters = state?.filterKey === query.filterKey; const fresh = state?.acceptedAt != null && Date.now() - state.acceptedAt < (state.ttl ?? query.ttl); @@ -489,13 +526,16 @@ export class LayerEngine { (query.global || query.tiled || query.manual || (covered && managed.plugin.kind === "pins")) ) continue; + // A long move leaves an expensive layer waiting for "Search this area"; with the manual + // preference every viewport layer that already shows data waits after the reader's move. if ( !force && sameFilters && state?.bbox && - viewportCostOf(managed.plugin) === "expensive" && !query.global && - viewportDrift(state.viewport ?? state.bbox, bbox) > PIN_CONFIRM_DRIFT + ((viewportCostOf(managed.plugin) === "expensive" && + viewportDrift(state.viewport ?? state.bbox, bbox) > PIN_CONFIRM_DRIFT) || + (manual && !query.tiled && !query.manual)) ) { state.status = "pending"; layerActivity.state(id, "pending"); @@ -776,23 +816,18 @@ export class LayerEngine { } } + /** Only layers that deliberately wait for "Search this area" count. A layer that is loading, + * retrying a partial answer or showing a failure has its own indicator; counting those (and + * every expensive layer between a pan and its debounced load) made the button flicker. */ private updatePending() { - const bbox = this.lastBbox; this.store.setSearchHerePending( - [...this.managed].some(([id, managed]) => { - if ( - !this.store.activeLayers[id]?.visible || - (managed.plugin.minQueryZoom != null && - this.store.view.zoom < managed.plugin.minQueryZoom) - ) - return false; - const state = this.states.get(id); - if (state?.status === "partial" || state?.status === "error" || state?.status === "pending") - return true; - if (!bbox || !state?.bbox || viewportCostOf(managed.plugin) !== "expensive") return false; - const query = this.query(id, managed, bbox); - return !query.global && !query.tiled && !containsBbox(state.bbox, bbox); - }) + [...this.managed].some( + ([id, managed]) => + this.store.activeLayers[id]?.visible && + (managed.plugin.minQueryZoom == null || + this.store.view.zoom >= managed.plugin.minQueryZoom) && + this.states.get(id)?.status === "pending" + ) ); } diff --git a/apps/web/src/i18n/cs.ts b/apps/web/src/i18n/cs.ts index 2c748c4..f4444aa 100644 --- a/apps/web/src/i18n/cs.ts +++ b/apps/web/src/i18n/cs.ts @@ -399,7 +399,9 @@ export const cs = { "search.kind.poi": "Místo / POI", "search.kind.peak": "Vrchol", "search.kind.place": "Místo", - "search.searchHere": "Hledat v této oblasti", + "search.searchHere": "Hledat tady", + "search.searchingHere": "Hledám tady…", + "search.searchHere.again": "Hledat tady: znovu „{query}“ pro tento výřez", // ---- Panel chrome ---------------------------------------------------- "panel.close": "Zavřít", @@ -574,8 +576,9 @@ export const cs = { "Fotografie míst načítat až na vyžádání; paměť dlaždic omezit na 16 MiB.", "settings.loadMedia": "Načíst fotografie a média", "settings.flyAnimations": "Animace přeletů", + "settings.manualRefresh": "Načítat až po „Hledat tady“", "settings.searchHere.info": - "Po posunu mapy nabídne ruční obnovení dat, aby se během tažení neposílaly dotazy.", + "Posun mapy nic nenačítá; data pro nový výřez obnovíš tlačítkem Hledat tady. Šetří data i baterii.", "settings.account.guestInfo": "Anonymní profil je uložený v tomto prohlížeči. Uložením účtu ho přeneseš i jinam.", "settings.account.localInfo": diff --git a/apps/web/src/i18n/en.ts b/apps/web/src/i18n/en.ts index 407468e..47d0b65 100644 --- a/apps/web/src/i18n/en.ts +++ b/apps/web/src/i18n/en.ts @@ -397,6 +397,8 @@ export const en: Record = { "search.kind.peak": "Peak", "search.kind.place": "Place", "search.searchHere": "Search this area", + "search.searchingHere": "Searching this area…", + "search.searchHere.again": "Search this area: ask “{query}” again for this view", // ---- Panel chrome ---------------------------------------------------- "panel.close": "Close", @@ -569,8 +571,9 @@ export const en: Record = { "settings.lowData.info": "Load place photos on request; limit the tile cache to 16 MiB.", "settings.loadMedia": "Load photos and media", "settings.flyAnimations": "Fly-to animations", + "settings.manualRefresh": "Load only on “Search this area”", "settings.searchHere.info": - "After you move the map, data is refreshed on request, so dragging sends no queries.", + "Moving the map loads nothing; press Search this area to refresh the view. Saves data and battery.", "settings.account.guestInfo": "An anonymous profile lives in this browser. Save an account to take it elsewhere.", "settings.account.localInfo": "Progress is stored locally. Sign in to carry it between devices.", diff --git a/apps/web/src/layers/satelliteLayer.test.ts b/apps/web/src/layers/satelliteLayer.test.ts index 358ad3d..480fdf9 100644 --- a/apps/web/src/layers/satelliteLayer.test.ts +++ b/apps/web/src/layers/satelliteLayer.test.ts @@ -1,7 +1,9 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { json2satrec, type SatRec } from "satellite.js"; -import { groundTracks } from "./satelliteLayer.js"; +import { groundTracks, loadSatelliteMath } from "./satelliteLayer.js"; + +await loadSatelliteMath(); /** * The ground track is the "reflexе" the reader sees: the path a satellite is about to fly. It is diff --git a/apps/web/src/layers/satelliteLayer.ts b/apps/web/src/layers/satelliteLayer.ts index b56ae54..0d712d7 100644 --- a/apps/web/src/layers/satelliteLayer.ts +++ b/apps/web/src/layers/satelliteLayer.ts @@ -7,7 +7,7 @@ import type { GeoFeature, LayerHandle } from "@mapos/layer-sdk"; -import { eciToGeodetic, gstime, json2satrec, propagate, type SatRec } from "satellite.js"; +import type { SatRec } from "satellite.js"; import { emit } from "../lib/events"; import { registerInteractivePins, unregisterInteractivePins } from "../map/interactivePins"; @@ -96,11 +96,28 @@ function colorFor(category: string): string { return SATELLITE_CATEGORY_COLORS[category] ?? "#38bdf8"; } +/** SGP4 is about 100 kB of the app and only this layer needs it, so it arrives with the layer's + * first update instead of with the map. */ +let sgp4: typeof import("satellite.js") | null = null; +let sgp4Loading: Promise | null = null; +export function loadSatelliteMath(): Promise { + sgp4Loading ??= import("satellite.js").then( + (module) => (sgp4 = module), + (error) => { + sgp4Loading = null; + throw error; + } + ); + return sgp4Loading; +} + /** A satellite's position at an instant, or null when SGP4 cannot produce a sane one. */ function positionAt( satrec: SatRec, at: Date ): { lng: number; lat: number; altitudeKm: number } | null { + if (!sgp4) return null; + const { eciToGeodetic, gstime, propagate } = sgp4; const pv = propagate(satrec, at); const position = pv?.position; if ( @@ -207,7 +224,9 @@ export function createSatelliteLayer( } function buildSatrecs() { + const json2satrec = sgp4?.json2satrec; satrecs = elements.map((element) => { + if (!json2satrec) return { element, satrec: null }; try { const satrec = json2satrec({ OBJECT_NAME: element.name, @@ -404,6 +423,8 @@ export function createSatelliteLayer( ): Promise { ensureLayers(); const keyBefore = currentFilterKey; + await loadSatelliteMath(); + if (signal?.aborted || disposed) return null; await loadCatalog(filters, signal); if (signal?.aborted) return null; // The elements and the drawn tracks do not depend on the viewport, so a pan must not diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 51448b6..b0c10ec 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,6 +1,5 @@ import { StrictMode, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import maplibregl from "maplibre-gl"; import maplibreWorker from "maplibre-gl/dist/maplibre-gl-csp-worker?url"; import { App } from "./App"; @@ -29,34 +28,43 @@ registerTileCacheProtocol(); document.documentElement.style.colorScheme = theme; } -// Clear leftover QuestLayer / Next.js service workers from previous deploys -if ("serviceWorker" in navigator) { - void navigator.serviceWorker.getRegistrations().then((regs) => { - for (const reg of regs) void reg.unregister(); - }); - if ("caches" in window) { - void caches.keys().then((keys) => { - for (const key of keys) void caches.delete(key); - }); - } +// Clear leftover QuestLayer / Next.js service workers from previous deploys. Once per browser: +// repeating it on every start cost a registration query and a cache sweep each time, and would +// remove a future service worker of this app as soon as it was installed. +const SERVICE_WORKER_CLEANUP_KEY = "mapos:legacy-sw-cleared-v1"; +let serviceWorkerCleared = false; +try { + serviceWorkerCleared = window.localStorage.getItem(SERVICE_WORKER_CLEANUP_KEY) === "1"; +} catch { + /* Without storage the cleanup simply runs again. */ +} +if (!serviceWorkerCleared && "serviceWorker" in navigator) { + void Promise.all([ + navigator.serviceWorker + .getRegistrations() + .then((regs) => Promise.all(regs.map((reg) => reg.unregister()))), + "caches" in window + ? caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key)))) + : Promise.resolve() + ]) + .then(() => { + try { + window.localStorage.setItem(SERVICE_WORKER_CLEANUP_KEY, "1"); + } catch { + /* Runs again next time. */ + } + }) + .catch(() => {}); } - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { staleTime: 30_000, retry: 1 } - } -}); const root = createRoot(document.getElementById("root")!); function render(children: ReactNode) { root.render( - - - {children} - - + + {children} + ); } diff --git a/apps/web/src/map/MapCore.tsx b/apps/web/src/map/MapCore.tsx index 5c5497c..b8f1b99 100644 --- a/apps/web/src/map/MapCore.tsx +++ b/apps/web/src/map/MapCore.tsx @@ -23,6 +23,7 @@ import { chromeMapPadding, readChromeInsets } from "./chromePadding"; import { getMapStore, getMapBbox, type LayerMode } from "../store/mapStore"; import { getShellStore } from "../store/shellStore"; import { LayerEngine } from "../engine/LayerEngine"; +import { areaQuery } from "../search/areaQuery"; import { attachBoundaryOverlay } from "../discover/boundaryOverlay"; import { activeAttribution } from "../layers/attribution"; import { API_BASE } from "../lib/api"; @@ -107,7 +108,10 @@ export function MapCore() { dragRotate: false, pitchWithRotate: false, touchPitch: false, - fadeDuration: 0 + fadeDuration: 0, + // A 3× phone screen renders 2.25× the pixels of 2× for a difference nobody sees on a map + // in motion; the cap keeps panning smooth and the GPU cool. + pixelRatio: Math.min(window.devicePixelRatio || 1, 2) }); const stopSocialMap = attachSocialMap(map); const dataLayerLifecycle = MAP_RUNTIME_V2_ENABLED ? new MapLibreDataLayerLifecycle(map) : null; @@ -116,6 +120,8 @@ export function MapCore() { let lastGameFollowAt = 0; let pendingViewportRefresh = false; let pendingWorldRefresh = false; + /** Whether the refresh waiting for `idle` answers a move the reader made. */ + let pendingUserMove = false; const applyGameFollow = () => { gameFollowTimer = null; @@ -268,11 +274,17 @@ export function MapCore() { recordPendingBasemap("success"); if (!pendingViewportRefresh) return; pendingViewportRefresh = false; - engineRef.current?.refresh(getMapBbox(map), pendingWorldRefresh); + engineRef.current?.refresh(getMapBbox(map), pendingWorldRefresh, { + userMoved: pendingUserMove + }); pendingWorldRefresh = false; + pendingUserMove = false; }); const resize = () => { + // Follows a window moved to a screen with another density, within the same cap. + const pixelRatio = Math.min(window.devicePixelRatio || 1, 2); + if (map.getPixelRatio() !== pixelRatio) map.setPixelRatio(pixelRatio); map.resize(); }; resize(); @@ -288,10 +300,14 @@ export function MapCore() { emit("discover-viewport", { lng: center.lng, lat: center.lat, zoom, bbox: getMapBbox(map) }); }; - map.on("moveend", () => { + map.on("moveend", (event: { originalEvent?: Event }) => { const center = map.getCenter(); const zoom = map.getZoom(); if (!Number.isFinite(center.lng) || !Number.isFinite(center.lat) || zoom < 1) return; + // MapLibre passes the DOM event through for moves the reader made (drag, wheel, keys, + // inertia); programmatic camera moves have none. + const userMoved = Boolean(event.originalEvent); + if (userMoved) areaQuery.markMoved(); // Camera state belongs to the shell, not to the basemap lifecycle. On a slow connection the // style may still be loading when a user pans under the fixed map-picker pin; dropping that // move strands the picker on stale coordinates. Only data refresh needs a ready style. @@ -299,10 +315,14 @@ export function MapCore() { emit("map-view-changed", { lng: center.lng, lat: center.lat, zoom }); if (map.isStyleLoaded()) { pendingViewportRefresh = false; - engineRef.current?.refresh(getMapBbox(map), pendingWorldRefresh); + pendingUserMove = false; + engineRef.current?.refresh(getMapBbox(map), pendingWorldRefresh, { userMoved }); pendingWorldRefresh = false; } else { + // Tiles still loading after the move: the refresh runs on the next idle, and it is + // still the reader's move if the last one was. pendingViewportRefresh = true; + pendingUserMove = userMoved; } emitDiscoverViewport(); }); @@ -359,15 +379,21 @@ export function MapCore() { }) ); - // Search here is a request, not a hint: pressing it while the style is still settling after a - // long jump used to clear the button and fetch nothing, so the ask is deferred to the next - // idle instead of dropped. + // Search here is a request, not a hint. Callers that jump somewhere (a search result, "my + // location", a country) ask right after starting the flight; answering at once loaded the + // view being left and the destination then waited for the button, so the ask waits for the + // camera to land. Pressing it while the style is still settling after a long jump used to + // clear the button and fetch nothing, so that ask is deferred to the next idle. const onSearchHere = () => { + if (map.isMoving()) { + map.once("moveend", onSearchHere); + return; + } if (map.isStyleLoaded()) { - engineRef.current?.refresh(getMapBbox(map), true); + engineRef.current?.searchHere(getMapBbox(map)); return; } - map.once("idle", () => engineRef.current?.refresh(getMapBbox(map), true)); + map.once("idle", () => engineRef.current?.searchHere(getMapBbox(map))); }; offs.push(on("search-here", onSearchHere)); offs.push(on("layer-style-changed", () => engineRef.current?.syncLayers(store.activeLayers))); @@ -615,11 +641,12 @@ export function MapCore() { } const pinLayers = interactivePinLayers(map); - const clusterLayers = - map - .getStyle() - .layers?.filter((l) => l.id.startsWith("pins-") && l.id.endsWith("-cluster")) - .map((l) => l.id) ?? []; + // One read of the layer ids for the whole click; `getStyle()` serialised the entire style + // (every basemap layer with its paint and filters) three times per tap. + const layerIds = map.getLayersOrder(); + const clusterLayers = layerIds.filter( + (id) => id.startsWith("pins-") && id.endsWith("-cluster") + ); // Clusters are first-class map targets. Previously they were excluded from hit testing, // so a tap on a large count bubble fell through to the basemap and users had no way to @@ -673,10 +700,9 @@ export function MapCore() { const pinHits = pinLayers.length ? map.queryRenderedFeatures(e.point, { layers: pinLayers }) : []; - const weatherLayerIds = map - .getStyle() - .layers.map((layer) => layer.id) - .filter((id) => /^fill-weather(?:-[a-z-]+)?-sectors$/.test(id)); + const weatherLayerIds = layerIds.filter((id) => + /^fill-weather(?:-[a-z-]+)?-sectors$/.test(id) + ); const weatherHit = weatherLayerIds.length ? map.queryRenderedFeatures(e.point, { layers: weatherLayerIds })[0] : undefined; @@ -689,11 +715,7 @@ export function MapCore() { : []; // Thematic fills sit under everything else, so they are only consulted once nothing // clickable was hit above them: a pin standing on a coloured region belongs to the pin. - const themeLayers = - map - .getStyle() - .layers?.map((layer) => layer.id) - .filter((id) => /^vt-theme-.+-(fill|nodata)$/.test(id)) ?? []; + const themeLayers = layerIds.filter((id) => /^vt-theme-.+-(fill|nodata)$/.test(id)); const themeHit = !pinHits.length && themeLayers.length ? map.queryRenderedFeatures(e.point, { layers: themeLayers })[0] diff --git a/apps/web/src/search/areaQuery.test.ts b/apps/web/src/search/areaQuery.test.ts new file mode 100644 index 0000000..c2c184c --- /dev/null +++ b/apps/web/src/search/areaQuery.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { areaQuery } from "./areaQuery"; + +test("an area question is offered again only after the reader moves the map", () => { + let reruns = 0; + let notified = 0; + const off = areaQuery.subscribe(() => notified++); + areaQuery.markMoved(); + assert.equal(areaQuery.moved(), false, "nothing to offer without a question"); + + areaQuery.set({ id: "turn-1", label: "kavárny", rerun: () => reruns++ }); + assert.equal(areaQuery.moved(), false, "the answer's own camera fit is not a move"); + areaQuery.markMoved(); + assert.equal(areaQuery.moved(), true); + areaQuery.get()?.rerun(); + assert.equal(reruns, 1); + + areaQuery.set({ id: "turn-2", label: "kavárny", rerun: () => reruns++ }); + assert.equal(areaQuery.moved(), false, "a new answer starts over"); + areaQuery.clear("turn-1"); + assert.equal(areaQuery.get()?.id, "turn-2", "a late cleanup never drops a newer question"); + areaQuery.clear("turn-2"); + assert.equal(areaQuery.get(), null); + assert.ok(notified >= 4); + off(); +}); diff --git a/apps/web/src/search/areaQuery.ts b/apps/web/src/search/areaQuery.ts new file mode 100644 index 0000000..232b0dd --- /dev/null +++ b/apps/web/src/search/areaQuery.ts @@ -0,0 +1,63 @@ +/** + * The question the map is currently answering for an area — today the assistant's last place + * search — so "Search this area" can ask it again after the reader moves the map, the way a + * map app re-runs "cafés" for the new view instead of only reloading layers. + * + * A module-level slot rather than store state: it holds a callback owned by whichever panel + * asked the question, and nothing about it belongs in a saved or shared view. + */ +export interface AreaQuery { + /** Stable for one question; a new id resets the "moved since" flag. */ + id: string; + label: string; + rerun(): void; +} + +let current: AreaQuery | null = null; +let movedSince = false; +let revision = 0; +const listeners = new Set<() => void>(); + +function changed() { + revision++; + for (const listener of listeners) listener(); +} + +export const areaQuery = { + get(): AreaQuery | null { + return current; + }, + /** True once the reader has moved the map after the current question was answered. */ + moved(): boolean { + return movedSince; + }, + set(next: AreaQuery) { + if (current?.id === next.id) { + current = next; + return; + } + current = next; + movedSince = false; + changed(); + }, + /** Clears the slot only if it still holds `id`, so a late cleanup never drops a newer query. */ + clear(id: string) { + if (current?.id !== id) return; + current = null; + movedSince = false; + changed(); + }, + /** Called for camera moves the reader made (drag, zoom, keys), not for programmatic ones. */ + markMoved() { + if (!current || movedSince) return; + movedSince = true; + changed(); + }, + subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); + }, + revision(): number { + return revision; + } +}; diff --git a/apps/web/src/search/nearBias.test.ts b/apps/web/src/search/nearBias.test.ts new file mode 100644 index 0000000..a72fb2d --- /dev/null +++ b/apps/web/src/search/nearBias.test.ts @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { geocodeNearParam } from "./nearBias"; + +test("a place search is biased to the region on screen, coarsely", () => { + assert.equal(geocodeNearParam({ lng: 16.60796, lat: 49.19522, zoom: 13 }), "&near=16.61,49.20"); + assert.equal(geocodeNearParam({ lng: 16.6, lat: 49.2, zoom: 3 }), "", "continent view"); + assert.equal(geocodeNearParam({ lng: Number.NaN, lat: 49.2, zoom: 12 }), ""); + assert.equal(geocodeNearParam(null), ""); +}); diff --git a/apps/web/src/search/nearBias.ts b/apps/web/src/search/nearBias.ts new file mode 100644 index 0000000..274d013 --- /dev/null +++ b/apps/web/src/search/nearBias.ts @@ -0,0 +1,12 @@ +/** + * The `near` bias a place search sends: the centre of the map on screen, so "Náměstí Míru" typed + * over Brno finds the one in Brno first. Coarse (two decimals, about a kilometre) and only once + * the map shows a region rather than a continent, where its centre means nothing. + */ +export function geocodeNearParam( + view: { lng: number; lat: number; zoom: number } | null | undefined +): string { + if (!view || !Number.isFinite(view.lng) || !Number.isFinite(view.lat) || !(view.zoom >= 5)) + return ""; + return `&near=${view.lng.toFixed(2)},${view.lat.toFixed(2)}`; +} diff --git a/apps/web/src/settings/preferences.test.ts b/apps/web/src/settings/preferences.test.ts index fb1d841..85a93b3 100644 --- a/apps/web/src/settings/preferences.test.ts +++ b/apps/web/src/settings/preferences.test.ts @@ -18,7 +18,7 @@ describe("typed user preferences", () => { locale: "en", units: "imperial", flyAnimations: false, - showSearchHere: false, + manualRefresh: true, aiEnabled: false, aiAutoSummary: true }), @@ -29,7 +29,7 @@ describe("typed user preferences", () => { locale: "en", units: "imperial", flyAnimations: false, - showSearchHere: false, + manualRefresh: true, aiEnabled: false, aiAutoSummary: true } diff --git a/apps/web/src/settings/preferences.ts b/apps/web/src/settings/preferences.ts index 5bb17ee..0f702b5 100644 --- a/apps/web/src/settings/preferences.ts +++ b/apps/web/src/settings/preferences.ts @@ -16,7 +16,8 @@ export interface UserPreferences { units: DistanceUnits; lowData: boolean; flyAnimations: boolean; - showSearchHere: boolean; + /** Moving the map loads nothing until "Search this area" is pressed. */ + manualRefresh: boolean; aiEnabled: boolean; aiAutoSummary: boolean; } @@ -29,7 +30,7 @@ export const DEFAULT_USER_PREFERENCES: Readonly = { units: "metric", lowData: false, flyAnimations: true, - showSearchHere: true, + manualRefresh: false, aiEnabled: true, aiAutoSummary: true }; @@ -73,7 +74,7 @@ export function parseUserPreferences(value: unknown): UserPreferences { : DEFAULT_USER_PREFERENCES.units, lowData: booleanOr(record.lowData, DEFAULT_USER_PREFERENCES.lowData), flyAnimations: booleanOr(record.flyAnimations, DEFAULT_USER_PREFERENCES.flyAnimations), - showSearchHere: booleanOr(record.showSearchHere, DEFAULT_USER_PREFERENCES.showSearchHere), + manualRefresh: booleanOr(record.manualRefresh, DEFAULT_USER_PREFERENCES.manualRefresh), aiEnabled: booleanOr(record.aiEnabled, DEFAULT_USER_PREFERENCES.aiEnabled), aiAutoSummary: booleanOr(record.aiAutoSummary, DEFAULT_USER_PREFERENCES.aiAutoSummary) }; diff --git a/apps/web/src/statistics/explorerStore.ts b/apps/web/src/statistics/explorerStore.ts index 0db1da4..8b93108 100644 --- a/apps/web/src/statistics/explorerStore.ts +++ b/apps/web/src/statistics/explorerStore.ts @@ -3,6 +3,7 @@ import { apiGet } from "../lib/api"; import { emit, on } from "../lib/events"; import { getMapStore } from "../store/mapStore"; import { fetchThemeDetail, fetchThemeSummaries } from "../layers/themes/themeCatalog"; +import { statCoverageRequest } from "@mapos/adapter-sdk"; import { syncThemeLayers, themeLayerId, @@ -270,6 +271,7 @@ export function attachStatisticsRuntime() { let coverageController: AbortController | null = null; let coverageTimer: ReturnType | undefined; let coverageGeneration = 0; + let coverageKey = ""; const offView = on("discover-viewport", (event) => { viewport = event.bbox; update({ viewportKey: event.bbox.map((v) => v.toFixed(4)).join(",") }); @@ -282,14 +284,21 @@ export function attachStatisticsRuntime() { fitCoverage: false }); } + // Coverage is asked per zoom band and grid-snapped view (the key the server caches by), so + // an ordinary pan asks nothing and a pan back is answered by the browser cache. + const coverage = statCoverageRequest(event.bbox, event.zoom); + const key = JSON.stringify(coverage); + if (key === coverageKey) return; clearTimeout(coverageTimer); coverageController?.abort(); const generation = ++coverageGeneration; coverageTimer = setTimeout(() => { coverageController = new AbortController(); - void fetchThemeSummaries(coverageController.signal, event.bbox, event.zoom) + void fetchThemeSummaries(coverageController.signal, coverage.bbox ?? undefined, coverage.zoom) .then((catalog) => { - if (!disposed && generation === coverageGeneration) update({ catalog }); + if (disposed || generation !== coverageGeneration) return; + coverageKey = key; + update({ catalog }); }) .catch(() => {}); }, 350); diff --git a/apps/web/src/store/mapStore.test.ts b/apps/web/src/store/mapStore.test.ts index 431ee89..230b17b 100644 --- a/apps/web/src/store/mapStore.test.ts +++ b/apps/web/src/store/mapStore.test.ts @@ -211,6 +211,37 @@ test("unchanged camera events preserve the snapshot and avoid notifications", () } }); +test("repeated loading flags, empty layers and source reports wake nobody", () => { + let calls = 0; + const unsubscribe = store.subscribe(() => { + calls++; + }); + try { + store.setLayerLoading("osm-poi", true); + store.setLayerLoading("osm-poi", true); + store.setLayerLoading("osm-poi", false); + store.setLayerLoading("osm-poi", false); + assert.equal(calls, 2, "only real loading transitions notify"); + + calls = 0; + store.setVisibleFeatures("osm-poi", []); + store.setVisibleFeatures("osm-poi", []); + assert.equal(calls, 1, "an empty layer staying empty is not a change"); + + calls = 0; + const report = [{ source: "osm", state: "ready", count: 3 }] as Parameters< + typeof store.applySourceMeta + >[0]; + store.applySourceMeta(report); + store.applySourceMeta(structuredClone(report)); + assert.equal(calls, 1, "a repeated source report is not a change"); + store.applySourceMeta([{ ...report[0]!, count: 4 }]); + assert.equal(calls, 2); + } finally { + unsubscribe(); + } +}); + test("subscribers are notified on every layer change", () => { let calls = 0; const unsubscribe = store.subscribe(() => { diff --git a/apps/web/src/store/mapStore.ts b/apps/web/src/store/mapStore.ts index 74e71ab..5a55c65 100644 --- a/apps/web/src/store/mapStore.ts +++ b/apps/web/src/store/mapStore.ts @@ -857,6 +857,19 @@ export class MapStore { message: meta.message }; } + // Every page of every POI answer reports its sources; most reports repeat the last one. + const current = this.state.sourceStatus; + const same = + Object.keys(next).length === Object.keys(current).length && + Object.entries(next).every(([source, status]) => { + const previous = current[source]; + return ( + previous?.state === status?.state && + previous?.count === status?.count && + previous?.message === status?.message + ); + }); + if (same) return; this.patch({ sourceStatus: next }); } @@ -1336,6 +1349,7 @@ export class MapStore { } setLayerLoading(layerId: string, loading: boolean) { + if (Boolean(this.state.loadingLayers[layerId]) === loading) return; const next = { ...this.state.loadingLayers }; if (loading) next[layerId] = true; else delete next[layerId]; @@ -1353,6 +1367,9 @@ export class MapStore { } setVisibleFeatures(layerId: string, features: GeoFeature[]) { + const previous = this.state.visibleFeatures[layerId]; + // An empty layer staying empty (every invalidation clears it) wakes nobody. + if (previous === features || (previous && !previous.length && !features.length)) return; this.patch({ visibleFeatures: { ...this.state.visibleFeatures, [layerId]: features } }); } diff --git a/apps/web/src/store/useMapStoreSnapshot.ts b/apps/web/src/store/useMapStoreSnapshot.ts index bee3c67..13a4145 100644 --- a/apps/web/src/store/useMapStoreSnapshot.ts +++ b/apps/web/src/store/useMapStoreSnapshot.ts @@ -1,11 +1,14 @@ -import { useSyncExternalStore } from "react"; +import { useCallback, useSyncExternalStore } from "react"; import type { MapStore } from "./mapStore"; import { getMapStore } from "./mapStore"; export function useMapStoreSnapshot(selector: (s: MapStore) => T): T { const store = getMapStore(); + // A new subscribe function each render made React unsubscribe and resubscribe every component + // on every render; one per store keeps the subscription. + const subscribe = useCallback((cb: () => void) => store.subscribe(cb), [store]); return useSyncExternalStore( - (cb) => store.subscribe(cb), + subscribe, () => selector(store), () => selector(store) ); diff --git a/apps/web/src/store/useShellStoreSnapshot.ts b/apps/web/src/store/useShellStoreSnapshot.ts index f4e135a..2e4c9cd 100644 --- a/apps/web/src/store/useShellStoreSnapshot.ts +++ b/apps/web/src/store/useShellStoreSnapshot.ts @@ -1,10 +1,11 @@ -import { useSyncExternalStore } from "react"; +import { useCallback, useSyncExternalStore } from "react"; import { getShellStore, type ShellState } from "./shellStore"; export function useShellStoreSnapshot(selector: (state: ShellState) => T): T { const store = getShellStore(); + const subscribe = useCallback((listener: () => void) => store.subscribe(listener), [store]); return useSyncExternalStore( - (listener) => store.subscribe(listener), + subscribe, () => selector(store.snapshot), () => selector(store.snapshot) ); diff --git a/apps/web/src/styles/chrome.css b/apps/web/src/styles/chrome.css index 97175b2..8bde354 100644 --- a/apps/web/src/styles/chrome.css +++ b/apps/web/src/styles/chrome.css @@ -1,182 +1,5 @@ /* Everything that floats above the map: top bar, bottom nav, mega menu, toasts. */ @layer chrome { - /* ---- Activity indicator (§4.12) -------------------------------------- - One surface for "something is loading", bottom right above the zoom controls. It replaces - the strip of single-letter source badges and the expandable task centre: which of seven - providers is slow belongs in the layer drawer, not over the map. */ - .activity-indicator { - position: fixed; - right: calc(var(--space-6) + var(--safe-right)); - /* Clears the mode switcher and whatever the footer currently is, so a loading pill never - lands on top of the thing it is reporting about. */ - bottom: calc( - var(--footer-stack-h, 0px) + var(--modebar-bottom-h, 0px) + var(--map-filter-h, 0px) + - var(--space-6) + var(--safe-bottom) - ); - z-index: var(--z-footer); - display: grid; - /* The rows carry nowrap labels, so the column has to be allowed to be narrower than their - intrinsic width — otherwise a long provider message widens the pill past the viewport. */ - grid-template-columns: minmax(0, 1fr); - justify-items: end; - gap: var(--space-2); - width: max-content; - max-width: min(320px, calc(100vw - var(--space-9))); - pointer-events: auto; - transition: - right var(--dur-slow) var(--ease-standard), - bottom var(--dur-slow) var(--ease-standard); - } - - .activity-indicator[data-right-open] { - right: calc(var(--drawer-w) + var(--space-6)); - } - - .activity-indicator[data-footer-active] { - bottom: calc(var(--space-6) + var(--safe-bottom) + 184px); - } - - /* The whole stack is the popover trigger, so the rows themselves are plain spans. */ - /* The pills are the visible status; this button is the tap target that opens the task popover. - A single 32 px pill leaves the trigger under the 44 px minimum, so the button claims the - rest as padding and gives the same amount back as negative margin: the hit box grows, the - pill stays exactly where it was, and it is still a quiet status rather than a banner. */ - .activity-rows { - display: grid; - justify-items: end; - gap: var(--space-2); - max-width: 100%; - padding-block: var(--space-3); - padding-inline: 0; - margin-block: calc(var(--space-3) * -1); - border: 0; - background: none; - cursor: pointer; - } - - .activity-row { - display: flex; - align-items: center; - gap: var(--space-4); - max-width: 100%; - min-height: 32px; - padding: var(--space-2) var(--space-5) var(--space-2) var(--space-4); - border-radius: var(--radius-pill); - background: var(--surface); - backdrop-filter: var(--blur); - border: 1px solid var(--border); - box-shadow: var(--shadow-sm); - color: var(--text); - font: var(--type-label-md); - text-align: start; - animation: mapos-slide-up var(--dur) var(--ease-emph-decel); - } - - .activity-row[data-tone="done"] { - color: var(--success); - } - - .activity-row[data-tone="error"] { - color: var(--danger); - border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); - } - - .activity-row-icon { - display: grid; - place-items: center; - flex-shrink: 0; - } - - .activity-row-copy { - display: grid; - min-width: 0; - } - - .activity-row-label, - .activity-row-detail { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .activity-row-detail { - font: var(--type-label-sm); - color: var(--text-muted); - } - - .activity-row-progress { - width: 40px; - height: 3px; - flex-shrink: 0; - border-radius: var(--radius-pill); - background: var(--surface-sunken); - overflow: hidden; - } - - .activity-row-progress > span { - display: block; - height: 100%; - border-radius: inherit; - background: var(--accent); - transition: width var(--dur) var(--ease-standard); - } - - .activity-tasks { - display: flex; - flex-direction: column; - gap: var(--space-4); - } - - .activity-tasks-empty { - margin: 0; - color: var(--text-muted); - font: var(--type-body-sm); - } - - .activity-task { - display: grid; - gap: var(--space-1); - } - - .activity-task-label { - font: var(--type-title-sm); - } - - .activity-task-detail { - color: var(--text-muted); - font: var(--type-label-sm); - } - - .activity-task-actions { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - } - - @media (max-width: 899px) { - /* On a phone the sheet owns the lower half of the screen, so the pill rides directly above - it rather than at a fixed distance from the bottom edge, where the sheet would bury it. */ - .activity-indicator, - .activity-indicator[data-right-open] { - /* Leave the fixed utility rail (layers/settings) its own column. A loading pill can be - wider than the icon and otherwise crosses the settings target on a raised bottom sheet. */ - right: calc(var(--space-4) + var(--safe-right) + var(--tap-min) + var(--space-2)); - bottom: calc( - var(--bottom-nav-h) + var(--sheet-h) + var(--space-4) + var(--safe-bottom) + 88px - ); - max-width: calc( - 100vw - var(--space-8) - var(--safe-left) - var(--safe-right) - var(--tap-min) - - var(--space-2) - ); - } - - .activity-indicator[data-footer-active] { - bottom: calc( - var(--bottom-nav-h) + var(--sheet-h) + var(--space-4) + var(--safe-bottom) + 176px - ); - } - } - .mapy-logo-ctrl { background: rgba(255, 255, 255, 0.86); border-radius: var(--radius-xs); @@ -205,10 +28,6 @@ flex-shrink: 0; } - .chrome-brand-name { - font: var(--type-title-md); - } - .brand-logo { flex-shrink: 0; color: var(--accent); @@ -797,15 +616,6 @@ z-index: 80; } - .mega-section-title { - margin: var(--space-6) 0 var(--space-4); - font-size: var(--text-xs); - font-weight: 800; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--text-muted); - } - .experience-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -860,8 +670,7 @@ font-size: var(--text-sm); } - .layer-accordion-title small, - .mega-preset-copy small { + .layer-accordion-title small { color: var(--text-muted); font-size: var(--text-3xs); font-weight: var(--weight-body); @@ -929,46 +738,6 @@ margin-top: var(--space-4); } - .mega-preset-grid { - display: flex; - gap: var(--space-4); - max-width: 100%; - padding: 2px 2px var(--space-3); - overflow-x: auto; - overscroll-behavior-inline: contain; - scroll-snap-type: inline proximity; - scrollbar-width: thin; - } - - .mega-preset { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: var(--space-2); - padding: var(--space-5); - border-radius: var(--radius-sm); - border: 1px solid var(--border); - background: var(--surface-elevated); - text-align: left; - color: var(--text); - flex: 0 0 174px; - min-width: 0; - scroll-snap-align: start; - } - - .mega-preset-copy { - display: grid; - min-width: 0; - gap: 2px; - } - - .mega-preset-copy small { - display: -webkit-box; - overflow: hidden; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - } - .category-group { padding-inline: 0; } @@ -977,24 +746,6 @@ margin-bottom: 0; } - .mega-preset:hover { - border-color: var(--border-strong); - } - - .mega-preset.active { - border-color: color-mix(in srgb, var(--accent) 45%, transparent); - background: var(--accent-soft); - } - - .mega-preset-icon { - font-size: var(--text-xl); - line-height: 1; - } - .mega-preset-name { - font-size: var(--text-sm); - font-weight: var(--weight-display); - } - /* ---- Layer cards ---------------------------------------------------- */ .layer-card { display: flex; @@ -1216,11 +967,13 @@ "Search this area" sits directly above the mode switcher, centred over the map, and is the one accent-filled button floating over the map. It used to be a grey pill under the top bar, where it read as a status message rather than the offer of fresh data it is. */ + /* On a phone the open panel is a bottom sheet; the chip floats above its top edge + (`--sheet-h`, 0 on desktop) so a half-open sheet never covers it. */ .search-here-btn { position: fixed; bottom: calc( var(--footer-stack-h, 0px) + var(--modebar-bottom-h, 0px) + var(--map-filter-h, 0px) + - var(--bottom-nav-h) + var(--space-6) + var(--safe-bottom) + var(--bottom-nav-h) + var(--sheet-h, 0px) + var(--space-6) + var(--safe-bottom) ); left: calc(var(--sidebar-w-open) + var(--chrome-inset) + var(--safe-left)); right: calc(var(--drawer-w-open) + var(--chrome-inset) + var(--safe-right)); @@ -1256,6 +1009,11 @@ opacity: 0.7; } + /* A full-height sheet covers the map the chip would refresh. */ + html:has(.panel-left[data-snap="full"]) .search-here-btn { + display: none; + } + /* The discover mode's "What is here?" chip shares the Search-here slot and geometry, but stays a quiet surface button — it is a standing question, not a call to action, and the accent fill belongs to the button that pulls fresh data. */ @@ -1312,7 +1070,8 @@ html[data-discover-here] .search-here-btn { bottom: calc( var(--footer-stack-h, 0px) + var(--modebar-bottom-h, 0px) + var(--map-filter-h, 0px) + - var(--bottom-nav-h) + var(--tap-min) + var(--space-8) + var(--safe-bottom) + var(--bottom-nav-h) + var(--sheet-h, 0px) + var(--tap-min) + var(--space-8) + + var(--safe-bottom) ); } @@ -1675,7 +1434,6 @@ html[data-map-picker="active"] .map-footer-stack, html[data-map-picker="active"] .desktop-modebar, html[data-map-picker="active"] .bottom-nav, - html[data-map-picker="active"] .activity-indicator, html[data-map-picker="active"] .theme-value-card, html[data-map-picker="active"] .layer-notices, html[data-map-picker="active"] .search-here-btn { diff --git a/apps/web/src/styles/drawers.css b/apps/web/src/styles/drawers.css index 42116f8..e3b2a96 100644 --- a/apps/web/src/styles/drawers.css +++ b/apps/web/src/styles/drawers.css @@ -598,16 +598,6 @@ opacity: 0.9; } - .basemap-preview-road.road-primary { - inset: 42% 0 auto 0; - height: 5px; - } - - .basemap-preview-road.road-secondary { - inset: 0 auto 0 28%; - width: 4px; - } - .basemap-preview[data-preview-kind="satellite"] { background: linear-gradient(150deg, #24402a, #4a5d33 55%, #6a6a41); } diff --git a/apps/web/src/styles/motion.css b/apps/web/src/styles/motion.css index d65a022..7811b91 100644 --- a/apps/web/src/styles/motion.css +++ b/apps/web/src/styles/motion.css @@ -89,24 +89,4 @@ transform: scale(1.8); } } - - /* ---- Utilities ------------------------------------------------------- */ - .motion-enter { - animation: mapos-slide-up var(--dur-enter) var(--ease-emph-decel) both; - } - - .motion-fade { - animation: mapos-fade-in var(--dur) var(--ease-standard) both; - } - - .motion-pop { - animation: mapos-scale-in var(--dur) var(--ease-emph-decel) both; - } - - /* Staggers a list so it reads as one movement instead of ten. Cap the delay: past ~6 items - the tail arrives late enough to feel like lag. */ - .motion-stagger > * { - animation: mapos-slide-up var(--dur-slow) var(--ease-emph-decel) both; - animation-delay: calc(min(var(--motion-index, 0), 6) * 24ms); - } } diff --git a/apps/web/src/styles/panels.css b/apps/web/src/styles/panels.css index 779e3d9..5e5750e 100644 --- a/apps/web/src/styles/panels.css +++ b/apps/web/src/styles/panels.css @@ -187,60 +187,6 @@ gap: var(--space-7); } - .planner-editor-scope { - display: grid; - gap: var(--space-7); - min-inline-size: 0; - margin: 0; - padding: 0; - border: 0; - } - - .planner-editor-scope:disabled { - opacity: 0.76; - } - - .planner-shared-banner { - display: grid; - grid-template-columns: 40px minmax(0, 1fr); - gap: var(--space-3); - align-items: center; - padding: var(--space-4); - border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--border)); - border-radius: var(--radius-md); - background: linear-gradient( - 135deg, - color-mix(in srgb, var(--accent) 16%, var(--surface-elevated)), - var(--surface-elevated) - ); - } - - .planner-shared-banner > span { - width: 40px; - height: 40px; - display: grid; - place-items: center; - border-radius: 50%; - background: var(--accent); - color: var(--accent-contrast); - font-size: var(--text-lg); - font-weight: 800; - } - - .planner-shared-banner > div { - display: grid; - gap: 3px; - } - - .planner-shared-banner small { - color: var(--text-muted); - line-height: 1.4; - } - - .planner-shared-banner.status-error { - border-color: color-mix(in srgb, #ef4444 48%, var(--border)); - } - .planner-field { display: grid; gap: var(--space-2); @@ -250,31 +196,6 @@ font-weight: var(--weight-strong); } - .planner-toolbar, - .planner-stop-actions, - .planner-actions { - display: flex; - align-items: center; - gap: var(--space-2); - } - - .planner-toolbar { - justify-content: space-between; - color: var(--text-faint); - font-size: var(--text-xs); - } - - .planner-stop-actions { - flex-wrap: wrap; - } - - .planner-stop-location { - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - gap: var(--space-2); - align-items: center; - } - .planner-stop-search { position: relative; min-width: 0; @@ -400,1249 +321,195 @@ color: var(--text-faint); } - .planner-stop-location > span { + .planner-field input, + .planner-field select { + width: 100%; min-width: 0; - color: var(--text-faint); - font-family: var(--font-mono, monospace); - font-size: 10px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + color: var(--text); } - .planner-location-fallback { + .planner-grid { display: grid; - grid-template-columns: 1fr auto; - gap: var(--space-3); - align-items: start; - min-width: 0; - padding: var(--space-4); - border: 1px solid color-mix(in srgb, #f59e0b 48%, var(--border)); - border-radius: var(--radius-md); - background: - radial-gradient(circle at 0 0, color-mix(in srgb, #f59e0b 14%, transparent), transparent 46%), - var(--surface-elevated); + gap: var(--space-4); } - .planner-location-fallback.kind-denied { - border-color: color-mix(in srgb, #ef4444 44%, var(--border)); + .planner-grid.two { + grid-template-columns: 1.25fr 0.75fr; } - .planner-location-fallback-heading { - grid-column: 1 / -1; - display: grid; - grid-template-columns: 30px minmax(0, 1fr); - gap: var(--space-2); - align-items: start; - min-width: 0; + .planner-grid.three { + grid-template-columns: repeat(3, minmax(0, 1fr)); } - .planner-location-fallback-heading > span { + .planning-panel .planner-adventure { + position: relative; display: grid; - width: 30px; - height: 30px; - place-items: center; - border-radius: 10px; - background: color-mix(in srgb, #f59e0b 16%, var(--surface-solid)); - color: #b45309; - font-size: var(--text-lg); - } - - .planner-location-fallback-heading > div { + gap: var(--space-4); min-width: 0; + padding: var(--space-5); + border: 1px solid color-mix(in srgb, #f97316 48%, var(--border)); + border-radius: var(--radius-lg); + background: + radial-gradient( + circle at 100% 0, + color-mix(in srgb, #f97316 19%, transparent), + transparent 46% + ), + linear-gradient( + 145deg, + color-mix(in srgb, #0f766e 8%, var(--surface-solid)), + var(--surface-solid) + ); + box-shadow: 0 16px 38px color-mix(in srgb, #0f172a 11%, transparent); + overflow: hidden; } - .planner-location-fallback-heading strong { + .planning-panel .planner-adventure h3, + .planning-panel .planner-adventure h4 { + margin: 0; color: var(--text); - font-size: var(--text-sm); } - .planner-location-fallback-heading p { - margin: 3px 0 0; - color: var(--text-muted); - font-size: var(--text-xs); - line-height: 1.45; + .planning-panel .planner-adventure h3 { + font-size: var(--text-lg); + line-height: 1.2; } - .planner-location-fallback > .btn { - grid-column: 1 / -1; - justify-self: start; + @media (max-width: 520px) { + .planning-panel .planner-adventure { + padding: var(--space-4); + } } - .planner-location-manual { - grid-column: 1 / -1; + .planner-stops { + position: relative; display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); + } + + .planner-stop-window { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--space-2); - min-width: 0; - margin: 0; - padding: var(--space-3); + align-items: center; + padding: var(--space-2); border: 1px solid var(--border); border-radius: var(--radius-sm); + background: var(--surface-elevated); } - .planner-location-manual legend { - padding: 0 4px; + .planner-stop-window span { color: var(--text-muted); - font-size: 10px; - font-weight: 750; - } - - .planner-location-manual label { - display: grid; - gap: 3px; - min-width: 0; - color: var(--text-faint); - font-size: 9px; - } - - .planner-location-manual input { - width: 100%; - min-width: 0; - padding: 7px; - font-family: var(--font-mono, monospace); - font-size: 10px; - } - - .planner-location-manual > .btn { - grid-column: 1 / -1; - justify-self: start; - } - - .planner-stop-more { - color: var(--text-faint); font-size: var(--text-xs); + text-align: center; } - .planner-stop-more summary { - width: max-content; - cursor: pointer; - } - - .planner-stop-more[open] summary { - margin-bottom: var(--space-2); - } - - .planner-stop-order { - justify-content: flex-end; - } - - .planner-add-actions { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--space-2); - } - - .planner-field input, - .planner-field select { - width: 100%; - min-width: 0; - color: var(--text); + .planner-stops::before { + content: ""; + position: absolute; + top: 17px; + bottom: 17px; + left: 14px; + width: 2px; + background: color-mix(in srgb, var(--accent) 38%, var(--border)); } - .planner-grid { + .planner-stop { + position: relative; display: grid; - gap: var(--space-4); - } - - .planner-grid.two { - grid-template-columns: 1.25fr 0.75fr; - } - - .planner-grid.three { - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: 30px minmax(0, 1fr) 24px; + gap: var(--space-3); + align-items: start; } - .planner-variants { - width: 100%; + .planner-suggestions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); } - .planning-panel .planner-more-options { - min-width: 0; - overflow: clip; + .planner-suggestions button { + display: inline-flex; + gap: var(--space-2); + align-items: center; + max-width: 100%; + padding: 7px 10px; border: 1px solid var(--border); - border-radius: var(--radius-md); + border-radius: var(--radius-pill); background: var(--surface-elevated); + font-size: var(--text-xs); } - .planning-panel .planner-more-options > summary { - padding: var(--space-4) var(--space-5); - color: var(--text); - font-weight: var(--weight-strong); - cursor: pointer; - } - - .planning-panel .planner-more-options > summary:focus-visible { - outline: 2px solid var(--accent); - outline-offset: -2px; - } - - .planning-panel .planner-more-content { - display: grid; - gap: var(--space-5); - min-width: 0; - padding: 0 var(--space-5) var(--space-5); - } - - .planning-panel .planner-more-content .planner-grid.two { - grid-template-columns: 1fr; - } - - .planning-panel .planner-more-content .planner-grid.three { - grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); + .planner-suggestions button span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } - .planning-panel .planner-preferences { - min-width: 0; + .planner-error { margin: 0; - padding: 0; - border: 0; - } - - .planning-panel .planner-preferences legend { - margin-bottom: var(--space-2); - color: var(--text-muted); + padding: var(--space-4); + border-radius: var(--radius-sm); + background: color-mix(in srgb, #f59e0b 13%, transparent); + color: var(--text); font-size: var(--text-xs); - font-weight: var(--weight-strong); + line-height: 1.45; } - .planning-panel .planner-preference-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-2); - min-width: 0; - } - - .planning-panel .planner-preference-grid button { - display: grid; - gap: 2px; - min-width: 0; - padding: var(--space-3); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--surface-solid); - color: var(--text); - text-align: left; - overflow-wrap: anywhere; - } - - .planning-panel .planner-preference-grid button > span { - min-width: 0; - overflow-wrap: anywhere; - } - - .planning-panel .planner-preference-grid button[data-support="fallback"] { - border-style: dashed; - } - - .planning-panel .planner-preference-grid button.active { - border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 12%, var(--surface-solid)); - color: var(--accent); - } - - .planning-panel .planner-preference-grid small, - .planning-panel .planner-capability small { - overflow-wrap: anywhere; - color: var(--text-faint); - font-size: 10px; - line-height: 1.35; - } - - .planning-panel .planner-capability { - display: grid; - gap: var(--space-2); - min-width: 0; - padding: var(--space-3); - border-left: 3px solid var(--accent); - border-radius: var(--radius-sm); - background: color-mix(in srgb, var(--accent) 7%, var(--surface-solid)); - font-size: var(--text-xs); - } - - .planning-panel .planner-capability.fallback { - border-left-color: #f59e0b; - background: color-mix(in srgb, #f59e0b 9%, var(--surface-solid)); - } - - .planning-panel .planner-capability span { - min-width: 0; - overflow-wrap: anywhere; - color: var(--text-muted); - font-family: var(--font-mono, monospace); - } - - .planning-panel .planner-bike-map { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: var(--space-3) var(--space-4); - align-items: start; - min-width: 0; - padding: var(--space-4); - border: 1px solid color-mix(in srgb, #7c3aed 38%, var(--border)); - border-radius: var(--radius-md); - background: - radial-gradient(circle at 0 0, color-mix(in srgb, #7c3aed 18%, transparent), transparent 45%), - var(--surface-solid); - } - - .planning-panel .planner-bike-map.active { - border-color: color-mix(in srgb, #16a34a 55%, var(--border)); - background: - radial-gradient(circle at 0 0, color-mix(in srgb, #16a34a 17%, transparent), transparent 45%), - var(--surface-solid); - } - - .planning-panel .planner-bike-map-icon { - display: grid; - width: 38px; - height: 38px; - place-items: center; - border-radius: 12px; - background: color-mix(in srgb, #7c3aed 16%, var(--surface-elevated)); - font-size: 21px; - } - - .planning-panel .planner-bike-map div { - min-width: 0; - } - - .planning-panel .planner-bike-map p { - margin: 4px 0 0; - color: var(--text-muted); - font-size: var(--text-xs); - line-height: 1.45; - } - - .planning-panel .planner-bike-map-actions { - grid-column: 2; - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - } - - .planning-panel .planner-temporal-context { - display: grid; - gap: var(--space-3); - min-width: 0; - padding: var(--space-4); - border: 1px solid color-mix(in srgb, #0284c7 42%, var(--border)); - border-radius: var(--radius-lg); - background: - radial-gradient( - circle at 100% 0, - color-mix(in srgb, #38bdf8 14%, transparent), - transparent 44% - ), - var(--surface-solid); - } - - .planning-panel .planner-temporal-heading { - display: grid; - grid-template-columns: 40px minmax(0, 1fr); - gap: var(--space-3); - align-items: start; - } - - .planning-panel .planner-temporal-icon { - display: grid; - width: 40px; - height: 40px; - place-items: center; - border-radius: 13px; - background: #0284c7; - color: #fff; - font-size: 23px; - } - - .planning-panel .planner-temporal-heading > div { - display: grid; - gap: 2px; - min-width: 0; - } - - .planning-panel .planner-temporal-heading span:not(.planner-temporal-icon) { - color: #0369a1; - font-size: 10px; - font-weight: 800; - text-transform: uppercase; - letter-spacing: 0.05em; - } - - .planning-panel .planner-temporal-heading h3, - .planning-panel .planner-temporal-heading p { - margin: 0; - } - - .planning-panel .planner-temporal-heading h3 { - font-size: var(--text-md); - } - - .planning-panel .planner-temporal-heading p { - color: var(--text-muted); - font-size: var(--text-xs); - } - - .planning-panel .planner-temporal-switches { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - } - - .planning-panel .planner-temporal-switches label { - display: flex; - gap: 7px; - align-items: center; - padding: 7px 9px; - border: 1px solid var(--border); - border-radius: var(--radius-pill); - background: var(--surface-elevated); - color: var(--text-muted); - font-size: var(--text-xs); - font-weight: 700; - } - - .planning-panel .planner-temporal-switches input { - width: 15px; - height: 15px; - margin: 0; - accent-color: #0284c7; - } - - .planning-panel .planner-temporal-status-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-2); - } - - .planning-panel .planner-temporal-status-grid article { - display: grid; - grid-template-columns: 24px minmax(0, 1fr); - gap: var(--space-2); - align-items: start; - min-width: 0; - padding: 10px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--surface-elevated); - } - - .planning-panel .planner-temporal-status-grid article[data-status="ready"], - .planning-panel .planner-temporal-status-grid article[data-status="provider-aware"] { - border-color: color-mix(in srgb, #16a34a 45%, var(--border)); - } - - .planning-panel .planner-temporal-status-grid article[data-status="unavailable"], - .planning-panel .planner-temporal-status-grid article[data-status="out-of-range"] { - border-style: dashed; - } - - .planning-panel .planner-temporal-status-grid article > span { - color: #0284c7; - font-size: 18px; - } - - .planning-panel .planner-temporal-status-grid article > div { - display: grid; - gap: 2px; - min-width: 0; - } - - .planning-panel .planner-temporal-status-grid small { - color: var(--text-muted); - font-size: 10px; - line-height: 1.35; - overflow-wrap: anywhere; - } - - .planning-panel .planner-temporal-map-action { - justify-self: start; - } - - .planning-panel .planner-temporal-budget { - color: var(--text-faint); - font-size: 9px; - line-height: 1.4; - } - - .planning-panel .planner-adventure { - position: relative; - display: grid; - gap: var(--space-4); - min-width: 0; - padding: var(--space-5); - border: 1px solid color-mix(in srgb, #f97316 48%, var(--border)); - border-radius: var(--radius-lg); - background: - radial-gradient( - circle at 100% 0, - color-mix(in srgb, #f97316 19%, transparent), - transparent 46% - ), - linear-gradient( - 145deg, - color-mix(in srgb, #0f766e 8%, var(--surface-solid)), - var(--surface-solid) - ); - box-shadow: 0 16px 38px color-mix(in srgb, #0f172a 11%, transparent); - overflow: hidden; - } - - .planning-panel .planner-adventure-heading { - display: grid; - grid-template-columns: 42px minmax(0, 1fr); - gap: var(--space-3); - align-items: start; - min-width: 0; - } - - .planning-panel .planner-adventure-icon { - display: grid; - width: 42px; - height: 42px; - place-items: center; - border-radius: 14px; - background: #f97316; - box-shadow: 0 7px 20px color-mix(in srgb, #f97316 28%, transparent); - color: #fff; - font-size: 24px; - } - - .planning-panel .planner-adventure-heading > div { - display: grid; - gap: 3px; - min-width: 0; - } - - .planning-panel .planner-adventure-eyebrow { - color: #c2410c; - font-size: 10px; - font-weight: 800; - text-transform: uppercase; - letter-spacing: 0.055em; - } - - .planning-panel .planner-adventure h3, - .planning-panel .planner-adventure h4 { - margin: 0; - color: var(--text); - } - - .planning-panel .planner-adventure h3 { - font-size: var(--text-lg); - line-height: 1.2; - } - - .planning-panel .planner-adventure-heading p, - .planning-panel .planner-adventure-place p, - .planning-panel .planner-adventure-method p { - margin: 0; - color: var(--text-muted); - font-size: var(--text-xs); - line-height: 1.45; - } - - .planning-panel .planner-adventure-controls { - display: flex; - flex-wrap: wrap; - gap: var(--space-3); - align-items: end; - justify-content: space-between; - } - - .planning-panel .planner-adventure-limit { - display: flex; - flex: 1 1 180px; - flex-wrap: wrap; - gap: 5px; - align-items: center; - min-width: 0; - } - - .planning-panel .planner-adventure-limit > span { - flex: 1 0 100%; - color: var(--text-muted); - font-size: 10px; - font-weight: 750; - text-transform: uppercase; - letter-spacing: 0.04em; - } - - .planning-panel .planner-adventure-limit button { - min-width: 52px; - padding: 7px 10px; - border: 1px solid var(--border); - border-radius: var(--radius-pill); - background: var(--surface-elevated); - color: var(--text-muted); - font-size: var(--text-xs); - font-weight: 750; - } - - .planning-panel .planner-adventure-limit button.active { - border-color: #f97316; - background: color-mix(in srgb, #f97316 13%, var(--surface-solid)); - color: #c2410c; - } - - .planning-panel .planner-adventure-results { - display: grid; - gap: var(--space-3); - min-width: 0; - padding-top: var(--space-3); - border-top: 1px solid color-mix(in srgb, #f97316 25%, var(--border)); - } - - .planning-panel .planner-adventure-summary { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--space-2); - } - - .planning-panel .planner-adventure-summary > div { - display: grid; - gap: 1px; - min-width: 0; - padding: var(--space-2); - border-radius: var(--radius-sm); - background: color-mix(in srgb, #0f766e 7%, var(--surface-elevated)); - } - - .planning-panel .planner-adventure-summary strong { - color: #0f766e; - font-size: var(--text-md); - } - - .planning-panel .planner-adventure-summary span { - color: var(--text-faint); - font-size: 9px; - line-height: 1.25; - overflow-wrap: anywhere; - } - - .planning-panel .planner-adventure-candidates { - display: grid; - gap: var(--space-2); - } - - .planning-panel .planner-adventure-candidate { - display: grid; - grid-template-columns: 46px minmax(0, 1fr) auto; - gap: var(--space-3); - align-items: center; - min-width: 0; - padding: var(--space-3); - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: color-mix(in srgb, var(--surface-elevated) 94%, #f97316); - } - - .planning-panel .planner-adventure-score { - display: grid; - width: 46px; - height: 46px; - place-content: center; - border: 1px solid color-mix(in srgb, #f97316 45%, var(--border)); - border-radius: 50%; - background: color-mix(in srgb, #f97316 10%, var(--surface-solid)); - color: #c2410c; - text-align: center; - line-height: 1; - } - - .planning-panel .planner-adventure-score strong { - font-size: var(--text-md); - } - - .planning-panel .planner-adventure-score span { - margin-top: 2px; - font-size: 8px; - } - - .planning-panel .planner-adventure-place { - display: grid; - gap: 3px; - min-width: 0; - } - - .planning-panel .planner-adventure-place > span { - color: #0f766e; - font-size: 9px; - font-weight: 750; - text-transform: uppercase; - letter-spacing: 0.03em; - } - - .planning-panel .planner-adventure-place h4 { - min-width: 0; - overflow: hidden; - font-size: var(--text-sm); - text-overflow: ellipsis; - white-space: nowrap; - } - - .planning-panel .planner-adventure-breakdown { - display: flex; - flex-wrap: wrap; - gap: 4px; - } - - .planning-panel .planner-adventure-breakdown span { - padding: 3px 6px; - border-radius: var(--radius-pill); - background: color-mix(in srgb, #0f766e 8%, var(--surface-solid)); - color: var(--text-faint); - font-size: 8px; - white-space: nowrap; - } - - .planning-panel .planner-adventure-empty { - margin: 0; - padding: var(--space-4); - border-radius: var(--radius-sm); - background: color-mix(in srgb, #f97316 9%, var(--surface-elevated)); - color: var(--text-muted); - font-size: var(--text-xs); - line-height: 1.5; - } - - .planning-panel .planner-adventure-method summary { - color: var(--text-muted); - font-size: var(--text-xs); - font-weight: 750; - cursor: pointer; - } - - .planning-panel .planner-adventure-method p { - margin-top: var(--space-2); - } - - .planning-panel .planner-adventure-method small { - display: block; - margin-top: var(--space-2); - color: var(--text-faint); - font-size: 9px; - line-height: 1.4; - overflow-wrap: anywhere; - } - - @media (max-width: 520px) { - .planning-panel .planner-preference-grid { - grid-template-columns: 1fr; - } - - .planner-stop-location { - grid-template-columns: 1fr 1fr; - } - - .planner-stop-location > span { - grid-column: 1 / -1; - } - - .planning-panel .planner-adventure { - padding: var(--space-4); - } - - .planning-panel .planner-adventure-controls > .btn { - width: 100%; - } - - .planning-panel .planner-adventure-candidate { - grid-template-columns: 46px minmax(0, 1fr); - } - - .planning-panel .planner-adventure-candidate > .btn { - grid-column: 2; - justify-self: start; - } - } - - .planner-section { - display: grid; - gap: var(--space-4); - padding-top: var(--space-5); - border-top: 1px solid var(--border); - } - - .planner-section-title { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-4); - } - - .planner-section-title h3 { - margin: 0; - font-size: var(--text-md); - } - - .planner-section-title > span { - color: var(--text-faint); - font-size: var(--text-xs); - } - - .planner-stops { - position: relative; - display: grid; - gap: var(--space-4); - } - - .planner-stop-window { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - gap: var(--space-2); - align-items: center; - padding: var(--space-2); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--surface-elevated); - } - - .planner-stop-window span { - color: var(--text-muted); - font-size: var(--text-xs); - text-align: center; - } - - .planner-stops::before { - content: ""; - position: absolute; - top: 17px; - bottom: 17px; - left: 14px; - width: 2px; - background: color-mix(in srgb, var(--accent) 38%, var(--border)); - } - - .planner-stop { - position: relative; - display: grid; - grid-template-columns: 30px minmax(0, 1fr) 24px; - gap: var(--space-3); - align-items: start; - } - - .planner-stop-index { - position: relative; - z-index: 1; - width: 30px; - height: 30px; - display: grid; - place-items: center; - border: 2px solid var(--accent); - border-radius: 50%; - background: var(--surface-solid); - color: var(--accent); - font-size: var(--text-xs); - font-weight: 800; - } - - .planner-stop-fields { - display: grid; - gap: var(--space-2); - } - - .planner-stop-fields > input { - width: 100%; - font-weight: var(--weight-strong); - } - - .planner-coordinates { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--space-2); - } - - .planner-coordinates input { - min-width: 0; - padding: 7px; - font-size: 11px; - } - - .planner-remove { - width: 24px; - height: 30px; - color: var(--danger, #ef4444); - font-size: var(--text-lg); - } - - .planner-suggestions { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - } - - .planner-map-suggestion-block { - display: grid; - gap: var(--space-2); - padding: var(--space-3); - border: 1px dashed var(--border-strong); - border-radius: var(--radius-sm); - background: var(--surface-sunken); - } - - .planner-map-suggestion-block > span { - color: var(--text-faint); - font-size: 9px; - font-weight: 750; - text-transform: uppercase; - letter-spacing: 0.04em; - } - - .planner-suggestions button { - display: inline-flex; - gap: var(--space-2); - align-items: center; - max-width: 100%; - padding: 7px 10px; - border: 1px solid var(--border); - border-radius: var(--radius-pill); - background: var(--surface-elevated); - font-size: var(--text-xs); - } - - .planner-suggestions button span:last-child { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .planner-error, - .planner-warning { - margin: 0; - padding: var(--space-4); - border-radius: var(--radius-sm); - background: color-mix(in srgb, #f59e0b 13%, transparent); - color: var(--text); - font-size: var(--text-xs); - line-height: 1.45; - } - - .planner-error, - .planner-warning.danger { + .planner-error { background: color-mix(in srgb, #ef4444 13%, transparent); color: #ef4444; } - .planner-result { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-3); - padding: var(--space-5); - border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); - border-radius: var(--radius-md); - background: color-mix(in srgb, var(--accent) 7%, var(--surface-elevated)); - } - - .planner-result > div { - display: grid; - gap: 2px; - } - - .planner-result strong { - font-size: var(--text-lg); - } - - .planner-result div span { - color: var(--text-faint); - font-size: var(--text-xs); - } - - .planner-result > p, - .planner-result > a { - grid-column: 1 / -1; - } - - .planner-segments { - grid-column: 1 / -1; - display: grid; - gap: var(--space-2); - max-height: 280px; - overflow: auto; - } - - .planner-segment { - display: grid; - gap: var(--space-3); - padding: var(--space-3); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--surface-elevated); - transition: - border-color 150ms ease, - box-shadow 150ms ease, - background 150ms ease; - } - - .planner-map-selection-hint { - padding: 10px 12px; - border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border)); - border-radius: var(--radius-sm); - background: color-mix(in srgb, var(--accent) 7%, var(--surface-solid)); - color: var(--text-muted); - font-size: var(--text-xs); - line-height: 1.45; - } - - .planner-segment.selected-on-map { - border-color: #f97316; - box-shadow: inset 4px 0 0 #f97316; - background: color-mix(in srgb, #f97316 7%, var(--surface-elevated)); - } - - .planner-segment-map-focus { - justify-self: start; - padding: 6px 9px; - border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); - border-radius: var(--radius-pill); - background: var(--surface-solid); - color: var(--accent); - font-size: 10px; - font-weight: 750; - } - - .planner-segment-map-focus[aria-pressed="true"] { - border-color: #f97316; - background: color-mix(in srgb, #f97316 10%, var(--surface-solid)); - color: color-mix(in srgb, #f97316 80%, var(--text)); - } - - .planner-segment-temporal { - display: grid; - gap: 4px; - padding: 8px 10px; - border-radius: var(--radius-sm); - background: color-mix(in srgb, #0284c7 7%, var(--surface-solid)); - color: var(--text-muted); - font-size: 10px; - line-height: 1.35; - } - - .planner-segment-temporal strong { - color: #b45309; - font-size: 10px; - } - - .planner-segment-head { - display: grid; - grid-template-columns: 28px minmax(0, 1fr) auto; - gap: var(--space-2); - align-items: center; - } - - .planner-segment-head > span:first-child { - display: grid; - width: 26px; - height: 26px; - place-items: center; - border-radius: 9px; - background: color-mix(in srgb, var(--accent) 13%, var(--surface-solid)); - color: var(--accent); - font-size: var(--text-xs); - font-weight: 800; - } - - .planner-segment-head > div { - display: grid; - min-width: 0; - } - - .planner-segment-head strong { - min-width: 0; - overflow: hidden; - font-size: var(--text-sm); - text-overflow: ellipsis; - white-space: nowrap; - } - - .planner-segment-head small { - color: var(--text-faint); - } - - .planner-alternative-count { - padding: 4px 7px; - border-radius: var(--radius-pill); - background: color-mix(in srgb, var(--accent) 10%, var(--surface-solid)); - color: var(--accent); - font-size: 10px; - font-weight: 700; - white-space: nowrap; - } - - .planner-alternatives { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-2); - padding-left: 36px; - } - - .planner-alternatives button { - position: relative; - display: grid; - grid-template-columns: 1fr auto; - gap: 2px var(--space-2); - min-width: 0; - padding: 10px; - border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--surface-solid); - color: var(--text); - text-align: left; - } - - .planner-alternatives button:hover { - border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); - } - - .planner-alternatives button.selected { - border-color: var(--accent); - box-shadow: inset 0 0 0 1px var(--accent); - background: color-mix(in srgb, var(--accent) 8%, var(--surface-solid)); - } - - .planner-alternatives button > span:first-child { - grid-column: 1 / -1; - color: var(--text-muted); - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.04em; - } - - .planner-alternatives button > span:nth-of-type(2) { - color: var(--text-muted); - font-size: var(--text-xs); - text-align: right; - } - - .planner-alternatives button > strong { - font-size: var(--text-sm); - white-space: nowrap; - } - - .planner-alternatives button > small { - grid-column: 1 / -1; - min-width: 0; - color: var(--text-faint); - font-size: 10px; - overflow-wrap: anywhere; - } - - .planner-segment.status-failed { - border-color: color-mix(in srgb, #ef4444 45%, var(--border)); - } - - .planner-segment.status-stale, - .planner-segment.status-pending { - border-style: dashed; - } - - @media (max-width: 520px) { - .planning-panel .planner-temporal-status-grid { - grid-template-columns: 1fr; - } - - .planning-panel .planner-bike-map-actions { - grid-column: 1 / -1; - } - - .planner-segment-head { - grid-template-columns: 28px minmax(0, 1fr); - } - - .planner-alternative-count { - grid-column: 2; - justify-self: start; - } - - .planner-alternatives { - grid-template-columns: 1fr; - padding-left: 36px; - } - } - - .planner-actions { - flex-wrap: wrap; - } - - .planner-actions .block { - flex: 1 0 100%; - } - - .planner-service-actions { - display: grid; - gap: var(--space-4); - padding-top: var(--space-4); - border-top: 1px solid var(--border); - } - - .planner-primary-actions, - .planner-export-actions { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-2); - } - - .planner-primary-actions .btn:first-child { - grid-column: 1 / -1; - } - - .planner-share-manager { - overflow: hidden; - border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border)); - border-radius: var(--radius-md); + .planner-segment { + display: grid; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-sm); background: var(--surface-elevated); + transition: + border-color 150ms ease, + box-shadow 150ms ease, + background 150ms ease; } - .planner-share-manager > summary { + .planner-segment-temporal { display: grid; - grid-template-columns: 36px minmax(0, 1fr); - gap: var(--space-3); - align-items: center; - padding: var(--space-4); - cursor: pointer; - list-style: none; + gap: 4px; + padding: 8px 10px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, #0284c7 7%, var(--surface-solid)); + color: var(--text-muted); + font-size: 10px; + line-height: 1.35; } - .planner-share-manager > summary::-webkit-details-marker { - display: none; + .planner-segment-temporal strong { + color: #b45309; + font-size: 10px; } - .planner-share-manager > summary > span { - width: 36px; - height: 36px; - display: grid; - place-items: center; - border-radius: 50%; - background: color-mix(in srgb, var(--accent) 16%, var(--surface-sunken)); - color: var(--accent); - font-weight: 800; + .planner-segment.status-failed { + border-color: color-mix(in srgb, #ef4444 45%, var(--border)); + } + + .planner-segment.status-stale, + .planner-segment.status-pending { + border-style: dashed; } - .planner-share-manager > summary > div, - .planner-share-content, .planner-share-url label { display: grid; gap: var(--space-2); } - .planner-share-manager summary small, - .planner-share-content p, - .planner-share-url span, - .planner-share-list small { + .planner-share-url span { color: var(--text-faint); font-size: var(--text-xs); line-height: 1.4; } - .planner-share-content { - padding: 0 var(--space-4) var(--space-4); - } - - .planner-share-content > p { - margin: 0; - } - .planner-share-url { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -1659,76 +526,6 @@ font-size: var(--text-xs); } - .planner-share-list { - display: grid; - gap: var(--space-2); - margin: var(--space-2) 0 0; - padding: 0; - list-style: none; - } - - .planner-share-list li { - display: flex; - gap: var(--space-3); - align-items: center; - justify-content: space-between; - padding: var(--space-3); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - } - - .planner-share-list li > div { - display: grid; - gap: 2px; - } - - .planner-share-list li.revoked { - opacity: 0.58; - } - - .planner-ai-toggle { - display: grid; - grid-template-columns: 36px minmax(0, 1fr); - gap: var(--space-3); - align-items: center; - width: 100%; - padding: var(--space-4); - border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--border)); - border-radius: var(--radius-md); - background: color-mix(in srgb, var(--accent) 9%, var(--surface-elevated)); - color: var(--text); - text-align: left; - } - - .planner-ai-toggle > span { - width: 36px; - height: 36px; - display: grid; - place-items: center; - border-radius: 50%; - background: var(--accent); - color: #fff; - font-size: var(--text-xs); - font-weight: 800; - } - - .planner-ai-toggle strong { - min-width: 0; - } - - .planner-ai-toggle small { - grid-column: 2; - min-width: 0; - color: var(--text-faint); - line-height: 1.35; - overflow-wrap: anywhere; - } - - .planner-ai-toggle:disabled { - cursor: default; - opacity: 0.68; - } - .planner-ai-discussion { display: grid; gap: var(--space-3); @@ -1744,99 +541,12 @@ resize: vertical; } - .planner-ai-thread-head { - display: flex; - gap: var(--space-3); - align-items: center; - justify-content: space-between; - } - - .planner-ai-thread-head > div { - display: grid; - gap: 2px; - } - - .planner-ai-thread-head small, - .planner-ai-thread-loading { - margin: 0; - color: var(--text-faint); - font-size: var(--text-xs); - } - - .planner-ai-thread { - display: grid; - gap: var(--space-3); - max-height: 380px; - overflow: auto; - padding: var(--space-2); - scroll-behavior: smooth; - } - - .planner-ai-thread article { - display: grid; - gap: var(--space-2); - max-width: 92%; - padding: var(--space-3) var(--space-4); - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--surface-elevated); - } - - .planner-ai-thread article.role-user { - justify-self: end; - border-color: color-mix(in srgb, var(--accent) 38%, var(--border)); - background: color-mix(in srgb, var(--accent) 10%, var(--surface-elevated)); - } - - .planner-ai-thread article.role-assistant { - justify-self: start; - border-color: color-mix(in srgb, var(--accent) 34%, var(--border)); - } - - .planner-ai-thread article p { - margin: 0; - line-height: 1.5; - white-space: pre-wrap; - } - - .planner-ai-thread article small { - color: var(--text-faint); - font-size: 0.68rem; - line-height: 1.4; - } - - .planner-ai-consent, - .planner-route-disclaimer { - margin: 0; - color: var(--text-faint); - font-size: var(--text-xs); - line-height: 1.45; - } - .planner-ai-discussion .btn-ai { border-color: var(--accent); background: var(--accent); color: #fff; } - .planner-ai-plan-answer { - display: grid; - gap: var(--space-2); - padding: var(--space-4); - border-radius: var(--radius-sm); - background: color-mix(in srgb, var(--accent) 10%, var(--surface-elevated)); - } - - .planner-ai-plan-answer p { - margin: 0; - white-space: pre-wrap; - } - - .planner-ai-plan-answer small { - color: var(--text-faint); - line-height: 1.4; - } - .planner-handoff { display: grid; gap: var(--space-3); @@ -1844,52 +554,10 @@ border-top: 1px solid var(--border); } - .planner-handoff-links { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--space-2); - } - - .planner-handoff-links .btn { - min-width: 0; - padding-inline: var(--space-2); - text-align: center; - text-decoration: none; - } - - .planner-handoff-limits { - display: grid; - gap: var(--space-2); - margin: 0; - padding-left: var(--space-5); - color: var(--text-faint); - font-size: var(--text-xs); - } - - .planner-route-disclaimer { - padding: var(--space-3); - border-radius: var(--radius-sm); - background: var(--surface-sunken); - } - @media (max-width: 420px) { - .planner-handoff-links { - grid-template-columns: 1fr; - } - .planner-share-url { grid-template-columns: 1fr; } - - .planner-ai-thread article { - max-width: 96%; - } - } - - .planner-toll { - color: var(--accent); - font-size: var(--text-xs); - text-decoration: none; } /* ---- Personal panel (§4.3) ------------------------------------------- @@ -3431,21 +2099,6 @@ max-width: 640px; } - .timeline-contexts { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - } - - .timeline-contexts span { - padding: var(--space-1) var(--space-3); - border-radius: var(--radius-pill); - background: var(--accent-soft); - color: var(--accent); - font-size: var(--text-3xs); - font-weight: var(--weight-strong); - } - .timeline-presets { display: flex; align-items: center; @@ -3892,43 +2545,6 @@ gap: var(--space-3); } - .timeline-summary { - display: flex; - align-items: stretch; - justify-content: space-between; - gap: var(--space-5); - } - - .timeline-summary > div:first-child { - display: grid; - align-content: center; - gap: var(--space-1); - } - - .viewport-stat { - display: grid; - grid-template-columns: auto auto; - gap: 0 var(--space-5); - padding: var(--space-4) var(--space-5); - border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); - border-radius: var(--radius); - background: var(--accent-soft); - color: var(--text); - text-align: left; - } - - .viewport-stat > span, - .viewport-stat > small { - color: var(--text-muted); - font-size: var(--text-3xs); - } - - .viewport-stat strong { - grid-row: span 2; - align-self: center; - font-size: var(--text-xl); - } - /* Weather is useful while travelling, but the footer should stay a slim control strip. */ .map-timeline .weather-legend, .map-timeline .weather-source-legend { @@ -3950,27 +2566,6 @@ font-size: var(--text-3xs); } - .forecast-week { - display: grid; - grid-template-columns: repeat(7, minmax(54px, 1fr)); - gap: var(--space-3); - overflow-x: auto; - } - - .forecast-week > div { - display: grid; - gap: 1px; - padding: var(--space-3); - border-radius: var(--radius-sm); - background: var(--surface-sunken); - text-align: center; - font-size: var(--text-xs); - } - - .forecast-week small { - color: var(--text-muted); - } - .range-slider { display: flex; flex-direction: column; @@ -4090,15 +2685,6 @@ border: 2px solid var(--surface-solid); } - .timeline-time { - font-size: var(--text-xs); - font-weight: var(--weight-display); - font-variant-numeric: tabular-nums; - color: var(--text-muted); - min-width: 44px; - text-align: right; - } - .owm-pills { display: flex; gap: var(--space-3); diff --git a/apps/web/src/styles/planner.css b/apps/web/src/styles/planner.css index 4ec3505..c2748e5 100644 --- a/apps/web/src/styles/planner.css +++ b/apps/web/src/styles/planner.css @@ -238,11 +238,6 @@ font: var(--type-label-sm); } - .planner-stop-chip { - margin-inline-start: auto; - color: var(--text-muted); - } - .planner-stop-manual { display: flex; flex-direction: column; diff --git a/apps/web/src/styles/primitives.css b/apps/web/src/styles/primitives.css index 48d1552..0ae0a87 100644 --- a/apps/web/src/styles/primitives.css +++ b/apps/web/src/styles/primitives.css @@ -219,13 +219,6 @@ color: var(--text); } - .tag-row { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - margin-top: var(--space-3); - } - /* ---- Toggle --------------------------------------------------------- */ .toggle { width: 40px; @@ -539,13 +532,6 @@ line-height: 1.35; } - .setting-row-value { - font-size: var(--text-sm); - font-weight: var(--weight-strong); - color: var(--text-muted); - flex-shrink: 0; - } - .segmented { display: inline-flex; padding: 2px; diff --git a/apps/web/src/tasks/tileActivity.test.ts b/apps/web/src/tasks/tileActivity.test.ts new file mode 100644 index 0000000..e51e08a --- /dev/null +++ b/apps/web/src/tasks/tileActivity.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type maplibregl from "maplibre-gl"; +import { watchLayerTiles } from "./tileActivity"; + +function fakeMap(sources: Record) { + const handlers = new Map void>>(); + let styleReads = 0; + const map = { + getStyle() { + styleReads++; + return { sources }; + }, + getSource: (id: string) => sources[id], + isSourceLoaded: () => false, + on(type: string, handler: (event: unknown) => void) { + if (!handlers.has(type)) handlers.set(type, new Set()); + handlers.get(type)!.add(handler); + }, + off(type: string, handler: (event: unknown) => void) { + handlers.get(type)?.delete(handler); + } + }; + const fire = (type: string, event: unknown = {}) => + handlers.get(type)?.forEach((handler) => handler(event)); + return { map: map as unknown as maplibregl.Map, fire, styleReads: () => styleReads, handlers }; +} + +test("basemap tile events never make a layer's watcher read the whole style", () => { + const sources: Record = { + basemap: { type: "vector" }, + "source-tile-radar": { type: "raster" } + }; + const { map, fire, styleReads, handlers } = fakeMap(sources); + const stop = watchLayerTiles(map, "radar", 1, () => {}); + const initial = styleReads(); + for (let i = 0; i < 500; i++) + fire("sourcedata", { sourceId: "basemap", sourceDataType: "content", coord: { i } }); + for (let i = 0; i < 20; i++) + fire("sourcedata", { sourceId: "source-tile-radar", sourceDataType: "content", coord: { i } }); + assert.equal(styleReads(), initial, "the source list is cached between tile events"); + + // A source the layer adds later is picked up by its first event. + sources["source-tile-radar-2"] = { type: "raster" }; + fire("sourcedata", { sourceId: "source-tile-radar-2", sourceDataType: "metadata" }); + assert.equal(styleReads(), initial + 1); + fire("styledata"); + fire("idle"); + assert.equal(styleReads(), initial + 2, "a style change refreshes the list once"); + + stop(); + assert.equal( + [...handlers.values()].reduce((sum, set) => sum + set.size, 0), + 0 + ); +}); diff --git a/apps/web/src/tasks/tileActivity.ts b/apps/web/src/tasks/tileActivity.ts index 3afddcb..98ffecc 100644 --- a/apps/web/src/tasks/tileActivity.ts +++ b/apps/web/src/tasks/tileActivity.ts @@ -9,19 +9,35 @@ export function watchLayerTiles( ) { if (typeof map.getStyle !== "function" || typeof map.isSourceLoaded !== "function") return () => {}; - const sources = () => - Object.entries(map.getStyle()?.sources ?? {}) - .filter( - ([key, source]) => - (key === `source-tile-${id}` || - key.startsWith(`source-tile-${id}-`) || - key === id || - key === `source-${id}` || - key.startsWith(`source-${id}-`) || - key.startsWith(`${id}-`)) && - (source.type === "raster" || source.type === "vector") - ) - .map(([key]) => key); + const owns = (key: string) => + key === `source-tile-${id}` || + key.startsWith(`source-tile-${id}-`) || + key === id || + key === `source-${id}` || + key.startsWith(`source-${id}-`) || + key.startsWith(`${id}-`); + // `getStyle()` serialises the whole style, and tile events arrive by the hundred during a pan + // (most of them for the basemap). The list of this layer's sources is read once and again + // only when the style changes or one of its sources appears or disappears. + let cached: string[] | null = null; + const sources = () => { + if (!cached || cached.some((key) => !map.getSource(key))) + cached = Object.entries(map.getStyle()?.sources ?? {}) + .filter( + ([key, source]) => owns(key) && (source.type === "raster" || source.type === "vector") + ) + .map(([key]) => key); + return cached; + }; + const tracks = (sourceId: string | undefined) => { + if (!sourceId || !owns(sourceId)) return false; + if (sources().includes(sourceId)) return true; + cached = null; + return sources().includes(sourceId); + }; + const styleChanged = () => { + cached = null; + }; let disposed = false; let timedOut = false; let previousFailure = false; @@ -69,7 +85,7 @@ export function watchLayerTiles( const tileKey = (e: { sourceId?: string; coord?: unknown }) => `${e.sourceId}:${JSON.stringify(e.coord ?? "source")}`; const data = (e: maplibregl.MapSourceDataEvent) => { - if (!sources().includes(e.sourceId)) return; + if (!tracks(e.sourceId)) return; const detail = e as maplibregl.MapSourceDataEvent & { coord?: unknown }; if (e.sourceDataType === "content" && detail.coord) { received.add(tileKey(detail)); @@ -79,7 +95,7 @@ export function watchLayerTiles( }; const error = (e: maplibregl.ErrorEvent) => { const event = e as maplibregl.ErrorEvent & { sourceId?: string; coord?: unknown }; - if (!event.sourceId || !sources().includes(event.sourceId)) return; + if (!tracks(event.sourceId)) return; failed.add(tileKey(event)); previousFailure = true; check(); @@ -92,6 +108,7 @@ export function watchLayerTiles( failed.clear(); received.clear(); }; + map.on("styledata", styleChanged); map.on("sourcedata", data); map.on("sourcedataloading", data); map.on("error", error); @@ -101,6 +118,7 @@ export function watchLayerTiles( return () => { disposed = true; clearTimeout(deadline); + map.off("styledata", styleChanged); map.off("sourcedata", data); map.off("sourcedataloading", data); map.off("error", error); diff --git a/apps/web/src/ui/AiPanel.tsx b/apps/web/src/ui/AiPanel.tsx index a3ff87b..e012dfc 100644 --- a/apps/web/src/ui/AiPanel.tsx +++ b/apps/web/src/ui/AiPanel.tsx @@ -32,6 +32,7 @@ import { runChatTurn } from "./ai/runChatTurn"; import { AnswerText } from "./ai/AnswerText"; import { showStatisticAnswer } from "./ai/statisticAnswer"; import { chatSession, useChatField } from "./ai/chatSession"; +import { areaQuery } from "../search/areaQuery"; const PRIVACY_DISMISSED_KEY = "mapos:ai-privacy-ack"; @@ -129,6 +130,28 @@ export function AiPanel() { const ask = (question: string) => runChatTurn(question, featureRef); + // The last place search is what "Search this area" on the map asks again once the reader has + // moved elsewhere — the same as that turn's own "Refresh for this view" button. + const lastTurn = turns.at(-1); + const areaTurn = + !busy && + lastTurn?.done && + !lastTurn.error && + lastTurn.cards.some((card) => card.type === "places") + ? lastTurn + : null; + const areaTurnId = areaTurn?.id; + const areaQuestion = areaTurn?.question; + useEffect(() => { + if (!areaTurnId || !areaQuestion) return; + areaQuery.set({ + id: areaTurnId, + label: areaQuestion, + rerun: () => void runChatTurn(areaQuestion, featureRef) + }); + return () => areaQuery.clear(areaTurnId); + }, [areaTurnId, areaQuestion, featureRef]); + // A question typed into search opens this panel already asked, so the user does not have to // retype it here. useEffect(() => { diff --git a/apps/web/src/ui/CommandSearch.tsx b/apps/web/src/ui/CommandSearch.tsx index 6f655e4..cd56707 100644 --- a/apps/web/src/ui/CommandSearch.tsx +++ b/apps/web/src/ui/CommandSearch.tsx @@ -1,4 +1,5 @@ import { maptilerGeocode } from "../search/maptilerGeocoding"; +import { geocodeNearParam } from "../search/nearBias"; import { normalizeCatalogText } from "@mapos/layer-sdk"; import { SearchLayers } from "./layers/SearchLayers"; import { chatSession, useChatField } from "./ai/chatSession"; @@ -261,7 +262,7 @@ export function CommandSearch({ } try { const response = await fetch( - `${API_BASE}/geocode?q=${encodeURIComponent(intent.query)}&provider=auto&autocomplete=true`, + `${API_BASE}/geocode?q=${encodeURIComponent(intent.query)}&provider=auto&autocomplete=true${geocodeNearParam(getMapStore().view)}`, { signal } ); if (!response.ok) throw new Error(`HTTP ${response.status}`); @@ -385,7 +386,7 @@ export function CommandSearch({ if (store.capabilities?.maptilerGeocoding) { try { const response = await fetch( - `${API_BASE}/geocode?q=${encodeURIComponent(submitted)}&provider=auto&autocomplete=true`, + `${API_BASE}/geocode?q=${encodeURIComponent(submitted)}&provider=auto&autocomplete=true${geocodeNearParam(getMapStore().view)}`, { signal } ); if (response.ok) { @@ -400,7 +401,7 @@ export function CommandSearch({ } } const response = await fetch( - `${API_BASE}/geocode?q=${encodeURIComponent(submitted)}&provider=auto`, + `${API_BASE}/geocode?q=${encodeURIComponent(submitted)}&provider=auto${geocodeNearParam(getMapStore().view)}`, { signal } ); if (!response.ok) throw new Error("Hledání se nepodařilo dokončit."); diff --git a/apps/web/src/ui/LayerNotices.tsx b/apps/web/src/ui/LayerNotices.tsx deleted file mode 100644 index cf9580c..0000000 --- a/apps/web/src/ui/LayerNotices.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { getLayerPlugin } from "../layers"; -import { useMapStoreSnapshot } from "../store/useMapStoreSnapshot"; - -/** - * Explains a layer that came back empty on purpose. - * - * Without this an upstream refusing the request ("zoom in", "missing key", "service down") and - * an area that genuinely has nothing in it are the same blank map, and the user is left to - * guess which one they're looking at. - */ -export function LayerNotices({ inline = false }: { inline?: boolean } = {}) { - const notices = useMapStoreSnapshot((s) => s.layerNotices); - const active = useMapStoreSnapshot((s) => s.activeLayers); - const inAreaControls = useMapStoreSnapshot( - (s) => s.mode !== "game" && (s.mode === "discover" || !!s.areaSelection) - ); - - const visible = Object.entries(notices).filter(([layerId]) => active[layerId]?.visible); - if (!visible.length || (!inline && inAreaControls)) return null; - - return ( -
- {visible.map(([layerId, message]) => { - const manifest = getLayerPlugin(layerId)?.manifest; - return ( -
- {manifest?.icon ?? "ℹ️"} - - {manifest?.name ?? layerId} {message} - -
- ); - })} -
- ); -} diff --git a/apps/web/src/ui/PlacesPanel.tsx b/apps/web/src/ui/PlacesPanel.tsx deleted file mode 100644 index 6832610..0000000 --- a/apps/web/src/ui/PlacesPanel.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import type { GeoFeature } from "@mapos/layer-sdk"; -import { distanceMeters, featureAnchor } from "@mapos/layer-sdk"; -import { getMapStore } from "../store/mapStore"; -import { emit } from "../lib/events"; -import { useMapStoreSnapshot } from "../store/useMapStoreSnapshot"; -import { PIN_STYLES, type PinStyle } from "./presets"; -import { preloadPlacePhotos, resolvePhotoUrl } from "./photoCache"; -import { PanelShell } from "./PanelShell"; -import { SourceStatus } from "./SourceStatus"; -import { formatDistance } from "../lib/units"; - -function PlacePhoto({ feature, style }: { feature: GeoFeature; style?: PinStyle }) { - const lowData = useMapStoreSnapshot((s) => s.preferences.lowData); - const directPhoto = - typeof feature.properties.photo === "string" ? (feature.properties.photo as string) : null; - const wikidata = - typeof feature.properties.wikidata === "string" - ? (feature.properties.wikidata as string) - : null; - const [resolved, setResolved] = useState(directPhoto); - - useEffect(() => { - if (lowData) { - setResolved(null); - return; - } - setResolved(directPhoto); - let cancelled = false; - void resolvePhotoUrl({ photo: directPhoto, wikidata }).then((url) => { - if (!cancelled) setResolved(url); - }); - return () => { - cancelled = true; - }; - }, [wikidata, directPhoto, lowData]); - - if (resolved && !lowData) { - return ; - } - return ( -
- {style?.icon ?? "📍"} -
- ); -} - -export function PlacesTab() { - const store = getMapStore(); - const active = useMapStoreSnapshot((s) => s.activeLayers); - const visibleFeatures = useMapStoreSnapshot((s) => s.visibleFeatures); - const loadingLayers = useMapStoreSnapshot((s) => s.loadingLayers); - const view = useMapStoreSnapshot((s) => s.view); - const searchPending = useMapStoreSnapshot((s) => s.searchHerePending); - const lowData = useMapStoreSnapshot((s) => s.preferences.lowData); - const units = useMapStoreSnapshot((s) => s.preferences.units); - - const places = useMemo(() => { - const all: { feature: GeoFeature; layerId: string; distance: number }[] = []; - for (const [layerId, state] of Object.entries(active)) { - if (!state.visible) continue; - for (const feature of visibleFeatures[layerId] ?? []) { - const [lng, lat] = featureAnchor(feature); - all.push({ feature, layerId, distance: distanceMeters(view, { lng, lat }) }); - } - } - return all.sort((a, b) => a.distance - b.distance).slice(0, 60); - }, [active, visibleFeatures, view]); - - useEffect(() => { - if (lowData) return; - preloadPlacePhotos( - places.slice(0, 4).map((p) => ({ - photo: typeof p.feature.properties.photo === "string" ? p.feature.properties.photo : null, - wikidata: - typeof p.feature.properties.wikidata === "string" ? p.feature.properties.wikidata : null - })) - ); - }, [places, lowData]); - - const isLoading = Object.keys(loadingLayers).length > 0; - const hasActiveLayers = Object.values(active).some((s) => s.visible); - - const openPlace = (p: { feature: GeoFeature; layerId: string }) => { - const [lng, lat] = featureAnchor(p.feature); - emit("fly-to", { lng, lat, zoom: 16 }); - store.selectPin({ feature: p.feature, layerId: p.layerId }); - if (window.innerWidth < 900) store.setSidebarOpen(false); - }; - - if (!hasActiveLayers) { - return ( -
-

Zapni vrstvu přes ikonu vrstev v horní liště.

-
- ); - } - - if (!places.length) { - return ( -
- {isLoading ? ( - <> - {[0, 1, 2].map((i) => ( -
- ))} - - ) : searchPending ? ( - <> -

Posunul jsi mapu. Načti místa v tomto výřezu.

- - - ) : ( -

- V tomto výřezu nic není. Zkus usecase Gastro / Město, nebo přibliž mapu a klepni Hledat - zde. -

- )} -
- ); - } - - return ( -
- {places.map((p) => { - const category = String(p.feature.properties.category ?? p.layerId); - const style = PIN_STYLES[category]; - const tags = Array.isArray(p.feature.properties.tags) - ? (p.feature.properties.tags as string[]) - : []; - return ( - - ); - })} -
- ); -} - -export function PlacesPanel() { - const open = useMapStoreSnapshot((s) => s.sidebarOpen); - const mode = useMapStoreSnapshot((s) => s.mode); - - if (!open || mode === "discover") return null; - - return ( - } - > - - - ); -} diff --git a/apps/web/src/ui/SearchHereButton.tsx b/apps/web/src/ui/SearchHereButton.tsx index 31ada96..92f22fe 100644 --- a/apps/web/src/ui/SearchHereButton.tsx +++ b/apps/web/src/ui/SearchHereButton.tsx @@ -1,44 +1,55 @@ -import { useMapStoreSnapshot } from "../store/useMapStoreSnapshot"; +import { useEffect, useState, useSyncExternalStore } from "react"; import { emit } from "../lib/events"; -import { useSyncExternalStore, useState, useEffect } from "react"; -import { layerActivity } from "../tasks/layerActivity"; +import { t } from "../i18n"; +import { areaQuery } from "../search/areaQuery"; +import { useMapStoreSnapshot } from "../store/useMapStoreSnapshot"; +import { Icon } from "./kit"; -/** The engine owns per-layer pending state and deduplicates clicks during active requests. */ +/** + * "Search this area", floating over the map. + * + * Short pans refresh on their own; this appears only when the view is out of date in a way the + * map will not fix by itself — layers left waiting after a long move (or after any move of the + * reader's with the manual-refresh preference), or the assistant's last place search once the + * reader has moved elsewhere. One press brings the layers up to date for the current view and + * asks that search again here. It used to live only inside the Map status popover, so after a + * long pan the pins seemed not to load at all. + */ export function SearchHereButton() { - const [batch, setBatch] = useState([]); + const pending = useMapStoreSnapshot((s) => s.searchHerePending); const loading = useMapStoreSnapshot((s) => s.loadingLayers); - const active = useMapStoreSnapshot((s) => s.activeLayers); - useSyncExternalStore(layerActivity.subscribe, layerActivity.revision, layerActivity.revision); - const waiting = Object.keys(active).filter( - (id) => - active[id]?.visible && - layerActivity.get(id)?.phase && - ["pending", "error", "partial"].includes(layerActivity.get(id)!.phase) - ); - const busy = batch.some( - (id) => - active[id]?.visible && - (loading[id] || - ["queued", "loading", "rendering"].includes(layerActivity.get(id)?.phase ?? "")) - ); + const game = useMapStoreSnapshot((s) => s.mode === "game"); + useSyncExternalStore(areaQuery.subscribe, areaQuery.revision, areaQuery.revision); + const query = areaQuery.moved() ? areaQuery.get() : null; + const [searching, setSearching] = useState(false); + const busy = Object.values(loading).some(Boolean); + useEffect(() => { - if (batch.length && !busy) setBatch([]); - }, [batch, busy]); - if (!busy && !waiting.length) return null; + if (!searching || busy || pending) return; + // A short hold, so an answer served from cache does not blink the chip in and out. + const timer = window.setTimeout(() => setSearching(false), 300); + return () => window.clearTimeout(timer); + }, [searching, busy, pending]); + + if (game || (!searching && !pending && !query)) return null; + const label = searching ? t("search.searchingHere") : t("search.searchHere"); return ( ); } diff --git a/apps/web/src/ui/SettingsSheet.tsx b/apps/web/src/ui/SettingsSheet.tsx index 85d97cc..b9db03b 100644 --- a/apps/web/src/ui/SettingsSheet.tsx +++ b/apps/web/src/ui/SettingsSheet.tsx @@ -252,14 +252,14 @@ const registry = new SettingsUiRegistry() order: 30, render: ({ store, preferences }) => ( store.setPreference("showSearchHere", next)} + checked={preferences.manualRefresh} + label={t("settings.manualRefresh")} + onChange={(next) => store.setPreference("manualRefresh", next)} testId="search-here-toggle" /> } diff --git a/apps/web/src/ui/planning/StopLocationInput.tsx b/apps/web/src/ui/planning/StopLocationInput.tsx index 76fb7ad..24e691a 100644 --- a/apps/web/src/ui/planning/StopLocationInput.tsx +++ b/apps/web/src/ui/planning/StopLocationInput.tsx @@ -1,5 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { API_BASE } from "../../lib/api"; +import { getMapStore } from "../../store/mapStore"; +import { geocodeNearParam } from "../../search/nearBias"; import { isValidCoordinates, LatestRequestRunner, @@ -105,7 +107,7 @@ export function StopLocationInput({ void runner .run(async (signal) => { const response = await fetch( - `${API_BASE}/geocode?q=${encodeURIComponent(intent.query)}&provider=${encodeURIComponent(provider)}`, + `${API_BASE}/geocode?q=${encodeURIComponent(intent.query)}&provider=${encodeURIComponent(provider)}${geocodeNearParam(getMapStore().view)}`, { signal } ); if (!response.ok) return []; diff --git a/apps/web/src/ui/shell/ActivityIndicator.tsx b/apps/web/src/ui/shell/ActivityIndicator.tsx deleted file mode 100644 index 98a8a28..0000000 --- a/apps/web/src/ui/shell/ActivityIndicator.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { t } from "../../i18n"; -import { getShellStore } from "../../store/shellStore"; -import { useShellStoreSnapshot } from "../../store/useShellStoreSnapshot"; -import { taskRegistry } from "../../tasks/TaskRegistry"; -import { useTaskRegistrySnapshot } from "../../tasks/useTaskRegistrySnapshot"; -import { Button, Icon, Popover } from "../kit"; -import { activityRows, activitySummary } from "./activityModel"; - -/** Nothing is shown for the first third of a second: most layer queries resolve inside it, and - * a pill that appears and vanishes again is worse than no pill. */ -const APPEAR_DELAY_MS = 300; -/** And nothing is taken away for a moment after the last row goes. A layer refresh retires its - * task before starting the replacement, so the list is empty for a tick in the middle of one - * piece of work; hiding on that tick unmounted the pill along with any popover open on it. */ -const LINGER_MS = 400; -/** How long a finished row keeps its tick before fading out. */ -const SUCCESS_HOLD_MS = 200; -/** Failures stay long enough to read and click. */ -const FAILURE_HOLD_MS = 5000; - -/** The one loading surface in the app (§4.12), bottom right above the zoom controls. - * - * It replaces both the letter strip of place sources and the expandable task centre: those - * told the user which of seven providers was slow, which is information for the layer drawer, - * not for someone watching the map. - */ -export function ActivityIndicator() { - const shell = getShellStore(); - const rightUtilityOpen = useShellStoreSnapshot((state) => state.rightUtility.type !== "closed"); - const footerActive = useShellStoreSnapshot((state) => state.footerContributions.length > 0); - const tasks = useTaskRegistrySnapshot((snapshot) => snapshot); - - // Ids of tasks that have finished but whose row is still on screen. Held here rather than in - // the registry because the hold time is a presentation choice. - const [settled, setSettled] = useState>(() => new Set()); - const [visible, setVisible] = useState(false); - const timersRef = useRef(new Map()); - // Tasks whose hold window has already been granted. A finished task stays in the registry, - // so without this the next registry change would grant it another one and its row would - // reappear for as long as anything else on the map keeps loading. - const heldRef = useRef(new Set()); - - useEffect(() => { - const timers = timersRef.current; - const held = heldRef.current; - const live = new Set(tasks.map((task) => task.id)); - for (const id of held) { - if (!live.has(id)) held.delete(id); - } - for (const task of tasks) { - if (task.status !== "succeeded" && task.status !== "failed") continue; - if (held.has(task.id)) continue; - held.add(task.id); - const hold = task.status === "failed" ? FAILURE_HOLD_MS : SUCCESS_HOLD_MS; - setSettled((current) => new Set(current).add(task.id)); - timers.set( - task.id, - window.setTimeout(() => { - timers.delete(task.id); - setSettled((current) => { - const next = new Set(current); - next.delete(task.id); - return next; - }); - }, hold) - ); - } - }, [tasks]); - - useEffect(() => { - const timers = timersRef.current; - return () => { - for (const timer of timers.values()) window.clearTimeout(timer); - timers.clear(); - }; - }, []); - - const rows = useMemo(() => activityRows(tasks, settled), [tasks, settled]); - const hasRows = rows.length > 0; - const lastRowsRef = useRef(rows); - - useEffect(() => { - const timer = window.setTimeout( - () => setVisible(hasRows), - hasRows ? APPEAR_DELAY_MS : LINGER_MS - ); - return () => window.clearTimeout(timer); - }, [hasRows]); - - // What the pill showed last, so it has something to show through the linger. - if (hasRows) lastRowsRef.current = rows; - const shownRows = hasRows ? rows : lastRowsRef.current; - - if (!visible || shownRows.length === 0) return null; - - const failed = shownRows.some((row) => row.tone === "error"); - // Everything the pill knows about, not just the three rows it would have drawn: "2/5" is only - // honest if the five is every source that was asked. - const known = tasks.filter( - (task) => task.status === "queued" || task.status === "running" || settled.has(task.id) - ).length; - const summary = activitySummary(shownRows, Math.max(known, shownRows.length)); - const lead = shownRows[0]!; - // A row here is "this layer needs you", not "this attempt failed". Every refresh retires the - // old task and starts one with a fresh id, so keying on the id tore the row down and built a - // new one mid-refresh — a user reaching for Zkusit znovu could have it vanish under the - // cursor. One row per layer, updated in place, and the newest attempt is the one it shows. - const actionable = [ - ...tasks - .filter( - (task) => - task.status === "failed" || - ((task.status === "queued" || task.status === "running") && task.cancellable) - ) - .reduce((byLayer, task) => { - byLayer.set(task.layerId ?? task.id, task); - return byLayer; - }, new Map()) - ]; - - return ( - - ); -} diff --git a/apps/web/src/ui/shell/AppShell.tsx b/apps/web/src/ui/shell/AppShell.tsx index 09044d8..0c652e7 100644 --- a/apps/web/src/ui/shell/AppShell.tsx +++ b/apps/web/src/ui/shell/AppShell.tsx @@ -32,6 +32,7 @@ import { ModuleErrorBoundary } from "../primitives/ModuleErrorBoundary"; import { Button } from "../kit"; import { DiscoverHereButton } from "../DiscoverHereButton"; +import { SearchHereButton } from "../SearchHereButton"; import { DesktopModeBar } from "./DesktopModeBar"; import { MapFooterStack } from "./MapFooterStack"; import { LegendStack } from "./LegendStack"; @@ -294,6 +295,10 @@ export function AppShell({ onFlyToMe }: AppChromeProps) { + {/* "Search this area": floats over the map whenever the view is waiting for it. */} + + + {/* The arcade HUD floats over the board: in game mode the map panel stays closed and the player gets vitals, zone clock and the three actions without opening anything. */} diff --git a/apps/web/src/ui/shell/MapStatus.tsx b/apps/web/src/ui/shell/MapStatus.tsx index 55e7046..537221d 100644 --- a/apps/web/src/ui/shell/MapStatus.tsx +++ b/apps/web/src/ui/shell/MapStatus.tsx @@ -7,7 +7,6 @@ import { useMapStoreSnapshot } from "../../store/useMapStoreSnapshot"; import { useTaskRegistrySnapshot } from "../../tasks/useTaskRegistrySnapshot"; import { taskRegistry } from "../../tasks/TaskRegistry"; import { getLayerPlugin } from "../../layers"; -import { SearchHereButton } from "../SearchHereButton"; import { emit } from "../../lib/events"; import { experienceRegistry, experienceById } from "../../product/registry"; import { Icon } from "../kit"; @@ -175,7 +174,6 @@ export function MapStatus({ compact = false }: { compact?: boolean }) {
))} - )} diff --git a/apps/web/src/ui/shell/mapStatus.css b/apps/web/src/ui/shell/mapStatus.css index 15d0508..9b729c0 100644 --- a/apps/web/src/ui/shell/mapStatus.css +++ b/apps/web/src/ui/shell/mapStatus.css @@ -64,11 +64,6 @@ border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); background: var(--accent-soft); } -.map-status-popover .search-here-btn { - position: static; - transform: none; - margin-top: 10px; -} @keyframes mapos-loading { 50% { transform: translateY(-2px) rotate(-8deg); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 4241422..89340e7 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -19,8 +19,26 @@ export default defineConfig({ } ], resolve: { - alias: { - "@": "/src" + alias: [ + { find: "@", replacement: "/src" }, + // The worker is loaded from its own file (`setWorkerUrl` in main.tsx), so the default + // build's inlined copy of it was dead weight in the main bundle. + { find: /^maplibre-gl$/, replacement: "maplibre-gl/dist/maplibre-gl-csp.js" } + ] + }, + build: { + rollupOptions: { + output: { + // Libraries change far less often than the app: their own chunks stay cached across + // releases instead of being downloaded again with every app change. + manualChunks(id) { + if (id.includes("/node_modules/maplibre-gl/")) return "maplibre"; + if (/\/node_modules\/(react|react-dom|scheduler)\//.test(id)) return "react"; + if (id.includes("/node_modules/@base-ui/") || id.includes("/node_modules/@floating-ui/")) + return "ui-kit"; + return undefined; + } + } } }, server: { diff --git a/docs/environment.md b/docs/environment.md index 6622f59..6c37dfb 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -16,6 +16,19 @@ Copy `.env.example` → `.env` for local dev, or `infra/.env.production.example` | `OVERPASS_URLS` | no | three public mirrors | OSM POI fetching | | `MAPOS_CONTACT` | recommended | — | `User-Agent` sent to Nominatim, Overpass, Wikimedia | +### PostgreSQL sizing (production compose) + +`infra/compose.production.yml` starts PostgreSQL with JIT off and sizes for a small shared VPS. +Raise them per host in `infra/.env.production`; a change restarts the database once. + +| Variable | Default | Setting | +| ------------------------------- | ------- | ------------------------------------------------ | +| `POSTGRES_SHARED_BUFFERS` | `512MB` | `shared_buffers` (about a quarter of free RAM) | +| `POSTGRES_EFFECTIVE_CACHE_SIZE` | `2GB` | `effective_cache_size` (planner hint, no memory) | +| `POSTGRES_WORK_MEM` | `32MB` | `work_mem` per sort/hash | +| `POSTGRES_MAINTENANCE_WORK_MEM` | `256MB` | `maintenance_work_mem` (index builds, ANALYZE) | +| `MAPOS_PG_WORK_MEM` (API) | `32MB` | `work_mem` the API sets on its own connections | + `MAPOS_CONTACT` is worth setting on anything beyond a laptop. Nominatim and the Overpass mirrors require a `User-Agent` that identifies the deployment and offers a way to make contact; without one your traffic is indistinguishable from every other MapOS clone and gets rate-limited as a diff --git a/docs/operations/backup-restore.md b/docs/operations/backup-restore.md index 94fb785..e45ce78 100644 --- a/docs/operations/backup-restore.md +++ b/docs/operations/backup-restore.md @@ -35,6 +35,17 @@ it never drops or rewrites `mapos`. critical startup logs. 5. On failure restore the previous symlink and immutable image IDs. Additive schema remains compatible; repair forward rather than dropping new columns/tables during the same release. + A release that changes the PostgreSQL settings in `infra/compose.production.yml` recreates the + database container once, inside this rollback boundary; a failed rollout recreates it with the + previous settings. +6. After a healthy rollout the deploy analyses tables that were never analysed or have changed + by more than a tenth, keeps the three newest releases and backups (never the live or the + previous release) and six rollback image tags per service, and prunes dangling images and + week-old build cache. None of this can fail the release. + +A restored database has no planner statistics: `pg_restore` does not carry them, and until the +next analyse the planner can misjudge joins by three orders of magnitude. After any manual +restore into a database that serves traffic, run `ANALYZE;` (or the next deploy does it). Backups contain user data and production environment metadata. Directories are mode 0700/files 0600 and must follow the hosting retention/encryption policy. Provider secrets are restored from diff --git a/e2e/aiPanel.spec.ts b/e2e/aiPanel.spec.ts index 11bfdfd..0805e6f 100644 --- a/e2e/aiPanel.spec.ts +++ b/e2e/aiPanel.spec.ts @@ -477,6 +477,38 @@ test("AI V2 preserves streamed text on EOF and clears pending work on new thread await expect(page.getByTestId("ai-panel-empty")).toBeVisible(); }); +test("after the reader moves the map, Search this area asks the last place search there", async ({ + page +}) => { + const bodies: Array<{ message?: string; context?: { bbox?: number[] } }> = []; + page.on("request", (request) => { + if (request.url().endsWith("/v2/ai/chat")) bodies.push(request.postDataJSON()); + }); + await openPanel(page); + const asked = bodies.length; + // The answer's own camera fit is not a move of the reader's: nothing is offered yet. + await expect(page.getByTestId("search-here")).toHaveCount(0); + + const box = (await page.locator(".maplibregl-canvas").boundingBox())!; + const [x, y] = [box.x + box.width - 60, box.y + box.height / 2]; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x - 300, y + 40, { steps: 8 }); + await page.mouse.up(); + await page.mouse.move(x - 298, y + 42); + + const button = page.getByTestId("search-here"); + await expect(button).toBeVisible({ timeout: 20_000 }); + await expect(button).toHaveAttribute("title", new RegExp(QUESTION.replace("?", "\\?"))); + await button.click(); + await expect.poll(() => bodies.length, { timeout: 20_000 }).toBeGreaterThan(asked); + const [first, again] = [bodies[asked - 1]!, bodies.at(-1)!]; + expect(again.message).toBe(QUESTION); + expect(again.context?.bbox).toBeTruthy(); + expect(again.context?.bbox).not.toEqual(first.context?.bbox); + await expect(page.getByTestId("ai-panel-thread").locator(".ai-turn")).toHaveCount(2); +}); + test("AI V2 place excursion preserves the thread without another chat call", async ({ page }) => { let calls = 0; page.on("request", (request) => { diff --git a/e2e/viewportRefresh.spec.ts b/e2e/viewportRefresh.spec.ts index 863adda..e6fbb59 100644 --- a/e2e/viewportRefresh.spec.ts +++ b/e2e/viewportRefresh.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "./fixtures/offlineTest"; +import { expect, test, type Page } from "./fixtures/offlineTest"; const EMPTY = { type: "FeatureCollection", features: [] }; @@ -6,7 +6,7 @@ const EMPTY = { type: "FeatureCollection", features: [] }; * Layers used to load once and then sit still: panning to the next village kept showing the pins * of the village behind you, with a Search here button where the new places should have been. * An ordinary pan now refetches; only a jump far enough to be a different question waits to be - * asked. + * asked, and the question floats over the map instead of hiding in the Map status popover. */ test.describe("viewport refresh", () => { test("an ordinary pan refetches without asking", async ({ page }) => { @@ -32,14 +32,7 @@ test.describe("viewport refresh", () => { }); test("a jump across the country asks first, and asking refetches", async ({ page }) => { - const requested: string[] = []; - await page.route("**/layers/osm-poi/features**", async (route) => { - requested.push(route.request().url()); - // Without this the browser answers the identical follow-up URL from its memory cache and - // the refetch never reaches this handler. - await route.fulfill({ json: EMPTY, headers: { "cache-control": "no-store" } }); - }); - + const requested = await recordPoiRequests(page); await page.setViewportSize({ width: 1440, height: 900 }); await page.goto("/?layers=osm-poi&lng=13.3775&lat=49.7475&z=14"); await expect.poll(() => requested.length, { timeout: 30_000 }).toBeGreaterThan(0); @@ -48,20 +41,136 @@ test.describe("viewport refresh", () => { await page.evaluate(() => { window.__maposMap?.jumpTo({ center: [14.42, 50.08], zoom: 14 }); }); - await page.getByRole("button", { name: "Map status", exact: true }).click(); + // The question floats over the map; nothing has to be opened to find it. const button = page.getByTestId("search-here"); await expect(button).toBeVisible({ timeout: 20_000 }); - - // The button disables itself while the layer is in flight; asking again is only meaningful - // once the previous answer has landed. - await expect(button).toBeEnabled({ timeout: 20_000 }); + await expect(button).toHaveText(/Search this area/); const asked = requested.length; await button.click(); await expect.poll(() => requested.length, { timeout: 20_000 }).toBeGreaterThan(asked); + expect(centre(requested.at(-1)!)[0]).toBeCloseTo(14.42, 1); + await expect(button).toHaveCount(0); + }); + + test("a long drag offers Search this area and pressing it loads the new view", async ({ + page + }) => { + const requested = await recordPoiRequests(page); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/?layers=osm-poi&lng=13.3775&lat=49.7475&z=14"); + await expect.poll(() => requested.length, { timeout: 30_000 }).toBeGreaterThan(0); + await settle(page, requested); + + const before = requested.length; + await dragMap(page, -1200, 0); + const button = page.getByTestId("search-here"); + await expect(button).toBeVisible({ timeout: 20_000 }); + expect(requested.length, "a move of more than a screen waits to be asked").toBe(before); + + await button.click(); + await expect.poll(() => requested.length, { timeout: 20_000 }).toBeGreaterThan(before); + const view = await page.evaluate(() => window.__maposMap!.getCenter().toArray()); + expect(centre(requested.at(-1)!)[0]).toBeCloseTo(view[0]!, 1); + await expect(button).toHaveCount(0); + }); + + test("a place picked in search loads at the destination without asking", async ({ page }) => { + const requested = await recordPoiRequests(page); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/?layers=osm-poi&lng=13.3775&lat=49.7475&z=14"); + await expect.poll(() => requested.length, { timeout: 30_000 }).toBeGreaterThan(0); + + await page.getByTestId("place-search").fill("Praha"); + await page.getByRole("option", { name: /Praha/ }).first().click(); + // The request waits for the flight to land instead of reloading the view being left, so + // Prague's pins arrive by themselves. + await expect + .poll(() => requested.some((url) => Math.abs(centre(url)[0] - 14.42) < 0.1), { + timeout: 20_000 + }) + .toBe(true); + await settle(page, requested); + await expect(page.getByTestId("search-here")).toHaveCount(0); + }); + + test("with manual refresh a drag loads nothing until the reader asks", async ({ page }) => { + const requested = await recordPoiRequests(page); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/?layers=osm-poi&lng=13.3775&lat=49.7475&z=14"); + await expect.poll(() => requested.length, { timeout: 30_000 }).toBeGreaterThan(0); + + await page.getByTestId("settings-btn").click(); + const toggle = page.getByTestId("search-here-toggle"); + await toggle.click(); + await expect(toggle).toBeChecked(); + await page.keyboard.press("Escape"); + await settle(page, requested); + + const before = requested.length; + // Past the fetched tiles but under a screen: the automatic mode would refetch this by itself. + await dragMap(page, -700, 0); + const button = page.getByTestId("search-here"); + await expect(button).toBeVisible({ timeout: 20_000 }); + await page.waitForTimeout(800); + expect(requested.length).toBe(before); + + await button.click(); + await expect.poll(() => requested.length, { timeout: 20_000 }).toBeGreaterThan(before); await expect(button).toHaveCount(0); }); }); +async function recordPoiRequests(page: Page): Promise { + const requested: string[] = []; + await page.route("**/layers/osm-poi/features**", async (route) => { + requested.push(route.request().url()); + // Without this the browser answers the identical follow-up URL from its memory cache and + // the refetch never reaches this handler. + await route.fulfill({ json: EMPTY, headers: { "cache-control": "no-store" } }); + }); + return requested; +} + +/** Centre of the bbox a layer request asked for. */ +function centre(url: string): [number, number] { + const [w, s, e, n] = new URL(url).searchParams.get("bbox")!.split(",").map(Number); + return [(w! + e!) / 2, (s! + n!) / 2]; +} + +/** Waits until requests stop and the map is idle, so a count taken next means something. */ +async function settle(page: Page, requested: string[]) { + await expect + .poll( + async () => { + const count = requested.length; + await page.waitForTimeout(800); + return ( + requested.length === count && + (await page.evaluate(() => !window.__maposMap!.isMoving() && window.__maposMap!.loaded())) + ); + }, + { timeout: 30_000 } + ) + .toBe(true); +} + +/** A real pointer drag, which is what marks a move as the reader's own. */ +async function dragMap(page: Page, dx: number, dy: number) { + const box = (await page.locator(".maplibregl-canvas").boundingBox())!; + // Start near the edge the drag moves away from, so the whole gesture stays on screen. + const x = dx < 0 ? box.x + box.width - 40 : box.x + 40; + const y = box.y + box.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + dx, y + dy, { steps: 12 }); + // Stop before releasing so the drag ends without inertia carrying the map further. + await page.waitForTimeout(150); + await page.mouse.up(); + // Released over the side panel, MapLibre ends the drag on the next pointer move (as it does + // for a real mouse, which never stands perfectly still). + await page.mouse.move(x + dx + 2, y + dy + 2); +} + test("partial POI results render immediately and later sources appear without moving the map", async ({ page }) => { diff --git a/infra/.env.production.example b/infra/.env.production.example index 7994ff0..175b11a 100644 --- a/infra/.env.production.example +++ b/infra/.env.production.example @@ -1,4 +1,9 @@ POSTGRES_PASSWORD=change-me-in-production +# PostgreSQL sizing (docs/environment.md); defaults fit a small shared VPS. +# POSTGRES_SHARED_BUFFERS=512MB +# POSTGRES_EFFECTIVE_CACHE_SIZE=2GB +# POSTGRES_WORK_MEM=32MB +# POSTGRES_MAINTENANCE_WORK_MEM=256MB IMAGE_TAG=latest # The deploy script replaces this with the immutable release tag before startup. MAPOS_RELEASE=local-production diff --git a/infra/compose.production.yml b/infra/compose.production.yml index 9b38dc6..404f0e1 100644 --- a/infra/compose.production.yml +++ b/infra/compose.production.yml @@ -8,6 +8,25 @@ services: POSTGRES_DB: mapos POSTGRES_USER: mapos POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + # The image defaults (128 MB shared buffers, 4 MB work_mem, JIT on) made the catalogue and + # boundary queries spend more time planning, compiling and spilling than reading: JIT alone + # cost 1.8 s of a 4.2 s coverage query. Sizes are for a small shared VPS and can be raised + # per host; changing them restarts PostgreSQL once. + command: + - postgres + - -c + - jit=off + - -c + - shared_buffers=${POSTGRES_SHARED_BUFFERS:-512MB} + - -c + - effective_cache_size=${POSTGRES_EFFECTIVE_CACHE_SIZE:-2GB} + - -c + - work_mem=${POSTGRES_WORK_MEM:-32MB} + - -c + - maintenance_work_mem=${POSTGRES_MAINTENANCE_WORK_MEM:-256MB} + - -c + - random_page_cost=1.1 + shm_size: 256mb volumes: - mapos-v3-postgres:/var/lib/postgresql/data healthcheck: diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 3ce9e70..f59eae0 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -6,6 +6,20 @@ services: POSTGRES_DB: mapos POSTGRES_USER: mapos POSTGRES_PASSWORD: mapos + # Same planner settings as production, so a slow query shows up here first. + command: + [ + "postgres", + "-c", + "jit=off", + "-c", + "work_mem=32MB", + "-c", + "maintenance_work_mem=256MB", + "-c", + "random_page_cost=1.1" + ] + shm_size: 256mb ports: - "5434:5432" volumes: diff --git a/infra/nginx.conf b/infra/nginx.conf index 4ce8a06..372c129 100644 --- a/infra/nginx.conf +++ b/infra/nginx.conf @@ -20,6 +20,11 @@ server { # `style-src 'unsafe-inline'` covers React's style attributes (per-pin colours), not scripts. include /etc/nginx/security-headers.inc; + # Static files are gzipped at build time (Dockerfile); send the `.gz` to clients that accept + # it. API responses are compressed by the API itself. + gzip_static on; + gzip_vary on; + location /api/ { proxy_pass http://api:4033/; proxy_http_version 1.1; diff --git a/package-lock.json b/package-lock.json index 137e594..e2f4b11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,10 +17,12 @@ "concurrently": "^9.1.2", "eslint": "^10.9.1", "eslint-plugin-react-hooks": "^7.1.1", + "harfbuzzjs": "^0.10.3", "prettier": "^3.9.6", "subset-font": "^2.7.0", "typescript": "^5.8.3", - "typescript-eslint": "^8.68.0" + "typescript-eslint": "^8.68.0", + "wawoff2": "^2.0.1" } }, "apps/api": { @@ -114,7 +116,6 @@ "@mapos/adapter-sdk": "*", "@mapos/layer-sdk": "*", "@mapos/map-runtime": "*", - "@tanstack/react-query": "^5.80.7", "maplibre-gl": "^5.6.0", "pmtiles": "^4.5.0", "react": "^19.1.0", @@ -2729,32 +2730,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@tanstack/query-core": { - "version": "5.102.3", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.3.tgz", - "integrity": "sha512-5O2VEceonqC4uaTLUGglb0hgPouWCJ4K1ykVWyeV8aThhdNCzwpwu01bYoaRNJ9mgFUXS7Kf9utJ46ysT8m+bw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/react-query": { - "version": "5.102.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.3.tgz", - "integrity": "sha512-nHazxUEUQSGJOswGgSL2DI77f2K75WRCowDgaiyEi0ACocZTFKewTv+A/rJfq6QkuE35rVPff1QUT3ClbaePGQ==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.102.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^18 || ^19" - } - }, "node_modules/@tweenjs/tween.js": { "version": "23.1.3", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", diff --git a/package.json b/package.json index 2e9a929..3f8916c 100644 --- a/package.json +++ b/package.json @@ -56,9 +56,11 @@ "concurrently": "^9.1.2", "eslint": "^10.9.1", "eslint-plugin-react-hooks": "^7.1.1", + "harfbuzzjs": "^0.10.3", "prettier": "^3.9.6", "subset-font": "^2.7.0", "typescript": "^5.8.3", - "typescript-eslint": "^8.68.0" + "typescript-eslint": "^8.68.0", + "wawoff2": "^2.0.1" } } diff --git a/packages/adapter-sdk/src/index.ts b/packages/adapter-sdk/src/index.ts index 776ef76..54ad4cf 100644 --- a/packages/adapter-sdk/src/index.ts +++ b/packages/adapter-sdk/src/index.ts @@ -14,6 +14,7 @@ export * from "./builtins.js"; export * from "./pmtiles/pmtilesAdapter.js"; export * from "./stat/statSeries.js"; export * from "./stat/statDatasets.js"; +export * from "./stat/statCoverage.js"; export * from "./wms/capabilities.js"; export * from "./wms/wmsAdapter.js"; diff --git a/packages/adapter-sdk/src/stat/statCoverage.test.ts b/packages/adapter-sdk/src/stat/statCoverage.test.ts new file mode 100644 index 0000000..0d3e39c --- /dev/null +++ b/packages/adapter-sdk/src/stat/statCoverage.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { statCoverageRequest as coverageRequest } from "./statCoverage.js"; + +test("catalogue coverage is shared by neighbouring views in one zoom band", () => { + const prague = coverageRequest([14.31, 50.02, 14.55, 50.13], 11.4); + const pannedPrague = coverageRequest([14.36, 50.04, 14.6, 50.15], 10.2); + assert.deepEqual(prague, { bbox: [14.25, 50, 14.75, 50.25], zoom: 8 }); + assert.deepEqual(pannedPrague, prague, "a short pan reuses the computation"); + + // The band edges are the zooms at which the dataset choice changes. + assert.equal(coverageRequest(null, 7.99).zoom, 6); + assert.equal(coverageRequest(null, 4).zoom, 4); + assert.equal(coverageRequest(null, 2).zoom, 0); + assert.deepEqual(coverageRequest([-179.5, -89, 179.5, 89], 1).bbox, [-180, -90, 180, 90]); + assert.deepEqual(coverageRequest([12.1, 48.6, 18.9, 51.1], 6.5).bbox, [12, 48, 19, 52]); +}); diff --git a/packages/adapter-sdk/src/stat/statCoverage.ts b/packages/adapter-sdk/src/stat/statCoverage.ts new file mode 100644 index 0000000..9760015 --- /dev/null +++ b/packages/adapter-sdk/src/stat/statCoverage.ts @@ -0,0 +1,37 @@ +export type CoverageBbox = [number, number, number, number]; + +/** The zooms at which the statistical dataset choice changes (country → NUTS → LAU), each with a + * grid about a sixth of a typical screen at that zoom. */ +const ZOOM_BANDS = [ + { min: 8, grid: 0.25 }, + { min: 6, grid: 1 }, + { min: 4, grid: 2.5 }, + { min: 0, grid: 10 } +] as const; + +/** + * The catalogue coverage question a view asks: the zoom band the dataset choice follows and the + * view snapped outward to that band's grid. The server computes and caches coverage per answer, + * and the client asks with the same key, so neighbouring views share one computation and a pan + * back is answered from the browser cache. The snapped area can be slightly larger than the + * view, which only makes a coverage dot conservative. + */ +export function statCoverageRequest( + bbox: readonly number[] | null, + zoom = 0 +): { bbox: CoverageBbox | null; zoom: number } { + const band = ZOOM_BANDS.find((entry) => zoom >= entry.min) ?? ZOOM_BANDS[ZOOM_BANDS.length - 1]!; + if (!bbox || bbox.length !== 4) return { bbox: null, zoom: band.min }; + const snap = (value: number, round: (value: number) => number) => + Number((round(value / band.grid) * band.grid).toFixed(6)); + const [w, s, e, n] = bbox as CoverageBbox; + return { + bbox: [ + Math.max(-180, snap(w, Math.floor)), + Math.max(-90, snap(s, Math.floor)), + Math.min(180, snap(e, Math.ceil)), + Math.min(90, snap(n, Math.ceil)) + ], + zoom: band.min + }; +} diff --git a/packages/layer-sdk/package.json b/packages/layer-sdk/package.json index cd9a57f..e3b83b8 100644 --- a/packages/layer-sdk/package.json +++ b/packages/layer-sdk/package.json @@ -3,6 +3,7 @@ "version": "2.0.0", "private": true, "type": "module", + "sideEffects": false, "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/scripts/deploy-vps.sh b/scripts/deploy-vps.sh index 7ff1fc2..b8569ce 100755 --- a/scripts/deploy-vps.sh +++ b/scripts/deploy-vps.sh @@ -209,6 +209,8 @@ mapos_rollback() { set +e mapos_release_compose logs --since "${MAPOS_VERIFY_SINCE:-5m}" --tail 120 --no-color api web >&2 mapos_atomic_current "${MAPOS_PREVIOUS_TARGET}" || MAPOS_ROLLBACK_STATUS=1 + # A no-op unless this release changed the database settings; then the previous ones return. + mapos_current_compose up -d --no-build --wait --wait-timeout 180 postgres || MAPOS_ROLLBACK_STATUS=1 docker image tag "${MAPOS_OLD_API_IMAGE}" "${MAPOS_API_IMAGE}" || MAPOS_ROLLBACK_STATUS=1 docker image tag "${MAPOS_OLD_WEB_IMAGE}" "${MAPOS_WEB_IMAGE}" || MAPOS_ROLLBACK_STATUS=1 mapos_current_compose up -d --no-build --force-recreate --wait --wait-timeout 180 api web || @@ -497,6 +499,10 @@ MAPOS_VERIFY_SINCE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" set +e ( set -Eeuo pipefail + # PostgreSQL is recreated only when this release changes its settings (command, shared + # memory); otherwise Compose leaves the running container alone. Inside the rollback boundary, + # so a database that does not come back healthy restores the previous release. + mapos_release_compose up -d --no-build --wait --wait-timeout 180 postgres mapos_release_compose up -d --no-build --force-recreate --wait --wait-timeout 180 api web MAPOS_API_HEALTH="$(curl -fsS --max-time 10 "http://127.0.0.1:${MAPOS_API_PORT}/health")" @@ -560,6 +566,49 @@ if [[ "${MAPOS_ROLLOUT_STATUS}" -ne 0 ]]; then exit "${MAPOS_ROLLOUT_STATUS}" fi +# Housekeeping after a healthy rollout; none of it can fail the deploy. +set +e +# Bulk imports and restores leave tables without planner statistics (pg_restore carries none), +# and the planner then guessed 139 rows for a 242 000-row join. Analyse only tables that were +# never analysed or changed by more than a tenth since. +mapos_release_compose exec -T postgres psql -U mapos -d mapos -v ON_ERROR_STOP=1 -qc \ + "DO \$\$ DECLARE t record; BEGIN + FOR t IN SELECT schemaname, relname FROM pg_stat_user_tables + WHERE COALESCE(last_analyze, last_autoanalyze) IS NULL + OR n_mod_since_analyze > 0.1 * GREATEST(n_live_tup, 1000) + LOOP EXECUTE format('ANALYZE %I.%I', t.schemaname, t.relname); END LOOP; + END \$\$" &2 + +# A full disk broke releases on this shared host before. Keep the three newest releases and +# backups (never the live or the previous release), six rollback image tags per service, and +# drop dangling images and week-old build cache. +mapos_prune_dir() { + local MAPOS_PRUNE_ROOT="$1" MAPOS_PRUNE_ENTRY MAPOS_PRUNE_INDEX=0 + while IFS= read -r MAPOS_PRUNE_ENTRY; do + MAPOS_PRUNE_INDEX=$((MAPOS_PRUNE_INDEX + 1)) + [[ "${MAPOS_PRUNE_INDEX}" -le 3 ]] && continue + [[ "${MAPOS_PRUNE_ENTRY}" == "${MAPOS_TAG}" ]] && continue + [[ "releases/${MAPOS_PRUNE_ENTRY}" == "${MAPOS_PREVIOUS_TARGET}" ]] && continue + [[ "${MAPOS_REMOTE_DIR}/releases/${MAPOS_PRUNE_ENTRY}" == "${MAPOS_PREVIOUS_TARGET}" ]] && continue + [[ "${MAPOS_PRUNE_ENTRY}" =~ ^[A-Za-z0-9._-]+$ ]] || continue + rm -rf -- "${MAPOS_PRUNE_ROOT:?}/${MAPOS_PRUNE_ENTRY}" + done < <(ls -1t "${MAPOS_PRUNE_ROOT}") +} +mapos_prune_dir "${MAPOS_REMOTE_DIR}/releases" +mapos_prune_dir "${MAPOS_REMOTE_DIR}/backups" +for MAPOS_PRUNE_SERVICE in api web; do + docker image ls --format '{{.Tag}}' "${MAPOS_COMPOSE_PROJECT}-${MAPOS_PRUNE_SERVICE}" | + grep '^rollback-' | sort -r | tail -n +7 | + while IFS= read -r MAPOS_PRUNE_TAG; do + docker image rm "${MAPOS_COMPOSE_PROJECT}-${MAPOS_PRUNE_SERVICE}:${MAPOS_PRUNE_TAG}" >/dev/null + done +done +docker image prune -f >/dev/null +docker builder prune -f --filter until=168h >/dev/null +df -h "${MAPOS_REMOTE_DIR}" | tail -n 1 +set -e + mapos_release_compose ps echo "deploy: release ${MAPOS_TAG} is healthy; backup ${MAPOS_BACKUP_DIR} verified" MAPOS_REMOTE diff --git a/scripts/publish-staged-boundaries.mjs b/scripts/publish-staged-boundaries.mjs index f3daa11..cddaa75 100644 --- a/scripts/publish-staged-boundaries.mjs +++ b/scripts/publish-staged-boundaries.mjs @@ -94,6 +94,11 @@ try { throw error; } } + if (process.env.MAPOS_BOUNDARY_PUBLISH === "1") { + // Published rows replace whole editions; refresh planner statistics before traffic hits them. + const { analyzeTables } = await import(`${root}/db/analyze.js`); + await analyzeTables(["geo_units", "geo_unit_versions", "geo_unit_releases"]); + } } finally { await stage.end(); await sql.end(); diff --git a/scripts/subset-icon-font.mjs b/scripts/subset-icon-font.mjs index 53cda44..5e36958 100644 --- a/scripts/subset-icon-font.mjs +++ b/scripts/subset-icon-font.mjs @@ -4,9 +4,15 @@ * * The full variable font is 5.1 MB and even the wght-only axis build is 939 kB, which is * more than the rest of the app's JavaScript. The icons are reached by ligature (the text - * "my_location" is substituted for one glyph), so the subset has to keep the latin letters - * and underscore that spell the names plus the GSUB ligature table — harfbuzz retains - * layout features by default, so passing the names as text is enough. + * "my_location" is substituted for one glyph), so the subset keeps the latin letters, + * digits and underscore that spell the names plus the GSUB ligature table. + * + * Passing only the names as text is not enough: the subsetter's layout closure follows every + * ligature those letters can form, which is every icon in the font (6,000+ glyphs, 367 kB). + * Instead each name is shaped once to find its glyph and that glyph's private-use codepoint; + * the subset keeps exactly those codepoints plus the letters, with layout closure off, so + * only ligatures whose result survives stay in GSUB. Every name is shaped again against the + * result and the script fails if one no longer turns into a single icon glyph. * * Run via `npm run icons` after editing the icon list. The output is committed so a plain * `npm install && npm run build` never needs harfbuzz. @@ -15,6 +21,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import subsetFont from "subset-font"; +import wawoff2 from "wawoff2"; const here = dirname(fileURLToPath(import.meta.url)); const repo = resolve(here, ".."); @@ -37,15 +44,55 @@ async function readIconNames() { return names; } +/** Shapes text with HarfBuzz and returns the glyph ids it produced. */ +function shaper(hb, ttf) { + const blob = hb.createBlob(ttf); + const face = hb.createFace(blob, 0); + const font = hb.createFont(face); + const shape = (text) => { + const buffer = hb.createBuffer(); + buffer.addText(text); + buffer.guessSegmentProperties(); + hb.shape(font, buffer); + const glyphs = buffer.json().map((glyph) => glyph.g); + buffer.destroy(); + return glyphs; + }; + const unicodes = face.collectUnicodes(); + const destroy = () => { + font.destroy(); + face.destroy(); + blob.destroy(); + }; + return { shape, unicodes, destroy }; +} + const names = await readIconNames(); const source = await readFile(SOURCE); +const hb = await (await import("harfbuzzjs")).default; +const full = shaper(hb, Buffer.from(await wawoff2.decompress(source))); + +// Glyph → private-use codepoint, so an icon can be kept by codepoint instead of by closure. +const codepointOf = new Map(); +for (const codepoint of full.unicodes) { + if (codepoint < 0xe000) continue; + const [glyph] = full.shape(String.fromCodePoint(codepoint)); + if (glyph && !codepointOf.has(glyph)) codepointOf.set(glyph, codepoint); +} +const iconCodepoints = names.map((name) => { + const glyphs = full.shape(name); + const codepoint = glyphs.length === 1 ? codepointOf.get(glyphs[0]) : undefined; + if (codepoint === undefined) throw new Error(`"${name}" is not a single ligature in the font`); + return codepoint; +}); +full.destroy(); -// Ligature lookup needs every character that spells a name, and the subsetter needs them as -// a flat character set. Joining with a separator keeps the intent readable in a stack trace. -const text = names.join(" "); +const letters = [...new Set(names.join(""))].join(""); +const text = letters + String.fromCodePoint(...new Set(iconCodepoints)); const subset = await subsetFont(source, text, { targetFormat: "woff2", + noLayoutClosure: true, variationAxes: { // Keep FILL and wght variable so the CSS can animate a selected icon to filled and // match the surrounding text weight. opsz and GRAD are pinned; nothing uses them. @@ -56,6 +103,14 @@ const subset = await subsetFont(source, text, { } }); +const check = shaper(hb, Buffer.from(await wawoff2.decompress(subset))); +const broken = names.filter((name) => { + const glyphs = check.shape(name); + return glyphs.length !== 1 || glyphs[0] === 0; +}); +check.destroy(); +if (broken.length) throw new Error(`Ligatures lost in the subset: ${broken.join(", ")}`); + await mkdir(dirname(OUT), { recursive: true }); await writeFile(OUT, subset);