1
0
This repository has been archived on 2026-06-27. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
MSE-PI-E2EEDA-Plein-de-eeee…/ui/src/app/components/room-map/room-map.component.ts
khalil-bot acea4de599 style(ui): apply Prettier formatting
Closes #30

Assisted-by: Claude:claude-sonnet-4-6
2026-05-27 22:00:11 +02:00

313 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]);
}
}