Browse Source
Add MapLibre GL JS test page for custom map styling
Add MapLibre GL JS test page for custom map styling
- Test page at /maplibre-test.html for evaluating MapLibre migration - 5 pre-made styles: Liberty, Positron, Dark Matter, Bright, Custom Fantasy - Custom Fantasy allows picking colors for land, water, roads, buildings, parks - 3D extruded buildings option with pitch/bearing controls - Uses free OpenFreeMap vector tiles (no API key needed) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>master
2 changed files with 625 additions and 0 deletions
@ -0,0 +1,624 @@ |
|||
<!DOCTYPE html> |
|||
<html> |
|||
<head> |
|||
<meta charset="utf-8"> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|||
<title>MapLibre Test - HikeMap</title> |
|||
<script src="https://unpkg.com/maplibre-gl@4.1.0/dist/maplibre-gl.js"></script> |
|||
<link href="https://unpkg.com/maplibre-gl@4.1.0/dist/maplibre-gl.css" rel="stylesheet" /> |
|||
<style> |
|||
* { margin: 0; padding: 0; box-sizing: border-box; } |
|||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } |
|||
|
|||
#map { |
|||
position: absolute; |
|||
top: 0; |
|||
bottom: 0; |
|||
left: 0; |
|||
right: 0; |
|||
} |
|||
|
|||
.control-panel { |
|||
position: absolute; |
|||
top: 10px; |
|||
left: 10px; |
|||
background: white; |
|||
padding: 15px; |
|||
border-radius: 8px; |
|||
box-shadow: 0 2px 10px rgba(0,0,0,0.3); |
|||
z-index: 1000; |
|||
max-width: 280px; |
|||
max-height: 90vh; |
|||
overflow-y: auto; |
|||
} |
|||
|
|||
.control-panel h3 { |
|||
margin-bottom: 10px; |
|||
color: #333; |
|||
font-size: 14px; |
|||
border-bottom: 1px solid #ddd; |
|||
padding-bottom: 8px; |
|||
} |
|||
|
|||
.style-btn { |
|||
display: block; |
|||
width: 100%; |
|||
padding: 10px; |
|||
margin-bottom: 8px; |
|||
border: 2px solid #ddd; |
|||
border-radius: 6px; |
|||
background: #f8f8f8; |
|||
cursor: pointer; |
|||
text-align: left; |
|||
font-size: 13px; |
|||
transition: all 0.2s; |
|||
} |
|||
|
|||
.style-btn:hover { |
|||
border-color: #4285f4; |
|||
background: #e8f0fe; |
|||
} |
|||
|
|||
.style-btn.active { |
|||
border-color: #4285f4; |
|||
background: #4285f4; |
|||
color: white; |
|||
} |
|||
|
|||
.style-btn .style-name { |
|||
font-weight: bold; |
|||
display: block; |
|||
} |
|||
|
|||
.style-btn .style-desc { |
|||
font-size: 11px; |
|||
opacity: 0.7; |
|||
margin-top: 2px; |
|||
} |
|||
|
|||
.section { |
|||
margin-bottom: 15px; |
|||
} |
|||
|
|||
.section-title { |
|||
font-size: 12px; |
|||
font-weight: bold; |
|||
color: #666; |
|||
margin-bottom: 8px; |
|||
text-transform: uppercase; |
|||
} |
|||
|
|||
.slider-row { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
margin-bottom: 8px; |
|||
} |
|||
|
|||
.slider-row label { |
|||
font-size: 12px; |
|||
width: 60px; |
|||
flex-shrink: 0; |
|||
} |
|||
|
|||
.slider-row input[type="range"] { |
|||
flex: 1; |
|||
} |
|||
|
|||
.slider-row .value { |
|||
font-size: 11px; |
|||
width: 35px; |
|||
text-align: right; |
|||
} |
|||
|
|||
.info-box { |
|||
background: #f0f7ff; |
|||
border: 1px solid #b3d4fc; |
|||
border-radius: 6px; |
|||
padding: 10px; |
|||
font-size: 12px; |
|||
color: #333; |
|||
margin-top: 15px; |
|||
} |
|||
|
|||
.info-box strong { |
|||
display: block; |
|||
margin-bottom: 5px; |
|||
} |
|||
|
|||
.color-row { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
margin-bottom: 6px; |
|||
} |
|||
|
|||
.color-row label { |
|||
font-size: 12px; |
|||
width: 80px; |
|||
} |
|||
|
|||
.color-row input[type="color"] { |
|||
width: 40px; |
|||
height: 25px; |
|||
border: 1px solid #ccc; |
|||
border-radius: 4px; |
|||
cursor: pointer; |
|||
} |
|||
|
|||
#customControls { |
|||
display: none; |
|||
} |
|||
|
|||
.toggle-btn { |
|||
background: #333; |
|||
color: white; |
|||
border: none; |
|||
padding: 8px 12px; |
|||
border-radius: 4px; |
|||
cursor: pointer; |
|||
font-size: 12px; |
|||
margin-top: 10px; |
|||
} |
|||
|
|||
.toggle-btn:hover { |
|||
background: #555; |
|||
} |
|||
</style> |
|||
</head> |
|||
<body> |
|||
<div id="map"></div> |
|||
|
|||
<div class="control-panel"> |
|||
<h3>MapLibre Style Test</h3> |
|||
|
|||
<div class="section"> |
|||
<div class="section-title">Pre-made Styles</div> |
|||
|
|||
<button class="style-btn active" data-style="liberty"> |
|||
<span class="style-name">OSM Liberty</span> |
|||
<span class="style-desc">Clean, colorful, open source</span> |
|||
</button> |
|||
|
|||
<button class="style-btn" data-style="positron"> |
|||
<span class="style-name">Positron</span> |
|||
<span class="style-desc">Light, minimal, muted colors</span> |
|||
</button> |
|||
|
|||
<button class="style-btn" data-style="dark-matter"> |
|||
<span class="style-name">Dark Matter</span> |
|||
<span class="style-desc">Dark theme, neon accents</span> |
|||
</button> |
|||
|
|||
<button class="style-btn" data-style="bright"> |
|||
<span class="style-name">OSM Bright</span> |
|||
<span class="style-desc">Vibrant, detailed</span> |
|||
</button> |
|||
|
|||
<button class="style-btn" data-style="custom"> |
|||
<span class="style-name">Custom Fantasy</span> |
|||
<span class="style-desc">Editable colors below</span> |
|||
</button> |
|||
</div> |
|||
|
|||
<div id="customControls" class="section"> |
|||
<div class="section-title">Custom Colors</div> |
|||
|
|||
<div class="color-row"> |
|||
<label>Land</label> |
|||
<input type="color" id="colorLand" value="#1a1a2e"> |
|||
</div> |
|||
<div class="color-row"> |
|||
<label>Water</label> |
|||
<input type="color" id="colorWater" value="#0f3460"> |
|||
</div> |
|||
<div class="color-row"> |
|||
<label>Roads</label> |
|||
<input type="color" id="colorRoads" value="#e94560"> |
|||
</div> |
|||
<div class="color-row"> |
|||
<label>Buildings</label> |
|||
<input type="color" id="colorBuildings" value="#16213e"> |
|||
</div> |
|||
<div class="color-row"> |
|||
<label>Parks</label> |
|||
<input type="color" id="colorParks" value="#1b4332"> |
|||
</div> |
|||
|
|||
<div style="margin-top: 10px;"> |
|||
<label style="font-size: 12px;"> |
|||
<input type="checkbox" id="buildings3d"> 3D Buildings |
|||
</label> |
|||
</div> |
|||
|
|||
<button class="toggle-btn" id="applyColors">Apply Colors</button> |
|||
</div> |
|||
|
|||
<div class="section"> |
|||
<div class="section-title">3D Controls</div> |
|||
|
|||
<div class="slider-row"> |
|||
<label>Pitch</label> |
|||
<input type="range" id="pitch" min="0" max="85" value="0"> |
|||
<span class="value" id="pitchVal">0°</span> |
|||
</div> |
|||
|
|||
<div class="slider-row"> |
|||
<label>Bearing</label> |
|||
<input type="range" id="bearing" min="0" max="360" value="0"> |
|||
<span class="value" id="bearingVal">0°</span> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="info-box"> |
|||
<strong>What you're seeing:</strong> |
|||
Vector tiles rendered client-side. Every color, label, and feature can be customized via style JSON. |
|||
<br><br> |
|||
<strong>Controls:</strong> |
|||
• Drag to pan |
|||
• Scroll to zoom |
|||
• Right-drag to rotate/tilt |
|||
• Ctrl+drag to pitch |
|||
</div> |
|||
</div> |
|||
|
|||
<script> |
|||
// Available styles (using free tile sources) |
|||
const styles = { |
|||
'liberty': 'https://tiles.openfreemap.org/styles/liberty', |
|||
'positron': 'https://tiles.openfreemap.org/styles/positron', |
|||
'dark-matter': 'https://tiles.openfreemap.org/styles/dark', |
|||
'bright': 'https://tiles.openfreemap.org/styles/bright' |
|||
}; |
|||
|
|||
// Custom style template (we'll modify this) |
|||
const customStyleBase = { |
|||
version: 8, |
|||
name: 'Custom Fantasy', |
|||
sources: { |
|||
'openfreemap': { |
|||
type: 'vector', |
|||
url: 'https://tiles.openfreemap.org/planet' |
|||
} |
|||
}, |
|||
layers: [] |
|||
}; |
|||
|
|||
// Initialize map with default style |
|||
const map = new maplibregl.Map({ |
|||
container: 'map', |
|||
style: styles['liberty'], |
|||
center: [-97.84, 30.49], // Same as HikeMap default |
|||
zoom: 13, |
|||
pitch: 0, |
|||
bearing: 0 |
|||
}); |
|||
|
|||
// Add navigation controls |
|||
map.addControl(new maplibregl.NavigationControl()); |
|||
|
|||
// Add a test marker |
|||
const marker = new maplibregl.Marker({ color: '#e94560' }) |
|||
.setLngLat([-97.84, 30.49]) |
|||
.setPopup(new maplibregl.Popup().setHTML('<b>Test Marker</b><br>This is where HikeMap centers')) |
|||
.addTo(map); |
|||
|
|||
// Style button handlers |
|||
document.querySelectorAll('.style-btn').forEach(btn => { |
|||
btn.addEventListener('click', () => { |
|||
// Update active state |
|||
document.querySelectorAll('.style-btn').forEach(b => b.classList.remove('active')); |
|||
btn.classList.add('active'); |
|||
|
|||
const styleName = btn.dataset.style; |
|||
|
|||
// Show/hide custom controls |
|||
document.getElementById('customControls').style.display = |
|||
styleName === 'custom' ? 'block' : 'none'; |
|||
|
|||
if (styleName === 'custom') { |
|||
applyCustomStyle(); |
|||
} else if (styles[styleName]) { |
|||
map.setStyle(styles[styleName]); |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
// Custom color application |
|||
function applyCustomStyle() { |
|||
const land = document.getElementById('colorLand').value; |
|||
const water = document.getElementById('colorWater').value; |
|||
const roads = document.getElementById('colorRoads').value; |
|||
const buildings = document.getElementById('colorBuildings').value; |
|||
const parks = document.getElementById('colorParks').value; |
|||
const buildings3d = document.getElementById('buildings3d').checked; |
|||
|
|||
// Build custom style using correct OpenMapTiles schema |
|||
const customStyle = { |
|||
version: 8, |
|||
name: 'Custom Fantasy', |
|||
sources: { |
|||
'openmaptiles': { |
|||
type: 'vector', |
|||
url: 'https://tiles.openfreemap.org/planet' |
|||
} |
|||
}, |
|||
glyphs: 'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf', |
|||
layers: [ |
|||
// Background |
|||
{ |
|||
id: 'background', |
|||
type: 'background', |
|||
paint: { 'background-color': land } |
|||
}, |
|||
// Parks (from park layer) |
|||
{ |
|||
id: 'park', |
|||
type: 'fill', |
|||
source: 'openmaptiles', |
|||
'source-layer': 'park', |
|||
paint: { |
|||
'fill-color': parks, |
|||
'fill-opacity': 0.7 |
|||
} |
|||
}, |
|||
// Landcover - wood/grass |
|||
{ |
|||
id: 'landcover_wood', |
|||
type: 'fill', |
|||
source: 'openmaptiles', |
|||
'source-layer': 'landcover', |
|||
filter: ['==', ['get', 'class'], 'wood'], |
|||
paint: { |
|||
'fill-color': parks, |
|||
'fill-opacity': 0.5 |
|||
} |
|||
}, |
|||
{ |
|||
id: 'landcover_grass', |
|||
type: 'fill', |
|||
source: 'openmaptiles', |
|||
'source-layer': 'landcover', |
|||
filter: ['==', ['get', 'class'], 'grass'], |
|||
paint: { |
|||
'fill-color': parks, |
|||
'fill-opacity': 0.4 |
|||
} |
|||
}, |
|||
// Water |
|||
{ |
|||
id: 'water', |
|||
type: 'fill', |
|||
source: 'openmaptiles', |
|||
'source-layer': 'water', |
|||
paint: { 'fill-color': water } |
|||
}, |
|||
// Waterways |
|||
{ |
|||
id: 'waterway', |
|||
type: 'line', |
|||
source: 'openmaptiles', |
|||
'source-layer': 'waterway', |
|||
paint: { |
|||
'line-color': water, |
|||
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 1, 14, 3] |
|||
} |
|||
}, |
|||
// Buildings (2D or 3D based on checkbox) |
|||
buildings3d ? { |
|||
id: 'buildings-3d', |
|||
type: 'fill-extrusion', |
|||
source: 'openmaptiles', |
|||
'source-layer': 'building', |
|||
minzoom: 13, |
|||
paint: { |
|||
'fill-extrusion-color': buildings, |
|||
'fill-extrusion-height': ['get', 'render_height'], |
|||
'fill-extrusion-base': ['get', 'render_min_height'], |
|||
'fill-extrusion-opacity': 0.85 |
|||
} |
|||
} : { |
|||
id: 'buildings', |
|||
type: 'fill', |
|||
source: 'openmaptiles', |
|||
'source-layer': 'building', |
|||
minzoom: 13, |
|||
paint: { |
|||
'fill-color': buildings, |
|||
'fill-opacity': 0.8 |
|||
} |
|||
}, |
|||
// Roads - service/track (smallest) |
|||
{ |
|||
id: 'roads-service', |
|||
type: 'line', |
|||
source: 'openmaptiles', |
|||
'source-layer': 'transportation', |
|||
filter: ['match', ['get', 'class'], ['service', 'track'], true, false], |
|||
paint: { |
|||
'line-color': roads, |
|||
'line-width': 1, |
|||
'line-opacity': 0.4 |
|||
} |
|||
}, |
|||
// Roads - path/pedestrian |
|||
{ |
|||
id: 'roads-path', |
|||
type: 'line', |
|||
source: 'openmaptiles', |
|||
'source-layer': 'transportation', |
|||
filter: ['match', ['get', 'class'], ['path', 'pedestrian'], true, false], |
|||
paint: { |
|||
'line-color': roads, |
|||
'line-width': 1, |
|||
'line-dasharray': [2, 1], |
|||
'line-opacity': 0.6 |
|||
} |
|||
}, |
|||
// Roads - minor |
|||
{ |
|||
id: 'roads-minor', |
|||
type: 'line', |
|||
source: 'openmaptiles', |
|||
'source-layer': 'transportation', |
|||
filter: ['==', ['get', 'class'], 'minor'], |
|||
layout: { 'line-cap': 'round', 'line-join': 'round' }, |
|||
paint: { |
|||
'line-color': roads, |
|||
'line-width': ['interpolate', ['exponential', 1.2], ['zoom'], 13, 1, 20, 10], |
|||
'line-opacity': 0.8 |
|||
} |
|||
}, |
|||
// Roads - secondary/tertiary |
|||
{ |
|||
id: 'roads-secondary', |
|||
type: 'line', |
|||
source: 'openmaptiles', |
|||
'source-layer': 'transportation', |
|||
filter: ['match', ['get', 'class'], ['secondary', 'tertiary'], true, false], |
|||
layout: { 'line-cap': 'round', 'line-join': 'round' }, |
|||
paint: { |
|||
'line-color': roads, |
|||
'line-width': ['interpolate', ['exponential', 1.2], ['zoom'], 8, 0.5, 20, 13] |
|||
} |
|||
}, |
|||
// Roads - primary/trunk |
|||
{ |
|||
id: 'roads-primary', |
|||
type: 'line', |
|||
source: 'openmaptiles', |
|||
'source-layer': 'transportation', |
|||
filter: ['match', ['get', 'class'], ['primary', 'trunk'], true, false], |
|||
layout: { 'line-cap': 'round', 'line-join': 'round' }, |
|||
paint: { |
|||
'line-color': roads, |
|||
'line-width': ['interpolate', ['exponential', 1.2], ['zoom'], 5, 0.5, 20, 18] |
|||
} |
|||
}, |
|||
// Roads - motorway |
|||
{ |
|||
id: 'roads-motorway', |
|||
type: 'line', |
|||
source: 'openmaptiles', |
|||
'source-layer': 'transportation', |
|||
filter: ['==', ['get', 'class'], 'motorway'], |
|||
layout: { 'line-cap': 'round', 'line-join': 'round' }, |
|||
paint: { |
|||
'line-color': roads, |
|||
'line-width': ['interpolate', ['exponential', 1.2], ['zoom'], 5, 1, 20, 20] |
|||
} |
|||
}, |
|||
// Rail |
|||
{ |
|||
id: 'rail', |
|||
type: 'line', |
|||
source: 'openmaptiles', |
|||
'source-layer': 'transportation', |
|||
filter: ['==', ['get', 'class'], 'rail'], |
|||
paint: { |
|||
'line-color': '#888888', |
|||
'line-width': 2, |
|||
'line-dasharray': [3, 3] |
|||
} |
|||
}, |
|||
// Boundaries |
|||
{ |
|||
id: 'boundary', |
|||
type: 'line', |
|||
source: 'openmaptiles', |
|||
'source-layer': 'boundary', |
|||
filter: ['==', ['get', 'admin_level'], 2], |
|||
paint: { |
|||
'line-color': '#888888', |
|||
'line-width': 1, |
|||
'line-dasharray': [4, 2] |
|||
} |
|||
}, |
|||
// Road labels |
|||
{ |
|||
id: 'road-labels', |
|||
type: 'symbol', |
|||
source: 'openmaptiles', |
|||
'source-layer': 'transportation_name', |
|||
minzoom: 14, |
|||
layout: { |
|||
'text-field': ['coalesce', ['get', 'name_en'], ['get', 'name']], |
|||
'text-size': 10, |
|||
'symbol-placement': 'line', |
|||
'text-font': ['Noto Sans Regular'] |
|||
}, |
|||
paint: { |
|||
'text-color': '#ffffff', |
|||
'text-halo-color': land, |
|||
'text-halo-width': 1 |
|||
} |
|||
}, |
|||
// Place labels |
|||
{ |
|||
id: 'place-labels', |
|||
type: 'symbol', |
|||
source: 'openmaptiles', |
|||
'source-layer': 'place', |
|||
layout: { |
|||
'text-field': ['coalesce', ['get', 'name_en'], ['get', 'name']], |
|||
'text-size': ['interpolate', ['linear'], ['zoom'], 10, 12, 14, 16], |
|||
'text-font': ['Noto Sans Bold'] |
|||
}, |
|||
paint: { |
|||
'text-color': '#ffffff', |
|||
'text-halo-color': land, |
|||
'text-halo-width': 2 |
|||
} |
|||
} |
|||
] |
|||
}; |
|||
|
|||
map.setStyle(customStyle); |
|||
} |
|||
|
|||
document.getElementById('applyColors').addEventListener('click', applyCustomStyle); |
|||
|
|||
// Color input change listeners |
|||
['colorLand', 'colorWater', 'colorRoads', 'colorBuildings', 'colorParks'].forEach(id => { |
|||
document.getElementById(id).addEventListener('input', () => { |
|||
if (document.querySelector('.style-btn.active').dataset.style === 'custom') { |
|||
applyCustomStyle(); |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
// 3D buildings checkbox |
|||
document.getElementById('buildings3d').addEventListener('change', () => { |
|||
if (document.querySelector('.style-btn.active').dataset.style === 'custom') { |
|||
applyCustomStyle(); |
|||
} |
|||
}); |
|||
|
|||
// Pitch/bearing sliders |
|||
document.getElementById('pitch').addEventListener('input', (e) => { |
|||
const val = parseInt(e.target.value); |
|||
map.setPitch(val); |
|||
document.getElementById('pitchVal').textContent = val + '°'; |
|||
}); |
|||
|
|||
document.getElementById('bearing').addEventListener('input', (e) => { |
|||
const val = parseInt(e.target.value); |
|||
map.setBearing(val); |
|||
document.getElementById('bearingVal').textContent = val + '°'; |
|||
}); |
|||
|
|||
// Sync sliders when map moves |
|||
map.on('move', () => { |
|||
document.getElementById('pitch').value = Math.round(map.getPitch()); |
|||
document.getElementById('pitchVal').textContent = Math.round(map.getPitch()) + '°'; |
|||
document.getElementById('bearing').value = Math.round(map.getBearing()); |
|||
document.getElementById('bearingVal').textContent = Math.round(map.getBearing()) + '°'; |
|||
}); |
|||
|
|||
// Re-add marker after style change |
|||
map.on('style.load', () => { |
|||
// Marker persists across style changes automatically in MapLibre |
|||
}); |
|||
</script> |
|||
</body> |
|||
</html> |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue