valheim-map/index.html

536 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nerdhalla — Valheim Server</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚔️</text></svg>">
<style>
:root {
--bg: #0a0a0f;
--surface: #14141f;
--surface2: #1a1a2e;
--accent: #c8a84e;
--accent2: #8b6f3a;
--text: #d4d4dc;
--text2: #8888a0;
--green: #4ade80;
--red: #f87171;
--border: #2a2a3e;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
/* Header */
.header {
background: linear-gradient(135deg, var(--surface) 0%, #0d0d1a 100%);
border-bottom: 1px solid var(--border);
padding: 1.5rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.header-icon { font-size: 2rem; }
.header h1 {
font-size: 1.5rem;
color: var(--accent);
font-weight: 700;
letter-spacing: 0.05em;
}
.header .subtitle {
font-size: 0.85rem;
color: var(--text2);
margin-top: 0.15rem;
}
.server-status {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.9rem;
}
.status-dot {
width: 10px; height: 10px;
border-radius: 50%;
display: inline-block;
}
.status-dot.online { background: var(--green); box-shadow: 0 0 8px var(--green); }
.status-dot.offline { background: var(--red); box-shadow: 0 0 8px var(--red); }
.status-dot.unknown { background: var(--text2); }
/* Nav */
.nav {
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
gap: 0;
overflow-x: auto;
}
.nav a {
padding: 0.75rem 1.5rem;
color: var(--text2);
text-decoration: none;
font-size: 0.9rem;
border-bottom: 2px solid transparent;
transition: all 0.2s;
white-space: nowrap;
}
.nav a:hover, .nav a.active {
color: var(--accent);
border-bottom-color: var(--accent);
background: rgba(200, 168, 78, 0.05);
}
/* Content */
.content { max-width: 1200px; margin: 0 auto; padding: 2rem; }
.page { display: none; }
.page.active { display: block; }
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.card h2 {
font-size: 1.1rem;
color: var(--accent);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.stat-card {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
text-align: center;
}
.stat-card .value {
font-size: 1.8rem;
font-weight: 700;
color: var(--accent);
}
.stat-card .label {
font-size: 0.8rem;
color: var(--text2);
margin-top: 0.25rem;
}
/* Map */
#map {
height: 600px;
border-radius: 8px;
border: 1px solid var(--border);
}
/* Info table */
.info-table { width: 100%; border-collapse: collapse; }
.info-table td {
padding: 0.6rem 0.8rem;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
.info-table td:first-child {
color: var(--text2);
width: 200px;
font-weight: 500;
}
.info-table tr:last-child td { border-bottom: none; }
/* Footer */
.footer {
text-align: center;
padding: 2rem;
color: var(--text2);
font-size: 0.8rem;
border-top: 1px solid var(--border);
}
.footer a { color: var(--accent); text-decoration: none; }
/* Responsive */
@media (max-width: 768px) {
.header { flex-direction: column; align-items: flex-start; }
.header-left { width: 100%; }
.server-status { width: 100%; justify-content: center; }
#map { height: 400px; }
}
</style>
</head>
<body>
<!-- Header -->
<div class="header">
<div class="header-left">
<div class="header-icon">⚔️</div>
<div>
<h1>Nerdhalla</h1>
<div class="subtitle">Valheim Community Server</div>
</div>
</div>
<div class="server-status" id="serverStatus">
<span class="status-dot unknown" id="statusDot"></span>
<span id="statusText">Checking server...</span>
</div>
</div>
<!-- Navigation -->
<nav class="nav">
<a href="#" class="active" data-page="home">🏠 Home</a>
<a href="#" data-page="map">🗺️ World Map</a>
<a href="#" data-page="world">🌍 World Info</a>
<a href="#" data-page="pois">📍 Points of Interest</a>
</nav>
<!-- Content -->
<div class="content">
<!-- Home Page -->
<div class="page active" id="page-home">
<div class="card">
<h2>🏰 Welcome to Nerdhalla</h2>
<p style="line-height:1.7; color: var(--text2);">
A vanilla Valheim community server — no mods, no BepInEx, just pure Viking survival.
Explore a hand-mapped world, find the best base locations, and conquer the tenth world together.
</p>
</div>
<div class="card-grid">
<div class="stat-card">
<div class="value" id="statSeed">yzZ5fr2tGa</div>
<div class="label">World Seed</div>
</div>
<div class="stat-card">
<div class="value" id="statBiomes">7</div>
<div class="label">Biomes</div>
</div>
<div class="stat-card">
<div class="value" id="statPOIs">11,309</div>
<div class="label">Points of Interest</div>
</div>
<div class="stat-card">
<div class="value" id="statMapSize">4,096</div>
<div class="label">Map Size (px)</div>
</div>
</div>
<div class="card">
<h2>🎯 Best Tar Pit Location</h2>
<p style="color: var(--text2); margin-bottom: 0.5rem;">
<strong style="color: var(--accent);">(1210, -4159)</strong> — 5 tar pits within 200m radius.
Prime spot for a base with easy tar access.
</p>
<p style="color: var(--text2); font-size: 0.85rem;">
Use the <a href="#" onclick="switchPage('map')" style="color: var(--accent);">World Map</a> to explore more locations.
</p>
</div>
<div class="card">
<h2>📋 Quick Links</h2>
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
<a href="#" onclick="switchPage('map')" style="background: var(--accent2); color: #fff; padding: 0.6rem 1.2rem; border-radius: 6px; text-decoration: none; font-size: 0.9rem;">🗺️ Open World Map</a>
<a href="#" onclick="switchPage('world')" style="background: var(--surface2); color: var(--text); padding: 0.6rem 1.2rem; border-radius: 6px; text-decoration: none; font-size: 0.9rem; border: 1px solid var(--border);">🌍 World Details</a>
<a href="#" onclick="switchPage('pois')" style="background: var(--surface2); color: var(--text); padding: 0.6rem 1.2rem; border-radius: 6px; text-decoration: none; font-size: 0.9rem; border: 1px solid var(--border);">📍 Browse POIs</a>
</div>
</div>
</div>
<!-- Map Page -->
<div class="page" id="page-map">
<div class="card" style="padding: 0; overflow: hidden;">
<div id="map"></div>
</div>
<div class="card" style="margin-top: 1rem;">
<h2>🗺️ Map Controls</h2>
<div style="display: flex; gap: 1rem; flex-wrap: wrap; font-size: 0.85rem; color: var(--text2);">
<span>🖱️ Click POI markers for details</span>
<span>🔍 Scroll to zoom</span>
<span>📦 Layer selector in top-right</span>
<span>🔎 Search box for locations</span>
</div>
</div>
</div>
<!-- World Info Page -->
<div class="page" id="page-world">
<div class="card">
<h2>🌍 World Settings</h2>
<table class="info-table">
<tr><td>Seed</td><td><code>yzZ5fr2tGa</code></td></tr>
<tr><td>World Name</td><td>Nerdhalla</td></tr>
<tr><td>Difficulty</td><td>Normal (combat, raids, resources)</td></tr>
<tr><td>Map Size</td><td>~10 km × 10 km (4,096 × 4,096 px tiles)</td></tr>
<tr><td>Biomes</td><td>Meadows, Black Forest, Swamp, Mountain, Plains, Ocean, Mistlands</td></tr>
<tr><td>Total POIs</td><td>11,309 locations catalogued</td></tr>
</table>
</div>
<div class="card">
<h2>📊 Biome Distribution</h2>
<div id="biomeChart" style="height: 200px; display: flex; align-items: flex-end; gap: 0.5rem; padding: 1rem 0;">
<div style="flex:1; text-align:center;">
<div style="background:#7ec850; height:120px; border-radius:4px 4px 0 0;"></div>
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Meadows</div>
</div>
<div style="flex:1; text-align:center;">
<div style="background:#2d5a1e; height:160px; border-radius:4px 4px 0 0;"></div>
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Black Forest</div>
</div>
<div style="flex:1; text-align:center;">
<div style="background:#3a3a1a; height:90px; border-radius:4px 4px 0 0;"></div>
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Swamp</div>
</div>
<div style="flex:1; text-align:center;">
<div style="background:#8b8b8b; height:140px; border-radius:4px 4px 0 0;"></div>
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Mountain</div>
</div>
<div style="flex:1; text-align:center;">
<div style="background:#c8a84e; height:100px; border-radius:4px 4px 0 0;"></div>
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Plains</div>
</div>
<div style="flex:1; text-align:center;">
<div style="background:#4a6fa5; height:180px; border-radius:4px 4px 0 0;"></div>
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Ocean</div>
</div>
<div style="flex:1; text-align:center;">
<div style="background:#5a2d7a; height:70px; border-radius:4px 4px 0 0;"></div>
<div style="font-size:0.75rem; color:var(--text2); margin-top:0.3rem;">Mistlands</div>
</div>
</div>
<p style="font-size:0.8rem; color:var(--text2); text-align:center;">Approximate — based on tile analysis</p>
</div>
</div>
<!-- POIs Page -->
<div class="page" id="page-pois">
<div class="card">
<h2>📍 Points of Interest</h2>
<p style="color: var(--text2); margin-bottom: 1rem;">
All 11,309 locations from the Nerdhalla world, searchable and categorized.
</p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem;">
<input type="text" id="poiSearch" placeholder="Search POIs..." style="flex:1; min-width:200px; padding:0.6rem; background:var(--surface2); border:1px solid var(--border); border-radius:6px; color:var(--text); font-size:0.9rem;">
<select id="poiCategory" style="padding:0.6rem; background:var(--surface2); border:1px solid var(--border); border-radius:6px; color:var(--text); font-size:0.9rem;">
<option value="">All Categories</option>
</select>
</div>
<div id="poiResults" style="max-height: 500px; overflow-y: auto;">
<p style="color: var(--text2); text-align: center; padding: 2rem;">
Loading POI data from database...
</p>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="footer">
<p>Nerdhalla — Vanilla Valheim Community Server</p>
<p style="margin-top: 0.3rem;">
<a href="http://git.sweeney.fyi:3000/aelith/valheim-map" target="_blank">Source on Forgejo</a>
&middot; Powered by <a href="https://caddyserver.com" target="_blank">Caddy</a>
&middot; Map by <a href="https://leafletjs.com" target="_blank">Leaflet</a>
</p>
</div>
<!-- Leaflet -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-search@3.0.10/dist/leaflet-search.src.js"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet-search@3.0.10/dist/leaflet-search.src.css" />
<script>
// ====== Page Navigation ======
function switchPage(name) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav a').forEach(a => a.classList.remove('active'));
document.getElementById('page-' + name).classList.add('active');
document.querySelector(`.nav a[data-page="${name}"]`).classList.add('active');
if (name === 'map' && map) setTimeout(() => map.invalidateSize(), 100);
if (name === 'pois') loadPOIs();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
document.querySelectorAll('.nav a').forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
switchPage(a.dataset.page);
});
});
// ====== Server Status ======
async function checkServer() {
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
try {
const resp = await fetch('/api/status');
const data = await resp.json();
if (data.online) {
dot.className = 'status-dot online';
text.textContent = `🟢 Online — ${data.players || 0} player(s)`;
} else {
dot.className = 'status-dot offline';
text.textContent = '🔴 Offline';
}
} catch {
dot.className = 'status-dot unknown';
text.textContent = '❓ Status unavailable';
}
}
checkServer();
setInterval(checkServer, 60000);
// ====== Map ======
let map = null;
function initMap() {
if (map) return;
map = L.map('map', {
center: [0, 0],
zoom: 1,
maxZoom: 5,
minZoom: 0,
crs: L.CRS.Simple,
zoomControl: true
});
const bounds = [[0, 0], [4096, 4096]];
// Tile layers
const composite = L.imageOverlay('world_composite.png', bounds, { opacity: 1 });
const biome = L.imageOverlay('world_biome.png', bounds, { opacity: 0.7 });
const height = L.imageOverlay('world_height.png', bounds, { opacity: 0.5 });
const baseMaps = {
"Composite": composite,
"Biome": biome,
"Height": height
};
composite.addTo(map);
map.fitBounds(bounds);
L.control.layers(baseMaps, null, { collapsed: false }).addTo(map);
// Coordinate display
const coordDisplay = L.control({ position: 'bottomleft' });
coordDisplay.onAdd = function() {
const div = L.DomUtil.create('div', 'coord-display');
div.style.cssText = 'background:rgba(20,20,31,0.9); color:#d4d4dc; padding:4px 10px; border-radius:4px; font-size:12px; border:1px solid #2a2a3e;';
div.innerHTML = 'Move cursor for coordinates';
return div;
};
coordDisplay.addTo(map);
map.on('mousemove', function(e) {
const x = Math.round(e.latlng.lng);
const y = Math.round(e.latlng.lat);
document.querySelector('.coord-display').innerHTML = `📍 (${x}, ${y})`;
});
// POI markers from database
fetch('/api/pois?limit=500')
.then(r => r.json())
.then(pois => {
const poiIcons = {
'point_of_interest': L.divIcon({ html: '📍', className: 'poi-icon', iconSize: [20, 20] }),
'boss': L.divIcon({ html: '👑', className: 'poi-icon', iconSize: [20, 20] }),
'dungeon': L.divIcon({ html: '🏚️', className: 'poi-icon', iconSize: [20, 20] }),
'resource': L.divIcon({ html: '⛏️', className: 'poi-icon', iconSize: [20, 20] }),
'structure': L.divIcon({ html: '🏠', className: 'poi-icon', iconSize: [20, 20] }),
'spawner': L.divIcon({ html: '👾', className: 'poi-icon', iconSize: [20, 20] }),
'altar': L.divIcon({ html: '🪦', className: 'poi-icon', iconSize: [20, 20] }),
'vendor': L.divIcon({ html: '🏪', className: 'poi-icon', iconSize: [20, 20] }),
'other': L.divIcon({ html: '❓', className: 'poi-icon', iconSize: [20, 20] }),
'default': L.divIcon({ html: '📍', className: 'poi-icon', iconSize: [20, 20] })
};
const markerLayer = L.layerGroup();
pois.forEach(p => {
const icon = poiIcons[p.category] || poiIcons['default'];
const m = L.marker([p.y, p.x], { icon }).bindPopup(
`<b>${p.prefab || 'Unknown'}</b><br>📍 (${Math.round(p.x)}, ${Math.round(p.y)})<br>📂 ${p.category || 'Unknown'}`
);
markerLayer.addLayer(m);
});
markerLayer.addTo(map);
L.control.layers(null, { "POIs (11k+)": markerLayer }, { collapsed: true }).addTo(map);
});
}
// Init map when page loads or switches to map
document.addEventListener('DOMContentLoaded', () => {
// Defer map init until tab is clicked
});
document.querySelector('.nav a[data-page="map"]').addEventListener('click', () => {
setTimeout(initMap, 200);
});
// ====== POI Browser ======
let allPOIs = [];
async function loadPOIs() {
const results = document.getElementById('poiResults');
if (allPOIs.length > 0) return renderPOIs();
try {
const resp = await fetch('/api/pois?limit=11309');
allPOIs = await resp.json();
// Populate categories
const cats = new Set(allPOIs.map(p => p.category).filter(Boolean));
const sel = document.getElementById('poiCategory');
cats.forEach(c => {
const opt = document.createElement('option');
opt.value = c;
opt.textContent = c.replace(/_/g, ' ');
sel.appendChild(opt);
});
renderPOIs();
} catch {
results.innerHTML = '<p style="color: var(--red); text-align: center; padding: 2rem;">Failed to load POIs</p>';
}
}
function renderPOIs() {
const search = document.getElementById('poiSearch').value.toLowerCase();
const cat = document.getElementById('poiCategory').value;
const filtered = allPOIs.filter(p => {
const name = (p.prefab || '').toLowerCase();
const catVal = (p.category || '');
return name.includes(search) && (!cat || catVal === cat);
});
const results = document.getElementById('poiResults');
if (filtered.length === 0) {
results.innerHTML = '<p style="color: var(--text2); text-align: center; padding: 2rem;">No matching POIs</p>';
return;
}
results.innerHTML = filtered.slice(0, 200).map(p => `
<div style="padding:0.5rem; border-bottom:1px solid var(--border); display:flex; justify-content:space-between; align-items:center;">
<span><strong>${p.prefab || 'Unknown'}</strong> <span style="color:var(--text2);font-size:0.8rem;">(${Math.round(p.x)}, ${Math.round(p.y)})</span></span>
<span style="color:var(--text2);font-size:0.8rem;">${(p.category || '').replace(/_/g, ' ')}</span>
</div>
`).join('');
if (filtered.length > 200) {
results.innerHTML += `<p style="color:var(--text2);text-align:center;padding:0.5rem;font-size:0.8rem;">Showing 200 of ${filtered.length} results</p>`;
}
}
document.getElementById('poiSearch').addEventListener('input', renderPOIs);
document.getElementById('poiCategory').addEventListener('change', renderPOIs);
</script>
</body>
</html>