// 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 (
{label} {value}
) } // ─── 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 (

Loading device fleet…

) } return ( <>
{/* ── Map canvas ──────────────────────────────────────────────────── */}
{/* ── Dot-grid background ── */} {/* Breathing ring animations */} {/* ── World outline ── */} {/* ── 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 ( { e.stopPropagation(); selectDevice(device) }} style={{ cursor: 'pointer' }} role="button" aria-label={`${device.console_name || device.device_name || 'Device'} — ${color.label}`} > {/* Breathing ring */} {/* Static halo */} {/* Core dot */} ) })} {/* Empty state dots for devices without coords */} {mappedDevices.length === 0 && !loading && ( No devices with location data )}
{/* ── Zoom controls ────────────────────────────────────────────── */}
{/* ── Fleet health widget (top-left) ────────────────────────────── */}
Fleet {stats.total} devices
{/* Ratio bar */}
0 ? `${(stats.online / stats.total) * 100}%` : '0%' }} />
{stats.withIssues > 0 && (
)}
{/* Dot legend */}
Online, OK
Online, Issues
Offline, OK
Offline, Issues
{/* ── Latest activity widget (bottom-left) ─────────────────────── */} {recentActivity.length > 0 && (
Latest Activity
{recentActivity.map((entry, i) => { const isOnline = entry.online return (
) })}
)} {/* ── Device info drawer (right side) ──────────────────────────── */} {/* ── No-coords notice ──────────────────────────────────────────── */} {devices.length > 0 && mappedDevices.length === 0 && (
No devices have location coordinates set.
)}
) } // ─── 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%; } } `