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:
Alexa Louise
2025-12-22 15:09:19 -06:00
commit ba71d4e76e
13 changed files with 1628 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
.DS_Store
*.log
.env
.env.local
dist/
.cache/
.wrangler/

142
README.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
name = "roadworld"
compatibility_date = "2024-01-01"
[site]
bucket = "./public"
pages_build_output_dir = "./public"