Major UI changes undergoing #2
21 changed files with 1437 additions and 2144 deletions
22
Jenkinsfile
vendored
22
Jenkinsfile
vendored
|
|
@ -1,22 +0,0 @@
|
|||
pipeline
|
||||
{
|
||||
agent {dockerfile true}
|
||||
stages
|
||||
{
|
||||
stage ('Build')
|
||||
{
|
||||
steps
|
||||
{
|
||||
echo 'running build'
|
||||
|
||||
}
|
||||
}
|
||||
stage ('Test')
|
||||
{
|
||||
steps
|
||||
{
|
||||
echo 'Running Test...'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
291
README.md
291
README.md
|
|
@ -1,259 +1,54 @@
|
|||
# React + TypeScript + Vite
|
||||
|
||||
A lightweight React + GeoJSON based mapping interface designed to visualize, explore, and interact with geospatial datasets using modern web technologies.
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
## Problem
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
Geospatial datasets are often difficult to visualize without specialized GIS software. Analysts, developers, and researchers need a simpler, browser-based tool that can:
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
* Load and display GeoJSON layers dynamically
|
||||
* Visualize markers, regions, and polygons interactively
|
||||
* Switch between different map layers and base tiles
|
||||
* Integrate custom APIs for external data (e.g., legislation, statistics)
|
||||
* Provide an intuitive UI that works seamlessly without GIS expertise
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
Traditional tools are either too heavy, too costly, or lack customization options for developers looking to embed maps in their applications.
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
MapLayer provides an extensible React-based mapping application built using OpenLayers, capable of:
|
||||
|
||||
* Rendering GeoJSON datasets directly inside the browser
|
||||
* Displaying multiple layers with controls for toggling visibility
|
||||
* Adding marker overlays with custom interactivity
|
||||
* Integrating external APIs (e.g., LegiScan API)
|
||||
* Offering a clean UI for switching maps, searching, and navigating
|
||||
* Deploying at scale using Docker, Jenkins, and containerized workflows
|
||||
|
||||
It enables quick visualization and experimentation with spatial datasets while staying lightweight and developer-friendly.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Core Features
|
||||
|
||||
* Load and visualize GeoJSON datasets with OpenLayers
|
||||
* Multiple layer rendering (base maps, vector layers, custom overlays)
|
||||
* Marker placement with custom interaction logic
|
||||
* Search bar for querying map elements
|
||||
* Modular component structure for easy extension
|
||||
* Integration-ready API layer
|
||||
|
||||
### UI/UX Features
|
||||
|
||||
* Landing page and project-specific information pages
|
||||
* Clean interactive mapping interface
|
||||
* Dock-style UI elements for layer controls
|
||||
* Responsive layouts using modern React patterns
|
||||
|
||||
### DevOps Features
|
||||
|
||||
* Dockerized build for consistent deployments
|
||||
* Docker Compose support
|
||||
* CI/CD automation using Jenkins pipelines
|
||||
* Production-ready Vite bundling
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Folder Structure
|
||||
|
||||
```plaintext
|
||||
maplayer/
|
||||
└── frontend
|
||||
└── public
|
||||
├── vite.svg
|
||||
└── src
|
||||
└── assets
|
||||
├── Address_Points.geojson
|
||||
├── react.svg
|
||||
└── components
|
||||
└── Methods
|
||||
├── GeoJson_Load.tsx
|
||||
├── Layers.tsx
|
||||
├── Marker.tsx
|
||||
└── UI
|
||||
├── another.tsx
|
||||
├── Dock.css
|
||||
├── Dock.tsx
|
||||
├── Map_page.css
|
||||
├── Map_page.tsx
|
||||
├── LandingPage.tsx
|
||||
├── LegiScanAPI.tsx
|
||||
├── OpenLayerEsri.tsx
|
||||
├── OpenLayerMap.tsx
|
||||
├── Projectpage.tsx
|
||||
├── SearchBar.tsx
|
||||
├── App.css
|
||||
├── App.tsx
|
||||
├── index.css
|
||||
├── main.tsx
|
||||
├── vite-env.d.ts
|
||||
├── .gitignore
|
||||
├── Dockerfile
|
||||
├── eslint.config.js
|
||||
├── index.html
|
||||
├── Jenkinsfile
|
||||
├── package-lock.json
|
||||
├── package.json
|
||||
├── README.md
|
||||
├── tsconfig.app.json
|
||||
├── tsconfig.json
|
||||
├── tsconfig.node.json
|
||||
├── vite.config.ts
|
||||
├── .gitattributes
|
||||
├── docker-compose.yaml
|
||||
├── package-lock.json
|
||||
└── package.json
|
||||
```js
|
||||
export default tseslint.config({
|
||||
extends: [
|
||||
// Remove ...tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
## Tech Stack
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
### Frontend
|
||||
|
||||
* React (TypeScript)
|
||||
* Vite bundler
|
||||
* OpenLayers for map rendering
|
||||
* GeoJSON for dataset representation
|
||||
* Custom CSS for UI components
|
||||
|
||||
### DevOps & Deployment
|
||||
|
||||
* Docker for containerization
|
||||
* Docker Compose for local orchestration
|
||||
* Jenkins CI/CD pipeline
|
||||
* Nginx (optional, for serving static builds)
|
||||
|
||||
---
|
||||
|
||||
## Frontend (React + GeoJSON)
|
||||
|
||||
The frontend is built with Vite + React and organized into functional modules:
|
||||
|
||||
### Methods
|
||||
|
||||
* **GeoJson_Load.tsx**
|
||||
Handles loading and parsing of GeoJSON datasets, converting them into OpenLayers vector sources.
|
||||
|
||||
* **Layers.tsx**
|
||||
Manages creation, toggling, and rendering of OpenLayers map layers.
|
||||
|
||||
* **Marker.tsx**
|
||||
Adds interactive marker overlays with custom styles.
|
||||
|
||||
### Mapping Components
|
||||
|
||||
* **OpenLayerMap.tsx**
|
||||
Main OpenLayers implementation with map initialization, view configuration, and layer attachment.
|
||||
|
||||
* **OpenLayerEsri.tsx**
|
||||
Optional integration for ESRI tiles/basemaps.
|
||||
|
||||
### UI Components
|
||||
|
||||
* **Dock.tsx**
|
||||
UI menu for layer switching and actions.
|
||||
|
||||
* **Map_page.tsx**
|
||||
Primary mapping interface.
|
||||
|
||||
* **SearchBar.tsx**
|
||||
Search functionality (e.g., filter markers, search dataset, API queries).
|
||||
|
||||
### Supporting Pages
|
||||
|
||||
* Landing page
|
||||
* Project information page
|
||||
* LegiScan API integration page
|
||||
|
||||
---
|
||||
|
||||
## DevOps & Deployment
|
||||
|
||||
MapLayer is designed to be production-ready with minimal configuration.
|
||||
|
||||
### Dockerfile
|
||||
|
||||
Defines a lightweight Node + Nginx pipeline for building and serving the application.
|
||||
|
||||
### docker-compose.yaml
|
||||
|
||||
Orchestrates services required to run the project locally, including:
|
||||
|
||||
* Frontend container
|
||||
* Optional reverse proxy container
|
||||
|
||||
|
||||
## Frontend Architecture
|
||||
|
||||
MapLayer follows a modular, scalable architecture:
|
||||
|
||||
1. **UI Layer**
|
||||
Presentation components (Dock, Map Page, Search Bar).
|
||||
|
||||
2. **Map Logic Layer**
|
||||
OpenLayers configuration (map object, layers, markers).
|
||||
|
||||
3. **Data Access Layer**
|
||||
GeoJSON loader, external API services (LegiScan API).
|
||||
|
||||
4. **Asset Layer**
|
||||
GeoJSON files, icons, static resources.
|
||||
|
||||
5. **Configuration Layer**
|
||||
Vite and TypeScript configs.
|
||||
|
||||
This separation makes the project easy to scale and adapt to new features.
|
||||
|
||||
|
||||
## High-Level Architecture Diagram
|
||||
|
||||
```plaintext
|
||||
+-------------------------+
|
||||
| Client (UI) |
|
||||
| React + OpenLayers |
|
||||
+-----------+-------------+
|
||||
|
|
||||
v
|
||||
+-------------------------+
|
||||
| Mapping Engine |
|
||||
| OpenLayers Logic |
|
||||
+-----------+-------------+
|
||||
|
|
||||
v
|
||||
+-------------------------+
|
||||
| GeoJSON Data Layer |
|
||||
| Local assets or APIs |
|
||||
+-----------+-------------+
|
||||
|
|
||||
v
|
||||
+-------------------------+
|
||||
| External Services |
|
||||
| Example: LegiScan API |
|
||||
+-------------------------+
|
||||
|
||||
+-------------------------+
|
||||
| Deployment Layer |
|
||||
| Docker + Jenkins + Nginx|
|
||||
+-------------------------+
|
||||
export default tseslint.config({
|
||||
plugins: {
|
||||
// Add the react-x and react-dom plugins
|
||||
'react-x': reactX,
|
||||
'react-dom': reactDom,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended typescript rules
|
||||
...reactX.configs['recommended-typescript'].rules,
|
||||
...reactDom.configs.recommended.rules,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Advantages
|
||||
|
||||
* Lightweight and fast due to React + Vite
|
||||
* Highly customizable OpenLayers engine
|
||||
* Easy to integrate external APIs
|
||||
* Full support for GeoJSON datasets
|
||||
* Completely containerized for reliable deployments
|
||||
* Modular structure suitable for scaling into a full GIS dashboard
|
||||
* Developer-friendly with TypeScript, linting, and formatted code
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="./src/assets/map.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MapLayer</title>
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
220
package-lock.json
generated
220
package-lock.json
generated
|
|
@ -11,7 +11,6 @@
|
|||
"-": "^0.0.1",
|
||||
"@types/leaflet": "^1.9.18",
|
||||
"framer-motion": "^12.17.3",
|
||||
"gsap": "^3.13.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"ol": "^10.5.0",
|
||||
"ol-ext": "^4.0.31",
|
||||
|
|
@ -85,7 +84,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz",
|
||||
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
|
|
@ -696,11 +694,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
|
||||
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.6.0.tgz",
|
||||
"integrity": "sha512-WhCn7Z7TauhBtmzhvKpoQs0Wwb/kBcy4CwpuI0/eEIr2Lx2auxmulAzLr91wVZJaz47iUZdkXOK7WlAfxGKCnA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eslint-visitor-keys": "^3.4.3"
|
||||
},
|
||||
|
|
@ -736,13 +733,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
|
||||
"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
|
||||
"version": "0.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz",
|
||||
"integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/object-schema": "^2.1.7",
|
||||
"@eslint/object-schema": "^2.1.6",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^3.1.2"
|
||||
},
|
||||
|
|
@ -751,24 +747,19 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@eslint/config-helpers": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
|
||||
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.1.tgz",
|
||||
"integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^0.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/core": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
|
||||
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz",
|
||||
"integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
},
|
||||
|
|
@ -812,42 +803,48 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.39.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
|
||||
"integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
|
||||
"version": "9.24.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.24.0.tgz",
|
||||
"integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://eslint.org/donate"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/object-schema": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
|
||||
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
|
||||
"version": "2.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
|
||||
"integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
|
||||
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
|
||||
"version": "0.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz",
|
||||
"integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^0.17.0",
|
||||
"@eslint/core": "^0.13.0",
|
||||
"levn": "^0.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz",
|
||||
"integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
|
|
@ -1311,8 +1308,7 @@
|
|||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.18",
|
||||
|
|
@ -1360,7 +1356,6 @@
|
|||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.1.tgz",
|
||||
"integrity": "sha512-ePapxDL7qrgqSF67s0h9m412d9DbXyC1n59O2st+9rjuuamWsZuD2w55rqY12CbzsZ7uVXb5Nw0gEp9Z8MMutQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
|
|
@ -1418,7 +1413,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.29.1.tgz",
|
||||
"integrity": "sha512-zczrHVEqEaTwh12gWBIJWj8nx+ayDcCJs06yoNMY0kwjMWDM6+kppljY+BxWI06d2Ja+h4+WdufDcwMnnMEWmg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.29.1",
|
||||
"@typescript-eslint/types": "8.29.1",
|
||||
|
|
@ -1518,11 +1512,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
|
|
@ -1614,12 +1607,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"version": "8.14.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
||||
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -1697,11 +1688,10 @@
|
|||
"devOptional": true
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
|
|
@ -1738,7 +1728,6 @@
|
|||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001688",
|
||||
"electron-to-chromium": "^1.5.73",
|
||||
|
|
@ -1842,16 +1831,12 @@
|
|||
"dev": true
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
|
|
@ -1974,33 +1959,32 @@
|
|||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "9.39.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"version": "9.24.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz",
|
||||
"integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
"@eslint/config-array": "^0.21.1",
|
||||
"@eslint/config-helpers": "^0.4.2",
|
||||
"@eslint/core": "^0.17.0",
|
||||
"@eslint/config-array": "^0.20.0",
|
||||
"@eslint/config-helpers": "^0.2.0",
|
||||
"@eslint/core": "^0.12.0",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "9.39.1",
|
||||
"@eslint/plugin-kit": "^0.4.1",
|
||||
"@eslint/js": "9.24.0",
|
||||
"@eslint/plugin-kit": "^0.2.7",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
"@types/estree": "^1.0.6",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"ajv": "^6.12.4",
|
||||
"chalk": "^4.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"debug": "^4.3.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"eslint-scope": "^8.4.0",
|
||||
"eslint-visitor-keys": "^4.2.1",
|
||||
"espree": "^10.4.0",
|
||||
"eslint-scope": "^8.3.0",
|
||||
"eslint-visitor-keys": "^4.2.0",
|
||||
"espree": "^10.3.0",
|
||||
"esquery": "^1.5.0",
|
||||
"esutils": "^2.0.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
|
|
@ -2056,11 +2040,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/eslint-scope": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
|
||||
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz",
|
||||
"integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"esrecurse": "^4.3.0",
|
||||
"estraverse": "^5.2.0"
|
||||
|
|
@ -2073,11 +2056,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/eslint-visitor-keys": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
|
||||
"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
|
|
@ -2086,15 +2068,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
|
||||
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
|
||||
"version": "10.3.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
|
||||
"integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
"acorn": "^8.14.0",
|
||||
"acorn-jsx": "^5.3.2",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
"eslint-visitor-keys": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
|
@ -2120,7 +2101,6 @@
|
|||
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
|
||||
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"estraverse": "^5.2.0"
|
||||
},
|
||||
|
|
@ -2378,12 +2358,6 @@
|
|||
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/gsap": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.13.0.tgz",
|
||||
"integrity": "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==",
|
||||
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
|
|
@ -2469,11 +2443,10 @@
|
|||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
|
|
@ -2536,8 +2509,7 @@
|
|||
"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",
|
||||
"peer": true
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/lerc": {
|
||||
"version": "3.0.0",
|
||||
|
|
@ -2573,9 +2545,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
||||
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
||||
"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": {
|
||||
|
|
@ -2726,7 +2698,6 @@
|
|||
"resolved": "https://registry.npmjs.org/ol/-/ol-10.5.0.tgz",
|
||||
"integrity": "sha512-nHFx8gkGmvYImsa7iKkwUnZidd5gn1XbMZd9GNOorvm9orjW9gQvT3Naw/MjIasVJ3cB9EJUdCGR2EFAulMHsQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/rbush": "4.0.0",
|
||||
"earcut": "^3.0.0",
|
||||
|
|
@ -2753,7 +2724,6 @@
|
|||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
||||
"devOptional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"deep-is": "^0.1.3",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
|
|
@ -2975,7 +2945,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -2998,7 +2967,6 @@
|
|||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
|
|
@ -3039,9 +3007,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
|
||||
"integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
|
||||
"version": "7.6.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.2.tgz",
|
||||
"integrity": "sha512-U7Nv3y+bMimgWjhlT5CRdzHPu2/KVmqPwKUCChW8en5P3znxUqwlYFlbmyj8Rgp1SF6zs5X4+77kBVknkg6a0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
|
|
@ -3061,12 +3029,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz",
|
||||
"integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==",
|
||||
"version": "7.6.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.2.tgz",
|
||||
"integrity": "sha512-Q8zb6VlTbdYKK5JJBLQEN06oTUa/RAbG/oQS1auK1I0TbJOXktqm+QENEVJU6QvWynlXPRBXI3fiOQcSEA78rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.13.0"
|
||||
"react-router": "7.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
|
@ -3203,9 +3171,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
|
||||
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
|
|
@ -3343,7 +3311,6 @@
|
|||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -3398,7 +3365,6 @@
|
|||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
|
||||
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
||||
"devOptional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -3480,11 +3446,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"version": "6.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
||||
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
|
@ -3644,7 +3609,6 @@
|
|||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@
|
|||
"-": "^0.0.1",
|
||||
"@types/leaflet": "^1.9.18",
|
||||
"framer-motion": "^12.17.3",
|
||||
"gsap": "^3.13.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"ol": "^10.5.0",
|
||||
"ol-ext": "^4.0.31",
|
||||
|
|
|
|||
60
src/App.css
60
src/App.css
|
|
@ -1,30 +1,42 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&display=swap');
|
||||
|
||||
:root {
|
||||
--bg-color: #050505;
|
||||
--text-main: #ffffff;
|
||||
--text-muted: #888888;
|
||||
--card-bg: rgba(255, 255, 255, 0.03);
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--accent: #00F0FF;
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[data-theme='light'] {
|
||||
--bg-color: #F4F4F5;
|
||||
--text-main: #18181B;
|
||||
--text-muted: #52525B;
|
||||
--card-bg: rgba(255, 255, 255, 0.6);
|
||||
--border-color: rgba(0, 0, 0, 0.08);
|
||||
--accent: #0070F3;
|
||||
.map {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 25px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
transition: background-color 0.5s ease, color 0.5s ease;
|
||||
.coordinates-container {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
}
|
||||
|
||||
/* Hide Scrollbar for cleaner look */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(128, 128, 128, 0.4); border-radius: 4px; }
|
||||
.ol-popup{
|
||||
position: absolute;
|
||||
background-color: white;
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid black;
|
||||
transform: translate(-50%, -100%);
|
||||
pointer-events: none;
|
||||
width: 220px;
|
||||
color: black;
|
||||
};
|
||||
|
||||
.input {
|
||||
margin-bottom: 30px;
|
||||
padding: 5px 15px;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
|||
24
src/App.tsx
24
src/App.tsx
|
|
@ -1,27 +1,17 @@
|
|||
import './App.css';
|
||||
import { useState } from 'react';
|
||||
import OpenLayerMap from './components/OpenLayerMap';
|
||||
import OpenLayerEsri from './components/OpenLayerEsri';
|
||||
import LegiScanAPI from './components/LegiScanAPI';
|
||||
|
||||
import './App.css'
|
||||
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
|
||||
import LandingPage from './components/LandingPage';
|
||||
|
||||
import OpenLayerMap from './components/UI/Map/OpenLayerMap';
|
||||
import ArchitecturalShowcase from './components/UI/Homepage/showcase';
|
||||
function App() {
|
||||
const [addressData, setAddressData] = useState<any>(null);
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
|
||||
const handleSearchSuccess = (data: any, term: string) => {
|
||||
setAddressData(data);
|
||||
setSearchTerm(term);
|
||||
};
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage onSearchSuccess={handleSearchSuccess} />} />
|
||||
<Route path="/open" element={<OpenLayerMap initialAddressData={addressData} initialTerm={searchTerm} />} />
|
||||
<Route path="/esri" element={<OpenLayerEsri />} />
|
||||
<Route path="/legi" element={<LegiScanAPI />} />
|
||||
<Route path="/" element={<ArchitecturalShowcase />} />
|
||||
<Route path="/open" element={<OpenLayerMap />} />
|
||||
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
|
|
|
|||
BIN
src/assets/Address_Points.geojson
Normal file
BIN
src/assets/Address_Points.geojson
Normal file
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 718 B |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4 KiB |
|
|
@ -1,292 +0,0 @@
|
|||
import React, { useState, useRef, useLayoutEffect } from 'react';
|
||||
import gsap from 'gsap';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
// --- TYPES ---
|
||||
interface LandingPageProps {
|
||||
onSearchSuccess: (addressData: any, searchTerm: string) => void;
|
||||
}
|
||||
|
||||
const LandingPage: React.FC<LandingPageProps> = ({ onSearchSuccess }) => {
|
||||
const [term, setTerm] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// --- REFS ---
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const glowRef = useRef<HTMLDivElement>(null);
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// --- GSAP INTRO ANIMATION ---
|
||||
useLayoutEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
const tl = gsap.timeline({ defaults: { ease: 'expo.out' } });
|
||||
|
||||
// 1. Background Glow expands
|
||||
tl.fromTo(glowRef.current,
|
||||
{ scale: 0.5, opacity: 0 },
|
||||
{ scale: 1, opacity: 0.6, duration: 2, ease: 'power3.out' }
|
||||
)
|
||||
// 2. Title floats up
|
||||
.from(titleRef.current, {
|
||||
y: 40,
|
||||
opacity: 0,
|
||||
duration: 1.5,
|
||||
filter: 'blur(10px)', // Cinematic blur in
|
||||
}, '-=1.5')
|
||||
// 3. Search Bar expands width-wise
|
||||
.from(formRef.current, {
|
||||
scaleX: 0.8,
|
||||
y: 20,
|
||||
opacity: 0,
|
||||
duration: 1.2,
|
||||
}, '-=1.2');
|
||||
|
||||
}, containerRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
// --- INTERACTION ANIMATIONS ---
|
||||
// Animate button in/out based on text content
|
||||
useLayoutEffect(() => {
|
||||
if (term.length > 0) {
|
||||
gsap.to(buttonRef.current, { width: 'auto', opacity: 1, paddingLeft: 20, paddingRight: 20, duration: 0.4, ease: 'power2.out' });
|
||||
} else {
|
||||
gsap.to(buttonRef.current, { width: 0, opacity: 0, paddingLeft: 0, paddingRight: 0, duration: 0.3, ease: 'power2.in' });
|
||||
}
|
||||
}, [term]);
|
||||
|
||||
// Animate Glow on Focus
|
||||
useLayoutEffect(() => {
|
||||
gsap.to(glowRef.current, {
|
||||
opacity: isFocused ? 0.8 : 0.4,
|
||||
scale: isFocused ? 1.1 : 1,
|
||||
duration: 0.5
|
||||
});
|
||||
}, [isFocused]);
|
||||
|
||||
|
||||
// --- LOGIC ---
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!term) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Cinematic Exit Animation
|
||||
const tl = gsap.timeline();
|
||||
tl.to(titleRef.current, { opacity: 0, y: -20, duration: 0.5 })
|
||||
.to(formRef.current, { scale: 0.9, opacity: 0, blur: 10, duration: 0.4 }, '-=0.3')
|
||||
.to(glowRef.current, { scale: 3, opacity: 0, duration: 0.8 }, '-=0.4');
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${term}&format=json&polygon=1&addressdetails=1`);
|
||||
const json = await response.json();
|
||||
|
||||
// Artificial delay for smooth transition feel
|
||||
setTimeout(() => {
|
||||
onSearchSuccess(json, term);
|
||||
navigate('/open');
|
||||
}, 800);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Search failed", error);
|
||||
setIsLoading(false);
|
||||
// Reset Animation if failed
|
||||
gsap.to([containerRef.current, titleRef.current, formRef.current], { opacity: 1, y: 0, scale: 1, blur: 0, duration: 0.5 });
|
||||
}
|
||||
};
|
||||
|
||||
// --- STYLES ---
|
||||
const styles = {
|
||||
wrapper: {
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
backgroundColor: '#030303', // Deepest black
|
||||
color: '#ffffff',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontFamily: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
// The Ambient Glow Behind
|
||||
glow: {
|
||||
position: 'absolute' as const,
|
||||
width: '600px',
|
||||
height: '600px',
|
||||
background: 'radial-gradient(circle, rgba(255,255,255,0.08) 0%, rgba(0,0,0,0) 70%)',
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none' as const,
|
||||
zIndex: 0,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
},
|
||||
contentZ: {
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
title: {
|
||||
fontSize: 'clamp(3rem, 6vw, 5rem)',
|
||||
fontWeight: 800,
|
||||
margin: '0 0 40px 0',
|
||||
textAlign: 'center' as const,
|
||||
letterSpacing: '-0.04em',
|
||||
// Gradient Text Effect
|
||||
background: 'linear-gradient(to bottom, #ffffff 0%, #888888 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
},
|
||||
form: {
|
||||
position: 'relative' as const,
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
padding: '0 24px',
|
||||
},
|
||||
glassContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
background: 'rgba(255, 255, 255, 0.03)', // Extremely subtle fill
|
||||
backdropFilter: 'blur(20px)', // Heavy blur
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: '24px', // Smooth pill shape
|
||||
padding: '8px',
|
||||
transition: 'border-color 0.3s ease, box-shadow 0.3s ease',
|
||||
boxShadow: isFocused
|
||||
? '0 0 40px rgba(255, 255, 255, 0.05), inset 0 0 0 1px rgba(255,255,255,0.1)'
|
||||
: '0 10px 40px rgba(0,0,0,0.5)',
|
||||
},
|
||||
searchIcon: {
|
||||
marginLeft: '16px',
|
||||
color: '#555',
|
||||
transition: 'color 0.3s',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: '#fff',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 400,
|
||||
padding: '16px',
|
||||
letterSpacing: '-0.02em',
|
||||
caretColor: '#fff', // White cursor
|
||||
},
|
||||
button: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#fff',
|
||||
color: '#000',
|
||||
border: 'none',
|
||||
borderRadius: '16px',
|
||||
height: '48px',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden', // Hide content when width is 0
|
||||
whiteSpace: 'nowrap' as const,
|
||||
},
|
||||
loader: {
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid rgba(0,0,0,0.1)',
|
||||
borderTop: '2px solid #000',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
},
|
||||
texting: {
|
||||
fontSize: 'clamp(1rem, 6vw, 1rem)',
|
||||
fontWeight: 500,
|
||||
margin: '80px 240px 20px 240px',
|
||||
textAlign: 'center' as const,
|
||||
letterSpacing: '-0.04em',
|
||||
// Gradient Text Effect
|
||||
background: 'linear-gradient(to bottom, #ffffff 0%, #888888 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={styles.wrapper}>
|
||||
|
||||
{/* CSS Animation for loader */}
|
||||
<style>{`
|
||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||
::selection { background: rgba(255,255,255,0.2); color: white; }
|
||||
`}</style>
|
||||
|
||||
{/* Ambient Background Aura */}
|
||||
<div ref={glowRef} style={styles.glow} />
|
||||
|
||||
<div style={styles.contentZ}>
|
||||
{/* Title */}
|
||||
<h1 ref={titleRef} style={styles.title}>
|
||||
Explore the San Diego.
|
||||
</h1>
|
||||
|
||||
{/* Search Component */}
|
||||
<form ref={formRef} onSubmit={handleSearch} style={styles.form}>
|
||||
<div style={styles.glassContainer}>
|
||||
|
||||
{/* Search Icon */}
|
||||
<div style={{...styles.searchIcon, color: isFocused ? '#fff' : '#555'}}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Input Field */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder="Search address, city, or place..."
|
||||
style={styles.input}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Animated Button */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="submit"
|
||||
style={styles.button}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div style={styles.loader} />
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<h3 ref={titleRef} style={styles.texting}>
|
||||
MapLayer is a lightweight and powerful geospatial visualization platform designed to make mapping simple and intuitive. It allows users to load, explore, and interact with GeoJSON datasets directly in the browser using a modern React interface. With customizable layers, markers, and seamless OpenLayers integration, MapLayer transforms raw geographic data into meaningful insights. Built for developers, researchers, and analysts, it delivers speed, flexibility, and a clean mapping experience.
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
|
|
@ -1,499 +0,0 @@
|
|||
import React, { useEffect, useRef, useState, useLayoutEffect } from 'react';
|
||||
import { Map, View } from 'ol';
|
||||
import 'ol/ol.css';
|
||||
import TileLayer from 'ol/layer/Tile';
|
||||
import XYZ from 'ol/source/XYZ';
|
||||
import { fromLonLat, toLonLat } from 'ol/proj';
|
||||
import { Coordinate } from 'ol/coordinate';
|
||||
import VectorLayer from 'ol/layer/Vector';
|
||||
import { layerConfigs } from './Methods/Layers';
|
||||
import { loadAndRenderGeoJsonLayer } from './Methods/GeoJson_Load';
|
||||
import { addMarker } from './Methods/Marker';
|
||||
import gsap from 'gsap';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { easeOut } from 'ol/easing';
|
||||
|
||||
// --- THEME CONFIGURATION ---
|
||||
const themes = {
|
||||
dark: {
|
||||
bg: '#050505',
|
||||
glass: 'rgba(20, 20, 20, 0.75)', // Slightly darker for better contrast
|
||||
border: 'rgba(255, 255, 255, 0.15)', // Stronger border
|
||||
textMain: '#ffffff',
|
||||
textMuted: '#AAAAAA',
|
||||
iconColor: '#ffffff',
|
||||
shadow: '0 4px 20px rgba(0,0,0,0.4)', // Strong shadow
|
||||
switchTrackActive: 'rgba(255,255,255,0.2)',
|
||||
switchTrackInactive: 'rgba(255,255,255,0.05)',
|
||||
switchKnobActive: '#fff',
|
||||
switchKnobInactive: '#666',
|
||||
mapUrl: 'https://{a-c}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
},
|
||||
light: {
|
||||
bg: '#F4F4F5',
|
||||
glass: 'rgba(255, 255, 255, 0.90)', // More opaque for visibility
|
||||
border: 'rgba(0, 0, 0, 0.1)',
|
||||
textMain: '#18181B',
|
||||
textMuted: '#52525B',
|
||||
iconColor: '#18181B',
|
||||
shadow: '0 4px 20px rgba(0,0,0,0.1)',
|
||||
switchTrackActive: 'rgba(0,0,0,0.2)',
|
||||
switchTrackInactive: 'rgba(0,0,0,0.05)',
|
||||
switchKnobActive: '#000',
|
||||
switchKnobInactive: '#999',
|
||||
mapUrl: 'https://{a-c}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||
}
|
||||
};
|
||||
|
||||
// --- SCROLLBAR CSS ---
|
||||
const scrollbarStyles = `
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-track { background: rgba(128, 128, 128, 0.1); border-radius: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(128, 128, 128, 0.3); border-radius: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: rgba(128, 128, 128, 0.5); }
|
||||
`;
|
||||
|
||||
// --- HELPER: Extract Color ---
|
||||
const extractColorFromStyle = (style: any): string => {
|
||||
let color: any = '#888888';
|
||||
if (style.getStroke() && style.getStroke().getColor()) color = style.getStroke().getColor();
|
||||
else if (style.getFill() && style.getFill().getColor()) color = style.getFill().getColor();
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
'skin': '#FFCBA4', 'darkyellow': '#FCC200', 'darkblue': '#00008B',
|
||||
'darkgreen': '#006400', 'purple': '#800080', 'orange': '#FFA500',
|
||||
'green': '#00FF00', 'blue': '#0000FF', 'red': '#FF0000', 'black': '#888888',
|
||||
};
|
||||
return colorMap[color] || color;
|
||||
};
|
||||
|
||||
interface MapViewProps {
|
||||
initialAddressData: any;
|
||||
initialTerm: string;
|
||||
}
|
||||
|
||||
const MapView: React.FC<MapViewProps> = ({ initialAddressData, initialTerm }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// -- STATE --
|
||||
const [isDarkMode, setIsDarkMode] = useState(true);
|
||||
const currentTheme = isDarkMode ? themes.dark : themes.light;
|
||||
|
||||
const [layerVisibility, setLayerVisibility] = useState<Record<string, boolean>>(() =>
|
||||
Object.fromEntries(layerConfigs.map(layer => [layer.topic, true]))
|
||||
);
|
||||
const [clickedCoordinate, setClickedCoordinate] = useState<Coordinate>();
|
||||
const [mapStats, setMapStats] = useState({
|
||||
zoom: 2,
|
||||
center: [0, 0] as Coordinate,
|
||||
rotation: 0,
|
||||
size: { width: 0, height: 0 }
|
||||
});
|
||||
|
||||
// -- REFS --
|
||||
const mapDivRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<Map | null>(null);
|
||||
const tileLayerRef = useRef<TileLayer<XYZ> | null>(null);
|
||||
const markerLayerRef = useRef<VectorLayer | null>(null);
|
||||
const layerRefs = useRef<Record<string, React.MutableRefObject<VectorLayer | null>>>(
|
||||
Object.fromEntries(layerConfigs.map((layer) => [layer.topic, { current: null }]))
|
||||
);
|
||||
const uiContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// -- GSAP ENTRANCE --
|
||||
useLayoutEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
// We use clearProps: 'all' to ensure GSAP doesn't leave the elements with weird opacity states
|
||||
gsap.from('.floating-ui', {
|
||||
y: 20,
|
||||
opacity: 0,
|
||||
duration: 0.8,
|
||||
stagger: 0.1,
|
||||
ease: 'power3.out',
|
||||
delay: 0.5,
|
||||
clearProps: 'opacity,transform' // Crucial: Ensures visibility after animation ends
|
||||
});
|
||||
}, uiContainerRef);
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
// -- MAP INIT --
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) {
|
||||
const baseLayer = new TileLayer({
|
||||
source: new XYZ({
|
||||
url: currentTheme.mapUrl,
|
||||
attributions: '© OpenStreetMap © CARTO'
|
||||
})
|
||||
});
|
||||
tileLayerRef.current = baseLayer;
|
||||
|
||||
const map = new Map({
|
||||
target: mapDivRef.current as HTMLDivElement,
|
||||
layers: [ baseLayer ],
|
||||
view: new View({ center: fromLonLat([0, 0]), zoom: 2 }),
|
||||
controls: []
|
||||
});
|
||||
|
||||
mapRef.current = map;
|
||||
map.on('click', (e) => setClickedCoordinate(e.coordinate));
|
||||
map.on('moveend', () => {
|
||||
const view = map.getView();
|
||||
const center = view.getCenter() ?? [0, 0];
|
||||
const zoom = view.getZoom() ?? 0;
|
||||
const rotation = view.getRotation() ?? 0;
|
||||
const size = map.getSize();
|
||||
|
||||
setMapStats({
|
||||
center,
|
||||
zoom,
|
||||
rotation,
|
||||
size: { width: size?.[0] ?? 0, height: size?.[1] ?? 0 }
|
||||
});
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// -- THEME SWITCHING --
|
||||
useEffect(() => {
|
||||
if (mapRef.current && tileLayerRef.current) {
|
||||
tileLayerRef.current.setSource(new XYZ({
|
||||
url: currentTheme.mapUrl,
|
||||
attributions: '© OpenStreetMap © CARTO'
|
||||
}));
|
||||
}
|
||||
}, [isDarkMode]);
|
||||
|
||||
// -- SEARCH DATA --
|
||||
useEffect(() => {
|
||||
if (initialAddressData && initialAddressData.length > 0) {
|
||||
const lat = parseFloat(initialAddressData[0].lat);
|
||||
const lon = parseFloat(initialAddressData[0].lon);
|
||||
const coords = fromLonLat([lon, lat]);
|
||||
|
||||
mapRef.current!.getView().animate({ center: coords, zoom: 14, duration: 2500, easing: easeOut });
|
||||
markerLayerRef.current = addMarker(coords, mapRef.current!, markerLayerRef.current);
|
||||
|
||||
layerConfigs.forEach((layerConfig) => {
|
||||
if(layerRefs.current[layerConfig.topic].current) mapRef.current!.removeLayer(layerRefs.current[layerConfig.topic].current!);
|
||||
const url = layerConfig.getUrl(lon, lat, layerConfig.radius);
|
||||
const ref = layerRefs.current[layerConfig.topic];
|
||||
loadAndRenderGeoJsonLayer(url, mapRef.current!, ref, layerConfig.style);
|
||||
});
|
||||
}
|
||||
}, [initialAddressData]);
|
||||
|
||||
const toggleLayer = (layerTopic: string) => {
|
||||
const layerRef = layerRefs.current[layerTopic];
|
||||
const isVisible = !layerVisibility[layerTopic];
|
||||
if (layerRef.current) layerRef.current.setVisible(isVisible);
|
||||
setLayerVisibility(prev => ({ ...prev, [layerTopic]: isVisible }));
|
||||
};
|
||||
|
||||
const formatLayerName = (topic: string) => topic.replace(/_/g, ' ').replace(/SD$/, '').trim();
|
||||
const formatNumber = (value: number, digits = 2) => Number.isFinite(value) ? value.toFixed(digits) : '—';
|
||||
const centerLonLat = toLonLat(mapStats.center);
|
||||
const clickedLonLat = clickedCoordinate ? toLonLat(clickedCoordinate) : null;
|
||||
const activeLayerCount = Object.values(layerVisibility).filter(Boolean).length;
|
||||
|
||||
// -- STYLES --
|
||||
const styles = {
|
||||
wrapper: {
|
||||
position: 'relative' as const, width: '100vw', height: '100vh',
|
||||
background: currentTheme.bg, overflow: 'hidden',
|
||||
fontFamily: '"Inter", -apple-system, sans-serif',
|
||||
transition: 'background 0.5s ease',
|
||||
},
|
||||
uiLayer: {
|
||||
position: 'absolute' as const, top: 0, left: 0, width: '100%', height: '100%',
|
||||
pointerEvents: 'none' as const,
|
||||
zIndex: 1000, // FORCE TOP LAYER
|
||||
padding: '24px',
|
||||
display: 'flex', flexDirection: 'column' as const, justifyContent: 'space-between',
|
||||
},
|
||||
topBar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
pointerEvents: 'auto' as const,
|
||||
gap: '12px',
|
||||
width: '100%' // Ensure it spans width
|
||||
},
|
||||
glassPanel: {
|
||||
background: currentTheme.glass,
|
||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
border: `1px solid ${currentTheme.border}`,
|
||||
borderRadius: '20px', padding: '40px',
|
||||
boxShadow: currentTheme.shadow,
|
||||
transition: 'all 0.3s ease'
|
||||
},
|
||||
locationBadge: {
|
||||
display: 'flex', alignItems: 'center', padding: '12px 24px',
|
||||
borderRadius: '30px', background: currentTheme.glass,
|
||||
backdropFilter: 'blur(20px)', border: `1px solid ${currentTheme.border}`,
|
||||
boxShadow: currentTheme.shadow,
|
||||
transition: 'all 0.3s ease',
|
||||
flexShrink: 0 // Prevent collapsing
|
||||
},
|
||||
actionButton: {
|
||||
width: '48px', height: '48px', borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: currentTheme.glass,
|
||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
border: `1px solid ${currentTheme.border}`,
|
||||
color: currentTheme.iconColor, // Explicit high contrast color
|
||||
boxShadow: currentTheme.shadow, // Force visibility
|
||||
cursor: 'pointer', transition: 'all 0.2s',
|
||||
flexShrink: 0 // Prevent collapsing
|
||||
},
|
||||
controlsContainer: {
|
||||
marginTop: 'auto', maxWidth: '340px', maxHeight: '60vh',
|
||||
display: 'flex', flexDirection: 'column' as const, pointerEvents: 'auto' as const,
|
||||
background: currentTheme.glass, backdropFilter: 'blur(20px)',
|
||||
border: `1px solid ${currentTheme.border}`, borderRadius: '10px', padding: '20px',
|
||||
boxShadow: currentTheme.shadow, transition: 'all 0.3s ease'
|
||||
},
|
||||
controlsHeader: {
|
||||
flexShrink: 0, fontSize: '10px', fontWeight: 700, letterSpacing: '1px',
|
||||
color: currentTheme.textMuted, marginBottom: '16px', textTransform: 'uppercase' as const,
|
||||
borderBottom: `1px solid ${currentTheme.border}`, paddingBottom: '12px'
|
||||
},
|
||||
scrollableList: { overflowY: 'auto' as const, paddingRight: '8px' },
|
||||
layerRow: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px', cursor: 'pointer', padding: '4px 8px' },
|
||||
layerLabel: { display: 'flex', alignItems: 'center', gap: '12px' },
|
||||
switchTrack: { width: '32px', height: '18px', borderRadius: '10px', position: 'relative' as const, transition: 'background 0.3s ease' },
|
||||
switchKnob: { width: '14px', height: '14px', borderRadius: '50%', position: 'absolute' as const, top: '2px', transition: 'left 0.3s cubic-bezier(0.4, 0.0, 0.2, 1)', boxShadow: '0 2px 4px rgba(0,0,0,0.2)' },
|
||||
hud: {
|
||||
position: 'absolute' as const, bottom: '24px', right: '24px',
|
||||
fontFamily: '"JetBrains Mono", monospace', fontSize: '10px',
|
||||
color: currentTheme.textMuted, pointerEvents: 'auto' as const,
|
||||
padding: '8px 12px', background: currentTheme.glass,
|
||||
borderRadius: '8px', border: `1px solid ${currentTheme.border}`,
|
||||
boxShadow: currentTheme.shadow,
|
||||
transition: 'background 0.3s, color 0.3s'
|
||||
},
|
||||
rightSidebar: {
|
||||
position: 'absolute' as const,
|
||||
top: '92px',
|
||||
right: '24px',
|
||||
width: '320px',
|
||||
maxHeight: 'calc(100% - 140px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '12px',
|
||||
pointerEvents: 'auto' as const,
|
||||
zIndex: 1100
|
||||
},
|
||||
sidebarCard: {
|
||||
background: currentTheme.glass,
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
border: `1px solid ${currentTheme.border}`,
|
||||
borderRadius: '16px',
|
||||
padding: '16px',
|
||||
boxShadow: currentTheme.shadow,
|
||||
transition: 'all 0.3s ease'
|
||||
},
|
||||
sidebarTitle: {
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '1px',
|
||||
color: currentTheme.textMuted,
|
||||
textTransform: 'uppercase' as const,
|
||||
marginBottom: '12px'
|
||||
},
|
||||
statRow: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '6px 0',
|
||||
borderBottom: `1px dashed ${currentTheme.border}`
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: '12px',
|
||||
color: currentTheme.textMuted
|
||||
},
|
||||
statValue: {
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: currentTheme.textMain
|
||||
},
|
||||
layerMetaRow: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '8px 0',
|
||||
borderBottom: `1px dashed ${currentTheme.border}`
|
||||
},
|
||||
layerMetaName: {
|
||||
fontSize: '12px',
|
||||
color: currentTheme.textMain,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
},
|
||||
layerMetaBadge: {
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
padding: '2px 8px',
|
||||
borderRadius: '999px',
|
||||
border: `1px solid ${currentTheme.border}`,
|
||||
color: currentTheme.textMuted
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.wrapper}>
|
||||
<style>{scrollbarStyles}</style>
|
||||
|
||||
{/* 1. MAP LAYER */}
|
||||
<div
|
||||
ref={mapDivRef}
|
||||
style={{ width: '100%', height: '100%', transition: 'filter 0.5s' }}
|
||||
/>
|
||||
|
||||
{/* 2. UI LAYER */}
|
||||
<div ref={uiContainerRef} style={styles.uiLayer}>
|
||||
|
||||
{/* TOP BAR */}
|
||||
<div style={styles.topBar}>
|
||||
|
||||
{/* Back Button */}
|
||||
<button
|
||||
className="floating-ui"
|
||||
style={styles.actionButton}
|
||||
onClick={() => navigate('/')}
|
||||
title="Go Back"
|
||||
onMouseEnter={(e) => e.currentTarget.style.borderColor = isDarkMode ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.borderColor = currentTheme.border}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
|
||||
{/* Location Badge */}
|
||||
<div className="floating-ui" style={styles.locationBadge}>
|
||||
<div style={{ width: '6px', height: '6px', background: '#00FF94', borderRadius: '50%', marginRight: '12px', boxShadow: '0 0 8px #00FF94' }} />
|
||||
<span style={{ fontSize: '14px', fontWeight: 600, letterSpacing: '0.5px', color: currentTheme.textMain, textTransform: 'uppercase' }}>
|
||||
{initialTerm || "Exploring View"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Theme Toggle Button */}
|
||||
<button
|
||||
className="floating-ui"
|
||||
style={styles.actionButton}
|
||||
onClick={() => setIsDarkMode(!isDarkMode)}
|
||||
title="Toggle Theme"
|
||||
onMouseEnter={(e) => e.currentTarget.style.borderColor = isDarkMode ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.borderColor = currentTheme.border}
|
||||
>
|
||||
{isDarkMode ? (
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
|
||||
) : (
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
{/* CONTROLS */}
|
||||
<div className="floating-ui" style={styles.controlsContainer}>
|
||||
<div style={styles.controlsHeader}>Overlay Controls</div>
|
||||
|
||||
<div className="custom-scrollbar" style={styles.scrollableList}>
|
||||
{layerConfigs.map((layer) => {
|
||||
const isActive = layerVisibility[layer.topic];
|
||||
const layerColor = extractColorFromStyle(layer.style);
|
||||
|
||||
return (
|
||||
<div key={layer.topic} style={styles.layerRow} onClick={() => toggleLayer(layer.topic)}>
|
||||
<div style={styles.layerLabel}>
|
||||
<div style={{
|
||||
width: '8px', height: '8px', borderRadius: '50%',
|
||||
backgroundColor: layerColor,
|
||||
boxShadow: `0 0 8px ${layerColor}`,
|
||||
opacity: isActive ? 1 : 0.3, transition: 'all 0.3s'
|
||||
}} />
|
||||
<span style={{ fontSize: '13px', color: isActive ? currentTheme.textMain : currentTheme.textMuted, transition: 'color 0.3s' }}>
|
||||
{formatLayerName(layer.topic)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ ...styles.switchTrack, background: isActive ? currentTheme.switchTrackActive : currentTheme.switchTrackInactive }}>
|
||||
<div style={{ ...styles.switchKnob, left: isActive ? '16px' : '2px', background: isActive ? currentTheme.switchKnobActive : currentTheme.switchKnobInactive }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HUD */}
|
||||
{clickedCoordinate && (
|
||||
<div className="floating-ui" style={styles.hud}>
|
||||
LOCATION_LOCK: {clickedCoordinate[1].toFixed(4)} N, {clickedCoordinate[0].toFixed(4)} E
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RIGHT SIDEBAR */}
|
||||
<div className="floating-ui" style={styles.rightSidebar}>
|
||||
<div style={styles.sidebarCard}>
|
||||
<div style={styles.sidebarTitle}>General Values</div>
|
||||
<div style={styles.statRow}>
|
||||
<span style={styles.statLabel}>Height</span>
|
||||
<span style={styles.statValue}>{mapStats.size.height ? `${mapStats.size.height}px` : '—'}</span>
|
||||
</div>
|
||||
<div style={styles.statRow}>
|
||||
<span style={styles.statLabel}>Depth</span>
|
||||
<span style={styles.statValue}>{mapStats.size.width ? `${mapStats.size.width}px` : '—'}</span>
|
||||
</div>
|
||||
<div style={styles.statRow}>
|
||||
<span style={styles.statLabel}>Zoom</span>
|
||||
<span style={styles.statValue}>{formatNumber(mapStats.zoom, 2)}</span>
|
||||
</div>
|
||||
<div style={styles.statRow}>
|
||||
<span style={styles.statLabel}>Center</span>
|
||||
<span style={styles.statValue}>{formatNumber(centerLonLat[1], 4)} N / {formatNumber(centerLonLat[0], 4)} E</span>
|
||||
</div>
|
||||
<div style={styles.statRow}>
|
||||
<span style={styles.statLabel}>Last Click</span>
|
||||
<span style={styles.statValue}>{clickedLonLat ? `${formatNumber(clickedLonLat[1], 4)} N / ${formatNumber(clickedLonLat[0], 4)} E` : '—'}</span>
|
||||
</div>
|
||||
<div style={{ ...styles.statRow, borderBottom: 'none' }}>
|
||||
<span style={styles.statLabel}>Layers On</span>
|
||||
<span style={styles.statValue}>{activeLayerCount}/{layerConfigs.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.sidebarCard}>
|
||||
<div style={styles.sidebarTitle}>Layer Values</div>
|
||||
<div className="custom-scrollbar" style={{ maxHeight: '36vh', overflowY: 'auto', paddingRight: '6px' }}>
|
||||
{layerConfigs.map((layer) => {
|
||||
const isActive = layerVisibility[layer.topic];
|
||||
const layerColor = extractColorFromStyle(layer.style);
|
||||
|
||||
return (
|
||||
<div key={layer.topic} style={{ ...styles.layerMetaRow, borderBottom: `1px dashed ${currentTheme.border}` }}>
|
||||
<div style={styles.layerMetaName}>
|
||||
<span style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: layerColor, boxShadow: `0 0 8px ${layerColor}`, opacity: isActive ? 1 : 0.3 }} />
|
||||
{formatLayerName(layer.topic)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<span style={styles.layerMetaBadge}>{layer.radius}m</span>
|
||||
<span style={{ ...styles.layerMetaBadge, color: isActive ? currentTheme.textMain : currentTheme.textMuted }}>
|
||||
{isActive ? 'On' : 'Off'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapView;
|
||||
|
|
@ -1,478 +0,0 @@
|
|||
import React, { useLayoutEffect, useRef, useState, useEffect } from 'react';
|
||||
import gsap from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
// Register GSAP plugins
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const ProjectShowcase: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [themeMode, setThemeMode] = useState<'dark' | 'light'>('dark');
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
// Refs
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// const cursorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Toggle Theme
|
||||
const toggleTheme = () => {
|
||||
const newTheme = themeMode === 'dark' ? 'light' : 'dark';
|
||||
setThemeMode(newTheme);
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
};
|
||||
|
||||
// Scroll Listener for Navbar
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setIsScrolled(window.scrollY > 50);
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// --- GSAP ANIMATIONS ---
|
||||
useLayoutEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
const tl = gsap.timeline();
|
||||
|
||||
// 1. Initial Load Sequence
|
||||
tl.from('.hero-word', {
|
||||
y: 100,
|
||||
opacity: 0,
|
||||
duration: 1.2,
|
||||
stagger: 0.05,
|
||||
ease: 'power4.out'
|
||||
})
|
||||
.from('.hero-desc', {
|
||||
y: 20,
|
||||
opacity: 0,
|
||||
duration: 1,
|
||||
ease: 'power2.out'
|
||||
}, '-=0.8')
|
||||
.from('.nav-item', {
|
||||
y: -20,
|
||||
opacity: 0,
|
||||
stagger: 0.1,
|
||||
duration: 0.8,
|
||||
ease: 'power2.out'
|
||||
}, '-=1');
|
||||
|
||||
// 2. Floating 3D Elements
|
||||
gsap.to('.visual-card', {
|
||||
y: '15px',
|
||||
rotateX: 4,
|
||||
rotateY: 4,
|
||||
duration: 4,
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
ease: 'sine.inOut',
|
||||
stagger: { each: 0.5, from: "end" }
|
||||
});
|
||||
|
||||
// 3. Scroll Triggered Reveals
|
||||
const sections = gsap.utils.toArray('.reveal-section');
|
||||
sections.forEach((section: any) => {
|
||||
gsap.from(section.children, {
|
||||
scrollTrigger: {
|
||||
trigger: section,
|
||||
start: 'top 80%',
|
||||
toggleActions: 'play none none reverse'
|
||||
},
|
||||
y: 60,
|
||||
opacity: 0,
|
||||
duration: 1,
|
||||
stagger: 0.15,
|
||||
ease: 'expo.out'
|
||||
});
|
||||
});
|
||||
|
||||
}, containerRef);
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
const handleLaunch = () => {
|
||||
const tl = gsap.timeline();
|
||||
tl.to(containerRef.current, { scale: 0.98, opacity: 0, filter: 'blur(20px)', duration: 0.8, ease: 'power2.in' });
|
||||
setTimeout(() => navigate('/site'), 800);
|
||||
};
|
||||
|
||||
// --- STYLES (Using CSS Variables for Theme Support) ---
|
||||
const styles = {
|
||||
wrapper: {
|
||||
fontFamily: '"Space Grotesk", sans-serif', // Modern Tech Font
|
||||
minHeight: '100vh',
|
||||
overflowX: 'hidden' as const,
|
||||
position: 'relative' as const,
|
||||
},
|
||||
// NAVBAR
|
||||
nav: {
|
||||
position: 'fixed' as const,
|
||||
top: 0, left: 0, right: 0,
|
||||
height: '90px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 5%',
|
||||
zIndex: 100,
|
||||
transition: 'all 0.4s ease',
|
||||
backgroundColor: isScrolled ? 'var(--bg-color)' : 'transparent',
|
||||
backdropFilter: isScrolled ? 'blur(20px)' : 'none',
|
||||
borderBottom: isScrolled ? '1px solid var(--border-color)' : '1px solid transparent',
|
||||
},
|
||||
logo: {
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
color: 'var(--text-main)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
logoDot: {
|
||||
width: '8px', height: '8px', background: 'var(--accent)', borderRadius: '50%',
|
||||
boxShadow: '0 0 10px var(--accent)'
|
||||
},
|
||||
navLinks: {
|
||||
display: 'flex', gap: '40px', alignItems: 'center',
|
||||
'@media (max-width: 768px)': { display: 'none' }
|
||||
},
|
||||
link: {
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 500,
|
||||
color: 'var(--text-muted)',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 0.3s',
|
||||
letterSpacing: '0.02em',
|
||||
},
|
||||
// HERO
|
||||
hero: {
|
||||
minHeight: '100vh',
|
||||
padding: '140px 5% 80px',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap' as const,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '60px',
|
||||
},
|
||||
heroText: { flex: '1 1 500px' },
|
||||
pill: {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '8px',
|
||||
padding: '8px 16px', borderRadius: '100px',
|
||||
background: 'rgba(0, 240, 255, 0.05)',
|
||||
border: '1px solid rgba(0, 240, 255, 0.2)',
|
||||
color: 'var(--accent)', fontSize: '0.75rem', fontWeight: 600,
|
||||
marginBottom: '32px', letterSpacing: '0.05em'
|
||||
},
|
||||
h1: {
|
||||
fontSize: 'clamp(3.5rem, 6vw, 6rem)',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.05,
|
||||
marginBottom: '32px',
|
||||
color: 'var(--text-main)',
|
||||
letterSpacing: '-0.03em',
|
||||
},
|
||||
desc: {
|
||||
fontSize: 'clamp(1.1rem, 1.5vw, 1.25rem)',
|
||||
color: 'var(--text-muted)',
|
||||
lineHeight: 1.6,
|
||||
maxWidth: '580px',
|
||||
marginBottom: '48px',
|
||||
fontFamily: '"Inter", sans-serif',
|
||||
fontWeight: 300,
|
||||
},
|
||||
btnPrimary: {
|
||||
padding: '18px 48px',
|
||||
background: 'var(--text-main)',
|
||||
color: 'var(--bg-color)',
|
||||
border: 'none',
|
||||
borderRadius: '2px', // Sharper corners for modern look
|
||||
fontWeight: 600,
|
||||
fontSize: '0.95rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
letterSpacing: '0.02em',
|
||||
},
|
||||
btnSecondary: {
|
||||
padding: '18px 48px',
|
||||
background: 'transparent',
|
||||
color: 'var(--text-main)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '2px',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.95rem',
|
||||
cursor: 'pointer',
|
||||
marginLeft: '20px',
|
||||
transition: 'background 0.2s',
|
||||
},
|
||||
// VISUAL
|
||||
visualContainer: {
|
||||
flex: '1 1 400px',
|
||||
height: '600px',
|
||||
position: 'relative' as const,
|
||||
perspective: '2000px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
},
|
||||
layerStack: {
|
||||
position: 'relative' as const,
|
||||
width: '340px', height: '440px',
|
||||
transformStyle: 'preserve-3d' as const,
|
||||
transform: 'rotateX(55deg) rotateZ(-35deg) rotateY(5deg)',
|
||||
},
|
||||
layerCard: (z: number, color: string) => ({
|
||||
position: 'absolute' as const,
|
||||
inset: 0,
|
||||
background: 'var(--card-bg)', // Glass var
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: `1px solid ${color}`,
|
||||
transform: `translateZ(${z}px)`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: `0 0 40px -10px ${color}40`,
|
||||
borderRadius: '16px',
|
||||
color: 'var(--text-main)',
|
||||
fontSize: '0.9rem', fontWeight: 600, letterSpacing: '0.1em'
|
||||
}),
|
||||
|
||||
// SECTION GENERAL
|
||||
section: { padding: '160px 5%', borderTop: '1px solid var(--border-color)' },
|
||||
sectionLabel: {
|
||||
color: 'var(--accent)',
|
||||
fontSize: '0.8rem', fontWeight: 600,
|
||||
textTransform: 'uppercase' as const, letterSpacing: '0.2em',
|
||||
marginBottom: '24px', display: 'block'
|
||||
},
|
||||
h2: {
|
||||
fontSize: 'clamp(2.5rem, 5vw, 4rem)',
|
||||
fontWeight: 700, color: 'var(--text-main)',
|
||||
marginBottom: '24px', lineHeight: 1.1, letterSpacing: '-0.02em'
|
||||
},
|
||||
|
||||
// GRID
|
||||
grid: {
|
||||
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
||||
gap: '40px', marginTop: '80px'
|
||||
},
|
||||
card: {
|
||||
padding: '48px',
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '4px',
|
||||
transition: 'border-color 0.3s',
|
||||
cursor: 'default'
|
||||
},
|
||||
icon: {
|
||||
width: '48px', height: '48px', marginBottom: '32px',
|
||||
color: 'var(--text-main)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(255,255,255,0.05)', borderRadius: '8px'
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: '1.5rem', fontWeight: 600, marginBottom: '16px',
|
||||
color: 'var(--text-main)', letterSpacing: '-0.02em'
|
||||
},
|
||||
cardText: {
|
||||
color: 'var(--text-muted)', lineHeight: 1.6, fontSize: '1rem',
|
||||
fontFamily: '"Inter", sans-serif'
|
||||
},
|
||||
|
||||
// FOOTER
|
||||
footer: {
|
||||
padding: '120px 5%',
|
||||
background: 'var(--bg-color)',
|
||||
borderTop: '1px solid var(--border-color)',
|
||||
textAlign: 'center' as const
|
||||
},
|
||||
themeBtn: {
|
||||
background: 'transparent', border: '1px solid var(--border-color)',
|
||||
color: 'var(--text-main)', width: '40px', height: '40px',
|
||||
borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', transition: 'all 0.3s'
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={styles.wrapper}>
|
||||
|
||||
{/* NAVBAR */}
|
||||
<nav style={styles.nav}>
|
||||
<div style={styles.logo} className="nav-item" onClick={() => window.scrollTo(0,0)}>
|
||||
<div style={styles.logoDot}/> MapLayer.
|
||||
</div>
|
||||
<div style={styles.navLinks} className="nav-item">
|
||||
{[].map((item) => (
|
||||
<span key={item} style={styles.link}
|
||||
onMouseEnter={(e) => e.currentTarget.style.color = 'var(--text-main)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = 'var(--text-muted)'}>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
<button style={styles.themeBtn} onClick={toggleTheme}>
|
||||
{themeMode === 'dark' ?
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
|
||||
:
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* HERO SECTION */}
|
||||
<header style={styles.hero}>
|
||||
<div style={styles.heroText}>
|
||||
|
||||
|
||||
<h1 style={styles.h1}>
|
||||
<div style={{overflow:'hidden'}}><div className="hero-word">The Operating System</div></div>
|
||||
<div style={{overflow:'hidden'}}><div className="hero-word" style={{color: 'var(--text-muted)'}}>for Modern Cities.</div></div>
|
||||
</h1>
|
||||
|
||||
<p className="hero-desc" style={styles.desc}>
|
||||
MapLayer unifies fragmented urban data into a single, high-performance geospatial decision engine. Visualize zoning, transit, and environmental risks in real-time.
|
||||
</p>
|
||||
|
||||
<div className="hero-desc">
|
||||
<button
|
||||
style={styles.btnPrimary}
|
||||
onClick={handleLaunch}
|
||||
onMouseEnter={(e) => {
|
||||
gsap.to(e.currentTarget, { scale: 1.05, duration: 0.3 });
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
gsap.to(e.currentTarget, { scale: 1, duration: 0.3 });
|
||||
}}
|
||||
>
|
||||
Launch Platform
|
||||
</button>
|
||||
{/* <button style={styles.btnSecondary}>Documentation</button> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3D VISUALIZATION */}
|
||||
<div style={styles.visualContainer}>
|
||||
<div style={styles.layerStack}>
|
||||
<div className="visual-card" style={styles.layerCard(0, 'var(--border-color)')}>
|
||||
BASEMAP
|
||||
</div>
|
||||
<div className="visual-card" style={styles.layerCard(80, 'var(--accent)')}>
|
||||
INFRASTRUCTURE
|
||||
</div>
|
||||
<div className="visual-card" style={styles.layerCard(160, '#7000FF')}>
|
||||
ZONING DATA
|
||||
</div>
|
||||
<div className="visual-card" style={styles.layerCard(240, 'var(--text-main)')}>
|
||||
ANALYTICS
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* CHALLENGE SECTION */}
|
||||
<section className="reveal-section" style={styles.section}>
|
||||
<div style={{maxWidth: '800px'}}>
|
||||
<span style={styles.sectionLabel}>01 — The Challenge</span>
|
||||
<h2 style={styles.h2}>Data fragmentation <br/>is paralyzing progress.</h2>
|
||||
<p style={styles.desc}>
|
||||
Cities generate terabytes of spatial data daily. Yet, Planners and Emergency Responders operate in silos, using outdated PDF maps and disjointed portals.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={styles.grid}>
|
||||
{/* Card 1 */}
|
||||
<div style={styles.card}
|
||||
onMouseEnter={(e) => e.currentTarget.style.borderColor = 'var(--accent)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.borderColor = 'var(--border-color)'}>
|
||||
<div style={styles.icon}>
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24"><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||
</div>
|
||||
<h3 style={styles.cardTitle}>Emergency Latency</h3>
|
||||
<p style={styles.cardText}>First responders struggle to access integrated utility and hazard data during critical incidents, costing seconds that matter.</p>
|
||||
</div>
|
||||
|
||||
{/* Card 2 */}
|
||||
<div style={styles.card}
|
||||
onMouseEnter={(e) => e.currentTarget.style.borderColor = 'var(--accent)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.borderColor = 'var(--border-color)'}>
|
||||
<div style={styles.icon}>
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24"><path d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
|
||||
</div>
|
||||
<h3 style={styles.cardTitle}>Planning Blindspots</h3>
|
||||
<p style={styles.cardText}>Zoning, environmental, and transit layers are rarely visualized together, making holistic impact analysis impossible.</p>
|
||||
</div>
|
||||
|
||||
{/* Card 3 */}
|
||||
<div style={styles.card}
|
||||
onMouseEnter={(e) => e.currentTarget.style.borderColor = 'var(--accent)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.borderColor = 'var(--border-color)'}>
|
||||
<div style={styles.icon}>
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24"><path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
</div>
|
||||
<h3 style={styles.cardTitle}>Transparency Gaps</h3>
|
||||
<p style={styles.cardText}>Citizens lack a clear, real-time window into construction projects, road closures, and city development plans.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* SOLUTION SECTION */}
|
||||
<section className="reveal-section" style={styles.section}>
|
||||
<div style={{maxWidth: '800px'}}>
|
||||
<span style={styles.sectionLabel}>02 — The Solution</span>
|
||||
<h2 style={styles.h2}>A unified geospatial <br/>intelligence core.</h2>
|
||||
</div>
|
||||
|
||||
<div style={styles.grid}>
|
||||
{/* Feature 1 */}
|
||||
<div style={{paddingTop: '20px'}}>
|
||||
<h3 style={{...styles.cardTitle, fontSize: '2rem', color: 'var(--accent)'}}>Modular Layering</h3>
|
||||
<p style={styles.cardText}>
|
||||
MapLayer's engine allows users to stack infinite datasets—parcels, sewer lines, fire zones—and toggle them instantly with zero latency.
|
||||
</p>
|
||||
</div>
|
||||
{/* Feature 2 */}
|
||||
<div style={{paddingTop: '20px', borderLeft: '1px solid var(--border-color)', paddingLeft: '40px'}}>
|
||||
<h3 style={{...styles.cardTitle, fontSize: '2rem', color: '#7000FF'}}>Real-Time Sync</h3>
|
||||
<p style={styles.cardText}>
|
||||
Direct integration with San Diego's Open Data portal ensures that the map you see today reflects the reality of the city right now.
|
||||
</p>
|
||||
</div>
|
||||
{/* Feature 3 */}
|
||||
<div style={{paddingTop: '20px', borderLeft: '1px solid var(--border-color)', paddingLeft: '40px'}}>
|
||||
<h3 style={{...styles.cardTitle, fontSize: '2rem'}}>Interactive HUD</h3>
|
||||
<p style={styles.cardText}>
|
||||
A designer-grade UI that floats above the map, providing coordinate locking, layer analysis, and distinct color coding without clutter.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FOOTER */}
|
||||
<footer className="reveal-section" style={styles.footer}>
|
||||
<h2 style={{...styles.h2, fontSize: 'clamp(3rem, 6vw, 5rem)', marginBottom: '40px'}}>
|
||||
See the data. <br/> Shape the future.
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleLaunch}
|
||||
style={{
|
||||
padding: '24px 64px',
|
||||
fontSize: '1.1rem',
|
||||
background: 'var(--text-main)',
|
||||
color: 'var(--bg-color)',
|
||||
border: 'none',
|
||||
borderRadius: '2px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.05em'
|
||||
}}
|
||||
onMouseEnter={(e) => gsap.to(e.currentTarget, {scale: 1.05})}
|
||||
onMouseLeave={(e) => gsap.to(e.currentTarget, {scale: 1})}
|
||||
>
|
||||
LAUNCH PLATFORM
|
||||
</button>
|
||||
|
||||
<div style={{marginTop: '120px', display: 'flex', justifyContent: 'space-between', borderTop: '1px solid var(--border-color)', paddingTop: '40px'}}>
|
||||
<span style={{color: 'var(--text-muted)', fontSize: '0.9rem'}}>© 2024 MapLayer.</span>
|
||||
<span style={{color: 'var(--text-muted)', fontSize: '0.9rem'}}>San Diego Smart City Initiative</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectShowcase;
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
.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%);
|
||||
}
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
613
src/components/UI/Homepage/showcase.tsx
Normal file
613
src/components/UI/Homepage/showcase.tsx
Normal file
|
|
@ -0,0 +1,613 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
const ArchitecturalShowcase: React.FC = () => {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [heroVisible, setHeroVisible] = useState(false);
|
||||
const [imageVisible, setImageVisible] = useState(false);
|
||||
const [purposeVisible, setPurposeVisible] = useState(false);
|
||||
const [servicesVisible, setServicesVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.margin = '0';
|
||||
document.body.style.padding = '0';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const timer1 = setTimeout(() => setIsLoaded(true), 200);
|
||||
const timer2 = setTimeout(() => setHeroVisible(true), 600);
|
||||
const timer3 = setTimeout(() => setImageVisible(true), 1200);
|
||||
const timer4 = setTimeout(() => setPurposeVisible(true), 1800);
|
||||
const timer5 = setTimeout(() => setServicesVisible(true), 2400);
|
||||
const timer6 = setTimeout(() => {
|
||||
document.body.style.overflow = 'auto';
|
||||
}, 2600);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer1);
|
||||
clearTimeout(timer2);
|
||||
clearTimeout(timer3);
|
||||
clearTimeout(timer4);
|
||||
clearTimeout(timer5);
|
||||
clearTimeout(timer6);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const globalStyles = `
|
||||
html, body, #root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-width: 100vw;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Responsive CSS */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Services grid single column */
|
||||
.services-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 2rem !important;
|
||||
}
|
||||
|
||||
/* Hide nav links (you can replace with hamburger menu) */
|
||||
.nav-links {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Hero Section Padding */
|
||||
.hero-content {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Hero Title */
|
||||
.hero-title {
|
||||
font-size: 2.5rem !important;
|
||||
line-height: 1.1 !important;
|
||||
}
|
||||
|
||||
/* Hero Subtitle */
|
||||
.hero-subtitle {
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
|
||||
/* CTA Button full width and centered content */
|
||||
.cta-btn {
|
||||
width: 100% !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
/* Image container adjustments */
|
||||
.image-container {
|
||||
padding: 0 1rem !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const styles = {
|
||||
showcase: {
|
||||
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
height: '100vh',
|
||||
backgroundColor: '#fafafa',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
opacity: isLoaded ? 1 : 0,
|
||||
transform: isLoaded ? 'translateY(0) scale(1)' : 'translateY(50px) scale(0.95)',
|
||||
transition: 'all 1.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
},
|
||||
header: {
|
||||
position: 'fixed' as const,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
borderBottom: '1px solid rgba(0, 0, 0, 0.08)',
|
||||
transform: isLoaded ? 'translateY(0)' : 'translateY(-100px)',
|
||||
transition: 'all 0.8s cubic-bezier(0.34, 1.26, 0.64, 1)',
|
||||
transitionDelay: '0.3s',
|
||||
},
|
||||
nav: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '1rem 2rem',
|
||||
maxWidth: '1400px',
|
||||
margin: '0 auto',
|
||||
},
|
||||
logo: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 600,
|
||||
color: '#1a1a1a',
|
||||
opacity: isLoaded ? 1 : 0,
|
||||
transform: isLoaded ? 'translateX(0) rotate(0deg)' : 'translateX(-30px) rotate(-10deg)',
|
||||
transition: 'all 0.8s cubic-bezier(0.68, -0.55, 0.265, 1.55)',
|
||||
transitionDelay: '0.5s',
|
||||
},
|
||||
logoIcon: {
|
||||
fontSize: '1.2rem',
|
||||
animation: isLoaded ? 'pulse 2s infinite' : 'none',
|
||||
},
|
||||
navLinks: {
|
||||
display: 'flex',
|
||||
listStyle: 'none',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
gap: '2rem',
|
||||
},
|
||||
navLink: {
|
||||
color: '#4a4a4a',
|
||||
textDecoration: 'none',
|
||||
fontSize: '0.95rem',
|
||||
fontWeight: 400,
|
||||
transition: 'all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
cursor: 'pointer',
|
||||
opacity: isLoaded ? 1 : 0,
|
||||
transform: isLoaded ? 'translateY(0)' : 'translateY(-20px)',
|
||||
transitionDelay: '0.7s',
|
||||
},
|
||||
hero: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
textAlign: 'center' as const,
|
||||
padding: '0 2rem',
|
||||
paddingTop: '80px',
|
||||
opacity: heroVisible ? 1 : 0,
|
||||
transform: heroVisible ? 'translateY(0)' : 'translateY(60px)',
|
||||
transition: 'all 1.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
},
|
||||
heroContent: {
|
||||
maxWidth: '900px',
|
||||
margin: '0 auto',
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 'clamp(2.5rem, 5vw, 4.5rem)',
|
||||
fontWeight: 300,
|
||||
lineHeight: 1.2,
|
||||
color: '#1a1a1a',
|
||||
margin: '0 0 1.5rem 0',
|
||||
letterSpacing: '-0.02em',
|
||||
},
|
||||
heroSubtitle: {
|
||||
fontSize: 'clamp(1.1rem, 2vw, 1.4rem)',
|
||||
color: '#6a6a6a',
|
||||
lineHeight: 1.6,
|
||||
margin: '0 0 3rem 0',
|
||||
fontWeight: 400,
|
||||
maxWidth: '700px',
|
||||
},
|
||||
ctaBtn: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
backgroundColor: '#1a1a1a',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '1.2rem 2.5rem',
|
||||
borderRadius: '50px',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
fontFamily: 'inherit',
|
||||
boxShadow: '0 4px 20px rgba(26, 26, 26, 0.15)',
|
||||
},
|
||||
ctaArrow: {
|
||||
fontSize: '1.2rem',
|
||||
transition: 'transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
},
|
||||
imageSection: {
|
||||
padding: '5rem 2rem',
|
||||
backgroundColor: '#ffffff',
|
||||
opacity: imageVisible ? 1 : 0,
|
||||
transform: imageVisible ? 'translateY(0)' : 'translateY(80px)',
|
||||
transition: 'all 1.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
},
|
||||
imageContainer: {
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
borderRadius: '20px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.1)',
|
||||
transform: imageVisible ? 'scale(1) rotateX(0deg)' : 'scale(0.9) rotateX(10deg)',
|
||||
transition: 'all 1s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
transitionDelay: '0.2s',
|
||||
},
|
||||
heroImage: {
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
transition: 'transform 0.6s ease',
|
||||
},
|
||||
purpose: {
|
||||
padding: '6rem 2rem',
|
||||
backgroundColor: '#fafafa',
|
||||
opacity: purposeVisible ? 1 : 0,
|
||||
transform: purposeVisible ? 'translateY(0)' : 'translateY(60px)',
|
||||
transition: 'all 1s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
purposeContent: {
|
||||
maxWidth: '1000px',
|
||||
margin: '0 auto',
|
||||
},
|
||||
purposeTitle: {
|
||||
fontSize: 'clamp(2rem, 4vw, 3rem)',
|
||||
fontWeight: 300,
|
||||
color: '#1a1a1a',
|
||||
margin: '0 0 2rem 0',
|
||||
letterSpacing: '-0.01em',
|
||||
},
|
||||
purposeDescription: {
|
||||
fontSize: '1.2rem',
|
||||
color: '#6a6a6a',
|
||||
lineHeight: 1.7,
|
||||
margin: '0',
|
||||
maxWidth: '800px',
|
||||
},
|
||||
servicesSection: {
|
||||
padding: '8rem 2rem',
|
||||
backgroundColor: '#ffffff',
|
||||
position: 'relative' as const,
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
opacity: servicesVisible ? 1 : 0,
|
||||
transform: servicesVisible ? 'translateY(0)' : 'translateY(80px)',
|
||||
transition: 'all 1.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
},
|
||||
servicesContainer: {
|
||||
maxWidth: '1400px',
|
||||
margin: '0 auto',
|
||||
width: '100%',
|
||||
},
|
||||
servicesHeader: {
|
||||
textAlign: 'center' as const,
|
||||
marginBottom: '5rem',
|
||||
position: 'relative' as const,
|
||||
},
|
||||
servicesTitle: {
|
||||
fontSize: 'clamp(2.5rem, 5vw, 4rem)',
|
||||
fontWeight: 300,
|
||||
color: '#1a1a1a',
|
||||
lineHeight: 1.2,
|
||||
margin: '0 0 1.5rem 0',
|
||||
letterSpacing: '-0.02em',
|
||||
},
|
||||
servicesSubtitle: {
|
||||
fontSize: '1.2rem',
|
||||
color: '#6a6a6a',
|
||||
lineHeight: 1.6,
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
},
|
||||
servicesGrid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(350px, 1fr))',
|
||||
gap: '3rem',
|
||||
marginTop: '3rem',
|
||||
},
|
||||
serviceCard: {
|
||||
backgroundColor: 'transparent',
|
||||
padding: '2.5rem 2rem',
|
||||
borderRadius: '20px',
|
||||
position: 'relative' as const,
|
||||
transition: 'all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.08)',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
},
|
||||
serviceIcon: {
|
||||
fontSize: '3rem',
|
||||
marginBottom: '1.5rem',
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
serviceTitle: {
|
||||
fontSize: 'clamp(1.5rem, 2.5vw, 1.8rem)',
|
||||
fontWeight: 500,
|
||||
color: '#1a1a1a',
|
||||
lineHeight: 1.3,
|
||||
margin: '0 0 1.5rem 0',
|
||||
letterSpacing: '-0.01em',
|
||||
},
|
||||
serviceDescription: {
|
||||
fontSize: '1rem',
|
||||
color: '#6a6a6a',
|
||||
lineHeight: 1.6,
|
||||
margin: '0',
|
||||
flex: 1,
|
||||
},
|
||||
footer: {
|
||||
padding: '4rem 2rem 2rem',
|
||||
backgroundColor: '#1a1a1a',
|
||||
color: '#ffffff',
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
footerContent: {
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
},
|
||||
footerText: {
|
||||
fontSize: '0.9rem',
|
||||
color: '#9a9a9a',
|
||||
margin: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// Event Handlers for hovers omitted for brevity — keep them from your original code
|
||||
|
||||
const handleNavLinkHover = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.currentTarget.style.color = '#1a1a1a';
|
||||
};
|
||||
|
||||
const handleNavLinkLeave = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.currentTarget.style.color = '#4a4a4a';
|
||||
};
|
||||
|
||||
const handleCtaBtnHover = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.currentTarget.style.backgroundColor = '#333333';
|
||||
e.currentTarget.style.transform = 'translateY(-3px)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 30px rgba(26, 26, 26, 0.25)';
|
||||
const arrow = e.currentTarget.querySelector('.cta-arrow') as HTMLElement;
|
||||
if (arrow) arrow.style.transform = 'translateX(5px)';
|
||||
};
|
||||
|
||||
const handleCtaBtnLeave = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.currentTarget.style.backgroundColor = '#1a1a1a';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 20px rgba(26, 26, 26, 0.15)';
|
||||
const arrow = e.currentTarget.querySelector('.cta-arrow') as HTMLElement;
|
||||
if (arrow) arrow.style.transform = 'translateX(0)';
|
||||
};
|
||||
|
||||
const handleImageHover = (e: React.MouseEvent<HTMLImageElement>) => {
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
};
|
||||
|
||||
const handleImageLeave = (e: React.MouseEvent<HTMLImageElement>) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
};
|
||||
|
||||
const handleServiceCardHover = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.currentTarget.style.transform = 'translateY(-8px)';
|
||||
e.currentTarget.style.boxShadow = '0 20px 40px rgba(0, 0, 0, 0.1)';
|
||||
e.currentTarget.style.backgroundColor = '#fafafa';
|
||||
};
|
||||
|
||||
const handleServiceCardLeave = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<style dangerouslySetInnerHTML={{ __html: globalStyles }} />
|
||||
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
@keyframes slideInLeft {
|
||||
from { transform: translateX(-100px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes slideInRight {
|
||||
from { transform: translateX(100px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes fadeInUp {
|
||||
from { transform: translateY(30px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes scaleIn {
|
||||
from { transform: scale(0.8); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes rotateIn {
|
||||
from { transform: rotate(-180deg) scale(0.5); opacity: 0; }
|
||||
to { transform: rotate(0deg) scale(1); opacity: 1; }
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={styles.showcase}>
|
||||
{/* Header */}
|
||||
<header style={styles.header}>
|
||||
<nav style={styles.nav}>
|
||||
<div style={styles.logo}>
|
||||
<span style={styles.logoIcon}>🗺️</span>
|
||||
MapLayer SD
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section style={styles.hero} aria-label="Main content">
|
||||
<div style={styles.heroContent} className="hero-content">
|
||||
<h1 style={styles.heroTitle} className="hero-title">
|
||||
San Diego Infrastructure
|
||||
<br />
|
||||
Mapping & Community Hub
|
||||
</h1>
|
||||
<p style={styles.heroSubtitle} className="hero-subtitle">
|
||||
Explore real-time infrastructure data, connect with your community, and make informed decisions about property development. From sewer mains to water lines—visualize San Diego's urban backbone and collaborate with builders, planners, and citizens.
|
||||
</p>
|
||||
<button
|
||||
style={styles.ctaBtn}
|
||||
className="cta-btn"
|
||||
onMouseEnter={handleCtaBtnHover}
|
||||
onMouseLeave={handleCtaBtnLeave}
|
||||
onClick={() => (window.location.href = '/open')}
|
||||
>
|
||||
<span>Explore the Map</span>
|
||||
<div className="cta-arrow" style={styles.ctaArrow}>
|
||||
→
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
{/* Image Section */}
|
||||
<section style={styles.imageSection} aria-label="Featured infrastructure map">
|
||||
<div style={styles.imageContainer}>
|
||||
<img
|
||||
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 600'%3E%3Cdefs%3E%3ClinearGradient id='mapBg' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0%25' stop-color='%23E8F4FD'/%3E%3Cstop offset='100%25' stop-color='%23F0F8FF'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='1200' height='600' fill='url(%23mapBg)'/%3E%3C!-- Map Grid --%3E%3Cg stroke='%23DDD' stroke-width='1' opacity='0.5'%3E%3Cline x1='0' y1='100' x2='1200' y2='100'/%3E%3Cline x1='0' y1='200' x2='1200' y2='200'/%3E%3Cline x1='0' y1='300' x2='1200' y2='300'/%3E%3Cline x1='0' y1='400' x2='1200' y2='400'/%3E%3Cline x1='0' y1='500' x2='1200' y2='500'/%3E%3Cline x1='200' y1='0' x2='200' y2='600'/%3E%3Cline x1='400' y1='0' x2='400' y2='600'/%3E%3Cline x1='600' y1='0' x2='600' y2='600'/%3E%3Cline x1='800' y1='0' x2='800' y2='600'/%3E%3Cline x1='1000' y1='0' x2='1000' y2='600'/%3E%3C/g%3E%3C!-- Water Mains (Blue) --%3E%3Cg stroke='%232196F3' stroke-width='4' fill='none'%3E%3Cpath d='M50 150 Q200 120 350 140 T650 160 Q800 150 950 170'/%3E%3Cpath d='M100 250 L300 240 Q450 230 600 250 L900 260'/%3E%3Cpath d='M150 350 Q400 340 650 360 L1000 370'/%3E%3Cpath d='M80 450 L280 440 Q500 450 750 460 Q900 455 1100 470'/%3E%3C/g%3E%3C!-- Sewer Mains (Brown) --%3E%3Cg stroke='%23795548' stroke-width='3' fill='none'%3E%3Cpath d='M70 180 Q250 170 400 185 T700 200 Q850 195 1050 210'/%3E%3Cpath d='M120 280 L320 270 Q480 275 630 285 L880 290'/%3E%3Cpath d='M180 380 Q420 375 680 385 L980 390'/%3E%3Cpath d='M110 480 L310 475 Q530 485 780 490 Q920 485 1120 500'/%3E%3C/g%3E%3C!-- Property Parcels (Green outlines) --%3E%3Cg stroke='%234CAF50' stroke-width='2' fill='%234CAF50' fill-opacity='0.1'%3E%3Crect x='220' y='120' width='80' height='60'/%3E%3Crect x='320' y='110' width='100' height='80'/%3E%3Crect x='450' y='130' width='90' height='70'/%3E%3Crect x='580' y='115' width='85' height='65'/%3E%3Crect x='720' y='125' width='95' height='75'/%3E%3Crect x='250' y='220' width='70' height='55'/%3E%3Crect x='370' y='210' width='110' height='85'/%3E%3Crect x='520' y='230' width='80' height='60'/%3E%3Crect x='650' y='215' width='90' height='70'/%3E%3Crect x='200' y='320' width='85' height='65'/%3E%3Crect x='340' y='310' width='95' height='75'/%3E%3Crect x='480' y='330' width='100' height='80'/%3E%3Crect x='620' y='315' width='75' height='55'/%3E%3C/g%3E%3C!-- Map Markers --%3E%3Cg%3E%3Ccircle cx='260' cy='150' r='8' fill='%23FF5722'/%3E%3Ccircle cx='410' cy='270' r='8' fill='%23FF5722'/%3E%3Ccircle cx='580' cy='350' r='8' fill='%23FF5722'/%3E%3Ccircle cx='750' cy='180' r='8' fill='%23FF5722'/%3E%3C/g%3E%3C!-- Legend --%3E%3Cg transform='translate(50, 50)'%3E%3Crect x='0' y='0' width='200' height='100' fill='white' stroke='%23DDD' rx='5'/%3E%3Ctext x='10' y='20' font-family='Arial' font-size='14' font-weight='bold' fill='%23333'%3EInfrastructure Layers%3C/text%3E%3Cline x1='10' y1='35' x2='30' y2='35' stroke='%232196F3' stroke-width='4'/%3E%3Ctext x='35' y='40' font-family='Arial' font-size='12' fill='%23666'%3EWater Mains%3C/text%3E%3Cline x1='10' y1='55' x2='30' y2='55' stroke='%23795548' stroke-width='3'/%3E%3Ctext x='35' y='60' font-family='Arial' font-size='12' fill='%23666'%3ESewer Lines%3C/text%3E%3Crect x='10' y='70' width='20' height='15' fill='%234CAF50' fill-opacity='0.3' stroke='%234CAF50'/%3E%3Ctext x='35' y='82' font-family='Arial' font-size='12' fill='%23666'%3EProperty Parcels%3C/text%3E%3C/g%3E%3C/svg%3E"
|
||||
alt="Interactive map showing San Diego's infrastructure including water mains, sewer lines, and property parcels with community markers"
|
||||
style={styles.heroImage}
|
||||
onMouseEnter={handleImageHover}
|
||||
onMouseLeave={handleImageLeave}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Purpose Section */}
|
||||
<section style={styles.purpose} aria-labelledby="purpose-heading">
|
||||
<div style={styles.purposeContent}>
|
||||
<h2 id="purpose-heading" style={styles.purposeTitle}>
|
||||
Empowering San Diego's Development Community
|
||||
</h2>
|
||||
<p style={styles.purposeDescription}>
|
||||
Our platform bridges the gap between raw infrastructure data and actionable community insights. By combining real-time GeoJSON data from San Diego's open APIs with community-driven collaboration tools, we create a comprehensive resource for property
|
||||
buyers, builders, city planners, and engaged citizens to make informed decisions about our city's future.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Services Section */}
|
||||
<section style={styles.servicesSection} id="services">
|
||||
<div style={styles.servicesContainer}>
|
||||
<div style={styles.servicesHeader}>
|
||||
<h2 style={styles.servicesTitle}>Comprehensive Infrastructure Solutions</h2>
|
||||
<p style={styles.servicesSubtitle}>
|
||||
From real-time data visualization to community collaboration—everything you need to understand San Diego's infrastructure landscape.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={styles.servicesGrid} className="services-grid">
|
||||
{/* Service Cards (keep all 6 as you had) */}
|
||||
<div style={styles.serviceCard} onMouseEnter={handleServiceCardHover} onMouseLeave={handleServiceCardLeave}>
|
||||
<div style={styles.serviceIcon}>🗺️</div>
|
||||
<h3 style={styles.serviceTitle}>Interactive Infrastructure Mapping</h3>
|
||||
<p style={styles.serviceDescription}>
|
||||
Visualize San Diego's complete infrastructure network including sewer mains, water lines, pipelines, and zoning areas. Toggle layers, zoom into specific neighborhoods, and access real-time GeoJSON data from official city APIs.
|
||||
</p>
|
||||
</div>
|
||||
<div style={styles.serviceCard} onMouseEnter={handleServiceCardHover} onMouseLeave={handleServiceCardLeave}>
|
||||
<div style={styles.serviceIcon}>👥</div>
|
||||
<h3 style={styles.serviceTitle}>Community Collaboration Hub</h3>
|
||||
<p style={styles.serviceDescription}>
|
||||
Connect with builders, property buyers, city planners, and fellow citizens. Participate in location-based discussions, share insights about infrastructure challenges, and collaborate on development projects in your area.
|
||||
</p>
|
||||
</div>
|
||||
<div style={styles.serviceCard} onMouseEnter={handleServiceCardHover} onMouseLeave={handleServiceCardLeave}>
|
||||
<div style={styles.serviceIcon}>📈</div>
|
||||
<h3 style={styles.serviceTitle}>Analytics & Decision Support</h3>
|
||||
<p style={styles.serviceDescription}>
|
||||
Access comprehensive analytics to evaluate infrastructure density, property development feasibility, and resource availability. Make data-driven decisions with heatmaps, trend analysis, and infrastructure capacity assessments.
|
||||
</p>
|
||||
</div>
|
||||
<div style={styles.serviceCard} onMouseEnter={handleServiceCardHover} onMouseLeave={handleServiceCardLeave}>
|
||||
<div style={styles.serviceIcon}>🏘️</div>
|
||||
<h3 style={styles.serviceTitle}>Property Development Tools</h3>
|
||||
<p style={styles.serviceDescription}>
|
||||
Essential tools for real estate developers and builders. Check infrastructure availability before starting projects, view ongoing public works, assess development feasibility, and access zoning information for informed decision-making.
|
||||
</p>
|
||||
</div>
|
||||
<div style={styles.serviceCard} onMouseEnter={handleServiceCardHover} onMouseLeave={handleServiceCardLeave}>
|
||||
<div style={styles.serviceIcon}>📊</div>
|
||||
<h3 style={styles.serviceTitle}>Issue Reporting & Crowdsourcing</h3>
|
||||
<p style={styles.serviceDescription}>
|
||||
Report infrastructure issues directly from the map interface. Crowdsource problem identification, track repair status, and contribute to maintaining accurate, community-verified infrastructure data across San Diego.
|
||||
</p>
|
||||
</div>
|
||||
<div style={styles.serviceCard} onMouseEnter={handleServiceCardHover} onMouseLeave={handleServiceCardLeave}>
|
||||
<div style={styles.serviceIcon}>🚀</div>
|
||||
<h3 style={styles.serviceTitle}>Future City Expansion</h3>
|
||||
<p style={styles.serviceDescription}>
|
||||
Built for scalability beyond San Diego. Our platform architecture supports expansion to other cities, additional datasets including environmental and transportation data, and integration with multiple municipal data sources.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section style={{ ...styles.purpose, backgroundColor: '#ffffff' }} aria-labelledby="features-heading">
|
||||
<div style={styles.purposeContent}>
|
||||
<h2 id="features-heading" style={styles.purposeTitle}>
|
||||
Why Choose MapLayer SD?
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
gap: '3rem',
|
||||
marginTop: '3rem',
|
||||
textAlign: 'left' as const,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1.3rem', color: '#1a1a1a', marginBottom: '1rem' }}>📍 Hyper-Local Focus</h3>
|
||||
<p style={{ color: '#6a6a6a', lineHeight: 1.6 }}>
|
||||
Unlike generic mapping solutions, we specialize exclusively in San Diego's infrastructure, providing unmatched detail and accuracy for local development needs.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1.3rem', color: '#1a1a1a', marginBottom: '1rem' }}>🔄 Real-Time Data</h3>
|
||||
<p style={{ color: '#6a6a6a', lineHeight: 1.6 }}>
|
||||
Direct integration with San Diego's open data APIs ensures you always have the most current infrastructure information available.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ fontSize: '1.3rem', color: '#1a1a1a', marginBottom: '1rem' }}>🤝 Community-Driven</h3>
|
||||
<p style={{ color: '#6a6a6a', lineHeight: 1.6 }}>
|
||||
Beyond visualization—we foster collaboration between builders, buyers, planners, and citizens for better urban development outcomes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer style={styles.footer}>
|
||||
<div style={styles.footerContent}>
|
||||
<p style={styles.footerText}>
|
||||
© 2024 MapLayer SD - San Diego Infrastructure & Community Mapping Platform. Empowering informed urban development through data transparency and community collaboration.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArchitecturalShowcase;
|
||||
27
src/components/UI/Map/OpenLayerMap.css
Normal file
27
src/components/UI/Map/OpenLayerMap.css
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* For your map container specifically */
|
||||
.map-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Ensure OL map gets proper dimensions */
|
||||
.ol-viewport {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
550
src/components/UI/Map/OpenLayerMap.tsx
Normal file
550
src/components/UI/Map/OpenLayerMap.tsx
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Map, View } from 'ol';
|
||||
import 'ol/ol.css';
|
||||
import TileLayer from 'ol/layer/Tile';
|
||||
import { OSM } from 'ol/source';
|
||||
import { fromLonLat } from 'ol/proj';
|
||||
import { Coordinate } from 'ol/coordinate';
|
||||
import VectorLayer from 'ol/layer/Vector';
|
||||
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<any>();
|
||||
const [layerVisibility, setLayerVisibility] = useState<Record<string, boolean>>({});
|
||||
const [searchCompleted, setSearchCompleted] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
|
||||
// Check for mobile view
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
if (window.innerWidth >= 768) {
|
||||
setSidebarOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Submit Button Logic
|
||||
const submitForm = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${term}&format=json&polygon=1&addressdetails=1`);
|
||||
const json = await response.json();
|
||||
setAddress(json);
|
||||
setSearchCompleted(true);
|
||||
|
||||
const initialVisibility = Object.fromEntries(
|
||||
layerConfigs.map(layer => [layer.topic, true])
|
||||
);
|
||||
setLayerVisibility(initialVisibility);
|
||||
}
|
||||
|
||||
const mapDivRef = useRef<HTMLDivElement>(null);
|
||||
const [clickedCoordinate, setClickedCoordinate] = useState<Coordinate>();
|
||||
const mapRef = useRef<Map | null>(null);
|
||||
const markerLayerRef = useRef<VectorLayer | null>(null);
|
||||
const layerRefs = useRef<Record<string, React.MutableRefObject<VectorLayer | null>>>(
|
||||
Object.fromEntries(
|
||||
layerConfigs.map((layer) => [layer.topic, { current: null }])
|
||||
)
|
||||
);
|
||||
|
||||
// Toggle layer visibility
|
||||
const toggleLayer = (layerTopic: string) => {
|
||||
const layerRef = layerRefs.current[layerTopic];
|
||||
const isCurrentlyVisible = layerVisibility[layerTopic];
|
||||
|
||||
if (layerRef.current) {
|
||||
layerRef.current.setVisible(!isCurrentlyVisible);
|
||||
}
|
||||
|
||||
setLayerVisibility(prev => ({
|
||||
...prev,
|
||||
[layerTopic]: !isCurrentlyVisible
|
||||
}));
|
||||
};
|
||||
|
||||
// Format layer name for display
|
||||
const formatLayerName = (topic: string) => {
|
||||
return topic.replace(/_/g, ' ').replace(/SD$/, '').trim();
|
||||
};
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
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]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
fontFamily: "'Poppins', sans-serif",
|
||||
backgroundColor: '#f8f9fa',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Google Fonts Link */}
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<style>
|
||||
{`
|
||||
/* Fix for black borders */
|
||||
body, html, #root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Map container fixes */
|
||||
.map-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
/* Layer list improvements */
|
||||
.layer-item {
|
||||
padding: 8px 12px;
|
||||
margin: 2px 0;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.layer-item:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #a1a1a1;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 280px !important;
|
||||
left: ${sidebarOpen ? '16px' : '-280px'};
|
||||
height: calc(100vh - 96px) !important;
|
||||
top: 80px;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.sidebar {
|
||||
width: 260px !important;
|
||||
left: ${sidebarOpen ? '8px' : '-260px'};
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
{/* Header */}
|
||||
<header style={{
|
||||
backgroundColor: 'transparent',
|
||||
borderBottom: 'none',
|
||||
boxShadow: 'none',
|
||||
padding: '0 32px',
|
||||
height: '72px',
|
||||
borderRadius: '60px',
|
||||
minHeight: '72px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
zIndex: 1000,
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{/* Navigation - centered with white container */}
|
||||
<nav className="nav-buttons" style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
borderRadius: '60px',
|
||||
backgroundColor: '#ffffff',
|
||||
padding: '8px',
|
||||
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.08)',
|
||||
border: '1px solid #e9ecef'
|
||||
}}>
|
||||
{['Home', 'Map'].map((item, index) => (
|
||||
<button
|
||||
key={item}
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
backgroundColor: index === 1 ? '#000000' : 'transparent',
|
||||
color: index === 1 ? 'white' : '#495057',
|
||||
border: 'none',
|
||||
fontSize: '14px',
|
||||
|
||||
borderRadius: '60px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: index === 1 ? '0 4px 12px rgba(44, 62, 80, 0.2)' : 'none',
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
height: 'calc(100vh - 72px)',
|
||||
width: '100vw',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Floating Sidebar */}
|
||||
<div className="sidebar custom-scrollbar" style={{
|
||||
position: 'absolute',
|
||||
top: '24px',
|
||||
left: '24px',
|
||||
bottom: '24px',
|
||||
width: '360px',
|
||||
backgroundColor: '#ffffff',
|
||||
padding: '24px',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
boxShadow: '#000000 0px 4px 12px rgba(44, 62, 80, 0.1)',
|
||||
zIndex: 100,
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transform: sidebarOpen ? 'translateX(0)' : 'translateX(-400px)'
|
||||
}}>
|
||||
{/* Sidebar toggle button */}
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '-48px',
|
||||
top: '24px',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e9ecef',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
|
||||
zIndex: 101
|
||||
}}
|
||||
>
|
||||
{sidebarOpen ? '❮' : '❯'}
|
||||
</button>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '8px',
|
||||
height: '32px',
|
||||
background: '#000000',
|
||||
}} />
|
||||
<h3 style={{
|
||||
margin: '0',
|
||||
color: '#000000',
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
letterSpacing: '-0.3px'
|
||||
}}>
|
||||
Map Controls
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Search Form */}
|
||||
<div className="search-container" style={{
|
||||
background: 'linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%)',
|
||||
padding: '20px',
|
||||
marginBottom: '24px',
|
||||
border: '1px solid #e9ecef',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.04)'
|
||||
}}>
|
||||
<form onSubmit={submitForm}>
|
||||
<input
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
type="text"
|
||||
placeholder="Search for any location..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 16px',
|
||||
border: '1px solid #dee2e6',
|
||||
marginBottom: '12px',
|
||||
fontSize: '14px',
|
||||
borderRadius: '60px',
|
||||
color: '#000000',
|
||||
fontFamily: "'Poppins', sans-serif",
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.04)',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px',
|
||||
background: '#000000',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '60px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
fontFamily: "'Poppins', sans-serif",
|
||||
boxShadow: '0 4px 12px rgba(44, 62, 80, 0.2)',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Layer Toggle List */}
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<h4 style={{
|
||||
margin: '0',
|
||||
color: '#000000',
|
||||
fontSize: '16px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
Data Layers
|
||||
</h4>
|
||||
<span style={{
|
||||
background: 'linear-gradient(135deg, #e9ecef 0%, #f8f9fa 100%)',
|
||||
color: '#000000',
|
||||
padding: '4px 8px',
|
||||
fontSize: '12px',
|
||||
borderRadius: '60px',
|
||||
|
||||
fontWeight: '500',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.05)'
|
||||
}}>
|
||||
{layerConfigs.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{layerConfigs.map((layer, _index) => (
|
||||
<div key={layer.topic} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '16px',
|
||||
marginBottom: '8px',
|
||||
background: layerVisibility[layer.topic]
|
||||
? 'linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%)'
|
||||
: 'white',
|
||||
border: `1px solid ${layerVisibility[layer.topic] ? '#000000' : '#e9ecef'}`,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: layerVisibility[layer.topic]
|
||||
? '0 4px 8px rgba(44, 62, 80, 0.1)'
|
||||
: '0 1px 3px rgba(0,0,0,0.05)',
|
||||
cursor: 'pointer'
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '14px',
|
||||
color: '#000000',
|
||||
flex: 1,
|
||||
marginRight: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{formatLayerName(layer.topic)}
|
||||
</span>
|
||||
|
||||
{searchCompleted && (
|
||||
<label style={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
width: '48px',
|
||||
height: '26px'
|
||||
}}>
|
||||
<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,
|
||||
borderRadius: '60px',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: layerVisibility[layer.topic]
|
||||
? 'linear-gradient(135deg, #2c3e50 0%, #34495e 100%)'
|
||||
: '#dee2e6',
|
||||
transition: '0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
boxShadow: 'inset 0 1px 3px rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
content: '',
|
||||
borderRadius: '60px',
|
||||
height: '20px',
|
||||
width: '20px',
|
||||
left: layerVisibility[layer.topic] ? '25px' : '3px',
|
||||
bottom: '3px',
|
||||
backgroundColor: 'white',
|
||||
transition: '0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)'
|
||||
}} />
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{!searchCompleted && (
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
color: '#adb5bd',
|
||||
fontStyle: 'italic',
|
||||
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '4px 8px',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.05)'
|
||||
}}>
|
||||
Search first
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map Container */}
|
||||
<div className="map-container" style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
backgroundColor: '#ffffff',
|
||||
zIndex: 1,
|
||||
}}>
|
||||
<div ref={mapDivRef} style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden'
|
||||
}} />
|
||||
|
||||
{/* Coordinate Display */}
|
||||
{clickedCoordinate && (
|
||||
<div className="coordinate-display" style={{
|
||||
position: 'absolute',
|
||||
bottom: '24px',
|
||||
left: '24px',
|
||||
background: '#000000',
|
||||
color: 'white',
|
||||
padding: '12px 16px',
|
||||
fontSize: '13px',
|
||||
fontFamily: "'Poppins', monospace",
|
||||
fontWeight: '500',
|
||||
boxShadow: '0 8px 32px rgba(44, 62, 80, 0.3)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
animation: 'fadeInUp 0.3s ease-out'
|
||||
}}>
|
||||
📍 Coordinates: {clickedCoordinate[0].toFixed(4)}, {clickedCoordinate[1].toFixed(4)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OpenLayersMap;
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
/* 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;
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,24 +1,68 @@
|
|||
/* IMPORTANT: Global Reset */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden; /* Prevents horizontal scrollbars */
|
||||
background-color: #030303; /* Match your theme.bg color so any loading gaps are black */
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Ensure the React root div also takes full width */
|
||||
#root {
|
||||
width: 100%;
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue