From e32cfd80150fa25d089081b0751135990b85cb78 Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Fri, 27 Mar 2026 23:09:08 +0800
Subject: [PATCH] Add three.js weather aura layer to dashboard map
---
.../components/dashboard/Dashboard.module.css | 40 +++
.../dashboard/PolyWeatherDashboard.tsx | 12 +
.../components/dashboard/WeatherAuraLayer.tsx | 229 ++++++++++++++++++
frontend/hooks/usePrefersReducedMotion.ts | 26 ++
frontend/lib/weather-aura.ts | 66 +++++
frontend/package-lock.json | 132 +++++++++-
frontend/package.json | 4 +-
7 files changed, 505 insertions(+), 4 deletions(-)
create mode 100644 frontend/components/dashboard/WeatherAuraLayer.tsx
create mode 100644 frontend/hooks/usePrefersReducedMotion.ts
create mode 100644 frontend/lib/weather-aura.ts
diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css
index b4ad2066..9b050258 100644
--- a/frontend/components/dashboard/Dashboard.module.css
+++ b/frontend/components/dashboard/Dashboard.module.css
@@ -75,6 +75,46 @@
z-index: 1;
}
+.weatherAura {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ pointer-events: none;
+ opacity: 0.88;
+ mix-blend-mode: screen;
+ overflow: hidden;
+}
+
+.weatherAura :global(canvas) {
+ width: 100%;
+ height: 100%;
+ display: block;
+ opacity: 0.94;
+ filter: saturate(1.05) blur(0.3px);
+}
+
+.weatherAuraScrim {
+ position: absolute;
+ inset: 0;
+ background:
+ linear-gradient(
+ 180deg,
+ rgba(5, 10, 24, 0.52) 0%,
+ rgba(6, 10, 22, 0.12) 24%,
+ rgba(5, 8, 19, 0.08) 54%,
+ rgba(4, 6, 15, 0.34) 100%
+ ),
+ radial-gradient(
+ circle at 50% 60%,
+ rgba(92, 142, 255, 0.06) 0%,
+ rgba(92, 142, 255, 0) 48%
+ );
+}
+
+.weatherAura[data-reduced-motion="true"] :global(canvas) {
+ display: none;
+}
+
.root :global(.map .leaflet-tile),
.root :global(.map .leaflet-marker-icon),
.root :global(.map .leaflet-marker-shadow),
diff --git a/frontend/components/dashboard/PolyWeatherDashboard.tsx b/frontend/components/dashboard/PolyWeatherDashboard.tsx
index 3ef6912e..7adc4bf5 100644
--- a/frontend/components/dashboard/PolyWeatherDashboard.tsx
+++ b/frontend/components/dashboard/PolyWeatherDashboard.tsx
@@ -21,6 +21,17 @@ const MapCanvas = dynamic(
},
);
+const WeatherAuraLayer = dynamic(
+ () =>
+ import("@/components/dashboard/WeatherAuraLayer").then(
+ (module) => module.WeatherAuraLayer,
+ ),
+ {
+ ssr: false,
+ loading: () => null,
+ },
+);
+
const HistoryModal = dynamic(
() =>
import("@/components/dashboard/HistoryModal").then(
@@ -83,6 +94,7 @@ function DashboardScreen() {
return (
+
diff --git a/frontend/components/dashboard/WeatherAuraLayer.tsx b/frontend/components/dashboard/WeatherAuraLayer.tsx
new file mode 100644
index 00000000..cd81c913
--- /dev/null
+++ b/frontend/components/dashboard/WeatherAuraLayer.tsx
@@ -0,0 +1,229 @@
+"use client";
+
+import { CSSProperties, useEffect, useRef, useState } from "react";
+import * as THREE from "three";
+import { useDashboardStore } from "@/hooks/useDashboardStore";
+import { usePrefersReducedMotion } from "@/hooks/usePrefersReducedMotion";
+import { getWeatherAuraProfile } from "@/lib/weather-aura";
+import styles from "./Dashboard.module.css";
+
+function hexToRgba(hex: string, alpha: number) {
+ const sanitized = hex.replace("#", "");
+ const normalized =
+ sanitized.length === 3
+ ? sanitized
+ .split("")
+ .map((char) => `${char}${char}`)
+ .join("")
+ : sanitized.padEnd(6, "0");
+ const numeric = Number.parseInt(normalized, 16);
+ const r = (numeric >> 16) & 255;
+ const g = (numeric >> 8) & 255;
+ const b = numeric & 255;
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+}
+
+export function WeatherAuraLayer() {
+ const store = useDashboardStore();
+ const prefersReducedMotion = usePrefersReducedMotion();
+ const [isDesktop, setIsDesktop] = useState(false);
+ const containerRef = useRef
(null);
+
+ const aura = getWeatherAuraProfile(store.selectedDetail, store.cities);
+
+ useEffect(() => {
+ if (typeof window === "undefined" || !window.matchMedia) {
+ return;
+ }
+
+ const mediaQuery = window.matchMedia("(min-width: 1024px)");
+ const apply = () => {
+ setIsDesktop(mediaQuery.matches);
+ };
+
+ apply();
+ mediaQuery.addEventListener("change", apply);
+ return () => {
+ mediaQuery.removeEventListener("change", apply);
+ };
+ }, []);
+
+ useEffect(() => {
+ const host = containerRef.current;
+ if (!host || !isDesktop || prefersReducedMotion) {
+ return;
+ }
+
+ const renderer = new THREE.WebGLRenderer({
+ alpha: true,
+ antialias: true,
+ powerPreference: "low-power",
+ });
+ renderer.setClearColor(0x000000, 0);
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
+
+ const scene = new THREE.Scene();
+ const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
+ camera.position.z = 2;
+
+ const clock = new THREE.Clock();
+ const particleGroups: Array<{
+ geometry: THREE.BufferGeometry;
+ positions: Float32Array;
+ baseY: Float32Array;
+ drift: Float32Array;
+ phase: Float32Array;
+ }> = [];
+
+ function createParticleField(
+ count: number,
+ pointSize: number,
+ opacity: number,
+ depthShift: number,
+ ) {
+ const geometry = new THREE.BufferGeometry();
+ const positions = new Float32Array(count * 3);
+ const colors = new Float32Array(count * 3);
+ const baseY = new Float32Array(count);
+ const drift = new Float32Array(count);
+ const phase = new Float32Array(count);
+ const primaryColor = new THREE.Color(aura.primary);
+ const secondaryColor = new THREE.Color(aura.secondary);
+ const tertiaryColor = new THREE.Color(aura.tertiary);
+
+ for (let index = 0; index < count; index += 1) {
+ const offset = index * 3;
+ const x = Math.random() * 2.6 - 1.3;
+ const y = Math.random() * 1.8 - 0.9;
+ const z = (Math.random() * 0.8 - 0.4) + depthShift;
+ positions[offset] = x;
+ positions[offset + 1] = y;
+ positions[offset + 2] = z;
+ baseY[index] = y;
+ drift[index] = (0.00045 + Math.random() * 0.0012) * aura.drift;
+ phase[index] = Math.random() * Math.PI * 2;
+
+ const mixedColor = primaryColor
+ .clone()
+ .lerp(secondaryColor, Math.random() * 0.65)
+ .lerp(tertiaryColor, Math.random() * 0.4);
+ colors[offset] = mixedColor.r;
+ colors[offset + 1] = mixedColor.g;
+ colors[offset + 2] = mixedColor.b;
+ }
+
+ geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
+ geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
+
+ const material = new THREE.PointsMaterial({
+ size: pointSize,
+ transparent: true,
+ opacity,
+ vertexColors: true,
+ depthWrite: false,
+ blending: THREE.AdditiveBlending,
+ sizeAttenuation: true,
+ });
+
+ const points = new THREE.Points(geometry, material);
+ scene.add(points);
+ particleGroups.push({ geometry, positions, baseY, drift, phase });
+ }
+
+ createParticleField(90, 0.018, aura.particleOpacity * 0.9, -0.1);
+ createParticleField(60, 0.026, aura.particleOpacity * 0.65, 0.08);
+
+ const resize = () => {
+ const width = host.clientWidth || window.innerWidth;
+ const height = host.clientHeight || window.innerHeight;
+ renderer.setSize(width, height, false);
+ };
+
+ resize();
+ host.appendChild(renderer.domElement);
+
+ let frameId = 0;
+ let lastFrameAt = 0;
+
+ const renderFrame = (timestamp: number) => {
+ frameId = window.requestAnimationFrame(renderFrame);
+ if (timestamp - lastFrameAt < 40) {
+ return;
+ }
+ lastFrameAt = timestamp;
+
+ const elapsed = clock.getElapsedTime();
+ for (const field of particleGroups) {
+ for (let index = 0; index < field.baseY.length; index += 1) {
+ const offset = index * 3;
+ let nextX = field.positions[offset] + field.drift[index];
+ if (nextX > 1.35) {
+ nextX = -1.35;
+ field.baseY[index] = Math.random() * 1.8 - 0.9;
+ }
+ field.positions[offset] = nextX;
+ field.positions[offset + 1] =
+ field.baseY[index] +
+ Math.sin(elapsed * 0.45 + field.phase[index] + nextX * 2.4) *
+ 0.06 *
+ aura.intensity;
+ }
+
+ field.geometry.attributes.position.needsUpdate = true;
+ }
+
+ renderer.render(scene, camera);
+ };
+
+ frameId = window.requestAnimationFrame(renderFrame);
+ window.addEventListener("resize", resize);
+
+ return () => {
+ window.cancelAnimationFrame(frameId);
+ window.removeEventListener("resize", resize);
+ for (const child of [...scene.children]) {
+ scene.remove(child);
+ }
+ for (const field of particleGroups) {
+ field.geometry.dispose();
+ }
+ renderer.dispose();
+ if (renderer.domElement.parentNode === host) {
+ host.removeChild(renderer.domElement);
+ }
+ };
+ }, [
+ aura.drift,
+ aura.intensity,
+ aura.particleOpacity,
+ aura.primary,
+ aura.secondary,
+ aura.tertiary,
+ isDesktop,
+ prefersReducedMotion,
+ ]);
+
+ if (!isDesktop) {
+ return null;
+ }
+
+ const overlayStyle = {
+ backgroundImage: [
+ `radial-gradient(circle at 18% 22%, ${hexToRgba(aura.primary, 0.18 * aura.intensity)}, transparent 32%)`,
+ `radial-gradient(circle at 78% 20%, ${hexToRgba(aura.secondary, 0.14 * aura.intensity)}, transparent 34%)`,
+ `radial-gradient(circle at 52% 78%, ${hexToRgba(aura.tertiary, 0.12 * aura.intensity)}, transparent 38%)`,
+ ].join(", "),
+ } as CSSProperties;
+
+ return (
+
+ );
+}
diff --git a/frontend/hooks/usePrefersReducedMotion.ts b/frontend/hooks/usePrefersReducedMotion.ts
new file mode 100644
index 00000000..b9bb32ed
--- /dev/null
+++ b/frontend/hooks/usePrefersReducedMotion.ts
@@ -0,0 +1,26 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+export function usePrefersReducedMotion() {
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
+
+ useEffect(() => {
+ if (typeof window === "undefined" || !window.matchMedia) {
+ return;
+ }
+
+ const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
+ const apply = () => {
+ setPrefersReducedMotion(mediaQuery.matches);
+ };
+
+ apply();
+ mediaQuery.addEventListener("change", apply);
+ return () => {
+ mediaQuery.removeEventListener("change", apply);
+ };
+ }, []);
+
+ return prefersReducedMotion;
+}
diff --git a/frontend/lib/weather-aura.ts b/frontend/lib/weather-aura.ts
new file mode 100644
index 00000000..867c46d4
--- /dev/null
+++ b/frontend/lib/weather-aura.ts
@@ -0,0 +1,66 @@
+import { CityDetail, CityListItem } from "@/lib/dashboard-types";
+
+export interface WeatherAuraProfile {
+ primary: string;
+ secondary: string;
+ tertiary: string;
+ intensity: number;
+ drift: number;
+ particleOpacity: number;
+}
+
+const RISK_AURA: Record> =
+ {
+ high: {
+ primary: "#ff7c2a",
+ secondary: "#ffcf66",
+ tertiary: "#56c7ff",
+ particleOpacity: 0.42,
+ },
+ medium: {
+ primary: "#f6c453",
+ secondary: "#5eead4",
+ tertiary: "#7dd3fc",
+ particleOpacity: 0.34,
+ },
+ low: {
+ primary: "#38bdf8",
+ secondary: "#22d3ee",
+ tertiary: "#34d399",
+ particleOpacity: 0.28,
+ },
+ default: {
+ primary: "#6ee7ff",
+ secondary: "#7c8dff",
+ tertiary: "#60a5fa",
+ particleOpacity: 0.3,
+ },
+ };
+
+function clamp(value: number, min: number, max: number) {
+ return Math.min(Math.max(value, min), max);
+}
+
+export function getWeatherAuraProfile(
+ detail: CityDetail | null,
+ cities: CityListItem[],
+): WeatherAuraProfile {
+ const dominantRisk =
+ String(detail?.risk?.level || "").toLowerCase() ||
+ String(cities.find((city) => city.risk_level)?.risk_level || "").toLowerCase() ||
+ "default";
+
+ const base = RISK_AURA[dominantRisk] || RISK_AURA.default;
+ const currentTemp = Number(detail?.current?.temp);
+ const validTemp = Number.isFinite(currentTemp) ? currentTemp : 18;
+ const intensity = clamp(0.7 + validTemp / 40, 0.72, 1.45);
+ const drift = clamp(0.45 + validTemp / 30, 0.55, 1.35);
+ const particleOpacity = clamp(base.particleOpacity + (intensity - 1) * 0.08, 0.22, 0.48);
+
+ return {
+ ...base,
+ intensity,
+ drift,
+ particleOpacity,
+ };
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index a8141d72..098fe06b 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "polyweather-frontend",
- "version": "0.1.0",
+ "version": "1.5.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "polyweather-frontend",
- "version": "0.1.0",
+ "version": "1.5.1",
"dependencies": {
"@radix-ui/react-slot": "^1.1.2",
"@supabase/ssr": "^0.5.2",
@@ -23,13 +23,15 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-leaflet": "^5.0.0",
- "tailwind-merge": "^3.3.0"
+ "tailwind-merge": "^3.3.0",
+ "three": "^0.183.2"
},
"devDependencies": {
"@types/leaflet": "^1.9.20",
"@types/node": "^22.15.21",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
+ "@types/three": "^0.183.1",
"@types/trusted-types": "^2.0.7",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.3",
@@ -109,6 +111,12 @@
"zod": "^3.24.4"
}
},
+ "node_modules/@dimforge/rapier3d-compat": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmmirror.com/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+ "dev": true
+ },
"node_modules/@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz",
@@ -2857,6 +2865,12 @@
"tslib": "^2.8.0"
}
},
+ "node_modules/@tweenjs/tween.js": {
+ "version": "23.1.3",
+ "resolved": "https://registry.npmmirror.com/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+ "dev": true
+ },
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmmirror.com/@types/connect/-/connect-3.4.38.tgz",
@@ -2917,6 +2931,27 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/stats.js": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmmirror.com/@types/stats.js/-/stats.js-0.17.4.tgz",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "dev": true
+ },
+ "node_modules/@types/three": {
+ "version": "0.183.1",
+ "resolved": "https://registry.npmmirror.com/@types/three/-/three-0.183.1.tgz",
+ "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==",
+ "dev": true,
+ "dependencies": {
+ "@dimforge/rapier3d-compat": "~0.12.0",
+ "@tweenjs/tween.js": "~23.1.3",
+ "@types/stats.js": "*",
+ "@types/webxr": ">=0.5.17",
+ "@webgpu/types": "*",
+ "fflate": "~0.8.2",
+ "meshoptimizer": "~1.0.1"
+ }
+ },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -2928,6 +2963,12 @@
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"optional": true
},
+ "node_modules/@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmmirror.com/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "dev": true
+ },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz",
@@ -3475,6 +3516,12 @@
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
},
+ "node_modules/@webgpu/types": {
+ "version": "0.1.69",
+ "resolved": "https://registry.npmmirror.com/@webgpu/types/-/types-0.1.69.tgz",
+ "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
+ "dev": true
+ },
"node_modules/abitype": {
"version": "1.0.6",
"resolved": "https://registry.npmmirror.com/abitype/-/abitype-1.0.6.tgz",
@@ -4308,6 +4355,12 @@
"reusify": "^1.0.4"
}
},
+ "node_modules/fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "dev": true
+ },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
@@ -4901,6 +4954,12 @@
"node": ">= 8"
}
},
+ "node_modules/meshoptimizer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/meshoptimizer/-/meshoptimizer-1.0.1.tgz",
+ "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==",
+ "dev": true
+ },
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz",
@@ -6016,6 +6075,11 @@
"real-require": "^0.2.0"
}
},
+ "node_modules/three": {
+ "version": "0.183.2",
+ "resolved": "https://registry.npmmirror.com/three/-/three-0.183.2.tgz",
+ "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ=="
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -6642,6 +6706,12 @@
"zod": "^3.24.4"
}
},
+ "@dimforge/rapier3d-compat": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmmirror.com/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+ "dev": true
+ },
"@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz",
@@ -8349,6 +8419,12 @@
"tslib": "^2.8.0"
}
},
+ "@tweenjs/tween.js": {
+ "version": "23.1.3",
+ "resolved": "https://registry.npmmirror.com/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+ "dev": true
+ },
"@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmmirror.com/@types/connect/-/connect-3.4.38.tgz",
@@ -8407,6 +8483,27 @@
"dev": true,
"requires": {}
},
+ "@types/stats.js": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmmirror.com/@types/stats.js/-/stats.js-0.17.4.tgz",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "dev": true
+ },
+ "@types/three": {
+ "version": "0.183.1",
+ "resolved": "https://registry.npmmirror.com/@types/three/-/three-0.183.1.tgz",
+ "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==",
+ "dev": true,
+ "requires": {
+ "@dimforge/rapier3d-compat": "~0.12.0",
+ "@tweenjs/tween.js": "~23.1.3",
+ "@types/stats.js": "*",
+ "@types/webxr": ">=0.5.17",
+ "@webgpu/types": "*",
+ "fflate": "~0.8.2",
+ "meshoptimizer": "~1.0.1"
+ }
+ },
"@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -8418,6 +8515,12 @@
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"optional": true
},
+ "@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmmirror.com/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "dev": true
+ },
"@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmmirror.com/@types/ws/-/ws-8.18.1.tgz",
@@ -8838,6 +8941,12 @@
}
}
},
+ "@webgpu/types": {
+ "version": "0.1.69",
+ "resolved": "https://registry.npmmirror.com/@webgpu/types/-/types-0.1.69.tgz",
+ "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
+ "dev": true
+ },
"abitype": {
"version": "1.0.6",
"resolved": "https://registry.npmmirror.com/abitype/-/abitype-1.0.6.tgz",
@@ -9423,6 +9532,12 @@
"reusify": "^1.0.4"
}
},
+ "fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "dev": true
+ },
"fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
@@ -9835,6 +9950,12 @@
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true
},
+ "meshoptimizer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/meshoptimizer/-/meshoptimizer-1.0.1.tgz",
+ "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==",
+ "dev": true
+ },
"micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz",
@@ -10534,6 +10655,11 @@
"real-require": "^0.2.0"
}
},
+ "three": {
+ "version": "0.183.2",
+ "resolved": "https://registry.npmmirror.com/three/-/three-0.183.2.tgz",
+ "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ=="
+ },
"tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 11d097b7..1f2116ce 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -24,13 +24,15 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-leaflet": "^5.0.0",
- "tailwind-merge": "^3.3.0"
+ "tailwind-merge": "^3.3.0",
+ "three": "^0.183.2"
},
"devDependencies": {
"@types/leaflet": "^1.9.20",
"@types/node": "^22.15.21",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
+ "@types/three": "^0.183.1",
"@types/trusted-types": "^2.0.7",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.3",