diff --git a/package.json b/package.json index 46f1e67..a95d7c7 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,8 @@ "preview": "vite preview" }, "dependencies": { - "buffer": "^6.0.3", - "exif-reader": "^2.0.3", "exifr": "^7.1.3", "maplibre-gl": "^5.24.0", - "piexif-ts": "^2.1.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-map-gl": "^8.1.1", @@ -33,6 +30,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.3.1", + "@testing-library/react": "^16.3.2", "@types/node": "^25.9.3", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", @@ -41,6 +39,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "jsdom": "^29.1.1", "prettier": "^3.8.4", "prettier-plugin-tailwindcss": "^0.8.0", "typescript": "^6.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a83685b..a972aaf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,21 +8,12 @@ importers: .: dependencies: - buffer: - specifier: ^6.0.3 - version: 6.0.3 - exif-reader: - specifier: ^2.0.3 - version: 2.0.3 exifr: specifier: ^7.1.3 version: 7.1.3 maplibre-gl: specifier: ^5.24.0 version: 5.24.0 - piexif-ts: - specifier: ^2.1.0 - version: 2.1.0 react: specifier: ^19.2.7 version: 19.2.7 @@ -42,6 +33,9 @@ importers: '@tailwindcss/vite': specifier: ^4.3.1 version: 4.3.1(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.2)(jiti@2.7.0)) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/node': specifier: ^25.9.3 version: 25.9.3 @@ -66,6 +60,9 @@ importers: globals: specifier: ^17.6.0 version: 17.6.0 + jsdom: + specifier: ^29.1.1 + version: 29.1.1 prettier: specifier: ^3.8.4 version: 3.8.4 @@ -83,10 +80,25 @@ importers: version: 8.0.16(@types/node@25.9.3)(esbuild@0.27.2)(jiti@2.7.0) vitest: specifier: ^4.1.8 - version: 4.1.8(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.2)(jiti@2.7.0)) + version: 4.1.8(@types/node@25.9.3)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.2)(jiti@2.7.0)) packages: + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -142,6 +154,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -154,6 +170,46 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.9': + resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6': + resolution: {integrity: sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -358,6 +414,15 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -628,9 +693,31 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -794,6 +881,17 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + arr-union@3.1.0: resolution: {integrity: sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==} engines: {node: '>=0.10.0'} @@ -810,14 +908,14 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.37: resolution: {integrity: sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==} engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} @@ -827,9 +925,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - buffer@6.0.3: - resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bytewise-core@1.2.3: resolution: {integrity: sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==} @@ -850,9 +945,17 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -862,13 +965,23 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + earcut@3.0.2: resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} @@ -879,6 +992,10 @@ packages: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} @@ -951,9 +1068,6 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - exif-reader@2.0.3: - resolution: {integrity: sha512-zFbQvguwT9JkqyYhR7pjE1Yn8SagwaGLNRU0Oh14xFa1paSf5Gzxn4gxgk0XhnudI0UIqU+HgnBX93+nva592A==} - exifr@7.1.3: resolution: {integrity: sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==} @@ -1035,8 +1149,9 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} @@ -1070,6 +1185,9 @@ packages: resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1084,6 +1202,15 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1193,9 +1320,17 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1203,6 +1338,9 @@ packages: resolution: {integrity: sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==} engines: {node: '>=16.14.0', npm: '>=8.1.0'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1244,6 +1382,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1270,9 +1411,6 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - piexif-ts@2.1.0: - resolution: {integrity: sha512-Fk1T7NbZnmiI4IPHrxz8y3dpzMAywDk1+9g0iGwHf+UPYPZJ1/CKALzhPPRPqAxwalwNz2t0NB4krY/PkAI93A==} - postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} @@ -1344,6 +1482,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + protocol-buffers-schema@3.6.1: resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==} @@ -1359,6 +1501,9 @@ packages: peerDependencies: react: ^19.2.7 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-map-gl@8.1.1: resolution: {integrity: sha512-aSqFAFoxvY7wxbGI93Dz0E41171mkAb3GcNbnkFIotmu88OFw495os6mIDZSi7irYNT/PZEIOEHUxhun4ToGuQ==} peerDependencies: @@ -1376,6 +1521,10 @@ packages: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-protobuf-schema@2.1.0: resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} @@ -1387,6 +1536,10 @@ packages: rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1440,6 +1593,9 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwindcss@4.3.1: resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} @@ -1465,6 +1621,21 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tldts-core@7.4.8: + resolution: {integrity: sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==} + + tldts@7.4.8: + resolution: {integrity: sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -1499,6 +1670,10 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + union-value@1.0.1: resolution: {integrity: sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==} engines: {node: '>=0.10.0'} @@ -1596,6 +1771,22 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1610,6 +1801,13 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -1628,6 +1826,26 @@ packages: snapshots: + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -1705,6 +1923,8 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -1728,6 +1948,34 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -1856,6 +2104,8 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2071,11 +2321,34 @@ snapshots: tailwindcss: 4.3.1 vite: 8.0.16(@types/node@25.9.3)(esbuild@0.27.2)(jiti@2.7.0) + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -2266,6 +2539,14 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + arr-union@3.1.0: {} assertion-error@2.0.1: {} @@ -2274,10 +2555,12 @@ snapshots: balanced-match@4.0.4: {} - base64-js@1.5.1: {} - baseline-browser-mapping@2.10.37: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -2290,11 +2573,6 @@ snapshots: node-releases: 2.0.47 update-browserslist-db: 1.2.3(browserslist@4.28.2) - buffer@6.0.3: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - bytewise-core@1.2.3: dependencies: typewise-core: 1.2.0 @@ -2316,16 +2594,34 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} + dom-accessibility-api@0.5.16: {} + earcut@3.0.2: {} electron-to-chromium@1.5.372: {} @@ -2335,6 +2631,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@8.0.0: {} + es-module-lexer@2.1.0: {} esbuild@0.27.2: @@ -2456,8 +2754,6 @@ snapshots: esutils@2.0.3: {} - exif-reader@2.0.3: {} - exifr@7.1.3: {} expect-type@1.3.0: {} @@ -2520,7 +2816,11 @@ snapshots: dependencies: hermes-estree: 0.25.1 - ieee754@1.2.1: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' ignore@5.3.2: {} @@ -2544,6 +2844,8 @@ snapshots: dependencies: isobject: 3.0.1 + is-potential-custom-element-name@1.0.1: {} + isexe@2.0.0: {} isobject@3.0.1: {} @@ -2552,6 +2854,32 @@ snapshots: js-tokens@4.0.0: {} + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -2630,10 +2958,14 @@ snapshots: dependencies: p-locate: 5.0.0 + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2660,6 +2992,8 @@ snapshots: quickselect: 3.0.0 tinyqueue: 3.0.0 + mdn-data@2.27.1: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -2695,6 +3029,10 @@ snapshots: dependencies: p-limit: 3.1.0 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -2713,8 +3051,6 @@ snapshots: picomatch@4.0.4: {} - piexif-ts@2.1.0: {} - postcss@8.5.15: dependencies: nanoid: 3.3.12 @@ -2731,6 +3067,12 @@ snapshots: prettier@3.8.4: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + protocol-buffers-schema@3.6.1: {} punycode@2.3.1: {} @@ -2742,6 +3084,8 @@ snapshots: react: 19.2.7 scheduler: 0.27.0 + react-is@17.0.2: {} + react-map-gl@8.1.1(maplibre-gl@5.24.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@vis.gl/react-mapbox': 8.1.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -2753,6 +3097,8 @@ snapshots: react@19.2.7: {} + require-from-string@2.0.2: {} + resolve-protobuf-schema@2.1.0: dependencies: protocol-buffers-schema: 3.6.1 @@ -2780,6 +3126,10 @@ snapshots: rw@1.3.3: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -2824,6 +3174,8 @@ snapshots: std-env@4.1.0: {} + symbol-tree@3.2.4: {} + tailwindcss@4.3.1: {} tapable@2.3.3: {} @@ -2841,6 +3193,20 @@ snapshots: tinyrainbow@3.1.0: {} + tldts-core@7.4.8: {} + + tldts@7.4.8: + dependencies: + tldts-core: 7.4.8 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.8 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -2873,6 +3239,8 @@ snapshots: undici-types@7.24.6: {} + undici@7.28.0: {} + union-value@1.0.1: dependencies: arr-union: 3.1.0 @@ -2903,7 +3271,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 - vitest@4.1.8(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.2)(jiti@2.7.0)): + vitest@4.1.8(@types/node@25.9.3)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.2)(jiti@2.7.0)): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.2)(jiti@2.7.0)) @@ -2927,9 +3295,26 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.3 + jsdom: 29.1.1 transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 @@ -2941,6 +3326,10 @@ snapshots: word-wrap@1.2.5: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/src/hooks/create-event-hook.test.tsx b/src/hooks/create-event-hook.test.tsx new file mode 100644 index 0000000..028aa41 --- /dev/null +++ b/src/hooks/create-event-hook.test.tsx @@ -0,0 +1,70 @@ +// @vitest-environment jsdom + +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { createEventHook } from "./create-event-hook"; + +afterEach(() => { + cleanup(); +}); + +describe("createEventHook", () => { + it("replays an earlier event and aborts it on unmount", () => { + const event = createEventHook(); + const signals: AbortSignal[] = []; + event.emit(); + + const { unmount } = renderHook(() => + event.useEvent((signal) => { + signals.push(signal); + return Promise.resolve(); + }), + ); + + expect(signals).toHaveLength(1); + expect(signals[0].aborted).toBe(false); + + unmount(); + + expect(signals[0].aborted).toBe(true); + }); + + it("aborts the previous handler before running the next event", () => { + const event = createEventHook(); + const signals: AbortSignal[] = []; + + renderHook(() => + event.useEvent((signal) => { + signals.push(signal); + return Promise.resolve(); + }), + ); + + act(() => event.emit()); + act(() => event.emit()); + + expect(signals).toHaveLength(2); + expect(signals[0].aborted).toBe(true); + expect(signals[1].aborted).toBe(false); + }); + + it("cleans up an earlier mount before replaying the event", () => { + const event = createEventHook(); + const signals: AbortSignal[] = []; + event.emit(); + + const useEvent = () => + event.useEvent((signal) => { + signals.push(signal); + return Promise.resolve(); + }); + + const firstRender = renderHook(useEvent); + firstRender.unmount(); + renderHook(useEvent); + + expect(signals).toHaveLength(2); + expect(signals[0].aborted).toBe(true); + expect(signals[1].aborted).toBe(false); + }); +}); diff --git a/src/hooks/create-event-hook.ts b/src/hooks/create-event-hook.ts new file mode 100644 index 0000000..8016365 --- /dev/null +++ b/src/hooks/create-event-hook.ts @@ -0,0 +1,41 @@ +import { useEffect, useEffectEvent } from "react"; + +export type CancellableEventHandler = (signal: AbortSignal) => Promise; + +/** Replays the latest event to new subscribers and aborts superseded handlers. */ +export function createEventHook() { + let hasEmitted = false; + const listeners = new Set<() => void>(); + + function emit(): void { + hasEmitted = true; + listeners.forEach((listener) => listener()); + } + + function useEvent(handler: CancellableEventHandler): void { + const handleEvent = useEffectEvent(handler); + + useEffect(() => { + let controller: AbortController | undefined; + + const listener = () => { + controller?.abort(); + controller = new AbortController(); + void handleEvent(controller.signal); + }; + + listeners.add(listener); + + if (hasEmitted) { + listener(); + } + + return () => { + listeners.delete(listener); + controller?.abort(); + }; + }, []); + } + + return { emit, useEvent }; +} diff --git a/src/hooks/use-eagle-selection.test.tsx b/src/hooks/use-eagle-selection.test.tsx new file mode 100644 index 0000000..6603107 --- /dev/null +++ b/src/hooks/use-eagle-selection.test.tsx @@ -0,0 +1,308 @@ +// @vitest-environment jsdom + +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { StrictMode, type PropsWithChildren } from "react"; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import type { SelectionLocationResult } from "../lib/selection-location-loader"; +import type { Coordinates } from "../types"; +import { useEagleSelection } from "./use-eagle-selection"; + +const mocks = vi.hoisted(() => { + type MinimalItem = { fileURL: string }; + type MinimalCoordinates = { + latitude: number; + longitude: number; + altitude?: number; + }; + type MinimalResult = + | { status: "no-selection" } + | { status: "no-gps" } + | { status: "ready"; coordinates: MinimalCoordinates }; + + const callbacks = { + create: [] as Array<() => void>, + run: [] as Array<() => void>, + }; + + return { + callbacks, + getSelected: vi.fn<() => Promise>(), + loadSelectionLocation: + vi.fn< + ( + selection: readonly MinimalItem[], + options?: { signal?: AbortSignal }, + ) => Promise + >(), + onPluginCreate: vi.fn<(callback: () => void) => void>((callback) => { + callbacks.create.push(callback); + }), + onPluginRun: vi.fn<(callback: () => void) => void>((callback) => { + callbacks.run.push(callback); + }), + }; +}); + +vi.mock("../eagle/env", () => ({ IN_EAGLE: true })); + +vi.mock("../eagle", () => ({ + eagle: { + item: { + getSelected: mocks.getSelected, + }, + onPluginCreate: mocks.onPluginCreate, + onPluginRun: mocks.onPluginRun, + }, +})); + +vi.mock("../lib/selection-location-loader", () => ({ + loadSelectionLocation: mocks.loadSelectionLocation, +})); + +const coordinates: Coordinates = { + latitude: 35.702755186666664, + longitude: 139.77182481666668, + altitude: 57.75, +}; + +const newerCoordinates: Coordinates = { + latitude: 35.7, + longitude: 139.77, + altitude: 42, +}; + +function selected(fileURL: string) { + return { fileURL }; +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + + return { promise, resolve, reject }; +} + +function StrictModeWrapper({ children }: PropsWithChildren) { + return {children}; +} + +function triggerPluginCreate(): void { + expect(mocks.callbacks.create.length).toBeGreaterThan(0); + act(() => { + mocks.callbacks.create.forEach((callback) => callback()); + }); +} + +function triggerPluginRun(): void { + expect(mocks.callbacks.run.length).toBeGreaterThan(0); + act(() => { + mocks.callbacks.run.forEach((callback) => callback()); + }); +} + +beforeEach(() => { + mocks.getSelected.mockReset(); + mocks.loadSelectionLocation.mockReset(); +}); + +afterEach(() => { + cleanup(); +}); + +beforeAll(() => { + triggerPluginCreate(); +}); + +describe("useEagleSelection", () => { + it("loads the selected image into the ready state", async () => { + mocks.getSelected.mockResolvedValue([selected("image.jpg")]); + mocks.loadSelectionLocation.mockResolvedValue({ + status: "ready", + coordinates, + }); + + const { result } = renderHook(() => useEagleSelection()); + + expect(result.current).toEqual({ + state: "loading", + coordinates: null, + errorMessage: "", + }); + + await waitFor(() => expect(result.current.state).toBe("ready")); + expect(result.current.coordinates).toEqual(coordinates); + expect(result.current.errorMessage).toBe(""); + + const firstCall = mocks.loadSelectionLocation.mock.calls[0]; + expect(firstCall[0]).toEqual([selected("image.jpg")]); + expect(firstCall[1]?.signal).toBeInstanceOf(AbortSignal); + }); + + it("registers Eagle selection callbacks once across remounts", () => { + mocks.getSelected.mockResolvedValue([selected("image.jpg")]); + mocks.loadSelectionLocation.mockResolvedValue({ + status: "ready", + coordinates, + }); + + const firstRender = renderHook(() => useEagleSelection(), { + wrapper: StrictModeWrapper, + }); + firstRender.unmount(); + + const secondRender = renderHook(() => useEagleSelection(), { + wrapper: StrictModeWrapper, + }); + + expect(mocks.onPluginCreate).toHaveBeenCalledTimes(1); + expect(mocks.onPluginRun).toHaveBeenCalledTimes(1); + + secondRender.unmount(); + }); + + it("ignores an older result after a newer selection request finishes", async () => { + const firstResult = deferred(); + const secondResult = deferred(); + mocks.getSelected.mockResolvedValue([selected("image.jpg")]); + mocks.loadSelectionLocation + .mockReturnValueOnce(firstResult.promise) + .mockReturnValueOnce(secondResult.promise); + + const { result } = renderHook(() => useEagleSelection()); + + await waitFor(() => + expect(mocks.loadSelectionLocation).toHaveBeenCalledTimes(1), + ); + + triggerPluginRun(); + await waitFor(() => + expect(mocks.loadSelectionLocation).toHaveBeenCalledTimes(2), + ); + + act(() => { + secondResult.resolve({ status: "ready", coordinates: newerCoordinates }); + }); + + await waitFor(() => + expect(result.current.coordinates).toEqual(newerCoordinates), + ); + + act(() => { + firstResult.resolve({ status: "ready", coordinates }); + }); + + expect(result.current.coordinates).toEqual(newerCoordinates); + }); + + it("does not show an error for an obsolete aborted request", async () => { + const firstResult = deferred(); + const secondResult = deferred(); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + mocks.getSelected.mockResolvedValue([selected("image.jpg")]); + mocks.loadSelectionLocation + .mockReturnValueOnce(firstResult.promise) + .mockReturnValueOnce(secondResult.promise); + + const { result } = renderHook(() => useEagleSelection()); + + await waitFor(() => + expect(mocks.loadSelectionLocation).toHaveBeenCalledTimes(1), + ); + + triggerPluginRun(); + await waitFor(() => + expect(mocks.loadSelectionLocation).toHaveBeenCalledTimes(2), + ); + + act(() => { + firstResult.reject(new DOMException("Aborted", "AbortError")); + secondResult.resolve({ status: "no-gps" }); + }); + + await waitFor(() => expect(result.current.state).toBe("no-gps")); + expect(result.current.errorMessage).toBe(""); + expect(consoleError).not.toHaveBeenCalled(); + + consoleError.mockRestore(); + }); + + it("reports an AbortError from the active request", async () => { + const error = new Error("Parser aborted unexpectedly"); + error.name = "AbortError"; + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + mocks.getSelected.mockResolvedValue([selected("image.jpg")]); + mocks.loadSelectionLocation.mockRejectedValue(error); + + const { result } = renderHook(() => useEagleSelection()); + + await waitFor(() => expect(result.current.state).toBe("error")); + expect(result.current.errorMessage).toBe("Parser aborted unexpectedly"); + expect(consoleError).toHaveBeenCalledWith( + "Failed to load Eagle selection", + error, + ); + + consoleError.mockRestore(); + }); + + it("aborts an in-flight request on unmount", async () => { + const pendingResult = deferred(); + let requestSignal: AbortSignal | undefined; + mocks.getSelected.mockResolvedValue([selected("image.jpg")]); + mocks.loadSelectionLocation.mockImplementation((_selection, options) => { + requestSignal = options?.signal; + return pendingResult.promise; + }); + + const { unmount } = renderHook(() => useEagleSelection()); + + await waitFor(() => + expect(mocks.loadSelectionLocation).toHaveBeenCalledTimes(1), + ); + expect(requestSignal?.aborted).toBe(false); + + unmount(); + + expect(requestSignal?.aborted).toBe(true); + + act(() => { + pendingResult.resolve({ status: "ready", coordinates }); + }); + }); + + it("ignores retained Eagle callbacks after unmount", async () => { + mocks.getSelected.mockResolvedValue([selected("image.jpg")]); + mocks.loadSelectionLocation.mockResolvedValue({ + status: "ready", + coordinates, + }); + + const { unmount } = renderHook(() => useEagleSelection()); + + await waitFor(() => expect(mocks.getSelected).toHaveBeenCalledTimes(1)); + + unmount(); + mocks.getSelected.mockClear(); + mocks.loadSelectionLocation.mockClear(); + triggerPluginRun(); + + expect(mocks.getSelected).not.toHaveBeenCalled(); + expect(mocks.loadSelectionLocation).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/use-eagle-selection.ts b/src/hooks/use-eagle-selection.ts index ae69d42..163a9f5 100644 --- a/src/hooks/use-eagle-selection.ts +++ b/src/hooks/use-eagle-selection.ts @@ -1,77 +1,62 @@ -import { useCallback, useEffect, useState } from "react"; +import { useState } from "react"; import { eagle } from "../eagle"; import { IN_EAGLE } from "../eagle/env"; -import type { Item } from "../eagle/types"; -import { resolveImageLocation } from "../lib/location"; -import type { Coordinates, LoadState } from "../types"; - -async function fetchBinary(url: string): Promise { - const response = await fetch(url, { cache: "no-store" }); - if (!response.ok) { - throw new Error(`Failed to fetch source (${response.status})`); - } - - return response.arrayBuffer(); +import { loadSelectionLocation } from "../lib/selection-location-loader"; +import type { Coordinates, LoadState, SelectionLocationState } from "../types"; +import { + createEventHook, + type CancellableEventHandler, +} from "./create-event-hook"; + +const selectionRefresh = createEventHook(); + +if (IN_EAGLE) { + eagle.onPluginCreate(selectionRefresh.emit); + eagle.onPluginRun(selectionRefresh.emit); +} else { + selectionRefresh.emit(); } -const resolveItemLocation = async (item: Item): Promise => { - const filePath = item.fileURL; - const buffer = await fetchBinary(filePath); - return resolveImageLocation(buffer); -}; +function useEagleSelectionRefresh(handler: CancellableEventHandler): void { + selectionRefresh.useEvent(handler); +} export function useEagleSelection() { - const [state, setState] = useState("loading"); - const [coordinates, setCoordinates] = useState(null); - const [errorMessage, setErrorMessage] = useState(""); + const [selectionState, setSelectionState] = useState({ + status: "loading", + }); - const loadSelection = useCallback(async (): Promise => { - setState("loading"); - setErrorMessage(""); + useEagleSelectionRefresh(async (signal) => { + setSelectionState({ status: "loading" }); try { const selection = await eagle.item.getSelected(); - const item = selection.length > 0 ? selection[0] : undefined; - if (!item) { - setCoordinates(null); - setState("no-selection"); + const result = await loadSelectionLocation(selection, { signal }); + + if (signal.aborted) { return; } - const location = await resolveItemLocation(item); - - if (!location) { - setCoordinates(null); - setState("no-gps"); + setSelectionState(result); + } catch (error) { + if (signal.aborted) { return; } - setCoordinates(location); - setState("ready"); - } catch (error) { console.error("Failed to load Eagle selection", error); - setErrorMessage( - error instanceof Error ? error.message : "Unexpected error", - ); - setState("error"); - } - }, []); - - useEffect(() => { - const initialize = () => { - void loadSelection(); - }; - - if (IN_EAGLE) { - eagle.onPluginCreate(initialize); - eagle.onPluginRun(() => { - void loadSelection(); + setSelectionState({ + status: "error", + message: error instanceof Error ? error.message : "Unexpected error", }); - } else { - initialize(); } - }, [loadSelection]); + }); + + const state: LoadState = selectionState.status; + const coordinates: Coordinates | null = + selectionState.status === "ready" ? selectionState.coordinates : null; + const errorMessage = + selectionState.status === "error" ? selectionState.message : ""; return { state, coordinates, errorMessage }; } diff --git a/src/lib/__snapshots__/location.test.ts.snap b/src/lib/__snapshots__/location.test.ts.snap deleted file mode 100644 index a5c0642..0000000 --- a/src/lib/__snapshots__/location.test.ts.snap +++ /dev/null @@ -1,9 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`resolveImageLocation > converts EXIF GPS metadata to decimal coordinates 1`] = ` -{ - "altitude": 57.75, - "latitude": 35.702755186666664, - "longitude": 139.77182481666668, -} -`; diff --git a/src/lib/binary-reader.test.ts b/src/lib/binary-reader.test.ts new file mode 100644 index 0000000..8626c3e --- /dev/null +++ b/src/lib/binary-reader.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readBinaryFromUrl } from "./binary-reader"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("readBinaryFromUrl", () => { + it("returns the response body and forwards the abort signal", async () => { + const binary = new ArrayBuffer(4); + const controller = new AbortController(); + const fetchMock = vi.fn(() => + Promise.resolve(new Response(binary, { status: 200 })), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + readBinaryFromUrl("/image.jpg", { signal: controller.signal }), + ).resolves.toEqual(binary); + expect(fetchMock).toHaveBeenCalledWith("/image.jpg", { + cache: "no-store", + signal: controller.signal, + }); + }); + + it("rejects non-OK responses", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(new Response(null, { status: 404 }))), + ); + + await expect(readBinaryFromUrl("/missing.jpg")).rejects.toThrow( + "Failed to fetch source (404)", + ); + }); +}); diff --git a/src/lib/binary-reader.ts b/src/lib/binary-reader.ts new file mode 100644 index 0000000..bf8328d --- /dev/null +++ b/src/lib/binary-reader.ts @@ -0,0 +1,19 @@ +export interface ReadBinaryOptions { + signal?: AbortSignal; +} + +export async function readBinaryFromUrl( + sourceUrl: string, + options?: ReadBinaryOptions, +): Promise { + const response = await fetch(sourceUrl, { + cache: "no-store", + signal: options?.signal, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch source (${response.status})`); + } + + return response.arrayBuffer(); +} diff --git a/src/lib/image-location-reader.exifr.test.ts b/src/lib/image-location-reader.exifr.test.ts new file mode 100644 index 0000000..cd0f40d --- /dev/null +++ b/src/lib/image-location-reader.exifr.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { parseImageLocation } from "./image-location-reader"; + +const mocks = vi.hoisted(() => { + type ExifrParse = ( + input: ArrayBuffer, + options: { pick: string[] }, + ) => Promise; + + return { + parse: vi.fn(), + }; +}); + +vi.mock("exifr", () => ({ + default: { + parse: mocks.parse, + }, +})); + +beforeEach(() => { + mocks.parse.mockReset(); +}); + +describe("parseImageLocation EXIF metadata mapping", () => { + it("maps exifr coordinates and below-sea-level altitude", async () => { + mocks.parse.mockResolvedValue({ + latitude: -35.5, + longitude: -139.25, + GPSAltitude: 12, + GPSAltitudeRef: Uint8Array.from([1]), + }); + + await expect(parseImageLocation(new ArrayBuffer(0))).resolves.toEqual({ + latitude: -35.5, + longitude: -139.25, + altitude: -12, + }); + }); +}); diff --git a/src/lib/image-location-reader.test.ts b/src/lib/image-location-reader.test.ts new file mode 100644 index 0000000..7165753 --- /dev/null +++ b/src/lib/image-location-reader.test.ts @@ -0,0 +1,32 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { parseImageLocation } from "./image-location-reader"; + +const fixturesDirectory = path.resolve(process.cwd(), "tests/fixtures"); +const infoDirectory = path.join(fixturesDirectory, "MJXX6FDDBW3FZ.info"); +const imageFixture = path.join(infoDirectory, "DSC02497.jpg"); +const thumbnailFixture = path.join(infoDirectory, "DSC02497_thumbnail.png"); + +async function readArrayBuffer(filePath: string): Promise { + const data = await readFile(filePath); + return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); +} + +describe("parseImageLocation", () => { + it("reads decimal coordinates and altitude from a JPEG", async () => { + const imageData = await readArrayBuffer(imageFixture); + + await expect(parseImageLocation(imageData)).resolves.toEqual({ + latitude: 35.702755186666664, + longitude: 139.77182481666668, + altitude: 57.75, + }); + }); + + it("returns null when an image has no readable GPS metadata", async () => { + const imageData = await readArrayBuffer(thumbnailFixture); + + await expect(parseImageLocation(imageData)).resolves.toBeNull(); + }); +}); diff --git a/src/lib/image-location-reader.ts b/src/lib/image-location-reader.ts new file mode 100644 index 0000000..473f392 --- /dev/null +++ b/src/lib/image-location-reader.ts @@ -0,0 +1,58 @@ +import exifr from "exifr"; +import type { Coordinates } from "../types"; + +interface ExifLocationMetadata { + latitude?: number; + longitude?: number; + GPSAltitude?: number; + GPSAltitudeRef?: Uint8Array; +} + +const GPS_TAGS = [ + "GPSLatitude", + "GPSLatitudeRef", + "GPSLongitude", + "GPSLongitudeRef", + "GPSAltitude", + "GPSAltitudeRef", +] as const; + +function toCoordinates(metadata?: ExifLocationMetadata): Coordinates | null { + if (metadata?.latitude === undefined || metadata.longitude === undefined) { + return null; + } + + if (metadata.GPSAltitude === undefined) { + return { + latitude: metadata.latitude, + longitude: metadata.longitude, + }; + } + + return { + latitude: metadata.latitude, + longitude: metadata.longitude, + altitude: + metadata.GPSAltitudeRef?.[0] === 1 + ? -metadata.GPSAltitude + : metadata.GPSAltitude, + }; +} + +export async function parseImageLocation( + imageArrayBuffer: ArrayBuffer, +): Promise { + try { + const metadata = (await exifr.parse(imageArrayBuffer, { + pick: [...GPS_TAGS], + })) as ExifLocationMetadata | undefined; + + return toCoordinates(metadata); + } catch (error) { + if (error instanceof Error && error.message === "Unknown file format") { + return null; + } + + throw error; + } +} diff --git a/src/lib/location.test.ts b/src/lib/location.test.ts deleted file mode 100644 index f491e6f..0000000 --- a/src/lib/location.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { resolveImageLocation } from "./location"; - -const fixturesDirectory = path.resolve(process.cwd(), "tests/fixtures"); -const infoDirectory = path.join(fixturesDirectory, "MJXX6FDDBW3FZ.info"); -const imageFixture = path.join(infoDirectory, "DSC02497.jpg"); - -describe("resolveImageLocation", () => { - it("converts EXIF GPS metadata to decimal coordinates", async () => { - const imageData = await readFile(imageFixture); - const location = resolveImageLocation(imageData.buffer); - expect(location).matchSnapshot(); - }); -}); diff --git a/src/lib/location.ts b/src/lib/location.ts deleted file mode 100644 index 92e67f9..0000000 --- a/src/lib/location.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Buffer } from "buffer"; -import type { Coordinates } from "../types"; -// https://github.com/devongovett/exif-reader -import exifReader from "exif-reader"; -// https://github.com/holwech/piexif-ts -import type * as Piexif from "piexif-ts"; -// @ts-expect-error - piexif-ts has incorrect package.json exports, importing dist directly -import * as piexifJs from "piexif-ts/dist/piexif.js"; - -// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -const piexif: typeof Piexif = piexifJs; - -// Convert GPS coordinates from EXIF format to decimal degrees -const convertGpsCoordinate = ( - coordinate: number[], - ref: string, -): number | null => { - if (coordinate.length < 3) return null; - - const degrees = coordinate[0]; - const minutes = coordinate[1]; - const seconds = coordinate[2]; - - let decimal = degrees + minutes / 60 + seconds / 3600; - - // Apply reference direction (S and W are negative) - if (ref === "S" || ref === "W") { - decimal = -decimal; - } - - return decimal; -}; - -// Convert decimal degrees to EXIF GPS format -export const convertDecimalToGps = ( - decimal: number, - type: "lat" | "lng", -): { coordinate: number[]; ref: string } => { - const ref = - type === "lat" ? (decimal >= 0 ? "N" : "S") : decimal >= 0 ? "E" : "W"; - const absDecimal = Math.abs(decimal); - - const degrees = Math.floor(absDecimal); - const minutes = Math.floor((absDecimal - degrees) * 60); - const seconds = ((absDecimal - degrees) * 60 - minutes) * 3600; - - return { coordinate: [degrees, minutes, seconds], ref }; -}; - -// Format GPS time from array to readable string -export const formatGpsTime = (timeArray: number[]): string => { - if (timeArray.length < 3) return ""; - - const hours = Math.floor(timeArray[0]).toString().padStart(2, "0"); - const minutes = Math.floor(timeArray[1]).toString().padStart(2, "0"); - const seconds = Math.floor(timeArray[2]).toString().padStart(2, "0"); - - return `${hours}:${minutes}:${seconds}`; -}; - -// Format GPS coordinate for display -export const formatGpsCoordinate = ( - coordinate: number[], - ref: string, - type: "lat" | "lng", -): string => { - if (coordinate.length < 3) return "N/A"; - - const degrees = Math.floor(coordinate[0]); - const minutes = Math.floor(coordinate[1]); - const seconds = coordinate[2].toFixed(2); - - const direction = - type === "lat" ? (ref === "N" ? "N" : "S") : ref === "E" ? "E" : "W"; - - return `${degrees}°${minutes}'${seconds}"${direction}`; -}; - -const extractExif = (arrayBuffer: ArrayBuffer) => { - // Convert ArrayBuffer to binary string for piexif.load() - const uint8Array = new Uint8Array(arrayBuffer); - let binaryString = ""; - for (const byte of uint8Array) { - binaryString += String.fromCodePoint(byte); - } - - const exifObj = piexif.load(binaryString); - const exifSegmentStr = piexif.dump(exifObj); - return Buffer.from(exifSegmentStr, "binary"); -}; - -export const resolveImageLocation = ( - imageArrayBuffer: ArrayBuffer, -): Coordinates | null => { - const exifSegmentBuffer = extractExif(imageArrayBuffer); - - const metadata = exifReader(exifSegmentBuffer); - const gpsData = metadata.GPSInfo; - - if (!gpsData) { - return null; - } - - // Convert coordinates to decimal degrees - const latitude = - gpsData.GPSLatitude && gpsData.GPSLatitudeRef - ? convertGpsCoordinate(gpsData.GPSLatitude, gpsData.GPSLatitudeRef) - : null; - - const longitude = - gpsData.GPSLongitude && gpsData.GPSLongitudeRef - ? convertGpsCoordinate(gpsData.GPSLongitude, gpsData.GPSLongitudeRef) - : null; - if (latitude == null || longitude == null) { - return null; - } - - const altitude = gpsData.GPSAltitude - ? gpsData.GPSAltitudeRef === 1 - ? -gpsData.GPSAltitude - : gpsData.GPSAltitude - : undefined; - - return { - latitude, - longitude, - altitude, - }; -}; diff --git a/src/lib/selection-location-loader.test.ts b/src/lib/selection-location-loader.test.ts new file mode 100644 index 0000000..19aac12 --- /dev/null +++ b/src/lib/selection-location-loader.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Coordinates } from "../types"; +import { loadSelectionLocation } from "./selection-location-loader"; + +const mocks = vi.hoisted(() => { + type ParseImageLocation = + typeof import("./image-location-reader").parseImageLocation; + type ReadBinaryFromUrl = typeof import("./binary-reader").readBinaryFromUrl; + + return { + parseImageLocation: vi.fn(), + readBinaryFromUrl: vi.fn(), + }; +}); + +vi.mock("./binary-reader", () => ({ + readBinaryFromUrl: mocks.readBinaryFromUrl, +})); + +vi.mock("./image-location-reader", () => ({ + parseImageLocation: mocks.parseImageLocation, +})); + +const coordinates: Coordinates = { + latitude: 35.702755186666664, + longitude: 139.77182481666668, + altitude: 57.75, +}; + +beforeEach(() => { + mocks.parseImageLocation.mockReset(); + mocks.readBinaryFromUrl.mockReset(); +}); + +describe("loadSelectionLocation", () => { + it("returns no-selection without reading a file", async () => { + await expect(loadSelectionLocation([])).resolves.toEqual({ + status: "no-selection", + }); + expect(mocks.readBinaryFromUrl).not.toHaveBeenCalled(); + }); + + it("reads and parses the first selected file", async () => { + const binary = new ArrayBuffer(4); + const controller = new AbortController(); + mocks.readBinaryFromUrl.mockResolvedValue(binary); + mocks.parseImageLocation.mockResolvedValue(coordinates); + + await expect( + loadSelectionLocation( + [{ fileURL: "first.jpg" }, { fileURL: "second.jpg" }], + { signal: controller.signal }, + ), + ).resolves.toEqual({ status: "ready", coordinates }); + expect(mocks.readBinaryFromUrl).toHaveBeenCalledWith("first.jpg", { + signal: controller.signal, + }); + expect(mocks.parseImageLocation).toHaveBeenCalledWith(binary); + }); + + it("returns no-gps when the parser returns null", async () => { + mocks.readBinaryFromUrl.mockResolvedValue(new ArrayBuffer(0)); + mocks.parseImageLocation.mockResolvedValue(null); + + await expect( + loadSelectionLocation([{ fileURL: "image.jpg" }]), + ).resolves.toEqual({ status: "no-gps" }); + }); + + it("propagates binary read errors", async () => { + const error = new Error("reader failed"); + mocks.readBinaryFromUrl.mockRejectedValue(error); + + await expect( + loadSelectionLocation([{ fileURL: "image.jpg" }]), + ).rejects.toBe(error); + }); + + it("propagates location parse errors", async () => { + const error = new Error("parser failed"); + mocks.readBinaryFromUrl.mockResolvedValue(new ArrayBuffer(0)); + mocks.parseImageLocation.mockRejectedValue(error); + + await expect( + loadSelectionLocation([{ fileURL: "image.jpg" }]), + ).rejects.toBe(error); + }); +}); diff --git a/src/lib/selection-location-loader.ts b/src/lib/selection-location-loader.ts new file mode 100644 index 0000000..3c65496 --- /dev/null +++ b/src/lib/selection-location-loader.ts @@ -0,0 +1,36 @@ +import type { Coordinates } from "../types"; +import { readBinaryFromUrl } from "./binary-reader"; +import { parseImageLocation } from "./image-location-reader"; + +export interface SelectedImage { + fileURL: string; +} + +export interface LoadSelectionLocationOptions { + signal?: AbortSignal; +} + +export type SelectionLocationResult = + | { status: "no-selection" } + | { status: "no-gps" } + | { status: "ready"; coordinates: Coordinates }; + +export async function loadSelectionLocation( + selection: readonly SelectedImage[], + options?: LoadSelectionLocationOptions, +): Promise { + if (selection.length === 0) { + return { status: "no-selection" }; + } + + const binary = await readBinaryFromUrl(selection[0].fileURL, { + signal: options?.signal, + }); + const coordinates = await parseImageLocation(binary); + + if (!coordinates) { + return { status: "no-gps" }; + } + + return { status: "ready", coordinates }; +} diff --git a/src/types.ts b/src/types.ts index 0077b1f..8adff97 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,3 +10,10 @@ export interface Coordinates { longitude: number; altitude?: number; } + +export type SelectionLocationState = + | { status: "loading" } + | { status: "ready"; coordinates: Coordinates } + | { status: "no-selection" } + | { status: "no-gps" } + | { status: "error"; message: string };