aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app
diff options
context:
space:
mode:
authorCopilot <198982749+Copilot@users.noreply.github.com>2026-02-25 16:15:16 +0100
committerGitHub <noreply@github.com>2026-02-25 16:15:16 +0100
commit7d86c66be248861f440089f37765778c69deaaa7 (patch)
tree6181be67a333c7afc1b39f81b49457dbc3fa1819 /src/frontend/app
parent191d36dbbea5fab6141d9a144f154c98757e284f (diff)
[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>
Diffstat (limited to 'src/frontend/app')
-rw-r--r--src/frontend/app/components/shared/AppMap.tsx9
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json3
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json3
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json3
-rw-r--r--src/frontend/app/maps/styleloader.ts92
-rw-r--r--src/frontend/app/routes/map.tsx87
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>
);