diff --git a/.pnp.cjs b/.pnp.cjs index 546639503..01b40acf0 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -72,7 +72,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@types/sortablejs", "npm:1.13.0"],\ ["@types/string-similarity", "npm:4.0.0"],\ ["@types/supertest", "npm:2.0.12"],\ - ["@types/three", "npm:0.141.0"],\ + ["@types/three", "npm:0.149.0"],\ ["@types/uuid", "npm:8.3.4"],\ ["@types/vimeo", "npm:2.1.4"],\ ["@types/webpack", "npm:5.28.0"],\ @@ -146,9 +146,9 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["sqlite3", "virtual:f84dba857fa10247bbb4af28a825f1d91a188c858b2b2b1b4a2d3cfedf850e14647ec99dabfb1535752c99ec0ff164e2ec4fb4c32c822be66b30b3cfa4630990#npm:5.1.2"],\ ["string-similarity", "npm:4.0.4"],\ ["supertest", "npm:6.3.3"],\ - ["three", "npm:0.141.0"],\ - ["three-conic-polygon-geometry", "virtual:f84dba857fa10247bbb4af28a825f1d91a188c858b2b2b1b4a2d3cfedf850e14647ec99dabfb1535752c99ec0ff164e2ec4fb4c32c822be66b30b3cfa4630990#npm:1.5.1"],\ - ["troika-three-text", "virtual:f84dba857fa10247bbb4af28a825f1d91a188c858b2b2b1b4a2d3cfedf850e14647ec99dabfb1535752c99ec0ff164e2ec4fb4c32c822be66b30b3cfa4630990#npm:0.46.4"],\ + ["three", "npm:0.150.1"],\ + ["three-conic-polygon-geometry", "virtual:f84dba857fa10247bbb4af28a825f1d91a188c858b2b2b1b4a2d3cfedf850e14647ec99dabfb1535752c99ec0ff164e2ec4fb4c32c822be66b30b3cfa4630990#npm:1.6.1"],\ + ["troika-three-text", "virtual:f84dba857fa10247bbb4af28a825f1d91a188c858b2b2b1b4a2d3cfedf850e14647ec99dabfb1535752c99ec0ff164e2ec4fb4c32c822be66b30b3cfa4630990#npm:0.47.1"],\ ["typeorm", "virtual:f84dba857fa10247bbb4af28a825f1d91a188c858b2b2b1b4a2d3cfedf850e14647ec99dabfb1535752c99ec0ff164e2ec4fb4c32c822be66b30b3cfa4630990#npm:0.3.11"],\ ["typescript", "patch:typescript@npm%3A4.7.4#~builtin::version=4.7.4&hash=65a307"],\ ["uuid", "npm:8.3.2"],\ @@ -210,7 +210,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@types/sortablejs", "npm:1.13.0"],\ ["@types/string-similarity", "npm:4.0.0"],\ ["@types/supertest", "npm:2.0.12"],\ - ["@types/three", "npm:0.141.0"],\ + ["@types/three", "npm:0.149.0"],\ ["@types/uuid", "npm:8.3.4"],\ ["@types/vimeo", "npm:2.1.4"],\ ["@types/webpack", "npm:5.28.0"],\ @@ -284,9 +284,9 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["sqlite3", "virtual:f84dba857fa10247bbb4af28a825f1d91a188c858b2b2b1b4a2d3cfedf850e14647ec99dabfb1535752c99ec0ff164e2ec4fb4c32c822be66b30b3cfa4630990#npm:5.1.2"],\ ["string-similarity", "npm:4.0.4"],\ ["supertest", "npm:6.3.3"],\ - ["three", "npm:0.141.0"],\ - ["three-conic-polygon-geometry", "virtual:f84dba857fa10247bbb4af28a825f1d91a188c858b2b2b1b4a2d3cfedf850e14647ec99dabfb1535752c99ec0ff164e2ec4fb4c32c822be66b30b3cfa4630990#npm:1.5.1"],\ - ["troika-three-text", "virtual:f84dba857fa10247bbb4af28a825f1d91a188c858b2b2b1b4a2d3cfedf850e14647ec99dabfb1535752c99ec0ff164e2ec4fb4c32c822be66b30b3cfa4630990#npm:0.46.4"],\ + ["three", "npm:0.150.1"],\ + ["three-conic-polygon-geometry", "virtual:f84dba857fa10247bbb4af28a825f1d91a188c858b2b2b1b4a2d3cfedf850e14647ec99dabfb1535752c99ec0ff164e2ec4fb4c32c822be66b30b3cfa4630990#npm:1.6.1"],\ + ["troika-three-text", "virtual:f84dba857fa10247bbb4af28a825f1d91a188c858b2b2b1b4a2d3cfedf850e14647ec99dabfb1535752c99ec0ff164e2ec4fb4c32c822be66b30b3cfa4630990#npm:0.47.1"],\ ["typeorm", "virtual:f84dba857fa10247bbb4af28a825f1d91a188c858b2b2b1b4a2d3cfedf850e14647ec99dabfb1535752c99ec0ff164e2ec4fb4c32c822be66b30b3cfa4630990#npm:0.3.11"],\ ["typescript", "patch:typescript@npm%3A4.7.4#~builtin::version=4.7.4&hash=65a307"],\ ["uuid", "npm:8.3.2"],\ @@ -6236,10 +6236,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["@types/three", [\ - ["npm:0.141.0", {\ - "packageLocation": "./.yarn/cache/@types-three-npm-0.141.0-626804a0d0-b4d1fd19ee.zip/node_modules/@types/three/",\ + ["npm:0.149.0", {\ + "packageLocation": "./.yarn/cache/@types-three-npm-0.149.0-b06e5f80a4-5def82bd5d.zip/node_modules/@types/three/",\ "packageDependencies": [\ - ["@types/three", "npm:0.141.0"],\ + ["@types/three", "npm:0.149.0"],\ ["@types/webxr", "npm:0.4.0"]\ ],\ "linkType": "HARD"\ @@ -17844,35 +17844,35 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["three", [\ - ["npm:0.141.0", {\ - "packageLocation": "./.yarn/cache/three-npm-0.141.0-0e1b669157-d161350b51.zip/node_modules/three/",\ + ["npm:0.150.1", {\ + "packageLocation": "./.yarn/cache/three-npm-0.150.1-6829be68c6-b12a92a681.zip/node_modules/three/",\ "packageDependencies": [\ - ["three", "npm:0.141.0"]\ + ["three", "npm:0.150.1"]\ ],\ "linkType": "HARD"\ }]\ ]],\ ["three-conic-polygon-geometry", [\ - ["npm:1.5.1", {\ - "packageLocation": "./.yarn/cache/three-conic-polygon-geometry-npm-1.5.1-be7c460055-ddfa1f1db8.zip/node_modules/three-conic-polygon-geometry/",\ + ["npm:1.6.1", {\ + "packageLocation": "./.yarn/cache/three-conic-polygon-geometry-npm-1.6.1-665a86c980-e5bbb3b09f.zip/node_modules/three-conic-polygon-geometry/",\ "packageDependencies": [\ - ["three-conic-polygon-geometry", "npm:1.5.1"]\ + ["three-conic-polygon-geometry", "npm:1.6.1"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:f84dba857fa10247bbb4af28a825f1d91a188c858b2b2b1b4a2d3cfedf850e14647ec99dabfb1535752c99ec0ff164e2ec4fb4c32c822be66b30b3cfa4630990#npm:1.5.1", {\ - "packageLocation": "./.yarn/__virtual__/three-conic-polygon-geometry-virtual-ff13086991/0/cache/three-conic-polygon-geometry-npm-1.5.1-be7c460055-ddfa1f1db8.zip/node_modules/three-conic-polygon-geometry/",\ + ["virtual:f84dba857fa10247bbb4af28a825f1d91a188c858b2b2b1b4a2d3cfedf850e14647ec99dabfb1535752c99ec0ff164e2ec4fb4c32c822be66b30b3cfa4630990#npm:1.6.1", {\ + "packageLocation": "./.yarn/__virtual__/three-conic-polygon-geometry-virtual-dcfba5cb30/0/cache/three-conic-polygon-geometry-npm-1.6.1-665a86c980-e5bbb3b09f.zip/node_modules/three-conic-polygon-geometry/",\ "packageDependencies": [\ - ["three-conic-polygon-geometry", "virtual:f84dba857fa10247bbb4af28a825f1d91a188c858b2b2b1b4a2d3cfedf850e14647ec99dabfb1535752c99ec0ff164e2ec4fb4c32c822be66b30b3cfa4630990#npm:1.5.1"],\ + ["three-conic-polygon-geometry", "virtual:f84dba857fa10247bbb4af28a825f1d91a188c858b2b2b1b4a2d3cfedf850e14647ec99dabfb1535752c99ec0ff164e2ec4fb4c32c822be66b30b3cfa4630990#npm:1.6.1"],\ ["@turf/boolean-point-in-polygon", "npm:6.5.0"],\ - ["@types/three", "npm:0.141.0"],\ + ["@types/three", "npm:0.149.0"],\ ["d3-array", "npm:3.1.1"],\ ["d3-geo", "npm:3.0.1"],\ ["d3-geo-voronoi", "npm:2.0.1"],\ ["d3-scale", "npm:4.0.2"],\ ["delaunator", "npm:5.0.0"],\ ["earcut", "npm:2.2.3"],\ - ["three", "npm:0.141.0"]\ + ["three", "npm:0.150.1"]\ ],\ "packagePeers": [\ "@types/three",\ @@ -18053,22 +18053,22 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["troika-three-text", [\ - ["npm:0.46.4", {\ - "packageLocation": "./.yarn/cache/troika-three-text-npm-0.46.4-03b8d10a6e-d36ca41079.zip/node_modules/troika-three-text/",\ + ["npm:0.47.1", {\ + "packageLocation": "./.yarn/cache/troika-three-text-npm-0.47.1-caf8602ddd-854d74e1f5.zip/node_modules/troika-three-text/",\ "packageDependencies": [\ - ["troika-three-text", "npm:0.46.4"]\ + ["troika-three-text", "npm:0.47.1"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:f84dba857fa10247bbb4af28a825f1d91a188c858b2b2b1b4a2d3cfedf850e14647ec99dabfb1535752c99ec0ff164e2ec4fb4c32c822be66b30b3cfa4630990#npm:0.46.4", {\ - "packageLocation": "./.yarn/__virtual__/troika-three-text-virtual-5f7a3308a4/0/cache/troika-three-text-npm-0.46.4-03b8d10a6e-d36ca41079.zip/node_modules/troika-three-text/",\ + ["virtual:f84dba857fa10247bbb4af28a825f1d91a188c858b2b2b1b4a2d3cfedf850e14647ec99dabfb1535752c99ec0ff164e2ec4fb4c32c822be66b30b3cfa4630990#npm:0.47.1", {\ + "packageLocation": "./.yarn/__virtual__/troika-three-text-virtual-b6ca61ca1b/0/cache/troika-three-text-npm-0.47.1-caf8602ddd-854d74e1f5.zip/node_modules/troika-three-text/",\ "packageDependencies": [\ - ["troika-three-text", "virtual:f84dba857fa10247bbb4af28a825f1d91a188c858b2b2b1b4a2d3cfedf850e14647ec99dabfb1535752c99ec0ff164e2ec4fb4c32c822be66b30b3cfa4630990#npm:0.46.4"],\ - ["@types/three", "npm:0.141.0"],\ + ["troika-three-text", "virtual:f84dba857fa10247bbb4af28a825f1d91a188c858b2b2b1b4a2d3cfedf850e14647ec99dabfb1535752c99ec0ff164e2ec4fb4c32c822be66b30b3cfa4630990#npm:0.47.1"],\ + ["@types/three", "npm:0.149.0"],\ ["bidi-js", "npm:1.0.2"],\ - ["three", "npm:0.141.0"],\ - ["troika-three-utils", "virtual:5f7a3308a4104e3697405e8ccc1399b2acefbe5124a8890f098ab81790d1accf30f6587f3ccfe305c29d6708505651e0f0420c705c859be315715934777c7505#npm:0.46.0"],\ - ["troika-worker-utils", "npm:0.46.0"],\ + ["three", "npm:0.150.1"],\ + ["troika-three-utils", "virtual:b6ca61ca1bb4124c35e3f77fb1186237cb79a914b35f6f833b6aa8bd1d381fa6d427244f01cf33bda7773fbdd9509cd27dab10625952517bb2eb11a903b39235#npm:0.47.0"],\ + ["troika-worker-utils", "npm:0.47.0"],\ ["webgl-sdf-generator", "npm:1.1.1"]\ ],\ "packagePeers": [\ @@ -18079,19 +18079,19 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["troika-three-utils", [\ - ["npm:0.46.0", {\ - "packageLocation": "./.yarn/cache/troika-three-utils-npm-0.46.0-ba4d1171a7-75e926bf14.zip/node_modules/troika-three-utils/",\ + ["npm:0.47.0", {\ + "packageLocation": "./.yarn/cache/troika-three-utils-npm-0.47.0-4ef0580dcd-2541865d95.zip/node_modules/troika-three-utils/",\ "packageDependencies": [\ - ["troika-three-utils", "npm:0.46.0"]\ + ["troika-three-utils", "npm:0.47.0"]\ ],\ "linkType": "SOFT"\ }],\ - ["virtual:5f7a3308a4104e3697405e8ccc1399b2acefbe5124a8890f098ab81790d1accf30f6587f3ccfe305c29d6708505651e0f0420c705c859be315715934777c7505#npm:0.46.0", {\ - "packageLocation": "./.yarn/__virtual__/troika-three-utils-virtual-345a4b29e2/0/cache/troika-three-utils-npm-0.46.0-ba4d1171a7-75e926bf14.zip/node_modules/troika-three-utils/",\ + ["virtual:b6ca61ca1bb4124c35e3f77fb1186237cb79a914b35f6f833b6aa8bd1d381fa6d427244f01cf33bda7773fbdd9509cd27dab10625952517bb2eb11a903b39235#npm:0.47.0", {\ + "packageLocation": "./.yarn/__virtual__/troika-three-utils-virtual-3c5fa48fc0/0/cache/troika-three-utils-npm-0.47.0-4ef0580dcd-2541865d95.zip/node_modules/troika-three-utils/",\ "packageDependencies": [\ - ["troika-three-utils", "virtual:5f7a3308a4104e3697405e8ccc1399b2acefbe5124a8890f098ab81790d1accf30f6587f3ccfe305c29d6708505651e0f0420c705c859be315715934777c7505#npm:0.46.0"],\ - ["@types/three", "npm:0.141.0"],\ - ["three", "npm:0.141.0"]\ + ["troika-three-utils", "virtual:b6ca61ca1bb4124c35e3f77fb1186237cb79a914b35f6f833b6aa8bd1d381fa6d427244f01cf33bda7773fbdd9509cd27dab10625952517bb2eb11a903b39235#npm:0.47.0"],\ + ["@types/three", "npm:0.149.0"],\ + ["three", "npm:0.150.1"]\ ],\ "packagePeers": [\ "@types/three",\ @@ -18101,10 +18101,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["troika-worker-utils", [\ - ["npm:0.46.0", {\ - "packageLocation": "./.yarn/cache/troika-worker-utils-npm-0.46.0-cfca9a1fa5-5fe6c17e34.zip/node_modules/troika-worker-utils/",\ + ["npm:0.47.0", {\ + "packageLocation": "./.yarn/cache/troika-worker-utils-npm-0.47.0-ebec5eb76a-9fa14a58fe.zip/node_modules/troika-worker-utils/",\ "packageDependencies": [\ - ["troika-worker-utils", "npm:0.46.0"]\ + ["troika-worker-utils", "npm:0.47.0"]\ ],\ "linkType": "HARD"\ }]\ diff --git a/.yarn/cache/@types-three-npm-0.141.0-626804a0d0-b4d1fd19ee.zip b/.yarn/cache/@types-three-npm-0.141.0-626804a0d0-b4d1fd19ee.zip deleted file mode 100644 index fbcebf13d..000000000 Binary files a/.yarn/cache/@types-three-npm-0.141.0-626804a0d0-b4d1fd19ee.zip and /dev/null differ diff --git a/.yarn/cache/@types-three-npm-0.149.0-b06e5f80a4-5def82bd5d.zip b/.yarn/cache/@types-three-npm-0.149.0-b06e5f80a4-5def82bd5d.zip new file mode 100644 index 000000000..5216ee5e8 Binary files /dev/null and b/.yarn/cache/@types-three-npm-0.149.0-b06e5f80a4-5def82bd5d.zip differ diff --git a/.yarn/cache/fsevents-patch-2882183fbf-8.zip b/.yarn/cache/fsevents-patch-2882183fbf-8.zip new file mode 100644 index 000000000..c4511f19b Binary files /dev/null and b/.yarn/cache/fsevents-patch-2882183fbf-8.zip differ diff --git a/.yarn/cache/fsevents-patch-61ccaa93a2-8.zip b/.yarn/cache/fsevents-patch-61ccaa93a2-8.zip new file mode 100644 index 000000000..34871c571 Binary files /dev/null and b/.yarn/cache/fsevents-patch-61ccaa93a2-8.zip differ diff --git a/.yarn/cache/three-conic-polygon-geometry-npm-1.5.1-be7c460055-ddfa1f1db8.zip b/.yarn/cache/three-conic-polygon-geometry-npm-1.6.1-665a86c980-e5bbb3b09f.zip similarity index 57% rename from .yarn/cache/three-conic-polygon-geometry-npm-1.5.1-be7c460055-ddfa1f1db8.zip rename to .yarn/cache/three-conic-polygon-geometry-npm-1.6.1-665a86c980-e5bbb3b09f.zip index 6ea739bc3..492ef1bef 100644 Binary files a/.yarn/cache/three-conic-polygon-geometry-npm-1.5.1-be7c460055-ddfa1f1db8.zip and b/.yarn/cache/three-conic-polygon-geometry-npm-1.6.1-665a86c980-e5bbb3b09f.zip differ diff --git a/.yarn/cache/three-npm-0.141.0-0e1b669157-d161350b51.zip b/.yarn/cache/three-npm-0.150.1-6829be68c6-b12a92a681.zip similarity index 63% rename from .yarn/cache/three-npm-0.141.0-0e1b669157-d161350b51.zip rename to .yarn/cache/three-npm-0.150.1-6829be68c6-b12a92a681.zip index ea5e0ee11..5b24400a3 100644 Binary files a/.yarn/cache/three-npm-0.141.0-0e1b669157-d161350b51.zip and b/.yarn/cache/three-npm-0.150.1-6829be68c6-b12a92a681.zip differ diff --git a/.yarn/cache/troika-three-text-npm-0.46.4-03b8d10a6e-d36ca41079.zip b/.yarn/cache/troika-three-text-npm-0.46.4-03b8d10a6e-d36ca41079.zip deleted file mode 100644 index cd688ed10..000000000 Binary files a/.yarn/cache/troika-three-text-npm-0.46.4-03b8d10a6e-d36ca41079.zip and /dev/null differ diff --git a/.yarn/cache/troika-three-text-npm-0.47.1-caf8602ddd-854d74e1f5.zip b/.yarn/cache/troika-three-text-npm-0.47.1-caf8602ddd-854d74e1f5.zip new file mode 100644 index 000000000..90bec3b63 Binary files /dev/null and b/.yarn/cache/troika-three-text-npm-0.47.1-caf8602ddd-854d74e1f5.zip differ diff --git a/.yarn/cache/troika-three-utils-npm-0.46.0-ba4d1171a7-75e926bf14.zip b/.yarn/cache/troika-three-utils-npm-0.46.0-ba4d1171a7-75e926bf14.zip deleted file mode 100644 index ea3baf1ac..000000000 Binary files a/.yarn/cache/troika-three-utils-npm-0.46.0-ba4d1171a7-75e926bf14.zip and /dev/null differ diff --git a/.yarn/cache/troika-three-utils-npm-0.47.0-4ef0580dcd-2541865d95.zip b/.yarn/cache/troika-three-utils-npm-0.47.0-4ef0580dcd-2541865d95.zip new file mode 100644 index 000000000..a28ee0866 Binary files /dev/null and b/.yarn/cache/troika-three-utils-npm-0.47.0-4ef0580dcd-2541865d95.zip differ diff --git a/.yarn/cache/troika-worker-utils-npm-0.46.0-cfca9a1fa5-5fe6c17e34.zip b/.yarn/cache/troika-worker-utils-npm-0.46.0-cfca9a1fa5-5fe6c17e34.zip deleted file mode 100644 index 2d45a3c40..000000000 Binary files a/.yarn/cache/troika-worker-utils-npm-0.46.0-cfca9a1fa5-5fe6c17e34.zip and /dev/null differ diff --git a/.yarn/cache/troika-worker-utils-npm-0.47.0-ebec5eb76a-9fa14a58fe.zip b/.yarn/cache/troika-worker-utils-npm-0.47.0-ebec5eb76a-9fa14a58fe.zip new file mode 100644 index 000000000..9a9752552 Binary files /dev/null and b/.yarn/cache/troika-worker-utils-npm-0.47.0-ebec5eb76a-9fa14a58fe.zip differ diff --git a/package.json b/package.json index 3b43e6a45..52c82bc8e 100644 --- a/package.json +++ b/package.json @@ -87,8 +87,8 @@ "@types/sharp": "0.30.4", "@types/sortablejs": "1.13.0", "@types/string-similarity": "4.0.0", - "@types/supertest": "^2.0.12", - "@types/three": "0.141.0", + "@types/supertest": "2.0.12", + "@types/three": "0.149.0", "@types/uuid": "8.3.4", "@types/vimeo": "2.1.4", "@types/webpack": "5.28.0", @@ -174,9 +174,9 @@ "sharp": "^0.30.7", "sortablejs": "^1.15.0", "string-similarity": "^4.0.4", - "three": "0.141.0", - "three-conic-polygon-geometry": "^1.5.1", - "troika-three-text": "^0.46.4", + "three": "0.150.1", + "three-conic-polygon-geometry": "^1.6.1", + "troika-three-text": "^0.47.1", "typeorm": "^0.3.11", "uuid": "^8.3.2", "vimeo": "^2.1.1", diff --git a/public/static-images/pelico-avatar.jpg b/public/static-images/pelico-avatar.jpg new file mode 100644 index 000000000..e87721587 Binary files /dev/null and b/public/static-images/pelico-avatar.jpg differ diff --git a/public/static-images/pelico-globe.jpg b/public/static-images/pelico-globe.jpg new file mode 100644 index 000000000..073a8e1fa Binary files /dev/null and b/public/static-images/pelico-globe.jpg differ diff --git a/server/controllers/image.ts b/server/controllers/image.ts index ae268b6a7..8d9845685 100644 --- a/server/controllers/image.ts +++ b/server/controllers/image.ts @@ -15,7 +15,11 @@ const imageController = new Controller('/images'); // get image imageController.get({ path: '/:id/:filename', userType: UserType.TEACHER }, async (req: Request, res: Response, next: NextFunction) => { const key = `images/${req.params.id}/${req.params.filename}`; - streamFile(key, req, res, next); + try { + await streamFile(key, req, res, next); + } catch (err) { + next(); + } }); // post image diff --git a/src/components/WorldMap/Popover.tsx b/src/components/WorldMap/Popover.tsx new file mode 100644 index 000000000..74cdfe97d --- /dev/null +++ b/src/components/WorldMap/Popover.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; + +import Card from '@mui/material/Card'; + +import { UserPopover } from './UserPopover'; +import type { User } from 'types/user.type'; + +export type PopoverData = { + type: T; + data: T extends 'country' + ? { + country: string; + } + : T extends 'user' + ? User + : never; +}; +export type PopoverProps = { + x: number; + y: number; +} & PopoverData; + +export const isCountry = (props: PopoverData): props is PopoverData<'country'> => props.type === 'country'; +export const isUser = (props: PopoverData): props is PopoverData<'user'> => props.type === 'user'; + +export const Popover = ({ x, y, ...props }: PopoverProps) => { + return ( +
+
+ + {isCountry(props) && {props.data.country}} + {isUser(props) && } + +
+
+ ); +}; diff --git a/src/components/WorldMap/UserPopover.tsx b/src/components/WorldMap/UserPopover.tsx index f5d902bfb..ad2951d4f 100644 --- a/src/components/WorldMap/UserPopover.tsx +++ b/src/components/WorldMap/UserPopover.tsx @@ -5,6 +5,7 @@ import { Flag } from 'src/components/Flag'; import { UserContext } from 'src/contexts/userContext'; import { getUserDisplayName } from 'src/utils'; import type { User } from 'types/user.type'; +import { UserType } from 'types/user.type'; export const UserPopover = ({ user }: { user: User }) => { const { user: selfUser } = React.useContext(UserContext); @@ -15,12 +16,14 @@ export const UserPopover = ({ user }: { user: User }) => {

{getUserDisplayName(user, isSelf)}

-
-

- {[user.address, user.city, user.country.name].filter((d) => d && d.length > 0).join(', ')} -

- -
+ {user.type === UserType.TEACHER && ( +
+

+ {[user.address, user.city, user.country.name].filter((d) => d && d.length > 0).join(', ')} +

+ +
+ )}
); diff --git a/src/components/WorldMap/WorldMap.tsx b/src/components/WorldMap/WorldMap.tsx index ebf293a18..4d95ee2eb 100644 --- a/src/components/WorldMap/WorldMap.tsx +++ b/src/components/WorldMap/WorldMap.tsx @@ -2,40 +2,26 @@ import 'leaflet/dist/leaflet.css'; import 'maplibre-gl/dist/maplibre-gl.css'; import L from 'leaflet'; import {} from 'leaflet.fullscreen'; +import { useRouter } from 'next/router'; import * as React from 'react'; import ReactDOM from 'react-dom'; -import { - Scene, - PerspectiveCamera, - WebGLRenderer, - SphereBufferGeometry, - MeshBasicMaterial, - AmbientLight, - DirectionalLight, - TextureLoader, - BackSide, - Raycaster, - Mesh, -} from 'three'; -import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { useQuery } from 'react-query'; import AddIcon from '@mui/icons-material/Add'; +import CloseIcon from '@mui/icons-material/Close'; import RemoveIcon from '@mui/icons-material/Remove'; -import { Button, ButtonGroup, CircularProgress } from '@mui/material'; +import { Button, ButtonGroup, IconButton, Typography } from '@mui/material'; +import type { PopoverData } from './Popover'; +import { isUser, Popover } from './Popover'; import { UserPopover } from './UserPopover'; -import { getCapitals } from './data/capitals'; -import { getCountries } from './data/countries'; -import type { HoverablePin } from './data/pin'; -import { getPins } from './data/pin'; -import { useFullScreen } from './hooks/use-full-screen'; -import { useObjectHover } from './hooks/use-object-hover'; -import { cartesian2Polar, polar2Cartesian } from './lib/coords-utils'; -import { disposeNode } from './lib/dispose-node'; -import { getAtmosphereGlow } from './lib/get-atmosphere-glow'; -import { GLOBE_IMAGE_URL, BACKGROUND_IMAGE_URL, SKY_RADIUS, MAX_DISTANCE, MIN_DISTANCE, GLOBE_RADIUS } from './world-map.constants'; +import { useFullScreen } from './use-full-screen'; +import { World } from './world'; +import type { GeoJSONCityData } from './world/objects/capital'; +import type { GeoJSONCountriesData } from './world/objects/country'; +import { VillageContext } from 'src/contexts/villageContext'; import { useVillageUsers } from 'src/services/useVillageUsers'; -import { clamp, debounce, throttle } from 'src/utils'; +import { axiosRequest } from 'src/utils/axiosRequest'; import { UserType } from 'types/user.type'; const isWebGLAvailable = () => { @@ -47,289 +33,137 @@ const isWebGLAvailable = () => { } }; +const getCountriesAndCapitals = async () => { + const [countriesResponse, capitalResponse] = await Promise.all([ + axiosRequest({ + method: 'GET', + baseURL: '', + url: '/earth/countries.geo.json', + }), + axiosRequest({ + method: 'GET', + baseURL: '', + url: '/earth/capitals.geo.json', + }), + ]); + return { + countries: countriesResponse.error ? [] : (countriesResponse.data as GeoJSONCountriesData).features, + capitals: capitalResponse.error ? [] : (capitalResponse.data as GeoJSONCityData).features, + }; +}; + const WorldMap = () => { + const router = useRouter(); + const { village, selectedPhase, setSelectedPhase } = React.useContext(VillageContext); const { users } = useVillageUsers(); + const [useLeafletFallback] = React.useState(() => !isWebGLAvailable()); - const leafletRef = React.useRef(null); - const leafletMapRef = React.useRef(null); + // -- 3D world -- const canvasRef = React.useRef(null); - const threeData = React.useRef<{ - scene: Scene; - camera: PerspectiveCamera; - renderer: WebGLRenderer; - controls: OrbitControls; - raycaster: Raycaster; - } | null>(null); - const [isInitialized, setIsInitialized] = React.useState(false); - const [useLeafletFallback, setUseLeafletFallback] = React.useState(false); - const altitudeRef = React.useRef(MAX_DISTANCE); - const animationFrameRef = React.useRef(null); - const showDecorsRef = React.useRef(true); - - const { - setHoverableObjects, - onClick: onHoverClick, - onUpdateHover, - onMouseMove, - onMouseLeave, - resetCanvasBoundingRect, - popover, - cursorStyle, - } = useObjectHover(); - + const [world, setWorld] = React.useState(null); + const [mouseStyle, setMouseStyle] = React.useState('default'); + const [popoverPos, setPopoverPos] = React.useState<{ x: number; y: number }>({ x: 0, y: 0 }); + const [popoverData, setPopoverData] = React.useState(null); const { containerRef, fullScreenButton } = useFullScreen(); - - const render = React.useCallback(() => { - if (threeData.current === null) { - return; + const [showSuccess, setShowSuccess] = React.useState(false); + const { data: countriesAndCapitals } = useQuery(['3d-world-countries-and-capitals'], getCountriesAndCapitals); + const selectedPhaseRef = React.useRef(selectedPhase); + React.useEffect(() => { + if (useLeafletFallback) { + return () => {}; } - const { renderer, scene, camera, controls, raycaster } = threeData.current; - - onUpdateHover(raycaster, camera, scene); - controls.update(); - renderer.render(scene, camera); - animationFrameRef.current = requestAnimationFrame(render); - }, [onUpdateHover]); - - const onResizeDebounced = React.useMemo(() => { - const onResize = () => { - if (canvasRef.current && threeData.current) { - const width = canvasRef.current.clientWidth; - const height = canvasRef.current.clientHeight; - threeData.current.camera.aspect = width / height; - threeData.current.camera.updateProjectionMatrix(); - threeData.current.renderer.setSize(width, height, false); - } - resetCanvasBoundingRect(); + const canvas = canvasRef.current; + if (!canvas) { + return () => {}; + } + const prevent = (event: Event) => { + event.preventDefault(); }; - return debounce(onResize, 250, false); - }, [resetCanvasBoundingRect]); - - const onCameraChangeThrottled = React.useMemo(() => { - const onCameraChange = () => { - if (threeData.current === null) { - return; - } - const { scene, camera, controls } = threeData.current; - const altitude = cartesian2Polar(camera.position).altitude; - altitudeRef.current = altitude; - controls.rotateSpeed = altitude * 0.001363 - 0.109; - - const pins = scene.children.find((child) => child.name === 'pins'); - if (pins) { - for (const pin of pins.children) { - (pin as HoverablePin).update(camera.position, altitude); - } - } - - if ((showDecorsRef.current === true && altitude < 240) || (showDecorsRef.current === false && altitude >= 240)) { - showDecorsRef.current = altitude >= 240; - for (const child of scene.children) { - if (child.name === 'countries' || child.name === 'capitals') { - child.visible = !showDecorsRef.current; - } - } - setHoverableObjects(scene, showDecorsRef.current); + canvas.addEventListener('mousewheel', prevent); + canvas.addEventListener('wheel', prevent); + const newWorld = new World(canvas, setMouseStyle, setPopoverData, selectedPhaseRef.current); + let animationFrame: number | null = null; + const render = (time: number) => { + newWorld.render(time); + animationFrame = requestAnimationFrame(render); + }; + animationFrame = requestAnimationFrame(render); + setWorld(newWorld); + return () => { + canvas.removeEventListener('mousewheel', prevent); + canvas.removeEventListener('wheel', prevent); + newWorld.dispose(); + if (animationFrame !== null) { + cancelAnimationFrame(animationFrame); } }; - return throttle(onCameraChange, 50); - }, [setHoverableObjects]); - - const onZoom = (delta: number) => { - if (threeData.current === null) { - return; - } - const cameraPosition = threeData.current.camera.position; - const { lat, lng, altitude } = cartesian2Polar(cameraPosition); - const { x, y, z } = polar2Cartesian(lat, lng, clamp(altitude + delta, MIN_DISTANCE, MAX_DISTANCE) - GLOBE_RADIUS); - cameraPosition.x = x; - cameraPosition.y = y; - cameraPosition.z = z; - onCameraChangeThrottled(); - }; - - const init = React.useCallback(async () => { - if (!isWebGLAvailable()) { - setUseLeafletFallback(true); - return; - } - - if (canvasRef.current) { - const width = canvasRef.current.clientWidth; - const height = canvasRef.current.clientHeight; - - // [1] Init scene, camera and renderer. - const scene = new Scene(); - const camera = new PerspectiveCamera(50, width / height, 0.1, SKY_RADIUS * 2.5); - const renderer = new WebGLRenderer({ canvas: canvasRef.current, antialias: true, alpha: true }); - const textureLoader = new TextureLoader(); - camera.position.z = MAX_DISTANCE; - renderer.setPixelRatio(window.devicePixelRatio || 1); - renderer.setSize(width, height, false); - - // [2] Add globe to the scene. - const globeGeometry = new SphereBufferGeometry(GLOBE_RADIUS, 75, 75); - const defaultGlobeMaterial = new MeshBasicMaterial({ map: textureLoader.load(GLOBE_IMAGE_URL), transparent: true }); - const globeObj = new Mesh(globeGeometry, defaultGlobeMaterial); - globeObj.rotation.y = -Math.PI / 2; // face prime meridian along Z axis - globeObj.name = 'globe'; - const glowObj = getAtmosphereGlow(); - const skyGeometry = new SphereBufferGeometry(50000, 75, 75); - const defaultSkyMaterial = new MeshBasicMaterial({ map: textureLoader.load(BACKGROUND_IMAGE_URL), side: BackSide }); - const skyObj = new Mesh(skyGeometry, defaultSkyMaterial); - scene.add(skyObj); - scene.add(globeObj); - scene.add(glowObj); - scene.add(new AmbientLight(0xbbbbbb)); - scene.add(new DirectionalLight(0xffffff, 0.6)); - - // [3] Add countries - const countries = await getCountries(); - const capitals = await getCapitals(); - countries.visible = false; - capitals.visible = false; - scene.add(countries); - scene.add(capitals); - - // [4] Setup camera controls. - const controls = new OrbitControls(camera, renderer.domElement); - controls.minDistance = MIN_DISTANCE; - controls.maxDistance = MAX_DISTANCE; - controls.enablePan = false; - controls.enableDamping = false; - // controls.dampingFactor = 0.05; - controls.rotateSpeed = 0.4; - controls.zoomSpeed = 0.25; - controls.addEventListener('change', onCameraChangeThrottled); - controls.update(); - - // [5] Start the animation loop. - threeData.current = { - scene, - camera, - renderer, - controls, - raycaster: new Raycaster(), - }; - requestAnimationFrame(render); - window.addEventListener('resize', onResizeDebounced); - setTimeout(() => { - setIsInitialized(true); - }, 100); - } - }, [render, onCameraChangeThrottled, onResizeDebounced]); - - const clearScene = React.useCallback(() => { - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); + }, [useLeafletFallback]); + React.useEffect(() => { + if (world && countriesAndCapitals) { + world.addCountriesAndCapitals(countriesAndCapitals); } - if (threeData.current) { - const { renderer, scene, controls } = threeData.current; - renderer.renderLists.dispose(); - renderer.dispose(); - controls.dispose(); - scene.children.forEach(disposeNode); + }, [world, countriesAndCapitals]); + React.useEffect(() => { + if (world) { + world.addUsers(users.filter((u) => u.type === UserType.TEACHER)); } - window.removeEventListener('resize', onResizeDebounced); - }, [onResizeDebounced]); - - //display globe with countries and capitals + }, [world, users]); React.useEffect(() => { - setIsInitialized(false); - init().catch(); - return clearScene; - }, [init, clearScene]); - - const onClick = React.useCallback(() => { - if (!threeData.current) { - return; + if (world) { + world.changeView(selectedPhase === 3 ? 'pelico' : 'earth'); } - onHoverClick(threeData.current.camera, altitudeRef.current); - onCameraChangeThrottled(); - }, [onHoverClick, onCameraChangeThrottled]); + }, [world, selectedPhase]); - //add leaflet fallback if webgl is not available + // -- Leaflet(2D) fallback -- + const leafletRef = React.useRef(null); + const leafletMapRef = React.useRef(null); React.useEffect(() => { - if (useLeafletFallback) { - if (leafletRef.current) { - const map = L.map(leafletRef.current, { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - fullscreenControl: true, - fullscreenControlOptions: { - position: 'topleft', - }, - }).setView([51.505, -0.09], 2); - L.tileLayer('https://api.maptiler.com/maps/streets/{z}/{x}/{y}.png?key=ecMNwc4xNgcrvp2RH6cr', { - tileSize: 512, - zoomOffset: -1, - minZoom: 1, - attribution: - '\u003ca href="https://www.maptiler.com/copyright/" target="_blank"\u003e\u0026copy; MapTiler\u003c/a\u003e \u003ca href="https://www.openstreetmap.org/copyright" target="_blank"\u003e\u0026copy; OpenStreetMap contributors\u003c/a\u003e', - crossOrigin: true, - }).addTo(map); - leafletMapRef.current = map; - setIsInitialized(true); - - return () => { - map.remove(); - }; - } + if (!leafletRef.current) { + return () => {}; } - return; + const map = L.map(leafletRef.current, { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + fullscreenControl: true, + fullscreenControlOptions: { + position: 'topleft', + }, + }).setView([51.505, -0.09], 2); + L.tileLayer('https://api.maptiler.com/maps/streets/{z}/{x}/{y}.png?key=ecMNwc4xNgcrvp2RH6cr', { + tileSize: 512, + zoomOffset: -1, + minZoom: 1, + attribution: + '\u003ca href="https://www.maptiler.com/copyright/" target="_blank"\u003e\u0026copy; MapTiler\u003c/a\u003e \u003ca href="https://www.openstreetmap.org/copyright" target="_blank"\u003e\u0026copy; OpenStreetMap contributors\u003c/a\u003e', + crossOrigin: true, + }).addTo(map); + leafletMapRef.current = map; + return () => { + map.remove(); + leafletMapRef.current = null; + }; }, [useLeafletFallback]); - - // add 3D pin models to leaflet map - const addPins = React.useCallback(async () => { - if (!isInitialized) { + React.useEffect(() => { + const map = leafletMapRef.current; + if (!map) { return; } - - if (useLeafletFallback && leafletMapRef.current !== null) { - const map = leafletMapRef.current; - users - .filter((u) => u.type === UserType.TEACHER) - .forEach((u) => { - const marker = L.marker(u.position, { - icon: new L.Icon({ - iconUrl: '/marker.svg', - iconSize: [25, 41], - iconAnchor: [13.5, 41], - }), - }).addTo(map); - - const $div = document.createElement('div'); - ReactDOM.render(, $div, () => { - marker.bindPopup($div.innerHTML); - }); + users + .filter((u) => u.type === UserType.TEACHER) + .forEach((u) => { + const marker = L.marker(u.position, { + icon: new L.Icon({ + iconUrl: '/marker.svg', + iconSize: [25, 41], + iconAnchor: [13.5, 41], + }), + }).addTo(map); + const $div = document.createElement('div'); + ReactDOM.render(, $div, () => { + marker.bindPopup($div.innerHTML); }); - } - - if (!threeData.current) { - return; - } - const { scene, camera } = threeData.current; - - // remove previous pins - for (const child of scene.children) { - if (child.name === 'pins') { - scene.remove(child); - disposeNode(child); - } - } - - // add pins - const pins = await getPins( - users.filter((u) => u.type === UserType.TEACHER), - camera.position, - ); - scene.add(pins); - setHoverableObjects(scene, showDecorsRef.current); - }, [isInitialized, useLeafletFallback, users, setHoverableObjects]); - React.useEffect(() => { - addPins().catch(); - }, [addPins]); - + }); + }, [users]); if (useLeafletFallback) { return
; } @@ -338,12 +172,84 @@ const WorldMap = () => {
{ + if (world && popoverData !== null && isUser(popoverData) && popoverData.data.mascotteId) { + world.resetHoverState(); + router.push(`/activite/${popoverData.data.mascotteId}`); + } else if ((!village || village.activePhase < 3) && world && world.getHoveredObjectName() === 'pelico') { + world.resetHoverState(); + setShowSuccess(true); + } else if (world) { + if (world.getHoveredObjectName() === 'pelico') { + setSelectedPhase(3); + } + if (world.getHoveredObjectName() === 'earth' && selectedPhase === 3) { + setSelectedPhase(2); + } + world.onClick.bind(world)(); + } + }} + onMouseMove={(event) => { + if (world !== null) { + world.onMouseMove.bind(world)(event); + setPopoverPos({ + x: event.clientX - world.canvasRect.left, + y: event.clientY - world.canvasRect.top + 20, + }); + } + }} > - {popover} + {popoverData !== null && } + {showSuccess && ( +
{ + setShowSuccess(false); + }} + > +
{ + event.stopPropagation(); + }} + > +
+ Énigme résolue! + { + setShowSuccess(false); + }} + > + + +
+
+ {"Félicitations, tu m'as retrouvé !"} + {'À très vite, pour imaginer ensemble notre village idéal !'} +
+
+
+ )}
{ }, }} > - - + {world && ( + <> + + + + )} + {fullScreenButton} - {fullScreenButton}
- {!isInitialized && ( -
- -
- )}
); }; diff --git a/src/components/WorldMap/data/capitals.ts b/src/components/WorldMap/data/capitals.ts deleted file mode 100644 index da42db098..000000000 --- a/src/components/WorldMap/data/capitals.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { FeatureCollection, Point } from 'geojson'; -import { CircleBufferGeometry, Group, Mesh, MeshLambertMaterial, Vector3 } from 'three'; -import { Text } from 'troika-three-text'; - -import { polar2Cartesian } from '../lib/coords-utils'; -import { GLOBE_RADIUS } from '../world-map.constants'; -import { axiosRequest } from 'src/utils/axiosRequest'; - -export type GeoJSONCityData = FeatureCollection; -export type GeoLabel = GeoJSONCityData['features'][0]; - -const pxPerDeg = (2 * Math.PI * GLOBE_RADIUS) / 360; - -export const getCapitals = async (): Promise => { - const capitals = new Group(); - capitals.name = 'capitals'; - - const response = await axiosRequest({ - method: 'GET', - baseURL: '', - url: '/earth/capitals.geo.json', - }); - if (response.error) { - return capitals; - } - - const features = (response.data as GeoJSONCityData).features; - for (let i = 0; i < features.length; i++) { - const capitalObj = getCapital(features[i]); - if (capitalObj !== null) { - capitals.add(capitalObj); - } - } - return capitals; -}; - -function getCapital(geojson: GeoLabel): Group | null { - const capitalObj = new Group(); - - const circleGeometry = new CircleBufferGeometry(1, 16); - const material = new MeshLambertMaterial({ color: '#000' }); - - // dot - const dotObj = new Mesh(circleGeometry, material); - const dotRadius = 0.1 * pxPerDeg; - dotObj.scale.x = dotObj.scale.y = dotRadius; - - // text - const textHeight = 0.4 * pxPerDeg; - const labelObj = new Text(); - labelObj.name = 'Text'; - labelObj.text = geojson.properties.cityNameFR; - labelObj.fontSize = textHeight; - labelObj.color = 0x000000; - labelObj.anchorX = 'center'; - labelObj.position.y = -dotRadius * 1.1; - labelObj.sync(); // Update the rendering - - capitalObj.add(dotObj); - capitalObj.add(labelObj); - - // place - const pos = polar2Cartesian(geojson.geometry.coordinates[1], geojson.geometry.coordinates[0], 1); - capitalObj.position.x = pos.x; - capitalObj.position.y = pos.y; - capitalObj.position.z = pos.z; - - // rotate - capitalObj.lookAt(new Vector3(0, 0, 0)); // face globe (local) center - capitalObj.rotateY(Math.PI); // face outwards - - return capitalObj; -} diff --git a/src/components/WorldMap/data/countries.ts b/src/components/WorldMap/data/countries.ts deleted file mode 100644 index 95fcf79b3..000000000 --- a/src/components/WorldMap/data/countries.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { FeatureCollection, Geometry, Position } from 'geojson'; -import type { Object3D } from 'three'; -import { Group, MeshBasicMaterial, DoubleSide, Mesh, Line, LineBasicMaterial } from 'three'; -import { ConicPolygonBufferGeometry } from 'three-conic-polygon-geometry'; - -import { GeoJsonGeometry } from '../lib/geo-json-geometry'; -import type { HoverableObject } from '../lib/hoverable-object'; -import { GLOBE_RADIUS } from '../world-map.constants'; -import { axiosRequest } from 'src/utils/axiosRequest'; - -// eslint-disable-next-line camelcase -export type GeoJSONCountriesData = FeatureCollection; -export type GeoJSONCountryData = GeoJSONCountriesData['features'][number]; - -const COLORS: { [key: string]: string[] } = { - 'North America': ['#ede7f6', '#d1c4e9', '#b39ddb', '#9575cd', '#7e57c2', '#673ab7'], - Asia: ['#e0f7fa', '#b2ebf2', '#80deea', '#4dd0e1', '#26c6da', '#00bcd4'], - Africa: ['#fff8e1', '#ffecb3', '#ffe082', '#ffd54f', '#ffca28', '#ffc107'], - Oceania: ['#ffccbc', '#ffab91', '#ff8a65', '#ff7043', '#ff5722', '#fbe9e7'], - Europe: ['#fce4ec', '#f8bbd0', '#f48fb1', '#f06292', '#ec407a', '#e91e63'], - 'South America': ['#BBF7D0', '#99F6E4', '#047857', '#059669', '#34D399', '#6EE7B7'], -}; - -export const getCountries = async (): Promise => { - const countries = new Group(); - countries.name = 'countries'; - - const response = await axiosRequest({ - method: 'GET', - baseURL: '', - url: '/earth/countries.geo.json', - }); - if (response.error) { - return countries; - } - - const features = (response.data as GeoJSONCountriesData).features; - for (let i = 0; i < features.length; i++) { - const countryObj = getCountry(features[i], i); - if (countryObj !== null) { - countries.add(countryObj); - } - } - return countries; -}; - -const hoverMaterial = new MeshBasicMaterial({ side: DoubleSide, color: '#3f51b5' }); - -function getCountry(geoJson: GeoJSONCountryData, index: number): Object3D | null { - const polygons: Position[][][] = []; - - if (geoJson.geometry.type === 'Polygon') { - polygons.push(geoJson.geometry.coordinates); - } else if (geoJson.geometry.type === 'MultiPolygon') { - polygons.push(...geoJson.geometry.coordinates); - } - - const countryObj = new Group(); - countryObj.name = 'country'; - const sideMaterial = new MeshBasicMaterial({ - side: DoubleSide, - color: COLORS[geoJson.properties.continent][index % 6], - }); - const capMaterial = new MeshBasicMaterial({ - side: DoubleSide, - color: COLORS[geoJson.properties.continent][index % 6], - }); - const lineMaterial = new LineBasicMaterial({ color: 'white' }); - - for (const polygon of polygons) { - const coneObj = new HoverableCountry(polygon, geoJson.properties, [sideMaterial, capMaterial]); - - // add polygon - countryObj.add(coneObj); - - // add strokes - countryObj.add(new Line(new GeoJsonGeometry({ type: 'Polygon', coordinates: polygon }, 1), lineMaterial)); - } - return countryObj; -} - -export class HoverableCountry - extends Mesh - implements - HoverableObject<{ - countryName: string; - }> -{ - private initMaterials: MeshBasicMaterial[]; - public userData: HoverableObject<{ - countryName: string; - }>['userData']; - - constructor(coords: Position[][], geojsonProperties: GeoJSONCountryData['properties'], materials: MeshBasicMaterial[]) { - super(new ConicPolygonBufferGeometry(coords, GLOBE_RADIUS + 0.5, GLOBE_RADIUS + 1, false, true, true, 5), materials); - - this.initMaterials = materials; - this.userData = { - isHoverable: true, - isClickable: false, - type: 'country', - countryName: geojsonProperties.nameFR, - }; - this.name = 'countryPolygon'; - } - - public getType(): string { - return this.userData.type; - } - - public getData(): { countryName: string } { - return this.userData; - } - - public onHover(): void { - this.material = hoverMaterial; - } - - public onReset(): void { - this.material = this.initMaterials; - } - - public onClick(): void {} -} diff --git a/src/components/WorldMap/hooks/use-object-hover.tsx b/src/components/WorldMap/hooks/use-object-hover.tsx deleted file mode 100644 index 6d3543b7d..000000000 --- a/src/components/WorldMap/hooks/use-object-hover.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { useRouter } from 'next/router'; -import * as React from 'react'; -import type { Object3D, Scene, Raycaster, Camera } from 'three'; - -import Card from '@mui/material/Card'; - -import { UserPopover } from '../UserPopover'; -import type { HoverableObject } from '../lib/hoverable-object'; -import { onResetHoveredObject, isHoverable } from '../lib/hoverable-object'; -import type { User } from 'types/user.type'; - -export const useObjectHover = () => { - const router = useRouter(); - const canvasBoundingRectRef = React.useRef(null); - - // Mouse position - const mousePositionRef = React.useRef<{ x: number; y: number } | null>(null); - const [popoverPos, setPopoverPos] = React.useState<{ x: number; y: number } | null>(null); - - // Hovered Object. - const [hoveredObject, setHoveredObject] = React.useState(null); - const hoveredObjectIdRef = React.useRef(null); - - // HoverableObjects - const hoverableObjectsRef = React.useRef([]); - - const mascotteData = hoveredObject?.userData['user'] as User | undefined; - const mascotteActivityId = mascotteData?.mascotteId; - - const setHoverableObjects = React.useCallback((scene: Scene, showDecors: boolean = true) => { - const addObjectFrom = (children: Object3D[]): Object3D[] => { - const obj: Object3D[] = []; - for (const child of children) { - if (child.type === 'Group' && child.children) { - obj.push(...addObjectFrom(child.children)); - } - if (isHoverable(child) && (child.getType() !== 'country' || !showDecors)) { - obj.push(child); - } - } - return obj; - }; - - hoverableObjectsRef.current = addObjectFrom(scene.children).concat(scene.children.filter((child) => child.name === 'globe')); - hoveredObjectIdRef.current = null; - setHoveredObject(null); - }, []); - - const onUpdateHover = React.useCallback((raycaster: Raycaster, camera: Camera, scene: Scene) => { - if (mousePositionRef.current === null) { - return; - } - - raycaster.setFromCamera(mousePositionRef.current, camera); - const hoveredObjects = raycaster.intersectObjects(hoverableObjectsRef.current); - let hoveredObject = hoveredObjects.length > 0 && hoveredObjects[0].object.name !== 'globe' ? hoveredObjects[0].object : null; - if (hoveredObject && !isHoverable(hoveredObject) && isHoverable(hoveredObject.parent)) { - hoveredObject = hoveredObject.parent; - } - - if (isHoverable(hoveredObject) && (hoveredObjectIdRef.current === null || hoveredObject.id !== hoveredObjectIdRef.current)) { - onResetHoveredObject(hoveredObjectIdRef.current, scene); - hoveredObjectIdRef.current = hoveredObject.id; - hoveredObject.onHover(); - setHoveredObject(hoveredObject); - } - if (hoveredObject === null && hoveredObjectIdRef.current !== null) { - onResetHoveredObject(hoveredObjectIdRef.current, scene); - hoveredObjectIdRef.current = null; - setHoveredObject(null); - } - }, []); - - const onMouseMove = React.useCallback((event: React.MouseEvent) => { - const canvas = event.target as HTMLCanvasElement; - if (canvasBoundingRectRef.current === null) { - canvasBoundingRectRef.current = canvas.getBoundingClientRect(); - } - const { top, left, width, height } = canvasBoundingRectRef.current; - - // calculate mouse position in normalized device coordinates, (-1 to +1) for both components - mousePositionRef.current = { - x: ((event.clientX - left) / width) * 2 - 1, - y: 1 - ((event.clientY - top) / height) * 2, - }; - setPopoverPos({ - x: event.clientX - left, - y: event.clientY - top + 20, - }); - }, []); - - const onMouseLeave = React.useCallback(() => { - mousePositionRef.current = null; - setPopoverPos(null); - }, []); - - const onClick = React.useCallback( - (camera: Camera, cameraAltitude: number) => { - if (hoveredObject !== null && mascotteData?.mascotteId) { - hoveredObject.onClick(camera, cameraAltitude); - router.push(`/activite/${mascotteActivityId}`); - } else { - return; - } - }, - [hoveredObject, mascotteActivityId, mascotteData?.mascotteId, router], - ); - - const resetCanvasBoundingRect = React.useCallback(() => { - canvasBoundingRectRef.current = null; - }, []); - - const popover = - popoverPos !== null && hoveredObject !== null ? ( -
-
- - {hoveredObject.getType() === 'country' && {hoveredObject.userData.countryName as string}} - {hoveredObject.getType() === 'pin' && } - -
-
- ) : null; - - return { - setHoverableObjects, - onUpdateHover, - onMouseMove, - onMouseLeave, - resetCanvasBoundingRect, - onClick, - cursorStyle: hoveredObject !== null && hoveredObject.userData.isClickable ? (mascotteData?.mascotteId ? 'pointer' : 'not-allowed') : 'default', - popover, - }; -}; diff --git a/src/components/WorldMap/index.tsx b/src/components/WorldMap/index.ts similarity index 100% rename from src/components/WorldMap/index.tsx rename to src/components/WorldMap/index.ts diff --git a/src/components/WorldMap/lib/get-atmosphere-glow.ts b/src/components/WorldMap/lib/get-atmosphere-glow.ts deleted file mode 100644 index 607540a8f..000000000 --- a/src/components/WorldMap/lib/get-atmosphere-glow.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { Object3D } from 'three'; -import { BackSide, BufferAttribute, Color, Mesh, ShaderMaterial, SphereBufferGeometry } from 'three'; - -import { GLOBE_RADIUS } from '../world-map.constants'; - -const fragmentShader = ` -uniform vec3 color; -uniform float coefficient; -uniform float power; -varying vec3 vVertexNormal; -varying vec3 vVertexWorldPosition; -void main() { - vec3 worldCameraToVertex = vVertexWorldPosition - cameraPosition; - vec3 viewCameraToVertex = (viewMatrix * vec4(worldCameraToVertex, 0.0)).xyz; - viewCameraToVertex = normalize(viewCameraToVertex); - float intensity = pow( - coefficient + dot(vVertexNormal, viewCameraToVertex), - power - ); - gl_FragColor = vec4(color, intensity); -}`; - -const vertexShader = ` -varying vec3 vVertexWorldPosition; -varying vec3 vVertexNormal; -void main() { - vVertexNormal = normalize(normalMatrix * normal); - vVertexWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz; - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); -} -`; - -export function getAtmosphereGlow(): Object3D { - // Geometry with resized vertex positions according to normals - const glowGeometry = new SphereBufferGeometry(GLOBE_RADIUS, 75, 75); - const position = new Float32Array(glowGeometry.attributes.position.count * 3); - for (let idx = 0, len = position.length; idx < len; idx++) { - const normal = glowGeometry.attributes.normal.array[idx]; - const curPos = glowGeometry.attributes.position.array[idx]; - position[idx] = curPos + normal * GLOBE_RADIUS * 0.15; - } - glowGeometry.setAttribute('position', new BufferAttribute(position, 3)); - - const glowMaterial = new ShaderMaterial({ - depthWrite: false, - fragmentShader, - transparent: true, - uniforms: { - coefficient: { - value: 0.1, - }, - color: { - value: new Color('lightskyblue'), - }, - power: { - value: 2.45, - }, - }, - vertexShader, - side: BackSide, - }); - - return new Mesh(glowGeometry, glowMaterial); -} diff --git a/src/components/WorldMap/lib/load-glb.ts b/src/components/WorldMap/lib/load-glb.ts deleted file mode 100644 index f5475f743..000000000 --- a/src/components/WorldMap/lib/load-glb.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Group } from 'three'; -import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; - -const LOADER = new GLTFLoader(); - -export const loadGLB = async (path: string): Promise => { - return new Promise((resolve) => { - LOADER.load( - path, - (gltf) => { - resolve(gltf.scene); - }, - () => {}, - (error) => { - console.error('An error happened loading the model...'); - console.error(error); - resolve(new Group()); - }, - ); - }); -}; diff --git a/src/components/WorldMap/hooks/use-full-screen.tsx b/src/components/WorldMap/use-full-screen.tsx similarity index 100% rename from src/components/WorldMap/hooks/use-full-screen.tsx rename to src/components/WorldMap/use-full-screen.tsx diff --git a/src/components/WorldMap/world-map.constants.ts b/src/components/WorldMap/world-map.constants.ts deleted file mode 100644 index 9861d67e2..000000000 --- a/src/components/WorldMap/world-map.constants.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* eslint-disable camelcase */ -export const GLOBE_IMAGE_URL = '/static-images/earth-blue-marble.jpg'; -export const BACKGROUND_IMAGE_URL = '/static-images/night-sky.png'; -export const GLOBE_RADIUS = 100; -export const SKY_RADIUS = GLOBE_RADIUS * 500; - -/* camera zoom */ -export const MIN_DISTANCE = 110; -export const MAX_DISTANCE = 310; -export const ZOOM_DELTA = 20; diff --git a/src/components/WorldMap/world/animations/animations.ts b/src/components/WorldMap/world/animations/animations.ts new file mode 100644 index 000000000..0f860e78a --- /dev/null +++ b/src/components/WorldMap/world/animations/animations.ts @@ -0,0 +1,35 @@ +export interface Animation { + animate: (dt: number) => boolean; // todo add time. +} + +export class Animations { + private animations: Animation[]; + constructor() { + this.animations = []; + } + + public animate(dt: number) { + const animationsEnded: number[] = []; + + // Animate + for (let i = 0; i < this.animations.length; i += 1) { + const animate = this.animations[i].animate(dt); + if (!animate) { + animationsEnded.push(i); + } + } + + // Remove animation that ended + for (const i of animationsEnded.reverse()) { + this.animations.splice(i, 1); + } + } + + public addAnimation(animation: Animation) { + this.animations.push(animation); + } + + public cancelAnimations() { + this.animations = []; + } +} diff --git a/src/components/WorldMap/world/animations/index.ts b/src/components/WorldMap/world/animations/index.ts new file mode 100644 index 000000000..4d1f45872 --- /dev/null +++ b/src/components/WorldMap/world/animations/index.ts @@ -0,0 +1 @@ +export { Animations } from './animations'; diff --git a/src/components/WorldMap/world/animations/linear-animation.ts b/src/components/WorldMap/world/animations/linear-animation.ts new file mode 100644 index 000000000..233d84775 --- /dev/null +++ b/src/components/WorldMap/world/animations/linear-animation.ts @@ -0,0 +1,30 @@ +import type { Animation } from './animations'; + +export class LinearAnimation implements Animation { + private current: number; + private to: number; + private speed: number; + private updateCallback: (newValue: number) => void; + + constructor(from: number, to: number, duration: number, updateCallback: (newValue: number) => void) { + this.current = from; + this.to = to; + + this.speed = (to - from) / duration; + if (Math.abs(this.speed) < 1e-5) { + this.speed = Math.sign(this.speed) * 1e-5; + } + this.updateCallback = updateCallback; + } + + public animate(dt: number) { + this.current = this.current + this.speed * dt; + if (this.speed >= 0 && this.current > this.to) { + this.current = this.to; + } else if (this.speed < 0 && this.current < this.to) { + this.current = this.to; + } + this.updateCallback(this.current); + return Math.abs(this.current - this.to) > 1e-5; + } +} diff --git a/src/components/WorldMap/world/index.ts b/src/components/WorldMap/world/index.ts new file mode 100644 index 000000000..b16656e7c --- /dev/null +++ b/src/components/WorldMap/world/index.ts @@ -0,0 +1 @@ +export { World } from './world'; diff --git a/src/components/WorldMap/lib/coords-utils.ts b/src/components/WorldMap/world/lib/coords-utils.ts similarity index 84% rename from src/components/WorldMap/lib/coords-utils.ts rename to src/components/WorldMap/world/lib/coords-utils.ts index 1b451b28f..b7d2217e9 100644 --- a/src/components/WorldMap/lib/coords-utils.ts +++ b/src/components/WorldMap/world/lib/coords-utils.ts @@ -1,11 +1,11 @@ import type { Vector3 } from 'three'; -import { GLOBE_RADIUS } from '../world-map.constants'; +import { GLOBE_RADIUS } from '../world.constants'; -export function polar2Cartesian(lat: number, lng: number, relAltitude = 0) { +export function polar2Cartesian(lat: number, lng: number, relAltitude = 0, radius = GLOBE_RADIUS) { const phi = ((90 - lat) * Math.PI) / 180; const theta = ((90 - lng) * Math.PI) / 180; - const r = GLOBE_RADIUS + relAltitude; + const r = radius + relAltitude; return { x: r * Math.sin(phi) * Math.cos(theta), y: r * Math.cos(phi), diff --git a/src/components/WorldMap/lib/dispose-node.ts b/src/components/WorldMap/world/lib/dispose-node.ts similarity index 100% rename from src/components/WorldMap/lib/dispose-node.ts rename to src/components/WorldMap/world/lib/dispose-node.ts diff --git a/src/components/WorldMap/lib/geo-json-geometry.ts b/src/components/WorldMap/world/lib/geo-json-geometry.ts similarity index 100% rename from src/components/WorldMap/lib/geo-json-geometry.ts rename to src/components/WorldMap/world/lib/geo-json-geometry.ts diff --git a/src/components/WorldMap/lib/hoverable-object.ts b/src/components/WorldMap/world/lib/hoverable-object.ts similarity index 60% rename from src/components/WorldMap/lib/hoverable-object.ts rename to src/components/WorldMap/world/lib/hoverable-object.ts index 66ceb040c..a22db2256 100644 --- a/src/components/WorldMap/lib/hoverable-object.ts +++ b/src/components/WorldMap/world/lib/hoverable-object.ts @@ -1,12 +1,14 @@ -import type { Object3D, Scene, Camera } from 'three'; +import type { Object3D, Scene } from 'three'; export interface HoverableObject = Record> extends Object3D { - userData: T & { isHoverable: true; isClickable: boolean; type: string }; - getData(): T; - getType(): string; - onHover(): void; - onReset(): void; // called when object is no more hovered. - onClick(camera: Camera, cameraAltitude: number): void; + userData: T & { + isHoverable: true; + hoverableViews: ('earth' | 'global' | 'pelico')[]; + hovarableTargets?: Object3D[]; + cursor?: React.CSSProperties['cursor']; + }; + onMouseEnter(): void; + onMouseLeave(): void; } export const isHoverable = (object?: Object3D | null): object is HoverableObject => @@ -18,6 +20,6 @@ export const onResetHoveredObject = (id: number | null, scene: Scene) => { } const previousObject = scene.getObjectById(id); if (isHoverable(previousObject)) { - previousObject.onReset(); + previousObject.onMouseLeave(); } }; diff --git a/src/components/WorldMap/world/lib/image-texture.ts b/src/components/WorldMap/world/lib/image-texture.ts new file mode 100644 index 000000000..d3dd00a01 --- /dev/null +++ b/src/components/WorldMap/world/lib/image-texture.ts @@ -0,0 +1,55 @@ +import type { LoadingManager } from 'three'; +import { Texture, Loader, Cache } from 'three'; + +class ImageLoader extends Loader { + constructor(manager?: LoadingManager) { + super(manager); + } + + load(url: string, onLoad: (image: HTMLImageElement) => void, onError?: (error: ErrorEvent) => void): void { + url = this.manager.resolveURL(url); + const cached = Cache.get(url) as HTMLImageElement; + if (cached !== undefined) { + this.manager.itemStart(url); + setTimeout(() => { + onLoad(cached); + this.manager.itemEnd(url); + }, 0); + return; + } + const image = new Image(); + const onImageLoad = () => { + removeEventListeners(); + Cache.add(url, image); + onLoad(image); + this.manager.itemEnd(url); + }; + const onImageError = (event: ErrorEvent) => { + removeEventListeners(); + onError?.(event); + this.manager.itemError(url); + this.manager.itemEnd(url); + }; + const removeEventListeners = () => { + image.removeEventListener('load', onImageLoad, false); + image.removeEventListener('error', onImageError, false); + }; + image.addEventListener('load', onImageLoad, false); + image.addEventListener('error', onImageError, false); + this.manager.itemStart(url); + image.crossOrigin = this.crossOrigin; + image.src = url; + } +} + +const IMAGE_LOADER = new ImageLoader(); + +export class ImageTexture extends Texture { + constructor(url: string) { + super(); + IMAGE_LOADER.load(url, (image) => { + this.image = image; + this.needsUpdate = true; + }); + } +} diff --git a/src/components/WorldMap/world/lib/load-glb.ts b/src/components/WorldMap/world/lib/load-glb.ts new file mode 100644 index 000000000..dc74e68ff --- /dev/null +++ b/src/components/WorldMap/world/lib/load-glb.ts @@ -0,0 +1,19 @@ +import type { Group } from 'three'; +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; + +const LOADER = new GLTFLoader(); + +export const loadGLB = (path: string, onLoad: (model: Group) => void, onError: (error: ErrorEvent) => void) => { + LOADER.load( + path, + (gltf) => { + onLoad(gltf.scene); + }, + () => {}, + (error) => { + console.error('An error happened loading the model...'); + console.error(error); + onError(error); + }, + ); +}; diff --git a/src/components/WorldMap/world/objects/atmosphere-glow.ts b/src/components/WorldMap/world/objects/atmosphere-glow.ts new file mode 100644 index 000000000..20b5e7c87 --- /dev/null +++ b/src/components/WorldMap/world/objects/atmosphere-glow.ts @@ -0,0 +1,67 @@ +import { BackSide, BufferAttribute, Color, Mesh, ShaderMaterial, SphereGeometry } from 'three'; + +import { GLOBE_RADIUS } from '../world.constants'; + +const fragmentShader = ` +uniform vec3 color; +uniform float coefficient; +uniform float power; +varying vec3 vVertexNormal; +varying vec3 vVertexWorldPosition; +void main() { + vec3 worldCameraToVertex = vVertexWorldPosition - cameraPosition; + vec3 viewCameraToVertex = (viewMatrix * vec4(worldCameraToVertex, 0.0)).xyz; + viewCameraToVertex = normalize(viewCameraToVertex); + float intensity = pow( + coefficient + dot(vVertexNormal, viewCameraToVertex), + power + ); + gl_FragColor = vec4(color, intensity); +}`; + +const vertexShader = ` +varying vec3 vVertexWorldPosition; +varying vec3 vVertexNormal; +void main() { + vVertexNormal = normalize(normalMatrix * normal); + vVertexWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +`; + +export class AtmosphereGlow extends Mesh { + constructor(radius = GLOBE_RADIUS) { + // Geometry with resized vertex positions according to normals + const glowGeometry = new SphereGeometry(radius, 75, 75); + const position = new Float32Array(glowGeometry.attributes.position.count * 3); + for (let idx = 0, len = position.length; idx < len; idx++) { + const normal = (glowGeometry.attributes.normal as BufferAttribute).array[idx]; + const curPos = (glowGeometry.attributes.position as BufferAttribute).array[idx]; + position[idx] = curPos + normal * radius * 0.15; + } + glowGeometry.setAttribute('position', new BufferAttribute(position, 3)); + + const glowMaterial = new ShaderMaterial({ + depthWrite: false, + fragmentShader, + transparent: true, + uniforms: { + coefficient: { + value: 0.1, + }, + color: { + value: new Color('lightskyblue'), + }, + power: { + value: 2.45, + }, + }, + vertexShader, + side: BackSide, + }); + + super(glowGeometry, glowMaterial); + + this.name = 'atmosphere-glow'; + } +} diff --git a/src/components/WorldMap/world/objects/capital.ts b/src/components/WorldMap/world/objects/capital.ts new file mode 100644 index 000000000..d3f383d2d --- /dev/null +++ b/src/components/WorldMap/world/objects/capital.ts @@ -0,0 +1,50 @@ +import type { FeatureCollection, Point } from 'geojson'; +import { CircleGeometry, Group, Mesh, MeshLambertMaterial, Vector3 } from 'three'; +import { Text } from 'troika-three-text'; + +import { polar2Cartesian } from '../lib/coords-utils'; +import { GLOBE_RADIUS } from '../world.constants'; + +export type GeoJSONCityData = FeatureCollection; +export type GeoLabel = GeoJSONCityData['features'][0]; + +const pxPerDeg = (2 * Math.PI * GLOBE_RADIUS) / 360; + +export class Capital extends Group { + constructor(geojson: GeoLabel) { + super(); + const circleGeometry = new CircleGeometry(1, 16); + const material = new MeshLambertMaterial({ color: '#000' }); + + // dot + const dotObj = new Mesh(circleGeometry, material); + const dotRadius = 0.1 * pxPerDeg; + dotObj.scale.x = dotObj.scale.y = dotRadius; + + // text + const textHeight = 0.4 * pxPerDeg; + const labelObj = new Text(); + labelObj.name = 'Text'; + labelObj.text = geojson.properties.cityNameFR; + labelObj.fontSize = textHeight; + labelObj.color = 0x000000; + labelObj.anchorX = 'center'; + labelObj.position.y = -dotRadius * 1.1; + labelObj.sync(); // Update the rendering + + this.add(dotObj); + this.add(labelObj); + + // place + const pos = polar2Cartesian(geojson.geometry.coordinates[1], geojson.geometry.coordinates[0], 1); + this.position.x = pos.x; + this.position.y = pos.y; + this.position.z = pos.z; + + // rotate + this.lookAt(new Vector3(0, 0, 0)); // face globe (local) center + this.rotateY(Math.PI); // face outwards + + this.name = 'capital'; + } +} diff --git a/src/components/WorldMap/world/objects/country.ts b/src/components/WorldMap/world/objects/country.ts new file mode 100644 index 000000000..06bc9212d --- /dev/null +++ b/src/components/WorldMap/world/objects/country.ts @@ -0,0 +1,64 @@ +import type { FeatureCollection, Geometry, Position } from 'geojson'; +import type { ColorRepresentation } from 'three'; +import { MeshBasicMaterial, Mesh, DoubleSide } from 'three'; +import { ConicPolygonBufferGeometry } from 'three-conic-polygon-geometry'; + +import type { HoverableObject } from '../lib/hoverable-object'; +import { GLOBE_RADIUS } from '../world.constants'; + +export type GeoJSONCountriesData = FeatureCollection; +export type GeoJSONCountryData = GeoJSONCountriesData['features'][number]; + +export class Country + extends Mesh + implements + HoverableObject<{ + countryName: string; + }> +{ + private baseMaterials: MeshBasicMaterial[]; + private hoverMaterial: MeshBasicMaterial; + + public userData: HoverableObject<{ + countryName: string; + }>['userData']; + + constructor(coords: Position[][], geojsonProperties: GeoJSONCountryData['properties'], color: ColorRepresentation) { + // materials + const baseMaterials = []; + baseMaterials.push( + new MeshBasicMaterial({ + side: DoubleSide, + color, + }), + ); + baseMaterials.push( + new MeshBasicMaterial({ + side: DoubleSide, + color, + }), + ); + const hoverMaterial = new MeshBasicMaterial({ side: DoubleSide, color: '#3f51b5' }); + + // country object + const countryGeometry = new ConicPolygonBufferGeometry(coords, GLOBE_RADIUS + 0.5, GLOBE_RADIUS + 1, false, true, true, 5); + super(countryGeometry, baseMaterials); + this.baseMaterials = baseMaterials; + this.hoverMaterial = hoverMaterial; + + this.userData = { + isHoverable: true, + hoverableViews: ['earth'], + cursor: 'default', + countryName: geojsonProperties.nameFR, + }; + this.name = 'country'; + } + + public onMouseEnter(): void { + this.material = this.hoverMaterial; + } + public onMouseLeave(): void { + this.material = this.baseMaterials; + } +} diff --git a/src/components/WorldMap/world/objects/countryGroup.ts b/src/components/WorldMap/world/objects/countryGroup.ts new file mode 100644 index 000000000..e624722e3 --- /dev/null +++ b/src/components/WorldMap/world/objects/countryGroup.ts @@ -0,0 +1,37 @@ +import type { Position } from 'geojson'; +import { Line, Group, LineBasicMaterial } from 'three'; + +import { GeoJsonGeometry } from '../lib/geo-json-geometry'; +import type { GeoJSONCountryData } from './country'; +import { Country } from './country'; + +const COLORS: { [key: string]: string[] } = { + 'North America': ['#ede7f6', '#d1c4e9', '#b39ddb', '#9575cd', '#7e57c2', '#673ab7'], + Asia: ['#e0f7fa', '#b2ebf2', '#80deea', '#4dd0e1', '#26c6da', '#00bcd4'], + Africa: ['#fff8e1', '#ffecb3', '#ffe082', '#ffd54f', '#ffca28', '#ffc107'], + Oceania: ['#ffccbc', '#ffab91', '#ff8a65', '#ff7043', '#ff5722', '#fbe9e7'], + Europe: ['#fce4ec', '#f8bbd0', '#f48fb1', '#f06292', '#ec407a', '#e91e63'], + 'South America': ['#BBF7D0', '#99F6E4', '#047857', '#059669', '#34D399', '#6EE7B7'], +}; + +export class CountryGroup extends Group { + constructor(geoJson: GeoJSONCountryData, index = 0) { + super(); + + const polygons: Position[][][] = []; + if (geoJson.geometry.type === 'Polygon') { + polygons.push(geoJson.geometry.coordinates); + } else if (geoJson.geometry.type === 'MultiPolygon') { + polygons.push(...geoJson.geometry.coordinates); + } + + const lineMaterial = new LineBasicMaterial({ color: 'white' }); + for (const polygon of polygons) { + const country = new Country(polygon, geoJson.properties, COLORS[geoJson.properties.continent][index % 6]); + this.add(country); + this.add(new Line(new GeoJsonGeometry({ type: 'Polygon', coordinates: polygon }, 1), lineMaterial)); + } + + this.name = 'country-group'; + } +} diff --git a/src/components/WorldMap/world/objects/earth.ts b/src/components/WorldMap/world/objects/earth.ts new file mode 100644 index 000000000..4dbdd2432 --- /dev/null +++ b/src/components/WorldMap/world/objects/earth.ts @@ -0,0 +1,115 @@ +import type { Vector3 } from 'three'; +import { DirectionalLight, Group, Mesh, MeshBasicMaterial, SphereGeometry } from 'three'; + +import type { HoverableObject } from '../lib/hoverable-object'; +import { ImageTexture } from '../lib/image-texture'; +import { GLOBE_IMAGE_URL, GLOBE_RADIUS } from '../world.constants'; +import { AtmosphereGlow } from './atmosphere-glow'; +import type { GeoLabel } from './capital'; +import { Capital } from './capital'; +import type { GeoJSONCountryData } from './country'; +import { CountryGroup } from './countryGroup'; +import { GlobeOutline } from './globe-outline'; +import { Pin } from './pin'; +import type { User } from 'types/user.type'; + +export class Earth extends Group implements HoverableObject { + public countryVisibility: boolean; + public userVisibility: boolean; + public userData: HoverableObject['userData']; + + constructor() { + super(); + + // Add globe + const globeGeometry = new SphereGeometry(GLOBE_RADIUS, 75, 75); + const defaultGlobeMaterial = new MeshBasicMaterial({ + map: new ImageTexture(GLOBE_IMAGE_URL), + transparent: false, + }); + const globeObj = new Mesh(globeGeometry, defaultGlobeMaterial); + globeObj.rotation.y = -Math.PI / 2; // face prime meridian along Z axis + globeObj.name = 'globe'; + this.add(globeObj); + + // Add glow + const glowObj = new AtmosphereGlow(); + this.add(glowObj); + + // Add outline + const outlineMesh1 = new GlobeOutline(GLOBE_RADIUS, 0x000000, 2); + const outlineMesh2 = new GlobeOutline(GLOBE_RADIUS, 0x4c3ed9, 6); + this.add(outlineMesh1); + this.add(outlineMesh2); + + // Add directional light + this.add(new DirectionalLight(0xffffff, 0.6)); + + // Set data + this.countryVisibility = true; + this.userVisibility = true; + this.name = 'earth'; + this.userData = { + isHoverable: true, + hoverableViews: ['global'], + hovarableTargets: [globeObj], + }; + } + + public addCountries(countries: GeoJSONCountryData[]) { + this.remove(...this.children.filter((child) => child.name === 'country-group')); + for (let i = 0; i < countries.length; i++) { + const newCountry = new CountryGroup(countries[i], i); + newCountry.visible = this.countryVisibility; + this.add(newCountry); + } + } + public addCapitals(capitals: GeoLabel[]) { + this.remove(...this.children.filter((child) => child.name === 'capital')); + for (let i = 0; i < capitals.length; i++) { + const newCapital = new Capital(capitals[i]); + newCapital.visible = this.countryVisibility; + this.add(newCapital); + } + } + public setCountryVisibility(isVisible: boolean) { + this.countryVisibility = isVisible; + for (const child of this.children) { + if (child.name === 'country-group' || child.name === 'capital') { + child.visible = isVisible; + } + } + } + + public addUsers(users: User[], cameraPos: Vector3) { + this.remove(...this.children.filter((child) => child.name === 'pin')); + for (const user of users) { + const userPin = new Pin(user, cameraPos); + userPin.visible = this.userVisibility; + this.add(userPin); + } + } + public setUserVisibility(isVisible: boolean) { + this.userVisibility = isVisible; + for (const child of this.children) { + if (child.name === 'pin') { + child.visible = isVisible; + } + } + } + + public onMouseEnter(): void { + for (const child of this.children) { + if (child.name === 'globe-outline') { + child.visible = true; + } + } + } + public onMouseLeave(): void { + for (const child of this.children) { + if (child.name === 'globe-outline') { + child.visible = false; + } + } + } +} diff --git a/src/components/WorldMap/world/objects/globe-outline.ts b/src/components/WorldMap/world/objects/globe-outline.ts new file mode 100644 index 000000000..a512b5d0f --- /dev/null +++ b/src/components/WorldMap/world/objects/globe-outline.ts @@ -0,0 +1,13 @@ +import { BackSide, Mesh, MeshBasicMaterial, SphereGeometry } from 'three'; + +export class GlobeOutline extends Mesh { + constructor(radius: number, color: number, width: number, visibleByDefault = false) { + const material = new MeshBasicMaterial({ color, side: BackSide }); + const geometry = new SphereGeometry(radius + width, 75, 75); + super(geometry, material); + + // By default, the outline is not visible + this.visible = visibleByDefault; + this.name = 'globe-outline'; + } +} diff --git a/src/components/WorldMap/world/objects/pelico.ts b/src/components/WorldMap/world/objects/pelico.ts new file mode 100644 index 000000000..694c1ed64 --- /dev/null +++ b/src/components/WorldMap/world/objects/pelico.ts @@ -0,0 +1,88 @@ +import { Vector3, Group, Mesh, MeshBasicMaterial, SphereGeometry } from 'three'; + +import type { User } from '../../../../../types/user.type'; +import type { HoverableObject } from '../lib/hoverable-object'; +import { ImageTexture } from '../lib/image-texture'; +import { GLOBE_RADIUS, PELICO_IMAGE_URL, PELICO_USER } from '../world.constants'; +import { AtmosphereGlow } from './atmosphere-glow'; +import { GlobeOutline } from './globe-outline'; +import { Pin } from './pin'; + +export class Pelico extends Group implements HoverableObject { + public userVisibility: boolean; + public userData: HoverableObject['userData']; + + constructor() { + super(); + + // Add globe + const globeGeometry = new SphereGeometry(GLOBE_RADIUS * 0.75, 75, 75); + const defaultGlobeMaterial = new MeshBasicMaterial({ map: new ImageTexture(PELICO_IMAGE_URL), transparent: false }); + const globeObj = new Mesh(globeGeometry, defaultGlobeMaterial); + globeObj.name = 'globe'; + this.add(globeObj); + + // Add glow + const glowObj = new AtmosphereGlow(GLOBE_RADIUS * 0.75); + this.add(glowObj); + + // Add outline + const outlineMesh1 = new GlobeOutline(GLOBE_RADIUS * 0.75, 0x000000, 2); + const outlineMesh2 = new GlobeOutline(GLOBE_RADIUS * 0.75, 0x4c3ed9, 6); + this.add(outlineMesh1); + this.add(outlineMesh2); + + // Set data + this.name = 'pelico'; + this.userData = { + isHoverable: true, + hoverableViews: ['global'], + hovarableTargets: [globeObj], + }; + } + + public addUsers(users: User[], cameraPos: Vector3) { + this.remove(...this.children.filter((child) => child.name === 'pin')); + // Add pelico user + const pelicoPin = new Pin(PELICO_USER, new Vector3(0, 0, 1), true, GLOBE_RADIUS * 0.75, true); + pelicoPin.visible = this.userVisibility; + this.add(pelicoPin); + + // Add all users + for (const user of users) { + const userPin = new Pin({ ...user, position: getRandomPos() }, cameraPos, true, GLOBE_RADIUS * 0.75); + userPin.visible = this.userVisibility; + this.add(userPin); + } + } + public setUserVisibility(isVisible: boolean) { + this.userVisibility = isVisible; + for (const child of this.children) { + if (child.name === 'pin') { + child.visible = isVisible; + } + } + } + + public onMouseEnter(): void { + for (const child of this.children) { + if (child.name === 'globe-outline') { + child.visible = true; + } + } + } + public onMouseLeave(): void { + for (const child of this.children) { + if (child.name === 'globe-outline') { + child.visible = false; + } + } + } +} + +const getRandomPos = () => { + return { + lat: Math.round(Math.random() * 180) - 90, + lng: Math.round(Math.random() * 360) - 180, + }; +}; diff --git a/src/components/WorldMap/data/pin.ts b/src/components/WorldMap/world/objects/pin.ts similarity index 50% rename from src/components/WorldMap/data/pin.ts rename to src/components/WorldMap/world/objects/pin.ts index 8264c244e..f91d2f258 100644 --- a/src/components/WorldMap/data/pin.ts +++ b/src/components/WorldMap/world/objects/pin.ts @@ -1,30 +1,15 @@ -import type { Vector3, Camera } from 'three'; -import { Group, CylinderGeometry, MeshStandardMaterial, Mesh, TextureLoader } from 'three'; +import { Vector3, Color, CylinderGeometry, Group, Mesh, MeshStandardMaterial } from 'three'; +import { clamp } from 'three/src/math/MathUtils'; import { polar2Cartesian } from '../lib/coords-utils'; import type { HoverableObject } from '../lib/hoverable-object'; +import { ImageTexture } from '../lib/image-texture'; import { loadGLB } from '../lib/load-glb'; -import { GLOBE_RADIUS } from '../world-map.constants'; -import { clamp, getGravatarUrl } from 'src/utils'; +import { GLOBE_RADIUS } from '../world.constants'; +import { getGravatarUrl } from 'src/utils'; import type { User } from 'types/user.type'; -export const getPins = async (users: Array, cameraPos: Vector3): Promise => { - const pins = new Group(); - pins.name = 'pins'; - - const pinModel = await getPinModel(); - const textureLoader = new TextureLoader(); - - for (const user of users) { - pins.add(new HoverablePin(pinModel, user, cameraPos, textureLoader)); - } - - return pins; -}; - -export const getPinModel = async () => loadGLB('/earth/pin.glb'); - -export class HoverablePin +export class Pin extends Group implements HoverableObject<{ @@ -35,20 +20,53 @@ export class HoverablePin lat: number; lng: number; }; + private isOnPelicoGlobe: boolean; public userData: HoverableObject<{ user: User; }>['userData']; - constructor(pinModel: Group, user: User, cameraPos: Vector3, textureLoader: TextureLoader) { + constructor(user: User, cameraPos: Vector3, isOnPelicoGlobe = false, radius = GLOBE_RADIUS, isPelico = false) { super(); + // place + this.coords = user.position; + const pos = polar2Cartesian(this.coords.lat, this.coords.lng, 2, radius); + this.position.x = pos.x; + this.position.y = pos.y; + this.position.z = pos.z; + this.up = this.position.clone().add(new Vector3(0, 10, 0)); + + this.lookAt(cameraPos); // face toward camera pos + + this.isOnPelicoGlobe = isOnPelicoGlobe; + this.userData = { + isHoverable: true, + hoverableViews: ['earth', 'global', 'pelico'], + hovarableTargets: this.children, + user, + }; + this.name = 'pin'; + + loadGLB( + '/earth/pin.glb', + (pinModel) => this.init(pinModel, user, isPelico), + () => {}, + ); + } + + public init(pinModel: Group, user: User, isPelico: boolean) { for (const child of pinModel.children) { - this.add(child.clone(true)); + const clonedChild = child.clone(true); + this.add(clonedChild); + if (isPelico) { + ((clonedChild as Mesh).material as MeshStandardMaterial).color = new Color(0x4c3ed9); + ((clonedChild as Mesh).material as MeshStandardMaterial).needsUpdate = true; + } } const cylindar = new Mesh( new CylinderGeometry(3.8, 3.8, 0.8, 30), - new MeshStandardMaterial({ color: 13111312, roughness: 0.292, metalness: 0.25 }), + new MeshStandardMaterial({ color: isPelico ? 0x4c3ed9 : 13111312, roughness: 0.292, metalness: 0.25 }), ); cylindar.position.y = 9.1; cylindar.position.z = 0.2; @@ -59,7 +77,7 @@ export class HoverablePin const cylindar2 = new Mesh( new CylinderGeometry(3.4, 3.4, 0.8, 30), new MeshStandardMaterial({ - map: textureLoader.load(imgSrc), + map: new ImageTexture(imgSrc), }), ); cylindar2.position.y = 9.1; @@ -67,29 +85,14 @@ export class HoverablePin cylindar2.rotation.x = Math.PI / 2; cylindar2.rotation.y = Math.PI / 2; this.add(cylindar2); - - this.userData = { - isHoverable: true, - isClickable: true, - type: 'pin', - user, - }; - this.name = 'pinGroup'; - - // place - this.coords = user.position; - const pos = polar2Cartesian(this.coords.lat, this.coords.lng, 2); - this.position.x = pos.x; - this.position.y = pos.y; - this.position.z = pos.z; - - // rotate - this.lookAt(cameraPos); // face toward camera pos } public update(cameraPos: Vector3, altitude: number) { // scale with altitude - const scale = clamp(altitude / 100 - 1, 0.2, 1); + let scale = clamp(altitude / 100 - 1, 0.2, 1); + if (this.isOnPelicoGlobe) { + scale *= 2; + } this.scale.x = scale; this.scale.y = scale; this.scale.z = scale; @@ -98,19 +101,6 @@ export class HoverablePin this.lookAt(cameraPos); } - public getData(): { user: User } { - return this.userData; - } - public getType(): string { - return this.userData.type; - } - public onHover(): void {} - public onReset(): void {} - - public onClick(camera: Camera, cameraAltitude: number): void { - const newPos = polar2Cartesian(this.coords.lat, this.coords.lng, Math.min(cameraAltitude, 200) - GLOBE_RADIUS); - camera.position.x = newPos.x; - camera.position.y = newPos.y; - camera.position.z = newPos.z; - } + onMouseEnter(): void {} + onMouseLeave(): void {} } diff --git a/src/components/WorldMap/world/objects/sky.ts b/src/components/WorldMap/world/objects/sky.ts new file mode 100644 index 000000000..d3caab79d --- /dev/null +++ b/src/components/WorldMap/world/objects/sky.ts @@ -0,0 +1,14 @@ +import { BackSide, Mesh, MeshBasicMaterial, SphereGeometry } from 'three'; + +import { ImageTexture } from '../lib/image-texture'; +import { BACKGROUND_IMAGE_URL } from '../world.constants'; + +export class Sky extends Mesh { + constructor() { + const skyGeometry = new SphereGeometry(50000, 75, 75); + const defaultSkyMaterial = new MeshBasicMaterial({ map: new ImageTexture(BACKGROUND_IMAGE_URL), side: BackSide }); + super(skyGeometry, defaultSkyMaterial); + + this.name = 'sky'; + } +} diff --git a/src/components/WorldMap/world/world.constants.ts b/src/components/WorldMap/world/world.constants.ts new file mode 100644 index 000000000..c50f85703 --- /dev/null +++ b/src/components/WorldMap/world/world.constants.ts @@ -0,0 +1,40 @@ +import type { User } from 'types/user.type'; +import { UserType } from 'types/user.type'; + +export const GLOBE_IMAGE_URL = '/static-images/earth-blue-marble.jpg'; +export const BACKGROUND_IMAGE_URL = '/static-images/night-sky.png'; +export const PELICO_IMAGE_URL = '/static-images/pelico-globe.jpg'; +export const GLOBE_RADIUS = 100; +export const SKY_RADIUS = GLOBE_RADIUS * 2000; + +/* camera zoom */ +export const MIN_DISTANCE = 110; +export const START_DISTANCE = 310; +export const MAX_DISTANCE = 510; +export const ZOOM_DELTA = 20; + +export const PELICO_USER: User = { + accountRegistration: 0, + address: '', + avatar: '/static-images/pelico-avatar.jpg', + city: '', + country: { + isoCode: 'FR', + name: 'France', + }, + displayName: null, + email: '', + firstLogin: 0, + id: 0, + level: '', + position: { + lat: 46.603354, // todo + lng: 1.8883335, // todo + }, + postalCode: '', + pseudo: 'Pelico', + school: '', + type: UserType.ADMIN, // pelico + villageId: 0, + village: null, +}; diff --git a/src/components/WorldMap/world/world.ts b/src/components/WorldMap/world/world.ts new file mode 100644 index 000000000..1d11fef7a --- /dev/null +++ b/src/components/WorldMap/world/world.ts @@ -0,0 +1,450 @@ +import type { Object3D } from 'three'; +import { Raycaster, Vector3, AmbientLight, PerspectiveCamera, Scene, WebGLRenderer } from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'; + +import type { User } from '../../../../types/user.type'; +import { clamp } from '../../../utils'; +import type { PopoverData } from '../Popover'; +import { Animations } from './animations'; +import { LinearAnimation } from './animations/linear-animation'; +import { cartesian2Polar, polar2Cartesian } from './lib/coords-utils'; +import { disposeNode } from './lib/dispose-node'; +import type { HoverableObject } from './lib/hoverable-object'; +import { isHoverable } from './lib/hoverable-object'; +import type { GeoLabel } from './objects/capital'; +import type { Country, GeoJSONCountryData } from './objects/country'; +import { Earth } from './objects/earth'; +import { Pelico } from './objects/pelico'; +import { Pin } from './objects/pin'; +import { Sky } from './objects/sky'; +import { GLOBE_RADIUS, MAX_DISTANCE, MIN_DISTANCE, SKY_RADIUS, START_DISTANCE, PELICO_USER } from './world.constants'; + +type View = 'earth' | 'global' | 'pelico'; +const CENTERS: Record = { + earth: new Vector3(-2 * GLOBE_RADIUS, -1 * GLOBE_RADIUS, 0), + global: new Vector3(), + pelico: new Vector3(1 * GLOBE_RADIUS, 1 * GLOBE_RADIUS, 0), +}; + +type MouseStyleSetter = (mouseStyle: React.CSSProperties['cursor']) => void; +type PopoverDataSetter = (popoverData: PopoverData | null) => void; + +export class World { + // -- global objects -- + private scene: Scene; + private renderer: WebGLRenderer; + private raycaster: Raycaster; + private camera: PerspectiveCamera; + private controls: OrbitControls; + private animations: Animations; + + // -- scene objects -- + private earth: Earth; + private pelico: Pelico; + private pelicoPin: Pin; + + // -- mouse pos -- + public canvasRect: DOMRect; + private mousePosition: { + x: number; + y: number; + } | null; + private setMouseStyle: MouseStyleSetter; + private setPopoverData: PopoverDataSetter; + private hoveredObject: HoverableObject | null; + + private view: 'earth' | 'global' | 'pelico'; + + constructor(canvas: HTMLCanvasElement, setMouseStyle: MouseStyleSetter, setPopoverData: PopoverDataSetter, selectedPhase: number) { + const width = canvas.clientWidth; + const height = canvas.clientHeight; + this.canvasRect = canvas.getBoundingClientRect(); + this.setMouseStyle = setMouseStyle; + this.setPopoverData = setPopoverData; + this.mousePosition = null; + this.hoveredObject = null; + + // -- Setup current view -- + this.view = selectedPhase === 3 ? 'pelico' : 'earth'; + + // -- Init scene, camera, and renderer -- + this.scene = new Scene(); + this.scene.add(new AmbientLight(0xffffff, 1)); + this.camera = new PerspectiveCamera(50, width / height, 0.1, SKY_RADIUS * 2.5); + this.camera.position.x = CENTERS[this.view].x; + this.camera.position.y = CENTERS[this.view].y; + this.camera.position.z = START_DISTANCE; + this.renderer = new WebGLRenderer({ canvas, powerPreference: 'high-performance', antialias: true, alpha: true }); + this.renderer.setPixelRatio(window.devicePixelRatio || 1); + this.renderer.setSize(width, height, false); + this.raycaster = new Raycaster(); + + // -- Add earth -- + this.earth = new Earth(); + this.earth.position.copy(CENTERS.earth); + this.earth.setCountryVisibility(false); + this.earth.visible = this.view === 'earth'; + this.scene.add(this.earth); + + // -- Add pelico globe -- + this.pelico = new Pelico(); + this.pelico.position.copy(CENTERS.pelico); + this.pelico.visible = this.view === 'pelico'; + this.scene.add(this.pelico); + + // -- Add pelico user -- + this.pelicoPin = new Pin(PELICO_USER, new Vector3(0, 0, 1), true, GLOBE_RADIUS * 0.75, true); + const pos = polar2Cartesian(30, -90, 2, GLOBE_RADIUS * 0.75); + this.pelicoPin.position.x = pos.x; + this.pelicoPin.position.y = pos.y; + this.pelicoPin.position.z = pos.z; + this.pelicoPin.position.add(CENTERS.pelico); + this.pelicoPin.lookAt(this.pelicoPin.position.clone().add(new Vector3(0, 0, 1))); + this.pelicoPin.rotateZ(0.9); + this.pelicoPin.scale.set(8, 8, 8); + this.pelicoPin.visible = false; + this.scene.add(this.pelicoPin); + + // -- Add sky -- + this.scene.add(new Sky()); + + // -- Setup camera controls -- + this.controls = new OrbitControls(this.camera, this.renderer.domElement); + this.controls.minDistance = MIN_DISTANCE; + this.controls.maxDistance = MAX_DISTANCE; + this.controls.enablePan = false; + this.controls.enableDamping = false; + this.controls.target = CENTERS[this.view].clone(); + this.controls.rotateSpeed = 0.2; + this.controls.zoomSpeed = 0.2; + this.controls.addEventListener('change', this.onCameraChange.bind(this)); + this.controls.addEventListener('start', this.onUserCameraMove.bind(this)); + this.controls.autoRotate = true; + this.controls.autoRotateSpeed = 0.4; + + // -- Add animations + this.animations = new Animations(); + } + + public addCountriesAndCapitals({ countries, capitals }: { countries: GeoJSONCountryData[]; capitals: GeoLabel[] }) { + this.earth.addCountries(countries); + this.earth.addCapitals(capitals); + } + + public addUsers(users: User[]) { + this.earth.addUsers(users, this.camera.position.clone().sub(CENTERS[this.view])); + this.pelico.addUsers(users, this.camera.position.clone().sub(CENTERS[this.view])); + } + + private onResize() { + const canvas = this.renderer.domElement; + const width = canvas.clientWidth; + const height = canvas.clientHeight; + const needResize = canvas.width !== width || canvas.height !== height; + if (needResize) { + this.canvasRect = canvas.getBoundingClientRect(); + this.camera.aspect = width / height; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(width, height, false); + } + } + + private previousTimestamp: number | undefined = undefined; + public render(timestamp: number) { + // Update resize if needed + this.onResize(); + + // Update animations + const dt = this.previousTimestamp ? timestamp - this.previousTimestamp : timestamp; + this.previousTimestamp = timestamp; + this.animations.animate(dt); + + // Animate global view + if (this.view === 'global') { + this.animateGlobalView(dt); + } + + // Update hovered object + this.onHover(); + + // Update camera + if (this.view !== 'global') { + this.controls.update(); + } + + // Update scene + this.renderer.render(this.scene, this.camera); + } + + public dispose() { + this.renderer.renderLists.dispose(); + this.renderer.dispose(); + this.controls.dispose(); + this.scene.children.forEach(disposeNode); + } + + public onZoom(delta: number) { + if (this.view === 'global') { + return; + } + const center = CENTERS[this.view]; + const { lat, lng, altitude } = cartesian2Polar(this.camera.position.clone().sub(center)); + const { x, y, z } = polar2Cartesian(lat, lng, clamp(altitude + delta, MIN_DISTANCE, MAX_DISTANCE) - GLOBE_RADIUS); + this.camera.position.x = x + center.x; + this.camera.position.y = y + center.y; + this.camera.position.z = z + center.z; + } + + private onCameraChange() { + const altitude = this.camera.position.clone().distanceTo(CENTERS[this.view]); + if (altitude > MAX_DISTANCE - 10 && this.view !== 'global') { + this.transitionToGlobal(); + } + const showDecors = altitude < 240; + if (this.earth.countryVisibility !== showDecors) { + this.earth.setCountryVisibility(showDecors); + } + this.updateUsers(altitude); + } + + private updateUsers(altitude: number) { + for (const child of this.earth.children) { + if (child.name === 'pin') { + (child as Pin).update(this.camera.position.clone(), altitude); + } + } + for (const child of this.pelico.children) { + if (child.name === 'pin') { + (child as Pin).update(this.camera.position.clone(), altitude); + } + } + } + + private setAltitude(altitude: number) { + const center = CENTERS[this.view]; + const { lat, lng } = cartesian2Polar(this.camera.position.clone().sub(center)); + const { x, y, z } = polar2Cartesian(lat, lng, clamp(altitude, MIN_DISTANCE, MAX_DISTANCE) - GLOBE_RADIUS); + this.camera.position.x = x + center.x; + this.camera.position.y = y + center.y; + this.camera.position.z = z + center.z; + } + + private transitionToGlobal() { + this.setAltitude(MAX_DISTANCE); + this.animations.cancelAnimations(); + this.earth.setCountryVisibility(false); + this.earth.setUserVisibility(false); + this.pelico.setUserVisibility(false); + const previousView = this.view; + const center = CENTERS[this.view]; + const polarAngle = this.controls.getPolarAngle(); + const azimuthalAngle = this.controls.getAzimuthalAngle(); + this.controls.enabled = false; + this.view = 'global'; + + // Translate camera to center. + let prevX = 0; + this.animations.addAnimation( + new LinearAnimation(0, -center.x, 250, (x) => { + this.camera.translateX(x - prevX); + prevX = x; + }), + ); + let prevY = 0; + this.animations.addAnimation( + new LinearAnimation(0, -center.y, 250, (y) => { + this.camera.translateY(y - prevY); + prevY = y; + }), + ); + + setTimeout(() => { + this.camera.position.x = 0; + this.camera.position.y = 0; + this.camera.position.z = MAX_DISTANCE; + this.camera.lookAt(new Vector3()); + this.scene.children.forEach((child) => { + if (child.name === previousView) { + child.rotateX(Math.PI / 2 - polarAngle); + child.rotateY(-azimuthalAngle); + } + }); + let prev = 0; + this.animations.addAnimation( + new LinearAnimation(0, polarAngle - Math.PI / 2, 200, (x) => { + this.scene.children.forEach((child) => { + if (child.name === previousView) { + child.rotateOnWorldAxis(new Vector3(1, 0, 0), x - prev); + prev = x; + } + }); + }), + ); + this.scene.children.forEach((child) => { + if ((child.name === 'pelico' && previousView === 'earth') || (child.name === 'earth' && previousView === 'pelico') || child.name === 'pin') { + child.visible = true; + child.scale.set(0, 0, 0); + } + }); + this.animations.addAnimation( + new LinearAnimation(0, 1, 200, (scale) => { + this.scene.children.forEach((child) => { + if ((child.name === 'pelico' && previousView === 'earth') || (child.name === 'earth' && previousView === 'pelico')) { + child.scale.set(scale, scale, scale); + } + if (child.name === 'pin') { + child.scale.set(scale * 8, scale * 8, scale * 8); + } + }); + }), + ); + }, 250); + } + + private onUserCameraMove() { + if (this.controls.autoRotate) { + this.controls.autoRotate = false; + setTimeout( + () => { + this.controls.autoRotate = true; + }, + this.view === 'earth' ? 1000 * 5 : 1000, + ); + } + } + + public onMouseMove(event: React.MouseEvent) { + const { top, left, width, height } = this.canvasRect; + // calculate mouse position in normalized device coordinates, (-1 to +1) for both components + this.mousePosition = { + x: ((event.clientX - left) / width) * 2 - 1, + y: 1 - ((event.clientY - top) / height) * 2, + }; + } + + public onHover() { + if (this.mousePosition === null) { + this.hoveredObject?.onMouseLeave(); + this.hoveredObject = null; + this.setMouseStyle('default'); + this.setPopoverData(null); + return; + } + + const hoverableObjects: Object3D[] = []; + this.scene.traverseVisible((object) => { + if (isHoverable(object) && object.userData.hoverableViews.includes(this.view)) { + if (object.userData.hovarableTargets) { + hoverableObjects.push(...object.userData.hovarableTargets); + } else { + hoverableObjects.push(object); + } + } + }); + this.raycaster.setFromCamera(this.mousePosition, this.camera); + const intersections = this.raycaster.intersectObjects(hoverableObjects, false); + const firstIntersectObject = intersections[0]?.object || null; + let hoveredObject: HoverableObject | null = null; + if (firstIntersectObject && isHoverable(firstIntersectObject)) { + hoveredObject = firstIntersectObject; + } else if (firstIntersectObject && isHoverable(firstIntersectObject.parent)) { + hoveredObject = firstIntersectObject.parent; + } + + // Update hovered events + if (this.hoveredObject !== hoveredObject) { + this.hoveredObject?.onMouseLeave(); + this.hoveredObject = hoveredObject; + this.hoveredObject?.onMouseEnter(); + this.setMouseStyle(this.hoveredObject === null ? 'default' : this.hoveredObject.userData.cursor || 'pointer'); + if (this.hoveredObject !== null && this.hoveredObject.name === 'country') { + this.setPopoverData({ + type: 'country', + data: { + country: (this.hoveredObject as Country).userData.countryName, + }, + }); + } else if (this.hoveredObject !== null && this.hoveredObject.name === 'pin') { + this.setPopoverData({ + type: 'user', + data: (this.hoveredObject as Pin).userData.user, + }); + } else { + this.setPopoverData(null); + } + } + } + + public getHoveredObjectName() { + return this.hoveredObject?.name; + } + public resetHoverState() { + this.mousePosition = null; + } + + public changeView(newView: 'pelico' | 'earth') { + if (this.view === newView) { + return; + } + this.resetHoverState(); + this.camera.lookAt(CENTERS[newView]); + this.camera.position.x = CENTERS[newView].clone().x; + this.camera.position.y = CENTERS[newView].clone().y; + this.camera.position.z = START_DISTANCE; + this.pelicoPin.visible = false; + if (newView === 'earth') { + this.view = 'earth'; + this.setAltitude(START_DISTANCE); + this.controls.target = CENTERS[this.view].clone(); + this.controls.enabled = true; + this.controls.autoRotate = true; + this.animations.cancelAnimations(); + this.earth.rotation.set(0, 0, 0); + this.earth.visible = true; + this.pelico.visible = false; + this.earth.setCountryVisibility(false); + this.earth.setUserVisibility(true); + } else { + this.view = 'pelico'; + this.setAltitude(START_DISTANCE); + this.controls.target = CENTERS[this.view].clone(); + this.controls.enabled = true; + this.controls.autoRotate = true; + this.pelico.rotation.set(0, 0, 0); + this.animations.cancelAnimations(); + this.pelico.visible = true; + this.earth.visible = false; + this.pelico.setUserVisibility(true); + } + } + + public onClick() { + if (this.hoveredObject !== null && this.hoveredObject.name === 'earth') { + this.changeView('earth'); + } + if (this.hoveredObject !== null && this.hoveredObject.name === 'pelico') { + this.changeView('pelico'); + } + if (this.hoveredObject !== null && this.hoveredObject.name === 'pin' && this.view !== 'global') { + const altitude = this.camera.position.clone().distanceTo(CENTERS[this.view]); + const coords = (this.hoveredObject as Pin).userData.user.position; + const center = CENTERS[this.view].clone(); + const { x, y, z } = polar2Cartesian(coords.lat, coords.lng, 100); + this.camera.position.x = x + center.x; + this.camera.position.y = y + center.y; + this.camera.position.z = z + center.z; + this.setAltitude(altitude); + } + } + + private animateGlobalView(dt: number) { + for (const child of this.scene.children) { + if (child.name === 'earth') { + child.rotateY(dt / 3000); + } + if (child.name === 'pelico') { + child.rotateY(dt / 4000); + } + } + } +} diff --git a/src/contexts/villageContext.tsx b/src/contexts/villageContext.tsx index 65a1267cd..1af949a09 100644 --- a/src/contexts/villageContext.tsx +++ b/src/contexts/villageContext.tsx @@ -41,7 +41,9 @@ export const VillageContextProvider = ({ initialVillage, children }: VillageCont const [village, setVillage] = React.useState(initialVillage); const [villages, setVillages] = React.useState([]); const [selectedVillageIndex, setSelectedVillageIndex] = React.useState(-1); - const [selectedPhase, setSelectedPhase] = React.useState(user !== null ? (user.firstLogin === 0 ? 1 : user.firstLogin) : -1); + const [selectedPhase, setSelectedPhase] = React.useState( + user !== null ? (user.type >= UserType.MEDIATOR ? village?.activePhase ?? 1 : user.firstLogin === 0 ? 1 : user.firstLogin) : -1, + ); const [isModalOpen, setIsModalOpen] = React.useState(false); const [showUnassignedModal, setShowUnassignedModal] = React.useState(user !== null && user.villageId === null && user.type <= UserType.MEDIATOR); diff --git a/yarn.lock b/yarn.lock index 28dcbe46f..420084e68 100644 --- a/yarn.lock +++ b/yarn.lock @@ -52,8 +52,8 @@ __metadata: "@types/sharp": 0.30.4 "@types/sortablejs": 1.13.0 "@types/string-similarity": 4.0.0 - "@types/supertest": ^2.0.12 - "@types/three": 0.141.0 + "@types/supertest": 2.0.12 + "@types/three": 0.149.0 "@types/uuid": 8.3.4 "@types/vimeo": 2.1.4 "@types/webpack": 5.28.0 @@ -127,9 +127,9 @@ __metadata: sqlite3: ^5.1.2 string-similarity: ^4.0.4 supertest: ^6.3.3 - three: 0.141.0 - three-conic-polygon-geometry: ^1.5.1 - troika-three-text: ^0.46.4 + three: 0.150.1 + three-conic-polygon-geometry: ^1.6.1 + troika-three-text: ^0.47.1 typeorm: ^0.3.11 typescript: 4.7.4 uuid: ^8.3.2 @@ -4170,7 +4170,7 @@ __metadata: languageName: node linkType: hard -"@types/supertest@npm:^2.0.12": +"@types/supertest@npm:2.0.12": version: 2.0.12 resolution: "@types/supertest@npm:2.0.12" dependencies: @@ -4179,12 +4179,12 @@ __metadata: languageName: node linkType: hard -"@types/three@npm:0.141.0": - version: 0.141.0 - resolution: "@types/three@npm:0.141.0" +"@types/three@npm:0.149.0": + version: 0.149.0 + resolution: "@types/three@npm:0.149.0" dependencies: "@types/webxr": "*" - checksum: b4d1fd19eec01a15374a9e0a181207f0b2a27df881f3cfc63ce167b36b214c6926d80ac0eeb80c365832f69e7180db514a859808150ece1c1503a4eb58d4ac1e + checksum: 5def82bd5d4fb0eb6046e747eecced06d68fbf1747ab8f3e7657872e42e58ce7a92218d7c5e4ca26204257927638ad14da5d1cce7310aca15eb8a9bd484ea191 languageName: node linkType: hard @@ -14209,9 +14209,9 @@ resolve@^2.0.0-next.3: languageName: node linkType: hard -"three-conic-polygon-geometry@npm:^1.5.1": - version: 1.5.1 - resolution: "three-conic-polygon-geometry@npm:1.5.1" +"three-conic-polygon-geometry@npm:^1.6.1": + version: 1.6.1 + resolution: "three-conic-polygon-geometry@npm:1.6.1" dependencies: "@turf/boolean-point-in-polygon": ^6.5 d3-array: 1 - 3 @@ -14222,14 +14222,14 @@ resolve@^2.0.0-next.3: earcut: 2 peerDependencies: three: ">=0.72.0" - checksum: ddfa1f1db8d87a749c8a1ab22fce01b9597e7f9e676db46dd57a7580038bf9690634ad1ad8ad4b97860aba3a4bc9cc547ed383611f54eebd7839fc4f2413a452 + checksum: e5bbb3b09f4e4195e611715bc94b52851af1073afdf0a50c660349c26061ac9edb5945b33e7b0e2a4a1373e5b02695ab055c9577dfb73a8982521834507f7aa5 languageName: node linkType: hard -"three@npm:0.141.0": - version: 0.141.0 - resolution: "three@npm:0.141.0" - checksum: d161350b5134f2db616c7081d625c84235a1d50658b9f9c6ffe94143ddb77944b1c1b6831ad0be00037f8c744c8c60ee8d740ea4d48844b54f4525ee73a2d44f +"three@npm:0.150.1": + version: 0.150.1 + resolution: "three@npm:0.150.1" + checksum: b12a92a681a30e62640d90bdf074d039c05d8294ad40f437e7e1807ce0e698ad6728878a1e72dad22bf05d7a359843ecea8739d05f2f13143bc3a4ecdfdbdd7b languageName: node linkType: hard @@ -14378,33 +14378,33 @@ resolve@^2.0.0-next.3: languageName: node linkType: hard -"troika-three-text@npm:^0.46.4": - version: 0.46.4 - resolution: "troika-three-text@npm:0.46.4" +"troika-three-text@npm:^0.47.1": + version: 0.47.1 + resolution: "troika-three-text@npm:0.47.1" dependencies: bidi-js: ^1.0.2 - troika-three-utils: ^0.46.0 - troika-worker-utils: ^0.46.0 + troika-three-utils: ^0.47.0 + troika-worker-utils: ^0.47.0 webgl-sdf-generator: 1.1.1 peerDependencies: - three: ">=0.103.0" - checksum: d36ca41079cf70804ab325a4f776b51fa917e4efbd80c97061e3d9180a1f8c21b2bf100bd502b6efff4aaafb1b70aef3aa73bdce6e62aabecc9faa7239c1a371 + three: ">=0.125.0" + checksum: 854d74e1f53898a22d34788f29efea59d253037b6dd4c518b46c60ebb42960ce026d8ced5dde8a04143e963afbe92cdd55123fb9d9c2d1e555d1f9c31cdbe21f languageName: node linkType: hard -"troika-three-utils@npm:^0.46.0": - version: 0.46.0 - resolution: "troika-three-utils@npm:0.46.0" +"troika-three-utils@npm:^0.47.0": + version: 0.47.0 + resolution: "troika-three-utils@npm:0.47.0" peerDependencies: - three: ">=0.103.0" - checksum: 75e926bf147c902c60fd5f013a6f9f481f67d610f93c4cf25146e56e4bd36a6f8f81b00ae0b1dc2af01f6dc7a9f051a334d415228b8a5a44d423be85f24de337 + three: ">=0.125.0" + checksum: 2541865d957317c9ceaf386025776ad431ad58ad4bea80782a4607f712f1ad13fe727cc83ac0e6063e9dedc2a7c2dfea8ca385fcdd5b8562bf777a6f87ed75f1 languageName: node linkType: hard -"troika-worker-utils@npm:^0.46.0": - version: 0.46.0 - resolution: "troika-worker-utils@npm:0.46.0" - checksum: 5fe6c17e34f2b840c83d87e7092891bf62f12cf5804ee4d97a9bbc11e4f1d85f81894a758322187d4fa40c76b359088a1aeb0e69e26b7f391f7d2834ef0fa33f +"troika-worker-utils@npm:^0.47.0": + version: 0.47.0 + resolution: "troika-worker-utils@npm:0.47.0" + checksum: 9fa14a58feba121a80576131eafdfe02c095e2695c071250b6517ea4e40c4f0600be2a9f41da34fb24f6870091f528124d6e47f948c85fdf585dc97f4245d720 languageName: node linkType: hard