diff --git a/ui/src/app/components/room-details-panel/room-details-panel.component.html b/ui/src/app/components/room-details-panel/room-details-panel.component.html index 851ba1b..963eafb 100644 --- a/ui/src/app/components/room-details-panel/room-details-panel.component.html +++ b/ui/src/app/components/room-details-panel/room-details-panel.component.html @@ -1,102 +1,160 @@ -
- ← Back to Dashboard +
+ + + + @if (room(); as r) { -
-

{{ r.name }}

-
+
- @if (latestReading(); as reading) { -
- {{ getCO2Level(reading.co2).label }} - {{ reading.co2 }} ppm CO₂ -
+ +
+ } @else { +
No history available.
+ } + - - - } +
} @else {
Room not found.
} +
diff --git a/ui/src/app/components/room-details-panel/room-details-panel.component.scss b/ui/src/app/components/room-details-panel/room-details-panel.component.scss index 77c8ea9..f97a505 100644 --- a/ui/src/app/components/room-details-panel/room-details-panel.component.scss +++ b/ui/src/app/components/room-details-panel/room-details-panel.component.scss @@ -1,206 +1,525 @@ -.details-container { - max-width: 900px; - margin: 0 auto; - padding: 24px; +// ── Tokens (aligned with room-map) ─────────────────────────────────────────── +$navy-deep: #0d1b2a; +$navy: #1a3a6b; +$navy-mid: #1f4e8c; +$navy-light: #cfe0f2; +$accent: #00b4d8; +$surface: #f4f7fb; +$border: #d1dce8; +$text-main: #0d1b2a; +$text-muted: #5a7694; +$success: #22c55e; +$success-border: #4ade80; +$success-bg: #f0fdf4; +$radius-lg: 10px; +$radius-md: 7px; +$radius-sm: 5px; - @media (max-width: 600px) { +// Breakpoints (identical to room-map) +$bp-md: 768px; +$bp-sm: 480px; + +// Transitions +$t-fast: 0.15s ease; + +// ── Page shell ──────────────────────────────────────────────────────────────── + +.page { + display: flex; + flex-direction: column; + height: calc(100vh - 64px); + box-sizing: border-box; + overflow: hidden; + background: $surface; + + // Sur mobile : on passe en scroll vertical naturel + @media (max-width: #{$bp-md}) { + height: auto; + min-height: calc(100vh - 64px); + overflow: visible; + } +} + +// ── Header ──────────────────────────────────────────────────────────────────── + +.page-header { + display: flex; + align-items: center; + gap: 14px; + padding: 0 24px; + height: 56px; + flex-shrink: 0; + background: #fff; + border-bottom: 1px solid $border; + box-shadow: 0 1px 4px rgba($navy, 0.05); + + @media (max-width: #{$bp-md}) { + height: auto; + padding: 10px 16px; + flex-wrap: wrap; + gap: 8px; + } + + @media (max-width: #{$bp-sm}) { + padding: 10px 12px; + gap: 6px; + } +} + +.back-btn { + display: flex; + align-items: center; + gap: 6px; + color: $text-muted; + text-decoration: none; + font: 500 13px/1 ui-sans-serif, system-ui, sans-serif; + transition: color 0.15s; + white-space: nowrap; + + svg { width: 14px; height: 14px; } + + &:hover { color: $navy; } +} + +.header-sep { + width: 1px; + height: 20px; + background: $border; + flex-shrink: 0; + + @media (max-width: #{$bp-sm}) { display: none; } +} + +.room-identity { + display: flex; + flex-direction: column; + gap: 1px; +} + +.room-eyebrow { + font: 600 9px/1 ui-sans-serif, system-ui, sans-serif; + letter-spacing: 0.14em; + text-transform: uppercase; + color: $accent; +} + +.room-name { + margin: 0; + font: 700 16px/1.2 ui-sans-serif, system-ui, sans-serif; + color: $navy; + white-space: nowrap; +} + +.co2-pill { + display: inline-flex; + align-items: center; + padding: 4px 12px; + border-radius: 20px; + font: 600 12px/1 ui-sans-serif, system-ui, sans-serif; + color: rgba(0, 0, 0, 0.72); + white-space: nowrap; + letter-spacing: 0.01em; + + // La pill est redondante sur mobile (visible dans panel-left) + @media (max-width: #{$bp-sm}) { display: none; } +} + +.header-spacer { + flex: 1; + + @media (max-width: #{$bp-md}) { display: none; } +} + +.last-updated { + display: flex; + align-items: center; + gap: 5px; + font: 400 12px/1 ui-sans-serif, system-ui, sans-serif; + color: $text-muted; + white-space: nowrap; + + svg { width: 12px; height: 12px; flex-shrink: 0; color: $accent; } + strong { font-weight: 600; color: $text-main; } + + @media (max-width: #{$bp-sm}) { font-size: 11px; } +} + +// ── Body ────────────────────────────────────────────────────────────────────── + +.page-body { + flex: 1; + display: flex; + min-height: 0; + overflow: hidden; + + @media (max-width: #{$bp-md}) { + flex-direction: column; + overflow: visible; + min-height: auto; + } +} + +// ── Left panel ──────────────────────────────────────────────────────────────── + +.panel-left { + width: 280px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px 16px; + border-right: 1px solid $border; + overflow-y: auto; + background: #fff; + box-sizing: border-box; + + @media (max-width: #{$bp-md}) { + width: 100%; + border-right: none; + border-bottom: 1px solid $border; + overflow-y: visible; padding: 16px; } + + @media (max-width: #{$bp-sm}) { + padding: 12px; + gap: 10px; + } } -.back-link { +// CO₂ hero card +.co2-hero { + border-radius: $radius-lg; + background: $surface; + border: 1.5px solid $border; + padding: 20px 16px 16px; + text-align: center; + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 4px; + background: var(--co2, #{$accent}); + border-radius: $radius-lg $radius-lg 0 0; + } +} + +.co2-hero-label { + display: block; + font: 600 10px/1 ui-sans-serif, system-ui, sans-serif; + letter-spacing: 0.14em; + text-transform: uppercase; + color: $text-muted; + margin-bottom: 8px; +} + +.co2-hero-value { + font-size: clamp(36px, 6vw, 52px); + font-weight: 700; + font-family: 'Courier New', monospace; + line-height: 1; + color: $navy; + letter-spacing: -1px; +} + +.co2-hero-unit { + font: 500 14px/1 ui-sans-serif, system-ui, sans-serif; + color: $text-muted; + margin-top: 4px; +} + +.co2-hero-status { + margin-top: 10px; display: inline-block; - color: #00bcd4; - text-decoration: none; - font-size: 14px; - margin-bottom: 20px; - - &:hover { - text-decoration: underline; - } + padding: 3px 12px; + border-radius: 20px; + background: var(--co2, #{$accent}); + color: rgba(0,0,0,0.72); + font: 600 11px/1.6 ui-sans-serif, system-ui, sans-serif; + letter-spacing: 0.03em; } -.room-header { - border-left: 5px solid #00bcd4; - padding-left: 16px; - margin-bottom: 20px; - - h1 { - margin: 0; - font-size: 26px; - color: #263238; - } -} - -.status-bar { - display: flex; - justify-content: space-between; - align-items: center; - border-radius: 6px; - padding: 12px 20px; - margin-bottom: 20px; - color: rgba(0, 0, 0, 0.75); - font-weight: 600; - flex-wrap: wrap; - gap: 8px; -} - -.status-label { - font-size: 16px; -} - -.status-ppm { - font-size: 20px; - - @media (max-width: 400px) { - font-size: 16px; - } -} - -.metrics-grid { +// Temp + Humidity row +.metric-row { display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 16px; - margin-bottom: 12px; - - @media (max-width: 700px) { - grid-template-columns: repeat(2, 1fr); - } - - @media (max-width: 360px) { - grid-template-columns: 1fr; - } + grid-template-columns: 1fr 1fr; + gap: 10px; } .metric-card { - background: #f5f5f5; - border-radius: 8px; - padding: 20px 16px; + background: $surface; + border: 1px solid $border; + border-radius: $radius-md; + padding: 14px 12px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; text-align: center; - border: 2px solid transparent; +} + +.metric-icon { + width: 22px; + height: 22px; + color: $accent; + margin-bottom: 2px; +} + +.metric-label { + font: 400 11px/1 ui-sans-serif, system-ui, sans-serif; + color: $text-muted; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.metric-value { + font: 700 26px/1.1 'Courier New', monospace; + color: $navy; +} + +.metric-unit { + font: 500 13px/1 ui-sans-serif, system-ui, sans-serif; + color: $text-muted; + margin-left: 2px; + font-family: ui-sans-serif, system-ui, sans-serif; +} + +// Windows card +.window-card { + background: $surface; + border: 1px solid $border; + border-radius: $radius-md; + padding: 14px 16px; + display: flex; + align-items: center; + justify-content: space-between; + transition: border-color $t-fast, background $t-fast; &.open { - border-color: #4caf50; - } - - .metric-icon { - font-size: 28px; - margin-bottom: 8px; - } - - .metric-value { - font-size: 24px; - font-weight: 700; - color: #263238; - text-transform: capitalize; - } - - .metric-unit { - font-size: 12px; - color: #78909c; - margin-top: 4px; + border-color: $success-border; + background: $success-bg; } } -.timestamp { - font-size: 12px; - color: #90a4ae; - text-align: right; - margin-bottom: 32px; +.window-card-left { + display: flex; + align-items: center; + gap: 12px; } -.no-sensor, -.not-found { - padding: 40px; - text-align: center; - color: #90a4ae; - font-style: italic; - background: #fafafa; - border-radius: 8px; - border: 1px dashed #cfd8dc; +.window-icon { + width: 24px; + height: 24px; + color: $text-muted; + flex-shrink: 0; } -.history-section { - h2 { - font-size: 16px; - color: #455a64; - margin-bottom: 12px; +.window-label { + font: 400 11px/1 ui-sans-serif, system-ui, sans-serif; + color: $text-muted; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.window-state { + font: 600 15px/1.4 ui-sans-serif, system-ui, sans-serif; + color: $navy; + text-transform: capitalize; +} + +.window-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: $border; + transition: background $t-fast; + + &.open { background: $success; } +} + +// ── Right panel ─────────────────────────────────────────────────────────────── + +.panel-right { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + padding: 20px 24px; + overflow: hidden; + gap: 14px; + + @media (max-width: #{$bp-md}) { + overflow: visible; + padding: 16px; + min-height: 420px; // garantit que la table est visible avant scroll + } + + @media (max-width: #{$bp-sm}) { + padding: 12px; + min-height: 360px; } } -.history-table-wrapper { - overflow-x: auto; - border-radius: 8px; - border: 1px solid #eceff1; +.history-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + gap: 12px; } -.history-table { - width: 100%; - border-collapse: collapse; - font-size: 13px; - - th { - background: #eceff1; - color: #546e7a; - font-weight: 600; - padding: 10px 14px; - text-align: left; - white-space: nowrap; - } - - td { - padding: 8px 14px; - border-top: 1px solid #f5f5f5; - color: #37474f; - text-transform: capitalize; - } - - tr:hover td { - background: #f9fafb; - } +.history-title { + margin: 0; + font: 700 15px/1.2 ui-sans-serif, system-ui, sans-serif; + color: $navy; } -.co2-badge { - display: inline-block; - padding: 2px 8px; - border-radius: 10px; - font-weight: 600; - color: rgba(0, 0, 0, 0.7); - font-size: 12px; +.history-sub { + font: 400 12px/1 ui-sans-serif, system-ui, sans-serif; + color: $text-muted; } +// Pagination (in header) .pagination { display: flex; align-items: center; - justify-content: center; - gap: 16px; - padding: 16px 0 24px; + gap: 8px; } .page-btn { - background: #00bcd4; - color: #fff; - border: none; - border-radius: 6px; - padding: 8px 18px; - font-size: 13px; - font-weight: 500; + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: 1px solid $border; + border-radius: $radius-sm; + background: #fff; + color: $text-muted; cursor: pointer; - transition: background 0.2s; + transition: background $t-fast, color $t-fast, border-color $t-fast; + + svg { width: 12px; height: 12px; } &:hover:not(:disabled) { - background: #0097a7; + background: $navy; + color: #fff; + border-color: $navy; } &:disabled { - background: #eceff1; - color: #b0bec5; + opacity: 0.35; cursor: default; } } .page-info { - font-size: 13px; - color: #546e7a; + font: 600 12px/1 'Courier New', monospace; + color: $text-muted; + min-width: 44px; + text-align: center; +} + +// Table +.history-table-wrapper { + flex: 1; + overflow-y: auto; + overflow-x: auto; // scroll horizontal sur petits écrans + border-radius: $radius-lg; + border: 1px solid $border; + background: #fff; + min-height: 0; + + @media (max-width: #{$bp-md}) { + flex: none; + overflow-y: visible; + max-height: none; + } +} + +.history-table { + width: 100%; + border-collapse: collapse; + font: 400 13px/1 ui-sans-serif, system-ui, sans-serif; + + thead { + position: sticky; + top: 0; + z-index: 1; + + @media (max-width: #{$bp-md}) { position: static; } + } + + th { + background: $surface; + color: $text-muted; + font: 600 11px/1 ui-sans-serif, system-ui, sans-serif; + text-transform: uppercase; + letter-spacing: 0.07em; + padding: 11px 16px; + text-align: left; + white-space: nowrap; + border-bottom: 1px solid $border; + + @media (max-width: #{$bp-sm}) { padding: 9px 10px; font-size: 10px; } + } + + td { + padding: 10px 16px; + border-top: 1px solid rgba($border, 0.6); + color: $text-main; + vertical-align: middle; + white-space: nowrap; + + @media (max-width: #{$bp-sm}) { padding: 8px 10px; font-size: 12px; } + } + + tr:hover td { + background: $surface; + } +} + +.td-time { + font: 500 13px/1 'Courier New', monospace; + color: $text-muted; +} + +.co2-badge { + display: inline-block; + padding: 3px 10px; + border-radius: 20px; + font: 600 12px/1.6 'Courier New', monospace; + color: rgba(0, 0, 0, 0.72); white-space: nowrap; } + +.win-state { + font: 500 12px/1 ui-sans-serif, system-ui, sans-serif; + color: $text-muted; + text-transform: capitalize; + + &.open { color: #16a34a; font-weight: 600; } +} + +// ── States ──────────────────────────────────────────────────────────────────── + +.no-sensor, +.no-history, +.not-found { + padding: 40px 24px; + text-align: center; + color: $text-muted; + font: 400 14px/1.5 ui-sans-serif, system-ui, sans-serif; + font-style: italic; + background: #fff; + border-radius: $radius-lg; + border: 1px dashed $border; +} + +.not-found { + margin: 40px auto; + max-width: 400px; +} diff --git a/ui/src/app/components/room-details-panel/room-details-panel.component.ts b/ui/src/app/components/room-details-panel/room-details-panel.component.ts index 3266d29..f94953c 100644 --- a/ui/src/app/components/room-details-panel/room-details-panel.component.ts +++ b/ui/src/app/components/room-details-panel/room-details-panel.component.ts @@ -1,14 +1,18 @@ import { ChangeDetectionStrategy, Component, + DestroyRef, OnInit, computed, inject, signal, } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, RouterModule } from '@angular/router'; +import { combineLatest, timer } from 'rxjs'; import { map, switchMap } from 'rxjs/operators'; +import { environment } from '../../../environments/environment'; import { RoomLayout } from '../../config/rooms-layout.config'; import { getCO2Color, getCO2Level } from '../../config/co2-levels.config'; import { SensorReading } from '../../models/sensor-reading.model'; @@ -26,17 +30,19 @@ const PAGE_SIZE = 8; changeDetection: ChangeDetectionStrategy.OnPush, }) export class RoomDetailsPanelComponent implements OnInit { - private route = inject(ActivatedRoute); - private roomService = inject(RoomService); + private route = inject(ActivatedRoute); + private roomService = inject(RoomService); private sensorService = inject(SensorService); + private destroyRef = inject(DestroyRef); - room = signal(undefined); + room = signal(undefined); latestReading = signal(undefined); - history = signal([]); + history = signal([]); + lastUpdated = signal(null); currentPage = signal(0); - totalPages = computed(() => Math.ceil(this.history().length / PAGE_SIZE)); + totalPages = computed(() => Math.ceil(this.history().length / PAGE_SIZE)); pagedHistory = computed(() => { const start = this.currentPage() * PAGE_SIZE; return this.history().slice(start, start + PAGE_SIZE); @@ -46,19 +52,21 @@ export class RoomDetailsPanelComponent implements OnInit { getCO2Level = getCO2Level; ngOnInit(): void { - const id$ = this.route.paramMap.pipe(map(params => params.get('id') ?? '')); + const interval = environment.polling.detailIntervalMs; - id$ - .pipe(switchMap(id => this.roomService.getRoomById(id))) - .subscribe(room => this.room.set(room)); - - id$ - .pipe(switchMap(id => this.sensorService.getLatestReadingForRoom(id))) - .subscribe(reading => this.latestReading.set(reading)); - - id$.pipe(switchMap(id => this.sensorService.getHistoryForRoom(id))).subscribe(history => { - this.history.set(history); - this.currentPage.set(0); + this.route.paramMap.pipe( + map(params => params.get('id') ?? ''), + switchMap(id => combineLatest([ + this.roomService.getRoomById(id), + timer(0, interval).pipe(switchMap(() => this.sensorService.getLatestReadingForRoom(id))), + timer(0, interval).pipe(switchMap(() => this.sensorService.getHistoryForRoom(id))), + ])), + takeUntilDestroyed(this.destroyRef), + ).subscribe(([room, reading, history]) => { + this.room.set(room); + this.latestReading.set(reading); + this.history.set(history ?? []); + if (reading) this.lastUpdated.set(new Date()); }); } diff --git a/ui/src/app/components/room-map/room-map.component.html b/ui/src/app/components/room-map/room-map.component.html index 5b40220..0d27692 100644 --- a/ui/src/app/components/room-map/room-map.component.html +++ b/ui/src/app/components/room-map/room-map.component.html @@ -1,92 +1,261 @@ -
-
-

Building Floor Plan

+
-
- - - - + +
- - - Corridor + +
+
+ CO₂ · Real-time +

Space A & B

+
- @for (room of rooms(); track room.layout.id) { - + + + + + Last updated {{ ts }} + + } + + +
+ + +
+ + + @if (isLoading()) { +
+
+

Chargement du plan architectural…

+
+ } + + + @if (svgError()) { +
+
+

+ Fichier introuvable.
+ Copiez plan.svg dans le dossier public/ du projet Angular. +

+
+ } + + + @if (!isLoading() && !svgError()) { +
+ + +
+ + + - - - {{ room.layout.id }} - - - @if (room.reading) { - {{ room.reading.co2 }} ppm - } @else { - No sensor - } - - @if (room.reading) { - - 🪟 {{ room.reading.windowState }} - - } - - } - + + + + + - + + + + + + + @for (room of rooms(); track room.layout.id) { + @if (getSvgCenter(room.layout.id); as pos) { + + + + + + + + + + @if (room.reading) { + + } + + + {{ room.layout.id }} + + + + @if (room.reading) { {{ room.reading.co2 }} ppm } + @else { — } + + + } + } + + +
+ } + + @if (tooltip(); as t) { -
-
{{ t.room.layout.name }}
+ + +
+ Mouse wheel · zoom + · + Drag · pan + · + Click · details +
+ +
+ +
+ + -
+ +
\ 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, + }, };