Compare commits
3 commits
80d52ff819
...
bdb3728494
Author | SHA1 | Date | |
---|---|---|---|
![]() |
bdb3728494 | ||
![]() |
f991aee8ed | ||
![]() |
83ef7bab6c |
28 changed files with 1465 additions and 71 deletions
7
frontend/.env.example
Normal file
7
frontend/.env.example
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
DB_USER=cipherbliss
|
||||||
|
POSTGRES_PASSWORD=tralalahihou
|
||||||
|
|
||||||
|
CLIENT_ID=ziozioizo-sllkslk
|
||||||
|
CLIENT_SECRET=spposfdo-msmldflkds
|
||||||
|
CLIENT_AUTORIZATIONS=read_prefs
|
||||||
|
CLIENT_REDIRECT=https://oedb.cipherbliss.com/demo/traffic
|
BIN
frontend/public/static/cone.png
Normal file
BIN
frontend/public/static/cone.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 171 KiB |
|
@ -1,4 +1,5 @@
|
||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
|
@ -7,6 +8,7 @@ export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
provideRouter(routes)
|
provideRouter(routes),
|
||||||
|
provideHttpClient()
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
html{
|
|
||||||
font-family: "Calibri", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
|
||||||
}
|
|
116
frontend/src/app/forms/edit-form/edit-form.html
Normal file
116
frontend/src/app/forms/edit-form/edit-form.html
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
<form (ngSubmit)="onSubmit()" [formGroup]="form">
|
||||||
|
<div class="row">
|
||||||
|
<label>What</label>
|
||||||
|
<input class="input" type="text" formControlName="what" placeholder="ex: traffic.roadwork" />
|
||||||
|
<div class="presets under-field">
|
||||||
|
<h4>Presets</h4>
|
||||||
|
<div class="preset-groups">
|
||||||
|
@for (g of filteredGroups(); track g.category) {
|
||||||
|
<div class="group">
|
||||||
|
<div class="group-title">{{g.category}}</div>
|
||||||
|
<div class="preset-list">
|
||||||
|
@for (p of g.items; track p.key) {
|
||||||
|
<button type="button" (click)="applyPreset(p.key)" title="{{p.key}}">
|
||||||
|
<span class="emoji">{{p.emoji}}</span>
|
||||||
|
<span class="label">{{p.label}}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Label</label>
|
||||||
|
<input class="input" type="text" formControlName="label" />
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Description</label>
|
||||||
|
<textarea class="input" formControlName="description"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label>Début</label>
|
||||||
|
<input class="input" type="datetime-local" formControlName="start" />
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Fin</label>
|
||||||
|
<input class="input" type="datetime-local" formControlName="stop" />
|
||||||
|
<div class="muted">Durée: {{durationHuman()}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Propriétés dynamiques selon le preset sélectionné -->
|
||||||
|
@if (currentPreset(); as cp) {
|
||||||
|
<div class="row">
|
||||||
|
<div class="preset-properties">
|
||||||
|
@for (entry of presetEntries(); track entry.key) {
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>{{entry.spec?.label || entry.key}}</label>
|
||||||
|
@if (entry.spec?.values; as vs) {
|
||||||
|
<select class="input" [disabled]="entry.spec?.writable === false" (change)="onPresetValueChange(entry.key, $any($event.target).value)">
|
||||||
|
@for (v of vs; track v) {
|
||||||
|
<option [value]="v" [selected]="(presetValues()[entry.key] ?? entry.spec?.default) === v">{{v}}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
} @else {
|
||||||
|
<input class="input" type="text" [disabled]="entry.spec?.writable === false" [value]="presetValues()[entry.key] ?? entry.spec?.default ?? ''" (input)="onPresetValueChange(entry.key, $any($event.target).value)"/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label>Type</label>
|
||||||
|
<select class="input" formControlName="type" >
|
||||||
|
<option value="unscheduled">Unscheduled</option>
|
||||||
|
<option value="scheduled">Scheduled</option>
|
||||||
|
<option value="forecast">Forecast</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Where</label>
|
||||||
|
<input class="input" type="text" formControlName="where" />
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Wikidata</label>
|
||||||
|
<input class="input" type="text" formControlName="wikidata" />
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Latitude</label>
|
||||||
|
<input class="input" type="number" step="any" formControlName="lat" />
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Longitude</label>
|
||||||
|
<input class="input" type="number" step="any" formControlName="lon" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn" type="submit">{{ featureId() ? 'Mettre à jour' : 'Créer' }}</button>
|
||||||
|
@if (featureId()) {
|
||||||
|
<button class="btn btn-ghost" type="button" (click)="onDelete()">Supprimer</button>
|
||||||
|
}
|
||||||
|
<button class="btn btn-ghost" type="button" (click)="onCancelEdit()">Quitter l’édition</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (status().state !== 'idle') {
|
||||||
|
<div class="toast-container">
|
||||||
|
<div class="toast" [class.is-info]="status().state==='saving'" [class.is-success]="status().state==='saved'" [class.is-error]="status().state==='error'">
|
||||||
|
@if (status().state === 'saving') {
|
||||||
|
<div>{{status().message}}</div>
|
||||||
|
} @else if (status().state === 'saved') {
|
||||||
|
<div>
|
||||||
|
{{status().message}}.
|
||||||
|
<a [href]="'/demo/by-what?what=' + (status().what || form.value.what)" target="_blank">Voir d'autres évènements de ce type</a>
|
||||||
|
</div>
|
||||||
|
} @else if (status().state === 'error') {
|
||||||
|
<div>{{status().message}}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</form>
|
46
frontend/src/app/forms/edit-form/edit-form.scss
Normal file
46
frontend/src/app/forms/edit-form/edit-form.scss
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presets {
|
||||||
|
background: rgba(159, 211, 246, 0.2);
|
||||||
|
border: 1px dashed rgba(0,0,0,0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presets.under-field { margin-top: 8px; }
|
||||||
|
|
||||||
|
.preset-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-list button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-groups { display: grid; gap: 10px; }
|
||||||
|
.group-title { font-weight: 700; opacity: 0.8; margin-bottom: 6px; }
|
||||||
|
.group { background: #fff; border: 1px solid rgba(0,0,0,0.06); border-radius: 10px; padding: 8px; }
|
||||||
|
|
23
frontend/src/app/forms/edit-form/edit-form.spec.ts
Normal file
23
frontend/src/app/forms/edit-form/edit-form.spec.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { EditForm } from './edit-form';
|
||||||
|
|
||||||
|
describe('EditForm', () => {
|
||||||
|
let component: EditForm;
|
||||||
|
let fixture: ComponentFixture<EditForm>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [EditForm]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(EditForm);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
324
frontend/src/app/forms/edit-form/edit-form.ts
Normal file
324
frontend/src/app/forms/edit-form/edit-form.ts
Normal file
|
@ -0,0 +1,324 @@
|
||||||
|
import { Component, EventEmitter, Input, Output, OnChanges, SimpleChanges, computed, effect, signal } from '@angular/core';
|
||||||
|
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { NgFor, NgIf } from '@angular/common';
|
||||||
|
import oedb from '../../../oedb-types';
|
||||||
|
import { OedbApi } from '../../services/oedb-api';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-edit-form',
|
||||||
|
imports: [ReactiveFormsModule],
|
||||||
|
templateUrl: './edit-form.html',
|
||||||
|
styleUrl: './edit-form.scss'
|
||||||
|
})
|
||||||
|
export class EditForm implements OnChanges {
|
||||||
|
@Input() selected: any | null = null;
|
||||||
|
@Output() saved = new EventEmitter<any>();
|
||||||
|
@Output() created = new EventEmitter<any>();
|
||||||
|
@Output() deleted = new EventEmitter<any>();
|
||||||
|
|
||||||
|
form: FormGroup;
|
||||||
|
allPresets: Array<{ key: string, label: string, emoji: string, category: string, description?: string, durationHours?: number, properties?: Record<string, { label?: string, writable?: boolean, values?: any[], default?: any, allow_custom?: boolean, allow_empty?: boolean }> }>;
|
||||||
|
filteredGroups = computed(() => this.groupPresets(this.form.get('what')?.value || ''));
|
||||||
|
currentPreset = computed(() => {
|
||||||
|
const key = this.form.get('what')?.value || '';
|
||||||
|
return (oedb.presets.what as any)[key] || null;
|
||||||
|
});
|
||||||
|
presetEntries = computed(() => {
|
||||||
|
const p = this.currentPreset();
|
||||||
|
const props = (p && p.properties) ? p.properties as Record<string, any> : {};
|
||||||
|
return Object.keys(props).map(k => ({ key: k, spec: props[k] }));
|
||||||
|
});
|
||||||
|
|
||||||
|
presetValues = signal<Record<string, any>>({});
|
||||||
|
|
||||||
|
status = signal<{ state: 'idle' | 'saving' | 'saved' | 'error', message?: string, what?: string }>({ state: 'idle' });
|
||||||
|
|
||||||
|
featureId = signal<string | number | null>(null);
|
||||||
|
durationHuman = signal<string>('');
|
||||||
|
|
||||||
|
constructor(private fb: FormBuilder, private api: OedbApi) {
|
||||||
|
this.form = this.fb.group({
|
||||||
|
label: ['', Validators.required],
|
||||||
|
description: [''],
|
||||||
|
what: ['', Validators.required],
|
||||||
|
where: [''],
|
||||||
|
lat: ['', Validators.required],
|
||||||
|
lon: ['', Validators.required],
|
||||||
|
wikidata: [''],
|
||||||
|
featureType: ['point'],
|
||||||
|
type: ['unscheduled'],
|
||||||
|
start: [''],
|
||||||
|
stop: ['']
|
||||||
|
});
|
||||||
|
|
||||||
|
const what = oedb.presets.what as Record<string, any>;
|
||||||
|
this.allPresets = Object.keys(what).map(k => ({
|
||||||
|
key: k,
|
||||||
|
label: what[k].label || k,
|
||||||
|
emoji: what[k].emoji || '',
|
||||||
|
category: what[k].category || 'Autres',
|
||||||
|
description: what[k].description || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
// initialize default 24h window
|
||||||
|
const now = new Date();
|
||||||
|
const in24h = new Date(now.getTime() + 24 * 3600 * 1000);
|
||||||
|
this.form.patchValue({
|
||||||
|
start: this.toLocalInputValue(now),
|
||||||
|
stop: this.toLocalInputValue(in24h)
|
||||||
|
}, { emitEvent: false });
|
||||||
|
|
||||||
|
// initial fill if provided on first render
|
||||||
|
this.fillFormFromSelected();
|
||||||
|
|
||||||
|
// watch start/stop changes to update human duration
|
||||||
|
this.form.valueChanges.subscribe(v => {
|
||||||
|
const startIso = this.toIsoFromLocalInput(v.start);
|
||||||
|
const stopIso = this.toIsoFromLocalInput(v.stop);
|
||||||
|
if (startIso && stopIso) this.durationHuman.set(this.humanDuration(startIso, stopIso));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
if (changes['selected']) {
|
||||||
|
this.fillFormFromSelected();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fillFormFromSelected() {
|
||||||
|
const sel = this.selected;
|
||||||
|
if (sel && sel.properties) {
|
||||||
|
const propId = sel?.properties?.id ?? sel?.properties?.uuid;
|
||||||
|
this.featureId.set((propId ?? sel.id) ?? null);
|
||||||
|
const p = sel.properties || {};
|
||||||
|
const coords = sel?.geometry?.coordinates || [];
|
||||||
|
this.form.patchValue({
|
||||||
|
label: p.label || p.name || '',
|
||||||
|
id: p.id || '',
|
||||||
|
description: p.description || '',
|
||||||
|
what: p.what || '',
|
||||||
|
"what:series": p['what:series'] || '',
|
||||||
|
where: p.where || '',
|
||||||
|
lat: coords[1] ?? '',
|
||||||
|
lon: coords[0] ?? '',
|
||||||
|
wikidata: p.wikidata || '',
|
||||||
|
featureType: 'point',
|
||||||
|
type: p.type || this.form.value.type || 'unscheduled',
|
||||||
|
start: this.toLocalInputValue(p.start || p.when || new Date()),
|
||||||
|
stop: this.toLocalInputValue(p.stop || new Date(new Date().getTime() + 24 * 3600 * 1000))
|
||||||
|
}, { emitEvent: false });
|
||||||
|
|
||||||
|
// hydrate presetValues from selected properties for known keys
|
||||||
|
const current = this.currentPreset();
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
if (current && current.properties) {
|
||||||
|
Object.keys(current.properties).forEach(key => {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(p, key)) {
|
||||||
|
result[key] = p[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.presetValues.set(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPreset(key: string) {
|
||||||
|
const what = oedb.presets.what as Record<string, any>;
|
||||||
|
const preset = what[key];
|
||||||
|
if (!preset) return;
|
||||||
|
this.form.patchValue({
|
||||||
|
what: key,
|
||||||
|
label: preset.label || this.form.value.label,
|
||||||
|
description: preset.description || this.form.value.description
|
||||||
|
});
|
||||||
|
|
||||||
|
// initialize presetValues with defaults if any
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
const props = preset.properties || {};
|
||||||
|
Object.keys(props).forEach(k => {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(props[k], 'default')) result[k] = props[k].default;
|
||||||
|
});
|
||||||
|
this.presetValues.set(result);
|
||||||
|
|
||||||
|
// adjust stop based on preset duration
|
||||||
|
const startIso = this.toIsoFromLocalInput(this.form.value.start);
|
||||||
|
if (typeof preset.durationHours === 'number' && startIso) {
|
||||||
|
const start = new Date(startIso);
|
||||||
|
const stop = new Date(start.getTime() + preset.durationHours * 3600 * 1000);
|
||||||
|
this.form.patchValue({ stop: this.toLocalInputValue(stop) }, { emitEvent: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private groupPresets(query: string): Array<{ category: string, items: Array<{ key: string, label: string, emoji: string }> }> {
|
||||||
|
const q = String(query || '').trim().toLowerCase();
|
||||||
|
const matches = (p: typeof this.allPresets[number]) => {
|
||||||
|
if (!q) return true;
|
||||||
|
return (
|
||||||
|
p.key.toLowerCase().includes(q) ||
|
||||||
|
(p.label || '').toLowerCase().includes(q) ||
|
||||||
|
(p.description || '').toLowerCase().includes(q) ||
|
||||||
|
(p.category || '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const grouped: Record<string, Array<{ key: string, label: string, emoji: string }>> = {};
|
||||||
|
for (const p of this.allPresets) {
|
||||||
|
if (!matches(p)) continue;
|
||||||
|
const cat = p.category || 'Autres';
|
||||||
|
if (!grouped[cat]) grouped[cat] = [];
|
||||||
|
grouped[cat].push({ key: p.key, label: p.label, emoji: p.emoji });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(grouped)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.map(cat => ({ category: cat, items: grouped[cat].sort((a, b) => a.label.localeCompare(b.label)) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit() {
|
||||||
|
const val = this.form.value;
|
||||||
|
const feature: any = {
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {
|
||||||
|
label: val.label,
|
||||||
|
description: val.description,
|
||||||
|
what: val.what,
|
||||||
|
where: val.where,
|
||||||
|
wikidata: val.wikidata,
|
||||||
|
type: val.type,
|
||||||
|
start: this.toIsoFromLocalInput(val.start),
|
||||||
|
stop: this.toIsoFromLocalInput(val.stop)
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [Number(val.lon), Number(val.lat)]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply default duration from preset when creating a new event
|
||||||
|
const preset = (oedb.presets.what as any)[val.what];
|
||||||
|
if ((!this.featureId()) && preset && typeof preset.durationHours === 'number') {
|
||||||
|
// already set from form; ensure consistency if empty
|
||||||
|
if (!feature.properties.start || !feature.properties.stop) {
|
||||||
|
const start = new Date();
|
||||||
|
const stop = new Date(start.getTime() + preset.durationHours * 3600 * 1000);
|
||||||
|
feature.properties.start = start.toISOString();
|
||||||
|
feature.properties.stop = stop.toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = this.featureId();
|
||||||
|
// merge dynamic preset properties
|
||||||
|
const extra = this.presetValues();
|
||||||
|
Object.keys(extra || {}).forEach(k => {
|
||||||
|
feature.properties[k] = extra[k];
|
||||||
|
});
|
||||||
|
|
||||||
|
this.status.set({ state: 'saving', what: val.what, message: 'Envoi en cours…' });
|
||||||
|
|
||||||
|
if (id !== null && id !== undefined && id !== '') {
|
||||||
|
this.api.updateEvent(id, feature).subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
this.status.set({ state: 'saved', what: val.what, message: 'Évènement mis à jour' });
|
||||||
|
this.saved.emit(res);
|
||||||
|
setTimeout(() => this.status.set({ state: 'idle' }), 3000);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.status.set({ state: 'error', what: val.what, message: 'Erreur lors de la mise à jour' });
|
||||||
|
console.error(err);
|
||||||
|
setTimeout(() => this.status.set({ state: 'idle' }), 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.api.createEvent(feature).subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
this.status.set({ state: 'saved', what: val.what, message: 'Évènement créé' });
|
||||||
|
this.created.emit(res);
|
||||||
|
setTimeout(() => this.status.set({ state: 'idle' }), 3000);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.status.set({ state: 'error', what: val.what, message: 'Erreur lors de la création' });
|
||||||
|
console.error(err);
|
||||||
|
setTimeout(() => this.status.set({ state: 'idle' }), 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPresetValueChange(key: string, value: any) {
|
||||||
|
const current = { ...this.presetValues() };
|
||||||
|
current[key] = value;
|
||||||
|
this.presetValues.set(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDelete() {
|
||||||
|
const id = this.featureId();
|
||||||
|
if (id === null || id === undefined || id === '') return;
|
||||||
|
this.status.set({ state: 'saving', what: this.form.value.what, message: 'Suppression…' });
|
||||||
|
this.api.deleteEvent(id).subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
this.status.set({ state: 'saved', what: this.form.value.what, message: 'Évènement supprimé' });
|
||||||
|
this.deleted.emit(res);
|
||||||
|
setTimeout(() => this.status.set({ state: 'idle' }), 3000);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.status.set({ state: 'error', what: this.form.value.what, message: 'Erreur lors de la suppression' });
|
||||||
|
console.error(err);
|
||||||
|
setTimeout(() => this.status.set({ state: 'idle' }), 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onCancelEdit() {
|
||||||
|
this.selected = null;
|
||||||
|
this.featureId.set(null);
|
||||||
|
this.form.reset({
|
||||||
|
label: '',
|
||||||
|
description: '',
|
||||||
|
what: '',
|
||||||
|
where: '',
|
||||||
|
lat: '',
|
||||||
|
lon: '',
|
||||||
|
wikidata: '',
|
||||||
|
featureType: 'point',
|
||||||
|
type: 'unscheduled',
|
||||||
|
start: this.toLocalInputValue(new Date()),
|
||||||
|
stop: this.toLocalInputValue(new Date(new Date().getTime() + 24 * 3600 * 1000))
|
||||||
|
});
|
||||||
|
this.presetValues.set({});
|
||||||
|
this.status.set({ state: 'idle' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private toLocalInputValue(d: string | Date): string {
|
||||||
|
const date = (typeof d === 'string') ? new Date(d) : d;
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = pad(date.getMonth() + 1);
|
||||||
|
const da = pad(date.getDate());
|
||||||
|
const h = pad(date.getHours());
|
||||||
|
const mi = pad(date.getMinutes());
|
||||||
|
return `${y}-${m}-${da}T${h}:${mi}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toIsoFromLocalInput(s?: string): string | null {
|
||||||
|
if (!s) return null;
|
||||||
|
// Treat input as local time and convert to ISO
|
||||||
|
const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2})$/.exec(s);
|
||||||
|
if (!m) return null;
|
||||||
|
const date = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), 0, 0);
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private humanDuration(startIso: string, stopIso: string): string {
|
||||||
|
const a = new Date(startIso).getTime();
|
||||||
|
const b = new Date(stopIso).getTime();
|
||||||
|
if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a) return '';
|
||||||
|
const ms = b - a;
|
||||||
|
const hours = Math.floor(ms / 3600000);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
const h = hours % 24;
|
||||||
|
if (days > 0 && h > 0) return `${days} j ${h} h`;
|
||||||
|
if (days > 0) return `${days} j`;
|
||||||
|
return `${h} h`;
|
||||||
|
}
|
||||||
|
}
|
17
frontend/src/app/forms/osm/osm.html
Normal file
17
frontend/src/app/forms/osm/osm.html
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<p>
|
||||||
|
osm works!
|
||||||
|
|
||||||
|
|
||||||
|
@if(isLogginIn){
|
||||||
|
<div class="pseudo">
|
||||||
|
{{osmPseudo}}
|
||||||
|
</div>
|
||||||
|
<button (click)="logout()">logout</button>
|
||||||
|
}
|
||||||
|
@else{
|
||||||
|
<div class="pseudo">
|
||||||
|
pas connecté
|
||||||
|
</div>
|
||||||
|
<button (click)="login()">osm login</button>
|
||||||
|
}
|
||||||
|
</p>
|
0
frontend/src/app/forms/osm/osm.scss
Normal file
0
frontend/src/app/forms/osm/osm.scss
Normal file
23
frontend/src/app/forms/osm/osm.spec.ts
Normal file
23
frontend/src/app/forms/osm/osm.spec.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { Osm } from './osm';
|
||||||
|
|
||||||
|
describe('Osm', () => {
|
||||||
|
let component: Osm;
|
||||||
|
let fixture: ComponentFixture<Osm>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Osm]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Osm);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
20
frontend/src/app/forms/osm/osm.ts
Normal file
20
frontend/src/app/forms/osm/osm.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-osm',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './osm.html',
|
||||||
|
styleUrl: './osm.scss'
|
||||||
|
})
|
||||||
|
export class Osm {
|
||||||
|
osmPseudo: string='';
|
||||||
|
isLogginIn: any = false;
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
login() {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
12
frontend/src/app/maps/all-events/all-events.html
Normal file
12
frontend/src/app/maps/all-events/all-events.html
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<div class="map-wrapper" style="position:relative;height:100%;">
|
||||||
|
<div #mapContainer class="map" style="width:100%;height:100%;border:1px solid #e6eef3;border-radius:10px;"></div>
|
||||||
|
<div class="search" style="position:absolute;top:10px;left:10px;right:10px;display:flex;gap:8px;">
|
||||||
|
<input #searchBox type="text" placeholder="Chercher un lieu (Nominatim)" class="input" style="flex:1;">
|
||||||
|
<button class="btn" type="button" (click)="searchPlace(searchBox.value)">Chercher</button>
|
||||||
|
</div>
|
||||||
|
@if (canRestoreOriginal) {
|
||||||
|
<div style="position:absolute;bottom:10px;left:10px;display:flex;gap:8px;">
|
||||||
|
<button class="btn btn-ghost" type="button" (click)="restoreOriginalCoords()">Reprendre les coordonnées initiales</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
20
frontend/src/app/maps/all-events/all-events.scss
Normal file
20
frontend/src/app/maps/all-events/all-events.scss
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
@keyframes pulseGreen {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.4); }
|
||||||
|
70% { box-shadow: 0 0 0 12px rgba(76, 175, 80, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0); }
|
||||||
|
}
|
||||||
|
@keyframes pulseRed {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(244, 67, 54, 0.4); }
|
||||||
|
70% { box-shadow: 0 0 0 12px rgba(244, 67, 54, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(244, 67, 54, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-feature-id].pulse-green {
|
||||||
|
animation: pulseGreen 1.2s ease-out 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
[data-feature-id].pulse-red {
|
||||||
|
animation: pulseRed 1.2s ease-out 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
23
frontend/src/app/maps/all-events/all-events.spec.ts
Normal file
23
frontend/src/app/maps/all-events/all-events.spec.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { AllEvents } from './all-events';
|
||||||
|
|
||||||
|
describe('AllEvents', () => {
|
||||||
|
let component: AllEvents;
|
||||||
|
let fixture: ComponentFixture<AllEvents>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AllEvents]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AllEvents);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
326
frontend/src/app/maps/all-events/all-events.ts
Normal file
326
frontend/src/app/maps/all-events/all-events.ts
Normal file
|
@ -0,0 +1,326 @@
|
||||||
|
import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
|
||||||
|
import oedb_what_categories from '../../../oedb-types';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-all-events',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './all-events.html',
|
||||||
|
styleUrl: './all-events.scss'
|
||||||
|
})
|
||||||
|
export class AllEvents {
|
||||||
|
@Input() features: Array<any> = [];
|
||||||
|
@Input() selected: any | null = null;
|
||||||
|
@Input() highlight: { id: string | number, type: 'saved' | 'deleted' } | null = null;
|
||||||
|
@Output() select = new EventEmitter<any>();
|
||||||
|
@Output() pickCoords = new EventEmitter<[number, number]>();
|
||||||
|
|
||||||
|
@ViewChild('mapContainer', { static: true }) mapContainer!: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
|
private map: any;
|
||||||
|
private markers: any[] = [];
|
||||||
|
private pickedMarker: any | null = null;
|
||||||
|
private originalCoords: [number, number] | null = null;
|
||||||
|
private currentPicked: [number, number] | null = null;
|
||||||
|
|
||||||
|
async ngOnInit() {
|
||||||
|
await this.ensureMapLibre();
|
||||||
|
this.initMap();
|
||||||
|
this.renderFeatures();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.markers.forEach(m => m.remove && m.remove());
|
||||||
|
this.markers = [];
|
||||||
|
if (this.map && this.map.remove) this.map.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(): void {
|
||||||
|
// track original coordinates of the selected feature
|
||||||
|
if (this.selected && Array.isArray(this.selected?.geometry?.coordinates)) {
|
||||||
|
const coords = this.selected.geometry.coordinates as [number, number];
|
||||||
|
this.originalCoords = coords;
|
||||||
|
// If no picked marker yet, align current picked to original
|
||||||
|
if (!this.currentPicked) this.currentPicked = coords;
|
||||||
|
}
|
||||||
|
this.renderFeatures();
|
||||||
|
|
||||||
|
// trigger animation highlight
|
||||||
|
if (this.highlight && this.highlight.id !== undefined && this.highlight.id !== null) {
|
||||||
|
const idStr = String(this.highlight.id);
|
||||||
|
const el = document.querySelector(`[data-feature-id="${CSS.escape(idStr)}"]`);
|
||||||
|
if (el) {
|
||||||
|
el.classList.remove('pulse-green', 'pulse-red');
|
||||||
|
if (this.highlight.type === 'saved') el.classList.add('pulse-green');
|
||||||
|
if (this.highlight.type === 'deleted') el.classList.add('pulse-red');
|
||||||
|
setTimeout(() => {
|
||||||
|
el.classList.remove('pulse-green', 'pulse-red');
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureMapLibre(): Promise<void> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if ((window as any).maplibregl) return resolve();
|
||||||
|
const css = document.createElement('link');
|
||||||
|
css.rel = 'stylesheet';
|
||||||
|
css.href = 'https://unpkg.com/maplibre-gl@3.6.0/dist/maplibre-gl.css';
|
||||||
|
document.head.appendChild(css);
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = 'https://unpkg.com/maplibre-gl@3.6.0/dist/maplibre-gl.js';
|
||||||
|
s.onload = () => resolve();
|
||||||
|
document.body.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private initMap() {
|
||||||
|
const maplibregl = (window as any).maplibregl;
|
||||||
|
this.map = new maplibregl.Map({
|
||||||
|
container: this.mapContainer.nativeElement,
|
||||||
|
style: 'https://tiles.openfreemap.org/styles/liberty',
|
||||||
|
center: [2.3522, 48.8566],
|
||||||
|
zoom: 5
|
||||||
|
});
|
||||||
|
this.map.addControl(new maplibregl.NavigationControl());
|
||||||
|
this.map.addControl(new maplibregl.GeolocateControl({ positionOptions: { enableHighAccuracy: true }, trackUserLocation: true }));
|
||||||
|
|
||||||
|
this.map.on('click', (e: any) => {
|
||||||
|
const coords: [number, number] = [e.lngLat.lng, e.lngLat.lat];
|
||||||
|
this.showPickedMarker(coords);
|
||||||
|
this.pickCoords.emit(coords);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEmojiForWhat(what: string): string {
|
||||||
|
try {
|
||||||
|
// if what is exact key
|
||||||
|
const preset: any = (oedb_what_categories as any).presets.what as Record<string, any>;
|
||||||
|
if (preset && preset[what] && preset[what].emoji) return preset[what].emoji;
|
||||||
|
const family = what?.split('.')[0] || '';
|
||||||
|
if (preset && preset[family] && preset[family].emoji) return preset[family].emoji;
|
||||||
|
} catch {}
|
||||||
|
return '📍';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getImageForWhat(what: string): string | null {
|
||||||
|
try {
|
||||||
|
const preset: any = (oedb_what_categories as any).presets.what as Record<string, any>;
|
||||||
|
if (preset && preset[what] && preset[what].image) return preset[what].image;
|
||||||
|
const family = what?.split('.')[0] || '';
|
||||||
|
if (preset && preset[family] && preset[family].image) return preset[family].image;
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private showPickedMarker(coords: [number, number]) {
|
||||||
|
const maplibregl = (window as any).maplibregl;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.style.width = '20px';
|
||||||
|
el.style.height = '20px';
|
||||||
|
el.style.borderRadius = '50%';
|
||||||
|
el.style.background = '#2196f3';
|
||||||
|
el.style.border = '2px solid white';
|
||||||
|
el.style.boxShadow = '0 0 0 2px rgba(33,150,243,0.3)';
|
||||||
|
|
||||||
|
if (this.pickedMarker && this.pickedMarker.remove) {
|
||||||
|
this.pickedMarker.remove();
|
||||||
|
}
|
||||||
|
this.pickedMarker = new maplibregl.Marker({ element: el }).setLngLat(coords).addTo(this.map);
|
||||||
|
this.currentPicked = coords;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchPlace(query: string) {
|
||||||
|
const q = (query || '').trim();
|
||||||
|
if (!q) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`https://nominatim.openstreetmap.org/search?format=geojson&q=${encodeURIComponent(q)}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
const f = data?.features?.[0];
|
||||||
|
const coords = f?.geometry?.type === 'Point' ? f.geometry.coordinates : f?.bbox;
|
||||||
|
if (Array.isArray(coords)) {
|
||||||
|
if (coords.length === 2) {
|
||||||
|
this.map.flyTo({ center: coords, zoom: 14 });
|
||||||
|
this.showPickedMarker(coords as [number, number]);
|
||||||
|
this.pickCoords.emit(coords as [number, number]);
|
||||||
|
} else if (coords.length === 4) {
|
||||||
|
const maplibregl = (window as any).maplibregl;
|
||||||
|
const bounds = new maplibregl.LngLatBounds([coords[0], coords[1]], [coords[2], coords[3]]);
|
||||||
|
this.map.fitBounds(bounds, { padding: 40 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
get canRestoreOriginal(): boolean {
|
||||||
|
if (!this.originalCoords || !this.currentPicked) return false;
|
||||||
|
return this.originalCoords[0] !== this.currentPicked[0] || this.originalCoords[1] !== this.currentPicked[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreOriginalCoords() {
|
||||||
|
if (!this.originalCoords) return;
|
||||||
|
this.showPickedMarker(this.originalCoords);
|
||||||
|
this.pickCoords.emit(this.originalCoords);
|
||||||
|
if (this.map) this.map.flyTo({ center: this.originalCoords, zoom: Math.max(this.map.getZoom() || 12, 12) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderFeatures() {
|
||||||
|
if (!this.map || !Array.isArray(this.features)) return;
|
||||||
|
// clear existing markers
|
||||||
|
this.markers.forEach(m => m.remove && m.remove());
|
||||||
|
this.markers = [];
|
||||||
|
|
||||||
|
const maplibregl = (window as any).maplibregl;
|
||||||
|
const bounds = new maplibregl.LngLatBounds();
|
||||||
|
|
||||||
|
this.features.forEach(f => {
|
||||||
|
const coords = f?.geometry?.coordinates;
|
||||||
|
if (!coords || !Array.isArray(coords)) return;
|
||||||
|
const p = f.properties || {};
|
||||||
|
const fid = (p && (p.id ?? p.uuid)) ?? f?.id;
|
||||||
|
const el = this.buildMarkerElement(p);
|
||||||
|
el.style.cursor = 'pointer';
|
||||||
|
if (typeof fid !== 'undefined') {
|
||||||
|
el.setAttribute('data-feature-id', String(fid));
|
||||||
|
}
|
||||||
|
// selected styling
|
||||||
|
const selId = this.selected?.properties?.id ?? this.selected?.properties?.uuid ?? this.selected?.id;
|
||||||
|
if (selId !== undefined && selId !== null && String(selId) === String(fid)) {
|
||||||
|
el.style.transform = 'scale(1.2)';
|
||||||
|
el.style.boxShadow = '0 0 0 4px rgba(25,118,210,0.25)';
|
||||||
|
el.style.borderRadius = '50%';
|
||||||
|
}
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
this.select.emit({
|
||||||
|
id: fid,
|
||||||
|
properties: p,
|
||||||
|
geometry: { type: 'Point', coordinates: coords }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const popupHtml = this.buildPopupHtml(p, (p && (p.id ?? p.uuid)) ?? f?.id);
|
||||||
|
const marker = new maplibregl.Marker({ element: el })
|
||||||
|
.setLngLat(coords)
|
||||||
|
.setPopup(new maplibregl.Popup({ offset: 12 }).setHTML(popupHtml))
|
||||||
|
.addTo(this.map);
|
||||||
|
|
||||||
|
const popup = marker.getPopup && marker.getPopup();
|
||||||
|
if (popup && popup.on) {
|
||||||
|
popup.on('open', () => {
|
||||||
|
const rawId = (p && (p.id ?? p.uuid)) ?? f?.id;
|
||||||
|
const targetId = typeof rawId !== 'undefined' ? String(rawId) : `${coords[0]},${coords[1]}`;
|
||||||
|
const elTitle = document.querySelector(`[data-feature-id="${CSS.escape(targetId)}"]`);
|
||||||
|
if (elTitle) {
|
||||||
|
elTitle.addEventListener('click', (ev: Event) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.select.emit({
|
||||||
|
id: (p && (p.id ?? p.uuid)) ?? f?.id,
|
||||||
|
properties: p,
|
||||||
|
geometry: { type: 'Point', coordinates: coords }
|
||||||
|
});
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.markers.push(marker);
|
||||||
|
bounds.extend(coords);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!bounds.isEmpty()) {
|
||||||
|
this.map.fitBounds(bounds, { padding: 40, maxZoom: 12 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMarkerElement(props: any): HTMLDivElement {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.style.fontSize = '20px';
|
||||||
|
container.style.lineHeight = '1';
|
||||||
|
container.style.display = 'flex';
|
||||||
|
container.style.alignItems = 'center';
|
||||||
|
container.style.justifyContent = 'center';
|
||||||
|
|
||||||
|
const htmlCandidate = this.findMarkerHtml(props);
|
||||||
|
if (htmlCandidate) {
|
||||||
|
const safe = this.sanitizeHtml(htmlCandidate);
|
||||||
|
container.innerHTML = safe;
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
const what = props?.what || '';
|
||||||
|
const image = this.getImageForWhat(what);
|
||||||
|
if (image) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = image;
|
||||||
|
img.alt = what || 'marker';
|
||||||
|
img.style.width = '24px';
|
||||||
|
img.style.height = '24px';
|
||||||
|
img.style.objectFit = 'contain';
|
||||||
|
container.appendChild(img);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emoji = this.getEmojiForWhat(what);
|
||||||
|
container.textContent = emoji;
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findMarkerHtml(props: any): string | null {
|
||||||
|
const keysToCheck = ['marker_html', 'icon_html', 'html', 'marker', 'icon'];
|
||||||
|
for (const key of keysToCheck) {
|
||||||
|
const value = props?.[key];
|
||||||
|
if (typeof value === 'string' && value.includes('<')) return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeHtml(html: string): string {
|
||||||
|
const temp = document.createElement('div');
|
||||||
|
temp.innerHTML = html;
|
||||||
|
|
||||||
|
const walk = (node: Element) => {
|
||||||
|
// Remove script and style tags entirely
|
||||||
|
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') {
|
||||||
|
node.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Strip event handlers and javascript: URLs
|
||||||
|
for (const attr of Array.from(node.attributes)) {
|
||||||
|
const name = attr.name.toLowerCase();
|
||||||
|
const value = attr.value || '';
|
||||||
|
if (name.startsWith('on')) {
|
||||||
|
node.removeAttribute(attr.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((name === 'href' || name === 'src') && /^\s*javascript:/i.test(value)) {
|
||||||
|
node.removeAttribute(attr.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (node.tagName === 'A' && name === 'href' && !/^(https?:|#|\/)/i.test(value)) {
|
||||||
|
node.removeAttribute(attr.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Recurse children
|
||||||
|
for (const child of Array.from(node.children)) walk(child as Element);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const child of Array.from(temp.children)) walk(child as Element);
|
||||||
|
return temp.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPopupHtml(props: any, id?: any): string {
|
||||||
|
const title = this.escapeHtml(String(props?.name || props?.label || props?.what || 'évènement'));
|
||||||
|
const titleId = typeof id !== 'undefined' ? String(id) : '';
|
||||||
|
const rows = Object.keys(props || {}).sort().map(k => {
|
||||||
|
const v = props[k];
|
||||||
|
const value = typeof v === 'object' ? `<pre>${this.escapeHtml(JSON.stringify(v, null, 2))}</pre>` : this.escapeHtml(String(v));
|
||||||
|
return `<tr><td style="font-weight:bold;vertical-align:top;padding:2px 6px;">${this.escapeHtml(k)}</td><td style="padding:2px 6px;">${value}</td></tr>`;
|
||||||
|
}).join('');
|
||||||
|
const clickable = `<div style="font-weight:700;margin:0 0 6px 0;">
|
||||||
|
<a href="#" data-feature-id="${this.escapeHtml(titleId)}" style="text-decoration:none;color:#1976d2;">${title}</a>
|
||||||
|
</div>`;
|
||||||
|
return `<div style="max-width:320px">${clickable}<table style="border-collapse:collapse;width:100%">${rows}</table></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeHtml(s: string): string {
|
||||||
|
return s.replace(/[&<>"]+/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c] as string));
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,13 +1,46 @@
|
||||||
<p>home works!</p>
|
<div class="layout">
|
||||||
<div class="aside">
|
<div class="aside">
|
||||||
|
<div class="toolbar">
|
||||||
<app-menu></app-menu>
|
<strong>OpenEventDatabase</strong>
|
||||||
</div>
|
<span class="muted">{{features.length}} évènements</span>
|
||||||
<div class="main">
|
</div>
|
||||||
main part
|
<div class="filters">
|
||||||
<br>
|
<label>Filtre rapide</label>
|
||||||
<div id="map">
|
<input class="input" type="text" placeholder="Rechercher...">
|
||||||
|
</div>
|
||||||
(map)
|
<hr>
|
||||||
|
<app-menu></app-menu>
|
||||||
|
<hr>
|
||||||
|
<app-edit-form [selected]="selected" (saved)="onSaved($event)" (created)="onCreated($event)" (deleted)="onDeleted($event)"></app-edit-form>
|
||||||
|
</div>
|
||||||
|
<div class="main">
|
||||||
|
@if (!showTable) {
|
||||||
|
<div class="map">
|
||||||
|
<app-all-events [features]="features" [selected]="selected" (select)="onSelect($event)" (pickCoords)="onPickCoords($event)"></app-all-events>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="table-wrapper" style="overflow:auto;height:100%;">
|
||||||
|
<table style="width:100%;border-collapse:collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="text-align:left;padding:6px;border-bottom:1px solid #e5e7eb;">Type</th>
|
||||||
|
<th style="text-align:left;padding:6px;border-bottom:1px solid #e5e7eb;">Label</th>
|
||||||
|
<th style="text-align:left;padding:6px;border-bottom:1px solid #e5e7eb;">Start</th>
|
||||||
|
<th style="text-align:left;padding:6px;border-bottom:1px solid #e5e7eb;">Stop</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (f of features; track f.id) {
|
||||||
|
<tr (click)="onSelect({ id: f?.properties?.id ?? f?.id, properties: f.properties, geometry: f.geometry })" style="cursor:pointer;">
|
||||||
|
<td style="padding:6px;border-bottom:1px solid #f1f5f9;">{{f?.properties?.what}}</td>
|
||||||
|
<td style="padding:6px;border-bottom:1px solid #f1f5f9;">{{f?.properties?.label || f?.properties?.name}}</td>
|
||||||
|
<td style="padding:6px;border-bottom:1px solid #f1f5f9;">{{f?.properties?.start || f?.properties?.when}}</td>
|
||||||
|
<td style="padding:6px;border-bottom:1px solid #f1f5f9;">{{f?.properties?.stop}}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,28 +1,37 @@
|
||||||
:host{
|
:host {
|
||||||
header{
|
display: block;
|
||||||
background: #00acc1;
|
}
|
||||||
position: fixed;
|
|
||||||
top: 0 ;
|
.layout {
|
||||||
width: 100vw;
|
display: grid;
|
||||||
min-height: 1rem;
|
grid-template-columns: 340px 1fr;
|
||||||
}
|
grid-template-rows: 100vh;
|
||||||
main{
|
gap: 0;
|
||||||
display: flex;
|
}
|
||||||
flex-direction: row;
|
|
||||||
justify-content: start;
|
.aside {
|
||||||
align-content: center;
|
background: #ffffff;
|
||||||
}
|
border-right: 1px solid rgba(0,0,0,0.06);
|
||||||
.aside{
|
box-shadow: 2px 0 12px rgba(0,0,0,0.03);
|
||||||
background: #fff8f8;
|
padding: 16px;
|
||||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
overflow: auto;
|
||||||
width : 0;
|
}
|
||||||
&.expanded{
|
|
||||||
width: 300px;
|
.main {
|
||||||
padding: 10px;
|
display: flex;
|
||||||
}
|
flex-direction: column;
|
||||||
}
|
height: 100vh;
|
||||||
#map{
|
overflow: hidden;
|
||||||
width: 100%;
|
}
|
||||||
height: 100vh;
|
|
||||||
}
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,117 @@
|
||||||
import { Component } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import {Menu} from './menu/menu';
|
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';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
imports: [
|
imports: [
|
||||||
Menu
|
Menu,
|
||||||
|
AllEvents,
|
||||||
|
EditForm
|
||||||
],
|
],
|
||||||
templateUrl: './home.html',
|
templateUrl: './home.html',
|
||||||
styleUrl: './home.scss'
|
styleUrl: './home.scss'
|
||||||
})
|
})
|
||||||
export class Home {
|
export class Home {
|
||||||
|
|
||||||
|
OedbApi = inject(OedbApi);
|
||||||
|
features: Array<any> = [];
|
||||||
|
selected: any | null = null;
|
||||||
|
showTable = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.OedbApi.getEvents({ when: 'now', limit: 500 }).subscribe((events: any) => {
|
||||||
|
this.features = Array.isArray(events?.features) ? events.features : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect(feature: any) {
|
||||||
|
this.selected = feature;
|
||||||
|
}
|
||||||
|
|
||||||
|
onPickCoords(coords: [number, number]) {
|
||||||
|
// Autofill lat/lon in the form selection or prepare a new feature shell
|
||||||
|
const [lon, lat] = coords;
|
||||||
|
if (this.selected && this.selected.properties) {
|
||||||
|
this.selected = {
|
||||||
|
...this.selected,
|
||||||
|
geometry: { type: 'Point', coordinates: [lon, lat] }
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.selected = {
|
||||||
|
id: null,
|
||||||
|
properties: { label: '', description: '', what: '', where: '' },
|
||||||
|
geometry: { type: 'Point', coordinates: [lon, lat] }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeleted(_res: any) {
|
||||||
|
this.selected = null;
|
||||||
|
this.OedbApi.getEvents({ when: 'now', limit: 500 }).subscribe((events: any) => {
|
||||||
|
this.features = Array.isArray(events?.features) ? events.features : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menu callbacks
|
||||||
|
ngAfterViewInit() {
|
||||||
|
// Wire menu callbacks if needed via querySelector; left simple for now
|
||||||
|
// We keep logic here: toggling and downloads
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleView() {
|
||||||
|
this.showTable = !this.showTable;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadGeoJSON() {
|
||||||
|
const blob = new Blob([JSON.stringify({ type: 'FeatureCollection', features: this.features }, 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.features.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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,14 +3,12 @@
|
||||||
<a href="/demo/stats">stats</a>
|
<a href="/demo/stats">stats</a>
|
||||||
<a href="https://source.cipherbliss.com/tykayn/oedb-backend">sources</a>
|
<a href="https://source.cipherbliss.com/tykayn/oedb-backend">sources</a>
|
||||||
|
|
||||||
(editor
|
(editor)
|
||||||
<div id="editor_form">
|
<div id="editor_form">
|
||||||
<div id="search_input">
|
<div id="search_input">
|
||||||
<input type="text" value="" placeholder="Rechercher une catégorie d'évènement">
|
<input type="text" value="" placeholder="Rechercher une catégorie d'évènement">
|
||||||
</div>
|
</div>
|
||||||
<div id="what_categories">
|
<div id="what_categories">
|
||||||
|
|
||||||
|
|
||||||
@for (oedbc of oedb_what_categories; track $index) {
|
@for (oedbc of oedb_what_categories; track $index) {
|
||||||
<div class="category">
|
<div class="category">
|
||||||
<div class="emoji">
|
<div class="emoji">
|
||||||
|
@ -63,8 +61,30 @@
|
||||||
<ul>
|
<ul>
|
||||||
<li></li>
|
<li></li>
|
||||||
</ul>
|
</ul>
|
||||||
(bouton pour exporter)
|
</div>
|
||||||
|
<div id="actions">
|
||||||
|
|
||||||
|
(actions)
|
||||||
<button class="button">exporter</button>
|
<button class="button">exporter</button>
|
||||||
|
<button class="button">envoyer</button>
|
||||||
|
<button class="button">créer</button>
|
||||||
|
<button class="button">supprimer</button>
|
||||||
|
<button class="button">oui, toujours là</button>
|
||||||
|
<button class="button">non plus là</button>
|
||||||
|
<button class="button">pouet pouet!</button>
|
||||||
|
<hr>
|
||||||
|
<button class="button" (click)="toggleView()">Basculer carte / tableau</button>
|
||||||
|
<div class="downloaders">
|
||||||
|
<button class="button" (click)="downloadGeoJSON()">Télécharger GeoJSON</button>
|
||||||
|
<button class="button" (click)="downloadCSV()">Télécharger CSV</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="user_infos">
|
||||||
|
login OSM:
|
||||||
|
<a href="https://www.openstreetmap.org/user/tykayn">tykayn</a>
|
||||||
|
<br>
|
||||||
|
points de l'utilisateur:
|
||||||
|
12 points.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</menu>
|
</menu>
|
||||||
|
|
|
@ -1,15 +1,28 @@
|
||||||
:host {
|
:host {
|
||||||
|
display: block;
|
||||||
#what_categories{
|
}
|
||||||
|
|
||||||
.cateogry {
|
#what_categories {
|
||||||
background: #f8f8f8;
|
display: grid;
|
||||||
border-radius: 4px;
|
grid-template-columns: 1fr;
|
||||||
padding: 10px;
|
gap: 8px;
|
||||||
margin-bottom: 10px;
|
}
|
||||||
width: 300px;
|
|
||||||
display: block;
|
.category {
|
||||||
border: 1px solid #ddd;
|
background: #ffffff;
|
||||||
}
|
border-radius: 10px;
|
||||||
}
|
padding: 10px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,9 @@ import oedb_what_categories from '../../../../oedb-types';
|
||||||
export class Menu {
|
export class Menu {
|
||||||
|
|
||||||
public oedb_what_categories: Array<any> = [];
|
public oedb_what_categories: Array<any> = [];
|
||||||
|
public onToggleView?: () => void;
|
||||||
|
public onDownloadGeoJSON?: () => void;
|
||||||
|
public onDownloadCSV?: () => void;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
let keys = Object.keys(oedb_what_categories.presets.what);
|
let keys = Object.keys(oedb_what_categories.presets.what);
|
||||||
|
@ -22,4 +25,8 @@ export class Menu {
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleView() { this.onToggleView && this.onToggleView(); }
|
||||||
|
downloadGeoJSON() { this.onDownloadGeoJSON && this.onDownloadGeoJSON(); }
|
||||||
|
downloadCSV() { this.onDownloadCSV && this.onDownloadCSV(); }
|
||||||
}
|
}
|
||||||
|
|
16
frontend/src/app/services/oedb-api.spec.ts
Normal file
16
frontend/src/app/services/oedb-api.spec.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { OedbApi } from './oedb-api';
|
||||||
|
|
||||||
|
describe('OedbApi', () => {
|
||||||
|
let service: OedbApi;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
service = TestBed.inject(OedbApi);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
28
frontend/src/app/services/oedb-api.ts
Normal file
28
frontend/src/app/services/oedb-api.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class OedbApi {
|
||||||
|
private baseUrl = 'https://api.openeventdatabase.org';
|
||||||
|
|
||||||
|
constructor(private http: HttpClient) {
|
||||||
|
}
|
||||||
|
|
||||||
|
getEvents(params: any) {
|
||||||
|
return this.http.get(`${this.baseUrl}/event`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
createEvent(feature: any) {
|
||||||
|
return this.http.post(`${this.baseUrl}/event`, feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEvent(id: string | number, feature: any) {
|
||||||
|
return this.http.put(`${this.baseUrl}/event/${id}`, feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteEvent(id: string | number) {
|
||||||
|
return this.http.delete(`${this.baseUrl}/event/${id}`);
|
||||||
|
}
|
||||||
|
}
|
16
frontend/src/app/services/osm-auth.spec.ts
Normal file
16
frontend/src/app/services/osm-auth.spec.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { OsmAuth } from '../osm-auth';
|
||||||
|
|
||||||
|
describe('OsmAuth', () => {
|
||||||
|
let service: OsmAuth;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
service = TestBed.inject(OsmAuth);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
8
frontend/src/app/services/osm-auth.ts
Normal file
8
frontend/src/app/services/osm-auth.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class OsmAuth {
|
||||||
|
|
||||||
|
}
|
|
@ -6,7 +6,7 @@ const oedb = {
|
||||||
description: 'Événement communautaire',
|
description: 'Événement communautaire',
|
||||||
category: 'Communauté',
|
category: 'Communauté',
|
||||||
emoji: '\\o/',
|
emoji: '\\o/',
|
||||||
duration : '1D' // 1 day
|
durationHours: 24
|
||||||
},
|
},
|
||||||
// Community / OSM
|
// Community / OSM
|
||||||
'community.osm.event': {
|
'community.osm.event': {
|
||||||
|
@ -65,7 +65,8 @@ const oedb = {
|
||||||
emoji: '☀️',
|
emoji: '☀️',
|
||||||
label: 'Heure d\'été',
|
label: 'Heure d\'été',
|
||||||
category: 'Temps',
|
category: 'Temps',
|
||||||
description: 'Passage à l\'heure d\'été'
|
description: 'Passage à l\'heure d\'été',
|
||||||
|
durationHours: 24
|
||||||
},
|
},
|
||||||
|
|
||||||
// Tourism
|
// Tourism
|
||||||
|
@ -81,7 +82,13 @@ const oedb = {
|
||||||
emoji: '💥',
|
emoji: '💥',
|
||||||
label: 'Accident',
|
label: 'Accident',
|
||||||
category: 'Circulation',
|
category: 'Circulation',
|
||||||
description: 'Accident de la circulation'
|
description: 'Accident de la circulation',
|
||||||
|
durationHours: 6,
|
||||||
|
properties: {
|
||||||
|
severity: { label: 'Gravité', writable: true },
|
||||||
|
lanes_closed: { label: 'Voies fermées', writable: true },
|
||||||
|
vehicles: { label: 'Nombre de véhicules', writable: true }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
'traffic.incident': {
|
'traffic.incident': {
|
||||||
emoji: '⚠️',
|
emoji: '⚠️',
|
||||||
|
@ -102,28 +109,94 @@ const oedb = {
|
||||||
description: 'Fermeture partielle de voie'
|
description: 'Fermeture partielle de voie'
|
||||||
},
|
},
|
||||||
'traffic.roadwork': {
|
'traffic.roadwork': {
|
||||||
emoji: '<img src="static/cone.png" class="icone cone" />',
|
emoji: '',
|
||||||
|
image: 'static/cone.png',
|
||||||
label: 'Travaux routiers',
|
label: 'Travaux routiers',
|
||||||
category: 'Circulation',
|
category: 'Circulation',
|
||||||
description: 'Travaux sur la chaussée'
|
description: 'Travaux sur la chaussée',
|
||||||
|
durationHours: 72,
|
||||||
|
properties: {
|
||||||
|
contractor: { label: 'Entreprise', writable: true },
|
||||||
|
reason: { label: 'Raison', writable: true },
|
||||||
|
lanes_affected: { label: 'Voies impactées', writable: true }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
'wildlife': {
|
'wildlife': {
|
||||||
emoji: '🦌',
|
emoji: '🦌',
|
||||||
label: 'Animal',
|
label: 'Animal',
|
||||||
category: 'Vie sauvage',
|
category: 'Vie sauvage',
|
||||||
description: 'Détection d\'animaux'
|
description: 'Détection d\'animaux',
|
||||||
|
properties: {
|
||||||
|
detection_by: {
|
||||||
|
values: ['human', 'camera'],
|
||||||
|
default: 'human',
|
||||||
|
allow_empty: true,
|
||||||
|
allow_custom: true,
|
||||||
|
label: 'Détection par',
|
||||||
|
description: 'Comment l\'animal a été détecté',
|
||||||
|
},
|
||||||
|
animal: {
|
||||||
|
values: ['deer', 'bear', 'fox', 'wolf', 'rabbit', 'bird', 'fish', 'insect', 'other'],
|
||||||
|
default: 'deer',
|
||||||
|
allow_empty: true,
|
||||||
|
allow_custom: true,
|
||||||
|
label: 'Animal',
|
||||||
|
description: 'L\'animal détecté',
|
||||||
|
},
|
||||||
|
}
|
||||||
},
|
},
|
||||||
'traffic.mammoth': {
|
'traffic.mammoth': {
|
||||||
emoji: '🦣',
|
emoji: '🦣',
|
||||||
label: 'Mammouth laineux wohoooo! (évènement de test)',
|
label: 'Mammouth laineux wohoooo! (évènement de test)',
|
||||||
category: 'Obstacle',
|
category: 'Obstacle',
|
||||||
description: 'Un mammouth laineux bloque la route'
|
description: 'Un mammouth laineux bloque la route (évènement de test)',
|
||||||
|
durationHours: 48,
|
||||||
|
properties: {
|
||||||
|
test: true,
|
||||||
|
weight: 1000
|
||||||
|
}
|
||||||
},
|
},
|
||||||
'hazard.piranha': {
|
'hazard.piranha': {
|
||||||
emoji: '🐟',
|
emoji: '🐟',
|
||||||
label: 'Piranha dans la piscine',
|
label: 'Piranha dans la piscine (évènement de test)',
|
||||||
category: 'Danger',
|
category: 'Danger',
|
||||||
description: 'Des pirana attaquent dans cette piscine'
|
description: 'Des pirana attaquent dans cette piscine (évènement de test)',
|
||||||
|
durationHours: 48
|
||||||
|
},
|
||||||
|
|
||||||
|
// Météo étendue
|
||||||
|
'weather.storm': {
|
||||||
|
emoji: '🌪️',
|
||||||
|
label: 'Tempête',
|
||||||
|
category: 'Météo',
|
||||||
|
description: 'Tempête (vent fort)',
|
||||||
|
durationHours: 48,
|
||||||
|
properties: {
|
||||||
|
wind_speed: { label: 'Vent moyen (km/h)', writable: true },
|
||||||
|
wind_gust: { label: 'Rafales (km/h)', writable: true },
|
||||||
|
severity: { label: 'Sévérité', writable: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'weather.thunder': {
|
||||||
|
emoji: '⚡',
|
||||||
|
label: 'Éclairs / orage',
|
||||||
|
category: 'Météo',
|
||||||
|
description: 'Activité orageuse',
|
||||||
|
durationHours: 12,
|
||||||
|
properties: {
|
||||||
|
lightning_count: { label: 'Nombre d’éclairs', writable: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'weather.earthquake': {
|
||||||
|
emoji: '🌎',
|
||||||
|
label: 'Tremblement de terre',
|
||||||
|
category: 'Météo',
|
||||||
|
description: 'Séisme',
|
||||||
|
durationHours: 6,
|
||||||
|
properties: {
|
||||||
|
magnitude: { label: 'Magnitude (Mw)', writable: true },
|
||||||
|
depth_km: { label: 'Profondeur (km)', writable: true }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// ici ajouter d'autres catégories d'évènements à suggérer
|
// ici ajouter d'autres catégories d'évènements à suggérer
|
||||||
}
|
}
|
||||||
|
|
|
@ -1 +1,112 @@
|
||||||
/* You can add global styles to this file, and also import other style files */
|
/* Theme variables */
|
||||||
|
$color-blue: #9fd3f6; /* pastel blue */
|
||||||
|
$color-green: #b9e4c9; /* pastel green */
|
||||||
|
$color-bg: #f7fafb;
|
||||||
|
$color-surface: #ffffff;
|
||||||
|
$color-text: #22303a;
|
||||||
|
$color-muted: #6b7b86;
|
||||||
|
$color-success: #b4e5c6;
|
||||||
|
$color-error: #f6c9c9;
|
||||||
|
$color-info: #cfe8ff;
|
||||||
|
$border-radius: 10px;
|
||||||
|
$shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
$shadow-md: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||||
|
$spacing: 12px;
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: $color-bg;
|
||||||
|
color: $color-text;
|
||||||
|
// font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
html, body{
|
||||||
|
font-family: "Calibri", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, .button{
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: #79a2d1;
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover{
|
||||||
|
background-color: #6992c1;
|
||||||
|
}
|
||||||
|
&:active{
|
||||||
|
background-color: #5982b1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input{
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||||
|
background: #ffffff;
|
||||||
|
color: #22303a;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
app-root, app-home {
|
||||||
|
display: block;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generic UI elements */
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
padding: 10px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: linear-gradient(135deg, $color-blue, $color-green);
|
||||||
|
color: $color-text;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
transition: transform 0.04s ease, box-shadow 0.2s ease, opacity 0.2s;
|
||||||
|
&:hover { box-shadow: $shadow-md; }
|
||||||
|
&:active { transform: translateY(1px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: $color-surface;
|
||||||
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input, .select, textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
border: 1px solid rgba(0,0,0,0.12);
|
||||||
|
background: $color-surface;
|
||||||
|
color: $color-text;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(0,0,0,0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
label { font-size: 0.85rem; color: $color-muted; }
|
||||||
|
|
||||||
|
/* Toasts */
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
min-width: 240px;
|
||||||
|
max-width: 360px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
border: 1px solid rgba(0,0,0,0.06);
|
||||||
|
background: $color-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.is-success { background: $color-success; }
|
||||||
|
.toast.is-error { background: $color-error; }
|
||||||
|
.toast.is-info { background: $color-info; }
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue