{point.title}
++ {typeNameById.get(normalizeTypeId(point.typeId) ?? "") ?? + point.typeName ?? + "Uncategorized"} +
+ {point.website && ( + + Visit + + )} +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 (
{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+ Priority, then closeness to map center. +
++ No locations found for the selected type filters. +
+ ) : ( ++ {typeNameById.get(normalizeTypeId(point.typeId) ?? "") ?? + point.typeName ?? + "Uncategorized"} +
+ {point.website && ( + + Visit + + )} ++ Rendering map. Please wait... +
++ Narrow the map to a specific attraction type. +
Loading types...
) : locationTypes.length === 0 ? (No location types available.
) : ( -- No locations found for the selected type filters. -
- ) : ( -| Name | -Type | -Website | -
|---|---|---|
| {point.title} | -- {typeNameById.get(normalizeTypeId(point.typeId) ?? "") ?? - point.typeName ?? - "Uncategorized"} - | -- {point.website ? ( - - Visit - - ) : ( - - - )} - | -