534 lines
16 KiB
JavaScript
534 lines
16 KiB
JavaScript
/**
|
|
* Cut Manager Module
|
|
* Handles cut CRUD operations and display functionality
|
|
*/
|
|
|
|
import { showStatus } from './utils.js';
|
|
|
|
export class CutManager {
|
|
constructor() {
|
|
this.cuts = [];
|
|
this.currentCut = null;
|
|
this.currentCutLayer = null;
|
|
this.map = null;
|
|
this.isInitialized = false;
|
|
|
|
// Add support for multiple cuts
|
|
this.displayedCuts = new Map(); // Track multiple displayed cuts
|
|
this.cutLayers = new Map(); // Track cut layers by ID
|
|
}
|
|
|
|
/**
|
|
* Initialize the cut manager
|
|
*/
|
|
async initialize(map) {
|
|
this.map = map;
|
|
this.isInitialized = true;
|
|
|
|
// Load public cuts for display
|
|
await this.loadPublicCuts();
|
|
|
|
console.log('Cut manager initialized');
|
|
}
|
|
|
|
/**
|
|
* Load all cuts (admin) or public cuts (users)
|
|
*/
|
|
async loadCuts(adminMode = false) {
|
|
try {
|
|
const endpoint = adminMode ? '/api/cuts' : '/api/cuts/public';
|
|
const response = await fetch(endpoint, {
|
|
credentials: 'include'
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load cuts: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
this.cuts = data.list || [];
|
|
|
|
console.log(`Loaded ${this.cuts.length} cuts`);
|
|
return this.cuts;
|
|
} catch (error) {
|
|
console.error('Error loading cuts:', error);
|
|
showStatus('Failed to load cuts', 'error');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load public cuts for map display
|
|
*/
|
|
async loadPublicCuts() {
|
|
return await this.loadCuts(false);
|
|
}
|
|
|
|
/**
|
|
* Get single cut by ID
|
|
*/
|
|
async getCut(id) {
|
|
try {
|
|
const response = await fetch(`/api/cuts/${id}`, {
|
|
credentials: 'include'
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load cut: ${response.statusText}`);
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('Error loading cut:', error);
|
|
showStatus('Failed to load cut', 'error');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create new cut
|
|
*/
|
|
async createCut(cutData) {
|
|
try {
|
|
const response = await fetch('/api/cuts', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
credentials: 'include',
|
|
body: JSON.stringify(cutData)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.error || `Failed to create cut: ${response.statusText}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
showStatus('Cut created successfully', 'success');
|
|
|
|
// Reload cuts
|
|
await this.loadCuts(true);
|
|
|
|
return result;
|
|
} catch (error) {
|
|
console.error('Error creating cut:', error);
|
|
showStatus(error.message, 'error');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update existing cut
|
|
*/
|
|
async updateCut(id, cutData) {
|
|
try {
|
|
const response = await fetch(`/api/cuts/${id}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
credentials: 'include',
|
|
body: JSON.stringify(cutData)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.error || `Failed to update cut: ${response.statusText}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
showStatus('Cut updated successfully', 'success');
|
|
|
|
// Reload cuts
|
|
await this.loadCuts(true);
|
|
|
|
return result;
|
|
} catch (error) {
|
|
console.error('Error updating cut:', error);
|
|
showStatus(error.message, 'error');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete cut
|
|
*/
|
|
async deleteCut(id) {
|
|
try {
|
|
const response = await fetch(`/api/cuts/${id}`, {
|
|
method: 'DELETE',
|
|
credentials: 'include'
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.error || `Failed to delete cut: ${response.statusText}`);
|
|
}
|
|
|
|
showStatus('Cut deleted successfully', 'success');
|
|
|
|
// If this was the currently displayed cut, hide it
|
|
if (this.currentCut && this.currentCut.id === id) {
|
|
this.hideCut();
|
|
}
|
|
|
|
// Reload cuts
|
|
await this.loadCuts(true);
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Error deleting cut:', error);
|
|
showStatus(error.message, 'error');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ...existing code...
|
|
|
|
displayCut(cutData, autoDisplayed = false) {
|
|
if (!this.map) {
|
|
console.error('Map not initialized');
|
|
return false;
|
|
}
|
|
|
|
// Normalize field names for consistent access
|
|
const normalizedCut = {
|
|
...cutData,
|
|
id: cutData.id || cutData.Id || cutData.ID,
|
|
name: cutData.name || cutData.Name,
|
|
description: cutData.description || cutData.Description,
|
|
color: cutData.color || cutData.Color,
|
|
opacity: cutData.opacity || cutData.Opacity,
|
|
category: cutData.category || cutData.Category,
|
|
geojson: cutData.geojson || cutData.GeoJSON || cutData['GeoJSON Data'],
|
|
is_public: cutData.is_public || cutData['Public Visibility'],
|
|
is_official: cutData.is_official || cutData['Official Cut'],
|
|
autoDisplayed: autoDisplayed // Track if this was auto-displayed
|
|
};
|
|
|
|
// Check if already displayed
|
|
if (this.cutLayers.has(normalizedCut.id)) {
|
|
console.log(`Cut already displayed: ${normalizedCut.name}`);
|
|
return true;
|
|
}
|
|
|
|
if (!normalizedCut.geojson) {
|
|
console.error('Cut has no GeoJSON data');
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const geojsonData = typeof normalizedCut.geojson === 'string' ?
|
|
JSON.parse(normalizedCut.geojson) : normalizedCut.geojson;
|
|
|
|
// Parse opacity value - ensure it's a number between 0 and 1
|
|
let opacityValue = parseFloat(normalizedCut.opacity);
|
|
|
|
// Validate opacity is within range
|
|
if (isNaN(opacityValue) || opacityValue < 0 || opacityValue > 1) {
|
|
opacityValue = 0.3; // Default fallback
|
|
console.log(`Invalid opacity value (${normalizedCut.opacity}), using default: ${opacityValue}`);
|
|
}
|
|
|
|
const cutLayer = L.geoJSON(geojsonData, {
|
|
style: {
|
|
color: normalizedCut.color || '#3388ff',
|
|
fillColor: normalizedCut.color || '#3388ff',
|
|
fillOpacity: opacityValue,
|
|
weight: 2,
|
|
opacity: 0.8, // Stroke opacity - keeping this slightly transparent for better visibility
|
|
className: 'cut-polygon'
|
|
},
|
|
// Add onEachFeature to apply styles to each individual feature
|
|
onEachFeature: function (feature, layer) {
|
|
// Apply styles directly to the layer to ensure they override CSS
|
|
if (layer.setStyle) {
|
|
layer.setStyle({
|
|
fillOpacity: opacityValue,
|
|
color: normalizedCut.color || '#3388ff',
|
|
fillColor: normalizedCut.color || '#3388ff',
|
|
weight: 2,
|
|
opacity: 0.8
|
|
});
|
|
}
|
|
|
|
// Add cut-polygon class to the path element
|
|
if (layer._path) {
|
|
layer._path.classList.add('cut-polygon');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Add popup with cut info
|
|
cutLayer.bindPopup(`
|
|
<div class="cut-popup">
|
|
<h3>${normalizedCut.name}</h3>
|
|
${normalizedCut.description ? `<p>${normalizedCut.description}</p>` : ''}
|
|
${normalizedCut.category ? `<p><strong>Category:</strong> ${normalizedCut.category}</p>` : ''}
|
|
${normalizedCut.is_official ? '<span class="badge official">Official Cut</span>' : ''}
|
|
</div>
|
|
`);
|
|
|
|
cutLayer.addTo(this.map);
|
|
|
|
// Ensure cut-polygon class is applied to all path elements after adding to map
|
|
cutLayer.eachLayer(function(layer) {
|
|
if (layer._path) {
|
|
layer._path.classList.add('cut-polygon');
|
|
// Force the fill-opacity style to ensure it overrides CSS
|
|
layer._path.style.fillOpacity = opacityValue;
|
|
}
|
|
});
|
|
|
|
// Store in both tracking systems
|
|
this.cutLayers.set(normalizedCut.id, cutLayer);
|
|
this.displayedCuts.set(normalizedCut.id, normalizedCut);
|
|
|
|
// Update current cut reference (for legacy compatibility)
|
|
this.currentCut = normalizedCut;
|
|
this.currentCutLayer = cutLayer;
|
|
|
|
console.log(`Displayed cut: ${normalizedCut.name} (ID: ${normalizedCut.id}) with opacity: ${opacityValue} (raw: ${normalizedCut.opacity})`);
|
|
return true;
|
|
|
|
} catch (error) {
|
|
console.error('Error displaying cut:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ...existing code...
|
|
|
|
hideCut() {
|
|
this.hideAllCuts();
|
|
}
|
|
|
|
/**
|
|
* Hide specific cut by ID
|
|
*/
|
|
hideCutById(cutId) {
|
|
// Try different ID formats to handle type mismatches
|
|
let layer = this.cutLayers.get(cutId);
|
|
let actualKey = cutId;
|
|
|
|
if (!layer) {
|
|
// Try as string
|
|
const stringId = String(cutId);
|
|
layer = this.cutLayers.get(stringId);
|
|
if (layer) actualKey = stringId;
|
|
}
|
|
|
|
if (!layer) {
|
|
// Try as number
|
|
const numberId = Number(cutId);
|
|
if (!isNaN(numberId)) {
|
|
layer = this.cutLayers.get(numberId);
|
|
if (layer) actualKey = numberId;
|
|
}
|
|
}
|
|
|
|
if (layer && this.map) {
|
|
this.map.removeLayer(layer);
|
|
this.cutLayers.delete(actualKey);
|
|
this.displayedCuts.delete(actualKey);
|
|
console.log(`Successfully hidden cut ID: ${actualKey} (original: ${cutId})`);
|
|
return true;
|
|
}
|
|
|
|
console.warn(`Failed to hide cut ID: ${cutId} - not found in layers`);
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Hide all displayed cuts
|
|
*/
|
|
hideAllCuts() {
|
|
// Hide all cuts using the new system
|
|
Array.from(this.cutLayers.keys()).forEach(cutId => {
|
|
this.hideCutById(cutId);
|
|
});
|
|
|
|
// Legacy cleanup
|
|
if (this.currentCutLayer && this.map) {
|
|
this.map.removeLayer(this.currentCutLayer);
|
|
this.currentCutLayer = null;
|
|
this.currentCut = null;
|
|
}
|
|
console.log('All cuts hidden');
|
|
}
|
|
|
|
/**
|
|
* Toggle cut visibility
|
|
*/
|
|
toggleCut(cutData) {
|
|
if (this.currentCut && this.currentCut.id === cutData.id) {
|
|
this.hideCut();
|
|
return false; // Hidden
|
|
} else {
|
|
this.displayCut(cutData);
|
|
return true; // Shown
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get currently displayed cut
|
|
*/
|
|
getCurrentCut() {
|
|
return this.currentCut;
|
|
}
|
|
|
|
/**
|
|
* Check if a cut is currently displayed
|
|
*/
|
|
isCutDisplayed(cutId) {
|
|
// Try different ID types to handle string/number mismatches
|
|
const hasInMap = this.displayedCuts.has(cutId);
|
|
const hasInMapAsString = this.displayedCuts.has(String(cutId));
|
|
const hasInMapAsNumber = this.displayedCuts.has(Number(cutId));
|
|
const currentCutMatch = this.currentCut && this.currentCut.id === cutId;
|
|
|
|
return hasInMap || hasInMapAsString || hasInMapAsNumber || currentCutMatch;
|
|
}
|
|
|
|
/**
|
|
* Get all displayed cuts
|
|
*/
|
|
getDisplayedCuts() {
|
|
return Array.from(this.displayedCuts.values());
|
|
}
|
|
|
|
/**
|
|
* Get all available cuts
|
|
*/
|
|
getCuts() {
|
|
return this.cuts;
|
|
}
|
|
|
|
/**
|
|
* Get cuts by category
|
|
*/
|
|
getCutsByCategory(category) {
|
|
return this.cuts.filter(cut => {
|
|
const cutCategory = cut.category || cut.Category || 'Other';
|
|
return cutCategory === category;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Search cuts by name
|
|
*/
|
|
searchCuts(query) {
|
|
if (!query) return this.cuts;
|
|
|
|
const searchTerm = query.toLowerCase();
|
|
return this.cuts.filter(cut => {
|
|
// Handle different possible field names
|
|
const name = cut.name || cut.Name || '';
|
|
const description = cut.description || cut.Description || '';
|
|
|
|
return name.toLowerCase().includes(searchTerm) ||
|
|
description.toLowerCase().includes(searchTerm);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Export cuts as JSON
|
|
*/
|
|
exportCuts(cutsToExport = null) {
|
|
const cuts = cutsToExport || this.cuts;
|
|
const exportData = {
|
|
version: '1.0',
|
|
timestamp: new Date().toISOString(),
|
|
cuts: cuts.map(cut => ({
|
|
name: cut.name,
|
|
description: cut.description,
|
|
color: cut.color,
|
|
opacity: cut.opacity,
|
|
category: cut.category,
|
|
is_official: cut.is_official,
|
|
geojson: cut.geojson,
|
|
bounds: cut.bounds
|
|
}))
|
|
};
|
|
|
|
return JSON.stringify(exportData, null, 2);
|
|
}
|
|
|
|
/**
|
|
* Validate cut data for import
|
|
*/
|
|
validateCutData(cutData) {
|
|
const errors = [];
|
|
|
|
if (!cutData.name || typeof cutData.name !== 'string') {
|
|
errors.push('Name is required and must be a string');
|
|
}
|
|
|
|
if (!cutData.geojson) {
|
|
errors.push('GeoJSON data is required');
|
|
} else {
|
|
try {
|
|
const geojson = JSON.parse(cutData.geojson);
|
|
if (!geojson.type || !['Polygon', 'MultiPolygon'].includes(geojson.type)) {
|
|
errors.push('GeoJSON must be a Polygon or MultiPolygon');
|
|
}
|
|
} catch (e) {
|
|
errors.push('Invalid GeoJSON format');
|
|
}
|
|
}
|
|
|
|
if (cutData.opacity !== undefined) {
|
|
const opacity = parseFloat(cutData.opacity);
|
|
if (isNaN(opacity) || opacity < 0 || opacity > 1) {
|
|
errors.push('Opacity must be a number between 0 and 1');
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
/**
|
|
* Get cut statistics
|
|
*/
|
|
getStatistics() {
|
|
const stats = {
|
|
total: this.cuts.length,
|
|
public: this.cuts.filter(cut => {
|
|
const isPublic = cut.is_public || cut['Public Visibility'];
|
|
return isPublic === true || isPublic === 1 || isPublic === '1';
|
|
}).length,
|
|
private: this.cuts.filter(cut => {
|
|
const isPublic = cut.is_public || cut['Public Visibility'];
|
|
return !(isPublic === true || isPublic === 1 || isPublic === '1');
|
|
}).length,
|
|
official: this.cuts.filter(cut => {
|
|
const isOfficial = cut.is_official || cut['Official Cut'];
|
|
return isOfficial === true || isOfficial === 1 || isOfficial === '1';
|
|
}).length,
|
|
byCategory: {}
|
|
};
|
|
|
|
// Count by category
|
|
this.cuts.forEach(cut => {
|
|
const category = cut.category || cut.Category || 'Uncategorized';
|
|
stats.byCategory[category] = (stats.byCategory[category] || 0) + 1;
|
|
});
|
|
|
|
return stats;
|
|
}
|
|
|
|
/**
|
|
* Hide all displayed cuts
|
|
*/
|
|
/**
|
|
* Get displayed cut data by ID
|
|
*/
|
|
getDisplayedCut(cutId) {
|
|
return this.displayedCuts.get(cutId);
|
|
}
|
|
}
|
|
|
|
// Create global instance
|
|
export const cutManager = new CutManager();
|