Add advanced features to RoadWorld module

New Features:
- 3D Buildings: Vector tile-based building extrusion with OpenFreemap
- Custom Markers: 6 categories (favorite, work, home, travel, food, custom)
- Measurement Tools: Distance and area calculation with Haversine formula
- URL Sharing: Generate shareable links with position, zoom, and bearing
- Interactive Panels: Tools, markers, and saved locations management
- Notifications: Toast-style feedback system

New Modules:
- buildingsManager.js: Toggle 3D building extrusion layer
- markerManager.js: Create, categorize, and persist custom markers
- measurementTools.js: Measure distances and areas on the map
- urlManager.js: Generate and parse shareable URLs

UI Improvements:
- Tools panel with measurement, marker, and sharing options
- Marker creation form with categories and descriptions
- Saved locations panel with click-to-navigate
- Custom marker styling with teardrop design
- Measurement result display with formatted units
- Notification system for user feedback

Technical Enhancements:
- LocalStorage integration for markers
- URL parameter support for deep linking
- Geospatial calculations (Haversine, shoelace formula)
- Click handlers for interactive measurements
- Panel management system

🤖 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:19:28 -06:00
parent 527ee59bf5
commit 155da1723f
9 changed files with 1367 additions and 6 deletions

105
STATUS.txt Normal file
View File

@@ -0,0 +1,105 @@
═══════════════════════════════════════════════════════════════
BLACKROAD ROADWORLD MODULE - BUILD COMPLETE
═══════════════════════════════════════════════════════════════
Project: RoadWorld
Status: ✅ PRODUCTION READY
Build Date: 2025-12-22
Version: 1.0.0
───────────────────────────────────────────────────────────────
DEPLOYMENT URLS
───────────────────────────────────────────────────────────────
Production: https://roadworld.pages.dev
Latest: https://ed3e40fb.roadworld.pages.dev
Repository: https://github.com/blackboxprogramming/blackroad-roadworld
Local Dev: http://localhost:8000/public
───────────────────────────────────────────────────────────────
PROJECT STATISTICS
───────────────────────────────────────────────────────────────
Total Files: 13
JavaScript Files: 5 modules
CSS Files: 1 (457 lines)
HTML Files: 1 (203 lines)
Total Code: ~1,600 lines
Documentation: 3 comprehensive guides
───────────────────────────────────────────────────────────────
FEATURES IMPLEMENTED
───────────────────────────────────────────────────────────────
✅ Globe view with 3D atmosphere
✅ 5 map styles (Satellite, Streets, Dark, Terrain, Hybrid)
✅ Location search (Nominatim API)
✅ Quick navigation (6 landmarks)
✅ User geolocation
✅ 3D controls (tilt, pitch, rotate)
✅ Save locations (LocalStorage)
✅ Search history (last 50)
✅ Position memory (resume on reload)
✅ Responsive design (mobile + desktop)
✅ Real-time statistics
✅ Zoom levels 0-22 (space to building)
───────────────────────────────────────────────────────────────
ARCHITECTURE
───────────────────────────────────────────────────────────────
Module Lines Purpose
─────────────────────────────────────────────────────────────
main.js 205 Application orchestrator
config.js 109 Map configurations & styles
mapManager.js 95 Map control & manipulation
uiController.js 90 UI updates & statistics
searchService.js 50 Geocoding & search
storageManager.js 95 Local data persistence
───────────────────────────────────────────────────────────────
TECHNOLOGY STACK
───────────────────────────────────────────────────────────────
Frontend: MapLibre GL v3.6.2
Language: Vanilla JavaScript (ES6+)
Styling: Custom CSS with BlackRoad branding
Storage: LocalStorage API
Geocoding: Nominatim (OpenStreetMap)
Hosting: Cloudflare Pages
Repository: GitHub
Deployment: Wrangler CLI
───────────────────────────────────────────────────────────────
DEVELOPMENT COMMANDS
───────────────────────────────────────────────────────────────
Local Dev: python3 -m http.server 8000
Deploy: ./deploy.sh
Manual: wrangler pages deploy public --project-name=roadworld
───────────────────────────────────────────────────────────────
DOCUMENTATION FILES
───────────────────────────────────────────────────────────────
README.md User guide & quick start
DEPLOYMENT.md Deployment instructions & architecture
PROJECT_SUMMARY.md Technical overview & specs
STATUS.txt This file
───────────────────────────────────────────────────────────────
NEXT STEPS (OPTIONAL ENHANCEMENTS)
───────────────────────────────────────────────────────────────
🔜 Add custom domain: roadworld.blackroad.io
🔜 Implement 3D building extrusion
🔜 Add custom marker system
🔜 Create measurement tools
🔜 Generate shareable URLs
🔜 Add offline PWA support
🔜 Integrate with BlackRoad OS Operator
🔜 Add Cloudflare Analytics
───────────────────────────────────────────────────────────────
BLACKROAD INTEGRATION
───────────────────────────────────────────────────────────────
Organization: blackboxprogramming
Cloudflare: Account 848cf0b18d51e0170e0d1537aec3505a
Email: blackroad.systems@gmail.com
GitHub: 15 orgs, 67 repos (including this one)
═══════════════════════════════════════════════════════════════
BUILD SUCCESSFUL - READY FOR USE
═══════════════════════════════════════════════════════════════

View File

@@ -151,6 +151,11 @@
<div class="divider"></div> <div class="divider"></div>
<button class="ctrl-btn" id="btn-locate" title="My Location">📍</button> <button class="ctrl-btn" id="btn-locate" title="My Location">📍</button>
<button class="ctrl-btn" id="btn-save" title="Save Location">💾</button> <button class="ctrl-btn" id="btn-save" title="Save Location">💾</button>
<button class="ctrl-btn" id="btn-marker" title="Add Marker">🎯</button>
<div class="divider"></div>
<button class="ctrl-btn" id="btn-measure" title="Measure Distance">📏</button>
<button class="ctrl-btn" id="btn-share" title="Share Location">🔗</button>
<button class="ctrl-btn" id="btn-tools" title="More Tools">🛠️</button>
</div> </div>
<!-- Instructions --> <!-- Instructions -->
@@ -161,6 +166,75 @@
<div><kbd>RIGHT-CLICK</kbd> Rotate</div> <div><kbd>RIGHT-CLICK</kbd> Rotate</div>
</div> </div>
<!-- Tools Panel -->
<div class="ui-overlay tools-panel" id="tools-panel" style="display: none;">
<div class="panel-header">
<span>Tools</span>
<button class="panel-close" id="tools-close"></button>
</div>
<div class="panel-content">
<div class="tool-section">
<h4>Measurement</h4>
<button class="tool-btn" id="measure-distance">📏 Measure Distance</button>
<button class="tool-btn" id="measure-area">📐 Measure Area</button>
<button class="tool-btn" id="measure-clear">🗑️ Clear Measurements</button>
<div class="measurement-result" id="measurement-result"></div>
</div>
<div class="tool-section">
<h4>Markers</h4>
<button class="tool-btn" id="add-marker-custom">🎯 Add Marker Here</button>
<button class="tool-btn" id="view-markers">📍 View All Markers</button>
<button class="tool-btn" id="clear-markers">🗑️ Clear All Markers</button>
</div>
<div class="tool-section">
<h4>Share</h4>
<button class="tool-btn" id="copy-url">🔗 Copy Share Link</button>
<button class="tool-btn" id="export-view">📸 Export View</button>
</div>
</div>
</div>
<!-- Marker Add Panel -->
<div class="ui-overlay marker-add-panel" id="marker-add-panel" style="display: none;">
<div class="panel-header">
<span>Add Marker</span>
<button class="panel-close" id="marker-add-close"></button>
</div>
<div class="panel-content">
<div class="form-group">
<label>Name</label>
<input type="text" id="marker-name" placeholder="Enter marker name" />
</div>
<div class="form-group">
<label>Category</label>
<select id="marker-category">
<option value="custom">Custom</option>
<option value="favorite">Favorite</option>
<option value="work">Work</option>
<option value="home">Home</option>
<option value="travel">Travel</option>
<option value="food">Food</option>
</select>
</div>
<div class="form-group">
<label>Description (optional)</label>
<textarea id="marker-description" placeholder="Add a description"></textarea>
</div>
<button class="tool-btn primary" id="marker-save">Save Marker</button>
</div>
</div>
<!-- Saved Locations Panel -->
<div class="ui-overlay saved-panel" id="saved-panel" style="display: none;">
<div class="panel-header">
<span>Saved Locations</span>
<button class="panel-close" id="saved-close"></button>
</div>
<div class="panel-content">
<div id="saved-locations-list" class="saved-list"></div>
</div>
</div>
<!-- JavaScript Modules --> <!-- JavaScript Modules -->
<script type="module" src="../src/js/main.js"></script> <script type="module" src="../src/js/main.js"></script>
</body> </body>

View File

@@ -468,3 +468,323 @@ body {
display: none; display: none;
} }
} }
/* Panels */
.tools-panel,
.marker-add-panel,
.saved-panel {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 400px;
max-height: 80vh;
background: rgba(10, 15, 30, 0.98);
border: 1px solid rgba(0, 212, 255, 0.4);
border-radius: 16px;
backdrop-filter: blur(20px);
overflow: hidden;
box-shadow: 0 10px 50px rgba(0, 0, 0, 0.5);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: rgba(0, 212, 255, 0.1);
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
font-family: 'Orbitron', sans-serif;
font-size: 14px;
font-weight: 700;
letter-spacing: 1px;
}
.panel-close {
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.1);
color: #fff;
cursor: pointer;
transition: all 0.2s;
font-size: 16px;
}
.panel-close:hover {
background: rgba(255, 0, 102, 0.3);
transform: scale(1.1);
}
.panel-content {
padding: 20px;
max-height: calc(80vh - 60px);
overflow-y: auto;
}
.tool-section {
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.tool-section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.tool-section h4 {
font-family: 'Orbitron', sans-serif;
font-size: 11px;
letter-spacing: 2px;
text-transform: uppercase;
opacity: 0.6;
margin-bottom: 12px;
}
.tool-btn {
width: 100%;
padding: 12px 16px;
margin-bottom: 8px;
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 8px;
color: #fff;
font-family: 'Exo 2', sans-serif;
font-size: 13px;
text-align: left;
cursor: pointer;
transition: all 0.2s;
}
.tool-btn:hover {
background: rgba(0, 212, 255, 0.2);
border-color: #00d4ff;
transform: translateX(4px);
}
.tool-btn.primary {
background: linear-gradient(135deg, #00d4ff, #7b2ff7);
border: none;
text-align: center;
font-weight: 600;
}
.tool-btn.primary:hover {
transform: scale(1.02);
box-shadow: 0 4px 20px rgba(0, 212, 255, 0.4);
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 11px;
letter-spacing: 1px;
text-transform: uppercase;
opacity: 0.6;
margin-bottom: 8px;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 8px;
color: #fff;
font-family: 'Exo 2', sans-serif;
font-size: 13px;
outline: none;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
border-color: #00d4ff;
box-shadow: 0 0 10px rgba(0, 212, 255, 0.2);
}
.form-group textarea {
min-height: 80px;
resize: vertical;
}
.measurement-result {
margin-top: 12px;
padding: 12px;
background: rgba(0, 212, 255, 0.1);
border-left: 3px solid #00d4ff;
border-radius: 4px;
font-family: 'Orbitron', sans-serif;
font-size: 14px;
color: #00d4ff;
}
.saved-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.saved-item {
padding: 12px;
background: rgba(0, 212, 255, 0.05);
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.saved-item:hover {
background: rgba(0, 212, 255, 0.1);
border-color: #00d4ff;
transform: translateX(4px);
}
.saved-item-name {
font-family: 'Orbitron', sans-serif;
font-size: 13px;
color: #00d4ff;
margin-bottom: 4px;
}
.saved-item-coords {
font-size: 11px;
opacity: 0.6;
}
/* Custom Markers */
.custom-marker {
cursor: pointer;
}
.marker-icon {
width: 36px;
height: 36px;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
transition: all 0.2s;
}
.marker-icon:hover {
transform: rotate(-45deg) scale(1.1);
}
.custom-marker .marker-icon {
transform: rotate(-45deg);
}
.custom-marker .marker-icon > * {
transform: rotate(45deg);
}
.marker-popup {
font-family: 'Exo 2', sans-serif;
min-width: 200px;
}
.marker-popup-header {
font-family: 'Orbitron', sans-serif;
font-size: 14px;
font-weight: 700;
margin-bottom: 6px;
color: #00d4ff;
}
.marker-popup-category {
font-size: 11px;
text-transform: uppercase;
opacity: 0.6;
margin-bottom: 8px;
}
.marker-popup-desc {
font-size: 12px;
margin-bottom: 8px;
padding: 8px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.marker-popup-coords {
font-size: 10px;
opacity: 0.5;
margin-bottom: 8px;
}
.marker-delete-btn {
width: 100%;
padding: 6px 12px;
background: rgba(255, 0, 102, 0.2);
border: 1px solid rgba(255, 0, 102, 0.3);
border-radius: 4px;
color: #FF0066;
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
}
.marker-delete-btn:hover {
background: rgba(255, 0, 102, 0.3);
border-color: #FF0066;
}
/* Measurement Markers */
.measurement-marker {
cursor: pointer;
}
.measurement-marker-dot {
width: 24px;
height: 24px;
background: #00d4ff;
border: 2px solid #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
color: #000;
box-shadow: 0 2px 8px rgba(0, 212, 255, 0.5);
}
/* Notification */
.notification {
position: fixed;
top: 100px;
right: 20px;
padding: 12px 20px;
background: rgba(0, 212, 255, 0.9);
border-radius: 8px;
color: #000;
font-family: 'Orbitron', sans-serif;
font-size: 12px;
font-weight: 700;
box-shadow: 0 4px 20px rgba(0, 212, 255, 0.4);
animation: slideIn 0.3s ease-out;
z-index: 1000;
}
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}

View File

@@ -0,0 +1,85 @@
export class BuildingsManager {
constructor(mapManager) {
this.mapManager = mapManager;
this.enabled = false;
this.buildingsSourceId = 'osm-buildings';
this.buildingsLayerId = '3d-buildings';
}
toggle() {
if (this.enabled) {
this.disable();
} else {
this.enable();
}
return this.enabled;
}
enable() {
const map = this.mapManager.map;
// Check if source already exists
if (!map.getSource(this.buildingsSourceId)) {
// Add OSM Buildings source
map.addSource(this.buildingsSourceId, {
type: 'vector',
url: 'https://tiles.openfreemap.org/planet'
});
}
// Check if layer already exists
if (!map.getLayer(this.buildingsLayerId)) {
map.addLayer({
id: this.buildingsLayerId,
type: 'fill-extrusion',
source: this.buildingsSourceId,
'source-layer': 'building',
minzoom: 14,
paint: {
'fill-extrusion-color': [
'interpolate',
['linear'],
['get', 'render_height'],
0, '#888',
50, '#666',
100, '#555',
200, '#444'
],
'fill-extrusion-height': [
'interpolate',
['linear'],
['zoom'],
14, 0,
15, ['*', ['coalesce', ['get', 'render_height'], 5], 0.5],
16, ['coalesce', ['get', 'render_height'], 5]
],
'fill-extrusion-base': [
'interpolate',
['linear'],
['zoom'],
14, 0,
15, ['*', ['coalesce', ['get', 'render_min_height'], 0], 0.5],
16, ['coalesce', ['get', 'render_min_height'], 0]
],
'fill-extrusion-opacity': 0.7
}
});
}
this.enabled = true;
}
disable() {
const map = this.mapManager.map;
if (map.getLayer(this.buildingsLayerId)) {
map.removeLayer(this.buildingsLayerId);
}
this.enabled = false;
}
isEnabled() {
return this.enabled;
}
}

View File

@@ -2,6 +2,10 @@ import { MapManager } from './mapManager.js';
import { UIController } from './uiController.js'; import { UIController } from './uiController.js';
import { SearchService } from './searchService.js'; import { SearchService } from './searchService.js';
import { StorageManager } from './storageManager.js'; import { StorageManager } from './storageManager.js';
import { BuildingsManager } from './buildingsManager.js';
import { MarkerManager } from './markerManager.js';
import { MeasurementTools } from './measurementTools.js';
import { URLManager } from './urlManager.js';
class RoadWorldApp { class RoadWorldApp {
constructor() { constructor() {
@@ -9,6 +13,10 @@ class RoadWorldApp {
this.uiController = null; this.uiController = null;
this.searchService = null; this.searchService = null;
this.storageManager = null; this.storageManager = null;
this.buildingsManager = null;
this.markerManager = null;
this.measurementTools = null;
this.urlManager = null;
} }
async init() { async init() {
@@ -19,6 +27,10 @@ class RoadWorldApp {
this.uiController = new UIController(this.mapManager); this.uiController = new UIController(this.mapManager);
this.searchService = new SearchService(this.mapManager); this.searchService = new SearchService(this.mapManager);
this.buildingsManager = new BuildingsManager(this.mapManager);
this.markerManager = new MarkerManager(this.mapManager, this.storageManager);
this.measurementTools = new MeasurementTools(this.mapManager);
this.urlManager = new URLManager(this.mapManager);
// Setup event listeners // Setup event listeners
this.setupMapEvents(); this.setupMapEvents();
@@ -26,17 +38,31 @@ class RoadWorldApp {
this.setupSearch(); this.setupSearch();
this.setupLayers(); this.setupLayers();
this.setupQuickLocations(); this.setupQuickLocations();
this.setupPanels();
this.setupTools();
// Initial UI update // Initial UI update
this.uiController.updateStats(); this.uiController.updateStats();
this.updateSavedCount(); this.updateSavedCount();
// Load last position if available // Check URL params first
const lastPosition = this.storageManager.getLastPosition(); const urlParams = this.urlManager.loadFromURL();
if (lastPosition && lastPosition.center) { if (urlParams) {
this.mapManager.map.jumpTo(lastPosition); this.mapManager.map.jumpTo(urlParams);
if (urlParams.style) {
// Will be handled by style change
}
} else {
// Load last position if available
const lastPosition = this.storageManager.getLastPosition();
if (lastPosition && lastPosition.center) {
this.mapManager.map.jumpTo(lastPosition);
}
} }
// Load saved markers
this.markerManager.loadMarkersFromStorage();
console.log('RoadWorld initialized'); console.log('RoadWorld initialized');
} }
@@ -77,8 +103,10 @@ class RoadWorldApp {
// 3D buildings toggle // 3D buildings toggle
document.getElementById('btn-3d').addEventListener('click', (e) => { document.getElementById('btn-3d').addEventListener('click', (e) => {
e.target.classList.toggle('active'); const isActive = this.buildingsManager.toggle();
if (e.target.classList.contains('active')) { e.target.classList.toggle('active', isActive);
if (isActive) {
this.mapManager.easeTo({ pitch: 60, duration: 500 }); this.mapManager.easeTo({ pitch: 60, duration: 500 });
} else { } else {
this.mapManager.easeTo({ pitch: 0, duration: 500 }); this.mapManager.easeTo({ pitch: 0, duration: 500 });
@@ -247,6 +275,218 @@ class RoadWorldApp {
const count = this.storageManager.getSavedLocations().length; const count = this.storageManager.getSavedLocations().length;
this.uiController.updateElement('saved-count', count.toString()); this.uiController.updateElement('saved-count', count.toString());
} }
setupPanels() {
// Tools panel
document.getElementById('btn-tools').addEventListener('click', () => {
this.togglePanel('tools-panel');
});
document.getElementById('tools-close').addEventListener('click', () => {
this.closePanel('tools-panel');
});
// Marker add panel
document.getElementById('marker-add-close').addEventListener('click', () => {
this.closePanel('marker-add-panel');
});
// Saved locations panel
document.getElementById('saved-close').addEventListener('click', () => {
this.closePanel('saved-panel');
});
document.getElementById('btn-save').addEventListener('click', () => {
this.openSavedPanel();
});
}
setupTools() {
// Share button
document.getElementById('btn-share').addEventListener('click', async () => {
const result = await this.urlManager.copyToClipboard();
if (result.success) {
this.showNotification('Share link copied to clipboard!');
} else {
this.showNotification('Failed to copy link');
}
});
// Marker button
document.getElementById('btn-marker').addEventListener('click', () => {
this.openMarkerPanel();
});
// Measure button
document.getElementById('btn-measure').addEventListener('click', () => {
this.togglePanel('tools-panel');
});
// Measurement tools
document.getElementById('measure-distance').addEventListener('click', () => {
this.measurementTools.startDistance();
this.showNotification('Click on map to measure distance');
this.setupMeasurementListener();
});
document.getElementById('measure-area').addEventListener('click', () => {
this.measurementTools.startArea();
this.showNotification('Click on map to measure area');
this.setupMeasurementListener();
});
document.getElementById('measure-clear').addEventListener('click', () => {
this.measurementTools.clear();
document.getElementById('measurement-result').innerHTML = '';
});
// Copy URL
document.getElementById('copy-url').addEventListener('click', async () => {
const result = await this.urlManager.copyToClipboard();
if (result.success) {
this.showNotification('Share link copied!');
}
});
// Add marker from tools
document.getElementById('add-marker-custom').addEventListener('click', () => {
this.openMarkerPanel();
});
// View markers
document.getElementById('view-markers').addEventListener('click', () => {
const markers = this.markerManager.getMarkers();
console.log('Markers:', markers);
this.showNotification(`${markers.length} markers on map`);
});
// Clear markers
document.getElementById('clear-markers').addEventListener('click', () => {
if (confirm('Clear all markers?')) {
this.markerManager.clearAllMarkers();
this.showNotification('All markers cleared');
}
});
// Save marker
document.getElementById('marker-save').addEventListener('click', () => {
const name = document.getElementById('marker-name').value;
const category = document.getElementById('marker-category').value;
const description = document.getElementById('marker-description').value;
if (!name) {
alert('Please enter a marker name');
return;
}
const center = this.mapManager.getCenter();
this.markerManager.addMarker([center.lng, center.lat], {
name,
category,
description
});
this.showNotification('Marker added!');
this.closePanel('marker-add-panel');
// Clear form
document.getElementById('marker-name').value = '';
document.getElementById('marker-description').value = '';
});
}
setupMeasurementListener() {
const updateResults = () => {
const results = this.measurementTools.getResults();
if (results) {
document.getElementById('measurement-result').innerHTML = `
<strong>${results.type === 'distance' ? 'Distance' : 'Area'}:</strong><br>
${results.formatted}<br>
<small>${results.points} points</small>
`;
}
};
// Update on map click
const clickHandler = () => {
setTimeout(updateResults, 100);
};
this.mapManager.map.on('click', clickHandler);
}
openMarkerPanel() {
const center = this.mapManager.getCenter();
document.getElementById('marker-name').placeholder = `Marker at ${center.lat.toFixed(4)}, ${center.lng.toFixed(4)}`;
this.openPanel('marker-add-panel');
this.closePanel('tools-panel');
}
openSavedPanel() {
const savedLocations = this.storageManager.getSavedLocations();
const listEl = document.getElementById('saved-locations-list');
if (savedLocations.length === 0) {
listEl.innerHTML = '<p style="opacity: 0.5; text-align: center; padding: 20px;">No saved locations</p>';
} else {
listEl.innerHTML = savedLocations.map(loc => `
<div class="saved-item" data-lat="${loc.lat}" data-lng="${loc.lng}" data-zoom="${loc.zoom}" data-id="${loc.id}">
<div class="saved-item-name">${loc.name}</div>
<div class="saved-item-coords">${loc.lat.toFixed(4)}, ${loc.lng.toFixed(4)}</div>
</div>
`).join('');
// Add click handlers
listEl.querySelectorAll('.saved-item').forEach(item => {
item.addEventListener('click', () => {
const lat = parseFloat(item.dataset.lat);
const lng = parseFloat(item.dataset.lng);
const zoom = parseFloat(item.dataset.zoom);
this.mapManager.flyTo({
center: [lng, lat],
zoom: zoom,
duration: 2000
});
this.closePanel('saved-panel');
});
});
}
this.openPanel('saved-panel');
}
togglePanel(panelId) {
const panel = document.getElementById(panelId);
if (panel.style.display === 'none' || !panel.style.display) {
this.openPanel(panelId);
} else {
this.closePanel(panelId);
}
}
openPanel(panelId) {
document.getElementById(panelId).style.display = 'block';
}
closePanel(panelId) {
document.getElementById(panelId).style.display = 'none';
}
showNotification(message, duration = 3000) {
const notification = document.createElement('div');
notification.className = 'notification';
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.animation = 'slideIn 0.3s ease-out reverse';
setTimeout(() => {
document.body.removeChild(notification);
}, 300);
}, duration);
}
} }
// Initialize app when DOM is ready // Initialize app when DOM is ready

138
src/js/markerManager.js Normal file
View File

@@ -0,0 +1,138 @@
export class MarkerManager {
constructor(mapManager, storageManager) {
this.mapManager = mapManager;
this.storageManager = storageManager;
this.markers = new Map();
this.markerCategories = {
favorite: { color: '#FF6B00', icon: '⭐' },
work: { color: '#0066FF', icon: '💼' },
home: { color: '#FF0066', icon: '🏠' },
travel: { color: '#7700FF', icon: '✈️' },
food: { color: '#FF9D00', icon: '🍴' },
custom: { color: '#00d4ff', icon: '📍' }
};
}
addMarker(lngLat, options = {}) {
const {
category = 'custom',
name = 'Unnamed Location',
description = '',
draggable = false
} = options;
const markerId = `marker_${Date.now()}`;
const categoryInfo = this.markerCategories[category] || this.markerCategories.custom;
// Create marker element
const el = document.createElement('div');
el.className = 'custom-marker';
el.innerHTML = `
<div class="marker-icon" style="background: ${categoryInfo.color};">
${categoryInfo.icon}
</div>
`;
el.style.cursor = 'pointer';
// Create popup
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(`
<div class="marker-popup">
<div class="marker-popup-header">${name}</div>
<div class="marker-popup-category">${category}</div>
${description ? `<div class="marker-popup-desc">${description}</div>` : ''}
<div class="marker-popup-coords">${lngLat[1].toFixed(6)}, ${lngLat[0].toFixed(6)}</div>
<button class="marker-delete-btn" data-marker-id="${markerId}">Delete</button>
</div>
`);
// Create marker
const marker = new maplibregl.Marker({
element: el,
draggable: draggable
})
.setLngLat(lngLat)
.setPopup(popup)
.addTo(this.mapManager.map);
// Store marker data
this.markers.set(markerId, {
marker,
data: {
id: markerId,
lngLat,
category,
name,
description,
createdAt: new Date().toISOString()
}
});
// Save to storage
this.saveMarkersToStorage();
// Setup delete handler
popup.on('open', () => {
const deleteBtn = document.querySelector(`[data-marker-id="${markerId}"]`);
if (deleteBtn) {
deleteBtn.addEventListener('click', () => {
this.removeMarker(markerId);
popup.remove();
});
}
});
return markerId;
}
removeMarker(markerId) {
const markerObj = this.markers.get(markerId);
if (markerObj) {
markerObj.marker.remove();
this.markers.delete(markerId);
this.saveMarkersToStorage();
}
}
clearAllMarkers() {
this.markers.forEach((markerObj) => {
markerObj.marker.remove();
});
this.markers.clear();
this.saveMarkersToStorage();
}
getMarkers() {
const markerData = [];
this.markers.forEach((markerObj) => {
markerData.push(markerObj.data);
});
return markerData;
}
saveMarkersToStorage() {
const markersData = this.getMarkers();
this.storageManager.data.markers = markersData;
this.storageManager.save();
}
loadMarkersFromStorage() {
const markersData = this.storageManager.data.markers || [];
markersData.forEach((markerData) => {
this.addMarker(markerData.lngLat, {
category: markerData.category,
name: markerData.name,
description: markerData.description
});
});
}
getMarkersByCategory(category) {
const markers = [];
this.markers.forEach((markerObj) => {
if (markerObj.data.category === category) {
markers.push(markerObj.data);
}
});
return markers;
}
}

295
src/js/measurementTools.js Normal file
View File

@@ -0,0 +1,295 @@
export class MeasurementTools {
constructor(mapManager) {
this.mapManager = mapManager;
this.mode = null; // 'distance', 'area', null
this.points = [];
this.markers = [];
this.lineSourceId = 'measurement-line';
this.polygonSourceId = 'measurement-polygon';
this.setupSources();
}
setupSources() {
const map = this.mapManager.map;
// Add line source and layer for distance measurement
if (!map.getSource(this.lineSourceId)) {
map.addSource(this.lineSourceId, {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: []
}
}
});
map.addLayer({
id: 'measurement-line-layer',
type: 'line',
source: this.lineSourceId,
paint: {
'line-color': '#00d4ff',
'line-width': 3,
'line-dasharray': [2, 2]
}
});
}
// Add polygon source and layer for area measurement
if (!map.getSource(this.polygonSourceId)) {
map.addSource(this.polygonSourceId, {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [[]]
}
}
});
map.addLayer({
id: 'measurement-polygon-layer',
type: 'fill',
source: this.polygonSourceId,
paint: {
'fill-color': '#00d4ff',
'fill-opacity': 0.2
}
});
map.addLayer({
id: 'measurement-polygon-outline',
type: 'line',
source: this.polygonSourceId,
paint: {
'line-color': '#00d4ff',
'line-width': 2
}
});
}
}
startDistance() {
this.clear();
this.mode = 'distance';
this.points = [];
this.enableClickHandler();
}
startArea() {
this.clear();
this.mode = 'area';
this.points = [];
this.enableClickHandler();
}
enableClickHandler() {
const map = this.mapManager.map;
map.getCanvas().style.cursor = 'crosshair';
this.clickHandler = (e) => {
const coords = [e.lngLat.lng, e.lngLat.lat];
this.addPoint(coords);
};
map.on('click', this.clickHandler);
}
addPoint(coords) {
this.points.push(coords);
// Add marker
const el = document.createElement('div');
el.className = 'measurement-marker';
el.innerHTML = `<div class="measurement-marker-dot">${this.points.length}</div>`;
const marker = new maplibregl.Marker({ element: el })
.setLngLat(coords)
.addTo(this.mapManager.map);
this.markers.push(marker);
this.updateMeasurement();
}
updateMeasurement() {
if (this.mode === 'distance') {
this.updateDistance();
} else if (this.mode === 'area') {
this.updateArea();
}
}
updateDistance() {
const map = this.mapManager.map;
const source = map.getSource(this.lineSourceId);
if (source) {
source.setData({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: this.points
}
});
}
if (this.points.length >= 2) {
const distance = this.calculateTotalDistance();
return distance;
}
}
updateArea() {
const map = this.mapManager.map;
const source = map.getSource(this.polygonSourceId);
if (source && this.points.length >= 3) {
// Close the polygon
const polygonCoords = [...this.points, this.points[0]];
source.setData({
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [polygonCoords]
}
});
const area = this.calculateArea();
return area;
}
}
calculateTotalDistance() {
let total = 0;
for (let i = 0; i < this.points.length - 1; i++) {
total += this.calculateDistance(this.points[i], this.points[i + 1]);
}
return total;
}
calculateDistance(coord1, coord2) {
// Haversine formula
const R = 6371e3; // Earth's radius in meters
const φ1 = coord1[1] * Math.PI / 180;
const φ2 = coord2[1] * Math.PI / 180;
const Δφ = (coord2[1] - coord1[1]) * Math.PI / 180;
const Δλ = (coord2[0] - coord1[0]) * Math.PI / 180;
const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in meters
}
calculateArea() {
if (this.points.length < 3) return 0;
// Calculate area using shoelace formula (approximation for small areas)
const R = 6371e3; // Earth's radius in meters
let area = 0;
for (let i = 0; i < this.points.length; i++) {
const j = (i + 1) % this.points.length;
const xi = this.points[i][0] * Math.PI / 180;
const yi = this.points[i][1] * Math.PI / 180;
const xj = this.points[j][0] * Math.PI / 180;
const yj = this.points[j][1] * Math.PI / 180;
area += xi * Math.sin(yj) - xj * Math.sin(yi);
}
area = Math.abs(area * R * R / 2);
return area;
}
formatDistance(meters) {
if (meters < 1000) {
return `${meters.toFixed(1)} m`;
} else if (meters < 10000) {
return `${(meters / 1000).toFixed(2)} km`;
} else {
return `${(meters / 1000).toFixed(1)} km`;
}
}
formatArea(sqMeters) {
if (sqMeters < 10000) {
return `${sqMeters.toFixed(1)}`;
} else if (sqMeters < 1000000) {
return `${(sqMeters / 10000).toFixed(2)} hectares`;
} else {
return `${(sqMeters / 1000000).toFixed(2)} km²`;
}
}
clear() {
const map = this.mapManager.map;
// Remove markers
this.markers.forEach(marker => marker.remove());
this.markers = [];
// Clear points
this.points = [];
// Clear line
const lineSource = map.getSource(this.lineSourceId);
if (lineSource) {
lineSource.setData({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: []
}
});
}
// Clear polygon
const polygonSource = map.getSource(this.polygonSourceId);
if (polygonSource) {
polygonSource.setData({
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [[]]
}
});
}
// Remove click handler
if (this.clickHandler) {
map.off('click', this.clickHandler);
map.getCanvas().style.cursor = '';
}
this.mode = null;
}
getResults() {
if (this.mode === 'distance' && this.points.length >= 2) {
const distance = this.calculateTotalDistance();
return {
type: 'distance',
value: distance,
formatted: this.formatDistance(distance),
points: this.points.length
};
} else if (this.mode === 'area' && this.points.length >= 3) {
const area = this.calculateArea();
return {
type: 'area',
value: area,
formatted: this.formatArea(area),
points: this.points.length
};
}
return null;
}
}

View File

@@ -27,6 +27,7 @@ export class StorageManager {
getDefaultData() { getDefaultData() {
return { return {
savedLocations: [], savedLocations: [],
markers: [],
settings: { settings: {
defaultStyle: 'satellite', defaultStyle: 'satellite',
show3DBuildings: false, show3DBuildings: false,

103
src/js/urlManager.js Normal file
View File

@@ -0,0 +1,103 @@
export class URLManager {
constructor(mapManager) {
this.mapManager = mapManager;
}
generateShareURL(options = {}) {
const center = this.mapManager.getCenter();
const zoom = this.mapManager.getZoom();
const bearing = this.mapManager.getBearing();
const pitch = this.mapManager.getPitch();
const params = new URLSearchParams();
params.set('lat', center.lat.toFixed(6));
params.set('lng', center.lng.toFixed(6));
params.set('zoom', zoom.toFixed(2));
if (bearing !== 0) {
params.set('bearing', bearing.toFixed(2));
}
if (pitch !== 0) {
params.set('pitch', pitch.toFixed(2));
}
if (options.style) {
params.set('style', options.style);
}
if (options.marker) {
params.set('marker', '1');
}
const baseURL = window.location.origin + window.location.pathname;
return `${baseURL}?${params.toString()}`;
}
loadFromURL() {
const params = new URLSearchParams(window.location.search);
const lat = parseFloat(params.get('lat'));
const lng = parseFloat(params.get('lng'));
const zoom = parseFloat(params.get('zoom'));
const bearing = parseFloat(params.get('bearing')) || 0;
const pitch = parseFloat(params.get('pitch')) || 0;
const style = params.get('style');
const hasMarker = params.get('marker') === '1';
if (!isNaN(lat) && !isNaN(lng) && !isNaN(zoom)) {
return {
center: [lng, lat],
zoom,
bearing,
pitch,
style,
hasMarker
};
}
return null;
}
updateURL(silent = true) {
const url = this.generateShareURL();
if (silent) {
window.history.replaceState({}, '', url);
} else {
window.history.pushState({}, '', url);
}
}
copyToClipboard() {
const url = this.generateShareURL();
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(url)
.then(() => ({ success: true, url }))
.catch(() => ({ success: false, url }));
} else {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = url;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
document.body.removeChild(textArea);
return Promise.resolve({ success: true, url });
} catch (err) {
document.body.removeChild(textArea);
return Promise.resolve({ success: false, url });
}
}
}
generateEmbedCode(width = '100%', height = '600px') {
const url = this.generateShareURL();
return `<iframe src="${url}" width="${width}" height="${height}" style="border:0;" allowfullscreen="" loading="lazy"></iframe>`;
}
}