Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions apps/api/src/data/offlineGazetteer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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);
}
21 changes: 21 additions & 0 deletions apps/api/src/db/analyze.ts
Original file line number Diff line number Diff line change
@@ -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));
}
}
}
6 changes: 5 additions & 1 deletion apps/api/src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/geo/importGeoUnits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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));
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/geo/importLauCorrespondence.ts
Original file line number Diff line number Diff line change
@@ -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): {
Expand Down Expand Up @@ -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;
Expand Down
99 changes: 60 additions & 39 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -71,6 +72,8 @@ import {
} from "./services/socialService.js";
import { mapyGeocode, mapySuggest } from "./services/mapyService.js";
import {
nearViewbox,
parseNearPoint,
presentMapyGeocodeResult,
presentNominatimGeocodeResult,
type NominatimGeocodeItem
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<NominatimGeocodeItem[]>(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<NominatimGeocodeItem[]>(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);
Expand Down Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions apps/api/src/memory-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down
9 changes: 8 additions & 1 deletion apps/api/src/routes/discoverBoundaryRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,19 @@ export function registerDiscoverBoundaryRoutes(
);
let metadata: { value: unknown; expires: number } | undefined;
let metadataFlight: Promise<unknown> | 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,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/routes/themeRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/services/geocodePresentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
geocodeConfidence,
nearViewbox,
parseNearPoint,
presentMapyGeocodeResult,
presentNominatimGeocodeResult
} from "./geocodePresentation.js";
Expand Down Expand Up @@ -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");
});
28 changes: 28 additions & 0 deletions apps/api/src/services/geocodePresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(",");
}
16 changes: 14 additions & 2 deletions apps/api/src/services/mapyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MapyGeocodeItem[]> {
const data = await mapyJson<MapyItemsResponse>("/v1/geocode", { query, lang, limit }, signal);
const data = await mapyJson<MapyItemsResponse>(
"/v1/geocode",
{
query,
lang,
limit,
preferNear: preferNear ? `${preferNear[0]},${preferNear[1]}` : undefined
},
signal
);
return normalizeItems(data);
}

Expand Down
Loading
Loading