diff --git a/backend/index.ts b/backend/index.ts index 8dd7788..b8b4f58 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -8,6 +8,8 @@ import authRouter from "./src/routes/public/auth"; import profilesRouter from "./src/routes/authenticated/profiles"; import neighbourhoodsRouter from "./src/routes/public/neighbourhoods"; import eventsRouter from "./src/routes/public/events"; +import attractionsRouter from "./src/routes/public/attractions"; +import locationsRouter from "./src/routes/public/locations"; import threadsRouter from "./src/routes/authenticated/forums/threads"; import repliesRouter from "./src/routes/authenticated/forums/replies"; @@ -30,6 +32,8 @@ app.use("/auth", authRouter); app.use("/profiles", profilesRouter); app.use("/neighbourhoods", neighbourhoodsRouter); app.use("/events", eventsRouter); +app.use("/attractions", attractionsRouter); +app.use("/locations", locationsRouter); app.use("/threads", threadsRouter); app.use("/threads/:threadId/replies", repliesRouter); diff --git a/backend/src/routes/public/attractions.ts b/backend/src/routes/public/attractions.ts new file mode 100644 index 0000000..c6c27e0 --- /dev/null +++ b/backend/src/routes/public/attractions.ts @@ -0,0 +1,208 @@ +import { Router } from "express"; +import { pool } from "../../db/pool"; +import { consola } from "consola"; + +const router = Router(); + +const DEFAULT_BOUNDS = { + minLat: 1.144, + maxLat: 1.494, + minLng: 103.535, + maxLng: 104.502, +}; + +const parseFloatParam = (value: unknown, fallback: number) => { + if (typeof value !== "string") { + return fallback; + } + + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +const parseZoom = (value: unknown, fallback = 11) => { + const parsed = parseFloatParam(value, fallback); + return Math.max(1, Math.min(22, parsed)); +}; + +const parseLimit = (value: unknown, fallback: number, max: number) => { + if (typeof value !== "string") { + return fallback; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + + return Math.min(parsed, max); +}; + +const parseBooleanParam = (value: unknown) => { + if (typeof value !== "string") { + return undefined; + } + + if (value === "true") return true; + if (value === "false") return false; + return undefined; +}; + +const normalizeBounds = ( + minLat: number, + maxLat: number, + minLng: number, + maxLng: number, +) => ({ + minLat: Math.min(minLat, maxLat), + maxLat: Math.max(minLat, maxLat), + minLng: Math.min(minLng, maxLng), + maxLng: Math.max(minLng, maxLng), +}); + +const getCellSizeByZoom = (zoom: number) => { + if (zoom < 12) return 0.05; + if (zoom < 13) return 0.03; + return 0.015; +}; + +router.get("/", async (req, res) => { + const minLat = parseFloatParam(req.query.minLat, DEFAULT_BOUNDS.minLat); + const maxLat = parseFloatParam(req.query.maxLat, DEFAULT_BOUNDS.maxLat); + const minLng = parseFloatParam(req.query.minLng, DEFAULT_BOUNDS.minLng); + const maxLng = parseFloatParam(req.query.maxLng, DEFAULT_BOUNDS.maxLng); + const zoom = parseZoom(req.query.zoom); + const clusterOverride = parseBooleanParam(req.query.cluster); + + const bounds = normalizeBounds(minLat, maxLat, minLng, maxLng); + const isClusterMode = clusterOverride ?? zoom < 14; + const limit = isClusterMode + ? parseLimit(req.query.limit, 80, 200) + : parseLimit(req.query.limit, 2000, 10000); + + try { + const countResult = await pool.query( + ` + SELECT COUNT(*)::int AS count + FROM public.locations l + WHERE l.latitude IS NOT NULL + AND l.longitude IS NOT NULL + AND l.latitude BETWEEN $1 AND $2 + AND l.longitude BETWEEN $3 AND $4 + `, + [bounds.minLat, bounds.maxLat, bounds.minLng, bounds.maxLng], + ); + + const totalInBounds = Number.parseInt(countResult.rows[0]?.count ?? "0", 10); + + let dataQuery = ""; + let values: Array = []; + + if (isClusterMode) { + const cellSize = getCellSizeByZoom(zoom); + dataQuery = ` + WITH base AS ( + SELECT + l.id, + l.name, + l.description, + l.latitude, + l.longitude, + l.website, + l.map_priority, + l.location_type_id, + lt.name AS location_type_name, + COALESCE(l.marker_icon, lt.icon) AS marker_icon, + lt.map_color, + FLOOR(l.latitude / $5)::int AS lat_bucket, + FLOOR(l.longitude / $5)::int AS lng_bucket + FROM public.locations l + LEFT JOIN public.location_types lt ON lt.id = l.location_type_id + WHERE l.latitude IS NOT NULL + AND l.longitude IS NOT NULL + AND l.latitude BETWEEN $1 AND $2 + AND l.longitude BETWEEN $3 AND $4 + ), + ranked AS ( + SELECT + *, + ROW_NUMBER() OVER ( + PARTITION BY lat_bucket, lng_bucket + ORDER BY map_priority ASC NULLS LAST, name ASC NULLS LAST, id + ) AS row_rank + FROM base + ) + SELECT + id, + name, + description, + latitude, + longitude, + website, + map_priority, + location_type_id, + location_type_name, + marker_icon, + map_color + FROM ranked + WHERE row_rank = 1 + ORDER BY map_priority ASC NULLS LAST, name ASC NULLS LAST, id + LIMIT $6 + `; + values = [ + bounds.minLat, + bounds.maxLat, + bounds.minLng, + bounds.maxLng, + cellSize, + limit, + ]; + } else { + dataQuery = ` + SELECT + l.id, + l.name, + l.description, + l.latitude, + l.longitude, + l.website, + l.map_priority, + l.location_type_id, + lt.name AS location_type_name, + COALESCE(l.marker_icon, lt.icon) AS marker_icon, + lt.map_color + FROM public.locations l + LEFT JOIN public.location_types lt ON lt.id = l.location_type_id + WHERE l.latitude IS NOT NULL + AND l.longitude IS NOT NULL + AND l.latitude BETWEEN $1 AND $2 + AND l.longitude BETWEEN $3 AND $4 + ORDER BY l.map_priority DESC NULLS LAST, name ASC NULLS LAST, l.id + LIMIT $5 + `; + values = [ + bounds.minLat, + bounds.maxLat, + bounds.minLng, + bounds.maxLng, + limit, + ]; + } + + const dataResult = await pool.query(dataQuery, values); + + return res.status(200).json({ + zoom, + bounds, + isClusterMode, + totalInBounds, + returnedCount: dataResult.rows.length, + data: dataResult.rows, + }); + } catch (error) { + consola.error("Error fetching attractions:", error); + return res.status(500).json({ error: "Internal Server Error" }); + } +}); + +export default router; diff --git a/backend/src/routes/public/locations.ts b/backend/src/routes/public/locations.ts new file mode 100644 index 0000000..3a25fdd --- /dev/null +++ b/backend/src/routes/public/locations.ts @@ -0,0 +1,30 @@ +import { Router } from "express"; +import { consola } from "consola"; +import { pool } from "../../db/pool"; + +const router = Router(); + +router.get("/types", async (_req, res) => { + try { + const result = await pool.query( + ` + SELECT + id, + name, + description, + icon, + map_color, + created_at + FROM public.location_types + ORDER BY name ASC NULLS LAST, id ASC + `, + ); + + return res.status(200).json({ data: result.rows }); + } catch (error) { + consola.error("Error fetching location types:", error); + return res.status(500).json({ error: "Internal Server Error" }); + } +}); + +export default router; \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0e324eb..cd5a071 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,12 +4,12 @@ import "./App.css"; import Home from "./pages/public/Home"; import Events from "./pages/public/Events"; -import Neighbourhoods from "./pages/public/Neighbourhoods"; +import Neighbourhoods from "./pages/public/explore/Neighbourhoods"; import PreTravelChecklist from "./pages/public/travel-advice/PreTravelChecklist"; import Auth from "./pages/public/Auth"; import { CurrencyProvider } from "./context/CurrencyContext"; -import { LocationProvider } from "./context/LocationContet"; +import { LocationProvider } from "./context/LocationContext"; import { AuthProvider } from "./context/AuthContext"; import ProtectedRoute from "./pages/ProtectedRoute"; import Profile from "./pages/authenticated/Profile"; @@ -22,10 +22,13 @@ import { resolvePathLocaleSegment, } from "./i18n/locales"; import { localizePath, parseLocaleFromPathname } from "./utils/localeRouting"; +import TopAttractions from "./pages/public/explore/TopAttractions"; const KNOWN_UNLOCALIZED_PATH_PATTERNS = [ /^\/$/, /^\/events(?:\/[^/]+)?\/?$/, + /^\/explore\/top-attractions\/?$/, + /^\/explore\/neighbourhoods\/?$/, /^\/neighbourhoods\/?$/, /^\/auth\/?$/, /^\/travel-advice\/pre-travel-checklist\/?$/, @@ -157,10 +160,14 @@ function App() { } /> } /> } /> - } /> + + }> + } /> + } /> + } /> - + }> } diff --git a/frontend/src/components/maps/Map.tsx b/frontend/src/components/maps/Map.tsx index 24edc2e..3489724 100644 --- a/frontend/src/components/maps/Map.tsx +++ b/frontend/src/components/maps/Map.tsx @@ -1,37 +1,219 @@ -import { MapContainer, TileLayer } from "react-leaflet"; -import {HomeMarker, SingaporeMarker} from "./Markers"; +import { useEffect, useRef } from "react"; +import { MapContainer, TileLayer, useMap, useMapEvents } from "react-leaflet"; +import { AttractionMarker, HomeMarker, SingaporeMarker } from "./Markers"; import { FitBounds } from "./utils"; type PositionType = [number, number]; +type BasemapType = "openstreetmap" | "onemap"; + +type MapPoint = { + id?: string; + position: PositionType; + title: string; + description?: string; + priority?: number; + iconUrl?: string | null; + mapColor?: string | null; + website?: string | null; + typeName?: string | null; +}; + +type MapViewport = { + minLat: number; + maxLat: number; + minLng: number; + maxLng: number; + zoom: number; +}; + +const SINGAPORE_CENTER: PositionType = [1.364917, 103.822872]; +const SINGAPORE_BOUNDS: [PositionType, PositionType] = [ + [1.144, 103.535], + [1.494, 104.502], +]; + +const ONE_MAP_ATTRIBUTION = + ' OneMap © contributors | Singapore Land Authority'; + +const OSM_ATTRIBUTION = + '© OpenStreetMap contributors'; + interface MapProps { homePosition?: PositionType; centerPosition?: PositionType; + points?: MapPoint[]; + basemap?: BasemapType; + onViewportChange?: (viewport: MapViewport) => void; + scrollWheelZoom?: boolean; } -const Map = ({ homePosition, centerPosition = [1.364917 ,103.822872]}: MapProps) => { - console.log(homePosition) +const ViewportEvents = ({ + onViewportChange, +}: { + onViewportChange?: (viewport: MapViewport) => void; +}) => { + const map = useMap(); + const onViewportChangeRef = useRef(onViewportChange); + + useEffect(() => { + onViewportChangeRef.current = onViewportChange; + }, [onViewportChange]); + + const emitViewport = () => { + const callback = onViewportChangeRef.current; + if (!callback) return; + + const bounds = map.getBounds(); + callback({ + minLat: bounds.getSouth(), + maxLat: bounds.getNorth(), + minLng: bounds.getWest(), + maxLng: bounds.getEast(), + zoom: map.getZoom(), + }); + }; + + useEffect(() => { + emitViewport(); + }, [map]); + + useMapEvents({ + moveend: emitViewport, + zoomend: emitViewport, + }); + + return null; +}; + +const getMinDistancePx = (zoom: number) => { + if (zoom >= 18) return 10; + if (zoom >= 16) return 14; + if (zoom >= 14) return 20; + return 28; +}; + +const AttractionPopupContent = ({ point }: { point: MapPoint }) => { + return ( +
+
{point.title}
+ {point.description &&
{point.description}
} + {point.typeName && ( +
{point.typeName}
+ )} + {point.website && ( + + Website + + )} +
+ ); +}; + +const AttractionMarkersLayer = ({ points }: { points: MapPoint[] }) => { + const map = useMap(); + const zoom = map.getZoom(); + const minimumDistancePx = getMinDistancePx(zoom); + const sortedPoints = [...points].sort( + (a, b) => (a.priority ?? 0) - (b.priority ?? 0), + ); + + const accepted: Array<{ point: MapPoint; x: number; y: number }> = []; + + for (const point of sortedPoints) { + const projected = map.latLngToLayerPoint(point.position); + const markerDistancePx = point.iconUrl + ? minimumDistancePx + 4 + : minimumDistancePx; + const markerDistanceSquared = markerDistancePx * markerDistancePx; + + const overlapsExisting = accepted.some((entry) => { + const dx = entry.x - projected.x; + const dy = entry.y - projected.y; + return dx * dx + dy * dy < markerDistanceSquared; + }); + + if (!overlapsExisting) { + accepted.push({ point, x: projected.x, y: projected.y }); + } + } + + return ( + <> + {accepted.map(({ point }) => ( + + + + ))} + + ); +}; + +const Map = ({ + homePosition, + centerPosition = SINGAPORE_CENTER, + points, + basemap = "openstreetmap", + onViewportChange, + scrollWheelZoom = false, +}: MapProps) => { + const mapPoints = points ?? []; + const hasPoints = mapPoints.length > 0; + const mapCenter = mapPoints[0]?.position ?? homePosition ?? centerPosition; + const isOneMap = basemap === "onemap"; + return ( - {homePosition ? ( + + {hasPoints ? ( + + ) : homePosition ? ( <> - You are here - Visit Singapore - + {isOneMap ? ( + + ) : ( + <> + You are here + Visit Singapore + + )} - ): - Enable map settings to show your location - } - + ) : ( + null + )} ); }; diff --git a/frontend/src/components/maps/Markers.tsx b/frontend/src/components/maps/Markers.tsx index 958f16f..68f00bb 100644 --- a/frontend/src/components/maps/Markers.tsx +++ b/frontend/src/components/maps/Markers.tsx @@ -1,6 +1,7 @@ import homeMarker from "../../assets/markers/homeMarker.svg"; import singaporeMarker from "../../assets/markers/singaporeMarker.svg" +import type { ReactNode } from "react"; import L from "leaflet"; import { Marker, Popup } from "react-leaflet"; @@ -22,9 +23,77 @@ type PositionType = [number, number]; interface markerProps { position: PositionType; - children?: string; + children?: ReactNode; + iconUrl?: string | null; + mapColor?: string | null; } +const customIconCache = new Map(); +const customDivIconCache = new Map(); + +const isHexColor = (value: string) => /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(value); + +const normalizeHexColor = (value?: string | null) => { + if (!value) return null; + + const trimmed = value.trim(); + if (!trimmed) return null; + + const withHash = trimmed.startsWith("#") ? trimmed : `#${trimmed}`; + return isHexColor(withHash) ? withHash : null; +}; + +const getColorMarkerIcon = (mapColor?: string | null) => { + const normalizedColor = normalizeHexColor(mapColor) ?? "#EE2536"; + const cacheKey = `color:${normalizedColor}`; + + const cached = customDivIconCache.get(cacheKey); + if (cached) { + return cached; + } + + const icon = L.divIcon({ + className: "", + html: ` +
+ `, + iconSize: [20, 20], + iconAnchor: [10, 10], + popupAnchor: [0, -10], + }); + + customDivIconCache.set(cacheKey, icon); + return icon; +}; + +const getMarkerIcon = (iconUrl?: string | null, mapColor?: string | null) => { + if (!iconUrl) { + return getColorMarkerIcon(mapColor); + } + + const cached = customIconCache.get(iconUrl); + if (cached) { + return cached; + } + + const icon = new L.Icon({ + iconUrl, + iconSize: [36, 36], + iconAnchor: [18, 36], + popupAnchor: [0, -36], + }); + + customIconCache.set(iconUrl, icon); + return icon; +}; + export const HomeMarker = ({ position, children }: markerProps) => { return ( @@ -41,3 +110,11 @@ export const SingaporeMarker = ({children}: {children?:string}) => { ); }; +export const AttractionMarker = ({ position, children, iconUrl, mapColor }: markerProps) => { + return ( + + {children && {children}} + + ); +}; + diff --git a/frontend/src/components/navigation/Navbar.tsx b/frontend/src/components/navigation/Navbar.tsx index e946423..60f7617 100644 --- a/frontend/src/components/navigation/Navbar.tsx +++ b/frontend/src/components/navigation/Navbar.tsx @@ -168,12 +168,12 @@ const Navbar = () => { ) : ( <>
  • - + {t("navigation:main.login")}
  • - + {t("navigation:main.signup")}
  • @@ -221,11 +221,11 @@ const Navbar = () => { ) : ( <> -
  • - {t("navigation:main.login")} +
  • + {t("navigation:main.login")}
  • -
  • - {t("navigation:main.signup")} +
  • + {t("navigation:main.signup")}
  • )} diff --git a/frontend/src/components/navigation/menu.ts b/frontend/src/components/navigation/menu.ts index 5a669d9..a21356d 100644 --- a/frontend/src/components/navigation/menu.ts +++ b/frontend/src/components/navigation/menu.ts @@ -31,12 +31,12 @@ export const NavCenterItems: MenuItem[] = [ { title: "navigation:megaMenu.explore.attractions.title", description: "navigation:megaMenu.explore.attractions.description", - link: "/attractions", + link: "/explore/top-attractions", }, { title: "navigation:megaMenu.explore.neighbourhoods.title", description: "navigation:megaMenu.explore.neighbourhoods.description", - link: "/neighbourhoods", + link: "/explore/neighbourhoods", }, ], }, diff --git a/frontend/src/context/LocationContet.tsx b/frontend/src/context/LocationContext.tsx similarity index 100% rename from frontend/src/context/LocationContet.tsx rename to frontend/src/context/LocationContext.tsx diff --git a/frontend/src/pages/public/Home.tsx b/frontend/src/pages/public/Home.tsx index 3a529cb..03afb49 100644 --- a/frontend/src/pages/public/Home.tsx +++ b/frontend/src/pages/public/Home.tsx @@ -4,7 +4,7 @@ import { useCurrency } from "../../context/CurrencyContext"; import { formatCurrency } from "../../utils/formatCurrency"; import HeroSection from "../../components/globals/HeroSection"; import Map from "../../components/maps/Map"; -import { useLocation } from "../../context/LocationContet"; +import { useLocation } from "../../context/LocationContext"; const Home = () => { @@ -14,11 +14,11 @@ const Home = () => { return ( +

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

    -
    Home
    -

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

    +

    {formatCurrency(1000 * rate, currency)}

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

    diff --git a/frontend/src/pages/public/Neighbourhoods.tsx b/frontend/src/pages/public/explore/Neighbourhoods.tsx similarity index 92% rename from frontend/src/pages/public/Neighbourhoods.tsx rename to frontend/src/pages/public/explore/Neighbourhoods.tsx index c642a58..6f26252 100644 --- a/frontend/src/pages/public/Neighbourhoods.tsx +++ b/frontend/src/pages/public/explore/Neighbourhoods.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from "react"; -import CONFIG from "../../config"; +import CONFIG from "../../../config"; -import HoverCard from "../../components/globals/HoverCard"; -import Layout from "../layout"; +import HoverCard from "../../../components/globals/HoverCard"; +import Layout from "../../layout"; import { useTranslation } from "react-i18next"; interface Neighbourhood { diff --git a/frontend/src/pages/public/explore/TopAttractions.tsx b/frontend/src/pages/public/explore/TopAttractions.tsx new file mode 100644 index 0000000..caca8c9 --- /dev/null +++ b/frontend/src/pages/public/explore/TopAttractions.tsx @@ -0,0 +1,498 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import Layout from "../../layout"; +import AttractionsMap from "../../../components/maps/Map"; +import CONFIG from "../../../config"; + +const SINGAPORE_BOUNDS = { + minLat: 1.144, + maxLat: 1.494, + minLng: 103.535, + maxLng: 104.502, +}; + +const CACHE_KEY = "top-attractions-cache-v2"; +const CACHE_TTL_MS = 10 * 60 * 1000; + +type AttractionResponse = { + id: string; + name: string | null; + description: string | null; + latitude: number; + longitude: number; + location_type_id: string | null; + marker_icon: string | null; + map_color: string | null; + map_priority: number | null; + website: string | null; + location_type_name: string | null; +}; + +type LocationType = { + id: string; + name: string; + description: string | null; + icon: string | null; + map_color: string | null; + created_at: string | null; +}; + +type AttractionsApiResponse = { + isClusterMode: boolean; + totalInBounds: number; + returnedCount: number; + data: AttractionResponse[]; +}; + +type MapPoint = { + id: string; + title: string; + description?: string; + position: [number, number]; + priority: number; + typeId?: string | null; + iconUrl?: string | null; + mapColor?: string | null; + website?: string | null; + typeName?: string | null; +}; + +type Viewport = { + minLat: number; + maxLat: number; + minLng: number; + maxLng: number; + zoom: number; +}; + +type CachedAttractionsPayload = { + timestamp: number; + points: MapPoint[]; +}; + +const normalizeTypeId = (value: string | null | undefined) => { + if (!value) return null; + const normalized = value.trim().toLowerCase(); + return normalized.length > 0 ? normalized : null; +}; + +const addAssetVersionParam = ( + iconUrl: string | null, + version: string, +): string | null => { + if (!iconUrl) return null; + + try { + const parsed = new URL(iconUrl); + parsed.searchParams.set("v", version); + return parsed.toString(); + } catch { + // If URL parsing fails, keep original value instead of dropping marker icons. + return iconUrl; + } +}; + +const mapApiPoints = (data: AttractionResponse[], iconVersion: string): MapPoint[] => + data + .map((item) => ({ + id: item.id, + title: item.name ?? "Untitled attraction", + description: item.description ?? undefined, + position: [item.latitude, item.longitude] as [number, number], + priority: item.map_priority ?? 0, + typeId: normalizeTypeId(item.location_type_id), + iconUrl: addAssetVersionParam(item.marker_icon, iconVersion), + mapColor: item.map_color, + website: item.website, + typeName: item.location_type_name, + })) + .sort((a, b) => a.priority - b.priority); + +const getCachedAttractions = (): MapPoint[] | null => { + try { + const rawCache = window.localStorage.getItem(CACHE_KEY); + if (!rawCache) return null; + + const parsed = JSON.parse(rawCache) as CachedAttractionsPayload; + if (!parsed?.timestamp || !Array.isArray(parsed.points)) return null; + if (Date.now() - parsed.timestamp > CACHE_TTL_MS) return null; + + // Ensure cached entries still include type information used by filtering. + const hasTypeIds = parsed.points.some((point) => normalizeTypeId(point.typeId) !== null); + if (!hasTypeIds) return null; + + return parsed.points.map((point) => ({ + ...point, + typeId: normalizeTypeId(point.typeId), + })); + } catch { + return null; + } +}; + +const setCachedAttractions = (points: MapPoint[]) => { + const payload: CachedAttractionsPayload = { + timestamp: Date.now(), + points, + }; + + window.localStorage.setItem(CACHE_KEY, JSON.stringify(payload)); +}; + +const getCellSizeByZoom = (zoom: number) => { + if (zoom < 12) return 0.05; + if (zoom < 13) return 0.03; + return 0.015; +}; + +const selectViewportPoints = (points: MapPoint[], viewport: Viewport | null) => { + if (!viewport) { + return points.slice(0, 80); + } + + const filtered = points.filter((point) => { + const [lat, lng] = point.position; + return ( + lat >= viewport.minLat && + lat <= viewport.maxLat && + lng >= viewport.minLng && + lng <= viewport.maxLng + ); + }); + + filtered.sort((a, b) => a.priority - b.priority); + + if (viewport.zoom >= 14) { + return filtered; + } + + const cellSize = getCellSizeByZoom(viewport.zoom); + const bucketed = new Map(); + + for (const point of filtered) { + const [lat, lng] = point.position; + const latBucket = Math.floor(lat / cellSize); + const lngBucket = Math.floor(lng / cellSize); + const key = `${latBucket}-${lngBucket}`; + + const current = bucketed.get(key); + if (!current || point.priority < current.priority) { + bucketed.set(key, point); + } + } + + return [...bucketed.values()]; +}; + + +const TopAttractions = () => { + const [allPoints, setAllPoints] = useState([]); + const [locationTypes, setLocationTypes] = useState([]); + const [selectedTypeIds, setSelectedTypeIds] = useState([]); + const [viewport, setViewport] = useState(null); + const [loading, setLoading] = useState(false); + const [loadingTypes, setLoadingTypes] = useState(false); + const [stats, setStats] = useState<{ total: number; visible: number; isClusterMode: boolean }>( { + total: 0, + visible: 0, + isClusterMode: true, + }); + const refreshIntervalRef = useRef(null); + + const filteredPoints = useMemo(() => { + if (selectedTypeIds.length === 0) { + return allPoints; + } + + const selectedSet = new Set(selectedTypeIds); + return allPoints.filter( + (point) => point.typeId && selectedSet.has(point.typeId), + ); + }, [allPoints, selectedTypeIds]); + + const visiblePoints = useMemo( + () => selectViewportPoints(filteredPoints, viewport), + [filteredPoints, viewport], + ); + + useEffect(() => { + setStats({ + total: filteredPoints.length, + visible: visiblePoints.length, + isClusterMode: viewport ? viewport.zoom < 14 : true, + }); + }, [filteredPoints.length, visiblePoints.length, viewport]); + + const fetchAttractions = async () => { + setLoading(true); + try { + const params = new URLSearchParams({ + minLat: String(SINGAPORE_BOUNDS.minLat), + maxLat: String(SINGAPORE_BOUNDS.maxLat), + minLng: String(SINGAPORE_BOUNDS.minLng), + maxLng: String(SINGAPORE_BOUNDS.maxLng), + zoom: "20", + cluster: "false", + limit: "10000", + }); + + const response = await fetch(`${CONFIG.API_BASE_URL}/attractions?${params.toString()}`); + if (!response.ok) { + throw new Error(`Failed to fetch attractions: ${response.statusText}`); + } + + const payload: AttractionsApiResponse = await response.json(); + const mappedPoints = mapApiPoints(payload.data, String(Date.now())); + + setAllPoints(mappedPoints); + setCachedAttractions(mappedPoints); + } catch (error) { + console.error("Error fetching attractions:", error); + setAllPoints([]); + setStats({ total: 0, visible: 0, isClusterMode: true }); + } finally { + setLoading(false); + } + }; + + const fetchLocationTypes = async () => { + setLoadingTypes(true); + try { + const response = await fetch(`${CONFIG.API_BASE_URL}/locations/types`); + if (!response.ok) { + throw new Error(`Failed to fetch location types: ${response.statusText}`); + } + + const payload: { data: LocationType[] } = await response.json(); + setLocationTypes( + (payload.data ?? []).map((type) => ({ + ...type, + id: normalizeTypeId(type.id) ?? type.id, + })), + ); + } catch (error) { + console.error("Error fetching location types:", error); + setLocationTypes([]); + } finally { + setLoadingTypes(false); + } + }; + + const handleViewportChange = (nextViewport: { + minLat: number; + maxLat: number; + minLng: number; + maxLng: number; + zoom: number; + }) => { + setViewport(nextViewport); + }; + + useEffect(() => { + void fetchLocationTypes(); + + const cached = getCachedAttractions(); + if (cached) { + setAllPoints(cached); + } + + // Always revalidate in the background so updated marker assets are picked up. + void fetchAttractions(); + + refreshIntervalRef.current = window.setInterval(() => { + void fetchAttractions(); + }, CACHE_TTL_MS); + + return () => { + if (refreshIntervalRef.current) { + window.clearInterval(refreshIntervalRef.current); + } + }; + }, []); + + const selectedTypeSet = useMemo( + () => new Set(selectedTypeIds.map((id) => normalizeTypeId(id) ?? id)), + [selectedTypeIds], + ); + + const typeNameById = useMemo(() => { + const mapped = new Map(); + for (const type of locationTypes) { + const normalizedId = normalizeTypeId(type.id); + if (!normalizedId) continue; + mapped.set(normalizedId, type.name); + } + return mapped; + }, [locationTypes]); + + const countsByType = useMemo(() => { + const counts = new Map(); + for (const point of allPoints) { + if (!point.typeId) continue; + counts.set(point.typeId, (counts.get(point.typeId) ?? 0) + 1); + } + return counts; + }, [allPoints]); + + const toggleType = (typeId: string) => { + const normalizedTypeId = normalizeTypeId(typeId); + if (!normalizedTypeId) return; + + setSelectedTypeIds((prev) => + prev.includes(normalizedTypeId) + ? prev.filter((item) => item !== normalizedTypeId) + : [...prev, normalizedTypeId], + ); + }; + + return ( + +
    +
    +

    Top Attractions

    +

    + Discover the must-see attractions in Singapore. Markers are loaded by + visible map area and zoom level. +

    +

    + {loading + ? "Loading markers..." + : `${stats.visible} markers shown in view (${stats.total} attractions after type filter).`} + {stats.isClusterMode ? " Zoom in to reveal more attractions." : ""} +

    +
    + +
    +
    + +
    + +
    +
    +
    +
    +

    Filter by location 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; + + const selected = selectedTypeSet.has(typeId); + const totalForType = countsByType.get(typeId) ?? 0; + + 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 + + ) : ( + - + )} +
    +
    + )} +
    +
    +
    +
    + ) +} + +export default TopAttractions \ No newline at end of file