diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f6cb45d..26cd436 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -963,6 +963,40 @@ "@noble/ciphers": "^1.0.0" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -1916,6 +1950,25 @@ "node": ">=18" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", @@ -2429,9 +2482,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", "cpu": [ "arm" ], @@ -2443,9 +2496,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", "cpu": [ "arm64" ], @@ -2457,9 +2510,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", "cpu": [ "arm64" ], @@ -2471,9 +2524,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", "cpu": [ "x64" ], @@ -2485,9 +2538,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", "cpu": [ "arm64" ], @@ -2499,9 +2552,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", "cpu": [ "x64" ], @@ -2513,16 +2566,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", "cpu": [ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2530,16 +2580,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", "cpu": [ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2547,16 +2594,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2564,16 +2608,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2581,16 +2622,13 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", "cpu": [ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2598,16 +2636,13 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", "cpu": [ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2615,16 +2650,13 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2632,16 +2664,13 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2649,16 +2678,13 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", "cpu": [ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2666,16 +2692,13 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", "cpu": [ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2683,16 +2706,13 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", "cpu": [ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2700,16 +2720,13 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2717,16 +2734,13 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2734,9 +2748,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", "cpu": [ "x64" ], @@ -2748,9 +2762,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", "cpu": [ "arm64" ], @@ -2762,9 +2776,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", "cpu": [ "arm64" ], @@ -2776,9 +2790,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", "cpu": [ "ia32" ], @@ -2790,9 +2804,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", "cpu": [ "x64" ], @@ -2804,9 +2818,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", "cpu": [ "x64" ], @@ -2858,6 +2872,13 @@ "tailwindcss": "4.2.2" } }, + "node_modules/@tailwindcss/node/node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/oxide": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", @@ -2975,9 +2996,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2995,9 +3013,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3015,9 +3030,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3035,9 +3047,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3126,6 +3135,13 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tailwindcss/vite/node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -3263,6 +3279,17 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -3391,9 +3418,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -6945,9 +6972,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6969,9 +6993,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6993,9 +7014,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7017,9 +7035,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7455,9 +7470,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -7888,9 +7903,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -7907,7 +7922,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8243,13 +8258,13 @@ } }, "node_modules/rollup": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -8259,31 +8274,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", "fsevents": "~2.3.2" } }, @@ -8856,9 +8871,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", "dev": true, "license": "MIT" }, @@ -8900,14 +8915,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0f833c2..39dc6bb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,7 @@ import { SettingsModal } from '@/components/modals/SettingsModal' import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal' import { ZwaveImportModal } from '@/components/zwave/ZwaveImportModal' import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal' +import { FloorMapModal } from '@/components/modals/FloorMapModal' import { TextModal, type TextFormData } from '@/components/modals/TextModal' import { ThemeModal } from '@/components/modals/ThemeModal' import { SearchModal } from '@/components/modals/SearchModal' @@ -41,14 +42,14 @@ import { canvasApi, designsApi, liveviewApi } from '@/api/client' import * as standaloneStorage from '@/utils/standaloneStorage' import { demoNodes, demoEdges } from '@/utils/demoData' import { useStatusPolling } from '@/hooks/useStatusPolling' -import type { NodeData, EdgeData, CustomStyleDef } from '@/types' +import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig } from '@/types' import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types' import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' export default function App() { - const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer } = useCanvasStore() + const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore() const canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() @@ -82,6 +83,7 @@ export default function App() { const [exportModalOpen, setExportModalOpen] = useState(false) const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false) const [zwaveImportOpen, setZwaveImportOpen] = useState(false) + const [floorMapModalOpen, setFloorMapModalOpen] = useState(false) // Declare handleSave before the Ctrl+S effect so it is in scope. // Returns true on success, false on failure — the design-switch effect relies @@ -91,14 +93,16 @@ export default function App() { const saveDesignId = designIdOverride ?? activeDesignId if (STANDALONE) { if (!saveDesignId) return false - standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle }) + standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle, floorMap }) markSaved() toast.success('Canvas saved') return true } const nodesToSave = nodes.map(serializeNode) const edgesToSave = edges.map(serializeEdge) - await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle, design_id: saveDesignId }) + const viewport: Record = { theme_id: activeTheme } + if (floorMap) viewport.floor_map = floorMap + await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport, custom_style: customStyle, design_id: saveDesignId }) markSaved() toast.success('Canvas saved') return true @@ -106,7 +110,7 @@ export default function App() { toast.error('Save failed') return false } - }, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId]) + }, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId, floorMap]) // Keep a ref so the keydown handler always calls the latest version const handleSaveRef = useRef(handleSave) @@ -127,6 +131,8 @@ export default function App() { const savedTheme = res.data.viewport?.theme_id if (savedTheme) setTheme(savedTheme) if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) + const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined + if (savedFloorMap) setFloorMap(savedFloorMap) loadCanvas(rfNodes, rfEdges) } else { loadCanvas(demoNodes, demoEdges) @@ -134,7 +140,7 @@ export default function App() { } catch { loadCanvas(demoNodes, demoEdges) } - }, [loadCanvas, setTheme, setCustomStyle]) + }, [loadCanvas, setTheme, setCustomStyle, setFloorMap]) // Standalone counterpart of loadCanvasFromApi — reads a design's canvas from // localStorage, falling back to the demo canvas when it has never been saved. @@ -143,11 +149,12 @@ export default function App() { if (saved && saved.nodes.length > 0) { if (saved.theme_id) setTheme(saved.theme_id) if (saved.custom_style) setCustomStyle(saved.custom_style) + if (saved.floorMap) setFloorMap(saved.floorMap) loadCanvas(saved.nodes, saved.edges) } else { loadCanvas(demoNodes, demoEdges) } - }, [loadCanvas, setTheme, setCustomStyle]) + }, [loadCanvas, setTheme, setCustomStyle, setFloorMap]) const loadDesignsAndCanvas = useCallback(async () => { if (STANDALONE) { @@ -518,6 +525,25 @@ export default function App() { } }, [activeDesignId]) + const handleFloorMapSubmit = useCallback((config: FloorMapConfig) => { + snapshotHistory() + if (floorMap) { + // Preserve position from existing config when editing + setFloorMap({ ...floorMap, ...config }) + } else { + setFloorMap(config) + } + setFloorMapModalOpen(false) + toast.success(floorMap ? 'Floor plan updated' : 'Floor plan imported') + }, [floorMap, setFloorMap, snapshotHistory]) + + const handleFloorMapRemove = useCallback(() => { + snapshotHistory() + setFloorMap(null) + setFloorMapModalOpen(false) + toast.success('Floor plan removed') + }, [setFloorMap, snapshotHistory]) + const handleExport = useCallback(() => { const el = canvasRef.current?.querySelector('.react-flow') if (!el) { toast.error('Canvas not ready'); return } @@ -697,6 +723,7 @@ export default function App() { onScan={() => setScanConfigOpen(true)} onZigbeeImport={() => setZigbeeImportOpen(true)} onZwaveImport={() => setZwaveImportOpen(true)} + onFloorMap={() => setFloorMapModalOpen(true)} onSave={handleSave} onOpenSettings={() => setSettingsOpen(true)} onOpenHistory={() => setScanHistoryOpen(true)} @@ -833,6 +860,15 @@ export default function App() { /> )} + setFloorMapModalOpen(false)} + onSubmit={handleFloorMapSubmit} + onRemove={handleFloorMapRemove} + initial={floorMap} + /> + setAddGroupRectOpen(false)} diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index 19082c0..3c28112 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -22,6 +22,7 @@ import { nodeTypes } from './nodes/nodeTypes' import { edgeTypes } from './edges/edgeTypes' import { SearchBar } from './SearchBar' import { AlignmentGuides } from './AlignmentGuides' +import { FloorMapLayer } from './FloorMapLayer' import { useAlignmentGuides } from '@/hooks/useAlignmentGuides' import { setViewportCenterProjector } from '@/utils/viewportCenter' import type { NodeData, EdgeData } from '@/types' @@ -199,6 +200,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o size={1} color={theme.colors.canvasDotColor} /> + diff --git a/frontend/src/components/canvas/FloorMapLayer.tsx b/frontend/src/components/canvas/FloorMapLayer.tsx new file mode 100644 index 0000000..31aad13 --- /dev/null +++ b/frontend/src/components/canvas/FloorMapLayer.tsx @@ -0,0 +1,145 @@ +import { useCallback, useRef } from 'react' +import { useCanvasStore } from '@/stores/canvasStore' + +interface FloorMapLayerProps { + screenToFlowPosition: (pos: { x: number; y: number }) => { x: number; y: number } +} + +interface ResizeState { + startMouseX: number + startMouseY: number + startX: number + startY: number + startW: number + startH: number + edges: Set<'n' | 's' | 'e' | 'w'> +} + +export function FloorMapLayer({ screenToFlowPosition }: FloorMapLayerProps) { + const floorMap = useCanvasStore((s) => s.floorMap) + const updateFloorMap = useCanvasStore((s) => s.updateFloorMap) + + const resizeRef = useRef(null) + + const onDragStart = useCallback((e: React.MouseEvent) => { + if (!floorMap) return + e.stopPropagation() + const startX = e.clientX + const startY = e.clientY + const origPosX = floorMap.posX + const origPosY = floorMap.posY + + const onMove = (ev: MouseEvent) => { + const start = screenToFlowPosition({ x: startX, y: startY }) + const cur = screenToFlowPosition({ x: ev.clientX, y: ev.clientY }) + updateFloorMap({ posX: origPosX + (cur.x - start.x), posY: origPosY + (cur.y - start.y) }) + } + const onUp = () => { + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + }, [floorMap, updateFloorMap, screenToFlowPosition]) + + const onResizeStart = useCallback((e: React.MouseEvent, edges: Set<'n' | 's' | 'e' | 'w'>) => { + if (!floorMap) return + e.stopPropagation() + resizeRef.current = { + startMouseX: e.clientX, + startMouseY: e.clientY, + startX: floorMap.posX, + startY: floorMap.posY, + startW: floorMap.width, + startH: floorMap.height, + edges, + } + + const onMove = (ev: MouseEvent) => { + const rs = resizeRef.current + if (!rs) return + const start = screenToFlowPosition({ x: rs.startMouseX, y: rs.startMouseY }) + const cur = screenToFlowPosition({ x: ev.clientX, y: ev.clientY }) + const dx = cur.x - start.x + const dy = cur.y - start.y + let x = rs.startX, y = rs.startY, w = rs.startW, h = rs.startH + if (rs.edges.has('w')) { x += dx; w -= dx } + if (rs.edges.has('e')) w += dx + if (rs.edges.has('n')) { y += dy; h -= dy } + if (rs.edges.has('s')) h += dy + const MIN = 80 + if (w < MIN) { + if (rs.edges.has('w')) x = rs.startX + rs.startW - MIN + w = MIN + } + if (h < MIN) { + if (rs.edges.has('n')) y = rs.startY + rs.startH - MIN + h = MIN + } + updateFloorMap({ posX: x, posY: y, width: w, height: h }) + } + const onUp = () => { + resizeRef.current = null + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + }, [floorMap, updateFloorMap, screenToFlowPosition]) + + if (!floorMap || !floorMap.enabled) return null + + const { imageData, posX, posY, width, height, opacity, locked } = floorMap + + const hs: React.CSSProperties = { + position: 'absolute', + width: 10, + height: 10, + background: '#00d4ff', + border: '2px solid #0d1117', + borderRadius: 2, + zIndex: 10, + } + + return ( +
+ Floor plan + {!locked && ( + <> +
onResizeStart(e, new Set(['n','w']))} /> +
onResizeStart(e, new Set(['n']))} /> +
onResizeStart(e, new Set(['n','e']))} /> +
onResizeStart(e, new Set(['e']))} /> +
onResizeStart(e, new Set(['s','e']))} /> +
onResizeStart(e, new Set(['s']))} /> +
onResizeStart(e, new Set(['s','w']))} /> +
onResizeStart(e, new Set(['w']))} /> + + )} +
+ ) +} diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx index bae77b8..9205d2e 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -9,7 +9,7 @@ import { type EdgeProps, type Edge, } from '@xyflow/react' -import type { EdgeData, EdgeType, Waypoint } from '@/types' +import type { EdgeData, EdgeType, NodeData, Waypoint } from '@/types' import { useThemeStore } from '@/stores/themeStore' import { useCanvasStore } from '@/stores/canvasStore' import { THEMES } from '@/utils/themes' @@ -22,6 +22,13 @@ function getVlanColor(vlanId?: number): string { return VLAN_COLORS[vlanId % VLAN_COLORS.length] } +function getLqiColor(lqi: number): string { + if (lqi >= 200) return '#00cc00' + if (lqi >= 150) return '#88cc00' + if (lqi >= 100) return '#ffaa00' + return '#ff3300' +} + // ── Waypoint drag handle ───────────────────────────────────────────────────── interface WaypointHandleProps { @@ -289,7 +296,8 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle const activeTheme = useThemeStore((s) => s.activeTheme) const theme = THEMES[activeTheme] const sourceType = useStore((s) => s.nodeLookup.get(source)?.type) - const targetType = useStore((s) => s.nodeLookup.get(target)?.type) + const targetNode = useStore((s) => s.nodeLookup.get(target)) + const targetType = targetNode?.type const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox' const waypoints: Waypoint[] = Array.isArray(data?.waypoints) && data.waypoints.length > 0 @@ -315,6 +323,8 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle const edgeType: EdgeType = data?.type ?? 'ethernet' const edgeColors = theme.colors.edgeColors + const highlightedPath = useCanvasStore((s) => s.highlightedPath) + const isHighlighted = highlightedPath.includes(id) const BASE_STYLES: Record = { ethernet: { stroke: edgeColors.ethernet, strokeWidth: 2 }, @@ -328,15 +338,36 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle } const customColor = data?.custom_color as string | undefined - const strokeColor: string = selected + + // Derive LQI color from the target node's properties for iot edges + const lqiColor = (edgeType === 'iot' && targetNode?.data?.properties) + ? (() => { + const props = (targetNode.data as NodeData).properties ?? [] + const lqiProp = props.find( + (p: { key: string; value: string }) => p.key === 'LQI' + ) + if (lqiProp) { + const lqi = parseInt(lqiProp.value, 10) + if (!isNaN(lqi)) return getLqiColor(lqi) + } + return null + })() + : null + + const pathHighlightColor = '#00d4ff' + const strokeColor: string = isHighlighted + ? pathHighlightColor + : selected ? theme.colors.edgeSelectedColor - : customColor + : lqiColor + ?? customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : (BASE_STYLES[edgeType].stroke as string ?? edgeColors.ethernet)) const style: React.CSSProperties = { ...BASE_STYLES[edgeType], ...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}), ...(customColor ? { stroke: customColor } : {}), + ...(isHighlighted ? { stroke: pathHighlightColor, strokeWidth: 3, filter: `drop-shadow(0 0 6px ${pathHighlightColor}aa)` } : {}), ...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}), } diff --git a/frontend/src/components/modals/FloorMapModal.tsx b/frontend/src/components/modals/FloorMapModal.tsx new file mode 100644 index 0000000..0bf45ca --- /dev/null +++ b/frontend/src/components/modals/FloorMapModal.tsx @@ -0,0 +1,138 @@ +import { useState, useRef } from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { FloorMapConfig } from '@/types' + +interface FloorMapModalProps { + open: boolean + onClose: () => void + onSubmit: (config: FloorMapConfig) => void + onRemove: () => void + initial: FloorMapConfig | null +} + +export function FloorMapModal({ open, onClose, onSubmit, onRemove, initial }: FloorMapModalProps) { + const [imageData, setImageData] = useState(initial?.imageData ?? '') + const [width, setWidth] = useState(initial?.width ?? 800) + const [height, setHeight] = useState(initial?.height ?? 600) + const [opacity, setOpacity] = useState(initial?.opacity ?? 0.8) + const [locked, setLocked] = useState(initial?.locked ?? false) + const [enabled, setEnabled] = useState(initial?.enabled ?? true) + const fileRef = useRef(null) + + const handleFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = (ev) => { + const dataUrl = ev.target?.result as string + setImageData(dataUrl) + const img = new Image() + img.onload = () => { + setWidth(img.naturalWidth) + setHeight(img.naturalHeight) + } + img.src = dataUrl + } + reader.readAsDataURL(file) + } + + const handleSubmit = () => { + if (!imageData) return + onSubmit({ + imageData, + posX: initial?.posX ?? 0, + posY: initial?.posY ?? 0, + width, + height, + opacity, + locked, + enabled, + }) + } + + const hasImage = !!imageData + + return ( + { if (!o) onClose() }}> + + + {initial ? 'Edit Floor Plan' : 'Import Floor Plan'} + + +
+ {!hasImage ? ( +
fileRef.current?.click()} + > + + Click to select a floor plan image + PNG, JPEG or WebP +
+ ) : ( + <> +
+ Floor plan preview +
+ +
+ + +
+ +
+
+ + setWidth(Math.max(80, Number(e.target.value)))} className="bg-[#21262d] border-[#30363d] text-xs h-8" /> +
+
+ + setHeight(Math.max(80, Number(e.target.value)))} className="bg-[#21262d] border-[#30363d] text-xs h-8" /> +
+
+ +
+ + setOpacity(Number(e.target.value))} + className="w-full accent-[#00d4ff]" + /> +
+ +
+ + +
+ + )} +
+ + + {hasImage && initial && ( + + )} +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index b75ff31..3864120 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -1,4 +1,4 @@ -import { createElement, useRef, useState } from 'react' +import { createElement, useEffect, useRef, useState } from 'react' import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -9,6 +9,8 @@ import { getServiceUrl } from '@/utils/serviceUrl' import { splitIps } from '@/utils/maskIp' import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons' import { formatTimestamp } from '@/utils/timeFormat' +import { findZigbeePath } from '@/utils/zigbeePathfinding' +import { isZigbeeType } from '@/utils/zigbeeProperties' import type { Node } from '@xyflow/react' interface DetailPanelProps { @@ -22,7 +24,7 @@ type PropForm = { key: string; value: string; icon: string | null; visible: bool const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true } export function DetailPanel({ onEdit }: DetailPanelProps) { - const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeSize } = useCanvasStore() + const { nodes, edges, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeSize, setHighlightedPath } = useCanvasStore() const serviceStatuses = useCanvasStore((s) => s.serviceStatuses) const [addingForNode, setAddingForNode] = useState(null) @@ -41,6 +43,25 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { // Multi-select panel const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id)) + // Zigbee path highlighting: when a zigbee node is selected, compute + // the optimal route to the coordinator and highlight those edges. + useEffect(() => { + const targetNode = nodes.find((n) => n.id === selectedNodeId) + if (!targetNode || !isZigbeeType(targetNode.data.type)) { + setHighlightedPath([]) + return + } + + const coordinator = nodes.find((n) => n.data.type === 'zigbee_coordinator') + if (!coordinator) { + setHighlightedPath([]) + return + } + + const path = findZigbeePath(targetNode.id, coordinator.id, nodes, edges) + setHighlightedPath(path) + }, [selectedNodeId, nodes, edges, setHighlightedPath]) + if (multiSelected.length > 1) { return ( void onZigbeeImport: () => void onZwaveImport: () => void + onFloorMap: () => void onSave: () => void onOpenSettings: () => void onOpenHistory: () => void onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void } -export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) { +export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onFloorMap, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) { const [collapsed, setCollapsed] = useState(false) const logout = useAuthStore((s) => s.logout) const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore() @@ -76,7 +77,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee } }, [designs.length, removeDesign]) - const { nodes, hasUnsavedChanges } = useCanvasStore() + const { nodes, hasUnsavedChanges, floorMap } = useCanvasStore() const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text') const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length @@ -228,6 +229,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee {!STANDALONE && } {!STANDALONE && } {!STANDALONE && } + = {}, serviceStatuses: Record { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) }) @@ -130,6 +134,8 @@ describe('DetailPanel', () => { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) render() @@ -157,6 +163,8 @@ describe('DetailPanel', () => { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) render() @@ -178,6 +186,8 @@ describe('DetailPanel', () => { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) render() @@ -199,6 +209,8 @@ describe('DetailPanel', () => { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) render() @@ -253,6 +265,8 @@ describe('DetailPanel', () => { deleteNode: vi.fn(), updateNode: vi.fn(), snapshotHistory: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) render() fireEvent.click(screen.getByLabelText('Close panel')) @@ -277,6 +291,8 @@ describe('DetailPanel', () => { deleteNode, updateNode: vi.fn(), snapshotHistory, + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) vi.spyOn(window, 'confirm').mockReturnValue(true) render() @@ -295,6 +311,8 @@ describe('DetailPanel', () => { deleteNode, updateNode: vi.fn(), snapshotHistory, + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) vi.spyOn(window, 'confirm').mockReturnValue(false) render() @@ -326,6 +344,8 @@ describe('DetailPanel', () => { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) render() // Two "Add" header buttons: first = properties, second = services @@ -351,6 +371,8 @@ describe('DetailPanel', () => { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) render() const addHeaders = screen.getAllByText('Add') @@ -374,6 +396,8 @@ describe('DetailPanel', () => { deleteNode: vi.fn(), updateNode, snapshotHistory: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) render() fireEvent.click(screen.getByTitle('Remove service')) @@ -389,6 +413,8 @@ describe('DetailPanel', () => { deleteNode: vi.fn(), updateNode: vi.fn(), snapshotHistory: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) expect(() => render()).not.toThrow() }) @@ -420,6 +446,8 @@ describe('DetailPanel', () => { deleteNode: vi.fn(), updateNode, snapshotHistory: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) render() @@ -445,6 +473,8 @@ describe('DetailPanel', () => { deleteNode: vi.fn(), updateNode, snapshotHistory: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) render() @@ -608,6 +638,7 @@ describe('DetailPanel', () => { function setupSized(node: Partial>, setNodeSize = vi.fn()) { const state = { nodes: [{ ...makeNode({}), ...node }], + edges: [], selectedNodeId: 'n1', selectedNodeIds: [], setSelectedNode: vi.fn(), @@ -617,6 +648,7 @@ describe('DetailPanel', () => { createGroup: vi.fn(), ungroup: vi.fn(), setNodeSize, + setHighlightedPath: vi.fn(), serviceStatuses: {}, } vi.mocked(canvasStore.useCanvasStore).mockImplementation( diff --git a/frontend/src/components/panels/__tests__/GroupPanels.test.tsx b/frontend/src/components/panels/__tests__/GroupPanels.test.tsx index ca73963..c3748c4 100644 --- a/frontend/src/components/panels/__tests__/GroupPanels.test.tsx +++ b/frontend/src/components/panels/__tests__/GroupPanels.test.tsx @@ -34,6 +34,7 @@ function makeGroupNode(id = 'g1', label = 'My Group', showBorder = true) { const mockStore = { nodes: [], + edges: [], selectedNodeId: null, selectedNodeIds: [], setSelectedNode: vi.fn(), @@ -43,6 +44,7 @@ const mockStore = { createGroup: vi.fn(), ungroup: vi.fn(), removeFromGroup: vi.fn(), + setHighlightedPath: vi.fn(), } function setupStore(overrides = {}) { diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index b1ba24e..a7fe8f8 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -9,7 +9,7 @@ import { applyEdgeChanges, addEdge, } from '@xyflow/react' -import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus } from '@/types' +import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus, FloorMapConfig } from '@/types' import { generateUUID } from '@/utils/uuid' import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils' import { applyOpacity } from '@/utils/colorUtils' @@ -35,6 +35,12 @@ interface CanvasState { // Live per-service status overlay (not persisted), keyed via serviceStatusKey. serviceStatuses: Record + floorMap: FloorMapConfig | null + setFloorMap: (config: FloorMapConfig | null) => void + updateFloorMap: (patch: Partial) => void + highlightedPath: string[] + setHighlightedPath: (edgeIds: string[]) => void + // History past: HistoryEntry[] future: HistoryEntry[] @@ -98,6 +104,8 @@ export const useCanvasStore = create((set) => ({ hideIp: readHideIp(), scanEventTs: 0, serviceStatuses: {}, + floorMap: null, + highlightedPath: [], fitViewPending: false, past: [], @@ -747,6 +755,16 @@ export const useCanvasStore = create((set) => ({ set({ hideIp: value }) }, + setFloorMap: (config) => set({ floorMap: config, hasUnsavedChanges: true }), + + updateFloorMap: (patch) => + set((state) => ({ + floorMap: state.floorMap ? { ...state.floorMap, ...patch } : null, + hasUnsavedChanges: true, + })), + + setHighlightedPath: (edgeIds) => set({ highlightedPath: edgeIds }), + loadCanvas: (nodes, edges) => { // React Flow requires parents before children in the array const parents = nodes.filter((n) => !n.parentId) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7f438fb..94469fd 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -252,3 +252,14 @@ export interface CustomStyleDef { nodes: Partial> edges: Partial> } + +export interface FloorMapConfig { + imageData: string + posX: number + posY: number + width: number + height: number + opacity: number + locked: boolean + enabled: boolean +} diff --git a/frontend/src/utils/standaloneStorage.ts b/frontend/src/utils/standaloneStorage.ts index f5f2506..c07e945 100644 --- a/frontend/src/utils/standaloneStorage.ts +++ b/frontend/src/utils/standaloneStorage.ts @@ -11,7 +11,7 @@ * default design on first run so existing users keep their canvas. */ import type { Node, Edge } from '@xyflow/react' -import type { Design, DesignType, NodeData, EdgeData, CustomStyleDef } from '@/types' +import type { Design, DesignType, NodeData, EdgeData, CustomStyleDef, FloorMapConfig } from '@/types' import type { ThemeId } from '@/utils/themes' import { generateUUID } from '@/utils/uuid' @@ -24,6 +24,7 @@ export interface StandaloneCanvas { edges: Edge[] theme_id?: ThemeId custom_style?: CustomStyleDef | null + floorMap?: FloorMapConfig } function readJSON(key: string): T | null { diff --git a/frontend/src/utils/zigbeePathfinding.ts b/frontend/src/utils/zigbeePathfinding.ts new file mode 100644 index 0000000..c55a55e --- /dev/null +++ b/frontend/src/utils/zigbeePathfinding.ts @@ -0,0 +1,89 @@ +import type { Node, Edge } from '@xyflow/react' +import type { NodeData, EdgeData } from '@/types' + +function getNodeLqi(node: Node): number { + const lqiProp = node.data.properties?.find((p) => p.key === 'LQI') + if (!lqiProp) return 255 + const lqi = parseInt(lqiProp.value, 10) + return isNaN(lqi) ? 255 : lqi +} + +/** + * Run Dijkstra on the Zigbee `iot` edge subgraph from `startNodeId` to + * `targetNodeId`. Returns the ordered list of edge IDs forming the optimal + * path, or an empty array if no path exists. + * + * Edge cost = 255 - LQI, where LQI is read from the edge's target node + * properties (higher LQI = lower cost). For reverse traversal, the same + * cost is used. + */ +export function findZigbeePath( + startNodeId: string, + targetNodeId: string, + nodes: Node[], + edges: Edge[], +): string[] { + if (startNodeId === targetNodeId) return [] + + const iotEdges = edges.filter((e) => (e.data?.type ?? 'ethernet') === 'iot') + if (iotEdges.length === 0) return [] + + const nodeMap = new Map(nodes.map((n) => [n.id, n])) + const adjacency = new Map() + + for (const e of iotEdges) { + const targetLqi = nodeMap.get(e.target) ? getNodeLqi(nodeMap.get(e.target)!) : 255 + const sourceLqi = nodeMap.get(e.source) ? getNodeLqi(nodeMap.get(e.source)!) : 255 + const cost = 255 - Math.min(targetLqi, sourceLqi) + + if (!adjacency.has(e.source)) adjacency.set(e.source, []) + if (!adjacency.has(e.target)) adjacency.set(e.target, []) + + adjacency.get(e.source)!.push({ neighbor: e.target, edgeId: e.id, cost }) + adjacency.get(e.target)!.push({ neighbor: e.source, edgeId: e.id, cost }) + } + + if (!adjacency.has(startNodeId) || !adjacency.has(targetNodeId)) return [] + + const dist = new Map() + const prev = new Map() + const unvisited = new Set(adjacency.keys()) + + for (const nodeId of unvisited) dist.set(nodeId, Infinity) + dist.set(startNodeId, 0) + + while (unvisited.size > 0) { + let current: string | null = null + let minDist = Infinity + for (const nodeId of unvisited) { + const d = dist.get(nodeId)! + if (d < minDist) { minDist = d; current = nodeId } + } + if (current === null || minDist === Infinity) break + if (current === targetNodeId) break + + unvisited.delete(current) + + const neighbors = adjacency.get(current) ?? [] + for (const { neighbor, edgeId, cost } of neighbors) { + if (!unvisited.has(neighbor)) continue + const alt = dist.get(current)! + cost + if (alt < (dist.get(neighbor) ?? Infinity)) { + dist.set(neighbor, alt) + prev.set(neighbor, { node: current, edgeId }) + } + } + } + + // Reconstruct path: walk from targetNodeId back to startNodeId + const pathEdges: string[] = [] + let current = targetNodeId + while (current !== startNodeId) { + const p = prev.get(current) + if (!p) return [] + pathEdges.unshift(p.edgeId) + current = p.node + } + + return pathEdges +}