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/Map.tsx b/frontend/src/components/maps/Map.tsx index 3489724..54f6596 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'; @@ -45,7 +47,10 @@ interface MapProps { points?: MapPoint[]; basemap?: BasemapType; onViewportChange?: (viewport: MapViewport) => void; + onRenderedPointsChange?: (points: MapPoint[]) => void; + onLoadingChange?: (isLoading: boolean) => void; scrollWheelZoom?: boolean; + className?: string; } const ViewportEvents = ({ @@ -86,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; @@ -93,6 +157,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 (
@@ -115,12 +184,25 @@ const AttractionPopupContent = ({ point }: { point: MapPoint }) => { ); }; -const AttractionMarkersLayer = ({ points }: { points: MapPoint[] }) => { +const AttractionMarkersLayer = ({ + points, + onRenderedPointsChange, +}: { + points: MapPoint[]; + 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( - (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 }> = []; @@ -143,6 +225,23 @@ const AttractionMarkersLayer = ({ points }: { points: MapPoint[] }) => { } } + 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(() => { + const callback = onRenderedPointsChangeRef.current; + if (!callback) return; + if (lastRenderedSignatureRef.current === renderedSignature) return; + + lastRenderedSignatureRef.current = renderedSignature; + callback(renderedPoints); + }, [renderedPoints, renderedSignature]); + return ( <> {accepted.map(({ point }) => ( @@ -168,23 +267,29 @@ const Map = ({ points, basemap = "openstreetmap", onViewportChange, + onRenderedPointsChange, + onLoadingChange, scrollWheelZoom = false, + className, }: 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 ( onLoadingChange?.(true), + load: () => onLoadingChange?.(false), + tileerror: () => onLoadingChange?.(false), + }} + /> + + + {hasPoints ? ( - + ) : homePosition ? ( <> {isOneMap ? ( 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 = () => {
-
+
); }; 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' })}

diff --git a/frontend/src/pages/public/explore/TopAttractions.tsx b/frontend/src/pages/public/explore/TopAttractions.tsx index caca8c9..21e2495 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,20 +194,63 @@ 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 [isMapLoading, setIsMapLoading] = useState(true); 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, }); const refreshIntervalRef = useRef(null); + const hasMapLoadedRef = useRef(false); const filteredPoints = useMemo(() => { if (selectedTypeIds.length === 0) { @@ -214,6 +268,10 @@ const TopAttractions = () => { [filteredPoints, viewport], ); + const sortedLocations = useMemo(() => { + return sortLocations(renderedPoints, getViewportCenter(viewport)); + }, [renderedPoints, viewport]); + useEffect(() => { setStats({ total: filteredPoints.length, @@ -344,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 (
@@ -361,38 +444,134 @@ 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}

+
+

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

+ {point.website && ( + + Visit + + )} +
+ ))} +
+ )} +
+
+ +
+ {isMapLoading && ( +
+
+ +

+ Rendering map. Please wait... +

+
+
+ )} { + 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 /> -
+ -
-
-
-
+
+
+
+

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 +582,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 - - ) : ( - - - )} -
-
- )} -
-
)