valheim-map/src/build_viewer.py
Aelith 3a08501b0d Initial commit: Nerdhalla Valheim World Map
- Phase 1: POI database (11,309 locations, SQLite with spatial queries)
- Phase 2: Tile decoder + Leaflet.js web viewer (3 map layers, search, categories)
- Phase 3: .db/.fch parser WIP for fog-of-war overlay
- Full README with roadmap, architecture, and usage docs
2026-06-16 13:18:39 -04:00

344 lines
13 KiB
Python

#!/usr/bin/env python3
"""
Build a Leaflet.js web viewer for the Nerdhalla Valheim world.
"""
import sqlite3, json, os
DB_PATH = "/tmp/valheim_poi.db"
OUTPUT_DIR = "/tmp/valheim_map_output"
VIEWER_PATH = os.path.join(OUTPUT_DIR, "index.html")
WORLD_SIZE = 24000
PIXELS_TOTAL = 4096
def valheim_to_pixel(vx, vz):
px = (vx + WORLD_SIZE/2) / WORLD_SIZE * PIXELS_TOTAL
py = (vz + WORLD_SIZE/2) / WORLD_SIZE * PIXELS_TOTAL
return px, py
def query_pois():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("""
SELECT id, prefab_name, category, pos_x, pos_z, has_dungeon, has_important
FROM locations WHERE pos_x IS NOT NULL AND pos_z IS NOT NULL
ORDER BY category, prefab_name
""")
pois = []
for row in c.fetchall():
loc_id, name, cat, x, z, has_dungeon, has_important = row
px, py = valheim_to_pixel(x, z)
pois.append({
'id': loc_id, 'name': name, 'category': cat,
'x': round(x, 1), 'z': round(z, 1),
'px': round(px, 1), 'py': round(py, 1),
'has_dungeon': bool(has_dungeon), 'has_important': bool(has_important),
})
c.execute("""
SELECT l.id, l.prefab_name, l.pos_x, l.pos_z, ic.friendly_name, ic.count
FROM locations l JOIN important_contents ic ON l.id = ic.location_id
WHERE l.pos_x IS NOT NULL AND l.pos_z IS NOT NULL
ORDER BY ic.count DESC
""")
contents = []
for row in c.fetchall():
loc_id, name, x, z, fname, count = row
px, py = valheim_to_pixel(x, z)
contents.append({
'loc_id': loc_id, 'loc_name': name, 'item': fname, 'count': count,
'x': round(x, 1), 'z': round(z, 1),
'px': round(px, 1), 'py': round(py, 1),
})
conn.close()
return pois, contents
def build_viewer(pois, contents):
pois_json = json.dumps(pois)
contents_json = json.dumps(contents)
cat_colors = {
'boss': '#ff4444', 'dungeon': '#ff8800', 'vegvisir': '#44aaff',
'altar': '#aa88ff', 'vendor': '#ffdd00', 'structure': '#88cc88',
'resource': '#ff66aa', 'spawner': '#ff44ff', 'point_of_interest': '#44ffaa',
'other': '#888888',
}
cat_colors_json = json.dumps(cat_colors)
cat_labels = {
'boss': 'Boss Altars', 'dungeon': 'Dungeons', 'vegvisir': 'Vegvisirs',
'vendor': 'Vendors', 'altar': 'Altars / Stones', 'structure': 'Structures',
'resource': 'Resources', 'spawner': 'Spawners', 'point_of_interest': 'POIs',
'other': 'Other',
}
cat_labels_json = json.dumps(cat_labels)
# Build category counts
cat_counts = {}
for p in pois:
cat_counts[p['category']] = cat_counts.get(p['category'], 0) + 1
cat_counts_json = json.dumps(cat_counts)
html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nerdhalla — Valheim World Map</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; font-family: 'Segoe UI', system-ui, sans-serif; }
#map { height: 100vh; width: 100vw; }
.leaflet-container { background: #1a1a2e; }
.info-panel {
position: absolute; top: 10px; right: 10px; z-index: 1000;
background: rgba(20, 20, 35, 0.92); color: #ddd;
padding: 12px 16px; border-radius: 8px; font-size: 13px;
max-width: 280px; max-height: 80vh; overflow-y: auto;
border: 1px solid #444; backdrop-filter: blur(8px);
}
.info-panel h3 { color: #ffd700; margin-bottom: 6px; font-size: 15px; }
.info-panel .stat { display: flex; justify-content: space-between; padding: 2px 0; }
.info-panel .stat span:last-child { color: #aaa; }
.legend {
position: absolute; bottom: 30px; right: 10px; z-index: 1000;
background: rgba(20, 20, 35, 0.92); color: #ddd;
padding: 10px 14px; border-radius: 8px; font-size: 12px;
border: 1px solid #444; backdrop-filter: blur(8px);
max-height: 50vh; overflow-y: auto;
}
.legend-item { display: flex; align-items: center; gap: 6px; padding: 2px 0; cursor: pointer; }
.legend-item .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.legend-item .count { color: #888; margin-left: auto; font-size: 11px; }
.legend-item.active { color: #fff; }
.legend-item.inactive { opacity: 0.4; }
.search-box {
position: absolute; top: 10px; left: 10px; z-index: 1000;
background: rgba(20, 20, 35, 0.92); border: 1px solid #444;
border-radius: 8px; padding: 8px 12px; backdrop-filter: blur(8px);
display: flex; gap: 6px; align-items: center;
}
.search-box input {
background: transparent; border: none; color: #ddd; font-size: 13px;
outline: none; width: 180px;
}
.search-box input::placeholder { color: #666; }
.search-box .results {
position: absolute; top: 100%; left: 0; right: 0;
background: rgba(20, 20, 35, 0.95); border: 1px solid #444;
border-radius: 0 0 8px 8px; max-height: 200px; overflow-y: auto;
display: none;
}
.search-box .results div { padding: 6px 12px; cursor: pointer; font-size: 12px; border-bottom: 1px solid #333; }
.search-box .results div:hover { background: rgba(255,255,255,0.1); }
.search-box .results div .cat { color: #888; font-size: 10px; }
.popup-content { font-size: 12px; line-height: 1.5; }
.popup-content .name { font-weight: bold; font-size: 14px; color: #ffd700; }
.popup-content .coords { color: #888; }
.popup-content .badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; margin: 2px 2px 0 0; }
.popup-content .badge-dungeon { background: #ff8800; color: #000; }
.popup-content .badge-treasure { background: #ffd700; color: #000; }
.toggle-layer {
position: absolute; bottom: 30px; left: 10px; z-index: 1000;
display: flex; flex-direction: column; gap: 4px;
}
.toggle-layer button {
background: rgba(20, 20, 35, 0.92); color: #ddd; border: 1px solid #444;
padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 11px;
backdrop-filter: blur(8px); text-align: left;
}
.toggle-layer button.active { border-color: #ffd700; color: #ffd700; }
.toggle-layer button:hover { background: rgba(40, 40, 60, 0.95); }
@media (max-width: 768px) { .info-panel { display: none; } .search-box input { width: 120px; } }
</style>
</head>
<body>
<div id="map"></div>
<div class="search-box">
<span style="color:#666;">&#x1F50D;</span>
<input type="text" id="search" placeholder="Search POIs..." oninput="doSearch(this.value)">
<div class="results" id="searchResults"></div>
</div>
<div class="info-panel" id="infoPanel">
<h3>&#x1F30D; Nerdhalla</h3>
<div class="stat"><span>Seed</span><span>yzZ5fr2tGa</span></div>
<div class="stat"><span>World</span><span>24000 x 24000</span></div>
<div class="stat"><span>POIs</span><span>""" + str(len(pois)) + """</span></div>
<div class="stat"><span>Items</span><span>""" + str(len(contents)) + """</span></div>
<div class="stat"><span>Biomes</span><span>9</span></div>
<hr style="border-color:#333;margin:6px 0;">
<div style="font-size:11px;color:#888;">
Click a marker for details<br>
Search by name or category
</div>
</div>
<div class="toggle-layer">
<button class="active" onclick="toggleLayer('composite')" id="btn-composite">&#x1F5FA; Composite</button>
<button onclick="toggleLayer('biome')" id="btn-biome">&#x1F33F; Biome</button>
<button onclick="toggleLayer('height')" id="btn-height">&#x26F0; Height</button>
</div>
<div class="legend" id="legend"></div>
<script>
const POIS = """ + pois_json + """;
const CONTENTS = """ + contents_json + """;
const CAT_COLORS = """ + cat_colors_json + """;
const CAT_LABELS = """ + cat_labels_json + """;
const CAT_COUNTS = """ + cat_counts_json + """;
const map = L.map('map', {
center: [2048, 2048],
zoom: -2,
minZoom: -3,
maxZoom: 3,
crs: L.CRS.Simple,
zoomSnap: 0.5,
zoomDelta: 0.5,
});
const bounds = [[0, 0], [4096, 4096]];
const layers = {};
layers.composite = L.imageOverlay('world_composite.png', bounds, { opacity: 1.0 }).addTo(map);
layers.biome = L.imageOverlay('world_biome.png', bounds, { opacity: 0.0 }).addTo(map);
layers.height = L.imageOverlay('world_height.png', bounds, { opacity: 0.0 }).addTo(map);
let activeLayer = 'composite';
function toggleLayer(name) {
if (name === activeLayer) return;
layers[activeLayer].setOpacity(0.0);
layers[name].setOpacity(1.0);
document.getElementById('btn-' + activeLayer).classList.remove('active');
document.getElementById('btn-' + name).classList.add('active');
activeLayer = name;
}
let markerLayer = L.layerGroup().addTo(map);
let allMarkers = [];
let activeCategories = new Set(Object.keys(CAT_COUNTS));
function createMarkers() {
markerLayer.clearLayers();
allMarkers = [];
POIS.forEach(function(p) {
if (!activeCategories.has(p.category)) return;
const color = CAT_COLORS[p.category] || '#888';
const icon = L.divIcon({
className: '',
html: '<div style="width:10px;height:10px;border-radius:50%;background:' + color + ';border:2px solid rgba(255,255,255,0.5);box-shadow:0 0 4px rgba(0,0,0,0.5);"></div>',
iconSize: [10, 10],
iconAnchor: [5, 5],
});
const marker = L.marker([p.py, p.px], { icon: icon }).addTo(markerLayer);
let popup = '<div class="popup-content">';
popup += '<div class="name">' + p.name + '</div>';
popup += '<div class="coords">(' + p.x + ', ' + p.z + ')</div>';
popup += '<div style="color:' + color + ';">' + p.category + '</div>';
if (p.has_dungeon) popup += '<span class="badge badge-dungeon">Dungeon</span>';
if (p.has_important) popup += '<span class="badge badge-treasure">Has Loot</span>';
const items = CONTENTS.filter(function(c) { return c.loc_id === p.id; });
if (items.length > 0) {
popup += '<hr style="border-color:#333;margin:4px 0;"><div style="font-size:11px;">';
for (let i = 0; i < Math.min(5, items.length); i++) {
popup += '<div>&bull; ' + items[i].item + ' x' + items[i].count + '</div>';
}
if (items.length > 5) {
popup += '<div style="color:#888;">... +' + (items.length - 5) + ' more</div>';
}
popup += '</div>';
}
popup += '</div>';
marker.bindPopup(popup);
allMarkers.push({ marker: marker, p: p });
});
}
function buildLegend() {
const legend = document.getElementById('legend');
const order = ['boss', 'dungeon', 'vegvisir', 'vendor', 'resource', 'altar', 'structure', 'spawner', 'point_of_interest', 'other'];
let html = '';
order.forEach(function(cat) {
if (!CAT_COUNTS[cat]) return;
const color = CAT_COLORS[cat] || '#888';
const active = activeCategories.has(cat) ? 'active' : 'inactive';
html += '<div class="legend-item ' + active + '" onclick="toggleCategory(\\'' + cat + '\\')">';
html += '<span class="dot" style="background:' + color + '"></span>';
html += '<span>' + (CAT_LABELS[cat] || cat) + '</span>';
html += '<span class="count">' + CAT_COUNTS[cat] + '</span></div>';
});
legend.innerHTML = html;
}
function toggleCategory(cat) {
if (activeCategories.has(cat)) {
activeCategories.delete(cat);
} else {
activeCategories.add(cat);
}
createMarkers();
buildLegend();
}
function doSearch(query) {
const results = document.getElementById('searchResults');
if (!query || query.length < 2) { results.style.display = 'none'; return; }
const q = query.toLowerCase();
const matches = POIS.filter(function(p) {
return p.name.toLowerCase().indexOf(q) !== -1 || p.category.toLowerCase().indexOf(q) !== -1;
}).slice(0, 20);
if (matches.length === 0) { results.style.display = 'none'; return; }
let html = '';
matches.forEach(function(p) {
html += '<div onclick="flyTo(' + p.px + ', ' + p.py + ', \\'' + p.name + '\\')">';
html += '<span style="color:' + (CAT_COLORS[p.category] || '#888') + ';">&#x25CF;</span> ';
html += p.name + ' <span class="cat">' + p.category + '</span></div>';
});
results.innerHTML = html;
results.style.display = 'block';
}
function flyTo(px, py, name) {
map.flyTo([py, px], 1, { duration: 0.5 });
document.getElementById('searchResults').style.display = 'none';
document.getElementById('search').value = name;
}
document.addEventListener('click', function(e) {
if (!e.target.closest('.search-box')) {
document.getElementById('searchResults').style.display = 'none';
}
});
createMarkers();
buildLegend();
map.fitBounds(bounds);
</script>
</body>
</html>"""
with open(VIEWER_PATH, 'w') as f:
f.write(html)
print(f"Viewer written to {VIEWER_PATH}")
if __name__ == '__main__':
print("Building Valheim map viewer...")
pois, contents = query_pois()
print(f"Loaded {len(pois)} POIs and {len(contents)} important contents")
build_viewer(pois, contents)
print("Done!")