style marqueurs events, page nouvelles catégories

This commit is contained in:
Tykayn 2025-10-04 16:14:42 +02:00 committed by tykayn
parent 20a8445a5f
commit 9fb9986a2c
15 changed files with 987 additions and 203 deletions

View file

@ -1,14 +1,20 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import * as moment from 'moment';
import 'moment/locale/fr';
import { routes } from './app.routes';
// Configuration du locale français pour moment
moment.locale('fr');
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient()
provideHttpClient(),
{ provide: 'moment', useValue: moment }
]
};

View file

@ -1,6 +1,7 @@
import { Routes } from '@angular/router';
import {Home} from './pages/home/home';
import { Agenda } from './pages/agenda/agenda';
import { NouvellesCategories } from './pages/nouvelles-categories/nouvelles-categories';
export const routes: Routes = [
{
@ -10,5 +11,9 @@ export const routes: Routes = [
{
path : 'agenda',
component: Agenda
},
{
path : 'nouvelles-categories',
component: NouvellesCategories
}
];

View file

@ -1,4 +1,5 @@
import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import oedb_what_categories from '../../../oedb-types';
@Component({
@ -8,7 +9,7 @@ import oedb_what_categories from '../../../oedb-types';
templateUrl: './all-events.html',
styleUrl: './all-events.scss'
})
export class AllEvents {
export class AllEvents implements OnInit, OnDestroy {
@Input() features: Array<any> = [];
@Input() selected: any | null = null;
@Input() highlight: { id: string | number, type: 'saved' | 'deleted' } | null = null;
@ -22,6 +23,13 @@ export class AllEvents {
private pickedMarker: any | null = null;
private originalCoords: [number, number] | null = null;
private currentPicked: [number, number] | null = null;
private isInitialLoad = true;
private mapInitialized = false;
constructor(
private route: ActivatedRoute,
private router: Router
) {}
async ngOnInit() {
await this.ensureMapLibre();
@ -76,11 +84,17 @@ export class AllEvents {
private initMap() {
const maplibregl = (window as any).maplibregl;
// Récupérer les paramètres de l'URL ou utiliser les valeurs par défaut (Île-de-France)
const lat = parseFloat(this.route.snapshot.queryParams['lat']) || 48.8566;
const lon = parseFloat(this.route.snapshot.queryParams['lon']) || 2.3522;
const zoom = parseFloat(this.route.snapshot.queryParams['zoom']) || 8;
this.map = new maplibregl.Map({
container: this.mapContainer.nativeElement,
style: 'https://tiles.openfreemap.org/styles/liberty',
center: [2.3522, 48.8566],
zoom: 5
center: [lon, lat],
zoom: zoom
});
this.map.addControl(new maplibregl.NavigationControl());
this.map.addControl(new maplibregl.GeolocateControl({ positionOptions: { enableHighAccuracy: true }, trackUserLocation: true }));
@ -90,6 +104,21 @@ export class AllEvents {
this.showPickedMarker(coords);
this.pickCoords.emit(coords);
});
// Écouter les changements de vue pour mettre à jour l'URL
this.map.on('moveend', () => {
if (this.mapInitialized) {
this.updateUrlFromMap();
}
});
this.map.on('zoomend', () => {
if (this.mapInitialized) {
this.updateUrlFromMap();
}
});
this.mapInitialized = true;
}
private getEmojiForWhat(what: string): string {
@ -164,6 +193,24 @@ export class AllEvents {
if (this.map) this.map.flyTo({ center: this.originalCoords, zoom: Math.max(this.map.getZoom() || 12, 12) });
}
private updateUrlFromMap() {
if (!this.map) return;
const center = this.map.getCenter();
const zoom = this.map.getZoom();
this.router.navigate([], {
relativeTo: this.route,
queryParams: {
lat: center.lat.toFixed(6),
lon: center.lng.toFixed(6),
zoom: Math.round(zoom)
},
queryParamsHandling: 'merge',
replaceUrl: true
});
}
private renderFeatures() {
if (!this.map || !Array.isArray(this.features)) return;
// clear existing markers
@ -225,7 +272,15 @@ export class AllEvents {
bounds.extend(coords);
});
if (!bounds.isEmpty()) {
// Ne pas faire de fitBounds lors du chargement initial si on a des paramètres URL
if (!bounds.isEmpty() && this.isInitialLoad) {
const hasUrlParams = this.route.snapshot.queryParams['lat'] || this.route.snapshot.queryParams['lon'] || this.route.snapshot.queryParams['zoom'];
if (!hasUrlParams) {
this.map.fitBounds(bounds, { padding: 40, maxZoom: 12 });
}
this.isInitialLoad = false;
} else if (!bounds.isEmpty() && !this.isInitialLoad) {
// Pour les mises à jour suivantes, on peut faire un fitBounds léger
this.map.fitBounds(bounds, { padding: 40, maxZoom: 12 });
}
}

View file

@ -2,35 +2,59 @@
<div class="agenda-header">
<h1>Agenda des événements</h1>
<p>Événements des 20 derniers jours (10 jours avant et 10 jours après aujourd'hui)</p>
<div class="calendar-controls">
<button
class="btn btn-sm"
[class.btn-primary]="view === CalendarView.Month"
(click)="setView(CalendarView.Month)">
Mois
</button>
<button
class="btn btn-sm"
[class.btn-primary]="view === CalendarView.Week"
(click)="setView(CalendarView.Week)">
Semaine
</button>
<button
class="btn btn-sm"
[class.btn-primary]="view === CalendarView.Day"
(click)="setView(CalendarView.Day)">
Jour
</button>
</div>
</div>
<div class="agenda-content">
<div class="days-grid">
@for (day of daysWithEvents; track day.date.getTime()) {
<div class="day-card" [class.today]="isToday(day.date)" [class.past]="isPast(day.date)">
<div class="day-header">
<h3>{{ formatDate(day.date) }}</h3>
<span class="event-count">{{ day.events.length }} événement(s)</span>
</div>
<div class="events-list">
@if (day.events.length === 0) {
<p class="no-events">Aucun événement</p>
} @else {
@for (event of day.events; track event.id) {
<div class="event-item" (click)="selectEvent(event)">
<div class="event-time">{{ getEventTime(event) }}</div>
<div class="event-title">{{ getEventTitle(event) }}</div>
@if (event.properties.what) {
<div class="event-type">{{ event.properties.what }}</div>
}
</div>
}
}
</div>
</div>
}
</div>
<mwl-calendar-month-view
*ngIf="view === CalendarView.Month"
[viewDate]="viewDate"
[events]="calendarEvents"
(eventClicked)="onEventClicked($event)"
(dayClicked)="dayClicked($event)"
[locale]="'fr'"
[eventTitleTemplate]="eventTitleTemplate">
</mwl-calendar-month-view>
<mwl-calendar-week-view
*ngIf="view === CalendarView.Week"
[viewDate]="viewDate"
[events]="calendarEvents"
(eventClicked)="onEventClicked($event)"
(dayClicked)="dayClicked($event)"
[locale]="'fr'"
[eventTitleTemplate]="eventTitleTemplate">
</mwl-calendar-week-view>
<mwl-calendar-day-view
*ngIf="view === CalendarView.Day"
[viewDate]="viewDate"
[events]="calendarEvents"
(eventClicked)="onEventClicked($event)"
(dayClicked)="dayClicked($event)"
[locale]="'fr'"
[eventTitleTemplate]="eventTitleTemplate">
</mwl-calendar-day-view>
</div>
<!-- Panneau latéral pour les détails de l'événement -->
@ -56,4 +80,12 @@
@if (showSidePanel) {
<div class="overlay" (click)="closeSidePanel()"></div>
}
</div>
</div>
<!-- Template personnalisé pour les événements du calendrier -->
<ng-template #eventTitleTemplate let-event="event">
<div class="custom-event">
<span class="event-emoji">{{ getEventIcon(event.meta?.preset) }}</span>
<span class="event-title">{{ event.title }}</span>
</div>
</ng-template>

View file

@ -1,6 +1,6 @@
.agenda-container {
padding: 20px;
max-width: 1200px;
max-width: 1400px;
margin: 0 auto;
position: relative;
}
@ -17,122 +17,132 @@
p {
color: #666;
font-size: 14px;
margin-bottom: 20px;
}
}
.calendar-controls {
display: flex;
justify-content: center;
gap: 10px;
margin-bottom: 20px;
.btn {
padding: 8px 16px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f8f9fa;
}
&.btn-primary {
background: #007bff;
color: white;
border-color: #007bff;
}
}
}
.agenda-content {
margin-bottom: 20px;
}
.days-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.day-card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 15px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
&.today {
border-color: #007bff;
background: #f8f9ff;
// Styles pour angular-calendar
::ng-deep {
.cal-month-view,
.cal-week-view,
.cal-day-view {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
}
.day-header h3 {
color: #007bff;
.cal-header {
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
.cal-header .cal-cell {
padding: 15px 10px;
font-weight: 600;
color: #495057;
}
.cal-cell {
border-right: 1px solid #e9ecef;
border-bottom: 1px solid #e9ecef;
&:last-child {
border-right: none;
}
}
.cal-today {
background: #e3f2fd !important;
}
.cal-event {
border-radius: 4px;
padding: 2px 6px;
margin: 1px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
transform: scale(1.02);
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
}
.cal-event-title {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.custom-event {
display: flex;
align-items: center;
gap: 4px;
.event-emoji {
font-size: 12px;
flex-shrink: 0;
}
.event-title {
font-size: 11px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
}
.cal-month-view .cal-day-number {
font-size: 14px;
font-weight: 500;
color: #495057;
}
.cal-today .cal-day-number {
background: #007bff;
color: white;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
}
&.past {
opacity: 0.7;
background: #f5f5f5;
}
}
.day-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.event-count {
background: #007bff;
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
}
.events-list {
min-height: 50px;
}
.no-events {
color: #999;
font-style: italic;
text-align: center;
margin: 20px 0;
}
.event-item {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 12px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #e9ecef;
border-color: #007bff;
transform: translateY(-1px);
}
&:last-child {
margin-bottom: 0;
}
}
.event-time {
font-size: 12px;
color: #666;
font-weight: 500;
margin-bottom: 4px;
}
.event-title {
font-weight: 600;
color: #333;
margin-bottom: 4px;
line-height: 1.3;
}
.event-type {
font-size: 12px;
color: #007bff;
background: #e7f3ff;
padding: 2px 6px;
border-radius: 4px;
display: inline-block;
}
// Panneau latéral

View file

@ -1,9 +1,12 @@
import { Component, inject, OnInit } from '@angular/core';
import { Component, inject, OnInit, ViewChild, TemplateRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { OedbApi } from '../../services/oedb-api';
import { EditForm } from '../../forms/edit-form/edit-form';
import { CalendarModule, CalendarView, CalendarEvent } from 'angular-calendar';
import { CalendarEventAction, CalendarEventTimesChangedEvent } from 'angular-calendar';
import oedb from '../../../oedb-types';
interface Event {
interface OedbEvent {
id: string;
properties: {
label?: string;
@ -23,23 +26,31 @@ interface Event {
interface DayEvents {
date: Date;
events: Event[];
events: OedbEvent[];
}
@Component({
selector: 'app-agenda',
standalone: true,
imports: [CommonModule, EditForm],
imports: [CommonModule, EditForm, CalendarModule],
templateUrl: './agenda.html',
styleUrl: './agenda.scss'
})
export class Agenda implements OnInit {
private oedbApi = inject(OedbApi);
events: Event[] = [];
daysWithEvents: DayEvents[] = [];
selectedEvent: Event | null = null;
@ViewChild('eventTitleTemplate', { static: true }) eventTitleTemplate!: TemplateRef<any>;
events: OedbEvent[] = [];
calendarEvents: CalendarEvent[] = [];
selectedEvent: OedbEvent | null = null;
showSidePanel = false;
view: CalendarView = CalendarView.Month;
viewDate: Date = new Date();
oedbPresets = oedb.presets.what;
// Exposer CalendarView pour l'utiliser dans le template
CalendarView = CalendarView;
ngOnInit() {
this.loadEvents();
@ -66,37 +77,24 @@ export class Agenda implements OnInit {
}
organizeEventsByDay() {
const daysMap = new Map<string, DayEvents>();
// Initialiser les 20 jours
for (let i = -10; i <= 10; i++) {
const date = new Date();
date.setDate(date.getDate() + i);
const dateKey = date.toISOString().split('T')[0];
daysMap.set(dateKey, {
date: new Date(date),
events: []
});
}
// Organiser les événements par jour
this.events.forEach(event => {
this.calendarEvents = this.events.map(event => {
const eventDate = this.getEventDate(event);
if (eventDate) {
const dateKey = eventDate.toISOString().split('T')[0];
const dayEvents = daysMap.get(dateKey);
if (dayEvents) {
dayEvents.events.push(event);
const preset = this.getEventPreset(event);
return {
id: event.id,
title: this.getEventTitle(event),
start: eventDate || new Date(),
color: this.getEventColor(preset),
meta: {
event: event,
preset: preset
}
}
};
});
this.daysWithEvents = Array.from(daysMap.values()).sort((a, b) =>
a.date.getTime() - b.date.getTime()
);
}
getEventDate(event: Event): Date | null {
getEventDate(event: OedbEvent): Date | null {
const startDate = event.properties.start || event.properties.when;
if (startDate) {
return new Date(startDate);
@ -104,11 +102,11 @@ export class Agenda implements OnInit {
return null;
}
getEventTitle(event: Event): string {
getEventTitle(event: OedbEvent): string {
return event.properties.label || event.properties.name || 'Événement sans titre';
}
getEventTime(event: Event): string {
getEventTime(event: OedbEvent): string {
const startDate = event.properties.start || event.properties.when;
if (startDate) {
const date = new Date(startDate);
@ -120,11 +118,55 @@ export class Agenda implements OnInit {
return '';
}
selectEvent(event: Event) {
selectEvent(event: OedbEvent) {
this.selectedEvent = event;
this.showSidePanel = true;
}
onEventClicked({ event }: { event: CalendarEvent; sourceEvent: MouseEvent | KeyboardEvent }) {
if (event.meta && event.meta.event) {
this.selectEvent(event.meta.event);
}
}
getEventPreset(event: OedbEvent): any {
const what = event.properties.what;
if (what && (this.oedbPresets as any)[what]) {
return (this.oedbPresets as any)[what];
}
return null;
}
getEventIcon(preset: any): string {
if (preset) {
return preset.emoji || '📅';
}
return '📅';
}
getEventColor(preset: any): any {
if (preset) {
// Couleurs basées sur la catégorie
const categoryColors: { [key: string]: any } = {
'Communauté': { primary: '#007bff', secondary: '#cce7ff' },
'Culture': { primary: '#28a745', secondary: '#d4edda' },
'Musique': { primary: '#ffc107', secondary: '#fff3cd' },
'Énergie': { primary: '#dc3545', secondary: '#f8d7da' },
'Commerce': { primary: '#6f42c1', secondary: '#e2d9f3' },
'Temps': { primary: '#17a2b8', secondary: '#d1ecf1' },
'Tourisme': { primary: '#fd7e14', secondary: '#ffeaa7' },
'Circulation': { primary: '#6c757d', secondary: '#e9ecef' },
'Randonnée': { primary: '#20c997', secondary: '#d1f2eb' },
'Vie sauvage': { primary: '#795548', secondary: '#efebe9' },
'Météo': { primary: '#2196f3', secondary: '#e3f2fd' }
};
const category = preset.category || 'Communauté';
return categoryColors[category] || { primary: '#6c757d', secondary: '#e9ecef' };
}
return { primary: '#6c757d', secondary: '#e9ecef' };
}
closeSidePanel() {
this.showSidePanel = false;
this.selectedEvent = null;
@ -145,22 +187,19 @@ export class Agenda implements OnInit {
this.closeSidePanel();
}
isToday(date: Date): boolean {
const today = new Date();
return date.toDateString() === today.toDateString();
setView(view: CalendarView) {
this.view = view;
}
isPast(date: Date): boolean {
const today = new Date();
today.setHours(0, 0, 0, 0);
return date < today;
dayClicked({ date, events }: { date: Date; events: CalendarEvent[] }): void {
console.log('Day clicked:', date, events);
}
formatDate(date: Date): string {
return date.toLocaleDateString('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long'
});
eventTimesChanged({
event,
newStart,
newEnd,
}: CalendarEventTimesChangedEvent): void {
console.log('Event times changed:', event, newStart, newEnd);
}
}

View file

@ -3,7 +3,43 @@
<div class="toolbar">
<strong>OpenEventDatabase</strong>
<span class="muted">{{features.length}} évènements</span>
@if (isLoading) {
<span class="loading">⏳ Chargement...</span>
}
</div>
<div class="controls">
<div class="control-group">
<label>Jours à venir</label>
<input
type="number"
class="input"
[(ngModel)]="daysAhead"
(ngModelChange)="onDaysAheadChange()"
min="1"
max="30"
placeholder="7">
</div>
<div class="control-group">
<label>
<input
type="checkbox"
[(ngModel)]="autoReloadEnabled"
(change)="toggleAutoReload()">
Rechargement auto (1min)
</label>
</div>
<div class="control-group">
<button
class="btn btn-sm"
(click)="goToNewCategories()">
📋 Nouvelles catégories
</button>
</div>
</div>
<div class="filters">
<label>Filtre rapide</label>
<input class="input" type="text" placeholder="Rechercher...">

View file

@ -29,6 +29,69 @@
align-items: center;
justify-content: space-between;
padding: 8px 12px;
.loading {
color: #007bff;
font-size: 12px;
}
}
.controls {
margin: 15px 0;
padding: 15px;
background: #f8f9fa;
border-radius: 6px;
border: 1px solid #e9ecef;
.control-group {
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
label {
display: block;
font-size: 12px;
font-weight: 600;
color: #495057;
margin-bottom: 4px;
input[type="checkbox"] {
margin-right: 6px;
}
}
input[type="number"] {
width: 100%;
padding: 6px 8px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
&:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0,123,255,0.25);
}
}
.btn {
width: 100%;
padding: 8px 12px;
background: #6c757d;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background-color 0.2s;
&:hover {
background: #5a6268;
}
}
}
}
.map {

View file

@ -1,4 +1,6 @@
import { Component, inject } from '@angular/core';
import { Component, inject, OnDestroy, OnInit } from '@angular/core';
import { Router } 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';
@ -11,24 +13,86 @@ import { UnlocatedEvents } from '../../shared/unlocated-events/unlocated-events'
Menu,
AllEvents,
UnlocatedEvents,
EditForm
EditForm,
FormsModule
],
templateUrl: './home.html',
styleUrl: './home.scss'
})
export class Home {
export class Home implements OnInit, OnDestroy {
OedbApi = inject(OedbApi);
private router = inject(Router);
features: Array<any> = [];
selected: any | null = null;
showTable = false;
// 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;
constructor() {
this.OedbApi.getEvents({ when: 'now', limit: 500 }).subscribe((events: any) => {
ngOnInit() {
this.loadEvents();
this.startAutoReload();
}
ngOnDestroy() {
this.stopAutoReload();
}
loadEvents() {
this.isLoading = true;
const today = new Date();
const endDate = new Date(today);
endDate.setDate(today.getDate() + this.daysAhead);
const params = {
start: today.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
limit: 1000
};
this.OedbApi.getEvents(params).subscribe((events: any) => {
this.features = Array.isArray(events?.features) ? events.features : [];
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();
}
goToNewCategories() {
this.router.navigate(['/nouvelles-categories']);
}
onSelect(feature: any) {
this.selected = feature;
}
@ -52,24 +116,18 @@ export class Home {
onSaved(_res: any) {
// refresh list after update
this.OedbApi.getEvents({ when: 'now', limit: 500 }).subscribe((events: any) => {
this.features = Array.isArray(events?.features) ? events.features : [];
});
this.loadEvents();
}
onCreated(_res: any) {
// refresh and clear selection after create
this.selected = null;
this.OedbApi.getEvents({ when: 'now', limit: 500 }).subscribe((events: any) => {
this.features = Array.isArray(events?.features) ? events.features : [];
});
this.loadEvents();
}
onDeleted(_res: any) {
this.selected = null;
this.OedbApi.getEvents({ when: 'now', limit: 500 }).subscribe((events: any) => {
this.features = Array.isArray(events?.features) ? events.features : [];
});
this.loadEvents();
}
// Menu callbacks

View file

@ -0,0 +1,68 @@
<div class="nouvelles-categories-container">
<div class="header">
<h1>📋 Nouvelles catégories d'événements</h1>
<p>Découvrez les nouveaux types d'événements qui ne sont pas encore dans la configuration OEDB</p>
<div class="controls">
<button
class="btn btn-primary"
(click)="loadNewCategories()"
[disabled]="isLoading">
@if (isLoading) {
⏳ Chargement...
} @else {
🔄 Actualiser
}
</button>
</div>
</div>
@if (isLoading) {
<div class="loading">
<p>Analyse des événements des 30 prochains jours...</p>
</div>
} @else if (eventTypes.length === 0) {
<div class="no-data">
<p>🎉 Aucune nouvelle catégorie trouvée ! Tous les types d'événements sont déjà dans la configuration.</p>
</div>
} @else {
<div class="categories-list">
<h2>{{ eventTypes.length }} nouvelle(s) catégorie(s) trouvée(s)</h2>
@for (eventType of eventTypes; track eventType.what) {
<div class="category-card">
<div class="category-header">
<h3>{{ eventType.what }}</h3>
<span class="count">{{ eventType.count }} événement(s)</span>
</div>
<div class="examples">
<h4>Exemples :</h4>
<ul>
@for (example of eventType.examples; track example.id) {
<li>
<strong>{{ example.properties?.label || example.properties?.name || 'Sans titre' }}</strong>
@if (example.properties?.start || example.properties?.when) {
- {{ example.properties?.start || example.properties?.when }}
}
</li>
}
</ul>
</div>
<div class="code-generation">
<h4>Code à ajouter dans oedb-types.ts :</h4>
<div class="code-block">
<pre><code>{{ generateJsonCode(eventType) }}</code></pre>
<button
class="btn btn-sm copy-btn"
(click)="copyToClipboard(generateJsonCode(eventType))">
📋 Copier
</button>
</div>
</div>
</div>
}
</div>
}
</div>

View file

@ -0,0 +1,168 @@
.nouvelles-categories-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.header {
margin-bottom: 30px;
text-align: center;
h1 {
color: #333;
margin-bottom: 10px;
}
p {
color: #666;
margin-bottom: 20px;
}
.controls {
margin-top: 20px;
}
}
.loading, .no-data {
text-align: center;
padding: 40px;
color: #666;
p {
font-size: 18px;
}
}
.categories-list {
h2 {
color: #333;
margin-bottom: 20px;
border-bottom: 2px solid #007bff;
padding-bottom: 10px;
}
}
.category-card {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
.category-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
h3 {
color: #007bff;
margin: 0;
font-family: 'Courier New', monospace;
}
.count {
background: #007bff;
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
}
}
.examples {
margin-bottom: 20px;
h4 {
color: #495057;
margin-bottom: 10px;
font-size: 14px;
}
ul {
margin: 0;
padding-left: 20px;
li {
margin-bottom: 5px;
color: #6c757d;
}
}
}
.code-generation {
h4 {
color: #495057;
margin-bottom: 10px;
font-size: 14px;
}
.code-block {
position: relative;
background: #2d3748;
border-radius: 6px;
padding: 15px;
pre {
margin: 0;
color: #e2e8f0;
font-family: 'Courier New', monospace;
font-size: 12px;
line-height: 1.4;
overflow-x: auto;
code {
color: inherit;
}
}
.copy-btn {
position: absolute;
top: 10px;
right: 10px;
background: #4a5568;
color: white;
border: none;
padding: 5px 10px;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background: #2d3748;
}
}
}
}
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
&.btn-primary {
background: #007bff;
color: white;
&:hover:not(:disabled) {
background: #0056b3;
}
&:disabled {
background: #6c757d;
cursor: not-allowed;
}
}
&.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
}

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NouvellesCategories } from './nouvelles-categories';
describe('NouvellesCategories', () => {
let component: NouvellesCategories;
let fixture: ComponentFixture<NouvellesCategories>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NouvellesCategories]
})
.compileComponents();
fixture = TestBed.createComponent(NouvellesCategories);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,190 @@
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { OedbApi } from '../../services/oedb-api';
import oedb from '../../../oedb-types';
interface EventType {
what: string;
count: number;
examples: any[];
}
@Component({
selector: 'app-nouvelles-categories',
standalone: true,
imports: [CommonModule],
templateUrl: './nouvelles-categories.html',
styleUrl: './nouvelles-categories.scss'
})
export class NouvellesCategories implements OnInit {
private oedbApi = inject(OedbApi);
eventTypes: EventType[] = [];
isLoading = false;
oedbPresets = oedb.presets.what;
ngOnInit() {
this.loadNewCategories();
}
loadNewCategories() {
this.isLoading = true;
const today = new Date();
const endDate = new Date(today);
endDate.setDate(today.getDate() + 30); // 30 prochains jours
const params = {
start: today.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0],
limit: 1000
};
this.oedbApi.getEvents(params).subscribe((response: any) => {
const events = Array.isArray(response?.features) ? response.features : [];
this.analyzeEventTypes(events);
this.isLoading = false;
});
}
private analyzeEventTypes(events: any[]) {
const typeMap = new Map<string, { count: number; examples: any[] }>();
events.forEach(event => {
const what = event.properties?.what;
if (what && !this.isKnownType(what)) {
if (!typeMap.has(what)) {
typeMap.set(what, { count: 0, examples: [] });
}
const typeData = typeMap.get(what)!;
typeData.count++;
if (typeData.examples.length < 3) {
typeData.examples.push(event);
}
}
});
this.eventTypes = Array.from(typeMap.entries())
.map(([what, data]) => ({
what,
count: data.count,
examples: data.examples
}))
.sort((a, b) => b.count - a.count);
}
private isKnownType(what: string): boolean {
return !!(this.oedbPresets as any)[what];
}
generateJsonCode(eventType: EventType): string {
const what = eventType.what;
const example = eventType.examples[0];
const properties = example?.properties || {};
// Générer un emoji basé sur le type
const emoji = this.suggestEmoji(what);
// Générer une catégorie basée sur le premier mot
const category = this.suggestCategory(what);
// Générer des propriétés basées sur l'exemple
const suggestedProperties = this.suggestProperties(properties);
return `'${what}': {
emoji: '${emoji}',
label: '${this.suggestLabel(what)}',
category: '${category}',
description: '${this.suggestDescription(what)}',
durationHours: 24${suggestedProperties ? ',\n properties: {\n' + suggestedProperties + '\n }' : ''}
}`;
}
private suggestEmoji(what: string): string {
const emojiMap: { [key: string]: string } = {
'music': '🎵',
'sport': '⚽',
'food': '🍽️',
'art': '🎨',
'tech': '💻',
'health': '🏥',
'education': '📚',
'business': '💼',
'travel': '✈️',
'nature': '🌿',
'social': '👥',
'festival': '🎪',
'conference': '🎤',
'workshop': '🔧',
'exhibition': '🖼️',
'sale': '🛒',
'meeting': '🤝',
'party': '🎉',
'concert': '🎸',
'theater': '🎭'
};
const firstWord = what.split('.')[0].toLowerCase();
return emojiMap[firstWord] || '📅';
}
private suggestCategory(what: string): string {
const categoryMap: { [key: string]: string } = {
'music': 'Musique',
'sport': 'Sport',
'food': 'Gastronomie',
'art': 'Culture',
'tech': 'Technologie',
'health': 'Santé',
'education': 'Éducation',
'business': 'Commerce',
'travel': 'Tourisme',
'nature': 'Nature',
'social': 'Communauté',
'festival': 'Culture',
'conference': 'Professionnel',
'workshop': 'Formation',
'exhibition': 'Culture',
'sale': 'Commerce',
'meeting': 'Professionnel',
'party': 'Social',
'concert': 'Musique',
'theater': 'Culture'
};
const firstWord = what.split('.')[0].toLowerCase();
return categoryMap[firstWord] || 'Autre';
}
private suggestLabel(what: string): string {
return what.split('.').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
}
private suggestDescription(what: string): string {
return `Événement de type ${this.suggestLabel(what)}`;
}
private suggestProperties(properties: any): string {
const suggestedProps: string[] = [];
// Analyser les propriétés communes pour suggérer des champs
Object.keys(properties).forEach(key => {
if (key !== 'what' && key !== 'label' && key !== 'name' && key !== 'id' && key !== 'uuid') {
const value = properties[key];
if (typeof value === 'string' && value.length > 0) {
suggestedProps.push(` ${key}: { label: '${this.suggestLabel(key)}', writable: true }`);
}
}
});
return suggestedProps.join(',\n');
}
copyToClipboard(text: string) {
navigator.clipboard.writeText(text).then(() => {
// Optionnel: afficher une notification de succès
console.log('Code copié dans le presse-papiers');
});
}
}

View file

@ -186,6 +186,25 @@ const oedb = {
properties: {
lightning_count: { label: 'Nombre déclairs', writable: true }
}
}, 'weather.flood': {
emoji: '🌊',
label: 'Inondation',
category: 'Météo',
description: 'Inondation',
durationHours: 24,
properties: {
flood_level: { label: 'Niveau d\'inondation', writable: true }
}
},
'weather.snow': {
emoji: '❄️',
label: 'Neige',
category: 'Météo',
description: 'Neige',
durationHours: 12,
properties: {
snow_level: { label: 'Niveau de neige', writable: true }
}
},
'weather.earthquake': {
emoji: '🌎',

View file

@ -110,3 +110,15 @@ label { font-size: 0.85rem; color: $color-muted; }
.toast.is-success { background: $color-success; }
.toast.is-error { background: $color-error; }
.toast.is-info { background: $color-info; }
// marquerus maplibre
.maplibregl-marker{
background-color: #fff;
border-radius: 50%;
width: 16px;
height: 16px;
padding: 10px;
box-shadow: 0 0 10px 5px rgba(0,0,0,0.2);
}