You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

771 lines
29 KiB

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Map Theme Editor - 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;
width: 300px;
max-height: 90vh;
overflow-y: auto;
}
.control-panel h3 {
margin-bottom: 10px;
color: #333;
font-size: 16px;
border-bottom: 2px solid #4285f4;
padding-bottom: 8px;
}
.section {
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.section:last-child {
border-bottom: none;
}
.section-title {
font-size: 12px;
font-weight: bold;
color: #666;
margin-bottom: 10px;
text-transform: uppercase;
}
.color-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.color-row label {
font-size: 13px;
width: 90px;
flex-shrink: 0;
}
.color-row input[type="color"] {
width: 50px;
height: 30px;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
padding: 2px;
}
.color-row .hex-value {
font-size: 11px;
color: #666;
font-family: monospace;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
}
.checkbox-row label {
font-size: 13px;
cursor: pointer;
}
.slider-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.slider-row label {
font-size: 12px;
width: 70px;
flex-shrink: 0;
}
.slider-row input[type="range"] {
flex: 1;
}
.slider-row .value {
font-size: 11px;
width: 40px;
text-align: right;
}
.btn {
display: inline-block;
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn-primary {
background: #4285f4;
color: white;
}
.btn-primary:hover {
background: #3367d6;
}
.btn-success {
background: #34a853;
color: white;
}
.btn-success:hover {
background: #2d8e47;
}
.btn-danger {
background: #ea4335;
color: white;
}
.btn-danger:hover {
background: #c5372b;
}
.btn-secondary {
background: #666;
color: white;
}
.btn-secondary:hover {
background: #555;
}
.btn-block {
display: block;
width: 100%;
margin-bottom: 8px;
}
.btn-group {
display: flex;
gap: 8px;
margin-top: 10px;
}
.btn-group .btn {
flex: 1;
}
.theme-name-input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
margin-bottom: 10px;
}
.theme-name-input:focus {
outline: none;
border-color: #4285f4;
}
.saved-themes {
max-height: 200px;
overflow-y: auto;
margin-top: 10px;
}
.theme-item {
display: flex;
align-items: center;
padding: 8px 10px;
background: #f5f5f5;
border-radius: 4px;
margin-bottom: 6px;
cursor: pointer;
transition: background 0.2s;
}
.theme-item:hover {
background: #e8f0fe;
}
.theme-item.active {
background: #4285f4;
color: white;
}
.theme-item .theme-name {
flex: 1;
font-size: 13px;
font-weight: 500;
}
.theme-item .theme-colors {
display: flex;
gap: 3px;
margin-right: 8px;
}
.theme-item .color-dot {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid rgba(0,0,0,0.2);
}
.theme-item .delete-btn {
background: none;
border: none;
color: #999;
cursor: pointer;
font-size: 16px;
padding: 0 4px;
}
.theme-item .delete-btn:hover {
color: #ea4335;
}
.theme-item.active .delete-btn {
color: rgba(255,255,255,0.7);
}
.theme-item.active .delete-btn:hover {
color: white;
}
.no-themes {
text-align: center;
color: #999;
font-size: 12px;
padding: 15px;
}
.preset-btns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.preset-btn {
padding: 8px;
border: 2px solid #ddd;
border-radius: 6px;
background: #f8f8f8;
cursor: pointer;
font-size: 11px;
text-align: center;
transition: all 0.2s;
}
.preset-btn:hover {
border-color: #4285f4;
background: #e8f0fe;
}
.preset-btn .preset-name {
font-weight: bold;
display: block;
}
.preset-btn .preset-colors {
display: flex;
justify-content: center;
gap: 3px;
margin-top: 4px;
}
.preset-btn .color-dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(0,0,0,0.2);
}
.success-msg {
background: #d4edda;
color: #155724;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
margin-bottom: 10px;
display: none;
}
.info-text {
font-size: 11px;
color: #666;
margin-top: 8px;
}
</style>
</head>
<body>
<div id="map"></div>
<div class="control-panel">
<h3>Map Theme Editor</h3>
<div id="successMsg" class="success-msg"></div>
<div class="section">
<div class="section-title">Color Palette</div>
<div class="color-row">
<label>Background</label>
<input type="color" id="colorLand" value="#1a1a2e">
<span class="hex-value" id="hexLand">#1a1a2e</span>
</div>
<div class="color-row">
<label>Water</label>
<input type="color" id="colorWater" value="#0f3460">
<span class="hex-value" id="hexWater">#0f3460</span>
</div>
<div class="color-row">
<label>Roads</label>
<input type="color" id="colorRoads" value="#e94560">
<span class="hex-value" id="hexRoads">#e94560</span>
</div>
<div class="color-row">
<label>Buildings</label>
<input type="color" id="colorBuildings" value="#16213e">
<span class="hex-value" id="hexBuildings">#16213e</span>
</div>
<div class="color-row">
<label>Parks/Nature</label>
<input type="color" id="colorParks" value="#1b4332">
<span class="hex-value" id="hexParks">#1b4332</span>
</div>
<div class="checkbox-row">
<input type="checkbox" id="buildings3d" checked>
<label for="buildings3d">3D Buildings (200% height)</label>
</div>
</div>
<div class="section">
<div class="section-title">Presets</div>
<div class="preset-btns">
<button class="preset-btn" data-preset="fantasy-dark">
<span class="preset-name">Fantasy Dark</span>
<div class="preset-colors">
<span class="color-dot" style="background:#1a1a2e"></span>
<span class="color-dot" style="background:#0f3460"></span>
<span class="color-dot" style="background:#e94560"></span>
</div>
</button>
<button class="preset-btn" data-preset="forest">
<span class="preset-name">Enchanted Forest</span>
<div class="preset-colors">
<span class="color-dot" style="background:#2d3a2d"></span>
<span class="color-dot" style="background:#1a3a3a"></span>
<span class="color-dot" style="background:#8b7355"></span>
</div>
</button>
<button class="preset-btn" data-preset="desert">
<span class="preset-name">Desert Oasis</span>
<div class="preset-colors">
<span class="color-dot" style="background:#c2b280"></span>
<span class="color-dot" style="background:#4a90a4"></span>
<span class="color-dot" style="background:#8b4513"></span>
</div>
</button>
<button class="preset-btn" data-preset="ice">
<span class="preset-name">Ice Kingdom</span>
<div class="preset-colors">
<span class="color-dot" style="background:#e8f4f8"></span>
<span class="color-dot" style="background:#4a90d9"></span>
<span class="color-dot" style="background:#6a8caf"></span>
</div>
</button>
<button class="preset-btn" data-preset="volcano">
<span class="preset-name">Volcanic</span>
<div class="preset-colors">
<span class="color-dot" style="background:#1a0a0a"></span>
<span class="color-dot" style="background:#2a1a1a"></span>
<span class="color-dot" style="background:#ff4500"></span>
</div>
</button>
<button class="preset-btn" data-preset="candy">
<span class="preset-name">Candy Land</span>
<div class="preset-colors">
<span class="color-dot" style="background:#ffe4ec"></span>
<span class="color-dot" style="background:#a8d5e5"></span>
<span class="color-dot" style="background:#ff69b4"></span>
</div>
</button>
</div>
</div>
<div class="section">
<div class="section-title">Save Theme</div>
<input type="text" id="themeName" class="theme-name-input" placeholder="Enter theme name...">
<button class="btn btn-success btn-block" id="saveTheme">Save Theme</button>
</div>
<div class="section">
<div class="section-title">Saved Themes</div>
<div id="savedThemes" class="saved-themes">
<div class="no-themes">No saved themes yet</div>
</div>
</div>
<div class="section">
<div class="section-title">Apply to Game</div>
<button class="btn btn-primary btn-block" id="applyToGame">Set as Active Game Theme</button>
<p class="info-text">This will save the current colors as the active theme used in HikeMap.</p>
</div>
<div class="section">
<div class="section-title">Camera Controls</div>
<div class="slider-row">
<label>Pitch</label>
<input type="range" id="pitch" min="0" max="85" value="45">
<span class="value" id="pitchVal">45°</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"></span>
</div>
</div>
</div>
<script>
// Presets
const presets = {
'fantasy-dark': {
land: '#1a1a2e', water: '#0f3460', roads: '#e94560', buildings: '#16213e', parks: '#1b4332'
},
'forest': {
land: '#2d3a2d', water: '#1a3a3a', roads: '#8b7355', buildings: '#3d4a3d', parks: '#1b5e20'
},
'desert': {
land: '#c2b280', water: '#4a90a4', roads: '#8b4513', buildings: '#a89060', parks: '#6b8e23'
},
'ice': {
land: '#e8f4f8', water: '#4a90d9', roads: '#6a8caf', buildings: '#b8c8d8', parks: '#90b8a8'
},
'volcano': {
land: '#1a0a0a', water: '#2a1a1a', roads: '#ff4500', buildings: '#2a1515', parks: '#3d2a1a'
},
'candy': {
land: '#ffe4ec', water: '#a8d5e5', roads: '#ff69b4', buildings: '#ffc0cb', parks: '#98fb98'
}
};
// Load saved themes from localStorage
function loadSavedThemes() {
const themes = JSON.parse(localStorage.getItem('hikemap_themes') || '{}');
return themes;
}
// Save themes to localStorage
function saveSavedThemes(themes) {
localStorage.setItem('hikemap_themes', JSON.stringify(themes));
}
// Get current colors from inputs
function getCurrentColors() {
return {
land: document.getElementById('colorLand').value,
water: document.getElementById('colorWater').value,
roads: document.getElementById('colorRoads').value,
buildings: document.getElementById('colorBuildings').value,
parks: document.getElementById('colorParks').value,
buildings3d: document.getElementById('buildings3d').checked
};
}
// Set colors to inputs
function setColors(colors) {
document.getElementById('colorLand').value = colors.land;
document.getElementById('colorWater').value = colors.water;
document.getElementById('colorRoads').value = colors.roads;
document.getElementById('colorBuildings').value = colors.buildings;
document.getElementById('colorParks').value = colors.parks;
if (colors.buildings3d !== undefined) {
document.getElementById('buildings3d').checked = colors.buildings3d;
}
updateHexDisplays();
applyStyle();
}
// Update hex value displays
function updateHexDisplays() {
document.getElementById('hexLand').textContent = document.getElementById('colorLand').value;
document.getElementById('hexWater').textContent = document.getElementById('colorWater').value;
document.getElementById('hexRoads').textContent = document.getElementById('colorRoads').value;
document.getElementById('hexBuildings').textContent = document.getElementById('colorBuildings').value;
document.getElementById('hexParks').textContent = document.getElementById('colorParks').value;
}
// Render saved themes list
function renderSavedThemes() {
const container = document.getElementById('savedThemes');
const themes = loadSavedThemes();
const themeNames = Object.keys(themes);
if (themeNames.length === 0) {
container.innerHTML = '<div class="no-themes">No saved themes yet</div>';
return;
}
container.innerHTML = themeNames.map(name => {
const t = themes[name];
return `
<div class="theme-item" data-theme="${name}">
<div class="theme-colors">
<span class="color-dot" style="background:${t.land}"></span>
<span class="color-dot" style="background:${t.water}"></span>
<span class="color-dot" style="background:${t.roads}"></span>
<span class="color-dot" style="background:${t.buildings}"></span>
<span class="color-dot" style="background:${t.parks}"></span>
</div>
<span class="theme-name">${name}</span>
<button class="delete-btn" data-delete="${name}">&times;</button>
</div>
`;
}).join('');
// Add click handlers
container.querySelectorAll('.theme-item').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.classList.contains('delete-btn')) return;
const themeName = item.dataset.theme;
const themes = loadSavedThemes();
if (themes[themeName]) {
setColors(themes[themeName]);
document.getElementById('themeName').value = themeName;
// Update active state
container.querySelectorAll('.theme-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
}
});
});
// Delete handlers
container.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const themeName = btn.dataset.delete;
if (confirm(`Delete theme "${themeName}"?`)) {
const themes = loadSavedThemes();
delete themes[themeName];
saveSavedThemes(themes);
renderSavedThemes();
showSuccess(`Deleted "${themeName}"`);
}
});
});
}
// Show success message
function showSuccess(msg) {
const el = document.getElementById('successMsg');
el.textContent = msg;
el.style.display = 'block';
setTimeout(() => { el.style.display = 'none'; }, 3000);
}
// Build and apply the map style
function applyStyle() {
const colors = getCurrentColors();
const style = {
version: 8,
name: 'HikeMap Theme',
sources: {
'openmaptiles': {
type: 'vector',
url: 'https://tiles.openfreemap.org/planet'
}
},
glyphs: 'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
layers: [
{ id: 'background', type: 'background', paint: { 'background-color': colors.land } },
{ id: 'park', type: 'fill', source: 'openmaptiles', 'source-layer': 'park',
paint: { 'fill-color': colors.parks, 'fill-opacity': 0.7 } },
{ id: 'landcover_wood', type: 'fill', source: 'openmaptiles', 'source-layer': 'landcover',
filter: ['==', ['get', 'class'], 'wood'],
paint: { 'fill-color': colors.parks, 'fill-opacity': 0.5 } },
{ id: 'landcover_grass', type: 'fill', source: 'openmaptiles', 'source-layer': 'landcover',
filter: ['==', ['get', 'class'], 'grass'],
paint: { 'fill-color': colors.parks, 'fill-opacity': 0.4 } },
{ id: 'water', type: 'fill', source: 'openmaptiles', 'source-layer': 'water',
paint: { 'fill-color': colors.water } },
{ id: 'waterway', type: 'line', source: 'openmaptiles', 'source-layer': 'waterway',
paint: { 'line-color': colors.water, 'line-width': ['interpolate', ['linear'], ['zoom'], 8, 1, 14, 3] } },
colors.buildings3d ? {
id: 'buildings-3d', type: 'fill-extrusion', source: 'openmaptiles', 'source-layer': 'building', minzoom: 13,
paint: {
'fill-extrusion-color': colors.buildings,
'fill-extrusion-height': ['*', 2, ['get', 'render_height']],
'fill-extrusion-base': ['*', 2, ['get', 'render_min_height']],
'fill-extrusion-opacity': 0.85
}
} : {
id: 'buildings', type: 'fill', source: 'openmaptiles', 'source-layer': 'building', minzoom: 13,
paint: { 'fill-color': colors.buildings, 'fill-opacity': 0.8 }
},
{ id: 'roads-service', type: 'line', source: 'openmaptiles', 'source-layer': 'transportation',
filter: ['match', ['get', 'class'], ['service', 'track'], true, false],
paint: { 'line-color': colors.roads, 'line-width': 1, 'line-opacity': 0.4 } },
{ id: 'roads-path', type: 'line', source: 'openmaptiles', 'source-layer': 'transportation',
filter: ['match', ['get', 'class'], ['path', 'pedestrian'], true, false],
paint: { 'line-color': colors.roads, 'line-width': 1, 'line-dasharray': [2, 1], 'line-opacity': 0.6 } },
{ id: 'roads-minor', type: 'line', source: 'openmaptiles', 'source-layer': 'transportation',
filter: ['==', ['get', 'class'], 'minor'],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': colors.roads, 'line-width': ['interpolate', ['exponential', 1.2], ['zoom'], 13, 1, 20, 10], 'line-opacity': 0.8 } },
{ 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': colors.roads, 'line-width': ['interpolate', ['exponential', 1.2], ['zoom'], 8, 0.5, 20, 13] } },
{ 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': colors.roads, 'line-width': ['interpolate', ['exponential', 1.2], ['zoom'], 5, 0.5, 20, 18] } },
{ id: 'roads-motorway', type: 'line', source: 'openmaptiles', 'source-layer': 'transportation',
filter: ['==', ['get', 'class'], 'motorway'],
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': colors.roads, 'line-width': ['interpolate', ['exponential', 1.2], ['zoom'], 5, 1, 20, 20] } },
{ id: 'rail', type: 'line', source: 'openmaptiles', 'source-layer': 'transportation',
filter: ['==', ['get', 'class'], 'rail'],
paint: { 'line-color': '#888888', 'line-width': 2, 'line-dasharray': [3, 3] } },
{ 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] } },
{ 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': colors.land, 'text-halo-width': 1 } },
{ 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': colors.land, 'text-halo-width': 2 } }
]
};
map.setStyle(style);
}
// Initialize map
const map = new maplibregl.Map({
container: 'map',
style: { version: 8, sources: {}, layers: [{ id: 'background', type: 'background', paint: { 'background-color': '#1a1a2e' } }] },
center: [-97.84, 30.49],
zoom: 15,
pitch: 45,
bearing: 0
});
map.addControl(new maplibregl.NavigationControl());
// Apply initial style after map loads
map.on('load', () => {
applyStyle();
renderSavedThemes();
// Load active game theme if exists
const activeTheme = localStorage.getItem('hikemap_active_theme');
if (activeTheme) {
try {
const colors = JSON.parse(activeTheme);
setColors(colors);
} catch (e) {}
}
});
// Color input handlers
['colorLand', 'colorWater', 'colorRoads', 'colorBuildings', 'colorParks'].forEach(id => {
document.getElementById(id).addEventListener('input', () => {
updateHexDisplays();
applyStyle();
});
});
document.getElementById('buildings3d').addEventListener('change', applyStyle);
// Preset handlers
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
const presetName = btn.dataset.preset;
if (presets[presetName]) {
setColors({ ...presets[presetName], buildings3d: true });
}
});
});
// Save theme
document.getElementById('saveTheme').addEventListener('click', () => {
const name = document.getElementById('themeName').value.trim();
if (!name) {
alert('Please enter a theme name');
return;
}
const themes = loadSavedThemes();
themes[name] = getCurrentColors();
saveSavedThemes(themes);
renderSavedThemes();
showSuccess(`Saved "${name}"`);
});
// Apply to game
document.getElementById('applyToGame').addEventListener('click', () => {
const colors = getCurrentColors();
localStorage.setItem('hikemap_active_theme', JSON.stringify(colors));
showSuccess('Theme applied to game! Refresh HikeMap to see changes.');
});
// Camera controls
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 + '°';
});
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()) + '°';
});
// Initial hex display update
updateHexDisplays();
</script>
</body>
</html>