feat: Add LandingPage and ProjectShowcase components with GSAP animations and responsive design
This commit is contained in:
parent
5de8228938
commit
d88874b550
5 changed files with 1164 additions and 298 deletions
184
package-lock.json
generated
184
package-lock.json
generated
|
|
@ -11,6 +11,7 @@
|
|||
"-": "^0.0.1",
|
||||
"@types/leaflet": "^1.9.18",
|
||||
"framer-motion": "^12.17.3",
|
||||
"gsap": "^3.13.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"ol": "^10.5.0",
|
||||
"ol-ext": "^4.0.31",
|
||||
|
|
@ -84,6 +85,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz",
|
||||
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
|
|
@ -694,10 +696,11 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.6.0.tgz",
|
||||
"integrity": "sha512-WhCn7Z7TauhBtmzhvKpoQs0Wwb/kBcy4CwpuI0/eEIr2Lx2auxmulAzLr91wVZJaz47iUZdkXOK7WlAfxGKCnA==",
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
|
||||
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eslint-visitor-keys": "^3.4.3"
|
||||
},
|
||||
|
|
@ -733,12 +736,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array": {
|
||||
"version": "0.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz",
|
||||
"integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==",
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
|
||||
"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/object-schema": "^2.1.6",
|
||||
"@eslint/object-schema": "^2.1.7",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^3.1.2"
|
||||
},
|
||||
|
|
@ -747,19 +751,24 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@eslint/config-helpers": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.1.tgz",
|
||||
"integrity": "sha512-RI17tsD2frtDu/3dmI7QRrD4bedNKPM08ziRYaC5AhkGrzIAJelm9kJU1TznK+apx6V+cqRz8tfpEeG3oIyjxw==",
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
|
||||
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^0.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/core": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz",
|
||||
"integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==",
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
|
||||
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
},
|
||||
|
|
@ -803,48 +812,42 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.24.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.24.0.tgz",
|
||||
"integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==",
|
||||
"version": "9.39.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
|
||||
"integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://eslint.org/donate"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/object-schema": {
|
||||
"version": "2.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
|
||||
"integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
|
||||
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit": {
|
||||
"version": "0.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz",
|
||||
"integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==",
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
|
||||
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/core": "^0.13.0",
|
||||
"@eslint/core": "^0.17.0",
|
||||
"levn": "^0.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz",
|
||||
"integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
|
|
@ -1308,7 +1311,8 @@
|
|||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||
"devOptional": true
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.18",
|
||||
|
|
@ -1356,6 +1360,7 @@
|
|||
"version": "19.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.1.tgz",
|
||||
"integrity": "sha512-ePapxDL7qrgqSF67s0h9m412d9DbXyC1n59O2st+9rjuuamWsZuD2w55rqY12CbzsZ7uVXb5Nw0gEp9Z8MMutQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
|
|
@ -1413,6 +1418,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.29.1.tgz",
|
||||
"integrity": "sha512-zczrHVEqEaTwh12gWBIJWj8nx+ayDcCJs06yoNMY0kwjMWDM6+kppljY+BxWI06d2Ja+h4+WdufDcwMnnMEWmg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.29.1",
|
||||
"@typescript-eslint/types": "8.29.1",
|
||||
|
|
@ -1512,10 +1518,11 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
|
|
@ -1607,10 +1614,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.14.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
|
||||
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -1688,10 +1697,11 @@
|
|||
"devOptional": true
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
|
|
@ -1728,6 +1738,7 @@
|
|||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001688",
|
||||
"electron-to-chromium": "^1.5.73",
|
||||
|
|
@ -1959,32 +1970,33 @@
|
|||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "9.24.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz",
|
||||
"integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==",
|
||||
"version": "9.39.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
"@eslint/config-array": "^0.20.0",
|
||||
"@eslint/config-helpers": "^0.2.0",
|
||||
"@eslint/core": "^0.12.0",
|
||||
"@eslint/config-array": "^0.21.1",
|
||||
"@eslint/config-helpers": "^0.4.2",
|
||||
"@eslint/core": "^0.17.0",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "9.24.0",
|
||||
"@eslint/plugin-kit": "^0.2.7",
|
||||
"@eslint/js": "9.39.1",
|
||||
"@eslint/plugin-kit": "^0.4.1",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
"@types/estree": "^1.0.6",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"ajv": "^6.12.4",
|
||||
"chalk": "^4.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
"debug": "^4.3.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"eslint-scope": "^8.3.0",
|
||||
"eslint-visitor-keys": "^4.2.0",
|
||||
"espree": "^10.3.0",
|
||||
"eslint-scope": "^8.4.0",
|
||||
"eslint-visitor-keys": "^4.2.1",
|
||||
"espree": "^10.4.0",
|
||||
"esquery": "^1.5.0",
|
||||
"esutils": "^2.0.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
|
|
@ -2040,10 +2052,11 @@
|
|||
}
|
||||
},
|
||||
"node_modules/eslint-scope": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz",
|
||||
"integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==",
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
|
||||
"integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"esrecurse": "^4.3.0",
|
||||
"estraverse": "^5.2.0"
|
||||
|
|
@ -2056,10 +2069,11 @@
|
|||
}
|
||||
},
|
||||
"node_modules/eslint-visitor-keys": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
|
||||
"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
|
|
@ -2068,14 +2082,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "10.3.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
|
||||
"integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==",
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
|
||||
"integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"acorn": "^8.14.0",
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-jsx": "^5.3.2",
|
||||
"eslint-visitor-keys": "^4.2.0"
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
|
@ -2101,6 +2116,7 @@
|
|||
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
|
||||
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"estraverse": "^5.2.0"
|
||||
},
|
||||
|
|
@ -2358,6 +2374,12 @@
|
|||
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/gsap": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.13.0.tgz",
|
||||
"integrity": "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==",
|
||||
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
|
|
@ -2443,10 +2465,11 @@
|
|||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
|
|
@ -2509,7 +2532,8 @@
|
|||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lerc": {
|
||||
"version": "3.0.0",
|
||||
|
|
@ -2698,6 +2722,7 @@
|
|||
"resolved": "https://registry.npmjs.org/ol/-/ol-10.5.0.tgz",
|
||||
"integrity": "sha512-nHFx8gkGmvYImsa7iKkwUnZidd5gn1XbMZd9GNOorvm9orjW9gQvT3Naw/MjIasVJ3cB9EJUdCGR2EFAulMHsQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/rbush": "4.0.0",
|
||||
"earcut": "^3.0.0",
|
||||
|
|
@ -2724,6 +2749,7 @@
|
|||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
|
||||
"devOptional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"deep-is": "^0.1.3",
|
||||
"fast-levenshtein": "^2.0.6",
|
||||
|
|
@ -2945,6 +2971,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -2967,6 +2994,7 @@
|
|||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
|
|
@ -3311,6 +3339,7 @@
|
|||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -3365,6 +3394,7 @@
|
|||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
|
||||
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
||||
"devOptional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -3446,10 +3476,11 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
||||
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
|
@ -3609,6 +3640,7 @@
|
|||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"-": "^0.0.1",
|
||||
"@types/leaflet": "^1.9.18",
|
||||
"framer-motion": "^12.17.3",
|
||||
"gsap": "^3.13.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"ol": "^10.5.0",
|
||||
"ol-ext": "^4.0.31",
|
||||
|
|
|
|||
278
src/components/LandingPage.tsx
Normal file
278
src/components/LandingPage.tsx
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import React, { useState, useRef, useLayoutEffect } from 'react';
|
||||
import gsap from 'gsap';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
// --- TYPES ---
|
||||
interface LandingPageProps {
|
||||
onSearchSuccess: (addressData: any, searchTerm: string) => void;
|
||||
}
|
||||
|
||||
const LandingPage: React.FC<LandingPageProps> = ({ onSearchSuccess }) => {
|
||||
const [term, setTerm] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// --- REFS ---
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const glowRef = useRef<HTMLDivElement>(null);
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// --- GSAP INTRO ANIMATION ---
|
||||
useLayoutEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
const tl = gsap.timeline({ defaults: { ease: 'expo.out' } });
|
||||
|
||||
// 1. Background Glow expands
|
||||
tl.fromTo(glowRef.current,
|
||||
{ scale: 0.5, opacity: 0 },
|
||||
{ scale: 1, opacity: 0.6, duration: 2, ease: 'power3.out' }
|
||||
)
|
||||
// 2. Title floats up
|
||||
.from(titleRef.current, {
|
||||
y: 40,
|
||||
opacity: 0,
|
||||
duration: 1.5,
|
||||
filter: 'blur(10px)', // Cinematic blur in
|
||||
}, '-=1.5')
|
||||
// 3. Search Bar expands width-wise
|
||||
.from(formRef.current, {
|
||||
scaleX: 0.8,
|
||||
y: 20,
|
||||
opacity: 0,
|
||||
duration: 1.2,
|
||||
}, '-=1.2');
|
||||
|
||||
}, containerRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
// --- INTERACTION ANIMATIONS ---
|
||||
// Animate button in/out based on text content
|
||||
useLayoutEffect(() => {
|
||||
if (term.length > 0) {
|
||||
gsap.to(buttonRef.current, { width: 'auto', opacity: 1, paddingLeft: 20, paddingRight: 20, duration: 0.4, ease: 'power2.out' });
|
||||
} else {
|
||||
gsap.to(buttonRef.current, { width: 0, opacity: 0, paddingLeft: 0, paddingRight: 0, duration: 0.3, ease: 'power2.in' });
|
||||
}
|
||||
}, [term]);
|
||||
|
||||
// Animate Glow on Focus
|
||||
useLayoutEffect(() => {
|
||||
gsap.to(glowRef.current, {
|
||||
opacity: isFocused ? 0.8 : 0.4,
|
||||
scale: isFocused ? 1.1 : 1,
|
||||
duration: 0.5
|
||||
});
|
||||
}, [isFocused]);
|
||||
|
||||
|
||||
// --- LOGIC ---
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!term) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Cinematic Exit Animation
|
||||
const tl = gsap.timeline();
|
||||
tl.to(titleRef.current, { opacity: 0, y: -20, duration: 0.5 })
|
||||
.to(formRef.current, { scale: 0.9, opacity: 0, blur: 10, duration: 0.4 }, '-=0.3')
|
||||
.to(glowRef.current, { scale: 3, opacity: 0, duration: 0.8 }, '-=0.4');
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${term}&format=json&polygon=1&addressdetails=1`);
|
||||
const json = await response.json();
|
||||
|
||||
// Artificial delay for smooth transition feel
|
||||
setTimeout(() => {
|
||||
onSearchSuccess(json, term);
|
||||
navigate('/open');
|
||||
}, 800);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Search failed", error);
|
||||
setIsLoading(false);
|
||||
// Reset Animation if failed
|
||||
gsap.to([containerRef.current, titleRef.current, formRef.current], { opacity: 1, y: 0, scale: 1, blur: 0, duration: 0.5 });
|
||||
}
|
||||
};
|
||||
|
||||
// --- STYLES ---
|
||||
const styles = {
|
||||
wrapper: {
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
backgroundColor: '#030303', // Deepest black
|
||||
color: '#ffffff',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontFamily: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
// The Ambient Glow Behind
|
||||
glow: {
|
||||
position: 'absolute' as const,
|
||||
width: '600px',
|
||||
height: '600px',
|
||||
background: 'radial-gradient(circle, rgba(255,255,255,0.08) 0%, rgba(0,0,0,0) 70%)',
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none' as const,
|
||||
zIndex: 0,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
},
|
||||
contentZ: {
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
title: {
|
||||
fontSize: 'clamp(3rem, 6vw, 5rem)',
|
||||
fontWeight: 800,
|
||||
margin: '0 0 40px 0',
|
||||
textAlign: 'center' as const,
|
||||
letterSpacing: '-0.04em',
|
||||
// Gradient Text Effect
|
||||
background: 'linear-gradient(to bottom, #ffffff 0%, #888888 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
},
|
||||
form: {
|
||||
position: 'relative' as const,
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
padding: '0 24px',
|
||||
},
|
||||
glassContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
background: 'rgba(255, 255, 255, 0.03)', // Extremely subtle fill
|
||||
backdropFilter: 'blur(20px)', // Heavy blur
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: '24px', // Smooth pill shape
|
||||
padding: '8px',
|
||||
transition: 'border-color 0.3s ease, box-shadow 0.3s ease',
|
||||
boxShadow: isFocused
|
||||
? '0 0 40px rgba(255, 255, 255, 0.05), inset 0 0 0 1px rgba(255,255,255,0.1)'
|
||||
: '0 10px 40px rgba(0,0,0,0.5)',
|
||||
},
|
||||
searchIcon: {
|
||||
marginLeft: '16px',
|
||||
color: '#555',
|
||||
transition: 'color 0.3s',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: '#fff',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 400,
|
||||
padding: '16px',
|
||||
letterSpacing: '-0.02em',
|
||||
caretColor: '#fff', // White cursor
|
||||
},
|
||||
button: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#fff',
|
||||
color: '#000',
|
||||
border: 'none',
|
||||
borderRadius: '16px',
|
||||
height: '48px',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden', // Hide content when width is 0
|
||||
whiteSpace: 'nowrap' as const,
|
||||
},
|
||||
loader: {
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid rgba(0,0,0,0.1)',
|
||||
borderTop: '2px solid #000',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={styles.wrapper}>
|
||||
|
||||
{/* CSS Animation for loader */}
|
||||
<style>{`
|
||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||
::selection { background: rgba(255,255,255,0.2); color: white; }
|
||||
`}</style>
|
||||
|
||||
{/* Ambient Background Aura */}
|
||||
<div ref={glowRef} style={styles.glow} />
|
||||
|
||||
<div style={styles.contentZ}>
|
||||
{/* Title */}
|
||||
<h1 ref={titleRef} style={styles.title}>
|
||||
Explore the San Diego.
|
||||
</h1>
|
||||
|
||||
{/* Search Component */}
|
||||
<form ref={formRef} onSubmit={handleSearch} style={styles.form}>
|
||||
<div style={styles.glassContainer}>
|
||||
|
||||
{/* Search Icon */}
|
||||
<div style={{...styles.searchIcon, color: isFocused ? '#fff' : '#555'}}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Input Field */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder="Search address, city, or place..."
|
||||
style={styles.input}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Animated Button */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="submit"
|
||||
style={styles.button}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div style={styles.loader} />
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
|
|
@ -1,272 +1,349 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState, useLayoutEffect } from 'react';
|
||||
import { Map, View } from 'ol';
|
||||
import 'ol/ol.css';
|
||||
import TileLayer from 'ol/layer/Tile';
|
||||
import { OSM } from 'ol/source';
|
||||
import XYZ from 'ol/source/XYZ';
|
||||
import { fromLonLat } from 'ol/proj';
|
||||
import { Coordinate } from 'ol/coordinate';
|
||||
import VectorLayer from 'ol/layer/Vector';
|
||||
import { layerConfigs } from './Methods/Layers';
|
||||
import { loadAndRenderGeoJsonLayer } from './Methods/GeoJson_Load'
|
||||
import { loadAndRenderGeoJsonLayer } from './Methods/GeoJson_Load';
|
||||
import { addMarker } from './Methods/Marker';
|
||||
import gsap from 'gsap';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const OpenLayersMap = () => {
|
||||
const [term, setTerm] = useState<string>('');
|
||||
const [address, setAddress] = useState();
|
||||
const [layerVisibility, setLayerVisibility] = useState<Record<string, boolean>>({});
|
||||
const [searchCompleted, setSearchCompleted] = useState(false);
|
||||
|
||||
// Submit Button Logic
|
||||
const submitForm = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
// Preventing the page from reloading
|
||||
event.preventDefault();
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${term}&format=json&polygon=1&addressdetails=1`);
|
||||
const json = await response.json();
|
||||
console.log(json);
|
||||
setAddress(json);
|
||||
console.log(address);
|
||||
setSearchCompleted(true);
|
||||
|
||||
// Initialize all layers as visible
|
||||
const initialVisibility = Object.fromEntries(
|
||||
layerConfigs.map(layer => [layer.topic, true])
|
||||
);
|
||||
setLayerVisibility(initialVisibility);
|
||||
|
||||
// alert(term);
|
||||
// --- THEME CONFIGURATION ---
|
||||
const themes = {
|
||||
dark: {
|
||||
bg: '#050505',
|
||||
glass: 'rgba(20, 20, 20, 0.75)', // Slightly darker for better contrast
|
||||
border: 'rgba(255, 255, 255, 0.15)', // Stronger border
|
||||
textMain: '#ffffff',
|
||||
textMuted: '#AAAAAA',
|
||||
iconColor: '#ffffff',
|
||||
shadow: '0 4px 20px rgba(0,0,0,0.4)', // Strong shadow
|
||||
switchTrackActive: 'rgba(255,255,255,0.2)',
|
||||
switchTrackInactive: 'rgba(255,255,255,0.05)',
|
||||
switchKnobActive: '#fff',
|
||||
switchKnobInactive: '#666',
|
||||
mapUrl: 'https://{a-c}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
},
|
||||
light: {
|
||||
bg: '#F4F4F5',
|
||||
glass: 'rgba(255, 255, 255, 0.90)', // More opaque for visibility
|
||||
border: 'rgba(0, 0, 0, 0.1)',
|
||||
textMain: '#18181B',
|
||||
textMuted: '#52525B',
|
||||
iconColor: '#18181B',
|
||||
shadow: '0 4px 20px rgba(0,0,0,0.1)',
|
||||
switchTrackActive: 'rgba(0,0,0,0.2)',
|
||||
switchTrackInactive: 'rgba(0,0,0,0.05)',
|
||||
switchKnobActive: '#000',
|
||||
switchKnobInactive: '#999',
|
||||
mapUrl: 'https://{a-c}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||
}
|
||||
};
|
||||
|
||||
const mapDivRef = useRef<HTMLDivElement>(null);
|
||||
// --- SCROLLBAR CSS ---
|
||||
const scrollbarStyles = `
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-track { background: rgba(128, 128, 128, 0.1); border-radius: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(128, 128, 128, 0.3); border-radius: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: rgba(128, 128, 128, 0.5); }
|
||||
`;
|
||||
|
||||
// --- HELPER: Extract Color ---
|
||||
const extractColorFromStyle = (style: any): string => {
|
||||
let color: any = '#888888';
|
||||
if (style.getStroke() && style.getStroke().getColor()) color = style.getStroke().getColor();
|
||||
else if (style.getFill() && style.getFill().getColor()) color = style.getFill().getColor();
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
'skin': '#FFCBA4', 'darkyellow': '#FCC200', 'darkblue': '#00008B',
|
||||
'darkgreen': '#006400', 'purple': '#800080', 'orange': '#FFA500',
|
||||
'green': '#00FF00', 'blue': '#0000FF', 'red': '#FF0000', 'black': '#888888',
|
||||
};
|
||||
return colorMap[color] || color;
|
||||
};
|
||||
|
||||
interface MapViewProps {
|
||||
initialAddressData: any;
|
||||
initialTerm: string;
|
||||
}
|
||||
|
||||
const MapView: React.FC<MapViewProps> = ({ initialAddressData, initialTerm }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// -- STATE --
|
||||
const [isDarkMode, setIsDarkMode] = useState(true);
|
||||
const currentTheme = isDarkMode ? themes.dark : themes.light;
|
||||
|
||||
const [layerVisibility, setLayerVisibility] = useState<Record<string, boolean>>(() =>
|
||||
Object.fromEntries(layerConfigs.map(layer => [layer.topic, true]))
|
||||
);
|
||||
const [clickedCoordinate, setClickedCoordinate] = useState<Coordinate>();
|
||||
|
||||
// -- REFS --
|
||||
const mapDivRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<Map | null>(null);
|
||||
const tileLayerRef = useRef<TileLayer<XYZ> | null>(null);
|
||||
const markerLayerRef = useRef<VectorLayer | null>(null);
|
||||
const layerRefs = useRef<Record<string, React.MutableRefObject<VectorLayer | null>>>(
|
||||
Object.fromEntries(
|
||||
layerConfigs.map((layer) => [layer.topic, { current: null }])
|
||||
)
|
||||
Object.fromEntries(layerConfigs.map((layer) => [layer.topic, { current: null }]))
|
||||
);
|
||||
const uiContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Toggle layer visibility
|
||||
const toggleLayer = (layerTopic: string) => {
|
||||
const layerRef = layerRefs.current[layerTopic];
|
||||
const isCurrentlyVisible = layerVisibility[layerTopic];
|
||||
|
||||
if (layerRef.current) {
|
||||
layerRef.current.setVisible(!isCurrentlyVisible);
|
||||
}
|
||||
|
||||
setLayerVisibility(prev => ({
|
||||
...prev,
|
||||
[layerTopic]: !isCurrentlyVisible
|
||||
}));
|
||||
};
|
||||
|
||||
// Format layer name for display
|
||||
const formatLayerName = (topic: string) => {
|
||||
return topic.replace(/_/g, ' ').replace(/SD$/, '').trim();
|
||||
};
|
||||
// -- GSAP ENTRANCE --
|
||||
useLayoutEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
// We use clearProps: 'all' to ensure GSAP doesn't leave the elements with weird opacity states
|
||||
gsap.from('.floating-ui', {
|
||||
y: 20,
|
||||
opacity: 0,
|
||||
duration: 0.8,
|
||||
stagger: 0.1,
|
||||
ease: 'power3.out',
|
||||
delay: 0.5,
|
||||
clearProps: 'opacity,transform' // Crucial: Ensures visibility after animation ends
|
||||
});
|
||||
}, uiContainerRef);
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
// -- MAP INIT --
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) {
|
||||
const baseLayer = new TileLayer({
|
||||
source: new XYZ({
|
||||
url: currentTheme.mapUrl,
|
||||
attributions: '© OpenStreetMap © CARTO'
|
||||
})
|
||||
});
|
||||
tileLayerRef.current = baseLayer;
|
||||
|
||||
const map = new Map({
|
||||
target: mapDivRef.current as HTMLDivElement,
|
||||
layers: [new TileLayer({ source: new OSM() })],
|
||||
layers: [ baseLayer ],
|
||||
view: new View({ center: fromLonLat([0, 0]), zoom: 2 }),
|
||||
controls: []
|
||||
});
|
||||
|
||||
mapRef.current = map;
|
||||
map.on('click', (e) => {
|
||||
setClickedCoordinate(e.coordinate);
|
||||
});
|
||||
map.on('click', (e) => setClickedCoordinate(e.coordinate));
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (address && address.length > 0) {
|
||||
const lat = parseFloat(address[0].lat);
|
||||
const lon = parseFloat(address[0].lon);
|
||||
// -- THEME SWITCHING --
|
||||
useEffect(() => {
|
||||
if (mapRef.current && tileLayerRef.current) {
|
||||
tileLayerRef.current.setSource(new XYZ({
|
||||
url: currentTheme.mapUrl,
|
||||
attributions: '© OpenStreetMap © CARTO'
|
||||
}));
|
||||
}
|
||||
}, [isDarkMode]);
|
||||
|
||||
// -- SEARCH DATA --
|
||||
useEffect(() => {
|
||||
if (initialAddressData && initialAddressData.length > 0) {
|
||||
const lat = parseFloat(initialAddressData[0].lat);
|
||||
const lon = parseFloat(initialAddressData[0].lon);
|
||||
const coords = fromLonLat([lon, lat]);
|
||||
mapRef.current!.setView(new View({
|
||||
center: coords,
|
||||
zoom: 15,
|
||||
}));
|
||||
|
||||
mapRef.current!.getView().animate({ center: coords, zoom: 14, duration: 2500, ease: (t) => Math.pow(t, 2) });
|
||||
markerLayerRef.current = addMarker(coords, mapRef.current!, markerLayerRef.current);
|
||||
|
||||
layerConfigs.forEach((layerConfig) => {
|
||||
if(layerRefs.current[layerConfig.topic].current) mapRef.current!.removeLayer(layerRefs.current[layerConfig.topic].current!);
|
||||
const url = layerConfig.getUrl(lon, lat, layerConfig.radius);
|
||||
const ref = layerRefs.current[layerConfig.topic];
|
||||
loadAndRenderGeoJsonLayer(
|
||||
url,
|
||||
mapRef.current!,
|
||||
ref,
|
||||
layerConfig.style
|
||||
);
|
||||
loadAndRenderGeoJsonLayer(url, mapRef.current!, ref, layerConfig.style);
|
||||
});
|
||||
}
|
||||
}, [address]);
|
||||
}, [initialAddressData]);
|
||||
|
||||
const toggleLayer = (layerTopic: string) => {
|
||||
const layerRef = layerRefs.current[layerTopic];
|
||||
const isVisible = !layerVisibility[layerTopic];
|
||||
if (layerRef.current) layerRef.current.setVisible(isVisible);
|
||||
setLayerVisibility(prev => ({ ...prev, [layerTopic]: isVisible }));
|
||||
};
|
||||
|
||||
const formatLayerName = (topic: string) => topic.replace(/_/g, ' ').replace(/SD$/, '').trim();
|
||||
|
||||
// -- STYLES --
|
||||
const styles = {
|
||||
wrapper: {
|
||||
position: 'relative' as const, width: '100vw', height: '100vh',
|
||||
background: currentTheme.bg, overflow: 'hidden',
|
||||
fontFamily: '"Inter", -apple-system, sans-serif',
|
||||
transition: 'background 0.5s ease',
|
||||
},
|
||||
uiLayer: {
|
||||
position: 'absolute' as const, top: 0, left: 0, width: '100%', height: '100%',
|
||||
pointerEvents: 'none' as const,
|
||||
zIndex: 1000, // FORCE TOP LAYER
|
||||
padding: '24px',
|
||||
display: 'flex', flexDirection: 'column' as const, justifyContent: 'space-between',
|
||||
},
|
||||
topBar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
pointerEvents: 'auto' as const,
|
||||
gap: '12px',
|
||||
width: '100%' // Ensure it spans width
|
||||
},
|
||||
glassPanel: {
|
||||
background: currentTheme.glass,
|
||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
border: `1px solid ${currentTheme.border}`,
|
||||
borderRadius: '20px', padding: '40px',
|
||||
boxShadow: currentTheme.shadow,
|
||||
transition: 'all 0.3s ease'
|
||||
},
|
||||
locationBadge: {
|
||||
display: 'flex', alignItems: 'center', padding: '12px 24px',
|
||||
borderRadius: '30px', background: currentTheme.glass,
|
||||
backdropFilter: 'blur(20px)', border: `1px solid ${currentTheme.border}`,
|
||||
boxShadow: currentTheme.shadow,
|
||||
transition: 'all 0.3s ease',
|
||||
flexShrink: 0 // Prevent collapsing
|
||||
},
|
||||
actionButton: {
|
||||
width: '48px', height: '48px', borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: currentTheme.glass,
|
||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
border: `1px solid ${currentTheme.border}`,
|
||||
color: currentTheme.iconColor, // Explicit high contrast color
|
||||
boxShadow: currentTheme.shadow, // Force visibility
|
||||
cursor: 'pointer', transition: 'all 0.2s',
|
||||
flexShrink: 0 // Prevent collapsing
|
||||
},
|
||||
controlsContainer: {
|
||||
marginTop: 'auto', maxWidth: '340px', maxHeight: '60vh',
|
||||
display: 'flex', flexDirection: 'column' as const, pointerEvents: 'auto' as const,
|
||||
background: currentTheme.glass, backdropFilter: 'blur(20px)',
|
||||
border: `1px solid ${currentTheme.border}`, borderRadius: '10px', padding: '20px',
|
||||
boxShadow: currentTheme.shadow, transition: 'all 0.3s ease'
|
||||
},
|
||||
controlsHeader: {
|
||||
flexShrink: 0, fontSize: '10px', fontWeight: 700, letterSpacing: '1px',
|
||||
color: currentTheme.textMuted, marginBottom: '16px', textTransform: 'uppercase' as const,
|
||||
borderBottom: `1px solid ${currentTheme.border}`, paddingBottom: '12px'
|
||||
},
|
||||
scrollableList: { overflowY: 'auto' as const, paddingRight: '8px' },
|
||||
layerRow: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px', cursor: 'pointer', padding: '4px 8px' },
|
||||
layerLabel: { display: 'flex', alignItems: 'center', gap: '12px' },
|
||||
switchTrack: { width: '32px', height: '18px', borderRadius: '10px', position: 'relative' as const, transition: 'background 0.3s ease' },
|
||||
switchKnob: { width: '14px', height: '14px', borderRadius: '50%', position: 'absolute' as const, top: '2px', transition: 'left 0.3s cubic-bezier(0.4, 0.0, 0.2, 1)', boxShadow: '0 2px 4px rgba(0,0,0,0.2)' },
|
||||
hud: {
|
||||
position: 'absolute' as const, bottom: '24px', right: '24px',
|
||||
fontFamily: '"JetBrains Mono", monospace', fontSize: '10px',
|
||||
color: currentTheme.textMuted, pointerEvents: 'auto' as const,
|
||||
padding: '8px 12px', background: currentTheme.glass,
|
||||
borderRadius: '8px', border: `1px solid ${currentTheme.border}`,
|
||||
boxShadow: currentTheme.shadow,
|
||||
transition: 'background 0.3s, color 0.3s'
|
||||
}
|
||||
};
|
||||
|
||||
// UI
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100vh', width: '100vw' }}>
|
||||
{/* Sidebar */}
|
||||
<div style={{
|
||||
width: '320px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRight: '1px solid #dee2e6',
|
||||
padding: '20px',
|
||||
overflowY: 'auto',
|
||||
boxShadow: '2px 0 5px rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: '0 0 20px 0',
|
||||
color: '#333',
|
||||
fontSize: '18px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
Map Layers
|
||||
</h3>
|
||||
|
||||
{/* Search Form */}
|
||||
<form onSubmit={submitForm} style={{ marginBottom: '20px' }}>
|
||||
<input
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
type="text"
|
||||
placeholder="Enter address to search"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '10px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
backgroundColor: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
<div style={styles.wrapper}>
|
||||
<style>{scrollbarStyles}</style>
|
||||
|
||||
{/* Layer Toggle List */}
|
||||
<div>
|
||||
<h4 style={{
|
||||
margin: '0 0 15px 0',
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px'
|
||||
}}>
|
||||
Available Layers ({layerConfigs.length})
|
||||
</h4>
|
||||
|
||||
{layerConfigs.map((layer) => (
|
||||
<div key={layer.topic} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 0',
|
||||
borderBottom: '1px solid #eee'
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
color: '#333',
|
||||
flex: 1,
|
||||
marginRight: '10px'
|
||||
}}>
|
||||
{formatLayerName(layer.topic)}
|
||||
</span>
|
||||
|
||||
{searchCompleted && (
|
||||
<label style={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
width: '44px',
|
||||
height: '24px'
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={layerVisibility[layer.topic] || false}
|
||||
onChange={() => toggleLayer(layer.topic)}
|
||||
style={{ opacity: 0, width: 0, height: 0 }}
|
||||
/>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
cursor: 'pointer',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: layerVisibility[layer.topic] ? '#007bff' : '#ccc',
|
||||
borderRadius: '24px',
|
||||
transition: '0.3s',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
content: '',
|
||||
height: '18px',
|
||||
width: '18px',
|
||||
left: layerVisibility[layer.topic] ? '23px' : '3px',
|
||||
bottom: '3px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '50%',
|
||||
transition: '0.3s',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)'
|
||||
}} />
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{!searchCompleted && (
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: '#999',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
Search first
|
||||
{/* 1. MAP LAYER */}
|
||||
<div
|
||||
ref={mapDivRef}
|
||||
style={{ width: '100%', height: '100%', transition: 'filter 0.5s' }}
|
||||
/>
|
||||
|
||||
{/* 2. UI LAYER */}
|
||||
<div ref={uiContainerRef} style={styles.uiLayer}>
|
||||
|
||||
{/* TOP BAR */}
|
||||
<div style={styles.topBar}>
|
||||
|
||||
{/* Back Button */}
|
||||
<button
|
||||
className="floating-ui"
|
||||
style={styles.actionButton}
|
||||
onClick={() => navigate('/site')}
|
||||
title="Go Back"
|
||||
onMouseEnter={(e) => e.currentTarget.style.borderColor = isDarkMode ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.borderColor = currentTheme.border}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
|
||||
{/* Location Badge */}
|
||||
<div className="floating-ui" style={styles.locationBadge}>
|
||||
<div style={{ width: '6px', height: '6px', background: '#00FF94', borderRadius: '50%', marginRight: '12px', boxShadow: '0 0 8px #00FF94' }} />
|
||||
<span style={{ fontSize: '14px', fontWeight: 600, letterSpacing: '0.5px', color: currentTheme.textMain, textTransform: 'uppercase' }}>
|
||||
{initialTerm || "Exploring View"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Theme Toggle Button */}
|
||||
<button
|
||||
className="floating-ui"
|
||||
style={styles.actionButton}
|
||||
onClick={() => setIsDarkMode(!isDarkMode)}
|
||||
title="Toggle Theme"
|
||||
onMouseEnter={(e) => e.currentTarget.style.borderColor = isDarkMode ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.borderColor = currentTheme.border}
|
||||
>
|
||||
{isDarkMode ? (
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
|
||||
) : (
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
{/* CONTROLS */}
|
||||
<div className="floating-ui" style={styles.controlsContainer}>
|
||||
<div style={styles.controlsHeader}>Overlay Controls</div>
|
||||
|
||||
<div className="custom-scrollbar" style={styles.scrollableList}>
|
||||
{layerConfigs.map((layer) => {
|
||||
const isActive = layerVisibility[layer.topic];
|
||||
const layerColor = extractColorFromStyle(layer.style);
|
||||
|
||||
{/* Map Container */}
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<div ref={mapDivRef} style={{ width: '100%', height: '100%' }} />
|
||||
|
||||
return (
|
||||
<div key={layer.topic} style={styles.layerRow} onClick={() => toggleLayer(layer.topic)}>
|
||||
<div style={styles.layerLabel}>
|
||||
<div style={{
|
||||
width: '8px', height: '8px', borderRadius: '50%',
|
||||
backgroundColor: layerColor,
|
||||
boxShadow: `0 0 8px ${layerColor}`,
|
||||
opacity: isActive ? 1 : 0.3, transition: 'all 0.3s'
|
||||
}} />
|
||||
<span style={{ fontSize: '13px', color: isActive ? currentTheme.textMain : currentTheme.textMuted, transition: 'color 0.3s' }}>
|
||||
{formatLayerName(layer.topic)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ ...styles.switchTrack, background: isActive ? currentTheme.switchTrackActive : currentTheme.switchTrackInactive }}>
|
||||
<div style={{ ...styles.switchKnob, left: isActive ? '16px' : '2px', background: isActive ? currentTheme.switchKnobActive : currentTheme.switchKnobInactive }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HUD */}
|
||||
{clickedCoordinate && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '20px',
|
||||
left: '20px',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
color: 'white',
|
||||
padding: '10px 15px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
Coordinates: {clickedCoordinate[0].toFixed(2)} / {clickedCoordinate[1].toFixed(2)}
|
||||
</div>
|
||||
<div className="floating-ui" style={styles.hud}>
|
||||
LOCATION_LOCK: {clickedCoordinate[1].toFixed(4)} N, {clickedCoordinate[0].toFixed(4)} E
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OpenLayersMap;
|
||||
export default MapView;
|
||||
478
src/components/Projectpage.tsx
Normal file
478
src/components/Projectpage.tsx
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
import React, { useLayoutEffect, useRef, useState, useEffect } from 'react';
|
||||
import gsap from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
// Register GSAP plugins
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const ProjectShowcase: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [themeMode, setThemeMode] = useState<'dark' | 'light'>('dark');
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
// Refs
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// const cursorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Toggle Theme
|
||||
const toggleTheme = () => {
|
||||
const newTheme = themeMode === 'dark' ? 'light' : 'dark';
|
||||
setThemeMode(newTheme);
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
};
|
||||
|
||||
// Scroll Listener for Navbar
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setIsScrolled(window.scrollY > 50);
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// --- GSAP ANIMATIONS ---
|
||||
useLayoutEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
const tl = gsap.timeline();
|
||||
|
||||
// 1. Initial Load Sequence
|
||||
tl.from('.hero-word', {
|
||||
y: 100,
|
||||
opacity: 0,
|
||||
duration: 1.2,
|
||||
stagger: 0.05,
|
||||
ease: 'power4.out'
|
||||
})
|
||||
.from('.hero-desc', {
|
||||
y: 20,
|
||||
opacity: 0,
|
||||
duration: 1,
|
||||
ease: 'power2.out'
|
||||
}, '-=0.8')
|
||||
.from('.nav-item', {
|
||||
y: -20,
|
||||
opacity: 0,
|
||||
stagger: 0.1,
|
||||
duration: 0.8,
|
||||
ease: 'power2.out'
|
||||
}, '-=1');
|
||||
|
||||
// 2. Floating 3D Elements
|
||||
gsap.to('.visual-card', {
|
||||
y: '15px',
|
||||
rotateX: 4,
|
||||
rotateY: 4,
|
||||
duration: 4,
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
ease: 'sine.inOut',
|
||||
stagger: { each: 0.5, from: "end" }
|
||||
});
|
||||
|
||||
// 3. Scroll Triggered Reveals
|
||||
const sections = gsap.utils.toArray('.reveal-section');
|
||||
sections.forEach((section: any) => {
|
||||
gsap.from(section.children, {
|
||||
scrollTrigger: {
|
||||
trigger: section,
|
||||
start: 'top 80%',
|
||||
toggleActions: 'play none none reverse'
|
||||
},
|
||||
y: 60,
|
||||
opacity: 0,
|
||||
duration: 1,
|
||||
stagger: 0.15,
|
||||
ease: 'expo.out'
|
||||
});
|
||||
});
|
||||
|
||||
}, containerRef);
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
const handleLaunch = () => {
|
||||
const tl = gsap.timeline();
|
||||
tl.to(containerRef.current, { scale: 0.98, opacity: 0, filter: 'blur(20px)', duration: 0.8, ease: 'power2.in' });
|
||||
setTimeout(() => navigate('/site'), 800);
|
||||
};
|
||||
|
||||
// --- STYLES (Using CSS Variables for Theme Support) ---
|
||||
const styles = {
|
||||
wrapper: {
|
||||
fontFamily: '"Space Grotesk", sans-serif', // Modern Tech Font
|
||||
minHeight: '100vh',
|
||||
overflowX: 'hidden' as const,
|
||||
position: 'relative' as const,
|
||||
},
|
||||
// NAVBAR
|
||||
nav: {
|
||||
position: 'fixed' as const,
|
||||
top: 0, left: 0, right: 0,
|
||||
height: '90px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 5%',
|
||||
zIndex: 100,
|
||||
transition: 'all 0.4s ease',
|
||||
backgroundColor: isScrolled ? 'var(--bg-color)' : 'transparent',
|
||||
backdropFilter: isScrolled ? 'blur(20px)' : 'none',
|
||||
borderBottom: isScrolled ? '1px solid var(--border-color)' : '1px solid transparent',
|
||||
},
|
||||
logo: {
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
color: 'var(--text-main)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
logoDot: {
|
||||
width: '8px', height: '8px', background: 'var(--accent)', borderRadius: '50%',
|
||||
boxShadow: '0 0 10px var(--accent)'
|
||||
},
|
||||
navLinks: {
|
||||
display: 'flex', gap: '40px', alignItems: 'center',
|
||||
'@media (max-width: 768px)': { display: 'none' }
|
||||
},
|
||||
link: {
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 500,
|
||||
color: 'var(--text-muted)',
|
||||
cursor: 'pointer',
|
||||
transition: 'color 0.3s',
|
||||
letterSpacing: '0.02em',
|
||||
},
|
||||
// HERO
|
||||
hero: {
|
||||
minHeight: '100vh',
|
||||
padding: '140px 5% 80px',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap' as const,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '60px',
|
||||
},
|
||||
heroText: { flex: '1 1 500px' },
|
||||
pill: {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '8px',
|
||||
padding: '8px 16px', borderRadius: '100px',
|
||||
background: 'rgba(0, 240, 255, 0.05)',
|
||||
border: '1px solid rgba(0, 240, 255, 0.2)',
|
||||
color: 'var(--accent)', fontSize: '0.75rem', fontWeight: 600,
|
||||
marginBottom: '32px', letterSpacing: '0.05em'
|
||||
},
|
||||
h1: {
|
||||
fontSize: 'clamp(3.5rem, 6vw, 6rem)',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.05,
|
||||
marginBottom: '32px',
|
||||
color: 'var(--text-main)',
|
||||
letterSpacing: '-0.03em',
|
||||
},
|
||||
desc: {
|
||||
fontSize: 'clamp(1.1rem, 1.5vw, 1.25rem)',
|
||||
color: 'var(--text-muted)',
|
||||
lineHeight: 1.6,
|
||||
maxWidth: '580px',
|
||||
marginBottom: '48px',
|
||||
fontFamily: '"Inter", sans-serif',
|
||||
fontWeight: 300,
|
||||
},
|
||||
btnPrimary: {
|
||||
padding: '18px 48px',
|
||||
background: 'var(--text-main)',
|
||||
color: 'var(--bg-color)',
|
||||
border: 'none',
|
||||
borderRadius: '2px', // Sharper corners for modern look
|
||||
fontWeight: 600,
|
||||
fontSize: '0.95rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
letterSpacing: '0.02em',
|
||||
},
|
||||
btnSecondary: {
|
||||
padding: '18px 48px',
|
||||
background: 'transparent',
|
||||
color: 'var(--text-main)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '2px',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.95rem',
|
||||
cursor: 'pointer',
|
||||
marginLeft: '20px',
|
||||
transition: 'background 0.2s',
|
||||
},
|
||||
// VISUAL
|
||||
visualContainer: {
|
||||
flex: '1 1 400px',
|
||||
height: '600px',
|
||||
position: 'relative' as const,
|
||||
perspective: '2000px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
},
|
||||
layerStack: {
|
||||
position: 'relative' as const,
|
||||
width: '340px', height: '440px',
|
||||
transformStyle: 'preserve-3d' as const,
|
||||
transform: 'rotateX(55deg) rotateZ(-35deg) rotateY(5deg)',
|
||||
},
|
||||
layerCard: (z: number, color: string) => ({
|
||||
position: 'absolute' as const,
|
||||
inset: 0,
|
||||
background: 'var(--card-bg)', // Glass var
|
||||
backdropFilter: 'blur(12px)',
|
||||
border: `1px solid ${color}`,
|
||||
transform: `translateZ(${z}px)`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: `0 0 40px -10px ${color}40`,
|
||||
borderRadius: '16px',
|
||||
color: 'var(--text-main)',
|
||||
fontSize: '0.9rem', fontWeight: 600, letterSpacing: '0.1em'
|
||||
}),
|
||||
|
||||
// SECTION GENERAL
|
||||
section: { padding: '160px 5%', borderTop: '1px solid var(--border-color)' },
|
||||
sectionLabel: {
|
||||
color: 'var(--accent)',
|
||||
fontSize: '0.8rem', fontWeight: 600,
|
||||
textTransform: 'uppercase' as const, letterSpacing: '0.2em',
|
||||
marginBottom: '24px', display: 'block'
|
||||
},
|
||||
h2: {
|
||||
fontSize: 'clamp(2.5rem, 5vw, 4rem)',
|
||||
fontWeight: 700, color: 'var(--text-main)',
|
||||
marginBottom: '24px', lineHeight: 1.1, letterSpacing: '-0.02em'
|
||||
},
|
||||
|
||||
// GRID
|
||||
grid: {
|
||||
display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
||||
gap: '40px', marginTop: '80px'
|
||||
},
|
||||
card: {
|
||||
padding: '48px',
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '4px',
|
||||
transition: 'border-color 0.3s',
|
||||
cursor: 'default'
|
||||
},
|
||||
icon: {
|
||||
width: '48px', height: '48px', marginBottom: '32px',
|
||||
color: 'var(--text-main)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(255,255,255,0.05)', borderRadius: '8px'
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: '1.5rem', fontWeight: 600, marginBottom: '16px',
|
||||
color: 'var(--text-main)', letterSpacing: '-0.02em'
|
||||
},
|
||||
cardText: {
|
||||
color: 'var(--text-muted)', lineHeight: 1.6, fontSize: '1rem',
|
||||
fontFamily: '"Inter", sans-serif'
|
||||
},
|
||||
|
||||
// FOOTER
|
||||
footer: {
|
||||
padding: '120px 5%',
|
||||
background: 'var(--bg-color)',
|
||||
borderTop: '1px solid var(--border-color)',
|
||||
textAlign: 'center' as const
|
||||
},
|
||||
themeBtn: {
|
||||
background: 'transparent', border: '1px solid var(--border-color)',
|
||||
color: 'var(--text-main)', width: '40px', height: '40px',
|
||||
borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', transition: 'all 0.3s'
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={styles.wrapper}>
|
||||
|
||||
{/* NAVBAR */}
|
||||
<nav style={styles.nav}>
|
||||
<div style={styles.logo} className="nav-item" onClick={() => window.scrollTo(0,0)}>
|
||||
<div style={styles.logoDot}/> MapLayer.
|
||||
</div>
|
||||
<div style={styles.navLinks} className="nav-item">
|
||||
{[].map((item) => (
|
||||
<span key={item} style={styles.link}
|
||||
onMouseEnter={(e) => e.currentTarget.style.color = 'var(--text-main)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.color = 'var(--text-muted)'}>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
<button style={styles.themeBtn} onClick={toggleTheme}>
|
||||
{themeMode === 'dark' ?
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
|
||||
:
|
||||
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* HERO SECTION */}
|
||||
<header style={styles.hero}>
|
||||
<div style={styles.heroText}>
|
||||
|
||||
|
||||
<h1 style={styles.h1}>
|
||||
<div style={{overflow:'hidden'}}><div className="hero-word">The Operating System</div></div>
|
||||
<div style={{overflow:'hidden'}}><div className="hero-word" style={{color: 'var(--text-muted)'}}>for Modern Cities.</div></div>
|
||||
</h1>
|
||||
|
||||
<p className="hero-desc" style={styles.desc}>
|
||||
MapLayer unifies fragmented urban data into a single, high-performance geospatial decision engine. Visualize zoning, transit, and environmental risks in real-time.
|
||||
</p>
|
||||
|
||||
<div className="hero-desc">
|
||||
<button
|
||||
style={styles.btnPrimary}
|
||||
onClick={handleLaunch}
|
||||
onMouseEnter={(e) => {
|
||||
gsap.to(e.currentTarget, { scale: 1.05, duration: 0.3 });
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
gsap.to(e.currentTarget, { scale: 1, duration: 0.3 });
|
||||
}}
|
||||
>
|
||||
Launch Platform
|
||||
</button>
|
||||
{/* <button style={styles.btnSecondary}>Documentation</button> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3D VISUALIZATION */}
|
||||
<div style={styles.visualContainer}>
|
||||
<div style={styles.layerStack}>
|
||||
<div className="visual-card" style={styles.layerCard(0, 'var(--border-color)')}>
|
||||
BASEMAP
|
||||
</div>
|
||||
<div className="visual-card" style={styles.layerCard(80, 'var(--accent)')}>
|
||||
INFRASTRUCTURE
|
||||
</div>
|
||||
<div className="visual-card" style={styles.layerCard(160, '#7000FF')}>
|
||||
ZONING DATA
|
||||
</div>
|
||||
<div className="visual-card" style={styles.layerCard(240, 'var(--text-main)')}>
|
||||
ANALYTICS
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* CHALLENGE SECTION */}
|
||||
<section className="reveal-section" style={styles.section}>
|
||||
<div style={{maxWidth: '800px'}}>
|
||||
<span style={styles.sectionLabel}>01 — The Challenge</span>
|
||||
<h2 style={styles.h2}>Data fragmentation <br/>is paralyzing progress.</h2>
|
||||
<p style={styles.desc}>
|
||||
Cities generate terabytes of spatial data daily. Yet, Planners and Emergency Responders operate in silos, using outdated PDF maps and disjointed portals.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={styles.grid}>
|
||||
{/* Card 1 */}
|
||||
<div style={styles.card}
|
||||
onMouseEnter={(e) => e.currentTarget.style.borderColor = 'var(--accent)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.borderColor = 'var(--border-color)'}>
|
||||
<div style={styles.icon}>
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24"><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||
</div>
|
||||
<h3 style={styles.cardTitle}>Emergency Latency</h3>
|
||||
<p style={styles.cardText}>First responders struggle to access integrated utility and hazard data during critical incidents, costing seconds that matter.</p>
|
||||
</div>
|
||||
|
||||
{/* Card 2 */}
|
||||
<div style={styles.card}
|
||||
onMouseEnter={(e) => e.currentTarget.style.borderColor = 'var(--accent)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.borderColor = 'var(--border-color)'}>
|
||||
<div style={styles.icon}>
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24"><path d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
|
||||
</div>
|
||||
<h3 style={styles.cardTitle}>Planning Blindspots</h3>
|
||||
<p style={styles.cardText}>Zoning, environmental, and transit layers are rarely visualized together, making holistic impact analysis impossible.</p>
|
||||
</div>
|
||||
|
||||
{/* Card 3 */}
|
||||
<div style={styles.card}
|
||||
onMouseEnter={(e) => e.currentTarget.style.borderColor = 'var(--accent)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.borderColor = 'var(--border-color)'}>
|
||||
<div style={styles.icon}>
|
||||
<svg width="24" height="24" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24"><path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
</div>
|
||||
<h3 style={styles.cardTitle}>Transparency Gaps</h3>
|
||||
<p style={styles.cardText}>Citizens lack a clear, real-time window into construction projects, road closures, and city development plans.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* SOLUTION SECTION */}
|
||||
<section className="reveal-section" style={styles.section}>
|
||||
<div style={{maxWidth: '800px'}}>
|
||||
<span style={styles.sectionLabel}>02 — The Solution</span>
|
||||
<h2 style={styles.h2}>A unified geospatial <br/>intelligence core.</h2>
|
||||
</div>
|
||||
|
||||
<div style={styles.grid}>
|
||||
{/* Feature 1 */}
|
||||
<div style={{paddingTop: '20px'}}>
|
||||
<h3 style={{...styles.cardTitle, fontSize: '2rem', color: 'var(--accent)'}}>Modular Layering</h3>
|
||||
<p style={styles.cardText}>
|
||||
MapLayer's engine allows users to stack infinite datasets—parcels, sewer lines, fire zones—and toggle them instantly with zero latency.
|
||||
</p>
|
||||
</div>
|
||||
{/* Feature 2 */}
|
||||
<div style={{paddingTop: '20px', borderLeft: '1px solid var(--border-color)', paddingLeft: '40px'}}>
|
||||
<h3 style={{...styles.cardTitle, fontSize: '2rem', color: '#7000FF'}}>Real-Time Sync</h3>
|
||||
<p style={styles.cardText}>
|
||||
Direct integration with San Diego's Open Data portal ensures that the map you see today reflects the reality of the city right now.
|
||||
</p>
|
||||
</div>
|
||||
{/* Feature 3 */}
|
||||
<div style={{paddingTop: '20px', borderLeft: '1px solid var(--border-color)', paddingLeft: '40px'}}>
|
||||
<h3 style={{...styles.cardTitle, fontSize: '2rem'}}>Interactive HUD</h3>
|
||||
<p style={styles.cardText}>
|
||||
A designer-grade UI that floats above the map, providing coordinate locking, layer analysis, and distinct color coding without clutter.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FOOTER */}
|
||||
<footer className="reveal-section" style={styles.footer}>
|
||||
<h2 style={{...styles.h2, fontSize: 'clamp(3rem, 6vw, 5rem)', marginBottom: '40px'}}>
|
||||
See the data. <br/> Shape the future.
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleLaunch}
|
||||
style={{
|
||||
padding: '24px 64px',
|
||||
fontSize: '1.1rem',
|
||||
background: 'var(--text-main)',
|
||||
color: 'var(--bg-color)',
|
||||
border: 'none',
|
||||
borderRadius: '2px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.05em'
|
||||
}}
|
||||
onMouseEnter={(e) => gsap.to(e.currentTarget, {scale: 1.05})}
|
||||
onMouseLeave={(e) => gsap.to(e.currentTarget, {scale: 1})}
|
||||
>
|
||||
LAUNCH PLATFORM
|
||||
</button>
|
||||
|
||||
<div style={{marginTop: '120px', display: 'flex', justifyContent: 'space-between', borderTop: '1px solid var(--border-color)', paddingTop: '40px'}}>
|
||||
<span style={{color: 'var(--text-muted)', fontSize: '0.9rem'}}>© 2024 MapLayer.</span>
|
||||
<span style={{color: 'var(--text-muted)', fontSize: '0.9rem'}}>San Diego Smart City Initiative</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectShowcase;
|
||||
Loading…
Reference in a new issue