This commit is contained in:
Tykayn 2025-10-03 14:00:35 +02:00 committed by tykayn
parent f991aee8ed
commit bdb3728494
13 changed files with 283 additions and 20 deletions

View file

@ -1,3 +0,0 @@
html{
font-family: "Calibri", "Helvetica Neue", Helvetica, Arial, sans-serif;
}

View file

@ -94,20 +94,23 @@
@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="status">
@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 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>

View file

@ -220,10 +220,12 @@ export class EditForm implements OnChanges {
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 {
@ -231,10 +233,12 @@ export class EditForm implements OnChanges {
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);
}
});
}
@ -254,14 +258,36 @@ export class EditForm implements OnChanges {
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 '';

View file

@ -1 +1,17 @@
<p>osm works!</p>
<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>

View file

@ -7,5 +7,14 @@ import { Component } from '@angular/core';
styleUrl: './osm.scss'
})
export class Osm {
osmPseudo: string='';
isLogginIn: any = false;
logout() {
}
login() {
}
}

View file

@ -4,4 +4,9 @@
<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>

View 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%;
}

View file

@ -9,6 +9,8 @@ import oedb_what_categories from '../../../oedb-types';
})
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]>();
@ -17,6 +19,8 @@ export class AllEvents {
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();
@ -31,7 +35,28 @@ export class AllEvents {
}
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> {
@ -101,6 +126,7 @@ export class AllEvents {
this.pickedMarker.remove();
}
this.pickedMarker = new maplibregl.Marker({ element: el }).setLngLat(coords).addTo(this.map);
this.currentPicked = coords;
}
async searchPlace(query: string) {
@ -125,6 +151,18 @@ export class AllEvents {
} 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
@ -138,11 +176,22 @@ export class AllEvents {
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: (p && (p.id ?? p.uuid)) ?? f?.id,
id: fid,
properties: p,
geometry: { type: 'Point', coordinates: coords }
});

View file

@ -14,8 +14,33 @@
<app-edit-form [selected]="selected" (saved)="onSaved($event)" (created)="onCreated($event)" (deleted)="onDeleted($event)"></app-edit-form>
</div>
<div class="main">
<div class="map">
<app-all-events [features]="features" (select)="onSelect($event)" (pickCoords)="onPickCoords($event)"></app-all-events>
</div>
@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>

View file

@ -19,6 +19,7 @@ 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) => {
@ -68,4 +69,49 @@ export class Home {
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();
}
}

View file

@ -72,6 +72,12 @@
<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:

View file

@ -10,6 +10,9 @@ import oedb_what_categories from '../../../../oedb-types';
export class Menu {
public oedb_what_categories: Array<any> = [];
public onToggleView?: () => void;
public onDownloadGeoJSON?: () => void;
public onDownloadCSV?: () => void;
constructor() {
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(); }
}

View file

@ -5,6 +5,9 @@ $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);
@ -16,8 +19,35 @@ html, body {
padding: 0;
background: $color-bg;
color: $color-text;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, sans-serif;
// 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;
@ -56,3 +86,27 @@ app-root, app-home {
}
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; }