import {Component, inject, signal, OnDestroy, OnInit} from '@angular/core'; import {Router, RouterLink} from '@angular/router'; import {FormsModule} from '@angular/forms'; import {Menu} from './menu/menu'; import {AllEvents} from '../../maps/all-events/all-events'; import {EditForm} from '../../forms/edit-form/edit-form'; import {OedbApi} from '../../services/oedb-api'; import {ActivatedRoute} from '@angular/router'; import oedb from '../../../oedb-types'; import {UnlocatedEvents} from '../../shared/unlocated-events/unlocated-events'; import {OsmAuth} from '../../services/osm-auth'; import {Osm} from '../../forms/osm/osm'; import {WhatFilterComponent} from '../../shared/what-filter/what-filter'; import {NgClass} from '@angular/common'; @Component({ selector: 'app-home', standalone: true, imports: [ Menu, AllEvents, UnlocatedEvents, EditForm, Osm, FormsModule, WhatFilterComponent, NgClass, RouterLink ], templateUrl: './home.html', styleUrl: './home.scss' }) export class Home implements OnInit, OnDestroy { OedbApi = inject(OedbApi); route = inject(ActivatedRoute); router = inject(Router); private osmAuth = inject(OsmAuth); features: Array = []; filteredFeatures: Array = []; selected: any | null = null; showTable = false; showFilters = false; showEditForm = false; showOptions = false; pleinAirMode = false; addMode: string | null = null; civilianMode = false; toasts: Array<{ id: number, type: 'success' | 'error' | 'info', message: string }> = []; selectionMode: 'none' | 'rectangle' | 'polygon' = 'none'; selectedIds: Array = []; batchAction: 'none' | 'changeWhat' | 'setField' | 'delete' = 'none'; batchWhat = ''; batchFieldKey = ''; batchFieldValue: any = ''; batchSummary: { success: number; failed: number; networkErrors: number } | null = null; // Nouvelles propriétés pour le rechargement automatique et la sélection de jours autoReloadEnabled = true; autoReloadInterval: any = null; daysAhead = 7; // Nombre de jours dans le futur par défaut isLoading = false; // Formulaire de recherche startDateStr: string | null = null; endDateStr: string | null = null; // Propriétés pour les filtres searchText = ''; selectedWhatFilter = ''; availableWhatTypes: string[] = []; theme = signal(null); subthemes: Array<{ key: string, label: string, emoji: string }> = []; activeSubtheme = signal(null); // Option bbox useBboxFilter = true; currentBbox: { minLng: number, minLat: number, maxLng: number, maxLat: number } | null = null; // Bbox par défaut pour l'Île-de-France private readonly IDF_BBOX = { minLng: 1.4, minLat: 48.1, maxLng: 3.6, maxLat: 49.2 }; // Debounce pour la recherche protected searchDebounceTimer: any = null; // Non localisés / en ligne unlocatedOrOnline: Array = []; showUnlocatedList = false; protected showQuickActions: boolean = true; firstToastDone = false; ngOnInit() { // Écouteur global pour toasts try { (window as any).addEventListener('toast', (e: any) => { const d = e?.detail || {}; this.pushToast(d.type || 'info', d.message || ''); }); } catch { } // Initialiser la bbox par défaut pour l'Île-de-France this.currentBbox = {...this.IDF_BBOX}; this.route.queryParamMap.subscribe(map => { const id = (map.get('id') || '').trim(); const what = (map.get('what') || 'culture').trim(); const add = (map.get('add') || '').trim(); const pleinAir = (map.get('pleinair') || '').trim().toLowerCase(); const preset = (map.get('preset') || '').trim().toLowerCase(); const limitParam = map.get('limit'); const limit = limitParam ? Number(limitParam) : null; // Gérer le paramètre add pour activer le formulaire de création if (add) { this.addMode = add; this.selectedWhatFilter = add; this.showEditForm = true; this.showOptions = true; // Afficher aussi le panel d'options // Créer un événement temporaire avec le type what défini this.selected = { id: null, properties: { what: add, label: '', description: '', start: new Date().toISOString(), stop: new Date(Date.now() + 24 * 3600 * 1000).toISOString() }, geometry: { type: 'Point', coordinates: [0, 0] } }; } else { this.addMode = null; // Si pas de paramètre add, s'assurer que showEditForm est géré correctement if (!this.selected) { this.showEditForm = false; } } // Charger selon les query params if (id) { this.loadSingleEvent(id); } else { this.loadEvents({what: what || undefined, limit: limit || undefined}); } // Appliquer filtre par what côté client si fourni (sauf si add est défini) if (what && !add) { this.selectedWhatFilter = what; } // Activer mode plein air via query param // if (pleinAir === '1' || pleinAir === 'true' || pleinAir === 'yes') { // this.enablePleinAirMode(); // } // Support: preset=plein_air if (preset === 'plein_air') { if (!this.firstToastDone) { this.selectedWhatFilter = "traffic" this.pushToast('info', "mode plein air activé") this.useBboxFilter = true; this.loadEvents({ what: 'traffic' }); } this.firstToastDone = true this.enablePleinAirMode(); } }); // Gérer aussi les paramètres du fragment (pour les URLs avec #) this.route.fragment.subscribe(fragment => { console.log('🔗 Fragment reçu:', fragment); if (fragment) { // Nettoyer le fragment en supprimant le & initial s'il existe const cleanFragment = fragment.startsWith('&') ? fragment.substring(1) : fragment; console.log('🧹 Fragment nettoyé:', cleanFragment); const params = new URLSearchParams(cleanFragment); const add = params.get('add'); const what = params.get('what'); console.log('🎯 Paramètre add extrait:', add); // Gérer le paramètre add du fragment if (add) { this.addMode = add; this.selectedWhatFilter = add; this.showEditForm = true; this.showOptions = true; // Afficher aussi le panel d'options // Créer un événement temporaire avec le type what défini this.selected = { id: null, properties: { what: add, label: '', description: '', start: new Date().toISOString(), stop: new Date(Date.now() + 24 * 3600 * 1000).toISOString() }, geometry: { type: 'Point', coordinates: [0, 0] } }; console.log('✅ Formulaire de création activé pour:', add); } else if (what) { this.selectedWhatFilter = what; console.log('✅ Filtre what défini:', this.selectedWhatFilter); this.loadEvents({ what: what }); } } }); this.startAutoReload(); this.loadEvents({what: "culture", limit: 100}); } ngOnDestroy() { this.stopAutoReload(); // Nettoyer le timer de debounce if (this.searchDebounceTimer) { clearTimeout(this.searchDebounceTimer); } } createEvent() { this.selected = null; //this.showTable = false; //this.showFilters = true; this.showEditForm = true; } loadEvents(overrides: { what?: string; limit?: number; start?: string; end?: string; daysAhead?: number } = {}) { this.isLoading = true; const today = new Date(); const startIso = overrides.start || this.startDateStr || today.toISOString().split('T')[0]; let endIso = overrides.end || this.endDateStr || ''; if (!endIso) { const d = new Date(today); const span = overrides.daysAhead ?? this.daysAhead; d.setDate(today.getDate() + span); endIso = d.toISOString().split('T')[0]; } const params: any = { start: startIso, end: endIso, limit: overrides.limit ?? 10000 }; if (overrides.what) { params.what = overrides.what; } else if (this.selectedWhatFilter && this.selectedWhatFilter !== '') { params.what = this.selectedWhatFilter; } // Ajouter bbox si activé et disponible if (this.useBboxFilter && this.currentBbox) { params.bbox = `${this.currentBbox.minLng},${this.currentBbox.minLat},${this.currentBbox.maxLng},${this.currentBbox.maxLat}`; } this.OedbApi.getEvents(params).subscribe((events: any) => { this.features = Array.isArray(events?.features) ? events.features : []; this.computeUnlocatedOrOnline(); this.updateAvailableWhatTypes(); this.applyFilters(); this.isLoading = false; }); } loadSingleEvent(id: string | number) { this.isLoading = true; this.OedbApi.getEventById(id).subscribe({ next: (feature: any) => { const f = (feature && (feature as any).type === 'Feature') ? feature : (feature?.feature || null); this.features = f ? [f] : []; this.filteredFeatures = this.features; this.updateAvailableWhatTypes(); this.isLoading = false; }, error: () => { this.features = []; this.filteredFeatures = []; this.isLoading = false; } }); } startAutoReload() { if (this.autoReloadEnabled && !this.autoReloadInterval) { this.autoReloadInterval = setInterval(() => { this.loadEvents(); }, 60000); // 1 minute } } stopAutoReload() { if (this.autoReloadInterval) { clearInterval(this.autoReloadInterval); this.autoReloadInterval = null; } } toggleAutoReload() { this.autoReloadEnabled = !this.autoReloadEnabled; if (this.autoReloadEnabled) { this.startAutoReload(); } else { this.stopAutoReload(); } } onDaysAheadChange() { this.loadEvents({daysAhead: this.daysAhead, what: this.selectedWhatFilter || undefined}); } updateAvailableWhatTypes() { const whatTypes = new Set(); this.features.forEach(feature => { if (feature?.properties?.what) { whatTypes.add(feature.properties.what); } }); this.route.queryParams.subscribe(p => { const t = (p?.['theme'] || '').trim(); this.theme.set(t || null); this.buildSubthemes(); }); // Ajouter les catégories principales whatTypes.add('culture'); whatTypes.add('traffic'); this.availableWhatTypes = Array.from(whatTypes).sort(); } onSearchChange() { // Annuler le timer précédent s'il existe if (this.searchDebounceTimer) { clearTimeout(this.searchDebounceTimer); } // Créer un nouveau timer de 500ms this.searchDebounceTimer = setTimeout(() => { this.applyFilters(); }, 500); } onWhatFilterChange() { this.applyFilters(); } applyFilters() { let filtered = [...this.features]; // Filtre par texte de recherche if (this.searchText.trim()) { const searchLower = this.searchText.toLowerCase(); filtered = filtered.filter(feature => { const label = feature?.properties?.label || feature?.properties?.name || ''; const description = feature?.properties?.description || ''; const what = feature?.properties?.what || ''; return label.toLowerCase().includes(searchLower) || description.toLowerCase().includes(searchLower) || what.toLowerCase().includes(searchLower); }); } // Filtre par type d'événement if (this.selectedWhatFilter) { filtered = filtered.filter(feature => { const what = feature?.properties?.what || ''; // Si c'est une catégorie (culture, traffic), filtrer par préfixe if (this.selectedWhatFilter === 'culture' || this.selectedWhatFilter === 'traffic') { return what.startsWith(this.selectedWhatFilter + '.'); } // Sinon, correspondance exacte return what === this.selectedWhatFilter; }); } // Mode plein air: ne garder que certains types if (this.pleinAirMode) { const allowed = new Set([ 'traffic.contestation', 'traffic.interruption', 'traffic.wrong_way' ]); filtered = filtered.filter(f => allowed.has(f?.properties?.what || '')); } this.filteredFeatures = filtered; } togglePleinAir() { this.pleinAirMode = !this.pleinAirMode; this.applyFilters(); } enablePleinAirMode() { if (!this.pleinAirMode) { this.pleinAirMode = true; this.applyFilters(); } } // Actions rapides plein air quickCreate(what: string) { const osmUsername = this.osmAuth.getUsername(); this.selectedPreset = what; this.showGuidePresetPlace = true; this.selected = { id: null, properties: { label: '', description: '', what, where: '', ...(osmUsername && {last_modified_by: osmUsername}) }, geometry: {type: 'Point', coordinates: [0, 0]} }; // Ensuite, l'utilisateur clique sur la carte: voir onPickCoords() // this.showEditForm = true; } cancelSubmitPreset(){ this.selected = null; this.presetMoreDetails = '' this.showGuidePresetMoreInfo = false this.showGuidePresetPlace = false; this.showQuickActions = true; } submitPreset() { const now = new Date(); const w = this.selected.properties.what; // fin du signalement par défaut dans 20 jours const stop = new Date(now.getTime() + 20 * 24 * 3600 * 1000); const feature = { type: 'Feature', properties: { type: 'unscheduled', label: this.selected.properties.label || (oedb.presets.what as any)[w]?.label || 'Évènement', description: this.selected.properties.description || (oedb.presets.what as any)[w]?.description || '', what: w, reporter: this.guidePresetMoreInfoPseudo, 'reporter:description': this.presetMoreDetails, where: this.selected.properties.where || '', start: now.toISOString(), stop: stop.toISOString() }, geometry: {type: 'Point', coordinates: [this.selected.lon, this.selected.lat]} } as any; this.OedbApi.createEvent(feature).subscribe({ next: () => { this.pushToast('success', 'Évènement créé'); this.selected = null; this.presetMoreDetails = '' this.showGuidePresetMoreInfo = false this.showGuidePresetPlace = false; // Après création rapide en plein air: recharger uniquement ce type pour feedback instantané this.selectedWhatFilter = w; this.loadEvents({what: 'traffic'}); }, error: () => { this.pushToast('error', 'Échec de création'); } }); } goToNewCategories() { this.router.navigate(['/nouvelles-categories']); } onSelect(feature: any) { this.selected = feature; } onSelectFromCalendarView(feature: any) { this.selected = feature; this.showEditForm = false; } onPickCoords(coords: [number, number]) { const [lon, lat] = coords; if (this.selected && this.selected.properties) { this.selected = { ...this.selected, geometry: {type: 'Point', coordinates: [lon, lat]} }; // this.showOptions = true; // En mode plein air, si c'est une création rapide, proposer l'envoi direct if (this.pleinAirMode && (this.selected.id == null)) { const w = this.selected.properties.what; const allowed = new Set(['traffic.contestation', 'traffic.interruption', 'traffic.wrong_way']); if (allowed.has(w)) { this.showGuidePresetPlace = true; this.showGuidePresetMoreInfo = true; this.showQuickActions = false; const self: this = this; const ok = typeof window !== 'undefined' ? window.confirm('Envoyer cet évènement maintenant ?') : true; if (ok) { self.submitPreset() } } } } else { const osmUsername = this.osmAuth.getUsername(); const whatKey = this.activeSubtheme(); let label = ''; let description = ''; if (whatKey) { const preset = (oedb.presets.what as any)[whatKey]; if (preset) { label = preset.label || ''; description = preset.description || ''; } } this.selected = { id: null, properties: { label: '', description: '', what: whatKey || '', where: '', ...(osmUsername && {last_modified_by: osmUsername}) }, geometry: {type: 'Point', coordinates: [lon, lat]} }; } } private pushToast(type: 'success' | 'error' | 'info', message: string) { if (!message) return; const id = Date.now() + Math.random(); this.toasts.push({id, type, message}); setTimeout(() => { this.toasts = this.toasts.filter(t => t.id !== id); }, 6000); } onSaved(_res: any) { // refresh list after update this.loadEvents(); } onCreated(_res: any) { this.selected = null; this.showEditForm = false; this.addMode = null; this.loadEvents(); // Retirer le paramètre add de l'URL (query params ou fragment) if (this.route.snapshot.queryParams['add']) { this.router.navigate([], { relativeTo: this.route, queryParams: { ...this.route.snapshot.queryParams, add: null }, queryParamsHandling: 'merge' }); } else if (this.route.snapshot.fragment && this.route.snapshot.fragment.includes('add=')) { // Nettoyer le fragment s'il contient add const fragment = this.route.snapshot.fragment || ''; const params = new URLSearchParams(fragment.startsWith('&') ? fragment.substring(1) : fragment); params.delete('add'); const newFragment = params.toString(); this.router.navigate([], { relativeTo: this.route, fragment: newFragment ? '?' + newFragment : undefined }); } } onDeleted(_res: any) { this.selected = null; this.loadEvents(); } // Selection from map onSelection(ids: Array) { this.selectedIds = ids; } startRectSelection() { this.selectionMode = this.selectionMode === 'rectangle' ? 'none' : 'rectangle'; } startPolySelection() { this.selectionMode = this.selectionMode === 'polygon' ? 'none' : 'polygon'; } clearSelection() { this.selectionMode = 'none'; this.selectedIds = []; this.batchAction = 'none'; this.batchWhat = ''; this.batchFieldKey = ''; this.batchFieldValue = ''; this.batchSummary = null; } async applyBatch() { if (!this.selectedIds.length || this.batchAction === 'none') return; let success = 0; let failed = 0; let networkErrors = 0; const doUpdate = async (id: string | number, updater: (f: any) => any) => { const feature = this.features.find(f => (f?.properties?.id ?? f?.id) === id); if (!feature) { failed++; return; } const updated = updater(feature); await new Promise((resolve) => { this.OedbApi.updateEvent(id, updated).subscribe({ next: () => { success++; resolve(); }, error: (err) => { (err?.status === 0 ? networkErrors++ : failed++); resolve(); } }); }); }; if (this.batchAction === 'delete') { for (const id of this.selectedIds) { await new Promise((resolve) => { this.OedbApi.deleteEvent(id).subscribe({ next: () => { success++; resolve(); }, error: (err) => { (err?.status === 0 ? networkErrors++ : failed++); resolve(); } }); }); } } else if (this.batchAction === 'changeWhat') { const what = this.batchWhat.trim(); if (!what) return; for (const id of this.selectedIds) { await doUpdate(id, (feature: any) => ({...feature, properties: {...feature.properties, what}})); } } else if (this.batchAction === 'setField') { const key = this.batchFieldKey.trim(); if (!key) return; for (const id of this.selectedIds) { await doUpdate(id, (feature: any) => ({ ...feature, properties: {...feature.properties, [key]: this.batchFieldValue} })); } } this.batchSummary = {success, failed, networkErrors}; this.loadEvents(); } closeEditForm() { this.selected = null; this.showEditForm = false; this.addMode = null; // Retirer le paramètre add de l'URL si présent (query params ou fragment) if (this.route.snapshot.queryParams['add']) { this.router.navigate([], { relativeTo: this.route, queryParams: { ...this.route.snapshot.queryParams, add: null }, queryParamsHandling: 'merge' }); } else if (this.route.snapshot.fragment && this.route.snapshot.fragment.includes('add=')) { // Nettoyer le fragment s'il contient add const fragment = this.route.snapshot.fragment || ''; const params = new URLSearchParams(fragment.startsWith('&') ? fragment.substring(1) : fragment); params.delete('add'); const newFragment = params.toString(); this.router.navigate([], { relativeTo: this.route, fragment: newFragment ? '?' + newFragment : undefined }); } } onCanceled() { this.showEditForm = false; this.addMode = null; this.selected = null; // Retirer le paramètre add de l'URL si présent (query params ou fragment) if (this.route.snapshot.queryParams['add']) { this.router.navigate([], { relativeTo: this.route, queryParams: { ...this.route.snapshot.queryParams, add: null }, queryParamsHandling: 'merge' }); } else if (this.route.snapshot.fragment && this.route.snapshot.fragment.includes('add=')) { // Nettoyer le fragment s'il contient add const fragment = this.route.snapshot.fragment || ''; const params = new URLSearchParams(fragment.startsWith('&') ? fragment.substring(1) : fragment); params.delete('add'); const newFragment = params.toString(); this.router.navigate([], { relativeTo: this.route, fragment: newFragment ? '?' + newFragment : undefined }); } } ngAfterViewInit() { // reserved } toggleView() { this.showTable = !this.showTable; } private isNonLocated(feature: any): boolean { const geom = feature?.geometry; if (!geom || geom.type !== 'Point') return true; const coords = geom.coordinates; if (!Array.isArray(coords) || coords.length !== 2) return true; const [lon, lat] = coords; if (lon == null || lat == null) return true; if (lon === 0 && lat === 0) return true; return false; } private isOnline(feature: any): boolean { const v = feature?.properties?.online; return v === 'yes' || v === true; } private computeUnlocatedOrOnline() { this.unlocatedOrOnline = (this.features || []).filter(f => this.isNonLocated(f) || this.isOnline(f)); } toggleUnlocatedPanel() { this.showUnlocatedList = !this.showUnlocatedList; } createMammoth() { const osmUsername = this.osmAuth.getUsername(); this.selected = { id: null, properties: { label: '', description: '', what: 'traffic.mammoth', where: '', ...(osmUsername && {last_modified_by: osmUsername}) }, geometry: {type: 'Point', coordinates: [0, 0]} }; this.showEditForm = true; } private buildSubthemes() { const t = this.theme(); if (!t) { this.subthemes = []; this.activeSubtheme.set(null); return; } const what = oedb.presets.what as Record; const list: Array<{ key: string, label: string, emoji: string }> = []; Object.keys(what).forEach(k => { if (k === t || k.startsWith(`${t}.`)) { list.push({key: k, label: what[k].label || k, emoji: what[k].emoji || ''}); } }); this.subthemes = list.sort((a, b) => a.key.localeCompare(b.key)); const exact = this.subthemes.find(s => s.key === t); this.activeSubtheme.set(exact ? exact.key : (this.subthemes[0]?.key || null)); } downloadGeoJSON() { const blob = new Blob([JSON.stringify({ type: 'FeatureCollection', features: this.filteredFeatures }, null, 2)], {type: 'application/geo+json'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'events.geojson'; document.body.appendChild(a); a.click(); URL.revokeObjectURL(url); a.remove(); } downloadCSV() { const header = ['id', 'what', 'label', 'start', 'stop', 'lon', 'lat']; const rows = this.filteredFeatures.map((f: any) => [ JSON.stringify(f?.properties?.id ?? f?.id ?? ''), JSON.stringify(f?.properties?.what ?? ''), JSON.stringify(f?.properties?.label ?? f?.properties?.name ?? ''), JSON.stringify(f?.properties?.start ?? f?.properties?.when ?? ''), JSON.stringify(f?.properties?.stop ?? ''), JSON.stringify(f?.geometry?.coordinates?.[0] ?? ''), JSON.stringify(f?.geometry?.coordinates?.[1] ?? '') ].join(',')); const csv = [header.join(','), ...rows].join('\n'); const blob = new Blob([csv], {type: 'text/csv'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'events.csv'; document.body.appendChild(a); a.click(); URL.revokeObjectURL(url); a.remove(); } onQuickSearchSubmit() { const start = (this.startDateStr || '').trim() || undefined; const end = (this.endDateStr || '').trim() || undefined; const days = this.daysAhead; const what = (this.selectedWhatFilter || '').trim() || undefined; this.loadEvents({start, end, daysAhead: days, what}); } onBboxFilterToggle() { this.useBboxFilter = !this.useBboxFilter; if (this.useBboxFilter) { // Demander la bbox actuelle à la carte this.requestCurrentBbox(); } this.loadEvents(); } requestCurrentBbox() { // Cette méthode sera appelée par le composant de carte // pour obtenir la bbox actuelle console.log('Demande de bbox actuelle...'); } setCurrentBbox(bbox: { minLng: number, minLat: number, maxLng: number, maxLat: number }) { this.currentBbox = bbox; if (this.useBboxFilter) { this.loadEvents(); } } // Méthode pour recharger les événements quand la carte bouge showGuidePresetPlace: boolean = false; showGuidePresetMoreInfo: boolean = false; guidePresetMoreInfoPseudo: string = ''; presetMoreDetails: string = ''; selectedPreset: string = ''; onMapMove(bbox: { minLng: number, minLat: number, maxLng: number, maxLat: number }) { this.setCurrentBbox(bbox); } getShareQueryParams(): any { const params: any = {}; // Récupérer les paramètres de filtrage actuels if (this.selectedWhatFilter) { params.what = this.selectedWhatFilter; } if (this.startDateStr) { params.start = this.startDateStr; } if (this.endDateStr) { params.end = this.endDateStr; } // Ajouter bbox si disponible if (this.useBboxFilter && this.currentBbox) { params.bbox = `${this.currentBbox.minLng},${this.currentBbox.minLat},${this.currentBbox.maxLng},${this.currentBbox.maxLat}`; } // Limite par défaut pour l'embed params.limit = 50; return params; } }