DB addition
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
261
cursebreaker-map/static/resources.js
Normal file
261
cursebreaker-map/static/resources.js
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user