style(ui): apply Prettier formatting

Closes #30

Assisted-by: Claude:claude-sonnet-4-6
This commit is contained in:
khalil-bot
2026-05-21 09:18:27 +02:00
parent a465cba225
commit acea4de599
14 changed files with 831 additions and 369 deletions

View File

@@ -1,10 +1,16 @@
<div class="page"> <div class="page">
<!-- ── Header ─────────────────────────────────────────────────────────── --> <!-- ── Header ─────────────────────────────────────────────────────────── -->
<header class="page-header"> <header class="page-header">
<a routerLink="/dashboard" class="back-btn"> <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"> <svg
<path d="M10 12L6 8l4-4"/> 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> </svg>
Dashboard Dashboard
</a> </a>
@@ -18,10 +24,9 @@
</div> </div>
@if (latestReading(); as reading) { @if (latestReading(); as reading) {
<span <span class="co2-pill" [style.background-color]="getCO2Color(reading.co2)"
class="co2-pill" >{{ getCO2Level(reading.co2).label }}&nbsp;·&nbsp;{{ reading.co2 }}&thinsp;ppm</span
[style.background-color]="getCO2Color(reading.co2)" >
>{{ getCO2Level(reading.co2).label }}&nbsp;·&nbsp;{{ reading.co2 }}&thinsp;ppm</span>
} }
} }
@@ -29,10 +34,17 @@
@if (lastUpdated(); as lu) { @if (lastUpdated(); as lu) {
<span class="last-updated"> <span class="last-updated">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"> <svg
<circle cx="6" cy="6" r="4.5"/><polyline points="6,3.5 6,6 7.5,7.5"/> 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> </svg>
Last updated&nbsp;<strong>{{ lu | date:'HH:mm:ss' }}</strong> Last updated&nbsp;<strong>{{ lu | date: 'HH:mm:ss' }}</strong>
</span> </span>
} }
</header> </header>
@@ -40,11 +52,9 @@
<!-- ── Body ───────────────────────────────────────────────────────────── --> <!-- ── Body ───────────────────────────────────────────────────────────── -->
@if (room(); as r) { @if (room(); as r) {
<div class="page-body"> <div class="page-body">
<!-- Left panel : current metrics --> <!-- Left panel : current metrics -->
<aside class="panel-left"> <aside class="panel-left">
@if (latestReading(); as reading) { @if (latestReading(); as reading) {
<!-- CO₂ hero --> <!-- CO₂ hero -->
<div class="co2-hero" [style.--co2]="getCO2Color(reading.co2)"> <div class="co2-hero" [style.--co2]="getCO2Color(reading.co2)">
<span class="co2-hero-label">CO₂</span> <span class="co2-hero-label">CO₂</span>
@@ -56,28 +66,50 @@
<!-- Temp + Humidity --> <!-- Temp + Humidity -->
<div class="metric-row"> <div class="metric-row">
<div class="metric-card"> <div class="metric-card">
<svg class="metric-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"> <svg
<path d="M12 2v10.5m0 0a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM8 6h1m-1 3h1m-1 3h1"/> 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> </svg>
<div class="metric-label">Temperature</div> <div class="metric-label">Temperature</div>
<div class="metric-value">{{ reading.temperature }}<span class="metric-unit">°C</span></div> <div class="metric-value">
{{ reading.temperature }}<span class="metric-unit">°C</span>
</div>
</div> </div>
<div class="metric-card"> <div class="metric-card">
<svg class="metric-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"> <svg
<path d="M12 2C6 9 4 13 4 16a8 8 0 0 0 16 0c0-3-2-7-8-14z"/> 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> </svg>
<div class="metric-label">Humidity</div> <div class="metric-label">Humidity</div>
<div class="metric-value">{{ reading.humidity }}<span class="metric-unit">%</span></div> <div class="metric-value">
{{ reading.humidity }}<span class="metric-unit">%</span>
</div>
</div> </div>
</div> </div>
<!-- Windows --> <!-- Windows -->
<div class="window-card" [class.open]="reading.windowState === 'open'"> <div class="window-card" [class.open]="reading.windowState === 'open'">
<div class="window-card-left"> <div class="window-card-left">
<svg class="window-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"> <svg
<rect x="3" y="3" width="18" height="18" rx="2"/> class="window-icon"
<line x1="12" y1="3" x2="12" y2="21"/> viewBox="0 0 24 24"
<line x1="3" y1="12" x2="21" y2="12"/> 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> </svg>
<div> <div>
<div class="window-label">Windows</div> <div class="window-label">Windows</div>
@@ -86,7 +118,6 @@
</div> </div>
<div class="window-dot" [class.open]="reading.windowState === 'open'"></div> <div class="window-dot" [class.open]="reading.windowState === 'open'"></div>
</div> </div>
} @else { } @else {
<div class="no-sensor">No data available currently.</div> <div class="no-sensor">No data available currently.</div>
} }
@@ -102,14 +133,30 @@
</div> </div>
<div class="pagination"> <div class="pagination">
<button class="page-btn" (click)="prevPage()" [disabled]="currentPage() === 0"> <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"> <svg
<path d="M9 2L5 7l4 5"/> viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
>
<path d="M9 2L5 7l4 5" />
</svg> </svg>
</button> </button>
<span class="page-info">{{ currentPage() + 1 }} / {{ totalPages() }}</span> <span class="page-info">{{ currentPage() + 1 }} / {{ totalPages() }}</span>
<button class="page-btn" (click)="nextPage()" [disabled]="currentPage() === totalPages() - 1"> <button
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> class="page-btn"
<path d="M5 2l4 5-4 5"/> (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> </svg>
</button> </button>
</div> </div>
@@ -129,7 +176,7 @@
<tbody> <tbody>
@for (entry of pagedHistory(); track entry.timestamp) { @for (entry of pagedHistory(); track entry.timestamp) {
<tr> <tr>
<td class="td-time">{{ entry.timestamp | date:'HH:mm' }}</td> <td class="td-time">{{ entry.timestamp | date: 'HH:mm' }}</td>
<td> <td>
<span class="co2-badge" [style.background-color]="getCO2Color(entry.co2)"> <span class="co2-badge" [style.background-color]="getCO2Color(entry.co2)">
{{ entry.co2 }} ppm {{ entry.co2 }} ppm
@@ -151,10 +198,8 @@
<div class="no-history">No history available.</div> <div class="no-history">No history available.</div>
} }
</main> </main>
</div> </div>
} @else { } @else {
<div class="not-found">Room not found.</div> <div class="not-found">Room not found.</div>
} }
</div> </div>

View File

@@ -72,13 +72,21 @@ $t-fast: 0.15s ease;
gap: 6px; gap: 6px;
color: $text-muted; color: $text-muted;
text-decoration: none; text-decoration: none;
font: 500 13px/1 ui-sans-serif, system-ui, sans-serif; font:
500 13px/1 ui-sans-serif,
system-ui,
sans-serif;
transition: color 0.15s; transition: color 0.15s;
white-space: nowrap; white-space: nowrap;
svg { width: 14px; height: 14px; } svg {
width: 14px;
height: 14px;
}
&:hover { color: $navy; } &:hover {
color: $navy;
}
} }
.header-sep { .header-sep {
@@ -87,7 +95,9 @@ $t-fast: 0.15s ease;
background: $border; background: $border;
flex-shrink: 0; flex-shrink: 0;
@media (max-width: #{$bp-sm}) { display: none; } @media (max-width: #{$bp-sm}) {
display: none;
}
} }
.room-identity { .room-identity {
@@ -97,7 +107,10 @@ $t-fast: 0.15s ease;
} }
.room-eyebrow { .room-eyebrow {
font: 600 9px/1 ui-sans-serif, system-ui, sans-serif; font:
600 9px/1 ui-sans-serif,
system-ui,
sans-serif;
letter-spacing: 0.14em; letter-spacing: 0.14em;
text-transform: uppercase; text-transform: uppercase;
color: $accent; color: $accent;
@@ -105,7 +118,10 @@ $t-fast: 0.15s ease;
.room-name { .room-name {
margin: 0; margin: 0;
font: 700 16px/1.2 ui-sans-serif, system-ui, sans-serif; font:
700 16px/1.2 ui-sans-serif,
system-ui,
sans-serif;
color: $navy; color: $navy;
white-space: nowrap; white-space: nowrap;
} }
@@ -115,33 +131,53 @@ $t-fast: 0.15s ease;
align-items: center; align-items: center;
padding: 4px 12px; padding: 4px 12px;
border-radius: 20px; border-radius: 20px;
font: 600 12px/1 ui-sans-serif, system-ui, sans-serif; font:
600 12px/1 ui-sans-serif,
system-ui,
sans-serif;
color: rgba(0, 0, 0, 0.72); color: rgba(0, 0, 0, 0.72);
white-space: nowrap; white-space: nowrap;
letter-spacing: 0.01em; letter-spacing: 0.01em;
// La pill est redondante sur mobile (visible dans panel-left) // La pill est redondante sur mobile (visible dans panel-left)
@media (max-width: #{$bp-sm}) { display: none; } @media (max-width: #{$bp-sm}) {
display: none;
}
} }
.header-spacer { .header-spacer {
flex: 1; flex: 1;
@media (max-width: #{$bp-md}) { display: none; } @media (max-width: #{$bp-md}) {
display: none;
}
} }
.last-updated { .last-updated {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
font: 400 12px/1 ui-sans-serif, system-ui, sans-serif; font:
400 12px/1 ui-sans-serif,
system-ui,
sans-serif;
color: $text-muted; color: $text-muted;
white-space: nowrap; white-space: nowrap;
svg { width: 12px; height: 12px; flex-shrink: 0; color: $accent; } svg {
strong { font-weight: 600; color: $text-main; } width: 12px;
height: 12px;
flex-shrink: 0;
color: $accent;
}
strong {
font-weight: 600;
color: $text-main;
}
@media (max-width: #{$bp-sm}) { font-size: 11px; } @media (max-width: #{$bp-sm}) {
font-size: 11px;
}
} }
// ── Body ────────────────────────────────────────────────────────────────────── // ── Body ──────────────────────────────────────────────────────────────────────
@@ -200,7 +236,9 @@ $t-fast: 0.15s ease;
&::before { &::before {
content: ''; content: '';
position: absolute; position: absolute;
top: 0; left: 0; right: 0; top: 0;
left: 0;
right: 0;
height: 4px; height: 4px;
background: var(--co2, #{$accent}); background: var(--co2, #{$accent});
border-radius: $radius-lg $radius-lg 0 0; border-radius: $radius-lg $radius-lg 0 0;
@@ -209,7 +247,10 @@ $t-fast: 0.15s ease;
.co2-hero-label { .co2-hero-label {
display: block; display: block;
font: 600 10px/1 ui-sans-serif, system-ui, sans-serif; font:
600 10px/1 ui-sans-serif,
system-ui,
sans-serif;
letter-spacing: 0.14em; letter-spacing: 0.14em;
text-transform: uppercase; text-transform: uppercase;
color: $text-muted; color: $text-muted;
@@ -226,7 +267,10 @@ $t-fast: 0.15s ease;
} }
.co2-hero-unit { .co2-hero-unit {
font: 500 14px/1 ui-sans-serif, system-ui, sans-serif; font:
500 14px/1 ui-sans-serif,
system-ui,
sans-serif;
color: $text-muted; color: $text-muted;
margin-top: 4px; margin-top: 4px;
} }
@@ -237,8 +281,11 @@ $t-fast: 0.15s ease;
padding: 3px 12px; padding: 3px 12px;
border-radius: 20px; border-radius: 20px;
background: var(--co2, #{$accent}); background: var(--co2, #{$accent});
color: rgba(0,0,0,0.72); color: rgba(0, 0, 0, 0.72);
font: 600 11px/1.6 ui-sans-serif, system-ui, sans-serif; font:
600 11px/1.6 ui-sans-serif,
system-ui,
sans-serif;
letter-spacing: 0.03em; letter-spacing: 0.03em;
} }
@@ -269,19 +316,27 @@ $t-fast: 0.15s ease;
} }
.metric-label { .metric-label {
font: 400 11px/1 ui-sans-serif, system-ui, sans-serif; font:
400 11px/1 ui-sans-serif,
system-ui,
sans-serif;
color: $text-muted; color: $text-muted;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.metric-value { .metric-value {
font: 700 26px/1.1 'Courier New', monospace; font:
700 26px/1.1 'Courier New',
monospace;
color: $navy; color: $navy;
} }
.metric-unit { .metric-unit {
font: 500 13px/1 ui-sans-serif, system-ui, sans-serif; font:
500 13px/1 ui-sans-serif,
system-ui,
sans-serif;
color: $text-muted; color: $text-muted;
margin-left: 2px; margin-left: 2px;
font-family: ui-sans-serif, system-ui, sans-serif; font-family: ui-sans-serif, system-ui, sans-serif;
@@ -296,7 +351,9 @@ $t-fast: 0.15s ease;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
transition: border-color $t-fast, background $t-fast; transition:
border-color $t-fast,
background $t-fast;
&.open { &.open {
border-color: $success-border; border-color: $success-border;
@@ -318,14 +375,20 @@ $t-fast: 0.15s ease;
} }
.window-label { .window-label {
font: 400 11px/1 ui-sans-serif, system-ui, sans-serif; font:
400 11px/1 ui-sans-serif,
system-ui,
sans-serif;
color: $text-muted; color: $text-muted;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.window-state { .window-state {
font: 600 15px/1.4 ui-sans-serif, system-ui, sans-serif; font:
600 15px/1.4 ui-sans-serif,
system-ui,
sans-serif;
color: $navy; color: $navy;
text-transform: capitalize; text-transform: capitalize;
} }
@@ -337,7 +400,9 @@ $t-fast: 0.15s ease;
background: $border; background: $border;
transition: background $t-fast; transition: background $t-fast;
&.open { background: $success; } &.open {
background: $success;
}
} }
// ── Right panel ─────────────────────────────────────────────────────────────── // ── Right panel ───────────────────────────────────────────────────────────────
@@ -373,12 +438,18 @@ $t-fast: 0.15s ease;
.history-title { .history-title {
margin: 0; margin: 0;
font: 700 15px/1.2 ui-sans-serif, system-ui, sans-serif; font:
700 15px/1.2 ui-sans-serif,
system-ui,
sans-serif;
color: $navy; color: $navy;
} }
.history-sub { .history-sub {
font: 400 12px/1 ui-sans-serif, system-ui, sans-serif; font:
400 12px/1 ui-sans-serif,
system-ui,
sans-serif;
color: $text-muted; color: $text-muted;
} }
@@ -400,9 +471,15 @@ $t-fast: 0.15s ease;
background: #fff; background: #fff;
color: $text-muted; color: $text-muted;
cursor: pointer; cursor: pointer;
transition: background $t-fast, color $t-fast, border-color $t-fast; transition:
background $t-fast,
color $t-fast,
border-color $t-fast;
svg { width: 12px; height: 12px; } svg {
width: 12px;
height: 12px;
}
&:hover:not(:disabled) { &:hover:not(:disabled) {
background: $navy; background: $navy;
@@ -417,7 +494,9 @@ $t-fast: 0.15s ease;
} }
.page-info { .page-info {
font: 600 12px/1 'Courier New', monospace; font:
600 12px/1 'Courier New',
monospace;
color: $text-muted; color: $text-muted;
min-width: 44px; min-width: 44px;
text-align: center; text-align: center;
@@ -443,20 +522,28 @@ $t-fast: 0.15s ease;
.history-table { .history-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font: 400 13px/1 ui-sans-serif, system-ui, sans-serif; font:
400 13px/1 ui-sans-serif,
system-ui,
sans-serif;
thead { thead {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1; z-index: 1;
@media (max-width: #{$bp-md}) { position: static; } @media (max-width: #{$bp-md}) {
position: static;
}
} }
th { th {
background: $surface; background: $surface;
color: $text-muted; color: $text-muted;
font: 600 11px/1 ui-sans-serif, system-ui, sans-serif; font:
600 11px/1 ui-sans-serif,
system-ui,
sans-serif;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.07em; letter-spacing: 0.07em;
padding: 11px 16px; padding: 11px 16px;
@@ -464,7 +551,10 @@ $t-fast: 0.15s ease;
white-space: nowrap; white-space: nowrap;
border-bottom: 1px solid $border; border-bottom: 1px solid $border;
@media (max-width: #{$bp-sm}) { padding: 9px 10px; font-size: 10px; } @media (max-width: #{$bp-sm}) {
padding: 9px 10px;
font-size: 10px;
}
} }
td { td {
@@ -474,7 +564,10 @@ $t-fast: 0.15s ease;
vertical-align: middle; vertical-align: middle;
white-space: nowrap; white-space: nowrap;
@media (max-width: #{$bp-sm}) { padding: 8px 10px; font-size: 12px; } @media (max-width: #{$bp-sm}) {
padding: 8px 10px;
font-size: 12px;
}
} }
tr:hover td { tr:hover td {
@@ -483,7 +576,9 @@ $t-fast: 0.15s ease;
} }
.td-time { .td-time {
font: 500 13px/1 'Courier New', monospace; font:
500 13px/1 'Courier New',
monospace;
color: $text-muted; color: $text-muted;
} }
@@ -491,17 +586,25 @@ $t-fast: 0.15s ease;
display: inline-block; display: inline-block;
padding: 3px 10px; padding: 3px 10px;
border-radius: 20px; border-radius: 20px;
font: 600 12px/1.6 'Courier New', monospace; font:
600 12px/1.6 'Courier New',
monospace;
color: rgba(0, 0, 0, 0.72); color: rgba(0, 0, 0, 0.72);
white-space: nowrap; white-space: nowrap;
} }
.win-state { .win-state {
font: 500 12px/1 ui-sans-serif, system-ui, sans-serif; font:
500 12px/1 ui-sans-serif,
system-ui,
sans-serif;
color: $text-muted; color: $text-muted;
text-transform: capitalize; text-transform: capitalize;
&.open { color: #16a34a; font-weight: 600; } &.open {
color: #16a34a;
font-weight: 600;
}
} }
// ── States ──────────────────────────────────────────────────────────────────── // ── States ────────────────────────────────────────────────────────────────────
@@ -512,7 +615,10 @@ $t-fast: 0.15s ease;
padding: 40px 24px; padding: 40px 24px;
text-align: center; text-align: center;
color: $text-muted; color: $text-muted;
font: 400 14px/1.5 ui-sans-serif, system-ui, sans-serif; font:
400 14px/1.5 ui-sans-serif,
system-ui,
sans-serif;
font-style: italic; font-style: italic;
background: #fff; background: #fff;
border-radius: $radius-lg; border-radius: $radius-lg;

View File

@@ -54,21 +54,29 @@ export class RoomDetailsPanelComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
const interval = environment.polling.detailIntervalMs; const interval = environment.polling.detailIntervalMs;
this.route.paramMap.pipe( this.route.paramMap
.pipe(
map(params => params.get('id') ?? ''), map(params => params.get('id') ?? ''),
switchMap(id => combineLatest([ switchMap(id =>
combineLatest([
this.roomService.getRoomById(id), this.roomService.getRoomById(id),
concat( concat(
this.sensorService.getLatestReadingForRoom(id), this.sensorService.getLatestReadingForRoom(id),
timer(interval, interval).pipe(switchMap(() => this.sensorService.getLatestReadingForRoom(id))), timer(interval, interval).pipe(
switchMap(() => this.sensorService.getLatestReadingForRoom(id))
)
), ),
concat( concat(
this.sensorService.getHistoryForRoom(id), this.sensorService.getHistoryForRoom(id),
timer(interval, interval).pipe(switchMap(() => this.sensorService.getHistoryForRoom(id))), timer(interval, interval).pipe(
switchMap(() => this.sensorService.getHistoryForRoom(id))
)
), ),
])), ])
takeUntilDestroyed(this.destroyRef), ),
).subscribe(([room, reading, history]) => { takeUntilDestroyed(this.destroyRef)
)
.subscribe(([room, reading, history]) => {
this.room.set(room); this.room.set(room);
this.latestReading.set(reading); this.latestReading.set(reading);
this.history.set(history ?? []); this.history.set(history ?? []);

View File

@@ -1,10 +1,8 @@
<div class="layout"> <div class="layout">
<!-- ══════════════════════════════════════════════ <!-- ══════════════════════════════════════════════
Zone carte principale Zone carte principale
═══════════════════════════════════════════════════ --> ═══════════════════════════════════════════════════ -->
<div class="map-area"> <div class="map-area">
<!-- En-tête + contrôles zoom --> <!-- En-tête + contrôles zoom -->
<div class="map-header"> <div class="map-header">
<div class="title-group"> <div class="title-group">
@@ -14,42 +12,93 @@
@if (lastUpdatedStr(); as ts) { @if (lastUpdatedStr(); as ts) {
<span class="last-updated-badge"> <span class="last-updated-badge">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"> <svg
<circle cx="6" cy="6" r="4.5"/> viewBox="0 0 12 12"
<polyline points="6,3.5 6,6 7.5,7.5"/> 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> </svg>
Last updated&nbsp;<strong>{{ ts }}</strong> Last updated&nbsp;<strong>{{ ts }}</strong>
</span> </span>
} }
<nav class="controls" aria-label="Contrôles de navigation du plan"> <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"> <button
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> class="ctrl ctrl-zoom"
<line x1="2" y1="7" x2="12" y2="7"/> (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> </svg>
</button> </button>
<span class="scale-badge">{{ scalePct() }}&thinsp;%</span> <span class="scale-badge">{{ scalePct() }}&thinsp;%</span>
<button class="ctrl ctrl-zoom" (click)="zoomIn()" title="Zoom avant (+)" aria-label="Zoom avant"> <button
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> class="ctrl ctrl-zoom"
<line x1="7" y1="2" x2="7" y2="12"/> (click)="zoomIn()"
<line x1="2" y1="7" x2="12" y2="7"/> 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> </svg>
</button> </button>
<div class="ctrl-divider"></div> <div class="ctrl-divider"></div>
<button class="ctrl ctrl-action" (click)="fitToView()" title="Ajuster à la fenêtre" aria-label="Ajuster à la fenêtre"> <button
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"> class="ctrl ctrl-action"
<path d="M1 5.5V2H4.5M15 5.5V2H11.5M1 10.5V14H4.5M15 10.5V14H11.5"/> (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> </svg>
</button> </button>
<button class="ctrl ctrl-action" (click)="reset()" title="Vue 1:1" aria-label="Réinitialiser le zoom"> <button
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> class="ctrl ctrl-action"
<path d="M13 3A7 7 0 1 0 14.5 8"/> (click)="reset()"
<path d="M14.5 3v3h-3"/> 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> </svg>
</button> </button>
</nav> </nav>
@@ -66,7 +115,6 @@
(pointercancel)="onPointerUp()" (pointercancel)="onPointerUp()"
(pointerleave)="onPointerUp(); hideTooltip()" (pointerleave)="onPointerUp(); hideTooltip()"
> >
<!-- Chargement --> <!-- Chargement -->
@if (isLoading()) { @if (isLoading()) {
<div class="state-overlay"> <div class="state-overlay">
@@ -80,7 +128,7 @@
<div class="state-overlay state-error"> <div class="state-overlay state-error">
<div class="error-icon"></div> <div class="error-icon"></div>
<p> <p>
Fichier introuvable.<br> Fichier introuvable.<br />
Copiez <code>plan.svg</code> dans le dossier <code>public/</code> du projet Angular. Copiez <code>plan.svg</code> dans le dossier <code>public/</code> du projet Angular.
</p> </p>
</div> </div>
@@ -94,7 +142,6 @@
[style.width.px]="CANVAS_W" [style.width.px]="CANVAS_W"
[style.height.px]="CANVAS_H" [style.height.px]="CANVAS_H"
> >
<!-- ① Plan SVG original — rendu fidèle via innerHTML --> <!-- ① Plan SVG original — rendu fidèle via innerHTML -->
<div class="plan-host" [innerHTML]="svgHtml()"></div> <div class="plan-host" [innerHTML]="svgHtml()"></div>
@@ -110,14 +157,24 @@
<defs> <defs>
<!-- Ombre portée sous chaque badge --> <!-- Ombre portée sous chaque badge -->
<filter id="f-shadow" x="-40%" y="-40%" width="180%" height="180%"> <filter id="f-shadow" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="300" stdDeviation="600" <feDropShadow
flood-color="#0d1b2a" flood-opacity="0.35"/> dx="0"
dy="300"
stdDeviation="600"
flood-color="#0d1b2a"
flood-opacity="0.35"
/>
</filter> </filter>
<!-- Halo de survol --> <!-- Halo de survol -->
<filter id="f-hover" x="-40%" y="-40%" width="180%" height="180%"> <filter id="f-hover" x="-40%" y="-40%" width="180%" height="180%">
<feDropShadow dx="0" dy="0" stdDeviation="800" <feDropShadow
flood-color="#ffffff" flood-opacity="0.6"/> dx="0"
dy="0"
stdDeviation="800"
flood-color="#ffffff"
flood-opacity="0.6"
/>
</filter> </filter>
</defs> </defs>
@@ -134,7 +191,9 @@
[class.no-sensor]="!room.layout.hasSensor" [class.no-sensor]="!room.layout.hasSensor"
[attr.transform]="'translate(' + pos.cx + ' ' + pos.cy + ')'" [attr.transform]="'translate(' + pos.cx + ' ' + pos.cy + ')'"
role="button" role="button"
[attr.aria-label]="room.layout.name + ' CO2 : ' + (room.reading?.co2 ?? 'N/A') + ' ppm'" [attr.aria-label]="
room.layout.name + ' CO2 : ' + (room.reading?.co2 ?? 'N/A') + ' ppm'
"
(click)="navigateToRoom(room.layout.id)" (click)="navigateToRoom(room.layout.id)"
(mouseenter)="showTooltip($event, room)" (mouseenter)="showTooltip($event, room)"
(mousemove)="showTooltip($event, room)" (mousemove)="showTooltip($event, room)"
@@ -142,9 +201,12 @@
> >
<!-- Fond du badge --> <!-- Fond du badge -->
<rect <rect
x="-8000" y="-5200" x="-8000"
width="16000" height="10400" y="-5200"
rx="2400" ry="2400" width="16000"
height="10400"
rx="2400"
ry="2400"
[attr.fill]="room.color" [attr.fill]="room.color"
fill-opacity="0.93" fill-opacity="0.93"
filter="url(#f-shadow)" filter="url(#f-shadow)"
@@ -153,23 +215,24 @@
<!-- Liseré blanc séparateur (ID / valeur) --> <!-- Liseré blanc séparateur (ID / valeur) -->
<line <line
x1="-7000" y1="600" x2="7000" y2="600" x1="-7000"
stroke="white" stroke-opacity="0.30" stroke-width="180" y1="600"
x2="7000"
y2="600"
stroke="white"
stroke-opacity="0.30"
stroke-width="180"
/> />
<!-- Indicateur de statut (petit cercle coin haut-droit) --> <!-- Indicateur de statut (petit cercle coin haut-droit) -->
@if (room.reading) { @if (room.reading) {
<circle <circle cx="5800" cy="-4000" r="900" fill="white" fill-opacity="0.85" />
cx="5800" cy="-4000"
r="900"
fill="white"
fill-opacity="0.85"
/>
} }
<!-- ID de la salle --> <!-- ID de la salle -->
<text <text
x="0" y="-2000" x="0"
y="-2000"
font-size="4200" font-size="4200"
font-weight="700" font-weight="700"
font-family="'Courier New', 'Lucida Console', monospace" font-family="'Courier New', 'Lucida Console', monospace"
@@ -177,11 +240,14 @@
text-anchor="middle" text-anchor="middle"
dominant-baseline="middle" dominant-baseline="middle"
letter-spacing="200" letter-spacing="200"
>{{ room.layout.id }}</text> >
{{ room.layout.id }}
</text>
<!-- Valeur CO₂ (ou tiret si pas de capteur) --> <!-- Valeur CO₂ (ou tiret si pas de capteur) -->
<text <text
x="0" y="2800" x="0"
y="2800"
font-size="3200" font-size="3200"
font-weight="500" font-weight="500"
font-family="'Courier New', 'Lucida Console', monospace" font-family="'Courier New', 'Lucida Console', monospace"
@@ -191,25 +257,22 @@
dominant-baseline="middle" dominant-baseline="middle"
letter-spacing="100" letter-spacing="100"
> >
@if (room.reading) { {{ room.reading.co2 }}&thinsp;ppm } @if (room.reading) {
@else { — } {{ room.reading.co2 }}&thinsp;ppm
} @else {
}
</text> </text>
</g> </g>
} }
} }
</svg> </svg>
</div> </div>
} }
<!-- ───── Tooltip ───── --> <!-- ───── Tooltip ───── -->
@if (tooltip(); as t) { @if (tooltip(); as t) {
<div <div class="tooltip" [style.left.px]="t.x" [style.top.px]="t.y" role="tooltip">
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-name">{{ t.room.layout.name }}</div>
<!-- <div class="tt-type">{{ t.room.layout.type }} · cap. {{ t.room.layout.capacity ?? '—' }}</div> --> <!-- <div class="tt-type">{{ t.room.layout.type }} · cap. {{ t.room.layout.capacity ?? '—' }}</div> -->
<div class="tt-divider"></div> <div class="tt-divider"></div>
@@ -218,8 +281,12 @@
<span>CO₂</span> <span>CO₂</span>
<strong [style.color]="t.room.color">{{ t.room.reading.co2 }} ppm</strong> <strong [style.color]="t.room.color">{{ t.room.reading.co2 }} ppm</strong>
</div> </div>
<div class="tt-row"><span>Temperature</span><strong>{{ t.room.reading.temperature }} °C</strong></div> <div class="tt-row">
<div class="tt-row"><span>Humidity</span> <strong>{{ t.room.reading.humidity }} %</strong></div> <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"> <div class="tt-row">
<span>Windows</span> <span>Windows</span>
<strong [class.open]="t.room.reading.windowState === 'open'"> <strong [class.open]="t.room.reading.windowState === 'open'">
@@ -240,10 +307,10 @@
<span class="hint-sep">·</span> <span class="hint-sep">·</span>
<span>Click · details</span> <span>Click · details</span>
</div> </div>
</div>
</div><!-- /viewport --> <!-- /viewport -->
</div>
</div><!-- /map-area --> <!-- /map-area -->
<!-- ══════════════════════════════════════════════ <!-- ══════════════════════════════════════════════
Légende (sidebar droite) Légende (sidebar droite)
@@ -257,5 +324,5 @@
<app-legend /> <app-legend />
</div> </div>
</aside> </aside>
</div>
</div><!-- /layout --> <!-- /layout -->

View File

@@ -26,7 +26,7 @@ $bp-sm: 480px;
// Transitions // Transitions
$t-fast: 0.12s ease; $t-fast: 0.12s ease;
$t-normal: 0.20s ease; $t-normal: 0.2s ease;
// ── Layout global ───────────────────────────────────────────────────────────── // ── Layout global ─────────────────────────────────────────────────────────────
@@ -84,7 +84,10 @@ $t-normal: 0.20s ease;
} }
.sidebar-eyebrow { .sidebar-eyebrow {
font: 600 10px/1 ui-sans-serif, system-ui, sans-serif; font:
600 10px/1 ui-sans-serif,
system-ui,
sans-serif;
letter-spacing: 0.14em; letter-spacing: 0.14em;
text-transform: uppercase; text-transform: uppercase;
color: $accent; color: $accent;
@@ -92,7 +95,10 @@ $t-normal: 0.20s ease;
.sidebar-title { .sidebar-title {
margin: 0; margin: 0;
font: 700 15px/1.2 ui-sans-serif, system-ui, sans-serif; font:
700 15px/1.2 ui-sans-serif,
system-ui,
sans-serif;
color: $navy; color: $navy;
} }
@@ -146,7 +152,10 @@ $t-normal: 0.20s ease;
} }
.title-eyebrow { .title-eyebrow {
font: 600 10px/1 ui-sans-serif, system-ui, sans-serif; font:
600 10px/1 ui-sans-serif,
system-ui,
sans-serif;
letter-spacing: 0.14em; letter-spacing: 0.14em;
text-transform: uppercase; text-transform: uppercase;
color: $accent; color: $accent;
@@ -154,7 +163,10 @@ $t-normal: 0.20s ease;
.title { .title {
margin: 0; margin: 0;
font: 700 18px/1.2 ui-sans-serif, system-ui, sans-serif; font:
700 18px/1.2 ui-sans-serif,
system-ui,
sans-serif;
color: $navy; color: $navy;
} }
@@ -164,13 +176,23 @@ $t-normal: 0.20s ease;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
font: 500 11px/1 ui-sans-serif, system-ui, sans-serif; font:
500 11px/1 ui-sans-serif,
system-ui,
sans-serif;
color: $text-muted; color: $text-muted;
letter-spacing: 0.02em; letter-spacing: 0.02em;
svg { width: 12px; height: 12px; flex-shrink: 0; color: $accent; } svg {
width: 12px;
height: 12px;
flex-shrink: 0;
color: $accent;
}
@media (max-width: 480px) { display: none; } @media (max-width: 480px) {
display: none;
}
} }
// ── Barre de contrôles ──────────────────────────────────────────────────────── // ── Barre de contrôles ────────────────────────────────────────────────────────
@@ -195,28 +217,40 @@ $t-normal: 0.20s ease;
background: transparent; background: transparent;
color: #8ab0cc; color: #8ab0cc;
cursor: pointer; cursor: pointer;
transition: background $t-fast, color $t-fast; transition:
background $t-fast,
color $t-fast;
&:hover { &:hover {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
color: #fff; color: #fff;
} }
&:active { transform: scale(0.93); } &:active {
transform: scale(0.93);
}
svg { display: block; } svg {
display: block;
}
} }
.ctrl-zoom { .ctrl-zoom {
width: $ctrl-h; width: $ctrl-h;
svg { width: 14px; height: 14px; } svg {
width: 14px;
height: 14px;
}
} }
.ctrl-action { .ctrl-action {
width: $ctrl-h; width: $ctrl-h;
svg { width: 15px; height: 15px; } svg {
width: 15px;
height: 15px;
}
} }
.ctrl-divider { .ctrl-divider {
@@ -227,7 +261,9 @@ $t-normal: 0.20s ease;
} }
.scale-badge { .scale-badge {
font: 600 12px/1 'Courier New', monospace; font:
600 12px/1 'Courier New',
monospace;
color: $accent; color: $accent;
min-width: 42px; min-width: 42px;
text-align: center; text-align: center;
@@ -259,19 +295,29 @@ $t-normal: 0.20s ease;
linear-gradient(45deg, transparent 75%, #e2eaf4 75%), linear-gradient(45deg, transparent 75%, #e2eaf4 75%),
linear-gradient(-45deg, transparent 75%, #e2eaf4 75%); linear-gradient(-45deg, transparent 75%, #e2eaf4 75%);
background-size: 24px 24px; background-size: 24px 24px;
background-position: 0 0, 0 12px, 12px -12px, -12px 0; background-position:
0 0,
0 12px,
12px -12px,
-12px 0;
&.panning { cursor: grabbing; } &.panning {
cursor: grabbing;
}
// Hauteur explicite sur mobile (flex:1 ne suffit pas en colonne avec height:auto) // Hauteur explicite sur mobile (flex:1 ne suffit pas en colonne avec height:auto)
@media (max-width: 1100px) { min-height: 55vh; } @media (max-width: 1100px) {
@media (max-width: 480px) { min-height: 50vh; border-radius: $radius-md; } min-height: 55vh;
}
@media (max-width: 480px) {
min-height: 50vh;
border-radius: $radius-md;
}
} }
// ── Canvas transformable ────────────────────────────────────────────────────── // ── Canvas transformable ──────────────────────────────────────────────────────
.canvas { .canvas {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@@ -279,7 +325,6 @@ $t-normal: 0.20s ease;
will-change: transform; will-change: transform;
// Ombre extérieure pour matérialiser les bords du plan // Ombre extérieure pour matérialiser les bords du plan
filter: drop-shadow(0 4px 24px rgba($navy-deep, 0.18)); filter: drop-shadow(0 4px 24px rgba($navy-deep, 0.18));
} }
// ── Hôte du SVG injecté ─────────────────────────────────────────────────────── // ── Hôte du SVG injecté ───────────────────────────────────────────────────────
@@ -322,7 +367,9 @@ $t-normal: 0.20s ease;
cursor: pointer; cursor: pointer;
.badge-bg { .badge-bg {
transition: filter 0.15s ease, fill-opacity 0.15s ease; transition:
filter 0.15s ease,
fill-opacity 0.15s ease;
} }
&:hover .badge-bg { &:hover .badge-bg {
@@ -357,7 +404,10 @@ $t-normal: 0.20s ease;
p { p {
margin: 0; margin: 0;
font: 400 14px/1.5 ui-sans-serif, system-ui, sans-serif; font:
400 14px/1.5 ui-sans-serif,
system-ui,
sans-serif;
color: $text-muted; color: $text-muted;
text-align: center; text-align: center;
} }
@@ -392,7 +442,9 @@ $t-normal: 0.20s ease;
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
// ── Hint viewport ───────────────────────────────────────────────────────────── // ── Hint viewport ─────────────────────────────────────────────────────────────
@@ -408,7 +460,10 @@ $t-normal: 0.20s ease;
background: rgba($navy-deep, 0.62); background: rgba($navy-deep, 0.62);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
font: 400 10px/1 ui-sans-serif, system-ui, sans-serif; font:
400 10px/1 ui-sans-serif,
system-ui,
sans-serif;
letter-spacing: 0.04em; letter-spacing: 0.04em;
padding: 5px 12px; padding: 5px 12px;
border-radius: 20px; border-radius: 20px;
@@ -423,7 +478,11 @@ $t-normal: 0.20s ease;
// Cache les spans "Mouse wheel" et "Drag" sur mobile, garde juste "Click" // Cache les spans "Mouse wheel" et "Drag" sur mobile, garde juste "Click"
@media (max-width: 480px) { @media (max-width: 480px) {
.hint-mouse, .hint-drag, .hint-sep:not(:last-of-type) { display: none; } .hint-mouse,
.hint-drag,
.hint-sep:not(:last-of-type) {
display: none;
}
} }
} }
@@ -446,19 +505,31 @@ $t-normal: 0.20s ease;
animation: tt-in 0.1s ease; animation: tt-in 0.1s ease;
@keyframes tt-in { @keyframes tt-in {
from { opacity: 0; transform: translateY(4px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
} }
.tt-name { .tt-name {
font: 700 13px/1.2 ui-sans-serif, system-ui, sans-serif; font:
700 13px/1.2 ui-sans-serif,
system-ui,
sans-serif;
color: $navy-light; color: $navy-light;
margin-bottom: 2px; margin-bottom: 2px;
} }
.tt-type { .tt-type {
font: 400 10px/1 ui-sans-serif, system-ui, sans-serif; font:
400 10px/1 ui-sans-serif,
system-ui,
sans-serif;
color: $text-muted; color: $text-muted;
text-transform: capitalize; text-transform: capitalize;
margin-bottom: 8px; margin-bottom: 8px;
@@ -475,16 +546,29 @@ $t-normal: 0.20s ease;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
font: 400 11px/1.8 ui-sans-serif, system-ui, sans-serif; font:
400 11px/1.8 ui-sans-serif,
system-ui,
sans-serif;
span { color: #6a90b0; } span {
strong { color: #fff; font-weight: 600; } color: #6a90b0;
}
strong {
color: #fff;
font-weight: 600;
}
strong.open { color: #69e09a; } strong.open {
color: #69e09a;
}
} }
.tt-empty { .tt-empty {
font: 400 11px/1 ui-sans-serif, system-ui, sans-serif; font:
400 11px/1 ui-sans-serif,
system-ui,
sans-serif;
color: #5a7694; color: #5a7694;
font-style: italic; font-style: italic;
margin-top: 4px; margin-top: 4px;

View File

@@ -140,7 +140,7 @@ export class RoomMapComponent implements OnInit, AfterViewInit, OnDestroy {
ngOnInit(): void { ngOnInit(): void {
// Chargement du plan SVG depuis public/ // Chargement du plan SVG depuis public/
this.svgSub = this.http.get('/plan.svg', { responseType: 'text' }).subscribe({ this.svgSub = this.http.get('/plan.svg', { responseType: 'text' }).subscribe({
next: (raw) => { next: raw => {
const cleaned = raw const cleaned = raw
.replace(/(<svg\b[^>]*?)\s+width="[^"]*"/, '$1') .replace(/(<svg\b[^>]*?)\s+width="[^"]*"/, '$1')
.replace(/(<svg\b[^>]*?)\s+height="[^"]*"/, '$1'); .replace(/(<svg\b[^>]*?)\s+height="[^"]*"/, '$1');
@@ -154,18 +154,25 @@ export class RoomMapComponent implements OnInit, AfterViewInit, OnDestroy {
}); });
// Charge les salles une fois, puis poll les capteurs selon l'intervalle configuré // Charge les salles une fois, puis poll les capteurs selon l'intervalle configuré
this.pollSub = this.roomService.getRooms().pipe( this.pollSub = this.roomService
tap((layouts) => { this.roomLayouts = layouts; }), .getRooms()
switchMap((rooms) => concat( .pipe(
tap(layouts => {
this.roomLayouts = layouts;
}),
switchMap(rooms =>
concat(
this.sensorService.getLatestReadings(rooms), this.sensorService.getLatestReadings(rooms),
timer(environment.polling.mapIntervalMs, environment.polling.mapIntervalMs).pipe( timer(environment.polling.mapIntervalMs, environment.polling.mapIntervalMs).pipe(
switchMap(() => this.sensorService.getLatestReadings(rooms)), switchMap(() => this.sensorService.getLatestReadings(rooms))
), )
)), )
).subscribe((readings) => { )
)
.subscribe(readings => {
this.rooms.set( this.rooms.set(
this.roomLayouts.map((layout) => { this.roomLayouts.map(layout => {
const reading = readings.find((r) => r.roomId === layout.id); const reading = readings.find(r => r.roomId === layout.id);
return { return {
layout, layout,
reading, reading,
@@ -230,13 +237,17 @@ export class RoomMapComponent implements OnInit, AfterViewInit, OnDestroy {
this.applyZoom(this._s * factor, e.clientX - rect.left, e.clientY - rect.top); this.applyZoom(this._s * factor, e.clientX - rect.left, e.clientY - rect.top);
} }
zoomIn(): void { this.applyZoom(this._s * 1.5, ...this.vpCenter()); } zoomIn(): void {
zoomOut(): void { this.applyZoom(this._s / 1.5, ...this.vpCenter()); } this.applyZoom(this._s * 1.5, ...this.vpCenter());
}
zoomOut(): void {
this.applyZoom(this._s / 1.5, ...this.vpCenter());
}
fitToView(): void { fitToView(): void {
const vp = this.viewportRef?.nativeElement; const vp = this.viewportRef?.nativeElement;
if (!vp) return; if (!vp) return;
const s = Math.min(vp.clientWidth / this.CANVAS_W, vp.clientHeight / this.CANVAS_H) * 0.90; const s = Math.min(vp.clientWidth / this.CANVAS_W, vp.clientHeight / this.CANVAS_H) * 0.9;
this._s = s; this._s = s;
this._tx = (vp.clientWidth - this.CANVAS_W * s) / 2; this._tx = (vp.clientWidth - this.CANVAS_W * s) / 2;
this._ty = (vp.clientHeight - this.CANVAS_H * s) / 2; this._ty = (vp.clientHeight - this.CANVAS_H * s) / 2;
@@ -246,8 +257,12 @@ export class RoomMapComponent implements OnInit, AfterViewInit, OnDestroy {
} }
reset(): void { reset(): void {
this._s = 1; this._tx = 0; this._ty = 0; this._s = 1;
this.scale.set(1); this.tx.set(0); this.ty.set(0); this._tx = 0;
this._ty = 0;
this.scale.set(1);
this.tx.set(0);
this.ty.set(0);
} }
private vpCenter(): [number, number] { private vpCenter(): [number, number] {
@@ -275,7 +290,9 @@ export class RoomMapComponent implements OnInit, AfterViewInit, OnDestroy {
this.ty.set(this._ty); this.ty.set(this._ty);
} }
onPointerUp(): void { this.isPanning.set(false); } onPointerUp(): void {
this.isPanning.set(false);
}
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Tooltip & Navigation // Tooltip & Navigation
@@ -286,6 +303,10 @@ export class RoomMapComponent implements OnInit, AfterViewInit, OnDestroy {
this.tooltip.set({ room, x: e.clientX - rect.left + 18, y: e.clientY - rect.top - 12 }); this.tooltip.set({ room, x: e.clientX - rect.left + 18, y: e.clientY - rect.top - 12 });
} }
hideTooltip(): void { this.tooltip.set(null); } hideTooltip(): void {
navigateToRoom(id: string): void { this.router.navigate(['/room', id]); } this.tooltip.set(null);
}
navigateToRoom(id: string): void {
this.router.navigate(['/room', id]);
}
} }

View File

@@ -32,61 +32,178 @@ export interface RoomLayout {
export const ROOM_LAYOUTS: RoomLayout[] = [ export const ROOM_LAYOUTS: RoomLayout[] = [
// ── Espace B (lower-left, x=29361) ────────────────────────────────────── // ── Espace B (lower-left, x=29361) ──────────────────────────────────────
{ {
id: 'B4', name: 'B4', espace: 'B', floor: 1, type: 'meeting', id: 'B4',
x: 29, y: 445, width: 332, height: 60, capacity: 12, hasSensor: true, name: 'B4',
espace: 'B',
floor: 1,
type: 'meeting',
x: 29,
y: 445,
width: 332,
height: 60,
capacity: 12,
hasSensor: true,
}, },
{ {
id: 'B3', name: 'B3', espace: 'B', floor: 1, type: 'office', id: 'B3',
x: 29, y: 505, width: 332, height: 60, capacity: 8, hasSensor: true, name: 'B3',
espace: 'B',
floor: 1,
type: 'office',
x: 29,
y: 505,
width: 332,
height: 60,
capacity: 8,
hasSensor: true,
}, },
{ {
id: 'B2', name: 'B2', espace: 'B', floor: 1, type: 'classroom', id: 'B2',
x: 29, y: 565, width: 332, height: 80, capacity: 20, hasSensor: true, name: 'B2',
espace: 'B',
floor: 1,
type: 'classroom',
x: 29,
y: 565,
width: 332,
height: 80,
capacity: 20,
hasSensor: true,
}, },
{ {
id: 'B1', name: 'B1', espace: 'B', floor: 1, type: 'lab', id: 'B1',
x: 29, y: 645, width: 332, height: 50, capacity: 0, hasSensor: false, name: 'B1',
espace: 'B',
floor: 1,
type: 'lab',
x: 29,
y: 645,
width: 332,
height: 50,
capacity: 0,
hasSensor: false,
}, },
// ── Espace A — upper-right column (x=8111095) ─────────────────────────── // ── Espace A — upper-right column (x=8111095) ───────────────────────────
{ {
id: 'A8', name: 'A8', espace: 'A', floor: 1, type: 'office', id: 'A8',
x: 811, y: 15, width: 142, height: 90, capacity: 10, hasSensor: true, name: 'A8',
espace: 'A',
floor: 1,
type: 'office',
x: 811,
y: 15,
width: 142,
height: 90,
capacity: 10,
hasSensor: true,
}, },
{ {
id: 'A9', name: 'A9', espace: 'A', floor: 1, type: 'classroom', id: 'A9',
x: 953, y: 15, width: 142, height: 90, capacity: 40, hasSensor: true, name: 'A9',
espace: 'A',
floor: 1,
type: 'classroom',
x: 953,
y: 15,
width: 142,
height: 90,
capacity: 40,
hasSensor: true,
}, },
{ {
id: 'A7', name: 'A7', espace: 'A', floor: 1, type: 'classroom', id: 'A7',
x: 811, y: 105, width: 284, height: 60, capacity: 40, hasSensor: true, name: 'A7',
espace: 'A',
floor: 1,
type: 'classroom',
x: 811,
y: 105,
width: 284,
height: 60,
capacity: 40,
hasSensor: true,
}, },
{ {
id: 'A6', name: 'A6', espace: 'A', floor: 1, type: 'classroom', id: 'A6',
x: 811, y: 165, width: 284, height: 80, capacity: 30, hasSensor: true, 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) ───────────────── // ── Espace A — centre-right (A5 left of A4, same y-band) ─────────────────
{ {
id: 'A5', name: 'A5', espace: 'A', floor: 1, type: 'conference', id: 'A5',
x: 598, y: 245, width: 213, height: 80, capacity: 20, hasSensor: true, name: 'A5',
espace: 'A',
floor: 1,
type: 'conference',
x: 598,
y: 245,
width: 213,
height: 80,
capacity: 20,
hasSensor: true,
}, },
{ {
id: 'A4', name: 'A4', espace: 'A', floor: 1, type: 'classroom', id: 'A4',
x: 811, y: 245, width: 284, height: 95, capacity: 60, hasSensor: true, name: 'A4',
espace: 'A',
floor: 1,
type: 'classroom',
x: 811,
y: 245,
width: 284,
height: 95,
capacity: 60,
hasSensor: true,
}, },
{ {
id: 'A2', name: 'A2', espace: 'A', floor: 1, type: 'classroom', id: 'A2',
x: 811, y: 340, width: 284, height: 95, capacity: 60, hasSensor: true, name: 'A2',
espace: 'A',
floor: 1,
type: 'classroom',
x: 811,
y: 340,
width: 284,
height: 95,
capacity: 60,
hasSensor: true,
}, },
// ── Espace A — lower centre ─────────────────────────────────────────────── // ── Espace A — lower centre ───────────────────────────────────────────────
{ {
id: 'A3', name: 'A3', espace: 'A', floor: 1, type: 'lab', id: 'A3',
x: 408, y: 445, width: 403, height: 60, capacity: 25, hasSensor: true, name: 'A3',
espace: 'A',
floor: 1,
type: 'lab',
x: 408,
y: 445,
width: 403,
height: 60,
capacity: 25,
hasSensor: true,
}, },
{ {
id: 'A1', name: 'A1', espace: 'A', floor: 1, type: 'meeting', id: 'A1',
x: 598, y: 595, width: 261, height: 100, capacity: 15, hasSensor: true, name: 'A1',
espace: 'A',
floor: 1,
type: 'meeting',
x: 598,
y: 595,
width: 261,
height: 100,
capacity: 15,
hasSensor: true,
}, },
]; ];

View File

@@ -21,7 +21,7 @@ export class RoomService {
.filter((r): r is RoomLayout => r !== undefined); .filter((r): r is RoomLayout => r !== undefined);
return matched.length > 0 ? matched : ROOM_LAYOUTS; return matched.length > 0 ? matched : ROOM_LAYOUTS;
}), }),
catchError(() => of(ROOM_LAYOUTS)), catchError(() => of(ROOM_LAYOUTS))
); );
} }

View File

@@ -32,8 +32,12 @@ describe('SensorService', () => {
service.getLatestReadings(roomsWithSensors).subscribe(); service.getLatestReadings(roomsWithSensors).subscribe();
roomsWithSensors.forEach(r => roomsWithSensors.forEach(r =>
httpMock.expectOne(`${environment.apiUrl}/rooms/${r.id}/current`).flush({ httpMock.expectOne(`${environment.apiUrl}/rooms/${r.id}/current`).flush({
time: new Date().toISOString(), co2_ppm: 600, temp: 21, time: new Date().toISOString(),
humidity: 45, window: false, room: r.id, co2_ppm: 600,
temp: 21,
humidity: 45,
window: false,
room: r.id,
}) })
); );
}); });
@@ -44,7 +48,8 @@ describe('SensorService', () => {
done(); done();
}); });
roomsWithSensors.forEach(r => roomsWithSensors.forEach(r =>
httpMock.expectOne(`${environment.apiUrl}/rooms/${r.id}/current`) httpMock
.expectOne(`${environment.apiUrl}/rooms/${r.id}/current`)
.error(new ProgressEvent('network error')) .error(new ProgressEvent('network error'))
); );
}); });
@@ -53,8 +58,12 @@ describe('SensorService', () => {
const subset = roomsWithSensors.slice(0, 1); const subset = roomsWithSensors.slice(0, 1);
service.getLatestReadings(subset).subscribe(); service.getLatestReadings(subset).subscribe();
httpMock.expectOne(`${environment.apiUrl}/rooms/${subset[0].id}/current`).flush({ httpMock.expectOne(`${environment.apiUrl}/rooms/${subset[0].id}/current`).flush({
time: new Date().toISOString(), co2_ppm: 500, temp: 20, time: new Date().toISOString(),
humidity: 50, window: true, room: subset[0].id, co2_ppm: 500,
temp: 20,
humidity: 50,
window: true,
room: subset[0].id,
}); });
}); });
}); });
@@ -64,7 +73,14 @@ describe('SensorService', () => {
service.getLatestReadingForRoom(firstRoomId).subscribe(); service.getLatestReadingForRoom(firstRoomId).subscribe();
const req = httpMock.expectOne(`${environment.apiUrl}/rooms/${firstRoomId}/current`); const req = httpMock.expectOne(`${environment.apiUrl}/rooms/${firstRoomId}/current`);
expect(req.request.method).toBe('GET'); expect(req.request.method).toBe('GET');
req.flush({ time: new Date().toISOString(), co2_ppm: 600, temp: 21, humidity: 45, window: false, room: firstRoomId }); req.flush({
time: new Date().toISOString(),
co2_ppm: 600,
temp: 21,
humidity: 45,
window: false,
room: firstRoomId,
});
}); });
it('should return undefined on HTTP error', done => { it('should return undefined on HTTP error', done => {
@@ -81,7 +97,9 @@ describe('SensorService', () => {
describe('getHistoryForRoom()', () => { describe('getHistoryForRoom()', () => {
it('should call GET /rooms/:roomId/history', () => { it('should call GET /rooms/:roomId/history', () => {
service.getHistoryForRoom(firstRoomId).subscribe(); service.getHistoryForRoom(firstRoomId).subscribe();
const req = httpMock.expectOne(`${environment.apiUrl}/rooms/${firstRoomId}/history?window=1 day`); const req = httpMock.expectOne(
`${environment.apiUrl}/rooms/${firstRoomId}/history?window=1 day`
);
expect(req.request.method).toBe('GET'); expect(req.request.method).toBe('GET');
req.flush([]); req.flush([]);
}); });

View File

@@ -30,7 +30,7 @@ function toSensorReading(raw: GoReading): SensorReading {
}; };
} }
const randomWindowState = (): WindowState => Math.random() > 0.5 ? 'open' : 'closed'; const randomWindowState = (): WindowState => (Math.random() > 0.5 ? 'open' : 'closed');
const MOCK_HISTORY: SensorReading[] = ROOM_LAYOUTS.filter(r => r.hasSensor).flatMap(room => const MOCK_HISTORY: SensorReading[] = ROOM_LAYOUTS.filter(r => r.hasSensor).flatMap(room =>
Array.from({ length: 24 }, (_, i) => ({ Array.from({ length: 24 }, (_, i) => ({
@@ -58,40 +58,36 @@ export class SensorService {
const requests = rooms.map(r => const requests = rooms.map(r =>
this.http.get<GoReading>(`${this.apiUrl}/rooms/${r.id}/current`).pipe( this.http.get<GoReading>(`${this.apiUrl}/rooms/${r.id}/current`).pipe(
map(toSensorReading), map(toSensorReading),
catchError(() => of(null)), catchError(() => of(null))
) )
); );
return forkJoin(requests).pipe( return forkJoin(requests).pipe(
map(results => results.filter((r): r is SensorReading => r !== null)), map(results => results.filter((r): r is SensorReading => r !== null)),
catchError((e) => { catchError(e => {
console.error('[SensorService] getLatestReadings failed', e); console.error('[SensorService] getLatestReadings failed', e);
return of(fallback); return of(fallback);
}), })
); );
} }
getLatestReadingForRoom(roomId: string): Observable<SensorReading | undefined> { getLatestReadingForRoom(roomId: string): Observable<SensorReading | undefined> {
return this.http return this.http.get<GoReading>(`${this.apiUrl}/rooms/${roomId}/current`).pipe(
.get<GoReading>(`${this.apiUrl}/rooms/${roomId}/current`)
.pipe(
map(toSensorReading), map(toSensorReading),
catchError((e) => { catchError(e => {
console.error(`[SensorService] getLatestReadingForRoom(${roomId}) failed`, e); console.error(`[SensorService] getLatestReadingForRoom(${roomId}) failed`, e);
return of(undefined); return of(undefined);
}), })
); );
} }
getHistoryForRoom(roomId: string): Observable<SensorReading[]> { getHistoryForRoom(roomId: string): Observable<SensorReading[]> {
return this.http return this.http.get<GoReading[]>(`${this.apiUrl}/rooms/${roomId}/history?window=1 day`).pipe(
.get<GoReading[]>(`${this.apiUrl}/rooms/${roomId}/history?window=1 day`)
.pipe(
map(rows => rows.map(toSensorReading)), map(rows => rows.map(toSensorReading)),
catchError((e) => { catchError(e => {
console.error(`[SensorService] getHistoryForRoom(${roomId}) failed`, e); console.error(`[SensorService] getHistoryForRoom(${roomId}) failed`, e);
return of([]); return of([]);
}), })
); );
} }
} }