313 lines
13 KiB
TypeScript
313 lines
13 KiB
TypeScript
import {
|
||
AfterViewInit,
|
||
ChangeDetectionStrategy,
|
||
Component,
|
||
ElementRef,
|
||
NgZone,
|
||
OnDestroy,
|
||
OnInit,
|
||
ViewChild,
|
||
computed,
|
||
inject,
|
||
signal,
|
||
} from '@angular/core';
|
||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||
import { HttpClient } from '@angular/common/http';
|
||
import { Router } from '@angular/router';
|
||
import { Subscription, concat, timer } from 'rxjs';
|
||
import { switchMap, tap } from 'rxjs/operators';
|
||
import { getCO2Color } from '../../config/co2-levels.config';
|
||
import { SensorReading } from '../../models/sensor-reading.model';
|
||
import { RoomLayout } from '../../config/rooms-layout.config';
|
||
import { RoomService } from '../../services/room.service';
|
||
import { SensorService } from '../../services/sensor.service';
|
||
import { LegendComponent } from '../legend/legend.component';
|
||
import { environment } from '../../../environments/environment';
|
||
|
||
export interface RoomMapEntry {
|
||
layout: RoomLayout;
|
||
reading?: SensorReading;
|
||
color: string;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Positions des badges CO₂ dans l'espace de coordonnées du SVG original.
|
||
// ViewBox source : 0 0 184322.64 245669.28
|
||
// Dérivées depuis les chemins de texte (aria-label) du fichier plan.svg.
|
||
//
|
||
// Note : A4 (cy≈161 500) et A2 (cy≈165 800) sont séparées de ~4 300 unités,
|
||
// ce qui reflète la réalité architecturale — elles sont adjacentes dans le plan.
|
||
// Les badges se chevauchent légèrement à l'échelle globale ; zoomer > 3× les sépare.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
const SVG_ROOM_CENTERS: Record<string, { cx: number; cy: number }> = {
|
||
A8: { cx: 155500, cy: 9000 },
|
||
A9: { cx: 177200, cy: 57000 },
|
||
A7: { cx: 134800, cy: 84700 },
|
||
A6: { cx: 166500, cy: 81200 },
|
||
A5: { cx: 134800, cy: 130200 },
|
||
A4: { cx: 166500, cy: 141500 },
|
||
A2: { cx: 166500, cy: 180800 },
|
||
A3: { cx: 90700, cy: 165000 },
|
||
A1: { cx: 110000, cy: 225000 },
|
||
// B rooms : labels hors de la zone visible analysée du SVG (section tronquée).
|
||
// Décommentez et ajustez après inspection du fichier complet.
|
||
B4: { cx: 17000, cy: 138000 },
|
||
B3: { cx: 17000, cy: 170000 },
|
||
B2: { cx: 17000, cy: 205000 },
|
||
B1: { cx: 52000, cy: 225000 },
|
||
};
|
||
|
||
// Dimensions du SVG original (attributs width/height du fichier Inkscape)
|
||
const SVG_W = 184322.64;
|
||
const SVG_H = 245669.28;
|
||
|
||
// Le canvas est rendu à SVG_W/SCALE × SVG_H/SCALE pixels avant le zoom CSS.
|
||
// SCALE = 100 → canvas = 1843.23 × 2456.69 px
|
||
const SCALE = 100;
|
||
|
||
@Component({
|
||
selector: 'app-room-map',
|
||
standalone: true,
|
||
imports: [LegendComponent],
|
||
templateUrl: './room-map.component.html',
|
||
styleUrl: './room-map.component.scss',
|
||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||
})
|
||
export class RoomMapComponent implements OnInit, AfterViewInit, OnDestroy {
|
||
private http = inject(HttpClient);
|
||
private sanitizer = inject(DomSanitizer);
|
||
private router = inject(Router);
|
||
private roomService = inject(RoomService);
|
||
private sensorService = inject(SensorService);
|
||
private zone = inject(NgZone);
|
||
|
||
@ViewChild('viewport') viewportRef!: ElementRef<HTMLDivElement>;
|
||
|
||
// ── Données ──────────────────────────────────────────────────────────────
|
||
rooms = signal<RoomMapEntry[]>([]);
|
||
svgHtml = signal<SafeHtml | null>(null);
|
||
isLoading = signal(true);
|
||
svgError = signal(false);
|
||
tooltip = signal<{ room: RoomMapEntry; x: number; y: number } | null>(null);
|
||
lastUpdated = signal<Date | null>(null);
|
||
|
||
lastUpdatedStr = computed(() => {
|
||
const d = this.lastUpdated();
|
||
if (!d) return null;
|
||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
});
|
||
|
||
private pollSub?: Subscription;
|
||
private svgSub?: Subscription;
|
||
private fitTimer?: ReturnType<typeof setTimeout>;
|
||
private roomLayouts: RoomLayout[] = [];
|
||
|
||
// ── État pan / zoom ──────────────────────────────────────────────────────
|
||
// Variables privées pour les mutations hors zone (wheel)
|
||
private _s = 1;
|
||
private _tx = 0;
|
||
private _ty = 0;
|
||
|
||
scale = signal(1);
|
||
tx = signal(0);
|
||
ty = signal(0);
|
||
isPanning = signal(false);
|
||
|
||
scalePct = computed(() => Math.round(this.scale() * 100));
|
||
transform = computed(() => `translate(${this.tx()}px,${this.ty()}px) scale(${this.scale()})`);
|
||
|
||
// ── Constantes exposées au template ──────────────────────────────────────
|
||
readonly SVG_W = SVG_W;
|
||
readonly SVG_H = SVG_H;
|
||
readonly CANVAS_W = Math.round(SVG_W / SCALE); // 1843 px
|
||
readonly CANVAS_H = Math.round(SVG_H / SCALE); // 2457 px
|
||
readonly MIN_SCALE = 0.04;
|
||
readonly MAX_SCALE = 25;
|
||
|
||
readonly NO_SENSOR_COLOR = '#94a3b8';
|
||
|
||
// ── Pan ──────────────────────────────────────────────────────────────────
|
||
private panStart = { x: 0, y: 0 };
|
||
private panOrigin = { tx: 0, ty: 0 };
|
||
|
||
// Référence stable pour removeEventListener
|
||
private readonly wheelFn = (e: WheelEvent) => this.onWheel(e);
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// Lifecycle
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
ngOnInit(): void {
|
||
// Chargement du plan SVG depuis public/
|
||
this.svgSub = this.http.get('/plan.svg', { responseType: 'text' }).subscribe({
|
||
next: raw => {
|
||
const cleaned = raw
|
||
.replace(/(<svg\b[^>]*?)\s+width="[^"]*"/, '$1')
|
||
.replace(/(<svg\b[^>]*?)\s+height="[^"]*"/, '$1');
|
||
this.svgHtml.set(this.sanitizer.bypassSecurityTrustHtml(cleaned));
|
||
this.isLoading.set(false);
|
||
},
|
||
error: () => {
|
||
this.isLoading.set(false);
|
||
this.svgError.set(true);
|
||
},
|
||
});
|
||
|
||
// Charge les salles une fois, puis poll les capteurs selon l'intervalle configuré
|
||
this.pollSub = this.roomService
|
||
.getRooms()
|
||
.pipe(
|
||
tap(layouts => {
|
||
this.roomLayouts = layouts;
|
||
}),
|
||
switchMap(rooms =>
|
||
concat(
|
||
this.sensorService.getLatestReadings(rooms),
|
||
timer(environment.polling.mapIntervalMs, environment.polling.mapIntervalMs).pipe(
|
||
switchMap(() => this.sensorService.getLatestReadings(rooms))
|
||
)
|
||
)
|
||
)
|
||
)
|
||
.subscribe(readings => {
|
||
this.rooms.set(
|
||
this.roomLayouts.map(layout => {
|
||
const reading = readings.find(r => r.roomId === layout.id);
|
||
return {
|
||
layout,
|
||
reading,
|
||
color: reading ? getCO2Color(reading.co2) : this.NO_SENSOR_COLOR,
|
||
};
|
||
})
|
||
);
|
||
this.lastUpdated.set(new Date());
|
||
});
|
||
}
|
||
|
||
ngAfterViewInit(): void {
|
||
// Écouteur de molette hors zone Angular (passive:false requis pour preventDefault)
|
||
this.zone.runOutsideAngular(() => {
|
||
this.viewportRef.nativeElement.addEventListener('wheel', this.wheelFn, {
|
||
passive: false,
|
||
});
|
||
});
|
||
|
||
this.fitTimer = setTimeout(() => this.fitToView(), 400);
|
||
}
|
||
|
||
ngOnDestroy(): void {
|
||
this.viewportRef?.nativeElement.removeEventListener('wheel', this.wheelFn);
|
||
clearTimeout(this.fitTimer);
|
||
this.svgSub?.unsubscribe();
|
||
this.pollSub?.unsubscribe();
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// Overlay helpers
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
getSvgCenter(roomId: string): { cx: number; cy: number } | null {
|
||
return SVG_ROOM_CENTERS[roomId] ?? null;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// Zoom
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
/** Applique un zoom en maintenant le point pivot fixe (coordonnées viewport). */
|
||
private applyZoom(newScale: number, pivotX: number, pivotY: number): void {
|
||
newScale = Math.min(this.MAX_SCALE, Math.max(this.MIN_SCALE, newScale));
|
||
const factor = newScale / this._s;
|
||
this._tx = pivotX - factor * (pivotX - this._tx);
|
||
this._ty = pivotY - factor * (pivotY - this._ty);
|
||
this._s = newScale;
|
||
// Re-entre dans la zone Angular pour déclencher la détection de changements
|
||
this.zone.run(() => {
|
||
this.scale.set(this._s);
|
||
this.tx.set(this._tx);
|
||
this.ty.set(this._ty);
|
||
});
|
||
}
|
||
|
||
onWheel(e: WheelEvent): void {
|
||
e.preventDefault();
|
||
const rect = this.viewportRef.nativeElement.getBoundingClientRect();
|
||
// Zoom progressif : ~12% par cran de molette
|
||
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||
this.applyZoom(this._s * factor, e.clientX - rect.left, e.clientY - rect.top);
|
||
}
|
||
|
||
zoomIn(): void {
|
||
this.applyZoom(this._s * 1.5, ...this.vpCenter());
|
||
}
|
||
zoomOut(): void {
|
||
this.applyZoom(this._s / 1.5, ...this.vpCenter());
|
||
}
|
||
|
||
fitToView(): void {
|
||
const vp = this.viewportRef?.nativeElement;
|
||
if (!vp) return;
|
||
const s = Math.min(vp.clientWidth / this.CANVAS_W, vp.clientHeight / this.CANVAS_H) * 0.9;
|
||
this._s = s;
|
||
this._tx = (vp.clientWidth - this.CANVAS_W * s) / 2;
|
||
this._ty = (vp.clientHeight - this.CANVAS_H * s) / 2;
|
||
this.scale.set(this._s);
|
||
this.tx.set(this._tx);
|
||
this.ty.set(this._ty);
|
||
}
|
||
|
||
reset(): void {
|
||
this._s = 1;
|
||
this._tx = 0;
|
||
this._ty = 0;
|
||
this.scale.set(1);
|
||
this.tx.set(0);
|
||
this.ty.set(0);
|
||
}
|
||
|
||
private vpCenter(): [number, number] {
|
||
const vp = this.viewportRef.nativeElement;
|
||
return [vp.clientWidth / 2, vp.clientHeight / 2];
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// Pan
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
onPointerDown(e: PointerEvent): void {
|
||
if (e.button !== 0) return;
|
||
this.isPanning.set(true);
|
||
this.panStart = { x: e.clientX, y: e.clientY };
|
||
this.panOrigin = { tx: this._tx, ty: this._ty };
|
||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||
}
|
||
|
||
onPointerMove(e: PointerEvent): void {
|
||
if (!this.isPanning()) return;
|
||
this._tx = this.panOrigin.tx + (e.clientX - this.panStart.x);
|
||
this._ty = this.panOrigin.ty + (e.clientY - this.panStart.y);
|
||
this.tx.set(this._tx);
|
||
this.ty.set(this._ty);
|
||
}
|
||
|
||
onPointerUp(): void {
|
||
this.isPanning.set(false);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// Tooltip & Navigation
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
showTooltip(e: MouseEvent, room: RoomMapEntry): void {
|
||
const rect = this.viewportRef.nativeElement.getBoundingClientRect();
|
||
this.tooltip.set({ room, x: e.clientX - rect.left + 18, y: e.clientY - rect.top - 12 });
|
||
}
|
||
|
||
hideTooltip(): void {
|
||
this.tooltip.set(null);
|
||
}
|
||
navigateToRoom(id: string): void {
|
||
this.router.navigate(['/room', id]);
|
||
}
|
||
}
|