From 7d86c66be248861f440089f37765778c69deaaa7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:15:16 +0100 Subject: [WIP] Implement UX improvements for map styles and feature selection (#136) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: arielcostas <94913521+arielcostas@users.noreply.github.com> --- src/frontend/app/components/shared/AppMap.tsx | 9 ++- src/frontend/app/i18n/locales/en-GB.json | 3 +- src/frontend/app/i18n/locales/es-ES.json | 3 +- src/frontend/app/i18n/locales/gl-ES.json | 3 +- src/frontend/app/maps/styleloader.ts | 92 +++++++++++++++++++++++++-- src/frontend/app/routes/map.tsx | 87 ++++++++++++++++++++++++- 6 files changed, 182 insertions(+), 15 deletions(-) (limited to 'src/frontend') 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( showCameras: settingsShowCameras, mapPositionMode, } = useApp(); + const { i18n } = useTranslation(); const mapRef = useRef(null); const [mapStyle, setMapStyle] = useState(DEFAULT_STYLE); const location = useLocation(); @@ -96,10 +98,13 @@ export const AppMap = forwardRef( 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 { - 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 + >([]); const mapRef = useRef(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(); + const candidates: Array = []; + 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 && ( +
+
+
+

+ {t("map.select_nearby_stop", "Seleccionar parada")} +

+ +
+
    + {disambiguationStops.map((stop) => ( +
  • + +
  • + ))} +
+
+
+ )} ); -- cgit v1.3