merge from lol

This commit is contained in:
priyatham 2025-06-22 00:27:14 -07:00
commit 96e616e88a
11 changed files with 967 additions and 134 deletions

99
package-lock.json generated
View file

@ -10,11 +10,14 @@
"dependencies": {
"-": "^0.0.1",
"@types/leaflet": "^1.9.18",
"framer-motion": "^12.17.3",
"leaflet": "^1.9.4",
"ol": "^10.5.0",
"ol-ext": "^4.0.31",
"react": "^19.1.0",
"react-dock": "^0.8.0",
"react-dom": "^19.0.0",
"react-icons": "^5.5.0",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.5.0",
"save": "^2.9.0",
@ -1316,6 +1319,21 @@
"@types/geojson": "*"
}
},
"node_modules/@types/lodash": {
"version": "4.17.17",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.17.tgz",
"integrity": "sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==",
"license": "MIT"
},
"node_modules/@types/lodash-es": {
"version": "4.17.12",
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/ol": {
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/@types/ol/-/ol-6.5.3.tgz",
@ -1338,7 +1356,6 @@
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.1.tgz",
"integrity": "sha512-ePapxDL7qrgqSF67s0h9m412d9DbXyC1n59O2st+9rjuuamWsZuD2w55rqY12CbzsZ7uVXb5Nw0gEp9Z8MMutQ==",
"dev": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -1839,8 +1856,7 @@
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/debug": {
"version": "4.4.0",
@ -2239,6 +2255,33 @@
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"devOptional": true
},
"node_modules/framer-motion": {
"version": "12.17.3",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.17.3.tgz",
"integrity": "sha512-+1GMxQvZGUu20clzYdUWQY+iUWGBFc6UmpxvZBJjWu++rPdj7imXcQITzVs+QLKyjOpLoPOOar2KAOeYL88YQg==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.17.3",
"motion-utils": "^12.12.1",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/from": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz",
@ -2501,6 +2544,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/lodash.assign": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz",
@ -2568,6 +2617,21 @@
"node": "*"
}
},
"node_modules/motion-dom": {
"version": "12.17.3",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.17.3.tgz",
"integrity": "sha512-nJrH4O9Z6lBSgllD5t+2lF1O8LkBxOq9QSUUmygHJVzHiTLaYpZRgd+U70919IL0CMbFBaFGEJ8hDSwnrmcyZw==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.12.1"
}
},
"node_modules/motion-utils": {
"version": "12.12.1",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.12.1.tgz",
"integrity": "sha512-f9qiqUHm7hWSLlNW8gS9pisnsN7CRFRD58vNjptKdsqFLpkVnX00TNeD6Q0d27V9KzT7ySFyK1TZ/DShfVOv6w==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -2885,6 +2949,20 @@
"node": ">=0.10.0"
}
},
"node_modules/react-dock": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/react-dock/-/react-dock-0.8.0.tgz",
"integrity": "sha512-/sUBWnsNlHIxmxoUHFPq8gcaH5k7MWGuHHeG3UjnXH3UblByxnARlUYewlMBtCUqcmbpPquYE5+GGvaG/hRkkg==",
"license": "MIT",
"dependencies": {
"@types/lodash-es": "^4.17.12",
"lodash-es": "^4.17.21"
},
"peerDependencies": {
"@types/react": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
@ -2896,6 +2974,15 @@
"react": "^19.1.0"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-leaflet": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
@ -3255,6 +3342,12 @@
"typescript": ">=4.8.4"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View file

@ -12,11 +12,14 @@
"dependencies": {
"-": "^0.0.1",
"@types/leaflet": "^1.9.18",
"framer-motion": "^12.17.3",
"leaflet": "^1.9.4",
"ol": "^10.5.0",
"ol-ext": "^4.0.31",
"react": "^19.1.0",
"react-dock": "^0.8.0",
"react-dom": "^19.0.0",
"react-icons": "^5.5.0",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.5.0",
"save": "^2.9.0",

View file

@ -1,4 +1,4 @@
import { useState } from 'react'
import './App.css'
import OpenLayerMap from './components/OpenLayerMap';
import OpenLayerEsri from './components/OpenLayerEsri';
@ -7,12 +7,12 @@ import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
function App() {
//return <OpenLayerMap />
//o
return (
<Router>
<Routes>
<Route path="/" element={<OpenLayerMap />} />
<Route path="/open" element={<OpenLayerMap />} />
<Route path="/esri" element={<OpenLayerEsri />} />
<Route path="/legi" element={<LegiScanAPI />} />
</Routes>
@ -20,4 +20,4 @@ return (
);
}
export default App
export default App;

View file

@ -0,0 +1,40 @@
import { Map } from 'ol';
import 'ol/ol.css';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import GeoJSON from 'ol/format/GeoJSON';
import Style from 'ol/style/Style';
export const loadAndRenderGeoJsonLayer = async (
url: string,
map: Map,
layerRef: React.MutableRefObject<VectorLayer | null>,
style: Style
) => {
try {
const res = await fetch(url);
const geojsonData = await res.json();
if (layerRef.current) {
map.removeLayer(layerRef.current);
}
const vectorSource = new VectorSource({
features: new GeoJSON().readFeatures(geojsonData, {
featureProjection: 'EPSG:3857',
}),
});
const vectorLayer = new VectorLayer({
source: vectorSource,
style: style,
});
map.addLayer(vectorLayer);
layerRef.current = vectorLayer;
} catch (err) {
console.error(`Failed to load layer from ${url}:`, err);
}
};

View file

@ -0,0 +1,137 @@
// src/config/layerConfig.ts
import { Style, Stroke, Fill } from 'ol/style';
export interface LayerConfig {
radius: number;
topic: string;
getUrl: (lon: number, lat: number, radius: number) => string;
style: Style;
}
const buildUrl =
(topic: string) =>
(lon: number, lat: number, radius: number) =>
`https://geo.sandag.org/server/rest/services/Hosted/${topic}/FeatureServer/0/query?geometry=${lon},${lat}&geometryType=esriGeometryPoint&inSR=4326&spatialRel=esriSpatialRelIntersects&distance=${radius}&units=esriSRUnit_Meter&outFields=*&returnGeometry=true&f=geojson`;
const baseConfigs: Omit<LayerConfig, 'getUrl'>[] = [
{
radius: 1,
topic: 'Parcels',
style: new Style({
stroke: new Stroke({ color: 'green', width: 1.5 }),
fill: new Fill({ color: 'rgba(0, 255, 0, 0.1)' }),
}),
},
{
radius: 100,
topic: 'Sewer_Main_SD',
style: new Style({
stroke: new Stroke({ color: 'purple', width: 2 }),
}),
},
{
radius: 50,
topic: 'Zoning_Base_SD',
style: new Style({
stroke: new Stroke({ color: 'red', width: 1 }),
}),
},
{
radius: 50,
topic: 'Water_Main_SD',
style: new Style({
stroke: new Stroke({ color: 'blue', width: 5 }),
}),
},
{
radius: 1000,
topic: 'Sustainable_Development_Areas',
style: new Style({
stroke: new Stroke({ color: 'green', width: 3 }),
}),
},
{
radius: 1000,
topic: 'Housing_Solution_Areas',
style: new Style({
stroke: new Stroke({ color: 'black', width: 2.5 }),
fill: new Fill({ color: 'rgba(255, 200, 0, 0.1)' }),
}),
},
{
radius: 100000,
topic: 'Promise_Zones_SD',
style: new Style({
stroke: new Stroke({ color: 'black', width: 2.5 }),
fill: new Fill({ color: 'rgba(116, 3, 255, 0.1)' }),
}),
},
{
radius: 100000,
topic: 'MHPA_SD',
style: new Style({
stroke: new Stroke({ color: 'orange', width: 2.5 }),
fill: new Fill({ color: 'rgba(116, 3, 255, 0.1)' }),
}),
},
{
radius: 100000,
topic: 'Coastal_Zones',
style: new Style({
stroke: new Stroke({ color: 'skin', width: 2.5 }),
fill: new Fill({ color: 'rgba(116, 3, 255, 0.1)' }),
}),
},
{
radius: 100000,
topic: 'Community_Plan_Implementation_Overlay_Zone_SD',
style: new Style({
stroke: new Stroke({ color: 'darkblue', width: 2.5 }),
fill: new Fill({ color: 'rgba(116, 3, 255, 0.1)' }),
}),
},
{
radius: 100000,
topic: 'Airport_Influence_Area',
style: new Style({
stroke: new Stroke({ color: 'darkyellow', width: 2.5 }),
fill: new Fill({ color: 'rgba(116, 3, 255, 0.1)' }),
}),
},
{
radius: 100000,
topic: 'Historic_Districts_SD',
style: new Style({
stroke: new Stroke({ color: 'white', width: 2.5 }),
fill: new Fill({ color: 'rgba(116, 3, 255, 0.1)' }),
}),
},
{
radius: 100000,
topic: 'HRB_Designated_Resources_SD',
style: new Style({
stroke: new Stroke({ color: 'darkgreen', width: 2.5 }),
fill: new Fill({ color: 'rgba(116, 3, 255, 0.1)' }),
}),
},
{
radius: 100000,
topic: 'Fire_Hazard_Severity_Zones_SD',
style: new Style({
stroke: new Stroke({ color: 'yellow', width: 1.5 }),
fill: new Fill({ color: 'rgba(116, 3, 255, 0.1)' }),
}),
},
];
export const layerConfigs: LayerConfig[] = baseConfigs.map(cfg => ({
...cfg,
getUrl: buildUrl(cfg.topic),
}));

View file

@ -0,0 +1,49 @@
// src/Methods/addMarker.ts
import Feature from 'ol/Feature';
import Point from 'ol/geom/Point';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import Style from 'ol/style/Style';
import Icon from 'ol/style/Icon';
import { Map } from 'ol';
import { Coordinate } from 'ol/coordinate';
/**
* Adds a custom icon marker to the map at a given coordinate,
* replacing the previous marker layer if one exists.
*/
export const addMarker = (
coords: Coordinate,
map: Map,
existingMarkerLayer: VectorLayer | null
): VectorLayer => {
// Create feature for the marker
const markerFeature = new Feature({
geometry: new Point(coords),
});
markerFeature.setStyle(
new Style({
image: new Icon({
anchor: [0.5, 1],
src: 'https://cdn-icons-png.flaticon.com/512/684/684908.png',
scale: 0.05,
}),
})
);
// Create new layer with this feature
const markerSource = new VectorSource({ features: [markerFeature] });
const markerLayer = new VectorLayer({ source: markerSource });
// Remove old marker layer if it exists
if (existingMarkerLayer) {
map.removeLayer(existingMarkerLayer);
}
// Add the new marker layer
map.addLayer(markerLayer);
return markerLayer;
};

View file

@ -6,164 +6,266 @@ import { OSM } from 'ol/source';
import { fromLonLat } from 'ol/proj';
import { Coordinate } from 'ol/coordinate';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import GeoJSON from 'ol/format/GeoJSON';
import Style from 'ol/style/Style';
import Stroke from 'ol/style/Stroke';
import Fill from 'ol/style/Fill';
import Icon from 'ol/style/Icon';
import Point from 'ol/geom/Point';
import Feature from 'ol/Feature';
import { layerConfigs } from './Methods/Layers';
import { loadAndRenderGeoJsonLayer } from './Methods/GeoJson_Load'
import { addMarker } from './Methods/Marker';
const OpenLayersMap = () => {
const [term, setTerm] = useState<string>('');
const [address, setAddress] = useState();
const [layerVisibility, setLayerVisibility] = useState<Record<string, boolean>>({});
const [searchCompleted, setSearchCompleted] = useState(false);
//Submit Button Logic
// Submit Button Logic
const submitForm = async (event: React.FormEvent<HTMLFormElement>) => {
// Preventing the page from reloading
event.preventDefault();
const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${term}&format=json&polygon=1&addressdetails=1`);
const json = await response.json();
console.log(json);
setAddress(json);
console.log(address);
setSearchCompleted(true);
// Initialize all layers as visible
const initialVisibility = Object.fromEntries(
layerConfigs.map(layer => [layer.topic, true])
);
setLayerVisibility(initialVisibility);
alert(term);
}
//Submit Button Logic
//const [data, setData] = useState({});
const mapDivRef = useRef<HTMLDivElement>(null);
const [clickedCoordinate, setClickedCoordinate] = useState<Coordinate>();
const mapRef = useRef<Map | null>(null);
const markerLayerRef = useRef<VectorLayer | null>(null);
const parcelLayerRef = useRef<VectorLayer | null>(null);
const sewerLayerRef = useRef<VectorLayer | null>(null);
const layerRefs = useRef<Record<string, React.MutableRefObject<VectorLayer | null>>>(
Object.fromEntries(
layerConfigs.map((layer) => [layer.topic, { current: null }])
)
);
useEffect(() => {
if (!mapRef.current) {
const map = new Map({
target: mapDivRef.current as HTMLDivElement,
layers: [new TileLayer({ source: new OSM() })],
});
mapRef.current = map;
map.on('click', (e) => {
setClickedCoordinate(e.coordinate);
});
}
if (address && address.length > 0) {
const lat = parseFloat(address[0].lat);
const lon = parseFloat(address[0].lon);
const coords = fromLonLat([lon, lat]);
mapRef.current!.setView(new View({
center: coords,
zoom: 15,
}));
const markerFeature = new Feature({
geometry: new Point(coords),
});
markerFeature.setStyle(new Style({
image: new Icon({
anchor: [0.5, 1],
src: 'https://cdn-icons-png.flaticon.com/512/684/684908.png',
scale: 0.05,
}),
}));
const markerSource = new VectorSource({ features: [markerFeature] });
const markerLayer = new VectorLayer({ source: markerSource });
if (markerLayerRef.current) {
mapRef.current!.removeLayer(markerLayerRef.current);
// Toggle layer visibility
const toggleLayer = (layerTopic: string) => {
const layerRef = layerRefs.current[layerTopic];
const isCurrentlyVisible = layerVisibility[layerTopic];
if (layerRef.current) {
layerRef.current.setVisible(!isCurrentlyVisible);
}
mapRef.current!.addLayer(markerLayer);
markerLayerRef.current = markerLayer;
setLayerVisibility(prev => ({
...prev,
[layerTopic]: !isCurrentlyVisible
}));
};
const radius = 10;
const parcelUrl = `https://geo.sandag.org/server/rest/services/Hosted/Parcels/FeatureServer/0/query?geometry=${lon},${lat}&geometryType=esriGeometryPoint&inSR=4326&spatialRel=esriSpatialRelIntersects&distance=${radius}&units=esriSRUnit_Meter&outFields=*&returnGeometry=true&f=geojson`;
const sewerUrl = `https://geo.sandag.org/server/rest/services/Hosted/Sewer_Main_SD/FeatureServer/0/query?geometry=${lon},${lat}&geometryType=esriGeometryPoint&inSR=4326&spatialRel=esriSpatialRelIntersects&distance=${radius}&units=esriSRUnit_Meter&outFields=*&returnGeometry=true&f=geojson`;
// Format layer name for display
const formatLayerName = (topic: string) => {
return topic.replace(/_/g, ' ').replace(/SD$/, '').trim();
};
fetch(parcelUrl)
.then((res) => res.json())
.then((parcelData) => {
if (parcelLayerRef.current) {
mapRef.current?.removeLayer(parcelLayerRef.current);
}
const parcelSource = new VectorSource({
features: new GeoJSON().readFeatures(parcelData, {
featureProjection: 'EPSG:3857',
}),
});
const parcelLayer = new VectorLayer({
source: parcelSource,
style: new Style({
stroke: new Stroke({ color: 'green', width: 1.5 }),
fill: new Fill({ color: 'rgba(0, 255, 0, 0.1)' }),
}),
});
mapRef.current?.addLayer(parcelLayer);
parcelLayerRef.current = parcelLayer;
useEffect(() => {
if (!mapRef.current) {
const map = new Map({
target: mapDivRef.current as HTMLDivElement,
layers: [new TileLayer({ source: new OSM() })],
});
fetch(sewerUrl)
.then((res) => res.json())
.then((sewerData) => {
if (sewerLayerRef.current) {
mapRef.current?.removeLayer(sewerLayerRef.current);
}
const sewerSource = new VectorSource({
features: new GeoJSON().readFeatures(sewerData, {
featureProjection: 'EPSG:3857',
}),
});
const sewerLayer = new VectorLayer({
source: sewerSource,
style: new Style({
stroke: new Stroke({ color: 'blue', width: 2 }),
}),
});
mapRef.current?.addLayer(sewerLayer);
sewerLayerRef.current = sewerLayer;
mapRef.current = map;
map.on('click', (e) => {
setClickedCoordinate(e.coordinate);
});
}
}, [address]);
}
if (address && address.length > 0) {
const lat = parseFloat(address[0].lat);
const lon = parseFloat(address[0].lon);
const coords = fromLonLat([lon, lat]);
mapRef.current!.setView(new View({
center: coords,
zoom: 15,
}));
//UI Downstairs
markerLayerRef.current = addMarker(coords, mapRef.current!, markerLayerRef.current);
layerConfigs.forEach((layerConfig) => {
const url = layerConfig.getUrl(lon, lat, layerConfig.radius);
const ref = layerRefs.current[layerConfig.topic];
loadAndRenderGeoJsonLayer(
url,
mapRef.current!,
ref,
layerConfig.style
);
});
}
}, [address]);
// UI
return (
<>
<div ref={mapDivRef} className='map'>
<div className="container">
<form onSubmit={submitForm}>
<input
value={term}
onChange={(e) => setTerm(e.target.value)}
type="text"
placeholder="Enter a term"
className="input"
/>
<button type="submit" className="btn">Submit</button>
</form>
<div style={{ display: 'flex', height: '100vh', width: '100vw' }}>
{/* Sidebar */}
<div style={{
width: '320px',
backgroundColor: '#f8f9fa',
borderRight: '1px solid #dee2e6',
padding: '20px',
overflowY: 'auto',
boxShadow: '2px 0 5px rgba(0,0,0,0.1)'
}}>
<h3 style={{
margin: '0 0 20px 0',
color: '#333',
fontSize: '18px',
fontWeight: '600'
}}>
Map Layers
</h3>
{/* Search Form */}
<form onSubmit={submitForm} style={{ marginBottom: '20px' }}>
<input
value={term}
onChange={(e) => setTerm(e.target.value)}
type="text"
placeholder="Enter address to search"
style={{
width: '100%',
padding: '10px',
border: '1px solid #ddd',
borderRadius: '4px',
marginBottom: '10px',
fontSize: '14px'
}}
/>
<button
type="submit"
style={{
width: '100%',
padding: '10px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
Search
</button>
</form>
{/* Layer Toggle List */}
<div>
<h4 style={{
margin: '0 0 15px 0',
color: '#666',
fontSize: '14px',
fontWeight: '500',
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}>
Available Layers ({layerConfigs.length})
</h4>
{layerConfigs.map((layer) => (
<div key={layer.topic} style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 0',
borderBottom: '1px solid #eee'
}}>
<span style={{
fontSize: '13px',
color: '#333',
flex: 1,
marginRight: '10px'
}}>
{formatLayerName(layer.topic)}
</span>
{searchCompleted && (
<label style={{
position: 'relative',
display: 'inline-block',
width: '44px',
height: '24px'
}}>
<input
type="checkbox"
checked={layerVisibility[layer.topic] || false}
onChange={() => toggleLayer(layer.topic)}
style={{ opacity: 0, width: 0, height: 0 }}
/>
<span style={{
position: 'absolute',
cursor: 'pointer',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: layerVisibility[layer.topic] ? '#007bff' : '#ccc',
borderRadius: '24px',
transition: '0.3s',
display: 'flex',
alignItems: 'center'
}}>
<span style={{
position: 'absolute',
content: '',
height: '18px',
width: '18px',
left: layerVisibility[layer.topic] ? '23px' : '3px',
bottom: '3px',
backgroundColor: 'white',
borderRadius: '50%',
transition: '0.3s',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)'
}} />
</span>
</label>
)}
{!searchCompleted && (
<span style={{
fontSize: '11px',
color: '#999',
fontStyle: 'italic'
}}>
Search first
</span>
)}
</div>
))}
</div>
</div>
{clickedCoordinate && (
<span className='coordinates-container'>
You clicked at: {clickedCoordinate[0]} /{' '} {clickedCoordinate[1]}
</span>
)}
</>
{/* Map Container */}
<div style={{ flex: 1, position: 'relative' }}>
<div ref={mapDivRef} style={{ width: '100%', height: '100%' }} />
{clickedCoordinate && (
<div style={{
position: 'absolute',
bottom: '20px',
left: '20px',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
color: 'white',
padding: '10px 15px',
borderRadius: '4px',
fontSize: '12px',
fontFamily: 'monospace'
}}>
Coordinates: {clickedCoordinate[0].toFixed(2)} / {clickedCoordinate[1].toFixed(2)}
</div>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,56 @@
.dock-outer {
margin: 0 0.5rem;
display: flex;
max-width: 100%;
align-items: center;
}
.dock-panel {
position: absolute;
bottom: 0.5rem;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: flex-end;
width: fit-content;
gap: 1rem;
border-radius: 1rem;
background-color: #060010;
border: 1px solid #222;
padding: 0 0.5rem 0.5rem;
}
.dock-item {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background-color: #060010;
border: 1px solid #222;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
cursor: pointer;
outline: none;
}
.dock-icon {
display: flex;
align-items: center;
justify-content: center;
}
.dock-label {
position: absolute;
top: -1.5rem;
left: 50%;
width: fit-content;
white-space: pre;
border-radius: 0.375rem;
border: 1px solid #222;
background-color: #060010;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
color: #fff;
transform: translateX(-50%);
}

205
src/components/UI/Dock.tsx Normal file
View file

@ -0,0 +1,205 @@
"use client";
import "./Dock.css";
import {
motion,
MotionValue,
useMotionValue,
useSpring,
useTransform,
type SpringOptions,
AnimatePresence,
} from "framer-motion";
import React, {
Children,
cloneElement,
useEffect,
useMemo,
useRef,
useState,
} from "react";
export type DockItemData = {
icon: React.ReactNode;
label: React.ReactNode;
onClick: () => void;
className?: string;
};
export type DockProps = {
items: DockItemData[];
className?: string;
distance?: number;
panelHeight?: number;
baseItemSize?: number;
dockHeight?: number;
magnification?: number;
spring?: SpringOptions;
};
type DockItemProps = {
className?: string;
children: React.ReactNode;
onClick?: () => void;
mouseX: MotionValue;
spring: SpringOptions;
distance: number;
baseItemSize: number;
magnification: number;
};
function DockItem({
children,
className = "",
onClick,
mouseX,
spring,
distance,
magnification,
baseItemSize,
}: DockItemProps) {
const ref = useRef<HTMLDivElement>(null);
const isHovered = useMotionValue(0);
const mouseDistance = useTransform(mouseX, (val) => {
const rect = ref.current?.getBoundingClientRect() ?? {
x: 0,
width: baseItemSize,
};
return val - rect.x - baseItemSize / 2;
});
const targetSize = useTransform(
mouseDistance,
[-distance, 0, distance],
[baseItemSize, magnification, baseItemSize]
);
const size = useSpring(targetSize, spring);
return (
<motion.div
ref={ref}
style={{
width: size,
height: size,
}}
onHoverStart={() => isHovered.set(1)}
onHoverEnd={() => isHovered.set(0)}
onFocus={() => isHovered.set(1)}
onBlur={() => isHovered.set(0)}
onClick={onClick}
className={`dock-item ${className}`}
tabIndex={0}
role="button"
aria-haspopup="true"
>
{Children.map(children, (child) =>
cloneElement(child as React.ReactElement, { })
)}
</motion.div>
);
}
type DockLabelProps = {
className?: string;
children: React.ReactNode;
};
function DockLabel({ children, className = "", ...rest }: DockLabelProps) {
const { isHovered } = rest as { isHovered: MotionValue<number> };
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const unsubscribe = isHovered.on("change", (latest) => {
setIsVisible(latest === 1);
});
return () => unsubscribe();
}, [isHovered]);
return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: 1, y: -10 }}
exit={{ opacity: 0, y: 0 }}
transition={{ duration: 0.2 }}
className={`dock-label ${className}`}
role="tooltip"
style={{ x: "-50%" }}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
}
type DockIconProps = {
className?: string;
children: React.ReactNode;
};
function DockIcon({ children, className = "" }: DockIconProps) {
return <div className={`dock-icon ${className}`}>{children}</div>;
}
export default function Dock({
items,
className = "",
spring = { mass: 0.1, stiffness: 150, damping: 12 },
magnification = 70,
distance = 200,
panelHeight = 68,
dockHeight = 256,
baseItemSize = 50,
}: DockProps) {
const mouseX = useMotionValue(Infinity);
const isHovered = useMotionValue(0);
const maxHeight = useMemo(
() => Math.max(dockHeight, magnification + magnification / 2 + 4),
[magnification, dockHeight]
);
const heightRow = useTransform(isHovered, [0, 1], [panelHeight, maxHeight]);
const height = useSpring(heightRow, spring);
return (
<motion.div
style={{ height, scrollbarWidth: "none" }}
className="dock-outer"
>
<motion.div
onMouseMove={({ pageX }) => {
isHovered.set(1);
mouseX.set(pageX);
}}
onMouseLeave={() => {
isHovered.set(0);
mouseX.set(Infinity);
}}
className={`dock-panel ${className}`}
style={{ height: panelHeight }}
role="toolbar"
aria-label="Application dock"
>
{items.map((item, index) => (
<DockItem
key={index}
onClick={item.onClick}
className={item.className}
mouseX={mouseX}
spring={spring}
distance={distance}
magnification={magnification}
baseItemSize={baseItemSize}
>
<DockIcon>{item.icon}</DockIcon>
<DockLabel>{item.label}</DockLabel>
</DockItem>
))}
</motion.div>
</motion.div>
);
}

View file

@ -0,0 +1,84 @@
/* Full height setup */
html, body, #root {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #f0f2f5;
}
/* Page container with space for navbar and sidebar */
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
height: 100vh;
padding-top: 80px; /* Reserve space for a top bar */
padding-left: 240px; /* Reserve space for a sidebar if needed */
box-sizing: border-box;
}
/* Card-like map container */
.map-container {
width: 700px;
height: 700px;
background-color: white;
border-radius: 20px;
box-shadow: 0 10px 20px rgba(0,0,0,0.05);
border: 1px solid #e0e0e0;
overflow: hidden;
position: relative;
}
/* The actual OpenLayers map inside the container */
.map {
width: 700px;
height: 700px;
}
.submit {
background: linear-gradient(135deg, #007AFF, #0051D5); /* iOS blue gradient */
color: white;
padding: 12px 28px;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
box-shadow: 0 6px 12px rgba(0, 122, 255, 0.3);
transition: all 0.2s ease-in-out;
outline: none;
}
.submit:hover {
background: linear-gradient(135deg, #005AE0, #0042B1);
transform: translateY(-2px);
box-shadow: 0 10px 18px rgba(0, 122, 255, 0.4);
}
.submit:active {
transform: translateY(1px);
box-shadow: 0 4px 10px rgba(0, 122, 255, 0.2);
}
.input-field {
width: 80%;
padding: 12px 16px;
font-size: 16px;
border: 1px solid #d1d1d6;
border-radius: 12px;
outline: none;
margin-bottom: 20px;
background-color: #f9f9f9;
color: #333;
transition: border 0.2s, box-shadow 0.2s;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
}
.input-field:focus {
border-color: #007AFF;
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.2);
background-color: #fff;
}

View file

@ -0,0 +1,64 @@
import { useEffect, useRef } from 'react';
import { Map, View } from 'ol';
import TileLayer from 'ol/layer/Tile';
import { OSM } from 'ol/source';
import { fromLonLat } from 'ol/proj';
import './Map_page.css';
import Dock from './Dock';
// ✅ Make sure these are imported from 'react-icons/vsc'
import { VscHome, VscArchive, VscAccount, VscSettingsGear } from 'react-icons/vsc';
const Map_page = () => {
const mapRef = useRef<Map | null>(null);
const mapDivRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!mapRef.current) {
const map = new Map({
target: mapDivRef.current as HTMLDivElement,
layers: [new TileLayer({ source: new OSM() })],
view: new View({
center: fromLonLat([-117.1611, 32.7157]), // Default to San Diego
zoom: 10,
}),
});
mapRef.current = map;
}
}, []);
const items = [
{ icon: <VscHome size={18} />, label: 'Home', onClick: () => alert('Home!') },
{ icon: <VscArchive size={18} />, label: 'Archive', onClick: () => alert('Archive!') },
{ icon: <VscAccount size={18} />, label: 'Profile', onClick: () => alert('Profile!') },
{ icon: <VscSettingsGear size={18} />, label: 'Settings', onClick: () => alert('Settings!') },
];
return (
<>
<div className="container">
<div className="map-container">
<div ref={mapDivRef} className="map" />
</div>
<div className="sidebar">
<input type="text" placeholder="Enter a location..." className="input-field" />
<button className="submit">Submit</button>
</div>
<Dock
items={items}
panelHeight={68}
baseItemSize={50}
magnification={70}
/>
</div>
</>
);
};
export default Map_page;