From 165a45248ae47285bf5c369dc0b394ff96d011ad Mon Sep 17 00:00:00 2001 From: stephen leong Date: Mon, 16 Mar 2026 15:32:02 +0000 Subject: [PATCH 01/13] feat: add Attractions page with SgMap component integration --- frontend/src/App.tsx | 2 ++ frontend/src/components/maps/SgMap.tsx | 26 +++++++++++++++++++++++ frontend/src/pages/public/Attractions.tsx | 12 +++++++++++ 3 files changed, 40 insertions(+) create mode 100644 frontend/src/components/maps/SgMap.tsx create mode 100644 frontend/src/pages/public/Attractions.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7acd30b..504e594 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import "./App.css"; import Home from "./pages/public/Home"; import Events from "./pages/public/Events"; import Neighbourhoods from "./pages/public/Neighbourhoods"; +import Attractions from "./pages/public/Attractions"; // Context Providers import { CurrencyProvider } from "./context/CurrencyContext"; @@ -19,6 +20,7 @@ function App() { } /> } /> } /> + } /> diff --git a/frontend/src/components/maps/SgMap.tsx b/frontend/src/components/maps/SgMap.tsx new file mode 100644 index 0000000..0073a21 --- /dev/null +++ b/frontend/src/components/maps/SgMap.tsx @@ -0,0 +1,26 @@ +import { MapContainer, TileLayer } from "react-leaflet"; + +type PositionType = [number, number]; + +interface MapProps { + centerPosition?: PositionType; +} + +const SgMap = ({ centerPosition = [1.2868108, 103.8545349]}: MapProps) => { + return ( + + + + + ); +}; + +export default SgMap; diff --git a/frontend/src/pages/public/Attractions.tsx b/frontend/src/pages/public/Attractions.tsx new file mode 100644 index 0000000..414447d --- /dev/null +++ b/frontend/src/pages/public/Attractions.tsx @@ -0,0 +1,12 @@ +import SgMap from '../../components/maps/Sgmap' +import Layout from '../layout' + +const Attractions = () => { + return ( + + + + ) +} + +export default Attractions \ No newline at end of file From 4334261405c09564202e93c6bcd3b49c4b0d7b84 Mon Sep 17 00:00:00 2001 From: stephen leong Date: Mon, 16 Mar 2026 16:52:51 +0000 Subject: [PATCH 02/13] feat: enhance Attractions page layout and integrate SgMap component with styling adjustments --- frontend/src/App.css | 47 ++++++++++++++++++- frontend/src/components/maps/SgMap.tsx | 1 + frontend/src/components/maps/style.css | 3 ++ frontend/src/components/navigation/Navbar.tsx | 2 +- frontend/src/pages/public/Attractions.tsx | 7 ++- 5 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/maps/style.css diff --git a/frontend/src/App.css b/frontend/src/App.css index 2058c4f..739102d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,3 +1,48 @@ @import "tailwindcss"; @import "flowbite-react/plugin/tailwindcss"; -@source "../.flowbite-react/class-list.json"; \ No newline at end of file +@source "../.flowbite-react/class-list.json"; + +@layer base { + h1 { + font-size: 2.25rem; + font-weight: 700; + line-height: 1.2; + } + + h2 { + font-size: 1.875rem; + font-weight: 600; + line-height: 1.2; + } + + h3 { + font-size: 1.5rem; + font-weight: 600; + line-height: 1.3; + } + + h4 { + font-size: 1.25rem; + font-weight: 600; + line-height: 1.3; + } + + h5 { + font-size: 1.125rem; + font-weight: 600; + line-height: 1.3; + } + + h6 { + font-size: 1rem; + font-weight: 600; + line-height: 1.3; + } + + p { + font-size: 1rem; + line-height: 1.625; + color: rgb(55 65 81); + } +} + diff --git a/frontend/src/components/maps/SgMap.tsx b/frontend/src/components/maps/SgMap.tsx index 0073a21..9f289f8 100644 --- a/frontend/src/components/maps/SgMap.tsx +++ b/frontend/src/components/maps/SgMap.tsx @@ -1,4 +1,5 @@ import { MapContainer, TileLayer } from "react-leaflet"; +import './style.css' type PositionType = [number, number]; diff --git a/frontend/src/components/maps/style.css b/frontend/src/components/maps/style.css new file mode 100644 index 0000000..00ef974 --- /dev/null +++ b/frontend/src/components/maps/style.css @@ -0,0 +1,3 @@ +.leaflet-control-attribution { + display: flex; +} \ No newline at end of file diff --git a/frontend/src/components/navigation/Navbar.tsx b/frontend/src/components/navigation/Navbar.tsx index 0906420..262af29 100644 --- a/frontend/src/components/navigation/Navbar.tsx +++ b/frontend/src/components/navigation/Navbar.tsx @@ -127,7 +127,7 @@ const Navbar = () => { -
+
); }; diff --git a/frontend/src/pages/public/Attractions.tsx b/frontend/src/pages/public/Attractions.tsx index 414447d..f039f4d 100644 --- a/frontend/src/pages/public/Attractions.tsx +++ b/frontend/src/pages/public/Attractions.tsx @@ -1,10 +1,15 @@ +import HeroSection from '../../components/globals/HeroSection' import SgMap from '../../components/maps/Sgmap' import Layout from '../layout' const Attractions = () => { return ( - +

Attractions

+ + + +
) } From 02ac70f57784a59cf05f2cbdf3cd1d3ca3323b12 Mon Sep 17 00:00:00 2001 From: stephen leong Date: Mon, 16 Mar 2026 16:55:35 +0000 Subject: [PATCH 03/13] fix: reorder welcome header in Home component for improved layout --- frontend/src/pages/public/Home.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/public/Home.tsx b/frontend/src/pages/public/Home.tsx index 7df680f..0dbc49e 100644 --- a/frontend/src/pages/public/Home.tsx +++ b/frontend/src/pages/public/Home.tsx @@ -14,11 +14,12 @@ const Home = () => { return ( +

{t('welcome')}

Home
-

{t('welcome')}

+

{formatCurrency(1000 * rate, currency)}

{t('greeting')}

From cefa30ec3ffc55ad3d4a7fe04af9bf9e982d9301 Mon Sep 17 00:00:00 2001 From: stephen leong Date: Mon, 16 Mar 2026 15:32:02 +0000 Subject: [PATCH 04/13] feat: add Attractions page with SgMap component integration --- frontend/src/components/maps/SgMap.tsx | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 frontend/src/components/maps/SgMap.tsx diff --git a/frontend/src/components/maps/SgMap.tsx b/frontend/src/components/maps/SgMap.tsx new file mode 100644 index 0000000..0073a21 --- /dev/null +++ b/frontend/src/components/maps/SgMap.tsx @@ -0,0 +1,26 @@ +import { MapContainer, TileLayer } from "react-leaflet"; + +type PositionType = [number, number]; + +interface MapProps { + centerPosition?: PositionType; +} + +const SgMap = ({ centerPosition = [1.2868108, 103.8545349]}: MapProps) => { + return ( + + + + + ); +}; + +export default SgMap; From 4bcd3b702dfd4949fa24beed57454b1c68051c99 Mon Sep 17 00:00:00 2001 From: stephen leong Date: Mon, 16 Mar 2026 16:52:51 +0000 Subject: [PATCH 05/13] feat: enhance Attractions page layout and integrate SgMap component with styling adjustments --- frontend/src/App.css | 37 ++++++++++++------- frontend/src/components/maps/SgMap.tsx | 1 + frontend/src/components/maps/style.css | 3 ++ frontend/src/components/navigation/Navbar.tsx | 2 +- 4 files changed, 28 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/maps/style.css diff --git a/frontend/src/App.css b/frontend/src/App.css index df8c919..aaddd80 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -2,7 +2,7 @@ @import "flowbite-react/plugin/tailwindcss"; @source "../.flowbite-react/class-list.json"; @plugin "daisyui" { - themes: light --default + themes: light --default; } @plugin "daisyui/theme" { name: "garden"; @@ -13,7 +13,7 @@ --color-base-200: oklch(86.445% 0.002 17.197); --color-base-300: oklch(79.938% 0.001 17.197); --color-base-content: oklch(16.961% 0.001 17.32); - --color-primary: #EE2536; + --color-primary: #ee2536; --color-primary-content: oklch(100% 0 0); --color-secondary: oklch(48.495% 0.11 355.095); --color-secondary-content: oklch(89.699% 0.022 355.095); @@ -39,43 +39,52 @@ --noise: 0; } - h1 { - @apply text-3xl font-bold + @apply text-3xl font-bold; } h2 { - @apply text-2xl + @apply text-2xl; } h3 { - @apply text-xl + @apply text-xl; } p a { - @apply text-blue-500 hover:underline + @apply text-blue-500 hover:underline; } :where(.shadow) { - box-shadow: 0 1px 2px 0 rgb(239 68 68 / 0.15), 0 1px 3px 0 rgb(239 68 68 / 0.10) !important; + box-shadow: + 0 1px 2px 0 rgb(239 68 68 / 0.15), + 0 1px 3px 0 rgb(239 68 68 / 0.1) !important; } :where(.shadow-sm) { - box-shadow: 0 1px 2px 0 rgb(239 68 68 / 0.12), 0 1px 3px 0 rgb(239 68 68 / 0.08) !important; + box-shadow: + 0 1px 2px 0 rgb(239 68 68 / 0.12), + 0 1px 3px 0 rgb(239 68 68 / 0.08) !important; } :where(.shadow-md) { - box-shadow: 0 4px 6px -1px rgb(239 68 68 / 0.16), 0 2px 4px -2px rgb(239 68 68 / 0.12) !important; + box-shadow: + 0 4px 6px -1px rgb(239 68 68 / 0.16), + 0 2px 4px -2px rgb(239 68 68 / 0.12) !important; } :where(.shadow-lg) { - box-shadow: 0 10px 15px -3px rgb(239 68 68 / 0.18), 0 4px 6px -4px rgb(239 68 68 / 0.14) !important; + box-shadow: + 0 10px 15px -3px rgb(239 68 68 / 0.18), + 0 4px 6px -4px rgb(239 68 68 / 0.14) !important; } :where(.shadow-xl) { - box-shadow: 0 20px 25px -5px rgb(239 68 68 / 0.20), 0 8px 10px -6px rgb(239 68 68 / 0.16) !important; + box-shadow: + 0 20px 25px -5px rgb(239 68 68 / 0.2), + 0 8px 10px -6px rgb(239 68 68 / 0.16) !important; } :where(.shadow-2xl) { - box-shadow: 0 25px 50px -12px rgb(239 68 68 / 0.22) !important; -} \ No newline at end of file + box-shadow: 0 25px 50px -12px rgb(239 68 68 / 0.22) !important; +} diff --git a/frontend/src/components/maps/SgMap.tsx b/frontend/src/components/maps/SgMap.tsx index 0073a21..9f289f8 100644 --- a/frontend/src/components/maps/SgMap.tsx +++ b/frontend/src/components/maps/SgMap.tsx @@ -1,4 +1,5 @@ import { MapContainer, TileLayer } from "react-leaflet"; +import './style.css' type PositionType = [number, number]; diff --git a/frontend/src/components/maps/style.css b/frontend/src/components/maps/style.css new file mode 100644 index 0000000..00ef974 --- /dev/null +++ b/frontend/src/components/maps/style.css @@ -0,0 +1,3 @@ +.leaflet-control-attribution { + display: flex; +} \ No newline at end of file diff --git a/frontend/src/components/navigation/Navbar.tsx b/frontend/src/components/navigation/Navbar.tsx index 60f7617..6f9a0c7 100644 --- a/frontend/src/components/navigation/Navbar.tsx +++ b/frontend/src/components/navigation/Navbar.tsx @@ -234,7 +234,7 @@ const Navbar = () => {
-
+
); }; From 1248e13d6406af045c3131d51e132d2917539e41 Mon Sep 17 00:00:00 2001 From: stephen leong Date: Mon, 16 Mar 2026 16:55:35 +0000 Subject: [PATCH 06/13] fix: reorder welcome header in Home component for improved layout --- frontend/src/pages/public/Home.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/public/Home.tsx b/frontend/src/pages/public/Home.tsx index 03afb49..c663798 100644 --- a/frontend/src/pages/public/Home.tsx +++ b/frontend/src/pages/public/Home.tsx @@ -18,7 +18,7 @@ const Home = () => { - +

{formatCurrency(1000 * rate, currency)}

{t('greeting', { ns: 'system' })}

From 9c887093d09fda63c4ac77bb51244118c921562c Mon Sep 17 00:00:00 2001 From: Stephen Date: Sun, 19 Apr 2026 23:29:18 +0100 Subject: [PATCH 07/13] refactor: remove SgMap component as part of project restructuring --- frontend/src/components/maps/SgMap.tsx | 27 -------------------------- 1 file changed, 27 deletions(-) delete mode 100644 frontend/src/components/maps/SgMap.tsx diff --git a/frontend/src/components/maps/SgMap.tsx b/frontend/src/components/maps/SgMap.tsx deleted file mode 100644 index 9f289f8..0000000 --- a/frontend/src/components/maps/SgMap.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { MapContainer, TileLayer } from "react-leaflet"; -import './style.css' - -type PositionType = [number, number]; - -interface MapProps { - centerPosition?: PositionType; -} - -const SgMap = ({ centerPosition = [1.2868108, 103.8545349]}: MapProps) => { - return ( - - - - - ); -}; - -export default SgMap; From fa484950a249061108a12a250b1439f434de76fa Mon Sep 17 00:00:00 2001 From: Stephen Date: Sun, 19 Apr 2026 23:34:02 +0100 Subject: [PATCH 08/13] feat: update map center and zoom logic for OneMap integration --- frontend/src/components/maps/Map.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/maps/Map.tsx b/frontend/src/components/maps/Map.tsx index 3489724..3c9ccff 100644 --- a/frontend/src/components/maps/Map.tsx +++ b/frontend/src/components/maps/Map.tsx @@ -28,10 +28,12 @@ type MapViewport = { }; const SINGAPORE_CENTER: PositionType = [1.364917, 103.822872]; +const SINGAPORE_CBD_CENTER: PositionType = [1.283, 103.851]; const SINGAPORE_BOUNDS: [PositionType, PositionType] = [ [1.144, 103.535], [1.494, 104.502], ]; +const ONE_MAP_DEFAULT_ZOOM = 15; const ONE_MAP_ATTRIBUTION = ' OneMap © contributors | Singapore Land Authority'; @@ -172,13 +174,16 @@ const Map = ({ }: MapProps) => { const mapPoints = points ?? []; const hasPoints = mapPoints.length > 0; - const mapCenter = mapPoints[0]?.position ?? homePosition ?? centerPosition; const isOneMap = basemap === "onemap"; + const mapCenter = isOneMap + ? SINGAPORE_CBD_CENTER + : mapPoints[0]?.position ?? homePosition ?? centerPosition; + const mapZoom = isOneMap ? ONE_MAP_DEFAULT_ZOOM : 13; return ( Date: Sun, 19 Apr 2026 23:51:30 +0100 Subject: [PATCH 09/13] feat: add onRenderedPointsChange callback and improve point sorting logic in Map and TopAttractions components --- frontend/src/components/maps/Map.tsx | 30 ++- .../pages/public/explore/TopAttractions.tsx | 255 ++++++++++++------ 2 files changed, 198 insertions(+), 87 deletions(-) diff --git a/frontend/src/components/maps/Map.tsx b/frontend/src/components/maps/Map.tsx index 3c9ccff..99fe4c6 100644 --- a/frontend/src/components/maps/Map.tsx +++ b/frontend/src/components/maps/Map.tsx @@ -47,7 +47,9 @@ interface MapProps { points?: MapPoint[]; basemap?: BasemapType; onViewportChange?: (viewport: MapViewport) => void; + onRenderedPointsChange?: (points: MapPoint[]) => void; scrollWheelZoom?: boolean; + className?: string; } const ViewportEvents = ({ @@ -95,6 +97,11 @@ const getMinDistancePx = (zoom: number) => { return 28; }; +const getEffectivePriority = (priority?: number) => { + if (!priority || priority <= 0) return Number.MAX_SAFE_INTEGER; + return priority; +}; + const AttractionPopupContent = ({ point }: { point: MapPoint }) => { return (
@@ -117,12 +124,18 @@ const AttractionPopupContent = ({ point }: { point: MapPoint }) => { ); }; -const AttractionMarkersLayer = ({ points }: { points: MapPoint[] }) => { +const AttractionMarkersLayer = ({ + points, + onRenderedPointsChange, +}: { + points: MapPoint[]; + onRenderedPointsChange?: (points: MapPoint[]) => void; +}) => { const map = useMap(); const zoom = map.getZoom(); const minimumDistancePx = getMinDistancePx(zoom); const sortedPoints = [...points].sort( - (a, b) => (a.priority ?? 0) - (b.priority ?? 0), + (a, b) => getEffectivePriority(a.priority) - getEffectivePriority(b.priority), ); const accepted: Array<{ point: MapPoint; x: number; y: number }> = []; @@ -145,6 +158,10 @@ const AttractionMarkersLayer = ({ points }: { points: MapPoint[] }) => { } } + useEffect(() => { + onRenderedPointsChange?.(accepted.map(({ point }) => point)); + }, [accepted, onRenderedPointsChange]); + return ( <> {accepted.map(({ point }) => ( @@ -170,7 +187,9 @@ const Map = ({ points, basemap = "openstreetmap", onViewportChange, + onRenderedPointsChange, scrollWheelZoom = false, + className, }: MapProps) => { const mapPoints = points ?? []; const hasPoints = mapPoints.length > 0; @@ -189,7 +208,7 @@ const Map = ({ maxBounds={isOneMap ? SINGAPORE_BOUNDS : undefined} maxBoundsViscosity={isOneMap ? 1 : undefined} scrollWheelZoom={scrollWheelZoom} - className="relative z-0 w-full h-100 overflow-hidden rounded-2xl border border-base-300" + className={`relative z-0 w-full h-full overflow-hidden rounded-2xl border border-base-300 ${className ?? ""}`} > {hasPoints ? ( - + ) : homePosition ? ( <> {isOneMap ? ( diff --git a/frontend/src/pages/public/explore/TopAttractions.tsx b/frontend/src/pages/public/explore/TopAttractions.tsx index caca8c9..f028db7 100644 --- a/frontend/src/pages/public/explore/TopAttractions.tsx +++ b/frontend/src/pages/public/explore/TopAttractions.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { FiChevronLeft, FiChevronRight } from "react-icons/fi"; import Layout from "../../layout"; import AttractionsMap from "../../../components/maps/Map"; import CONFIG from "../../../config"; @@ -12,6 +13,7 @@ const SINGAPORE_BOUNDS = { const CACHE_KEY = "top-attractions-cache-v2"; const CACHE_TTL_MS = 10 * 60 * 1000; +const DEFAULT_MAP_CENTER: [number, number] = [1.283, 103.851]; type AttractionResponse = { id: string; @@ -44,7 +46,7 @@ type AttractionsApiResponse = { }; type MapPoint = { - id: string; + id?: string; title: string; description?: string; position: [number, number]; @@ -75,6 +77,11 @@ const normalizeTypeId = (value: string | null | undefined) => { return normalized.length > 0 ? normalized : null; }; +const getEffectivePriority = (priority?: number | null) => { + if (!priority || priority <= 0) return Number.MAX_SAFE_INTEGER; + return priority; +}; + const addAssetVersionParam = ( iconUrl: string | null, version: string, @@ -105,7 +112,11 @@ const mapApiPoints = (data: AttractionResponse[], iconVersion: string): MapPoint website: item.website, typeName: item.location_type_name, })) - .sort((a, b) => a.priority - b.priority); + .sort((a, b) => { + const priorityDelta = getEffectivePriority(a.priority) - getEffectivePriority(b.priority); + if (priorityDelta !== 0) return priorityDelta; + return a.title.localeCompare(b.title); + }); const getCachedAttractions = (): MapPoint[] | null => { try { @@ -159,7 +170,7 @@ const selectViewportPoints = (points: MapPoint[], viewport: Viewport | null) => ); }); - filtered.sort((a, b) => a.priority - b.priority); + filtered.sort((a, b) => getEffectivePriority(a.priority) - getEffectivePriority(b.priority)); if (viewport.zoom >= 14) { return filtered; @@ -175,7 +186,7 @@ const selectViewportPoints = (points: MapPoint[], viewport: Viewport | null) => const key = `${latBucket}-${lngBucket}`; const current = bucketed.get(key); - if (!current || point.priority < current.priority) { + if (!current || getEffectivePriority(point.priority) < getEffectivePriority(current.priority)) { bucketed.set(key, point); } } @@ -183,15 +194,56 @@ const selectViewportPoints = (points: MapPoint[], viewport: Viewport | null) => return [...bucketed.values()]; }; +const getViewportCenter = (viewport: Viewport | null): [number, number] => { + if (!viewport) { + return DEFAULT_MAP_CENTER; + } + + return [ + (viewport.minLat + viewport.maxLat) / 2, + (viewport.minLng + viewport.maxLng) / 2, + ]; +}; + +const getPointDistanceSquared = (point: MapPoint, center: [number, number]) => { + const [lat, lng] = point.position; + const [centerLat, centerLng] = center; + const latDistance = lat - centerLat; + const lngDistance = lng - centerLng; + return latDistance * latDistance + lngDistance * lngDistance; +}; + +const sortLocations = (points: MapPoint[], center: [number, number]) => { + return [...points].sort((a, b) => { + const priorityA = getEffectivePriority(a.priority); + const priorityB = getEffectivePriority(b.priority); + + if (priorityA !== priorityB) { + return priorityA - priorityB; + } + + const distanceA = getPointDistanceSquared(a, center); + const distanceB = getPointDistanceSquared(b, center); + + if (distanceA !== distanceB) { + return distanceA - distanceB; + } + + return a.title.localeCompare(b.title); + }); +}; + const TopAttractions = () => { const [allPoints, setAllPoints] = useState([]); + const [renderedPoints, setRenderedPoints] = useState([]); const [locationTypes, setLocationTypes] = useState([]); const [selectedTypeIds, setSelectedTypeIds] = useState([]); const [viewport, setViewport] = useState(null); + const [isLocationsCollapsed, setIsLocationsCollapsed] = useState(false); const [loading, setLoading] = useState(false); const [loadingTypes, setLoadingTypes] = useState(false); - const [stats, setStats] = useState<{ total: number; visible: number; isClusterMode: boolean }>( { + const [stats, setStats] = useState<{ total: number; visible: number; isClusterMode: boolean }>({ total: 0, visible: 0, isClusterMode: true, @@ -214,6 +266,10 @@ const TopAttractions = () => { [filteredPoints, viewport], ); + const sortedLocations = useMemo(() => { + return sortLocations(renderedPoints, getViewportCenter(viewport)); + }, [renderedPoints, viewport]); + useEffect(() => { setStats({ total: filteredPoints.length, @@ -361,38 +417,127 @@ const TopAttractions = () => {

-
-
+
+
+
+
+
+

Locations

+

+ Priority, then closeness to map center. +

+
+ +
+ +
+ + {sortedLocations.length} location{sortedLocations.length === 1 ? "" : "s"} + +
+ + {sortedLocations.length === 0 ? ( +

+ No locations found for the selected type filters. +

+ ) : ( +
+ {sortedLocations.map((point) => ( +
+
+

{point.title}

+ + P{point.priority > 0 ? point.priority : "-"} + +
+

+ {typeNameById.get(normalizeTypeId(point.typeId) ?? "") ?? + point.typeName ?? + "Uncategorized"} +

+ {point.website && ( + + Visit + + )} +
+ ))} +
+ )} +
+
+ +
{ + setRenderedPoints( + points.map((point) => ({ + id: point.id, + title: point.title, + description: point.description, + position: point.position, + priority: point.priority ?? 0, + typeId: null, + iconUrl: point.iconUrl, + mapColor: point.mapColor, + website: point.website, + typeName: point.typeName, + })), + ); + }} scrollWheelZoom + className="h-136 lg:h-full" /> -
+ -
-
-
-
+
+
+
+

Filter by location type

- {selectedTypeIds.length > 0 && ( - - )} +

+ Narrow the map to a specific attraction type. +

+ {selectedTypeIds.length > 0 && ( + + )} +
+
{loadingTypes ? (

Loading types...

) : locationTypes.length === 0 ? (

No location types available.

) : ( -
+
{locationTypes.map((type) => { const typeId = normalizeTypeId(type.id); if (!typeId) return null; @@ -403,11 +548,10 @@ const TopAttractions = () => { return (
-
+
- -
-
-
-

Locations

- - {filteredPoints.length} location{filteredPoints.length === 1 ? "" : "s"} - -
- - {filteredPoints.length === 0 ? ( -

- No locations found for the selected type filters. -

- ) : ( -
- - - - - - - - - - {filteredPoints.map((point) => ( - - - - - - ))} - -
NameTypeWebsite
{point.title} - {typeNameById.get(normalizeTypeId(point.typeId) ?? "") ?? - point.typeName ?? - "Uncategorized"} - - {point.website ? ( - - Visit - - ) : ( - - - )} -
-
- )} -
-
) From 8a74a2f8fb8365b594f14725ee6fd20b06747644 Mon Sep 17 00:00:00 2001 From: Stephen Date: Sun, 19 Apr 2026 23:54:42 +0100 Subject: [PATCH 10/13] feat: enhance AttractionMarkersLayer with onRenderedPointsChange callback and optimize rendering logic; update TopAttractions layout for better responsiveness --- frontend/src/components/maps/Map.tsx | 24 +++++++++++++++++-- .../pages/public/explore/TopAttractions.tsx | 2 +- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/maps/Map.tsx b/frontend/src/components/maps/Map.tsx index 99fe4c6..4638a0d 100644 --- a/frontend/src/components/maps/Map.tsx +++ b/frontend/src/components/maps/Map.tsx @@ -132,6 +132,13 @@ const AttractionMarkersLayer = ({ onRenderedPointsChange?: (points: MapPoint[]) => void; }) => { const map = useMap(); + const onRenderedPointsChangeRef = useRef(onRenderedPointsChange); + const lastRenderedSignatureRef = useRef(""); + + useEffect(() => { + onRenderedPointsChangeRef.current = onRenderedPointsChange; + }, [onRenderedPointsChange]); + const zoom = map.getZoom(); const minimumDistancePx = getMinDistancePx(zoom); const sortedPoints = [...points].sort( @@ -158,9 +165,22 @@ const AttractionMarkersLayer = ({ } } + const renderedPoints = accepted.map(({ point }) => point); + const renderedSignature = renderedPoints + .map( + (point) => + `${point.id ?? point.title}:${point.position[0].toFixed(6)}:${point.position[1].toFixed(6)}`, + ) + .join("|"); + useEffect(() => { - onRenderedPointsChange?.(accepted.map(({ point }) => point)); - }, [accepted, onRenderedPointsChange]); + const callback = onRenderedPointsChangeRef.current; + if (!callback) return; + if (lastRenderedSignatureRef.current === renderedSignature) return; + + lastRenderedSignatureRef.current = renderedSignature; + callback(renderedPoints); + }, [renderedPoints, renderedSignature]); return ( <> diff --git a/frontend/src/pages/public/explore/TopAttractions.tsx b/frontend/src/pages/public/explore/TopAttractions.tsx index f028db7..75ce14b 100644 --- a/frontend/src/pages/public/explore/TopAttractions.tsx +++ b/frontend/src/pages/public/explore/TopAttractions.tsx @@ -417,7 +417,7 @@ const TopAttractions = () => {

-
+
From 690196493dee24c6121952a8c5a3c470cff16e70 Mon Sep 17 00:00:00 2001 From: Stephen Date: Mon, 20 Apr 2026 00:02:23 +0100 Subject: [PATCH 11/13] feat: add loading state management for map component; implement loading indicator in TopAttractions --- frontend/src/components/maps/Map.tsx | 73 +++++++++++++++++++ .../pages/public/explore/TopAttractions.tsx | 43 ++++++++++- 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/maps/Map.tsx b/frontend/src/components/maps/Map.tsx index 4638a0d..54f6596 100644 --- a/frontend/src/components/maps/Map.tsx +++ b/frontend/src/components/maps/Map.tsx @@ -48,6 +48,7 @@ interface MapProps { basemap?: BasemapType; onViewportChange?: (viewport: MapViewport) => void; onRenderedPointsChange?: (points: MapPoint[]) => void; + onLoadingChange?: (isLoading: boolean) => void; scrollWheelZoom?: boolean; className?: string; } @@ -90,6 +91,65 @@ const ViewportEvents = ({ return null; }; +const MapLoadEvents = ({ + onLoadingChange, +}: { + onLoadingChange?: (isLoading: boolean) => void; +}) => { + const map = useMap(); + + useEffect(() => { + map.whenReady(() => { + onLoadingChange?.(false); + }); + }, [map, onLoadingChange]); + + return null; +}; + +const OneMapDefaultView = ({ + enabled, + center, + zoom, +}: { + enabled: boolean; + center: PositionType; + zoom: number; +}) => { + const map = useMap(); + + useEffect(() => { + if (!enabled) return; + + map.whenReady(() => { + map.setView(center, zoom, { animate: false }); + }); + }, [map, enabled, center, zoom]); + + return null; +}; + +const InvalidateMapOnResize = () => { + const map = useMap(); + + useEffect(() => { + const container = map.getContainer(); + if (!container) return; + + const observer = new ResizeObserver(() => { + map.invalidateSize({ animate: false }); + }); + + observer.observe(container); + + return () => { + observer.disconnect(); + }; + }, [map]); + + return null; +}; + const getMinDistancePx = (zoom: number) => { if (zoom >= 18) return 10; if (zoom >= 16) return 14; @@ -208,6 +268,7 @@ const Map = ({ basemap = "openstreetmap", onViewportChange, onRenderedPointsChange, + onLoadingChange, scrollWheelZoom = false, className, }: MapProps) => { @@ -240,7 +301,19 @@ const Map = ({ detectRetina={isOneMap} minZoom={isOneMap ? 11 : undefined} maxZoom={isOneMap ? 19 : undefined} + eventHandlers={{ + loading: () => onLoadingChange?.(true), + load: () => onLoadingChange?.(false), + tileerror: () => onLoadingChange?.(false), + }} + /> + + + {hasPoints ? ( { const [selectedTypeIds, setSelectedTypeIds] = useState([]); const [viewport, setViewport] = useState(null); const [isLocationsCollapsed, setIsLocationsCollapsed] = useState(false); + const [isMapLoading, setIsMapLoading] = useState(true); const [loading, setLoading] = useState(false); const [loadingTypes, setLoadingTypes] = useState(false); const [stats, setStats] = useState<{ total: number; visible: number; isClusterMode: boolean }>({ @@ -249,6 +250,7 @@ const TopAttractions = () => { isClusterMode: true, }); const refreshIntervalRef = useRef(null); + const hasMapLoadedRef = useRef(false); const filteredPoints = useMemo(() => { if (selectedTypeIds.length === 0) { @@ -400,6 +402,31 @@ const TopAttractions = () => { ); }; + const handleMapLoadingChange = (isLoading: boolean) => { + if (hasMapLoadedRef.current) { + return; + } + + if (!isLoading) { + hasMapLoadedRef.current = true; + setIsMapLoading(false); + } + }; + + useEffect(() => { + if (!isMapLoading) return; + + // Fallback so the map never stays hidden if a tile event is missed. + const timeoutId = window.setTimeout(() => { + hasMapLoadedRef.current = true; + setIsMapLoading(false); + }, 8000); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [isMapLoading]); + return (
@@ -417,7 +444,7 @@ const TopAttractions = () => {

-
+
@@ -485,11 +512,22 @@ const TopAttractions = () => {
-
+
+ {isMapLoading && ( +
+
+ +

+ Rendering map. Please wait... +

+
+
+ )} { setRenderedPoints( points.map((point) => ({ @@ -507,7 +545,6 @@ const TopAttractions = () => { ); }} scrollWheelZoom - className="h-136 lg:h-full" />
From b798ea79e429f252359ed4688be94287ac503d04 Mon Sep 17 00:00:00 2001 From: Stephen Date: Mon, 20 Apr 2026 00:04:47 +0100 Subject: [PATCH 12/13] feat: remove priority badge from TopAttractions component for cleaner UI --- frontend/src/pages/public/explore/TopAttractions.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/frontend/src/pages/public/explore/TopAttractions.tsx b/frontend/src/pages/public/explore/TopAttractions.tsx index e113e21..94efba8 100644 --- a/frontend/src/pages/public/explore/TopAttractions.tsx +++ b/frontend/src/pages/public/explore/TopAttractions.tsx @@ -486,9 +486,6 @@ const TopAttractions = () => { >

{point.title}

- - P{point.priority > 0 ? point.priority : "-"} -

{typeNameById.get(normalizeTypeId(point.typeId) ?? "") ?? From 16d55f7f666ab7db2656f0615bac265aff1ed28e Mon Sep 17 00:00:00 2001 From: Stephen Date: Mon, 20 Apr 2026 00:11:21 +0100 Subject: [PATCH 13/13] feat: adjust layout and padding for TopAttractions component to improve responsiveness --- frontend/src/pages/public/explore/TopAttractions.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/public/explore/TopAttractions.tsx b/frontend/src/pages/public/explore/TopAttractions.tsx index 94efba8..21e2495 100644 --- a/frontend/src/pages/public/explore/TopAttractions.tsx +++ b/frontend/src/pages/public/explore/TopAttractions.tsx @@ -446,10 +446,10 @@ const TopAttractions = () => {

-
-
+
+

Locations

@@ -458,7 +458,7 @@ const TopAttractions = () => {