mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Explore places on the map, planner route fixes, and instance-wide Mapbox (#1147)
* feat(maps): add an OSM POI search endpoint (category within a viewport) New /api/maps/pois queries OpenStreetMap via Overpass for places of a category (restaurants, cafes, hotels, sights, …) inside a bounding box. OSM-only by design — it never calls Google, even when a Google key is configured. * feat(map): explore nearby places on the trip map (OSM category pill) A floating, icon-only pill over the planner map lets you toggle a POI category and see those OpenStreetMap places in the current view; clicking a marker opens the add-place form pre-filled (name, address, website, phone). Single-select with a 'search this area' action after the map moves. Renders on both the Leaflet and Mapbox maps, and can be turned off in settings (discussion #841). * fix(planner): anchor timed places when optimising and route transports by location - The day optimiser no longer reshuffles places that have a set time — they stay anchored to their time, like locked places. - The route now uses a transport's departure/arrival location as a waypoint when it has one (e.g. a flight's airport), instead of breaking the route at every booking; transports without a location are ignored for routing but still show their leg's distance/duration under the booking. * feat(admin): instance-wide Mapbox defaults in default user settings Admins can set a shared Mapbox token (plus style, 3D and quality) as instance defaults, so the whole instance can use Mapbox without each user pasting their own key. Users without their own value inherit it via the existing admin-defaults merge; the shared token is stored encrypted (discussion #920).
This commit is contained in:
@@ -67,6 +67,27 @@ export class MapsController {
|
||||
}
|
||||
}
|
||||
|
||||
// OSM-only POI explore: places of a category within the current map viewport.
|
||||
@Get('pois')
|
||||
async pois(
|
||||
@Query('category') category?: string,
|
||||
@Query('south') south?: string,
|
||||
@Query('west') west?: string,
|
||||
@Query('north') north?: string,
|
||||
@Query('east') east?: string,
|
||||
) {
|
||||
if (!category) throw new HttpException({ error: 'A category is required' }, 400);
|
||||
const bbox = { south: Number(south), west: Number(west), north: Number(north), east: Number(east) };
|
||||
if (Object.values(bbox).some(v => !Number.isFinite(v))) {
|
||||
throw new HttpException({ error: 'A valid bbox (south, west, north, east) is required' }, 400);
|
||||
}
|
||||
try {
|
||||
return await this.maps.pois(category, bbox);
|
||||
} catch (err: unknown) {
|
||||
throw toHttpException(err, 'POI search error', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('autocomplete')
|
||||
@HttpCode(200)
|
||||
async autocomplete(
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getPlacePhoto,
|
||||
reverseGeocode,
|
||||
resolveGoogleMapsUrl,
|
||||
searchOverpassPois,
|
||||
} from '../../services/mapsService';
|
||||
import { serveFilePath } from '../../services/placePhotoCache';
|
||||
|
||||
@@ -86,4 +87,9 @@ export class MapsService {
|
||||
resolveUrl(url: string): Promise<MapsResolveUrlResult> {
|
||||
return resolveGoogleMapsUrl(url) as Promise<MapsResolveUrlResult>;
|
||||
}
|
||||
|
||||
// OSM-only POI search by category within a viewport bbox (never calls Google).
|
||||
pois(category: string, bbox: { south: number; west: number; north: number; east: number }) {
|
||||
return searchOverpassPois(category, bbox);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +204,114 @@ export async function fetchOverpassDetails(osmType: string, osmId: string): Prom
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ── Overpass POI search (by category within a viewport bbox) ─────────────────
|
||||
// Powers the "explore places on the map" pill. OSM-ONLY by design — this never
|
||||
// calls Google, even when a Google key is configured.
|
||||
|
||||
export interface OverpassPoi {
|
||||
osm_id: string; // 'node:123' | 'way:123' | 'relation:123' (matches the placeId format elsewhere)
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
category: string; // the requested pill category key, e.g. 'restaurant'
|
||||
poi_type: string; // the raw OSM tag that matched, e.g. 'amenity=restaurant'
|
||||
address: string | null;
|
||||
website: string | null;
|
||||
phone: string | null;
|
||||
opening_hours: string | null;
|
||||
cuisine: string | null;
|
||||
source: 'openstreetmap';
|
||||
}
|
||||
|
||||
// Each pill category → the OSM tag selectors it searches. Keys here are the
|
||||
// contract with the client's POI_CATEGORIES (same keys, label/icon/colour live
|
||||
// client-side).
|
||||
const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
|
||||
restaurant: ['amenity=restaurant', 'amenity=fast_food'],
|
||||
cafe: ['amenity=cafe'],
|
||||
bar: ['amenity=bar', 'amenity=pub', 'amenity=nightclub'],
|
||||
hotel: ['tourism=hotel', 'tourism=hostel', 'tourism=guest_house', 'tourism=apartment', 'tourism=motel'],
|
||||
sights: ['tourism=attraction', 'tourism=viewpoint', 'historic=monument', 'historic=castle', 'historic=memorial', 'historic=ruins'],
|
||||
museum: ['tourism=museum', 'tourism=gallery', 'tourism=artwork', 'amenity=theatre'],
|
||||
nature: ['leisure=park', 'leisure=garden', 'natural=beach', 'natural=peak'],
|
||||
activity: ['tourism=theme_park', 'tourism=zoo', 'tourism=aquarium', 'leisure=water_park'],
|
||||
shopping: ['shop=mall', 'shop=department_store', 'amenity=marketplace'],
|
||||
supermarket: ['shop=supermarket', 'shop=convenience'],
|
||||
};
|
||||
|
||||
export const POI_CATEGORY_KEYS = Object.keys(CATEGORY_OSM_FILTERS);
|
||||
|
||||
interface OverpassPoiElement {
|
||||
type: string;
|
||||
id: number;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
center?: { lat: number; lon: number };
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function searchOverpassPois(
|
||||
category: string,
|
||||
bbox: { south: number; west: number; north: number; east: number },
|
||||
limit = 60,
|
||||
): Promise<{ pois: OverpassPoi[]; source: 'openstreetmap'; truncated: boolean }> {
|
||||
const filters = CATEGORY_OSM_FILTERS[category];
|
||||
if (!filters) throw Object.assign(new Error('Unknown POI category'), { status: 400 });
|
||||
|
||||
// Overpass wants the box as (south,west,north,east) = (minLat,minLng,maxLat,maxLng).
|
||||
const box = `(${bbox.south},${bbox.west},${bbox.north},${bbox.east})`;
|
||||
const selectors = filters.map(f => {
|
||||
const [k, v] = f.split('=');
|
||||
return ` nwr["${k}"="${v}"]${box};`;
|
||||
}).join('\n');
|
||||
// `out center tags <n>` returns ways/relations with a computed center and caps
|
||||
// the result count in one round-trip.
|
||||
const query = `[out:json][timeout:25];\n(\n${selectors}\n);\nout center tags ${limit + 25};`;
|
||||
|
||||
let elements: OverpassPoiElement[] = [];
|
||||
try {
|
||||
const res = await fetch('https://overpass-api.de/api/interpreter', {
|
||||
method: 'POST',
|
||||
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `data=${encodeURIComponent(query)}`,
|
||||
});
|
||||
if (!res.ok) throw Object.assign(new Error('Overpass request failed'), { status: 502 });
|
||||
const data = await res.json() as { elements?: OverpassPoiElement[] };
|
||||
elements = data.elements || [];
|
||||
} catch (err: any) {
|
||||
if (err?.status) throw err;
|
||||
throw Object.assign(new Error('Overpass request failed'), { status: 502 });
|
||||
}
|
||||
|
||||
const pois: OverpassPoi[] = [];
|
||||
for (const el of elements) {
|
||||
const tags = el.tags || {};
|
||||
const name = tags.name || tags['name:en'] || tags.brand || null;
|
||||
if (!name) continue; // unnamed POIs aren't useful to add to a plan
|
||||
const lat = el.lat ?? el.center?.lat;
|
||||
const lng = el.lon ?? el.center?.lon;
|
||||
if (lat == null || lng == null) continue;
|
||||
const matched = filters.find(f => { const [k, v] = f.split('='); return tags[k] === v; }) || filters[0];
|
||||
const addr = [tags['addr:street'], tags['addr:housenumber'], tags['addr:postcode'], tags['addr:city']].filter(Boolean).join(' ') || null;
|
||||
pois.push({
|
||||
osm_id: `${el.type}:${el.id}`,
|
||||
name,
|
||||
lat,
|
||||
lng,
|
||||
category,
|
||||
poi_type: matched,
|
||||
address: addr,
|
||||
website: tags.website || tags['contact:website'] || null,
|
||||
phone: tags.phone || tags['contact:phone'] || null,
|
||||
opening_hours: tags.opening_hours || null,
|
||||
cuisine: tags.cuisine || null,
|
||||
source: 'openstreetmap',
|
||||
});
|
||||
}
|
||||
const truncated = pois.length > limit;
|
||||
return { pois: pois.slice(0, limit), source: 'openstreetmap', truncated };
|
||||
}
|
||||
|
||||
// ── Opening hours parsing ────────────────────────────────────────────────────
|
||||
|
||||
export function parseOpeningHours(ohString: string): { weekdayDescriptions: string[]; openNow: boolean | null } {
|
||||
|
||||
@@ -12,6 +12,13 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
|
||||
'time_format',
|
||||
'blur_booking_codes',
|
||||
'map_tile_url',
|
||||
// Instance-wide Mapbox defaults: an admin can set a shared token + style so the
|
||||
// whole instance uses Mapbox without each user pasting their own key (#920).
|
||||
'map_provider',
|
||||
'mapbox_access_token',
|
||||
'mapbox_style',
|
||||
'mapbox_3d_enabled',
|
||||
'mapbox_quality_mode',
|
||||
] as const;
|
||||
|
||||
type DefaultableKey = typeof DEFAULTABLE_USER_SETTING_KEYS[number];
|
||||
@@ -20,9 +27,10 @@ const VALID_VALUES: Partial<Record<DefaultableKey, unknown[]>> = {
|
||||
temperature_unit: ['fahrenheit', 'celsius'],
|
||||
time_format: ['12h', '24h'],
|
||||
dark_mode: [true, false, 'light', 'dark', 'auto'],
|
||||
map_provider: ['leaflet', 'mapbox-gl'],
|
||||
};
|
||||
|
||||
const BOOLEAN_KEYS = new Set<DefaultableKey>(['blur_booking_codes']);
|
||||
const BOOLEAN_KEYS = new Set<DefaultableKey>(['blur_booking_codes', 'mapbox_3d_enabled', 'mapbox_quality_mode']);
|
||||
|
||||
function parseValue(raw: string): unknown {
|
||||
try { return JSON.parse(raw); } catch { return raw; }
|
||||
@@ -35,7 +43,11 @@ export function getAdminUserDefaults(): Record<string, unknown> {
|
||||
const defaults: Record<string, unknown> = {};
|
||||
for (const row of rows) {
|
||||
const settingKey = row.key.slice('default_user_setting_'.length);
|
||||
defaults[settingKey] = parseValue(row.value);
|
||||
if (ENCRYPTED_SETTING_KEYS.has(settingKey)) {
|
||||
defaults[settingKey] = row.value ? (decrypt_api_key(row.value) ?? '') : '';
|
||||
} else {
|
||||
defaults[settingKey] = parseValue(row.value);
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
@@ -70,7 +82,12 @@ export function setAdminUserDefaults(partial: Record<string, unknown>): void {
|
||||
throw new Error(`Invalid value for ${key}: ${value}`);
|
||||
}
|
||||
|
||||
upsert.run(appKey, JSON.stringify(value));
|
||||
// Encrypt sensitive defaults (the shared Mapbox token) at rest, like the
|
||||
// per-user equivalents; everything else is stored as plain JSON.
|
||||
const stored = ENCRYPTED_SETTING_KEYS.has(key)
|
||||
? (maybe_encrypt_api_key(String(value)) ?? String(value))
|
||||
: JSON.stringify(value);
|
||||
upsert.run(appKey, stored);
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user