feat(ui): interactive room map, detail panel redesign and polling
- Pan/zoom SVG map with CO₂ badge overlay and tooltip (#28) - Room detail page: full-page layout, 24h history table (#28) - 30s/15s polling with last-updated timestamp (#29) - Responsive design (1100/768/480px breakpoints) (#29) - Fix memory leaks, SCSS tokens, env polling config Closes #28, #29
This commit is contained in:
@@ -1,102 +1,160 @@
|
||||
<div class="details-container">
|
||||
<a routerLink="/dashboard" class="back-link">← Back to Dashboard</a>
|
||||
<div class="page">
|
||||
|
||||
<!-- ── Header ─────────────────────────────────────────────────────────── -->
|
||||
<header class="page-header">
|
||||
<a routerLink="/dashboard" class="back-btn">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 12L6 8l4-4"/>
|
||||
</svg>
|
||||
Dashboard
|
||||
</a>
|
||||
|
||||
<div class="header-sep"></div>
|
||||
|
||||
@if (room(); as r) {
|
||||
<div class="room-identity">
|
||||
<span class="room-eyebrow">Room</span>
|
||||
<h1 class="room-name">{{ r.name }}</h1>
|
||||
</div>
|
||||
|
||||
@if (latestReading(); as reading) {
|
||||
<span
|
||||
class="co2-pill"
|
||||
[style.background-color]="getCO2Color(reading.co2)"
|
||||
>{{ getCO2Level(reading.co2).label }} · {{ reading.co2 }} ppm</span>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="header-spacer"></div>
|
||||
|
||||
@if (lastUpdated(); as lu) {
|
||||
<span class="last-updated">
|
||||
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||
<circle cx="6" cy="6" r="4.5"/><polyline points="6,3.5 6,6 7.5,7.5"/>
|
||||
</svg>
|
||||
Last updated <strong>{{ lu | date:'HH:mm:ss' }}</strong>
|
||||
</span>
|
||||
}
|
||||
</header>
|
||||
|
||||
<!-- ── Body ───────────────────────────────────────────────────────────── -->
|
||||
@if (room(); as r) {
|
||||
<div
|
||||
class="room-header"
|
||||
[style.border-left-color]="latestReading() ? getCO2Color(latestReading()!.co2) : '#e0e0e0'"
|
||||
>
|
||||
<h1>{{ r.name }}</h1>
|
||||
</div>
|
||||
<div class="page-body">
|
||||
|
||||
@if (latestReading(); as reading) {
|
||||
<div class="status-bar" [style.background-color]="getCO2Color(reading.co2)">
|
||||
<span class="status-label">{{ getCO2Level(reading.co2).label }}</span>
|
||||
<span class="status-ppm">{{ reading.co2 }} ppm CO₂</span>
|
||||
</div>
|
||||
<!-- Left panel : current metrics -->
|
||||
<aside class="panel-left">
|
||||
@if (latestReading(); as reading) {
|
||||
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">🌫️</div>
|
||||
<div class="metric-value">{{ reading.co2 }}</div>
|
||||
<div class="metric-unit">ppm CO₂</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">🌡️</div>
|
||||
<div class="metric-value">{{ reading.temperature }}</div>
|
||||
<div class="metric-unit">°C</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon">💧</div>
|
||||
<div class="metric-value">{{ reading.humidity }}</div>
|
||||
<div class="metric-unit">% Humidity</div>
|
||||
</div>
|
||||
<div class="metric-card" [class.open]="reading.windowState === 'open'">
|
||||
<div class="metric-icon">🪟</div>
|
||||
<div class="metric-value">{{ reading.windowState }}</div>
|
||||
<div class="metric-unit">Windows</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- CO₂ hero -->
|
||||
<div class="co2-hero" [style.--co2]="getCO2Color(reading.co2)">
|
||||
<span class="co2-hero-label">CO₂</span>
|
||||
<div class="co2-hero-value">{{ reading.co2 }}</div>
|
||||
<div class="co2-hero-unit">ppm</div>
|
||||
<div class="co2-hero-status">{{ getCO2Level(reading.co2).label }}</div>
|
||||
</div>
|
||||
|
||||
<div class="timestamp">
|
||||
Last updated: {{ reading.timestamp | date: 'HH:mm:ss, dd MMM yyyy' : 'UTC' }} UTC
|
||||
</div>
|
||||
} @else {
|
||||
<div class="no-sensor">No sensor installed in this room.</div>
|
||||
}
|
||||
<!-- Temp + Humidity -->
|
||||
<div class="metric-row">
|
||||
<div class="metric-card">
|
||||
<svg class="metric-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<path d="M12 2v10.5m0 0a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM8 6h1m-1 3h1m-1 3h1"/>
|
||||
</svg>
|
||||
<div class="metric-label">Temperature</div>
|
||||
<div class="metric-value">{{ reading.temperature }}<span class="metric-unit">°C</span></div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<svg class="metric-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<path d="M12 2C6 9 4 13 4 16a8 8 0 0 0 16 0c0-3-2-7-8-14z"/>
|
||||
</svg>
|
||||
<div class="metric-label">Humidity</div>
|
||||
<div class="metric-value">{{ reading.humidity }}<span class="metric-unit">%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (history().length > 0) {
|
||||
<section class="history-section">
|
||||
<h2>Last 24h readings</h2>
|
||||
<div class="history-table-wrapper">
|
||||
<table class="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time (UTC)</th>
|
||||
<th>CO₂ (ppm)</th>
|
||||
<th>Temp (°C)</th>
|
||||
<th>Humidity (%)</th>
|
||||
<th>Windows</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of pagedHistory(); track entry.timestamp) {
|
||||
<!-- Windows -->
|
||||
<div class="window-card" [class.open]="reading.windowState === 'open'">
|
||||
<div class="window-card-left">
|
||||
<svg class="window-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<line x1="12" y1="3" x2="12" y2="21"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
</svg>
|
||||
<div>
|
||||
<div class="window-label">Windows</div>
|
||||
<div class="window-state">{{ reading.windowState }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="window-dot" [class.open]="reading.windowState === 'open'"></div>
|
||||
</div>
|
||||
|
||||
} @else {
|
||||
<div class="no-sensor">No sensor installed in this room.</div>
|
||||
}
|
||||
</aside>
|
||||
|
||||
<!-- Right panel : history -->
|
||||
<main class="panel-right">
|
||||
@if (history().length > 0) {
|
||||
<div class="history-header">
|
||||
<div>
|
||||
<h2 class="history-title">24h History</h2>
|
||||
<span class="history-sub">{{ history().length }} readings</span>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<button class="page-btn" (click)="prevPage()" [disabled]="currentPage() === 0">
|
||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M9 2L5 7l4 5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="page-info">{{ currentPage() + 1 }} / {{ totalPages() }}</span>
|
||||
<button class="page-btn" (click)="nextPage()" [disabled]="currentPage() === totalPages() - 1">
|
||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M5 2l4 5-4 5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="history-table-wrapper">
|
||||
<table class="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{{ entry.timestamp | date: 'HH:mm' : 'UTC' }}</td>
|
||||
<td>
|
||||
<span class="co2-badge" [style.background-color]="getCO2Color(entry.co2)">
|
||||
{{ entry.co2 }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ entry.temperature }}</td>
|
||||
<td>{{ entry.humidity }}</td>
|
||||
<td>{{ entry.windowState }}</td>
|
||||
<th>Time</th>
|
||||
<th>CO₂</th>
|
||||
<th>Temp</th>
|
||||
<th>Humidity</th>
|
||||
<th>Windows</th>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of pagedHistory(); track entry.timestamp) {
|
||||
<tr>
|
||||
<td class="td-time">{{ entry.timestamp | date:'HH:mm' }}</td>
|
||||
<td>
|
||||
<span class="co2-badge" [style.background-color]="getCO2Color(entry.co2)">
|
||||
{{ entry.co2 }} ppm
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ entry.temperature }} °C</td>
|
||||
<td>{{ entry.humidity }} %</td>
|
||||
<td>
|
||||
<span class="win-state" [class.open]="entry.windowState === 'open'">
|
||||
{{ entry.windowState }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="no-history">No history available.</div>
|
||||
}
|
||||
</main>
|
||||
|
||||
<div class="pagination">
|
||||
<button class="page-btn" (click)="prevPage()" [disabled]="currentPage() === 0">
|
||||
← Prev
|
||||
</button>
|
||||
<span class="page-info">
|
||||
Page {{ currentPage() + 1 }} / {{ totalPages() }}
|
||||
·
|
||||
{{ history().length }} entries
|
||||
</span>
|
||||
<button
|
||||
class="page-btn"
|
||||
(click)="nextPage()"
|
||||
[disabled]="currentPage() === totalPages() - 1"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="not-found">Room not found.</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<RoomLayout | undefined>(undefined);
|
||||
room = signal<RoomLayout | undefined>(undefined);
|
||||
latestReading = signal<SensorReading | undefined>(undefined);
|
||||
history = signal<SensorReading[]>([]);
|
||||
history = signal<SensorReading[]>([]);
|
||||
lastUpdated = signal<Date | null>(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());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,92 +1,261 @@
|
||||
<div class="dashboard-layout">
|
||||
<div class="map-wrapper">
|
||||
<h2 class="map-title">Building Floor Plan</h2>
|
||||
<div class="layout">
|
||||
|
||||
<div class="svg-container">
|
||||
<svg viewBox="0 0 1230 760" xmlns="http://www.w3.org/2000/svg" (mouseleave)="hideTooltip()">
|
||||
<!-- Section labels -->
|
||||
<text x="160" y="48" class="section-label">Espace B</text>
|
||||
<text x="690" y="48" class="section-label">Espace A</text>
|
||||
<!-- ══════════════════════════════════════════════
|
||||
Zone carte principale
|
||||
═══════════════════════════════════════════════════ -->
|
||||
<div class="map-area">
|
||||
|
||||
<!-- Corridor separator -->
|
||||
<rect x="290" y="60" width="100" height="680" class="corridor" />
|
||||
<text x="340" y="408" class="corridor-label">Corridor</text>
|
||||
<!-- En-tête + contrôles zoom -->
|
||||
<div class="map-header">
|
||||
<div class="title-group">
|
||||
<span class="title-eyebrow">CO₂ · Real-time</span>
|
||||
<h2 class="title">Space A & B</h2>
|
||||
</div>
|
||||
|
||||
@for (room of rooms(); track room.layout.id) {
|
||||
<g
|
||||
class="room-group"
|
||||
[class.no-sensor]="!room.layout.hasSensor"
|
||||
(click)="navigateToRoom(room.layout.id)"
|
||||
(mouseenter)="showTooltip($event, room)"
|
||||
(mousemove)="showTooltip($event, room)"
|
||||
(mouseleave)="hideTooltip()"
|
||||
@if (lastUpdatedStr(); as ts) {
|
||||
<span class="last-updated-badge">
|
||||
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||
<circle cx="6" cy="6" r="4.5"/>
|
||||
<polyline points="6,3.5 6,6 7.5,7.5"/>
|
||||
</svg>
|
||||
Last updated <strong>{{ ts }}</strong>
|
||||
</span>
|
||||
}
|
||||
|
||||
<nav class="controls" aria-label="Contrôles de navigation du plan">
|
||||
<button class="ctrl ctrl-zoom" (click)="zoomOut()" title="Zoom arrière (−)" aria-label="Zoom arrière">
|
||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="2" y1="7" x2="12" y2="7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<span class="scale-badge">{{ scalePct() }} %</span>
|
||||
|
||||
<button class="ctrl ctrl-zoom" (click)="zoomIn()" title="Zoom avant (+)" aria-label="Zoom avant">
|
||||
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="7" y1="2" x2="7" y2="12"/>
|
||||
<line x1="2" y1="7" x2="12" y2="7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="ctrl-divider"></div>
|
||||
|
||||
<button class="ctrl ctrl-action" (click)="fitToView()" title="Ajuster à la fenêtre" aria-label="Ajuster à la fenêtre">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
|
||||
<path d="M1 5.5V2H4.5M15 5.5V2H11.5M1 10.5V14H4.5M15 10.5V14H11.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="ctrl ctrl-action" (click)="reset()" title="Vue 1:1" aria-label="Réinitialiser le zoom">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 3A7 7 0 1 0 14.5 8"/>
|
||||
<path d="M14.5 3v3h-3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- ───── Viewport ───── -->
|
||||
<div
|
||||
class="viewport"
|
||||
#viewport
|
||||
[class.panning]="isPanning()"
|
||||
(pointerdown)="onPointerDown($event)"
|
||||
(pointermove)="onPointerMove($event)"
|
||||
(pointerup)="onPointerUp()"
|
||||
(pointercancel)="onPointerUp()"
|
||||
(pointerleave)="onPointerUp(); hideTooltip()"
|
||||
>
|
||||
|
||||
<!-- Chargement -->
|
||||
@if (isLoading()) {
|
||||
<div class="state-overlay">
|
||||
<div class="spinner"></div>
|
||||
<p>Chargement du plan architectural…</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Erreur : SVG manquant -->
|
||||
@if (svgError()) {
|
||||
<div class="state-overlay state-error">
|
||||
<div class="error-icon">⚠</div>
|
||||
<p>
|
||||
Fichier introuvable.<br>
|
||||
Copiez <code>plan.svg</code> dans le dossier <code>public/</code> du projet Angular.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Canvas transformable (pan + zoom via CSS transform) -->
|
||||
@if (!isLoading() && !svgError()) {
|
||||
<div
|
||||
class="canvas"
|
||||
[style.transform]="transform()"
|
||||
[style.width.px]="CANVAS_W"
|
||||
[style.height.px]="CANVAS_H"
|
||||
>
|
||||
|
||||
<!-- ① Plan SVG original — rendu fidèle via innerHTML -->
|
||||
<div class="plan-host" [innerHTML]="svgHtml()"></div>
|
||||
|
||||
<!-- ② Overlay CO₂ — même ViewBox que le plan source -->
|
||||
<svg
|
||||
class="co2-overlay"
|
||||
[attr.viewBox]="'0 0 ' + SVG_W + ' ' + SVG_H"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
role="group"
|
||||
aria-label="CO₂ badges par salle"
|
||||
>
|
||||
<rect
|
||||
[attr.x]="room.layout.x"
|
||||
[attr.y]="room.layout.y"
|
||||
[attr.width]="room.layout.width"
|
||||
[attr.height]="room.layout.height"
|
||||
[attr.fill]="room.color"
|
||||
class="room-rect"
|
||||
rx="6"
|
||||
/>
|
||||
<text
|
||||
[attr.x]="room.layout.x + room.layout.width / 2"
|
||||
[attr.y]="room.layout.y + room.layout.height / 2 - 10"
|
||||
class="room-id"
|
||||
>
|
||||
{{ room.layout.id }}
|
||||
</text>
|
||||
<text
|
||||
[attr.x]="room.layout.x + room.layout.width / 2"
|
||||
[attr.y]="room.layout.y + room.layout.height / 2 + 12"
|
||||
class="room-co2"
|
||||
>
|
||||
@if (room.reading) {
|
||||
{{ room.reading.co2 }} ppm
|
||||
} @else {
|
||||
No sensor
|
||||
}
|
||||
</text>
|
||||
@if (room.reading) {
|
||||
<text
|
||||
[attr.x]="room.layout.x + room.layout.width / 2"
|
||||
[attr.y]="room.layout.y + room.layout.height / 2 + 30"
|
||||
class="room-window"
|
||||
>
|
||||
🪟 {{ room.reading.windowState }}
|
||||
</text>
|
||||
}
|
||||
</g>
|
||||
}
|
||||
</svg>
|
||||
<defs>
|
||||
<!-- Ombre portée sous chaque badge -->
|
||||
<filter id="f-shadow" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feDropShadow dx="0" dy="300" stdDeviation="600"
|
||||
flood-color="#0d1b2a" flood-opacity="0.35"/>
|
||||
</filter>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<!-- Halo de survol -->
|
||||
<filter id="f-hover" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="800"
|
||||
flood-color="#ffffff" flood-opacity="0.6"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
@for (room of rooms(); track room.layout.id) {
|
||||
@if (getSvgCenter(room.layout.id); as pos) {
|
||||
<!--
|
||||
Badge centré sur pos.cx / pos.cy.
|
||||
Toutes les coordonnées internes sont relatives au centre (transform translate).
|
||||
Dimensions badge : 16 000 × 10 000 unités SVG
|
||||
→ à l'échelle fit-to-view (~0.27×) : ≈ 43 × 27 px à l'écran.
|
||||
-->
|
||||
<g
|
||||
class="badge"
|
||||
[class.no-sensor]="!room.layout.hasSensor"
|
||||
[attr.transform]="'translate(' + pos.cx + ' ' + pos.cy + ')'"
|
||||
role="button"
|
||||
[attr.aria-label]="room.layout.name + ' — CO2 : ' + (room.reading?.co2 ?? 'N/A') + ' ppm'"
|
||||
(click)="navigateToRoom(room.layout.id)"
|
||||
(mouseenter)="showTooltip($event, room)"
|
||||
(mousemove)="showTooltip($event, room)"
|
||||
(mouseleave)="hideTooltip()"
|
||||
>
|
||||
<!-- Fond du badge -->
|
||||
<rect
|
||||
x="-8000" y="-5200"
|
||||
width="16000" height="10400"
|
||||
rx="2400" ry="2400"
|
||||
[attr.fill]="room.color"
|
||||
fill-opacity="0.93"
|
||||
filter="url(#f-shadow)"
|
||||
class="badge-bg"
|
||||
/>
|
||||
|
||||
<!-- Liseré blanc séparateur (ID / valeur) -->
|
||||
<line
|
||||
x1="-7000" y1="600" x2="7000" y2="600"
|
||||
stroke="white" stroke-opacity="0.30" stroke-width="180"
|
||||
/>
|
||||
|
||||
<!-- Indicateur de statut (petit cercle coin haut-droit) -->
|
||||
@if (room.reading) {
|
||||
<circle
|
||||
cx="5800" cy="-4000"
|
||||
r="900"
|
||||
fill="white"
|
||||
fill-opacity="0.85"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- ID de la salle -->
|
||||
<text
|
||||
x="0" y="-2000"
|
||||
font-size="4200"
|
||||
font-weight="700"
|
||||
font-family="'Courier New', 'Lucida Console', monospace"
|
||||
fill="white"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
letter-spacing="200"
|
||||
>{{ room.layout.id }}</text>
|
||||
|
||||
<!-- Valeur CO₂ (ou tiret si pas de capteur) -->
|
||||
<text
|
||||
x="0" y="2800"
|
||||
font-size="3200"
|
||||
font-weight="500"
|
||||
font-family="'Courier New', 'Lucida Console', monospace"
|
||||
fill="white"
|
||||
fill-opacity="0.92"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
letter-spacing="100"
|
||||
>
|
||||
@if (room.reading) { {{ room.reading.co2 }} ppm }
|
||||
@else { — }
|
||||
</text>
|
||||
</g>
|
||||
}
|
||||
}
|
||||
</svg>
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ───── Tooltip ───── -->
|
||||
@if (tooltip(); as t) {
|
||||
<div class="tooltip" [style.left.px]="t.x" [style.top.px]="t.y">
|
||||
<div class="tooltip-title">{{ t.room.layout.name }}</div>
|
||||
<div
|
||||
class="tooltip"
|
||||
[style.left.px]="t.x"
|
||||
[style.top.px]="t.y"
|
||||
role="tooltip"
|
||||
>
|
||||
<div class="tt-name">{{ t.room.layout.name }}</div>
|
||||
<!-- <div class="tt-type">{{ t.room.layout.type }} · cap. {{ t.room.layout.capacity ?? '—' }}</div> -->
|
||||
<div class="tt-divider"></div>
|
||||
@if (t.room.reading) {
|
||||
<div class="tooltip-row">
|
||||
<span>CO₂</span><strong>{{ t.room.reading.co2 }} ppm</strong>
|
||||
<div class="tt-row">
|
||||
<span>CO₂</span>
|
||||
<strong [style.color]="t.room.color">{{ t.room.reading.co2 }} ppm</strong>
|
||||
</div>
|
||||
<div class="tooltip-row">
|
||||
<span>Temp.</span><strong>{{ t.room.reading.temperature }} °C</strong>
|
||||
</div>
|
||||
<div class="tooltip-row">
|
||||
<span>Humidity</span><strong>{{ t.room.reading.humidity }} %</strong>
|
||||
</div>
|
||||
<div class="tooltip-row">
|
||||
<span>Windows</span><strong>{{ t.room.reading.windowState }}</strong>
|
||||
<div class="tt-row"><span>Temperature</span><strong>{{ t.room.reading.temperature }} °C</strong></div>
|
||||
<div class="tt-row"><span>Humidity</span> <strong>{{ t.room.reading.humidity }} %</strong></div>
|
||||
<div class="tt-row">
|
||||
<span>Windows</span>
|
||||
<strong [class.open]="t.room.reading.windowState === 'open'">
|
||||
{{ t.room.reading.windowState === 'open' ? '⊙ Opened' : '◎ Closed' }}
|
||||
</strong>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tooltip-no-sensor">No sensor installed</div>
|
||||
<div class="tt-empty">No sensor installed</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hint discret en bas -->
|
||||
<div class="viewport-hint">
|
||||
<span>Mouse wheel · zoom</span>
|
||||
<span class="hint-sep">·</span>
|
||||
<span>Drag · pan</span>
|
||||
<span class="hint-sep">·</span>
|
||||
<span>Click · details</span>
|
||||
</div>
|
||||
|
||||
</div><!-- /viewport -->
|
||||
|
||||
</div><!-- /map-area -->
|
||||
|
||||
<!-- ══════════════════════════════════════════════
|
||||
Légende (sidebar droite)
|
||||
═══════════════════════════════════════════════════ -->
|
||||
<aside class="sidebar">
|
||||
<app-legend />
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-eyebrow">CO₂ Levels</span>
|
||||
<h3 class="sidebar-title">Legend</h3>
|
||||
</div>
|
||||
<div class="sidebar-body">
|
||||
<app-legend />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
</div><!-- /layout -->
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<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,
|
||||
@@ -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<RoomMapEntry[]>([]);
|
||||
tooltip = signal<{ room: RoomMapEntry; x: number; y: number } | null>(null);
|
||||
@ViewChild('viewport') viewportRef!: ElementRef<HTMLDivElement>;
|
||||
|
||||
readonly NO_SENSOR_COLOR = '#e0e0e0';
|
||||
// ── 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 {
|
||||
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(/(<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);
|
||||
},
|
||||
});
|
||||
|
||||
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]); }
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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<SensorReading[]> {
|
||||
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<SensorReading[]>(`${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<SensorReading | undefined> {
|
||||
const fallback = MOCK_HISTORY.find(r => r.roomId === roomId);
|
||||
|
||||
return this.http
|
||||
.get<SensorReading>(`${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<SensorReading[]> {
|
||||
const fallback = MOCK_HISTORY.filter(r => r.roomId === roomId);
|
||||
|
||||
return this.http
|
||||
.get<SensorReading[]>(`${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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,8 @@ export const environment = {
|
||||
wsUrl: 'wss://ui.e.kb28.ch/ws',
|
||||
influxUrl: '',
|
||||
logLevel: 'error',
|
||||
polling: {
|
||||
mapIntervalMs: 30_000,
|
||||
detailIntervalMs: 15_000,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,4 +8,8 @@ export const environment = {
|
||||
wsUrl: 'wss://YOUR_DOMAIN/ws',
|
||||
influxUrl: '',
|
||||
logLevel: 'error',
|
||||
polling: {
|
||||
mapIntervalMs: 30_000,
|
||||
detailIntervalMs: 15_000,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,4 +4,8 @@ export const environment = {
|
||||
wsUrl: 'ws://localhost:8080/ws',
|
||||
influxUrl: '',
|
||||
logLevel: 'debug',
|
||||
polling: {
|
||||
mapIntervalMs: 30_000,
|
||||
detailIntervalMs: 15_000,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user