export type MapSource = "mapy.cz" | "google-maps" | "coordinates" | "geocoded" | "unknown"; export interface MapCoordinates { latitude: number; longitude: number; zoom?: number; address?: string; source: MapSource; street?: string; houseNumber?: string; city?: string; zip?: string; country?: string; } export interface MapTileStyle { id: string; name: string; description: string; url: string; attribution: string; tileClassName?: string; } export const DEFAULT_MAP_STYLE_ID = "bookra-voyager"; export const MAP_TILE_STYLES: readonly MapTileStyle[] = [ { id: "bookra-voyager", name: "Bookra Voyager", description: "Warm, calm default that fits Bookra surfaces.", url: "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", attribution: "© OpenStreetMap contributors © CARTO", tileClassName: "bookra-map-tiles-warm", }, { id: "light", name: "Clean Light", description: "Quiet light basemap with high readability.", url: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", attribution: "© OpenStreetMap contributors © CARTO", }, { id: "dark", name: "Dark Matter", description: "Dark basemap for dark sections and evening brands.", url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", attribution: "© OpenStreetMap contributors © CARTO", }, { id: "voyager", name: "Voyager", description: "Balanced color and street detail.", url: "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", attribution: "© OpenStreetMap contributors © CARTO", }, { id: "openstreetmap", name: "OpenStreetMap", description: "Standard OSM tiles.", url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", attribution: "© OpenStreetMap contributors", }, { id: "satellite", name: "Satellite", description: "Esri satellite imagery.", url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", attribution: "Tiles © Esri", }, ]; export const MAP_STYLE_OPTIONS = [ ...MAP_TILE_STYLES.map((style) => ({ value: style.id, label: style.name, })), { value: "custom", label: "Custom tile URL", }, ]; export function mapStyleById(styleId: string | undefined): MapTileStyle { return MAP_TILE_STYLES.find((style) => style.id === styleId) ?? MAP_TILE_STYLES[0]; } export function resolveMapTileStyle(styleId: string | undefined, customTileUrl?: string): MapTileStyle { const trimmedUrl = customTileUrl?.trim(); if (styleId === "custom" && trimmedUrl) { return { id: "custom", name: "Custom", description: "User-provided tile URL.", url: trimmedUrl, attribution: "© OpenStreetMap contributors", }; } return mapStyleById(styleId); } export function validateCoordinates(lat: number, lng: number): boolean { return ( Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180 ); } export function parseCoordinateText(input: string): MapCoordinates | null { const match = input .trim() .match(/^\s*(-?\d+(?:\.\d+)?)\s*[,;\s]\s*(-?\d+(?:\.\d+)?)\s*(?:[,;\s]\s*(\d{1,2}))?\s*$/); if (!match) return null; const latitude = Number(match[1]); const longitude = Number(match[2]); const zoom = match[3] ? Number(match[3]) : undefined; if (!validateCoordinates(latitude, longitude)) return null; return { latitude, longitude, zoom: Number.isFinite(zoom) ? zoom : undefined, source: "coordinates", }; } export function parseMapyCzUrl(url: string): MapCoordinates | null { try { const urlObj = new URL(url); if (!urlObj.hostname.includes("mapy.cz") && !urlObj.hostname.includes("mapy.com")) { return null; } const longitude = Number(urlObj.searchParams.get("x")); const latitude = Number(urlObj.searchParams.get("y")); const zoom = Number(urlObj.searchParams.get("z")); const address = urlObj.searchParams.get("q") ?? undefined; if (!validateCoordinates(latitude, longitude)) return null; return { latitude, longitude, zoom: Number.isFinite(zoom) ? zoom : undefined, address: address ? decodeURIComponent(address) : undefined, source: "mapy.cz", }; } catch { return null; } } export function parseGoogleMapsUrl(url: string): MapCoordinates | null { try { const urlObj = new URL(url); if ( !urlObj.hostname.includes("google.") && !urlObj.hostname.includes("goo.gl") && !urlObj.hostname.includes("maps.app.goo.gl") ) { return null; } const pathMatch = urlObj.href.match(/@(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?),(\d+)(?:[mz])/); if (pathMatch) { const latitude = Number(pathMatch[1]); const longitude = Number(pathMatch[2]); const zoom = Number(pathMatch[3]); if (validateCoordinates(latitude, longitude)) { const placeMatch = urlObj.pathname.match(/\/place\/([^/]+)/); return { latitude, longitude, zoom: Number.isFinite(zoom) ? zoom : undefined, address: placeMatch ? decodeURIComponent(placeMatch[1].replace(/\+/g, " ")) : undefined, source: "google-maps", }; } } const qParam = urlObj.searchParams.get("q"); if (qParam) { const coordinateResult = parseCoordinateText(qParam); if (coordinateResult) { return { ...coordinateResult, source: "google-maps", }; } } const dataMatch = urlObj.href.match(/!3d(-?\d+(?:\.\d+)?)!4d(-?\d+(?:\.\d+)?)/); if (dataMatch) { const latitude = Number(dataMatch[1]); const longitude = Number(dataMatch[2]); if (validateCoordinates(latitude, longitude)) { return { latitude, longitude, source: "google-maps", }; } } return null; } catch { return null; } } export function parseMapUrl(input: string): MapCoordinates | null { if (!input.trim()) return null; let normalizedUrl = input.trim(); if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) { normalizedUrl = `https://${normalizedUrl}`; } return parseMapyCzUrl(normalizedUrl) ?? parseGoogleMapsUrl(normalizedUrl); } export async function geocodeLocation(query: string, signal?: AbortSignal): Promise { const trimmed = query.trim(); if (!trimmed) return null; const response = await fetch( `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&addressdetails=1&accept-language=cs,en&q=${encodeURIComponent(trimmed)}`, { signal }, ); if (!response.ok) { throw new Error("Location search failed"); } const results = (await response.json()) as Array<{ lat?: string; lon?: string; display_name?: string; address?: { road?: string; street?: string; pedestrian?: string; footway?: string; house_number?: string; city?: string; town?: string; village?: string; municipality?: string; postcode?: string; country?: string; }; }>; const first = results[0]; if (!first) return null; const latitude = Number(first.lat); const longitude = Number(first.lon); if (!validateCoordinates(latitude, longitude)) return null; const address = first.address ?? {}; return { latitude, longitude, zoom: 16, address: first.display_name ?? trimmed, source: "geocoded", street: address.road ?? address.street ?? address.pedestrian ?? address.footway, houseNumber: address.house_number, city: address.city ?? address.town ?? address.village ?? address.municipality, zip: address.postcode, country: address.country, }; } export async function reverseGeocode(lat: number, lng: number, signal?: AbortSignal): Promise> { if (!validateCoordinates(lat, lng)) return {}; const response = await fetch( `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lng}&addressdetails=1&accept-language=cs,en`, { signal }, ); if (!response.ok) return {}; const data = (await response.json()) as { display_name?: string; address?: { road?: string; street?: string; pedestrian?: string; footway?: string; house_number?: string; city?: string; town?: string; village?: string; municipality?: string; postcode?: string; country?: string; }; }; const address = data.address ?? {}; return { address: data.display_name, street: address.road ?? address.street ?? address.pedestrian ?? address.footway, houseNumber: address.house_number, city: address.city ?? address.town ?? address.village ?? address.municipality, zip: address.postcode, country: address.country, }; } export async function resolveLocationInput(input: string, signal?: AbortSignal): Promise { const coordinateResult = parseCoordinateText(input); if (coordinateResult) { const reverse = await reverseGeocode(coordinateResult.latitude, coordinateResult.longitude, signal).catch(() => ({})); return { ...coordinateResult, ...reverse, }; } const urlResult = parseMapUrl(input); if (urlResult) { const reverse = urlResult.address ? {} : await reverseGeocode(urlResult.latitude, urlResult.longitude, signal).catch(() => ({})); return { ...urlResult, ...reverse, }; } return geocodeLocation(input, signal); }