diff --git a/components/map/mockData.ts b/components/map/mockData.ts new file mode 100644 index 0000000..24cb725 --- /dev/null +++ b/components/map/mockData.ts @@ -0,0 +1,36 @@ +export type MapItem = { + id: number; + title: string; + lat: number; + lng: number; +}; + +export const requests: MapItem[] = [ + { + id: 1, + title: "Food Assistance – Petaling Jaya", + lat: 3.1073, + lng: 101.6067, + }, + { + id: 2, + title: "Medical Help – Cheras", + lat: 3.0800, + lng: 101.7350, + }, +]; + +export const offers: MapItem[] = [ + { + id: 3, + title: "Free Meals – Bangsar", + lat: 3.1303, + lng: 101.6640, + }, + { + id: 4, + title: "Clothes Donation – Setapak", + lat: 3.2100, + lng: 101.7200, + }, +]; diff --git a/package-lock.json b/package-lock.json index 07c49e5..6d8706c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "openhelpmap", + "name": "open-help-map", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "openhelpmap", + "name": "open-help-map", "version": "0.0.0", "dependencies": { "@supabase/supabase-js": "^2.90.1", @@ -65,6 +65,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1562,6 +1563,7 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1630,6 +1632,7 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -1881,6 +1884,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1986,6 +1990,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2226,6 +2231,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2736,7 +2742,8 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/levn": { "version": "0.4.1", @@ -2943,6 +2950,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3004,6 +3012,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3013,6 +3022,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3294,6 +3304,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3379,6 +3390,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3541,6 +3553,7 @@ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/public/markers/blue.png b/public/markers/blue.png new file mode 100644 index 0000000..e2e9f75 Binary files /dev/null and b/public/markers/blue.png differ diff --git a/public/markers/red.png b/public/markers/red.png new file mode 100644 index 0000000..3e64e06 Binary files /dev/null and b/public/markers/red.png differ diff --git a/public/markers/shadow.png b/public/markers/shadow.png new file mode 100644 index 0000000..9fd2979 Binary files /dev/null and b/public/markers/shadow.png differ diff --git a/src/components/map/MapPin.tsx b/src/components/map/MapPin.tsx new file mode 100644 index 0000000..4ac5dcb --- /dev/null +++ b/src/components/map/MapPin.tsx @@ -0,0 +1,38 @@ +import { Marker, Popup } from "react-leaflet"; +import "../../styles/map.css"; +import type { MapItem } from "./mockData"; + +type Props = { + item: MapItem; +}; + +export default function MapPin({ item }: Props) { + + // 🔹 Simulated logged-in user role + // Change to "user" to test restriction + const userRole: "user" | "volunteer" = "volunteer"; + + return ( + + +
+

{item.title}

+ + {item.pickup_allowed ? ( + + ) : userRole === "volunteer" ? ( + + ) : ( +

+ Delivery assistance available for volunteers only +

+ )} +
+
+
+ ); +} diff --git a/src/components/map/RadiusControl.tsx b/src/components/map/RadiusControl.tsx new file mode 100644 index 0000000..71cb8f1 --- /dev/null +++ b/src/components/map/RadiusControl.tsx @@ -0,0 +1,30 @@ +type Props = { + radius: number; + setRadius: (r: number) => void; +}; + +export default function RadiusControl({ radius, setRadius }: Props) { + return ( +
+ Radius: {radius} km + setRadius(Number(e.target.value))} + /> +
+ ); +} diff --git a/src/components/map/UserLocation.tsx b/src/components/map/UserLocation.tsx new file mode 100644 index 0000000..1b4a6f2 --- /dev/null +++ b/src/components/map/UserLocation.tsx @@ -0,0 +1,30 @@ +import { useEffect } from "react"; +import { Marker, Popup, useMap } from "react-leaflet"; + +type Props = { + setUserPos: (pos: [number, number]) => void; +}; + +export default function UserLocation({ setUserPos }: Props) { + const map = useMap(); + + useEffect(() => { + if (!navigator.geolocation) return; + + navigator.geolocation.getCurrentPosition( + (pos) => { + const coords: [number, number] = [ + pos.coords.latitude, + pos.coords.longitude, + ]; + setUserPos(coords); + map.setView(coords, 14); + }, + (err) => { + console.error("GPS error:", err); + } + ); + }, [map, setUserPos]); + + return null; +} diff --git a/src/components/map/distance.ts b/src/components/map/distance.ts new file mode 100644 index 0000000..d9a8917 --- /dev/null +++ b/src/components/map/distance.ts @@ -0,0 +1,18 @@ +export const getDistanceKm = ( + lat1: number, + lon1: number, + lat2: number, + lon2: number +) => { + const R = 6371; + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLon = ((lon2 - lon1) * Math.PI) / 180; + + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos((lat1 * Math.PI) / 180) * + Math.cos((lat2 * Math.PI) / 180) * + Math.sin(dLon / 2) ** 2; + + return R * (2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))); +}; diff --git a/src/components/map/mapIcons.ts b/src/components/map/mapIcons.ts new file mode 100644 index 0000000..827e486 --- /dev/null +++ b/src/components/map/mapIcons.ts @@ -0,0 +1,22 @@ +import L from "leaflet"; + +const baseStyle = ` + width: 16px; + height: 16px; + border-radius: 50%; + border: 3px solid white; +`; + +export const requestIcon = L.divIcon({ + className: "", + html: `
`, + iconSize: [16, 16], + iconAnchor: [8, 8], +}); + +export const offerIcon = L.divIcon({ + className: "", + html: `
`, + iconSize: [16, 16], + iconAnchor: [8, 8], +}); diff --git a/src/components/map/mockData.ts b/src/components/map/mockData.ts new file mode 100644 index 0000000..0b6ea78 --- /dev/null +++ b/src/components/map/mockData.ts @@ -0,0 +1,18 @@ +export type MapItem = { + id: number; + title: string; + lat: number; + lng: number; + pickup_allowed: boolean; +}; + + +export const requests: MapItem[] = [ + { id: 1, title: "Food Assistance", lat: 3.1405, lng: 101.6877, pickup_allowed: true }, + { id: 2, title: "Medical Supplies", lat: 3.1302, lng: 101.6953, pickup_allowed: false }, +]; + +export const offers: MapItem[] = [ + { id: 3, title: "Free Meals", lat: 3.1501, lng: 101.6823, pickup_allowed: true }, + { id: 4, title: "Clothes Donation", lat: 3.1358, lng: 101.6754, pickup_allowed: false }, +]; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..6ee4d9e --- /dev/null +++ b/src/index.css @@ -0,0 +1,17 @@ +* { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; + width: 100%; + margin: 0; + padding: 0; +} + +.leaflet-container { + height: 100%; + width: 100%; +} diff --git a/src/main.tsx b/src/main.tsx index fd10eb6..0c7d451 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,9 +1,12 @@ -import { StrictMode } from "react" -import { createRoot } from "react-dom/client" -import Map from "./pages/Map/index.tsx"; +import "./index.css"; +import React from "react"; +import ReactDOM from "react-dom/client"; +import MapPage from "./pages/MapPage"; +import "leaflet/dist/leaflet.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +); -createRoot(document.getElementById("root")!).render( - - - , -) diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx new file mode 100644 index 0000000..770a25f --- /dev/null +++ b/src/pages/MapPage.tsx @@ -0,0 +1,69 @@ +import { + MapContainer, + TileLayer, + Marker, + Popup, + CircleMarker, +} from "react-leaflet"; +import { useState } from "react"; +import UserLocation from "../components/map/UserLocation"; +import RadiusControl from "../components/map/RadiusControl"; +import { requests, offers } from "../components/map/mockData"; +import { getDistanceKm } from "../components/map/distance"; +import "leaflet/dist/leaflet.css"; + +export default function MapPage() { + const [userPos, setUserPos] = useState<[number, number] | null>(null); + const [radius, setRadius] = useState(5); + + const withinRadius = (lat: number, lng: number) => { + if (!userPos) return true; + return getDistanceKm(userPos[0], userPos[1], lat, lng) <= radius; + }; + + return ( + + + + + + {userPos && ( + + 📍 You are here + + )} + + {requests + .filter((r) => withinRadius(r.lat, r.lng)) + .map((r) => ( + + 🔴 {r.title} + + ))} + + {offers + .filter((o) => withinRadius(o.lat, o.lng)) + .map((o) => ( + + 🔵 {o.title} + + ))} + + + + ); +} diff --git a/src/styles/map.css b/src/styles/map.css new file mode 100644 index 0000000..5f14979 --- /dev/null +++ b/src/styles/map.css @@ -0,0 +1,19 @@ +html, +body, +#root { + height: 100%; + margin: 0; + padding: 0; +} + +.radius-control { + position: absolute; + bottom: 20px; + left: 20px; + background: white; + padding: 10px 12px; + border-radius: 8px; + z-index: 1000; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); + font-size: 14px; +} diff --git a/src/utils/leafletIconFix.ts b/src/utils/leafletIconFix.ts new file mode 100644 index 0000000..c14cb87 --- /dev/null +++ b/src/utils/leafletIconFix.ts @@ -0,0 +1,10 @@ +import L from "leaflet"; +import icon from "leaflet/dist/images/marker-icon.png"; +import iconShadow from "leaflet/dist/images/marker-shadow.png"; + +delete (L.Icon.Default.prototype as any)._getIconUrl; + +L.Icon.Default.mergeOptions({ + iconUrl: icon, + shadowUrl: iconShadow, +});