Files
construprogress/resources/views/livewire/projects/project-map.blade.php
T
javier 558b1732aa feat(project-map): clearer editor tabs + per-phase and per-layer visibility
1. Editor tabs restyled as spaced DaisyUI buttons (btn-primary when active,
   btn-ghost otherwise) — fixes cramped labels and missing active indicator.
2. Layer visibility now works at two levels:
   - Phase toggle calls togglePhase() and shows/hides ALL its layers
     (checked only when every layer of the phase is active)
   - Each layer has its own independent toggle calling toggleLayer()
   Map JS regrouped to build one Leaflet group per LAYER (keyed by layer id)
   instead of per phase, so activeLayers (layer ids) drives visibility
   correctly per layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:14:17 +02:00

647 lines
38 KiB
PHP

<div x-data="{ formFullscreen: $wire.entangle('formFullscreen') }"
class="flex flex-col lg:flex-row gap-0 h-screen p-1">
<!-- Columna izquierda: Mapa -->
<div x-show="!formFullscreen" x-cloak x-data="{ showLayers: true }" class="w-full lg:w-2/3 flex-1 relative">
<div id="map" style="height: 100%; min-height: 500px; width: 100%;" wire:ignore></div>
<!-- Botón para reabrir el panel (solo cuando está colapsado) -->
<button x-show="!showLayers" x-cloak @click="showLayers = true"
class="absolute top-2 right-2 z-[1001] btn btn-sm btn-circle shadow-lg"
title="{{ __('Show/hide panel') }}">
<x-heroicon-o-bars-3 class="w-5 h-5" />
</button>
<!-- Panel lateral de capas -->
<div x-show="showLayers" x-transition
class="absolute top-2 right-2 z-[1000] bg-base-100 rounded-box shadow-xl p-4 w-72 border border-base-300 text-sm max-h-[calc(100vh-4rem)] overflow-y-auto">
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold text-base">{{ __('Phases and layers') }}</h3>
<button @click="showLayers = false" class="btn btn-xs btn-circle btn-ghost" title="{{ __('Show/hide panel') }}">
<x-heroicon-o-x-mark class="w-4 h-4" />
</button>
</div>
<div class="space-y-3">
@foreach($phases as $phase)
@php
$phaseLayerIds = $phase->layers->pluck('id')->map(fn($i) => (int) $i)->all();
$phaseAllActive = count($phaseLayerIds) > 0 && collect($phaseLayerIds)->every(fn($i) => in_array($i, $activeLayers));
@endphp
<div class="border rounded-lg p-2 {{ $phaseAllActive ? 'bg-base-200' : '' }}">
{{-- Fase: el toggle muestra/oculta TODAS sus capas --}}
<div class="flex items-center gap-2">
<input type="checkbox"
wire:change="togglePhase({{ $phase->id }})"
@if($phaseAllActive) checked @endif
class="toggle toggle-xs toggle-primary"
title="{{ __('Show/hide all layers of this phase') }}">
<span style="color: {{ $phase->color }};" class="text-lg"></span>
<span class="flex-1 font-medium text-sm truncate">{{ $phase->name }}</span>
<span class="badge badge-sm {{ $phase->progress_percent >= 100 ? 'badge-success' : 'badge-ghost' }}">{{ $phase->progress_percent }}</span>
</div>
{{-- Capas de esta fase: cada una con su propio toggle independiente --}}
@if($phase->layers->isNotEmpty())
<div class="ml-7 mt-1 space-y-1">
@foreach($phase->layers as $layer)
<div class="flex items-center gap-2 text-xs text-gray-600">
<input type="checkbox"
wire:change="toggleLayer({{ $layer->id }})"
@if(in_array((int) $layer->id, $activeLayers)) checked @endif
class="toggle toggle-xs toggle-primary"
title="{{ __('Show/hide layer') }}">
<span class="w-2 h-2 rounded-full inline-block shrink-0" style="background: {{ $layer->color ?? '#ccc' }}"></span>
<span class="flex-1 truncate">{{ $layer->name }}</span>
<span class="badge badge-xs">{{ $layer->features_count ?? $layer->features->count() }} {{ __('elem.') }}</span>
</div>
@endforeach
</div>
@endif
{{-- Botón para ir a gestión de capas de esta fase --}}
<div class="mt-1 ml-7">
<a href="{{ route('layers.manage', [$project, $phase]) }}" class="btn btn-xs btn-outline btn-primary gap-1">
<x-heroicon-o-pencil-square class="w-3.5 h-3.5" /> {{ __('Manage Layers') }}
</a>
<a href="{{ route('phases.progress', $phase) }}" class="btn btn-xs btn-outline gap-1">
<x-heroicon-o-chart-bar class="w-3.5 h-3.5" /> {{ __('Progress') }}
</a>
</div>
</div>
@endforeach
</div>
{{-- Checkbox imágenes en mapa --}}
<div class="mt-3">
<label class="flex items-center gap-2 text-xs cursor-pointer">
<input type="checkbox" wire:change="toggleFeatureImages" @if($showFeatureImages) checked @endif class="checkbox checkbox-xs checkbox-primary" />
<x-heroicon-o-photo class="w-4 h-4" /> {{ __('Show images on map') }}
@if($featureImageMarkers)
<span class="badge badge-xs">{{ count($featureImageMarkers) }}</span>
@endif
</label>
</div>
{{-- Botones generales --}}
<div class="mt-2 space-y-1">
<a href="{{ route('projects.media', $project) }}" class="btn btn-sm btn-outline w-full gap-1">
<x-heroicon-o-folder class="w-4 h-4" /> {{ __('Project files') }}
</a>
<button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full gap-1">
<x-heroicon-o-map-pin class="w-4 h-4" /> {{ __('Centered in project') }}
</button>
<button onclick="getUserLocation()" class="btn btn-sm btn-outline w-full gap-1">
<x-heroicon-o-viewfinder-circle class="w-4 h-4" /> {{ __('My location') }}
</button>
</div>
</div>
</div>
<!-- Columna derecha: {{ __('Edit') }} de progreso / inspecciones -->
<div class="w-full lg:w-1/3 transition-all duration-300" :class="{'lg:w-full': formFullscreen}">
<div class="card bg-base-100 shadow-xl h-full flex flex-col">
<div class="card-body overflow-y-auto flex-1">
<div class="flex justify-between items-center gap-2 mb-4">
<!-- Tabs -->
<div class="flex flex-wrap gap-1">
<button wire:click="setActiveTab('edit')" class="btn btn-sm {{ $activeTab === 'edit' ? 'btn-primary' : 'btn-ghost' }}">{{ __('Edit') }}</button>
<button wire:click="setActiveTab('features')" class="btn btn-sm {{ $activeTab === 'features' ? 'btn-primary' : 'btn-ghost' }}">{{ __('Features') }}</button>
<button wire:click="setActiveTab('inspections')" class="btn btn-sm {{ $activeTab === 'inspections' ? 'btn-primary' : 'btn-ghost' }}">{{ __('Inspections') }}</button>
<button wire:click="setActiveTab('issues')" class="btn btn-sm gap-1 {{ $activeTab === 'issues' ? 'btn-primary' : 'btn-ghost' }}">
{{ __('Issues') }}
@if($openIssuesCount > 0)
<span class="badge badge-error badge-xs">{{ $openIssuesCount }}</span>
@endif
</button>
</div>
<button wire:click="toggleFullscreen" class="btn btn-circle btn-sm shrink-0" title="{{ __('Fullscreen') }}">
<span x-text="formFullscreen ? '✕' : '⤢'"></span>
</button>
</div>
<!-- Project navigation bar (hidden for now, kept for later) -->
<div class="hidden flex-wrap gap-1 mb-3">
<a href="{{ route('projects.dashboard', $project) }}"
class="btn btn-xs gap-1 {{ request()->routeIs('projects.dashboard') ? 'btn-primary' : 'btn-outline' }}">
<x-heroicon-o-squares-2x2 class="w-3.5 h-3.5" /> {{ __('Dashboard') }}
</a>
<a href="{{ route('projects.map', $project) }}"
class="btn btn-xs gap-1 {{ request()->routeIs('projects.map') ? 'btn-primary' : 'btn-outline' }}">
<x-heroicon-o-map class="w-3.5 h-3.5" /> {{ __('Map') }}
</a>
<a href="{{ route('projects.gantt', $project) }}"
class="btn btn-xs gap-1 {{ request()->routeIs('projects.gantt') ? 'btn-primary' : 'btn-outline' }}">
<x-heroicon-o-calendar-days class="w-3.5 h-3.5" /> {{ __('Gantt') }}
</a>
<a href="{{ route('projects.report', $project) }}"
class="btn btn-xs gap-1 {{ request()->routeIs('projects.report') ? 'btn-primary' : 'btn-outline' }}">
<x-heroicon-o-document-text class="w-3.5 h-3.5" /> {{ __('Report') }}
</a>
<a href="{{ route('projects.issues', $project) }}"
class="btn btn-xs gap-1 {{ request()->routeIs('projects.issues') ? 'btn-error' : 'btn-outline' }}">
<x-heroicon-o-exclamation-triangle class="w-3.5 h-3.5" /> {{ __('Issues') }}
@if($openIssuesCount > 0)
<span class="badge badge-error badge-xs">{{ $openIssuesCount }}</span>
@endif
</a>
</div>
<!-- Tab Content: visibility controlled by Livewire conditionals, not DaisyUI -->
<div class="mt-2">
@if($activeTab === 'edit')
@if($selectedFeature)
{{-- Título a todo el ancho: progreso (solo número) a la izquierda + nombre --}}
<div class="flex items-center gap-3 mb-4 pb-2 border-b border-base-300">
<span class="badge badge-lg shrink-0 {{ $editProgress >= 100 ? 'badge-success' : ($editProgress > 0 ? 'badge-warning' : 'badge-ghost') }}">{{ $editProgress }}%</span>
<div class="flex-1 min-w-0">
<h3 class="font-bold text-base truncate">{{ $selectedFeature->name ?? __('Feature') }}</h3>
<p class="text-xs text-gray-500 truncate">{{ __('Phase') }}: {{ $selectedFeature->layer?->phase?->name ?? '—' }} · {{ __('Layer') }}: {{ $selectedFeature->layer?->name ?? '—' }}</p>
</div>
</div>
{{-- En pantalla completa el contenido se reparte en columnas --}}
<div :class="formFullscreen ? 'grid grid-cols-1 lg:grid-cols-2 gap-x-8 items-start' : ''">
<div>
{{-- Responsable (se guarda al salir del campo) --}}
<div class="form-control mb-3">
<label class="label-text">{{ __('Responsible') }}</label>
<input type="text" wire:model="editResponsible" wire:blur="saveFeatureProgress" class="input input-bordered input-sm" placeholder="{{ __('Name of responsible') }}" />
</div>
{{-- Gestor de archivos del feature --}}
<details class="mb-3 border rounded-lg">
<summary class="text-xs font-semibold cursor-pointer p-2 bg-base-200 rounded-t-lg">
<x-heroicon-o-paper-clip class="w-4 h-4 inline" /> {{ __('Files of element') }}
</summary>
<div class="p-2">
@livewire('media-manager', [
'mediableType' => 'App\\Models\\Feature',
'mediableId' => $selectedFeature->id,
], key('media-feature-' . $selectedFeature->id))
</div>
</details>
</div>
<div>
{{-- Templates / Inspecciones --}}
@if($templates->isNotEmpty())
<div class="divider text-xs">{{ __('Inspection') }}</div>
<div class="form-control mb-2">
<label class="label-text">{{ __('Template') }}</label>
<select wire:model.live="selectedTemplateId" wire:change="onTemplateChange" class="select select-bordered select-sm">
<option value="">{{ __('Select template...') }}</option>
@foreach($templates as $t)
<option value="{{ $t->id }}">{{ $t->name }}</option>
@endforeach
</select>
</div>
@if($selectedTemplateId && !empty($inspectionFormData))
@php $template = $templates->firstWhere('id', $selectedTemplateId); @endphp
@if($template)
@foreach($template->fields as $field)
<div class="mb-2">
<label class="label-text text-xs">{{ $field['label'] }} @if($field['required'] ?? false)<span class="text-error">*</span>@endif</label>
@switch($field['type'] ?? 'text')
@case('percentage')
<div class="flex items-center gap-1">
<input type="number" wire:model="inspectionFormData.{{ $field['name'] }}" min="0" max="100" class="input input-bordered input-sm w-16" />
<span class="text-xs">%</span>
<input type="range" min="0" max="100" wire:model.live="inspectionFormData.{{ $field['name'] }}" class="range range-primary range-xs flex-1" />
</div>
@break
@case('boolean')
<input type="checkbox" wire:model="inspectionFormData.{{ $field['name'] }}" class="checkbox checkbox-sm" />
@break
@case('select')
<select wire:model="inspectionFormData.{{ $field['name'] }}" class="select select-bordered select-sm w-full">
<option value="">{{ __('Select') }}</option>
@foreach(explode(',', $field['options'] ?? '') as $opt)
<option value="{{ trim($opt) }}">{{ trim($opt) }}</option>
@endforeach
</select>
@break
@case('textarea')
<textarea wire:model="inspectionFormData.{{ $field['name'] }}" rows="2" class="textarea textarea-bordered textarea-sm w-full"></textarea>
@break
@default
<input type="{{ $field['type'] ?? 'text' }}" wire:model="inspectionFormData.{{ $field['name'] }}" class="input input-bordered input-sm w-full" />
@endswitch
</div>
@endforeach
<button wire:click="saveInspection" class="btn btn-primary btn-xs w-full mt-1">{{ __('Register inspection') }}</button>
@endif
@endif
{{-- Historial de inspecciones --}}
@if($inspectionHistory->isNotEmpty())
<div class="divider text-xs">{{ __('History') }}</div>
<div class="space-y-1 max-h-40 overflow-y-auto">
@foreach($inspectionHistory as $ins)
<div class="border rounded p-2 text-xs" wire:key="hist-{{ $ins->id }}">
<div class="flex justify-between">
<span class="font-medium">{{ $ins->template?->name ?? __('Inspection') }}</span>
<span class="text-gray-400">{{ $ins->created_at->diffForHumans() }}</span>
</div>
@if($ins->user)<span class="text-gray-500">{{ __('by') }} {{ $ins->user->name }}</span>@endif
</div>
@endforeach
</div>
@endif
@else
<div role="alert" class="alert alert-vertical sm:alert-horizontal">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info h-6 w-6 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="font-bold">{{ __('No templates yet') }}</h3>
<div class="text-xs">{{ __('Create an inspection template') }}.</div>
</div>
<a href="{{ route('projects.templates', $project) }}" class="btn btn-primary btn-sm">{{ __('Create') }}</a>
</div>
@endif
</div>
</div>
@else
<div class="text-center text-gray-400 py-8">
<x-heroicon-o-cursor-arrow-rays class="w-10 h-10 mx-auto opacity-30" />
<p>{{ __('Click on a map element or search above to edit it') }}</p>
</div>
@endif
@elseif($activeTab === 'features')
<!-- Features Table -->
@if($allFeatures->isNotEmpty())
<div class="overflow-x-auto rounded-lg border border-base-300">
<table class="table table-sm table-zebra table-pin-rows">
<thead>
<tr>
<th>{{ __('Feature') }}</th>
<th>{{ __('Layer') }}</th>
<th>{{ __('Phase') }}</th>
<th class="text-center">{{ __('Progress') }}</th>
<th class="w-10"></th>
</tr>
</thead>
<tbody>
@foreach($allFeatures as $feature)
<tr class="hover cursor-pointer" wire:click="selectFeature({{ $feature->id }})" wire:key="feat-{{ $feature->id }}">
<td class="font-medium">{{ $feature->name }}</td>
<td>{{ $feature->layer?->name ?? '—' }}</td>
<td>{{ $feature->layer?->phase?->name ?? '—' }}</td>
<td class="text-center">
<span class="badge badge-sm {{ $feature->progress >= 100 ? 'badge-success' : ($feature->progress > 0 ? 'badge-warning' : 'badge-ghost') }}">{{ $feature->progress }}%</span>
</td>
<td class="text-right">
<x-heroicon-o-chevron-right class="w-4 h-4 opacity-40" />
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center text-gray-400 py-8">
<x-heroicon-o-clipboard-document-list class="w-10 h-10 mx-auto opacity-30" />
<p>{{ __('No elements in this project') }}</p>
</div>
@endif
@elseif($activeTab === 'inspections')
<!-- Inspections Table -->
@if($allInspections->isNotEmpty())
<div class="overflow-x-auto rounded-lg border border-base-300">
<table class="table table-sm table-zebra table-pin-rows">
<thead>
<tr>
<th>{{ __('Date') }}</th>
<th>{{ __('Feature') }}</th>
<th>{{ __('Template') }}</th>
<th>{{ __('User') }}</th>
<th class="w-10"></th>
</tr>
</thead>
<tbody>
@foreach($allInspections as $inspection)
<tr class="hover" wire:key="insp-{{ $inspection->id }}">
<td class="whitespace-nowrap">{{ $inspection->created_at?->format('d/m/Y') ?? '—' }}</td>
<td class="font-medium">{{ $inspection->feature?->name ?? '—' }}</td>
<td>{{ $inspection->template?->name ?? '—' }}</td>
<td>{{ $inspection->user?->name ?? '—' }}</td>
<td class="text-right">
<button wire:click="viewInspection({{ $inspection->id }})" class="btn btn-xs btn-ghost" title="{{ __('View') }}">
<x-heroicon-o-eye class="w-4 h-4" />
</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center text-gray-400 py-8">
<x-heroicon-o-clipboard-document-list class="w-10 h-10 mx-auto opacity-30" />
<p>{{ __('No inspections registered') }}</p>
</div>
@endif
@elseif($activeTab === 'issues')
<!-- Issues tab: render embedded IssueManager component -->
@livewire('issue-manager', ['project' => $project], key('issues-tab-' . $project->id))
@endif
</div>
{{-- Visor de inspección --}}
@if($viewingInspection)
<div class="modal modal-open z-[2000]" wire:key="ins-viewer-{{ $viewingInspection['id'] }}">
<div class="modal-box max-w-lg">
<div class="flex justify-between items-start mb-3">
<h3 class="font-bold text-lg">{{ __('Inspection') }} #{{ $viewingInspection['id'] }}</h3>
<button wire:click="closeViewInspection" class="btn btn-sm btn-circle btn-ghost">
<x-heroicon-o-x-mark class="w-5 h-5" />
</button>
</div>
<div class="grid grid-cols-2 gap-2 text-sm mb-2">
<div><span class="text-gray-500">{{ __('Feature') }}:</span> {{ $viewingInspection['feature_name'] }}</div>
<div><span class="text-gray-500">{{ __('Template') }}:</span> {{ $viewingInspection['template_name'] }}</div>
<div><span class="text-gray-500">{{ __('Phase') }}:</span> {{ $viewingInspection['phase_name'] }}</div>
<div><span class="text-gray-500">{{ __('Layer') }}:</span> {{ $viewingInspection['layer_name'] }}</div>
<div><span class="text-gray-500">{{ __('User') }}:</span> {{ $viewingInspection['user_name'] }}</div>
<div><span class="text-gray-500">{{ __('Date') }}:</span> {{ $viewingInspection['date'] }}</div>
</div>
@if(!empty($viewingInspection['fields']))
<div class="divider text-xs">{{ __('Data') }}</div>
<div class="space-y-1 text-sm">
@foreach($viewingInspection['fields'] as $field)
<div class="flex justify-between gap-3 border-b border-base-200 py-1">
<span class="text-gray-500">{{ $field['label'] ?? ($field['name'] ?? '') }}</span>
<span class="font-medium text-right">{{ $viewingInspection['data'][$field['name']] ?? '—' }}</span>
</div>
@endforeach
</div>
@endif
@if(!empty($viewingInspection['notes']))
<div class="divider text-xs">{{ __('Notes') }}</div>
<p class="text-sm whitespace-pre-line">{{ $viewingInspection['notes'] }}</p>
@endif
<div class="modal-action">
<button wire:click="closeViewInspection" class="btn btn-sm">{{ __('Close') }}</button>
</div>
</div>
<div class="modal-backdrop bg-black/40" wire:click="closeViewInspection"></div>
</div>
@endif
</div>
</div>
</div>
@push('styles')
<style>
.leaflet-container { z-index: 0 !important; }
</style>
@endpush
@push('scripts')
<script>
let map;
const layers = {};
let imageMarkersLayer = null;
let imageViewerModal = null;
let mapInitialized = false;
let combinedBounds = null;
// Utility function to escape HTML
function escapeHtml(text) {
if (text === null || text === undefined) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Utility function to validate URL
function isValidUrl(string) {
try {
new URL(string);
return true;
} catch (_) {
return false;
}
}
function initMap() {
// Prevent multiple initializations
if (mapInitialized || map) return;
mapInitialized = true;
const center = [{{ $project->lat }}, {{ $project->lng }}];
map = L.map('map').setView(center, 16);
// Capas base seleccionables (calles / OSM / satélite)
const baseLayers = {
'{{ __('Streets') }}': L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> & CartoDB'
}),
'OpenStreetMap': L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}),
'{{ __('Satellite') }}': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles &copy; Esri — Source: Esri, Maxar, Earthstar Geographics'
}),
};
baseLayers['{{ __('Streets') }}'].addTo(map);
L.control.layers(baseLayers, null, { position: 'topleft' }).addTo(map);
// Cargar capas y sus features (cada capa = un grupo Leaflet independiente)
@foreach($phases as $phase)
@foreach($phase->layers as $layer)
@php
$fc = [
'type' => 'FeatureCollection',
'features' => $layer->features->map(function($f) {
return [
'type' => 'Feature',
'id' => $f->id,
'geometry' => $f->geometry,
'properties' => array_merge($f->properties ?? [], [
'name' => $f->name,
'progress' => $f->progress,
'responsible' => $f->responsible,
'template_id' => $f->template_id,
'_feature_id' => $f->id,
])
];
})->values()->toArray()
];
@endphp
(function() {
const data = @json($fc);
if (data && data.features && data.features.length > 0) {
const layerGroup = L.geoJSON(data, {
style: { color: '{{ $layer->color ?? $phase->color }}', weight: 3, opacity: 0.8, fillOpacity: 0.3 },
onEachFeature: function(feature, lyr) {
const props = feature.properties || {};
const featId = props._feature_id || feature.id;
const safeName = escapeHtml(props.name || '{{ __('Feature') }}');
const safeProgress = escapeHtml(props.progress || 0);
const safeResponsible = escapeHtml(props.responsible || '-');
let content = `<b>${safeName}</b><br>
{{ __('Progress') }}: ${safeProgress}%<br>
{{ __('Responsible') }}: ${safeResponsible}<br>
<button class="btn btn-xs btn-primary mt-1" onclick="selectFeature('${featId}')">{{ __('Edit') }}</button>`;
lyr.bindPopup(content);
lyr.on('click', function() { selectFeature(featId); });
}
});
layers[{{ $layer->id }}] = layerGroup;
@if(in_array((int) $layer->id, $activeLayers))
layerGroup.addTo(map);
@endif
}
})()
@endforeach
@endforeach
// Initialize combined bounds
updateCombinedBounds();
setTimeout(() => {
map.invalidateSize();
zoomToAllFeatures();
}, 100);
}
function updateCombinedBounds() {
if (!map) return;
combinedBounds = L.latLngBounds();
let hasBounds = false;
for (let id in layers) {
const layer = layers[id];
if (map.hasLayer(layer) && typeof layer.getBounds === 'function') {
const b = layer.getBounds();
if (b.isValid()) {
combinedBounds.extend(b);
hasBounds = true;
}
}
}
return hasBounds;
}
function zoomToAllFeatures() {
if (!map) return;
updateCombinedBounds();
if (combinedBounds && combinedBounds.isValid()) {
map.fitBounds(combinedBounds, { padding: [20, 20] });
} else {
map.setView([{{ $project->lat }}, {{ $project->lng }}], 16);
}
}
function selectFeature(featureId) {
@this.selectFeature(featureId);
}
function getUserLocation() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition((pos) => {
const latlng = [pos.coords.latitude, pos.coords.longitude];
L.marker(latlng).addTo(map).bindPopup('{{ __('My location') }}').openPopup();
map.setView(latlng, 16);
}, () => alert('{{ __('No results') }}'));
} else {
alert('{{ __('No results') }}');
}
}
document.addEventListener('livewire:init', function () {
setTimeout(initMap, 50);
Livewire.on('layersUpdated', (activeIds) => {
const ids = Array.isArray(activeIds) ? activeIds[0] : activeIds;
for (let id in layers) {
const lid = parseInt(id);
if (ids.includes(lid)) {
if (!map.hasLayer(layers[id])) {
layers[id].addTo(map);
updateCombinedBounds();
}
} else {
if (map.hasLayer(layers[id])) {
map.removeLayer(layers[id]);
updateCombinedBounds();
}
}
}
zoomToAllFeatures();
});
Livewire.on('centerMap', zoomToAllFeatures);
Livewire.on('mapResize', () => {
if (map) {
if (!this.resizeTimeout) {
this.resizeTimeout = setTimeout(() => {
map.invalidateSize();
this.resizeTimeout = null;
}, 100);
}
}
});
Livewire.on('featureImagesToggled', (show, markers) => {
const m = Array.isArray(markers) ? markers : markers[1];
const s = Array.isArray(show) ? show[0] : show;
if (imageMarkersLayer) {
map.removeLayer(imageMarkersLayer);
imageMarkersLayer = null;
updateCombinedBounds();
}
if (s && m && m.length > 0) {
imageMarkersLayer = L.layerGroup().addTo(map);
const photoIcon = L.divIcon({
html: '<span style="font-size: 20px; filter: drop-shadow(0 2px 2px rgba(0,0,0,0.5));">&#128252;</span>',
className: '',
iconSize: [20, 20],
iconAnchor: [10, 10]
});
m.forEach(marker => {
const safeUrl = isValidUrl(marker.image_url) ? marker.image_url : '';
const safeName = escapeHtml(marker.image_name || '');
if (safeUrl) {
const popupContent = `<b>${safeName}</b><br>
<img src="${safeUrl}" class="max-w-[200px] max-h-[150px] rounded cursor-pointer"
onclick="window.openViewer('${safeUrl}', '${safeName}')" />`;
L.marker([marker.lat, marker.lng], { icon: photoIcon })
.bindPopup(popupContent)
.addTo(imageMarkersLayer);
}
});
updateCombinedBounds();
}
});
window.openViewer = function(url, name) {
if (!isValidUrl(url)) {
console.error('Invalid URL provided to openViewer:', url);
return;
}
const safeName = escapeHtml(name);
if (imageViewerModal) imageViewerModal.remove();
const overlay = document.createElement('div');
overlay.id = 'imageViewerModal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:5000;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;padding:20px;cursor:pointer';
overlay.innerHTML = `<div style="position:relative;max-width:90vw;max-height:90vh">
<button onclick="this.closest('#imageViewerModal').remove()" style="position:absolute;top:-30px;right:0;color:white;font-size:24px;background:none;border:none;cursor:pointer">✕</button>
<img src="${url}" alt="${safeName}" style="max-width:100%;max-height:85vh;object-fit:contain;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,0.5)" />
<p style="color:white;text-align:center;margin-top:8px;font-size:14px">${safeName}</p>
</div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
imageViewerModal = overlay;
};
});
</script>
@endpush