DB addition

This commit is contained in:
2026-01-16 09:33:30 +00:00
parent 3720b6ad80
commit 642ba643ad
25 changed files with 1037 additions and 416 deletions

View File

@@ -14,6 +14,7 @@ diesel = { version = "2.1", features = ["sqlite", "returning_clauses_for_sqlite_
dotenvy = "0.15"
tracing = "0.1"
tracing-subscriber = "0.3"
base64 = "0.22"
[dependencies.cursebreaker-parser]
path = "../cursebreaker-parser"

View File

@@ -5,6 +5,7 @@ use axum::{
routing::get,
Json, Router,
};
use base64::Engine;
use diesel::prelude::*;
use serde::Serialize;
use std::sync::Arc;
@@ -27,6 +28,27 @@ struct MapBounds {
max_y: i32,
}
#[derive(Serialize)]
struct ResourceResponse {
resources: Vec<ResourceGroup>,
}
#[derive(Serialize)]
struct ResourceGroup {
item_id: i32,
name: String,
skill: String,
level: i32,
icon_base64: String,
positions: Vec<Position>,
}
#[derive(Serialize)]
struct Position {
x: f32,
y: f32,
}
// Establish database connection
fn establish_connection(database_url: &str) -> Result<DbConnection, diesel::ConnectionError> {
SqliteConnection::establish(database_url)
@@ -111,6 +133,74 @@ async fn get_tile(
}
}
// Get all resources with icons from database
async fn get_resources(
State(state): State<Arc<AppState>>,
) -> Result<Json<ResourceResponse>, StatusCode> {
use cursebreaker_parser::schema::{harvestables, resource_icons, world_resources};
let mut conn = establish_connection(&state.database_url).map_err(|e| {
tracing::error!("Database connection error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Query with three-way join
let results = world_resources::table
.inner_join(
resource_icons::table.on(world_resources::item_id.eq(resource_icons::item_id)),
)
.inner_join(harvestables::table.on(resource_icons::item_id.eq(harvestables::id)))
.select((
resource_icons::item_id,
resource_icons::name,
harvestables::skill,
harvestables::level,
resource_icons::icon_64,
world_resources::pos_x,
world_resources::pos_y,
))
.order_by((harvestables::skill, harvestables::level))
.load::<(i32, String, String, i32, Vec<u8>, f32, f32)>(&mut conn)
.map_err(|e| {
tracing::error!("Error querying resources: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Group results by item_id
use std::collections::HashMap;
let mut grouped: HashMap<i32, ResourceGroup> = HashMap::new();
for (item_id, name, skill, level, icon_bytes, pos_x, pos_y) in results {
let entry = grouped.entry(item_id).or_insert_with(|| {
// Convert icon to base64 (only once per resource type)
let icon_base64 = base64::engine::general_purpose::STANDARD.encode(&icon_bytes);
ResourceGroup {
item_id,
name,
skill,
level,
icon_base64,
positions: Vec::new(),
}
});
// Add position with multiplier applied
entry.positions.push(Position {
x: pos_x * 5.12,
y: pos_y * 5.12,
});
}
// Convert to vec and sort by skill and level
let mut resources: Vec<ResourceGroup> = grouped.into_values().collect();
resources.sort_by(|a, b| a.skill.cmp(&b.skill).then(a.level.cmp(&b.level)));
info!("Returning {} resource types", resources.len());
Ok(Json(ResourceResponse { resources }))
}
#[tokio::main]
async fn main() {
// Initialize tracing
@@ -128,6 +218,7 @@ async fn main() {
let app = Router::new()
.route("/api/bounds", get(get_bounds))
.route("/api/tiles/:z/:x/:y", get(get_tile))
.route("/api/resources", get(get_resources))
.nest_service("/", ServeDir::new("static"))
.layer(CorsLayer::permissive())
.with_state(state);

View File

@@ -10,7 +10,7 @@ const MapConfig = {
// Leaflet zoom 1 → Database zoom 1 (2x2 merged)
{ leafletZoom: -0.5, dbZoom: 1, mergeFactor: 2, label: "2x2 merged" },
// Leaflet zoom 2+ → Database zoom 2 (original tiles)
{ leafletZoom: 1.5, dbZoom: 2, mergeFactor: 1, label: "original" },
{ leafletZoom: 1, dbZoom: 2, mergeFactor: 1, label: "original" },
],
// Leaflet map settings
@@ -23,6 +23,9 @@ const MapConfig = {
// Debug mode - shows tile boundaries and coordinates
debug: true,
// Resource icon configuration
resourceIconSize: 48, // Icon size in pixels (configurable)
// Get zoom configuration for a specific Leaflet zoom level
getZoomConfig(leafletZoom) {
// Find the appropriate config for this zoom level

View File

@@ -15,7 +15,6 @@
<div id="app">
<!-- Sidebar -->
<div id="sidebar" class="sidebar collapsed">
<button id="toggle-sidebar" class="toggle-btn"></button>
<div class="sidebar-content">
<h2>Cursebreaker Map</h2>
<div class="info-section">
@@ -23,15 +22,13 @@
</div>
<div class="filters-section">
<h3>Filters</h3>
<p class="coming-soon">Coming soon: Filter shops, resources, and more</p>
<!-- Placeholder for future filters -->
<div class="filter-group" style="opacity: 0.5; pointer-events: none;">
<label><input type="checkbox" checked disabled> Shops</label>
<label><input type="checkbox" checked disabled> Resources</label>
<label><input type="checkbox" checked disabled> Fast Travel</label>
<label><input type="checkbox" checked disabled> Workbenches</label>
<h3>Resources</h3>
<div class="filter-controls">
<button id="select-all-resources" class="filter-btn">Show All</button>
<button id="deselect-all-resources" class="filter-btn">Hide All</button>
</div>
<div id="resource-filters" class="filter-group">
<p class="loading-text">Loading resources...</p>
</div>
</div>
@@ -61,5 +58,6 @@
<!-- Custom JS -->
<script src="map.js"></script>
<script src="resources.js"></script>
</body>
</html>

View File

@@ -86,10 +86,40 @@ async function initMap() {
L.control.attribution({
position: 'bottomright',
prefix: false
}).addHTML('The Black Grimoire: Cursebreaker').addTo(map);
}).addAttribution('The Black Grimoire: Cursebreaker').addTo(map);
// Add sidebar toggle control
const SidebarControl = L.Control.extend({
options: {
position: 'topleft'
},
onAdd: function(map) {
const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control');
const button = L.DomUtil.create('a', 'leaflet-control-sidebar', container);
button.innerHTML = '☰';
button.href = '#';
button.title = 'Toggle Sidebar';
L.DomEvent.on(button, 'click', function(e) {
L.DomEvent.preventDefault(e);
const sidebar = document.getElementById('sidebar');
sidebar.classList.toggle('collapsed');
});
return container;
}
});
map.addControl(new SidebarControl());
console.log('Map initialized successfully');
// Load resources asynchronously
loadResources().catch(error => {
console.error('Failed to load resources:', error);
});
} catch (error) {
console.error('Error initializing map:', error);
document.getElementById('map-stats').innerHTML =
@@ -236,11 +266,5 @@ function updateMapInfo(bounds) {
`;
}
// Toggle sidebar
document.getElementById('toggle-sidebar').addEventListener('click', function() {
const sidebar = document.getElementById('sidebar');
sidebar.classList.toggle('collapsed');
});
// Initialize map when page loads
window.addEventListener('DOMContentLoaded', initMap);

View File

@@ -0,0 +1,261 @@
// Resource management for Cursebreaker map
let resourceLayerGroups = {}; // Map: resource name -> L.layerGroup
let resourceIcons = {}; // Map: resource name -> L.icon
let resourceData = {}; // Map: resource name -> resource metadata (skill, level, etc.)
let filterState = {}; // Map: resource name -> boolean (visible)
// Load resources from API
async function loadResources() {
try {
console.log('Loading resources from API...');
const response = await fetch('/api/resources');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log(`Received ${data.resources.length} resource types`);
// Create icons and layer groups for each resource
for (const group of data.resources) {
createResourceGroup(group);
}
// Initialize filter UI
initializeFilterUI();
// Restore filter state from localStorage
restoreFilterState();
console.log(`Loaded ${data.resources.length} resource types successfully`);
} catch (error) {
console.error('Error loading resources:', error);
const container = document.getElementById('resource-filters');
if (container) {
container.innerHTML = '<p style="color: #ff6b6b;">Failed to load resources. Check console for details.</p>';
}
}
}
// Create resource group with icon and markers
function createResourceGroup(group) {
const config = window.MapConfig;
// Create icon definition (cached per resource type)
const iconUrl = `data:image/webp;base64,${group.icon_base64}`;
const icon = L.icon({
iconUrl: iconUrl,
iconSize: [config.resourceIconSize, config.resourceIconSize],
iconAnchor: [config.resourceIconSize / 2, config.resourceIconSize / 2],
popupAnchor: [0, -(config.resourceIconSize / 2)],
});
resourceIcons[group.name] = icon;
// Store metadata
resourceData[group.name] = {
item_id: group.item_id,
skill: group.skill,
level: group.level,
};
// Create layer group for this resource type
const layerGroup = L.layerGroup();
// Add markers for all positions
for (const pos of group.positions) {
const marker = L.marker([pos.y, pos.x], {
icon: icon,
title: group.name,
});
// Add popup with resource details
marker.bindPopup(
`<strong>${group.name}</strong><br/>Position: (${pos.x.toFixed(1)}, ${pos.y.toFixed(1)})`
);
marker.addTo(layerGroup);
}
// Add to map (initially visible)
layerGroup.addTo(map);
resourceLayerGroups[group.name] = layerGroup;
filterState[group.name] = true; // Initially visible
}
// Initialize filter UI with skill grouping
function initializeFilterUI() {
const container = document.getElementById('resource-filters');
if (!container) {
console.error('resource-filters container not found');
return;
}
container.innerHTML = ''; // Clear loading text
// Group resources by skill
const skillGroups = {};
for (const name in resourceLayerGroups) {
const metadata = resourceData[name];
if (!skillGroups[metadata.skill]) {
skillGroups[metadata.skill] = [];
}
skillGroups[metadata.skill].push({
name: name,
level: metadata.level,
});
}
// Sort skills alphabetically
const sortedSkills = Object.keys(skillGroups).sort();
// Create UI for each skill group
for (const skill of sortedSkills) {
const skillDiv = document.createElement('div');
skillDiv.className = 'skill-group';
const header = document.createElement('div');
header.className = 'skill-header';
// Capitalize first letter of skill
header.textContent = skill.charAt(0).toUpperCase() + skill.slice(1);
skillDiv.appendChild(header);
// Resources are already sorted by level in backend, but sort again to be sure
skillGroups[skill].sort((a, b) => a.level - b.level);
// Create checkbox for each resource
for (const resource of skillGroups[skill]) {
const label = createFilterLabel(resource.name);
skillDiv.appendChild(label);
}
container.appendChild(skillDiv);
}
// Attach bulk filter handlers
const selectAllBtn = document.getElementById('select-all-resources');
const deselectAllBtn = document.getElementById('deselect-all-resources');
if (selectAllBtn) {
selectAllBtn.addEventListener('click', () => {
setAllFilters(true);
});
}
if (deselectAllBtn) {
deselectAllBtn.addEventListener('click', () => {
setAllFilters(false);
});
}
}
// Create filter label with checkbox and icon
function createFilterLabel(resourceName) {
const label = document.createElement('label');
label.className = 'filter-label';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = filterState[resourceName];
checkbox.dataset.resource = resourceName;
checkbox.addEventListener('change', handleFilterChange);
const icon = document.createElement('img');
icon.src = resourceIcons[resourceName].options.iconUrl;
icon.className = 'filter-icon';
icon.alt = resourceName;
const text = document.createElement('span');
text.textContent = resourceName;
label.appendChild(checkbox);
label.appendChild(icon);
label.appendChild(text);
return label;
}
// Handle filter checkbox change
function handleFilterChange(event) {
const resourceName = event.target.dataset.resource;
const isVisible = event.target.checked;
filterState[resourceName] = isVisible;
// Show/hide layer group
const layerGroup = resourceLayerGroups[resourceName];
if (isVisible) {
layerGroup.addTo(map);
} else {
map.removeLayer(layerGroup);
}
// Persist state
saveFilterState();
}
// Set all filters to visible or hidden
function setAllFilters(visible) {
for (const name in filterState) {
filterState[name] = visible;
const layerGroup = resourceLayerGroups[name];
if (visible) {
layerGroup.addTo(map);
} else {
map.removeLayer(layerGroup);
}
}
// Update checkboxes
document.querySelectorAll('#resource-filters input[type="checkbox"]').forEach((cb) => {
cb.checked = visible;
});
saveFilterState();
}
// Save filter state to localStorage
function saveFilterState() {
try {
localStorage.setItem('cursebreaker_resource_filters', JSON.stringify(filterState));
} catch (error) {
console.warn('Failed to save filter state to localStorage:', error);
}
}
// Restore filter state from localStorage
function restoreFilterState() {
const saved = localStorage.getItem('cursebreaker_resource_filters');
if (!saved) return;
try {
const savedState = JSON.parse(saved);
for (const name in savedState) {
if (resourceLayerGroups[name]) {
filterState[name] = savedState[name];
const layerGroup = resourceLayerGroups[name];
if (!savedState[name]) {
map.removeLayer(layerGroup);
}
}
}
// Update checkboxes after UI is created
setTimeout(() => {
document.querySelectorAll('#resource-filters input[type="checkbox"]').forEach((cb) => {
const name = cb.dataset.resource;
if (filterState[name] !== undefined) {
cb.checked = filterState[name];
}
});
}, 100);
console.log('Restored filter state from localStorage');
} catch (error) {
console.warn('Failed to restore filter state:', error);
}
}

View File

@@ -32,24 +32,17 @@ body {
margin-left: -320px;
}
.toggle-btn {
position: absolute;
right: -40px;
top: 10px;
width: 40px;
height: 40px;
background: #2a2a2a;
border: none;
border-radius: 0 5px 5px 0;
color: #e0e0e0;
font-size: 20px;
cursor: pointer;
transition: background 0.2s;
z-index: 1001;
}
.toggle-btn:hover {
background: #3a3a3a;
/* Sidebar toggle control */
.leaflet-control-sidebar {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
line-height: 30px;
text-align: center;
text-decoration: none;
}
.sidebar-content {
@@ -176,6 +169,96 @@ body {
font-weight: bold;
}
/* Filter controls */
.filter-controls {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.filter-btn {
flex: 1;
padding: 6px 12px;
background: #3a3a3a;
color: #e0e0e0;
border: 1px solid #4a4a4a;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background 0.2s;
}
.filter-btn:hover {
background: #4a4a4a;
}
.filter-btn:active {
background: #2a2a2a;
}
/* Filter groups by skill */
.skill-group {
margin-bottom: 12px;
}
.skill-header {
color: #8b5cf6;
font-size: 13px;
font-weight: bold;
margin-bottom: 4px;
padding: 4px 8px;
background: rgba(139, 92, 246, 0.1);
border-radius: 3px;
}
/* Filter items */
.filter-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
margin-left: 8px;
border-radius: 4px;
transition: background 0.2s;
font-size: 13px;
}
.filter-label:hover {
background: #3a3a3a;
}
.filter-icon {
width: 20px;
height: 20px;
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
}
.loading-text {
color: #a0a0a0;
font-style: italic;
font-size: 13px;
padding: 8px;
}
/* Dark theme popups */
.leaflet-popup-content-wrapper {
background: #2a2a2a;
color: #e0e0e0;
border: 1px solid #3a3a3a;
}
.leaflet-popup-content {
margin: 8px 12px;
font-size: 13px;
}
.leaflet-popup-tip {
background: #2a2a2a;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {