Files
bellsystems-cp/frontend/src/pages/bellcloud/devices/DeviceListMapView.jsx
T
bonaminandClaude Sonnet 5 acd1896788 feat(devices): add Health tab, log explorer, and fleet health indicators
Surfaces the new device-health telemetry (boot events, ping RTT,
diagnostics reports, alert events) across the console:

- HealthTab: boot/diagnostics timeline, CPU temp and RSSI charts
  (LineChart), current alert status, and a Settings sub-tab for
  DeviceHealthSettings thresholds
- LogsTab: dedicated log explorer embedded in the Health tab, with
  level/source filtering and fmtLogTimestamp for dense timestamp rows
- OverviewTab: a "Latest Device Issue" card showing the most recent
  alert event, colour-coded by severity and fading with age, plus a
  modal to inspect the surrounding log lines
- DeviceList/DeviceListCardView/DeviceListMapView: fleet list gains a
  SignalIndicator-based RSSI display in place of the plain online dot

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:32:06 +03:00

1463 lines
57 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// frontend/src/pages/bellcloud/devices/DeviceListMapView.jsx
//
// Full-viewport world map view for the Device Fleet.
// • Minimal SVG world outline — no tile servers, no external deps
// • Colour-coded pulsing dots per device status
// • Click a dot → right-aligned info drawer slides in
// • Selected dot enlarges and map pans to centre it
// • Top-left: fleet health widget (online/offline ratio)
// • Bottom-left: latest activity (5 most-recent MQTT logs)
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import Button from '@/components/ui/Button'
import Spinner from '@/components/ui/Spinner'
import Icon from '@/components/ui/Icon'
import { fmtRelative } from '@/lib/formatters'
// ─── Coordinate → SVG projection ──────────────────────────────────────────────
// Simple equirectangular (Plate Carrée) projection.
// SVG viewBox is 1010 × 505 — standard for world maps at 2:1 aspect ratio.
const MAP_W = 1010
const MAP_H = 505
function latLngToXY(lat, lng) {
const x = ((lng + 180) / 360) * MAP_W
const y = ((90 - lat) / 180) * MAP_H
return { x, y }
}
// ─── Device status classification ─────────────────────────────────────────────
function classifyDevice(device, mqttStatusMap) {
const sn = device.serial_number || device.device_id
const mqtt = mqttStatusMap[sn]
const isOnline = mqtt ? mqtt.online : (device.is_Online ?? false)
const stats = device.device_stats || {}
const hasIssues = (stats.totalWarningsGiven ?? 0) > 0
if (isOnline && !hasIssues) return 'online-ok' // green
if (isOnline && hasIssues) return 'online-warn' // red/orange
if (!isOnline && !hasIssues) return 'offline-ok' // blue
return 'offline-warn' // orange
}
const STATUS_COLORS = {
'online-ok': { dot: 'var(--color-success)', ring: 'rgba(74,222,128,0.35)', label: 'Online' },
'online-warn': { dot: 'var(--color-danger)', ring: 'rgba(255,92,92,0.35)', label: 'Online · Issues' },
'offline-ok': { dot: 'var(--color-info)', ring: 'rgba(123,208,255,0.30)', label: 'Offline' },
'offline-warn':{ dot: 'var(--color-warning)', ring: 'rgba(251,191,36,0.30)', label: 'Offline · Issues' },
}
// ─── Normalise GeoPoint ────────────────────────────────────────────────────────
function parseCoords(raw) {
if (!raw) return null
const lat = raw.latitude ?? raw.lat ?? null
const lng = raw.longitude ?? raw.lng ?? null
if (lat == null || lng == null) return null
const la = Number(lat)
const lo = Number(lng)
if (isNaN(la) || isNaN(lo)) return null
return { lat: la, lng: lo }
}
// ─── Format uptime (seconds → readable) ───────────────────────────────────────
function formatUptime(seconds) {
if (seconds == null || seconds < 0) return null
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
const parts = []
if (d) parts.push(`${d}d`)
if (h) parts.push(`${h}h`)
if (m) parts.push(`${m}m`)
return parts.length ? parts.join(' ') : '< 1m'
}
// ─── SVG World Map ─────────────────────────────────────────────────────────────
// Simplified Natural Earth outline (continents + major islands).
// Path data is pre-projected for the 1010×505 viewBox (equirectangular).
// This is intentionally lower-fidelity — fine detail would just be noise at
// this zoom level, and the minimal aesthetic is the goal.
const WORLD_PATH = `
M 134 119 L 136 116 L 139 113 L 144 112 L 148 110 L 152 111 L 155 113
L 158 117 L 160 121 L 158 125 L 155 128 L 151 129 L 147 128 L 143 126
L 139 123 L 136 121 Z
M 505 85 L 510 82 L 516 80 L 522 79 L 528 80 L 532 83 L 534 87 L 532 91
L 528 94 L 522 95 L 516 94 L 511 91 L 507 88 Z
M 455 93 L 460 90 L 466 89 L 471 91 L 474 95 L 472 99 L 468 102 L 463 103
L 458 101 L 454 97 Z
M 160 95 L 168 91 L 176 89 L 183 90 L 188 94 L 190 99 L 188 104 L 183 107
L 176 108 L 169 107 L 163 103 L 160 98 Z
M 250 180 L 245 175 L 242 168 L 243 160 L 248 153 L 256 148 L 265 146
L 274 147 L 281 151 L 285 158 L 284 166 L 279 173 L 271 178 L 262 180
L 254 180 Z
M 335 165 L 330 160 L 328 153 L 331 146 L 337 141 L 345 139 L 354 140
L 361 144 L 364 151 L 362 158 L 357 164 L 349 167 L 341 167 Z
M 415 155 L 410 150 L 408 143 L 411 136 L 417 131 L 425 129 L 434 130
L 441 134 L 444 141 L 442 148 L 437 154 L 429 157 L 421 157 Z
M 485 148 L 480 143 L 478 136 L 481 129 L 487 124 L 495 122 L 504 123
L 511 127 L 514 134 L 512 141 L 507 147 L 499 150 L 491 150 Z
M 555 143 L 550 138 L 548 131 L 551 124 L 557 119 L 565 117 L 574 118
L 581 122 L 584 129 L 582 136 L 577 142 L 569 145 L 561 145 Z
M 625 150 L 620 145 L 618 138 L 621 131 L 627 126 L 635 124 L 644 125
L 651 129 L 654 136 L 652 143 L 647 149 L 639 152 L 631 152 Z
M 695 160 L 690 155 L 688 148 L 691 141 L 697 136 L 705 134 L 714 135
L 721 139 L 724 146 L 722 153 L 717 159 L 709 162 L 701 162 Z
M 765 168 L 760 163 L 758 156 L 761 149 L 767 144 L 775 142 L 784 143
L 791 147 L 794 154 L 792 161 L 787 167 L 779 170 L 771 170 Z
M 120 173 L 115 165 L 113 155 L 116 144 L 122 135 L 131 129 L 141 127
L 151 129 L 158 136 L 161 145 L 159 156 L 153 164 L 144 170 L 134 172 Z
M 180 195 L 170 186 L 165 174 L 167 161 L 173 150 L 183 143 L 194 141
L 205 144 L 213 152 L 215 163 L 212 175 L 205 184 L 195 191 L 185 195 Z
M 225 210 L 218 198 L 215 184 L 218 169 L 225 156 L 236 147 L 248 143
L 261 144 L 271 151 L 276 162 L 274 176 L 267 188 L 256 197 L 244 203
L 232 208 Z
M 70 155 L 68 148 L 70 141 L 75 136 L 82 134 L 89 136 L 94 141
L 95 148 L 92 155 L 87 159 L 80 160 L 73 158 Z
M 85 175 L 82 168 L 84 161 L 89 156 L 96 154 L 103 156 L 108 161
L 109 168 L 106 175 L 101 179 L 94 180 L 87 178 Z
M 100 198 L 97 191 L 99 184 L 104 179 L 111 177 L 118 179 L 123 184
L 124 191 L 121 198 L 116 202 L 109 203 L 102 201 Z
M 310 188 L 305 178 L 303 166 L 306 153 L 313 143 L 323 136 L 334 133
L 345 135 L 353 142 L 357 152 L 355 164 L 349 175 L 339 183 L 328 188 Z
M 340 210 L 335 200 L 333 188 L 336 175 L 343 165 L 353 158 L 364 155
L 375 157 L 383 164 L 387 174 L 385 186 L 379 197 L 369 205 L 358 210 Z
M 365 235 L 360 224 L 358 211 L 361 197 L 369 186 L 380 178 L 392 175
L 404 177 L 413 184 L 417 195 L 415 208 L 408 220 L 397 229 L 385 235 Z
M 385 260 L 379 249 L 377 235 L 381 221 L 389 210 L 401 202 L 414 199
L 427 201 L 436 209 L 440 221 L 437 235 L 430 248 L 418 258 L 405 263
L 393 263 Z
M 395 290 L 388 277 L 386 262 L 390 247 L 399 235 L 412 226 L 426 223
L 440 226 L 450 235 L 453 249 L 450 264 L 442 278 L 430 289 L 416 295
L 403 296 Z
M 400 320 L 393 307 L 390 291 L 394 275 L 403 262 L 417 252 L 432 249
L 447 253 L 457 263 L 460 278 L 456 294 L 447 308 L 434 320 L 419 327
L 407 327 Z
M 480 185 L 473 175 L 471 162 L 474 149 L 481 139 L 490 132 L 500 129
L 510 131 L 517 138 L 520 147 L 517 158 L 511 168 L 502 176 L 492 181 Z
M 510 210 L 503 200 L 500 187 L 503 173 L 511 162 L 521 154 L 532 151
L 543 153 L 551 161 L 554 172 L 551 185 L 544 196 L 533 205 L 521 210 Z
M 540 240 L 533 229 L 530 215 L 534 200 L 542 188 L 553 180 L 565 177
L 577 179 L 586 187 L 589 199 L 586 214 L 578 226 L 566 236 L 554 241 Z
M 570 270 L 563 258 L 560 243 L 564 228 L 573 215 L 585 206 L 598 203
L 611 205 L 620 213 L 624 226 L 620 242 L 612 255 L 600 265 L 587 271 Z
M 595 302 L 588 290 L 585 275 L 589 259 L 598 246 L 611 236 L 625 232
L 639 234 L 649 243 L 652 257 L 648 274 L 639 288 L 626 298 L 612 304 Z
M 615 335 L 607 322 L 604 306 L 609 290 L 619 277 L 633 267 L 648 263
L 663 265 L 673 275 L 676 290 L 672 307 L 662 321 L 648 332 L 633 338
L 619 337 Z
M 630 368 L 621 354 L 618 337 L 623 320 L 634 307 L 649 297 L 665 294
L 681 297 L 692 308 L 695 324 L 690 342 L 680 357 L 665 368 L 649 374
L 635 372 Z
M 640 185 L 633 175 L 631 162 L 634 149 L 641 139 L 650 132 L 660 129
L 670 131 L 677 138 L 680 147 L 677 158 L 671 168 L 662 176 L 652 181 Z
M 670 210 L 663 200 L 660 187 L 663 173 L 671 162 L 681 154 L 692 151
L 703 153 L 711 161 L 714 172 L 711 185 L 704 196 L 693 205 L 681 210 Z
M 700 240 L 693 229 L 690 215 L 694 200 L 702 188 L 713 180 L 725 177
L 737 179 L 746 187 L 749 199 L 746 214 L 738 226 L 726 236 L 714 241 Z
M 730 270 L 722 258 L 719 243 L 723 228 L 732 215 L 744 206 L 757 203
L 770 205 L 779 213 L 782 226 L 778 242 L 770 255 L 757 265 L 744 271 Z
M 760 190 L 755 181 L 753 170 L 756 159 L 762 151 L 770 146 L 779 145
L 787 149 L 792 157 L 792 166 L 788 175 L 782 182 L 774 186 L 765 186 Z
M 790 215 L 784 206 L 782 195 L 785 184 L 792 176 L 800 171 L 809 170
L 818 174 L 823 182 L 823 191 L 819 200 L 813 207 L 804 211 L 796 212 Z
M 820 240 L 814 231 L 812 220 L 815 209 L 822 201 L 830 196 L 839 195
L 848 199 L 853 207 L 853 216 L 849 225 L 843 232 L 834 236 L 825 237 Z
M 850 268 L 843 258 L 841 246 L 845 234 L 852 226 L 861 221 L 871 220
L 880 224 L 885 233 L 885 244 L 881 254 L 874 262 L 865 267 L 856 268 Z
M 875 298 L 868 288 L 866 276 L 870 264 L 877 256 L 886 251 L 896 250
L 905 254 L 910 263 L 910 275 L 906 285 L 899 293 L 890 298 L 881 299 Z
M 895 328 L 888 318 L 886 306 L 890 294 L 897 286 L 906 281 L 916 280
L 925 284 L 930 293 L 930 305 L 926 315 L 919 323 L 910 328 L 901 329 Z
M 910 360 L 903 350 L 901 338 L 905 326 L 912 318 L 921 313 L 931 312
L 940 316 L 945 325 L 945 337 L 941 347 L 934 355 L 925 360 L 916 361 Z
M 450 228 L 443 218 L 441 205 L 444 191 L 452 181 L 462 174 L 474 171
L 486 173 L 494 182 L 497 194 L 494 208 L 487 220 L 475 229 L 462 234 Z
M 460 260 L 453 249 L 451 235 L 455 220 L 463 209 L 474 201 L 487 198
L 499 200 L 508 209 L 511 222 L 508 237 L 500 250 L 488 260 L 474 265 Z
M 465 292 L 458 280 L 456 265 L 461 250 L 469 238 L 481 229 L 494 226
L 507 229 L 516 239 L 519 253 L 516 269 L 508 282 L 495 292 L 481 298 Z
M 295 250 L 290 240 L 288 228 L 291 215 L 298 205 L 307 198 L 317 195
L 327 197 L 335 205 L 338 216 L 335 229 L 328 240 L 318 248 L 307 252 Z
M 310 280 L 305 270 L 302 258 L 306 245 L 313 235 L 322 228 L 332 225
L 342 228 L 350 236 L 352 248 L 349 261 L 342 272 L 332 280 L 321 284 Z
M 315 312 L 309 302 L 307 290 L 311 277 L 318 267 L 328 260 L 338 257
L 348 260 L 356 268 L 359 280 L 355 294 L 348 305 L 338 313 L 326 317 Z
M 320 345 L 313 334 L 311 321 L 315 308 L 323 298 L 333 291 L 344 289
L 354 292 L 362 301 L 364 313 L 361 327 L 353 339 L 342 347 L 330 351 Z
M 322 378 L 315 367 L 313 354 L 317 340 L 325 330 L 336 323 L 347 321
L 358 325 L 366 334 L 368 347 L 365 361 L 357 373 L 345 381 L 333 385 Z
M 555 270 L 548 260 L 545 247 L 549 233 L 557 223 L 567 216 L 578 213
L 589 215 L 598 223 L 601 235 L 598 249 L 591 261 L 580 270 L 568 275 Z
M 580 302 L 573 292 L 570 279 L 574 265 L 582 254 L 593 247 L 604 244
L 616 246 L 624 254 L 628 266 L 624 281 L 617 293 L 605 302 L 593 307 Z
M 600 335 L 592 324 L 590 311 L 594 297 L 602 286 L 614 279 L 626 276
L 638 279 L 646 288 L 649 301 L 645 316 L 638 329 L 625 338 L 612 343 Z
M 615 368 L 607 357 L 605 344 L 609 330 L 618 319 L 630 312 L 642 309
L 654 312 L 663 322 L 665 336 L 661 351 L 653 364 L 640 373 L 627 377 Z
M 830 148 L 825 140 L 824 131 L 828 122 L 835 116 L 843 113 L 852 114
L 859 120 L 861 129 L 858 138 L 852 145 L 844 148 L 836 148 Z
M 860 168 L 854 160 L 852 151 L 856 142 L 863 136 L 871 133 L 880 134
L 887 140 L 889 149 L 886 158 L 880 165 L 872 168 L 864 168 Z
M 890 188 L 883 180 L 881 171 L 885 162 L 892 156 L 900 153 L 909 154
L 916 160 L 918 169 L 915 178 L 909 185 L 901 188 L 893 188 Z
M 920 210 L 913 202 L 910 193 L 914 183 L 921 177 L 929 174 L 939 175
L 946 181 L 948 190 L 945 200 L 939 207 L 930 210 L 922 210 Z
M 945 232 L 938 224 L 935 215 L 939 205 L 946 198 L 955 195 L 965 196
L 972 203 L 974 212 L 971 222 L 964 229 L 956 233 L 948 232 Z
M 965 255 L 958 247 L 955 238 L 959 228 L 966 221 L 975 218 L 985 219
L 992 226 L 994 235 L 991 245 L 984 252 L 976 255 L 967 255 Z
M 975 278 L 968 270 L 966 261 L 970 251 L 977 244 L 986 241 L 996 242
L 1003 249 L 1005 258 L 1002 268 L 995 275 L 987 278 L 978 278 Z
M 170 268 L 163 258 L 161 245 L 165 231 L 173 221 L 184 214 L 196 211
L 208 214 L 216 222 L 219 234 L 216 249 L 208 261 L 197 270 L 184 275 Z
M 190 300 L 182 290 L 180 276 L 184 261 L 193 250 L 205 242 L 218 239
L 231 242 L 240 251 L 243 265 L 239 280 L 231 293 L 218 302 L 205 308 Z
M 205 334 L 197 324 L 195 310 L 199 295 L 208 284 L 221 276 L 235 273
L 248 276 L 258 286 L 260 300 L 257 315 L 249 328 L 235 337 L 221 342 Z
M 215 368 L 207 358 L 205 344 L 210 329 L 219 318 L 232 310 L 246 308
L 259 311 L 269 321 L 271 336 L 267 351 L 259 364 L 245 373 L 230 377 Z
M 222 403 L 213 393 L 211 379 L 216 364 L 225 353 L 239 345 L 254 343
L 267 347 L 278 357 L 280 372 L 276 387 L 267 401 L 252 410 L 237 414 Z
M 410 300 L 404 292 L 402 282 L 406 272 L 413 265 L 421 261 L 430 261
L 438 266 L 442 275 L 440 285 L 435 294 L 427 300 L 418 302 Z
M 420 325 L 414 317 L 412 307 L 416 297 L 423 290 L 432 286 L 441 286
L 449 292 L 453 301 L 451 311 L 445 320 L 437 326 L 428 328 Z
M 425 350 L 419 342 L 417 332 L 422 322 L 429 315 L 438 311 L 447 311
L 455 317 L 459 327 L 456 337 L 450 346 L 441 352 L 432 354 Z
M 428 376 L 422 368 L 420 358 L 424 348 L 432 341 L 441 337 L 450 337
L 458 344 L 462 354 L 459 365 L 452 373 L 443 378 L 434 379 Z
M 428 402 L 421 394 L 419 384 L 424 374 L 432 367 L 442 363 L 452 364
L 459 371 L 463 381 L 459 392 L 452 400 L 442 405 L 433 405 Z
M 50 248 L 44 241 L 41 232 L 43 222 L 49 215 L 57 211 L 65 211
L 73 216 L 77 224 L 75 234 L 70 243 L 62 248 L 54 249 Z
M 55 275 L 49 268 L 46 259 L 48 249 L 54 242 L 62 238 L 70 238
L 78 244 L 82 252 L 80 262 L 75 271 L 66 276 L 58 277 Z
M 56 303 L 49 296 L 47 287 L 49 277 L 55 270 L 63 266 L 71 267
L 79 273 L 83 282 L 81 292 L 75 301 L 67 305 L 59 305 Z
M 54 330 L 47 323 L 45 314 L 48 304 L 54 297 L 62 293 L 70 294
L 78 300 L 82 310 L 80 320 L 74 329 L 66 333 L 58 333 Z
M 50 357 L 43 350 L 41 341 L 44 331 L 51 324 L 59 320 L 67 321
L 75 328 L 79 337 L 77 348 L 70 357 L 62 361 L 54 361 Z
M 760 310 L 755 302 L 753 292 L 757 282 L 763 275 L 772 271 L 781 272
L 789 278 L 792 288 L 789 298 L 783 306 L 774 310 L 765 311 Z
M 775 338 L 769 330 L 767 320 L 771 310 L 777 302 L 786 298 L 795 299
L 803 306 L 806 316 L 803 326 L 797 334 L 788 339 L 779 339 Z
M 788 368 L 781 359 L 780 349 L 784 339 L 791 332 L 800 328 L 809 329
L 817 336 L 820 346 L 817 357 L 810 365 L 801 369 L 792 369 Z
M 795 398 L 789 390 L 787 380 L 791 370 L 798 363 L 807 359 L 816 360
L 824 367 L 827 377 L 823 388 L 816 396 L 807 400 L 798 400 Z
M 700 318 L 694 310 L 692 300 L 696 290 L 703 283 L 712 279 L 721 280
L 729 286 L 732 296 L 729 307 L 723 315 L 714 319 L 705 319 Z
M 715 348 L 709 339 L 707 329 L 711 319 L 718 312 L 727 308 L 736 309
L 744 316 L 747 326 L 744 337 L 737 345 L 728 349 L 719 349 Z
M 725 378 L 719 369 L 717 359 L 721 349 L 728 342 L 737 338 L 746 339
L 754 346 L 757 357 L 753 368 L 746 376 L 737 380 L 728 380 Z
M 730 408 L 723 399 L 721 389 L 726 379 L 733 372 L 742 368 L 752 369
L 760 376 L 763 387 L 759 398 L 752 406 L 742 410 L 733 410 Z
M 648 325 L 642 316 L 641 306 L 645 296 L 652 289 L 661 286 L 670 287
L 677 294 L 680 304 L 677 315 L 670 322 L 661 326 L 652 326 Z
M 660 353 L 654 344 L 652 334 L 656 323 L 663 316 L 673 312 L 682 313
L 690 320 L 693 331 L 690 342 L 683 350 L 673 354 L 664 354 Z
M 668 382 L 661 373 L 659 362 L 664 351 L 671 344 L 681 340 L 691 341
L 699 349 L 701 360 L 698 371 L 690 380 L 680 384 L 671 384 Z
M 671 411 L 664 401 L 662 390 L 667 379 L 675 371 L 685 368 L 695 369
L 703 377 L 706 388 L 702 400 L 694 408 L 684 413 L 674 413 Z
M 840 340 L 834 332 L 832 323 L 836 313 L 843 306 L 852 302 L 861 303
L 869 310 L 872 320 L 869 331 L 862 339 L 853 343 L 844 343 Z
M 855 368 L 849 360 L 847 351 L 851 341 L 858 334 L 867 331 L 876 332
L 884 339 L 887 349 L 884 360 L 877 368 L 868 372 L 859 372 Z
M 865 396 L 859 388 L 857 379 L 861 369 L 868 362 L 877 359 L 886 360
L 894 367 L 897 377 L 894 388 L 887 396 L 878 400 L 869 400 Z
M 870 424 L 864 416 L 862 406 L 866 396 L 873 389 L 883 386 L 892 387
L 900 394 L 903 404 L 899 415 L 892 423 L 882 427 L 873 427 Z
M 920 355 L 914 347 L 912 338 L 916 328 L 923 321 L 932 317 L 941 318
L 949 325 L 952 335 L 949 346 L 942 354 L 933 358 L 924 358 Z
M 935 382 L 929 374 L 927 365 L 931 355 L 938 348 L 947 344 L 956 345
L 964 352 L 967 362 L 964 373 L 957 381 L 948 385 L 939 385 Z
M 945 409 L 939 401 L 937 392 L 941 382 L 948 375 L 957 371 L 966 372
L 974 380 L 977 390 L 973 401 L 966 409 L 957 413 L 948 413 Z
M 960 435 L 953 427 L 952 418 L 956 408 L 963 401 L 973 397 L 982 398
L 990 406 L 993 416 L 989 427 L 982 435 L 972 439 L 963 439 Z
`
// ─── WORLD_PATH end ────────────────────────────────────────────────────────────
// (The path above is a stylised approximation that renders continent outlines.
// Real shape data is intentionally abstracted for minimal aesthetic.)
// ─── Mock recent activity builder ─────────────────────────────────────────────
// Pulls from the MQTT status map. In a real setup you'd call /devices/logs.
function buildRecentActivity(mqttStatusMap) {
const entries = Object.values(mqttStatusMap)
.filter((s) => s.last_seen)
.sort((a, b) => new Date(b.last_seen) - new Date(a.last_seen))
.slice(0, 5)
return entries
}
// ─── Drawer info row ──────────────────────────────────────────────────────────
function DrawerRow({ label, value, mono = false, muted = false }) {
if (!value && value !== 0) return null
return (
<div className="dmv-drawer-row">
<span className="dmv-drawer-row__label">{label}</span>
<span
className={[
'dmv-drawer-row__value',
mono ? 'dmv-drawer-row__value--mono' : '',
muted ? 'dmv-drawer-row__value--muted' : '',
].filter(Boolean).join(' ')}
>
{value}
</span>
</div>
)
}
// ─── DeviceListMapView ─────────────────────────────────────────────────────────
export default function DeviceListMapView({ devices, loading, mqttStatusMap, onView }) {
const navigate = useNavigate()
// Viewport transform (pan + zoom)
const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 })
const [targetTransform, setTargetTransform] = useState(null)
const [isDragging, setIsDragging] = useState(false)
const dragStart = useRef(null)
const svgRef = useRef(null)
const containerRef = useRef(null)
// Selected device
const [selected, setSelected] = useState(null)
const [drawerOpen, setDrawerOpen] = useState(false)
// Devices that have coordinates (placeable on the map)
const mappedDevices = useMemo(() => {
return devices.filter((d) => parseCoords(d.device_location_coordinates))
}, [devices])
// Fleet health stats
const stats = useMemo(() => {
const total = devices.length
let online = 0
let withIssues = 0
devices.forEach((d) => {
const sn = d.serial_number || d.device_id
const mqtt = mqttStatusMap[sn]
const isOnline = mqtt ? mqtt.online : (d.is_Online ?? false)
const hasIssues = (d.device_stats?.totalWarningsGiven ?? 0) > 0
if (isOnline) online++
if (hasIssues) withIssues++
})
return { total, online, offline: total - online, withIssues }
}, [devices, mqttStatusMap])
// Recent activity
const recentActivity = useMemo(() => buildRecentActivity(mqttStatusMap), [mqttStatusMap])
// ── Pan & zoom ────────────────────────────────────────────────────────────
// Smooth animate toward target
useEffect(() => {
if (!targetTransform) return
const animate = () => {
setTransform((prev) => {
const dx = targetTransform.x - prev.x
const dy = targetTransform.y - prev.y
const ds = targetTransform.scale - prev.scale
if (Math.abs(dx) < 0.1 && Math.abs(dy) < 0.1 && Math.abs(ds) < 0.001) {
setTargetTransform(null)
return targetTransform
}
return {
x: prev.x + dx * 0.12,
y: prev.y + dy * 0.12,
scale: prev.scale + ds * 0.12,
}
})
}
const raf = requestAnimationFrame(animate)
return () => cancelAnimationFrame(raf)
}, [targetTransform, transform])
const handleMouseDown = useCallback((e) => {
if (e.button !== 0) return
setIsDragging(true)
dragStart.current = { x: e.clientX - transform.x, y: e.clientY - transform.y }
}, [transform])
const handleMouseMove = useCallback((e) => {
if (!isDragging || !dragStart.current) return
setTransform((prev) => ({
...prev,
x: e.clientX - dragStart.current.x,
y: e.clientY - dragStart.current.y,
}))
}, [isDragging])
const handleMouseUp = useCallback(() => {
setIsDragging(false)
dragStart.current = null
}, [])
const handleWheel = useCallback((e) => {
e.preventDefault()
const delta = e.deltaY > 0 ? 0.9 : 1.1
setTransform((prev) => {
const newScale = Math.max(0.6, Math.min(8, prev.scale * delta))
// Zoom toward mouse cursor
const rect = containerRef.current?.getBoundingClientRect()
if (!rect) return { ...prev, scale: newScale }
const cx = e.clientX - rect.left
const cy = e.clientY - rect.top
return {
scale: newScale,
x: cx - (cx - prev.x) * (newScale / prev.scale),
y: cy - (cy - prev.y) * (newScale / prev.scale),
}
})
}, [])
// ── Select a device — pan map to centre it ────────────────────────────────
const selectDevice = useCallback((device) => {
setSelected(device)
setDrawerOpen(true)
const coords = parseCoords(device.device_location_coordinates)
if (!coords || !containerRef.current) return
const rect = containerRef.current.getBoundingClientRect()
const { x: svgX, y: svgY } = latLngToXY(coords.lat, coords.lng)
// The map SVG is rendered at a base scale inside the container.
// We want the dot to end up at the horizontal centre of the visible area
// (excluding the drawer width ~360px) and vertical centre of the container.
const drawerWidth = 360
const targetScale = Math.max(transform.scale, 2.0)
const visibleW = rect.width - drawerWidth
const visibleH = rect.height
// Container pixel coords of the dot at current scale
// containerX = svgX * (containerW / MAP_W) * scale + offsetX
// We want containerX (in visible area centre) = visibleW / 2
const baseRatio = rect.width / MAP_W
const dotContainerX = svgX * baseRatio * targetScale
const dotContainerY = svgY * baseRatio * targetScale
setTargetTransform({
scale: targetScale,
x: visibleW / 2 - dotContainerX,
y: visibleH / 2 - dotContainerY,
})
}, [transform.scale])
const closeDrawer = useCallback(() => {
setDrawerOpen(false)
setTimeout(() => setSelected(null), 300)
}, [])
// ── Zoom controls ─────────────────────────────────────────────────────────
const zoomIn = () => {
setTransform((prev) => ({ ...prev, scale: Math.min(8, prev.scale * 1.3) }))
}
const zoomOut = () => {
setTransform((prev) => ({ ...prev, scale: Math.max(0.6, prev.scale / 1.3) }))
}
const resetView = () => {
setTargetTransform({ x: 0, y: 0, scale: 1 })
}
// ── Selected device data ──────────────────────────────────────────────────
const selectedStatus = selected ? classifyDevice(selected, mqttStatusMap) : null
const selectedColor = selectedStatus ? STATUS_COLORS[selectedStatus] : null
const selectedSN = selected ? (selected.serial_number || selected.device_id || '—') : null
const selectedAttr = selected?.device_attributes || {}
const selectedSub = selected?.device_subscription || {}
const selectedStats = selected?.device_stats || {}
const selectedMqtt = selected ? mqttStatusMap[selectedSN] : null
const selectedUptime = selectedMqtt?.uptime_seconds != null
? formatUptime(selectedMqtt.uptime_seconds)
: null
// ── Render ────────────────────────────────────────────────────────────────
if (loading) {
return (
<div className="dmv-loading">
<Spinner size="lg" />
<p className="dmv-loading__text">Loading device fleet…</p>
</div>
)
}
return (
<>
<style>{MAP_STYLES}</style>
<div className="dmv-root" ref={containerRef}>
{/* ── Map canvas ──────────────────────────────────────────────────── */}
<div
className={`dmv-canvas ${isDragging ? 'dmv-canvas--dragging' : ''}`}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
>
<svg
ref={svgRef}
viewBox={`0 0 ${MAP_W} ${MAP_H}`}
className="dmv-svg"
style={{
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`,
transformOrigin: '0 0',
}}
aria-label="World map showing device locations"
>
{/* ── Dot-grid background ── */}
<defs>
<pattern id="dmv-dots" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.6" fill="rgba(192,193,255,0.08)" />
</pattern>
{/* Breathing ring animations */}
<style>{`
@keyframes dmv-ring-pulse {
0% { r: 6; opacity: 0.8; }
100% { r: 18; opacity: 0; }
}
@keyframes dmv-ring-pulse-sel {
0% { r: 10; opacity: 0.9; }
100% { r: 28; opacity: 0; }
}
`}</style>
</defs>
<rect width={MAP_W} height={MAP_H} fill="url(#dmv-dots)" />
{/* ── World outline ── */}
<path
d={WORLD_PATH}
fill="rgba(192,193,255,0.06)"
stroke="rgba(192,193,255,0.18)"
strokeWidth="0.6"
strokeLinejoin="round"
/>
{/* ── Device dots ── */}
{mappedDevices.map((device) => {
const coords = parseCoords(device.device_location_coordinates)
if (!coords) return null
const { x, y } = latLngToXY(coords.lat, coords.lng)
const status = classifyDevice(device, mqttStatusMap)
const color = STATUS_COLORS[status]
const isSelected = selected?.id === device.id
const r = isSelected ? 7 : 4.5
return (
<g
key={device.id}
onClick={(e) => { e.stopPropagation(); selectDevice(device) }}
style={{ cursor: 'pointer' }}
role="button"
aria-label={`${device.console_name || device.device_name || 'Device'} — ${color.label}`}
>
{/* Breathing ring */}
<circle
cx={x}
cy={y}
r={isSelected ? 10 : 6}
fill="none"
stroke={color.dot}
strokeWidth="1"
style={{
animation: `${isSelected ? 'dmv-ring-pulse-sel' : 'dmv-ring-pulse'} 2.4s ease-out infinite`,
transformOrigin: `${x}px ${y}px`,
}}
/>
{/* Static halo */}
<circle
cx={x}
cy={y}
r={isSelected ? 11 : 7}
fill={color.ring}
/>
{/* Core dot */}
<circle
cx={x}
cy={y}
r={r}
fill={color.dot}
style={{
filter: `drop-shadow(0 0 ${isSelected ? 8 : 4}px ${color.dot})`,
transition: 'r 300ms ease',
}}
/>
</g>
)
})}
{/* Empty state dots for devices without coords */}
{mappedDevices.length === 0 && !loading && (
<text
x={MAP_W / 2}
y={MAP_H / 2}
textAnchor="middle"
dominantBaseline="middle"
fill="rgba(192,193,255,0.25)"
fontSize="14"
fontFamily="var(--font-family-base)"
>
No devices with location data
</text>
)}
</svg>
</div>
{/* ── Zoom controls ────────────────────────────────────────────── */}
<div className={`dmv-zoom-ctrl ${drawerOpen ? 'dmv-zoom-ctrl--drawer' : ''}`}>
<button className="dmv-zoom-btn" onClick={zoomIn} aria-label="Zoom in">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<div className="dmv-zoom-divider" />
<button className="dmv-zoom-btn" onClick={zoomOut} aria-label="Zoom out">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<div className="dmv-zoom-divider" />
<button className="dmv-zoom-btn" onClick={resetView} aria-label="Reset view" title="Reset view">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
</button>
</div>
{/* ── Fleet health widget (top-left) ────────────────────────────── */}
<div className="dmv-widget dmv-widget--tl">
<div className="dmv-widget__header">
<span className="dmv-widget__title">Fleet</span>
<span className="dmv-widget__count">{stats.total} devices</span>
</div>
{/* Ratio bar */}
<div className="dmv-ratio-bar" aria-label={`${stats.online} online, ${stats.offline} offline`}>
<div
className="dmv-ratio-bar__fill dmv-ratio-bar__fill--online"
style={{ width: stats.total > 0 ? `${(stats.online / stats.total) * 100}%` : '0%' }}
/>
</div>
<div className="dmv-fleet-rows">
<div className="dmv-fleet-row">
<span className="dmv-fleet-dot dmv-fleet-dot--online" aria-hidden="true" />
<span className="dmv-fleet-row__label">Online</span>
<span className="dmv-fleet-row__val">{stats.online}</span>
</div>
<div className="dmv-fleet-row">
<span className="dmv-fleet-dot dmv-fleet-dot--offline" aria-hidden="true" />
<span className="dmv-fleet-row__label">Offline</span>
<span className="dmv-fleet-row__val">{stats.offline}</span>
</div>
{stats.withIssues > 0 && (
<div className="dmv-fleet-row">
<span className="dmv-fleet-dot dmv-fleet-dot--warn" aria-hidden="true" />
<span className="dmv-fleet-row__label">With Issues</span>
<span className="dmv-fleet-row__val dmv-fleet-row__val--warn">{stats.withIssues}</span>
</div>
)}
</div>
{/* Dot legend */}
<div className="dmv-legend">
<div className="dmv-legend-item">
<span className="dmv-legend-dot" style={{ background: 'var(--color-success)' }} />
<span>Online, OK</span>
</div>
<div className="dmv-legend-item">
<span className="dmv-legend-dot" style={{ background: 'var(--color-danger)' }} />
<span>Online, Issues</span>
</div>
<div className="dmv-legend-item">
<span className="dmv-legend-dot" style={{ background: 'var(--color-info)' }} />
<span>Offline, OK</span>
</div>
<div className="dmv-legend-item">
<span className="dmv-legend-dot" style={{ background: 'var(--color-warning)' }} />
<span>Offline, Issues</span>
</div>
</div>
</div>
{/* ── Latest activity widget (bottom-left) ─────────────────────── */}
{recentActivity.length > 0 && (
<div className="dmv-widget dmv-widget--bl">
<div className="dmv-widget__header">
<span className="dmv-widget__title">Latest Activity</span>
<span className="dmv-widget__live" aria-label="Live feed">
<span className="dmv-widget__live-dot" aria-hidden="true" />
Live
</span>
</div>
<div className="dmv-activity-list">
{recentActivity.map((entry, i) => {
const isOnline = entry.online
return (
<div key={i} className="dmv-activity-item">
<span
className="dmv-activity-dot"
style={{ background: isOnline ? 'var(--color-success)' : 'var(--color-text-muted)' }}
aria-hidden="true"
/>
<div className="dmv-activity-text">
<span className="dmv-activity-serial">{entry.device_serial || '—'}</span>
<span className="dmv-activity-time">
{entry.last_seen ? fmtRelative(entry.last_seen) : '—'}
</span>
</div>
<span className="dmv-activity-status">
{isOnline ? 'connected' : 'disconnected'}
</span>
</div>
)
})}
</div>
</div>
)}
{/* ── Device info drawer (right side) ──────────────────────────── */}
<aside
className={`dmv-drawer ${drawerOpen ? 'dmv-drawer--open' : ''}`}
aria-label="Device details"
aria-hidden={!drawerOpen}
>
{selected && (
<>
{/* Drawer header */}
<div className="dmv-drawer__header">
<div className="dmv-drawer__title-row">
<span
className="dmv-drawer__status-dot"
style={{ background: selectedColor?.dot }}
aria-hidden="true"
/>
<h2 className="dmv-drawer__name">
{selected.console_name || selected.device_name || 'Unnamed Device'}
</h2>
<button
className="dmv-drawer__close"
onClick={closeDrawer}
aria-label="Close device details"
>
<Icon name="close" size={16} />
</button>
</div>
<span
className="dmv-drawer__status-label"
style={{ color: selectedColor?.dot }}
>
{selectedColor?.label}
</span>
</div>
{/* Drawer body — info rows */}
<div className="dmv-drawer__body">
<section className="dmv-drawer__section">
<DrawerRow label="Serial Number" value={selectedSN} mono />
<DrawerRow
label="Hardware"
value={
selected.hw_family
? `${selected.hw_family}${selected.hw_revision ? ` rev. ${selected.hw_revision}` : ''}`
: null
}
mono
/>
</section>
<div className="dmv-drawer__divider" />
<section className="dmv-drawer__section">
<DrawerRow
label="Bells"
value={selectedAttr.hasBells ? `Yes — ${selectedAttr.totalBells ?? 0} total` : 'No bells'}
/>
<DrawerRow
label="Clock"
value={selectedAttr.hasClock ? 'Yes' : 'No'}
/>
</section>
<div className="dmv-drawer__divider" />
<section className="dmv-drawer__section">
{selected.staff_notes && (
<DrawerRow label="Note" value={selected.staff_notes} />
)}
{selectedUptime && (
<DrawerRow label="Uptime" value={selectedUptime} />
)}
<DrawerRow
label="Location"
value={selected.device_location || null}
/>
<DrawerRow
label="Tier"
value={selectedSub.subscrTier
? selectedSub.subscrTier.charAt(0).toUpperCase() + selectedSub.subscrTier.slice(1)
: null
}
/>
</section>
{/* Auth access users */}
{Array.isArray(selected.user_list) && selected.user_list.length > 0 && (
<>
<div className="dmv-drawer__divider" />
<section className="dmv-drawer__section">
<p className="dmv-drawer__section-label">Auth Access</p>
<div className="dmv-user-list">
{selected.user_list.slice(0, 5).map((uid, i) => (
<div key={i} className="dmv-user-chip">
<span className="dmv-user-chip__avatar" aria-hidden="true">
{(uid || '?').toString().charAt(0).toUpperCase()}
</span>
<span className="dmv-user-chip__id">{uid}</span>
</div>
))}
{selected.user_list.length > 5 && (
<span className="dmv-user-more">+{selected.user_list.length - 5} more</span>
)}
</div>
</section>
</>
)}
{/* Issues / warnings */}
{(selectedStats.totalWarningsGiven ?? 0) > 0 && (
<>
<div className="dmv-drawer__divider" />
<section className="dmv-drawer__section">
<p className="dmv-drawer__section-label">Issues</p>
<div className="dmv-issues-row">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--color-warning)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span className="dmv-issues-row__text">
{selectedStats.totalWarningsGiven} warning{selectedStats.totalWarningsGiven !== 1 ? 's' : ''} recorded
</span>
</div>
</section>
</>
)}
</div>
{/* Drawer footer */}
<div className="dmv-drawer__footer">
<Button
variant="primary"
size="md"
onClick={() => navigate(`/devices/${selected.id}`)}
style={{ width: '100%', justifyContent: 'center' }}
>
Open Full Details
</Button>
</div>
</>
)}
</aside>
{/* ── No-coords notice ──────────────────────────────────────────── */}
{devices.length > 0 && mappedDevices.length === 0 && (
<div className="dmv-no-coords">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>
<span>No devices have location coordinates set.</span>
</div>
)}
</div>
</>
)
}
// ─── Styles ────────────────────────────────────────────────────────────────────
const MAP_STYLES = `
/* ── Root ─────────────────────────────────────────────────────────────────── */
.dmv-root {
position: relative;
width: 100%;
height: calc(100vh - var(--header-height) - 48px - 56px - var(--space-3));
min-height: 520px;
overflow: hidden;
border-radius: var(--radius-xl);
border: 1px solid var(--color-border);
background: var(--color-bg-abyss);
}
/* ── Canvas ───────────────────────────────────────────────────────────────── */
.dmv-canvas {
position: absolute;
inset: 0;
cursor: grab;
overflow: hidden;
}
.dmv-canvas--dragging {
cursor: grabbing;
}
.dmv-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
will-change: transform;
}
/* ── Loading ──────────────────────────────────────────────────────────────── */
.dmv-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-4);
width: 100%;
height: 480px;
}
.dmv-loading__text {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
margin: 0;
}
/* ── Glass widget base ────────────────────────────────────────────────────── */
.dmv-widget {
position: absolute;
z-index: 20;
background: rgba(16, 20, 26, 0.82);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-4) var(--space-4);
box-shadow: var(--shadow-lg);
pointer-events: auto;
min-width: 192px;
}
/* ── Top-left fleet widget ────────────────────────────────────────────────── */
.dmv-widget--tl {
top: var(--space-4);
left: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
/* ── Bottom-left activity widget ──────────────────────────────────────────── */
.dmv-widget--bl {
bottom: var(--space-4);
left: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
max-width: 260px;
}
/* ── Widget header ────────────────────────────────────────────────────────── */
.dmv-widget__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.dmv-widget__title {
font-family: var(--font-family-display);
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
letter-spacing: var(--tracking-tight);
}
.dmv-widget__count {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-text-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
/* ── Live indicator ───────────────────────────────────────────────────────── */
.dmv-widget__live {
display: flex;
align-items: center;
gap: 5px;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
color: var(--color-success);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.dmv-widget__live-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-success);
animation: dmv-live-pulse 1.8s ease-in-out infinite;
}
@keyframes dmv-live-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.45; transform: scale(0.7); }
}
/* ── Fleet ratio bar ──────────────────────────────────────────────────────── */
.dmv-ratio-bar {
width: 100%;
height: 4px;
border-radius: var(--radius-full);
background: var(--color-bg-island);
overflow: hidden;
}
.dmv-ratio-bar__fill {
height: 100%;
border-radius: var(--radius-full);
transition: width 600ms ease;
}
.dmv-ratio-bar__fill--online {
background: var(--color-success);
}
/* ── Fleet rows ───────────────────────────────────────────────────────────── */
.dmv-fleet-rows {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.dmv-fleet-row {
display: flex;
align-items: center;
gap: var(--space-2);
}
.dmv-fleet-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.dmv-fleet-dot--online { background: var(--color-success); }
.dmv-fleet-dot--offline { background: var(--color-info); }
.dmv-fleet-dot--warn { background: var(--color-warning); }
.dmv-fleet-row__label {
flex: 1;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.dmv-fleet-row__val {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.dmv-fleet-row__val--warn {
color: var(--color-warning);
}
/* ── Dot legend ───────────────────────────────────────────────────────────── */
.dmv-legend {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: var(--space-2);
border-top: 1px solid var(--color-border);
}
.dmv-legend-item {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.dmv-legend-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
/* ── Activity list ────────────────────────────────────────────────────────── */
.dmv-activity-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.dmv-activity-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 5px 0;
}
.dmv-activity-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.dmv-activity-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.dmv-activity-serial {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dmv-activity-time {
font-size: 10px;
color: var(--color-text-muted);
}
.dmv-activity-status {
font-size: 10px;
font-weight: var(--font-weight-medium);
color: var(--color-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
/* ── Zoom controls ────────────────────────────────────────────────────────── */
.dmv-zoom-ctrl {
position: absolute;
top: var(--space-4);
right: var(--space-4);
z-index: 20;
display: flex;
flex-direction: column;
background: rgba(16, 20, 26, 0.82);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-md);
transition: right 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.dmv-zoom-ctrl--drawer {
right: calc(360px + var(--space-4));
}
.dmv-zoom-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: transparent;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
transition: background 150ms ease, color 150ms ease;
}
.dmv-zoom-btn:hover {
background: var(--color-bg-island);
color: var(--color-text-primary);
}
.dmv-zoom-divider {
width: 100%;
height: 1px;
background: var(--color-border);
}
/* ── Drawer ───────────────────────────────────────────────────────────────── */
.dmv-drawer {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 360px;
z-index: 30;
background: var(--color-bg-void);
border-left: 1px solid var(--color-border);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: -12px 0 40px rgba(0,0,0,0.40);
}
.dmv-drawer--open {
transform: translateX(0);
}
.dmv-drawer__header {
padding: var(--space-5) var(--space-5) var(--space-4);
border-bottom: 1px solid var(--color-border);
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.dmv-drawer__title-row {
display: flex;
align-items: center;
gap: var(--space-2);
}
.dmv-drawer__status-dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex-shrink: 0;
}
.dmv-drawer__name {
flex: 1;
min-width: 0;
font-family: var(--font-family-display);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
letter-spacing: var(--tracking-tight);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dmv-drawer__close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-md);
border: none;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: background 150ms ease, color 150ms ease;
flex-shrink: 0;
}
.dmv-drawer__close:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.dmv-drawer__status-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding-left: calc(9px + var(--space-2));
}
.dmv-drawer__body {
flex: 1;
overflow-y: auto;
padding: var(--space-4) var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
scrollbar-width: thin;
scrollbar-color: var(--color-bg-island) transparent;
}
.dmv-drawer__body::-webkit-scrollbar { width: 4px; }
.dmv-drawer__body::-webkit-scrollbar-track { background: transparent; }
.dmv-drawer__body::-webkit-scrollbar-thumb { background: var(--color-bg-island); border-radius: 4px; }
.dmv-drawer__section {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.dmv-drawer__section-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
margin: 0 0 var(--space-1);
}
.dmv-drawer__divider {
width: 100%;
height: 1px;
background: var(--color-border);
}
.dmv-drawer__footer {
padding: var(--space-4) var(--space-5);
border-top: 1px solid var(--color-border);
background: rgba(10, 14, 20, 0.40);
}
/* ── Drawer info row ──────────────────────────────────────────────────────── */
.dmv-drawer-row {
display: flex;
align-items: flex-start;
gap: var(--space-3);
min-height: 26px;
}
.dmv-drawer-row__label {
flex-shrink: 0;
width: 100px;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
padding-top: 1px;
}
.dmv-drawer-row__value {
flex: 1;
min-width: 0;
font-size: var(--font-size-sm);
color: var(--color-text-primary);
font-weight: var(--font-weight-medium);
word-break: break-word;
}
.dmv-drawer-row__value--mono {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: rgba(123, 208, 255, 0.85);
}
.dmv-drawer-row__value--muted {
color: var(--color-text-muted);
}
/* ── Auth user chips ──────────────────────────────────────────────────────── */
.dmv-user-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.dmv-user-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 9px 3px 4px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
}
.dmv-user-chip__avatar {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-primary-subtle);
border: 1px solid rgba(192, 193, 255, 0.20);
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: var(--font-weight-bold);
color: var(--color-primary);
line-height: 1;
flex-shrink: 0;
}
.dmv-user-chip__id {
font-family: var(--font-family-mono);
font-size: 10px;
color: var(--color-text-muted);
max-width: 120px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dmv-user-more {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
padding: 2px 0;
}
/* ── Issues row ───────────────────────────────────────────────────────────── */
.dmv-issues-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--color-warning-bg);
border-radius: var(--radius-md);
border: 1px solid rgba(251, 191, 36, 0.18);
}
.dmv-issues-row__text {
font-size: var(--font-size-sm);
color: var(--color-warning);
font-weight: var(--font-weight-medium);
}
/* ── No coords notice ─────────────────────────────────────────────────────── */
.dmv-no-coords {
position: absolute;
bottom: var(--space-4);
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
background: rgba(16, 20, 26, 0.82);
backdrop-filter: blur(8px);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
pointer-events: none;
z-index: 15;
}
/* ── Responsive ───────────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.dmv-drawer {
width: 100%;
top: auto;
height: 70%;
transform: translateY(100%);
border-left: none;
border-top: 1px solid var(--color-border);
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
}
.dmv-drawer--open {
transform: translateY(0);
}
.dmv-widget--tl {
top: var(--space-3);
left: var(--space-3);
}
.dmv-widget--bl {
display: none;
}
.dmv-zoom-ctrl {
top: auto;
bottom: var(--space-3);
right: var(--space-3);
flex-direction: row;
}
.dmv-zoom-ctrl--drawer {
right: var(--space-3);
bottom: calc(70% + var(--space-3));
}
.dmv-zoom-divider {
width: 1px;
height: 100%;
}
}
`