Update places layer to use Leaflet.Control.Layers.Tree for hierarchical layer control

This commit is contained in:
Eugene Burmakin
2025-11-18 21:03:53 +01:00
parent 602975eeaa
commit e8e7bcc91b
17 changed files with 3459 additions and 224 deletions
+236 -126
View File
@@ -1,6 +1,7 @@
import { Controller } from "@hotwired/stimulus";
import L from "leaflet";
import "leaflet.heat";
import "leaflet.control.layers.tree";
import consumer from "../channels/consumer";
import { createMarkersArray } from "../maps/markers";
@@ -45,7 +46,11 @@ import { TileMonitor } from "../maps/tile_monitor";
import BaseController from "./base_controller";
import { createAllMapLayers } from "../maps/layers";
import { applyThemeToControl, applyThemeToButton, applyThemeToPanel } from "../maps/theme_utils";
import { addTopRightButtons } from "../maps/map_controls";
import {
addTopRightButtons,
setCreatePlaceButtonActive,
setCreatePlaceButtonInactive
} from "../maps/map_controls";
export default class extends BaseController {
static targets = ["container"];
@@ -218,6 +223,14 @@ export default class extends BaseController {
this.placesManager = new PlacesManager(this.map, this.apiKey);
this.placesManager.initialize();
// Parse user tags for places layer control
try {
this.userTags = this.element.dataset.user_tags ? JSON.parse(this.element.dataset.user_tags) : [];
} catch (error) {
console.error('Error parsing user tags:', error);
this.userTags = [];
}
// Expose maps controller globally for family integration
window.mapsController = this;
@@ -234,9 +247,6 @@ export default class extends BaseController {
}
this.switchRouteMode('routes', true);
// Initialize layers based on settings
this.initializeLayersFromSettings();
// Listen for Family Members layer becoming ready
this.setupFamilyLayerListener();
@@ -252,22 +262,12 @@ export default class extends BaseController {
// Add all top-right buttons in the correct order
this.initializeTopRightButtons();
// Initialize layers for the layer control
const controlsLayer = {
Points: this.markersLayer,
Routes: this.polylinesLayer,
Tracks: this.tracksLayer,
Heatmap: this.heatmapLayer,
"Fog of War": this.fogOverlay,
"Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(),
Areas: this.areasLayer,
Photos: this.photoMarkers,
"Suggested Visits": this.visitsManager.getVisitCirclesLayer(),
"Confirmed Visits": this.visitsManager.getConfirmedVisitCirclesLayer(),
"Places": this.placesManager.placesLayer
};
// Initialize tree-based layer control (must be before initializeLayersFromSettings)
this.layerControl = this.createTreeLayerControl();
this.map.addControl(this.layerControl);
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
// Initialize layers based on settings (must be after tree control creation)
this.initializeLayersFromSettings();
// Initialize Live Map Handler
@@ -447,6 +447,134 @@ export default class extends BaseController {
return maps;
}
createTreeLayerControl(additionalLayers = {}) {
// Build base maps tree structure
const baseMapsTree = {
label: 'Map Styles',
children: []
};
const maps = this.baseMaps();
Object.entries(maps).forEach(([name, layer]) => {
baseMapsTree.children.push({
label: name,
layer: layer
});
});
// Build places subtree with tags
// Store filtered layers for later restoration
if (!this.placesFilteredLayers) {
this.placesFilteredLayers = {};
}
// Create Untagged layer
const untaggedLayer = this.placesManager?.createFilteredLayer([]) || L.layerGroup();
this.placesFilteredLayers['Untagged'] = untaggedLayer;
const placesChildren = [
{
label: 'Untagged',
layer: untaggedLayer
}
];
// Add individual tag layers
if (this.userTags && this.userTags.length > 0) {
this.userTags.forEach(tag => {
const icon = tag.icon || '📍';
const label = `${icon} ${tag.name}`;
const tagLayer = this.placesManager?.createFilteredLayer([tag.id]) || L.layerGroup();
this.placesFilteredLayers[label] = tagLayer;
placesChildren.push({
label: label,
layer: tagLayer
});
});
}
// Build visits subtree
const visitsChildren = [
{
label: 'Suggested',
layer: this.visitsManager?.getVisitCirclesLayer() || L.layerGroup()
},
{
label: 'Confirmed',
layer: this.visitsManager?.getConfirmedVisitCirclesLayer() || L.layerGroup()
}
];
// Build the overlays tree structure
const overlaysTree = {
label: 'Layers',
selectAllCheckbox: false,
children: [
{
label: 'Points',
layer: this.markersLayer
},
{
label: 'Routes',
layer: this.polylinesLayer
},
{
label: 'Tracks',
layer: this.tracksLayer
},
{
label: 'Heatmap',
layer: this.heatmapLayer
},
{
label: 'Fog of War',
layer: this.fogOverlay
},
{
label: 'Scratch map',
layer: this.scratchLayerManager?.getLayer() || L.layerGroup()
},
{
label: 'Areas',
layer: this.areasLayer
},
{
label: 'Photos',
layer: this.photoMarkers
},
{
label: 'Visits',
selectAllCheckbox: true,
children: visitsChildren
},
{
label: 'Places',
selectAllCheckbox: true,
children: placesChildren
}
]
};
// Add Family Members layer if available
if (additionalLayers['Family Members']) {
overlaysTree.children.push({
label: 'Family Members',
layer: additionalLayers['Family Members']
});
}
// Create the tree control
return L.control.layers.tree(
baseMapsTree,
overlaysTree,
{
namedToggle: false,
collapsed: true,
position: 'topright'
}
);
}
removeEventListeners() {
document.removeEventListener('click', this.handleDeleteClick);
}
@@ -572,6 +700,15 @@ export default class extends BaseController {
this.fogOverlay = null;
}
});
// Listen for place creation events to disable creation mode
document.addEventListener('place:created', () => {
this.disablePlaceCreationMode();
});
document.addEventListener('place:create:cancelled', () => {
this.disablePlaceCreationMode();
});
}
updatePreferredBaseLayer(selectedLayerName) {
@@ -599,32 +736,23 @@ export default class extends BaseController {
saveEnabledLayers() {
const enabledLayers = [];
const layerNames = [
'Points', 'Routes', 'Tracks', 'Heatmap', 'Fog of War',
'Scratch map', 'Areas', 'Photos', 'Suggested Visits', 'Confirmed Visits',
'Family Members'
];
const controlsLayer = {
'Points': this.markersLayer,
'Routes': this.polylinesLayer,
'Tracks': this.tracksLayer,
'Heatmap': this.heatmapLayer,
'Fog of War': this.fogOverlay,
'Scratch map': this.scratchLayerManager?.getLayer(),
'Areas': this.areasLayer,
'Photos': this.photoMarkers,
'Suggested Visits': this.visitsManager?.getVisitCirclesLayer(),
'Confirmed Visits': this.visitsManager?.getConfirmedVisitCirclesLayer(),
'Family Members': window.familyMembersController?.familyMarkersLayer
};
layerNames.forEach(name => {
const layer = controlsLayer[name];
if (layer && this.map.hasLayer(layer)) {
enabledLayers.push(name);
}
});
// Get all checked inputs from the tree control
const layerControl = document.querySelector('.leaflet-control-layers');
if (layerControl) {
const inputs = layerControl.querySelectorAll('input[type="checkbox"]:checked');
inputs.forEach(input => {
// Get the label text for this checkbox
const label = input.closest('label') || input.nextElementSibling;
if (label) {
const layerName = label.textContent.trim();
// Skip group headers that might have checkboxes
if (layerName && !layerName.includes('Map Styles') && !layerName.includes('Layers')) {
enabledLayers.push(layerName);
}
}
});
}
fetch('/api/v1/settings', {
method: 'PATCH',
@@ -642,7 +770,7 @@ export default class extends BaseController {
.then((data) => {
if (data.status === 'success') {
console.log('Enabled layers saved:', enabledLayers);
showFlashMessage('notice', 'Map layer preferences saved');
// showFlashMessage('notice', 'Map layer preferences saved');
} else {
console.error('Failed to save enabled layers:', data.message);
showFlashMessage('error', `Failed to save layer preferences: ${data.message}`);
@@ -699,16 +827,8 @@ export default class extends BaseController {
// Update the layer control
if (this.layerControl) {
this.map.removeControl(this.layerControl);
const controlsLayer = {
Points: this.markersLayer || L.layerGroup(),
Routes: this.polylinesLayer || L.layerGroup(),
Heatmap: this.heatmapLayer || L.layerGroup(),
"Fog of War": this.fogOverlay,
"Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(),
Areas: this.areasLayer || L.layerGroup(),
Photos: this.photoMarkers || L.layerGroup()
};
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
this.layerControl = this.createTreeLayerControl();
this.map.addControl(this.layerControl);
}
// Update heatmap
@@ -1280,7 +1400,8 @@ export default class extends BaseController {
};
// Re-add the layer control in the same position
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
this.layerControl = this.createTreeLayerControl();
this.map.addControl(this.layerControl);
// Restore layer visibility states
Object.entries(layerStates).forEach(([name, wasVisible]) => {
@@ -1321,7 +1442,7 @@ export default class extends BaseController {
initializeTopRightButtons() {
// Add all top-right buttons in the correct order:
// 1. Select Area, 2. Add Visit, 3. Open Calendar, 4. Open Drawer
// 1. Select Area, 2. Add Visit, 3. Create Place, 4. Open Calendar, 5. Open Drawer
// Note: Layer control is added separately and appears at the top
this.topRightControls = addTopRightButtons(
@@ -1330,6 +1451,7 @@ export default class extends BaseController {
onSelectArea: () => this.visitsManager.toggleSelectionMode(),
// onAddVisit is intentionally null - the add_visit_controller will attach its handler
onAddVisit: null,
onCreatePlace: () => this.togglePlaceCreationMode(),
onToggleCalendar: () => this.toggleRightPanel(),
onToggleDrawer: () => this.visitsManager.toggleDrawer()
},
@@ -1534,7 +1656,9 @@ export default class extends BaseController {
'Photos': this.photoMarkers,
'Suggested Visits': this.visitsManager?.getVisitCirclesLayer(),
'Confirmed Visits': this.visitsManager?.getConfirmedVisitCirclesLayer(),
'Family Members': window.familyMembersController?.familyMarkersLayer
'Family Members': window.familyMembersController?.familyMarkersLayer,
// Add Places filtered layers
...this.placesFilteredLayers || {}
};
// Apply saved layer preferences
@@ -1606,6 +1730,38 @@ export default class extends BaseController {
console.log(`Disabled layer: ${name}`);
}
});
// Update the tree control checkboxes to reflect the layer states
// Wait a bit for the tree control to be fully initialized
setTimeout(() => {
this.updateTreeControlCheckboxes(enabledLayers);
}, 100);
}
updateTreeControlCheckboxes(enabledLayers) {
const layerControl = document.querySelector('.leaflet-control-layers');
if (!layerControl) {
console.log('Layer control not found, skipping checkbox update');
return;
}
// Find and check/uncheck all layer checkboxes based on saved state
const inputs = layerControl.querySelectorAll('input[type="checkbox"]');
inputs.forEach(input => {
const label = input.closest('label') || input.nextElementSibling;
if (label) {
const layerName = label.textContent.trim();
const shouldBeEnabled = enabledLayers.includes(layerName);
// Skip group headers that might have checkboxes
if (layerName && !layerName.includes('Map Styles') && !layerName.includes('Layers')) {
if (shouldBeEnabled !== input.checked) {
input.checked = shouldBeEnabled;
console.log(`Updated checkbox for ${layerName}: ${shouldBeEnabled}`);
}
}
}
});
}
setupFamilyLayerListener() {
@@ -2155,71 +2311,12 @@ export default class extends BaseController {
updateLayerControl(additionalLayers = {}) {
if (!this.layerControl) return;
// Store which base and overlay layers are currently visible
const overlayStates = {};
let activeBaseLayer = null;
let activeBaseLayerName = null;
if (this.layerControl._layers) {
Object.values(this.layerControl._layers).forEach(layerObj => {
if (layerObj.overlay && layerObj.layer) {
// Store overlay layer states
overlayStates[layerObj.name] = this.map.hasLayer(layerObj.layer);
} else if (!layerObj.overlay && this.map.hasLayer(layerObj.layer)) {
// Store the currently active base layer
activeBaseLayer = layerObj.layer;
activeBaseLayerName = layerObj.name;
}
});
}
// Remove existing layer control
this.map.removeControl(this.layerControl);
// Create base controls layer object
const baseControlsLayer = {
Points: this.markersLayer || L.layerGroup(),
Routes: this.polylinesLayer || L.layerGroup(),
Tracks: this.tracksLayer || L.layerGroup(),
Heatmap: this.heatmapLayer || L.heatLayer([]),
"Fog of War": this.fogOverlay,
"Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(),
Areas: this.areasLayer || L.layerGroup(),
Photos: this.photoMarkers || L.layerGroup(),
"Suggested Visits": this.visitsManager?.getVisitCirclesLayer() || L.layerGroup(),
"Confirmed Visits": this.visitsManager?.getConfirmedVisitCirclesLayer() || L.layerGroup()
};
// Merge with additional layers (like family members)
const controlsLayer = { ...baseControlsLayer, ...additionalLayers };
// Get base maps and re-add the layer control
const baseMaps = this.baseMaps();
this.layerControl = L.control.layers(baseMaps, controlsLayer).addTo(this.map);
// Restore the active base layer if we had one
if (activeBaseLayer && activeBaseLayerName) {
console.log(`Restoring base layer: ${activeBaseLayerName}`);
// Make sure the base layer is added to the map
if (!this.map.hasLayer(activeBaseLayer)) {
activeBaseLayer.addTo(this.map);
}
} else {
// If no active base layer was found, ensure we have a default one
console.log('No active base layer found, adding default');
const defaultBaseLayer = Object.values(baseMaps)[0];
if (defaultBaseLayer && !this.map.hasLayer(defaultBaseLayer)) {
defaultBaseLayer.addTo(this.map);
}
}
// Restore overlay layer visibility states
Object.entries(overlayStates).forEach(([name, wasVisible]) => {
const layer = controlsLayer[name];
if (layer && wasVisible && !this.map.hasLayer(layer)) {
layer.addTo(this.map);
}
});
// Re-add the layer control with additional layers
this.layerControl = this.createTreeLayerControl(additionalLayers);
this.map.addControl(this.layerControl);
}
togglePlaceCreationMode() {
@@ -2234,20 +2331,33 @@ export default class extends BaseController {
// Disable creation mode
this.placesManager.disableCreationMode();
if (button) {
button.classList.remove('btn-error');
button.classList.add('btn-success');
button.title = 'Click to create a place on the map';
setCreatePlaceButtonInactive(button, this.userTheme);
button.setAttribute('data-tip', 'Create a place');
}
} else {
// Enable creation mode
this.placesManager.enableCreationMode();
if (button) {
button.classList.remove('btn-success');
button.classList.add('btn-error');
button.title = 'Click map to place marker (click again to cancel)';
setCreatePlaceButtonActive(button);
button.setAttribute('data-tip', 'Click map to place marker (click to cancel)');
}
}
}
disablePlaceCreationMode() {
if (!this.placesManager) {
return;
}
// Only disable if currently in creation mode
if (this.placesManager.creationMode) {
this.placesManager.disableCreationMode();
const button = document.getElementById('create-place-btn');
if (button) {
setCreatePlaceButtonInactive(button, this.userTheme);
button.setAttribute('data-tip', 'Create a place');
}
}
}
}