// Map visualization with pan, zoom, and interactive features
let canvas, ctx;
let mapData = { locations: [], connections: [], interactables: [], spawn_tables: {} };
let viewOffset = { x: 0, y: 0 };
let viewScale = 1.0;
let isDragging = false;
let lastMouse = { x: 0, y: 0 };
let showLabels = true;
let selectedLocation = null;
// Visual settings
const gridSize = 100; // pixels per game unit
const nodeRadius = 12;
const colors = {
background: '#0a0a1a',
grid: '#1a1a2e',
connection: '#3a3a6a',
nodeSafe: '#4fc3f7',
nodeLowDanger: '#ffa726',
nodeMediumDanger: '#ff7043',
nodeHighDanger: '#e53935',
nodeSelected: '#00ff88',
text: '#ffffff',
label: '#e0e0e0'
};
// Initialize
window.addEventListener('load', async () => {
canvas = document.getElementById('mapCanvas');
ctx = canvas.getContext('2d');
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// Load map data
await loadMapData();
// Event listeners
canvas.addEventListener('mousedown', onMouseDown);
canvas.addEventListener('mousemove', onMouseMove);
canvas.addEventListener('mouseup', onMouseUp);
canvas.addEventListener('mouseleave', onMouseUp);
canvas.addEventListener('wheel', onWheel);
canvas.addEventListener('click', onClick);
// Touch event listeners for mobile
canvas.addEventListener('touchstart', onTouchStart, { passive: false });
canvas.addEventListener('touchmove', onTouchMove, { passive: false });
canvas.addEventListener('touchend', onTouchEnd);
document.getElementById('zoomIn').addEventListener('click', () => zoom(1.2));
document.getElementById('zoomOut').addEventListener('click', () => zoom(0.8));
document.getElementById('resetView').addEventListener('click', resetView);
document.getElementById('toggleLabels').addEventListener('click', toggleLabels);
// Image modal setup
setupImageModal();
draw();
});
function resizeCanvas() {
canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
draw();
}
async function loadMapData() {
try {
const response = await fetch('/map_data.json');
mapData = await response.json();
centerView();
updateStatistics();
draw();
} catch (error) {
console.error('Failed to load map data:', error);
}
}
function updateStatistics() {
document.getElementById('totalLocations').textContent = mapData.locations.length;
document.getElementById('totalConnections').textContent = mapData.connections.length;
document.getElementById('totalInteractables').textContent = mapData.interactables.length;
const uniqueEnemies = new Set();
Object.values(mapData.spawn_tables).forEach(enemies => {
enemies.forEach(enemy => uniqueEnemies.add(enemy.npc_id));
});
document.getElementById('totalEnemies').textContent = uniqueEnemies.size;
}
function worldToScreen(x, y) {
return {
x: (x * gridSize * viewScale) + viewOffset.x + canvas.width / 2,
y: (-y * gridSize * viewScale) + viewOffset.y + canvas.height / 2
};
}
function screenToWorld(sx, sy) {
return {
x: ((sx - canvas.width / 2 - viewOffset.x) / gridSize) / viewScale,
y: -((sy - canvas.height / 2 - viewOffset.y) / gridSize) / viewScale
};
}
function centerView() {
if (mapData.locations.length === 0) return;
// Calculate bounds
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
mapData.locations.forEach(loc => {
minX = Math.min(minX, loc.x);
maxX = Math.max(maxX, loc.x);
minY = Math.min(minY, loc.y);
maxY = Math.max(maxY, loc.y);
});
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
viewOffset.x = -centerX * gridSize * viewScale;
viewOffset.y = centerY * gridSize * viewScale;
}
function resetView() {
viewScale = 1.0;
centerView();
draw();
}
function toggleLabels() {
showLabels = !showLabels;
draw();
}
function zoom(factor) {
const oldScale = viewScale;
viewScale *= factor;
viewScale = Math.max(0.3, Math.min(3.0, viewScale));
const scaleDiff = viewScale - oldScale;
viewOffset.x -= (canvas.width / 2) * scaleDiff / oldScale;
viewOffset.y -= (canvas.height / 2) * scaleDiff / oldScale;
draw();
}
function onMouseDown(e) {
isDragging = true;
lastMouse = { x: e.clientX, y: e.clientY };
canvas.style.cursor = 'grabbing';
}
function onMouseMove(e) {
if (isDragging) {
const dx = e.clientX - lastMouse.x;
const dy = e.clientY - lastMouse.y;
viewOffset.x += dx;
viewOffset.y += dy;
lastMouse = { x: e.clientX, y: e.clientY };
draw();
}
}
function onMouseUp() {
isDragging = false;
canvas.style.cursor = 'grab';
}
// Touch event handlers for mobile
let lastTouch = { x: 0, y: 0 };
let touchStartTime = 0;
let lastTouchDistance = 0;
function onTouchStart(e) {
e.preventDefault();
if (e.touches.length === 1) {
// Single touch - pan
isDragging = true;
const touch = e.touches[0];
lastTouch = { x: touch.clientX, y: touch.clientY };
lastMouse = { x: touch.clientX, y: touch.clientY };
touchStartTime = Date.now();
} else if (e.touches.length === 2) {
// Two touches - pinch zoom
isDragging = false;
const touch1 = e.touches[0];
const touch2 = e.touches[1];
lastTouchDistance = Math.sqrt(
Math.pow(touch2.clientX - touch1.clientX, 2) +
Math.pow(touch2.clientY - touch1.clientY, 2)
);
}
}
function onTouchMove(e) {
e.preventDefault();
if (e.touches.length === 1 && isDragging) {
// Single touch - pan
const touch = e.touches[0];
const dx = touch.clientX - lastTouch.x;
const dy = touch.clientY - lastTouch.y;
viewOffset.x += dx;
viewOffset.y += dy;
lastTouch = { x: touch.clientX, y: touch.clientY };
draw();
} else if (e.touches.length === 2) {
// Two touches - pinch zoom
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const newDistance = Math.sqrt(
Math.pow(touch2.clientX - touch1.clientX, 2) +
Math.pow(touch2.clientY - touch1.clientY, 2)
);
if (lastTouchDistance > 0) {
const zoomFactor = newDistance / lastTouchDistance;
// Calculate center point between two touches
const centerX = (touch1.clientX + touch2.clientX) / 2;
const centerY = (touch1.clientY + touch2.clientY) / 2;
const rect = canvas.getBoundingClientRect();
const mouseX = centerX - rect.left;
const mouseY = centerY - rect.top;
const worldBefore = screenToWorld(mouseX, mouseY);
zoom(zoomFactor);
const worldAfter = screenToWorld(mouseX, mouseY);
viewOffset.x += (worldAfter.x - worldBefore.x) * gridSize * viewScale;
viewOffset.y -= (worldAfter.y - worldBefore.y) * gridSize * viewScale;
}
lastTouchDistance = newDistance;
draw();
}
}
function onTouchEnd(e) {
if (e.touches.length === 0) {
isDragging = false;
lastTouchDistance = 0;
// Check if it was a quick tap (< 200ms) for click action
const touchDuration = Date.now() - touchStartTime;
if (touchDuration < 200 && e.changedTouches.length === 1) {
const touch = e.changedTouches[0];
// Simulate click event
const clickEvent = {
clientX: touch.clientX,
clientY: touch.clientY
};
onClick(clickEvent);
}
} else if (e.touches.length === 1) {
// One finger remaining, reset for pan
const touch = e.touches[0];
lastTouch = { x: touch.clientX, y: touch.clientY };
isDragging = true;
lastTouchDistance = 0;
}
}
function onWheel(e) {
e.preventDefault();
const zoomSpeed = 0.001;
const delta = -e.deltaY * zoomSpeed;
const factor = 1 + delta;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const worldBefore = screenToWorld(mouseX, mouseY);
zoom(factor);
const worldAfter = screenToWorld(mouseX, mouseY);
viewOffset.x += (worldAfter.x - worldBefore.x) * gridSize * viewScale;
viewOffset.y -= (worldAfter.y - worldBefore.y) * gridSize * viewScale;
draw();
}
function onClick(e) {
if (isDragging) return;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const worldPos = screenToWorld(mouseX, mouseY);
// Find clicked location
for (const location of mapData.locations) {
const dist = Math.sqrt(
Math.pow(location.x - worldPos.x, 2) +
Math.pow(location.y - worldPos.y, 2)
);
if (dist < 0.3) { // Click threshold
selectedLocation = location;
showLocationInfo(location);
draw();
return;
}
}
}
function getDangerLevel(locationId) {
const spawns = mapData.spawn_tables[locationId];
if (!spawns || spawns.length === 0) return 'safe';
// Calculate average enemy strength
let totalXP = 0;
spawns.forEach(enemy => {
totalXP += enemy.xp_reward * (enemy.spawn_weight / 100);
});
if (totalXP < 20) return 'low';
if (totalXP < 40) return 'medium';
return 'high';
}
function getNodeColor(locationId) {
const danger = getDangerLevel(locationId);
switch (danger) {
case 'safe': return colors.nodeSafe;
case 'low': return colors.nodeLowDanger;
case 'medium': return colors.nodeMediumDanger;
case 'high': return colors.nodeHighDanger;
default: return colors.nodeSafe;
}
}
function draw() {
// Clear canvas
ctx.fillStyle = colors.background;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw grid
drawGrid();
// Draw connections
ctx.lineWidth = 2 * viewScale;
mapData.connections.forEach(conn => {
const from = mapData.locations.find(l => l.id === conn.from);
const to = mapData.locations.find(l => l.id === conn.to);
if (from && to) {
const fromScreen = worldToScreen(from.x, from.y);
const toScreen = worldToScreen(to.x, to.y);
ctx.strokeStyle = colors.connection;
ctx.beginPath();
ctx.moveTo(fromScreen.x, fromScreen.y);
ctx.lineTo(toScreen.x, toScreen.y);
ctx.stroke();
// Draw distance label
if (showLabels && viewScale > 0.6) {
const midX = (fromScreen.x + toScreen.x) / 2;
const midY = (fromScreen.y + toScreen.y) / 2;
const stamina = Math.ceil(conn.distance * 3);
ctx.fillStyle = 'rgba(42, 42, 74, 0.9)';
ctx.fillRect(midX - 20, midY - 10, 40, 20);
ctx.fillStyle = colors.label;
ctx.font = `${10 * viewScale}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${stamina}β‘`, midX, midY);
}
}
});
// Draw locations
mapData.locations.forEach(location => {
const screen = worldToScreen(location.x, location.y);
const radius = nodeRadius * viewScale;
// Node circle
const isSelected = selectedLocation && selectedLocation.id === location.id;
ctx.fillStyle = isSelected ? colors.nodeSelected : getNodeColor(location.id);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = isSelected ? 3 * viewScale : 2 * viewScale;
ctx.beginPath();
ctx.arc(screen.x, screen.y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Interactable indicator
if (location.interactable_count > 0) {
ctx.fillStyle = '#ffd700';
ctx.font = `${8 * viewScale}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(location.interactable_count, screen.x, screen.y);
}
// Label
if (showLabels) {
ctx.fillStyle = colors.label;
ctx.font = `${12 * viewScale}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(location.name, screen.x, screen.y + radius + 5);
}
});
}
function drawGrid() {
ctx.strokeStyle = colors.grid;
ctx.lineWidth = 1;
const step = gridSize * viewScale;
const startX = (viewOffset.x % step) - step;
const startY = (viewOffset.y % step) - step;
for (let x = startX; x < canvas.width; x += step) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
}
for (let y = startY; y < canvas.height; y += step) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();
}
}
function showLocationInfo(location) {
// Location details
const locationHTML = `
${location.image_path ?
`` :
`
Coordinates: (${location.x}, ${location.y})
Interactables: ${location.interactable_count}
No interactables at this location
'; } // Enemies const enemies = mapData.spawn_tables[location.id]; if (enemies && enemies.length > 0) { const enemiesHTML = `Encounter Rate: ${enemies[0].encounter_rate}% when traveling
β Safe zone - no enemies spawn here
'; } } // Image Modal Functions function setupImageModal() { const modal = document.getElementById('imageModal'); const closeBtn = document.querySelector('.image-modal-close'); // Close modal when clicking close button, backdrop, or pressing Escape closeBtn.addEventListener('click', closeImageModal); modal.addEventListener('click', (e) => { if (e.target === modal) { closeImageModal(); } }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.classList.contains('active')) { closeImageModal(); } }); } function openImageModal(imageSrc, imageTitle) { const modal = document.getElementById('imageModal'); const modalImg = document.getElementById('modalImage'); const modalInfo = document.getElementById('modalInfo'); modalImg.src = imageSrc; modalImg.alt = imageTitle; modalInfo.textContent = imageTitle + ' - Click anywhere to close'; modal.classList.add('active'); // Prevent body scrolling when modal is open document.body.style.overflow = 'hidden'; } function closeImageModal() { const modal = document.getElementById('imageModal'); modal.classList.remove('active'); // Re-enable body scrolling document.body.style.overflow = 'auto'; }