diff --git a/map/app/public/js/admin-cuts.js b/map/app/public/js/admin-cuts.js
new file mode 100644
index 0000000..1647d7d
--- /dev/null
+++ b/map/app/public/js/admin-cuts.js
@@ -0,0 +1,1990 @@
+/**
+ * Admin Cuts Management Module
+ * Handles cut creation, editing, and management in the admin panel
+ */
+
+// Cut Drawing Class (converted to regular JS)
+class CutDrawing {
+ constructor(map) {
+ this.map = map;
+ this.vertices = [];
+ this.markers = [];
+ this.polyline = null;
+ this.polygon = null; // Add polygon preview
+ this.finalPolygon = null; // Final polygon after finishing
+ this.isDrawing = false;
+ this.onFinishCallback = null;
+ this.onCancelCallback = null;
+ this.currentColor = '#3388ff';
+ this.currentOpacity = 0.3;
+ }
+
+ startDrawing(onFinish, onCancel) {
+ if (this.isDrawing) {
+ this.cancelDrawing();
+ }
+
+ this.isDrawing = true;
+ this.onFinishCallback = onFinish;
+ this.onCancelCallback = onCancel;
+ this.vertices = [];
+ this.markers = [];
+
+ this.map.getContainer().style.cursor = 'crosshair';
+ this.map.on('click', this.onMapClick.bind(this));
+ this.map.doubleClickZoom.disable();
+
+ console.log('Cut drawing started - click to add points');
+ }
+
+ onMapClick(e) {
+ if (!this.isDrawing) return;
+
+ // Check if clicking on first vertex to close polygon
+ if (this.vertices.length >= 3) {
+ const firstVertex = this.vertices[0];
+ const clickPoint = this.map.latLngToContainerPoint(e.latlng);
+ const firstPoint = this.map.latLngToContainerPoint(firstVertex);
+ const distance = Math.sqrt(
+ Math.pow(clickPoint.x - firstPoint.x, 2) +
+ Math.pow(clickPoint.y - firstPoint.y, 2)
+ );
+
+ // If clicked within 15 pixels of first vertex, close the polygon
+ if (distance < 15) {
+ console.log('Closing polygon by clicking first vertex');
+ this.finishDrawing();
+ return;
+ }
+ }
+
+ // Add vertex marker with special styling for first vertex
+ const isFirstVertex = this.vertices.length === 0;
+ const marker = L.marker(e.latlng, {
+ icon: L.divIcon({
+ className: 'cut-vertex-marker' + (isFirstVertex ? ' first-vertex' : ''),
+ html: `
`,
+ iconSize: [12, 12],
+ iconAnchor: [6, 6]
+ }),
+ draggable: false
+ }).addTo(this.map);
+
+ // Add tooltip to first vertex after 3 points
+ if (isFirstVertex) {
+ marker.bindTooltip('Click to close polygon', {
+ permanent: false,
+ direction: 'top',
+ offset: [0, -10]
+ });
+ }
+
+ this.vertices.push(e.latlng);
+ this.markers.push(marker);
+ this.updatePolyline();
+
+ // Show tooltip on first vertex when we have enough points
+ if (this.vertices.length === 3 && this.markers[0]) {
+ this.markers[0].openTooltip();
+ }
+
+ // Call update callback if available
+ if (this.onUpdate) {
+ this.onUpdate();
+ }
+
+ console.log(`Added vertex ${this.vertices.length} at`, e.latlng);
+ }
+
+ updatePolyline() {
+ // Use stored color and opacity values
+ const color = this.currentColor;
+ const opacity = this.currentOpacity;
+
+ if (this.polyline) {
+ this.map.removeLayer(this.polyline);
+ }
+
+ if (this.polygon) {
+ this.map.removeLayer(this.polygon);
+ this.polygon = null;
+ }
+
+ if (this.vertices.length > 1) {
+ // Show polyline for incomplete polygon
+ this.polyline = L.polyline(this.vertices, {
+ color: color,
+ weight: 2,
+ dashArray: '5, 5',
+ opacity: 1.0 // Keep polyline stroke visible
+ }).addTo(this.map);
+
+ // Show preview polygon if we have 3+ vertices
+ if (this.vertices.length >= 3) {
+ this.polygon = L.polygon(this.vertices, {
+ color: color,
+ fillColor: color,
+ fillOpacity: opacity,
+ weight: 2,
+ opacity: 0.8,
+ dashArray: '5, 5'
+ }).addTo(this.map);
+
+ // Add cut-polygon class and force inline style
+ if (this.polygon._path) {
+ this.polygon._path.classList.add('cut-polygon');
+ // Use setProperty with important flag for stronger override
+ this.polygon._path.style.setProperty('fill-opacity', opacity, 'important');
+ this.polygon._path.style.setProperty('fill', color, 'important');
+ console.log(`Created/updated polygon with opacity: ${opacity}`);
+ }
+
+ console.log('Created/updated polygon with opacity:', opacity);
+ }
+ }
+ }
+
+ finishDrawing() {
+ console.log('finishDrawing() called');
+ console.log('isDrawing:', this.isDrawing);
+ console.log('vertices count:', this.vertices.length);
+
+ if (!this.isDrawing) {
+ console.log('Not in drawing mode, returning null');
+ return null;
+ }
+
+ if (this.vertices.length < 3) {
+ alert('A cut must have at least 3 points. Click more points or cancel drawing.');
+ return null;
+ }
+
+ // Store vertices before cleanup
+ const finalVertices = [...this.vertices];
+
+ // Use stored color and opacity values
+ const color = this.currentColor;
+ const opacity = this.currentOpacity;
+
+ // Create polygon from vertices
+ const polygon = L.polygon(finalVertices, {
+ color: color,
+ fillColor: color,
+ fillOpacity: opacity,
+ weight: 2,
+ opacity: 1.0 // Keep stroke visible
+ });
+
+ // Get GeoJSON and bounds
+ const geojson = polygon.toGeoJSON().geometry; // Get just the geometry part
+ const bounds = polygon.getBounds();
+
+ // Clean up drawing elements but keep the final polygon
+ this.cleanupDrawingElements();
+
+ // Add the final polygon to the map
+ this.finalPolygon = polygon.addTo(this.map);
+
+ // Call finish callback with the data
+ console.log('Calling finish callbacks...');
+ const callbackData = {
+ geojson: JSON.stringify(geojson),
+ bounds: JSON.stringify([
+ [bounds.getSouth(), bounds.getWest()],
+ [bounds.getNorth(), bounds.getEast()]
+ ]),
+ vertexCount: finalVertices.length,
+ polygon: this.finalPolygon
+ };
+
+ if (this.onFinishCallback) {
+ console.log('Calling onFinishCallback');
+ this.onFinishCallback(callbackData);
+ } else {
+ console.log('No onFinishCallback set');
+ }
+
+ // Also call the onFinish callback if set
+ if (this.onFinish) {
+ console.log('Calling onFinish');
+ this.onFinish(callbackData);
+ } else {
+ console.log('No onFinish callback set');
+ }
+
+ console.log('Cut drawing finished with', finalVertices.length, 'vertices');
+ return geojson;
+ }
+
+ cancelDrawing() {
+ if (!this.isDrawing) return;
+
+ console.log('Cut drawing cancelled');
+ this.cleanup();
+
+ if (this.onCancelCallback) {
+ this.onCancelCallback();
+ }
+
+ // Also call the onCancel callback if set
+ if (this.onCancel) {
+ this.onCancel();
+ }
+ }
+
+ undoLastVertex() {
+ if (!this.isDrawing || this.vertices.length === 0) return;
+
+ this.vertices.pop();
+ const lastMarker = this.markers.pop();
+ if (lastMarker) {
+ this.map.removeLayer(lastMarker);
+ }
+
+ this.updatePolyline();
+
+ // Call update callback if available
+ if (this.onUpdate) {
+ this.onUpdate();
+ }
+
+ console.log('Removed last vertex, remaining:', this.vertices.length);
+ }
+
+ clearVertices() {
+ if (!this.isDrawing) return;
+
+ this.markers.forEach(marker => {
+ this.map.removeLayer(marker);
+ });
+
+ if (this.polyline) {
+ this.map.removeLayer(this.polyline);
+ this.polyline = null;
+ }
+
+ if (this.polygon) {
+ this.map.removeLayer(this.polygon);
+ this.polygon = null;
+ }
+
+ this.vertices = [];
+ this.markers = [];
+
+ // Call update callback if available
+ if (this.onUpdate) {
+ this.onUpdate();
+ }
+
+ console.log('Cleared all vertices');
+ }
+
+ cleanupDrawingElements() {
+ // Remove drawing elements but preserve final polygon
+ this.markers.forEach(marker => {
+ this.map.removeLayer(marker);
+ });
+
+ if (this.polyline) {
+ this.map.removeLayer(this.polyline);
+ }
+
+ if (this.polygon) {
+ this.map.removeLayer(this.polygon);
+ }
+
+ this.map.getContainer().style.cursor = '';
+ this.map.off('click', this.onMapClick);
+ this.map.doubleClickZoom.enable();
+
+ this.isDrawing = false;
+ this.vertices = [];
+ this.markers = [];
+ this.polyline = null;
+ this.polygon = null;
+ this.onFinishCallback = null;
+ this.onCancelCallback = null;
+ }
+
+ cleanup() {
+ this.markers.forEach(marker => {
+ this.map.removeLayer(marker);
+ });
+
+ if (this.polyline) {
+ this.map.removeLayer(this.polyline);
+ }
+
+ if (this.polygon) {
+ this.map.removeLayer(this.polygon);
+ }
+
+ if (this.finalPolygon) {
+ this.map.removeLayer(this.finalPolygon);
+ }
+
+ this.map.getContainer().style.cursor = '';
+ this.map.off('click', this.onMapClick);
+ this.map.doubleClickZoom.enable();
+
+ this.isDrawing = false;
+ this.vertices = [];
+ this.markers = [];
+ this.polyline = null;
+ this.polygon = null;
+ this.finalPolygon = null;
+ this.onFinishCallback = null;
+ this.onCancelCallback = null;
+ }
+
+ getState() {
+ return {
+ isDrawing: this.isDrawing,
+ vertexCount: this.vertices.length,
+ canFinish: this.vertices.length >= 3
+ };
+ }
+
+ // Add method to update current drawing style
+ updateDrawingStyle(color, opacity) {
+ this.currentColor = color;
+ this.currentOpacity = opacity;
+
+ console.log(`CutDrawing.updateDrawingStyle called with color: ${color}, opacity: ${opacity}`);
+
+ // Update polyline color
+ if (this.polyline) {
+ this.polyline.setStyle({ color: color });
+ }
+
+ // Update polygon if it exists
+ if (this.polygon) {
+ this.polygon.setStyle({
+ color: color,
+ fillColor: color,
+ fillOpacity: opacity,
+ opacity: 0.8 // Border opacity
+ });
+
+ // Force inline style update and browser reflow
+ if (this.polygon._path) {
+ // Use setProperty with important flag for stronger override
+ this.polygon._path.style.setProperty('fill-opacity', opacity, 'important');
+ this.polygon._path.style.setProperty('fill', color, 'important');
+
+ // Force multiple reflows to ensure update
+ void this.polygon._path.offsetHeight;
+ void this.polygon._path.offsetWidth;
+
+ // Force repaint by temporarily changing a property
+ const oldDisplay = this.polygon._path.style.display;
+ this.polygon._path.style.display = 'none';
+ void this.polygon._path.offsetHeight;
+ this.polygon._path.style.display = oldDisplay;
+ }
+
+ // Also try Leaflet's internal redraw
+ if (this.polygon._updatePath) {
+ this.polygon._updatePath();
+ }
+
+ console.log(`Updated active drawing polygon with opacity: ${opacity}`);
+ } else {
+ // If no polygon exists but we have vertices, force a complete redraw
+ console.log('No polygon exists, forcing complete redraw');
+ this.forceRedraw();
+ }
+ }
+
+ clearPreview() {
+ // Clear any preview polygons but not the final polygon
+ if (this.polygon) {
+ this.map.removeLayer(this.polygon);
+ this.polygon = null;
+ }
+
+ if (this.polyline && this.isDrawing) {
+ this.map.removeLayer(this.polyline);
+ this.polyline = null;
+ }
+ }
+
+ // Force a complete redraw with current style settings
+ forceRedraw() {
+ if (this.vertices.length > 1) {
+ console.log('Forcing complete redraw with vertices:', this.vertices.length);
+ this.updatePolyline();
+ }
+ }
+}
+
+// Admin Cuts Manager Class
+class AdminCutsManager {
+ constructor() {
+ this.cutsMap = null;
+ this.cutDrawing = null;
+ this.currentCutId = null;
+ this.allCuts = [];
+ this.filteredCuts = [];
+ this.currentCutLayer = null;
+ this.isInitialized = false; // Add initialization flag
+
+ // Bind event handler once to avoid issues with removing listeners
+ this.boundHandleCutActionClick = this.handleCutActionClick.bind(this);
+ }
+
+ async initialize() {
+ // Prevent double initialization
+ if (this.isInitialized) {
+ console.log('AdminCutsManager already initialized');
+ return;
+ }
+
+ console.log('Initializing admin cuts manager...');
+
+ // Initialize map first
+ this.initializeMap();
+
+ // Initialize form first
+ this.initializeFormState();
+
+ // Initialize cuts list element
+ this.cutsList = document.getElementById('cuts-list');
+
+ // Initialize drawing
+ this.initializeDrawing();
+
+ // Load existing cuts
+ await this.loadCuts();
+
+ // Set initialized flag BEFORE logging to prevent re-entry
+ this.isInitialized = true;
+
+ console.log('Admin cuts manager initialized');
+ }
+
+ initializeFormState() {
+ console.log('Initializing form state...');
+
+ // Set up form elements
+ this.form = document.getElementById('cut-form');
+ if (!this.form) {
+ console.error('Cut form not found');
+ return;
+ }
+
+ // Keep form enabled at all times - users should be able to fill properties anytime
+ // Only disable the save button until we have geometry
+ const saveCutBtn = document.getElementById('save-cut-btn');
+ if (saveCutBtn) {
+ saveCutBtn.disabled = true;
+ }
+
+ // Set up form submission
+ this.form.addEventListener('submit', (e) => this.handleFormSubmit(e));
+
+ // NOTE: Opacity event listener is set up in setupFormControls() to avoid conflicts
+
+ // Set up other form controls
+ this.setupFormControls();
+
+ // Set up toolbar controls for drawing
+ this.setupToolbarControls();
+
+ console.log('Form state initialized - form inputs enabled, save button disabled until geometry complete');
+ }
+
+ // Add method to update preview style when color/opacity changes
+ updatePreviewStyle() {
+ // Simplified update method - uses toolbar values
+ console.log('Note: updatePreviewStyle() called but now using toolbar controls');
+ // This method is no longer needed since toolbar controls update drawing style directly
+ }
+
+ // Method that accepts direct values to avoid DOM reading issues (kept for compatibility)
+ updatePreviewStyleWithValues(colorOverride = null, opacityOverride = null) {
+ const color = colorOverride || this.getCurrentColor();
+ const opacity = opacityOverride !== null ? opacityOverride : this.getCurrentOpacity();
+
+ console.log('updatePreviewStyleWithValues called with:', color, opacity);
+
+ // Update drawing style if drawing is active
+ if (this.cutDrawing) {
+ this.cutDrawing.updateDrawingStyle(color, opacity);
+ }
+ }
+
+ // Method to apply styles to all relevant layers
+ applyStyleToLayers(color, opacity) {
+ console.log('applyStyleToLayers called with color:', color, 'opacity:', opacity);
+
+ // First, update the drawing tool's current style if drawing is active
+ if (this.cutDrawing) {
+ this.cutDrawing.updateDrawingStyle(color, opacity);
+ }
+
+ // Update any preview layer (GeoJSON) - this is the critical fix
+ if (this.previewLayer) {
+ this.updateLayerStyle(this.previewLayer, color, opacity);
+ console.log('Preview GeoJSON layer style updated with opacity:', opacity);
+ }
+
+ // Update current cut layer if it exists - Enhanced handling
+ if (this.currentCutLayer) {
+ this.updateLayerStyle(this.currentCutLayer, color, opacity);
+ console.log('Updated currentCutLayer with opacity:', opacity);
+ }
+
+ // If preview layer doesn't exist but we have drawing data, refresh the preview
+ if (!this.previewLayer && this.currentDrawingData) {
+ console.log('No preview layer found, refreshing preview with drawing data');
+ this.updateDrawingPreview(this.currentDrawingData);
+ }
+ }
+
+ // New unified method to update any layer style
+ updateLayerStyle(layer, color, opacity) {
+ if (!layer) return;
+
+ // Update layer options
+ layer.options.fillOpacity = opacity;
+ layer.options.fillColor = color;
+ layer.options.color = color;
+
+ // Apply new style
+ layer.setStyle({
+ fillColor: color,
+ color: color,
+ fillOpacity: opacity,
+ opacity: 0.8 // Border opacity
+ });
+
+ // Force update on the path element(s)
+ if (layer._path) {
+ layer._path.style.setProperty('fill-opacity', opacity, 'important');
+ layer._path.style.setProperty('fill', color, 'important');
+ layer._path.style.setProperty('stroke', color, 'important');
+
+ // Force browser to recognize the change
+ layer._path.style.opacity = ''; // Clear any overall opacity
+ void layer._path.offsetHeight; // Force reflow
+ }
+
+ // Handle renderer sub-layers
+ if (layer._renderer && layer._renderer._container) {
+ const paths = layer._renderer._container.querySelectorAll('path');
+ paths.forEach(path => {
+ path.style.setProperty('fill-opacity', opacity, 'important');
+ path.style.setProperty('fill', color, 'important');
+ path.style.setProperty('stroke', color, 'important');
+
+ // Force browser to recognize the change
+ path.style.opacity = ''; // Clear any overall opacity
+ void path.offsetHeight; // Force reflow
+ });
+ }
+
+ // Handle GeoJSON layers and layer groups
+ if (layer.eachLayer) {
+ // It's a layer group or GeoJSON - iterate through each feature
+ layer.eachLayer((subLayer) => {
+ if (subLayer.setStyle) {
+ subLayer.setStyle({
+ color: color,
+ fillColor: color,
+ fillOpacity: opacity,
+ opacity: 0.8
+ });
+
+ // Force inline styles on sub-layer paths
+ if (subLayer._path) {
+ subLayer._path.style.setProperty('fill-opacity', opacity, 'important');
+ subLayer._path.style.setProperty('fill', color, 'important');
+ subLayer._path.style.setProperty('stroke', color, 'important');
+ subLayer._path.style.opacity = '';
+ void subLayer._path.offsetHeight;
+ }
+
+ // Add CSS class to identify cut polygons
+ const pathElement = subLayer.getElement();
+ if (pathElement) {
+ pathElement.classList.add('cut-polygon');
+ pathElement.style.setProperty('fill-opacity', opacity, 'important');
+ console.log('Added cut-polygon class to sub-layer path element');
+ }
+ // Force DOM update immediately
+ this.forceLayerRedraw(subLayer);
+ }
+ });
+
+ // Also force a redraw of the entire layer group
+ if (layer.redraw) {
+ layer.redraw();
+ }
+ } else if (layer.setStyle) {
+ // It's a single layer (Leaflet Polygon)
+ layer.setStyle({
+ fillColor: color,
+ color: color,
+ fillOpacity: opacity,
+ opacity: 0.8
+ });
+ // Add CSS class to identify cut polygons
+ const pathElement = layer.getElement();
+ if (pathElement) {
+ pathElement.classList.add('cut-polygon');
+ console.log('Added cut-polygon class to single layer path element');
+ }
+ // Force DOM update immediately
+ this.forceLayerRedraw(layer);
+ }
+ }
+
+ // New method to force layer redraw - addresses browser rendering issues
+ forceLayerRedraw(layer) {
+ if (layer._path) {
+ // Direct SVG path manipulation for immediate visual update
+ const path = layer._path;
+ const targetOpacity = layer.options.fillOpacity;
+ const targetColor = layer.options.fillColor || layer.options.color;
+
+ console.log('forceLayerRedraw called:');
+ console.log(' - layer.options.fillOpacity:', targetOpacity);
+ console.log(' - layer.options.fillColor:', targetColor);
+ console.log(' - layer._path exists:', !!path);
+
+ // Set the attribute directly on the SVG element
+ path.setAttribute('fill-opacity', targetOpacity);
+ path.setAttribute('fill', targetColor);
+
+ // Also try setting as CSS style with important flag for better browser compatibility
+ path.style.setProperty('fill-opacity', targetOpacity, 'important');
+ path.style.setProperty('fill', targetColor, 'important');
+
+ // Force browser reflow by temporarily changing a property
+ const originalDisplay = path.style.display;
+ path.style.display = 'none';
+
+ // Use requestAnimationFrame for better timing
+ requestAnimationFrame(() => {
+ path.style.display = originalDisplay;
+
+ // Double-check the attribute was set
+ const finalOpacity = path.getAttribute('fill-opacity');
+ const finalColor = path.getAttribute('fill');
+ const styleOpacity = path.style.fillOpacity;
+
+ console.log('forceLayerRedraw completed:');
+ console.log(' - target opacity:', targetOpacity);
+ console.log(' - target color:', targetColor);
+ console.log(' - SVG attr opacity result:', finalOpacity);
+ console.log(' - SVG attr color result:', finalColor);
+ console.log(' - CSS style opacity result:', styleOpacity);
+
+ // If attributes don't match, try one more time
+ if (finalOpacity !== targetOpacity.toString()) {
+ path.setAttribute('fill-opacity', targetOpacity);
+ path.style.setProperty('fill-opacity', targetOpacity, 'important');
+ console.log(' - Re-applied fill-opacity attribute and style');
+ }
+ });
+ } else {
+ console.log('forceLayerRedraw: no _path found on layer');
+ }
+ }
+
+ setupFormControls() {
+ // Set up start drawing button
+ const startDrawingBtn = document.getElementById('start-drawing-btn');
+ if (startDrawingBtn) {
+ // Remove any existing listeners first
+ startDrawingBtn.removeEventListener('click', this.boundHandleStartDrawing);
+ // Create bound method if it doesn't exist
+ if (!this.boundHandleStartDrawing) {
+ this.boundHandleStartDrawing = this.handleStartDrawing.bind(this);
+ }
+ startDrawingBtn.addEventListener('click', this.boundHandleStartDrawing);
+ }
+
+ // Set up reset form button
+ const resetFormBtn = document.getElementById('reset-form-btn');
+ if (resetFormBtn) {
+ resetFormBtn.addEventListener('click', () => this.resetForm());
+ }
+
+ // Set up cancel edit button
+ const cancelEditBtn = document.getElementById('cancel-edit-btn');
+ if (cancelEditBtn) {
+ cancelEditBtn.addEventListener('click', () => this.cancelEdit());
+ }
+
+ // Set up refresh cuts button
+ const refreshCutsBtn = document.getElementById('refresh-cuts-btn');
+ if (refreshCutsBtn) {
+ refreshCutsBtn.addEventListener('click', () => this.loadCuts());
+ }
+
+ // Set up export button
+ const exportCutsBtn = document.getElementById('export-cuts-btn');
+ if (exportCutsBtn) {
+ exportCutsBtn.addEventListener('click', () => this.exportCuts());
+ }
+
+ // Set up import file input
+ const importCutsFile = document.getElementById('import-cuts-file');
+ if (importCutsFile) {
+ importCutsFile.addEventListener('change', (e) => this.handleImport(e));
+ }
+
+ // Set up search and filter
+ const searchInput = document.getElementById('cuts-search');
+ if (searchInput) {
+ searchInput.addEventListener('input', () => this.filterCuts());
+ }
+
+ const categoryFilter = document.getElementById('cuts-category-filter');
+ if (categoryFilter) {
+ categoryFilter.addEventListener('change', () => this.filterCuts());
+ }
+
+ // NOTE: Color and opacity controls moved to toolbar for real-time feedback
+ // Form-based color/opacity controls are no longer used
+
+ // Add drawing toolbar button handlers
+ const finishDrawingBtn = document.getElementById('finish-cut-btn'); // Fixed ID
+ if (finishDrawingBtn) {
+ finishDrawingBtn.addEventListener('click', () => {
+ console.log('Finish button clicked');
+ if (this.cutDrawing) {
+ console.log('Cut drawing exists, calling finishDrawing()');
+ console.log('Drawing state:', this.cutDrawing.getState());
+ this.cutDrawing.finishDrawing();
+ } else {
+ console.error('Cut drawing not initialized');
+ }
+ });
+ } else {
+ console.error('Finish drawing button not found');
+ }
+
+ const undoVertexBtn = document.getElementById('undo-vertex-btn');
+ if (undoVertexBtn) {
+ undoVertexBtn.addEventListener('click', () => {
+ if (this.cutDrawing) {
+ this.cutDrawing.undoLastVertex();
+ this.updateDrawingUI();
+ }
+ });
+ }
+
+ const clearVerticesBtn = document.getElementById('clear-vertices-btn');
+ if (clearVerticesBtn) {
+ clearVerticesBtn.addEventListener('click', () => {
+ if (this.cutDrawing) {
+ this.cutDrawing.clearVertices();
+ this.updateDrawingUI();
+ }
+ });
+ }
+
+ const cancelDrawingBtn = document.getElementById('cancel-cut-btn'); // Fixed ID
+ if (cancelDrawingBtn) {
+ cancelDrawingBtn.addEventListener('click', () => {
+ if (this.cutDrawing) {
+ this.cutDrawing.cancelDrawing();
+ }
+ });
+ }
+ }
+
+ // Set up toolbar controls for real-time drawing feedback
+ setupToolbarControls() {
+ const colorPicker = document.getElementById('toolbar-color');
+ const opacitySlider = document.getElementById('toolbar-opacity');
+ const opacityDisplay = document.getElementById('toolbar-opacity-display');
+
+ console.log('Setting up toolbar controls...', {
+ colorPicker: !!colorPicker,
+ opacitySlider: !!opacitySlider,
+ opacityDisplay: !!opacityDisplay
+ });
+
+ if (colorPicker) {
+ colorPicker.addEventListener('input', (e) => {
+ const color = e.target.value;
+ console.log('Toolbar color changed to:', color);
+
+ // Update drawing style immediately
+ if (this.cutDrawing) {
+ const opacity = this.getCurrentOpacity();
+ console.log('Updating drawing style with color:', color, 'opacity:', opacity);
+ this.cutDrawing.updateDrawingStyle(color, opacity);
+ }
+ });
+ }
+
+ if (opacitySlider && opacityDisplay) {
+ opacitySlider.addEventListener('input', (e) => {
+ const opacity = parseFloat(e.target.value);
+ const percentage = Math.round(opacity * 100);
+
+ opacityDisplay.textContent = percentage + '%';
+ console.log('Toolbar opacity changed to:', opacity, 'percentage:', percentage);
+
+ // Update drawing style immediately
+ if (this.cutDrawing) {
+ const color = this.getCurrentColor();
+ console.log('Updating drawing style with color:', color, 'opacity:', opacity);
+ this.cutDrawing.updateDrawingStyle(color, opacity);
+ } else {
+ console.warn('cutDrawing instance not available');
+ }
+ });
+ }
+
+ console.log('Toolbar controls setup complete');
+ }
+
+ // Helper methods to get current toolbar values
+ getCurrentColor() {
+ const colorPicker = document.getElementById('toolbar-color');
+ return colorPicker ? colorPicker.value : '#3388ff';
+ }
+
+ getCurrentOpacity() {
+ const opacitySlider = document.getElementById('toolbar-opacity');
+ return opacitySlider ? parseFloat(opacitySlider.value) : 0.3;
+ }
+
+ // Force update the drawing style - useful for debugging or manual refresh
+ forceUpdateDrawingStyle() {
+ if (this.cutDrawing) {
+ const color = this.getCurrentColor();
+ const opacity = this.getCurrentOpacity();
+ console.log('Force updating drawing style with color:', color, 'opacity:', opacity);
+ this.cutDrawing.updateDrawingStyle(color, opacity);
+ } else {
+ console.warn('cutDrawing instance not available for force update');
+ }
+ }
+
+ // Sync toolbar display values with actual slider values
+ syncToolbarDisplayValues() {
+ const opacitySlider = document.getElementById('toolbar-opacity');
+ const opacityDisplay = document.getElementById('toolbar-opacity-display');
+
+ if (opacitySlider && opacityDisplay) {
+ const opacity = parseFloat(opacitySlider.value);
+ const percentage = Math.round(opacity * 100);
+ opacityDisplay.textContent = percentage + '%';
+ console.log('Synced opacity display to:', percentage + '%');
+ }
+
+ // Also sync any existing preview layers with current toolbar values
+ const color = this.getCurrentColor();
+ const opacity = this.getCurrentOpacity();
+
+ if (this.previewLayer) {
+ console.log('Syncing preview layer with toolbar values:', color, opacity);
+ this.updateLayerStyle(this.previewLayer, color, opacity);
+ }
+
+ if (this.currentCutLayer) {
+ console.log('Syncing current cut layer with toolbar values:', color, opacity);
+ this.updateLayerStyle(this.currentCutLayer, color, opacity);
+ }
+ }
+
+ // Remove the form disable/enable methods since we keep form enabled at all times
+ updateDrawingUI() {
+ if (!this.cutDrawing) return;
+
+ const state = this.cutDrawing.getState();
+ const vertexCount = document.getElementById('vertex-count');
+ const finishBtn = document.getElementById('finish-cut-btn'); // Fixed ID
+ const undoBtn = document.getElementById('undo-vertex-btn');
+
+ if (vertexCount) {
+ vertexCount.textContent = `${state.vertexCount} points`;
+ }
+
+ if (finishBtn) {
+ finishBtn.disabled = !state.canFinish;
+ }
+
+ if (undoBtn) {
+ undoBtn.disabled = state.vertexCount === 0;
+ }
+ }
+
+ async initializeMap() {
+ const mapContainer = document.getElementById('cuts-map');
+ if (!mapContainer) {
+ console.error('Cuts map container not found');
+ return;
+ }
+
+ // Check if map is already initialized
+ if (this.cutsMap) {
+ console.log('Cuts map already initialized');
+ return;
+ }
+
+ // Check if container already has a map instance
+ if (mapContainer._leaflet_id) {
+ console.log('Map container already has a Leaflet instance, cleaning up...');
+ // Try to find and remove existing map
+ const existingMap = L.map.hasOwnProperty(mapContainer._leaflet_id) ? L.map[mapContainer._leaflet_id] : null;
+ if (existingMap) {
+ existingMap.remove();
+ }
+ delete mapContainer._leaflet_id;
+ }
+
+ // Initialize map
+ this.cutsMap = L.map('cuts-map').setView([53.5461, -113.4938], 11);
+
+ // Add tile layer
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ attribution: '© OpenStreetMap contributors'
+ }).addTo(this.cutsMap);
+
+ console.log('Cuts map initialized');
+ }
+
+ initializeDrawing() {
+ this.cutDrawing = new CutDrawing(this.cutsMap);
+
+ // Ensure clean state
+ this.cutDrawing.isDrawing = false;
+
+ // Bind drawing events
+ this.cutDrawing.onFinish = (data) => this.handleDrawingFinished(data);
+ this.cutDrawing.onCancel = () => this.handleDrawingCancelled();
+ this.cutDrawing.onUpdate = () => this.updateDrawingUI();
+
+ console.log('Drawing initialized, isDrawing:', this.cutDrawing.isDrawing);
+ }
+
+ handleStartDrawing() {
+ console.log('handleStartDrawing called, current drawing state:', this.cutDrawing.isDrawing);
+
+ // Prevent double-click issues by adding a small delay check
+ if (this.handleStartDrawing._processing) {
+ console.log('Already processing start drawing, ignoring...');
+ return;
+ }
+
+ this.handleStartDrawing._processing = true;
+
+ try {
+ if (this.cutDrawing.isDrawing) {
+ console.log('Already drawing, canceling...');
+ this.cutDrawing.cancelDrawing();
+ return;
+ }
+
+ console.log('Starting new drawing...');
+
+ // Get current toolbar values instead of form values
+ const color = this.getCurrentColor();
+ const opacity = this.getCurrentOpacity();
+
+ console.log('Starting new drawing with color:', color, 'opacity:', opacity);
+
+ // Update the drawing tool with current style
+ if (this.cutDrawing) {
+ this.cutDrawing.currentColor = color;
+ this.cutDrawing.currentOpacity = opacity;
+ // Force update the drawing style immediately
+ this.cutDrawing.updateDrawingStyle(color, opacity);
+ }
+
+ // Clear any existing preview layers
+ if (this.previewLayer) {
+ this.cutsMap.removeLayer(this.previewLayer);
+ this.previewLayer = null;
+ }
+
+ // Clear any existing current cut layer
+ if (this.currentCutLayer) {
+ if (this.currentCutLayer.remove) {
+ this.currentCutLayer.remove();
+ } else {
+ this.cutsMap.removeLayer(this.currentCutLayer);
+ }
+ this.currentCutLayer = null;
+ }
+
+ // Clear form data
+ this.currentDrawingData = null;
+ document.getElementById('cut-geojson').value = '';
+ document.getElementById('cut-bounds').value = '';
+
+ // Clear any existing preview from the drawing tool
+ if (this.cutDrawing && this.cutDrawing.clearPreview) {
+ this.cutDrawing.clearPreview();
+ }
+
+ // Update button text
+ const startDrawingBtn = document.getElementById('start-drawing-btn');
+ if (startDrawingBtn) {
+ startDrawingBtn.textContent = 'Cancel Drawing';
+ }
+
+ this.cutDrawing.startDrawing(
+ (data) => this.handleDrawingFinished(data),
+ () => this.handleDrawingCancelled()
+ );
+
+ // Show drawing toolbar
+ const toolbar = document.getElementById('cut-drawing-toolbar');
+ if (toolbar) {
+ toolbar.classList.add('active');
+ // Sync toolbar display values and apply to any existing layers
+ this.syncToolbarDisplayValues();
+ }
+
+ // Update UI
+ this.updateDrawingUI();
+ } finally {
+ // Clear the processing flag after a short delay
+ setTimeout(() => {
+ this.handleStartDrawing._processing = false;
+ }, 100);
+ }
+ }
+
+ handleDrawingFinished(drawingData) {
+ console.log('handleDrawingFinished() called with data:', drawingData);
+
+ // Store the drawing data
+ this.currentDrawingData = drawingData;
+
+ // Hide drawing toolbar
+ document.getElementById('cut-drawing-toolbar').classList.remove('active');
+
+ // Store geojson and bounds in hidden form fields
+ document.getElementById('cut-geojson').value = drawingData.geojson;
+ document.getElementById('cut-bounds').value = drawingData.bounds;
+
+ // Store the geojson in form dataset for form submission
+ const form = document.getElementById('cut-form');
+ if (form) {
+ form.dataset.geojson = drawingData.geojson;
+ form.dataset.bounds = drawingData.bounds;
+ }
+
+ // Store the polygon reference for later use
+ if (drawingData.polygon) {
+ this.currentCutLayer = drawingData.polygon;
+ }
+
+ // Update form title
+ const titleElement = document.getElementById('cut-form-title');
+ if (titleElement) {
+ titleElement.textContent = 'Cut Properties - Ready to Save';
+ }
+
+ // Enable the save button now that we have geometry
+ const saveCutBtn = document.getElementById('save-cut-btn');
+ if (saveCutBtn) {
+ saveCutBtn.disabled = false;
+ }
+
+ // Update start drawing button text
+ const startDrawingBtn = document.getElementById('start-drawing-btn');
+ if (startDrawingBtn) {
+ startDrawingBtn.textContent = 'Redraw Polygon';
+ }
+
+ // Update preview with the drawn polygon using current toolbar values
+ this.updateDrawingPreview(drawingData);
+
+ // Force immediate style update with current toolbar values
+ const color = this.getCurrentColor();
+ const opacity = this.getCurrentOpacity();
+ console.log('handleDrawingFinished: Applying toolbar values - color:', color, 'opacity:', opacity);
+
+ // Apply to the preview layer immediately
+ if (this.previewLayer) {
+ this.updateLayerStyle(this.previewLayer, color, opacity);
+ }
+
+ // Also update the polygon from drawing data if it exists
+ if (drawingData.polygon) {
+ this.updateLayerStyle(drawingData.polygon, color, opacity);
+ }
+
+ this.showStatus('Cut drawing completed. Fill in the properties and save.', 'success');
+ }
+
+ handleDrawingCancelled() {
+ console.log('handleDrawingCancelled called');
+
+ const toolbar = document.getElementById('cut-drawing-toolbar');
+ if (toolbar) {
+ toolbar.classList.remove('active');
+ }
+
+ // Clear stored drawing data
+ this.currentDrawingData = null;
+
+ const geojsonField = document.getElementById('cut-geojson');
+ const boundsField = document.getElementById('cut-bounds');
+ if (geojsonField) geojsonField.value = '';
+ if (boundsField) boundsField.value = '';
+
+ // Clear form dataset
+ const form = document.getElementById('cut-form');
+ if (form) {
+ delete form.dataset.geojson;
+ delete form.dataset.bounds;
+ }
+
+ const saveCutBtn = document.getElementById('save-cut-btn');
+ if (saveCutBtn) {
+ saveCutBtn.disabled = true;
+ }
+
+ // Reset start drawing button text
+ const startDrawingBtn = document.getElementById('start-drawing-btn');
+ if (startDrawingBtn) {
+ startDrawingBtn.textContent = 'Start Drawing';
+ }
+
+ console.log('Drawing cancelled, state reset');
+ this.showStatus('Cut drawing cancelled', 'info');
+ }
+
+ reset() {
+ console.log('Resetting cut form and drawing...');
+
+ // Reset the drawing if active
+ if (this.cutDrawing) {
+ this.cutDrawing.reset();
+ }
+
+ // Reset form
+ if (this.cutForm) {
+ this.cutForm.reset();
+
+ // Set default values
+ const colorInput = document.getElementById('cut-color');
+ const opacityInput = document.getElementById('cut-opacity');
+
+ if (colorInput) colorInput.value = '#3388ff';
+ if (opacityInput) opacityInput.value = '0.3';
+
+ this.updateColorValue();
+ this.updateOpacityValue();
+ }
+
+ // Hide drawing toolbar
+ const drawingToolbar = document.getElementById('cut-drawing-toolbar');
+ if (drawingToolbar) {
+ drawingToolbar.classList.remove('active');
+ }
+
+ // Reset buttons
+ const startDrawingBtn = document.getElementById('start-drawing-btn');
+ if (startDrawingBtn) {
+ startDrawingBtn.textContent = 'Start Drawing';
+ startDrawingBtn.classList.remove('danger');
+ }
+
+ console.log('Cut form and drawing reset complete');
+ }
+
+ async handleFormSubmit(event) {
+ event.preventDefault();
+
+ console.log('Form submitted!');
+
+ const formData = new FormData(this.form); // Use this.form instead of this.cutForm
+ console.log('Form data entries:');
+ for (let [key, value] of formData.entries()) {
+ console.log(`${key}: ${value}`);
+ }
+
+ const cutData = {
+ name: formData.get('name'), // Use the actual HTML name attributes
+ description: formData.get('description'),
+ color: this.getCurrentColor(), // Get from toolbar instead of form
+ opacity: this.getCurrentOpacity(), // Get from toolbar instead of form
+ category: formData.get('category'),
+ is_public: formData.has('is_public'),
+ is_official: formData.has('is_official')
+ };
+
+ // Add the geojson and bounds from stored data
+ if (this.currentDrawingData || event.target.dataset.geojson) {
+ cutData.geojson = this.currentDrawingData?.geojson || event.target.dataset.geojson;
+ cutData.bounds = this.currentDrawingData?.bounds || event.target.dataset.bounds;
+ } else if (this.editingCutId) {
+ // If editing and no new drawing, keep existing geometry
+ const existingCut = this.allCuts.find(c => c.id === this.editingCutId);
+ if (existingCut) {
+ cutData.geojson = existingCut.geojson;
+ cutData.bounds = existingCut.bounds;
+ }
+ } else {
+ // Also try to get from hidden form fields as fallback
+ cutData.geojson = formData.get('geojson');
+ cutData.bounds = formData.get('bounds');
+ }
+
+ console.log('Cut data:', cutData);
+
+ if (!cutData.name || !cutData.geojson) {
+ this.showStatus('Name and geometry are required', 'error');
+ console.log('Validation failed - missing name or geojson');
+ return;
+ }
+
+ try {
+ let result;
+ if (this.editingCutId) {
+ result = await this.updateCut(this.editingCutId, cutData);
+ } else {
+ result = await this.createCut(cutData);
+ }
+
+ if (result) {
+ this.resetForm();
+ this.currentDrawingData = null;
+ await this.loadCuts();
+ }
+ } catch (error) {
+ console.error('Error saving cut:', error);
+ this.showStatus('Failed to save cut', 'error');
+ }
+ }
+
+ // Add a new method to update the drawing preview
+ updateDrawingPreview(drawingData) {
+ if (!drawingData || !drawingData.geojson) return;
+
+ try {
+ const geojson = JSON.parse(drawingData.geojson);
+
+ // Remove any existing preview layer
+ if (this.previewLayer) {
+ this.cutsMap.removeLayer(this.previewLayer);
+ this.previewLayer = null;
+ }
+
+ // Get current toolbar colors - this is the key fix
+ const color = this.getCurrentColor();
+ const opacity = this.getCurrentOpacity();
+
+ console.log('updateDrawingPreview: Using toolbar values - color:', color, 'opacity:', opacity);
+
+ // Create the GeoJSON layer with a static style object (not a function)
+ // This allows setStyle() to work properly later
+ this.previewLayer = L.geoJSON(geojson, {
+ style: {
+ color: color,
+ fillColor: color,
+ fillOpacity: opacity,
+ weight: 2,
+ opacity: 0.8, // Change border opacity to 0.8 for consistency
+ className: 'cut-polygon',
+ dashArray: '3, 3'
+ }
+ }).addTo(this.cutsMap);
+
+ // Add the cut-polygon CSS class to the path element and force inline styles
+ if (this.previewLayer._layers) {
+ Object.values(this.previewLayer._layers).forEach(layer => {
+ if (layer._path) {
+ layer._path.classList.add('cut-polygon');
+ // Force the fill-opacity inline style with important flag
+ layer._path.style.setProperty('fill-opacity', opacity, 'important');
+ layer._path.style.setProperty('fill', color, 'important');
+ }
+ });
+ }
+
+ // Also check if we need to access sub-layers
+ if (this.previewLayer._renderer && this.previewLayer._renderer._container) {
+ const paths = this.previewLayer._renderer._container.querySelectorAll('path');
+ paths.forEach(path => {
+ path.classList.add('cut-polygon');
+ // Force the fill-opacity inline style on all paths with important flag
+ path.style.setProperty('fill-opacity', opacity, 'important');
+ path.style.setProperty('fill', color, 'important');
+ });
+ }
+
+ // Force initial style application using our unified method
+ this.updateLayerStyle(this.previewLayer, color, opacity);
+
+ console.log('Drawing preview updated with opacity:', opacity);
+
+ // Fit map to bounds if available
+ if (drawingData.bounds) {
+ const bounds = JSON.parse(drawingData.bounds);
+ this.cutsMap.fitBounds(bounds, { padding: [20, 20] });
+ }
+ } catch (error) {
+ console.error('Error updating drawing preview:', error);
+ }
+ }
+
+ // Method to refresh preview with current drawing data and form values
+ refreshPreview() {
+ if (this.currentDrawingData) {
+ console.log('Refreshing preview with current form values');
+ this.updateDrawingPreview(this.currentDrawingData);
+ }
+ }
+
+ 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();
+ this.showStatus('Cut created successfully', 'success');
+ return result;
+ } catch (error) {
+ console.error('Error creating cut:', error);
+ this.showStatus(error.message, 'error');
+ return null;
+ }
+ }
+
+ 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();
+ this.showStatus('Cut updated successfully', 'success');
+ return result;
+ } catch (error) {
+ console.error('Error updating cut:', error);
+ this.showStatus(error.message, 'error');
+ return null;
+ }
+ }
+
+ async loadCuts() {
+ try {
+ const response = await fetch('/api/cuts', {
+ credentials: 'include'
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error('Failed to load cuts:', response.status, errorText);
+ throw new Error(`Failed to load cuts: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+
+ // Handle case where cuts table might not exist or be empty
+ if (!data.list) {
+ console.log('No cuts data returned, initializing empty list');
+ data.list = [];
+ }
+
+ this.allCuts = data.list || [];
+ this.filteredCuts = [...this.allCuts];
+ this.renderCutsList();
+
+ } catch (error) {
+ console.error('Error loading cuts:', error);
+ this.showNotification('Failed to load cuts. Please check if the cuts table exists.', 'error');
+ // Initialize with empty array so the UI still works
+ this.allCuts = [];
+ this.filteredCuts = [];
+ this.renderCutsList();
+ }
+ }
+
+ renderCutsList() {
+ if (!this.cutsList) return;
+
+ // Remove existing event listener to prevent duplicates
+ this.cutsList.removeEventListener('click', this.boundHandleCutActionClick);
+
+ if (this.filteredCuts.length === 0) {
+ this.cutsList.innerHTML = '
No cuts found
';
+ return;
+ }
+
+ const html = this.filteredCuts.map(cut => this.renderCutItem(cut)).join('');
+ this.cutsList.innerHTML = html;
+
+ // Add event delegation for cut action buttons
+ this.cutsList.addEventListener('click', this.boundHandleCutActionClick);
+ }
+
+ renderCutItem(cut) {
+ console.log('Rendering cut item:', cut); // Debug log to see the cut structure
+ const badges = [];
+ const isPublic = cut.is_public || cut.Is_public || cut['Public Visibility'];
+ const isOfficial = cut.is_official || cut.Is_official || cut['Official Cut'];
+
+ if (isPublic) badges.push('
Public');
+ else badges.push('
Private');
+ if (isOfficial) badges.push('
Official');
+
+ // Check different possible ID field names
+ const cutId = cut.id || cut.Id || cut.ID || cut._id;
+ const cutName = cut.name || cut.Name || 'Unknown';
+ const cutDescription = cut.description || cut.Description || '';
+ const cutCategory = cut.category || cut.Category || 'Custom';
+ const cutCreatedAt = cut.CreatedAt || cut.created_at || new Date().toISOString();
+
+ console.log('Cut ID found:', cutId, 'from cut object keys:', Object.keys(cut));
+
+ return `
+
+
+ ${cutDescription ? `
${cutDescription}
` : ''}
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ handleCutActionClick(event) {
+ console.log('handleCutActionClick called', event);
+ const button = event.target;
+ console.log('Button:', button, 'Has data-action:', button.hasAttribute('data-action'));
+
+ if (!button.hasAttribute('data-action')) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ const action = button.getAttribute('data-action');
+ const cutId = button.getAttribute('data-cut-id');
+
+ console.log('Action:', action, 'Cut ID:', cutId);
+
+ if (!cutId) return;
+
+ switch (action) {
+ case 'view':
+ console.log('Calling viewCut');
+ this.viewCut(cutId);
+ break;
+ case 'edit':
+ console.log('Calling editCut');
+ this.editCut(cutId);
+ break;
+ case 'duplicate':
+ console.log('Calling duplicateCut');
+ this.duplicateCut(cutId);
+ break;
+ case 'delete':
+ console.log('Calling deleteCut');
+ this.deleteCut(cutId);
+ break;
+ default:
+ console.warn('Unknown cut action:', action);
+ }
+ }
+
+ async viewCut(cutId) {
+ console.log('viewCut called with ID:', cutId);
+ const cut = this.allCuts.find(c => (c.id || c.Id || c.ID || c._id) == cutId);
+ console.log('Found cut:', cut);
+ if (!cut) return;
+
+ this.displayCut(cut);
+ const cutName = cut.name || cut.Name || 'Unknown';
+ this.showStatus(`Viewing cut: ${cutName}`, 'info');
+ }
+
+ displayCut(cutData) {
+ if (this.currentCutLayer) {
+ this.cutsMap.removeLayer(this.currentCutLayer);
+ this.currentCutLayer = null;
+ }
+
+ if (!cutData) return false;
+
+ // Get geojson from different possible field names
+ const geojson = cutData.geojson || cutData.Geojson || cutData.GeoJSON || cutData['GeoJSON Data'];
+ if (!geojson) {
+ console.error('No geojson data found in cut:', cutData);
+ return false;
+ }
+
+ try {
+ const parsedGeojson = JSON.parse(geojson);
+
+ // Get color and opacity from different possible field names
+ const color = cutData.color || cutData.Color || '#3388ff';
+ const opacity = cutData.opacity || cutData.Opacity || 0.3;
+
+ console.log('displayCut: Using color:', color, 'opacity:', opacity);
+
+ // Create GeoJSON layer with static style object (not function) for proper setStyle() support
+ this.currentCutLayer = L.geoJSON(parsedGeojson, {
+ style: {
+ color: color,
+ fillColor: color,
+ fillOpacity: opacity,
+ weight: 2,
+ opacity: 1.0 // Keep stroke solid
+ }
+ });
+
+ this.currentCutLayer.addTo(this.cutsMap);
+
+ // Force apply the opacity using our enhanced styling approach
+ this.updateLayerStyle(this.currentCutLayer, color, opacity);
+
+ console.log('displayCut: Created currentCutLayer with opacity:', opacity);
+
+ // Get bounds from different possible field names
+ const bounds = cutData.bounds || cutData.Bounds;
+ if (bounds) {
+ try {
+ const parsedBounds = JSON.parse(bounds);
+ this.cutsMap.fitBounds(parsedBounds, { padding: [20, 20] });
+ } catch (boundsError) {
+ this.cutsMap.fitBounds(this.currentCutLayer.getBounds(), { padding: [20, 20] });
+ }
+ } else {
+ this.cutsMap.fitBounds(this.currentCutLayer.getBounds(), { padding: [20, 20] });
+ }
+
+ return true;
+ } catch (error) {
+ console.error('Error displaying cut:', error);
+ this.showStatus('Failed to display cut', 'error');
+ return false;
+ }
+ }
+
+ async editCut(cutId) {
+ console.log('editCut called with ID:', cutId);
+ const cut = this.allCuts.find(c => (c.id || c.Id || c.ID || c._id) == cutId);
+ console.log('Found cut for editing:', cut);
+ if (!cut) return;
+
+ this.editingCutId = cutId;
+
+ // Use both lowercase and uppercase field names
+ document.getElementById('cut-name').value = cut.name || cut.Name || '';
+ document.getElementById('cut-description').value = cut.description || cut.Description || '';
+
+ // Set toolbar values (these are the ones we actually use now)
+ const cutColor = cut.color || cut.Color || '#3388ff';
+ const cutOpacity = cut.opacity || cut.Opacity || 0.3;
+
+ const toolbarColor = document.getElementById('toolbar-color');
+ const toolbarOpacity = document.getElementById('toolbar-opacity');
+ const toolbarOpacityDisplay = document.getElementById('toolbar-opacity-display');
+
+ if (toolbarColor) toolbarColor.value = cutColor;
+ if (toolbarOpacity) toolbarOpacity.value = cutOpacity;
+ if (toolbarOpacityDisplay) toolbarOpacityDisplay.textContent = Math.round(cutOpacity * 100) + '%';
+
+ document.getElementById('cut-category').value = cut.category || cut.Category || 'Custom';
+ document.getElementById('cut-public').checked = cut.is_public || cut.Is_public || cut['Public Visibility'] || false;
+ document.getElementById('cut-official').checked = cut.is_official || cut.Is_official || cut['Official Cut'] || false;
+ document.getElementById('cut-geojson').value = cut.geojson || cut.Geojson || cut.GeoJSON || cut['GeoJSON Data'] || '';
+ document.getElementById('cut-bounds').value = cut.bounds || cut.Bounds || '';
+ document.getElementById('cut-id').value = cut.id || cut.Id || cut.ID || cut._id;
+
+ // Store the existing geometry in form dataset
+ const form = document.getElementById('cut-form');
+ const geojsonData = cut.geojson || cut.Geojson || cut.GeoJSON || cut['GeoJSON Data'];
+ const boundsData = cut.bounds || cut.Bounds;
+ if (form && geojsonData) {
+ form.dataset.geojson = geojsonData;
+ form.dataset.bounds = boundsData || '';
+ }
+
+ const cutName = cut.name || cut.Name || 'Unknown';
+ document.getElementById('cut-form-title').textContent = `Edit Cut: ${cutName}`;
+ document.getElementById('save-cut-btn').textContent = 'Update Cut';
+ document.getElementById('save-cut-btn').disabled = false;
+ document.getElementById('cancel-edit-btn').style.display = 'inline-block';
+ document.getElementById('start-drawing-btn').style.display = 'none';
+
+ this.updateColorValue();
+ this.updateOpacityValue();
+ this.displayCut(cut);
+
+ this.showStatus(`Editing cut: ${cutName}`, 'info');
+ }
+
+ async duplicateCut(cutId) {
+ console.log('duplicateCut called with ID:', cutId);
+ const cut = this.allCuts.find(c => (c.id || c.Id || c.ID || c._id) == cutId);
+ console.log('Found cut for duplication:', cut);
+ if (!cut) return;
+
+ // Use both lowercase and uppercase field names
+ const cutName = cut.name || cut.Name || 'Unknown';
+ const cutDescription = cut.description || cut.Description || '';
+ const cutColor = cut.color || cut.Color || '#3388ff';
+ const cutOpacity = cut.opacity || cut.Opacity || 0.3;
+ const cutCategory = cut.category || cut.Category || 'Custom';
+ const cutGeojson = cut.geojson || cut.Geojson || cut.GeoJSON || cut['GeoJSON Data'] || '';
+ const cutBounds = cut.bounds || cut.Bounds || '';
+
+ const duplicateData = {
+ name: `${cutName} (Copy)`,
+ description: cutDescription,
+ color: cutColor,
+ opacity: cutOpacity,
+ category: cutCategory,
+ is_public: false,
+ is_official: false,
+ geojson: cutGeojson,
+ bounds: cutBounds
+ };
+
+ console.log('Duplicate data:', duplicateData);
+
+ const result = await this.createCut(duplicateData);
+ if (result) {
+ await this.loadCuts();
+ this.showStatus(`Duplicated cut: ${cutName}`, 'success');
+ }
+ }
+
+ async deleteCut(cutId) {
+ console.log('deleteCut called with ID:', cutId);
+ const cut = this.allCuts.find(c => (c.id || c.Id || c.ID || c._id) == cutId);
+ console.log('Found cut for deletion:', cut);
+ if (!cut) return;
+
+ const cutName = cut.name || cut.Name || 'Unknown';
+
+ if (!confirm(`Are you sure you want to delete the cut "${cutName}"? This action cannot be undone.`)) {
+ return;
+ }
+
+ try {
+ const response = await fetch(`/api/cuts/${cutId}`, {
+ method: 'DELETE',
+ credentials: 'include'
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || `Failed to delete cut: ${response.statusText}`);
+ }
+
+ this.showStatus('Cut deleted successfully', 'success');
+
+ if (this.currentCutLayer) {
+ this.cutsMap.removeLayer(this.currentCutLayer);
+ this.currentCutLayer = null;
+ }
+
+ await this.loadCuts();
+ } catch (error) {
+ console.error('Error deleting cut:', error);
+ this.showStatus(error.message, 'error');
+ }
+ }
+
+ cancelEdit() {
+ this.resetForm();
+ // Hide the cancel button
+ const cancelBtn = document.getElementById('cancel-edit-btn');
+ if (cancelBtn) {
+ cancelBtn.style.display = 'none';
+ }
+ }
+
+ resetForm() {
+ this.form.reset();
+ document.getElementById('cut-id').value = '';
+ document.getElementById('cut-geojson').value = '';
+ document.getElementById('cut-bounds').value = '';
+
+ // Reset toolbar values to defaults
+ const toolbarColor = document.getElementById('toolbar-color');
+ const toolbarOpacity = document.getElementById('toolbar-opacity');
+ const toolbarOpacityDisplay = document.getElementById('toolbar-opacity-display');
+
+ if (toolbarColor) toolbarColor.value = '#3388ff';
+ if (toolbarOpacity) toolbarOpacity.value = '0.3';
+ if (toolbarOpacityDisplay) toolbarOpacityDisplay.textContent = '30%';
+
+ // Update UI
+ document.getElementById('cut-form-title').textContent = 'Cut Properties';
+ document.getElementById('cancel-edit-btn').style.display = 'none';
+
+ // Only disable save button (form inputs stay enabled)
+ const saveCutBtn = document.getElementById('save-cut-btn');
+ if (saveCutBtn) {
+ saveCutBtn.disabled = true;
+ }
+
+ // Clear current cut
+ this.currentCutId = null;
+
+ // Clear any preview
+ if (this.cutDrawing) {
+ this.cutDrawing.clearPreview();
+ }
+ }
+
+ updateColorValue() {
+ const colorInput = document.getElementById('cut-color');
+ const colorValue = document.getElementById('cut-color-text');
+ if (colorInput && colorValue) {
+ colorValue.value = colorInput.value;
+ }
+ }
+
+ updateOpacityValue() {
+ const opacityInput = document.getElementById('cut-opacity');
+ const opacityValue = document.getElementById('opacity-value');
+ if (opacityInput && opacityValue) {
+ const percentage = Math.round(opacityInput.value * 100);
+ opacityValue.textContent = `${percentage}%`;
+ }
+ }
+
+ filterCuts() {
+ const searchTerm = document.getElementById('cuts-search').value.toLowerCase();
+ const categoryFilter = document.getElementById('cuts-category-filter').value;
+
+ let filteredCuts = this.allCuts;
+
+ if (searchTerm) {
+ filteredCuts = filteredCuts.filter(cut =>
+ cut.name.toLowerCase().includes(searchTerm) ||
+ (cut.description && cut.description.toLowerCase().includes(searchTerm))
+ );
+ }
+
+ if (categoryFilter) {
+ filteredCuts = filteredCuts.filter(cut => cut.category === categoryFilter);
+ }
+
+ this.filteredCuts = filteredCuts;
+ this.renderCutsList();
+ }
+
+ exportCuts() {
+ const exportData = {
+ version: '1.0',
+ timestamp: new Date().toISOString(),
+ cuts: this.allCuts.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
+ }))
+ };
+
+ const data = JSON.stringify(exportData, null, 2);
+ const blob = new Blob([data], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `cuts-export-${new Date().toISOString().split('T')[0]}.json`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ this.showStatus('Cuts exported successfully', 'success');
+ }
+
+ async handleImportFile(event) {
+ const file = event.target.files[0];
+ if (!file) return;
+
+ try {
+ const text = await file.text();
+ const data = JSON.parse(text);
+
+ if (!data.cuts || !Array.isArray(data.cuts)) {
+ throw new Error('Invalid import file format');
+ }
+
+ let successCount = 0;
+ let errorCount = 0;
+
+ for (const cutData of data.cuts) {
+ const result = await this.createCut(cutData);
+ if (result) {
+ successCount++;
+ } else {
+ errorCount++;
+ }
+ }
+
+ await this.loadCuts();
+
+ if (successCount > 0) {
+ this.showStatus(`Successfully imported ${successCount} cuts${errorCount > 0 ? `, ${errorCount} failed` : ''}`, 'success');
+ } else {
+ this.showStatus('No cuts were imported', 'error');
+ }
+ } catch (error) {
+ console.error('Import error:', error);
+ this.showStatus('Failed to import cuts: ' + error.message, 'error');
+ }
+
+ event.target.value = '';
+ }
+
+ // Debug method to check form state
+ debugFormState() {
+ console.log('=== Form State Debug ===');
+ const inputs = [
+ 'cut-name', 'cut-description', 'cut-color',
+ 'cut-opacity', 'cut-category', 'cut-public', 'cut-official', 'save-cut-btn'
+ ];
+
+ inputs.forEach(id => {
+ const element = document.getElementById(id);
+ if (element) {
+ console.log(`${id}: disabled=${element.disabled}, value="${element.value || element.checked}"`);
+ } else {
+ console.log(`${id}: NOT FOUND`);
+ }
+ });
+
+ console.log(`currentDrawingData exists: ${!!this.currentDrawingData}`);
+ console.log(`previewLayer exists: ${!!this.previewLayer}`);
+ console.log('========================');
+ }
+
+ // Add a debug method to check layer opacity state specifically
+ debugOpacityState() {
+ const colorElement = document.getElementById('cut-color');
+ const opacityElement = document.getElementById('cut-opacity');
+
+ console.log('=== DEBUG: Opacity State ===');
+ console.log('Color value:', colorElement?.value);
+ console.log('Opacity value:', opacityElement?.value);
+ console.log('Opacity parsed:', parseFloat(opacityElement?.value));
+
+ if (this.previewLayer) {
+ console.log('Preview layer exists');
+ this.previewLayer.eachLayer((layer) => {
+ console.log('Layer options fillOpacity:', layer.options.fillOpacity);
+ if (layer._path) {
+ const svgOpacity = layer._path.getAttribute('fill-opacity');
+ const computedStyle = window.getComputedStyle(layer._path);
+ console.log('SVG fill-opacity attribute:', svgOpacity);
+ console.log('Computed fill-opacity style:', computedStyle.fillOpacity);
+ console.log('SVG fill color:', layer._path.getAttribute('fill'));
+ }
+ });
+ } else {
+ console.log('No preview layer found');
+ }
+
+ if (this.currentCutLayer) {
+ console.log('Current cut layer exists');
+ if (this.currentCutLayer.eachLayer) {
+ this.currentCutLayer.eachLayer((layer) => {
+ console.log('Current layer options fillOpacity:', layer.options.fillOpacity);
+ if (layer._path) {
+ console.log('Current SVG fill-opacity:', layer._path.getAttribute('fill-opacity'));
+ }
+ });
+ }
+ } else {
+ console.log('No current cut layer found');
+ }
+ console.log('========================');
+ }
+
+ showStatus(message, type) {
+ // Use existing admin notification system if available
+ if (typeof showNotification === 'function') {
+ showNotification(message, type);
+ } else {
+ console.log(`[${type.toUpperCase()}] ${message}`);
+ }
+ }
+}
+
+// Global instance
+const adminCutsManager = new AdminCutsManager();
+window.adminCutsManager = adminCutsManager;
+
+// Expose debug function globally for easy access
+window.debugCutsForm = () => adminCutsManager.debugFormState();
+window.debugOpacity = () => adminCutsManager.debugOpacityState();
+window.forceUpdateCutStyle = () => adminCutsManager.forceUpdateDrawingStyle();
+window.syncToolbarValues = () => adminCutsManager.syncToolbarDisplayValues();
+
+// Initialize when cuts section becomes visible
+document.addEventListener('DOMContentLoaded', function() {
+ // Initialize immediately if cuts section is already visible
+ const cutsSection = document.getElementById('cuts');
+ if (cutsSection && cutsSection.style.display !== 'none') {
+ adminCutsManager.initialize().catch(error => {
+ console.error('Failed to initialize cuts manager:', error);
+ });
+ return;
+ }
+
+ // Otherwise, watch for it to become visible
+ const observer = new MutationObserver(function(mutations) {
+ mutations.forEach(function(mutation) {
+ if (mutation.type === 'attributes' &&
+ mutation.attributeName === 'style' &&
+ mutation.target.id === 'cuts' &&
+ mutation.target.style.display !== 'none' &&
+ !adminCutsManager.isInitialized) {
+
+ // Small delay to ensure DOM is fully rendered
+ setTimeout(() => {
+ adminCutsManager.initialize().catch(error => {
+ console.error('Failed to initialize cuts manager:', error);
+ });
+ }, 100);
+ }
+ });
+ });
+
+ if (cutsSection) {
+ observer.observe(cutsSection, { attributes: true });
+ }
+});
diff --git a/map/app/public/js/admin.js b/map/app/public/js/admin.js
index 312e3c2..8fc00b2 100644
--- a/map/app/public/js/admin.js
+++ b/map/app/public/js/admin.js
@@ -55,6 +55,8 @@ document.addEventListener('DOMContentLoaded', () => {
checkAndLoadWalkSheetConfig();
} else if (hash === '#convert-data') {
showSection('convert-data');
+ } else if (hash === '#cuts') {
+ showSection('cuts');
} else {
// Default to dashboard
showSection('dashboard');
@@ -479,6 +481,25 @@ function showSection(sectionId) {
}
}, 100);
}
+
+ // Special handling for cuts section
+ if (sectionId === 'cuts') {
+ // Initialize admin cuts manager when section is shown
+ setTimeout(() => {
+ if (typeof window.adminCutsManager === 'object' && window.adminCutsManager.initialize) {
+ if (!window.adminCutsManager.isInitialized) {
+ console.log('Initializing admin cuts manager from showSection...');
+ window.adminCutsManager.initialize().catch(error => {
+ console.error('Failed to initialize cuts manager:', error);
+ });
+ } else {
+ console.log('Admin cuts manager already initialized');
+ }
+ } else {
+ console.error('adminCutsManager not found in showSection');
+ }
+ }, 100);
+ }
}
// Update map from input fields
diff --git a/map/app/public/js/cut-controls.js b/map/app/public/js/cut-controls.js
new file mode 100644
index 0000000..451eb9a
--- /dev/null
+++ b/map/app/public/js/cut-controls.js
@@ -0,0 +1,1289 @@
+/**
+ * Cut Controls for Public Map
+ * Handles cut selection and display on the public map interface
+ */
+
+import { cutManager } from './cut-manager.js';
+import { showStatus } from './utils.js';
+
+let cutSelector = null;
+let cutLegend = null;
+let cutLegendContent = null;
+let legendExpanded = false;
+let mobileOverlayModal = null;
+let mobileOverlayBtn = null;
+let cutSelectorListenerAttached = false; // Flag to prevent duplicate listeners
+let currentDropdownContainer = null; // Track current dropdown container
+
+/**
+ * Initialize cut controls
+ */
+export async function initializeCutControls() {
+ // Get DOM elements
+ cutSelector = document.getElementById('cut-selector');
+ cutLegend = document.getElementById('cut-legend');
+ cutLegendContent = document.getElementById('cut-legend-content');
+ mobileOverlayModal = document.getElementById('mobile-overlay-modal');
+ mobileOverlayBtn = document.getElementById('mobile-overlay-btn');
+
+ if (!cutSelector) {
+ console.warn('Cut selector not found');
+ return;
+ }
+
+ // Debug logging
+ console.log('Mobile overlay button found:', !!mobileOverlayBtn);
+ console.log('Mobile overlay modal found:', !!mobileOverlayModal);
+
+ // Set up event listeners - remove the change listener since we're not using the select element as a dropdown
+ // cutSelector.addEventListener('change', handleCutSelection);
+
+ // Set up mobile overlay event delegation
+ if (mobileOverlayModal) {
+ setupMobileOverlayEventListeners();
+ }
+
+ // Mobile overlay button is handled in ui-controls.js
+ console.log('Cut controls setup - mobile overlay handled by ui-controls.js');
+
+ // Load and populate cuts
+ await loadAndPopulateCuts();
+
+ console.log('Cut controls initialized');
+}
+
+/**
+ * Load cuts and populate selector
+ */
+async function loadAndPopulateCuts() {
+ try {
+ const response = await fetch('/api/cuts/public');
+ if (!response.ok) {
+ console.warn('Failed to fetch cuts:', response.status);
+ // For testing: create mock data if API fails
+ const mockCuts = [
+ {
+ id: 'test-1',
+ name: 'Test Ward 1',
+ description: 'Test overlay for debugging',
+ category: 'Ward',
+ color: '#ff6b6b',
+ opacity: 0.3,
+ is_public: true,
+ is_official: true,
+ geojson: '{"type":"Polygon","coordinates":[[[-113.5,-113.4],[53.5,53.6],[53.6,53.6],[53.6,53.5],[-113.5,-113.4]]]}'
+ },
+ {
+ id: 'test-2',
+ name: 'Test Neighborhood',
+ description: 'Another test overlay',
+ category: 'Neighborhood',
+ color: '#4ecdc4',
+ opacity: 0.4,
+ is_public: true,
+ is_official: false
+ }
+ ];
+ console.log('Using mock cuts for testing');
+ await processCuts(mockCuts);
+ return;
+ }
+
+ const data = await response.json();
+ console.log('Raw API response data:', data);
+ const cuts = data.list || [];
+ console.log('Extracted cuts from API:', cuts);
+ await processCuts(cuts);
+
+ console.log(`Loaded ${cuts.length} public cuts`);
+ } catch (error) {
+ console.error('Error loading cuts:', error);
+ // Fallback to empty array
+ await processCuts([]);
+ }
+}
+
+/**
+ * Process cuts data (shared logic for real and mock data)
+ */
+async function processCuts(cuts) {
+ console.log('Processing cuts:', cuts);
+ console.log('Number of cuts to process:', cuts?.length || 0);
+
+ // Store cuts globally for reference
+ window.cuts = cuts;
+ console.log('Set window.cuts to:', window.cuts);
+
+ // Populate both desktop and mobile selectors
+ populateCutSelector(cuts);
+
+ // Add a small delay to ensure mobile DOM elements are ready
+ setTimeout(() => {
+ populateMobileOverlayOptions(cuts);
+ }, 100);
+
+ // Auto-display all public cuts
+ await autoDisplayAllPublicCuts(cuts);
+}
+
+/**
+ * Auto-display all public cuts on map load
+ */
+async function autoDisplayAllPublicCuts(cuts) {
+ if (!cuts || cuts.length === 0) return;
+
+ // Filter for public cuts that should auto-display
+ const publicCuts = cuts.filter(cut => {
+ // Handle different possible field names for public visibility
+ const isPublic = cut.is_public || cut.Is_public || cut['Public Visibility'] || cut['Public'];
+ return isPublic === true || isPublic === 1 || isPublic === '1';
+ });
+
+ console.log(`Auto-displaying ${publicCuts.length} public cuts`);
+
+ // Display all public cuts
+ for (const cut of publicCuts) {
+ try {
+ const normalizedCut = normalizeCutData(cut);
+ // Pass true as second parameter to indicate auto-displayed
+ cutManager.displayCut(normalizedCut, true);
+ } catch (error) {
+ console.error(`Failed to auto-display cut: ${cut.name || cut.Name}`, error);
+ }
+ }
+
+ // Update UI to show which cuts are active
+ if (publicCuts.length > 0) {
+ updateMultipleCutsUI();
+ console.log('About to update checkbox states after auto-display...');
+
+ // Add a small delay to ensure cuts are fully displayed before updating checkboxes
+ setTimeout(() => {
+ updateCheckboxStates(); // Update checkbox states to reflect auto-displayed cuts
+ updateMobileCheckboxStates(); // Update mobile checkboxes too
+ console.log('Checkbox states updated after auto-display');
+ }, 100);
+ }
+}
+
+/**
+ * Normalize cut data field names for consistent access
+ */
+function normalizeCutData(cut) {
+ return {
+ ...cut,
+ id: cut.id || cut.Id || cut.ID,
+ name: cut.name || cut.Name || 'Unnamed Cut',
+ description: cut.description || cut.Description,
+ color: cut.color || cut.Color || '#3388ff',
+ opacity: cut.opacity || cut.Opacity || 0.3,
+ category: cut.category || cut.Category || 'Other',
+ geojson: cut.geojson || cut.GeoJSON || cut['GeoJSON Data'],
+ is_public: cut.is_public || cut['Public Visibility'],
+ is_official: cut.is_official || cut['Official Cut']
+ };
+}
+
+/**
+ * Populate the cut selector with all available cuts
+ */
+/**
+ * Populate the cut selector with multi-select checkboxes
+ */
+function populateCutSelector(cuts) {
+ if (!cutSelector || !cuts?.length) {
+ console.warn('Cannot populate cut selector - missing selector or cuts');
+ return;
+ }
+
+ // Store cuts globally for global functions
+ window.cuts = cuts;
+
+ // Create the main selector (acts as a button to show/hide dropdown)
+ cutSelector.textContent = 'Manage map overlays...';
+
+ // Remove any existing checkbox container
+ const existingContainer = cutSelector.parentNode.querySelector('.cut-checkbox-container');
+ if (existingContainer) {
+ existingContainer.remove();
+ }
+
+ // Create checkbox container
+ const checkboxContainer = document.createElement('div');
+ checkboxContainer.className = 'cut-checkbox-container';
+ checkboxContainer.style.display = 'none'; // Explicitly set to none initially
+
+ // Create header with buttons
+ const header = document.createElement('div');
+ header.className = 'cut-checkbox-header';
+
+ const showAllBtn = document.createElement('button');
+ showAllBtn.type = 'button';
+ showAllBtn.className = 'btn btn-sm';
+ showAllBtn.textContent = 'Show All';
+ showAllBtn.dataset.action = 'show-all';
+
+ const hideAllBtn = document.createElement('button');
+ hideAllBtn.type = 'button';
+ hideAllBtn.className = 'btn btn-sm';
+ hideAllBtn.textContent = 'Hide All';
+ hideAllBtn.dataset.action = 'hide-all';
+
+ header.appendChild(showAllBtn);
+ header.appendChild(hideAllBtn);
+
+ // Create list container
+ const listContainer = document.createElement('div');
+ listContainer.className = 'cut-checkbox-list';
+
+ // Add checkbox items
+ cuts.forEach(cut => {
+ const normalized = normalizeCutData(cut);
+ const isDisplayed = cutManager.isCutDisplayed(normalized.id);
+
+ const item = document.createElement('div');
+ item.className = 'cut-checkbox-item';
+ item.dataset.cutId = normalized.id;
+
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ checkbox.checked = isDisplayed;
+ checkbox.dataset.cutId = normalized.id;
+
+ const colorBox = document.createElement('div');
+ colorBox.className = 'cut-color-box';
+ colorBox.style.backgroundColor = normalized.color;
+
+ const nameSpan = document.createElement('span');
+ nameSpan.className = 'cut-name';
+ nameSpan.textContent = normalized.name;
+
+ item.appendChild(checkbox);
+ item.appendChild(colorBox);
+ item.appendChild(nameSpan);
+
+ if (normalized.is_official) {
+ const badge = document.createElement('span');
+ badge.className = 'badge official';
+ badge.textContent = 'Official';
+ item.appendChild(badge);
+ }
+
+ listContainer.appendChild(item);
+ });
+
+ checkboxContainer.appendChild(header);
+ checkboxContainer.appendChild(listContainer);
+
+ // Insert after selector
+ cutSelector.parentNode.appendChild(checkboxContainer);
+
+ // Setup event listeners using delegation
+ setupCutSelectorEventListeners(checkboxContainer);
+
+ console.log(`Populated cut selector with ${cuts.length} cuts`);
+}
+
+/**
+ * Setup event listeners for cut selector dropdown
+ */
+function setupCutSelectorEventListeners(container) {
+ // Store reference to current container
+ currentDropdownContainer = container;
+
+ // Handle action buttons and checkbox clicks
+ container.addEventListener('click', async (e) => {
+ e.stopPropagation(); // Prevent event bubbling
+
+ // Handle action buttons
+ if (e.target.matches('button[data-action]')) {
+ const action = e.target.dataset.action;
+ if (action === 'show-all') {
+ await showAllCuts();
+ updateCheckboxStates();
+ updateMobileCheckboxStates();
+ } else if (action === 'hide-all') {
+ await hideAllCuts();
+ updateCheckboxStates();
+ updateMobileCheckboxStates();
+ }
+ return;
+ }
+
+ // Handle checkbox clicks
+ if (e.target.matches('input[type="checkbox"]')) {
+ const cutId = e.target.dataset.cutId;
+ console.log('Checkbox clicked for cut ID:', cutId, 'checked:', e.target.checked);
+ console.log('Available window.cuts:', window.cuts);
+
+ const cut = window.cuts?.find(c => {
+ const normalized = normalizeCutData(c);
+ return String(normalized.id) === String(cutId);
+ });
+
+ console.log('Found cut:', cut);
+
+ if (cut) {
+ const normalized = normalizeCutData(cut);
+ console.log('Normalized cut ID:', normalized.id, 'Original cutId:', cutId);
+
+ if (e.target.checked) {
+ console.log('Attempting to display cut:', normalized.name);
+ const displayResult = cutManager.displayCut(cut);
+ console.log('Display result:', displayResult);
+ } else {
+ console.log('Attempting to hide cut:', normalized.name, 'with ID:', normalized.id);
+ const hideResult = cutManager.hideCutById(normalized.id); // Use normalized.id instead of cutId
+ console.log('Hide result:', hideResult);
+
+ // Also try with string conversion in case of ID format mismatch
+ if (!hideResult) {
+ console.log('Trying to hide with string ID:', String(normalized.id));
+ const hideResult2 = cutManager.hideCutById(String(normalized.id));
+ console.log('Hide result (string ID):', hideResult2);
+ }
+ }
+ updateMultipleCutsUI();
+ updateSelectorText();
+ updateMobileCheckboxStates(); // Update mobile checkboxes too
+ } else {
+ console.warn('Cut not found for ID:', cutId, 'Available cuts:', window.cuts?.length || 'undefined');
+ console.warn('Window.cuts content:', window.cuts);
+ }
+ return;
+ }
+
+ // Handle clicking on the item (not checkbox) to toggle
+ if (e.target.closest('.cut-checkbox-item') && !e.target.matches('input')) {
+ const item = e.target.closest('.cut-checkbox-item');
+ const checkbox = item.querySelector('input[type="checkbox"]');
+ if (checkbox) {
+ checkbox.checked = !checkbox.checked;
+ // Trigger the checkbox change event
+ const clickEvent = new Event('click', { bubbles: true });
+ checkbox.dispatchEvent(clickEvent);
+ }
+ }
+ });
+
+ // Only attach cutSelector event listener once
+ if (!cutSelectorListenerAttached) {
+ cutSelector.addEventListener('click', (e) => {
+ // Use current active container
+ const activeContainer = currentDropdownContainer;
+ if (!activeContainer) return;
+
+ // Always toggle based on current display state
+ if (activeContainer.style.display === 'block') {
+ activeContainer.style.display = 'none';
+ } else {
+ activeContainer.style.display = 'block';
+ // Update checkbox states when opening dropdown to reflect current state
+ updateCheckboxStates();
+ updateSelectorText();
+ }
+ });
+
+ // Close dropdown when clicking outside
+ document.addEventListener('click', (e) => {
+ const activeContainer = currentDropdownContainer;
+ if (activeContainer && !cutSelector.contains(e.target) && !activeContainer.contains(e.target)) {
+ activeContainer.style.display = 'none';
+ }
+ });
+
+ cutSelectorListenerAttached = true;
+ }
+}
+
+/**
+ * Update checkbox states to match current displayed cuts
+ */
+function updateCheckboxStates() {
+ const checkboxes = document.querySelectorAll('.cut-checkbox-item input[type="checkbox"]');
+
+ checkboxes.forEach(cb => {
+ const cutId = cb.dataset.cutId;
+ const isDisplayed = cutManager.isCutDisplayed(cutId);
+ cb.checked = isDisplayed;
+ });
+}
+
+/**
+ * Update mobile checkbox states to match current displayed cuts
+ */
+function updateMobileCheckboxStates() {
+ document.querySelectorAll('input[name="mobile-cut"]').forEach(cb => {
+ const cutId = cb.dataset.cutId;
+ cb.checked = cutManager.isCutDisplayed(cutId);
+ });
+}
+
+/**
+ * Update selector text based on active cuts
+ */
+function updateSelectorText() {
+ const activeCuts = cutManager.getDisplayedCuts();
+
+ if (activeCuts.length === 0) {
+ cutSelector.textContent = 'Select map overlays...';
+ } else if (activeCuts.length === 1) {
+ cutSelector.textContent = `📍 ${activeCuts[0].name}`;
+ } else {
+ cutSelector.textContent = `📍 ${activeCuts.length} overlays active`;
+ }
+}
+
+/**
+ * Show all cuts function
+ */
+async function showAllCuts() {
+ if (window.cuts) {
+ for (const cut of window.cuts) {
+ const normalized = normalizeCutData(cut);
+ if (!cutManager.isCutDisplayed(normalized.id)) {
+ cutManager.displayCut(cut);
+ console.log(`Displayed cut: ${normalized.name}`);
+ }
+ }
+ updateMultipleCutsUI();
+ updateSelectorText();
+ console.log('All cuts shown');
+ }
+}
+
+/**
+ * Hide all cuts function (updated)
+ */
+async function hideAllCuts() {
+ cutManager.hideAllCuts();
+ updateMultipleCutsUI();
+ updateSelectorText();
+ console.log('All cuts hidden by user');
+}
+
+/**
+ * Handle cut selection change
+ */
+async function handleCutSelection(event) {
+ const selectedValue = event.target.value;
+
+ if (!selectedValue) {
+ // Reset selector to show current state
+ refreshSelectorUI();
+ return;
+ }
+
+ if (selectedValue === '__show_all__') {
+ // Show all cuts
+ const allCuts = window.cuts || [];
+ for (const cut of allCuts) {
+ const normalizedCut = normalizeCutData(cut);
+ if (!cutManager.isCutDisplayed(normalizedCut.id)) {
+ cutManager.displayCut(normalizedCut);
+ console.log(`Displayed cut: ${normalizedCut.name}`);
+ }
+ }
+ refreshSelectorUI();
+ updateLegendAndUI();
+ return;
+ }
+
+ if (selectedValue === '__hide_all__') {
+ // Hide all cuts
+ hideAllCuts();
+ return;
+ }
+
+ // Individual cut selection - toggle the cut
+ const cut = window.cuts?.find(c => {
+ const cutId = c.id || c.Id || c.ID;
+ return cutId == selectedValue;
+ });
+
+ if (cut) {
+ const normalizedCut = normalizeCutData(cut);
+
+ if (cutManager.isCutDisplayed(normalizedCut.id)) {
+ cutManager.hideCutById(normalizedCut.id);
+ console.log(`Hidden cut: ${normalizedCut.name}`);
+ } else {
+ cutManager.displayCut(normalizedCut);
+ console.log(`Displayed cut: ${normalizedCut.name}`);
+ }
+
+ // Refresh the selector to show updated state
+ refreshSelectorUI();
+ updateLegendAndUI();
+ }
+}
+
+/**
+ * Refresh the selector UI to show current state
+ */
+function refreshSelectorUI() {
+ // Re-populate the selector to update the icons
+ if (window.cuts && window.cuts.length > 0) {
+ populateCutSelector(window.cuts);
+ }
+
+ // Update the selector value based on displayed cuts
+ const displayedCuts = cutManager.getDisplayedCuts();
+
+ if (displayedCuts.length === 0) {
+ // No cuts displayed - reset to placeholder
+ cutSelector.value = '';
+ } else if (displayedCuts.length === 1) {
+ // Single cut displayed
+ cutSelector.value = displayedCuts[0].id;
+ } else {
+ // Multiple cuts displayed - show count in placeholder
+ cutSelector.value = '';
+ setTimeout(() => {
+ const firstOption = cutSelector.querySelector('option[value=""]');
+ if (firstOption) {
+ firstOption.textContent = `${displayedCuts.length} cuts active - Select actions...`;
+ }
+ }, 0);
+ }
+}
+
+/**
+ * Update legend and UI components
+ */
+function updateLegendAndUI() {
+ const activeCuts = cutManager.getDisplayedCuts();
+
+ if (activeCuts.length > 0) {
+ showMultipleCutsLegend(activeCuts);
+ updateCutControlsUI(activeCuts);
+ console.log(`Active cuts: ${activeCuts.map(c => c.name || 'Unnamed').join(', ')}`);
+ } else {
+ hideCutLegend();
+ updateCutControlsUI([]);
+ console.log('No cuts currently displayed');
+ }
+}
+
+/**
+ * Show cut legend with cut information
+ */
+function showCutLegend(cutData) {
+ if (!cutLegend) return;
+
+ // Update legend content
+ const legendColor = document.getElementById('legend-color');
+ const legendName = document.getElementById('legend-name');
+ const legendDescription = document.getElementById('legend-description');
+
+ // Handle different possible field names
+ const color = cutData.color || cutData.Color || '#3388ff';
+ const opacity = cutData.opacity || cutData.Opacity || 0.3;
+ const name = cutData.name || cutData.Name || 'Unnamed Cut';
+ const description = cutData.description || cutData.Description || '';
+
+ if (legendColor) {
+ legendColor.style.backgroundColor = color;
+ legendColor.style.opacity = opacity;
+ }
+
+ if (legendName) {
+ legendName.textContent = name;
+ }
+
+ if (legendDescription) {
+ legendDescription.textContent = description;
+ legendDescription.style.display = description ? 'block' : 'none';
+ }
+
+ // Show legend
+ cutLegend.classList.add('visible');
+
+ // Auto-expand legend content for first-time users
+ if (!legendExpanded) {
+ expandLegend();
+ }
+}
+
+/**
+ * Hide cut legend
+ */
+function hideCutLegend() {
+ if (!cutLegend) return;
+
+ cutLegend.classList.remove('visible');
+ collapseLegend();
+}
+
+/**
+ * Toggle cut legend expansion
+ */
+export function toggleCutLegend() {
+ if (legendExpanded) {
+ collapseLegend();
+ } else {
+ expandLegend();
+ }
+}
+
+/**
+ * Expand legend content
+ */
+function expandLegend() {
+ if (!cutLegendContent) return;
+
+ cutLegendContent.classList.add('expanded');
+ legendExpanded = true;
+
+ // Update toggle indicator
+ const toggle = cutLegend.querySelector('.legend-toggle');
+ if (toggle) {
+ toggle.textContent = '▲';
+ }
+}
+
+/**
+ * Collapse legend content
+ */
+function collapseLegend() {
+ if (!cutLegendContent) return;
+
+ cutLegendContent.classList.remove('expanded');
+ legendExpanded = false;
+
+ // Update toggle indicator
+ const toggle = cutLegend.querySelector('.legend-toggle');
+ if (toggle) {
+ toggle.textContent = '▼';
+ }
+}
+
+/**
+ * Refresh cut controls (e.g., when new cuts are added)
+ */
+export async function refreshCutControls() {
+ await loadAndPopulateCuts();
+}
+
+/**
+ * Get currently selected cut ID
+ */
+export function getCurrentCutSelection() {
+ return cutSelector ? cutSelector.value : null;
+}
+
+/**
+ * Set cut selection programmatically
+ */
+export function setCutSelection(cutId) {
+ if (!cutSelector) return false;
+
+ cutSelector.value = cutId;
+ cutSelector.dispatchEvent(new Event('change'));
+
+ // Also update mobile selection
+ const mobileRadio = document.querySelector(`input[name="mobile-overlay"][value="${cutId || ''}"]`);
+ if (mobileRadio) {
+ mobileRadio.checked = true;
+ updateMobileOverlayInfo(cutId);
+ }
+
+ return true;
+}
+
+/**
+ * Populate mobile overlay options with multi-select support
+ */
+function populateMobileOverlayOptions(cuts) {
+ console.log('populateMobileOverlayOptions called with', cuts?.length, 'cuts');
+
+ const container = document.getElementById('mobile-overlay-list'); // Changed from mobile-overlay-options
+ if (!container) {
+ console.warn('Mobile overlay list container not found');
+ return;
+ }
+
+ console.log('Mobile overlay list container found');
+ container.innerHTML = '';
+
+ if (!cuts || cuts.length === 0) {
+ container.innerHTML = '
No cuts available
';
+ return;
+ }
+
+ // Create checkboxes for each cut
+ cuts.forEach(cut => {
+ const cutId = cut.id || cut.Id || cut.ID;
+ const cutName = cut.name || cut.Name || 'Unnamed Cut';
+ const cutDescription = cut.description || cut.Description;
+ const cutColor = cut.color || cut.Color || '#3388ff';
+ const cutCategory = cut.category || cut.Category || 'Other';
+ const isDisplayed = cutManager.isCutDisplayed(cutId);
+
+ const optionDiv = document.createElement('div');
+ optionDiv.className = 'overlay-option';
+
+ const label = document.createElement('label');
+ label.className = 'cut-checkbox-label';
+
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ checkbox.name = 'mobile-cut';
+ checkbox.value = cutId;
+ checkbox.checked = isDisplayed;
+ checkbox.dataset.cutId = cutId;
+
+ const colorIndicator = document.createElement('span');
+ colorIndicator.className = 'cut-color-indicator';
+ colorIndicator.style.backgroundColor = cutColor;
+
+ const cutInfo = document.createElement('div');
+ cutInfo.className = 'cut-info';
+ cutInfo.innerHTML = `
+
${cutName}
+
${cutCategory}
+ ${cutDescription ? `
${cutDescription}
` : ''}
+ `;
+
+ label.appendChild(checkbox);
+ label.appendChild(colorIndicator);
+ label.appendChild(cutInfo);
+ optionDiv.appendChild(label);
+
+ container.appendChild(optionDiv);
+ });
+
+ // Update the active overlays info
+ updateMobileActiveOverlaysInfo();
+
+ console.log('Finished populating mobile overlay options with multi-select');
+}
+
+/**
+ * Open mobile overlay modal
+ */
+function openMobileOverlayModal() {
+ console.log('openMobileOverlayModal called');
+ console.log('mobileOverlayModal exists:', !!mobileOverlayModal);
+
+ if (!mobileOverlayModal) {
+ console.error('Mobile overlay modal not found!');
+ return;
+ }
+
+ // Refresh the mobile options to show current state
+ if (window.cuts && window.cuts.length > 0) {
+ populateMobileOverlayOptions(window.cuts);
+ }
+
+ // Show modal
+ mobileOverlayModal.classList.remove('hidden');
+ console.log('Mobile overlay modal opened with multi-select support');
+}
+
+/**
+ * Close mobile overlay modal
+ */
+function closeMobileOverlayModal() {
+ if (!mobileOverlayModal) return;
+
+ mobileOverlayModal.classList.add('hidden');
+
+ // Remove button active state
+ if (mobileOverlayBtn) {
+ mobileOverlayBtn.classList.remove('active');
+ }
+}
+
+/**
+ * Setup mobile overlay event listeners
+ */
+function setupMobileOverlayEventListeners() {
+ // Event delegation for mobile overlay modal
+ mobileOverlayModal.addEventListener('change', (e) => {
+ if (e.target.matches('input[type="checkbox"][name="mobile-cut"]')) {
+ const cutId = e.target.dataset.cutId;
+ handleMobileCutToggle(cutId);
+ }
+ });
+
+ // Handle click events for buttons in mobile overlay
+ mobileOverlayModal.addEventListener('click', (e) => {
+ if (e.target.matches('.btn[data-action="show-all"]')) {
+ showAllCutsMobile();
+ } else if (e.target.matches('.btn[data-action="hide-all"]')) {
+ hideAllCutsMobile();
+ } else if (e.target.matches('button[data-action="close-modal"]')) {
+ closeMobileOverlayModal();
+ }
+ });
+}
+
+/**
+ * Handle mobile cut toggle (checkbox)
+ */
+function handleMobileCutToggle(cutId) {
+ console.log('Mobile cut toggle:', cutId);
+
+ // Find the checkbox that triggered this event - fix the selector
+ const checkbox = document.querySelector(`input[name="mobile-cut"][data-cut-id="${cutId}"]`);
+ if (!checkbox) {
+ console.warn('Mobile checkbox not found for cutId:', cutId);
+ console.warn('Available mobile checkboxes:', document.querySelectorAll('input[name="mobile-cut"]'));
+ return;
+ }
+
+ const isChecked = checkbox.checked;
+ console.log('Mobile checkbox checked state:', isChecked);
+
+ const cut = window.cuts?.find(c => {
+ const id = c.id || c.Id || c.ID;
+ return id == cutId;
+ });
+
+ if (cut) {
+ const normalizedCut = normalizeCutData(cut);
+ console.log('Mobile toggle - normalized cut ID:', normalizedCut.id, 'original cutId:', cutId);
+
+ if (isChecked) {
+ // Checkbox is checked - show the cut
+ if (!cutManager.isCutDisplayed(cutId)) {
+ cutManager.displayCut(normalizedCut);
+ console.log(`Mobile: Displayed cut: ${normalizedCut.name}`);
+ }
+ } else {
+ // Checkbox is unchecked - hide the cut
+ console.log(`Mobile: Attempting to hide cut: ${normalizedCut.name} with ID: ${normalizedCut.id}`);
+
+ // Try different ID formats to ensure the hide works
+ let hideResult = cutManager.hideCutById(normalizedCut.id);
+ if (!hideResult) {
+ hideResult = cutManager.hideCutById(String(normalizedCut.id));
+ }
+ if (!hideResult) {
+ hideResult = cutManager.hideCutById(Number(normalizedCut.id));
+ }
+
+ console.log(`Mobile: Hide result: ${hideResult}`);
+
+ if (hideResult) {
+ console.log(`Mobile: Successfully hidden cut: ${normalizedCut.name}`);
+ } else {
+ console.error(`Mobile: Failed to hide cut: ${normalizedCut.name} with ID: ${normalizedCut.id}`);
+ }
+ }
+
+ // Update mobile UI
+ updateMobileActiveOverlaysInfo();
+
+ // Update desktop UI as well
+ updateCheckboxStates(); // Sync desktop checkboxes
+ updateSelectorText();
+ updateMultipleCutsUI();
+ } else {
+ console.warn('Mobile: Cut not found for ID:', cutId);
+ }
+}
+
+/**
+ * Handle mobile overlay selection (legacy - for compatibility)
+ */
+function handleMobileOverlaySelection(cutId) {
+ // Redirect to new toggle function
+ handleMobileCutToggle(cutId);
+}
+
+/**
+ * Show all cuts (mobile)
+ */
+function showAllCutsMobile() {
+ const allCuts = window.cuts || [];
+ for (const cut of allCuts) {
+ const normalizedCut = normalizeCutData(cut);
+ if (!cutManager.isCutDisplayed(normalizedCut.id)) {
+ cutManager.displayCut(normalizedCut);
+ }
+ }
+
+ // Update all mobile checkboxes
+ const checkboxes = document.querySelectorAll('input[name="mobile-cut"]');
+ checkboxes.forEach(cb => cb.checked = true);
+
+ // Update desktop checkboxes too
+ updateCheckboxStates();
+
+ updateMobileActiveOverlaysInfo();
+ updateSelectorText();
+ updateMultipleCutsUI();
+
+ console.log('Mobile: All cuts shown');
+}
+
+/**
+ * Hide all cuts (mobile)
+ */
+function hideAllCutsMobile() {
+ cutManager.hideAllCuts();
+
+ // Update all mobile checkboxes
+ const checkboxes = document.querySelectorAll('input[name="mobile-cut"]');
+ checkboxes.forEach(cb => cb.checked = false);
+
+ // Update desktop checkboxes too
+ updateCheckboxStates();
+
+ updateMobileActiveOverlaysInfo();
+ updateSelectorText();
+ updateMultipleCutsUI();
+
+ console.log('Mobile: All cuts hidden');
+}
+
+/**
+ * Update mobile overlay info display
+ */
+async function updateMobileOverlayInfo(cutId) {
+ const infoContainer = document.getElementById('mobile-current-overlay-info');
+ const colorDiv = document.getElementById('mobile-overlay-color');
+ const nameDiv = document.getElementById('mobile-overlay-name');
+ const descDiv = document.getElementById('mobile-overlay-description');
+
+ if (!infoContainer || !colorDiv || !nameDiv || !descDiv) return;
+
+ if (!cutId) {
+ infoContainer.style.display = 'none';
+ return;
+ }
+
+ try {
+ const response = await fetch(`/api/cuts/public`);
+ const data = await response.json();
+ const cut = data.list?.find(c => c.id == cutId);
+
+ if (cut) {
+ colorDiv.style.backgroundColor = cut.color || '#3388ff';
+ nameDiv.textContent = cut.name;
+ descDiv.textContent = cut.description || 'No description available';
+ infoContainer.style.display = 'block';
+ } else {
+ infoContainer.style.display = 'none';
+ }
+ } catch (error) {
+ console.error('Error fetching cut info:', error);
+ infoContainer.style.display = 'none';
+ }
+}
+
+/**
+ * Update mobile active overlays info
+ */
+function updateMobileActiveOverlaysInfo() {
+ const activeCountEl = document.getElementById('mobile-active-count');
+ const overlayListEl = document.getElementById('mobile-overlay-list');
+
+ if (!activeCountEl || !overlayListEl) return;
+
+ const activeCuts = cutManager.getDisplayedCuts();
+ activeCountEl.textContent = `(${activeCuts.length})`;
+
+ if (activeCuts.length === 0) {
+ overlayListEl.innerHTML = '
No active overlays
';
+ return;
+ }
+
+ overlayListEl.innerHTML = activeCuts.map(cut => {
+ const cutColor = cut.color || cut.Color || '#3388ff';
+ const cutName = cut.name || cut.Name || 'Unnamed Cut';
+ const cutDescription = cut.description || cut.Description;
+
+ return `
+
+
+
+
${cutName}
+ ${cutDescription ? `
${cutDescription}
` : ''}
+
+
+ `;
+ }).join('');
+}
+
+/**
+ * Update UI controls to show multiple active cuts
+ */
+function updateCutControlsUI(activeCuts) {
+ if (!cutSelector || !activeCuts || activeCuts.length === 0) return;
+
+ // Update selector to show multiple active cuts
+ const allCuts = window.cuts || [];
+ cutSelector.innerHTML = `
+
+
+ ${allCuts.map(cut => {
+ // Normalize field names
+ const cutId = cut.id || cut.Id || cut.ID;
+ const cutName = cut.name || cut.Name || 'Unnamed Cut';
+ const isActive = cutManager.isCutDisplayed(cutId);
+ return `
`;
+ }).join('')}
+
+ `;
+}
+
+/**
+ * Show legend for multiple active cuts
+ */
+function showMultipleCutsLegend(activeCuts) {
+ if (!cutLegend || !cutLegendContent) return;
+
+ cutLegend.style.display = 'block';
+
+ // Create header
+ const header = document.createElement('div');
+ header.className = 'legend-header';
+
+ const title = document.createElement('h3');
+ title.textContent = `📍 Active Cut Overlays (${activeCuts.length})`;
+
+ const toggleBtn = document.createElement('button');
+ toggleBtn.className = 'legend-toggle';
+ toggleBtn.textContent = '▼';
+ toggleBtn.dataset.action = 'toggle-legend';
+
+ header.appendChild(title);
+ header.appendChild(toggleBtn);
+
+ // Create content container
+ const content = document.createElement('div');
+ content.className = 'legend-content';
+
+ // Add cut items
+ activeCuts.forEach(cut => {
+ const cutId = cut.id || cut.Id || cut.ID;
+ const cutName = cut.name || cut.Name || 'Unnamed Cut';
+ const cutDescription = cut.description || cut.Description;
+ const cutColor = cut.color || cut.Color || '#3388ff';
+
+ const item = document.createElement('div');
+ item.className = 'cut-legend-item';
+ item.dataset.cutId = cutId;
+
+ const colorSpan = document.createElement('span');
+ colorSpan.className = 'overlay-color';
+ colorSpan.style.backgroundColor = cutColor;
+
+ const details = document.createElement('div');
+ details.className = 'overlay-details';
+
+ const nameDiv = document.createElement('div');
+ nameDiv.className = 'overlay-name';
+ nameDiv.textContent = cutName;
+ details.appendChild(nameDiv);
+
+ if (cutDescription) {
+ const descDiv = document.createElement('div');
+ descDiv.className = 'overlay-description';
+ descDiv.textContent = cutDescription;
+ details.appendChild(descDiv);
+ }
+
+ const toggleBtn = document.createElement('button');
+ toggleBtn.className = 'cut-toggle-btn';
+ toggleBtn.title = 'Toggle visibility';
+ toggleBtn.textContent = '👁️';
+ toggleBtn.dataset.cutId = cutId;
+ toggleBtn.dataset.action = 'toggle-cut';
+
+ item.appendChild(colorSpan);
+ item.appendChild(details);
+ item.appendChild(toggleBtn);
+ content.appendChild(item);
+ });
+
+ // Add actions
+ const actions = document.createElement('div');
+ actions.className = 'legend-actions';
+
+ const hideAllBtn = document.createElement('button');
+ hideAllBtn.className = 'btn btn-sm';
+ hideAllBtn.textContent = 'Hide All';
+ hideAllBtn.dataset.action = 'hide-all';
+
+ actions.appendChild(hideAllBtn);
+ content.appendChild(actions);
+
+ // Clear and rebuild content
+ cutLegendContent.innerHTML = '';
+ cutLegendContent.appendChild(header);
+ cutLegendContent.appendChild(content);
+
+ // Setup event listeners for this legend
+ setupLegendEventListeners();
+}
+
+/**
+ * Setup legend event listeners
+ */
+function setupLegendEventListeners() {
+ if (!cutLegend) return;
+
+ // Remove existing listeners to prevent duplicates
+ const clonedLegend = cutLegend.cloneNode(true);
+ cutLegend.parentNode.replaceChild(clonedLegend, cutLegend);
+ cutLegend = clonedLegend;
+ cutLegendContent = cutLegend.querySelector('#cut-legend-content');
+
+ // Add event delegation for legend
+ cutLegend.addEventListener('click', (e) => {
+ if (e.target.matches('button[data-action="toggle-legend"]')) {
+ toggleCutLegend();
+ } else if (e.target.matches('button[data-action="toggle-cut"]')) {
+ const cutId = e.target.dataset.cutId;
+ toggleIndividualCut(cutId);
+ } else if (e.target.matches('button[data-action="hide-all"]')) {
+ hideAllCuts();
+ }
+ });
+}
+
+/**
+ * Toggle individual cut visibility
+ */
+function toggleIndividualCut(cutId) {
+ if (cutManager.isCutDisplayed(cutId)) {
+ cutManager.hideCutById(cutId);
+ console.log(`Hidden cut ID: ${cutId}`);
+ } else {
+ const cut = window.cuts?.find(c => {
+ const id = c.id || c.Id || c.ID;
+ return id == cutId;
+ });
+ if (cut) {
+ const normalizedCut = normalizeCutData(cut);
+ cutManager.displayCut(normalizedCut);
+ console.log(`Displayed cut: ${normalizedCut.name}`);
+ }
+ }
+
+ // Refresh the UI
+ const activeCuts = cutManager.getDisplayedCuts();
+ if (activeCuts.length > 0) {
+ showMultipleCutsLegend(activeCuts);
+ updateCutControlsUI(activeCuts);
+ } else {
+ hideCutLegend();
+ updateCutControlsUI([]);
+ }
+}
+
+/**
+ * Toggle display of individual cut
+ */
+window.toggleCutDisplay = async function(cutId) {
+ const cut = window.cuts?.find(c => {
+ const normalized = normalizeCutData(c);
+ return normalized.id == cutId;
+ });
+
+ if (cut) {
+ const normalized = normalizeCutData(cut);
+ if (cutManager.isCutDisplayed(cutId)) {
+ cutManager.hideCutById(cutId);
+ } else {
+ cutManager.displayCut(normalized);
+ }
+ updateMultipleCutsUI();
+ }
+};
+
+/**
+ * Show all cuts
+ */
+window.showAllCuts = async function() {
+ await showAllCuts();
+ updateCheckboxStates();
+};
+
+/**
+ * Hide all cuts
+ */
+window.hideAllCuts = async function() {
+ await hideAllCuts();
+ updateCheckboxStates();
+};
+
+/**
+ * Update UI for multiple active cuts
+ */
+function updateMultipleCutsUI() {
+ const activeCuts = cutManager.getDisplayedCuts();
+
+ // Update selector text - now using button instead of select
+ if (cutSelector) {
+ if (activeCuts.length === 0) {
+ cutSelector.textContent = 'Select map overlays...';
+ } else if (activeCuts.length === 1) {
+ cutSelector.textContent = `📍 ${activeCuts[0].name}`;
+ } else {
+ cutSelector.textContent = `📍 ${activeCuts.length} overlays active`;
+ }
+ }
+
+ // Update legend for multiple cuts
+ if (activeCuts.length > 0) {
+ showMultipleCutsLegend(activeCuts);
+ } else {
+ hideCutLegend();
+ }
+}
+
+/**
+ * Make functions available globally for external access and backwards compatibility
+ */
+window.toggleCutDisplay = async function(cutId) {
+ const cut = window.cuts?.find(c => {
+ const normalized = normalizeCutData(c);
+ return String(normalized.id) === String(cutId);
+ });
+
+ if (cut) {
+ if (cutManager.isCutDisplayed(cutId)) {
+ cutManager.hideCutById(cutId);
+ } else {
+ cutManager.displayCut(cut);
+ }
+ updateMultipleCutsUI();
+ updateSelectorText();
+ }
+};
+
+// Assign other functions to window for global access
+window.showAllCutsMobile = showAllCutsMobile;
+window.hideAllCutsMobile = hideAllCutsMobile;
+window.toggleCutLegend = toggleCutLegend;
+window.toggleIndividualCut = toggleIndividualCut;
+window.handleMobileCutToggle = handleMobileCutToggle;
+
+window.openMobileOverlayModal = function() {
+ if (mobileOverlayModal) {
+ // Populate mobile overlay list
+ populateMobileOverlayOptions(window.cuts || []);
+ mobileOverlayModal.classList.remove('hidden');
+ }
+};
+
+window.closeMobileOverlayModal = function() {
+ if (mobileOverlayModal) {
+ mobileOverlayModal.classList.add('hidden');
+ }
+};
+
+// Debug: Log that functions are available
+console.log('Cut controls functions made global:', {
+ toggleCutDisplay: typeof window.toggleCutDisplay,
+ showAllCuts: typeof window.showAllCuts,
+ hideAllCuts: typeof window.hideAllCuts,
+ openMobileOverlayModal: typeof window.openMobileOverlayModal,
+ closeMobileOverlayModal: typeof window.closeMobileOverlayModal,
+ toggleCutLegend: typeof window.toggleCutLegend
+});
diff --git a/map/app/public/js/cut-drawing.js b/map/app/public/js/cut-drawing.js
new file mode 100644
index 0000000..faddfbf
--- /dev/null
+++ b/map/app/public/js/cut-drawing.js
@@ -0,0 +1,336 @@
+/**
+ * Cut Drawing Module
+ * Handles polygon drawing functionality for creating map cuts
+ */
+
+export class CutDrawing {
+ constructor(map, options = {}) {
+ this.map = map;
+ this.vertices = [];
+ this.markers = [];
+ this.polyline = null;
+ this.previewPolygon = null; // Add preview polygon
+ this.isDrawing = false;
+ this.onComplete = options.onComplete || null;
+ }
+
+ /**
+ * Start drawing mode
+ */
+ startDrawing(onFinish, onCancel) {
+ if (this.isDrawing) {
+ this.cancelDrawing();
+ }
+
+ this.isDrawing = true;
+ this.onFinishCallback = onFinish;
+ this.onCancelCallback = onCancel;
+ this.vertices = [];
+ this.markers = [];
+
+ // Change cursor and add click listener
+ this.map.getContainer().style.cursor = 'crosshair';
+ this.map.on('click', this.onMapClick.bind(this));
+
+ // Disable double-click zoom while drawing
+ this.map.doubleClickZoom.disable();
+
+ console.log('Cut drawing started - click to add points');
+ }
+
+ /**
+ * Handle map clicks to add vertices
+ */
+ onMapClick(e) {
+ if (!this.isDrawing) return;
+
+ // Add vertex marker
+ const marker = L.marker(e.latlng, {
+ icon: L.divIcon({
+ className: 'cut-vertex-marker',
+ html: '
',
+ iconSize: [12, 12],
+ iconAnchor: [6, 6]
+ }),
+ draggable: false
+ }).addTo(this.map);
+
+ this.vertices.push(e.latlng);
+ this.markers.push(marker);
+
+ // Update the polyline
+ this.updatePolyline();
+
+ // Call update callback if available
+ if (this.onUpdate) {
+ this.onUpdate();
+ }
+
+ console.log(`Added vertex ${this.vertices.length} at`, e.latlng);
+ }
+
+ /**
+ * Update the polyline connecting vertices
+ */
+ updatePolyline() {
+ // Remove existing polyline
+ if (this.polyline) {
+ this.map.removeLayer(this.polyline);
+ }
+
+ if (this.vertices.length > 1) {
+ // Create polyline connecting all vertices
+ this.polyline = L.polyline(this.vertices, {
+ color: '#3388ff',
+ weight: 2,
+ dashArray: '5, 5',
+ opacity: 0.8
+ }).addTo(this.map);
+ }
+ }
+
+ /**
+ * Finish drawing and create polygon
+ */
+ finishDrawing() {
+ if (this.vertices.length < 3) {
+ alert('A cut must have at least 3 points');
+ return;
+ }
+
+ // Create the polygon
+ const latlngs = this.vertices.map(v => v.getLatLng());
+
+ // Close the polygon
+ latlngs.push(latlngs[0]);
+
+ // Generate GeoJSON
+ const geojson = {
+ type: 'Polygon',
+ coordinates: [latlngs.map(ll => [ll.lng, ll.lat])]
+ };
+
+ // Calculate bounds
+ const bounds = {
+ north: Math.max(...latlngs.map(ll => ll.lat)),
+ south: Math.min(...latlngs.map(ll => ll.lat)),
+ east: Math.max(...latlngs.map(ll => ll.lng)),
+ west: Math.min(...latlngs.map(ll => ll.lng))
+ };
+
+ console.log('Cut drawing finished with', this.vertices.length, 'vertices');
+
+ // Show preview before clearing drawing
+ const color = document.getElementById('cut-color')?.value || '#3388ff';
+ const opacity = parseFloat(document.getElementById('cut-opacity')?.value) || 0.3;
+ this.showPreview(geojson, color, opacity);
+
+ // Clean up drawing elements
+ this.clearDrawing();
+
+ // Call completion callback with the data
+ if (this.onComplete && typeof this.onComplete === 'function') {
+ console.log('Calling completion callback with geojson and bounds');
+ this.onComplete(geojson, bounds);
+ } else {
+ console.error('No completion callback defined');
+ }
+
+ // Reset state
+ this.isDrawing = false;
+ this.updateToolbar();
+ }
+
+ /**
+ * Cancel drawing
+ */
+ cancelDrawing() {
+ if (!this.isDrawing) return;
+
+ console.log('Cut drawing cancelled');
+ this.cleanup();
+
+ if (this.onCancelCallback) {
+ this.onCancelCallback();
+ }
+ }
+
+ /**
+ * Remove the last added vertex
+ */
+ undoLastVertex() {
+ if (!this.isDrawing || this.vertices.length === 0) return;
+
+ // Remove last vertex and marker
+ this.vertices.pop();
+ const lastMarker = this.markers.pop();
+ if (lastMarker) {
+ this.map.removeLayer(lastMarker);
+ }
+
+ // Update polyline
+ this.updatePolyline();
+
+ // Call update callback if available
+ if (this.onUpdate) {
+ this.onUpdate();
+ }
+
+ console.log('Removed last vertex, remaining:', this.vertices.length);
+ }
+
+ /**
+ * Clear all vertices and start over
+ */
+ clearVertices() {
+ if (!this.isDrawing) return;
+
+ // Remove all markers
+ this.markers.forEach(marker => {
+ this.map.removeLayer(marker);
+ });
+
+ // Remove polyline
+ if (this.polyline) {
+ this.map.removeLayer(this.polyline);
+ this.polyline = null;
+ }
+
+ // Reset arrays
+ this.vertices = [];
+ this.markers = [];
+
+ // Call update callback if available
+ if (this.onUpdate) {
+ this.onUpdate();
+ }
+
+ console.log('Cleared all vertices');
+ }
+
+ /**
+ * Cleanup drawing state
+ */
+ cleanup() {
+ // Remove all markers
+ this.markers.forEach(marker => {
+ this.map.removeLayer(marker);
+ });
+
+ // Remove polyline
+ if (this.polyline) {
+ this.map.removeLayer(this.polyline);
+ }
+
+ // Reset cursor
+ this.map.getContainer().style.cursor = '';
+
+ // Remove event listeners
+ this.map.off('click', this.onMapClick);
+
+ // Re-enable double-click zoom
+ this.map.doubleClickZoom.enable();
+
+ // Reset state
+ this.isDrawing = false;
+ this.vertices = [];
+ this.markers = [];
+ this.polyline = null;
+ this.onFinishCallback = null;
+ this.onCancelCallback = null;
+ }
+
+ /**
+ * Get current drawing state
+ */
+ getState() {
+ return {
+ isDrawing: this.isDrawing,
+ vertexCount: this.vertices.length,
+ canFinish: this.vertices.length >= 3
+ };
+ }
+
+ /**
+ * Preview polygon without finishing
+ */
+ showPreview(geojson, color = '#3388ff', opacity = 0.3) {
+ this.clearPreview();
+
+ if (!geojson) return;
+
+ try {
+ const coordinates = geojson.coordinates[0];
+ const latlngs = coordinates.map(coord => L.latLng(coord[1], coord[0]));
+
+ this.previewPolygon = L.polygon(latlngs, {
+ color: color,
+ weight: 2,
+ opacity: 0.8,
+ fillColor: color,
+ fillOpacity: opacity,
+ className: 'cut-preview-polygon'
+ }).addTo(this.map);
+
+ // Add CSS class for opacity control
+ const pathElement = this.previewPolygon.getElement();
+ if (pathElement) {
+ pathElement.classList.add('cut-polygon');
+ console.log('Added cut-polygon class to preview polygon');
+ }
+
+ console.log('Preview polygon shown with opacity:', opacity);
+ } catch (error) {
+ console.error('Error showing preview polygon:', error);
+ }
+ }
+
+ /**
+ * Update preview polygon style without recreating it
+ */
+ updatePreview(color = '#3388ff', opacity = 0.3) {
+ if (this.previewPolygon) {
+ this.previewPolygon.setStyle({
+ color: color,
+ weight: 2,
+ opacity: 0.8,
+ fillColor: color,
+ fillOpacity: opacity
+ });
+
+ // Ensure CSS class is still present
+ const pathElement = this.previewPolygon.getElement();
+ if (pathElement) {
+ pathElement.classList.add('cut-polygon');
+ }
+
+ console.log('Preview polygon style updated with opacity:', opacity);
+ }
+ }
+
+ clearPreview() {
+ if (this.previewPolygon) {
+ this.map.removeLayer(this.previewPolygon);
+ this.previewPolygon = null;
+ }
+ }
+
+ /**
+ * Update drawing style (called from admin cuts manager)
+ */
+ updateDrawingStyle(color = '#3388ff', opacity = 0.3) {
+ // Update the polyline connecting vertices if it exists
+ if (this.polyline) {
+ this.polyline.setStyle({
+ color: color,
+ weight: 2,
+ opacity: 0.8
+ });
+ }
+
+ // Update preview polygon if it exists
+ this.updatePreview(color, opacity);
+
+ console.log('Cut drawing style updated with color:', color, 'opacity:', opacity);
+ }
+}
diff --git a/map/app/public/js/cut-manager.js b/map/app/public/js/cut-manager.js
new file mode 100644
index 0000000..f0455de
--- /dev/null
+++ b/map/app/public/js/cut-manager.js
@@ -0,0 +1,502 @@
+/**
+ * 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;
+ }
+ }
+
+ /**
+ * Display a cut on the map (enhanced to support multiple cuts)
+ */
+ /**
+ * Display a cut on the map (enhanced to support multiple cuts)
+ */
+ 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;
+
+ const cutLayer = L.geoJSON(geojsonData, {
+ style: {
+ color: normalizedCut.color || '#3388ff',
+ fillColor: normalizedCut.color || '#3388ff',
+ fillOpacity: parseFloat(normalizedCut.opacity) || 0.3,
+ weight: 2,
+ opacity: 1,
+ className: 'cut-polygon'
+ }
+ });
+
+ // Add popup with cut info
+ cutLayer.bindPopup(`
+
+ `);
+
+ cutLayer.addTo(this.map);
+
+ // 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})`);
+ return true;
+
+ } catch (error) {
+ console.error('Error displaying cut:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Hide the currently displayed cut (legacy method - now hides all cuts)
+ */
+ 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();
diff --git a/map/app/public/js/location-manager.js b/map/app/public/js/location-manager.js
index 5a49d49..c50343d 100644
--- a/map/app/public/js/location-manager.js
+++ b/map/app/public/js/location-manager.js
@@ -545,29 +545,67 @@ export async function handleDeleteLocation() {
export function closeAddModal() {
const modal = document.getElementById('add-modal');
- modal.classList.add('hidden');
- document.getElementById('location-form').reset();
+ if (modal) {
+ modal.classList.add('hidden');
+ }
+
+ // Try to find and reset the form with multiple possible IDs
+ const form = document.getElementById('location-form') ||
+ document.getElementById('add-location-form');
+ if (form) {
+ form.reset();
+ }
}
export function openAddModal(lat, lng, performLookup = true) {
const modal = document.getElementById('add-modal');
- const latInput = document.getElementById('location-lat');
- const lngInput = document.getElementById('location-lng');
- const geoInput = document.getElementById('geo-location');
+
+ if (!modal) {
+ console.error('Add modal not found');
+ return;
+ }
+
+ // Try multiple possible field IDs for coordinates
+ const latInput = document.getElementById('location-lat') ||
+ document.getElementById('add-latitude') ||
+ document.getElementById('latitude');
+ const lngInput = document.getElementById('location-lng') ||
+ document.getElementById('add-longitude') ||
+ document.getElementById('longitude');
+ const geoInput = document.getElementById('geo-location') ||
+ document.getElementById('add-geo-location') ||
+ document.getElementById('Geo-Location');
// Reset address confirmation state
resetAddressConfirmation('add');
- // Set coordinates
- latInput.value = lat.toFixed(8);
- lngInput.value = lng.toFixed(8);
- geoInput.value = `${lat.toFixed(8)};${lng.toFixed(8)}`;
+ // Set coordinates if input fields exist
+ if (latInput && lngInput) {
+ latInput.value = lat.toFixed(8);
+ lngInput.value = lng.toFixed(8);
+ }
- // Clear other fields
- document.getElementById('location-form').reset();
- latInput.value = lat.toFixed(8);
- lngInput.value = lng.toFixed(8);
- geoInput.value = `${lat.toFixed(8)};${lng.toFixed(8)}`;
+ if (geoInput) {
+ geoInput.value = `${lat.toFixed(8)};${lng.toFixed(8)}`;
+ }
+
+ // Try to find and reset the form
+ const form = document.getElementById('location-form') ||
+ document.getElementById('add-location-form');
+
+ if (form) {
+ // Clear other fields but preserve coordinates
+ const tempLat = lat.toFixed(8);
+ const tempLng = lng.toFixed(8);
+ const tempGeo = `${tempLat};${tempLng}`;
+
+ form.reset();
+
+ // Restore coordinates after reset
+ if (latInput) latInput.value = tempLat;
+ if (lngInput) lngInput.value = tempLng;
+ if (geoInput) geoInput.value = tempGeo;
+ }
// Show modal
modal.classList.remove('hidden');
diff --git a/map/app/public/js/main.js b/map/app/public/js/main.js
index bdcae8a..0591ae5 100644
--- a/map/app/public/js/main.js
+++ b/map/app/public/js/main.js
@@ -2,10 +2,12 @@
import { CONFIG, loadDomainConfig } from './config.js';
import { hideLoading, showStatus, setViewportDimensions } from './utils.js';
import { checkAuth } from './auth.js';
-import { initializeMap } from './map-manager.js';
+import { initializeMap, getMap } from './map-manager.js';
import { loadLocations } from './location-manager.js';
import { setupEventListeners } from './ui-controls.js';
import { UnifiedSearchManager } from './search-manager.js';
+import { cutManager } from './cut-manager.js';
+import { initializeCutControls } from './cut-controls.js';
// Application state
let refreshInterval = null;
@@ -36,6 +38,12 @@ document.addEventListener('DOMContentLoaded', async () => {
// Then initialize the map
await initializeMap();
+ // Initialize cut manager after map is ready
+ await cutManager.initialize(getMap());
+
+ // Initialize cut controls for public map
+ await initializeCutControls();
+
// Only load locations after map is ready
await loadLocations();
diff --git a/map/app/public/js/map-manager.js b/map/app/public/js/map-manager.js
index 9078f9f..844aa41 100644
--- a/map/app/public/js/map-manager.js
+++ b/map/app/public/js/map-manager.js
@@ -7,6 +7,11 @@ export let map = null;
export let startLocationMarker = null;
export let isStartLocationVisible = true;
+// Function to get the map instance
+export function getMap() {
+ return map;
+}
+
export async function initializeMap() {
try {
// Get start location from PUBLIC endpoint (not admin endpoint)
diff --git a/map/app/public/js/map-search.js b/map/app/public/js/map-search.js
index 39ea380..e3b0fb3 100644
--- a/map/app/public/js/map-search.js
+++ b/map/app/public/js/map-search.js
@@ -98,7 +98,7 @@ export class MapSearch {
*/
selectResult(result) {
if (!map) {
- console.error('Map not available');
+ console.error('Map not initialized');
return;
}
@@ -107,7 +107,7 @@ export class MapSearch {
const lng = parseFloat(result.coordinates?.lng || result.longitude || 0);
if (isNaN(lat) || isNaN(lng)) {
- console.error('Invalid coordinates in result:', result);
+ console.error('Invalid coordinates:', result);
return;
}
@@ -121,34 +121,37 @@ export class MapSearch {
this.tempMarker = L.marker([lat, lng], {
icon: L.divIcon({
className: 'temp-search-marker',
- html: '📍',
+ html: '
',
iconSize: [30, 30],
iconAnchor: [15, 30]
})
}).addTo(map);
- // Create popup with add location option
- const popupContent = `
-
+ // Create popup content without inline handlers
+ const popupContent = document.createElement('div');
+ popupContent.className = 'search-result-popup';
+ popupContent.innerHTML = `
+
${result.formattedAddress || 'Search Result'}
+
${result.fullAddress || ''}
+
`;
+ // Bind the popup
this.tempMarker.bindPopup(popupContent).openPopup();
- // Auto-clear the marker after 30 seconds
+ // Add event listener after popup is opened
setTimeout(() => {
- this.clearTempMarker();
- }, 30000);
+ const addBtn = document.querySelector('.search-add-location-btn');
+ if (addBtn) {
+ addBtn.addEventListener('click', (e) => {
+ const btnLat = parseFloat(e.target.dataset.lat);
+ const btnLng = parseFloat(e.target.dataset.lng);
+ this.openAddLocationModal(btnLat, btnLng);
+ });
+ }
+ }, 100);
}
/**
diff --git a/map/app/public/js/ui-controls.js b/map/app/public/js/ui-controls.js
index 7d23599..a059441 100644
--- a/map/app/public/js/ui-controls.js
+++ b/map/app/public/js/ui-controls.js
@@ -492,6 +492,17 @@ export function setupEventListeners() {
document.getElementById('mobile-geolocate-btn')?.addEventListener('click', getUserLocation);
document.getElementById('mobile-toggle-start-location-btn')?.addEventListener('click', toggleStartLocationVisibility);
document.getElementById('mobile-add-location-btn')?.addEventListener('click', toggleAddLocationMode);
+ document.getElementById('mobile-overlay-btn')?.addEventListener('click', () => {
+ console.log('Mobile overlay button clicked!');
+ // Call the global function to open mobile overlay modal
+ if (window.openMobileOverlayModal) {
+ console.log('openMobileOverlayModal function found - calling it');
+ window.openMobileOverlayModal();
+ } else {
+ console.error('openMobileOverlayModal function not available');
+ console.log('Available window functions:', Object.keys(window).filter(k => k.includes('overlay') || k.includes('Modal')));
+ }
+ });
document.getElementById('mobile-toggle-edmonton-layer-btn')?.addEventListener('click', toggleEdmontonParcelsLayer);
document.getElementById('mobile-fullscreen-btn')?.addEventListener('click', toggleFullscreen);
diff --git a/map/app/routes/cuts.js b/map/app/routes/cuts.js
new file mode 100644
index 0000000..325a5ec
--- /dev/null
+++ b/map/app/routes/cuts.js
@@ -0,0 +1,30 @@
+const express = require('express');
+const router = express.Router();
+const cutsController = require('../controllers/cutsController');
+const { requireAuth, requireAdmin } = require('../middleware/auth');
+const config = require('../config');
+
+// Add middleware to check if cuts table is configured
+const checkCutsTable = (req, res, next) => {
+ if (!config.nocodb.cutsSheetId) {
+ console.warn('Cuts table not configured - NOCODB_CUTS_SHEET not set');
+ // Continue anyway, controller will handle it
+ }
+ next();
+};
+
+// Apply the check to all routes
+router.use(checkCutsTable);
+
+// Get all cuts (filtered by permissions)
+router.get('/', cutsController.getAll);
+
+// Get single cut by ID
+router.get('/:id', cutsController.getById);
+
+// Admin only routes
+router.post('/', requireAdmin, cutsController.create);
+router.put('/:id', requireAdmin, cutsController.update);
+router.delete('/:id', requireAdmin, cutsController.delete);
+
+module.exports = router;
diff --git a/map/app/routes/index.js b/map/app/routes/index.js
index c2f7dc9..7a26a92 100644
--- a/map/app/routes/index.js
+++ b/map/app/routes/index.js
@@ -13,6 +13,7 @@ const debugRoutes = require('./debug');
const geocodingRoutes = require('../routes/geocoding'); // Existing geocoding routes
const shiftsRoutes = require('./shifts');
const externalDataRoutes = require('./external');
+const cutsRoutes = require('./cuts');
module.exports = (app) => {
// Health check (no auth)
@@ -44,9 +45,12 @@ module.exports = (app) => {
// QR code routes (authenticated)
app.use('/api/qr', requireAuth, qrRoutes);
+ // Public cuts endpoint (no auth required)
+ app.get('/api/cuts/public', require('../controllers/cutsController').getPublic);
+
// Test QR page (no auth for testing)
app.get('/test-qr', (req, res) => {
- res.sendFile(path.join(__dirname, '../public', 'test-qr.html'));
+ res.sendFile(path.join(__dirname, '../public/test-qr.html'));
});
// Protected routes
@@ -56,6 +60,9 @@ module.exports = (app) => {
app.use('/api/shifts', requireNonTemp, shiftsRoutes);
app.use('/api/external', externalDataRoutes);
+ // Cuts routes (add after other protected routes)
+ app.use('/api/cuts', requireAuth, cutsRoutes);
+
// Admin routes
app.get('/admin.html', requireAdmin, (req, res) => {
res.sendFile(path.join(__dirname, '../public', 'admin.html'));
@@ -66,6 +73,28 @@ module.exports = (app) => {
// Debug routes (admin only)
app.use('/api/debug', requireAdmin, debugRoutes);
+ // Debug cuts endpoint to see raw field names
+ app.get('/api/debug/cuts-raw', requireAdmin, async (req, res) => {
+ try {
+ const config = require('../config');
+ const nocodbService = require('../services/nocodb');
+
+ if (!config.nocodb.cutsSheetId) {
+ return res.json({ error: 'Cuts table not configured' });
+ }
+
+ const response = await nocodbService.getAll(config.nocodb.cutsSheetId);
+
+ res.json({
+ totalCuts: response?.list?.length || 0,
+ sampleCut: response?.list?.[0] || null,
+ allFields: response?.list?.[0] ? Object.keys(response.list[0]) : []
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+ });
+
// Config check endpoint (authenticated)
app.get('/api/config-check', requireAuth, (req, res) => {
const config = require('../config');
@@ -77,10 +106,12 @@ module.exports = (app) => {
hasTableId: !!config.nocodb.tableId,
hasLoginSheet: !!config.nocodb.loginSheetId,
hasSettingsSheet: !!config.nocodb.settingsSheetId,
+ hasCutsSheet: !!config.nocodb.cutsSheetId,
projectId: config.nocodb.projectId,
tableId: config.nocodb.tableId,
loginSheet: config.nocodb.loginSheetId,
settingsSheet: config.nocodb.settingsSheetId,
+ cutsSheet: config.nocodb.cutsSheetId,
nodeEnv: config.nodeEnv
};
diff --git a/map/app/server.js b/map/app/server.js
index a745d6d..a22ff30 100644
--- a/map/app/server.js
+++ b/map/app/server.js
@@ -1,3 +1,9 @@
+// Prevent duplicate execution
+if (require.main !== module) {
+ console.log('Server.js being imported, not executed directly');
+ return;
+}
+
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
@@ -8,7 +14,7 @@ const fetch = require('node-fetch');
// Debug: Check if server.js is being loaded multiple times
const serverInstanceId = Math.random().toString(36).substr(2, 9);
-console.log(`[DEBUG] Server.js instance ${serverInstanceId} loading at ${new Date().toISOString()}`);
+console.log(`[DEBUG] Server.js PID:${process.pid} instance ${serverInstanceId} loading at ${new Date().toISOString()}`);
// Import configuration and utilities
const config = require('./config');
@@ -180,6 +186,7 @@ const server = app.listen(config.port, () => {
║ Project ID: ${config.nocodb.projectId} ║
║ Table ID: ${config.nocodb.tableId} ║
║ Login Sheet: ${config.nocodb.loginSheetId || 'Not Configured'} ║
+ ║ PID: ${process.pid} ║
║ Time: ${new Date().toISOString()} ║
╚════════════════════════════════════════╝
`);
diff --git a/map/build-nocodb.sh b/map/build-nocodb.sh
index e36b75d..c50b8af 100755
--- a/map/build-nocodb.sh
+++ b/map/build-nocodb.sh
@@ -4,10 +4,13 @@
# This script automatically creates the necessary base and tables for the BNKops Map Viewer application using NocoDB.
# Based on requirements from README.md and using proper NocoDB column types
#
-# Creates three tables:
+# Creates six tables:
# 1. locations - Main table with GeoData, proper field types per README.md
# 2. login - Simple authentication table with Email, Name, Admin fields
# 3. settings - Configuration table with text fields only (no QR image storage)
+# 4. shifts - Table for volunteer shift scheduling
+# 5. shift_signups - Table for tracking signups to shifts
+# 6. cuts - Table for storing polygon overlays for the map
#
# Updated: July 2025 - Always creates a new base, does not touch existing data
@@ -550,6 +553,7 @@ create_settings_table() {
create_table "$base_id" "settings" "$table_data" "System configuration with walk sheet text fields"
}
+
# Function to create the shifts table
create_shifts_table() {
local base_id=$1
@@ -713,6 +717,115 @@ create_shift_signups_table() {
create_table "$base_id" "shift_signups" "$table_data" "shift signups table"
}
+# Function to create the cuts table
+create_cuts_table() {
+ local base_id=$1
+
+ local table_data='{
+ "table_name": "cuts",
+ "title": "Cuts",
+ "columns": [
+ {
+ "column_name": "id",
+ "title": "ID",
+ "uidt": "ID",
+ "pk": true,
+ "ai": true,
+ "rqd": true
+ },
+ {
+ "column_name": "name",
+ "title": "Name",
+ "uidt": "SingleLineText",
+ "rqd": true
+ },
+ {
+ "column_name": "description",
+ "title": "Description",
+ "uidt": "LongText",
+ "rqd": false
+ },
+ {
+ "column_name": "color",
+ "title": "Color",
+ "uidt": "SingleLineText",
+ "rqd": true,
+ "cdf": "#3388ff"
+ },
+ {
+ "column_name": "opacity",
+ "title": "Opacity",
+ "uidt": "Decimal",
+ "rqd": true,
+ "cdf": "0.3",
+ "meta": {
+ "precision": 3,
+ "scale": 2
+ }
+ },
+ {
+ "column_name": "category",
+ "title": "Category",
+ "uidt": "SingleSelect",
+ "rqd": false,
+ "colOptions": {
+ "options": [
+ {"title": "Custom", "color": "#2196F3"},
+ {"title": "Ward", "color": "#4CAF50"},
+ {"title": "Neighborhood", "color": "#FF9800"},
+ {"title": "District", "color": "#9C27B0"}
+ ]
+ }
+ },
+ {
+ "column_name": "is_public",
+ "title": "Public Visibility",
+ "uidt": "Checkbox",
+ "rqd": false,
+ "cdf": false
+ },
+ {
+ "column_name": "is_official",
+ "title": "Official Cut",
+ "uidt": "Checkbox",
+ "rqd": false,
+ "cdf": false
+ },
+ {
+ "column_name": "geojson",
+ "title": "GeoJSON Data",
+ "uidt": "LongText",
+ "rqd": true
+ },
+ {
+ "column_name": "bounds",
+ "title": "Bounds",
+ "uidt": "LongText",
+ "rqd": false
+ },
+ {
+ "column_name": "created_by",
+ "title": "Created By",
+ "uidt": "SingleLineText",
+ "rqd": false
+ },
+ {
+ "column_name": "created_at",
+ "title": "Created At",
+ "uidt": "DateTime",
+ "rqd": false
+ },
+ {
+ "column_name": "updated_at",
+ "title": "Updated At",
+ "uidt": "DateTime",
+ "rqd": false
+ }
+ ]
+ }'
+
+ create_table "$base_id" "cuts" "$table_data" "Polygon cuts for map overlays"
+}
# Function to create default admin user
create_default_admin() {
@@ -767,6 +880,76 @@ create_default_start_location() {
make_api_call "POST" "/tables/$settings_table_id/records" "$start_location_data" "Creating default settings row" "v2"
}
+# Function to create sample cuts data
+create_default_cuts() {
+ local base_id=$1
+ local cuts_table_id=$2
+
+ print_status "Creating sample cuts data..."
+
+ # Sample cut 1: Downtown Area (Public)
+ local cut1_geojson='{"type":"Polygon","coordinates":[[[-113.52,53.54],[-113.48,53.54],[-113.48,53.56],[-113.52,53.56],[-113.52,53.54]]]}'
+ local cut1_bounds='{"north":53.56,"south":53.54,"east":-113.48,"west":-113.52}'
+
+ local cut1_data='{
+ "name": "Downtown Core",
+ "description": "Main downtown business district area for canvassing",
+ "color": "#e74c3c",
+ "opacity": 0.4,
+ "category": "District",
+ "is_public": 1,
+ "is_official": 1,
+ "geojson": "'"$cut1_geojson"'",
+ "bounds": "'"$cut1_bounds"'",
+ "created_by": "system",
+ "created_at": "'"$(date -u +"%Y-%m-%d %H:%M:%S")"'"
+ }'
+
+ make_api_call "POST" "/tables/$cuts_table_id/records" "$cut1_data" "Creating sample cut 1" "v2"
+
+ # Sample cut 2: Residential Area (Public)
+ local cut2_geojson='{"type":"Polygon","coordinates":[[[-113.55,53.50],[-113.50,53.50],[-113.50,53.53],[-113.55,53.53],[-113.55,53.50]]]}'
+ local cut2_bounds='{"north":53.53,"south":53.50,"east":-113.50,"west":-113.55}'
+
+ local cut2_data='{
+ "name": "River Valley Neighborhoods",
+ "description": "Residential area near the river valley",
+ "color": "#3498db",
+ "opacity": 0.3,
+ "category": "Neighborhood",
+ "is_public": 1,
+ "is_official": 0,
+ "geojson": "'"$cut2_geojson"'",
+ "bounds": "'"$cut2_bounds"'",
+ "created_by": "system",
+ "created_at": "'"$(date -u +"%Y-%m-%d %H:%M:%S")"'"
+ }'
+
+ make_api_call "POST" "/tables/$cuts_table_id/records" "$cut2_data" "Creating sample cut 2" "v2"
+
+ # Sample cut 3: Private Admin Cut (Not Public)
+ local cut3_geojson='{"type":"Polygon","coordinates":[[[-113.45,53.57],[-113.40,53.57],[-113.40,53.60],[-113.45,53.60],[-113.45,53.57]]]}'
+ local cut3_bounds='{"north":53.60,"south":53.57,"east":-113.40,"west":-113.45}'
+
+ local cut3_data='{
+ "name": "Admin Only Area",
+ "description": "Private administrative boundary for internal use",
+ "color": "#9b59b6",
+ "opacity": 0.5,
+ "category": "Custom",
+ "is_public": 0,
+ "is_official": 0,
+ "geojson": "'"$cut3_geojson"'",
+ "bounds": "'"$cut3_bounds"'",
+ "created_by": "system",
+ "created_at": "'"$(date -u +"%Y-%m-%d %H:%M:%S")"'"
+ }'
+
+ make_api_call "POST" "/tables/$cuts_table_id/records" "$cut3_data" "Creating sample cut 3" "v2"
+
+ print_success "Created 3 sample cuts (2 public, 1 private)"
+}
+
# Main execution
main() {
print_status "Starting NocoDB Auto-Setup..."
@@ -802,6 +985,9 @@ main() {
# Create shift signups table
SHIFT_SIGNUPS_TABLE_ID=$(create_shift_signups_table "$BASE_ID")
+ # Create cuts table
+ CUTS_TABLE_ID=$(create_cuts_table "$BASE_ID")
+
# Wait a moment for tables to be fully created
sleep 3
@@ -814,6 +1000,9 @@ main() {
# Create default settings row (includes both start location and walk sheet config)
create_default_start_location "$BASE_ID" "$SETTINGS_TABLE_ID"
+ # Create sample cuts data for testing
+ create_default_cuts "$BASE_ID" "$CUTS_TABLE_ID"
+
print_status "================================"
print_success "NocoDB Auto-Setup completed successfully!"
print_status "================================"
@@ -829,6 +1018,7 @@ main() {
print_status " - NOCODB_SETTINGS_SHEET (for settings table)"
print_status " - NOCODB_SHIFTS_SHEET (for shifts table)"
print_status " - NOCODB_SHIFT_SIGNUPS_SHEET (for shift signups table)"
+ print_status " - NOCODB_CUTS_SHEET (for cuts table)"
print_status "4. The default admin user is: admin@thebunkerops.ca with password: admin123"
print_status "5. IMPORTANT: Change the default password after first login!"
print_status "6. Start adding your location data!"
@@ -837,11 +1027,6 @@ main() {
print_warning "IMPORTANT: This script created a NEW base. Your existing data was NOT modified."
print_warning "Please update your .env file with the new table URLs from the newly created base."
print_warning "SECURITY: Change the default admin password immediately after first login!"
-
-
- print_warning ""
- print_warning "IMPORTANT: This script created a NEW base. Your existing data was NOT modified."
- print_warning "Please update your .env file with the new table URLs from the newly created base."
}
# Check if script is being run directly
diff --git a/map/debug-mobile-overlay.html b/map/debug-mobile-overlay.html
new file mode 100644
index 0000000..e69de29
diff --git a/map/debug-mobile-overlay.js b/map/debug-mobile-overlay.js
new file mode 100644
index 0000000..e69de29
diff --git a/map/files-explainer.md b/map/files-explainer.md
index 2b1dfae..2daf410 100644
--- a/map/files-explainer.md
+++ b/map/files-explainer.md
@@ -64,6 +64,10 @@ Controller for aggregating and calculating dashboard statistics from locations a
Controller for user management (list, create, delete users, send login details via email).
+# app/controllers/cutsController.js
+
+Controller for CRUD operations on map cuts (geographic polygon overlays). Handles cut creation, editing, deletion, and visibility management with admin-only access for modifications and public access for viewing public cuts.
+
# app/middleware/auth.js
Express middleware for authentication and admin access control.
@@ -148,6 +152,10 @@ Contains base styles, including CSS variables for theming, resets, and default b
Defines styles for all button types, states (hover, disabled), and variants (primary, danger, etc.).
+# app/public/css/modules/cuts.css
+
+Styles for the cut feature including drawing controls, polygon overlays, vertex markers, cut management interface, and responsive design for both admin and public views.
+
# app/public/css/modules/cache-busting.css
Styles for the cache busting update notification that prompts users to refresh the page.
@@ -300,6 +308,18 @@ Utility functions for the frontend (escaping HTML, parsing geolocation, etc).
Frontend JavaScript for the Convert Data admin section. Handles file upload UI, drag-and-drop, real-time progress updates, visual representation of geocoding results on a map, and saving successful results to the database.
+# app/public/js/cut-drawing.js
+
+JavaScript module for interactive polygon drawing functionality. Implements click-to-add-points drawing system for creating cut boundaries on the map using Leaflet.js drawing tools.
+
+# app/public/js/cut-controls.js
+
+JavaScript module for cut display controls on the public map. Handles loading and rendering of public cuts as polygon overlays for authenticated users.
+
+# app/public/js/admin-cuts.js
+
+JavaScript for the admin cut management interface. Provides complete CRUD functionality for cuts including interactive drawing, form management, cut list display, and import/export capabilities.
+
# app/routes/admin.js
Express router for admin-only endpoints (start location, walk sheet config).
@@ -344,6 +364,10 @@ Express router for volunteer shift management endpoints (public and admin).
Express router for user management endpoints (list, create, delete users).
+# app/routes/cuts.js
+
+Express router for cut management endpoints. Provides CRUD operations for geographic polygon overlays with admin-only access for modifications and public read access for viewing public cuts.
+
# app/routes/dataConvert.js
Express routes for data conversion features. Handles CSV file upload with multer middleware and provides endpoints for processing CSV files and saving geocoded results to the database.
diff --git a/map/test-mobile-overlay.js b/map/test-mobile-overlay.js
new file mode 100644
index 0000000..e69de29