diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/frontend/app/components/shared/AppMap.tsx | 9 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/en-GB.json | 3 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/es-ES.json | 3 | ||||
| -rw-r--r-- | src/frontend/app/i18n/locales/gl-ES.json | 3 | ||||
| -rw-r--r-- | src/frontend/app/maps/styleloader.ts | 92 | ||||
| -rw-r--r-- | src/frontend/app/routes/map.tsx | 87 |
6 files changed, 182 insertions, 15 deletions
diff --git a/src/frontend/app/components/shared/AppMap.tsx b/src/frontend/app/components/shared/AppMap.tsx index 2c8d097..c6eb8ee 100644 --- a/src/frontend/app/components/shared/AppMap.tsx +++ b/src/frontend/app/components/shared/AppMap.tsx @@ -15,6 +15,7 @@ import Map, { type MapRef, type StyleSpecification, } from "react-map-gl/maplibre"; +import { useTranslation } from "react-i18next"; import { useLocation } from "react-router"; import { useApp } from "~/AppContext"; import { APP_CONSTANTS } from "~/config/constants"; @@ -82,6 +83,7 @@ export const AppMap = forwardRef<MapRef, AppMapProps>( showCameras: settingsShowCameras, mapPositionMode, } = useApp(); + const { i18n } = useTranslation(); const mapRef = useRef<MapRef>(null); const [mapStyle, setMapStyle] = useState<StyleSpecification>(DEFAULT_STYLE); const location = useLocation(); @@ -96,10 +98,13 @@ export const AppMap = forwardRef<MapRef, AppMapProps>( useImperativeHandle(ref, () => mapRef.current!); useEffect(() => { - loadStyle("openfreemap", theme, { includeTraffic: showTraffic }) + loadStyle("openfreemap", theme, { + includeTraffic: showTraffic, + language: i18n.language, + }) .then((style) => setMapStyle(style)) .catch((error) => console.error("Failed to load map style:", error)); - }, [theme, showTraffic]); + }, [theme, showTraffic, i18n.language]); useEffect(() => { const handleMapChange = () => { diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json index 6cb939d..3d8b32f 100644 --- a/src/frontend/app/i18n/locales/en-GB.json +++ b/src/frontend/app/i18n/locales/en-GB.json @@ -102,7 +102,8 @@ "map": { "popup_title": "Stop", "lines": "Lines", - "view_all_estimates": "View all estimates" + "view_all_estimates": "View all estimates", + "select_nearby_stop": "Select stop" }, "planner": { "where_to": "Where do you want to go?", diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json index 5334fe1..2184cfc 100644 --- a/src/frontend/app/i18n/locales/es-ES.json +++ b/src/frontend/app/i18n/locales/es-ES.json @@ -102,7 +102,8 @@ "map": { "popup_title": "Parada", "lines": "Líneas", - "view_all_estimates": "Ver todas las estimaciones" + "view_all_estimates": "Ver todas las estimaciones", + "select_nearby_stop": "Seleccionar parada" }, "planner": { "where_to": "¿A donde quieres ir?", diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json index 132ab0b..b951278 100644 --- a/src/frontend/app/i18n/locales/gl-ES.json +++ b/src/frontend/app/i18n/locales/gl-ES.json @@ -106,7 +106,8 @@ "map": { "popup_title": "Parada", "lines": "Liñas", - "view_all_estimates": "Ver todas as estimacións" + "view_all_estimates": "Ver todas as estimacións", + "select_nearby_stop": "Seleccionar parada" }, "planner": { "where_to": "Onde queres ir?", diff --git a/src/frontend/app/maps/styleloader.ts b/src/frontend/app/maps/styleloader.ts index 7d90116..62ab2fc 100644 --- a/src/frontend/app/maps/styleloader.ts +++ b/src/frontend/app/maps/styleloader.ts @@ -3,6 +3,7 @@ import type { Theme } from "~/AppContext"; export interface StyleLoaderOptions { includeTraffic?: boolean; + language?: string; } export const DEFAULT_STYLE: StyleSpecification = { @@ -13,19 +14,76 @@ export const DEFAULT_STYLE: StyleSpecification = { layers: [], }; +/** + * Builds a MapLibre text-field expression that prefers the given language. + */ +function buildLanguageTextField(language: string): unknown[] { + const lang = language.toLowerCase().split("-")[0]; + switch (lang) { + case "es": + return [ + "coalesce", + ["get", "name:es"], + ["get", "name:latin"], + ["get", "name"], + ]; + case "gl": + return [ + "coalesce", + ["get", "name:gl"], + ["get", "name:es"], + ["get", "name:latin"], + ["get", "name"], + ]; + case "en": + return [ + "coalesce", + ["get", "name:en"], + ["get", "name_en"], + ["get", "name:latin"], + ["get", "name"], + ]; + default: + return ["coalesce", ["get", "name:latin"], ["get", "name"]]; + } +} + +/** + * Returns true for text-field expressions that encode multi-language name + * logic (they reference name:latin or name_en). These are the label layers + * produced by OpenMapTiles / OpenFreeMap that need localisation. + */ +function isMultiLanguageTextField(textField: unknown): boolean { + if (!Array.isArray(textField)) return false; + const str = JSON.stringify(textField); + return str.includes('"name:latin"') || str.includes('"name_en"'); +} + +/** + * Mutates the loaded style to replace multi-language label expressions with + * a localised version appropriate for the given language code. + */ +function applyLanguageToStyle(style: any, language: string): void { + const newTextField = buildLanguageTextField(language); + for (const layer of style.layers ?? []) { + if ( + layer.layout?.["text-field"] && + isMultiLanguageTextField(layer.layout["text-field"]) + ) { + layer.layout["text-field"] = newTextField; + } + } +} + export async function loadStyle( styleName: string, colorScheme: Theme, options?: StyleLoaderOptions ): Promise<StyleSpecification> { - const { includeTraffic = true } = options || {}; + const { includeTraffic = true, language } = options || {}; - if (colorScheme == "system") { - const isDarkMode = window.matchMedia( - "(prefers-color-scheme: dark)" - ).matches; - colorScheme = isDarkMode ? "dark" : "light"; - } + // Always use the light style as the single canonical base style. + colorScheme = "light"; if (styleName == "openfreemap") { const url = `/maps/styles/openfreemap-${colorScheme}.json`; @@ -45,6 +103,16 @@ export async function loadStyle( delete style.sources?.vigo_traffic; } + // Remove the pseudo-3D building-top layer (fill-translate shadow effect). + style.layers = (style.layers || []).filter( + (layer: any) => layer.id !== "building-top" + ); + + // Apply language-aware label expressions. + if (language) { + applyLanguageToStyle(style, language); + } + return style as StyleSpecification; } @@ -106,5 +174,15 @@ export async function loadStyle( } } + // Remove the pseudo-3D building-top layer. + style.layers = (style.layers || []).filter( + (layer: any) => layer.id !== "building-top" + ); + + // Apply language-aware label expressions. + if (language) { + applyLanguageToStyle(style, language); + } + return style as StyleSpecification; } diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx index 2686222..af94509 100644 --- a/src/frontend/app/routes/map.tsx +++ b/src/frontend/app/routes/map.tsx @@ -1,4 +1,4 @@ -import { Check, X } from "lucide-react"; +import { Check, MapPin, X } from "lucide-react"; import type { FilterSpecification } from "maplibre-gl"; import { useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -37,6 +37,9 @@ export default function StopMap() { StopSheetProps["stop"] | null >(null); const [isSheetOpen, setIsSheetOpen] = useState(false); + const [disambiguationStops, setDisambiguationStops] = useState< + Array<StopSheetProps["stop"]> + >([]); const mapRef = useRef<MapRef>(null); const { @@ -105,9 +108,42 @@ export default function StopMap() { ); return; } - const feature = features[0]; - handlePointClick(feature); + // Collect only stop-layer features with valid properties + const stopFeatures = features.filter( + (f) => f.layer?.id?.startsWith("stops") && f.properties?.id + ); + + if (stopFeatures.length === 0) return; + + if (stopFeatures.length === 1) { + // Single unambiguous stop – open the sheet directly + handlePointClick(stopFeatures[0]); + return; + } + + // Multiple overlapping stops – deduplicate by stop id and ask the user + const seen = new Set<string>(); + const candidates: Array<StopSheetProps["stop"]> = []; + for (const f of stopFeatures) { + const id: string = f.properties!.id; + if (!seen.has(id)) { + seen.add(id); + candidates.push({ + stopId: id, + stopCode: f.properties!.code, + name: f.properties!.name || "Unknown Stop", + }); + } + } + + if (candidates.length === 1) { + // After deduplication only one stop remains + setSelectedStop(candidates[0]); + setIsSheetOpen(true); + } else { + setDisambiguationStops(candidates); + } }; const stopLayerFilter = useMemo(() => { @@ -350,6 +386,51 @@ export default function StopMap() { stop={selectedStop} /> )} + + {disambiguationStops.length > 1 && ( + <div className="fixed inset-x-0 bottom-0 z-30 flex justify-center pointer-events-none pb-safe"> + <div className="pointer-events-auto w-full max-w-md bg-white dark:bg-slate-900 rounded-t-2xl shadow-2xl border border-slate-200 dark:border-slate-700 p-4"> + <div className="flex items-center justify-between mb-3"> + <h3 className="font-semibold text-slate-900 dark:text-slate-100 text-base"> + {t("map.select_nearby_stop", "Seleccionar parada")} + </h3> + <button + onClick={() => setDisambiguationStops([])} + className="p-1 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" + aria-label={t("planner.close", "Cerrar")} + > + <X className="w-5 h-5 text-slate-500" /> + </button> + </div> + <ul className="divide-y divide-slate-100 dark:divide-slate-800"> + {disambiguationStops.map((stop) => ( + <li key={stop.stopId}> + <button + className="w-full flex items-center gap-3 py-3 text-left hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors rounded-lg px-2" + onClick={() => { + setDisambiguationStops([]); + setSelectedStop(stop); + setIsSheetOpen(true); + }} + > + <MapPin className="w-4 h-4 flex-shrink-0 text-primary-600" /> + <div> + <div className="font-medium text-slate-900 dark:text-slate-100 text-sm"> + {stop.name} + </div> + {stop.stopCode && ( + <div className="text-xs text-slate-500 dark:text-slate-400"> + {stop.stopCode} + </div> + )} + </div> + </button> + </li> + ))} + </ul> + </div> + </div> + )} </AppMap> </div> ); |
