Refactor client UI with shadcn heatmap layout

This commit is contained in:
Bernard Ngandu
2025-10-10 10:04:04 +02:00
parent 9834438ff1
commit 8f4b954af8
20 changed files with 2133 additions and 739 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
+622 -5
View File
@@ -8,14 +8,20 @@
"name": "client",
"version": "0.0.0",
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"lucide-react": "^0.545.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwind-merge": "^3.3.1"
"tailwind-merge": "^3.3.1",
"tslib": "^2.8.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
@@ -726,6 +732,72 @@
"node": ">=14"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
@@ -741,6 +813,290 @@
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
"integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
"integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
@@ -759,6 +1115,121 @@
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-beta.41",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.41.tgz",
@@ -1115,7 +1586,7 @@
"version": "19.2.1",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -1527,6 +1998,18 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
@@ -1894,6 +2377,12 @@
"node": ">=8"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -2321,6 +2810,15 @@
"node": ">=6.9.0"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
@@ -2982,6 +3480,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.545.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.545.0.tgz",
"integrity": "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3510,6 +4017,75 @@
"node": ">=0.10.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
"integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"license": "MIT",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"license": "MIT",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -4034,9 +4610,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
@@ -4137,6 +4711,49 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"license": "MIT",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+7 -1
View File
@@ -10,14 +10,20 @@
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"lucide-react": "^0.545.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwind-merge": "^3.3.1"
"tailwind-merge": "^3.3.1",
"tslib": "^2.8.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
+239 -701
View File
@@ -1,76 +1,36 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import L, { type LayerGroup, type LeafletMouseEvent, type Map as LeafletMap } from 'leaflet'
import 'leaflet.heat'
import { useCallback, useMemo, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { cn, distanceInKm, formatCoordinate, formatRelativeTime } from '@/lib/utils'
import { AppHeader } from '@/components/layout/AppHeader'
import { ActivityPanel } from '@/components/panels/ActivityPanel'
import { HotspotStatsPanel } from '@/components/panels/HotspotStatsPanel'
import { OverviewPanel } from '@/components/panels/OverviewPanel'
import { MapViewport } from '@/components/map/MapViewport'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { useHotspotFeed } from '@/hooks/useHotspotFeed'
import { useLeafletHeatmap } from '@/hooks/useLeafletHeatmap'
import { useUserLocation } from '@/hooks/useUserLocation'
import { distanceInKm, formatCoordinate, formatRelativeTime, formatTimestamp } from '@/lib/utils'
import type { FeedStatus, LatLng } from '@/types/api'
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api/signals'
const VISIBLE_RADIUS_KM = 5
const RADIUS_KM = 1
type Status = 'loading' | 'idle' | 'error' | 'posting' | 'refreshing'
type ApiPoint = {
id: number
lat: number
lng: number
createdAt: string
userKey: string
}
type ApiDensity = {
lat: number
lng: number
intensity: number
}
type ApiResponse = {
clientKey?: string
points?: ApiPoint[]
density?: ApiDensity[]
latestByUser?: ApiPoint[]
totals?: {
points: number
contributors: number
}
updatedAt?: string
}
type HeatPoint = [number, number, number?]
type LeafletHeatLayer = L.Layer & {
setLatLngs(points: HeatPoint[]): LeafletHeatLayer
}
interface HeatLayerOptions {
radius?: number
blur?: number
maxZoom?: number
max?: number
minOpacity?: number
gradient?: Record<number, string>
}
type LeafletWithHeat = typeof L & {
heatLayer?: (points: HeatPoint[], options?: HeatLayerOptions) => LeafletHeatLayer
}
interface LatLng {
lat: number
lng: number
}
function getStatusLabel(status: Status): string {
function getStatusLabel(status: FeedStatus): string {
switch (status) {
case 'loading':
return 'Syncing map'
case 'posting':
return 'Sending your signal'
return 'Sending signal'
case 'refreshing':
return 'Updating hotspots'
return 'Updating heat'
case 'error':
return 'Offline'
default:
@@ -78,679 +38,257 @@ function getStatusLabel(status: Status): string {
}
}
function geolocationErrorMessage(error: GeolocationPositionError): string {
switch (error.code) {
case error.PERMISSION_DENIED:
return 'Location access denied. Enable it to view nearby pings.'
case error.POSITION_UNAVAILABLE:
return 'Unable to determine your position. Try again.'
case error.TIMEOUT:
return 'Timed out while fetching your location.'
default:
return 'Failed to retrieve your location.'
}
}
export default function App() {
const mapRef = useRef<LeafletMap | null>(null)
const mapContainerRef = useRef<HTMLDivElement | null>(null)
const heatLayerRef = useRef<LeafletHeatLayer | null>(null)
const markersLayerRef = useRef<LayerGroup | null>(null)
const zonesLayerRef = useRef<LayerGroup | null>(null)
const userLayerRef = useRef<LayerGroup | null>(null)
const locationWatchIdRef = useRef<number | null>(null)
const statusRef = useRef<Status>('loading')
const initialLoadRef = useRef(true)
const hasCenteredOnUserRef = useRef(false)
const [pendingSpot, setPendingSpot] = useState<LatLng | null>(null)
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
const [isConfirming, setIsConfirming] = useState(false)
const [status, setStatus] = useState<Status>('loading')
const [rawPoints, setRawPoints] = useState<ApiPoint[]>([])
const [rawDensity, setRawDensity] = useState<ApiDensity[]>([])
const [rawLatestByUser, setRawLatestByUser] = useState<ApiPoint[]>([])
const [clientKey, setClientKey] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<string | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [userLocation, setUserLocation] = useState<LatLng | null>(null)
const [locationError, setLocationError] = useState<string | null>(null)
const [isRequestingLocation, setIsRequestingLocation] = useState<boolean>(false)
const {
status,
errorMessage,
submitPoint,
fetchSnapshot,
selectVisibleDensity,
selectVisiblePoints,
selectVisibleLatestByUser,
myLatestPoint,
lastUpdated,
} = useHotspotFeed()
const setStatusSafe = useCallback((next: Status) => {
statusRef.current = next
setStatus(next)
}, [])
const { location: userLocation, error: locationError, isRequesting: isRequestingLocation } = useUserLocation()
const startLocationWatch = useCallback(() => {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
setLocationError('Geolocation is not supported in this browser.')
setIsRequestingLocation(false)
return
}
setIsRequestingLocation(true)
setLocationError(null)
if (locationWatchIdRef.current !== null) {
navigator.geolocation.clearWatch(locationWatchIdRef.current)
locationWatchIdRef.current = null
}
navigator.geolocation.getCurrentPosition(
(position) => {
setUserLocation({ lat: position.coords.latitude, lng: position.coords.longitude })
setIsRequestingLocation(false)
},
(error) => {
setLocationError(geolocationErrorMessage(error))
setIsRequestingLocation(false)
},
{ enableHighAccuracy: true, timeout: 10000 },
)
const watchId = navigator.geolocation.watchPosition(
(position) => {
setUserLocation({ lat: position.coords.latitude, lng: position.coords.longitude })
setLocationError(null)
setIsRequestingLocation(false)
},
(error) => {
setLocationError(geolocationErrorMessage(error))
setIsRequestingLocation(false)
},
{ enableHighAccuracy: true, maximumAge: 15000, timeout: 10000 },
)
locationWatchIdRef.current = watchId
}, [])
const fetchSnapshot = useCallback(
async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false
const previousStatus = statusRef.current
const isInitial = initialLoadRef.current
if (previousStatus !== 'posting') {
if (isInitial) {
setStatusSafe('loading')
} else if (!silent) {
setStatusSafe('refreshing')
}
}
try {
const response = await fetch(`${API_BASE}?limit=750`, {
cache: 'no-store',
})
if (!response.ok) {
throw new Error('Unable to reach the hotspot feed.')
}
const data: ApiResponse = await response.json()
setRawPoints(data.points ?? [])
setRawDensity(data.density ?? [])
setRawLatestByUser(data.latestByUser ?? [])
setClientKey(data.clientKey ?? null)
setLastUpdated(data.updatedAt ?? new Date().toISOString())
setErrorMessage(null)
initialLoadRef.current = false
const nextStatus = previousStatus === 'posting' ? 'posting' : 'idle'
setStatusSafe(nextStatus)
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error while loading hotspots.'
setErrorMessage(message)
if (initialLoadRef.current) {
setStatusSafe('error')
} else if (previousStatus !== 'posting') {
setStatusSafe('idle')
}
}
},
[setStatusSafe],
const visibleDensity = useMemo(
() => selectVisibleDensity(userLocation ?? null),
[selectVisibleDensity, userLocation],
)
const submitPoint = useCallback(
async (lat: number, lng: number) => {
setStatusSafe('posting')
try {
const response = await fetch(API_BASE, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ lat, lng }),
})
if (!response.ok) {
const payload = await response.json().catch(() => null)
const message = payload?.message ?? 'Unable to store your signal.'
throw new Error(message)
}
await fetchSnapshot({ silent: true })
setStatusSafe('idle')
} catch (error) {
const message = error instanceof Error ? error.message : 'Something went wrong while saving your signal.'
setErrorMessage(message)
setStatusSafe('error')
throw error
}
},
[fetchSnapshot, setStatusSafe],
const visiblePoints = useMemo(
() => selectVisiblePoints(userLocation ?? null),
[selectVisiblePoints, userLocation],
)
const handleMapClick = useCallback(
({ lat, lng }: LatLng) => {
submitPoint(lat, lng).catch(() => {})
},
[submitPoint],
const visibleLatestByUser = useMemo(
() => selectVisibleLatestByUser(userLocation ?? null),
[selectVisibleLatestByUser, userLocation],
)
const initialiseMap = useCallback(() => {
if (mapRef.current || !mapContainerRef.current) {
return
}
const leaflet = L as LeafletWithHeat
const container = mapContainerRef.current
const map = leaflet
.map(container, {
worldCopyJump: true,
minZoom: 2,
zoomControl: true,
})
.setView([20, 0], 2)
leaflet
.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
crossOrigin: true,
maxZoom: 19,
})
.addTo(map)
const heatLayer =
typeof leaflet.heatLayer === 'function'
? leaflet.heatLayer([], {
radius: 32,
blur: 24,
maxZoom: 12,
gradient: {
0.2: '#38bdf8',
0.4: '#0ea5e9',
0.6: '#fbbf24',
0.8: '#f97316',
1.0: '#ef4444',
},
})
: null
const markersLayer = leaflet.layerGroup().addTo(map)
const zonesLayer = leaflet.layerGroup().addTo(map)
const userLayer = leaflet.layerGroup().addTo(map)
if (heatLayer) {
heatLayer.addTo(map)
}
const onClick = (event: LeafletMouseEvent) => {
const { lat, lng } = event.latlng
if (typeof lat === 'number' && typeof lng === 'number') {
handleMapClick({ lat, lng })
}
}
map.on('click', onClick)
map.whenReady(() => {
requestAnimationFrame(() => {
map.invalidateSize()
})
})
const onResize = () => {
requestAnimationFrame(() => {
map.invalidateSize()
})
}
window.addEventListener('resize', onResize)
mapRef.current = map
heatLayerRef.current = heatLayer
markersLayerRef.current = markersLayer
zonesLayerRef.current = zonesLayer
userLayerRef.current = userLayer
return () => {
map.off('click', onClick)
window.removeEventListener('resize', onResize)
map.remove()
mapRef.current = null
heatLayerRef.current = null
markersLayerRef.current = null
zonesLayerRef.current = null
userLayerRef.current = null
}
}, [handleMapClick])
useEffect(() => {
const cleanup = initialiseMap()
const sizeTimer = window.setTimeout(() => {
if (mapRef.current) {
mapRef.current.invalidateSize()
}
}, 150)
fetchSnapshot().catch(() => {})
const interval = window.setInterval(() => {
fetchSnapshot({ silent: true }).catch(() => {})
}, 7000)
startLocationWatch()
return () => {
if (typeof cleanup === 'function') {
cleanup()
}
window.clearTimeout(sizeTimer)
window.clearInterval(interval)
if (typeof navigator !== 'undefined' && navigator.geolocation && locationWatchIdRef.current !== null) {
navigator.geolocation.clearWatch(locationWatchIdRef.current)
}
}
}, [fetchSnapshot, initialiseMap, startLocationWatch])
const visibleDensity = useMemo(() => {
if (!userLocation) {
return []
}
return rawDensity.filter((entry) => distanceInKm(userLocation, entry) <= VISIBLE_RADIUS_KM)
}, [rawDensity, userLocation])
const visibleDangerZones = useMemo(() => {
if (!visibleDensity.length) {
return []
}
return visibleDensity.slice(0, 3)
}, [visibleDensity])
const visiblePoints = useMemo(() => {
if (!userLocation) {
return []
}
return rawPoints.filter((point) => distanceInKm(userLocation, point) <= VISIBLE_RADIUS_KM)
}, [rawPoints, userLocation])
const visibleLatestByUser = useMemo(() => {
if (!userLocation) {
return []
}
return rawLatestByUser.filter((point) => distanceInKm(userLocation, point) <= VISIBLE_RADIUS_KM)
}, [rawLatestByUser, userLocation])
const localTotals = useMemo(() => {
const uniqueUsers = new Set<string>()
visibleLatestByUser.forEach((point) => uniqueUsers.add(point.userKey))
return {
points: visiblePoints.length,
contributors: uniqueUsers.size,
}
return { points: visiblePoints.length, contributors: uniqueUsers.size }
}, [visibleLatestByUser, visiblePoints])
useEffect(() => {
const heatLayer = heatLayerRef.current
if (!heatLayer) {
return
const myVisibleSignal = useMemo(() => {
if (!myLatestPoint) {
return null
}
if (!visibleDensity.length) {
heatLayer.setLatLngs([])
return
if (userLocation && distanceInKm(userLocation, myLatestPoint) > RADIUS_KM) {
return null
}
return myLatestPoint
}, [myLatestPoint, userLocation])
const maxIntensity = Math.max(...visibleDensity.map((entry) => entry.intensity)) || 1
const heatPoints: HeatPoint[] = visibleDensity.map((entry) => [
entry.lat,
entry.lng,
Math.max(0.25, entry.intensity / maxIntensity),
])
heatLayer.setLatLngs(heatPoints)
}, [visibleDensity])
useEffect(() => {
const layer = zonesLayerRef.current
if (!layer) {
return
}
layer.clearLayers()
if (!visibleDangerZones.length) {
return
}
const maxIntensity = visibleDangerZones[0]?.intensity ?? 0
visibleDangerZones.forEach((zone, index) => {
const intensityRatio = maxIntensity ? zone.intensity / maxIntensity : 0.5
const radius = 400 + intensityRatio * 1600
const circle = L.circle([zone.lat, zone.lng], {
radius,
color: index === 0 ? '#ef4444' : '#f97316',
weight: 2,
fillColor: '#ef4444',
fillOpacity: Math.max(0.12, 0.22 - index * 0.04),
})
circle.addTo(layer).bindTooltip(`Hotspot #${index + 1}\nIntensity: ${zone.intensity}`, {
direction: 'top',
offset: [0, -12],
})
})
}, [visibleDangerZones])
useEffect(() => {
const layer = markersLayerRef.current
if (!layer) {
return
}
layer.clearLayers()
const latestMap = new Map<string, ApiPoint>()
visibleLatestByUser.forEach((point) => {
const existing = latestMap.get(point.userKey)
if (!existing || existing.createdAt < point.createdAt) {
latestMap.set(point.userKey, point)
}
})
latestMap.forEach((point) => {
const isSelf = clientKey && point.userKey === clientKey
const marker = L.circleMarker([point.lat, point.lng], {
radius: isSelf ? 10 : 6,
color: isSelf ? '#38bdf8' : '#94a3b8',
weight: isSelf ? 3 : 1.5,
opacity: 0.9,
fillOpacity: isSelf ? 0.45 : 0.28,
fillColor: isSelf ? '#38bdf8' : '#cbd5f5',
})
marker.addTo(layer).bindTooltip(
`User ${point.userKey}\n${formatCoordinate(point.lat)}, ${formatCoordinate(point.lng)}`,
{
direction: 'top',
offset: [0, -8],
},
)
})
}, [visibleLatestByUser, clientKey])
useEffect(() => {
const layer = userLayerRef.current
if (!layer) {
return
}
layer.clearLayers()
if (!userLocation) {
return
}
L.circle([userLocation.lat, userLocation.lng], {
radius: VISIBLE_RADIUS_KM * 1000,
color: '#38bdf8',
weight: 1.5,
opacity: 0.6,
dashArray: '6 6',
fillColor: '#38bdf8',
fillOpacity: 0.05,
}).addTo(layer)
L.circleMarker([userLocation.lat, userLocation.lng], {
radius: 7,
color: '#38bdf8',
weight: 2,
opacity: 0.9,
fillColor: '#38bdf8',
fillOpacity: 0.5,
}).addTo(layer)
}, [userLocation])
useEffect(() => {
if (!mapRef.current || !userLocation || hasCenteredOnUserRef.current) {
return
}
mapRef.current.setView([userLocation.lat, userLocation.lng], 12, {
animate: true,
})
hasCenteredOnUserRef.current = true
}, [userLocation])
const recentActivity = useMemo(() => visiblePoints.slice(0, 8), [visiblePoints])
const statusLabel = getStatusLabel(status)
const statusBadgeClass = cn(
'inline-flex items-center gap-2 rounded-full border border-border/60 bg-secondary/50 px-3 py-1 text-xs font-medium tracking-wide text-muted-foreground backdrop-blur',
status === 'error' && 'border-destructive/40 bg-destructive/15 text-destructive',
)
const statusAccentClass = status === 'error' ? 'text-destructive' : 'text-primary'
const lastUpdatedLabel = lastUpdated ? formatRelativeTime(lastUpdated) : 'never'
const isLoading = status === 'loading'
const isPosting = status === 'posting'
const heatMax = visibleDangerZones[0]?.intensity ?? 0
const isRefreshing = status === 'refreshing'
const hasLocation = Boolean(userLocation)
const myLatestPoint = useMemo(() => {
if (!clientKey) {
return null
}
const candidate = rawLatestByUser.find((point) => point.userKey === clientKey)
if (!candidate) {
return null
}
if (userLocation && distanceInKm(userLocation, candidate) > VISIBLE_RADIUS_KM) {
return null
}
return candidate
}, [rawLatestByUser, clientKey, userLocation])
const focusDangerZone = useCallback(() => {
if (!mapRef.current || !visibleDangerZones.length) {
return
}
const zone = visibleDangerZones[0]
mapRef.current.setView([zone.lat, zone.lng], 13, {
animate: true,
})
}, [visibleDangerZones])
const focusMySignal = useCallback(() => {
if (!mapRef.current || !myLatestPoint) {
return
}
mapRef.current.setView([myLatestPoint.lat, myLatestPoint.lng], 14, {
animate: true,
})
}, [myLatestPoint])
const refreshNow = useCallback(() => {
fetchSnapshot().catch(() => {})
}, [fetchSnapshot])
const showLocationCta = !hasLocation || Boolean(locationError)
const locationHint = locationError
? locationError
: hasLocation
? `Showing reports within ${VISIBLE_RADIUS_KM}km of you.`
? `Showing reports within ${RADIUS_KM}km of you.`
: isRequestingLocation
? 'Fetching your location...'
: 'Allow location access to view nearby danger pings.'
? 'Fetching your location'
: 'Allow location access to view nearby reports.'
const showLocationCta = !hasLocation || Boolean(locationError)
const { mapContainerRef, focusOn, fitToHeat } = useLeafletHeatmap({
heatCells: visibleDensity,
userLocation: userLocation ?? null,
onRequestSpot: (position) => {
setPendingSpot(position)
setIsConfirmOpen(true)
},
})
const handleConfirmSignal = useCallback(async () => {
if (!pendingSpot) {
return
}
setIsConfirming(true)
const result = await submitPoint(pendingSpot.lat, pendingSpot.lng)
setIsConfirming(false)
if (result.success) {
setIsConfirmOpen(false)
setPendingSpot(null)
}
}, [pendingSpot, submitPoint])
const handleRefresh = useCallback(() => {
fetchSnapshot().catch(() => undefined)
}, [fetchSnapshot])
const handleFocusHeat = useCallback(() => {
fitToHeat()
}, [fitToHeat])
const handleLocateUser = useCallback(() => {
if (userLocation) {
focusOn(userLocation, 14)
}
}, [focusOn, userLocation])
const handleFocusMySignal = useCallback(() => {
if (myVisibleSignal) {
focusOn({ lat: myVisibleSignal.lat, lng: myVisibleSignal.lng }, 15)
}
}, [focusOn, myVisibleSignal])
const handleManualReport = useCallback(() => {
if (!userLocation) {
return
}
setPendingSpot(userLocation)
setIsConfirmOpen(true)
}, [userLocation])
const dangerCells = useMemo(
() =>
[...visibleDensity]
.sort((a, b) => b.intensity - a.intensity)
.slice(0, 5)
.map((cell, index) => ({
id: `${cell.lat}-${cell.lng}-${index}`,
title: `Hotspot #${index + 1}`,
subtitle: hasLocation
? `${distanceInKm(userLocation!, cell).toFixed(2)}km away · ${formatCoordinate(cell.lat)}°, ${formatCoordinate(cell.lng)}°`
: `${formatCoordinate(cell.lat)}°, ${formatCoordinate(cell.lng)}°`,
intensity: cell.intensity,
onFocus: () => focusOn({ lat: cell.lat, lng: cell.lng }, 15),
})),
[visibleDensity, hasLocation, userLocation, focusOn],
)
const recentActivity = useMemo(
() =>
[...visiblePoints]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 8)
.map((point) => ({
id: point.id,
title: `${formatCoordinate(point.lat)}°, ${formatCoordinate(point.lng)}°`,
subtitle: `User ${point.userKey.slice(0, 4).toUpperCase()}`,
timestampLabel: formatRelativeTime(point.createdAt),
distanceLabel: hasLocation
? `${distanceInKm(userLocation!, point).toFixed(2)}km away`
: formatTimestamp(point.createdAt),
onFocus: () => focusOn({ lat: point.lat, lng: point.lng }, 15),
})),
[visiblePoints, hasLocation, userLocation, focusOn],
)
const confirmationLat = pendingSpot ? formatCoordinate(pendingSpot.lat) : '--'
const confirmationLng = pendingSpot ? formatCoordinate(pendingSpot.lng) : '--'
const isDialogDisabled = !pendingSpot || isConfirming
return (
<div className="flex min-h-screen flex-col bg-background text-foreground">
<header className="sticky top-0 z-10 border-b border-border/60 bg-background/70 backdrop-blur">
<div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-4 px-6 py-4">
<div className="flex flex-col gap-1">
<h1 className="text-xl font-semibold sm:text-2xl">SignalMap</h1>
<p className="text-sm text-muted-foreground sm:text-base">
Crowd-powered danger zones layered on Leaflet + OpenStreetMap.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className={statusBadgeClass} aria-live="polite">
<span className={cn('flex items-center gap-2', statusAccentClass)}>
<span className="status-dot relative block h-2.5 w-2.5 rounded-full bg-[currentColor]" aria-hidden />
{statusLabel}
</span>
</span>
<Button variant="ghost" onClick={refreshNow} disabled={isLoading || status === 'refreshing' || isPosting}>
Refresh
</Button>
<Button onClick={focusDangerZone} disabled={!visibleDangerZones.length}>
Focus danger zone
</Button>
<Button variant="ghost" onClick={focusMySignal} disabled={!myLatestPoint}>
Locate me
</Button>
</div>
</div>
</header>
<AppHeader
status={status}
statusLabel={statusLabel}
lastUpdatedLabel={lastUpdatedLabel}
onRefresh={handleRefresh}
onFocusHeat={handleFocusHeat}
onLocateUser={handleLocateUser}
onFocusMySignal={handleFocusMySignal}
disableRefresh={isLoading || isRefreshing || isPosting}
disableHeat={visibleDensity.length === 0}
disableLocate={!hasLocation}
disableMySignal={!myVisibleSignal}
/>
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 px-6 py-6 lg:flex-row">
<section className="flex w-full flex-col gap-4 lg:max-w-sm">
<Card>
<CardHeader>
<CardTitle>Danger zone intel</CardTitle>
<CardDescription>Highest intensity cells near you right now.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{!hasLocation && (
<p className="text-sm text-muted-foreground">
We need your location to reveal community alerts around you.
</p>
)}
{hasLocation && visibleDangerZones.length === 0 && (
<p className="text-sm text-muted-foreground">
No hotspots within {VISIBLE_RADIUS_KM}km yet. Tap anywhere on the map to raise the first signal.
</p>
)}
{visibleDangerZones.map((zone, index) => (
<div
className="flex items-center justify-between rounded-lg border border-border/60 bg-muted/20 px-3 py-2"
key={`${zone.lat}-${zone.lng}`}
>
<div className="flex flex-col">
<span className="text-sm font-semibold text-foreground">Hotspot #{index + 1}</span>
<span className="text-xs text-muted-foreground">
{formatCoordinate(zone.lat)}, {formatCoordinate(zone.lng)}
</span>
</div>
<span className="text-sm font-semibold text-orange-400">×{zone.intensity}</span>
</div>
))}
</CardContent>
<CardFooter>
{hasLocation ? (
<span>
Heatmap max intensity: {heatMax || '—'} Last sync {lastUpdatedLabel}
</span>
) : (
<span>Totals unavailable until we can access your position.</span>
)}
</CardFooter>
</Card>
<Card>
<CardHeader>
<CardTitle>Community feed</CardTitle>
<CardDescription>
Local reports: {localTotals.points} · Unique spotters: {localTotals.contributors}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{!hasLocation && (
<p className="text-sm text-muted-foreground">
Allow location access to tune the feed to your area.
</p>
)}
{hasLocation && recentActivity.length === 0 && (
<p className="text-sm text-muted-foreground">
Waiting for the first signals nearby. Click the map to broadcast a hazard ping.
</p>
)}
{recentActivity.map((item) => (
<div
className="flex flex-col gap-1 rounded-lg border border-border/60 bg-card/40 px-3 py-2"
key={item.id}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">
{formatCoordinate(item.lat)}, {formatCoordinate(item.lng)}
</span>
<Badge variant={item.userKey === clientKey ? 'success' : 'muted'}>
{item.userKey === clientKey ? 'You' : `User ${item.userKey}`}
</Badge>
</div>
<span className="text-xs text-muted-foreground">{formatRelativeTime(item.createdAt)}</span>
</div>
))}
</CardContent>
</Card>
</section>
<section className="flex w-full flex-1 flex-col gap-4">
{status === 'error' && errorMessage && (
<div className="flex items-center justify-between rounded-xl border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive shadow-lg shadow-destructive/20">
<span>{errorMessage}</span>
<Button variant="ghost" onClick={refreshNow} className="text-destructive hover:text-destructive">
Try again
</Button>
</div>
)}
<div className="relative flex min-h-[480px] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-card/70 shadow-2xl shadow-black/40">
<div
ref={mapContainerRef}
className="h-full min-h-[480px] w-full"
aria-label="Collaborative danger zone map"
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 px-4 py-6 sm:px-6">
<section className="flex flex-col gap-6 lg:flex-row">
<div className="order-2 flex w-full flex-col gap-4 lg:order-1 lg:max-w-sm">
<OverviewPanel
nearbySignals={localTotals.points}
uniqueContributors={localTotals.contributors}
lastUpdatedLabel={lastUpdatedLabel}
mySignalLabel={myVisibleSignal ? formatRelativeTime(myVisibleSignal.createdAt) : null}
errorMessage={errorMessage}
onReport={handleManualReport}
onRetry={handleRefresh}
isPosting={isPosting || isConfirming}
locationHint={locationHint}
showLocationCta={showLocationCta}
disableReport={!hasLocation}
/>
<HotspotStatsPanel
hasLocation={hasLocation}
radiusKm={RADIUS_KM}
locationHint={locationHint}
cells={dangerCells}
/>
<ActivityPanel items={recentActivity} emptyMessage="No recent signals within your area yet." />
</div>
<div className="order-1 flex w-full flex-1 lg:order-2">
<MapViewport
containerRef={mapContainerRef}
isPosting={isPosting || isConfirming}
isLoading={isLoading}
confirmationHint={isConfirmOpen ? 'Confirm the new signal in the dialog to send it.' : null}
/>
<div className="pointer-events-none absolute bottom-4 left-4 right-4 mx-auto flex max-w-md flex-col gap-3 rounded-2xl border border-border/60 bg-background/90 p-4 text-xs text-muted-foreground shadow-xl shadow-black/50 backdrop-blur">
<div className="flex items-center justify-between text-[0.8rem] text-foreground">
<span className="font-semibold">Collaborative heatmap</span>
<Badge variant="destructive">LIVE</Badge>
</div>
<p className="text-[0.8rem] leading-relaxed text-muted-foreground">
Click anywhere to drop a signal. We blend every report into a shared danger zone heatmap focused on your
surroundings.
</p>
<Separator className="bg-border/40" />
<div className="flex flex-col gap-1 text-[0.75rem] text-muted-foreground">
<span>Local signals: {localTotals.points} Last sync {lastUpdatedLabel}</span>
<span>{locationHint}</span>
</div>
{showLocationCta && (
<div className="pointer-events-auto flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => startLocationWatch()}
disabled={isRequestingLocation}
>
{isRequestingLocation ? 'Requesting location…' : 'Enable location'}
</Button>
</div>
)}
</div>
</div>
</section>
</main>
<AlertDialog
open={isConfirmOpen}
onOpenChange={(nextOpen) => {
setIsConfirmOpen(nextOpen)
if (!nextOpen) {
setPendingSpot(null)
setIsConfirming(false)
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm new signal</AlertDialogTitle>
<AlertDialogDescription>
You&apos;re about to publish a community alert at these coordinates. Double-check the spot before confirming.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="rounded-2xl border border-border/60 bg-muted/40 p-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Latitude</span>
<span className="font-medium text-foreground">{confirmationLat}°</span>
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-muted-foreground">Longitude</span>
<span className="font-medium text-foreground">{confirmationLng}°</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
Signals are visible to travellers within {RADIUS_KM}km and help the community stay aware of hotspots.
</p>
<AlertDialogFooter>
<AlertDialogCancel disabled={isConfirming}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isDialogDisabled}
onClick={(event) => {
event.preventDefault()
handleConfirmSignal().catch(() => undefined)
}}
>
{isConfirming ? 'Sending…' : 'Confirm signal'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
@@ -0,0 +1,84 @@
import { Flame, Focus, LocateFixed, MapPin, RefreshCw } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ThemeToggle } from '@/components/layout/ThemeToggle'
import type { FeedStatus } from '@/types/api'
interface AppHeaderProps {
status: FeedStatus
statusLabel: string
lastUpdatedLabel: string
onRefresh: () => void
onFocusHeat: () => void
onLocateUser: () => void
onFocusMySignal: () => void
disableRefresh: boolean
disableHeat: boolean
disableLocate: boolean
disableMySignal: boolean
}
export function AppHeader({
status,
statusLabel,
lastUpdatedLabel,
onRefresh,
onFocusHeat,
onLocateUser,
onFocusMySignal,
disableRefresh,
disableHeat,
disableLocate,
disableMySignal,
}: AppHeaderProps) {
const isError = status === 'error'
return (
<header className="sticky top-0 z-40 border-b border-border/60 bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-3 px-4 py-3 sm:px-6">
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-border/60 bg-primary/10 text-primary">
<Flame className="h-5 w-5" />
</span>
<div className="flex flex-col">
<span className="text-lg font-semibold sm:text-xl">SignalMap</span>
<span className="text-xs text-muted-foreground sm:text-sm">Crowd signals around your route</span>
</div>
</div>
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">
<Badge
variant="secondary"
className={
'inline-flex items-center gap-2 rounded-full border border-border/60 bg-muted/60 px-3 py-1 text-xs font-medium uppercase tracking-wide'
}
>
<span
className={`flex items-center gap-2 ${isError ? 'text-destructive' : 'text-primary'}`}
aria-live="polite"
>
<span className="relative block h-2.5 w-2.5 rounded-full bg-current">
<span className="absolute inset-[-0.35rem] rounded-full border border-current opacity-40 animate-status-pulse" />
</span>
{statusLabel}
</span>
<span className="text-[10px] uppercase text-muted-foreground">{lastUpdatedLabel}</span>
</Badge>
<Button variant="ghost" size="icon" onClick={onRefresh} disabled={disableRefresh} aria-label="Refresh now">
<RefreshCw className="h-4 w-4" />
</Button>
<Button variant="secondary" size="icon" onClick={onFocusHeat} disabled={disableHeat} aria-label="Focus heatmap">
<Focus className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={onLocateUser} disabled={disableLocate} aria-label="Locate me">
<LocateFixed className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={onFocusMySignal} disabled={disableMySignal} aria-label="My last signal">
<MapPin className="h-4 w-4" />
</Button>
<ThemeToggle />
</div>
</div>
</header>
)
}
@@ -0,0 +1,19 @@
import { Moon, Sun } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useTheme } from '@/hooks/useTheme'
export function ThemeToggle() {
const { toggleTheme, isDark } = useTheme()
return (
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
className="rounded-full border border-border/60 bg-background/80 backdrop-blur"
>
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
)
}
+32
View File
@@ -0,0 +1,32 @@
import type { MutableRefObject } from 'react'
import { cn } from '@/lib/utils'
interface MapViewportProps {
containerRef: MutableRefObject<HTMLDivElement | null>
isPosting: boolean
isLoading: boolean
confirmationHint?: string | null
}
export function MapViewport({ containerRef, isPosting, isLoading, confirmationHint }: MapViewportProps) {
return (
<div className="relative min-h-[360px] flex-1 overflow-hidden rounded-3xl border border-border/50 bg-muted/40 shadow-inner">
<div ref={containerRef} className={cn('absolute inset-0 z-0', 'leaflet-wrapper')} />
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-24 bg-gradient-to-b from-background/70 to-transparent" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-24 bg-gradient-to-t from-background/70 to-transparent" />
{(isPosting || isLoading) && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<span className="rounded-full border border-border/60 bg-background/80 px-4 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground backdrop-blur">
{isPosting ? 'Sending your signal…' : 'Syncing map…'}
</span>
</div>
)}
{confirmationHint && (
<div className="pointer-events-none absolute bottom-6 left-1/2 z-20 w-[90%] max-w-sm -translate-x-1/2 rounded-full border border-border/70 bg-background/90 px-4 py-2 text-xs text-muted-foreground shadow">
{confirmationHint}
</div>
)}
</div>
)
}
@@ -0,0 +1,63 @@
import { Activity, ArrowRight } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area'
interface ActivityItem {
id: string | number
title: string
subtitle: string
timestampLabel: string
distanceLabel: string
onFocus: () => void
}
interface ActivityPanelProps {
items: ActivityItem[]
emptyMessage: string
}
export function ActivityPanel({ items, emptyMessage }: ActivityPanelProps) {
return (
<Card className="h-full">
<CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5 text-primary" />
Live community pings
</CardTitle>
<CardDescription>Latest activity reported by nearby contributors.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{items.length === 0 && <p className="text-sm text-muted-foreground">{emptyMessage}</p>}
{items.length > 0 && (
<ScrollArea className="max-h-[280px] pr-2">
<ul className="space-y-3">
{items.map((item) => (
<li key={item.id} className="rounded-2xl border border-border/60 bg-muted/50 p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col">
<span className="text-sm font-medium text-foreground">{item.title}</span>
<span className="text-xs text-muted-foreground">{item.subtitle}</span>
</div>
<Badge variant="outline" className="border-border/60 text-[10px] uppercase text-muted-foreground">
{item.timestampLabel}
</Badge>
</div>
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span>{item.distanceLabel}</span>
<Button variant="ghost" size="sm" className="h-8 gap-2 text-xs" onClick={item.onFocus}>
View
<ArrowRight className="h-3.5 w-3.5" />
</Button>
</div>
</li>
))}
</ul>
</ScrollArea>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,68 @@
import { Flame, MapPin } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area'
interface HotspotCellInfo {
id: string
title: string
subtitle: string
intensity: number
onFocus: () => void
}
interface HotspotStatsPanelProps {
hasLocation: boolean
radiusKm: number
locationHint: string
cells: HotspotCellInfo[]
}
export function HotspotStatsPanel({ hasLocation, radiusKm, locationHint, cells }: HotspotStatsPanelProps) {
return (
<Card className="h-full">
<CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-2">
<Flame className="h-5 w-5 text-primary" />
Danger zone intel
</CardTitle>
<CardDescription>Highest intensity heat within {radiusKm}km.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!hasLocation && <p className="text-sm text-muted-foreground">{locationHint}</p>}
{hasLocation && cells.length === 0 && (
<p className="text-sm text-muted-foreground">No active hotspots nearby. Tap the map to log a new signal.</p>
)}
{cells.length > 0 && (
<ScrollArea className="max-h-[280px] pr-2">
<ul className="space-y-3">
{cells.map((cell) => (
<li key={cell.id} className="rounded-2xl border border-border/60 bg-muted/50 p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col">
<span className="text-sm font-semibold text-foreground">{cell.title}</span>
<span className="text-xs text-muted-foreground">{cell.subtitle}</span>
</div>
<Badge variant="outline" className="border-primary/40 text-xs text-primary">
{cell.intensity.toFixed(1)}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
className="mt-2 w-full justify-center gap-2 text-xs"
onClick={cell.onFocus}
>
<MapPin className="h-3.5 w-3.5" /> Focus
</Button>
</li>
))}
</ul>
</ScrollArea>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,82 @@
import { AlertCircle, Radio } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
interface OverviewPanelProps {
nearbySignals: number
uniqueContributors: number
lastUpdatedLabel: string
mySignalLabel: string | null
errorMessage: string | null
onReport: () => void
onRetry: () => void
isPosting: boolean
locationHint: string
showLocationCta: boolean
disableReport: boolean
}
export function OverviewPanel({
nearbySignals,
uniqueContributors,
lastUpdatedLabel,
mySignalLabel,
errorMessage,
onReport,
onRetry,
isPosting,
locationHint,
showLocationCta,
disableReport,
}: OverviewPanelProps) {
return (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-2">
<Radio className="h-5 w-5 text-primary" />
Nearby coverage
</CardTitle>
<CardDescription>Signals refresh automatically every few seconds.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-2xl border border-border/60 bg-muted/50 p-3">
<span className="text-xs uppercase text-muted-foreground">Signals</span>
<p className="text-xl font-semibold">{nearbySignals}</p>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/50 p-3">
<span className="text-xs uppercase text-muted-foreground">Contributors</span>
<p className="text-xl font-semibold">{uniqueContributors}</p>
</div>
</div>
{mySignalLabel && (
<Badge variant="secondary" className="w-full justify-center rounded-full py-2 text-xs uppercase">
Your last signal {mySignalLabel}
</Badge>
)}
{errorMessage ? (
<div className="flex items-start gap-3 rounded-2xl border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 h-4 w-4" />
<div className="space-y-2">
<p>{errorMessage}</p>
<Button variant="outline" size="sm" className="text-xs" onClick={onRetry}>
Try again
</Button>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">{locationHint}</p>
)}
<Button className="w-full" onClick={onReport} disabled={isPosting || disableReport}>
{isPosting ? 'Sending…' : disableReport ? 'Waiting for location…' : 'Drop a signal manually'}
</Button>
{showLocationCta && !errorMessage && (
<p className="text-xs text-muted-foreground">Allow location permissions to personalise the feed.</p>
)}
<p className="text-[11px] uppercase text-muted-foreground">Last synced {lastUpdatedLabel}</p>
</CardContent>
</Card>
)
}
+109
View File
@@ -0,0 +1,109 @@
import * as React from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import { cn } from '@/lib/utils'
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 border border-border/70 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
)
AlertDialogHeader.displayName = 'AlertDialogHeader'
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />
)
AlertDialogFooter.displayName = 'AlertDialogFooter'
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn('inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:pointer-events-none disabled:opacity-50', className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn('inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:pointer-events-none disabled:opacity-50 sm:mr-2', className)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+40
View File
@@ -0,0 +1,40 @@
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import * as React from 'react'
import { cn } from '@/lib/utils'
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2 border-t border-t-transparent p-[1px]',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border/60" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+101
View File
@@ -0,0 +1,101 @@
import * as React from 'react'
import * as SheetPrimitive from '@radix-ui/react-dialog'
import { cn } from '@/lib/utils'
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'fixed inset-0 z-40 bg-background/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
type SheetSide = 'top' | 'bottom' | 'left' | 'right'
interface SheetContentProps extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> {
side?: SheetSide
}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = 'bottom', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(
'fixed z-50 grid gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out',
side === 'bottom' && 'inset-x-0 bottom-0 rounded-t-2xl border-t',
side === 'top' && 'inset-x-0 top-0 rounded-b-2xl border-b',
side === 'left' && 'inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'right' && 'inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
className,
)}
data-side={side}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-6 top-6 rounded-full border border-border/50 bg-background/60 px-3 py-1 text-xs font-medium text-muted-foreground transition hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background">
Close
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
)
SheetHeader.displayName = 'SheetHeader'
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />
)
SheetFooter.displayName = 'SheetFooter'
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+50
View File
@@ -0,0 +1,50 @@
import * as TabsPrimitive from '@radix-ui/react-tabs'
import * as React from 'react'
import { cn } from '@/lib/utils'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn('inline-flex h-10 items-center justify-center rounded-full bg-muted p-1 text-muted-foreground', className)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex min-w-[120px] items-center justify-center whitespace-nowrap rounded-full px-4 py-1.5 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=inactive]:opacity-70',
className,
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
className,
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
+174
View File
@@ -0,0 +1,174 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { distanceInKm } from '@/lib/utils'
import type { ApiDensityCell, ApiPoint, ApiSnapshot, FeedStatus, LatLng } from '@/types/api'
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api/signals'
const SNAPSHOT_LIMIT = 750
const DEFAULT_REFRESH_MS = 7000
const VISIBLE_RADIUS_KM = 1
interface UseHotspotFeedOptions {
autoRefreshMs?: number
}
interface SubmitResult {
success: boolean
}
export function useHotspotFeed({ autoRefreshMs = DEFAULT_REFRESH_MS }: UseHotspotFeedOptions = {}) {
const [status, setStatus] = useState<FeedStatus>('loading')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [rawPoints, setRawPoints] = useState<ApiPoint[]>([])
const [rawDensity, setRawDensity] = useState<ApiDensityCell[]>([])
const [rawLatestByUser, setRawLatestByUser] = useState<ApiPoint[]>([])
const [clientKey, setClientKey] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<string | null>(null)
const statusRef = useRef<FeedStatus>('loading')
const initialLoadRef = useRef(true)
const setStatusSafe = useCallback((next: FeedStatus) => {
statusRef.current = next
setStatus(next)
}, [])
const fetchSnapshot = useCallback(
async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false
const previousStatus = statusRef.current
const isInitial = initialLoadRef.current
if (previousStatus !== 'posting') {
if (isInitial) {
setStatusSafe('loading')
} else if (!silent) {
setStatusSafe('refreshing')
}
}
try {
const response = await fetch(`${API_BASE}?limit=${SNAPSHOT_LIMIT}`, { cache: 'no-store' })
if (!response.ok) {
throw new Error('Unable to reach the hotspot feed.')
}
const data: ApiSnapshot = await response.json()
setRawPoints(data.points ?? [])
setRawDensity(data.density ?? [])
setRawLatestByUser(data.latestByUser ?? [])
setClientKey(data.clientKey ?? null)
setLastUpdated(data.updatedAt ?? new Date().toISOString())
setErrorMessage(null)
initialLoadRef.current = false
const nextStatus = previousStatus === 'posting' ? 'posting' : 'idle'
setStatusSafe(nextStatus)
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error while loading hotspots.'
setErrorMessage(message)
if (initialLoadRef.current) {
setStatusSafe('error')
} else if (previousStatus !== 'posting') {
setStatusSafe('idle')
}
}
},
[setStatusSafe],
)
useEffect(() => {
fetchSnapshot().catch(() => undefined)
if (!autoRefreshMs) {
return
}
const interval = window.setInterval(() => {
fetchSnapshot({ silent: true }).catch(() => undefined)
}, autoRefreshMs)
return () => {
window.clearInterval(interval)
}
}, [autoRefreshMs, fetchSnapshot])
const submitPoint = useCallback(
async (lat: number, lng: number): Promise<SubmitResult> => {
setStatusSafe('posting')
try {
const response = await fetch(API_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat, lng }),
})
if (!response.ok) {
const payload = await response.json().catch(() => null)
const message = payload?.message ?? 'Unable to store your signal.'
throw new Error(message)
}
await fetchSnapshot({ silent: true })
setStatusSafe('idle')
return { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Something went wrong while saving your signal.'
setErrorMessage(message)
setStatusSafe('error')
return { success: false }
}
},
[fetchSnapshot, setStatusSafe],
)
const hasClientKey = Boolean(clientKey)
const filterWithinRadius = useCallback(
<T extends LatLng>(collection: T[], origin: LatLng | null) => {
if (!origin) {
return []
}
return collection.filter((item) => distanceInKm(origin, item) <= VISIBLE_RADIUS_KM)
},
[],
)
const selectVisibleDensity = useCallback(
(origin: LatLng | null) => filterWithinRadius(rawDensity, origin),
[filterWithinRadius, rawDensity],
)
const selectVisiblePoints = useCallback(
(origin: LatLng | null) => filterWithinRadius(rawPoints, origin),
[filterWithinRadius, rawPoints],
)
const selectVisibleLatestByUser = useCallback(
(origin: LatLng | null) => filterWithinRadius(rawLatestByUser, origin),
[filterWithinRadius, rawLatestByUser],
)
const myLatestPoint = useMemo(() => {
if (!hasClientKey) {
return null
}
return rawLatestByUser.find((point) => point.userKey === clientKey) ?? null
}, [clientKey, hasClientKey, rawLatestByUser])
return {
status,
errorMessage,
submitPoint,
fetchSnapshot,
rawDensity,
rawPoints,
rawLatestByUser,
clientKey,
lastUpdated,
hasClientKey,
selectVisibleDensity,
selectVisiblePoints,
selectVisibleLatestByUser,
myLatestPoint,
}
}
+212
View File
@@ -0,0 +1,212 @@
import { useCallback, useEffect, useRef } from 'react'
import type { MutableRefObject } from 'react'
import L, { type LeafletMouseEvent, type Map as LeafletMap, type LayerGroup } from 'leaflet'
import 'leaflet.heat'
import type { ApiDensityCell, LatLng } from '@/types/api'
type HeatPoint = [number, number, number?]
type LeafletHeatLayer = L.Layer & {
setLatLngs(points: HeatPoint[]): LeafletHeatLayer
getBounds?: () => L.LatLngBounds
}
type LeafletWithHeat = typeof L & {
heatLayer?: (points: HeatPoint[], options?: Record<string, unknown>) => LeafletHeatLayer
}
interface UseLeafletHeatmapParams {
heatCells: ApiDensityCell[]
userLocation: LatLng | null
onRequestSpot?: (position: LatLng) => void
}
interface UseLeafletHeatmapResult {
mapContainerRef: MutableRefObject<HTMLDivElement | null>
focusOn: (position: LatLng, zoom?: number) => void
fitToHeat: () => void
map: LeafletMap | null
}
const INITIAL_VIEW: LatLng = { lat: 20, lng: 0 }
const DEFAULT_ZOOM = 3
export function useLeafletHeatmap({ heatCells, userLocation, onRequestSpot }: UseLeafletHeatmapParams): UseLeafletHeatmapResult {
const mapRef = useRef<LeafletMap | null>(null)
const heatLayerRef = useRef<LeafletHeatLayer | null>(null)
const userLayerRef = useRef<LayerGroup | null>(null)
const hasCenteredOnUserRef = useRef(false)
const mapContainerRef = useRef<HTMLDivElement | null>(null)
const initialiseMap = useCallback(() => {
if (mapRef.current || !mapContainerRef.current) {
return
}
const leaflet = L as LeafletWithHeat
const container = mapContainerRef.current
const map = leaflet
.map(container, {
worldCopyJump: true,
minZoom: 2,
zoomControl: false,
attributionControl: false,
})
.setView([INITIAL_VIEW.lat, INITIAL_VIEW.lng], DEFAULT_ZOOM)
leaflet
.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
crossOrigin: true,
maxZoom: 19,
})
.addTo(map)
const heatLayer =
typeof leaflet.heatLayer === 'function'
? leaflet.heatLayer([], {
radius: 32,
blur: 24,
maxZoom: 12,
gradient: {
0.2: '#38bdf8',
0.4: '#0ea5e9',
0.6: '#fbbf24',
0.8: '#fb923c',
1: '#ef4444',
},
})
: null
const userLayer = leaflet.layerGroup().addTo(map)
if (heatLayer) {
heatLayer.addTo(map)
}
const handleClick = (event: LeafletMouseEvent) => {
const { lat, lng } = event.latlng
if (typeof lat === 'number' && typeof lng === 'number') {
onRequestSpot?.({ lat, lng })
}
}
map.on('click', handleClick)
map.whenReady(() => {
requestAnimationFrame(() => {
map.invalidateSize()
})
})
const onResize = () => {
requestAnimationFrame(() => {
map.invalidateSize()
})
}
window.addEventListener('resize', onResize)
mapRef.current = map
heatLayerRef.current = heatLayer
userLayerRef.current = userLayer
return () => {
map.off('click', handleClick)
window.removeEventListener('resize', onResize)
map.remove()
mapRef.current = null
heatLayerRef.current = null
userLayerRef.current = null
hasCenteredOnUserRef.current = false
}
}, [onRequestSpot])
useEffect(() => {
const cleanup = initialiseMap()
return () => {
if (typeof cleanup === 'function') {
cleanup()
}
}
}, [initialiseMap])
useEffect(() => {
const heatLayer = heatLayerRef.current
if (!heatLayer) {
return
}
if (!heatCells.length) {
heatLayer.setLatLngs([])
return
}
const maxIntensity = Math.max(...heatCells.map((cell) => cell.intensity)) || 1
const heatPoints: HeatPoint[] = heatCells.map((cell) => [
cell.lat,
cell.lng,
Math.max(0.2, cell.intensity / maxIntensity),
])
heatLayer.setLatLngs(heatPoints)
}, [heatCells])
useEffect(() => {
const userLayer = userLayerRef.current
const map = mapRef.current
if (!userLayer || !map) {
return
}
userLayer.clearLayers()
if (!userLocation) {
return
}
L.circleMarker([userLocation.lat, userLocation.lng], {
radius: 7,
color: '#38bdf8',
weight: 3,
opacity: 0.9,
fillColor: '#38bdf8',
fillOpacity: 0.35,
}).addTo(userLayer)
if (!hasCenteredOnUserRef.current) {
map.setView([userLocation.lat, userLocation.lng], 13, { animate: true })
hasCenteredOnUserRef.current = true
}
}, [userLocation])
const focusOn = useCallback((position: LatLng, zoom = 14) => {
const map = mapRef.current
if (!map) {
return
}
map.setView([position.lat, position.lng], zoom, { animate: true })
}, [])
const fitToHeat = useCallback(() => {
const map = mapRef.current
const heatLayer = heatLayerRef.current
if (!map || !heatLayer) {
return
}
const bounds = heatLayer.getBounds?.()
if (bounds && bounds.isValid()) {
map.fitBounds(bounds.pad(0.25), { animate: true })
}
}, [])
return {
mapContainerRef,
focusOn,
fitToHeat,
map: mapRef.current,
}
}
+46
View File
@@ -0,0 +1,46 @@
import { useCallback, useEffect, useState } from 'react'
type Theme = 'light' | 'dark'
const STORAGE_KEY = 'signalmap-theme'
function applyTheme(theme: Theme) {
const root = document.documentElement
if (theme === 'dark') {
root.classList.add('dark')
} else {
root.classList.remove('dark')
}
}
function getPreferredTheme(): Theme {
if (typeof window === 'undefined') {
return 'dark'
}
const stored = window.localStorage.getItem(STORAGE_KEY)
if (stored === 'light' || stored === 'dark') {
return stored
}
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
return prefersDark ? 'dark' : 'light'
}
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => getPreferredTheme())
useEffect(() => {
applyTheme(theme)
window.localStorage.setItem(STORAGE_KEY, theme)
}, [theme])
const toggleTheme = useCallback(() => {
setTheme((current) => (current === 'dark' ? 'light' : 'dark'))
}, [])
return {
theme,
setTheme,
toggleTheme,
isDark: theme === 'dark',
}
}
+88
View File
@@ -0,0 +1,88 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { LatLng } from '@/types/api'
function geolocationErrorMessage(error: GeolocationPositionError): string {
switch (error.code) {
case error.PERMISSION_DENIED:
return 'Location access denied. Enable it to view nearby pings.'
case error.POSITION_UNAVAILABLE:
return 'Unable to determine your position. Try again.'
case error.TIMEOUT:
return 'Timed out while fetching your location.'
default:
return 'Failed to retrieve your location.'
}
}
export function useUserLocation() {
const [location, setLocation] = useState<LatLng | null>(null)
const [error, setError] = useState<string | null>(null)
const [isRequesting, setIsRequesting] = useState<boolean>(false)
const watchIdRef = useRef<number | null>(null)
const clearWatch = useCallback(() => {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
return
}
if (watchIdRef.current !== null) {
navigator.geolocation.clearWatch(watchIdRef.current)
watchIdRef.current = null
}
}, [])
const start = useCallback(() => {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
setError('Geolocation is not supported in this browser.')
setIsRequesting(false)
return
}
clearWatch()
setIsRequesting(true)
setError(null)
navigator.geolocation.getCurrentPosition(
(position) => {
setLocation({ lat: position.coords.latitude, lng: position.coords.longitude })
setIsRequesting(false)
setError(null)
},
(geoError) => {
setError(geolocationErrorMessage(geoError))
setIsRequesting(false)
},
{ enableHighAccuracy: true, timeout: 10000 },
)
const watchId = navigator.geolocation.watchPosition(
(position) => {
setLocation({ lat: position.coords.latitude, lng: position.coords.longitude })
setError(null)
setIsRequesting(false)
},
(geoError) => {
setError(geolocationErrorMessage(geoError))
setIsRequesting(false)
},
{ enableHighAccuracy: true, maximumAge: 15000, timeout: 10000 },
)
watchIdRef.current = watchId
}, [clearWatch])
useEffect(() => {
start()
return () => {
clearWatch()
}
}, [clearWatch, start])
return {
location,
error,
isRequesting,
refresh: start,
}
}
+49 -32
View File
@@ -5,15 +5,32 @@
@tailwind utilities;
@layer base {
html,
body,
#root {
height: 100%;
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 200 98% 39%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 200 98% 39%;
--accent-foreground: 210 40% 98%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 200 98% 39%;
--radius: 0.9rem;
}
:root {
.dark {
color-scheme: dark;
--background: 222.2 84% 4.9%;
--background: 224 47% 8%;
--foreground: 210 40% 98%;
--card: 222.2 84% 6.5%;
--card-foreground: 210 40% 96%;
@@ -23,52 +40,52 @@
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 96%;
--muted: 223 50% 12%;
--muted: 223 47% 10%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 199 89% 62%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 221.6 30% 23%;
--input: 221.6 30% 23%;
--border: 217 33% 18%;
--input: 217 33% 18%;
--ring: 199 89% 62%;
--radius: 0.8rem;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
* {
*,
*::before,
*::after {
@apply border-border;
}
body {
@apply min-h-screen bg-background bg-radial-signal text-foreground antialiased;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
@apply min-h-screen bg-background text-foreground antialiased;
background-image: radial-gradient(circle at top, hsla(var(--primary) / 0.1), transparent 45%),
radial-gradient(circle at bottom, hsla(var(--destructive) / 0.08), transparent 55%);
}
p {
@apply leading-relaxed;
#root {
@apply min-h-screen;
}
}
@layer components {
.leaflet-wrapper,
.leaflet-container {
height: 100%;
width: 100%;
}
.leaflet-container {
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
z-index: 0;
}
.leaflet-tooltip {
background: rgba(15, 23, 42, 0.9);
border: 1px solid rgba(148, 163, 184, 0.35);
color: #e2e8f0;
border-radius: 0.75rem;
box-shadow: 0 15px 35px rgba(2, 6, 23, 0.45);
padding: 0.65rem 0.75rem;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
border-top-color: rgba(15, 23, 42, 0.9);
border-bottom-color: rgba(15, 23, 42, 0.9);
border-left-color: rgba(15, 23, 42, 0.9);
border-right-color: rgba(15, 23, 42, 0.9);
.leaflet-pane,
.leaflet-top,
.leaflet-bottom,
.leaflet-control-container {
z-index: 20;
}
}
+32
View File
@@ -0,0 +1,32 @@
export type FeedStatus = 'loading' | 'idle' | 'error' | 'posting' | 'refreshing'
export interface ApiPoint {
id: number
lat: number
lng: number
createdAt: string
userKey: string
}
export interface ApiDensityCell {
lat: number
lng: number
intensity: number
}
export interface ApiSnapshot {
clientKey?: string
points?: ApiPoint[]
density?: ApiDensityCell[]
latestByUser?: ApiPoint[]
totals?: {
points: number
contributors: number
}
updatedAt?: string
}
export type LatLng = {
lat: number
lng: number
}