-
-
\ No newline at end of file
diff --git a/ui/src/app/components/room-map/room-map.component.scss b/ui/src/app/components/room-map/room-map.component.scss
index 22a772c..92e3824 100644
--- a/ui/src/app/components/room-map/room-map.component.scss
+++ b/ui/src/app/components/room-map/room-map.component.scss
@@ -1,164 +1,491 @@
-.dashboard-layout {
- display: flex;
- gap: 24px;
- padding: 24px;
- align-items: flex-start;
- min-height: 0;
+// ═════════════════════════════════════════════════════════════════════════════
+// room-map.component.scss
+// Visionneuse interactive du plan SVG avec overlay CO₂
+// ═════════════════════════════════════════════════════════════════════════════
- @media (max-width: 900px) {
+// ── Tokens ───────────────────────────────────────────────────────────────────
+
+$navy-deep: #0d1b2a;
+$navy: #1a3a6b;
+$navy-mid: #1f4e8c;
+$navy-light: #cfe0f2;
+$accent: #00b4d8;
+$surface: #f4f7fb;
+$border: #d1dce8;
+$text-main: #0d1b2a;
+$text-muted: #5a7694;
+$radius-lg: 10px;
+$radius-md: 7px;
+$radius-sm: 5px;
+$ctrl-h: 34px;
+
+// Breakpoints
+$bp-lg: 1100px;
+$bp-md: 768px;
+$bp-sm: 480px;
+
+// Transitions
+$t-fast: 0.12s ease;
+$t-normal: 0.20s ease;
+
+// ── Layout global ─────────────────────────────────────────────────────────────
+
+.layout {
+ display: flex;
+ flex-direction: row;
+ gap: 16px;
+ padding: 12px 16px;
+ height: calc(100vh - 64px);
+ min-height: 0;
+ box-sizing: border-box;
+
+ @media (max-width: $bp-lg) {
flex-direction: column;
- padding: 16px;
+ height: auto;
+ min-height: calc(100vh - 64px);
+ gap: 12px;
+ }
+
+ @media (max-width: $bp-sm) {
+ padding: 8px;
+ gap: 8px;
}
}
-.map-wrapper {
+// ── Sidebar ───────────────────────────────────────────────────────────────────
+
+.sidebar {
+ width: 240px;
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ max-height: 100%;
+ overflow-y: auto;
+ background: #fff;
+ border-radius: $radius-lg;
+ border: 1px solid $border;
+ box-shadow: 0 2px 12px rgba($navy, 0.07);
+ box-sizing: border-box;
+
+ @media (max-width: 1100px) {
+ width: 100%;
+ max-height: none;
+ flex-shrink: 0;
+ }
+}
+
+.sidebar-header {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 14px 16px 10px;
+ border-bottom: 1px solid $border;
+ flex-shrink: 0;
+}
+
+.sidebar-eyebrow {
+ font: 600 10px/1 ui-sans-serif, system-ui, sans-serif;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: $accent;
+}
+
+.sidebar-title {
+ margin: 0;
+ font: 700 15px/1.2 ui-sans-serif, system-ui, sans-serif;
+ color: $navy;
+}
+
+.sidebar-body {
+ flex: 1;
+ padding: 12px 14px;
+ overflow-y: auto;
+}
+
+// ── Zone carte ────────────────────────────────────────────────────────────────
+
+.map-area {
flex: 1;
min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+
+ @media (max-width: 1100px) {
+ // viewport prend 55vh sur tablet, le reste au scroll
+ min-height: 0;
+ }
}
-.map-title {
- color: #00bcd4;
- margin: 0 0 16px;
- font-size: 20px;
+// ── En-tête ───────────────────────────────────────────────────────────────────
+
+.map-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-shrink: 0;
+ padding: 10px 14px;
+ background: #fff;
+ border-radius: $radius-lg;
+ border: 1px solid $border;
+ box-shadow: 0 1px 6px rgba($navy, 0.06);
+ flex-wrap: wrap;
+
+ @media (max-width: 480px) {
+ padding: 8px 10px;
+ gap: 8px;
+ }
}
-.svg-container {
+.title-group {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.title-eyebrow {
+ font: 600 10px/1 ui-sans-serif, system-ui, sans-serif;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: $accent;
+}
+
+.title {
+ margin: 0;
+ font: 700 18px/1.2 ui-sans-serif, system-ui, sans-serif;
+ color: $navy;
+}
+
+// ── Badge "last updated" ──────────────────────────────────────────────────────
+
+.last-updated-badge {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font: 500 11px/1 ui-sans-serif, system-ui, sans-serif;
+ color: $text-muted;
+ letter-spacing: 0.02em;
+
+ svg { width: 12px; height: 12px; flex-shrink: 0; color: $accent; }
+
+ @media (max-width: 480px) { display: none; }
+}
+
+// ── Barre de contrôles ────────────────────────────────────────────────────────
+
+.controls {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ background: $navy-deep;
+ border-radius: $radius-md;
+ padding: 5px 8px;
+ box-shadow: 0 2px 8px rgba($navy-deep, 0.4);
+}
+
+.ctrl {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: $ctrl-h;
+ border: none;
+ border-radius: $radius-sm;
+ background: transparent;
+ color: #8ab0cc;
+ cursor: pointer;
+ transition: background $t-fast, color $t-fast;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.1);
+ color: #fff;
+ }
+
+ &:active { transform: scale(0.93); }
+
+ svg { display: block; }
+}
+
+.ctrl-zoom {
+ width: $ctrl-h;
+
+ svg { width: 14px; height: 14px; }
+}
+
+.ctrl-action {
+ width: $ctrl-h;
+
+ svg { width: 15px; height: 15px; }
+}
+
+.ctrl-divider {
+ width: 1px;
+ height: 18px;
+ background: rgba(255, 255, 255, 0.12);
+ margin: 0 2px;
+}
+
+.scale-badge {
+ font: 600 12px/1 'Courier New', monospace;
+ color: $accent;
+ min-width: 42px;
+ text-align: center;
+ letter-spacing: 0.03em;
+}
+
+// ── Viewport ──────────────────────────────────────────────────────────────────
+
+.viewport {
position: relative;
- background: #fafafa;
- border: 1px solid #e0e0e0;
- border-radius: 8px;
- overflow-x: auto;
- overflow-y: hidden;
- -webkit-overflow-scrolling: touch;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ border-radius: $radius-lg;
+ border: 1px solid $border;
+ background: $surface;
+ box-shadow:
+ inset 0 1px 3px rgba($navy, 0.06),
+ 0 2px 16px rgba($navy, 0.07);
+ cursor: grab;
+ user-select: none;
+ touch-action: none; // requis pour pointer events sur mobile
- svg {
+ // Damier de fond discret
+ background-color: #f0f5fb;
+ background-image:
+ linear-gradient(45deg, #e2eaf4 25%, transparent 25%),
+ linear-gradient(-45deg, #e2eaf4 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, #e2eaf4 75%),
+ linear-gradient(-45deg, transparent 75%, #e2eaf4 75%);
+ background-size: 24px 24px;
+ background-position: 0 0, 0 12px, 12px -12px, -12px 0;
+
+ &.panning { cursor: grabbing; }
+
+ // Hauteur explicite sur mobile (flex:1 ne suffit pas en colonne avec height:auto)
+ @media (max-width: 1100px) { min-height: 55vh; }
+ @media (max-width: 480px) { min-height: 50vh; border-radius: $radius-md; }
+}
+
+// ── Canvas transformable ──────────────────────────────────────────────────────
+
+.canvas {
+
+ position: absolute;
+ top: 0;
+ left: 0;
+ transform-origin: top left;
+ will-change: transform;
+ // Ombre extérieure pour matérialiser les bords du plan
+ filter: drop-shadow(0 4px 24px rgba($navy-deep, 0.18));
+
+}
+
+// ── Hôte du SVG injecté ───────────────────────────────────────────────────────
+
+.plan-host {
+ width: 100%;
+ height: 100%;
+ display: block;
+ pointer-events: none;
+
+ // ::ng-deep est justifié ici : le SVG est injecté via innerHTML (bypassSecurityTrustHtml),
+ // il échappe à l'encapsulation Angular et ne peut pas être ciblé autrement.
+ ::ng-deep svg {
display: block;
- min-width: 600px;
width: 100%;
- height: auto;
- }
-
- @media (max-width: 900px) {
- svg {
- min-width: 720px;
- }
+ height: 100%;
+ shape-rendering: geometricPrecision;
+ text-rendering: optimizeLegibility;
}
}
-/* SVG styles */
-.section-label {
- font-size: 22px;
- font-weight: 700;
- fill: #455a64;
- text-anchor: middle;
+// ── Overlay CO₂ ───────────────────────────────────────────────────────────────
+
+.co2-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ overflow: visible; // badges peuvent dépasser légèrement
+
+ // Les pointer-events sont activés individuellement sur chaque .badge
+ pointer-events: none;
}
-.corridor {
- fill: #eceff1;
-}
+// ── Badges CO₂ ────────────────────────────────────────────────────────────────
-.corridor-label {
- font-size: 14px;
- fill: #90a4ae;
- text-anchor: middle;
- writing-mode: vertical-rl;
- dominant-baseline: middle;
-}
-
-.room-group {
+.badge {
+ pointer-events: all;
cursor: pointer;
- .room-rect {
- stroke: #fff;
- stroke-width: 2;
- transition: filter 0.15s ease;
+ .badge-bg {
+ transition: filter 0.15s ease, fill-opacity 0.15s ease;
}
- &:hover .room-rect {
- filter: brightness(0.88);
- stroke: #37474f;
- stroke-width: 2.5;
+ &:hover .badge-bg {
+ fill-opacity: 1;
+ filter: url(#f-shadow) url(#f-hover);
+ }
+
+ &:active .badge-bg {
+ fill-opacity: 1;
+ filter: url(#f-shadow);
}
&.no-sensor {
cursor: default;
+ opacity: 0.55;
+ pointer-events: none;
}
}
-.room-id {
- font-size: 18px;
- font-weight: 700;
- fill: rgba(0, 0, 0, 0.75);
- text-anchor: middle;
- pointer-events: none;
-}
+// ── États chargement / erreur ─────────────────────────────────────────────────
-.room-co2 {
- font-size: 13px;
- fill: rgba(0, 0, 0, 0.6);
- text-anchor: middle;
- pointer-events: none;
-}
-
-.room-window {
- font-size: 11px;
- fill: rgba(0, 0, 0, 0.55);
- text-anchor: middle;
- pointer-events: none;
-}
-
-/* Tooltip */
-.tooltip {
+.state-overlay {
position: absolute;
- background: #263238;
- color: #fff;
- border-radius: 6px;
- padding: 10px 14px;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ background: rgba($surface, 0.92);
+ z-index: 10;
+
+ p {
+ margin: 0;
+ font: 400 14px/1.5 ui-sans-serif, system-ui, sans-serif;
+ color: $text-muted;
+ text-align: center;
+ }
+
+ code {
+ font-family: 'Courier New', monospace;
+ font-size: 0.9em;
+ background: rgba($navy, 0.08);
+ padding: 1px 5px;
+ border-radius: 3px;
+ color: $navy-mid;
+ }
+}
+
+.state-error {
+ .error-icon {
+ font-size: 28px;
+ color: #e57373;
+ line-height: 1;
+ }
+}
+
+// ── Spinner ───────────────────────────────────────────────────────────────────
+
+.spinner {
+ width: 36px;
+ height: 36px;
+ border: 3px solid rgba($navy-mid, 0.15);
+ border-top-color: $navy-mid;
+ border-radius: 50%;
+ animation: spin 0.75s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+// ── Hint viewport ─────────────────────────────────────────────────────────────
+
+.viewport-hint {
+ position: absolute;
+ bottom: 10px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: rgba($navy-deep, 0.62);
+ backdrop-filter: blur(4px);
+ color: rgba(255, 255, 255, 0.7);
+ font: 400 10px/1 ui-sans-serif, system-ui, sans-serif;
+ letter-spacing: 0.04em;
+ padding: 5px 12px;
+ border-radius: 20px;
pointer-events: none;
white-space: nowrap;
- z-index: 10;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
- min-width: 160px;
+
+ // Sur mobile, simplifie le hint et réduit la taille
+ @media (max-width: 768px) {
+ font-size: 9px;
+ padding: 4px 10px;
+ }
+
+ // Cache les spans "Mouse wheel" et "Drag" sur mobile, garde juste "Click"
+ @media (max-width: 480px) {
+ .hint-mouse, .hint-drag, .hint-sep:not(:last-of-type) { display: none; }
+ }
}
-.tooltip-title {
- font-weight: 600;
- font-size: 13px;
- margin-bottom: 6px;
- color: #80cbc4;
+.hint-sep {
+ opacity: 0.4;
}
-.tooltip-row {
+// ── Tooltip ───────────────────────────────────────────────────────────────────
+
+.tooltip {
+ position: absolute;
+ z-index: 20;
+ pointer-events: none;
+ background: $navy-deep;
+ border: 1px solid rgba($accent, 0.25);
+ border-radius: $radius-md;
+ padding: 10px 13px;
+ min-width: 180px;
+ box-shadow: 0 8px 28px rgba($navy-deep, 0.45);
+ animation: tt-in 0.1s ease;
+
+ @keyframes tt-in {
+ from { opacity: 0; transform: translateY(4px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
+}
+
+.tt-name {
+ font: 700 13px/1.2 ui-sans-serif, system-ui, sans-serif;
+ color: $navy-light;
+ margin-bottom: 2px;
+}
+
+.tt-type {
+ font: 400 10px/1 ui-sans-serif, system-ui, sans-serif;
+ color: $text-muted;
+ text-transform: capitalize;
+ margin-bottom: 8px;
+}
+
+.tt-divider {
+ height: 1px;
+ background: rgba(255, 255, 255, 0.08);
+ margin: 6px 0;
+}
+
+.tt-row {
display: flex;
justify-content: space-between;
+ align-items: center;
gap: 16px;
- font-size: 12px;
- line-height: 1.8;
- color: #cfd8dc;
+ font: 400 11px/1.8 ui-sans-serif, system-ui, sans-serif;
- strong {
- color: #fff;
- }
+ span { color: #6a90b0; }
+ strong { color: #fff; font-weight: 600; }
+
+ strong.open { color: #69e09a; }
}
-.tooltip-no-sensor {
- font-size: 12px;
- color: #90a4ae;
+.tt-empty {
+ font: 400 11px/1 ui-sans-serif, system-ui, sans-serif;
+ color: #5a7694;
font-style: italic;
-}
-
-/* Sidebar */
-.sidebar {
- width: 220px;
- flex-shrink: 0;
- position: sticky;
- top: 24px;
- max-height: calc(100vh - 48px);
- overflow-y: auto;
-
- @media (max-width: 900px) {
- width: 100%;
- position: static;
- max-height: none;
- }
-}
+ margin-top: 4px;
+}
\ No newline at end of file
diff --git a/ui/src/app/components/room-map/room-map.component.ts b/ui/src/app/components/room-map/room-map.component.ts
index 313aedc..ba73bc9 100644
--- a/ui/src/app/components/room-map/room-map.component.ts
+++ b/ui/src/app/components/room-map/room-map.component.ts
@@ -1,12 +1,28 @@
-import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+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 { combineLatest } from 'rxjs';
-import { RoomLayout } from '../../config/rooms-layout.config';
+import { Subscription, 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;
@@ -14,6 +30,41 @@ export interface RoomMapEntry {
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
= {
+ 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,
@@ -22,49 +73,215 @@ export interface RoomMapEntry {
styleUrl: './room-map.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
-export class RoomMapComponent implements OnInit {
- private router = inject(Router);
- private roomService = inject(RoomService);
+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);
- rooms = signal([]);
- tooltip = signal<{ room: RoomMapEntry; x: number; y: number } | null>(null);
+ @ViewChild('viewport') viewportRef!: ElementRef;
- readonly NO_SENSOR_COLOR = '#e0e0e0';
+ // ── Données ──────────────────────────────────────────────────────────────
+ rooms = signal([]);
+ svgHtml = signal(null);
+ isLoading = signal(true);
+ svgError = signal(false);
+ tooltip = signal<{ room: RoomMapEntry; x: number; y: number } | null>(null);
+ lastUpdated = signal(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;
+ 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 {
- combineLatest([this.roomService.getRooms(), this.sensorService.getLatestReadings()]).subscribe(
- ([layouts, readings]) => {
- this.rooms.set(
- layouts.map(layout => {
- const reading = readings.find(r => r.roomId === layout.id);
- return {
- layout,
- reading,
- color: reading ? getCO2Color(reading.co2) : this.NO_SENSOR_COLOR,
- };
- })
- );
- }
- );
- }
+ // Chargement du plan SVG depuis public/
+ this.svgSub = this.http.get('/plan.svg', { responseType: 'text' }).subscribe({
+ next: (raw) => {
+ const cleaned = raw
+ .replace(/(]*?)\s+width="[^"]*"/, '$1')
+ .replace(/(]*?)\s+height="[^"]*"/, '$1');
+ this.svgHtml.set(this.sanitizer.bypassSecurityTrustHtml(cleaned));
+ this.isLoading.set(false);
+ },
+ error: () => {
+ this.isLoading.set(false);
+ this.svgError.set(true);
+ },
+ });
- navigateToRoom(roomId: string): void {
- this.router.navigate(['/room', roomId]);
- }
-
- showTooltip(event: MouseEvent, room: RoomMapEntry): void {
- const svg = (event.target as SVGElement).closest('svg');
- if (!svg) return;
- const rect = svg.getBoundingClientRect();
- this.tooltip.set({
- room,
- x: event.clientX - rect.left + 12,
- y: event.clientY - rect.top - 10,
+ // 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(() => timer(0, environment.polling.mapIntervalMs)),
+ switchMap(() => this.sensorService.getLatestReadings()),
+ ).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());
});
}
- hideTooltip(): void {
- this.tooltip.set(null);
+ 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.90;
+ 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]); }
+}
\ No newline at end of file
diff --git a/ui/src/app/config/co2-levels.config.ts b/ui/src/app/config/co2-levels.config.ts
index e2203fc..1c2490d 100644
--- a/ui/src/app/config/co2-levels.config.ts
+++ b/ui/src/app/config/co2-levels.config.ts
@@ -13,9 +13,8 @@ export interface CO2Level {
export const CO2_LEVELS: CO2Level[] = [
{ label: 'Excellent', range: '< 800 ppm', color: '#4caf50', maxPpm: 800 },
{ label: 'Good', range: '800-1000 ppm', color: '#8bc34a', maxPpm: 1000 },
- { label: 'Moderate', range: '1000-1200 ppm', color: '#ffc107', maxPpm: 1200 },
- { label: 'Poor', range: '1200-1500 ppm', color: '#ff9800', maxPpm: 1500 },
- { label: 'Very Poor', range: '1500-2000 ppm', color: '#ff5722', maxPpm: 2000 },
+ { label: 'Moderate', range: '1000-1400 ppm', color: '#ffc107', maxPpm: 1400 },
+ { label: 'Poor', range: '1400-2000 ppm', color: '#ff9800', maxPpm: 2000 },
{ label: 'Critical', range: '> 2000 ppm', color: '#f44336', maxPpm: Infinity },
];
diff --git a/ui/src/app/config/rooms-layout.config.ts b/ui/src/app/config/rooms-layout.config.ts
index 2cce136..f87706b 100644
--- a/ui/src/app/config/rooms-layout.config.ts
+++ b/ui/src/app/config/rooms-layout.config.ts
@@ -1,6 +1,18 @@
/**
* Room Layout Configuration
- * Defines all rooms in Espace A and Espace B with their SVG positions and metadata
+ * Coordinates derived from architectural HTML floor plan (viewBox 1000×1500).
+ * Mapped to schematic viewBox 0 0 1100 700:
+ * x_s = (x_html - 40) × 1.185 + 5 (building x 40–960 → schematic 5–1095)
+ * y_s = (y_html - 60) × 0.500 + 5 (building y 60–1440 → schematic 5–695)
+ *
+ * Layout:
+ * Hall (L-shape): upper-left, x=29–811, y=15–445
+ * Espace B: lower-left, x=29–361, y=445–695
+ * Espace A: right column, x=811–1095, y=15–435
+ * + A5 (centre-right, y=245–325)
+ * + A3 (lower centre, y=445–505)
+ * + A1 (bottom centre, y=595–695)
+ * Door at bottom of Espace B.
*/
export interface RoomLayout {
@@ -18,180 +30,63 @@ export interface RoomLayout {
}
export const ROOM_LAYOUTS: RoomLayout[] = [
- // ========================================
- // ESPACE B (Left side - 4 rooms)
- // ========================================
+ // ── Espace B (lower-left, x=29–361) ──────────────────────────────────────
{
- id: 'B1',
- name: 'B1',
- espace: 'B',
- floor: 1,
- type: 'meeting',
- x: 50,
- y: 80,
- width: 220,
- height: 140,
- capacity: 12,
- hasSensor: true,
+ id: 'B4', name: 'B4', espace: 'B', floor: 1, type: 'meeting',
+ x: 29, y: 445, width: 332, height: 60, capacity: 12, hasSensor: true,
},
{
- id: 'B2',
- name: 'B2',
- espace: 'B',
- floor: 1,
- type: 'lab',
- x: 50,
- y: 240,
- width: 220,
- height: 140,
- capacity: 20,
- hasSensor: true,
+ id: 'B3', name: 'B3', espace: 'B', floor: 1, type: 'office',
+ x: 29, y: 505, width: 332, height: 60, capacity: 8, hasSensor: true,
},
{
- id: 'B3',
- name: 'B3',
- espace: 'B',
- floor: 1,
- type: 'office',
- x: 50,
- y: 400,
- width: 220,
- height: 140,
- capacity: 8,
- hasSensor: true,
+ id: 'B2', name: 'B2', espace: 'B', floor: 1, type: 'classroom',
+ x: 29, y: 565, width: 332, height: 80, capacity: 20, hasSensor: true,
},
{
- id: 'B4',
- name: 'B4',
- espace: 'B',
- floor: 1,
- type: 'storage',
- x: 50,
- y: 560,
- width: 220,
- height: 140,
- capacity: 0,
- hasSensor: false,
+ id: 'B1', name: 'B1', espace: 'B', floor: 1, type: 'lab',
+ x: 29, y: 645, width: 332, height: 50, capacity: 0, hasSensor: false,
},
- // ========================================
- // ESPACE A (Right side - 9 rooms)
- // ========================================
+ // ── Espace A — upper-right column (x=811–1095) ───────────────────────────
{
- id: 'A1',
- name: 'A1',
- espace: 'A',
- floor: 1,
- type: 'auditorium',
- x: 410,
- y: 240,
- width: 220,
- height: 300,
- capacity: 150,
- hasSensor: true,
+ id: 'A8', name: 'A8', espace: 'A', floor: 1, type: 'office',
+ x: 811, y: 15, width: 142, height: 90, capacity: 10, hasSensor: true,
},
{
- id: 'A2',
- name: 'A2',
- espace: 'A',
- floor: 1,
- type: 'classroom',
- x: 670,
- y: 80,
- width: 220,
- height: 140,
- capacity: 60,
- hasSensor: true,
+ id: 'A9', name: 'A9', espace: 'A', floor: 1, type: 'classroom',
+ x: 953, y: 15, width: 142, height: 90, capacity: 40, hasSensor: true,
},
{
- id: 'A3',
- name: 'A3',
- espace: 'A',
- floor: 1,
- type: 'workshop',
- x: 410,
- y: 80,
- width: 220,
- height: 140,
- capacity: 25,
- hasSensor: true,
+ id: 'A7', name: 'A7', espace: 'A', floor: 1, type: 'classroom',
+ x: 811, y: 105, width: 284, height: 60, capacity: 40, hasSensor: true,
},
{
- id: 'A4',
- name: 'A4',
- espace: 'A',
- floor: 1,
- type: 'openspace',
- x: 930,
- y: 80,
- width: 220,
- height: 140,
- capacity: 30,
- hasSensor: true,
+ id: 'A6', name: 'A6', espace: 'A', floor: 1, type: 'classroom',
+ x: 811, y: 165, width: 284, height: 80, capacity: 30, hasSensor: true,
+ },
+
+ // ── Espace A — centre-right (A5 left of A4, same y-band) ─────────────────
+ {
+ id: 'A5', name: 'A5', espace: 'A', floor: 1, type: 'conference',
+ x: 598, y: 245, width: 213, height: 80, capacity: 20, hasSensor: true,
},
{
- id: 'A5',
- name: 'A5',
- espace: 'A',
- floor: 1,
- type: 'conference',
- x: 670,
- y: 400,
- width: 220,
- height: 140,
- capacity: 20,
- hasSensor: true,
+ id: 'A4', name: 'A4', espace: 'A', floor: 1, type: 'classroom',
+ x: 811, y: 245, width: 284, height: 95, capacity: 60, hasSensor: true,
},
{
- id: 'A6',
- name: 'A6',
- espace: 'A',
- floor: 1,
- type: 'study',
- x: 930,
- y: 240,
- width: 220,
- height: 140,
- capacity: 15,
- hasSensor: true,
+ id: 'A2', name: 'A2', espace: 'A', floor: 1, type: 'classroom',
+ x: 811, y: 340, width: 284, height: 95, capacity: 60, hasSensor: true,
+ },
+
+ // ── Espace A — lower centre ───────────────────────────────────────────────
+ {
+ id: 'A3', name: 'A3', espace: 'A', floor: 1, type: 'lab',
+ x: 408, y: 445, width: 403, height: 60, capacity: 25, hasSensor: true,
},
{
- id: 'A7',
- name: 'A7',
- espace: 'A',
- floor: 1,
- type: 'lab',
- x: 670,
- y: 560,
- width: 220,
- height: 140,
- capacity: 18,
- hasSensor: true,
- },
- {
- id: 'A8',
- name: 'A8',
- espace: 'A',
- floor: 1,
- type: 'classroom',
- x: 930,
- y: 560,
- width: 220,
- height: 140,
- capacity: 40,
- hasSensor: true,
- },
- {
- id: 'A9',
- name: 'A9',
- espace: 'A',
- floor: 1,
- type: 'office',
- x: 930,
- y: 400,
- width: 220,
- height: 140,
- capacity: 10,
- hasSensor: true,
+ id: 'A1', name: 'A1', espace: 'A', floor: 1, type: 'meeting',
+ x: 598, y: 595, width: 261, height: 100, capacity: 15, hasSensor: true,
},
];
diff --git a/ui/src/app/services/sensor.service.ts b/ui/src/app/services/sensor.service.ts
index ac21111..42b4e5e 100644
--- a/ui/src/app/services/sensor.service.ts
+++ b/ui/src/app/services/sensor.service.ts
@@ -1,45 +1,62 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable, catchError, of } from 'rxjs';
+import { tap } from 'rxjs/operators';
import { SensorReading, WindowState } from '../models/sensor-reading.model';
import { ROOM_LAYOUTS } from '../config/rooms-layout.config';
import { environment } from '../../environments/environment';
+const randomWindowState = (): WindowState => Math.random() > 0.5 ? 'open' : 'closed';
+
const MOCK_HISTORY: SensorReading[] = ROOM_LAYOUTS.filter(r => r.hasSensor).flatMap(room =>
Array.from({ length: 24 }, (_, i) => ({
- roomId: room.id,
- roomName: room.name,
- co2: Math.floor(Math.random() * 1600) + 400,
+ roomId: room.id,
+ roomName: room.name,
+ co2: Math.floor(Math.random() * 800) + 300,
temperature: Math.round((Math.random() * 8 + 18) * 10) / 10,
- humidity: Math.floor(Math.random() * 30 + 35),
- windowState: (Math.random() > 0.5 ? 'open' : 'closed') as WindowState,
- timestamp: new Date(Date.now() - i * 60 * 60 * 1000),
+ humidity: Math.floor(Math.random() * 30 + 35),
+ windowState: randomWindowState(),
+ timestamp: new Date(Date.now() - i * 60 * 60 * 1000),
}))
);
@Injectable({ providedIn: 'root' })
export class SensorService {
- private http = inject(HttpClient);
- private apiUrl = environment.apiUrl;
+ private http = inject(HttpClient);
+ private apiUrl = environment.apiUrl ?? 'http://localhost:8080';
getLatestReadings(): Observable {
- const latest = ROOM_LAYOUTS.filter(r => r.hasSensor).map(
- room => MOCK_HISTORY.find(r => r.roomId === room.id)!
- );
+ const fallback = ROOM_LAYOUTS.filter(r => r.hasSensor)
+ .map(room => MOCK_HISTORY.find(r => r.roomId === room.id))
+ .filter((r): r is SensorReading => r !== undefined);
+
return this.http
.get(`${this.apiUrl}/sensors/latest`)
- .pipe(catchError(() => of(latest)));
+ .pipe(
+ tap({ error: (e) => console.error('[SensorService] getLatestReadings failed', e) }),
+ catchError(() => of(fallback)),
+ );
}
getLatestReadingForRoom(roomId: string): Observable {
+ const fallback = MOCK_HISTORY.find(r => r.roomId === roomId);
+
return this.http
.get(`${this.apiUrl}/sensors/${roomId}/latest`)
- .pipe(catchError(() => of(MOCK_HISTORY.find(r => r.roomId === roomId))));
+ .pipe(
+ tap({ error: (e) => console.error(`[SensorService] getLatestReadingForRoom(${roomId}) failed`, e) }),
+ catchError(() => of(fallback)),
+ );
}
getHistoryForRoom(roomId: string): Observable {
+ const fallback = MOCK_HISTORY.filter(r => r.roomId === roomId);
+
return this.http
.get(`${this.apiUrl}/sensors/${roomId}/history`)
- .pipe(catchError(() => of(MOCK_HISTORY.filter(r => r.roomId === roomId))));
+ .pipe(
+ tap({ error: (e) => console.error(`[SensorService] getHistoryForRoom(${roomId}) failed`, e) }),
+ catchError(() => of(fallback)),
+ );
}
}
diff --git a/ui/src/environments/environment.prod.ts b/ui/src/environments/environment.prod.ts
index 2204905..ea08429 100644
--- a/ui/src/environments/environment.prod.ts
+++ b/ui/src/environments/environment.prod.ts
@@ -4,4 +4,8 @@ export const environment = {
wsUrl: 'wss://ui.e.kb28.ch/ws',
influxUrl: '',
logLevel: 'error',
+ polling: {
+ mapIntervalMs: 30_000,
+ detailIntervalMs: 15_000,
+ },
};
diff --git a/ui/src/environments/environment.template.ts b/ui/src/environments/environment.template.ts
index ec5248f..ef9735a 100644
--- a/ui/src/environments/environment.template.ts
+++ b/ui/src/environments/environment.template.ts
@@ -8,4 +8,8 @@ export const environment = {
wsUrl: 'wss://YOUR_DOMAIN/ws',
influxUrl: '',
logLevel: 'error',
+ polling: {
+ mapIntervalMs: 30_000,
+ detailIntervalMs: 15_000,
+ },
};
diff --git a/ui/src/environments/environment.ts b/ui/src/environments/environment.ts
index a3b48f0..189a788 100644
--- a/ui/src/environments/environment.ts
+++ b/ui/src/environments/environment.ts
@@ -4,4 +4,8 @@ export const environment = {
wsUrl: 'ws://localhost:8080/ws',
influxUrl: '',
logLevel: 'debug',
+ polling: {
+ mapIntervalMs: 30_000,
+ detailIntervalMs: 15_000,
+ },
};