mirror of
https://github.com/blackboxprogramming/blackroad-roadworld.git
synced 2026-03-17 23:34:02 -05:00
Initial RoadWorld module implementation
- Modular JavaScript architecture with ES6 modules - MapLibre GL integration with multiple tile providers - Location search using Nominatim API - Local storage for saved locations and settings - Responsive UI with BlackRoad Earth branding - 3D controls and globe view - Quick location navigation - User geolocation support - Deployment configuration for Cloudflare Pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
dist/
|
||||||
|
.cache/
|
||||||
|
.wrangler/
|
||||||
142
README.md
Normal file
142
README.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# RoadWorld Module
|
||||||
|
|
||||||
|
BlackRoad Earth street-level exploration module built with MapLibre GL.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Globe View**: Start from space and zoom down to street level
|
||||||
|
- **Multiple Map Styles**: Satellite, Streets, Dark, Terrain, Hybrid
|
||||||
|
- **Location Search**: Search any location worldwide using Nominatim
|
||||||
|
- **Quick Locations**: Pre-configured famous landmarks
|
||||||
|
- **3D Controls**: Tilt, rotate, and pitch the map
|
||||||
|
- **User Location**: Find and navigate to your current location
|
||||||
|
- **Save Locations**: Save favorite places to local storage
|
||||||
|
- **History Tracking**: Track search and navigation history
|
||||||
|
- **Responsive UI**: Works on desktop and mobile
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
- **MapLibre GL**: Open-source mapping library
|
||||||
|
- **Vanilla JavaScript**: Modular ES6+ architecture
|
||||||
|
- **LocalStorage**: Client-side data persistence
|
||||||
|
- **Nominatim**: OpenStreetMap geocoding service
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
roadworld/
|
||||||
|
├── public/
|
||||||
|
│ └── index.html # Main HTML file
|
||||||
|
├── src/
|
||||||
|
│ ├── css/
|
||||||
|
│ │ └── main.css # Styles
|
||||||
|
│ └── js/
|
||||||
|
│ ├── main.js # Application entry point
|
||||||
|
│ ├── config.js # Configuration and constants
|
||||||
|
│ ├── mapManager.js # Map initialization and control
|
||||||
|
│ ├── uiController.js # UI updates and interactions
|
||||||
|
│ ├── searchService.js # Location search
|
||||||
|
│ └── storageManager.js # Local storage management
|
||||||
|
├── package.json
|
||||||
|
├── wrangler.toml
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Run locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/roadworld
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:8000/public in your browser.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Deploy to Cloudflare Pages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Or deploy via Git:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git init
|
||||||
|
git add .
|
||||||
|
git commit -m "Initial RoadWorld module"
|
||||||
|
gh repo create blackroad-roadworld --public --source=. --remote=origin
|
||||||
|
git push -u origin main
|
||||||
|
|
||||||
|
# Then connect to Cloudflare Pages dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Map Styles
|
||||||
|
|
||||||
|
Edit `src/js/config.js` to add or modify map tile sources:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export const STYLES = {
|
||||||
|
// Add your custom style here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Default Settings
|
||||||
|
|
||||||
|
Modify `MAP_CONFIG` in `src/js/config.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export const MAP_CONFIG = {
|
||||||
|
center: [0, 20],
|
||||||
|
zoom: 1.5,
|
||||||
|
minZoom: 0,
|
||||||
|
maxZoom: 22,
|
||||||
|
projection: 'globe',
|
||||||
|
antialias: true
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Search Locations
|
||||||
|
|
||||||
|
Type any location name, address, or coordinates in the search bar and click "FLY TO".
|
||||||
|
|
||||||
|
### Quick Navigation
|
||||||
|
|
||||||
|
Click any of the quick location buttons on the right side for instant travel to famous landmarks.
|
||||||
|
|
||||||
|
### 3D View
|
||||||
|
|
||||||
|
Click the 🏢 button to toggle 3D building mode and tilt the map.
|
||||||
|
|
||||||
|
### Save Locations
|
||||||
|
|
||||||
|
Click the 💾 button to save the current view to your favorites.
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
- **Drag**: Pan and rotate
|
||||||
|
- **Scroll**: Zoom in/out
|
||||||
|
- **Ctrl+Drag**: Tilt the map
|
||||||
|
- **Right-click drag**: Rotate the view
|
||||||
|
|
||||||
|
## API Usage
|
||||||
|
|
||||||
|
The application uses free tile providers and the Nominatim geocoding service. No API keys required.
|
||||||
|
|
||||||
|
## BlackRoad Integration
|
||||||
|
|
||||||
|
Part of the BlackRoad Earth infrastructure. Integrates with:
|
||||||
|
|
||||||
|
- BlackRoad OS Operator
|
||||||
|
- Cloudflare Pages deployment
|
||||||
|
- GitHub repository system
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - BlackRoad Systems
|
||||||
26
deploy.sh
Executable file
26
deploy.sh
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# RoadWorld Deployment Script
|
||||||
|
# Deploys to Cloudflare Pages
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🚀 BlackRoad RoadWorld Deployment"
|
||||||
|
echo "=================================="
|
||||||
|
|
||||||
|
# Check if wrangler is installed
|
||||||
|
if ! command -v wrangler &> /dev/null; then
|
||||||
|
echo "❌ Wrangler CLI not found. Installing..."
|
||||||
|
npm install -g wrangler
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check authentication
|
||||||
|
echo "📋 Checking Cloudflare authentication..."
|
||||||
|
wrangler whoami
|
||||||
|
|
||||||
|
# Deploy to Cloudflare Pages
|
||||||
|
echo "🌍 Deploying to Cloudflare Pages..."
|
||||||
|
wrangler pages deploy public --project-name=roadworld
|
||||||
|
|
||||||
|
echo "✅ Deployment complete!"
|
||||||
|
echo "🔗 Your site should be available at: https://roadworld.pages.dev"
|
||||||
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "roadworld",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "BlackRoad Earth - RoadWorld Module for street-level exploration",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "python3 -m http.server 8000",
|
||||||
|
"build": "echo 'No build step required for static site'",
|
||||||
|
"deploy": "wrangler pages deploy public --project-name=roadworld"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"maps",
|
||||||
|
"geolocation",
|
||||||
|
"maplibre",
|
||||||
|
"blackroad"
|
||||||
|
],
|
||||||
|
"author": "BlackRoad Systems",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {}
|
||||||
|
}
|
||||||
167
public/index.html
Normal file
167
public/index.html
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>BlackRoad Earth | RoadWorld Module</title>
|
||||||
|
|
||||||
|
<!-- MapLibre GL -->
|
||||||
|
<script src="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.js"></script>
|
||||||
|
<link href="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.css" rel="stylesheet" />
|
||||||
|
|
||||||
|
<!-- Styles -->
|
||||||
|
<link rel="stylesheet" href="../src/css/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="map"></div>
|
||||||
|
<div class="crosshair"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="ui-overlay header">
|
||||||
|
<div>
|
||||||
|
<div class="logo">BLACKROAD EARTH</div>
|
||||||
|
<div class="logo-sub">ROADWORLD MODULE</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value highlight" id="zoom-level">1.0</div>
|
||||||
|
<div class="stat-label">Zoom Level</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value" id="altitude">10,000 km</div>
|
||||||
|
<div class="stat-label">Altitude</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value" id="resolution">10 km/px</div>
|
||||||
|
<div class="stat-label">Resolution</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-value" id="coordinates">0°, 0°</div>
|
||||||
|
<div class="stat-label">Center</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="globe-mode-indicator">
|
||||||
|
<div class="mode-dot"></div>
|
||||||
|
<span id="view-mode">GLOBE VIEW</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="ui-overlay search-container">
|
||||||
|
<input type="text" class="search-input" id="search" placeholder="Search any location... (Times Square, Eiffel Tower, 123 Main St)">
|
||||||
|
<button class="search-btn" id="search-btn">FLY TO</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zoom Panel -->
|
||||||
|
<div class="ui-overlay zoom-panel">
|
||||||
|
<div class="zoom-title">DETAIL LEVEL</div>
|
||||||
|
<div class="zoom-bar">
|
||||||
|
<div class="zoom-fill" id="zoom-fill" style="width: 5%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="zoom-labels">
|
||||||
|
<span>Space</span>
|
||||||
|
<span>Street</span>
|
||||||
|
</div>
|
||||||
|
<div class="scale-list">
|
||||||
|
<div class="scale-item active" data-min="0" data-max="3">
|
||||||
|
<span class="scale-icon">🌍</span>
|
||||||
|
<span>Planet View</span>
|
||||||
|
</div>
|
||||||
|
<div class="scale-item" data-min="3" data-max="6">
|
||||||
|
<span class="scale-icon">🗺️</span>
|
||||||
|
<span>Continent</span>
|
||||||
|
</div>
|
||||||
|
<div class="scale-item" data-min="6" data-max="10">
|
||||||
|
<span class="scale-icon">🏔️</span>
|
||||||
|
<span>Region / Country</span>
|
||||||
|
</div>
|
||||||
|
<div class="scale-item" data-min="10" data-max="14">
|
||||||
|
<span class="scale-icon">🏙️</span>
|
||||||
|
<span>City</span>
|
||||||
|
</div>
|
||||||
|
<div class="scale-item" data-min="14" data-max="17">
|
||||||
|
<span class="scale-icon">🏘️</span>
|
||||||
|
<span>Neighborhood</span>
|
||||||
|
</div>
|
||||||
|
<div class="scale-item" data-min="17" data-max="20">
|
||||||
|
<span class="scale-icon">🏠</span>
|
||||||
|
<span>Street Level</span>
|
||||||
|
</div>
|
||||||
|
<div class="scale-item" data-min="20" data-max="24">
|
||||||
|
<span class="scale-icon">🚶</span>
|
||||||
|
<span>Building Detail</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Panel -->
|
||||||
|
<div class="ui-overlay info-panel">
|
||||||
|
<div class="info-section">
|
||||||
|
<div class="info-label">Location</div>
|
||||||
|
<div class="info-value large" id="location-name">Earth</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-section">
|
||||||
|
<div class="info-label">Latitude</div>
|
||||||
|
<div class="info-value" id="lat">0.000000°</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-section">
|
||||||
|
<div class="info-label">Longitude</div>
|
||||||
|
<div class="info-value" id="lng">0.000000°</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-section">
|
||||||
|
<div class="info-label">Tile Coordinates</div>
|
||||||
|
<div class="info-value" id="tile-coords">z0 / x0 / y0</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-section">
|
||||||
|
<div class="info-label">Saved Locations</div>
|
||||||
|
<div class="info-value" id="saved-count">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Layer Selection -->
|
||||||
|
<div class="ui-overlay layer-panel">
|
||||||
|
<button class="layer-btn active" data-style="satellite">🛰️ Satellite</button>
|
||||||
|
<button class="layer-btn" data-style="streets">🗺️ Streets</button>
|
||||||
|
<button class="layer-btn" data-style="dark">🌙 Dark</button>
|
||||||
|
<button class="layer-btn" data-style="terrain">🏔️ Terrain</button>
|
||||||
|
<button class="layer-btn" data-style="hybrid">🔀 Hybrid</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Locations -->
|
||||||
|
<div class="ui-overlay quick-locations">
|
||||||
|
<button class="quick-btn" data-lat="40.7580" data-lng="-73.9855" data-zoom="18">Times Square <span>NYC</span></button>
|
||||||
|
<button class="quick-btn" data-lat="48.8584" data-lng="2.2945" data-zoom="18">Eiffel Tower <span>Paris</span></button>
|
||||||
|
<button class="quick-btn" data-lat="35.6595" data-lng="139.7004" data-zoom="18">Shibuya Crossing <span>Tokyo</span></button>
|
||||||
|
<button class="quick-btn" data-lat="51.5007" data-lng="-0.1246" data-zoom="18">Big Ben <span>London</span></button>
|
||||||
|
<button class="quick-btn" data-lat="40.6892" data-lng="-74.0445" data-zoom="17">Statue of Liberty <span>NYC</span></button>
|
||||||
|
<button class="quick-btn" data-lat="37.8199" data-lng="-122.4783" data-zoom="16">Golden Gate <span>SF</span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="ui-overlay controls">
|
||||||
|
<button class="ctrl-btn" id="btn-home" title="Home">🏠</button>
|
||||||
|
<button class="ctrl-btn" id="btn-north" title="Reset North">🧭</button>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<button class="ctrl-btn" id="btn-zoom-in" title="Zoom In">+</button>
|
||||||
|
<button class="ctrl-btn" id="btn-zoom-out" title="Zoom Out">−</button>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<button class="ctrl-btn" id="btn-3d" title="3D Buildings">🏢</button>
|
||||||
|
<button class="ctrl-btn" id="btn-globe" title="Globe View">🌐</button>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<button class="ctrl-btn" id="btn-locate" title="My Location">📍</button>
|
||||||
|
<button class="ctrl-btn" id="btn-save" title="Save Location">💾</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Instructions -->
|
||||||
|
<div class="ui-overlay instructions">
|
||||||
|
<div><kbd>DRAG</kbd> Pan / Rotate</div>
|
||||||
|
<div><kbd>SCROLL</kbd> Zoom</div>
|
||||||
|
<div><kbd>CTRL+DRAG</kbd> Tilt</div>
|
||||||
|
<div><kbd>RIGHT-CLICK</kbd> Rotate</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JavaScript Modules -->
|
||||||
|
<script type="module" src="../src/js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
470
src/css/main.css
Normal file
470
src/css/main.css
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Exo+2:wght@300;400;600&display=swap');
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
font-family: 'Exo 2', sans-serif;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maplibregl-ctrl-attrib {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-overlay {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-overlay > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 16px 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(to bottom, rgba(0,0,0,0.9) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
background: linear-gradient(135deg, #FF9D00 0%, #FF6B00 20%, #FF0066 40%, #D600AA 60%, #7700FF 80%, #0066FF 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-sub {
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.highlight {
|
||||||
|
color: #ff6b35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 8px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Globe Mode Indicator */
|
||||||
|
.globe-mode-indicator {
|
||||||
|
position: fixed;
|
||||||
|
top: 75px;
|
||||||
|
left: 20px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(0, 212, 255, 0.2);
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.4);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #00d4ff;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search Container */
|
||||||
|
.search-container {
|
||||||
|
top: 75px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 350px;
|
||||||
|
padding: 14px 24px;
|
||||||
|
background: rgba(10, 15, 30, 0.95);
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
|
border-radius: 30px;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Exo 2', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input::placeholder {
|
||||||
|
color: rgba(255,255,255,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: #00d4ff;
|
||||||
|
box-shadow: 0 0 30px rgba(0, 212, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
padding: 14px 24px;
|
||||||
|
background: linear-gradient(135deg, #00d4ff, #7b2ff7);
|
||||||
|
border: none;
|
||||||
|
border-radius: 30px;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 0 30px rgba(0, 212, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info Panel */
|
||||||
|
.info-panel {
|
||||||
|
top: 140px;
|
||||||
|
right: 20px;
|
||||||
|
width: 280px;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(10, 15, 30, 0.95);
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
|
border-radius: 16px;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value.large {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zoom Panel */
|
||||||
|
.zoom-panel {
|
||||||
|
top: 140px;
|
||||||
|
left: 20px;
|
||||||
|
width: 220px;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(10, 15, 30, 0.95);
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
|
border-radius: 16px;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-title {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #00d4ff, #7b2ff7, #ff6b35);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 9px;
|
||||||
|
opacity: 0.4;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scale-list {
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.1);
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scale-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.4;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scale-item.active {
|
||||||
|
opacity: 1;
|
||||||
|
color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scale-icon {
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layer Panel */
|
||||||
|
.layer-panel {
|
||||||
|
bottom: 100px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
background: rgba(10, 15, 30, 0.95);
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||||
|
border-radius: 25px;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-btn {
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255,255,255,0.5);
|
||||||
|
font-family: 'Exo 2', sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 20px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-btn:hover {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-btn.active {
|
||||||
|
background: linear-gradient(135deg, #00d4ff, #7b2ff7);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls */
|
||||||
|
.controls {
|
||||||
|
bottom: 30px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: rgba(10, 15, 30, 0.95);
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
|
border-radius: 30px;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(0, 212, 255, 0.3);
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
|
color: #00d4ff;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn:hover {
|
||||||
|
background: rgba(0, 212, 255, 0.3);
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 0 20px rgba(0, 212, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn.active {
|
||||||
|
background: #00d4ff;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 1px;
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick Locations */
|
||||||
|
.quick-locations {
|
||||||
|
bottom: 100px;
|
||||||
|
right: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-btn {
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: rgba(10, 15, 30, 0.9);
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||||
|
border-radius: 20px;
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-btn:hover {
|
||||||
|
background: rgba(0, 212, 255, 0.2);
|
||||||
|
border-color: #00d4ff;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-btn span {
|
||||||
|
opacity: 0.5;
|
||||||
|
font-size: 9px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Instructions */
|
||||||
|
.instructions {
|
||||||
|
bottom: 30px;
|
||||||
|
left: 20px;
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.35;
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions kbd {
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Crosshair */
|
||||||
|
.crosshair {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crosshair::before,
|
||||||
|
.crosshair::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
background: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crosshair::before {
|
||||||
|
width: 20px;
|
||||||
|
height: 2px;
|
||||||
|
left: -10px;
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crosshair::after {
|
||||||
|
width: 2px;
|
||||||
|
height: 20px;
|
||||||
|
left: -1px;
|
||||||
|
top: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-panel,
|
||||||
|
.zoom-panel {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-locations {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
158
src/js/config.js
Normal file
158
src/js/config.js
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
// Map tile styles configuration
|
||||||
|
export const STYLES = {
|
||||||
|
satellite: {
|
||||||
|
version: 8,
|
||||||
|
name: 'Satellite',
|
||||||
|
sources: {
|
||||||
|
'satellite': {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: [
|
||||||
|
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
|
||||||
|
],
|
||||||
|
tileSize: 256,
|
||||||
|
maxzoom: 19,
|
||||||
|
attribution: '© Esri'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
layers: [{
|
||||||
|
id: 'satellite-layer',
|
||||||
|
type: 'raster',
|
||||||
|
source: 'satellite',
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 22
|
||||||
|
}],
|
||||||
|
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf'
|
||||||
|
},
|
||||||
|
streets: {
|
||||||
|
version: 8,
|
||||||
|
name: 'Streets',
|
||||||
|
sources: {
|
||||||
|
'osm': {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: [
|
||||||
|
'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
'https://b.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||||
|
],
|
||||||
|
tileSize: 256,
|
||||||
|
maxzoom: 19,
|
||||||
|
attribution: '© OpenStreetMap'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
layers: [{
|
||||||
|
id: 'osm-layer',
|
||||||
|
type: 'raster',
|
||||||
|
source: 'osm'
|
||||||
|
}],
|
||||||
|
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf'
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
version: 8,
|
||||||
|
name: 'Dark',
|
||||||
|
sources: {
|
||||||
|
'carto-dark': {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: [
|
||||||
|
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
||||||
|
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
||||||
|
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png'
|
||||||
|
],
|
||||||
|
tileSize: 256,
|
||||||
|
maxzoom: 20,
|
||||||
|
attribution: '© CARTO'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
layers: [{
|
||||||
|
id: 'carto-layer',
|
||||||
|
type: 'raster',
|
||||||
|
source: 'carto-dark'
|
||||||
|
}],
|
||||||
|
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf'
|
||||||
|
},
|
||||||
|
terrain: {
|
||||||
|
version: 8,
|
||||||
|
name: 'Terrain',
|
||||||
|
sources: {
|
||||||
|
'stamen': {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: [
|
||||||
|
'https://tiles.stadiamaps.com/tiles/stamen_terrain/{z}/{x}/{y}.jpg'
|
||||||
|
],
|
||||||
|
tileSize: 256,
|
||||||
|
maxzoom: 18,
|
||||||
|
attribution: '© Stadia Maps'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
layers: [{
|
||||||
|
id: 'stamen-layer',
|
||||||
|
type: 'raster',
|
||||||
|
source: 'stamen'
|
||||||
|
}],
|
||||||
|
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf'
|
||||||
|
},
|
||||||
|
hybrid: {
|
||||||
|
version: 8,
|
||||||
|
name: 'Hybrid',
|
||||||
|
sources: {
|
||||||
|
'hybrid': {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: [
|
||||||
|
'https://mt0.google.com/vt/lyrs=y&x={x}&y={y}&z={z}',
|
||||||
|
'https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}'
|
||||||
|
],
|
||||||
|
tileSize: 256,
|
||||||
|
maxzoom: 21,
|
||||||
|
attribution: '© Google'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
layers: [{
|
||||||
|
id: 'hybrid-layer',
|
||||||
|
type: 'raster',
|
||||||
|
source: 'hybrid'
|
||||||
|
}],
|
||||||
|
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map configuration
|
||||||
|
export const MAP_CONFIG = {
|
||||||
|
center: [0, 20],
|
||||||
|
zoom: 1.5,
|
||||||
|
minZoom: 0,
|
||||||
|
maxZoom: 22,
|
||||||
|
projection: 'globe',
|
||||||
|
antialias: true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fog configurations for different styles
|
||||||
|
export const FOG_CONFIG = {
|
||||||
|
default: {
|
||||||
|
color: 'rgb(186, 210, 235)',
|
||||||
|
'high-color': 'rgb(36, 92, 223)',
|
||||||
|
'horizon-blend': 0.02,
|
||||||
|
'space-color': 'rgb(5, 5, 15)',
|
||||||
|
'star-intensity': 0.6
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
color: 'rgb(20, 20, 30)',
|
||||||
|
'high-color': 'rgb(10, 20, 50)',
|
||||||
|
'horizon-blend': 0.02,
|
||||||
|
'space-color': 'rgb(5, 5, 15)',
|
||||||
|
'star-intensity': 0.6
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3D Buildings layer configuration
|
||||||
|
export const BUILDINGS_3D_LAYER = {
|
||||||
|
id: '3d-buildings',
|
||||||
|
source: 'openmaptiles',
|
||||||
|
'source-layer': 'building',
|
||||||
|
type: 'fill-extrusion',
|
||||||
|
minzoom: 14,
|
||||||
|
paint: {
|
||||||
|
'fill-extrusion-color': '#aaa',
|
||||||
|
'fill-extrusion-height': ['get', 'render_height'],
|
||||||
|
'fill-extrusion-base': ['get', 'render_min_height'],
|
||||||
|
'fill-extrusion-opacity': 0.6
|
||||||
|
}
|
||||||
|
};
|
||||||
261
src/js/main.js
Normal file
261
src/js/main.js
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import { MapManager } from './mapManager.js';
|
||||||
|
import { UIController } from './uiController.js';
|
||||||
|
import { SearchService } from './searchService.js';
|
||||||
|
import { StorageManager } from './storageManager.js';
|
||||||
|
|
||||||
|
class RoadWorldApp {
|
||||||
|
constructor() {
|
||||||
|
this.mapManager = null;
|
||||||
|
this.uiController = null;
|
||||||
|
this.searchService = null;
|
||||||
|
this.storageManager = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// Initialize managers
|
||||||
|
this.storageManager = new StorageManager();
|
||||||
|
this.mapManager = new MapManager('map');
|
||||||
|
await this.mapManager.init();
|
||||||
|
|
||||||
|
this.uiController = new UIController(this.mapManager);
|
||||||
|
this.searchService = new SearchService(this.mapManager);
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
this.setupMapEvents();
|
||||||
|
this.setupControls();
|
||||||
|
this.setupSearch();
|
||||||
|
this.setupLayers();
|
||||||
|
this.setupQuickLocations();
|
||||||
|
|
||||||
|
// Initial UI update
|
||||||
|
this.uiController.updateStats();
|
||||||
|
this.updateSavedCount();
|
||||||
|
|
||||||
|
// Load last position if available
|
||||||
|
const lastPosition = this.storageManager.getLastPosition();
|
||||||
|
if (lastPosition && lastPosition.center) {
|
||||||
|
this.mapManager.map.jumpTo(lastPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('RoadWorld initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
setupMapEvents() {
|
||||||
|
this.mapManager.on('move', () => this.uiController.updateStats());
|
||||||
|
this.mapManager.on('zoom', () => this.uiController.updateStats());
|
||||||
|
this.mapManager.on('moveend', () => {
|
||||||
|
this.uiController.updateStats();
|
||||||
|
this.saveCurrentPosition();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupControls() {
|
||||||
|
// Home button
|
||||||
|
document.getElementById('btn-home').addEventListener('click', () => {
|
||||||
|
this.mapManager.flyTo({
|
||||||
|
center: [0, 20],
|
||||||
|
zoom: 1.5,
|
||||||
|
pitch: 0,
|
||||||
|
bearing: 0,
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// North button
|
||||||
|
document.getElementById('btn-north').addEventListener('click', () => {
|
||||||
|
this.mapManager.easeTo({ bearing: 0, pitch: 0, duration: 500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zoom buttons
|
||||||
|
document.getElementById('btn-zoom-in').addEventListener('click', () => {
|
||||||
|
this.mapManager.zoomIn();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-zoom-out').addEventListener('click', () => {
|
||||||
|
this.mapManager.zoomOut();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3D buildings toggle
|
||||||
|
document.getElementById('btn-3d').addEventListener('click', (e) => {
|
||||||
|
e.target.classList.toggle('active');
|
||||||
|
if (e.target.classList.contains('active')) {
|
||||||
|
this.mapManager.easeTo({ pitch: 60, duration: 500 });
|
||||||
|
} else {
|
||||||
|
this.mapManager.easeTo({ pitch: 0, duration: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Globe view
|
||||||
|
document.getElementById('btn-globe').addEventListener('click', () => {
|
||||||
|
this.mapManager.flyTo({
|
||||||
|
center: [0, 20],
|
||||||
|
zoom: 1.5,
|
||||||
|
pitch: 0,
|
||||||
|
bearing: 0,
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Locate user
|
||||||
|
document.getElementById('btn-locate').addEventListener('click', () => {
|
||||||
|
this.locateUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save location
|
||||||
|
document.getElementById('btn-save').addEventListener('click', () => {
|
||||||
|
this.saveCurrentLocation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSearch() {
|
||||||
|
const searchInput = document.getElementById('search');
|
||||||
|
const searchBtn = document.getElementById('search-btn');
|
||||||
|
|
||||||
|
const doSearch = async () => {
|
||||||
|
const query = searchInput.value.trim();
|
||||||
|
if (!query) return;
|
||||||
|
|
||||||
|
const result = await this.searchService.search(query);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.searchService.flyToResult(result);
|
||||||
|
this.uiController.updateElement('location-name', result.name);
|
||||||
|
|
||||||
|
// Add to history
|
||||||
|
this.storageManager.addToHistory({
|
||||||
|
type: 'search',
|
||||||
|
query: query,
|
||||||
|
result: result.name
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.uiController.updateElement('location-name', 'Not found');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
searchBtn.addEventListener('click', doSearch);
|
||||||
|
searchInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') doSearch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupLayers() {
|
||||||
|
document.querySelectorAll('.layer-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const styleName = btn.dataset.style;
|
||||||
|
this.mapManager.changeStyle(styleName);
|
||||||
|
|
||||||
|
document.querySelectorAll('.layer-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
|
||||||
|
this.storageManager.updateSettings({ defaultStyle: styleName });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupQuickLocations() {
|
||||||
|
document.querySelectorAll('.quick-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const lat = parseFloat(btn.dataset.lat);
|
||||||
|
const lng = parseFloat(btn.dataset.lng);
|
||||||
|
const zoom = parseFloat(btn.dataset.zoom);
|
||||||
|
|
||||||
|
this.mapManager.flyTo({
|
||||||
|
center: [lng, lat],
|
||||||
|
zoom: zoom,
|
||||||
|
pitch: 60,
|
||||||
|
bearing: Math.random() * 60 - 30,
|
||||||
|
duration: 3000
|
||||||
|
});
|
||||||
|
|
||||||
|
const locationName = btn.textContent.split(' ')[0] + ' ' +
|
||||||
|
(btn.textContent.split(' ')[1] || '');
|
||||||
|
this.uiController.updateElement('location-name', locationName);
|
||||||
|
|
||||||
|
// Add to history
|
||||||
|
this.storageManager.addToHistory({
|
||||||
|
type: 'quick_location',
|
||||||
|
name: locationName,
|
||||||
|
lat,
|
||||||
|
lng
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
locateUser() {
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
navigator.geolocation.getCurrentPosition((pos) => {
|
||||||
|
this.mapManager.flyTo({
|
||||||
|
center: [pos.coords.longitude, pos.coords.latitude],
|
||||||
|
zoom: 17,
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
this.uiController.updateElement('location-name', 'Your Location');
|
||||||
|
|
||||||
|
// Add marker
|
||||||
|
this.mapManager.addMarker([pos.coords.longitude, pos.coords.latitude], {
|
||||||
|
color: '#FF6B00'
|
||||||
|
});
|
||||||
|
}, (error) => {
|
||||||
|
console.error('Geolocation error:', error);
|
||||||
|
alert('Unable to get your location. Please check permissions.');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert('Geolocation is not supported by your browser.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCurrentLocation() {
|
||||||
|
const center = this.mapManager.getCenter();
|
||||||
|
const zoom = this.mapManager.getZoom();
|
||||||
|
const locationName = document.getElementById('location-name').textContent;
|
||||||
|
|
||||||
|
const saved = this.storageManager.saveLocation({
|
||||||
|
name: locationName,
|
||||||
|
lat: center.lat,
|
||||||
|
lng: center.lng,
|
||||||
|
zoom: zoom
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateSavedCount();
|
||||||
|
|
||||||
|
// Visual feedback
|
||||||
|
const btn = document.getElementById('btn-save');
|
||||||
|
btn.style.background = 'rgba(0, 212, 255, 0.5)';
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.style.background = '';
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
console.log('Location saved:', saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCurrentPosition() {
|
||||||
|
const center = this.mapManager.getCenter();
|
||||||
|
const zoom = this.mapManager.getZoom();
|
||||||
|
const pitch = this.mapManager.getPitch();
|
||||||
|
const bearing = this.mapManager.getBearing();
|
||||||
|
|
||||||
|
this.storageManager.savePosition({
|
||||||
|
center: [center.lng, center.lat],
|
||||||
|
zoom,
|
||||||
|
pitch,
|
||||||
|
bearing
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSavedCount() {
|
||||||
|
const count = this.storageManager.getSavedLocations().length;
|
||||||
|
this.uiController.updateElement('saved-count', count.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize app when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const app = new RoadWorldApp();
|
||||||
|
app.init();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const app = new RoadWorldApp();
|
||||||
|
app.init();
|
||||||
|
}
|
||||||
111
src/js/mapManager.js
Normal file
111
src/js/mapManager.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { STYLES, MAP_CONFIG, FOG_CONFIG } from './config.js';
|
||||||
|
|
||||||
|
export class MapManager {
|
||||||
|
constructor(containerId) {
|
||||||
|
this.map = null;
|
||||||
|
this.containerId = containerId;
|
||||||
|
this.currentStyle = 'satellite';
|
||||||
|
this.show3DBuildings = false;
|
||||||
|
this.markers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.map = new maplibregl.Map({
|
||||||
|
container: this.containerId,
|
||||||
|
style: STYLES.satellite,
|
||||||
|
...MAP_CONFIG
|
||||||
|
});
|
||||||
|
|
||||||
|
this.map.on('style.load', () => {
|
||||||
|
this.setFog('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.map.on('load', () => {
|
||||||
|
resolve(this.map);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setFog(type = 'default') {
|
||||||
|
const fogConfig = FOG_CONFIG[type] || FOG_CONFIG.default;
|
||||||
|
this.map.setFog(fogConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
changeStyle(styleName) {
|
||||||
|
if (!STYLES[styleName]) return;
|
||||||
|
|
||||||
|
// Save current position
|
||||||
|
const center = this.map.getCenter();
|
||||||
|
const zoom = this.map.getZoom();
|
||||||
|
const pitch = this.map.getPitch();
|
||||||
|
const bearing = this.map.getBearing();
|
||||||
|
|
||||||
|
this.map.setStyle(STYLES[styleName]);
|
||||||
|
|
||||||
|
// Restore position after style loads
|
||||||
|
this.map.once('style.load', () => {
|
||||||
|
this.map.jumpTo({ center, zoom, pitch, bearing });
|
||||||
|
this.setFog(styleName === 'dark' ? 'dark' : 'default');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.currentStyle = styleName;
|
||||||
|
}
|
||||||
|
|
||||||
|
flyTo(options) {
|
||||||
|
this.map.flyTo(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
easeTo(options) {
|
||||||
|
this.map.easeTo(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomIn(duration = 300) {
|
||||||
|
this.map.zoomIn({ duration });
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomOut(duration = 300) {
|
||||||
|
this.map.zoomOut({ duration });
|
||||||
|
}
|
||||||
|
|
||||||
|
getZoom() {
|
||||||
|
return this.map.getZoom();
|
||||||
|
}
|
||||||
|
|
||||||
|
getCenter() {
|
||||||
|
return this.map.getCenter();
|
||||||
|
}
|
||||||
|
|
||||||
|
getPitch() {
|
||||||
|
return this.map.getPitch();
|
||||||
|
}
|
||||||
|
|
||||||
|
getBearing() {
|
||||||
|
return this.map.getBearing();
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event, handler) {
|
||||||
|
this.map.on(event, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
addMarker(lngLat, options = {}) {
|
||||||
|
const marker = new maplibregl.Marker(options)
|
||||||
|
.setLngLat(lngLat)
|
||||||
|
.addTo(this.map);
|
||||||
|
|
||||||
|
this.markers.push(marker);
|
||||||
|
return marker;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMarkers() {
|
||||||
|
this.markers.forEach(marker => marker.remove());
|
||||||
|
this.markers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
addPopup(lngLat, html) {
|
||||||
|
return new maplibregl.Popup()
|
||||||
|
.setLngLat(lngLat)
|
||||||
|
.setHTML(html)
|
||||||
|
.addTo(this.map);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/js/searchService.js
Normal file
65
src/js/searchService.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
export class SearchService {
|
||||||
|
constructor(mapManager) {
|
||||||
|
this.mapManager = mapManager;
|
||||||
|
this.geocodeEndpoint = 'https://nominatim.openstreetmap.org/search';
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(query) {
|
||||||
|
if (!query || !query.trim()) {
|
||||||
|
return { success: false, error: 'Empty query' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.geocodeEndpoint}?format=json&q=${encodeURIComponent(query)}&limit=1`
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
const result = data[0];
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
lat: parseFloat(result.lat),
|
||||||
|
lon: parseFloat(result.lon),
|
||||||
|
name: result.display_name.split(',')[0],
|
||||||
|
displayName: result.display_name,
|
||||||
|
type: result.type,
|
||||||
|
zoom: this.getZoomForType(result.type)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: 'Location not found' };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Search error:', err);
|
||||||
|
return { success: false, error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getZoomForType(type) {
|
||||||
|
const zoomMap = {
|
||||||
|
'country': 5,
|
||||||
|
'state': 7,
|
||||||
|
'city': 12,
|
||||||
|
'town': 12,
|
||||||
|
'suburb': 15,
|
||||||
|
'neighbourhood': 15,
|
||||||
|
'building': 18,
|
||||||
|
'default': 17
|
||||||
|
};
|
||||||
|
|
||||||
|
return zoomMap[type] || zoomMap.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
flyToResult(result) {
|
||||||
|
if (!result.success) return;
|
||||||
|
|
||||||
|
this.mapManager.flyTo({
|
||||||
|
center: [result.lon, result.lat],
|
||||||
|
zoom: result.zoom,
|
||||||
|
pitch: result.zoom > 15 ? 45 : 0,
|
||||||
|
duration: 2500
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/js/storageManager.js
Normal file
103
src/js/storageManager.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
export class StorageManager {
|
||||||
|
constructor() {
|
||||||
|
this.storageKey = 'blackroad_roadworld';
|
||||||
|
this.data = this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.storageKey);
|
||||||
|
return stored ? JSON.parse(stored) : this.getDefaultData();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading storage:', err);
|
||||||
|
return this.getDefaultData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.storageKey, JSON.stringify(this.data));
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving storage:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultData() {
|
||||||
|
return {
|
||||||
|
savedLocations: [],
|
||||||
|
settings: {
|
||||||
|
defaultStyle: 'satellite',
|
||||||
|
show3DBuildings: false,
|
||||||
|
lastPosition: {
|
||||||
|
center: [0, 20],
|
||||||
|
zoom: 1.5,
|
||||||
|
pitch: 0,
|
||||||
|
bearing: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
history: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
saveLocation(location) {
|
||||||
|
const newLocation = {
|
||||||
|
id: Date.now(),
|
||||||
|
name: location.name,
|
||||||
|
lat: location.lat,
|
||||||
|
lng: location.lng,
|
||||||
|
zoom: location.zoom || 17,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.data.savedLocations.push(newLocation);
|
||||||
|
this.save();
|
||||||
|
return newLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSavedLocations() {
|
||||||
|
return this.data.savedLocations || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteLocation(id) {
|
||||||
|
this.data.savedLocations = this.data.savedLocations.filter(loc => loc.id !== id);
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
savePosition(position) {
|
||||||
|
this.data.settings.lastPosition = position;
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
getLastPosition() {
|
||||||
|
return this.data.settings.lastPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
addToHistory(entry) {
|
||||||
|
this.data.history.unshift({
|
||||||
|
...entry,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep only last 50 entries
|
||||||
|
if (this.data.history.length > 50) {
|
||||||
|
this.data.history = this.data.history.slice(0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
getHistory() {
|
||||||
|
return this.data.history || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSettings(settings) {
|
||||||
|
this.data.settings = { ...this.data.settings, ...settings };
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
getSettings() {
|
||||||
|
return this.data.settings;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/js/uiController.js
Normal file
90
src/js/uiController.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
export class UIController {
|
||||||
|
constructor(mapManager) {
|
||||||
|
this.mapManager = mapManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStats() {
|
||||||
|
const zoom = this.mapManager.getZoom();
|
||||||
|
const center = this.mapManager.getCenter();
|
||||||
|
|
||||||
|
// Update zoom display
|
||||||
|
this.updateElement('zoom-level', zoom.toFixed(1));
|
||||||
|
|
||||||
|
// Update zoom bar
|
||||||
|
const zoomPercent = Math.min(100, (zoom / 22) * 100);
|
||||||
|
this.updateElement('zoom-fill', null, { width: zoomPercent + '%' });
|
||||||
|
|
||||||
|
// Update altitude estimate
|
||||||
|
const altitude = this.getAltitudeFromZoom(zoom);
|
||||||
|
this.updateElement('altitude', this.formatAltitude(altitude));
|
||||||
|
|
||||||
|
// Update resolution
|
||||||
|
const resolution = this.getResolutionFromZoom(zoom);
|
||||||
|
this.updateElement('resolution', resolution);
|
||||||
|
|
||||||
|
// Update coordinates
|
||||||
|
this.updateElement('coordinates',
|
||||||
|
`${center.lat.toFixed(2)}°, ${center.lng.toFixed(2)}°`);
|
||||||
|
this.updateElement('lat',
|
||||||
|
`${Math.abs(center.lat).toFixed(6)}° ${center.lat >= 0 ? 'N' : 'S'}`);
|
||||||
|
this.updateElement('lng',
|
||||||
|
`${Math.abs(center.lng).toFixed(6)}° ${center.lng >= 0 ? 'E' : 'W'}`);
|
||||||
|
|
||||||
|
// Update tile coords
|
||||||
|
const tileX = Math.floor((center.lng + 180) / 360 * Math.pow(2, Math.floor(zoom)));
|
||||||
|
const tileY = Math.floor((1 - Math.log(Math.tan(center.lat * Math.PI / 180) +
|
||||||
|
1 / Math.cos(center.lat * Math.PI / 180)) / Math.PI) / 2 * Math.pow(2, Math.floor(zoom)));
|
||||||
|
this.updateElement('tile-coords',
|
||||||
|
`z${Math.floor(zoom)} / x${tileX} / y${tileY}`);
|
||||||
|
|
||||||
|
// Update scale indicators
|
||||||
|
this.updateScaleIndicators(zoom);
|
||||||
|
|
||||||
|
// Update view mode
|
||||||
|
this.updateElement('view-mode',
|
||||||
|
zoom < 5 ? 'GLOBE VIEW' : zoom < 14 ? 'MAP VIEW' : 'STREET VIEW');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateElement(id, text = null, styles = null) {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
if (text !== null) {
|
||||||
|
element.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (styles) {
|
||||||
|
Object.assign(element.style, styles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateScaleIndicators(zoom) {
|
||||||
|
document.querySelectorAll('.scale-item').forEach(item => {
|
||||||
|
const min = parseFloat(item.dataset.min);
|
||||||
|
const max = parseFloat(item.dataset.max);
|
||||||
|
item.classList.toggle('active', zoom >= min && zoom < max);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getAltitudeFromZoom(zoom) {
|
||||||
|
// Approximate altitude in meters based on zoom level
|
||||||
|
return 40075000 / Math.pow(2, zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatAltitude(meters) {
|
||||||
|
if (meters > 1000000) return (meters / 1000000).toFixed(1) + ' Mm';
|
||||||
|
if (meters > 1000) return (meters / 1000).toFixed(1) + ' km';
|
||||||
|
if (meters > 1) return meters.toFixed(0) + ' m';
|
||||||
|
return (meters * 100).toFixed(0) + ' cm';
|
||||||
|
}
|
||||||
|
|
||||||
|
getResolutionFromZoom(zoom) {
|
||||||
|
const center = this.mapManager.getCenter();
|
||||||
|
const metersPerPixel = 40075000 * Math.cos(center.lat * Math.PI / 180) /
|
||||||
|
(Math.pow(2, zoom) * 256);
|
||||||
|
|
||||||
|
if (metersPerPixel > 1000) return `~${(metersPerPixel/1000).toFixed(0)} km/px`;
|
||||||
|
if (metersPerPixel > 1) return `~${metersPerPixel.toFixed(1)} m/px`;
|
||||||
|
return `~${(metersPerPixel * 100).toFixed(0)} cm/px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
wrangler.toml
Normal file
7
wrangler.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
name = "roadworld"
|
||||||
|
compatibility_date = "2024-01-01"
|
||||||
|
|
||||||
|
[site]
|
||||||
|
bucket = "./public"
|
||||||
|
|
||||||
|
pages_build_output_dir = "./public"
|
||||||
Reference in New Issue
Block a user