diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..d0b3f16 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "plugins": ["prettier-plugin-tailwindcss"] +} \ No newline at end of file diff --git a/README.md b/README.md index 2aac916..2145c4b 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,6 @@ mapcn banner

- ## Features - 🎨 **Theme-aware** — Automatically adapts to light/dark mode diff --git a/components.json b/components.json index edcaef2..b4d9660 100644 --- a/components.json +++ b/components.json @@ -5,7 +5,7 @@ "tsx": true, "tailwind": { "config": "", - "css": "src/app/globals.css", + "css": "src/styles/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" diff --git a/package-lock.json b/package-lock.json index 04796a8..ef754fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", + "recharts": "^2.15.4", "shiki": "^3.20.0", "tailwind-merge": "^2.6.0" }, @@ -29,6 +30,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.1.1", + "prettier-plugin-tailwindcss": "^0.7.2", "shadcn": "^3.6.2", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", @@ -455,6 +457,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -5566,6 +5577,69 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -7222,9 +7296,129 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -7314,6 +7508,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", @@ -7496,6 +7696,16 @@ "node": ">=0.10.0" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -8268,6 +8478,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -8405,6 +8621,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -9248,6 +9473,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", @@ -9912,7 +10146,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -10368,6 +10601,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -10422,7 +10661,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -11049,7 +11287,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11618,6 +11855,102 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", + "integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -11662,7 +11995,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -11945,7 +12277,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/react-remove-scroll": { @@ -11995,6 +12326,21 @@ } } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -12017,6 +12363,22 @@ } } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/recast": { "version": "0.23.11", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", @@ -12034,6 +12396,44 @@ "node": ">= 4" } }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -13179,7 +13579,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "dev": true, "license": "MIT" }, "node_modules/tinyexec": { @@ -13848,6 +14247,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/package.json b/package.json index 541a41d..2f49190 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", + "recharts": "^2.15.4", "shiki": "^3.20.0", "tailwind-merge": "^2.6.0" }, @@ -31,6 +32,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.1.1", + "prettier-plugin-tailwindcss": "^0.7.2", "shadcn": "^3.6.2", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", diff --git a/public/r/analytics-map.json b/public/r/analytics-map.json new file mode 100644 index 0000000..82681d3 --- /dev/null +++ b/public/r/analytics-map.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "analytics-map", + "title": "Analytics Map", + "description": "Real-time analytics overview with a world map, breakdown cards, and device stats.", + "dependencies": [ + "recharts", + "lucide-react" + ], + "registryDependencies": [ + "map", + "card", + "chart" + ], + "files": [ + { + "path": "src/registry/blocks/analytics-map/page.tsx", + "content": "\"use client\";\n\nimport {\n Map,\n MapControls,\n MapMarker,\n MarkerContent,\n MarkerTooltip,\n} from \"@/registry/map\";\nimport { OverviewCard } from \"./components/overview-card\";\nimport { BreakdownCard } from \"./components/breakdown-card\";\nimport {\n locations,\n visitedPagesRows,\n countriesRows,\n referrersRows,\n browsersRows,\n} from \"./data\";\n\nconst MAP_HEIGHT = \"38rem\";\n\nexport function AnalyticsMapBlock() {\n return (\n \n
\n \n \n {locations.map((location) => (\n \n \n \n \n \n

\n {location.city}\n

\n

\n \n {location.size}\n {\" \"}\n active users\n

\n \n \n ))}\n \n \n \n
\n\n
\n \n \n \n \n
\n \n );\n}\n", + "type": "registry:page", + "target": "app/analytics/page.tsx" + }, + { + "path": "src/registry/blocks/analytics-map/data.ts", + "content": "import { type ChartConfig } from \"@/components/ui/chart\";\n\nexport interface LocationPoint {\n city: string;\n lng: number;\n lat: number;\n size: number;\n}\n\nexport interface BreakdownRow {\n label: string;\n value: number;\n}\n\nexport const locations: LocationPoint[] = [\n { city: \"San Francisco\", lng: -122.4194, lat: 37.7749, size: 16 },\n { city: \"New York\", lng: -74.006, lat: 40.7128, size: 15 },\n { city: \"Toronto\", lng: -79.3832, lat: 43.6532, size: 11 },\n { city: \"Mexico City\", lng: -99.1332, lat: 19.4326, size: 10 },\n { city: \"Sao Paulo\", lng: -46.6333, lat: -23.5505, size: 12 },\n { city: \"Buenos Aires\", lng: -58.3816, lat: -34.6037, size: 9 },\n { city: \"London\", lng: -0.1276, lat: 51.5074, size: 14 },\n { city: \"Berlin\", lng: 13.405, lat: 52.52, size: 11 },\n { city: \"Paris\", lng: 2.3522, lat: 48.8566, size: 13 },\n { city: \"Madrid\", lng: -3.7038, lat: 40.4168, size: 10 },\n { city: \"Cairo\", lng: 31.2357, lat: 30.0444, size: 9 },\n { city: \"Lagos\", lng: 3.3792, lat: 6.5244, size: 10 },\n { city: \"Mumbai\", lng: 72.8777, lat: 19.076, size: 13 },\n { city: \"Dubai\", lng: 55.2708, lat: 25.2048, size: 11 },\n { city: \"Seoul\", lng: 126.978, lat: 37.5665, size: 12 },\n { city: \"Singapore\", lng: 103.8198, lat: 1.3521, size: 10 },\n { city: \"Tokyo\", lng: 139.6917, lat: 35.6895, size: 12 },\n { city: \"Sydney\", lng: 151.2093, lat: -33.8688, size: 9 },\n { city: \"Auckland\", lng: 174.7633, lat: -36.8485, size: 8 },\n];\n\nexport const usersPerDay = [\n { day: \"Mon\", users: 320 },\n { day: \"Tue\", users: 410 },\n { day: \"Wed\", users: 560 },\n { day: \"Thu\", users: 640 },\n { day: \"Fri\", users: 780 },\n { day: \"Sat\", users: 690 },\n { day: \"Sun\", users: 720 },\n];\n\nexport const usersPerDayChartConfig = {\n users: {\n label: \"Users\",\n color: \"var(--color-blue-500)\",\n },\n} satisfies ChartConfig;\n\nexport const deviceCategoryData = [\n { name: \"Desktop\", value: 73.3, fill: \"var(--color-blue-500)\" },\n { name: \"Mobile\", value: 25.0, fill: \"var(--color-blue-400)\" },\n { name: \"Tablet\", value: 1.7, fill: \"var(--color-blue-300)\" },\n];\n\nexport const deviceCategoryChartConfig = {\n desktop: { label: \"Desktop\", color: \"var(--color-blue-500)\" },\n mobile: { label: \"Mobile\", color: \"var(--color-blue-400)\" },\n tablet: { label: \"Tablet\", color: \"var(--color-blue-300)\" },\n} satisfies ChartConfig;\n\nexport const visitedPagesRows: BreakdownRow[] = [\n { label: \"Home\", value: 31 },\n { label: \"Pricing\", value: 23 },\n { label: \"Docs / Basic Map\", value: 18 },\n { label: \"Installation\", value: 12 },\n { label: \"Components\", value: 9 },\n { label: \"Blog\", value: 6 },\n];\n\nexport const countriesRows: BreakdownRow[] = [\n { label: \"United States\", value: 27 },\n { label: \"India\", value: 14 },\n { label: \"United Kingdom\", value: 8 },\n { label: \"Germany\", value: 6 },\n { label: \"Japan\", value: 4 },\n { label: \"Australia\", value: 2 },\n];\n\nexport const referrersRows: BreakdownRow[] = [\n { label: \"google\", value: 38 },\n { label: \"direct\", value: 26 },\n { label: \"github.com\", value: 19 },\n { label: \"x.com\", value: 11 },\n { label: \"ui.shadcn.com\", value: 8 },\n { label: \"other\", value: 5 },\n];\n\nexport const browsersRows: BreakdownRow[] = [\n { label: \"Chrome\", value: 52 },\n { label: \"Safari\", value: 21 },\n { label: \"Firefox\", value: 14 },\n { label: \"Edge\", value: 8 },\n { label: \"Other\", value: 5 },\n];\n", + "type": "registry:component", + "target": "app/analytics/data.ts" + }, + { + "path": "src/registry/blocks/analytics-map/components/overview-card.tsx", + "content": "\"use client\";\n\nimport { Card, CardContent, CardHeader } from \"@/components/ui/card\";\nimport { ChartContainer } from \"@/components/ui/chart\";\nimport { TrendingUp } from \"lucide-react\";\nimport { Area, AreaChart, Cell, Pie, PieChart } from \"recharts\";\nimport {\n deviceCategoryChartConfig,\n deviceCategoryData,\n usersPerDay,\n usersPerDayChartConfig,\n} from \"../data\";\n\nfunction MetricChart() {\n return (\n \n \n \n \n \n \n \n \n\n \n \n \n );\n}\n\nexport function OverviewCard() {\n return (\n \n \n
\n

\n Users in last 7 days\n

\n

3,803

\n
\n
\n\n \n \n
\n \n +12.5%\n vs previous 7 days\n
\n\n
\n

\n Device category in last 7 days\n

\n\n \n \n \n {deviceCategoryData.map((entry) => (\n \n ))}\n \n \n \n\n
\n {deviceCategoryData.map((device) => (\n
\n

\n \n {device.name}\n

\n

\n {device.value}%\n

\n
\n ))}\n
\n
\n
\n
\n );\n}\n", + "type": "registry:component", + "target": "app/analytics/components/overview-card.tsx" + }, + { + "path": "src/registry/blocks/analytics-map/components/breakdown-card.tsx", + "content": "\"use client\";\n\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { type BreakdownRow } from \"../data\";\n\ninterface BreakdownCardProps {\n title: string;\n rows: BreakdownRow[];\n}\n\nexport function BreakdownCard({ title, rows }: BreakdownCardProps) {\n const maxRowValue =\n rows.length > 0 ? Math.max(...rows.map((row) => row.value)) : 0;\n\n return (\n \n \n {title}\n \n\n \n
\n {title}\n Visitors\n
\n
\n {rows.map((row) => (\n
\n
\n {row.label}\n {row.value}\n
\n
\n \n
\n
\n ))}\n
\n
\n
\n );\n}\n", + "type": "registry:component", + "target": "app/analytics/components/breakdown-card.tsx" + } + ], + "meta": { + "iframeHeight": "970px" + }, + "categories": [ + "analytics", + "dashboard" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/public/r/delivery-tracker.json b/public/r/delivery-tracker.json new file mode 100644 index 0000000..bde6de3 --- /dev/null +++ b/public/r/delivery-tracker.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "delivery-tracker", + "title": "Delivery Tracker", + "description": "Live order tracking with route progress, courier position, and order details.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "map", + "card", + "badge", + "button" + ], + "files": [ + { + "path": "src/registry/blocks/delivery-tracker/page.tsx", + "content": "\"use client\";\n\nimport { useEffect, useMemo, useState } from \"react\";\nimport { Clock3, Utensils, Truck, UserRound } from \"lucide-react\";\n\nimport {\n Map,\n MapMarker,\n MapRoute,\n MarkerContent,\n MarkerTooltip,\n} from \"@/registry/map\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\n\ninterface DeliveryMeal {\n name: string;\n price: string;\n quantity: number;\n}\n\ninterface OsrmRouteData {\n coordinates: [number, number][];\n duration: number;\n distance: number;\n}\n\nconst deliveryMeals: DeliveryMeal[] = [\n {\n name: \"Spicy Tofu Grain Bowl\",\n price: \"$44.00\",\n quantity: 1,\n },\n {\n name: \"Herb Chicken Rice Box\",\n price: \"$58.00\",\n quantity: 2,\n },\n {\n name: \"Roasted Veggie Wrap\",\n price: \"$29.00\",\n quantity: 1,\n },\n];\n\nconst pickup = { lng: -122.466, lat: 37.716 };\nconst dropoff = { lng: -122.399, lat: 37.683 };\n\nfunction formatDistance(meters?: number) {\n if (!meters) return \"--\";\n if (meters < 1000) return `${Math.round(meters)} m`;\n return `${(meters / 1000).toFixed(1)} km`;\n}\n\nfunction formatDuration(seconds?: number) {\n if (!seconds) return \"--\";\n const minutes = Math.round(seconds / 60);\n if (minutes < 60) return `${minutes} min`;\n const hours = Math.floor(minutes / 60);\n const remainingMinutes = minutes % 60;\n return `${hours}h ${remainingMinutes}m`;\n}\n\nexport function DeliveryBlock() {\n const [routeData, setRouteData] = useState(null);\n const [loading, setLoading] = useState(true);\n\n useEffect(() => {\n async function fetchRoute() {\n setLoading(true);\n try {\n const response = await fetch(\n `https://router.project-osrm.org/route/v1/driving/${pickup.lng},${pickup.lat};${dropoff.lng},${dropoff.lat}?overview=full&geometries=geojson`\n );\n const data = await response.json();\n const route = data?.routes?.[0];\n if (!route?.geometry?.coordinates) return;\n\n setRouteData({\n coordinates: route.geometry.coordinates as [number, number][],\n duration: route.duration as number,\n distance: route.distance as number,\n });\n } catch (error) {\n console.error(\"Failed to fetch route:\", error);\n } finally {\n setLoading(false);\n }\n }\n\n fetchRoute();\n }, []);\n\n const progressCoordinates = useMemo(() => {\n const progressCount = Math.max(\n 2,\n Math.floor(\n (routeData?.coordinates?.length ?? 0) * (routeData ? 0.62 : 0.66)\n )\n );\n return routeData?.coordinates?.slice(0, progressCount) ?? [];\n }, [routeData]);\n\n const courierPosition = progressCoordinates[progressCoordinates.length - 1];\n\n return (\n
\n
\n
\n
\n

\n Track Delivery\n

\n

Mon Feb 10 - 2-3 PM

\n
\n\n \n \n \n Order items ({deliveryMeals.length})\n \n \n \n {deliveryMeals.map((meal) => (\n
\n
\n \n
\n
\n

\n {meal.name}\n

\n

\n {meal.price}\n

\n
\n \n x{meal.quantity}\n \n
\n ))}\n
\n Bundle total\n $189.00\n
\n
\n
\n\n
\n \n \n

\n Pickup confirmed\n

\n

Mon, Feb 10 at 1:48 PM

\n
\n
\n \n \n

\n Remaining travel\n

\n

\n {formatDuration(routeData?.duration)}\n

\n
\n
\n
\n\n
\n \n \n
\n
\n\n
\n \n \n \n\n {courierPosition && (\n \n \n
\n \n
\n
\n \n
\n

\n Order {formatDuration(routeData?.duration)} away\n

\n

\n Route {formatDistance(routeData?.distance)}\n

\n
\n
\n \n )}\n\n \n \n
\n \n \n

Origin

\n
\n \n\n \n \n
\n \n \n

Destination

\n
\n \n \n
\n
\n
\n );\n}\n", + "type": "registry:page", + "target": "app/delivery/page.tsx" + } + ], + "meta": { + "iframeHeight": "680px" + }, + "categories": [ + "tracking", + "delivery" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/public/r/heatmap.json b/public/r/heatmap.json new file mode 100644 index 0000000..3855ebe --- /dev/null +++ b/public/r/heatmap.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "heatmap", + "title": "Heatmap", + "description": "Globe-projected heatmap visualizing earthquake density with zoom-dependent styling.", + "dependencies": [], + "registryDependencies": [ + "map", + "card" + ], + "files": [ + { + "path": "src/registry/blocks/heatmap/page.tsx", + "content": "\"use client\";\n\nimport { useEffect, useId } from \"react\";\n\nimport { Map, useMap } from \"@/registry/map\";\nimport { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\n\nconst EARTHQUAKE_GEOJSON_URL =\n \"https://maplibre.org/maplibre-gl-js/docs/assets/earthquakes.geojson\";\n\nconst HEATMAP_GRADIENT_COLORS = [\n \"#fff7bc\",\n \"#fee391\",\n \"#fec44f\",\n \"#fe9929\",\n \"#d7301f\",\n];\n\nconst HEATMAP_COLOR_STOPS: [number, string][] = [\n [0.15, HEATMAP_GRADIENT_COLORS[0]],\n [0.35, HEATMAP_GRADIENT_COLORS[1]],\n [0.55, HEATMAP_GRADIENT_COLORS[2]],\n [0.75, HEATMAP_GRADIENT_COLORS[3]],\n [1, HEATMAP_GRADIENT_COLORS[4]],\n];\n\nfunction GlobeHeatmapLayers() {\n const { map, isLoaded } = useMap();\n const id = useId();\n const sourceId = `heatmap-source-${id}`;\n const heatLayerId = `heatmap-layer-${id}`;\n const pointLayerId = `heatmap-point-layer-${id}`;\n\n useEffect(() => {\n if (!map || !isLoaded) return;\n\n if (!map.getSource(sourceId)) {\n map.addSource(sourceId, {\n type: \"geojson\",\n data: EARTHQUAKE_GEOJSON_URL,\n });\n }\n\n if (!map.getLayer(heatLayerId)) {\n map.addLayer({\n id: heatLayerId,\n type: \"heatmap\",\n source: sourceId,\n maxzoom: 6,\n paint: {\n \"heatmap-weight\": [\n \"interpolate\",\n [\"linear\"],\n [\"get\", \"mag\"],\n 0,\n 0,\n 6,\n 0.8,\n ],\n \"heatmap-intensity\": [\n \"interpolate\",\n [\"linear\"],\n [\"zoom\"],\n 0,\n 0.55,\n 6,\n 1.25,\n ],\n \"heatmap-color\": [\n \"interpolate\",\n [\"linear\"],\n [\"heatmap-density\"],\n 0,\n \"rgba(59, 130, 246, 0)\",\n ...HEATMAP_COLOR_STOPS.flat(),\n ],\n \"heatmap-radius\": [\"interpolate\", [\"linear\"], [\"zoom\"], 0, 8, 6, 34],\n \"heatmap-opacity\": [\n \"interpolate\",\n [\"linear\"],\n [\"zoom\"],\n 4.5,\n 0.75,\n 6.5,\n 0.08,\n ],\n },\n });\n }\n\n if (!map.getLayer(pointLayerId)) {\n map.addLayer({\n id: pointLayerId,\n type: \"circle\",\n source: sourceId,\n minzoom: 4.5,\n paint: {\n \"circle-radius\": [\n \"interpolate\",\n [\"linear\"],\n [\"get\", \"mag\"],\n 1,\n 3,\n 6,\n 10,\n ],\n \"circle-color\": [\n \"interpolate\",\n [\"linear\"],\n [\"get\", \"mag\"],\n 1,\n HEATMAP_GRADIENT_COLORS[1],\n 2.5,\n HEATMAP_GRADIENT_COLORS[2],\n 4,\n HEATMAP_GRADIENT_COLORS[3],\n 6,\n HEATMAP_GRADIENT_COLORS[4],\n ],\n \"circle-stroke-width\": 1,\n \"circle-stroke-color\": \"rgba(255,255,255,0.8)\",\n \"circle-opacity\": [\n \"interpolate\",\n [\"linear\"],\n [\"zoom\"],\n 4.5,\n 0,\n 6.5,\n 0.7,\n ],\n },\n });\n }\n\n return () => {\n try {\n if (map.getLayer(pointLayerId)) map.removeLayer(pointLayerId);\n if (map.getLayer(heatLayerId)) map.removeLayer(heatLayerId);\n if (map.getSource(sourceId)) map.removeSource(sourceId);\n } catch {\n // ignore\n }\n };\n }, [map, isLoaded, sourceId, heatLayerId, pointLayerId]);\n\n return null;\n}\n\nexport function HeatmapBlock() {\n return (\n
\n
\n \n \n \n
\n\n \n \n Global Earthquakes Heatmap\n \n \n
\n {HEATMAP_GRADIENT_COLORS.map((color) => (\n \n ))}\n
\n
\n Low\n High\n
\n

\n Data source:{\" \"}\n \n MapLibre earthquakes.geojson\n \n

\n
\n
\n
\n );\n}\n", + "type": "registry:page", + "target": "app/heatmap/page.tsx" + } + ], + "categories": [ + "visualization", + "heatmap" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/public/r/logistics-network.json b/public/r/logistics-network.json new file mode 100644 index 0000000..086e32c --- /dev/null +++ b/public/r/logistics-network.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "logistics-network", + "title": "Logistics Network", + "description": "Domestic logistics map with a sidebar of stats.", + "dependencies": [ + "lucide-react" + ], + "registryDependencies": [ + "map", + "card", + "badge", + "button" + ], + "files": [ + { + "path": "src/registry/blocks/logistics-network/page.tsx", + "content": "\"use client\";\n\nimport { SidebarInset, SidebarProvider } from \"@/components/ui/sidebar\";\nimport { hubs, routes } from \"./data\";\nimport { FilterSidebar } from \"./components/filter-sidebar\";\nimport { NetworkMap } from \"./components/network-map\";\n\nexport function LogisticsNetworkBlock() {\n return (\n \n \n \n \n \n \n );\n}\n", + "type": "registry:page", + "target": "app/logistics/page.tsx" + }, + { + "path": "src/registry/blocks/logistics-network/data.ts", + "content": "export interface Hub {\n id: string;\n city: string;\n lng: number;\n lat: number;\n type: \"primary\" | \"secondary\";\n shipments: number;\n region: \"west\" | \"midwest\" | \"south\" | \"northeast\";\n}\n\nexport interface Route {\n from: string;\n to: string;\n mode: \"air\" | \"ground\";\n shipments: number;\n status: \"active\" | \"delayed\";\n}\n\nexport const hubs: Hub[] = [\n {\n id: \"ord\",\n city: \"Chicago\",\n lng: -87.6298,\n lat: 41.8781,\n type: \"primary\",\n shipments: 1247,\n region: \"midwest\",\n },\n {\n id: \"lax\",\n city: \"Los Angeles\",\n lng: -118.2437,\n lat: 34.0522,\n type: \"primary\",\n shipments: 1102,\n region: \"west\",\n },\n {\n id: \"jfk\",\n city: \"New York\",\n lng: -73.9352,\n lat: 40.6413,\n type: \"primary\",\n shipments: 983,\n region: \"northeast\",\n },\n {\n id: \"dfw\",\n city: \"Dallas\",\n lng: -96.797,\n lat: 32.8968,\n type: \"primary\",\n shipments: 856,\n region: \"south\",\n },\n {\n id: \"atl\",\n city: \"Atlanta\",\n lng: -84.4281,\n lat: 33.6407,\n type: \"primary\",\n shipments: 914,\n region: \"south\",\n },\n {\n id: \"den\",\n city: \"Denver\",\n lng: -104.6731,\n lat: 39.8617,\n type: \"secondary\",\n shipments: 634,\n region: \"west\",\n },\n {\n id: \"sea\",\n city: \"Seattle\",\n lng: -122.3321,\n lat: 47.6062,\n type: \"secondary\",\n shipments: 723,\n region: \"west\",\n },\n {\n id: \"mia\",\n city: \"Miami\",\n lng: -80.1918,\n lat: 25.7617,\n type: \"secondary\",\n shipments: 478,\n region: \"south\",\n },\n {\n id: \"phx\",\n city: \"Phoenix\",\n lng: -112.074,\n lat: 33.4484,\n type: \"secondary\",\n shipments: 512,\n region: \"west\",\n },\n {\n id: \"iah\",\n city: \"Houston\",\n lng: -95.3698,\n lat: 29.9844,\n type: \"secondary\",\n shipments: 698,\n region: \"south\",\n },\n {\n id: \"bos\",\n city: \"Boston\",\n lng: -71.0054,\n lat: 42.3643,\n type: \"secondary\",\n shipments: 534,\n region: \"northeast\",\n },\n {\n id: \"sfo\",\n city: \"San Francisco\",\n lng: -122.4194,\n lat: 37.7749,\n type: \"secondary\",\n shipments: 789,\n region: \"west\",\n },\n {\n id: \"msp\",\n city: \"Minneapolis\",\n lng: -93.2219,\n lat: 44.8848,\n type: \"secondary\",\n shipments: 423,\n region: \"midwest\",\n },\n {\n id: \"dtw\",\n city: \"Detroit\",\n lng: -83.0458,\n lat: 42.2162,\n type: \"secondary\",\n shipments: 456,\n region: \"midwest\",\n },\n {\n id: \"slc\",\n city: \"Salt Lake City\",\n lng: -111.978,\n lat: 40.758,\n type: \"secondary\",\n shipments: 342,\n region: \"west\",\n },\n];\n\nexport const routes: Route[] = [\n { from: \"ord\", to: \"lax\", mode: \"air\", shipments: 234, status: \"active\" },\n { from: \"ord\", to: \"jfk\", mode: \"ground\", shipments: 312, status: \"active\" },\n { from: \"ord\", to: \"dfw\", mode: \"air\", shipments: 189, status: \"active\" },\n { from: \"ord\", to: \"atl\", mode: \"air\", shipments: 213, status: \"active\" },\n { from: \"ord\", to: \"den\", mode: \"ground\", shipments: 156, status: \"active\" },\n { from: \"ord\", to: \"msp\", mode: \"ground\", shipments: 198, status: \"active\" },\n { from: \"ord\", to: \"dtw\", mode: \"ground\", shipments: 167, status: \"delayed\" },\n { from: \"lax\", to: \"sfo\", mode: \"ground\", shipments: 245, status: \"active\" },\n { from: \"lax\", to: \"sea\", mode: \"air\", shipments: 178, status: \"active\" },\n { from: \"lax\", to: \"den\", mode: \"air\", shipments: 198, status: \"active\" },\n { from: \"lax\", to: \"phx\", mode: \"ground\", shipments: 212, status: \"active\" },\n { from: \"lax\", to: \"dfw\", mode: \"air\", shipments: 223, status: \"active\" },\n { from: \"jfk\", to: \"atl\", mode: \"air\", shipments: 267, status: \"active\" },\n { from: \"jfk\", to: \"mia\", mode: \"air\", shipments: 234, status: \"active\" },\n { from: \"jfk\", to: \"bos\", mode: \"ground\", shipments: 189, status: \"active\" },\n { from: \"jfk\", to: \"ord\", mode: \"air\", shipments: 278, status: \"active\" },\n { from: \"dfw\", to: \"atl\", mode: \"air\", shipments: 198, status: \"active\" },\n { from: \"dfw\", to: \"iah\", mode: \"ground\", shipments: 245, status: \"active\" },\n { from: \"dfw\", to: \"den\", mode: \"air\", shipments: 167, status: \"active\" },\n { from: \"dfw\", to: \"phx\", mode: \"ground\", shipments: 156, status: \"delayed\" },\n { from: \"atl\", to: \"mia\", mode: \"ground\", shipments: 234, status: \"active\" },\n { from: \"atl\", to: \"iah\", mode: \"air\", shipments: 189, status: \"active\" },\n { from: \"den\", to: \"sea\", mode: \"air\", shipments: 178, status: \"active\" },\n { from: \"den\", to: \"slc\", mode: \"ground\", shipments: 198, status: \"active\" },\n { from: \"den\", to: \"phx\", mode: \"air\", shipments: 167, status: \"active\" },\n { from: \"sea\", to: \"sfo\", mode: \"air\", shipments: 212, status: \"active\" },\n { from: \"sfo\", to: \"phx\", mode: \"air\", shipments: 156, status: \"active\" },\n { from: \"mia\", to: \"iah\", mode: \"air\", shipments: 198, status: \"active\" },\n { from: \"msp\", to: \"ord\", mode: \"ground\", shipments: 189, status: \"active\" },\n { from: \"msp\", to: \"den\", mode: \"air\", shipments: 145, status: \"active\" },\n { from: \"dtw\", to: \"ord\", mode: \"ground\", shipments: 167, status: \"active\" },\n { from: \"dtw\", to: \"jfk\", mode: \"air\", shipments: 178, status: \"active\" },\n { from: \"bos\", to: \"jfk\", mode: \"ground\", shipments: 156, status: \"active\" },\n { from: \"slc\", to: \"den\", mode: \"ground\", shipments: 134, status: \"active\" },\n { from: \"slc\", to: \"phx\", mode: \"air\", shipments: 123, status: \"delayed\" },\n { from: \"ord\", to: \"sfo\", mode: \"air\", shipments: 198, status: \"active\" },\n { from: \"ord\", to: \"mia\", mode: \"air\", shipments: 245, status: \"active\" },\n { from: \"lax\", to: \"atl\", mode: \"air\", shipments: 289, status: \"active\" },\n { from: \"jfk\", to: \"dfw\", mode: \"air\", shipments: 234, status: \"active\" },\n { from: \"atl\", to: \"ord\", mode: \"air\", shipments: 267, status: \"active\" },\n { from: \"sfo\", to: \"den\", mode: \"air\", shipments: 189, status: \"active\" },\n];\n\nexport const modeConfig = {\n air: { color: \"#3b82f6\", label: \"Air\" },\n ground: { color: \"#22c55e\", label: \"Ground\" },\n} as const;\n\nexport const statusConfig = {\n active: { color: \"#10b981\", label: \"Active\" },\n delayed: { color: \"#f59e0b\", label: \"Delayed\" },\n} as const;\n\nexport const regionLabels: Record = {\n west: \"West\",\n midwest: \"Midwest\",\n south: \"South\",\n northeast: \"Northeast\",\n};\n", + "type": "registry:component", + "target": "app/logistics/data.ts" + }, + { + "path": "src/registry/blocks/logistics-network/components/map-arcs.tsx", + "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo } from \"react\";\nimport { useMap } from \"@/registry/map\";\nimport type MapLibreGL from \"maplibre-gl\";\nimport { hubs, modeConfig, statusConfig, type Route } from \"../data\";\n\nconst SOURCE_ID = \"logistics-arcs-source\";\nconst LAYER_ID = \"logistics-arcs-layer\";\n\nfunction generateArc(\n start: [number, number],\n end: [number, number],\n segments = 50,\n): number[][] {\n const [x1, y1] = start;\n const [x2, y2] = end;\n\n const mx = (x1 + x2) / 2;\n const my = (y1 + y2) / 2;\n\n const dx = x2 - x1;\n const dy = y2 - y1;\n const dist = Math.sqrt(dx * dx + dy * dy);\n\n const nx = -dy / dist;\n const ny = dx / dist;\n const height = dist * 0.3;\n\n const cx = mx + nx * height;\n const cy = my + ny * height;\n\n const coords: number[][] = [];\n for (let i = 0; i <= segments; i++) {\n const t = i / segments;\n const px = (1 - t) * (1 - t) * x1 + 2 * (1 - t) * t * cx + t * t * x2;\n const py = (1 - t) * (1 - t) * y1 + 2 * (1 - t) * t * cy + t * t * y2;\n coords.push([px, py]);\n }\n return coords;\n}\n\nfunction getHubById(id: string) {\n return hubs.find((h) => h.id === id)!;\n}\n\ninterface MapArcsProps {\n routes: Route[];\n}\n\nexport function MapArcs({ routes: arcRoutes }: MapArcsProps) {\n const { map, isLoaded } = useMap();\n\n const geoJSON = useMemo(() => {\n const features: GeoJSON.Feature[] = arcRoutes.map((route) => {\n const fromHub = getHubById(route.from);\n const toHub = getHubById(route.to);\n const coordinates = generateArc(\n [fromHub.lng, fromHub.lat],\n [toHub.lng, toHub.lat],\n );\n return {\n type: \"Feature\" as const,\n properties: {\n id: `${route.from}-${route.to}`,\n mode: route.mode,\n status: route.status,\n color:\n route.status === \"delayed\"\n ? statusConfig.delayed.color\n : modeConfig[route.mode].color,\n },\n geometry: {\n type: \"LineString\" as const,\n coordinates,\n },\n };\n });\n return { type: \"FeatureCollection\", features };\n }, [arcRoutes]);\n\n const addLayer = useCallback(() => {\n if (!map) return;\n\n if (!map.getSource(SOURCE_ID)) {\n map.addSource(SOURCE_ID, { type: \"geojson\", data: geoJSON });\n map.addLayer({\n id: LAYER_ID,\n type: \"line\",\n source: SOURCE_ID,\n layout: { \"line-join\": \"round\", \"line-cap\": \"round\" },\n paint: {\n \"line-color\": [\"get\", \"color\"],\n \"line-width\": 2,\n \"line-opacity\": 0.65,\n },\n });\n } else {\n (map.getSource(SOURCE_ID) as MapLibreGL.GeoJSONSource).setData(geoJSON);\n }\n }, [map, geoJSON]);\n\n useEffect(() => {\n if (!map || !isLoaded) return;\n\n addLayer();\n\n return () => {\n try {\n if (map.getLayer(LAYER_ID)) map.removeLayer(LAYER_ID);\n if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID);\n } catch {\n // ignore\n }\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [map, isLoaded]);\n\n useEffect(() => {\n if (!map || !isLoaded) return;\n const source = map.getSource(SOURCE_ID) as MapLibreGL.GeoJSONSource;\n if (source) source.setData(geoJSON);\n }, [map, isLoaded, geoJSON]);\n\n return null;\n}\n", + "type": "registry:component", + "target": "app/logistics/components/map-arcs.tsx" + }, + { + "path": "src/registry/blocks/logistics-network/components/filter-sidebar.tsx", + "content": "\"use client\";\n\nimport {\n Sidebar,\n SidebarContent,\n SidebarFooter,\n SidebarGroup,\n SidebarGroupContent,\n SidebarGroupLabel,\n SidebarHeader,\n SidebarMenu,\n SidebarMenuBadge,\n SidebarMenuButton,\n SidebarMenuItem,\n SidebarSeparator,\n} from \"@/components/ui/sidebar\";\nimport { Network, Plane, Truck } from \"lucide-react\";\nimport { regionLabels, statusConfig, type Hub, type Route } from \"../data\";\n\nconst regionIcons: Record = {\n west: \"W\",\n midwest: \"MW\",\n south: \"S\",\n northeast: \"NE\",\n};\n\ninterface FilterSidebarProps {\n hubs: Hub[];\n routes: Route[];\n}\n\nexport function FilterSidebar({ hubs, routes }: FilterSidebarProps) {\n const totalShipments = routes.reduce((s, r) => s + r.shipments, 0);\n const activeCount = routes.filter((r) => r.status === \"active\").length;\n const delayedCount = routes.filter((r) => r.status === \"delayed\").length;\n const airRouteCount = routes.filter((r) => r.mode === \"air\").length;\n const groundRouteCount = routes.filter((r) => r.mode === \"ground\").length;\n\n return (\n \n \n
\n
\n \n
\n
\n Logistics Network\n \n Domestic Routes\n \n
\n
\n
\n
\n

\n {hubs.length}\n

\n

Hubs

\n
\n
\n

\n {activeCount}\n

\n

Active

\n
\n
\n

\n {delayedCount}\n

\n

Delayed

\n
\n
\n
\n\n \n\n \n \n Transport Mode\n \n \n \n \n \n Air Freight\n \n {airRouteCount}\n \n \n \n \n Ground\n \n {groundRouteCount}\n \n \n \n \n\n \n Status\n \n \n \n \n \n \n \n {statusConfig.active.label}\n \n {activeCount}\n \n \n \n \n \n \n {statusConfig.delayed.label}\n \n {delayedCount}\n \n \n \n \n\n \n Region\n \n \n {([\"west\", \"midwest\", \"south\", \"northeast\"] as const).map(\n (region) => {\n const hubsInRegion = hubs.filter((h) => h.region === region);\n return (\n \n \n \n {regionIcons[region]}\n \n {regionLabels[region]}\n \n {hubsInRegion.length}\n \n );\n },\n )}\n \n \n \n \n\n \n

\n Summary\n

\n
\n
\n Shipments\n \n {totalShipments.toLocaleString()}\n \n
\n
\n Routes\n \n {routes.length}\n \n
\n
\n
\n
\n );\n}\n", + "type": "registry:component", + "target": "app/logistics/components/filter-sidebar.tsx" + }, + { + "path": "src/registry/blocks/logistics-network/components/network-map.tsx", + "content": "\"use client\";\n\nimport {\n Map,\n MapControls,\n MapMarker,\n MarkerContent,\n MarkerTooltip,\n} from \"@/registry/map\";\nimport { SidebarTrigger } from \"@/components/ui/sidebar\";\nimport {\n modeConfig,\n regionLabels,\n statusConfig,\n type Hub,\n type Route,\n} from \"../data\";\nimport { MapArcs } from \"./map-arcs\";\nimport { Separator } from \"@/components/ui/separator\";\n\ninterface NetworkMapProps {\n hubs: Hub[];\n routes: Route[];\n}\n\nfunction MapControlsCard() {\n return (\n
\n \n \n
\n
\n \n {modeConfig.air.label}\n
\n
\n \n {modeConfig.ground.label}\n
\n
\n \n {statusConfig.delayed.label}\n
\n
\n
\n
\n Hub\n
\n
\n
\n );\n}\n\nexport function NetworkMap({ hubs, routes }: NetworkMapProps) {\n return (\n
\n \n\n \n \n \n\n {hubs.map((hub) => (\n \n \n
\n \n \n

{hub.city}

\n

\n {hub.shipments.toLocaleString()} shipments\n ·\n {regionLabels[hub.region]}\n

\n \n \n ))}\n \n
\n );\n}\n", + "type": "registry:component", + "target": "app/logistics/components/network-map.tsx" + } + ], + "meta": { + "iframeHeight": "800px" + }, + "categories": [ + "logistics", + "network" + ], + "type": "registry:block" +} \ No newline at end of file diff --git a/public/r/map.json b/public/r/map.json index 76884c9..9e3daaf 100644 --- a/public/r/map.json +++ b/public/r/map.json @@ -11,7 +11,7 @@ "files": [ { "path": "src/registry/map.tsx", - "content": "\"use client\";\n\nimport MapLibreGL, { type PopupOptions, type MarkerOptions } from \"maplibre-gl\";\nimport \"maplibre-gl/dist/maplibre-gl.css\";\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { X, Minus, Plus, Locate, Maximize, Loader2 } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst defaultStyles = {\n dark: \"https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json\",\n light: \"https://basemaps.cartocdn.com/gl/positron-gl-style/style.json\",\n};\n\ntype Theme = \"light\" | \"dark\";\n\n// Check document class for theme (works with next-themes, etc.)\nfunction getDocumentTheme(): Theme | null {\n if (typeof document === \"undefined\") return null;\n if (document.documentElement.classList.contains(\"dark\")) return \"dark\";\n if (document.documentElement.classList.contains(\"light\")) return \"light\";\n return null;\n}\n\n// Get system preference\nfunction getSystemTheme(): Theme {\n if (typeof window === \"undefined\") return \"light\";\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n ? \"dark\"\n : \"light\";\n}\n\nfunction useResolvedTheme(themeProp?: \"light\" | \"dark\"): Theme {\n const [detectedTheme, setDetectedTheme] = useState(\n () => getDocumentTheme() ?? getSystemTheme()\n );\n\n useEffect(() => {\n if (themeProp) return; // Skip detection if theme is provided via prop\n\n // Watch for document class changes (e.g., next-themes toggling dark class)\n const observer = new MutationObserver(() => {\n const docTheme = getDocumentTheme();\n if (docTheme) {\n setDetectedTheme(docTheme);\n }\n });\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\"],\n });\n\n // Also watch for system preference changes\n const mediaQuery = window.matchMedia(\"(prefers-color-scheme: dark)\");\n const handleSystemChange = (e: MediaQueryListEvent) => {\n // Only use system preference if no document class is set\n if (!getDocumentTheme()) {\n setDetectedTheme(e.matches ? \"dark\" : \"light\");\n }\n };\n mediaQuery.addEventListener(\"change\", handleSystemChange);\n\n return () => {\n observer.disconnect();\n mediaQuery.removeEventListener(\"change\", handleSystemChange);\n };\n }, [themeProp]);\n\n return themeProp ?? detectedTheme;\n}\n\ntype MapContextValue = {\n map: MapLibreGL.Map | null;\n isLoaded: boolean;\n};\n\nconst MapContext = createContext(null);\n\nfunction useMap() {\n const context = useContext(MapContext);\n if (!context) {\n throw new Error(\"useMap must be used within a Map component\");\n }\n return context;\n}\n\n/** Map viewport state */\ntype MapViewport = {\n /** Center coordinates [longitude, latitude] */\n center: [number, number];\n /** Zoom level */\n zoom: number;\n /** Bearing (rotation) in degrees */\n bearing: number;\n /** Pitch (tilt) in degrees */\n pitch: number;\n};\n\ntype MapStyleOption = string | MapLibreGL.StyleSpecification;\n\ntype MapRef = MapLibreGL.Map;\n\ntype MapProps = {\n children?: ReactNode;\n /** Additional CSS classes for the map container */\n className?: string;\n /**\n * Theme for the map. If not provided, automatically detects system preference.\n * Pass your theme value here.\n */\n theme?: Theme;\n /** Custom map styles for light and dark themes. Overrides the default Carto styles. */\n styles?: {\n light?: MapStyleOption;\n dark?: MapStyleOption;\n };\n /** Map projection type. Use `{ type: \"globe\" }` for 3D globe view. */\n projection?: MapLibreGL.ProjectionSpecification;\n /**\n * Controlled viewport. When provided with onViewportChange,\n * the map becomes controlled and viewport is driven by this prop.\n */\n viewport?: Partial;\n /**\n * Callback fired continuously as the viewport changes (pan, zoom, rotate, pitch).\n * Can be used standalone to observe changes, or with `viewport` prop\n * to enable controlled mode where the map viewport is driven by your state.\n */\n onViewportChange?: (viewport: MapViewport) => void;\n} & Omit;\n\nfunction DefaultLoader() {\n return (\n
\n
\n \n \n \n
\n
\n );\n}\n\nfunction getViewport(map: MapLibreGL.Map): MapViewport {\n const center = map.getCenter();\n return {\n center: [center.lng, center.lat],\n zoom: map.getZoom(),\n bearing: map.getBearing(),\n pitch: map.getPitch(),\n };\n}\n\nconst Map = forwardRef(function Map(\n {\n children,\n className,\n theme: themeProp,\n styles,\n projection,\n viewport,\n onViewportChange,\n ...props\n },\n ref\n) {\n const containerRef = useRef(null);\n const [mapInstance, setMapInstance] = useState(null);\n const [isLoaded, setIsLoaded] = useState(false);\n const [isStyleLoaded, setIsStyleLoaded] = useState(false);\n const currentStyleRef = useRef(null);\n const styleTimeoutRef = useRef | null>(null);\n const internalUpdateRef = useRef(false);\n const resolvedTheme = useResolvedTheme(themeProp);\n\n const isControlled = viewport !== undefined && onViewportChange !== undefined;\n\n const onViewportChangeRef = useRef(onViewportChange);\n onViewportChangeRef.current = onViewportChange;\n\n const mapStyles = useMemo(\n () => ({\n dark: styles?.dark ?? defaultStyles.dark,\n light: styles?.light ?? defaultStyles.light,\n }),\n [styles]\n );\n\n // Expose the map instance to the parent component\n useImperativeHandle(ref, () => mapInstance as MapLibreGL.Map, [mapInstance]);\n\n const clearStyleTimeout = useCallback(() => {\n if (styleTimeoutRef.current) {\n clearTimeout(styleTimeoutRef.current);\n styleTimeoutRef.current = null;\n }\n }, []);\n\n // Initialize the map\n useEffect(() => {\n if (!containerRef.current) return;\n\n const initialStyle =\n resolvedTheme === \"dark\" ? mapStyles.dark : mapStyles.light;\n currentStyleRef.current = initialStyle;\n\n const map = new MapLibreGL.Map({\n container: containerRef.current,\n style: initialStyle,\n renderWorldCopies: false,\n attributionControl: {\n compact: true,\n },\n ...props,\n ...viewport,\n });\n\n const styleDataHandler = () => {\n clearStyleTimeout();\n // Delay to ensure style is fully processed before allowing layer operations\n // This is a workaround to avoid race conditions with the style loading\n // else we have to force update every layer on setStyle change\n styleTimeoutRef.current = setTimeout(() => {\n setIsStyleLoaded(true);\n if (projection) {\n map.setProjection(projection);\n }\n }, 100);\n };\n const loadHandler = () => setIsLoaded(true);\n\n // Viewport change handler - skip if triggered by internal update\n const handleMove = () => {\n if (internalUpdateRef.current) return;\n onViewportChangeRef.current?.(getViewport(map));\n };\n\n map.on(\"load\", loadHandler);\n map.on(\"styledata\", styleDataHandler);\n map.on(\"move\", handleMove);\n setMapInstance(map);\n\n return () => {\n clearStyleTimeout();\n map.off(\"load\", loadHandler);\n map.off(\"styledata\", styleDataHandler);\n map.off(\"move\", handleMove);\n map.remove();\n setIsLoaded(false);\n setIsStyleLoaded(false);\n setMapInstance(null);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Sync controlled viewport to map\n useEffect(() => {\n if (!mapInstance || !isControlled || !viewport) return;\n if (mapInstance.isMoving()) return;\n\n const current = getViewport(mapInstance);\n const next = {\n center: viewport.center ?? current.center,\n zoom: viewport.zoom ?? current.zoom,\n bearing: viewport.bearing ?? current.bearing,\n pitch: viewport.pitch ?? current.pitch,\n };\n\n if (\n next.center[0] === current.center[0] &&\n next.center[1] === current.center[1] &&\n next.zoom === current.zoom &&\n next.bearing === current.bearing &&\n next.pitch === current.pitch\n ) {\n return;\n }\n\n internalUpdateRef.current = true;\n mapInstance.jumpTo(next);\n internalUpdateRef.current = false;\n }, [mapInstance, isControlled, viewport]);\n\n // Handle style change\n useEffect(() => {\n if (!mapInstance || !resolvedTheme) return;\n\n const newStyle =\n resolvedTheme === \"dark\" ? mapStyles.dark : mapStyles.light;\n\n if (currentStyleRef.current === newStyle) return;\n\n clearStyleTimeout();\n currentStyleRef.current = newStyle;\n setIsStyleLoaded(false);\n\n mapInstance.setStyle(newStyle, { diff: true });\n }, [mapInstance, resolvedTheme, mapStyles, clearStyleTimeout]);\n\n const contextValue = useMemo(\n () => ({\n map: mapInstance,\n isLoaded: isLoaded && isStyleLoaded,\n }),\n [mapInstance, isLoaded, isStyleLoaded]\n );\n\n return (\n \n \n {!isLoaded && }\n {/* SSR-safe: children render only when map is loaded on client */}\n {mapInstance && children}\n
\n \n );\n});\n\ntype MarkerContextValue = {\n marker: MapLibreGL.Marker;\n map: MapLibreGL.Map | null;\n};\n\nconst MarkerContext = createContext(null);\n\nfunction useMarkerContext() {\n const context = useContext(MarkerContext);\n if (!context) {\n throw new Error(\"Marker components must be used within MapMarker\");\n }\n return context;\n}\n\ntype MapMarkerProps = {\n /** Longitude coordinate for marker position */\n longitude: number;\n /** Latitude coordinate for marker position */\n latitude: number;\n /** Marker subcomponents (MarkerContent, MarkerPopup, MarkerTooltip, MarkerLabel) */\n children: ReactNode;\n /** Callback when marker is clicked */\n onClick?: (e: MouseEvent) => void;\n /** Callback when mouse enters marker */\n onMouseEnter?: (e: MouseEvent) => void;\n /** Callback when mouse leaves marker */\n onMouseLeave?: (e: MouseEvent) => void;\n /** Callback when marker drag starts (requires draggable: true) */\n onDragStart?: (lngLat: { lng: number; lat: number }) => void;\n /** Callback during marker drag (requires draggable: true) */\n onDrag?: (lngLat: { lng: number; lat: number }) => void;\n /** Callback when marker drag ends (requires draggable: true) */\n onDragEnd?: (lngLat: { lng: number; lat: number }) => void;\n} & Omit;\n\nfunction MapMarker({\n longitude,\n latitude,\n children,\n onClick,\n onMouseEnter,\n onMouseLeave,\n onDragStart,\n onDrag,\n onDragEnd,\n draggable = false,\n ...markerOptions\n}: MapMarkerProps) {\n const { map } = useMap();\n\n const callbacksRef = useRef({\n onClick,\n onMouseEnter,\n onMouseLeave,\n onDragStart,\n onDrag,\n onDragEnd,\n });\n callbacksRef.current = {\n onClick,\n onMouseEnter,\n onMouseLeave,\n onDragStart,\n onDrag,\n onDragEnd,\n };\n\n const marker = useMemo(() => {\n const markerInstance = new MapLibreGL.Marker({\n ...markerOptions,\n element: document.createElement(\"div\"),\n draggable,\n }).setLngLat([longitude, latitude]);\n\n const handleClick = (e: MouseEvent) => callbacksRef.current.onClick?.(e);\n const handleMouseEnter = (e: MouseEvent) =>\n callbacksRef.current.onMouseEnter?.(e);\n const handleMouseLeave = (e: MouseEvent) =>\n callbacksRef.current.onMouseLeave?.(e);\n\n markerInstance.getElement()?.addEventListener(\"click\", handleClick);\n markerInstance\n .getElement()\n ?.addEventListener(\"mouseenter\", handleMouseEnter);\n markerInstance\n .getElement()\n ?.addEventListener(\"mouseleave\", handleMouseLeave);\n\n const handleDragStart = () => {\n const lngLat = markerInstance.getLngLat();\n callbacksRef.current.onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat });\n };\n const handleDrag = () => {\n const lngLat = markerInstance.getLngLat();\n callbacksRef.current.onDrag?.({ lng: lngLat.lng, lat: lngLat.lat });\n };\n const handleDragEnd = () => {\n const lngLat = markerInstance.getLngLat();\n callbacksRef.current.onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat });\n };\n\n markerInstance.on(\"dragstart\", handleDragStart);\n markerInstance.on(\"drag\", handleDrag);\n markerInstance.on(\"dragend\", handleDragEnd);\n\n return markerInstance;\n\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n if (!map) return;\n\n marker.addTo(map);\n\n return () => {\n marker.remove();\n };\n\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [map]);\n\n if (\n marker.getLngLat().lng !== longitude ||\n marker.getLngLat().lat !== latitude\n ) {\n marker.setLngLat([longitude, latitude]);\n }\n if (marker.isDraggable() !== draggable) {\n marker.setDraggable(draggable);\n }\n\n const currentOffset = marker.getOffset();\n const newOffset = markerOptions.offset ?? [0, 0];\n const [newOffsetX, newOffsetY] = Array.isArray(newOffset)\n ? newOffset\n : [newOffset.x, newOffset.y];\n if (currentOffset.x !== newOffsetX || currentOffset.y !== newOffsetY) {\n marker.setOffset(newOffset);\n }\n\n if (marker.getRotation() !== markerOptions.rotation) {\n marker.setRotation(markerOptions.rotation ?? 0);\n }\n if (marker.getRotationAlignment() !== markerOptions.rotationAlignment) {\n marker.setRotationAlignment(markerOptions.rotationAlignment ?? \"auto\");\n }\n if (marker.getPitchAlignment() !== markerOptions.pitchAlignment) {\n marker.setPitchAlignment(markerOptions.pitchAlignment ?? \"auto\");\n }\n\n return (\n \n {children}\n \n );\n}\n\ntype MarkerContentProps = {\n /** Custom marker content. Defaults to a blue dot if not provided */\n children?: ReactNode;\n /** Additional CSS classes for the marker container */\n className?: string;\n};\n\nfunction MarkerContent({ children, className }: MarkerContentProps) {\n const { marker } = useMarkerContext();\n\n return createPortal(\n
\n {children || }\n
,\n marker.getElement()\n );\n}\n\nfunction DefaultMarkerIcon() {\n return (\n
\n );\n}\n\ntype MarkerPopupProps = {\n /** Popup content */\n children: ReactNode;\n /** Additional CSS classes for the popup container */\n className?: string;\n /** Show a close button in the popup (default: false) */\n closeButton?: boolean;\n} & Omit;\n\nfunction MarkerPopup({\n children,\n className,\n closeButton = false,\n ...popupOptions\n}: MarkerPopupProps) {\n const { marker, map } = useMarkerContext();\n const container = useMemo(() => document.createElement(\"div\"), []);\n const prevPopupOptions = useRef(popupOptions);\n\n const popup = useMemo(() => {\n const popupInstance = new MapLibreGL.Popup({\n offset: 16,\n ...popupOptions,\n closeButton: false,\n })\n .setMaxWidth(\"none\")\n .setDOMContent(container);\n\n return popupInstance;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n if (!map) return;\n\n popup.setDOMContent(container);\n marker.setPopup(popup);\n\n return () => {\n marker.setPopup(null);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [map]);\n\n if (popup.isOpen()) {\n const prev = prevPopupOptions.current;\n\n if (prev.offset !== popupOptions.offset) {\n popup.setOffset(popupOptions.offset ?? 16);\n }\n if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) {\n popup.setMaxWidth(popupOptions.maxWidth ?? \"none\");\n }\n\n prevPopupOptions.current = popupOptions;\n }\n\n const handleClose = () => popup.remove();\n\n return createPortal(\n \n {closeButton && (\n \n \n Close\n \n )}\n {children}\n
,\n container\n );\n}\n\ntype MarkerTooltipProps = {\n /** Tooltip content */\n children: ReactNode;\n /** Additional CSS classes for the tooltip container */\n className?: string;\n} & Omit;\n\nfunction MarkerTooltip({\n children,\n className,\n ...popupOptions\n}: MarkerTooltipProps) {\n const { marker, map } = useMarkerContext();\n const container = useMemo(() => document.createElement(\"div\"), []);\n const prevTooltipOptions = useRef(popupOptions);\n\n const tooltip = useMemo(() => {\n const tooltipInstance = new MapLibreGL.Popup({\n offset: 16,\n ...popupOptions,\n closeOnClick: true,\n closeButton: false,\n }).setMaxWidth(\"none\");\n\n return tooltipInstance;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n if (!map) return;\n\n tooltip.setDOMContent(container);\n\n const handleMouseEnter = () => {\n tooltip.setLngLat(marker.getLngLat()).addTo(map);\n };\n const handleMouseLeave = () => tooltip.remove();\n\n marker.getElement()?.addEventListener(\"mouseenter\", handleMouseEnter);\n marker.getElement()?.addEventListener(\"mouseleave\", handleMouseLeave);\n\n return () => {\n marker.getElement()?.removeEventListener(\"mouseenter\", handleMouseEnter);\n marker.getElement()?.removeEventListener(\"mouseleave\", handleMouseLeave);\n tooltip.remove();\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [map]);\n\n if (tooltip.isOpen()) {\n const prev = prevTooltipOptions.current;\n\n if (prev.offset !== popupOptions.offset) {\n tooltip.setOffset(popupOptions.offset ?? 16);\n }\n if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) {\n tooltip.setMaxWidth(popupOptions.maxWidth ?? \"none\");\n }\n\n prevTooltipOptions.current = popupOptions;\n }\n\n return createPortal(\n \n {children}\n
,\n container\n );\n}\n\ntype MarkerLabelProps = {\n /** Label text content */\n children: ReactNode;\n /** Additional CSS classes for the label */\n className?: string;\n /** Position of the label relative to the marker (default: \"top\") */\n position?: \"top\" | \"bottom\";\n};\n\nfunction MarkerLabel({\n children,\n className,\n position = \"top\",\n}: MarkerLabelProps) {\n const positionClasses = {\n top: \"bottom-full mb-1\",\n bottom: \"top-full mt-1\",\n };\n\n return (\n \n {children}\n
\n );\n}\n\ntype MapControlsProps = {\n /** Position of the controls on the map (default: \"bottom-right\") */\n position?: \"top-left\" | \"top-right\" | \"bottom-left\" | \"bottom-right\";\n /** Show zoom in/out buttons (default: true) */\n showZoom?: boolean;\n /** Show compass button to reset bearing (default: false) */\n showCompass?: boolean;\n /** Show locate button to find user's location (default: false) */\n showLocate?: boolean;\n /** Show fullscreen toggle button (default: false) */\n showFullscreen?: boolean;\n /** Additional CSS classes for the controls container */\n className?: string;\n /** Callback with user coordinates when located */\n onLocate?: (coords: { longitude: number; latitude: number }) => void;\n};\n\nconst positionClasses = {\n \"top-left\": \"top-2 left-2\",\n \"top-right\": \"top-2 right-2\",\n \"bottom-left\": \"bottom-2 left-2\",\n \"bottom-right\": \"bottom-10 right-2\",\n};\n\nfunction ControlGroup({ children }: { children: React.ReactNode }) {\n return (\n
button:not(:last-child)]:border-b [&>button:not(:last-child)]:border-border\">\n {children}\n
\n );\n}\n\nfunction ControlButton({\n onClick,\n label,\n children,\n disabled = false,\n}: {\n onClick: () => void;\n label: string;\n children: React.ReactNode;\n disabled?: boolean;\n}) {\n return (\n \n {children}\n \n );\n}\n\nfunction MapControls({\n position = \"bottom-right\",\n showZoom = true,\n showCompass = false,\n showLocate = false,\n showFullscreen = false,\n className,\n onLocate,\n}: MapControlsProps) {\n const { map } = useMap();\n const [waitingForLocation, setWaitingForLocation] = useState(false);\n\n const handleZoomIn = useCallback(() => {\n map?.zoomTo(map.getZoom() + 1, { duration: 300 });\n }, [map]);\n\n const handleZoomOut = useCallback(() => {\n map?.zoomTo(map.getZoom() - 1, { duration: 300 });\n }, [map]);\n\n const handleResetBearing = useCallback(() => {\n map?.resetNorthPitch({ duration: 300 });\n }, [map]);\n\n const handleLocate = useCallback(() => {\n setWaitingForLocation(true);\n if (\"geolocation\" in navigator) {\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const coords = {\n longitude: pos.coords.longitude,\n latitude: pos.coords.latitude,\n };\n map?.flyTo({\n center: [coords.longitude, coords.latitude],\n zoom: 14,\n duration: 1500,\n });\n onLocate?.(coords);\n setWaitingForLocation(false);\n },\n (error) => {\n console.error(\"Error getting location:\", error);\n setWaitingForLocation(false);\n }\n );\n }\n }, [map, onLocate]);\n\n const handleFullscreen = useCallback(() => {\n const container = map?.getContainer();\n if (!container) return;\n if (document.fullscreenElement) {\n document.exitFullscreen();\n } else {\n container.requestFullscreen();\n }\n }, [map]);\n\n return (\n \n {showZoom && (\n \n \n \n \n \n \n \n \n )}\n {showCompass && (\n \n \n \n )}\n {showLocate && (\n \n \n {waitingForLocation ? (\n \n ) : (\n \n )}\n \n \n )}\n {showFullscreen && (\n \n \n \n \n \n )}\n
\n );\n}\n\nfunction CompassButton({ onClick }: { onClick: () => void }) {\n const { map } = useMap();\n const compassRef = useRef(null);\n\n useEffect(() => {\n if (!map || !compassRef.current) return;\n\n const compass = compassRef.current;\n\n const updateRotation = () => {\n const bearing = map.getBearing();\n const pitch = map.getPitch();\n compass.style.transform = `rotateX(${pitch}deg) rotateZ(${-bearing}deg)`;\n };\n\n map.on(\"rotate\", updateRotation);\n map.on(\"pitch\", updateRotation);\n updateRotation();\n\n return () => {\n map.off(\"rotate\", updateRotation);\n map.off(\"pitch\", updateRotation);\n };\n }, [map]);\n\n return (\n \n \n \n \n \n \n \n \n );\n}\n\ntype MapPopupProps = {\n /** Longitude coordinate for popup position */\n longitude: number;\n /** Latitude coordinate for popup position */\n latitude: number;\n /** Callback when popup is closed */\n onClose?: () => void;\n /** Popup content */\n children: ReactNode;\n /** Additional CSS classes for the popup container */\n className?: string;\n /** Show a close button in the popup (default: false) */\n closeButton?: boolean;\n} & Omit;\n\nfunction MapPopup({\n longitude,\n latitude,\n onClose,\n children,\n className,\n closeButton = false,\n ...popupOptions\n}: MapPopupProps) {\n const { map } = useMap();\n const popupOptionsRef = useRef(popupOptions);\n const onCloseRef = useRef(onClose);\n onCloseRef.current = onClose;\n const container = useMemo(() => document.createElement(\"div\"), []);\n\n const popup = useMemo(() => {\n const popupInstance = new MapLibreGL.Popup({\n offset: 16,\n ...popupOptions,\n closeButton: false,\n })\n .setMaxWidth(\"none\")\n .setLngLat([longitude, latitude]);\n\n return popupInstance;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n if (!map) return;\n\n const onCloseProp = () => onCloseRef.current?.();\n\n popup.on(\"close\", onCloseProp);\n\n popup.setDOMContent(container);\n popup.addTo(map);\n\n return () => {\n popup.off(\"close\", onCloseProp);\n if (popup.isOpen()) {\n popup.remove();\n }\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [map]);\n\n if (popup.isOpen()) {\n const prev = popupOptionsRef.current;\n\n if (\n popup.getLngLat().lng !== longitude ||\n popup.getLngLat().lat !== latitude\n ) {\n popup.setLngLat([longitude, latitude]);\n }\n\n if (prev.offset !== popupOptions.offset) {\n popup.setOffset(popupOptions.offset ?? 16);\n }\n if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) {\n popup.setMaxWidth(popupOptions.maxWidth ?? \"none\");\n }\n popupOptionsRef.current = popupOptions;\n }\n\n const handleClose = () => {\n popup.remove();\n };\n\n return createPortal(\n \n {closeButton && (\n \n \n Close\n \n )}\n {children}\n
,\n container\n );\n}\n\ntype MapRouteProps = {\n /** Optional unique identifier for the route layer */\n id?: string;\n /** Array of [longitude, latitude] coordinate pairs defining the route */\n coordinates: [number, number][];\n /** Line color as CSS color value (default: \"#4285F4\") */\n color?: string;\n /** Line width in pixels (default: 3) */\n width?: number;\n /** Line opacity from 0 to 1 (default: 0.8) */\n opacity?: number;\n /** Dash pattern [dash length, gap length] for dashed lines */\n dashArray?: [number, number];\n /** Callback when the route line is clicked */\n onClick?: () => void;\n /** Callback when mouse enters the route line */\n onMouseEnter?: () => void;\n /** Callback when mouse leaves the route line */\n onMouseLeave?: () => void;\n /** Whether the route is interactive - shows pointer cursor on hover (default: true) */\n interactive?: boolean;\n};\n\nfunction MapRoute({\n id: propId,\n coordinates,\n color = \"#4285F4\",\n width = 3,\n opacity = 0.8,\n dashArray,\n onClick,\n onMouseEnter,\n onMouseLeave,\n interactive = true,\n}: MapRouteProps) {\n const { map, isLoaded } = useMap();\n const autoId = useId();\n const id = propId ?? autoId;\n const sourceId = `route-source-${id}`;\n const layerId = `route-layer-${id}`;\n\n // Add source and layer on mount\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n map.addSource(sourceId, {\n type: \"geojson\",\n data: {\n type: \"Feature\",\n properties: {},\n geometry: { type: \"LineString\", coordinates: [] },\n },\n });\n\n map.addLayer({\n id: layerId,\n type: \"line\",\n source: sourceId,\n layout: { \"line-join\": \"round\", \"line-cap\": \"round\" },\n paint: {\n \"line-color\": color,\n \"line-width\": width,\n \"line-opacity\": opacity,\n ...(dashArray && { \"line-dasharray\": dashArray }),\n },\n });\n\n return () => {\n try {\n if (map.getLayer(layerId)) map.removeLayer(layerId);\n if (map.getSource(sourceId)) map.removeSource(sourceId);\n } catch {\n // ignore\n }\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [isLoaded, map]);\n\n // When coordinates change, update the source data\n useEffect(() => {\n if (!isLoaded || !map || coordinates.length < 2) return;\n\n const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;\n if (source) {\n source.setData({\n type: \"Feature\",\n properties: {},\n geometry: { type: \"LineString\", coordinates },\n });\n }\n }, [isLoaded, map, coordinates, sourceId]);\n\n useEffect(() => {\n if (!isLoaded || !map || !map.getLayer(layerId)) return;\n\n map.setPaintProperty(layerId, \"line-color\", color);\n map.setPaintProperty(layerId, \"line-width\", width);\n map.setPaintProperty(layerId, \"line-opacity\", opacity);\n if (dashArray) {\n map.setPaintProperty(layerId, \"line-dasharray\", dashArray);\n }\n }, [isLoaded, map, layerId, color, width, opacity, dashArray]);\n\n // Handle click and hover events\n useEffect(() => {\n if (!isLoaded || !map || !interactive) return;\n\n const handleClick = () => {\n onClick?.();\n };\n const handleMouseEnter = () => {\n map.getCanvas().style.cursor = \"pointer\";\n onMouseEnter?.();\n };\n const handleMouseLeave = () => {\n map.getCanvas().style.cursor = \"\";\n onMouseLeave?.();\n };\n\n map.on(\"click\", layerId, handleClick);\n map.on(\"mouseenter\", layerId, handleMouseEnter);\n map.on(\"mouseleave\", layerId, handleMouseLeave);\n\n return () => {\n map.off(\"click\", layerId, handleClick);\n map.off(\"mouseenter\", layerId, handleMouseEnter);\n map.off(\"mouseleave\", layerId, handleMouseLeave);\n };\n }, [\n isLoaded,\n map,\n layerId,\n onClick,\n onMouseEnter,\n onMouseLeave,\n interactive,\n ]);\n\n return null;\n}\n\ntype MapClusterLayerProps<\n P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties\n> = {\n /** GeoJSON FeatureCollection data or URL to fetch GeoJSON from */\n data: string | GeoJSON.FeatureCollection;\n /** Maximum zoom level to cluster points on (default: 14) */\n clusterMaxZoom?: number;\n /** Radius of each cluster when clustering points in pixels (default: 50) */\n clusterRadius?: number;\n /** Colors for cluster circles: [small, medium, large] based on point count (default: [\"#22c55e\", \"#eab308\", \"#ef4444\"]) */\n clusterColors?: [string, string, string];\n /** Point count thresholds for color/size steps: [medium, large] (default: [100, 750]) */\n clusterThresholds?: [number, number];\n /** Color for unclustered individual points (default: \"#3b82f6\") */\n pointColor?: string;\n /** Callback when an unclustered point is clicked */\n onPointClick?: (\n feature: GeoJSON.Feature,\n coordinates: [number, number]\n ) => void;\n /** Callback when a cluster is clicked. If not provided, zooms into the cluster */\n onClusterClick?: (\n clusterId: number,\n coordinates: [number, number],\n pointCount: number\n ) => void;\n};\n\nfunction MapClusterLayer<\n P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties\n>({\n data,\n clusterMaxZoom = 14,\n clusterRadius = 50,\n clusterColors = [\"#22c55e\", \"#eab308\", \"#ef4444\"],\n clusterThresholds = [100, 750],\n pointColor = \"#3b82f6\",\n onPointClick,\n onClusterClick,\n}: MapClusterLayerProps

) {\n const { map, isLoaded } = useMap();\n const id = useId();\n const sourceId = `cluster-source-${id}`;\n const clusterLayerId = `clusters-${id}`;\n const clusterCountLayerId = `cluster-count-${id}`;\n const unclusteredLayerId = `unclustered-point-${id}`;\n\n const stylePropsRef = useRef({\n clusterColors,\n clusterThresholds,\n pointColor,\n });\n\n // Add source and layers on mount\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n // Add clustered GeoJSON source\n map.addSource(sourceId, {\n type: \"geojson\",\n data,\n cluster: true,\n clusterMaxZoom,\n clusterRadius,\n });\n\n // Add cluster circles layer\n map.addLayer({\n id: clusterLayerId,\n type: \"circle\",\n source: sourceId,\n filter: [\"has\", \"point_count\"],\n paint: {\n \"circle-color\": [\n \"step\",\n [\"get\", \"point_count\"],\n clusterColors[0],\n clusterThresholds[0],\n clusterColors[1],\n clusterThresholds[1],\n clusterColors[2],\n ],\n \"circle-radius\": [\n \"step\",\n [\"get\", \"point_count\"],\n 20,\n clusterThresholds[0],\n 30,\n clusterThresholds[1],\n 40,\n ],\n \"circle-stroke-width\": 1,\n \"circle-stroke-color\": \"#fff\",\n \"circle-opacity\": 0.85,\n },\n });\n\n // Add cluster count text layer\n map.addLayer({\n id: clusterCountLayerId,\n type: \"symbol\",\n source: sourceId,\n filter: [\"has\", \"point_count\"],\n layout: {\n \"text-field\": \"{point_count_abbreviated}\",\n \"text-font\": [\"Open Sans\"],\n \"text-size\": 12,\n },\n paint: {\n \"text-color\": \"#fff\",\n },\n });\n\n // Add unclustered point layer\n map.addLayer({\n id: unclusteredLayerId,\n type: \"circle\",\n source: sourceId,\n filter: [\"!\", [\"has\", \"point_count\"]],\n paint: {\n \"circle-color\": pointColor,\n \"circle-radius\": 5,\n \"circle-stroke-width\": 2,\n \"circle-stroke-color\": \"#fff\",\n },\n });\n\n return () => {\n try {\n if (map.getLayer(clusterCountLayerId))\n map.removeLayer(clusterCountLayerId);\n if (map.getLayer(unclusteredLayerId))\n map.removeLayer(unclusteredLayerId);\n if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId);\n if (map.getSource(sourceId)) map.removeSource(sourceId);\n } catch {\n // ignore\n }\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [isLoaded, map, sourceId]);\n\n // Update source data when data prop changes (only for non-URL data)\n useEffect(() => {\n if (!isLoaded || !map || typeof data === \"string\") return;\n\n const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;\n if (source) {\n source.setData(data);\n }\n }, [isLoaded, map, data, sourceId]);\n\n // Update layer styles when props change\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n const prev = stylePropsRef.current;\n const colorsChanged =\n prev.clusterColors !== clusterColors ||\n prev.clusterThresholds !== clusterThresholds;\n\n // Update cluster layer colors and sizes\n if (map.getLayer(clusterLayerId) && colorsChanged) {\n map.setPaintProperty(clusterLayerId, \"circle-color\", [\n \"step\",\n [\"get\", \"point_count\"],\n clusterColors[0],\n clusterThresholds[0],\n clusterColors[1],\n clusterThresholds[1],\n clusterColors[2],\n ]);\n map.setPaintProperty(clusterLayerId, \"circle-radius\", [\n \"step\",\n [\"get\", \"point_count\"],\n 20,\n clusterThresholds[0],\n 30,\n clusterThresholds[1],\n 40,\n ]);\n }\n\n // Update unclustered point layer color\n if (map.getLayer(unclusteredLayerId) && prev.pointColor !== pointColor) {\n map.setPaintProperty(unclusteredLayerId, \"circle-color\", pointColor);\n }\n\n stylePropsRef.current = { clusterColors, clusterThresholds, pointColor };\n }, [\n isLoaded,\n map,\n clusterLayerId,\n unclusteredLayerId,\n clusterColors,\n clusterThresholds,\n pointColor,\n ]);\n\n // Handle click events\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n // Cluster click handler - zoom into cluster\n const handleClusterClick = async (\n e: MapLibreGL.MapMouseEvent & {\n features?: MapLibreGL.MapGeoJSONFeature[];\n }\n ) => {\n const features = map.queryRenderedFeatures(e.point, {\n layers: [clusterLayerId],\n });\n if (!features.length) return;\n\n const feature = features[0];\n const clusterId = feature.properties?.cluster_id as number;\n const pointCount = feature.properties?.point_count as number;\n const coordinates = (feature.geometry as GeoJSON.Point).coordinates as [\n number,\n number\n ];\n\n if (onClusterClick) {\n onClusterClick(clusterId, coordinates, pointCount);\n } else {\n // Default behavior: zoom to cluster expansion zoom\n const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;\n const zoom = await source.getClusterExpansionZoom(clusterId);\n map.easeTo({\n center: coordinates,\n zoom,\n });\n }\n };\n\n // Unclustered point click handler\n const handlePointClick = (\n e: MapLibreGL.MapMouseEvent & {\n features?: MapLibreGL.MapGeoJSONFeature[];\n }\n ) => {\n if (!onPointClick || !e.features?.length) return;\n\n const feature = e.features[0];\n const coordinates = (\n feature.geometry as GeoJSON.Point\n ).coordinates.slice() as [number, number];\n\n // Handle world copies\n while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {\n coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;\n }\n\n onPointClick(\n feature as unknown as GeoJSON.Feature,\n coordinates\n );\n };\n\n // Cursor style handlers\n const handleMouseEnterCluster = () => {\n map.getCanvas().style.cursor = \"pointer\";\n };\n const handleMouseLeaveCluster = () => {\n map.getCanvas().style.cursor = \"\";\n };\n const handleMouseEnterPoint = () => {\n if (onPointClick) {\n map.getCanvas().style.cursor = \"pointer\";\n }\n };\n const handleMouseLeavePoint = () => {\n map.getCanvas().style.cursor = \"\";\n };\n\n map.on(\"click\", clusterLayerId, handleClusterClick);\n map.on(\"click\", unclusteredLayerId, handlePointClick);\n map.on(\"mouseenter\", clusterLayerId, handleMouseEnterCluster);\n map.on(\"mouseleave\", clusterLayerId, handleMouseLeaveCluster);\n map.on(\"mouseenter\", unclusteredLayerId, handleMouseEnterPoint);\n map.on(\"mouseleave\", unclusteredLayerId, handleMouseLeavePoint);\n\n return () => {\n map.off(\"click\", clusterLayerId, handleClusterClick);\n map.off(\"click\", unclusteredLayerId, handlePointClick);\n map.off(\"mouseenter\", clusterLayerId, handleMouseEnterCluster);\n map.off(\"mouseleave\", clusterLayerId, handleMouseLeaveCluster);\n map.off(\"mouseenter\", unclusteredLayerId, handleMouseEnterPoint);\n map.off(\"mouseleave\", unclusteredLayerId, handleMouseLeavePoint);\n };\n }, [\n isLoaded,\n map,\n clusterLayerId,\n unclusteredLayerId,\n sourceId,\n onClusterClick,\n onPointClick,\n ]);\n\n return null;\n}\n\nexport {\n Map,\n useMap,\n MapMarker,\n MarkerContent,\n MarkerPopup,\n MarkerTooltip,\n MarkerLabel,\n MapPopup,\n MapControls,\n MapRoute,\n MapClusterLayer,\n};\n\nexport type { MapRef, MapViewport };\n", + "content": "\"use client\";\n\nimport MapLibreGL, { type PopupOptions, type MarkerOptions } from \"maplibre-gl\";\nimport \"maplibre-gl/dist/maplibre-gl.css\";\nimport {\n createContext,\n forwardRef,\n useCallback,\n useContext,\n useEffect,\n useId,\n useImperativeHandle,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { X, Minus, Plus, Locate, Maximize, Loader2 } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst defaultStyles = {\n dark: \"https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json\",\n light: \"https://basemaps.cartocdn.com/gl/positron-gl-style/style.json\",\n};\n\ntype Theme = \"light\" | \"dark\";\n\n// Check document class for theme (works with next-themes, etc.)\nfunction getDocumentTheme(): Theme | null {\n if (typeof document === \"undefined\") return null;\n if (document.documentElement.classList.contains(\"dark\")) return \"dark\";\n if (document.documentElement.classList.contains(\"light\")) return \"light\";\n return null;\n}\n\n// Get system preference\nfunction getSystemTheme(): Theme {\n if (typeof window === \"undefined\") return \"light\";\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n ? \"dark\"\n : \"light\";\n}\n\nfunction useResolvedTheme(themeProp?: \"light\" | \"dark\"): Theme {\n const [detectedTheme, setDetectedTheme] = useState(\n () => getDocumentTheme() ?? getSystemTheme()\n );\n\n useEffect(() => {\n if (themeProp) return; // Skip detection if theme is provided via prop\n\n // Watch for document class changes (e.g., next-themes toggling dark class)\n const observer = new MutationObserver(() => {\n const docTheme = getDocumentTheme();\n if (docTheme) {\n setDetectedTheme(docTheme);\n }\n });\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\"],\n });\n\n // Also watch for system preference changes\n const mediaQuery = window.matchMedia(\"(prefers-color-scheme: dark)\");\n const handleSystemChange = (e: MediaQueryListEvent) => {\n // Only use system preference if no document class is set\n if (!getDocumentTheme()) {\n setDetectedTheme(e.matches ? \"dark\" : \"light\");\n }\n };\n mediaQuery.addEventListener(\"change\", handleSystemChange);\n\n return () => {\n observer.disconnect();\n mediaQuery.removeEventListener(\"change\", handleSystemChange);\n };\n }, [themeProp]);\n\n return themeProp ?? detectedTheme;\n}\n\ntype MapContextValue = {\n map: MapLibreGL.Map | null;\n isLoaded: boolean;\n};\n\nconst MapContext = createContext(null);\n\nfunction useMap() {\n const context = useContext(MapContext);\n if (!context) {\n throw new Error(\"useMap must be used within a Map component\");\n }\n return context;\n}\n\n/** Map viewport state */\ntype MapViewport = {\n /** Center coordinates [longitude, latitude] */\n center: [number, number];\n /** Zoom level */\n zoom: number;\n /** Bearing (rotation) in degrees */\n bearing: number;\n /** Pitch (tilt) in degrees */\n pitch: number;\n};\n\ntype MapStyleOption = string | MapLibreGL.StyleSpecification;\n\ntype MapRef = MapLibreGL.Map;\n\ntype MapProps = {\n children?: ReactNode;\n /** Additional CSS classes for the map container */\n className?: string;\n /**\n * Theme for the map. If not provided, automatically detects system preference.\n * Pass your theme value here.\n */\n theme?: Theme;\n /** Custom map styles for light and dark themes. Overrides the default Carto styles. */\n styles?: {\n light?: MapStyleOption;\n dark?: MapStyleOption;\n };\n /** Map projection type. Use `{ type: \"globe\" }` for 3D globe view. */\n projection?: MapLibreGL.ProjectionSpecification;\n /**\n * Controlled viewport. When provided with onViewportChange,\n * the map becomes controlled and viewport is driven by this prop.\n */\n viewport?: Partial;\n /**\n * Callback fired continuously as the viewport changes (pan, zoom, rotate, pitch).\n * Can be used standalone to observe changes, or with `viewport` prop\n * to enable controlled mode where the map viewport is driven by your state.\n */\n onViewportChange?: (viewport: MapViewport) => void;\n /** Show a loading indicator on the map */\n loading?: boolean;\n} & Omit;\n\nfunction DefaultLoader() {\n return (\n

\n
\n \n \n \n
\n
\n );\n}\n\nfunction getViewport(map: MapLibreGL.Map): MapViewport {\n const center = map.getCenter();\n return {\n center: [center.lng, center.lat],\n zoom: map.getZoom(),\n bearing: map.getBearing(),\n pitch: map.getPitch(),\n };\n}\n\nconst Map = forwardRef(function Map(\n {\n children,\n className,\n theme: themeProp,\n styles,\n projection,\n viewport,\n onViewportChange,\n loading = false,\n ...props\n },\n ref\n) {\n const containerRef = useRef(null);\n const [mapInstance, setMapInstance] = useState(null);\n const [isLoaded, setIsLoaded] = useState(false);\n const [isStyleLoaded, setIsStyleLoaded] = useState(false);\n const currentStyleRef = useRef(null);\n const styleTimeoutRef = useRef | null>(null);\n const internalUpdateRef = useRef(false);\n const resolvedTheme = useResolvedTheme(themeProp);\n\n const isControlled = viewport !== undefined && onViewportChange !== undefined;\n\n const onViewportChangeRef = useRef(onViewportChange);\n onViewportChangeRef.current = onViewportChange;\n\n const mapStyles = useMemo(\n () => ({\n dark: styles?.dark ?? defaultStyles.dark,\n light: styles?.light ?? defaultStyles.light,\n }),\n [styles]\n );\n\n // Expose the map instance to the parent component\n useImperativeHandle(ref, () => mapInstance as MapLibreGL.Map, [mapInstance]);\n\n const clearStyleTimeout = useCallback(() => {\n if (styleTimeoutRef.current) {\n clearTimeout(styleTimeoutRef.current);\n styleTimeoutRef.current = null;\n }\n }, []);\n\n // Initialize the map\n useEffect(() => {\n if (!containerRef.current) return;\n\n const initialStyle =\n resolvedTheme === \"dark\" ? mapStyles.dark : mapStyles.light;\n currentStyleRef.current = initialStyle;\n\n const map = new MapLibreGL.Map({\n container: containerRef.current,\n style: initialStyle,\n renderWorldCopies: false,\n attributionControl: {\n compact: true,\n },\n ...props,\n ...viewport,\n });\n\n const styleDataHandler = () => {\n clearStyleTimeout();\n // Delay to ensure style is fully processed before allowing layer operations\n // This is a workaround to avoid race conditions with the style loading\n // else we have to force update every layer on setStyle change\n styleTimeoutRef.current = setTimeout(() => {\n setIsStyleLoaded(true);\n if (projection) {\n map.setProjection(projection);\n }\n }, 100);\n };\n const loadHandler = () => setIsLoaded(true);\n\n // Viewport change handler - skip if triggered by internal update\n const handleMove = () => {\n if (internalUpdateRef.current) return;\n onViewportChangeRef.current?.(getViewport(map));\n };\n\n map.on(\"load\", loadHandler);\n map.on(\"styledata\", styleDataHandler);\n map.on(\"move\", handleMove);\n setMapInstance(map);\n\n return () => {\n clearStyleTimeout();\n map.off(\"load\", loadHandler);\n map.off(\"styledata\", styleDataHandler);\n map.off(\"move\", handleMove);\n map.remove();\n setIsLoaded(false);\n setIsStyleLoaded(false);\n setMapInstance(null);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Sync controlled viewport to map\n useEffect(() => {\n if (!mapInstance || !isControlled || !viewport) return;\n if (mapInstance.isMoving()) return;\n\n const current = getViewport(mapInstance);\n const next = {\n center: viewport.center ?? current.center,\n zoom: viewport.zoom ?? current.zoom,\n bearing: viewport.bearing ?? current.bearing,\n pitch: viewport.pitch ?? current.pitch,\n };\n\n if (\n next.center[0] === current.center[0] &&\n next.center[1] === current.center[1] &&\n next.zoom === current.zoom &&\n next.bearing === current.bearing &&\n next.pitch === current.pitch\n ) {\n return;\n }\n\n internalUpdateRef.current = true;\n mapInstance.jumpTo(next);\n internalUpdateRef.current = false;\n }, [mapInstance, isControlled, viewport]);\n\n // Handle style change\n useEffect(() => {\n if (!mapInstance || !resolvedTheme) return;\n\n const newStyle =\n resolvedTheme === \"dark\" ? mapStyles.dark : mapStyles.light;\n\n if (currentStyleRef.current === newStyle) return;\n\n clearStyleTimeout();\n currentStyleRef.current = newStyle;\n setIsStyleLoaded(false);\n\n mapInstance.setStyle(newStyle, { diff: true });\n }, [mapInstance, resolvedTheme, mapStyles, clearStyleTimeout]);\n\n const contextValue = useMemo(\n () => ({\n map: mapInstance,\n isLoaded: isLoaded && isStyleLoaded,\n }),\n [mapInstance, isLoaded, isStyleLoaded]\n );\n\n return (\n \n \n {(!isLoaded || loading) && }\n {/* SSR-safe: children render only when map is loaded on client */}\n {mapInstance && children}\n \n \n );\n});\n\ntype MarkerContextValue = {\n marker: MapLibreGL.Marker;\n map: MapLibreGL.Map | null;\n};\n\nconst MarkerContext = createContext(null);\n\nfunction useMarkerContext() {\n const context = useContext(MarkerContext);\n if (!context) {\n throw new Error(\"Marker components must be used within MapMarker\");\n }\n return context;\n}\n\ntype MapMarkerProps = {\n /** Longitude coordinate for marker position */\n longitude: number;\n /** Latitude coordinate for marker position */\n latitude: number;\n /** Marker subcomponents (MarkerContent, MarkerPopup, MarkerTooltip, MarkerLabel) */\n children: ReactNode;\n /** Callback when marker is clicked */\n onClick?: (e: MouseEvent) => void;\n /** Callback when mouse enters marker */\n onMouseEnter?: (e: MouseEvent) => void;\n /** Callback when mouse leaves marker */\n onMouseLeave?: (e: MouseEvent) => void;\n /** Callback when marker drag starts (requires draggable: true) */\n onDragStart?: (lngLat: { lng: number; lat: number }) => void;\n /** Callback during marker drag (requires draggable: true) */\n onDrag?: (lngLat: { lng: number; lat: number }) => void;\n /** Callback when marker drag ends (requires draggable: true) */\n onDragEnd?: (lngLat: { lng: number; lat: number }) => void;\n} & Omit;\n\nfunction MapMarker({\n longitude,\n latitude,\n children,\n onClick,\n onMouseEnter,\n onMouseLeave,\n onDragStart,\n onDrag,\n onDragEnd,\n draggable = false,\n ...markerOptions\n}: MapMarkerProps) {\n const { map } = useMap();\n\n const callbacksRef = useRef({\n onClick,\n onMouseEnter,\n onMouseLeave,\n onDragStart,\n onDrag,\n onDragEnd,\n });\n callbacksRef.current = {\n onClick,\n onMouseEnter,\n onMouseLeave,\n onDragStart,\n onDrag,\n onDragEnd,\n };\n\n const marker = useMemo(() => {\n const markerInstance = new MapLibreGL.Marker({\n ...markerOptions,\n element: document.createElement(\"div\"),\n draggable,\n }).setLngLat([longitude, latitude]);\n\n const handleClick = (e: MouseEvent) => callbacksRef.current.onClick?.(e);\n const handleMouseEnter = (e: MouseEvent) =>\n callbacksRef.current.onMouseEnter?.(e);\n const handleMouseLeave = (e: MouseEvent) =>\n callbacksRef.current.onMouseLeave?.(e);\n\n markerInstance.getElement()?.addEventListener(\"click\", handleClick);\n markerInstance\n .getElement()\n ?.addEventListener(\"mouseenter\", handleMouseEnter);\n markerInstance\n .getElement()\n ?.addEventListener(\"mouseleave\", handleMouseLeave);\n\n const handleDragStart = () => {\n const lngLat = markerInstance.getLngLat();\n callbacksRef.current.onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat });\n };\n const handleDrag = () => {\n const lngLat = markerInstance.getLngLat();\n callbacksRef.current.onDrag?.({ lng: lngLat.lng, lat: lngLat.lat });\n };\n const handleDragEnd = () => {\n const lngLat = markerInstance.getLngLat();\n callbacksRef.current.onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat });\n };\n\n markerInstance.on(\"dragstart\", handleDragStart);\n markerInstance.on(\"drag\", handleDrag);\n markerInstance.on(\"dragend\", handleDragEnd);\n\n return markerInstance;\n\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n if (!map) return;\n\n marker.addTo(map);\n\n return () => {\n marker.remove();\n };\n\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [map]);\n\n if (\n marker.getLngLat().lng !== longitude ||\n marker.getLngLat().lat !== latitude\n ) {\n marker.setLngLat([longitude, latitude]);\n }\n if (marker.isDraggable() !== draggable) {\n marker.setDraggable(draggable);\n }\n\n const currentOffset = marker.getOffset();\n const newOffset = markerOptions.offset ?? [0, 0];\n const [newOffsetX, newOffsetY] = Array.isArray(newOffset)\n ? newOffset\n : [newOffset.x, newOffset.y];\n if (currentOffset.x !== newOffsetX || currentOffset.y !== newOffsetY) {\n marker.setOffset(newOffset);\n }\n\n if (marker.getRotation() !== markerOptions.rotation) {\n marker.setRotation(markerOptions.rotation ?? 0);\n }\n if (marker.getRotationAlignment() !== markerOptions.rotationAlignment) {\n marker.setRotationAlignment(markerOptions.rotationAlignment ?? \"auto\");\n }\n if (marker.getPitchAlignment() !== markerOptions.pitchAlignment) {\n marker.setPitchAlignment(markerOptions.pitchAlignment ?? \"auto\");\n }\n\n return (\n \n {children}\n \n );\n}\n\ntype MarkerContentProps = {\n /** Custom marker content. Defaults to a blue dot if not provided */\n children?: ReactNode;\n /** Additional CSS classes for the marker container */\n className?: string;\n};\n\nfunction MarkerContent({ children, className }: MarkerContentProps) {\n const { marker } = useMarkerContext();\n\n return createPortal(\n
\n {children || }\n
,\n marker.getElement()\n );\n}\n\nfunction DefaultMarkerIcon() {\n return (\n
\n );\n}\n\ntype MarkerPopupProps = {\n /** Popup content */\n children: ReactNode;\n /** Additional CSS classes for the popup container */\n className?: string;\n /** Show a close button in the popup (default: false) */\n closeButton?: boolean;\n} & Omit;\n\nfunction MarkerPopup({\n children,\n className,\n closeButton = false,\n ...popupOptions\n}: MarkerPopupProps) {\n const { marker, map } = useMarkerContext();\n const container = useMemo(() => document.createElement(\"div\"), []);\n const prevPopupOptions = useRef(popupOptions);\n\n const popup = useMemo(() => {\n const popupInstance = new MapLibreGL.Popup({\n offset: 16,\n ...popupOptions,\n closeButton: false,\n })\n .setMaxWidth(\"none\")\n .setDOMContent(container);\n\n return popupInstance;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n if (!map) return;\n\n popup.setDOMContent(container);\n marker.setPopup(popup);\n\n return () => {\n marker.setPopup(null);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [map]);\n\n if (popup.isOpen()) {\n const prev = prevPopupOptions.current;\n\n if (prev.offset !== popupOptions.offset) {\n popup.setOffset(popupOptions.offset ?? 16);\n }\n if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) {\n popup.setMaxWidth(popupOptions.maxWidth ?? \"none\");\n }\n\n prevPopupOptions.current = popupOptions;\n }\n\n const handleClose = () => popup.remove();\n\n return createPortal(\n \n {closeButton && (\n \n \n Close\n \n )}\n {children}\n
,\n container\n );\n}\n\ntype MarkerTooltipProps = {\n /** Tooltip content */\n children: ReactNode;\n /** Additional CSS classes for the tooltip container */\n className?: string;\n} & Omit;\n\nfunction MarkerTooltip({\n children,\n className,\n ...popupOptions\n}: MarkerTooltipProps) {\n const { marker, map } = useMarkerContext();\n const container = useMemo(() => document.createElement(\"div\"), []);\n const prevTooltipOptions = useRef(popupOptions);\n\n const tooltip = useMemo(() => {\n const tooltipInstance = new MapLibreGL.Popup({\n offset: 16,\n ...popupOptions,\n closeOnClick: true,\n closeButton: false,\n }).setMaxWidth(\"none\");\n\n return tooltipInstance;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n if (!map) return;\n\n tooltip.setDOMContent(container);\n\n const handleMouseEnter = () => {\n tooltip.setLngLat(marker.getLngLat()).addTo(map);\n };\n const handleMouseLeave = () => tooltip.remove();\n\n marker.getElement()?.addEventListener(\"mouseenter\", handleMouseEnter);\n marker.getElement()?.addEventListener(\"mouseleave\", handleMouseLeave);\n\n return () => {\n marker.getElement()?.removeEventListener(\"mouseenter\", handleMouseEnter);\n marker.getElement()?.removeEventListener(\"mouseleave\", handleMouseLeave);\n tooltip.remove();\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [map]);\n\n if (tooltip.isOpen()) {\n const prev = prevTooltipOptions.current;\n\n if (prev.offset !== popupOptions.offset) {\n tooltip.setOffset(popupOptions.offset ?? 16);\n }\n if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) {\n tooltip.setMaxWidth(popupOptions.maxWidth ?? \"none\");\n }\n\n prevTooltipOptions.current = popupOptions;\n }\n\n return createPortal(\n \n {children}\n ,\n container\n );\n}\n\ntype MarkerLabelProps = {\n /** Label text content */\n children: ReactNode;\n /** Additional CSS classes for the label */\n className?: string;\n /** Position of the label relative to the marker (default: \"top\") */\n position?: \"top\" | \"bottom\";\n};\n\nfunction MarkerLabel({\n children,\n className,\n position = \"top\",\n}: MarkerLabelProps) {\n const positionClasses = {\n top: \"bottom-full mb-1\",\n bottom: \"top-full mt-1\",\n };\n\n return (\n \n {children}\n \n );\n}\n\ntype MapControlsProps = {\n /** Position of the controls on the map (default: \"bottom-right\") */\n position?: \"top-left\" | \"top-right\" | \"bottom-left\" | \"bottom-right\";\n /** Show zoom in/out buttons (default: true) */\n showZoom?: boolean;\n /** Show compass button to reset bearing (default: false) */\n showCompass?: boolean;\n /** Show locate button to find user's location (default: false) */\n showLocate?: boolean;\n /** Show fullscreen toggle button (default: false) */\n showFullscreen?: boolean;\n /** Additional CSS classes for the controls container */\n className?: string;\n /** Callback with user coordinates when located */\n onLocate?: (coords: { longitude: number; latitude: number }) => void;\n};\n\nconst positionClasses = {\n \"top-left\": \"top-2 left-2\",\n \"top-right\": \"top-2 right-2\",\n \"bottom-left\": \"bottom-2 left-2\",\n \"bottom-right\": \"bottom-10 right-2\",\n};\n\nfunction ControlGroup({ children }: { children: React.ReactNode }) {\n return (\n
button:not(:last-child)]:border-b [&>button:not(:last-child)]:border-border\">\n {children}\n
\n );\n}\n\nfunction ControlButton({\n onClick,\n label,\n children,\n disabled = false,\n}: {\n onClick: () => void;\n label: string;\n children: React.ReactNode;\n disabled?: boolean;\n}) {\n return (\n \n {children}\n \n );\n}\n\nfunction MapControls({\n position = \"bottom-right\",\n showZoom = true,\n showCompass = false,\n showLocate = false,\n showFullscreen = false,\n className,\n onLocate,\n}: MapControlsProps) {\n const { map } = useMap();\n const [waitingForLocation, setWaitingForLocation] = useState(false);\n\n const handleZoomIn = useCallback(() => {\n map?.zoomTo(map.getZoom() + 1, { duration: 300 });\n }, [map]);\n\n const handleZoomOut = useCallback(() => {\n map?.zoomTo(map.getZoom() - 1, { duration: 300 });\n }, [map]);\n\n const handleResetBearing = useCallback(() => {\n map?.resetNorthPitch({ duration: 300 });\n }, [map]);\n\n const handleLocate = useCallback(() => {\n setWaitingForLocation(true);\n if (\"geolocation\" in navigator) {\n navigator.geolocation.getCurrentPosition(\n (pos) => {\n const coords = {\n longitude: pos.coords.longitude,\n latitude: pos.coords.latitude,\n };\n map?.flyTo({\n center: [coords.longitude, coords.latitude],\n zoom: 14,\n duration: 1500,\n });\n onLocate?.(coords);\n setWaitingForLocation(false);\n },\n (error) => {\n console.error(\"Error getting location:\", error);\n setWaitingForLocation(false);\n }\n );\n }\n }, [map, onLocate]);\n\n const handleFullscreen = useCallback(() => {\n const container = map?.getContainer();\n if (!container) return;\n if (document.fullscreenElement) {\n document.exitFullscreen();\n } else {\n container.requestFullscreen();\n }\n }, [map]);\n\n return (\n \n {showZoom && (\n \n \n \n \n \n \n \n \n )}\n {showCompass && (\n \n \n \n )}\n {showLocate && (\n \n \n {waitingForLocation ? (\n \n ) : (\n \n )}\n \n \n )}\n {showFullscreen && (\n \n \n \n \n \n )}\n \n );\n}\n\nfunction CompassButton({ onClick }: { onClick: () => void }) {\n const { map } = useMap();\n const compassRef = useRef(null);\n\n useEffect(() => {\n if (!map || !compassRef.current) return;\n\n const compass = compassRef.current;\n\n const updateRotation = () => {\n const bearing = map.getBearing();\n const pitch = map.getPitch();\n compass.style.transform = `rotateX(${pitch}deg) rotateZ(${-bearing}deg)`;\n };\n\n map.on(\"rotate\", updateRotation);\n map.on(\"pitch\", updateRotation);\n updateRotation();\n\n return () => {\n map.off(\"rotate\", updateRotation);\n map.off(\"pitch\", updateRotation);\n };\n }, [map]);\n\n return (\n \n \n \n \n \n \n \n \n );\n}\n\ntype MapPopupProps = {\n /** Longitude coordinate for popup position */\n longitude: number;\n /** Latitude coordinate for popup position */\n latitude: number;\n /** Callback when popup is closed */\n onClose?: () => void;\n /** Popup content */\n children: ReactNode;\n /** Additional CSS classes for the popup container */\n className?: string;\n /** Show a close button in the popup (default: false) */\n closeButton?: boolean;\n} & Omit;\n\nfunction MapPopup({\n longitude,\n latitude,\n onClose,\n children,\n className,\n closeButton = false,\n ...popupOptions\n}: MapPopupProps) {\n const { map } = useMap();\n const popupOptionsRef = useRef(popupOptions);\n const onCloseRef = useRef(onClose);\n onCloseRef.current = onClose;\n const container = useMemo(() => document.createElement(\"div\"), []);\n\n const popup = useMemo(() => {\n const popupInstance = new MapLibreGL.Popup({\n offset: 16,\n ...popupOptions,\n closeButton: false,\n })\n .setMaxWidth(\"none\")\n .setLngLat([longitude, latitude]);\n\n return popupInstance;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n useEffect(() => {\n if (!map) return;\n\n const onCloseProp = () => onCloseRef.current?.();\n\n popup.on(\"close\", onCloseProp);\n\n popup.setDOMContent(container);\n popup.addTo(map);\n\n return () => {\n popup.off(\"close\", onCloseProp);\n if (popup.isOpen()) {\n popup.remove();\n }\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [map]);\n\n if (popup.isOpen()) {\n const prev = popupOptionsRef.current;\n\n if (\n popup.getLngLat().lng !== longitude ||\n popup.getLngLat().lat !== latitude\n ) {\n popup.setLngLat([longitude, latitude]);\n }\n\n if (prev.offset !== popupOptions.offset) {\n popup.setOffset(popupOptions.offset ?? 16);\n }\n if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) {\n popup.setMaxWidth(popupOptions.maxWidth ?? \"none\");\n }\n popupOptionsRef.current = popupOptions;\n }\n\n const handleClose = () => {\n popup.remove();\n };\n\n return createPortal(\n \n {closeButton && (\n \n \n Close\n \n )}\n {children}\n ,\n container\n );\n}\n\ntype MapRouteProps = {\n /** Optional unique identifier for the route layer */\n id?: string;\n /** Array of [longitude, latitude] coordinate pairs defining the route */\n coordinates: [number, number][];\n /** Line color as CSS color value (default: \"#4285F4\") */\n color?: string;\n /** Line width in pixels (default: 3) */\n width?: number;\n /** Line opacity from 0 to 1 (default: 0.8) */\n opacity?: number;\n /** Dash pattern [dash length, gap length] for dashed lines */\n dashArray?: [number, number];\n /** Callback when the route line is clicked */\n onClick?: () => void;\n /** Callback when mouse enters the route line */\n onMouseEnter?: () => void;\n /** Callback when mouse leaves the route line */\n onMouseLeave?: () => void;\n /** Whether the route is interactive - shows pointer cursor on hover (default: true) */\n interactive?: boolean;\n};\n\nfunction MapRoute({\n id: propId,\n coordinates,\n color = \"#4285F4\",\n width = 3,\n opacity = 0.8,\n dashArray,\n onClick,\n onMouseEnter,\n onMouseLeave,\n interactive = true,\n}: MapRouteProps) {\n const { map, isLoaded } = useMap();\n const autoId = useId();\n const id = propId ?? autoId;\n const sourceId = `route-source-${id}`;\n const layerId = `route-layer-${id}`;\n\n // Add source and layer on mount\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n map.addSource(sourceId, {\n type: \"geojson\",\n data: {\n type: \"Feature\",\n properties: {},\n geometry: { type: \"LineString\", coordinates: [] },\n },\n });\n\n map.addLayer({\n id: layerId,\n type: \"line\",\n source: sourceId,\n layout: { \"line-join\": \"round\", \"line-cap\": \"round\" },\n paint: {\n \"line-color\": color,\n \"line-width\": width,\n \"line-opacity\": opacity,\n ...(dashArray && { \"line-dasharray\": dashArray }),\n },\n });\n\n return () => {\n try {\n if (map.getLayer(layerId)) map.removeLayer(layerId);\n if (map.getSource(sourceId)) map.removeSource(sourceId);\n } catch {\n // ignore\n }\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [isLoaded, map]);\n\n // When coordinates change, update the source data\n useEffect(() => {\n if (!isLoaded || !map || coordinates.length < 2) return;\n\n const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;\n if (source) {\n source.setData({\n type: \"Feature\",\n properties: {},\n geometry: { type: \"LineString\", coordinates },\n });\n }\n }, [isLoaded, map, coordinates, sourceId]);\n\n useEffect(() => {\n if (!isLoaded || !map || !map.getLayer(layerId)) return;\n\n map.setPaintProperty(layerId, \"line-color\", color);\n map.setPaintProperty(layerId, \"line-width\", width);\n map.setPaintProperty(layerId, \"line-opacity\", opacity);\n if (dashArray) {\n map.setPaintProperty(layerId, \"line-dasharray\", dashArray);\n }\n }, [isLoaded, map, layerId, color, width, opacity, dashArray]);\n\n // Handle click and hover events\n useEffect(() => {\n if (!isLoaded || !map || !interactive) return;\n\n const handleClick = () => {\n onClick?.();\n };\n const handleMouseEnter = () => {\n map.getCanvas().style.cursor = \"pointer\";\n onMouseEnter?.();\n };\n const handleMouseLeave = () => {\n map.getCanvas().style.cursor = \"\";\n onMouseLeave?.();\n };\n\n map.on(\"click\", layerId, handleClick);\n map.on(\"mouseenter\", layerId, handleMouseEnter);\n map.on(\"mouseleave\", layerId, handleMouseLeave);\n\n return () => {\n map.off(\"click\", layerId, handleClick);\n map.off(\"mouseenter\", layerId, handleMouseEnter);\n map.off(\"mouseleave\", layerId, handleMouseLeave);\n };\n }, [\n isLoaded,\n map,\n layerId,\n onClick,\n onMouseEnter,\n onMouseLeave,\n interactive,\n ]);\n\n return null;\n}\n\ntype MapClusterLayerProps<\n P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties\n> = {\n /** GeoJSON FeatureCollection data or URL to fetch GeoJSON from */\n data: string | GeoJSON.FeatureCollection;\n /** Maximum zoom level to cluster points on (default: 14) */\n clusterMaxZoom?: number;\n /** Radius of each cluster when clustering points in pixels (default: 50) */\n clusterRadius?: number;\n /** Colors for cluster circles: [small, medium, large] based on point count (default: [\"#22c55e\", \"#eab308\", \"#ef4444\"]) */\n clusterColors?: [string, string, string];\n /** Point count thresholds for color/size steps: [medium, large] (default: [100, 750]) */\n clusterThresholds?: [number, number];\n /** Color for unclustered individual points (default: \"#3b82f6\") */\n pointColor?: string;\n /** Callback when an unclustered point is clicked */\n onPointClick?: (\n feature: GeoJSON.Feature,\n coordinates: [number, number]\n ) => void;\n /** Callback when a cluster is clicked. If not provided, zooms into the cluster */\n onClusterClick?: (\n clusterId: number,\n coordinates: [number, number],\n pointCount: number\n ) => void;\n};\n\nfunction MapClusterLayer<\n P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties\n>({\n data,\n clusterMaxZoom = 14,\n clusterRadius = 50,\n clusterColors = [\"#22c55e\", \"#eab308\", \"#ef4444\"],\n clusterThresholds = [100, 750],\n pointColor = \"#3b82f6\",\n onPointClick,\n onClusterClick,\n}: MapClusterLayerProps

) {\n const { map, isLoaded } = useMap();\n const id = useId();\n const sourceId = `cluster-source-${id}`;\n const clusterLayerId = `clusters-${id}`;\n const clusterCountLayerId = `cluster-count-${id}`;\n const unclusteredLayerId = `unclustered-point-${id}`;\n\n const stylePropsRef = useRef({\n clusterColors,\n clusterThresholds,\n pointColor,\n });\n\n // Add source and layers on mount\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n // Add clustered GeoJSON source\n map.addSource(sourceId, {\n type: \"geojson\",\n data,\n cluster: true,\n clusterMaxZoom,\n clusterRadius,\n });\n\n // Add cluster circles layer\n map.addLayer({\n id: clusterLayerId,\n type: \"circle\",\n source: sourceId,\n filter: [\"has\", \"point_count\"],\n paint: {\n \"circle-color\": [\n \"step\",\n [\"get\", \"point_count\"],\n clusterColors[0],\n clusterThresholds[0],\n clusterColors[1],\n clusterThresholds[1],\n clusterColors[2],\n ],\n \"circle-radius\": [\n \"step\",\n [\"get\", \"point_count\"],\n 20,\n clusterThresholds[0],\n 30,\n clusterThresholds[1],\n 40,\n ],\n \"circle-stroke-width\": 1,\n \"circle-stroke-color\": \"#fff\",\n \"circle-opacity\": 0.85,\n },\n });\n\n // Add cluster count text layer\n map.addLayer({\n id: clusterCountLayerId,\n type: \"symbol\",\n source: sourceId,\n filter: [\"has\", \"point_count\"],\n layout: {\n \"text-field\": \"{point_count_abbreviated}\",\n \"text-font\": [\"Open Sans\"],\n \"text-size\": 12,\n },\n paint: {\n \"text-color\": \"#fff\",\n },\n });\n\n // Add unclustered point layer\n map.addLayer({\n id: unclusteredLayerId,\n type: \"circle\",\n source: sourceId,\n filter: [\"!\", [\"has\", \"point_count\"]],\n paint: {\n \"circle-color\": pointColor,\n \"circle-radius\": 5,\n \"circle-stroke-width\": 2,\n \"circle-stroke-color\": \"#fff\",\n },\n });\n\n return () => {\n try {\n if (map.getLayer(clusterCountLayerId))\n map.removeLayer(clusterCountLayerId);\n if (map.getLayer(unclusteredLayerId))\n map.removeLayer(unclusteredLayerId);\n if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId);\n if (map.getSource(sourceId)) map.removeSource(sourceId);\n } catch {\n // ignore\n }\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [isLoaded, map, sourceId]);\n\n // Update source data when data prop changes (only for non-URL data)\n useEffect(() => {\n if (!isLoaded || !map || typeof data === \"string\") return;\n\n const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;\n if (source) {\n source.setData(data);\n }\n }, [isLoaded, map, data, sourceId]);\n\n // Update layer styles when props change\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n const prev = stylePropsRef.current;\n const colorsChanged =\n prev.clusterColors !== clusterColors ||\n prev.clusterThresholds !== clusterThresholds;\n\n // Update cluster layer colors and sizes\n if (map.getLayer(clusterLayerId) && colorsChanged) {\n map.setPaintProperty(clusterLayerId, \"circle-color\", [\n \"step\",\n [\"get\", \"point_count\"],\n clusterColors[0],\n clusterThresholds[0],\n clusterColors[1],\n clusterThresholds[1],\n clusterColors[2],\n ]);\n map.setPaintProperty(clusterLayerId, \"circle-radius\", [\n \"step\",\n [\"get\", \"point_count\"],\n 20,\n clusterThresholds[0],\n 30,\n clusterThresholds[1],\n 40,\n ]);\n }\n\n // Update unclustered point layer color\n if (map.getLayer(unclusteredLayerId) && prev.pointColor !== pointColor) {\n map.setPaintProperty(unclusteredLayerId, \"circle-color\", pointColor);\n }\n\n stylePropsRef.current = { clusterColors, clusterThresholds, pointColor };\n }, [\n isLoaded,\n map,\n clusterLayerId,\n unclusteredLayerId,\n clusterColors,\n clusterThresholds,\n pointColor,\n ]);\n\n // Handle click events\n useEffect(() => {\n if (!isLoaded || !map) return;\n\n // Cluster click handler - zoom into cluster\n const handleClusterClick = async (\n e: MapLibreGL.MapMouseEvent & {\n features?: MapLibreGL.MapGeoJSONFeature[];\n }\n ) => {\n const features = map.queryRenderedFeatures(e.point, {\n layers: [clusterLayerId],\n });\n if (!features.length) return;\n\n const feature = features[0];\n const clusterId = feature.properties?.cluster_id as number;\n const pointCount = feature.properties?.point_count as number;\n const coordinates = (feature.geometry as GeoJSON.Point).coordinates as [\n number,\n number\n ];\n\n if (onClusterClick) {\n onClusterClick(clusterId, coordinates, pointCount);\n } else {\n // Default behavior: zoom to cluster expansion zoom\n const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource;\n const zoom = await source.getClusterExpansionZoom(clusterId);\n map.easeTo({\n center: coordinates,\n zoom,\n });\n }\n };\n\n // Unclustered point click handler\n const handlePointClick = (\n e: MapLibreGL.MapMouseEvent & {\n features?: MapLibreGL.MapGeoJSONFeature[];\n }\n ) => {\n if (!onPointClick || !e.features?.length) return;\n\n const feature = e.features[0];\n const coordinates = (\n feature.geometry as GeoJSON.Point\n ).coordinates.slice() as [number, number];\n\n // Handle world copies\n while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {\n coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;\n }\n\n onPointClick(\n feature as unknown as GeoJSON.Feature,\n coordinates\n );\n };\n\n // Cursor style handlers\n const handleMouseEnterCluster = () => {\n map.getCanvas().style.cursor = \"pointer\";\n };\n const handleMouseLeaveCluster = () => {\n map.getCanvas().style.cursor = \"\";\n };\n const handleMouseEnterPoint = () => {\n if (onPointClick) {\n map.getCanvas().style.cursor = \"pointer\";\n }\n };\n const handleMouseLeavePoint = () => {\n map.getCanvas().style.cursor = \"\";\n };\n\n map.on(\"click\", clusterLayerId, handleClusterClick);\n map.on(\"click\", unclusteredLayerId, handlePointClick);\n map.on(\"mouseenter\", clusterLayerId, handleMouseEnterCluster);\n map.on(\"mouseleave\", clusterLayerId, handleMouseLeaveCluster);\n map.on(\"mouseenter\", unclusteredLayerId, handleMouseEnterPoint);\n map.on(\"mouseleave\", unclusteredLayerId, handleMouseLeavePoint);\n\n return () => {\n map.off(\"click\", clusterLayerId, handleClusterClick);\n map.off(\"click\", unclusteredLayerId, handlePointClick);\n map.off(\"mouseenter\", clusterLayerId, handleMouseEnterCluster);\n map.off(\"mouseleave\", clusterLayerId, handleMouseLeaveCluster);\n map.off(\"mouseenter\", unclusteredLayerId, handleMouseEnterPoint);\n map.off(\"mouseleave\", unclusteredLayerId, handleMouseLeavePoint);\n };\n }, [\n isLoaded,\n map,\n clusterLayerId,\n unclusteredLayerId,\n sourceId,\n onClusterClick,\n onPointClick,\n ]);\n\n return null;\n}\n\nexport {\n Map,\n useMap,\n MapMarker,\n MarkerContent,\n MarkerPopup,\n MarkerTooltip,\n MarkerLabel,\n MapPopup,\n MapControls,\n MapRoute,\n MapClusterLayer,\n};\n\nexport type { MapRef, MapViewport };\n", "type": "registry:ui", "target": "components/ui/map.tsx" } diff --git a/public/r/registry.json b/public/r/registry.json index 7579221..af7d423 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -27,6 +27,114 @@ } } } + }, + { + "name": "analytics-map", + "type": "registry:block", + "title": "Analytics Map", + "description": "Real-time analytics overview with a world map, breakdown cards, and device stats.", + "dependencies": ["recharts", "lucide-react"], + "registryDependencies": ["map", "card", "chart"], + "files": [ + { + "path": "src/registry/blocks/analytics-map/page.tsx", + "type": "registry:page", + "target": "app/analytics/page.tsx" + }, + { + "path": "src/registry/blocks/analytics-map/data.ts", + "type": "registry:component", + "target": "app/analytics/data.ts" + }, + { + "path": "src/registry/blocks/analytics-map/components/overview-card.tsx", + "type": "registry:component", + "target": "app/analytics/components/overview-card.tsx" + }, + { + "path": "src/registry/blocks/analytics-map/components/breakdown-card.tsx", + "type": "registry:component", + "target": "app/analytics/components/breakdown-card.tsx" + } + ], + "categories": ["analytics", "dashboard"], + "meta": { + "iframeHeight": "970px" + } + }, + { + "name": "logistics-network", + "type": "registry:block", + "title": "Logistics Network", + "description": "Domestic logistics map with a sidebar of stats.", + "dependencies": ["lucide-react"], + "registryDependencies": ["map", "card", "badge", "button"], + "files": [ + { + "path": "src/registry/blocks/logistics-network/page.tsx", + "type": "registry:page", + "target": "app/logistics/page.tsx" + }, + { + "path": "src/registry/blocks/logistics-network/data.ts", + "type": "registry:component", + "target": "app/logistics/data.ts" + }, + { + "path": "src/registry/blocks/logistics-network/components/map-arcs.tsx", + "type": "registry:component", + "target": "app/logistics/components/map-arcs.tsx" + }, + { + "path": "src/registry/blocks/logistics-network/components/filter-sidebar.tsx", + "type": "registry:component", + "target": "app/logistics/components/filter-sidebar.tsx" + }, + { + "path": "src/registry/blocks/logistics-network/components/network-map.tsx", + "type": "registry:component", + "target": "app/logistics/components/network-map.tsx" + } + ], + "categories": ["logistics", "network"], + "meta": { + "iframeHeight": "800px" + } + }, + { + "name": "heatmap", + "type": "registry:block", + "title": "Heatmap", + "description": "Globe-projected heatmap visualizing earthquake density with zoom-dependent styling.", + "dependencies": [], + "registryDependencies": ["map", "card"], + "files": [ + { + "path": "src/registry/blocks/heatmap/page.tsx", + "type": "registry:page", + "target": "app/heatmap/page.tsx" + } + ], + "categories": ["visualization", "heatmap"] + }, + { + "name": "delivery-tracker", + "type": "registry:block", + "title": "Delivery Tracker", + "description": "Live order tracking with route progress, courier position, and order details.", + "dependencies": ["lucide-react"], + "registryDependencies": ["map", "card", "badge", "button"], + "files": [ + { + "path": "src/registry/blocks/delivery-tracker/page.tsx", + "type": "registry:page", + "target": "app/delivery/page.tsx" + } + ], + "categories": ["tracking", "delivery"], + "meta": { + "iframeHeight": "680px" + } } ] } diff --git a/registry.json b/registry.json index 7579221..af7d423 100644 --- a/registry.json +++ b/registry.json @@ -27,6 +27,114 @@ } } } + }, + { + "name": "analytics-map", + "type": "registry:block", + "title": "Analytics Map", + "description": "Real-time analytics overview with a world map, breakdown cards, and device stats.", + "dependencies": ["recharts", "lucide-react"], + "registryDependencies": ["map", "card", "chart"], + "files": [ + { + "path": "src/registry/blocks/analytics-map/page.tsx", + "type": "registry:page", + "target": "app/analytics/page.tsx" + }, + { + "path": "src/registry/blocks/analytics-map/data.ts", + "type": "registry:component", + "target": "app/analytics/data.ts" + }, + { + "path": "src/registry/blocks/analytics-map/components/overview-card.tsx", + "type": "registry:component", + "target": "app/analytics/components/overview-card.tsx" + }, + { + "path": "src/registry/blocks/analytics-map/components/breakdown-card.tsx", + "type": "registry:component", + "target": "app/analytics/components/breakdown-card.tsx" + } + ], + "categories": ["analytics", "dashboard"], + "meta": { + "iframeHeight": "970px" + } + }, + { + "name": "logistics-network", + "type": "registry:block", + "title": "Logistics Network", + "description": "Domestic logistics map with a sidebar of stats.", + "dependencies": ["lucide-react"], + "registryDependencies": ["map", "card", "badge", "button"], + "files": [ + { + "path": "src/registry/blocks/logistics-network/page.tsx", + "type": "registry:page", + "target": "app/logistics/page.tsx" + }, + { + "path": "src/registry/blocks/logistics-network/data.ts", + "type": "registry:component", + "target": "app/logistics/data.ts" + }, + { + "path": "src/registry/blocks/logistics-network/components/map-arcs.tsx", + "type": "registry:component", + "target": "app/logistics/components/map-arcs.tsx" + }, + { + "path": "src/registry/blocks/logistics-network/components/filter-sidebar.tsx", + "type": "registry:component", + "target": "app/logistics/components/filter-sidebar.tsx" + }, + { + "path": "src/registry/blocks/logistics-network/components/network-map.tsx", + "type": "registry:component", + "target": "app/logistics/components/network-map.tsx" + } + ], + "categories": ["logistics", "network"], + "meta": { + "iframeHeight": "800px" + } + }, + { + "name": "heatmap", + "type": "registry:block", + "title": "Heatmap", + "description": "Globe-projected heatmap visualizing earthquake density with zoom-dependent styling.", + "dependencies": [], + "registryDependencies": ["map", "card"], + "files": [ + { + "path": "src/registry/blocks/heatmap/page.tsx", + "type": "registry:page", + "target": "app/heatmap/page.tsx" + } + ], + "categories": ["visualization", "heatmap"] + }, + { + "name": "delivery-tracker", + "type": "registry:block", + "title": "Delivery Tracker", + "description": "Live order tracking with route progress, courier position, and order details.", + "dependencies": ["lucide-react"], + "registryDependencies": ["map", "card", "badge", "button"], + "files": [ + { + "path": "src/registry/blocks/delivery-tracker/page.tsx", + "type": "registry:page", + "target": "app/delivery/page.tsx" + } + ], + "categories": ["tracking", "delivery"], + "meta": { + "iframeHeight": "680px" + } } ] } diff --git a/src/app/(home)/_components/examples/ev-charging-example.tsx b/src/app/(home)/_components/examples/ev-charging-example.tsx deleted file mode 100644 index 528498e..0000000 --- a/src/app/(home)/_components/examples/ev-charging-example.tsx +++ /dev/null @@ -1,88 +0,0 @@ -"use client"; - -import { Map, MapMarker, MarkerContent, MarkerTooltip } from "@/registry/map"; -import { Zap } from "lucide-react"; -import { ExampleCard } from "./example-card"; - -export function EVChargingExample() { - return ( - - - - -

- -
- - -
-
Market St Station
-
- - Available -
-
150 kW • $0.35/kWh
-
-
- - - - -
- -
-
- -
-
Union Square
-
- - 2 Available -
-
50 kW • $0.28/kWh
-
-
-
- - - -
- -
-
- -
-
Castro Station
-
- - In Use -
-
~15 min remaining
-
-
-
- - - -
- -
-
- -
-
Hayes Valley
-
- - Offline -
-
-
-
- - - ); -} diff --git a/src/app/(home)/_components/footer.tsx b/src/app/(home)/_components/footer.tsx deleted file mode 100644 index 39530ee..0000000 --- a/src/app/(home)/_components/footer.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Button } from "@/components/ui/button"; -import Link from "next/link"; - -export function Footer() { - return ( -
-
-
- mapcn - • - - Built by - - -
-
- - - -
-
-
- ); -} diff --git a/src/app/(home)/_components/hero.tsx b/src/app/(home)/_components/hero.tsx deleted file mode 100644 index 1b44627..0000000 --- a/src/app/(home)/_components/hero.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { Copy, Check, ArrowRight } from "lucide-react"; -import Link from "next/link"; -import { useState } from "react"; - -const installCommand = "npx shadcn@latest add @mapcn/map"; - -function CopyButton({ text }: { text: string }) { - const [copied, setCopied] = useState(false); - - const copy = () => { - navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - return ( - - ); -} - -export function Hero() { - return ( -
-
-
-
-
- -
-

- - Beautiful maps, made simple. - -

- -

- Ready to use, customizable map components for React. -
- Built on MapLibre. Styled with Tailwind. -

- -
-
-
- - - -
- -
- $ - - {installCommand} - - -
-
-
- -
- - -
-
-
- ); -} diff --git a/src/app/(home)/page.tsx b/src/app/(home)/page.tsx deleted file mode 100644 index 18f28c6..0000000 --- a/src/app/(home)/page.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Hero } from "./_components/hero"; -import { ExamplesGrid } from "./_components/examples-grid"; -import { Footer } from "./_components/footer"; -import { Header } from "./_components/header"; - -export default function Page() { - return ( -
-
- -
-
- -
- -
- -
-
- -
-
- ); -} diff --git a/src/app/(home)/_components/examples-grid.tsx b/src/app/(main)/(home)/_components/examples-grid.tsx similarity index 80% rename from src/app/(home)/_components/examples-grid.tsx rename to src/app/(main)/(home)/_components/examples-grid.tsx index e9939ef..f4f8f46 100644 --- a/src/app/(home)/_components/examples-grid.tsx +++ b/src/app/(main)/(home)/_components/examples-grid.tsx @@ -9,7 +9,7 @@ import { FlyToExample } from "./examples/flyto-example"; export function ExamplesGrid() { return ( -
+
diff --git a/src/app/(home)/_components/examples/analytics-example.tsx b/src/app/(main)/(home)/_components/examples/analytics-example.tsx similarity index 100% rename from src/app/(home)/_components/examples/analytics-example.tsx rename to src/app/(main)/(home)/_components/examples/analytics-example.tsx diff --git a/src/app/(home)/_components/examples/delivery-example.tsx b/src/app/(main)/(home)/_components/examples/delivery-example.tsx similarity index 96% rename from src/app/(home)/_components/examples/delivery-example.tsx rename to src/app/(main)/(home)/_components/examples/delivery-example.tsx index bff24c4..0056752 100644 --- a/src/app/(home)/_components/examples/delivery-example.tsx +++ b/src/app/(main)/(home)/_components/examples/delivery-example.tsx @@ -56,7 +56,7 @@ export function DeliveryExample() { )} -
+
Store diff --git a/src/app/(main)/(home)/_components/examples/ev-charging-example.tsx b/src/app/(main)/(home)/_components/examples/ev-charging-example.tsx new file mode 100644 index 0000000..c614647 --- /dev/null +++ b/src/app/(main)/(home)/_components/examples/ev-charging-example.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { Map, MapMarker, MarkerContent, MarkerTooltip } from "@/registry/map"; +import { Zap } from "lucide-react"; +import { ExampleCard } from "./example-card"; + +type Status = "available" | "in-use" | "offline"; + +interface ChargingStation { + name: string; + lng: number; + lat: number; + status: Status; + detail: string; +} + +const stations: ChargingStation[] = [ + { + name: "Union Square", + lng: -122.4074, + lat: 37.7879, + status: "available", + detail: "50 kW • $0.28/kWh", + }, + { + name: "Castro Station", + lng: -122.435, + lat: 37.7625, + status: "in-use", + detail: "~15 min remaining", + }, + { + name: "Hayes Valley", + lng: -122.4264, + lat: 37.7759, + status: "offline", + detail: "", + }, + { + name: "Embarcadero", + lng: -122.3934, + lat: 37.7935, + status: "available", + detail: "350 kW • $0.40/kWh", + }, + { + name: "Marina District", + lng: -122.437, + lat: 37.801, + status: "available", + detail: "150 kW • $0.32/kWh", + }, + { + name: "SoMa Charger", + lng: -122.401, + lat: 37.778, + status: "available", + detail: "50 kW • $0.30/kWh", + }, + { + name: "Noe Valley", + lng: -122.431, + lat: 37.75, + status: "available", + detail: "150 kW • $0.33/kWh", + }, + { + name: "Richmond Charger", + lng: -122.478, + lat: 37.781, + status: "in-use", + detail: "~8 min remaining", + }, + { + name: "Potrero Hill", + lng: -122.401, + lat: 37.76, + status: "offline", + detail: "", + }, + { + name: "Mission Bay", + lng: -122.391, + lat: 37.77, + status: "available", + detail: "350 kW • $0.38/kWh", + }, + { + name: "Golden Gate Park", + lng: -122.466, + lat: 37.77, + status: "available", + detail: "150 kW • $0.34/kWh", + }, +]; + +const statusConfig: Record< + Status, + { bg: string; label: string; textClass: string } +> = { + available: { + bg: "bg-emerald-500", + label: "Available", + textClass: "text-emerald-500", + }, + "in-use": { + bg: "bg-amber-500", + label: "In Use", + textClass: "text-amber-500", + }, + offline: { + bg: "bg-zinc-400", + label: "Offline", + textClass: "text-muted-foreground", + }, +}; + +export function EVChargingExample() { + return ( + + + {stations.map((station) => { + const config = statusConfig[station.status]; + return ( + + +
+ +
+
+ +
+
{station.name}
+
+ + {config.label} +
+ {station.detail && ( +
+ {station.detail} +
+ )} +
+
+
+ ); + })} +
+
+ ); +} diff --git a/src/app/(home)/_components/examples/example-card.tsx b/src/app/(main)/(home)/_components/examples/example-card.tsx similarity index 60% rename from src/app/(home)/_components/examples/example-card.tsx rename to src/app/(main)/(home)/_components/examples/example-card.tsx index 1c95dd7..7fc3481 100644 --- a/src/app/(home)/_components/examples/example-card.tsx +++ b/src/app/(main)/(home)/_components/examples/example-card.tsx @@ -18,13 +18,13 @@ export function ExampleCard({ return (
{label && ( -
+
{label}
)} diff --git a/src/app/(home)/_components/examples/flyto-example.tsx b/src/app/(main)/(home)/_components/examples/flyto-example.tsx similarity index 100% rename from src/app/(home)/_components/examples/flyto-example.tsx rename to src/app/(main)/(home)/_components/examples/flyto-example.tsx diff --git a/src/app/(home)/_components/examples/index.tsx b/src/app/(main)/(home)/_components/examples/index.tsx similarity index 100% rename from src/app/(home)/_components/examples/index.tsx rename to src/app/(main)/(home)/_components/examples/index.tsx diff --git a/src/app/(home)/_components/examples/trail-example.tsx b/src/app/(main)/(home)/_components/examples/trail-example.tsx similarity index 100% rename from src/app/(home)/_components/examples/trail-example.tsx rename to src/app/(main)/(home)/_components/examples/trail-example.tsx diff --git a/src/app/(home)/_components/examples/trending-example.tsx b/src/app/(main)/(home)/_components/examples/trending-example.tsx similarity index 100% rename from src/app/(home)/_components/examples/trending-example.tsx rename to src/app/(main)/(home)/_components/examples/trending-example.tsx diff --git a/src/app/(main)/(home)/page.tsx b/src/app/(main)/(home)/page.tsx new file mode 100644 index 0000000..fbb7e94 --- /dev/null +++ b/src/app/(main)/(home)/page.tsx @@ -0,0 +1,40 @@ +import { ExamplesGrid } from "./_components/examples-grid"; +import { Footer } from "@/components/footer"; +import { + PageHeader, + PageHeaderHeading, + PageHeaderDescription, + PageActions, +} from "@/components/page-header"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; + +export default function Page() { + return ( + <> + + Beautiful maps, made simple + + Ready to use, customizable map components for React. +
+ Built on MapLibre. Styled with Tailwind. +
+ + + + + +
+ +
+ +
+ +