From 408de047d0b10b417713c8a21586a52a519b0fae Mon Sep 17 00:00:00 2001 From: ummsehun Date: Sun, 5 Apr 2026 23:28:02 +0900 Subject: [PATCH 01/28] feat: centralize live simulation policies and density selectors Move duplicated hour/weather/traffic and density mapping logic into shared domain services/selectors to reduce policy drift across store, API routes, and scene systems. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/app/api/live/[slug]/places/route.ts | 4 +-- src/app/api/live/[slug]/traffic/route.ts | 9 ++--- src/app/api/live/[slug]/weather/route.ts | 15 +++------ src/scene/place/PedestrianSystem.tsx | 9 ++--- src/scene/place/VehicleSystem.tsx | 9 ++--- src/shared/domains/index.ts | 3 ++ src/shared/domains/time.ts | 31 +++++++++++++++++ src/shared/domains/traffic.ts | 30 +++++++++++++++++ src/shared/domains/weather.ts | 43 ++++++++++++++++++++++++ src/shared/selectors/density.ts | 35 +++++++++++++++++++ src/shared/selectors/index.ts | 1 + src/stores/playbackStore.ts | 17 ++-------- 12 files changed, 162 insertions(+), 44 deletions(-) create mode 100644 src/shared/domains/index.ts create mode 100644 src/shared/domains/time.ts create mode 100644 src/shared/domains/traffic.ts create mode 100644 src/shared/domains/weather.ts create mode 100644 src/shared/selectors/density.ts create mode 100644 src/shared/selectors/index.ts diff --git a/src/app/api/live/[slug]/places/route.ts b/src/app/api/live/[slug]/places/route.ts index 17a26a4..95c2d2e 100644 --- a/src/app/api/live/[slug]/places/route.ts +++ b/src/app/api/live/[slug]/places/route.ts @@ -3,6 +3,7 @@ import { createStaticSceneBootstrap } from "../../../../../shared/scene"; import { MVP_PLACES } from "../../../../../data/places"; import { toLiveStateCacheKey } from "../../../../../shared/cache"; import { validateLivePlaceSnapshot } from "../../../../../shared/contracts"; +import { normalizeLiveLevel } from "../../../../../shared/selectors"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -26,8 +27,7 @@ export async function GET( } const bootstrap = createStaticSceneBootstrap(place); - const raw = request.nextUrl.searchParams.get("density") ?? "medium"; - const density = raw === "low" || raw === "high" ? raw : "medium"; + const density = normalizeLiveLevel(request.nextUrl.searchParams.get("density")); const snapshot = validateLivePlaceSnapshot({ geometryId: bootstrap.geometryId, diff --git a/src/app/api/live/[slug]/traffic/route.ts b/src/app/api/live/[slug]/traffic/route.ts index 7c63ebf..ab3a848 100644 --- a/src/app/api/live/[slug]/traffic/route.ts +++ b/src/app/api/live/[slug]/traffic/route.ts @@ -3,6 +3,7 @@ import { createStaticSceneBootstrap } from "../../../../../shared/scene"; import { MVP_PLACES } from "../../../../../data/places"; import { toLiveStateCacheKey } from "../../../../../shared/cache"; import { validateLiveTrafficSnapshot } from "../../../../../shared/contracts"; +import { normalizeHour, toTrafficPolicy } from "../../../../../shared/domains"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -27,14 +28,14 @@ export async function GET( const bootstrap = createStaticSceneBootstrap(place); const hour = Number(request.nextUrl.searchParams.get("hour") ?? "12"); - const normalizedHour = Number.isFinite(hour) ? ((hour % 24) + 24) % 24 : 12; - const rushHour = (normalizedHour >= 8 && normalizedHour <= 10) || (normalizedHour >= 17 && normalizedHour <= 20); + const normalizedHour = normalizeHour(hour); + const trafficPolicy = toTrafficPolicy(normalizedHour); const payload = validateLiveTrafficSnapshot({ geometryId: bootstrap.geometryId, key: toLiveStateCacheKey(bootstrap.geometryId, "traffic"), - density: rushHour ? "high" : "medium", - speedKph: rushHour ? 18 : 32, + density: trafficPolicy.density, + speedKph: trafficPolicy.speedKph, capturedAtIso: new Date().toISOString(), }); diff --git a/src/app/api/live/[slug]/weather/route.ts b/src/app/api/live/[slug]/weather/route.ts index 54b1cc3..58d6e57 100644 --- a/src/app/api/live/[slug]/weather/route.ts +++ b/src/app/api/live/[slug]/weather/route.ts @@ -3,6 +3,7 @@ import { createStaticSceneBootstrap } from "../../../../../shared/scene"; import { MVP_PLACES } from "../../../../../data/places"; import { toLiveStateCacheKey } from "../../../../../shared/cache"; import { validateLiveWeatherSnapshot } from "../../../../../shared/contracts"; +import { buildWeatherPolicy, normalizeHour } from "../../../../../shared/domains"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -27,20 +28,14 @@ export async function GET( const bootstrap = createStaticSceneBootstrap(place); const hour = Number(request.nextUrl.searchParams.get("hour") ?? "12"); - const normalizedHour = Number.isFinite(hour) ? ((hour % 24) + 24) % 24 : 12; - - const condition = - normalizedHour >= 6 && normalizedHour < 17 - ? "clear" - : normalizedHour >= 17 && normalizedHour < 21 - ? "cloudy" - : "rain"; + const normalizedHour = normalizeHour(hour); + const weatherPolicy = buildWeatherPolicy(normalizedHour); const snapshot = validateLiveWeatherSnapshot({ geometryId: bootstrap.geometryId, key: toLiveStateCacheKey(bootstrap.geometryId, "weather"), - condition, - temperatureCelsius: condition === "rain" ? 10 : 18, + condition: weatherPolicy.condition, + temperatureCelsius: weatherPolicy.temperatureCelsius, capturedAtIso: new Date().toISOString(), }); diff --git a/src/scene/place/PedestrianSystem.tsx b/src/scene/place/PedestrianSystem.tsx index 857a026..e753f6a 100644 --- a/src/scene/place/PedestrianSystem.tsx +++ b/src/scene/place/PedestrianSystem.tsx @@ -4,6 +4,7 @@ import { useMemo, useRef } from "react"; import { useFrame } from "@react-three/fiber"; import * as THREE from "three"; import { usePlaybackStore } from "../../stores/playbackStore"; +import { getPedestrianCountByLevel } from "../../shared/selectors"; const MAX_PEDESTRIANS = 20; const AREA = 44; @@ -20,12 +21,6 @@ function rand(min: number, max: number) { return min + Math.random() * (max - min); } -function countByLevel(level: "low" | "medium" | "high") { - if (level === "low") return 10; - if (level === "high") return 20; - return 15; -} - export default function PedestrianSystem() { const meshRef = useRef(null); const tempObj = useMemo(() => new THREE.Object3D(), []); @@ -48,7 +43,7 @@ export default function PedestrianSystem() { const mesh = meshRef.current; if (!mesh) return; - const activeCount = countByLevel(level); + const activeCount = getPedestrianCountByLevel(level); const walkers = walkersRef.current; for (let i = 0; i < MAX_PEDESTRIANS; i += 1) { diff --git a/src/scene/place/VehicleSystem.tsx b/src/scene/place/VehicleSystem.tsx index 8ade9db..76819d7 100644 --- a/src/scene/place/VehicleSystem.tsx +++ b/src/scene/place/VehicleSystem.tsx @@ -5,6 +5,7 @@ import { useFrame } from "@react-three/fiber"; import * as THREE from "three"; import { usePlaybackStore } from "../../stores/playbackStore"; import type { PlacePackage } from "../../data/placePackages"; +import { getVehicleCountByLevel } from "../../shared/selectors"; const MAX_VEHICLES = 8; @@ -19,12 +20,6 @@ function rand(min: number, max: number) { return min + Math.random() * (max - min); } -function countByLevel(level: "low" | "medium" | "high") { - if (level === "low") return 4; - if (level === "high") return 8; - return 6; -} - type VehicleSystemProps = { pkg: PlacePackage; }; @@ -50,7 +45,7 @@ export default function VehicleSystem({ pkg }: VehicleSystemProps) { const mesh = meshRef.current; if (!mesh) return; - const activeCount = countByLevel(level); + const activeCount = getVehicleCountByLevel(level); const vehicles = vehiclesRef.current; for (let i = 0; i < MAX_VEHICLES; i += 1) { diff --git a/src/shared/domains/index.ts b/src/shared/domains/index.ts new file mode 100644 index 0000000..98120c6 --- /dev/null +++ b/src/shared/domains/index.ts @@ -0,0 +1,3 @@ +export * from "./time"; +export * from "./weather"; +export * from "./traffic"; diff --git a/src/shared/domains/time.ts b/src/shared/domains/time.ts new file mode 100644 index 0000000..f041c1b --- /dev/null +++ b/src/shared/domains/time.ts @@ -0,0 +1,31 @@ +export type TimeOfDay = "day" | "dusk" | "night"; + +const HOURS_IN_DAY = 24; +const DEFAULT_HOUR = 12; + +export function normalizeHour(hour: number, fallback = DEFAULT_HOUR): number { + if (!Number.isFinite(hour)) { + return fallback; + } + + const mod = hour % HOURS_IN_DAY; + return mod < 0 ? mod + HOURS_IN_DAY : mod; +} + +export function toTimeOfDay(hour: number): TimeOfDay { + const normalizedHour = normalizeHour(hour); + + if (normalizedHour >= 6 && normalizedHour < 17) { + return "day"; + } + + if (normalizedHour >= 17 && normalizedHour < 20) { + return "dusk"; + } + + return "night"; +} + +export function isNightTime(hour: number): boolean { + return toTimeOfDay(hour) === "night"; +} diff --git a/src/shared/domains/traffic.ts b/src/shared/domains/traffic.ts new file mode 100644 index 0000000..3ebf576 --- /dev/null +++ b/src/shared/domains/traffic.ts @@ -0,0 +1,30 @@ +import { normalizeHour } from "./time"; + +export type LiveLevel = "low" | "medium" | "high"; + +export type TrafficSnapshotPolicy = { + density: LiveLevel; + speedKph: number; +}; + +function isRushHour(hour: number): boolean { + const normalizedHour = normalizeHour(hour); + const morningRush = normalizedHour >= 8 && normalizedHour <= 10; + const eveningRush = normalizedHour >= 17 && normalizedHour <= 20; + + return morningRush || eveningRush; +} + +export function toTrafficPolicy(hour: number): TrafficSnapshotPolicy { + if (isRushHour(hour)) { + return { + density: "high", + speedKph: 18, + }; + } + + return { + density: "medium", + speedKph: 32, + }; +} diff --git a/src/shared/domains/weather.ts b/src/shared/domains/weather.ts new file mode 100644 index 0000000..844253d --- /dev/null +++ b/src/shared/domains/weather.ts @@ -0,0 +1,43 @@ +import { normalizeHour } from "./time"; + +export type WeatherCondition = "clear" | "cloudy" | "rain" | "snow"; + +export type WeatherSnapshotPolicy = { + condition: WeatherCondition; + temperatureCelsius: number; +}; + +export function toWeatherConditionByHour(hour: number): WeatherCondition { + const normalizedHour = normalizeHour(hour); + + if (normalizedHour >= 6 && normalizedHour < 17) { + return "clear"; + } + + if (normalizedHour >= 17 && normalizedHour < 21) { + return "cloudy"; + } + + return "rain"; +} + +export function toTemperatureByCondition(condition: WeatherCondition): number { + if (condition === "rain") { + return 10; + } + + if (condition === "snow") { + return -2; + } + + return 18; +} + +export function buildWeatherPolicy(hour: number): WeatherSnapshotPolicy { + const condition = toWeatherConditionByHour(hour); + + return { + condition, + temperatureCelsius: toTemperatureByCondition(condition), + }; +} diff --git a/src/shared/selectors/density.ts b/src/shared/selectors/density.ts new file mode 100644 index 0000000..5656ed9 --- /dev/null +++ b/src/shared/selectors/density.ts @@ -0,0 +1,35 @@ +export type LiveLevel = "low" | "medium" | "high"; + +const DEFAULT_DENSITY: LiveLevel = "medium"; + +export function normalizeLiveLevel(level: string | null | undefined): LiveLevel { + if (level === "low" || level === "high") { + return level; + } + + return DEFAULT_DENSITY; +} + +export function getPedestrianCountByLevel(level: LiveLevel): number { + if (level === "low") { + return 10; + } + + if (level === "high") { + return 20; + } + + return 15; +} + +export function getVehicleCountByLevel(level: LiveLevel): number { + if (level === "low") { + return 4; + } + + if (level === "high") { + return 8; + } + + return 6; +} diff --git a/src/shared/selectors/index.ts b/src/shared/selectors/index.ts new file mode 100644 index 0000000..c6cdd5e --- /dev/null +++ b/src/shared/selectors/index.ts @@ -0,0 +1 @@ +export * from "./density"; diff --git a/src/stores/playbackStore.ts b/src/stores/playbackStore.ts index 66ed758..33660a4 100644 --- a/src/stores/playbackStore.ts +++ b/src/stores/playbackStore.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import { isNightTime, normalizeHour, toTimeOfDay } from "../shared/domains"; export type WeatherMode = "clear" | "cloudy" | "rain" | "snow"; export type TimeOfDay = "day" | "dusk" | "night"; @@ -29,18 +30,6 @@ type PlaybackStore = { isNight: () => boolean; }; -function normalizeHour(hour: number) { - const mod = hour % 24; - return mod < 0 ? mod + 24 : mod; -} - -function hourToTimeOfDay(hour: number): TimeOfDay { - const h = normalizeHour(hour); - if (h >= 6 && h < 17) return "day"; - if (h >= 17 && h < 20) return "dusk"; - return "night"; -} - export const usePlaybackStore = create((set, get) => ({ currentTime: 12, setCurrentTime: (time) => set({ currentTime: normalizeHour(time) }), @@ -63,10 +52,10 @@ export const usePlaybackStore = create((set, get) => ({ getTimeOfDay: () => { const { currentTime } = get(); - return hourToTimeOfDay(currentTime); + return toTimeOfDay(currentTime); }, isNight: () => { const { currentTime } = get(); - return hourToTimeOfDay(currentTime) === "night"; + return isNightTime(currentTime); }, })); From fe940371d9cca2cb8faf9f00604a2257bed8ed07 Mon Sep 17 00:00:00 2001 From: ummsehun Date: Sun, 5 Apr 2026 23:29:49 +0900 Subject: [PATCH 02/28] feat:Pr-1 clear --- .gitignore | 1 + bun.lock | 17 +-- package.json | 1 + src/app/globals.css | 29 ++++- src/app/page.tsx | 76 ++++++++++++-- src/components/hud/PlaybackHUD.tsx | 157 +++++++++++++++++----------- src/components/hud/SceneInfoHUD.tsx | 58 ++++++++-- src/globe/GlobeScene.tsx | 60 ++++++++--- 8 files changed, 299 insertions(+), 100 deletions(-) diff --git a/.gitignore b/.gitignore index 64ce93f..6850046 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +.bkit \ No newline at end of file diff --git a/bun.lock b/bun.lock index 700ee7e..61bca16 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "framer-motion": "^12.38.0", + "lucide-react": "^1.7.0", "next": "16.2.2", "react": "19.2.4", "react-dom": "19.2.4", @@ -21,15 +22,15 @@ "zustand": "^5.0.12", }, "devDependencies": { - "@tailwindcss/postcss": "^4", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", + "@tailwindcss/postcss": "^4.2.2", + "@types/node": "^20.19.39", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "cpx2": "^8.0.1", - "eslint": "^9", + "eslint": "^9.39.4", "eslint-config-next": "16.2.2", - "tailwindcss": "^4", - "typescript": "^5", + "tailwindcss": "^4.2.2", + "typescript": "^5.9.3", }, }, }, @@ -794,6 +795,8 @@ "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], + "lucide-react": ["lucide-react@1.7.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg=="], + "maath": ["maath@0.10.8", "", { "peerDependencies": { "@types/three": ">=0.134.0", "three": ">=0.134.0" } }, "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], diff --git a/package.json b/package.json index f9b8888..4e93ef1 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "framer-motion": "^12.38.0", + "lucide-react": "^1.7.0", "next": "16.2.2", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..a3a2664 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -20,7 +20,32 @@ } body { - background: var(--background); + background: #000; color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-sans), system-ui, -apple-system, sans-serif; + overflow: hidden; +} + +@layer components { + .glass-panel { + @apply rounded-2xl border border-white/15 bg-black/45 backdrop-blur-xl; + box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); + } + + .glass-button { + @apply flex items-center justify-center rounded-xl border border-white/10 bg-white/5 transition-all hover:bg-white/15 active:scale-95 disabled:opacity-50; + } + + .glass-button-active { + @apply border-cyan-400/50 bg-cyan-500/20 text-cyan-300 ring-1 ring-cyan-500/30; + } +} + +/* Cesium UI Hiding */ +.cesium-viewer-bottom { + display: none !important; +} +.cesium-viewer-toolbar { + top: 20px !important; + right: 20px !important; } diff --git a/src/app/page.tsx b/src/app/page.tsx index 36730a0..d2cf2c4 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,18 +1,80 @@ import GlobeScene from "../globe/GlobeScene"; import { MVP_PLACES } from "../data/places"; +import { Globe, MapPin, Search } from "lucide-react"; +import Link from "next/link"; export default function Home() { return (
-
-

- WorMap · Globe MVP -

-

장소를 클릭해 진입하세요

-

- 마커 3개(Shibuya / Times Square / Gangnam) 준비됨 + {/* Top Header */} +

+
+
+
+ +
+
+

WorMap Engine

+

GLOBAL DISCOVERY

+
+
+ +
+ + Search coordinates or places... +
+ +
+
+ System Status + Online +
+
+
+
+
+
+
+ + {/* Bottom Place Selector */} +
+
+ {MVP_PLACES.map((place, idx) => ( + +
+
+ Travel to {place.name} +
+
+ +
+
+
+
+ +
+
+

{place.city}

+

{place.name}

+
+
+
+ + ))} +
+
+ + {/* Info Badge */} +
+
+

+ Coordinates: 37.5665° N, 126.9780° E

diff --git a/src/components/hud/PlaybackHUD.tsx b/src/components/hud/PlaybackHUD.tsx index f77fba5..a8d7583 100644 --- a/src/components/hud/PlaybackHUD.tsx +++ b/src/components/hud/PlaybackHUD.tsx @@ -1,11 +1,26 @@ -"use client"; - +import { + Play, + Pause, + FastForward, + Cloud, + CloudRain, + Sun, + Clock, + Navigation, + Move, + ChevronLeft, + ChevronRight +} from "lucide-react"; import { usePlaybackStore } from "../../stores/playbackStore"; import { usePlaceStore } from "../../stores/placeStore"; import type { InputPreset } from "../../stores/placeStore"; const SPEED_OPTIONS = [1, 2, 4]; -const WEATHER_OPTIONS = ["clear", "cloudy", "rain"] as const; +const WEATHER_OPTIONS = [ + { value: "clear", icon: Sun }, + { value: "cloudy", icon: Cloud }, + { value: "rain", icon: CloudRain }, +] as const; const INPUT_PRESET_OPTIONS: InputPreset[] = ["precision", "balanced", "fast"]; function formatTime(hour: number) { @@ -24,87 +39,103 @@ export default function PlaybackHUD() { const setInputPreset = usePlaceStore((s) => s.setInputPreset); return ( -
-
- +
+
+ {/* Playback Controls */} +
+ -
- {SPEED_OPTIONS.map((s) => ( - - ))} +
+ {SPEED_OPTIONS.map((s) => ( + + ))} +
-
+
-
- {WEATHER_OPTIONS.map((w) => ( + {/* Weather Controls */} +
+ {WEATHER_OPTIONS.map(({ value, icon: Icon }) => ( ))}
-
- -
- Time - {formatTime(currentTime)} -
- -
- - +
+ + {/* Time Controls */} +
+
+ + + {formatTime(currentTime)} + +
+ +
+ + +
-
+
+ {/* View Mode Toggle */} -
+
-
+ {/* Sensitivity / Input Presets */} +
{INPUT_PRESET_OPTIONS.map((preset) => (
{viewMode === "walk" && ( -

- WASD 이동 · E 상승 / R 하강 · 마우스 좌클릭 드래그로 시야 회전 · V 탑뷰 전환 · ESC 복귀 · precision/balanced/fast 감도 -

+
+

+ WASD 이동 · E/R 상승/하강 · 드래그 회전 · V 탑뷰 · ESC 복귀 +

+
)}
); diff --git a/src/components/hud/SceneInfoHUD.tsx b/src/components/hud/SceneInfoHUD.tsx index ec36088..4b1b860 100644 --- a/src/components/hud/SceneInfoHUD.tsx +++ b/src/components/hud/SceneInfoHUD.tsx @@ -1,6 +1,5 @@ -"use client"; - import { useMemo } from "react"; +import { MapPin, Wind, Thermometer, Info } from "lucide-react"; import { usePlaceStore } from "../../stores/placeStore"; import { usePlaybackStore } from "../../stores/playbackStore"; @@ -25,14 +24,53 @@ export default function SceneInfoHUD() { }, [currentPlace]); return ( -
-

WorMap · Place MVP

-

{placeLabel}

-
- Mode: {viewMode === "top" ? "Top" : "Walk"} - Weather: {weather} - Time: {formatTime(currentTime)} - Playback: {isPlaying ? `${speed}x` : "Paused"} +
+
+
+
+ +
+

WorMap Simulation

+
+ +

{placeLabel}

+

{currentPlace?.city || "Discovery Mode"}

+ +
+
+ Status + + + {isPlaying ? `Active ${speed}x` : "Paused"} + +
+ +
+ +
+
+ Environment +
+ + {weather} +
+
+
+ Local Time +
+ + {formatTime(currentTime)} +
+
+
+
+
+ +
+ +

+ Mode: {viewMode} View +

); diff --git a/src/globe/GlobeScene.tsx b/src/globe/GlobeScene.tsx index cadb695..1c36a31 100644 --- a/src/globe/GlobeScene.tsx +++ b/src/globe/GlobeScene.tsx @@ -70,9 +70,25 @@ export default function GlobeScene({ places }: GlobeSceneProps) { navigationHelpButton: false, fullscreenButton: false, shouldAnimate: true, + skyAtmosphere: new Cesium.SkyAtmosphere(), + requestRenderMode: true, }); + // Google Earth 스타일 대기 및 안개 설정 viewer.scene.globe.enableLighting = true; + viewer.scene.globe.showGroundAtmosphere = true; + viewer.scene.fog.enabled = true; + viewer.scene.fog.density = 0.0001; + viewer.scene.fog.screenSpaceErrorFactor = 2.0; + + if (viewer.scene.moon) { + viewer.scene.moon.show = true; + } + + if (viewer.scene.sun) { + viewer.scene.sun.show = true; + } + viewerRef.current = viewer; slugMap.clear(); @@ -92,24 +108,27 @@ export default function GlobeScene({ places }: GlobeSceneProps) { name: place.name, position: Cesium.Cartesian3.fromDegrees(place.lng, place.lat, 0), point: { - pixelSize: APP_CONFIG.cesium.marker.pixelSize, - color: Cesium.Color.CYAN, - outlineColor: Cesium.Color.WHITE, - outlineWidth: APP_CONFIG.cesium.marker.outlineWidth, + pixelSize: 8, + color: Cesium.Color.CYAN.withAlpha(0.8), + outlineColor: Cesium.Color.WHITE.withAlpha(0.5), + outlineWidth: 2, disableDepthTestDistance: Number.POSITIVE_INFINITY, + scaleByDistance: new Cesium.NearFarScalar(1.5e2, 1.5, 8.0e6, 0.5), }, label: { text: place.name, - font: APP_CONFIG.cesium.marker.labelFont, + font: "14px 'Inter', system-ui, sans-serif", style: Cesium.LabelStyle.FILL_AND_OUTLINE, fillColor: Cesium.Color.WHITE, - outlineColor: Cesium.Color.BLACK, - outlineWidth: APP_CONFIG.cesium.marker.outlineWidth, - pixelOffset: new Cesium.Cartesian2( - APP_CONFIG.cesium.marker.labelOffset[0], - APP_CONFIG.cesium.marker.labelOffset[1], - ), + outlineColor: Cesium.Color.BLACK.withAlpha(0.5), + outlineWidth: 2, + pixelOffset: new Cesium.Cartesian2(0, -28), disableDepthTestDistance: Number.POSITIVE_INFINITY, + showBackground: true, + backgroundColor: Cesium.Color.BLACK.withAlpha(0.6), + backgroundPadding: new Cesium.Cartesian2(8, 4), + scaleByDistance: new Cesium.NearFarScalar(1.5e2, 1.0, 1.5e7, 0.5), + translucencyByDistance: new Cesium.NearFarScalar(1.5e2, 1.0, 1.5e7, 0.2), }, }); @@ -118,7 +137,24 @@ export default function GlobeScene({ places }: GlobeSceneProps) { placeNameMap.set(entityId, place.name); } - viewer.camera.flyHome(APP_CONFIG.cesium.marker.flyHomeDuration); + // 우주에서 빨려 들어가는 듯한 카메라 연출 (Google Earth Fly-in) + viewer.camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(127.0276, 37.4979, 15000000), // 우주 고도 + orientation: { + heading: 0, + pitch: Cesium.Math.toRadians(-90), + roll: 0, + }, + }); + + // 1초 뒤 강남역 부근으로 빠르게 이동 후 서서히 안정화 + setTimeout(() => { + viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees(127.0276, 37.4979, 12000000), + duration: 3, + easingFunction: Cesium.EasingFunction.QUADRATIC_IN_OUT, + }); + }, 500); const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); clickHandlerRef.current = handler; From 130731cecc31a42a277145a37a824c34889e5db1 Mon Sep 17 00:00:00 2001 From: ummsehun Date: Sun, 5 Apr 2026 23:43:42 +0900 Subject: [PATCH 03/28] refactor: centralize camera controller policies in app config Move camera movement, sensitivity, bounds, clamp ranges, and keybind policies into APP_CONFIG.scene.camera so runtime tuning can happen from a single configuration source. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/scene/place/CameraController.tsx | 61 +++++++++++++++++----------- src/shared/config/app.ts | 38 +++++++++++++++++ 2 files changed, 75 insertions(+), 24 deletions(-) diff --git a/src/scene/place/CameraController.tsx b/src/scene/place/CameraController.tsx index b7b51a7..ab7fcf1 100644 --- a/src/scene/place/CameraController.tsx +++ b/src/scene/place/CameraController.tsx @@ -6,29 +6,27 @@ import * as THREE from "three"; import { usePlaceStore } from "../../stores/placeStore"; import type { InputPreset } from "../../stores/placeStore"; import type { PlacePackage } from "../../data/placePackages"; +import { APP_CONFIG } from "../../shared/config"; type CameraControllerProps = { pkg: PlacePackage; }; +type CameraBounds = { + min: number; + max: number; +}; + type PlaceStoreState = { viewMode: "top" | "walk"; inputPreset: InputPreset; setViewMode: (m: "top" | "walk") => void; }; -const WALK_HEIGHT = 1.7; -const TOP_HEIGHT = 80; -const TOP_X = 0; -const TOP_Y = 0; -const TOP_Z = TOP_HEIGHT; -const MAX_PITCH = Math.PI * 0.46; - -const PRESET_CONFIG: Record = { - precision: { moveSpeed: 3.8, verticalSpeed: 2.4, lookSensitivity: 0.0015 }, - balanced: { moveSpeed: 5.2, verticalSpeed: 3.2, lookSensitivity: 0.0022 }, - fast: { moveSpeed: 7.1, verticalSpeed: 4.3, lookSensitivity: 0.003 }, -}; +const CAMERA_CONFIG = APP_CONFIG.scene.camera; +const PRESET_CONFIG: Record = + CAMERA_CONFIG.inputPreset; +const CAMERA_KEYBIND = CAMERA_CONFIG.keybind; export default function CameraController({ pkg }: CameraControllerProps) { const { camera } = useThree(); @@ -42,7 +40,10 @@ export default function CameraController({ pkg }: CameraControllerProps) { const yawRef = useRef(0); const pitchRef = useRef(0); - const boundsRef = useRef({ min: -48, max: 48 }); + const boundsRef = useRef({ + min: -CAMERA_CONFIG.boundsFallbackMaxAbs, + max: CAMERA_CONFIG.boundsFallbackMaxAbs, + }); const applyLookQuaternion = useCallback(() => { const qYaw = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yawRef.current); @@ -51,7 +52,8 @@ export default function CameraController({ pkg }: CameraControllerProps) { }, [camera]); const switchToTop = useCallback(() => { - camera.position.set(TOP_X, TOP_Y, TOP_Z); + const [topX, topY, topZ] = CAMERA_CONFIG.topViewPosition; + camera.position.set(topX, topY, topZ); camera.lookAt(0, 0, 0); }, [camera]); @@ -66,9 +68,12 @@ export default function CameraController({ pkg }: CameraControllerProps) { useEffect(() => { const maxAbs = Math.max( ...pkg.roads.flatMap((r) => [Math.abs(r.start[0]), Math.abs(r.start[1]), Math.abs(r.end[0]), Math.abs(r.end[1])]), - 48, + CAMERA_CONFIG.boundsFallbackMaxAbs, ); - boundsRef.current = { min: -maxAbs - 6, max: maxAbs + 6 }; + boundsRef.current = { + min: -maxAbs - CAMERA_CONFIG.boundsPadding, + max: maxAbs + CAMERA_CONFIG.boundsPadding, + }; }, [pkg]); useEffect(() => { @@ -86,11 +91,11 @@ export default function CameraController({ pkg }: CameraControllerProps) { const key = e.key.toLowerCase(); keysRef.current.add(key); - if (key === "v") { + if (key === CAMERA_KEYBIND.toggleView) { setViewMode(viewMode === "top" ? "walk" : "top"); } - if (key === "escape" && viewMode === "walk") { + if (key === CAMERA_KEYBIND.exitWalk && viewMode === "walk") { setViewMode("top"); } }; @@ -116,7 +121,11 @@ export default function CameraController({ pkg }: CameraControllerProps) { const lookSensitivity = PRESET_CONFIG[inputPreset].lookSensitivity; yawRef.current -= e.movementX * lookSensitivity; pitchRef.current -= e.movementY * lookSensitivity; - pitchRef.current = THREE.MathUtils.clamp(pitchRef.current, -MAX_PITCH, MAX_PITCH); + pitchRef.current = THREE.MathUtils.clamp( + pitchRef.current, + -CAMERA_CONFIG.maxPitchRadians, + CAMERA_CONFIG.maxPitchRadians, + ); applyLookQuaternion(); }; @@ -147,9 +156,9 @@ export default function CameraController({ pkg }: CameraControllerProps) { const verticalDistance = config.verticalSpeed * delta; const moveInput = new THREE.Vector3( - Number(keys.has("d")) - Number(keys.has("a")), + Number(keys.has(CAMERA_KEYBIND.moveRight)) - Number(keys.has(CAMERA_KEYBIND.moveLeft)), 0, - Number(keys.has("s")) - Number(keys.has("w")), + Number(keys.has(CAMERA_KEYBIND.moveBackward)) - Number(keys.has(CAMERA_KEYBIND.moveForward)), ); if (moveInput.lengthSq() > 0) { @@ -169,18 +178,22 @@ export default function CameraController({ pkg }: CameraControllerProps) { let nextY = camera.position.y; - if (keys.has("e")) { + if (keys.has(CAMERA_KEYBIND.moveUp)) { nextY += verticalDistance; } - if (keys.has("r")) { + if (keys.has(CAMERA_KEYBIND.moveDown)) { nextY -= verticalDistance; } const { min, max } = boundsRef.current; camera.position.set( THREE.MathUtils.clamp(camera.position.x, min, max), - THREE.MathUtils.clamp(nextY, WALK_HEIGHT - 0.4, WALK_HEIGHT + 3.2), + THREE.MathUtils.clamp( + nextY, + CAMERA_CONFIG.walkHeight + CAMERA_CONFIG.walkVerticalClampOffset.min, + CAMERA_CONFIG.walkHeight + CAMERA_CONFIG.walkVerticalClampOffset.max, + ), THREE.MathUtils.clamp(camera.position.z, min, max), ); }); diff --git a/src/shared/config/app.ts b/src/shared/config/app.ts index 1b2f1b9..7622a6e 100644 --- a/src/shared/config/app.ts +++ b/src/shared/config/app.ts @@ -22,6 +22,44 @@ export const APP_CONFIG = { directionalPosition: [50, 80, 30] as const, directionalShadowMapSize: [2048, 2048] as const, }, + camera: { + walkHeight: 1.7, + topViewPosition: [0, 0, 80] as const, + maxPitchRadians: Math.PI * 0.46, + boundsFallbackMaxAbs: 48, + boundsPadding: 6, + walkVerticalClampOffset: { + min: -0.4, + max: 3.2, + }, + inputPreset: { + precision: { + moveSpeed: 3.8, + verticalSpeed: 2.4, + lookSensitivity: 0.0015, + }, + balanced: { + moveSpeed: 5.2, + verticalSpeed: 3.2, + lookSensitivity: 0.0022, + }, + fast: { + moveSpeed: 7.1, + verticalSpeed: 4.3, + lookSensitivity: 0.003, + }, + }, + keybind: { + toggleView: "v", + exitWalk: "escape", + moveLeft: "a", + moveRight: "d", + moveForward: "w", + moveBackward: "s", + moveUp: "e", + moveDown: "r", + }, + }, }, cesium: { marker: { From 40ac878564e900036255d0678cd11950b6ec6c5b Mon Sep 17 00:00:00 2001 From: ummsehun Date: Sun, 5 Apr 2026 23:52:17 +0900 Subject: [PATCH 04/28] feat: add reusable UI primitives for HUD composition Introduce Panel, Label, StatusBadge, and ControlButton components to centralize repeated glass-style UI patterns and reduce style duplication in scene overlays. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/components/ui/ControlButton.tsx | 51 +++++++++++++++++++++++++++++ src/components/ui/Label.tsx | 43 ++++++++++++++++++++++++ src/components/ui/Panel.tsx | 25 ++++++++++++++ src/components/ui/StatusBadge.tsx | 39 ++++++++++++++++++++++ 4 files changed, 158 insertions(+) create mode 100644 src/components/ui/ControlButton.tsx create mode 100644 src/components/ui/Label.tsx create mode 100644 src/components/ui/Panel.tsx create mode 100644 src/components/ui/StatusBadge.tsx diff --git a/src/components/ui/ControlButton.tsx b/src/components/ui/ControlButton.tsx new file mode 100644 index 0000000..b058fe3 --- /dev/null +++ b/src/components/ui/ControlButton.tsx @@ -0,0 +1,51 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; + +type ControlButtonTone = "default" | "active"; +type ControlButtonSize = "icon-sm" | "icon-md" | "chip"; + +type ControlButtonProps = { + className?: string; + tone?: ControlButtonTone; + size?: ControlButtonSize; + children: ReactNode; +} & ButtonHTMLAttributes; + +function toToneClass(tone: ControlButtonTone): string { + if (tone === "active") { + return "glass-button-active"; + } + + return ""; +} + +function toSizeClass(size: ControlButtonSize): string { + if (size === "icon-sm") { + return "h-8 w-8"; + } + + if (size === "icon-md") { + return "h-9 w-9"; + } + + return "h-8 px-3 text-[11px] font-bold"; +} + +export function ControlButton(props: ControlButtonProps) { + const { + className: userClassName, + tone = "default", + size = "icon-md", + children, + ...buttonProps + } = props; + + const className = ["glass-button", toToneClass(tone), toSizeClass(size), userClassName] + .filter(Boolean) + .join(" "); + + return ( + + ); +} diff --git a/src/components/ui/Label.tsx b/src/components/ui/Label.tsx new file mode 100644 index 0000000..829b57f --- /dev/null +++ b/src/components/ui/Label.tsx @@ -0,0 +1,43 @@ +import type { ReactNode } from "react"; + +type LabelTone = "default" | "muted" | "accent"; +type LabelSize = "xs" | "sm"; + +type LabelProps = { + className?: string; + tone?: LabelTone; + size?: LabelSize; + children: ReactNode; +}; + +function toToneClass(tone: LabelTone): string { + if (tone === "muted") { + return "text-zinc-500"; + } + + if (tone === "accent") { + return "text-cyan-300"; + } + + return "text-zinc-200"; +} + +function toSizeClass(size: LabelSize): string { + if (size === "sm") { + return "text-xs"; + } + + return "text-[10px]"; +} + +export function Label(props: LabelProps) { + const tone = props.tone ?? "default"; + const size = props.size ?? "xs"; + + const baseClass = "font-bold uppercase"; + const className = [baseClass, toToneClass(tone), toSizeClass(size), props.className] + .filter(Boolean) + .join(" "); + + return {props.children}; +} diff --git a/src/components/ui/Panel.tsx b/src/components/ui/Panel.tsx new file mode 100644 index 0000000..a0aa80b --- /dev/null +++ b/src/components/ui/Panel.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from "react"; + +type PanelTone = "default" | "subtle"; + +type PanelProps = { + className?: string; + tone?: PanelTone; + children: ReactNode; +}; + +function toPanelToneClass(tone: PanelTone): string { + if (tone === "subtle") { + return "bg-white/5"; + } + + return "glass-panel"; +} + +export function Panel(props: PanelProps) { + const tone = props.tone ?? "default"; + const toneClass = toPanelToneClass(tone); + const className = props.className ? `${toneClass} ${props.className}` : toneClass; + + return
{props.children}
; +} diff --git a/src/components/ui/StatusBadge.tsx b/src/components/ui/StatusBadge.tsx new file mode 100644 index 0000000..a64eb76 --- /dev/null +++ b/src/components/ui/StatusBadge.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; + +type StatusBadgeTone = "active" | "paused" | "neutral"; + +type StatusBadgeProps = { + className?: string; + tone?: StatusBadgeTone; + pulse?: boolean; + children: ReactNode; +}; + +function toDotClass(tone: StatusBadgeTone, pulse: boolean): string { + const base = "h-1.5 w-1.5 rounded-full"; + + if (tone === "active") { + return pulse ? `${base} bg-green-500 animate-pulse` : `${base} bg-green-500`; + } + + if (tone === "paused") { + return `${base} bg-zinc-500`; + } + + return `${base} bg-cyan-400`; +} + +export function StatusBadge(props: StatusBadgeProps) { + const tone = props.tone ?? "neutral"; + const pulse = props.pulse ?? false; + const className = ["text-zinc-200 font-bold flex items-center gap-1.5", props.className] + .filter(Boolean) + .join(" "); + + return ( + + + {props.children} + + ); +} From b0ffd8290e0168b42f982e1f8b04b151259201c3 Mon Sep 17 00:00:00 2001 From: ummsehun Date: Sun, 5 Apr 2026 23:52:23 +0900 Subject: [PATCH 05/28] refactor: adopt UI primitives in playback and scene info HUD Replace repeated panel/label/status/button markup in HUDs with shared primitives to improve consistency and make upcoming tokenization changes safer. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/components/hud/PlaybackHUD.tsx | 51 ++++++++++++++--------------- src/components/hud/SceneInfoHUD.tsx | 33 ++++++++++++------- 2 files changed, 47 insertions(+), 37 deletions(-) diff --git a/src/components/hud/PlaybackHUD.tsx b/src/components/hud/PlaybackHUD.tsx index a8d7583..5698c55 100644 --- a/src/components/hud/PlaybackHUD.tsx +++ b/src/components/hud/PlaybackHUD.tsx @@ -1,7 +1,6 @@ import { Play, Pause, - FastForward, Cloud, CloudRain, Sun, @@ -14,6 +13,8 @@ import { import { usePlaybackStore } from "../../stores/playbackStore"; import { usePlaceStore } from "../../stores/placeStore"; import type { InputPreset } from "../../stores/placeStore"; +import { ControlButton } from "../ui/ControlButton"; +import { Panel } from "../ui/Panel"; const SPEED_OPTIONS = [1, 2, 4]; const WEATHER_OPTIONS = [ @@ -40,7 +41,7 @@ export default function PlaybackHUD() { return (
-
+ {/* Playback Controls */}
+ ))}
@@ -71,16 +71,15 @@ export default function PlaybackHUD() { {/* Weather Controls */}
{WEATHER_OPTIONS.map(({ value, icon: Icon }) => ( - + ))}
@@ -96,20 +95,20 @@ export default function PlaybackHUD() {
- - +
@@ -131,25 +130,25 @@ export default function PlaybackHUD() { {/* Sensitivity / Input Presets */}
{INPUT_PRESET_OPTIONS.map((preset) => ( - + ))}
-
+ {viewMode === "walk" && ( -
+

WASD 이동 · E/R 상승/하강 · 드래그 회전 · V 탑뷰 · ESC 복귀

-
+ )}
); diff --git a/src/components/hud/SceneInfoHUD.tsx b/src/components/hud/SceneInfoHUD.tsx index 4b1b860..483b0b2 100644 --- a/src/components/hud/SceneInfoHUD.tsx +++ b/src/components/hud/SceneInfoHUD.tsx @@ -2,6 +2,9 @@ import { useMemo } from "react"; import { MapPin, Wind, Thermometer, Info } from "lucide-react"; import { usePlaceStore } from "../../stores/placeStore"; import { usePlaybackStore } from "../../stores/playbackStore"; +import { Label } from "../ui/Label"; +import { Panel } from "../ui/Panel"; +import { StatusBadge } from "../ui/StatusBadge"; function formatTime(hour: number) { const normalized = ((hour % 24) + 24) % 24; @@ -23,9 +26,12 @@ export default function SceneInfoHUD() { return currentPlace.name; }, [currentPlace]); + const statusLabel = isPlaying ? `Active ${speed}x` : "Paused"; + const statusTone = isPlaying ? "active" : "paused"; + return (
-
+
@@ -38,25 +44,30 @@ export default function SceneInfoHUD() {
- Status - - - {isPlaying ? `Active ${speed}x` : "Paused"} - + + + {statusLabel} +
- Environment +
{weather}
- Local Time +
{formatTime(currentTime)} @@ -64,14 +75,14 @@ export default function SceneInfoHUD() {
-
+ -
+

Mode: {viewMode} View

-
+
); } From b3584d7b31bf78a49b76a10e81542b06b28303a3 Mon Sep 17 00:00:00 2001 From: ummsehun Date: Sun, 5 Apr 2026 23:59:10 +0900 Subject: [PATCH 06/28] refactor: add semantic surface and text utility classes Introduce reusable semantic utility classes in globals.css for accent text, muted text, panel surfaces, progress tracks, dividers, and status dots to reduce repeated raw Tailwind color literals. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/app/globals.css | 76 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/app/globals.css b/src/app/globals.css index a3a2664..c559124 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -32,6 +32,82 @@ body { box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); } + .text-accent-primary { + @apply text-cyan-300; + } + + .text-accent-strong { + @apply text-cyan-400; + } + + .text-foreground-strong { + @apply text-white; + } + + .text-foreground-soft { + @apply text-zinc-100; + } + + .text-muted-strong { + @apply text-zinc-400; + } + + .text-muted-soft { + @apply text-zinc-500; + } + + .surface-muted { + @apply bg-white/5; + } + + .surface-accent { + @apply bg-cyan-500/20; + } + + .surface-track { + @apply bg-white/5; + } + + .surface-progress-accent { + @apply bg-cyan-500/50; + } + + .surface-loading-base { + @apply bg-zinc-950; + } + + .surface-loading-track { + @apply bg-zinc-800; + } + + .surface-loading-progress { + @apply bg-cyan-400; + } + + .ring-accent-soft { + @apply ring-1 ring-cyan-500/30; + } + + .divider-soft { + @apply h-px bg-white/5; + } + + .divider-vertical { + @apply h-6 w-px bg-white/10; + } + + .status-dot { + @apply h-1.5 w-1.5 rounded-full; + } + + .status-dot-active { + @apply h-1.5 w-1.5 rounded-full bg-cyan-400; + } + + .status-dot-success { + @apply h-1.5 w-1.5 rounded-full bg-green-500; + } + .glass-button { @apply flex items-center justify-center rounded-xl border border-white/10 bg-white/5 transition-all hover:bg-white/15 active:scale-95 disabled:opacity-50; } From 68420f52f115f83e45f3426b3f61fe564d1eb020 Mon Sep 17 00:00:00 2001 From: ummsehun Date: Sun, 5 Apr 2026 23:59:21 +0900 Subject: [PATCH 07/28] refactor: map UI primitive tones to semantic token classes Update Label and StatusBadge primitives to use semantic token classes so downstream HUD and page components inherit centralized color decisions. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/components/ui/Label.tsx | 6 +++--- src/components/ui/StatusBadge.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/ui/Label.tsx b/src/components/ui/Label.tsx index 829b57f..03108dc 100644 --- a/src/components/ui/Label.tsx +++ b/src/components/ui/Label.tsx @@ -12,14 +12,14 @@ type LabelProps = { function toToneClass(tone: LabelTone): string { if (tone === "muted") { - return "text-zinc-500"; + return "text-muted-soft"; } if (tone === "accent") { - return "text-cyan-300"; + return "text-accent-primary"; } - return "text-zinc-200"; + return "text-foreground-strong"; } function toSizeClass(size: LabelSize): string { diff --git a/src/components/ui/StatusBadge.tsx b/src/components/ui/StatusBadge.tsx index a64eb76..76351fb 100644 --- a/src/components/ui/StatusBadge.tsx +++ b/src/components/ui/StatusBadge.tsx @@ -10,17 +10,17 @@ type StatusBadgeProps = { }; function toDotClass(tone: StatusBadgeTone, pulse: boolean): string { - const base = "h-1.5 w-1.5 rounded-full"; + const base = "status-dot"; if (tone === "active") { - return pulse ? `${base} bg-green-500 animate-pulse` : `${base} bg-green-500`; + return pulse ? `${base} status-dot-success animate-pulse` : `${base} status-dot-success`; } if (tone === "paused") { return `${base} bg-zinc-500`; } - return `${base} bg-cyan-400`; + return "status-dot-active"; } export function StatusBadge(props: StatusBadgeProps) { From 717a2fe1404a28290407e9a8696f38b9a89240e5 Mon Sep 17 00:00:00 2001 From: ummsehun Date: Sun, 5 Apr 2026 23:59:30 +0900 Subject: [PATCH 08/28] refactor: apply semantic UI tokens across home, loading, and HUD Replace repeated cyan/zinc raw classes in core screens with semantic utility classes to improve consistency and make future theming/branding changes centralized. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/app/page.tsx | 30 ++++++++++++++--------------- src/app/place/[slug]/loading.tsx | 14 +++++++------- src/components/hud/PlaybackHUD.tsx | 20 +++++++++---------- src/components/hud/SceneInfoHUD.tsx | 24 +++++++++++------------ 4 files changed, 44 insertions(+), 44 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index d2cf2c4..5acd49e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -12,27 +12,27 @@ export default function Home() {
-
+
-

WorMap Engine

-

GLOBAL DISCOVERY

+

WorMap Engine

+

GLOBAL DISCOVERY

-
+
Search coordinates or places...
-
+
System Status Online
-
-
+
+
@@ -41,14 +41,14 @@ export default function Home() { {/* Bottom Place Selector */}
- {MVP_PLACES.map((place, idx) => ( + {MVP_PLACES.map((place) => (
-
+
Travel to {place.name}
@@ -57,11 +57,11 @@ export default function Home() {
- +
-

{place.city}

-

{place.name}

+

{place.city}

+

{place.name}

@@ -72,9 +72,9 @@ export default function Home() { {/* Info Badge */}
-
-

- Coordinates: 37.5665° N, 126.9780° E +

+

+ Coordinates: 37.5665° N, 126.9780° E

diff --git a/src/app/place/[slug]/loading.tsx b/src/app/place/[slug]/loading.tsx index dc04e20..be2d1a7 100644 --- a/src/app/place/[slug]/loading.tsx +++ b/src/app/place/[slug]/loading.tsx @@ -20,24 +20,24 @@ export default function PlaceLoading() { : "장소를 찾는 중"; return ( -
+
-

+

WorMap · Loading

-

+

{placeName}

-
+
-

+

씬 준비 중{dots} ({progress}%)

@@ -46,7 +46,7 @@ export default function PlaceLoading() { {[0, 1, 2].map((i) => (
))} diff --git a/src/components/hud/PlaybackHUD.tsx b/src/components/hud/PlaybackHUD.tsx index 5698c55..d5c609b 100644 --- a/src/components/hud/PlaybackHUD.tsx +++ b/src/components/hud/PlaybackHUD.tsx @@ -46,7 +46,7 @@ export default function PlaybackHUD() {
-
+
{/* Weather Controls */}
@@ -83,13 +83,13 @@ export default function PlaybackHUD() { ))}
-
+
{/* Time Controls */}
-
- - +
+ + {formatTime(currentTime)}
@@ -112,7 +112,7 @@ export default function PlaybackHUD() {
-
+
{/* View Mode Toggle */}
@@ -146,7 +146,7 @@ export default function PlaybackHUD() { {viewMode === "walk" && (

- WASD 이동 · E/R 상승/하강 · 드래그 회전 · V 탑뷰 · ESC 복귀 + WASD move · E / R up/down · Drag look · ⌘/Ctrl + Trackpad look · V toggle view · ESC back

)} diff --git a/src/components/hud/SceneInfoHUD.tsx b/src/components/hud/SceneInfoHUD.tsx index cb3d393..0b469d3 100644 --- a/src/components/hud/SceneInfoHUD.tsx +++ b/src/components/hud/SceneInfoHUD.tsx @@ -13,6 +13,22 @@ function formatTime(hour: number) { return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; } +function toWeatherLabel(weather: "clear" | "cloudy" | "rain" | "snow"): string { + if (weather === "clear") { + return "Clear"; + } + + if (weather === "cloudy") { + return "Cloudy"; + } + + if (weather === "rain") { + return "Rain"; + } + + return "Snow"; +} + export default function SceneInfoHUD() { const currentPlace = usePlaceStore((s) => s.currentPlace); const viewMode = usePlaceStore((s) => s.viewMode); @@ -26,7 +42,7 @@ export default function SceneInfoHUD() { return currentPlace.name; }, [currentPlace]); - const statusLabel = isPlaying ? `Active ${speed}x` : "Paused"; + const statusLabel = isPlaying ? `Running · ${speed}x` : "Paused"; const statusTone = isPlaying ? "active" : "paused"; return ( @@ -36,7 +52,7 @@ export default function SceneInfoHUD() {
-

WorMap Simulation

+

WorMap Live Scene

{placeLabel}

@@ -61,7 +77,7 @@ export default function SceneInfoHUD() {
- {weather} + {toWeatherLabel(weather)}
@@ -80,7 +96,7 @@ export default function SceneInfoHUD() {

- Mode: {viewMode} View + Camera: {viewMode === "walk" ? "Street" : "Overview"}

From a8a91700e2efa385ac92a777541f0dc3cd8c1b79 Mon Sep 17 00:00:00 2001 From: ummsehun Date: Mon, 6 Apr 2026 00:15:53 +0900 Subject: [PATCH 13/28] feat: improve trackpad camera control without modifier lock-in Enable horizontal trackpad look without modifier keys while preserving page-scroll safety, add gesture tuning ratio in config, and update HUD guidance to match the improved controls. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/components/hud/PlaybackHUD.tsx | 2 +- src/scene/place/CameraController.tsx | 18 +++++++++++++++--- src/shared/config/app.ts | 1 + 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/components/hud/PlaybackHUD.tsx b/src/components/hud/PlaybackHUD.tsx index 2b8a645..94e40a7 100644 --- a/src/components/hud/PlaybackHUD.tsx +++ b/src/components/hud/PlaybackHUD.tsx @@ -146,7 +146,7 @@ export default function PlaybackHUD() { {viewMode === "walk" && (

- WASD move · E / R up/down · Drag look · ⌘/Ctrl + Trackpad look · V toggle view · ESC back + WASD move · E / R up/down · Drag look · Trackpad ↔ look · ⌘/Ctrl + Pinch tilt · V toggle view · ESC back

)} diff --git a/src/scene/place/CameraController.tsx b/src/scene/place/CameraController.tsx index 779c24c..ce3ba07 100644 --- a/src/scene/place/CameraController.tsx +++ b/src/scene/place/CameraController.tsx @@ -141,14 +141,26 @@ export default function CameraController({ pkg }: CameraControllerProps) { const onWheel = (e: WheelEvent) => { if (viewMode !== "walk") return; - if (!e.ctrlKey && !e.metaKey) { + + const absX = Math.abs(e.deltaX); + const absY = Math.abs(e.deltaY); + const dominantRatio = CAMERA_CONFIG.gesture.wheelDominantAxisRatio; + + const isHorizontalLook = absX > absY * dominantRatio; + const isPinchLikeLook = e.ctrlKey || e.metaKey; + + if (!isHorizontalLook && !isPinchLikeLook) { return; } e.preventDefault(); + + const deltaX = isHorizontalLook ? e.deltaX : 0; + const deltaY = isPinchLikeLook ? e.deltaY : 0; + applyLookDelta( - e.deltaX, - e.deltaY, + deltaX, + deltaY, CAMERA_CONFIG.gesture.wheelLookMultiplier, ); }; diff --git a/src/shared/config/app.ts b/src/shared/config/app.ts index 77a1208..42ba2f7 100644 --- a/src/shared/config/app.ts +++ b/src/shared/config/app.ts @@ -35,6 +35,7 @@ export const APP_CONFIG = { gesture: { wheelLookMultiplier: 0.32, touchLookMultiplier: 0.9, + wheelDominantAxisRatio: 1.2, }, inputPreset: { precision: { From bda84a038da19b96a99ebd8b45f603ad4e9076bc Mon Sep 17 00:00:00 2001 From: ummsehun Date: Mon, 6 Apr 2026 00:21:54 +0900 Subject: [PATCH 14/28] feat: add trackpad vertical zoom for walk camera Map dominant vertical trackpad gestures to forward/backward camera zoom in walk mode with bounds-safe clamping, and expose zoom sensitivity in camera gesture config. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/components/hud/PlaybackHUD.tsx | 2 +- src/scene/place/CameraController.tsx | 30 +++++++++++++++++++++++++++- src/shared/config/app.ts | 1 + 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/components/hud/PlaybackHUD.tsx b/src/components/hud/PlaybackHUD.tsx index 94e40a7..7d8c715 100644 --- a/src/components/hud/PlaybackHUD.tsx +++ b/src/components/hud/PlaybackHUD.tsx @@ -146,7 +146,7 @@ export default function PlaybackHUD() { {viewMode === "walk" && (

- WASD move · E / R up/down · Drag look · Trackpad ↔ look · ⌘/Ctrl + Pinch tilt · V toggle view · ESC back + WASD move · Trackpad ↑↓ zoom · Trackpad ↔ look · Drag look · ⌘/Ctrl + Pinch tilt · V toggle view · ESC back

)} diff --git a/src/scene/place/CameraController.tsx b/src/scene/place/CameraController.tsx index ce3ba07..fe7a3c7 100644 --- a/src/scene/place/CameraController.tsx +++ b/src/scene/place/CameraController.tsx @@ -43,6 +43,7 @@ export default function CameraController({ pkg }: CameraControllerProps) { const moveInputRef = useRef(new THREE.Vector3()); const forwardRef = useRef(new THREE.Vector3()); const rightRef = useRef(new THREE.Vector3()); + const zoomForwardRef = useRef(new THREE.Vector3()); const boundsRef = useRef({ min: -CAMERA_CONFIG.boundsFallbackMaxAbs, @@ -148,13 +149,40 @@ export default function CameraController({ pkg }: CameraControllerProps) { const isHorizontalLook = absX > absY * dominantRatio; const isPinchLikeLook = e.ctrlKey || e.metaKey; + const isVerticalZoom = absY > absX * dominantRatio && !isPinchLikeLook; - if (!isHorizontalLook && !isPinchLikeLook) { + if (!isHorizontalLook && !isPinchLikeLook && !isVerticalZoom) { return; } e.preventDefault(); + if (isVerticalZoom) { + const zoomForward = zoomForwardRef.current; + zoomForward.set(0, 0, -1).applyQuaternion(camera.quaternion); + zoomForward.y = 0; + + if (zoomForward.lengthSq() > 0) { + zoomForward.normalize(); + const zoomDistance = e.deltaY * CAMERA_CONFIG.gesture.wheelZoomMultiplier; + const { min, max } = boundsRef.current; + const nextX = THREE.MathUtils.clamp( + camera.position.x + zoomForward.x * zoomDistance, + min, + max, + ); + const nextZ = THREE.MathUtils.clamp( + camera.position.z + zoomForward.z * zoomDistance, + min, + max, + ); + + camera.position.set(nextX, camera.position.y, nextZ); + } + + return; + } + const deltaX = isHorizontalLook ? e.deltaX : 0; const deltaY = isPinchLikeLook ? e.deltaY : 0; diff --git a/src/shared/config/app.ts b/src/shared/config/app.ts index 42ba2f7..6abb9d0 100644 --- a/src/shared/config/app.ts +++ b/src/shared/config/app.ts @@ -36,6 +36,7 @@ export const APP_CONFIG = { wheelLookMultiplier: 0.32, touchLookMultiplier: 0.9, wheelDominantAxisRatio: 1.2, + wheelZoomMultiplier: 0.012, }, inputPreset: { precision: { From a3830ef84e4b07031ca9bc5d02cc0d5ec6ca948f Mon Sep 17 00:00:00 2001 From: ummsehun Date: Mon, 6 Apr 2026 00:26:29 +0900 Subject: [PATCH 15/28] feat: enable two-finger zoom gestures for trackpad Add explicit two-finger zoom support in walk and top views, including pinch-to-zoom handling and top-view zoom bounds so trackpad zoom in/out works consistently. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/components/hud/PlaybackHUD.tsx | 2 +- src/scene/place/CameraController.tsx | 49 +++++++++++++++++++++++++--- src/shared/config/app.ts | 6 ++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/components/hud/PlaybackHUD.tsx b/src/components/hud/PlaybackHUD.tsx index 7d8c715..e109b7a 100644 --- a/src/components/hud/PlaybackHUD.tsx +++ b/src/components/hud/PlaybackHUD.tsx @@ -146,7 +146,7 @@ export default function PlaybackHUD() { {viewMode === "walk" && (

- WASD move · Trackpad ↑↓ zoom · Trackpad ↔ look · Drag look · ⌘/Ctrl + Pinch tilt · V toggle view · ESC back + WASD move · Two-finger ↑↓ zoom · Two-finger ↔ look · Drag look · Pinch zoom · V toggle view · ESC back

)} diff --git a/src/scene/place/CameraController.tsx b/src/scene/place/CameraController.tsx index fe7a3c7..564ec4f 100644 --- a/src/scene/place/CameraController.tsx +++ b/src/scene/place/CameraController.tsx @@ -141,6 +141,21 @@ export default function CameraController({ pkg }: CameraControllerProps) { }; const onWheel = (e: WheelEvent) => { + if (viewMode === "top") { + e.preventDefault(); + const [topX, topY] = CAMERA_CONFIG.topViewPosition; + const minZ = CAMERA_CONFIG.topViewZoomRange.minZ; + const maxZ = CAMERA_CONFIG.topViewZoomRange.maxZ; + const nextZ = THREE.MathUtils.clamp( + camera.position.z + e.deltaY * CAMERA_CONFIG.topViewWheelZoomMultiplier, + minZ, + maxZ, + ); + camera.position.set(topX, topY, nextZ); + camera.lookAt(0, 0, 0); + return; + } + if (viewMode !== "walk") return; const absX = Math.abs(e.deltaX); @@ -148,15 +163,41 @@ export default function CameraController({ pkg }: CameraControllerProps) { const dominantRatio = CAMERA_CONFIG.gesture.wheelDominantAxisRatio; const isHorizontalLook = absX > absY * dominantRatio; - const isPinchLikeLook = e.ctrlKey || e.metaKey; - const isVerticalZoom = absY > absX * dominantRatio && !isPinchLikeLook; + const isPinchLikeZoom = e.ctrlKey || e.metaKey; + const isVerticalZoom = absY > absX * dominantRatio && !isPinchLikeZoom; - if (!isHorizontalLook && !isPinchLikeLook && !isVerticalZoom) { + if (!isHorizontalLook && !isPinchLikeZoom && !isVerticalZoom) { return; } e.preventDefault(); + if (isPinchLikeZoom) { + const zoomForward = zoomForwardRef.current; + zoomForward.set(0, 0, -1).applyQuaternion(camera.quaternion); + zoomForward.y = 0; + + if (zoomForward.lengthSq() > 0) { + zoomForward.normalize(); + const zoomDistance = e.deltaY * CAMERA_CONFIG.gesture.pinchZoomMultiplier; + const { min, max } = boundsRef.current; + const nextX = THREE.MathUtils.clamp( + camera.position.x + zoomForward.x * zoomDistance, + min, + max, + ); + const nextZ = THREE.MathUtils.clamp( + camera.position.z + zoomForward.z * zoomDistance, + min, + max, + ); + + camera.position.set(nextX, camera.position.y, nextZ); + } + + return; + } + if (isVerticalZoom) { const zoomForward = zoomForwardRef.current; zoomForward.set(0, 0, -1).applyQuaternion(camera.quaternion); @@ -184,7 +225,7 @@ export default function CameraController({ pkg }: CameraControllerProps) { } const deltaX = isHorizontalLook ? e.deltaX : 0; - const deltaY = isPinchLikeLook ? e.deltaY : 0; + const deltaY = 0; applyLookDelta( deltaX, diff --git a/src/shared/config/app.ts b/src/shared/config/app.ts index 6abb9d0..142746d 100644 --- a/src/shared/config/app.ts +++ b/src/shared/config/app.ts @@ -25,6 +25,11 @@ export const APP_CONFIG = { camera: { walkHeight: 1.7, topViewPosition: [0, 0, 80] as const, + topViewZoomRange: { + minZ: 32, + maxZ: 140, + }, + topViewWheelZoomMultiplier: 0.028, maxPitchRadians: Math.PI * 0.46, boundsFallbackMaxAbs: 48, boundsPadding: 6, @@ -37,6 +42,7 @@ export const APP_CONFIG = { touchLookMultiplier: 0.9, wheelDominantAxisRatio: 1.2, wheelZoomMultiplier: 0.012, + pinchZoomMultiplier: 0.02, }, inputPreset: { precision: { From 1385539facb6f0bae75148c91aef083ed84cea8e Mon Sep 17 00:00:00 2001 From: ummsehun Date: Mon, 6 Apr 2026 00:35:19 +0900 Subject: [PATCH 16/28] feat: support both two-finger scroll and pinch zoom gestures Handle both two-finger scroll zoom and spread/pinch gesture zoom paths for trackpads so zoom in/out works consistently across gesture styles and browsers. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/components/hud/PlaybackHUD.tsx | 2 +- src/scene/place/CameraController.tsx | 138 ++++++++++++++++++--------- src/shared/config/app.ts | 1 + 3 files changed, 97 insertions(+), 44 deletions(-) diff --git a/src/components/hud/PlaybackHUD.tsx b/src/components/hud/PlaybackHUD.tsx index e109b7a..2d85cf3 100644 --- a/src/components/hud/PlaybackHUD.tsx +++ b/src/components/hud/PlaybackHUD.tsx @@ -146,7 +146,7 @@ export default function PlaybackHUD() { {viewMode === "walk" && (

- WASD move · Two-finger ↑↓ zoom · Two-finger ↔ look · Drag look · Pinch zoom · V toggle view · ESC back + WASD move · Two-finger ↑↓ zoom · Two-finger ↔ look · Spread / Pinch zoom · Drag look · V toggle view · ESC back

)} diff --git a/src/scene/place/CameraController.tsx b/src/scene/place/CameraController.tsx index 564ec4f..f3fea03 100644 --- a/src/scene/place/CameraController.tsx +++ b/src/scene/place/CameraController.tsx @@ -8,6 +8,10 @@ import type { InputPreset } from "../../stores/placeStore"; import type { PlacePackage } from "../../data/placePackages"; import { APP_CONFIG } from "../../shared/config"; +type GestureEventWithScale = Event & { + scale?: number; +}; + type CameraControllerProps = { pkg: PlacePackage; }; @@ -44,6 +48,7 @@ export default function CameraController({ pkg }: CameraControllerProps) { const forwardRef = useRef(new THREE.Vector3()); const rightRef = useRef(new THREE.Vector3()); const zoomForwardRef = useRef(new THREE.Vector3()); + const pinchScalePrevRef = useRef(null); const boundsRef = useRef({ min: -CAMERA_CONFIG.boundsFallbackMaxAbs, @@ -85,6 +90,39 @@ export default function CameraController({ pkg }: CameraControllerProps) { applyLookQuaternion(); }, [camera, pkg.walkStartPosition, applyLookQuaternion]); + const applyForwardZoom = useCallback( + (delta: number, multiplier: number) => { + if (viewMode !== "walk") { + return; + } + + const zoomForward = zoomForwardRef.current; + zoomForward.set(0, 0, -1).applyQuaternion(camera.quaternion); + zoomForward.y = 0; + + if (zoomForward.lengthSq() <= 0) { + return; + } + + zoomForward.normalize(); + const zoomDistance = delta * multiplier; + const { min, max } = boundsRef.current; + const nextX = THREE.MathUtils.clamp( + camera.position.x + zoomForward.x * zoomDistance, + min, + max, + ); + const nextZ = THREE.MathUtils.clamp( + camera.position.z + zoomForward.z * zoomDistance, + min, + max, + ); + + camera.position.set(nextX, camera.position.y, nextZ); + }, + [camera, viewMode], + ); + useEffect(() => { const maxAbs = Math.max( ...pkg.roads.flatMap((r) => [Math.abs(r.start[0]), Math.abs(r.start[1]), Math.abs(r.end[0]), Math.abs(r.end[1])]), @@ -173,53 +211,13 @@ export default function CameraController({ pkg }: CameraControllerProps) { e.preventDefault(); if (isPinchLikeZoom) { - const zoomForward = zoomForwardRef.current; - zoomForward.set(0, 0, -1).applyQuaternion(camera.quaternion); - zoomForward.y = 0; - - if (zoomForward.lengthSq() > 0) { - zoomForward.normalize(); - const zoomDistance = e.deltaY * CAMERA_CONFIG.gesture.pinchZoomMultiplier; - const { min, max } = boundsRef.current; - const nextX = THREE.MathUtils.clamp( - camera.position.x + zoomForward.x * zoomDistance, - min, - max, - ); - const nextZ = THREE.MathUtils.clamp( - camera.position.z + zoomForward.z * zoomDistance, - min, - max, - ); - - camera.position.set(nextX, camera.position.y, nextZ); - } + applyForwardZoom(e.deltaY, CAMERA_CONFIG.gesture.pinchZoomMultiplier); return; } if (isVerticalZoom) { - const zoomForward = zoomForwardRef.current; - zoomForward.set(0, 0, -1).applyQuaternion(camera.quaternion); - zoomForward.y = 0; - - if (zoomForward.lengthSq() > 0) { - zoomForward.normalize(); - const zoomDistance = e.deltaY * CAMERA_CONFIG.gesture.wheelZoomMultiplier; - const { min, max } = boundsRef.current; - const nextX = THREE.MathUtils.clamp( - camera.position.x + zoomForward.x * zoomDistance, - min, - max, - ); - const nextZ = THREE.MathUtils.clamp( - camera.position.z + zoomForward.z * zoomDistance, - min, - max, - ); - - camera.position.set(nextX, camera.position.y, nextZ); - } + applyForwardZoom(e.deltaY, CAMERA_CONFIG.gesture.wheelZoomMultiplier); return; } @@ -276,6 +274,54 @@ export default function CameraController({ pkg }: CameraControllerProps) { touchPrevRef.current = null; }; + const onGestureStart = (event: Event) => { + const e = event as GestureEventWithScale; + if (viewMode !== "walk") { + return; + } + + if (typeof e.scale !== "number") { + return; + } + + pinchScalePrevRef.current = e.scale; + event.preventDefault(); + }; + + const onGestureChange = (event: Event) => { + const e = event as GestureEventWithScale; + if (viewMode !== "walk") { + return; + } + + if (typeof e.scale !== "number") { + return; + } + + const previousScale = pinchScalePrevRef.current; + if (previousScale === null) { + pinchScalePrevRef.current = e.scale; + return; + } + + const deltaScale = e.scale - previousScale; + pinchScalePrevRef.current = e.scale; + + if (Math.abs(deltaScale) < 0.001) { + return; + } + + event.preventDefault(); + applyForwardZoom( + -deltaScale, + CAMERA_CONFIG.gesture.pinchGestureZoomMultiplier, + ); + }; + + const onGestureEnd = () => { + pinchScalePrevRef.current = null; + }; + window.addEventListener("keydown", onKeyDown); window.addEventListener("keyup", onKeyUp); window.addEventListener("mousedown", onMouseDown); @@ -285,6 +331,9 @@ export default function CameraController({ pkg }: CameraControllerProps) { window.addEventListener("touchstart", onTouchStart, { passive: true }); window.addEventListener("touchmove", onTouchMove, { passive: true }); window.addEventListener("touchend", onTouchEnd); + window.addEventListener("gesturestart", onGestureStart as EventListener, { passive: false }); + window.addEventListener("gesturechange", onGestureChange as EventListener, { passive: false }); + window.addEventListener("gestureend", onGestureEnd as EventListener); return () => { window.removeEventListener("keydown", onKeyDown); @@ -296,8 +345,11 @@ export default function CameraController({ pkg }: CameraControllerProps) { window.removeEventListener("touchstart", onTouchStart); window.removeEventListener("touchmove", onTouchMove); window.removeEventListener("touchend", onTouchEnd); + window.removeEventListener("gesturestart", onGestureStart as EventListener); + window.removeEventListener("gesturechange", onGestureChange as EventListener); + window.removeEventListener("gestureend", onGestureEnd as EventListener); }; - }, [viewMode, inputPreset, setViewMode, applyLookDelta]); + }, [viewMode, inputPreset, setViewMode, applyLookDelta, applyForwardZoom]); useFrame((_state, delta) => { if (!isWalkingRef.current) { diff --git a/src/shared/config/app.ts b/src/shared/config/app.ts index 142746d..6271795 100644 --- a/src/shared/config/app.ts +++ b/src/shared/config/app.ts @@ -43,6 +43,7 @@ export const APP_CONFIG = { wheelDominantAxisRatio: 1.2, wheelZoomMultiplier: 0.012, pinchZoomMultiplier: 0.02, + pinchGestureZoomMultiplier: 5, }, inputPreset: { precision: { From e282f2b5e602d2c8e0c143f5551f542fb90199c4 Mon Sep 17 00:00:00 2001 From: ummsehun Date: Mon, 6 Apr 2026 08:11:20 +0900 Subject: [PATCH 17/28] fix: enable spread/pinch zoom path reliably Add and stabilize gesture event handling so two-finger spread/pinch zoom works alongside two-finger scroll zoom in both walk and top camera modes. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/scene/place/CameraController.tsx | 47 ++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/src/scene/place/CameraController.tsx b/src/scene/place/CameraController.tsx index f3fea03..4a5144e 100644 --- a/src/scene/place/CameraController.tsx +++ b/src/scene/place/CameraController.tsx @@ -123,6 +123,27 @@ export default function CameraController({ pkg }: CameraControllerProps) { [camera, viewMode], ); + const applyTopZoom = useCallback( + (deltaY: number, multiplier = 1) => { + if (viewMode !== "top") { + return; + } + + const [topX, topY] = CAMERA_CONFIG.topViewPosition; + const minZ = CAMERA_CONFIG.topViewZoomRange.minZ; + const maxZ = CAMERA_CONFIG.topViewZoomRange.maxZ; + const nextZ = THREE.MathUtils.clamp( + camera.position.z + deltaY * CAMERA_CONFIG.topViewWheelZoomMultiplier * multiplier, + minZ, + maxZ, + ); + + camera.position.set(topX, topY, nextZ); + camera.lookAt(0, 0, 0); + }, + [camera, viewMode], + ); + useEffect(() => { const maxAbs = Math.max( ...pkg.roads.flatMap((r) => [Math.abs(r.start[0]), Math.abs(r.start[1]), Math.abs(r.end[0]), Math.abs(r.end[1])]), @@ -181,16 +202,7 @@ export default function CameraController({ pkg }: CameraControllerProps) { const onWheel = (e: WheelEvent) => { if (viewMode === "top") { e.preventDefault(); - const [topX, topY] = CAMERA_CONFIG.topViewPosition; - const minZ = CAMERA_CONFIG.topViewZoomRange.minZ; - const maxZ = CAMERA_CONFIG.topViewZoomRange.maxZ; - const nextZ = THREE.MathUtils.clamp( - camera.position.z + e.deltaY * CAMERA_CONFIG.topViewWheelZoomMultiplier, - minZ, - maxZ, - ); - camera.position.set(topX, topY, nextZ); - camera.lookAt(0, 0, 0); + applyTopZoom(e.deltaY); return; } @@ -276,7 +288,7 @@ export default function CameraController({ pkg }: CameraControllerProps) { const onGestureStart = (event: Event) => { const e = event as GestureEventWithScale; - if (viewMode !== "walk") { + if (viewMode !== "walk" && viewMode !== "top") { return; } @@ -290,7 +302,7 @@ export default function CameraController({ pkg }: CameraControllerProps) { const onGestureChange = (event: Event) => { const e = event as GestureEventWithScale; - if (viewMode !== "walk") { + if (viewMode !== "walk" && viewMode !== "top") { return; } @@ -312,6 +324,15 @@ export default function CameraController({ pkg }: CameraControllerProps) { } event.preventDefault(); + + if (viewMode === "top") { + applyTopZoom( + -deltaScale, + CAMERA_CONFIG.gesture.pinchGestureZoomMultiplier, + ); + return; + } + applyForwardZoom( -deltaScale, CAMERA_CONFIG.gesture.pinchGestureZoomMultiplier, @@ -349,7 +370,7 @@ export default function CameraController({ pkg }: CameraControllerProps) { window.removeEventListener("gesturechange", onGestureChange as EventListener); window.removeEventListener("gestureend", onGestureEnd as EventListener); }; - }, [viewMode, inputPreset, setViewMode, applyLookDelta, applyForwardZoom]); + }, [viewMode, inputPreset, setViewMode, applyLookDelta, applyForwardZoom, applyTopZoom]); useFrame((_state, delta) => { if (!isWalkingRef.current) { From db5bfcc27b2363104d06af56f18795c910b18de6 Mon Sep 17 00:00:00 2001 From: ummsehun Date: Mon, 6 Apr 2026 08:57:49 +0900 Subject: [PATCH 18/28] feat:pinch out add --- .bkit/state/memory.json | 8 +-- .bkit/state/pdca-status.json | 16 ++++- src/globe/GlobeScene.tsx | 118 ++++++++++++++++++++++++++++++++++- 3 files changed, 134 insertions(+), 8 deletions(-) diff --git a/.bkit/state/memory.json b/.bkit/state/memory.json index 7bd0942..ab6bf6f 100644 --- a/.bkit/state/memory.json +++ b/.bkit/state/memory.json @@ -1,12 +1,12 @@ { "version": "1.0", - "lastUpdated": "2026-04-04T06:57:03.197Z", + "lastUpdated": "2026-04-05T23:22:41.783Z", "platform": "gemini", "data": { "session": { - "sessionCount": 2, - "lastSessionStarted": "2026-04-04T06:57:03.197Z", - "lastSessionEnded": "2026-04-04T06:57:02.768Z" + "sessionCount": 3, + "lastSessionStarted": "2026-04-05T23:18:25.147Z", + "lastSessionEnded": "2026-04-05T23:22:41.783Z" }, "pdca": { "lastFeature": null, diff --git a/.bkit/state/pdca-status.json b/.bkit/state/pdca-status.json index f619962..c63a14d 100644 --- a/.bkit/state/pdca-status.json +++ b/.bkit/state/pdca-status.json @@ -1,6 +1,6 @@ { "version": "3.0", - "lastUpdated": "2026-04-04T06:57:02.805Z", + "lastUpdated": "2026-04-05T23:22:41.783Z", "activeFeatures": {}, "primaryFeature": null, "archivedFeatures": {}, @@ -12,7 +12,7 @@ "session": { "startedAt": "2026-04-04T06:56:16.748Z", "onboardingCompleted": false, - "lastActivity": "2026-04-04T06:57:02.767Z" + "lastActivity": "2026-04-05T23:22:41.782Z" }, "history": [ { @@ -20,6 +20,18 @@ "action": "session_end", "feature": null, "details": "Session ended" + }, + { + "timestamp": "2026-04-05T23:22:41.751Z", + "action": "session_end", + "feature": null, + "details": "Session ended" + }, + { + "timestamp": "2026-04-05T23:22:41.783Z", + "action": "session_end", + "feature": null, + "details": "Session ended" } ] } \ No newline at end of file diff --git a/src/globe/GlobeScene.tsx b/src/globe/GlobeScene.tsx index 1c36a31..c619597 100644 --- a/src/globe/GlobeScene.tsx +++ b/src/globe/GlobeScene.tsx @@ -5,7 +5,6 @@ import { useRouter } from "next/navigation"; import "cesium/Build/Cesium/Widgets/widgets.css"; import { useAppStore } from "../stores/appStore"; import type { Place } from "../types/place"; -import { APP_CONFIG } from "../shared/config"; import { createLogger, toErrorContext } from "../shared/logger"; import { createStaticSceneBootstrap } from "../shared/scene"; @@ -13,6 +12,17 @@ type GlobeSceneProps = { places: Place[]; }; +type WebKitGestureEvent = Event & { + scale?: number; +}; + +type GestureHandlers = { + onGestureStart: (event: Event) => void; + onGestureChange: (event: Event) => void; + onGestureEnd: (event: Event) => void; + onCtrlWheelPinch: (event: WheelEvent) => void; +}; + const logger = createLogger("globe:scene"); declare global { @@ -30,6 +40,7 @@ export default function GlobeScene({ places }: GlobeSceneProps) { const clickHandlerRef = useRef( null, ); + const gestureHandlersRef = useRef(null); const entityMapRef = useRef>(new Map()); const nameMapRef = useRef>(new Map()); @@ -74,6 +85,16 @@ export default function GlobeScene({ places }: GlobeSceneProps) { requestRenderMode: true, }); + const screenSpaceController = viewer.scene.screenSpaceCameraController; + screenSpaceController.enableInputs = true; + screenSpaceController.enableZoom = true; + screenSpaceController.zoomEventTypes = [ + Cesium.CameraEventType.RIGHT_DRAG, + Cesium.CameraEventType.WHEEL, + Cesium.CameraEventType.PINCH, + ]; + screenSpaceController.zoomFactor = 5; + // Google Earth 스타일 대기 및 안개 설정 viewer.scene.globe.enableLighting = true; viewer.scene.globe.showGroundAtmosphere = true; @@ -88,6 +109,88 @@ export default function GlobeScene({ places }: GlobeSceneProps) { if (viewer.scene.sun) { viewer.scene.sun.show = true; } + + let previousGestureScale: number | null = null; + const onGestureStart = (event: Event) => { + const gestureEvent = event as WebKitGestureEvent; + + if (typeof gestureEvent.scale !== "number") { + return; + } + + event.preventDefault(); + previousGestureScale = gestureEvent.scale; + }; + + const onGestureChange = (event: Event) => { + const gestureEvent = event as WebKitGestureEvent; + + if (typeof gestureEvent.scale !== "number") { + return; + } + + event.preventDefault(); + + if (previousGestureScale === null) { + previousGestureScale = gestureEvent.scale; + return; + } + + const scaleDelta = gestureEvent.scale - previousGestureScale; + previousGestureScale = gestureEvent.scale; + + if (Math.abs(scaleDelta) < 0.001) { + return; + } + + const cameraHeight = viewer.camera.positionCartographic.height; + const zoomAmount = Math.max(cameraHeight * Math.abs(scaleDelta) * 0.08, 10); + + if (scaleDelta > 0) { + viewer.camera.zoomIn(zoomAmount); + } else { + viewer.camera.zoomOut(zoomAmount); + } + + viewer.scene.requestRender(); + }; + + const onGestureEnd = (event: Event) => { + event.preventDefault(); + previousGestureScale = null; + }; + + const onCtrlWheelPinch = (event: WheelEvent) => { + if (!event.ctrlKey) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const cameraHeight = viewer.camera.positionCartographic.height; + const zoomAmount = Math.max(cameraHeight * Math.min(Math.abs(event.deltaY), 240) * 0.0015, 10); + + if (event.deltaY < 0) { + viewer.camera.zoomIn(zoomAmount); + } else { + viewer.camera.zoomOut(zoomAmount); + } + + viewer.scene.requestRender(); + }; + + viewer.canvas.addEventListener("gesturestart", onGestureStart, { passive: false }); + viewer.canvas.addEventListener("gesturechange", onGestureChange, { passive: false }); + viewer.canvas.addEventListener("gestureend", onGestureEnd, { passive: false }); + viewer.canvas.addEventListener("wheel", onCtrlWheelPinch, { passive: false }); + + gestureHandlersRef.current = { + onGestureStart, + onGestureChange, + onGestureEnd, + onCtrlWheelPinch, + }; viewerRef.current = viewer; @@ -196,10 +299,21 @@ export default function GlobeScene({ places }: GlobeSceneProps) { } if (viewerRef.current) { + const gestureHandlers = gestureHandlersRef.current; + + if (gestureHandlers) { + viewerRef.current.canvas.removeEventListener("gesturestart", gestureHandlers.onGestureStart); + viewerRef.current.canvas.removeEventListener("gesturechange", gestureHandlers.onGestureChange); + viewerRef.current.canvas.removeEventListener("gestureend", gestureHandlers.onGestureEnd); + viewerRef.current.canvas.removeEventListener("wheel", gestureHandlers.onCtrlWheelPinch); + } + viewerRef.current.destroy(); viewerRef.current = null; } + gestureHandlersRef.current = null; + slugMap.clear(); placeNameMap.clear(); }; @@ -207,7 +321,7 @@ export default function GlobeScene({ places }: GlobeSceneProps) { return (
-
+

Markers: {markerSummary}

From b245528f601513f6916ed83b1ab1d2bff728bbc6 Mon Sep 17 00:00:00 2001 From: ummsehun Date: Mon, 6 Apr 2026 09:01:59 +0900 Subject: [PATCH 19/28] feat:pad UX update --- src/globe/GlobeScene.tsx | 110 ++------------------------------------- 1 file changed, 4 insertions(+), 106 deletions(-) diff --git a/src/globe/GlobeScene.tsx b/src/globe/GlobeScene.tsx index c619597..9d336bf 100644 --- a/src/globe/GlobeScene.tsx +++ b/src/globe/GlobeScene.tsx @@ -12,17 +12,6 @@ type GlobeSceneProps = { places: Place[]; }; -type WebKitGestureEvent = Event & { - scale?: number; -}; - -type GestureHandlers = { - onGestureStart: (event: Event) => void; - onGestureChange: (event: Event) => void; - onGestureEnd: (event: Event) => void; - onCtrlWheelPinch: (event: WheelEvent) => void; -}; - const logger = createLogger("globe:scene"); declare global { @@ -40,7 +29,6 @@ export default function GlobeScene({ places }: GlobeSceneProps) { const clickHandlerRef = useRef( null, ); - const gestureHandlersRef = useRef(null); const entityMapRef = useRef>(new Map()); const nameMapRef = useRef>(new Map()); @@ -93,7 +81,10 @@ export default function GlobeScene({ places }: GlobeSceneProps) { Cesium.CameraEventType.WHEEL, Cesium.CameraEventType.PINCH, ]; - screenSpaceController.zoomFactor = 5; + screenSpaceController.zoomFactor = 3; + screenSpaceController.inertiaZoom = 0.85; + screenSpaceController.minimumZoomDistance = 1; + screenSpaceController.maximumZoomDistance = 1e7; // Google Earth 스타일 대기 및 안개 설정 viewer.scene.globe.enableLighting = true; @@ -109,88 +100,6 @@ export default function GlobeScene({ places }: GlobeSceneProps) { if (viewer.scene.sun) { viewer.scene.sun.show = true; } - - let previousGestureScale: number | null = null; - const onGestureStart = (event: Event) => { - const gestureEvent = event as WebKitGestureEvent; - - if (typeof gestureEvent.scale !== "number") { - return; - } - - event.preventDefault(); - previousGestureScale = gestureEvent.scale; - }; - - const onGestureChange = (event: Event) => { - const gestureEvent = event as WebKitGestureEvent; - - if (typeof gestureEvent.scale !== "number") { - return; - } - - event.preventDefault(); - - if (previousGestureScale === null) { - previousGestureScale = gestureEvent.scale; - return; - } - - const scaleDelta = gestureEvent.scale - previousGestureScale; - previousGestureScale = gestureEvent.scale; - - if (Math.abs(scaleDelta) < 0.001) { - return; - } - - const cameraHeight = viewer.camera.positionCartographic.height; - const zoomAmount = Math.max(cameraHeight * Math.abs(scaleDelta) * 0.08, 10); - - if (scaleDelta > 0) { - viewer.camera.zoomIn(zoomAmount); - } else { - viewer.camera.zoomOut(zoomAmount); - } - - viewer.scene.requestRender(); - }; - - const onGestureEnd = (event: Event) => { - event.preventDefault(); - previousGestureScale = null; - }; - - const onCtrlWheelPinch = (event: WheelEvent) => { - if (!event.ctrlKey) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - const cameraHeight = viewer.camera.positionCartographic.height; - const zoomAmount = Math.max(cameraHeight * Math.min(Math.abs(event.deltaY), 240) * 0.0015, 10); - - if (event.deltaY < 0) { - viewer.camera.zoomIn(zoomAmount); - } else { - viewer.camera.zoomOut(zoomAmount); - } - - viewer.scene.requestRender(); - }; - - viewer.canvas.addEventListener("gesturestart", onGestureStart, { passive: false }); - viewer.canvas.addEventListener("gesturechange", onGestureChange, { passive: false }); - viewer.canvas.addEventListener("gestureend", onGestureEnd, { passive: false }); - viewer.canvas.addEventListener("wheel", onCtrlWheelPinch, { passive: false }); - - gestureHandlersRef.current = { - onGestureStart, - onGestureChange, - onGestureEnd, - onCtrlWheelPinch, - }; viewerRef.current = viewer; @@ -299,21 +208,10 @@ export default function GlobeScene({ places }: GlobeSceneProps) { } if (viewerRef.current) { - const gestureHandlers = gestureHandlersRef.current; - - if (gestureHandlers) { - viewerRef.current.canvas.removeEventListener("gesturestart", gestureHandlers.onGestureStart); - viewerRef.current.canvas.removeEventListener("gesturechange", gestureHandlers.onGestureChange); - viewerRef.current.canvas.removeEventListener("gestureend", gestureHandlers.onGestureEnd); - viewerRef.current.canvas.removeEventListener("wheel", gestureHandlers.onCtrlWheelPinch); - } - viewerRef.current.destroy(); viewerRef.current = null; } - gestureHandlersRef.current = null; - slugMap.clear(); placeNameMap.clear(); }; From c6c81d1644c52b21ae57b8ba8c0ec15f9b30580f Mon Sep 17 00:00:00 2001 From: ummsehun Date: Mon, 6 Apr 2026 09:12:11 +0900 Subject: [PATCH 20/28] feat:CameraController module --- src/scene/place/CameraController.tsx | 378 +++----------------- src/scene/place/cameraMath.ts | 150 ++++++++ src/scene/place/useCameraInputHandlers.ts | 263 ++++++++++++++ src/scene/place/useCameraModeTransitions.ts | 23 ++ 4 files changed, 479 insertions(+), 335 deletions(-) create mode 100644 src/scene/place/cameraMath.ts create mode 100644 src/scene/place/useCameraInputHandlers.ts create mode 100644 src/scene/place/useCameraModeTransitions.ts diff --git a/src/scene/place/CameraController.tsx b/src/scene/place/CameraController.tsx index 4a5144e..53fbb4b 100644 --- a/src/scene/place/CameraController.tsx +++ b/src/scene/place/CameraController.tsx @@ -7,20 +7,19 @@ import { usePlaceStore } from "../../stores/placeStore"; import type { InputPreset } from "../../stores/placeStore"; import type { PlacePackage } from "../../data/placePackages"; import { APP_CONFIG } from "../../shared/config"; - -type GestureEventWithScale = Event & { - scale?: number; -}; +import { + applyLookQuaternion as applyCameraLookQuaternion, + applyWalkMovementFrame, + computeCameraBounds, + type CameraBounds, +} from "./cameraMath"; +import { useCameraModeTransitions } from "./useCameraModeTransitions"; +import { useCameraInputHandlers } from "./useCameraInputHandlers"; type CameraControllerProps = { pkg: PlacePackage; }; -type CameraBounds = { - min: number; - max: number; -}; - type PlaceStoreState = { viewMode: "top" | "walk"; inputPreset: InputPreset; @@ -56,9 +55,7 @@ export default function CameraController({ pkg }: CameraControllerProps) { }); const applyLookQuaternion = useCallback(() => { - const qYaw = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yawRef.current); - const qPitch = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), pitchRef.current); - camera.quaternion.copy(qYaw).multiply(qPitch); + applyCameraLookQuaternion(camera, yawRef.current, pitchRef.current); }, [camera]); const applyLookDelta = useCallback( @@ -90,287 +87,32 @@ export default function CameraController({ pkg }: CameraControllerProps) { applyLookQuaternion(); }, [camera, pkg.walkStartPosition, applyLookQuaternion]); - const applyForwardZoom = useCallback( - (delta: number, multiplier: number) => { - if (viewMode !== "walk") { - return; - } - - const zoomForward = zoomForwardRef.current; - zoomForward.set(0, 0, -1).applyQuaternion(camera.quaternion); - zoomForward.y = 0; - - if (zoomForward.lengthSq() <= 0) { - return; - } - - zoomForward.normalize(); - const zoomDistance = delta * multiplier; - const { min, max } = boundsRef.current; - const nextX = THREE.MathUtils.clamp( - camera.position.x + zoomForward.x * zoomDistance, - min, - max, - ); - const nextZ = THREE.MathUtils.clamp( - camera.position.z + zoomForward.z * zoomDistance, - min, - max, - ); - - camera.position.set(nextX, camera.position.y, nextZ); - }, - [camera, viewMode], - ); - - const applyTopZoom = useCallback( - (deltaY: number, multiplier = 1) => { - if (viewMode !== "top") { - return; - } - - const [topX, topY] = CAMERA_CONFIG.topViewPosition; - const minZ = CAMERA_CONFIG.topViewZoomRange.minZ; - const maxZ = CAMERA_CONFIG.topViewZoomRange.maxZ; - const nextZ = THREE.MathUtils.clamp( - camera.position.z + deltaY * CAMERA_CONFIG.topViewWheelZoomMultiplier * multiplier, - minZ, - maxZ, - ); - - camera.position.set(topX, topY, nextZ); - camera.lookAt(0, 0, 0); - }, - [camera, viewMode], - ); - useEffect(() => { - const maxAbs = Math.max( - ...pkg.roads.flatMap((r) => [Math.abs(r.start[0]), Math.abs(r.start[1]), Math.abs(r.end[0]), Math.abs(r.end[1])]), - CAMERA_CONFIG.boundsFallbackMaxAbs, - ); - boundsRef.current = { - min: -maxAbs - CAMERA_CONFIG.boundsPadding, - max: maxAbs + CAMERA_CONFIG.boundsPadding, - }; + boundsRef.current = computeCameraBounds(pkg, CAMERA_CONFIG); }, [pkg]); - useEffect(() => { - if (viewMode === "top") { - switchToTop(); - isWalkingRef.current = false; - } else { - switchToWalk(); - isWalkingRef.current = true; - } - }, [viewMode, switchToTop, switchToWalk]); - - useEffect(() => { - const onKeyDown = (e: KeyboardEvent) => { - const key = e.key.toLowerCase(); - keysRef.current.add(key); - - if (key === CAMERA_KEYBIND.toggleView) { - setViewMode(viewMode === "top" ? "walk" : "top"); - } - - if (key === CAMERA_KEYBIND.exitWalk && viewMode === "walk") { - setViewMode("top"); - } - }; - - const onKeyUp = (e: KeyboardEvent) => { - keysRef.current.delete(e.key.toLowerCase()); - }; - - const onMouseDown = (e: MouseEvent) => { - if (viewMode !== "walk") return; - if (e.button !== 0) return; - isMouseDraggingRef.current = true; - }; - - const onMouseUp = () => { - isMouseDraggingRef.current = false; - }; - - const onMouseMove = (e: MouseEvent) => { - if (viewMode !== "walk") return; - if (!isMouseDraggingRef.current) return; - applyLookDelta(e.movementX, e.movementY); - }; - - const onWheel = (e: WheelEvent) => { - if (viewMode === "top") { - e.preventDefault(); - applyTopZoom(e.deltaY); - return; - } - - if (viewMode !== "walk") return; - - const absX = Math.abs(e.deltaX); - const absY = Math.abs(e.deltaY); - const dominantRatio = CAMERA_CONFIG.gesture.wheelDominantAxisRatio; - - const isHorizontalLook = absX > absY * dominantRatio; - const isPinchLikeZoom = e.ctrlKey || e.metaKey; - const isVerticalZoom = absY > absX * dominantRatio && !isPinchLikeZoom; - - if (!isHorizontalLook && !isPinchLikeZoom && !isVerticalZoom) { - return; - } - - e.preventDefault(); - - if (isPinchLikeZoom) { - applyForwardZoom(e.deltaY, CAMERA_CONFIG.gesture.pinchZoomMultiplier); - - return; - } - - if (isVerticalZoom) { - applyForwardZoom(e.deltaY, CAMERA_CONFIG.gesture.wheelZoomMultiplier); - - return; - } - - const deltaX = isHorizontalLook ? e.deltaX : 0; - const deltaY = 0; - - applyLookDelta( - deltaX, - deltaY, - CAMERA_CONFIG.gesture.wheelLookMultiplier, - ); - }; - - const onTouchStart = (e: TouchEvent) => { - if (viewMode !== "walk") return; - const touch = e.touches[0]; - if (!touch) return; - touchPrevRef.current = { - x: touch.clientX, - y: touch.clientY, - }; - }; - - const onTouchMove = (e: TouchEvent) => { - if (viewMode !== "walk") return; - const touch = e.touches[0]; - if (!touch) return; - const previous = touchPrevRef.current; - if (!previous) { - touchPrevRef.current = { - x: touch.clientX, - y: touch.clientY, - }; - return; - } - - const deltaX = touch.clientX - previous.x; - const deltaY = touch.clientY - previous.y; - - touchPrevRef.current = { - x: touch.clientX, - y: touch.clientY, - }; - - applyLookDelta( - deltaX, - deltaY, - CAMERA_CONFIG.gesture.touchLookMultiplier, - ); - }; - - const onTouchEnd = () => { - touchPrevRef.current = null; - }; - - const onGestureStart = (event: Event) => { - const e = event as GestureEventWithScale; - if (viewMode !== "walk" && viewMode !== "top") { - return; - } - - if (typeof e.scale !== "number") { - return; - } - - pinchScalePrevRef.current = e.scale; - event.preventDefault(); - }; - - const onGestureChange = (event: Event) => { - const e = event as GestureEventWithScale; - if (viewMode !== "walk" && viewMode !== "top") { - return; - } - - if (typeof e.scale !== "number") { - return; - } - - const previousScale = pinchScalePrevRef.current; - if (previousScale === null) { - pinchScalePrevRef.current = e.scale; - return; - } - - const deltaScale = e.scale - previousScale; - pinchScalePrevRef.current = e.scale; - - if (Math.abs(deltaScale) < 0.001) { - return; - } - - event.preventDefault(); - - if (viewMode === "top") { - applyTopZoom( - -deltaScale, - CAMERA_CONFIG.gesture.pinchGestureZoomMultiplier, - ); - return; - } - - applyForwardZoom( - -deltaScale, - CAMERA_CONFIG.gesture.pinchGestureZoomMultiplier, - ); - }; - - const onGestureEnd = () => { - pinchScalePrevRef.current = null; - }; - - window.addEventListener("keydown", onKeyDown); - window.addEventListener("keyup", onKeyUp); - window.addEventListener("mousedown", onMouseDown); - window.addEventListener("mouseup", onMouseUp); - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("wheel", onWheel, { passive: false }); - window.addEventListener("touchstart", onTouchStart, { passive: true }); - window.addEventListener("touchmove", onTouchMove, { passive: true }); - window.addEventListener("touchend", onTouchEnd); - window.addEventListener("gesturestart", onGestureStart as EventListener, { passive: false }); - window.addEventListener("gesturechange", onGestureChange as EventListener, { passive: false }); - window.addEventListener("gestureend", onGestureEnd as EventListener); + useCameraModeTransitions({ + viewMode, + switchToTop, + switchToWalk, + isWalkingRef, + }); - return () => { - window.removeEventListener("keydown", onKeyDown); - window.removeEventListener("keyup", onKeyUp); - window.removeEventListener("mousedown", onMouseDown); - window.removeEventListener("mouseup", onMouseUp); - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("wheel", onWheel); - window.removeEventListener("touchstart", onTouchStart); - window.removeEventListener("touchmove", onTouchMove); - window.removeEventListener("touchend", onTouchEnd); - window.removeEventListener("gesturestart", onGestureStart as EventListener); - window.removeEventListener("gesturechange", onGestureChange as EventListener); - window.removeEventListener("gestureend", onGestureEnd as EventListener); - }; - }, [viewMode, inputPreset, setViewMode, applyLookDelta, applyForwardZoom, applyTopZoom]); + useCameraInputHandlers({ + viewMode, + inputPreset, + setViewMode, + camera, + cameraConfig: CAMERA_CONFIG, + cameraKeybind: CAMERA_KEYBIND, + boundsRef, + keysRef, + isMouseDraggingRef, + touchPrevRef, + pinchScalePrevRef, + zoomForwardRef, + applyLookDelta, + }); useFrame((_state, delta) => { if (!isWalkingRef.current) { @@ -379,53 +121,19 @@ export default function CameraController({ pkg }: CameraControllerProps) { const keys = keysRef.current; const config = PRESET_CONFIG[inputPreset]; - const moveDistance = config.moveSpeed * delta; - const verticalDistance = config.verticalSpeed * delta; - - const moveInput = moveInputRef.current; - moveInput.set( - Number(keys.has(CAMERA_KEYBIND.moveRight)) - Number(keys.has(CAMERA_KEYBIND.moveLeft)), - 0, - Number(keys.has(CAMERA_KEYBIND.moveBackward)) - Number(keys.has(CAMERA_KEYBIND.moveForward)), - ); - - if (moveInput.lengthSq() > 0) { - moveInput.normalize().multiplyScalar(moveDistance); - - const forward = forwardRef.current; - forward.set(0, 0, -1).applyQuaternion(camera.quaternion); - forward.y = 0; - forward.normalize(); - - const right = rightRef.current; - right.set(1, 0, 0).applyQuaternion(camera.quaternion); - right.y = 0; - right.normalize(); - - camera.position.addScaledVector(right, moveInput.x); - camera.position.addScaledVector(forward, -moveInput.z); - } - - let nextY = camera.position.y; - - if (keys.has(CAMERA_KEYBIND.moveUp)) { - nextY += verticalDistance; - } - - if (keys.has(CAMERA_KEYBIND.moveDown)) { - nextY -= verticalDistance; - } - const { min, max } = boundsRef.current; - camera.position.set( - THREE.MathUtils.clamp(camera.position.x, min, max), - THREE.MathUtils.clamp( - nextY, - CAMERA_CONFIG.walkHeight + CAMERA_CONFIG.walkVerticalClampOffset.min, - CAMERA_CONFIG.walkHeight + CAMERA_CONFIG.walkVerticalClampOffset.max, - ), - THREE.MathUtils.clamp(camera.position.z, min, max), - ); + applyWalkMovementFrame({ + camera, + keys, + inputConfig: config, + keybind: CAMERA_KEYBIND, + cameraConfig: CAMERA_CONFIG, + bounds: boundsRef.current, + delta, + moveInput: moveInputRef.current, + forward: forwardRef.current, + right: rightRef.current, + }); }); return null; diff --git a/src/scene/place/cameraMath.ts b/src/scene/place/cameraMath.ts new file mode 100644 index 0000000..468c13e --- /dev/null +++ b/src/scene/place/cameraMath.ts @@ -0,0 +1,150 @@ +import * as THREE from "three"; +import type { PlacePackage } from "../../data/placePackages"; +import { APP_CONFIG } from "../../shared/config"; + +export type CameraBounds = { + min: number; + max: number; +}; + +export type CameraConfig = typeof APP_CONFIG.scene.camera; +export type InputPresetConfig = { + moveSpeed: number; + verticalSpeed: number; + lookSensitivity: number; +}; +export type CameraKeybind = CameraConfig["keybind"]; + +export function applyLookQuaternion(camera: THREE.Camera, yaw: number, pitch: number) { + const qYaw = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yaw); + const qPitch = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), pitch); + camera.quaternion.copy(qYaw).multiply(qPitch); +} + +export function computeCameraBounds(pkg: PlacePackage, cameraConfig: CameraConfig): CameraBounds { + const maxAbs = Math.max( + ...pkg.roads.flatMap((r) => [Math.abs(r.start[0]), Math.abs(r.start[1]), Math.abs(r.end[0]), Math.abs(r.end[1])]), + cameraConfig.boundsFallbackMaxAbs, + ); + + return { + min: -maxAbs - cameraConfig.boundsPadding, + max: maxAbs + cameraConfig.boundsPadding, + }; +} + +export function applyWalkForwardZoom(params: { + camera: THREE.Camera; + bounds: CameraBounds; + delta: number; + multiplier: number; + zoomForward: THREE.Vector3; +}) { + const { camera, bounds, delta, multiplier, zoomForward } = params; + + zoomForward.set(0, 0, -1).applyQuaternion(camera.quaternion); + zoomForward.y = 0; + + if (zoomForward.lengthSq() <= 0) { + return; + } + + zoomForward.normalize(); + + const zoomDistance = delta * multiplier; + const nextX = THREE.MathUtils.clamp(camera.position.x + zoomForward.x * zoomDistance, bounds.min, bounds.max); + const nextZ = THREE.MathUtils.clamp(camera.position.z + zoomForward.z * zoomDistance, bounds.min, bounds.max); + + camera.position.set(nextX, camera.position.y, nextZ); +} + +export function applyTopViewZoom(params: { + camera: THREE.Camera; + cameraConfig: CameraConfig; + deltaY: number; + multiplier?: number; +}) { + const { camera, cameraConfig, deltaY, multiplier = 1 } = params; + const [topX, topY] = cameraConfig.topViewPosition; + const minZ = cameraConfig.topViewZoomRange.minZ; + const maxZ = cameraConfig.topViewZoomRange.maxZ; + + const nextZ = THREE.MathUtils.clamp( + camera.position.z + deltaY * cameraConfig.topViewWheelZoomMultiplier * multiplier, + minZ, + maxZ, + ); + + camera.position.set(topX, topY, nextZ); + camera.lookAt(0, 0, 0); +} + +export function applyWalkMovementFrame(params: { + camera: THREE.Camera; + keys: Set; + inputConfig: InputPresetConfig; + keybind: CameraKeybind; + cameraConfig: CameraConfig; + bounds: CameraBounds; + delta: number; + moveInput: THREE.Vector3; + forward: THREE.Vector3; + right: THREE.Vector3; +}) { + const { + camera, + keys, + inputConfig, + keybind, + cameraConfig, + bounds, + delta, + moveInput, + forward, + right, + } = params; + + const moveDistance = inputConfig.moveSpeed * delta; + const verticalDistance = inputConfig.verticalSpeed * delta; + + moveInput.set( + Number(keys.has(keybind.moveRight)) - Number(keys.has(keybind.moveLeft)), + 0, + Number(keys.has(keybind.moveBackward)) - Number(keys.has(keybind.moveForward)), + ); + + if (moveInput.lengthSq() > 0) { + moveInput.normalize().multiplyScalar(moveDistance); + + forward.set(0, 0, -1).applyQuaternion(camera.quaternion); + forward.y = 0; + forward.normalize(); + + right.set(1, 0, 0).applyQuaternion(camera.quaternion); + right.y = 0; + right.normalize(); + + camera.position.addScaledVector(right, moveInput.x); + camera.position.addScaledVector(forward, -moveInput.z); + } + + let nextY = camera.position.y; + + if (keys.has(keybind.moveUp)) { + nextY += verticalDistance; + } + + if (keys.has(keybind.moveDown)) { + nextY -= verticalDistance; + } + + camera.position.set( + THREE.MathUtils.clamp(camera.position.x, bounds.min, bounds.max), + THREE.MathUtils.clamp( + nextY, + cameraConfig.walkHeight + cameraConfig.walkVerticalClampOffset.min, + cameraConfig.walkHeight + cameraConfig.walkVerticalClampOffset.max, + ), + THREE.MathUtils.clamp(camera.position.z, bounds.min, bounds.max), + ); +} diff --git a/src/scene/place/useCameraInputHandlers.ts b/src/scene/place/useCameraInputHandlers.ts new file mode 100644 index 0000000..bd85837 --- /dev/null +++ b/src/scene/place/useCameraInputHandlers.ts @@ -0,0 +1,263 @@ +import { useEffect, type RefObject } from "react"; +import type * as THREE from "three"; +import type { InputPreset } from "../../stores/placeStore"; +import type { CameraConfig, CameraBounds, CameraKeybind } from "./cameraMath"; +import { applyTopViewZoom, applyWalkForwardZoom } from "./cameraMath"; + +type GestureEventWithScale = Event & { + scale?: number; +}; + +type UseCameraInputHandlersInput = { + viewMode: "top" | "walk"; + inputPreset: InputPreset; + setViewMode: (m: "top" | "walk") => void; + camera: THREE.Camera; + cameraConfig: CameraConfig; + cameraKeybind: CameraKeybind; + boundsRef: RefObject; + keysRef: RefObject>; + isMouseDraggingRef: RefObject; + touchPrevRef: RefObject<{ x: number; y: number } | null>; + pinchScalePrevRef: RefObject; + zoomForwardRef: RefObject; + applyLookDelta: (deltaX: number, deltaY: number, multiplier?: number) => void; +}; + +export function useCameraInputHandlers(input: UseCameraInputHandlersInput) { + const { + viewMode, + inputPreset, + setViewMode, + camera, + cameraConfig, + cameraKeybind, + boundsRef, + keysRef, + isMouseDraggingRef, + touchPrevRef, + pinchScalePrevRef, + zoomForwardRef, + applyLookDelta, + } = input; + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + const key = e.key.toLowerCase(); + keysRef.current.add(key); + + if (key === cameraKeybind.toggleView) { + setViewMode(viewMode === "top" ? "walk" : "top"); + } + + if (key === cameraKeybind.exitWalk && viewMode === "walk") { + setViewMode("top"); + } + }; + + const onKeyUp = (e: KeyboardEvent) => { + keysRef.current.delete(e.key.toLowerCase()); + }; + + const onMouseDown = (e: MouseEvent) => { + if (viewMode !== "walk") return; + if (e.button !== 0) return; + isMouseDraggingRef.current = true; + }; + + const onMouseUp = () => { + isMouseDraggingRef.current = false; + }; + + const onMouseMove = (e: MouseEvent) => { + if (viewMode !== "walk") return; + if (!isMouseDraggingRef.current) return; + applyLookDelta(e.movementX, e.movementY); + }; + + const onWheel = (e: WheelEvent) => { + if (viewMode === "top") { + e.preventDefault(); + applyTopViewZoom({ + camera, + cameraConfig, + deltaY: e.deltaY, + }); + return; + } + + if (viewMode !== "walk") return; + + const absX = Math.abs(e.deltaX); + const absY = Math.abs(e.deltaY); + const dominantRatio = cameraConfig.gesture.wheelDominantAxisRatio; + + const isHorizontalLook = absX > absY * dominantRatio; + const isPinchLikeZoom = e.ctrlKey || e.metaKey; + const isVerticalZoom = absY > absX * dominantRatio && !isPinchLikeZoom; + + if (!isHorizontalLook && !isPinchLikeZoom && !isVerticalZoom) { + return; + } + + e.preventDefault(); + + if (isPinchLikeZoom) { + applyWalkForwardZoom({ + camera, + bounds: boundsRef.current, + delta: e.deltaY, + multiplier: cameraConfig.gesture.pinchZoomMultiplier, + zoomForward: zoomForwardRef.current, + }); + return; + } + + if (isVerticalZoom) { + applyWalkForwardZoom({ + camera, + bounds: boundsRef.current, + delta: e.deltaY, + multiplier: cameraConfig.gesture.wheelZoomMultiplier, + zoomForward: zoomForwardRef.current, + }); + return; + } + + applyLookDelta(e.deltaX, 0, cameraConfig.gesture.wheelLookMultiplier); + }; + + const onTouchStart = (e: TouchEvent) => { + if (viewMode !== "walk") return; + const touch = e.touches[0]; + if (!touch) return; + touchPrevRef.current = { + x: touch.clientX, + y: touch.clientY, + }; + }; + + const onTouchMove = (e: TouchEvent) => { + if (viewMode !== "walk") return; + const touch = e.touches[0]; + if (!touch) return; + + const previous = touchPrevRef.current; + if (!previous) { + touchPrevRef.current = { + x: touch.clientX, + y: touch.clientY, + }; + return; + } + + const deltaX = touch.clientX - previous.x; + const deltaY = touch.clientY - previous.y; + + touchPrevRef.current = { + x: touch.clientX, + y: touch.clientY, + }; + + applyLookDelta(deltaX, deltaY, cameraConfig.gesture.touchLookMultiplier); + }; + + const onTouchEnd = () => { + touchPrevRef.current = null; + }; + + const onGestureStart = (event: Event) => { + const e = event as GestureEventWithScale; + if (viewMode !== "walk" && viewMode !== "top") return; + if (typeof e.scale !== "number") return; + + pinchScalePrevRef.current = e.scale; + event.preventDefault(); + }; + + const onGestureChange = (event: Event) => { + const e = event as GestureEventWithScale; + if (viewMode !== "walk" && viewMode !== "top") return; + if (typeof e.scale !== "number") return; + + const previousScale = pinchScalePrevRef.current; + if (previousScale === null) { + pinchScalePrevRef.current = e.scale; + return; + } + + const deltaScale = e.scale - previousScale; + pinchScalePrevRef.current = e.scale; + + if (Math.abs(deltaScale) < 0.001) { + return; + } + + event.preventDefault(); + + if (viewMode === "top") { + applyTopViewZoom({ + camera, + cameraConfig, + deltaY: -deltaScale, + multiplier: cameraConfig.gesture.pinchGestureZoomMultiplier, + }); + return; + } + + applyWalkForwardZoom({ + camera, + bounds: boundsRef.current, + delta: -deltaScale, + multiplier: cameraConfig.gesture.pinchGestureZoomMultiplier, + zoomForward: zoomForwardRef.current, + }); + }; + + const onGestureEnd = () => { + pinchScalePrevRef.current = null; + }; + + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + window.addEventListener("mousedown", onMouseDown); + window.addEventListener("mouseup", onMouseUp); + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("wheel", onWheel, { passive: false }); + window.addEventListener("touchstart", onTouchStart, { passive: true }); + window.addEventListener("touchmove", onTouchMove, { passive: true }); + window.addEventListener("touchend", onTouchEnd); + window.addEventListener("gesturestart", onGestureStart as EventListener, { passive: false }); + window.addEventListener("gesturechange", onGestureChange as EventListener, { passive: false }); + window.addEventListener("gestureend", onGestureEnd as EventListener); + + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("mousedown", onMouseDown); + window.removeEventListener("mouseup", onMouseUp); + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("wheel", onWheel); + window.removeEventListener("touchstart", onTouchStart); + window.removeEventListener("touchmove", onTouchMove); + window.removeEventListener("touchend", onTouchEnd); + window.removeEventListener("gesturestart", onGestureStart as EventListener); + window.removeEventListener("gesturechange", onGestureChange as EventListener); + window.removeEventListener("gestureend", onGestureEnd as EventListener); + }; + }, [ + applyLookDelta, + boundsRef, + camera, + cameraConfig, + cameraKeybind, + inputPreset, + isMouseDraggingRef, + keysRef, + pinchScalePrevRef, + setViewMode, + touchPrevRef, + viewMode, + zoomForwardRef, + ]); +} diff --git a/src/scene/place/useCameraModeTransitions.ts b/src/scene/place/useCameraModeTransitions.ts new file mode 100644 index 0000000..126bfd4 --- /dev/null +++ b/src/scene/place/useCameraModeTransitions.ts @@ -0,0 +1,23 @@ +import { useEffect } from "react"; + +type UseCameraModeTransitionsInput = { + viewMode: "top" | "walk"; + switchToTop: () => void; + switchToWalk: () => void; + isWalkingRef: { current: boolean }; +}; + +export function useCameraModeTransitions(input: UseCameraModeTransitionsInput) { + const { viewMode, switchToTop, switchToWalk, isWalkingRef } = input; + + useEffect(() => { + if (viewMode === "top") { + switchToTop(); + isWalkingRef.current = false; + return; + } + + switchToWalk(); + isWalkingRef.current = true; + }, [isWalkingRef, switchToTop, switchToWalk, viewMode]); +} From 73b836bfa78dd7c7cde605dfc8ffbb6e3837bec8 Mon Sep 17 00:00:00 2001 From: ummsehun Date: Mon, 6 Apr 2026 09:13:53 +0900 Subject: [PATCH 21/28] feat:phase02 clear --- src/scene/place/PlaceSceneContent.tsx | 104 ++------------------- src/scene/place/usePlaceBootstrap.ts | 124 ++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 95 deletions(-) create mode 100644 src/scene/place/usePlaceBootstrap.ts diff --git a/src/scene/place/PlaceSceneContent.tsx b/src/scene/place/PlaceSceneContent.tsx index e5d522b..32fea57 100644 --- a/src/scene/place/PlaceSceneContent.tsx +++ b/src/scene/place/PlaceSceneContent.tsx @@ -1,16 +1,10 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; import { usePlaceStore } from "../../stores/placeStore"; import { useAppStore } from "../../stores/appStore"; -import type { PlacePackage } from "../../data/placePackages"; import { usePlaybackStore } from "../../stores/playbackStore"; import { APP_CONFIG } from "../../shared/config"; -import { createLogger, toErrorContext } from "../../shared/logger"; -import { - fetchSceneBootstrapBundle, -} from "../../shared/api"; -import type { GeometryLiveMapping, SceneBootstrap } from "../../shared/contracts"; import StaticEnvironment from "./StaticEnvironment"; import CameraController from "./CameraController"; import PlaybackSystem from "./PlaybackSystem"; @@ -18,17 +12,12 @@ import RainEffect from "./RainEffect"; import PedestrianSystem from "./PedestrianSystem"; import VehicleSystem from "./VehicleSystem"; import { useSceneLiveData } from "./useSceneLiveData"; +import { usePlaceBootstrap } from "./usePlaceBootstrap"; type PlaceSceneContentProps = { slug: string; }; -const logger = createLogger("scene:place-content"); - -function toPlaceLabel(slug: string) { - return slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); -} - export default function PlaceSceneContent({ slug }: PlaceSceneContentProps) { const setStatus = usePlaceStore((s) => s.setStatus); const setProgress = usePlaceStore((s) => s.setProgress); @@ -44,94 +33,19 @@ export default function PlaceSceneContent({ slug }: PlaceSceneContentProps) { const isNight = usePlaybackStore((s) => s.isNight()); const weather = usePlaybackStore((s) => s.weather); - const [scenePkgBySlug, setScenePkgBySlug] = useState>({}); - const [sceneBootstrapBySlug, setSceneBootstrapBySlug] = useState>({}); - const [sceneMappingBySlug, setSceneMappingBySlug] = useState>({}); - - const scenePkg = scenePkgBySlug[slug] ?? null; - const sceneBootstrap = sceneBootstrapBySlug[slug] ?? null; - const sceneMapping = sceneMappingBySlug[slug] ?? null; + const { scenePkg, sceneBootstrap, sceneMapping } = usePlaceBootstrap({ + slug, + setStatus, + setProgress, + setCurrentPlace, + setMode, + }); const normalizedHour = useMemo(() => { const raw = Math.floor(currentTime); return ((raw % 24) + 24) % 24; }, [currentTime]); - useEffect(() => { - let mounted = true; - let readyTimer: ReturnType | null = null; - - setStatus("loading"); - setProgress(APP_CONFIG.place.loading.initialProgress); - - void fetchSceneBootstrapBundle(slug) - .then(({ bootstrap, mapping, pkg: fetchedPkg }) => { - if (!mounted) { - return; - } - - setScenePkgBySlug((previous) => ({ - ...previous, - [slug]: fetchedPkg, - })); - setSceneBootstrapBySlug((previous) => ({ - ...previous, - [slug]: bootstrap, - })); - setSceneMappingBySlug((previous) => ({ - ...previous, - [slug]: mapping, - })); - - logger.info("Bootstrapping place scene", { - slug, - geometryId: bootstrap.geometryId, - bindingCount: mapping.bindings.length, - assetUrl: bootstrap.assetUrl, - }); - - setCurrentPlace({ - id: bootstrap.placeId, - slug: bootstrap.slug, - name: toPlaceLabel(bootstrap.slug), - lat: 0, - lng: 0, - city: "", - country: "", - }); - setMode("place"); - - readyTimer = setTimeout(() => { - if (!mounted) { - return; - } - setProgress(APP_CONFIG.place.loading.completedProgress); - setStatus("ready"); - logger.info("Place scene ready", { - slug, - }); - }, APP_CONFIG.place.loading.readyDelayMs); - - }) - .catch((error) => { - if (!mounted) { - return; - } - setStatus("error"); - logger.error("Failed to bootstrap place scene", { - slug, - ...toErrorContext(error), - }); - }); - - return () => { - mounted = false; - if (readyTimer) { - clearTimeout(readyTimer); - } - }; - }, [setCurrentPlace, setMode, setProgress, setStatus, slug]); - useSceneLiveData({ slug, bootstrap: sceneBootstrap, diff --git a/src/scene/place/usePlaceBootstrap.ts b/src/scene/place/usePlaceBootstrap.ts new file mode 100644 index 0000000..1cb4044 --- /dev/null +++ b/src/scene/place/usePlaceBootstrap.ts @@ -0,0 +1,124 @@ +import { useEffect, useState } from "react"; +import type { PlacePackage } from "../../data/placePackages"; +import { APP_CONFIG } from "../../shared/config"; +import { fetchSceneBootstrapBundle } from "../../shared/api"; +import type { GeometryLiveMapping, SceneBootstrap } from "../../shared/contracts"; +import { createLogger, toErrorContext } from "../../shared/logger"; + +type UsePlaceBootstrapInput = { + slug: string; + setStatus: (status: "idle" | "loading" | "ready" | "error") => void; + setProgress: (progress: number) => void; + setCurrentPlace: (place: { + id: string; + slug: string; + name: string; + lat: number; + lng: number; + city: string; + country: string; + } | null) => void; + setMode: (mode: "globe" | "loading" | "place") => void; +}; + +type UsePlaceBootstrapResult = { + scenePkg: PlacePackage | null; + sceneBootstrap: SceneBootstrap | null; + sceneMapping: GeometryLiveMapping | null; +}; + +const logger = createLogger("scene:place-bootstrap"); + +function toPlaceLabel(slug: string) { + return slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +export function usePlaceBootstrap(input: UsePlaceBootstrapInput): UsePlaceBootstrapResult { + const { slug, setStatus, setProgress, setCurrentPlace, setMode } = input; + + const [scenePkgBySlug, setScenePkgBySlug] = useState>({}); + const [sceneBootstrapBySlug, setSceneBootstrapBySlug] = useState>({}); + const [sceneMappingBySlug, setSceneMappingBySlug] = useState>({}); + + useEffect(() => { + let mounted = true; + let readyTimer: ReturnType | null = null; + + setStatus("loading"); + setProgress(APP_CONFIG.place.loading.initialProgress); + + void fetchSceneBootstrapBundle(slug) + .then(({ bootstrap, mapping, pkg: fetchedPkg }) => { + if (!mounted) { + return; + } + + setScenePkgBySlug((previous) => ({ + ...previous, + [slug]: fetchedPkg, + })); + setSceneBootstrapBySlug((previous) => ({ + ...previous, + [slug]: bootstrap, + })); + setSceneMappingBySlug((previous) => ({ + ...previous, + [slug]: mapping, + })); + + logger.info("Bootstrapping place scene", { + slug, + geometryId: bootstrap.geometryId, + bindingCount: mapping.bindings.length, + assetUrl: bootstrap.assetUrl, + }); + + setCurrentPlace({ + id: bootstrap.placeId, + slug: bootstrap.slug, + name: toPlaceLabel(bootstrap.slug), + lat: 0, + lng: 0, + city: "", + country: "", + }); + setMode("place"); + + readyTimer = setTimeout(() => { + if (!mounted) { + return; + } + + setProgress(APP_CONFIG.place.loading.completedProgress); + setStatus("ready"); + logger.info("Place scene ready", { + slug, + }); + }, APP_CONFIG.place.loading.readyDelayMs); + }) + .catch((error) => { + if (!mounted) { + return; + } + + setStatus("error"); + logger.error("Failed to bootstrap place scene", { + slug, + ...toErrorContext(error), + }); + }); + + return () => { + mounted = false; + if (readyTimer) { + clearTimeout(readyTimer); + } + }; + }, [setCurrentPlace, setMode, setProgress, setStatus, slug]); + + return { + scenePkg: scenePkgBySlug[slug] ?? null, + sceneBootstrap: sceneBootstrapBySlug[slug] ?? null, + sceneMapping: sceneMappingBySlug[slug] ?? null, + }; +} From 6271455fb6256754601f6b7795a1e25cd58e5d2d Mon Sep 17 00:00:00 2001 From: ummsehun Date: Mon, 6 Apr 2026 19:16:57 +0900 Subject: [PATCH 22/28] feat:scene module --- src/app/explorer/page.tsx | 83 ++++++++ src/app/page.tsx | 244 +++++++++++++++++------- src/app/place/[slug]/page.tsx | 2 +- src/components/hud/PlaybackHUD.tsx | 141 +++----------- src/components/hud/playbackSections.tsx | 173 +++++++++++++++++ src/scene/place/PlaceSceneContent.tsx | 5 +- src/scene/place/useSceneLiveData.ts | 43 ++++- 7 files changed, 497 insertions(+), 194 deletions(-) create mode 100644 src/app/explorer/page.tsx create mode 100644 src/components/hud/playbackSections.tsx diff --git a/src/app/explorer/page.tsx b/src/app/explorer/page.tsx new file mode 100644 index 0000000..68b4a31 --- /dev/null +++ b/src/app/explorer/page.tsx @@ -0,0 +1,83 @@ +import GlobeScene from "@/src/globe/GlobeScene"; +import { MVP_PLACES } from "@/src/data/places"; +import type { Place } from "@/src/types/place"; +import { Globe, MapPin, Search } from "lucide-react"; +import Link from "next/link"; + +export default function ExplorerHome() { + return ( +

+ + + {/* Top Header */} +
+
+
+
+ +
+
+

WorMap Engine

+

GLOBAL DISCOVERY

+
+
+ +
+ + Search coordinates or places... +
+ +
+
+ System Status + Online +
+
+
+
+
+
+
+ + {/* Bottom Place Selector */} +
+
+ {MVP_PLACES.map((place: Place) => ( + +
+
+ Travel to {place.name} +
+
+ +
+
+
+
+ +
+
+

{place.city}

+

{place.name}

+
+
+
+ + ))} +
+
+ + {/* Info Badge */} +
+
+

+ Coordinates: 37.5665° N, 126.9780° E +

+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 5acd49e..443a015 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,82 +1,194 @@ -import GlobeScene from "../globe/GlobeScene"; -import { MVP_PLACES } from "../data/places"; -import { Globe, MapPin, Search } from "lucide-react"; -import Link from "next/link"; +import Link from 'next/link'; +import { + Globe, Search, Folder, PlusCircle, HelpCircle, Archive, + Bell, Settings, User, MoreVertical, Layers, ChevronDown, Monitor, Map +} from 'lucide-react'; -export default function Home() { +export default function Dashboard() { return ( -
- +
+ {/* Sidebar */} + - {/* Top Header */} -
-
-
-
- -
-
-

WorMap Engine

-

GLOBAL DISCOVERY

-
+ {/* Main Content */} +
+ {/* Header */} +
+
+

WorMap Geospatial

+ +
+
+ + +
+
+ + {/* Scrollable Area */} +
-
- - Search coordinates or places... + {/* Banner */} +
+
+ New Release +

Experience WorMap v4.0

+

+ Introducing multi-layered topographic contouring and real-time 3D terrain rendering. Optimized for large-scale enterprise spatial data management. +

+
+ + Explore Layers + +
+
+ + {/* Banner Background Pattern using Lucide Icons */} +
+
+ + + + + + + + + + +
+
+ {/* Gradient fade to blend left and right */} +
-
-
- System Status - Online + {/* Controls Bar */} +
+
+ +
-
-
+
+ +
+ + {/* Project List */} +
+
+

Project Inventory

+ 4 Active Projects +
+ + + + + + + + + + + + + {[ + { name: 'Untitled Map', icon: , owner: 'Me', size: '14.2 MB', date: '2 mins ago', link: '/explorer', type: 'map' }, + { name: 'Urban Expansion Analysis', icon: , owner: 'Team Alpha', size: '1.4 GB', date: 'Yesterday, 4:12 PM', link: '/explorer', type: 'layers' }, + { name: 'Grid Network Beta', icon: , owner: 'Me', size: '84 KB', date: 'Oct 24, 2023', link: '/explorer', type: 'globe' } + ].map((item, i) => ( + + + + + + + + ))} + +
NameOwnerStorage UsedLast ModifiedActions
+ +
+ {item.icon} +
+ {item.name} + +
{item.owner}{item.size}{item.date} + +
+
+
-
- {/* Bottom Place Selector */} -
-
- {MVP_PLACES.map((place) => ( - -
-
- Travel to {place.name} -
-
- -
-
-
-
- -
-
-

{place.city}

-

{place.name}

-
-
-
- - ))} + {/* Floating Action Button */} +
+ + + New Project +
-
- {/* Info Badge */} -
-
-

- Coordinates: 37.5665° N, 126.9780° E -

-
-
+ {/* Footer */} +
+
+
+ + System Online: Node-West-01 +
+
+ + 8.2 GB / 15 GB Used +
+
+
+ + Lat: 34.0522° N, Lon: 118.2437° W +
+
+
+
); } diff --git a/src/app/place/[slug]/page.tsx b/src/app/place/[slug]/page.tsx index 1d40c8d..96b2a7b 100644 --- a/src/app/place/[slug]/page.tsx +++ b/src/app/place/[slug]/page.tsx @@ -1,4 +1,4 @@ -import PlaceScene from "../../../scene/place/PlaceScene"; +import PlaceScene from "@/src/scene/place/PlaceScene"; type PlacePageProps = { params: Promise<{ slug: string }>; diff --git a/src/components/hud/PlaybackHUD.tsx b/src/components/hud/PlaybackHUD.tsx index 2d85cf3..3164239 100644 --- a/src/components/hud/PlaybackHUD.tsx +++ b/src/components/hud/PlaybackHUD.tsx @@ -1,35 +1,14 @@ -import { - Play, - Pause, - Cloud, - CloudRain, - Sun, - Clock, - Navigation, - Move, - ChevronLeft, - ChevronRight -} from "lucide-react"; import { usePlaybackStore } from "../../stores/playbackStore"; import { usePlaceStore } from "../../stores/placeStore"; -import type { InputPreset } from "../../stores/placeStore"; -import { ControlButton } from "../ui/ControlButton"; import { Panel } from "../ui/Panel"; - -const SPEED_OPTIONS = [1, 2, 4]; -const WEATHER_OPTIONS = [ - { value: "clear", icon: Sun, label: "Clear" }, - { value: "cloudy", icon: Cloud, label: "Cloudy" }, - { value: "rain", icon: CloudRain, label: "Rain" }, -] as const; -const INPUT_PRESET_OPTIONS: InputPreset[] = ["precision", "balanced", "fast"]; - -function formatTime(hour: number) { - const normalized = ((hour % 24) + 24) % 24; - const h = Math.floor(normalized); - const m = Math.floor((normalized - h) * 60); - return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; -} +import { + InputPresetSection, + PlaybackControlSection, + TimeControlSection, + ViewModeToggleSection, + WalkModeHint, + WeatherControlSection, +} from "./playbackSections"; export default function PlaybackHUD() { const { isPlaying, speed, weather, currentTime, setIsPlaying, setSpeed, setWeather, setCurrentTime } = @@ -42,112 +21,36 @@ export default function PlaybackHUD() { return (
- {/* Playback Controls */} -
- - -
- {SPEED_OPTIONS.map((s) => ( - setSpeed(s)} - size="chip" - tone={speed === s ? "active" : "default"} - > - {s}× - - ))} -
-
+ setIsPlaying(!isPlaying)} + onSetSpeed={setSpeed} + />
- {/* Weather Controls */} -
- {WEATHER_OPTIONS.map(({ value, icon: Icon, label }) => ( - setWeather(value)} - size="icon-md" - tone={weather === value ? "active" : "default"} - title={label} - > - - - ))} -
+
- {/* Time Controls */} -
-
- - - {formatTime(currentTime)} - -
- -
- setCurrentTime(currentTime - 1)} - size="icon-sm" - title="-1시간" - > - - - setCurrentTime(currentTime + 1)} - size="icon-sm" - title="+1시간" - > - - -
-
+
- {/* View Mode Toggle */} - + setViewMode(viewMode === "top" ? "walk" : "top")} + />
- {/* Sensitivity / Input Presets */} -
- {INPUT_PRESET_OPTIONS.map((preset) => ( - setInputPreset(preset)} - className="px-2.5 text-[10px] tracking-tighter" - size="chip" - tone={inputPreset === preset ? "active" : "default"} - > - {preset} - - ))} -
+ {viewMode === "walk" && ( -

- WASD move · Two-finger ↑↓ zoom · Two-finger ↔ look · Spread / Pinch zoom · Drag look · V toggle view · ESC back -

+
)}
diff --git a/src/components/hud/playbackSections.tsx b/src/components/hud/playbackSections.tsx new file mode 100644 index 0000000..f84477d --- /dev/null +++ b/src/components/hud/playbackSections.tsx @@ -0,0 +1,173 @@ +import { + Play, + Pause, + Cloud, + CloudRain, + Snowflake, + Sun, + Clock, + Navigation, + Move, + ChevronLeft, + ChevronRight, +} from "lucide-react"; +import { ControlButton } from "../ui/ControlButton"; +import type { InputPreset } from "../../stores/placeStore"; + +const SPEED_OPTIONS = [1, 2, 4]; +const WEATHER_OPTIONS = [ + { value: "clear", icon: Sun, label: "Clear" }, + { value: "cloudy", icon: Cloud, label: "Cloudy" }, + { value: "rain", icon: CloudRain, label: "Rain" }, + { value: "snow", icon: Snowflake, label: "Snow" }, +] as const; +const INPUT_PRESET_OPTIONS: InputPreset[] = ["precision", "balanced", "fast"]; + +function formatTime(hour: number) { + const normalized = ((hour % 24) + 24) % 24; + const h = Math.floor(normalized); + const m = Math.floor((normalized - h) * 60); + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; +} + +type PlaybackControlSectionProps = { + isPlaying: boolean; + speed: number; + onTogglePlayback: () => void; + onSetSpeed: (speed: number) => void; +}; + +export function PlaybackControlSection(props: PlaybackControlSectionProps) { + const { isPlaying, speed, onTogglePlayback, onSetSpeed } = props; + + return ( +
+ + +
+ {SPEED_OPTIONS.map((itemSpeed) => ( + onSetSpeed(itemSpeed)} + size="chip" + tone={speed === itemSpeed ? "active" : "default"} + > + {itemSpeed}× + + ))} +
+
+ ); +} + +type WeatherControlSectionProps = { + weather: "clear" | "cloudy" | "rain" | "snow"; + onSetWeather: (weather: "clear" | "cloudy" | "rain" | "snow") => void; +}; + +export function WeatherControlSection(props: WeatherControlSectionProps) { + const { weather, onSetWeather } = props; + + return ( +
+ {WEATHER_OPTIONS.map(({ value, icon: Icon, label }) => ( + onSetWeather(value)} + size="icon-md" + tone={weather === value ? "active" : "default"} + title={label} + > + + + ))} +
+ ); +} + +type TimeControlSectionProps = { + currentTime: number; + onSetCurrentTime: (time: number) => void; +}; + +export function TimeControlSection(props: TimeControlSectionProps) { + const { currentTime, onSetCurrentTime } = props; + + return ( +
+
+ + + {formatTime(currentTime)} + +
+ +
+ onSetCurrentTime(currentTime - 1)} size="icon-sm" title="-1시간"> + + + onSetCurrentTime(currentTime + 1)} size="icon-sm" title="+1시간"> + + +
+
+ ); +} + +type ViewModeToggleSectionProps = { + viewMode: "top" | "walk"; + onToggleViewMode: () => void; +}; + +export function ViewModeToggleSection(props: ViewModeToggleSectionProps) { + const { viewMode, onToggleViewMode } = props; + + return ( + + ); +} + +type InputPresetSectionProps = { + inputPreset: InputPreset; + onSetInputPreset: (preset: InputPreset) => void; +}; + +export function InputPresetSection(props: InputPresetSectionProps) { + const { inputPreset, onSetInputPreset } = props; + + return ( +
+ {INPUT_PRESET_OPTIONS.map((preset) => ( + onSetInputPreset(preset)} + className="px-2.5 text-[10px] tracking-tighter" + size="chip" + tone={inputPreset === preset ? "active" : "default"} + > + {preset} + + ))} +
+ ); +} + +export function WalkModeHint() { + return ( +

+ WASD move · Two-finger ↑↓ zoom · Two-finger ↔ look · Spread / Pinch zoom · Drag look · V toggle view · ESC back +

+ ); +} diff --git a/src/scene/place/PlaceSceneContent.tsx b/src/scene/place/PlaceSceneContent.tsx index 32fea57..d060e2e 100644 --- a/src/scene/place/PlaceSceneContent.tsx +++ b/src/scene/place/PlaceSceneContent.tsx @@ -27,11 +27,10 @@ export default function PlaceSceneContent({ slug }: PlaceSceneContentProps) { const setPedestrianLevel = usePlaybackStore((s) => s.setPedestrianLevel); const setVehicleLevel = usePlaybackStore((s) => s.setVehicleLevel); const currentTime = usePlaybackStore((s) => s.currentTime); - const currentWeather = usePlaybackStore((s) => s.weather); + const weather = usePlaybackStore((s) => s.weather); const currentPedestrianLevel = usePlaybackStore((s) => s.pedestrianLevel); const currentVehicleLevel = usePlaybackStore((s) => s.vehicleLevel); const isNight = usePlaybackStore((s) => s.isNight()); - const weather = usePlaybackStore((s) => s.weather); const { scenePkg, sceneBootstrap, sceneMapping } = usePlaceBootstrap({ slug, @@ -50,7 +49,7 @@ export default function PlaceSceneContent({ slug }: PlaceSceneContentProps) { slug, bootstrap: sceneBootstrap, normalizedHour, - currentWeather, + currentWeather: weather, currentPedestrianLevel, currentVehicleLevel, setWeather, diff --git a/src/scene/place/useSceneLiveData.ts b/src/scene/place/useSceneLiveData.ts index b2e4d66..7f92c38 100644 --- a/src/scene/place/useSceneLiveData.ts +++ b/src/scene/place/useSceneLiveData.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { fetchLivePlaces, fetchLiveTraffic, @@ -8,6 +8,7 @@ import { createLogger, toErrorContext } from "../../shared/logger"; import type { SceneBootstrap } from "../../shared/contracts"; const logger = createLogger("scene:use-live-data"); +const LIVE_FETCH_MIN_INTERVAL_MS = 1200; type UseSceneLiveDataInput = { slug: string; @@ -34,11 +35,43 @@ export function useSceneLiveData(input: UseSceneLiveDataInput) { setVehicleLevel, } = input; + const latestWeatherRef = useRef(currentWeather); + const latestPedestrianLevelRef = useRef(currentPedestrianLevel); + const latestVehicleLevelRef = useRef(currentVehicleLevel); + const lastFetchRef = useRef<{ key: string; requestedAt: number } | null>(null); + + latestWeatherRef.current = currentWeather; + latestPedestrianLevelRef.current = currentPedestrianLevel; + latestVehicleLevelRef.current = currentVehicleLevel; + useEffect(() => { if (!bootstrap) { return; } + const requestKey = [ + bootstrap.geometryId, + normalizedHour, + currentWeather, + currentPedestrianLevel, + currentVehicleLevel, + ].join("|"); + const now = Date.now(); + const lastFetch = lastFetchRef.current; + + if ( + lastFetch && + lastFetch.key === requestKey && + now - lastFetch.requestedAt < LIVE_FETCH_MIN_INTERVAL_MS + ) { + return; + } + + lastFetchRef.current = { + key: requestKey, + requestedAt: now, + }; + let mounted = true; void Promise.all([ @@ -51,13 +84,13 @@ export function useSceneLiveData(input: UseSceneLiveDataInput) { return; } - if (weatherSnapshot.condition !== currentWeather) { + if (weatherSnapshot.condition !== latestWeatherRef.current) { setWeather(weatherSnapshot.condition); } - if (placeSnapshot.pedestrianDensity !== currentPedestrianLevel) { + if (placeSnapshot.pedestrianDensity !== latestPedestrianLevelRef.current) { setPedestrianLevel(placeSnapshot.pedestrianDensity); } - if (placeSnapshot.vehicleDensity !== currentVehicleLevel) { + if (placeSnapshot.vehicleDensity !== latestVehicleLevelRef.current) { setVehicleLevel(placeSnapshot.vehicleDensity); } @@ -85,9 +118,9 @@ export function useSceneLiveData(input: UseSceneLiveDataInput) { }; }, [ bootstrap, + currentWeather, currentPedestrianLevel, currentVehicleLevel, - currentWeather, normalizedHour, setPedestrianLevel, setVehicleLevel, From 06803a4fb897d85f317436c55e96053742cb2944 Mon Sep 17 00:00:00 2001 From: ummsehun Date: Mon, 6 Apr 2026 19:29:50 +0900 Subject: [PATCH 23/28] feat:i18n add --- src/app/page.tsx | 292 ++++++++++++++++++++++++------------- src/i18n/en.ts | 57 ++++++++ src/i18n/ko.ts | 57 ++++++++ src/stores/useI18nStore.ts | 43 ++++++ 4 files changed, 351 insertions(+), 98 deletions(-) create mode 100644 src/i18n/en.ts create mode 100644 src/i18n/ko.ts create mode 100644 src/stores/useI18nStore.ts diff --git a/src/app/page.tsx b/src/app/page.tsx index 443a015..a89e4a0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,33 +1,72 @@ +'use client'; + +import { useState } from 'react'; import Link from 'next/link'; import { Globe, Search, Folder, PlusCircle, HelpCircle, Archive, Bell, Settings, User, MoreVertical, Layers, ChevronDown, Monitor, Map } from 'lucide-react'; +import { useTranslation } from '../stores/useI18nStore'; export default function Dashboard() { + const { t, lang, setLang } = useTranslation(); + const [activeSidebar, setActiveSidebar] = useState('projects'); + const [filterTab, setFilterTab] = useState('projects'); + const [popover, setPopover] = useState(null); + + const togglePopover = (id: string) => { + setPopover((prev) => (prev === id ? null : id)); + }; + + const inventoryItems = [ + { id: '1', name: 'Untitled Map', icon: , owner: 'Me', size: '14.2 MB', date: '2 mins ago', link: '/explorer', type: 'map' }, + { id: '2', name: 'Urban Expansion Analysis', icon: , owner: 'Team Alpha', size: '1.4 GB', date: 'Yesterday, 4:12 PM', link: '/explorer', type: 'layers' }, + { id: '3', name: 'Grid Network Beta', icon: , owner: 'Me', size: '84 KB', date: 'Oct 24, 2023', link: '/explorer', type: 'globe' } + ]; + return ( -
+
setPopover(null)}> {/* Sidebar */} -
diff --git a/src/i18n/en.ts b/src/i18n/en.ts new file mode 100644 index 0000000..3846c9b --- /dev/null +++ b/src/i18n/en.ts @@ -0,0 +1,57 @@ +export const en = { + sidebar: { + search: "Search", + projects: "Projects", + new: "New Project", + help: "Help", + archive: "Archive" + }, + header: { + title: "WorMap Geospatial", + explorer: "Explorer", + projects: "Projects", + assets: "Assets" + }, + banner: { + release: "New Release", + title: "Experience WorMap v4.0", + description: "Introducing multi-layered topographic contouring and real-time 3D terrain rendering. Optimized for large-scale enterprise spatial data management.", + button: "Explore Layers" + }, + filters: { + projects: "Projects", + archived: "Archived", + filterBy: "Filter by", + ownerAll: "Owner: All", + sort: "Sort", + lastModified: "Last Modified" + }, + inventory: { + title: "Project Inventory", + activeProjects: "{count} Active Projects", + colName: "Name", + colOwner: "Owner", + colStorage: "Storage Used", + colLastModified: "Last Modified", + colActions: "Actions" + }, + footer: { + newProject: "New Project", + systemOnline: "System Online", + used: "Used", + modifyKml: "Modify Local KML", + lat: "Lat", + lon: "Lon" + }, + popovers: { + notifications: "No new notifications", + settings: "Settings", + profile: "Profile menu", + edit: "Edit", + delete: "Delete", + share: "Share", + changeLang: "한국어로 변경" + } +}; + +export type Dictionary = typeof en; diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts new file mode 100644 index 0000000..3fedd33 --- /dev/null +++ b/src/i18n/ko.ts @@ -0,0 +1,57 @@ +import { Dictionary } from "./en"; + +export const ko: Dictionary = { + sidebar: { + search: "검색", + projects: "프로젝트", + new: "새 프로젝트", + help: "도움말", + archive: "보관된 항목" + }, + header: { + title: "WorMap Geospatial", + explorer: "탐색 모드", + projects: "프로젝트", + assets: "에셋" + }, + banner: { + release: "최신 릴리스", + title: "WorMap v4.0 경험하기", + description: "다중 레이어 지형 등고선 처리 및 실시간 3D 지형 렌더링 도입. 대규모 기업용 공간 데이터 관리에 최적화되었습니다.", + button: "레이어 탐색" + }, + filters: { + projects: "프로젝트", + archived: "보관됨", + filterBy: "필터 기준", + ownerAll: "소유자: 전체", + sort: "정렬", + lastModified: "최근 수정일" + }, + inventory: { + title: "프로젝트 인벤토리", + activeProjects: "{count}개의 활성 프로젝트", + colName: "이름", + colOwner: "소유자", + colStorage: "사용 용량", + colLastModified: "마지막 수정", + colActions: "동작" + }, + footer: { + newProject: "새 프로젝트", + systemOnline: "시스템 온라인", + used: "사용됨", + modifyKml: "로컬 KML 수정", + lat: "위도", + lon: "경도" + }, + popovers: { + notifications: "새로운 알림이 없습니다.", + settings: "설정", + profile: "프로필 메뉴", + edit: "수정", + delete: "삭제", + share: "공유", + changeLang: "Change to English" + } +}; diff --git a/src/stores/useI18nStore.ts b/src/stores/useI18nStore.ts new file mode 100644 index 0000000..2841c7f --- /dev/null +++ b/src/stores/useI18nStore.ts @@ -0,0 +1,43 @@ +import { create } from 'zustand'; +import { en } from '../i18n/en'; +import { ko } from '../i18n/ko'; + +type Language = 'en' | 'ko'; + +interface I18nState { + lang: Language; + setLang: (lang: Language) => void; +} + +export const useI18nStore = create((set) => ({ + lang: 'ko', // default to Korean + setLang: (lang) => set({ lang }), +})); + +export function useTranslation() { + const lang = useI18nStore((state) => state.lang); + const dict = lang === 'en' ? en : ko; + + const t = (path: string, values?: Record) => { + const keys = path.split('.'); + let current: any = dict; + + for (const key of keys) { + if (current[key] === undefined) { + return path; // fallback + } + current = current[key]; + } + + let result = current as string; + if (values) { + Object.entries(values).forEach(([k, v]) => { + result = result.replace(`{${k}}`, String(v)); + }); + } + + return result; + }; + + return { t, lang, setLang: useI18nStore.getState().setLang }; +} From 99c9e90752d1a5c815e63d6a0b33a5aeda87e02e Mon Sep 17 00:00:00 2001 From: ummsehun Date: Mon, 6 Apr 2026 19:40:54 +0900 Subject: [PATCH 24/28] feat:exploerer UI fixd --- src/app/explorer/page.tsx | 81 +++++++++++++---------- src/i18n/en.ts | 19 ++++++ src/i18n/ko.ts | 19 ++++++ src/scene/loading/LoadingScene.tsx | 100 +++++++++++++++++++++++++++++ src/scene/place/PlaceScene.tsx | 2 + 5 files changed, 188 insertions(+), 33 deletions(-) create mode 100644 src/scene/loading/LoadingScene.tsx diff --git a/src/app/explorer/page.tsx b/src/app/explorer/page.tsx index 68b4a31..76d5729 100644 --- a/src/app/explorer/page.tsx +++ b/src/app/explorer/page.tsx @@ -1,68 +1,83 @@ +"use client"; + import GlobeScene from "@/src/globe/GlobeScene"; import { MVP_PLACES } from "@/src/data/places"; import type { Place } from "@/src/types/place"; -import { Globe, MapPin, Search } from "lucide-react"; +import { Globe, MapPin, Search, ArrowLeft, Map } from "lucide-react"; import Link from "next/link"; +import { useTranslation } from "@/src/stores/useI18nStore"; export default function ExplorerHome() { + const { t } = useTranslation(); + return ( -
+
- {/* Top Header */} + {/* Top Header - Redesigned to match the dark theme */}
-
-
-
+ + {/* Back Button */} + + + Dashboard + + +
+
+
-

WorMap Engine

-

GLOBAL DISCOVERY

+

{t('explorer.engine')}

+

{t('explorer.title')}

-
- - Search coordinates or places... +
+ + {t('explorer.search')}
-
-
- System Status - Online -
-
-
+
+
+ {t('explorer.systemStatus')} +
+ + {t('explorer.online')} +
{/* Bottom Place Selector */} -
-
+
+
{MVP_PLACES.map((place: Place) => ( -
-
- Travel to {place.name} +
+
+ + {t('explorer.travelTo', { name: place.name })}
+ {/* Tooltip triangle */} +
-
+
-
-
- +
+
+
-

{place.city}

-

{place.name}

+

{place.city}

+

{place.name}

@@ -72,10 +87,10 @@ export default function ExplorerHome() {
{/* Info Badge */} -
-
-

- Coordinates: 37.5665° N, 126.9780° E +

+ +

+ {t('explorer.coordinates')}: 37.5665° N, 126.9780° E

diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 3846c9b..84c36af 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -51,6 +51,25 @@ export const en = { delete: "Delete", share: "Share", changeLang: "한국어로 변경" + }, + loading: { + title: "Synchronizing Spatial Data...", + subtitle: "Loading geography and metadata.", + tooltips: [ + "WorMap pedestrians are affected by weather and time.", + "Vehicle traffic volume is regulated by simulation settings.", + "City neon signs activate automatically at night.", + "Use WASD to move around in Walk View mode." + ] + }, + explorer: { + engine: "WorMap Engine", + title: "GLOBAL DISCOVERY", + search: "Search coordinates or places...", + systemStatus: "System Status", + online: "Online", + travelTo: "Travel to {name}", + coordinates: "Coordinates" } }; diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 3fedd33..4ac67f4 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -53,5 +53,24 @@ export const ko: Dictionary = { delete: "삭제", share: "공유", changeLang: "Change to English" + }, + loading: { + title: "공간 데이터 동기화 중...", + subtitle: "지형 및 메타데이터를 로드하고 있습니다.", + tooltips: [ + "WorMap의 보행자는 날씨와 시간에 영향을 받습니다.", + "차량의 트래픽 양은 시뮬레이션 설정에 따라 조절됩니다.", + "밤이 되면 도시의 네온사인 기능이 활성화됩니다.", + "워크뷰(Walk View) 모드에서 WASD 키로 이동할 수 있습니다." + ] + }, + explorer: { + engine: "WorMap 엔진", + title: "글로벌 탐색", + search: "좌표 또는 장소 검색...", + systemStatus: "시스템 상태", + online: "온라인", + travelTo: "{name} (으)로 이동", + coordinates: "좌표" } }; diff --git a/src/scene/loading/LoadingScene.tsx b/src/scene/loading/LoadingScene.tsx new file mode 100644 index 0000000..1759d2e --- /dev/null +++ b/src/scene/loading/LoadingScene.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useProgress } from "@react-three/drei"; +import { useEffect, useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { useTranslation } from "../../stores/useI18nStore"; + +export default function LoadingScene() { + const { progress, active } = useProgress(); + const { t } = useTranslation(); + + // Cast safety because our basic useTranslation custom hook simply passes through array structures if they match + const tooltips = t('loading.tooltips') as unknown as string[]; + const [tipIndex, setTipIndex] = useState(0); + + useEffect(() => { + if (!active || !tooltips || !tooltips.length) return; + const interval = setInterval(() => { + setTipIndex((prev) => (prev + 1) % tooltips.length); + }, 3500); + return () => clearInterval(interval); + }, [active, tooltips]); + + const [isVisible, setIsVisible] = useState(true); + + useEffect(() => { + // If progress completes, delay fade-out to let user see 100% and avoid flashing + if (progress === 100 && !active) { + const timeout = setTimeout(() => setIsVisible(false), 1200); + return () => clearTimeout(timeout); + } + }, [progress, active]); + + return ( + + {isVisible && ( + + {/* Decorative background topography or grid emulation */} +
+ + {/* Subtle gradient glow */} +
+ +
+ {/* Spinning Indicator */} +
+
+ {Math.round(progress)}% +
+ + + +
+ +

{t('loading.title')}

+

{t('loading.subtitle')}

+ + {/* Tracking Progress Bar */} +
+ +
+ + {/* Changing Tooltips */} +
+ + + {tooltips && tooltips[tipIndex]} + + +
+
+ + )} + + ); +} diff --git a/src/scene/place/PlaceScene.tsx b/src/scene/place/PlaceScene.tsx index 1220cdd..dd6b2ad 100644 --- a/src/scene/place/PlaceScene.tsx +++ b/src/scene/place/PlaceScene.tsx @@ -10,6 +10,7 @@ import SceneInfoHUD from "../../components/hud/SceneInfoHUD"; import PerformanceHUD from "../../components/hud/PerformanceHUD"; import PerformanceSystem from "./PerformanceSystem"; import { usePlaybackStore } from "../../stores/playbackStore"; +import LoadingScene from "../loading/LoadingScene"; type PlaceSceneProps = { slug: string; @@ -45,6 +46,7 @@ export default function PlaceScene({ slug }: PlaceSceneProps) { +
); } From 41e390cd3f8695201ad9dd0916151cd724419aa7 Mon Sep 17 00:00:00 2001 From: ummsehun Date: Mon, 6 Apr 2026 19:55:48 +0900 Subject: [PATCH 25/28] feat:Cinematic scene delete --- src/globe/GlobeScene.tsx | 106 ++++++++++++++++++++------------------- 1 file changed, 55 insertions(+), 51 deletions(-) diff --git a/src/globe/GlobeScene.tsx b/src/globe/GlobeScene.tsx index 9d336bf..398d9d6 100644 --- a/src/globe/GlobeScene.tsx +++ b/src/globe/GlobeScene.tsx @@ -32,11 +32,6 @@ export default function GlobeScene({ places }: GlobeSceneProps) { const entityMapRef = useRef>(new Map()); const nameMapRef = useRef>(new Map()); - const markerSummary = useMemo( - () => places.map((place) => place.name).join(" · "), - [places], - ); - useEffect(() => { let isCancelled = false; const slugMap = entityMapRef.current; @@ -71,8 +66,16 @@ export default function GlobeScene({ places }: GlobeSceneProps) { shouldAnimate: true, skyAtmosphere: new Cesium.SkyAtmosphere(), requestRenderMode: true, + useBrowserRecommendedResolution: false, // 브라우저 자동 해상도 다운스케일링 금지 (선명해짐) + terrainShadows: Cesium.ShadowMode.ENABLED, // 지형 간 그림자 생성 + selectionIndicator: false, + infoBox: false }); + // 픽셀 비율(DPI)에 맞춰 해상도 강제 1:1 매칭 (매우 선명해짐) + viewer.resolutionScale = typeof window !== 'undefined' ? Math.min(window.devicePixelRatio, 2) : 1; + viewer.scene.shadowMap.maximumDistance = 5000.0; + const screenSpaceController = viewer.scene.screenSpaceCameraController; screenSpaceController.enableInputs = true; screenSpaceController.enableZoom = true; @@ -84,25 +87,47 @@ export default function GlobeScene({ places }: GlobeSceneProps) { screenSpaceController.zoomFactor = 3; screenSpaceController.inertiaZoom = 0.85; screenSpaceController.minimumZoomDistance = 1; - screenSpaceController.maximumZoomDistance = 1e7; + screenSpaceController.maximumZoomDistance = 2.5e7; - // Google Earth 스타일 대기 및 안개 설정 + // Google Earth 수준의 렌더링 튜닝 viewer.scene.globe.enableLighting = true; viewer.scene.globe.showGroundAtmosphere = true; viewer.scene.fog.enabled = true; viewer.scene.fog.density = 0.0001; viewer.scene.fog.screenSpaceErrorFactor = 2.0; - if (viewer.scene.moon) { - viewer.scene.moon.show = true; - } - - if (viewer.scene.sun) { - viewer.scene.sun.show = true; - } + // 고해상도 최적화: HDR 오픈, FXAA 안티앨리어싱 + viewer.scene.highDynamicRange = true; + viewer.scene.postProcessStages.fxaa.enabled = true; + viewer.scene.globe.maximumScreenSpaceError = 1.0; // 1.0 이하로 줄여 텍스처를 최고 해상도로 타일링 (기본값 2) + + if (viewer.scene.moon) viewer.scene.moon.show = true; + if (viewer.scene.sun) viewer.scene.sun.show = true; viewerRef.current = viewer; + // 3D 지형 (Terrain) 및 전 세계 3D 빌딩 (OSM) 로드 + try { + const terrainProvider = await Cesium.createWorldTerrainAsync({ + requestVertexNormals: true, // 지형의 곡류와 입체적인 빛 반사/그림자를 살림 + requestWaterMask: true + }); + if (!isCancelled && viewerRef.current) { + viewer.terrainProvider = terrainProvider; + // 그림자 디테일 증가 + viewer.scene.globe.shadows = Cesium.ShadowMode.RECEIVE_ONLY; + } + + const osmBuildings = await Cesium.createOsmBuildingsAsync(); + if (!isCancelled && viewerRef.current) { + // 건물 디테일 최적화 + osmBuildings.maximumScreenSpaceError = 1; + viewer.scene.primitives.add(osmBuildings); + } + } catch (e) { + logger.warn("Could not load 3D terrain or buildings (Skipping)", e); + } + slugMap.clear(); placeNameMap.clear(); @@ -112,34 +137,31 @@ export default function GlobeScene({ places }: GlobeSceneProps) { placeId: place.id, slug: place.slug, geometryId: bootstrap.geometryId, - assetUrl: bootstrap.assetUrl, }); const entity = viewer.entities.add({ id: place.id, name: place.name, - position: Cesium.Cartesian3.fromDegrees(place.lng, place.lat, 0), + position: Cesium.Cartesian3.fromDegrees(place.lng, place.lat, 150), point: { - pixelSize: 8, - color: Cesium.Color.CYAN.withAlpha(0.8), - outlineColor: Cesium.Color.WHITE.withAlpha(0.5), + pixelSize: 10, + color: Cesium.Color.fromCssColorString('#3b82f6'), // Tailwind blue-500 + outlineColor: Cesium.Color.WHITE, outlineWidth: 2, disableDepthTestDistance: Number.POSITIVE_INFINITY, - scaleByDistance: new Cesium.NearFarScalar(1.5e2, 1.5, 8.0e6, 0.5), + scaleByDistance: new Cesium.NearFarScalar(1.5e2, 1.5, 8.0e6, 0.4), }, label: { - text: place.name, - font: "14px 'Inter', system-ui, sans-serif", - style: Cesium.LabelStyle.FILL_AND_OUTLINE, + text: place.name.toUpperCase(), + font: "700 12px 'Inter', system-ui, sans-serif", + style: Cesium.LabelStyle.FILL, fillColor: Cesium.Color.WHITE, - outlineColor: Cesium.Color.BLACK.withAlpha(0.5), - outlineWidth: 2, - pixelOffset: new Cesium.Cartesian2(0, -28), + pixelOffset: new Cesium.Cartesian2(0, -22), disableDepthTestDistance: Number.POSITIVE_INFINITY, showBackground: true, - backgroundColor: Cesium.Color.BLACK.withAlpha(0.6), - backgroundPadding: new Cesium.Cartesian2(8, 4), - scaleByDistance: new Cesium.NearFarScalar(1.5e2, 1.0, 1.5e7, 0.5), + backgroundColor: Cesium.Color.fromCssColorString('#16181A').withAlpha(0.85), + backgroundPadding: new Cesium.Cartesian2(12, 6), + scaleByDistance: new Cesium.NearFarScalar(1.5e2, 1.0, 1.5e7, 0.3), translucencyByDistance: new Cesium.NearFarScalar(1.5e2, 1.0, 1.5e7, 0.2), }, }); @@ -149,9 +171,9 @@ export default function GlobeScene({ places }: GlobeSceneProps) { placeNameMap.set(entityId, place.name); } - // 우주에서 빨려 들어가는 듯한 카메라 연출 (Google Earth Fly-in) + // 우주에서 바라보는 초기 고정 카메라 위치 (시네마틱 X) viewer.camera.setView({ - destination: Cesium.Cartesian3.fromDegrees(127.0276, 37.4979, 15000000), // 우주 고도 + destination: Cesium.Cartesian3.fromDegrees(127.0276, 37.4979, 15000000), orientation: { heading: 0, pitch: Cesium.Math.toRadians(-90), @@ -159,15 +181,6 @@ export default function GlobeScene({ places }: GlobeSceneProps) { }, }); - // 1초 뒤 강남역 부근으로 빠르게 이동 후 서서히 안정화 - setTimeout(() => { - viewer.camera.flyTo({ - destination: Cesium.Cartesian3.fromDegrees(127.0276, 37.4979, 12000000), - duration: 3, - easingFunction: Cesium.EasingFunction.QUADRATIC_IN_OUT, - }); - }, 500); - const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); clickHandlerRef.current = handler; handler.setInputAction((movement: { position: import("cesium").Cartesian2 }) => { @@ -185,10 +198,7 @@ export default function GlobeScene({ places }: GlobeSceneProps) { setSelectedPlaceId(pickedId); setMode("loading"); - logger.info("Place selected from globe", { - placeId: pickedId, - slug, - }); + logger.info("Place selected from globe", { placeId: pickedId, slug }); router.push(`/place/${slug}`); }, Cesium.ScreenSpaceEventType.LEFT_CLICK); } @@ -218,14 +228,8 @@ export default function GlobeScene({ places }: GlobeSceneProps) { }, [places, router, setMode, setSelectedPlaceId]); return ( -
+
-
-

Markers: {markerSummary}

-

- 마커를 클릭해 Place Scene으로 이동 -

-
); } From 6795489f1652122d5e2b01a2d321f11293ba5d27 Mon Sep 17 00:00:00 2001 From: ummsehun Date: Mon, 6 Apr 2026 20:04:39 +0900 Subject: [PATCH 26/28] feat:Layer-4 fixd --- src/components/hud/SceneInfoHUD.tsx | 75 +++++----- src/components/hud/playbackSections.tsx | 88 ++++++------ src/globe/GlobeScene.tsx | 182 +++++++++++------------- src/i18n/en.ts | 23 +++ src/i18n/ko.ts | 23 +++ src/scene/loading/LoadingScene.tsx | 7 +- src/shared/config/app.ts | 38 ++++- src/shared/domains/time.ts | 7 + src/stores/useI18nStore.ts | 58 ++++---- 9 files changed, 287 insertions(+), 214 deletions(-) diff --git a/src/components/hud/SceneInfoHUD.tsx b/src/components/hud/SceneInfoHUD.tsx index 0b469d3..0af387b 100644 --- a/src/components/hud/SceneInfoHUD.tsx +++ b/src/components/hud/SceneInfoHUD.tsx @@ -1,35 +1,15 @@ import { useMemo } from "react"; -import { MapPin, Wind, Thermometer, Info } from "lucide-react"; -import { usePlaceStore } from "../../stores/placeStore"; -import { usePlaybackStore } from "../../stores/playbackStore"; -import { Label } from "../ui/Label"; -import { Panel } from "../ui/Panel"; -import { StatusBadge } from "../ui/StatusBadge"; - -function formatTime(hour: number) { - const normalized = ((hour % 24) + 24) % 24; - const h = Math.floor(normalized); - const m = Math.floor((normalized - h) * 60); - return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; -} - -function toWeatherLabel(weather: "clear" | "cloudy" | "rain" | "snow"): string { - if (weather === "clear") { - return "Clear"; - } - - if (weather === "cloudy") { - return "Cloudy"; - } - - if (weather === "rain") { - return "Rain"; - } - - return "Snow"; -} +import { MapPin, Wind, Clock, Info } from "lucide-react"; +import { usePlaceStore } from "@/src/stores/placeStore"; +import { usePlaybackStore } from "@/src/stores/playbackStore"; +import { useTranslation } from "@/src/stores/useI18nStore"; +import { formatTime } from "@/src/shared/domains"; +import { Label } from "@/src/components/ui/Label"; +import { Panel } from "@/src/components/ui/Panel"; +import { StatusBadge } from "@/src/components/ui/StatusBadge"; export default function SceneInfoHUD() { + const { t } = useTranslation(); const currentPlace = usePlaceStore((s) => s.currentPlace); const viewMode = usePlaceStore((s) => s.viewMode); const weather = usePlaybackStore((s) => s.weather); @@ -37,12 +17,14 @@ export default function SceneInfoHUD() { const speed = usePlaybackStore((s) => s.speed); const isPlaying = usePlaybackStore((s) => s.isPlaying); - const placeLabel = useMemo(() => { - if (!currentPlace) return "Unknown Place"; - return currentPlace.name; - }, [currentPlace]); + const placeLabel = useMemo( + () => currentPlace?.name ?? t('scene.info.unknown'), + [currentPlace, t], + ); - const statusLabel = isPlaying ? `Running · ${speed}x` : "Paused"; + const statusLabel = isPlaying + ? `${t('scene.status.running')} · ${speed}x` + : t('scene.status.paused'); const statusTone = isPlaying ? "active" : "paused"; return ( @@ -52,16 +34,20 @@ export default function SceneInfoHUD() {
-

WorMap Live Scene

+

+ {t('scene.info.tag')} +

- +

{placeLabel}

-

{currentPlace?.city || "Discovery Mode"}

+

+ {currentPlace?.city ?? t('scene.info.discoveryMode')} +

{statusLabel} @@ -73,19 +59,19 @@ export default function SceneInfoHUD() {
- {toWeatherLabel(weather)} + {t(`scene.weather.${weather}`)}
- + {formatTime(currentTime)}
@@ -96,7 +82,10 @@ export default function SceneInfoHUD() {

- Camera: {viewMode === "walk" ? "Street" : "Overview"} + {t('scene.info.camera')}:{" "} + + {viewMode === "walk" ? t('scene.info.street') : t('scene.info.overview')} +

diff --git a/src/components/hud/playbackSections.tsx b/src/components/hud/playbackSections.tsx index f84477d..5e904b1 100644 --- a/src/components/hud/playbackSections.tsx +++ b/src/components/hud/playbackSections.tsx @@ -1,35 +1,23 @@ import { - Play, - Pause, - Cloud, - CloudRain, - Snowflake, - Sun, - Clock, - Navigation, - Move, - ChevronLeft, - ChevronRight, + Play, Pause, Cloud, CloudRain, Snowflake, Sun, + Clock, Navigation, Move, ChevronLeft, ChevronRight, + type LucideIcon, } from "lucide-react"; -import { ControlButton } from "../ui/ControlButton"; -import type { InputPreset } from "../../stores/placeStore"; - -const SPEED_OPTIONS = [1, 2, 4]; -const WEATHER_OPTIONS = [ - { value: "clear", icon: Sun, label: "Clear" }, - { value: "cloudy", icon: Cloud, label: "Cloudy" }, - { value: "rain", icon: CloudRain, label: "Rain" }, - { value: "snow", icon: Snowflake, label: "Snow" }, -] as const; +import { ControlButton } from "@/src/components/ui/ControlButton"; +import type { InputPreset } from "@/src/stores/placeStore"; +import { useTranslation } from "@/src/stores/useI18nStore"; +import { formatTime } from "@/src/shared/domains"; +import type { WeatherMode } from "@/src/stores/playbackStore"; + +const SPEED_OPTIONS = [1, 2, 4] as const; +const WEATHER_OPTIONS: { value: WeatherMode; icon: LucideIcon }[] = [ + { value: "clear", icon: Sun }, + { value: "cloudy", icon: Cloud }, + { value: "rain", icon: CloudRain }, + { value: "snow", icon: Snowflake }, +]; const INPUT_PRESET_OPTIONS: InputPreset[] = ["precision", "balanced", "fast"]; -function formatTime(hour: number) { - const normalized = ((hour % 24) + 24) % 24; - const h = Math.floor(normalized); - const m = Math.floor((normalized - h) * 60); - return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; -} - type PlaybackControlSectionProps = { isPlaying: boolean; speed: number; @@ -39,15 +27,23 @@ type PlaybackControlSectionProps = { export function PlaybackControlSection(props: PlaybackControlSectionProps) { const { isPlaying, speed, onTogglePlayback, onSetSpeed } = props; + const { t } = useTranslation(); return (
@@ -67,22 +63,23 @@ export function PlaybackControlSection(props: PlaybackControlSectionProps) { } type WeatherControlSectionProps = { - weather: "clear" | "cloudy" | "rain" | "snow"; - onSetWeather: (weather: "clear" | "cloudy" | "rain" | "snow") => void; + weather: WeatherMode; + onSetWeather: (weather: WeatherMode) => void; }; export function WeatherControlSection(props: WeatherControlSectionProps) { const { weather, onSetWeather } = props; + const { t } = useTranslation(); return (
- {WEATHER_OPTIONS.map(({ value, icon: Icon, label }) => ( + {WEATHER_OPTIONS.map(({ value, icon: Icon }) => ( onSetWeather(value)} size="icon-md" tone={weather === value ? "active" : "default"} - title={label} + title={t(`scene.weather.${value}`)} > @@ -109,10 +106,10 @@ export function TimeControlSection(props: TimeControlSectionProps) {
- onSetCurrentTime(currentTime - 1)} size="icon-sm" title="-1시간"> + onSetCurrentTime(currentTime - 1)} size="icon-sm" title="-1h"> - onSetCurrentTime(currentTime + 1)} size="icon-sm" title="+1시간"> + onSetCurrentTime(currentTime + 1)} size="icon-sm" title="+1h">
@@ -127,14 +124,19 @@ type ViewModeToggleSectionProps = { export function ViewModeToggleSection(props: ViewModeToggleSectionProps) { const { viewMode, onToggleViewMode } = props; + const { t } = useTranslation(); return ( ); } @@ -167,7 +169,13 @@ export function InputPresetSection(props: InputPresetSectionProps) { export function WalkModeHint() { return (

- WASD move · Two-finger ↑↓ zoom · Two-finger ↔ look · Spread / Pinch zoom · Drag look · V toggle view · ESC back + WASD move ·{" "} + Two-finger ↑↓ zoom ·{" "} + Two-finger ↔ look ·{" "} + Spread / Pinch zoom ·{" "} + Drag look ·{" "} + V toggle view ·{" "} + ESC back

); } diff --git a/src/globe/GlobeScene.tsx b/src/globe/GlobeScene.tsx index 398d9d6..a1ce85f 100644 --- a/src/globe/GlobeScene.tsx +++ b/src/globe/GlobeScene.tsx @@ -1,12 +1,13 @@ "use client"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import "cesium/Build/Cesium/Widgets/widgets.css"; -import { useAppStore } from "../stores/appStore"; -import type { Place } from "../types/place"; -import { createLogger, toErrorContext } from "../shared/logger"; -import { createStaticSceneBootstrap } from "../shared/scene"; +import { useAppStore } from "@/src/stores/appStore"; +import type { Place } from "@/src/types/place"; +import { createLogger, toErrorContext } from "@/src/shared/logger"; +import { createStaticSceneBootstrap } from "@/src/shared/scene"; +import { APP_CONFIG } from "@/src/shared/config"; type GlobeSceneProps = { places: Place[]; @@ -20,15 +21,15 @@ declare global { } } +const { globe: GLOBE, marker: MARKER } = APP_CONFIG.cesium; + export default function GlobeScene({ places }: GlobeSceneProps) { const router = useRouter(); const setSelectedPlaceId = useAppStore((s) => s.setSelectedPlaceId); const setMode = useAppStore((s) => s.setMode); const containerRef = useRef(null); const viewerRef = useRef(null); - const clickHandlerRef = useRef( - null, - ); + const clickHandlerRef = useRef(null); const entityMapRef = useRef>(new Map()); const nameMapRef = useRef>(new Map()); @@ -38,17 +39,11 @@ export default function GlobeScene({ places }: GlobeSceneProps) { const placeNameMap = nameMapRef.current; async function initCesium() { - if (!containerRef.current || viewerRef.current) { - return; - } + if (!containerRef.current || viewerRef.current) return; window.CESIUM_BASE_URL = "/cesium"; - const Cesium = await import("cesium"); - - if (isCancelled || !containerRef.current) { - return; - } + if (isCancelled || !containerRef.current) return; if (process.env.NEXT_PUBLIC_CESIUM_TOKEN) { Cesium.Ion.defaultAccessToken = process.env.NEXT_PUBLIC_CESIUM_TOKEN; @@ -63,71 +58,66 @@ export default function GlobeScene({ places }: GlobeSceneProps) { homeButton: false, navigationHelpButton: false, fullscreenButton: false, + selectionIndicator: false, + infoBox: false, shouldAnimate: true, skyAtmosphere: new Cesium.SkyAtmosphere(), requestRenderMode: true, - useBrowserRecommendedResolution: false, // 브라우저 자동 해상도 다운스케일링 금지 (선명해짐) - terrainShadows: Cesium.ShadowMode.ENABLED, // 지형 간 그림자 생성 - selectionIndicator: false, - infoBox: false + useBrowserRecommendedResolution: false, + terrainShadows: Cesium.ShadowMode.ENABLED, }); - // 픽셀 비율(DPI)에 맞춰 해상도 강제 1:1 매칭 (매우 선명해짐) - viewer.resolutionScale = typeof window !== 'undefined' ? Math.min(window.devicePixelRatio, 2) : 1; - viewer.scene.shadowMap.maximumDistance = 5000.0; - - const screenSpaceController = viewer.scene.screenSpaceCameraController; - screenSpaceController.enableInputs = true; - screenSpaceController.enableZoom = true; - screenSpaceController.zoomEventTypes = [ + // 픽셀 밀도에 맞춰 해상도 강제 1:1 매칭 (retina 지원) + viewer.resolutionScale = typeof window !== "undefined" + ? Math.min(window.devicePixelRatio, 2) + : 1; + viewer.scene.shadowMap.maximumDistance = GLOBE.rendering.shadowMapMaxDistance; + + // 카메라 입력 설정 + const ssc = viewer.scene.screenSpaceCameraController; + ssc.enableInputs = true; + ssc.enableZoom = true; + ssc.zoomEventTypes = [ Cesium.CameraEventType.RIGHT_DRAG, Cesium.CameraEventType.WHEEL, Cesium.CameraEventType.PINCH, ]; - screenSpaceController.zoomFactor = 3; - screenSpaceController.inertiaZoom = 0.85; - screenSpaceController.minimumZoomDistance = 1; - screenSpaceController.maximumZoomDistance = 2.5e7; + ssc.zoomFactor = GLOBE.zoom.factor; + ssc.inertiaZoom = GLOBE.zoom.inertia; + ssc.minimumZoomDistance = GLOBE.zoom.min; + ssc.maximumZoomDistance = GLOBE.zoom.max; - // Google Earth 수준의 렌더링 튜닝 + // 렌더링 품질 설정 viewer.scene.globe.enableLighting = true; viewer.scene.globe.showGroundAtmosphere = true; + viewer.scene.globe.maximumScreenSpaceError = GLOBE.rendering.maximumScreenSpaceError; + viewer.scene.globe.shadows = Cesium.ShadowMode.RECEIVE_ONLY; viewer.scene.fog.enabled = true; - viewer.scene.fog.density = 0.0001; - viewer.scene.fog.screenSpaceErrorFactor = 2.0; - - // 고해상도 최적화: HDR 오픈, FXAA 안티앨리어싱 + viewer.scene.fog.density = GLOBE.fog.density; + viewer.scene.fog.screenSpaceErrorFactor = GLOBE.fog.screenSpaceErrorFactor; viewer.scene.highDynamicRange = true; viewer.scene.postProcessStages.fxaa.enabled = true; - viewer.scene.globe.maximumScreenSpaceError = 1.0; // 1.0 이하로 줄여 텍스처를 최고 해상도로 타일링 (기본값 2) - if (viewer.scene.moon) viewer.scene.moon.show = true; if (viewer.scene.sun) viewer.scene.sun.show = true; - + viewerRef.current = viewer; - // 3D 지형 (Terrain) 및 전 세계 3D 빌딩 (OSM) 로드 + // 3D 지형 및 전 세계 빌딩 (비동기 — 실패해도 graceful degradation) try { - const terrainProvider = await Cesium.createWorldTerrainAsync({ - requestVertexNormals: true, // 지형의 곡류와 입체적인 빛 반사/그림자를 살림 - requestWaterMask: true - }); + const [terrainProvider, osmBuildings] = await Promise.all([ + Cesium.createWorldTerrainAsync({ requestVertexNormals: true, requestWaterMask: true }), + Cesium.createOsmBuildingsAsync(), + ]); if (!isCancelled && viewerRef.current) { viewer.terrainProvider = terrainProvider; - // 그림자 디테일 증가 - viewer.scene.globe.shadows = Cesium.ShadowMode.RECEIVE_ONLY; - } - - const osmBuildings = await Cesium.createOsmBuildingsAsync(); - if (!isCancelled && viewerRef.current) { - // 건물 디테일 최적화 - osmBuildings.maximumScreenSpaceError = 1; + osmBuildings.maximumScreenSpaceError = 1; viewer.scene.primitives.add(osmBuildings); } } catch (e) { - logger.warn("Could not load 3D terrain or buildings (Skipping)", e); + logger.warn("Could not load world terrain or OSM buildings — degrading gracefully", e); } + // 마커 추가 slugMap.clear(); placeNameMap.clear(); @@ -142,27 +132,27 @@ export default function GlobeScene({ places }: GlobeSceneProps) { const entity = viewer.entities.add({ id: place.id, name: place.name, - position: Cesium.Cartesian3.fromDegrees(place.lng, place.lat, 150), + position: Cesium.Cartesian3.fromDegrees(place.lng, place.lat, MARKER.markerAltitude), point: { - pixelSize: 10, - color: Cesium.Color.fromCssColorString('#3b82f6'), // Tailwind blue-500 + pixelSize: MARKER.pixelSize, + color: Cesium.Color.fromCssColorString(MARKER.pointColor), outlineColor: Cesium.Color.WHITE, - outlineWidth: 2, + outlineWidth: MARKER.outlineWidth, disableDepthTestDistance: Number.POSITIVE_INFINITY, - scaleByDistance: new Cesium.NearFarScalar(1.5e2, 1.5, 8.0e6, 0.4), + scaleByDistance: new Cesium.NearFarScalar(...GLOBE.scaleByDistance.point), }, label: { text: place.name.toUpperCase(), - font: "700 12px 'Inter', system-ui, sans-serif", + font: MARKER.labelFont, style: Cesium.LabelStyle.FILL, fillColor: Cesium.Color.WHITE, - pixelOffset: new Cesium.Cartesian2(0, -22), + pixelOffset: new Cesium.Cartesian2(...MARKER.labelOffset), disableDepthTestDistance: Number.POSITIVE_INFINITY, showBackground: true, - backgroundColor: Cesium.Color.fromCssColorString('#16181A').withAlpha(0.85), - backgroundPadding: new Cesium.Cartesian2(12, 6), - scaleByDistance: new Cesium.NearFarScalar(1.5e2, 1.0, 1.5e7, 0.3), - translucencyByDistance: new Cesium.NearFarScalar(1.5e2, 1.0, 1.5e7, 0.2), + backgroundColor: Cesium.Color.fromCssColorString(MARKER.labelBackgroundColor).withAlpha(MARKER.labelBackgroundAlpha), + backgroundPadding: new Cesium.Cartesian2(...MARKER.labelBackgroundPadding), + scaleByDistance: new Cesium.NearFarScalar(...GLOBE.scaleByDistance.labelNear), + translucencyByDistance: new Cesium.NearFarScalar(...GLOBE.scaleByDistance.labelFar), }, }); @@ -171,9 +161,13 @@ export default function GlobeScene({ places }: GlobeSceneProps) { placeNameMap.set(entityId, place.name); } - // 우주에서 바라보는 초기 고정 카메라 위치 (시네마틱 X) + // 초기 카메라 위치 viewer.camera.setView({ - destination: Cesium.Cartesian3.fromDegrees(127.0276, 37.4979, 15000000), + destination: Cesium.Cartesian3.fromDegrees( + GLOBE.initialView.lng, + GLOBE.initialView.lat, + GLOBE.initialView.altitude, + ), orientation: { heading: 0, pitch: Cesium.Math.toRadians(-90), @@ -181,47 +175,37 @@ export default function GlobeScene({ places }: GlobeSceneProps) { }, }); + // 클릭 핸들러 const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); clickHandlerRef.current = handler; - handler.setInputAction((movement: { position: import("cesium").Cartesian2 }) => { - const picked = viewer.scene.pick(movement.position); - if (!picked || !picked.id) { - return; - } - - const pickedId = String(picked.id.id); - const slug = slugMap.get(pickedId); - - if (!slug) { - return; - } - - setSelectedPlaceId(pickedId); - setMode("loading"); - logger.info("Place selected from globe", { placeId: pickedId, slug }); - router.push(`/place/${slug}`); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + handler.setInputAction( + (movement: { position: import("cesium").Cartesian2 }) => { + const picked = viewer.scene.pick(movement.position); + if (!picked?.id) return; + + const pickedId = String(picked.id.id); + const slug = slugMap.get(pickedId); + if (!slug) return; + + setSelectedPlaceId(pickedId); + setMode("loading"); + logger.info("Place selected from globe", { placeId: pickedId, slug }); + router.push(`/place/${slug}`); + }, + Cesium.ScreenSpaceEventType.LEFT_CLICK, + ); } void initCesium().catch((error) => { - logger.error("Failed to initialize Cesium scene", { - ...toErrorContext(error), - }); + logger.error("Failed to initialize Cesium scene", { ...toErrorContext(error) }); }); return () => { isCancelled = true; - - if (clickHandlerRef.current) { - clickHandlerRef.current.destroy(); - clickHandlerRef.current = null; - } - - if (viewerRef.current) { - viewerRef.current.destroy(); - viewerRef.current = null; - } - + clickHandlerRef.current?.destroy(); + clickHandlerRef.current = null; + viewerRef.current?.destroy(); + viewerRef.current = null; slugMap.clear(); placeNameMap.clear(); }; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 84c36af..77e8de7 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -70,6 +70,29 @@ export const en = { online: "Online", travelTo: "Travel to {name}", coordinates: "Coordinates" + }, + scene: { + info: { + tag: "WorMap Live Scene", + unknown: "Unknown Place", + discoveryMode: "Discovery Mode", + status: "Status", + environment: "Environment", + localTime: "Local Time", + camera: "Camera", + overview: "Overview", + street: "Street" + }, + status: { + running: "Running", + paused: "Paused" + }, + weather: { + clear: "Clear", + cloudy: "Cloudy", + rain: "Rain", + snow: "Snow" + } } }; diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 4ac67f4..7d29ee2 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -72,5 +72,28 @@ export const ko: Dictionary = { online: "온라인", travelTo: "{name} (으)로 이동", coordinates: "좌표" + }, + scene: { + info: { + tag: "WorMap 라이브 씬", + unknown: "알 수 없는 장소", + discoveryMode: "탐색 모드", + status: "상태", + environment: "환경", + localTime: "현지 시간", + camera: "카메라", + overview: "오버뷰", + street: "스트리트" + }, + status: { + running: "실행 중", + paused: "일시 정지" + }, + weather: { + clear: "맑음", + cloudy: "흐림", + rain: "비", + snow: "눈" + } } }; diff --git a/src/scene/loading/LoadingScene.tsx b/src/scene/loading/LoadingScene.tsx index 1759d2e..90871c0 100644 --- a/src/scene/loading/LoadingScene.tsx +++ b/src/scene/loading/LoadingScene.tsx @@ -3,14 +3,13 @@ import { useProgress } from "@react-three/drei"; import { useEffect, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; -import { useTranslation } from "../../stores/useI18nStore"; +import { useTranslation } from "@/src/stores/useI18nStore"; export default function LoadingScene() { const { progress, active } = useProgress(); - const { t } = useTranslation(); + const { t, tArray } = useTranslation(); - // Cast safety because our basic useTranslation custom hook simply passes through array structures if they match - const tooltips = t('loading.tooltips') as unknown as string[]; + const tooltips = tArray('loading.tooltips'); const [tipIndex, setTipIndex] = useState(0); useEffect(() => { diff --git a/src/shared/config/app.ts b/src/shared/config/app.ts index 6271795..cfbc5f6 100644 --- a/src/shared/config/app.ts +++ b/src/shared/config/app.ts @@ -81,11 +81,41 @@ export const APP_CONFIG = { }, cesium: { marker: { - pixelSize: 12, + pixelSize: 10, outlineWidth: 2, - labelFont: "14px sans-serif", - labelOffset: [0, -24] as const, - flyHomeDuration: 0, + labelFont: "700 12px 'Inter', system-ui, sans-serif", + labelOffset: [0, -22] as const, + labelBackgroundColor: "#16181A", + labelBackgroundAlpha: 0.85, + labelBackgroundPadding: [12, 6] as const, + pointColor: "#3b82f6", + markerAltitude: 150, + }, + globe: { + initialView: { + lng: 127.0276, + lat: 37.4979, + altitude: 15_000_000, + }, + zoom: { + factor: 3, + inertia: 0.85, + min: 1, + max: 2.5e7, + }, + fog: { + density: 0.0001, + screenSpaceErrorFactor: 2.0, + }, + rendering: { + maximumScreenSpaceError: 1.0, + shadowMapMaxDistance: 5000.0, + }, + scaleByDistance: { + point: [1.5e2, 1.5, 8.0e6, 0.4] as const, + labelNear: [1.5e2, 1.0, 1.5e7, 0.3] as const, + labelFar: [1.5e2, 1.0, 1.5e7, 0.2] as const, + }, }, }, } as const; diff --git a/src/shared/domains/time.ts b/src/shared/domains/time.ts index f041c1b..1fdb4b3 100644 --- a/src/shared/domains/time.ts +++ b/src/shared/domains/time.ts @@ -29,3 +29,10 @@ export function toTimeOfDay(hour: number): TimeOfDay { export function isNightTime(hour: number): boolean { return toTimeOfDay(hour) === "night"; } + +export function formatTime(hour: number): string { + const normalized = normalizeHour(hour); + const h = Math.floor(normalized); + const m = Math.floor((normalized - h) * 60); + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; +} diff --git a/src/stores/useI18nStore.ts b/src/stores/useI18nStore.ts index 2841c7f..dcaad27 100644 --- a/src/stores/useI18nStore.ts +++ b/src/stores/useI18nStore.ts @@ -1,8 +1,8 @@ import { create } from 'zustand'; -import { en } from '../i18n/en'; -import { ko } from '../i18n/ko'; +import { en } from '@/src/i18n/en'; +import { ko } from '@/src/i18n/ko'; -type Language = 'en' | 'ko'; +export type Language = 'en' | 'ko'; interface I18nState { lang: Language; @@ -10,34 +10,44 @@ interface I18nState { } export const useI18nStore = create((set) => ({ - lang: 'ko', // default to Korean + lang: 'ko', setLang: (lang) => set({ lang }), })); +function resolvePath(dict: object, path: string): unknown { + const keys = path.split('.'); + let current: unknown = dict; + for (const key of keys) { + if (current === null || typeof current !== 'object' || !(key in current)) { + return undefined; + } + current = (current as Record)[key]; + } + return current; +} + export function useTranslation() { const lang = useI18nStore((state) => state.lang); const dict = lang === 'en' ? en : ko; - const t = (path: string, values?: Record) => { - const keys = path.split('.'); - let current: any = dict; - - for (const key of keys) { - if (current[key] === undefined) { - return path; // fallback - } - current = current[key]; - } - - let result = current as string; - if (values) { - Object.entries(values).forEach(([k, v]) => { - result = result.replace(`{${k}}`, String(v)); - }); - } - - return result; + /** 문자열 번역. 미경로일 경우 path 자체를 fallback으로 반환. */ + const t = (path: string, values?: Record): string => { + const resolved = resolvePath(dict, path); + if (typeof resolved !== 'string') return path; + + if (!values) return resolved; + return Object.entries(values).reduce( + (acc, [k, v]) => acc.replace(`{${k}}`, String(v)), + resolved, + ); + }; + + /** 배열 번역. 미경로이거나 배열이 아닐 경우 빈 배열 반환. */ + const tArray = (path: string): string[] => { + const resolved = resolvePath(dict, path); + if (!Array.isArray(resolved)) return []; + return resolved.filter((item): item is string => typeof item === 'string'); }; - return { t, lang, setLang: useI18nStore.getState().setLang }; + return { t, tArray, lang, setLang: useI18nStore.getState().setLang }; } From 7c7c2b2e541765c071113d651127153a3b3075b8 Mon Sep 17 00:00:00 2001 From: ummsehun Date: Tue, 7 Apr 2026 08:04:51 +0900 Subject: [PATCH 27/28] feat:cn utilty add --- bun.lock | 3 + package.json | 1 + src/app/layout.tsx | 8 +- src/app/page.tsx | 340 ++++++++++-------- src/app/providers.tsx | 25 ++ src/components/hud/PerformanceHUD.tsx | 24 +- src/components/hud/playbackSections.tsx | 21 +- src/components/ui/ControlButton.tsx | 39 +- src/components/ui/Label.tsx | 44 +-- src/components/ui/Panel.tsx | 24 +- src/components/ui/StatusBadge.tsx | 39 +- src/data/mock/inventoryItems.ts | 76 ++++ src/globe/GlobeScene.tsx | 2 +- src/i18n/en.ts | 13 + src/i18n/ko.ts | 13 + src/scene/place/PlaceScene.tsx | 3 +- src/scene/place/PlaceSceneContent.tsx | 67 +++- src/scene/place/SceneAssetModel.tsx | 3 +- src/scene/place/StaticEnvironmentFallback.tsx | 5 +- src/scene/place/usePlaceBootstrap.ts | 163 +++------ src/scene/place/useSceneLiveData.ts | 145 ++------ src/shared/config/app.ts | 4 + src/shared/utils/cn.ts | 13 + src/stores/placeStore.ts | 54 +-- src/stores/playbackStore.ts | 16 +- src/stores/selectors/playbackSelectors.ts | 10 + src/stores/slices/cameraSlice.ts | 20 ++ src/stores/slices/placeSlice.ts | 21 ++ src/stores/slices/sceneLoadingSlice.ts | 19 + 29 files changed, 657 insertions(+), 558 deletions(-) create mode 100644 src/app/providers.tsx create mode 100644 src/data/mock/inventoryItems.ts create mode 100644 src/shared/utils/cn.ts create mode 100644 src/stores/selectors/playbackSelectors.ts create mode 100644 src/stores/slices/cameraSlice.ts create mode 100644 src/stores/slices/placeSlice.ts create mode 100644 src/stores/slices/sceneLoadingSlice.ts diff --git a/bun.lock b/bun.lock index 61bca16..e1d5e7a 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "next": "16.2.2", "react": "19.2.4", "react-dom": "19.2.4", + "tailwind-merge": "^3.5.0", "three": "^0.183.2", "zod": "^4.3.6", "zustand": "^5.0.12", @@ -995,6 +996,8 @@ "suspend-react": ["suspend-react@0.1.3", "", { "peerDependencies": { "react": ">=17.0" } }, "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ=="], + "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], diff --git a/package.json b/package.json index 4e93ef1..efaef85 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "next": "16.2.2", "react": "19.2.4", "react-dom": "19.2.4", + "tailwind-merge": "^3.5.0", "three": "^0.183.2", "zod": "^4.3.6", "zustand": "^5.0.12" diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 976eb90..66bc83a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -17,6 +17,8 @@ export const metadata: Metadata = { description: "Generated by create next app", }; +import Providers from "./providers"; + export default function RootLayout({ children, }: Readonly<{ @@ -27,7 +29,11 @@ export default function RootLayout({ lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > - {children} + + + {children} + + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index a89e4a0..5d5d096 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,98 +4,124 @@ import { useState } from 'react'; import Link from 'next/link'; import { Globe, Search, Folder, PlusCircle, HelpCircle, Archive, - Bell, Settings, User, MoreVertical, Layers, ChevronDown, Monitor, Map + Bell, Settings, User, MoreVertical, Layers, ChevronDown, Monitor, Map, } from 'lucide-react'; -import { useTranslation } from '../stores/useI18nStore'; +import { useTranslation } from '@/src/stores/useI18nStore'; +import { cn } from '@/src/shared/utils/cn'; +import { + MOCK_INVENTORY_ITEMS, + PROJECT_TYPE_META, +} from '@/src/data/mock/inventoryItems'; + +// ─── Banner 배경 패턴 아이콘 ──────────────────────────────────────────────────── +// JSX 엘리먼트를 인라인으로 반복하는 대신 LucideIcon 배열로 관리합니다. +const BANNER_PATTERN_ICONS = [Globe, Map, Layers, Globe, Map, Layers, Globe, Map, Layers, Globe] as const; + +// ─── Sidebar 네비게이션 정의 ──────────────────────────────────────────────────── +const SIDEBAR_NAV_ITEMS = [ + { id: 'search', icon: Search, i18nKey: 'sidebar.search', position: 'top' }, + { id: 'projects', icon: Folder, i18nKey: 'sidebar.projects', position: 'top' }, + { id: 'new', icon: PlusCircle, i18nKey: 'sidebar.new', position: 'top' }, +] as const; + +const SIDEBAR_BOTTOM_ITEMS = [ + { id: 'help', icon: HelpCircle, i18nKey: 'sidebar.help' }, + { id: 'archive', icon: Archive, i18nKey: 'sidebar.archive' }, +] as const; export default function Dashboard() { const { t, lang, setLang } = useTranslation(); - const [activeSidebar, setActiveSidebar] = useState('projects'); - const [filterTab, setFilterTab] = useState('projects'); + const [activeSidebar, setActiveSidebar] = useState('projects'); + const [filterTab, setFilterTab] = useState<'projects' | 'archived'>('projects'); const [popover, setPopover] = useState(null); - const togglePopover = (id: string) => { + const togglePopover = (id: string) => setPopover((prev) => (prev === id ? null : id)); - }; - const inventoryItems = [ - { id: '1', name: 'Untitled Map', icon: , owner: 'Me', size: '14.2 MB', date: '2 mins ago', link: '/explorer', type: 'map' }, - { id: '2', name: 'Urban Expansion Analysis', icon: , owner: 'Team Alpha', size: '1.4 GB', date: 'Yesterday, 4:12 PM', link: '/explorer', type: 'layers' }, - { id: '3', name: 'Grid Network Beta', icon: , owner: 'Me', size: '84 KB', date: 'Oct 24, 2023', link: '/explorer', type: 'globe' } - ]; + const sidebarItemClass = (id: string) => + cn( + 'transition-colors p-2 rounded-lg border-l-2', + activeSidebar === id + ? 'text-zinc-300 bg-zinc-800/50 border-blue-500' + : 'text-zinc-500 hover:text-zinc-300 border-transparent', + ); return ( -
setPopover(null)}> - {/* Sidebar */} +
setPopover(null)} + > + {/* ── Sidebar ──────────────────────────────────────────────────────────── */} - {/* Main Content */} + {/* ── Main ─────────────────────────────────────────────────────────────── */}
+ {/* Header */}

{t('header.title')}

- +
- {/* Bell Notification */} + {/* Bell */}
- {popover === 'bell' && ( -
e.stopPropagation()}> +
e.stopPropagation()} + >

{t('popovers.notifications')}

)} @@ -103,13 +129,25 @@ export default function Dashboard() { {/* Settings */}
- {popover === 'settings' && ( -
e.stopPropagation()}> -
)} @@ -117,53 +155,57 @@ export default function Dashboard() { {/* User Profile */}
- {popover === 'user' && ( -
e.stopPropagation()}> +
e.stopPropagation()} + >
{t('popovers.profile')}
- +
)}
- {/* Scrollable Area */} + {/* Scrollable Content */}
- + {/* Banner */}
{t('banner.release')}

{t('banner.title')}

-

- {t('banner.description')} -

+

{t('banner.description')}

{t('banner.button')}
- - {/* Banner Background Pattern */} + + {/* 배경 패턴 — 배열 기반으로 반복 렌더링 */}
- - - - - - - - - - + {BANNER_PATTERN_ICONS.map((Icon, i) => ( + + ))}
@@ -172,37 +214,42 @@ export default function Dashboard() { {/* Controls Bar */}
- - + {(['projects', 'archived'] as const).map((tab) => ( + + ))}
- {/* Project List */} + {/* Inventory Table */}

{t('inventory.title')}

- {t('inventory.activeProjects', { count: inventoryItems.length })} + + {t('inventory.activeProjects', { count: MOCK_INVENTORY_ITEMS.length })} +
- +
@@ -215,51 +262,53 @@ export default function Dashboard() { - {inventoryItems.map((item) => ( - - - - - - - - ))} + {MOCK_INVENTORY_ITEMS.map((item) => { + const meta = PROJECT_TYPE_META[item.type]; + const Icon = meta.icon; + return ( + + + + + + + + ); + })}
- -
- {item.icon} -
- {item.name} - -
{item.owner}{item.size}{item.date} - - - {/* Action Menu Popover */} - {popover === `action-${item.id}` && ( -
e.stopPropagation()}> - - -
- -
- )} -
+ +
+ +
+ + {item.name} + + +
{item.owner}{item.size}{item.date} + + {popover === `action-${item.id}` && ( +
e.stopPropagation()} + > + + +
+ +
+ )} +
-
- {/* Floating Action Button */} + {/* FAB */}
@@ -271,17 +320,26 @@ export default function Dashboard() {
- - {t('footer.systemOnline')}: Node-West-01 + + + {t('footer.systemOnline')}: {t('dashboard.serverNode')} +
- 8.2 GB / 15 GB {t('footer.used')} + + {t('dashboard.storageUsed')} / {t('dashboard.storageTotal')} {t('footer.used')} +
- - {t('footer.lat')}: 34.05° N, {t('footer.lon')}: 118.24° W + + + {t('footer.lat')}: 34.05° N, {t('footer.lon')}: 118.24° W +
diff --git a/src/app/providers.tsx b/src/app/providers.tsx new file mode 100644 index 0000000..c2e62d2 --- /dev/null +++ b/src/app/providers.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState, type ReactNode } from "react"; + +export default function Providers({ children }: { children: ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, // 1 minute + refetchOnWindowFocus: false, + retry: 1, + }, + }, + }) + ); + + return ( + + {children} + + ); +} diff --git a/src/components/hud/PerformanceHUD.tsx b/src/components/hud/PerformanceHUD.tsx index a1d5ed6..e48cc85 100644 --- a/src/components/hud/PerformanceHUD.tsx +++ b/src/components/hud/PerformanceHUD.tsx @@ -1,27 +1,27 @@ -import { APP_CONFIG } from "../../shared/config"; -import { usePerformanceStore } from "../../stores/performanceStore"; -import { Label } from "../ui/Label"; -import { Panel } from "../ui/Panel"; -import { StatusBadge } from "../ui/StatusBadge"; +import { APP_CONFIG } from "@/src/shared/config"; +import { usePerformanceStore } from "@/src/stores/performanceStore"; +import { useTranslation } from "@/src/stores/useI18nStore"; +import { Label } from "@/src/components/ui/Label"; +import { Panel } from "@/src/components/ui/Panel"; +import { StatusBadge } from "@/src/components/ui/StatusBadge"; export default function PerformanceHUD() { + const { t } = useTranslation(); const snapshot = usePerformanceStore((s) => s.snapshot); const isWarning = snapshot.fps < APP_CONFIG.scene.performance.warningFps || snapshot.frameTimeMs > APP_CONFIG.scene.performance.warningFrameTimeMs; - const statusLabel = isWarning ? "Watch" : "Healthy"; - return (
- +
- {statusLabel} + {isWarning ? t('scene.performance.watch') : t('scene.performance.healthy')}
@@ -30,13 +30,13 @@ export default function PerformanceHUD() {

{snapshot.fps}

{snapshot.frameTimeMs.toFixed(1)}ms diff --git a/src/components/hud/playbackSections.tsx b/src/components/hud/playbackSections.tsx index 5e904b1..d1b1e25 100644 --- a/src/components/hud/playbackSections.tsx +++ b/src/components/hud/playbackSections.tsx @@ -3,6 +3,7 @@ import { Clock, Navigation, Move, ChevronLeft, ChevronRight, type LucideIcon, } from "lucide-react"; +import { cn } from "@/src/shared/utils/cn"; import { ControlButton } from "@/src/components/ui/ControlButton"; import type { InputPreset } from "@/src/stores/placeStore"; import { useTranslation } from "@/src/stores/useI18nStore"; @@ -33,11 +34,12 @@ export function PlaybackControlSection(props: PlaybackControlSectionProps) {

); diff --git a/src/components/ui/Label.tsx b/src/components/ui/Label.tsx index 03108dc..6141932 100644 --- a/src/components/ui/Label.tsx +++ b/src/components/ui/Label.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { cn } from "@/src/shared/utils/cn"; type LabelTone = "default" | "muted" | "accent"; type LabelSize = "xs" | "sm"; @@ -10,34 +11,21 @@ type LabelProps = { children: ReactNode; }; -function toToneClass(tone: LabelTone): string { - if (tone === "muted") { - return "text-muted-soft"; - } - - if (tone === "accent") { - return "text-accent-primary"; - } - - return "text-foreground-strong"; -} - -function toSizeClass(size: LabelSize): string { - if (size === "sm") { - return "text-xs"; - } - - return "text-[10px]"; -} - -export function Label(props: LabelProps) { - const tone = props.tone ?? "default"; - const size = props.size ?? "xs"; +const toneClasses: Record = { + default: "text-foreground-strong", + muted: "text-muted-soft", + accent: "text-accent-primary", +}; - const baseClass = "font-bold uppercase"; - const className = [baseClass, toToneClass(tone), toSizeClass(size), props.className] - .filter(Boolean) - .join(" "); +const sizeClasses: Record = { + xs: "text-[10px]", + sm: "text-xs", +}; - return {props.children}; +export function Label({ tone = "default", size = "xs", className, children }: LabelProps) { + return ( + + {children} + + ); } diff --git a/src/components/ui/Panel.tsx b/src/components/ui/Panel.tsx index a0aa80b..9ae9265 100644 --- a/src/components/ui/Panel.tsx +++ b/src/components/ui/Panel.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { cn } from "@/src/shared/utils/cn"; type PanelTone = "default" | "subtle"; @@ -8,18 +9,15 @@ type PanelProps = { children: ReactNode; }; -function toPanelToneClass(tone: PanelTone): string { - if (tone === "subtle") { - return "bg-white/5"; - } - - return "glass-panel"; -} - -export function Panel(props: PanelProps) { - const tone = props.tone ?? "default"; - const toneClass = toPanelToneClass(tone); - const className = props.className ? `${toneClass} ${props.className}` : toneClass; +const toneClasses: Record = { + default: "glass-panel", + subtle: "bg-white/5", +}; - return
{props.children}
; +export function Panel({ tone = "default", className, children }: PanelProps) { + return ( +
+ {children} +
+ ); } diff --git a/src/components/ui/StatusBadge.tsx b/src/components/ui/StatusBadge.tsx index 76351fb..4acd06e 100644 --- a/src/components/ui/StatusBadge.tsx +++ b/src/components/ui/StatusBadge.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { cn } from "@/src/shared/utils/cn"; type StatusBadgeTone = "active" | "paused" | "neutral"; @@ -9,31 +10,25 @@ type StatusBadgeProps = { children: ReactNode; }; -function toDotClass(tone: StatusBadgeTone, pulse: boolean): string { - const base = "status-dot"; +const dotBaseClass = "status-dot"; - if (tone === "active") { - return pulse ? `${base} status-dot-success animate-pulse` : `${base} status-dot-success`; - } - - if (tone === "paused") { - return `${base} bg-zinc-500`; - } - - return "status-dot-active"; -} - -export function StatusBadge(props: StatusBadgeProps) { - const tone = props.tone ?? "neutral"; - const pulse = props.pulse ?? false; - const className = ["text-zinc-200 font-bold flex items-center gap-1.5", props.className] - .filter(Boolean) - .join(" "); +const dotToneClasses: Record = { + active: "status-dot-success", + paused: "bg-zinc-500", + neutral: "status-dot-active", +}; +export function StatusBadge({ tone = "neutral", pulse = false, className, children }: StatusBadgeProps) { return ( - - - {props.children} + + + {children} ); } diff --git a/src/data/mock/inventoryItems.ts b/src/data/mock/inventoryItems.ts new file mode 100644 index 0000000..96f8bb5 --- /dev/null +++ b/src/data/mock/inventoryItems.ts @@ -0,0 +1,76 @@ +import type { LucideIcon } from "lucide-react"; +import { Map, Layers, Globe } from "lucide-react"; + +// ─── Project Type ────────────────────────────────────────────────────────────── + +export type ProjectType = "map" | "layers" | "globe"; + +/** + * 파일/프로젝트 타입별 아이콘 컴포넌트와 색상 클래스를 중앙화합니다. + * JSX 엘리먼트를 데이터에 포함시키지 않고, 렌더 시 컴포넌트를 호출합니다. + */ +export const PROJECT_TYPE_META: Record< + ProjectType, + { icon: LucideIcon; colorClass: string } +> = { + map: { + icon: Map, + colorClass: "bg-blue-500/10 border-blue-500/20 text-blue-400", + }, + layers: { + icon: Layers, + colorClass: "bg-orange-500/10 border-orange-500/20 text-orange-400", + }, + globe: { + icon: Globe, + colorClass: "bg-indigo-500/10 border-indigo-500/20 text-indigo-400", + }, +}; + +// ─── Mock Data ───────────────────────────────────────────────────────────────── + +export type InventoryItem = { + id: string; + name: string; + owner: string; + /** 포맷된 파일 크기 문자열 (예: "14.2 MB") */ + size: string; + /** 포맷된 날짜 문자열 (예: "2 mins ago") */ + date: string; + link: string; + type: ProjectType; +}; + +/** + * 개발/데모용 mock 데이터입니다. + * 실제 API 연동 시 이 배열을 제거하고 API 응답으로 교체하세요. + */ +export const MOCK_INVENTORY_ITEMS: InventoryItem[] = [ + { + id: "1", + name: "Untitled Map", + owner: "Me", + size: "14.2 MB", + date: "2 mins ago", + link: "/explorer", + type: "map", + }, + { + id: "2", + name: "Urban Expansion Analysis", + owner: "Team Alpha", + size: "1.4 GB", + date: "Yesterday, 4:12 PM", + link: "/explorer", + type: "layers", + }, + { + id: "3", + name: "Grid Network Beta", + owner: "Me", + size: "84 KB", + date: "Oct 24, 2023", + link: "/explorer", + type: "globe", + }, +]; diff --git a/src/globe/GlobeScene.tsx b/src/globe/GlobeScene.tsx index a1ce85f..313d6be 100644 --- a/src/globe/GlobeScene.tsx +++ b/src/globe/GlobeScene.tsx @@ -114,7 +114,7 @@ export default function GlobeScene({ places }: GlobeSceneProps) { viewer.scene.primitives.add(osmBuildings); } } catch (e) { - logger.warn("Could not load world terrain or OSM buildings — degrading gracefully", e); + logger.warn("Could not load world terrain or OSM buildings — degrading gracefully", toErrorContext(e)); } // 마커 추가 diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 77e8de7..45bbf49 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -92,7 +92,20 @@ export const en = { cloudy: "Cloudy", rain: "Rain", snow: "Snow" + }, + performance: { + title: "Performance", + fps: "FPS", + frame: "Frame", + healthy: "Healthy", + watch: "Watch" } + }, + dashboard: { + logout: "Logout", + serverNode: "Node-West-01", + storageUsed: "8.2 GB", + storageTotal: "15 GB" } }; diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 7d29ee2..fb0fef5 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -94,6 +94,19 @@ export const ko: Dictionary = { cloudy: "흐림", rain: "비", snow: "눈" + }, + performance: { + title: "성능", + fps: "FPS", + frame: "프레임", + healthy: "정상", + watch: "감시 중" } + }, + dashboard: { + logout: "로그아웃", + serverNode: "Node-West-01", + storageUsed: "8.2 GB", + storageTotal: "15 GB" } }; diff --git a/src/scene/place/PlaceScene.tsx b/src/scene/place/PlaceScene.tsx index dd6b2ad..0dd2259 100644 --- a/src/scene/place/PlaceScene.tsx +++ b/src/scene/place/PlaceScene.tsx @@ -10,6 +10,7 @@ import SceneInfoHUD from "../../components/hud/SceneInfoHUD"; import PerformanceHUD from "../../components/hud/PerformanceHUD"; import PerformanceSystem from "./PerformanceSystem"; import { usePlaybackStore } from "../../stores/playbackStore"; +import { selectIsNight } from "../../stores/selectors/playbackSelectors"; import LoadingScene from "../loading/LoadingScene"; type PlaceSceneProps = { @@ -18,7 +19,7 @@ type PlaceSceneProps = { export default function PlaceScene({ slug }: PlaceSceneProps) { const setStatus = usePlaceStore((s) => s.setStatus); - const isNight = usePlaybackStore((s) => s.isNight()); + const isNight = usePlaybackStore(selectIsNight); const backgroundClass = useMemo(() => { return isNight ? "bg-[#03040a]" : "bg-[#7fc5ff]"; diff --git a/src/scene/place/PlaceSceneContent.tsx b/src/scene/place/PlaceSceneContent.tsx index d060e2e..3060435 100644 --- a/src/scene/place/PlaceSceneContent.tsx +++ b/src/scene/place/PlaceSceneContent.tsx @@ -1,10 +1,12 @@ "use client"; -import { useMemo } from "react"; +import { useEffect } from "react"; import { usePlaceStore } from "../../stores/placeStore"; import { useAppStore } from "../../stores/appStore"; import { usePlaybackStore } from "../../stores/playbackStore"; +import { selectIsNight } from "../../stores/selectors/playbackSelectors"; import { APP_CONFIG } from "../../shared/config"; +import { normalizeHour } from "../../shared/domains"; import StaticEnvironment from "./StaticEnvironment"; import CameraController from "./CameraController"; import PlaybackSystem from "./PlaybackSystem"; @@ -30,37 +32,66 @@ export default function PlaceSceneContent({ slug }: PlaceSceneContentProps) { const weather = usePlaybackStore((s) => s.weather); const currentPedestrianLevel = usePlaybackStore((s) => s.pedestrianLevel); const currentVehicleLevel = usePlaybackStore((s) => s.vehicleLevel); - const isNight = usePlaybackStore((s) => s.isNight()); + const isNight = usePlaybackStore(selectIsNight); - const { scenePkg, sceneBootstrap, sceneMapping } = usePlaceBootstrap({ - slug, - setStatus, - setProgress, - setCurrentPlace, - setMode, - }); + const { data, isLoading, isError } = usePlaceBootstrap(slug); + + useEffect(() => { + if (isLoading) { + setStatus("loading"); + setProgress(APP_CONFIG.place.loading.initialProgress); + } else if (isError) { + setStatus("error"); + } else if (data) { + setCurrentPlace(data.placeMeta); + setMode("place"); + + const readyTimer = setTimeout(() => { + setProgress(APP_CONFIG.place.loading.completedProgress); + setStatus("ready"); + }, APP_CONFIG.place.loading.readyDelayMs); + + return () => clearTimeout(readyTimer); + } + }, [data, isLoading, isError, setCurrentPlace, setMode, setProgress, setStatus]); - const normalizedHour = useMemo(() => { - const raw = Math.floor(currentTime); - return ((raw % 24) + 24) % 24; - }, [currentTime]); + const normalizedHour = normalizeHour(Math.floor(currentTime)); - useSceneLiveData({ + const { data: liveData } = useSceneLiveData({ slug, - bootstrap: sceneBootstrap, + bootstrap: data?.bootstrap ?? null, normalizedHour, - currentWeather: weather, + currentPedestrianLevel, + }); + + useEffect(() => { + if (liveData) { + if (liveData.weather.condition !== weather) { + setWeather(liveData.weather.condition); + } + if (liveData.places.pedestrianDensity !== currentPedestrianLevel) { + setPedestrianLevel(liveData.places.pedestrianDensity); + } + if (liveData.places.vehicleDensity !== currentVehicleLevel) { + setVehicleLevel(liveData.places.vehicleDensity); + } + } + }, [ + liveData, + weather, currentPedestrianLevel, currentVehicleLevel, setWeather, setPedestrianLevel, setVehicleLevel, - }); + ]); - if (!scenePkg) { + if (!data) { return null; } + const { pkg: scenePkg, bootstrap: sceneBootstrap, mapping: sceneMapping } = data; + const ambientIntensity = isNight ? APP_CONFIG.scene.light.night.ambientIntensity : APP_CONFIG.scene.light.day.ambientIntensity; diff --git a/src/scene/place/SceneAssetModel.tsx b/src/scene/place/SceneAssetModel.tsx index 3cff38b..377d8ad 100644 --- a/src/scene/place/SceneAssetModel.tsx +++ b/src/scene/place/SceneAssetModel.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo } from "react"; import { useGLTF } from "@react-three/drei"; import * as THREE from "three"; import { usePlaybackStore } from "../../stores/playbackStore"; +import { selectIsNight } from "../../stores/selectors/playbackSelectors"; import type { GeometryLiveMapping } from "../../shared/contracts"; type SceneAssetModelProps = { @@ -51,7 +52,7 @@ type LoadedSceneAssetModelProps = { }; function LoadedSceneAssetModel({ assetUrl, mapping }: LoadedSceneAssetModelProps) { - const isNight = usePlaybackStore((state) => state.isNight()); + const isNight = usePlaybackStore(selectIsNight); const gltf = useGLTF(assetUrl); const scene = useMemo(() => { diff --git a/src/scene/place/StaticEnvironmentFallback.tsx b/src/scene/place/StaticEnvironmentFallback.tsx index c4083bf..d883ad6 100644 --- a/src/scene/place/StaticEnvironmentFallback.tsx +++ b/src/scene/place/StaticEnvironmentFallback.tsx @@ -3,6 +3,7 @@ import { useMemo } from "react"; import * as THREE from "three"; import { usePlaybackStore } from "../../stores/playbackStore"; +import { selectIsNight } from "../../stores/selectors/playbackSelectors"; import type { PlacePackage, BuildingConfig, RoadConfig } from "../../data/placePackages"; type StaticEnvironmentFallbackProps = { @@ -19,7 +20,7 @@ function Ground({ pkg }: { pkg: PlacePackage }) { } function Building({ config }: { config: BuildingConfig }) { - const isNight = usePlaybackStore((s) => s.isNight()); + const isNight = usePlaybackStore(selectIsNight); const baseColor = config.color; return ( @@ -54,7 +55,7 @@ function Road({ config }: { config: RoadConfig }) { } function NeonSigns({ pkg }: { pkg: PlacePackage }) { - const isNight = usePlaybackStore((s) => s.isNight()); + const isNight = usePlaybackStore(selectIsNight); if (!isNight) return null; return ( diff --git a/src/scene/place/usePlaceBootstrap.ts b/src/scene/place/usePlaceBootstrap.ts index 1cb4044..80dcf43 100644 --- a/src/scene/place/usePlaceBootstrap.ts +++ b/src/scene/place/usePlaceBootstrap.ts @@ -1,124 +1,61 @@ -import { useEffect, useState } from "react"; -import type { PlacePackage } from "../../data/placePackages"; -import { APP_CONFIG } from "../../shared/config"; -import { fetchSceneBootstrapBundle } from "../../shared/api"; -import type { GeometryLiveMapping, SceneBootstrap } from "../../shared/contracts"; -import { createLogger, toErrorContext } from "../../shared/logger"; - -type UsePlaceBootstrapInput = { +import { useQuery } from "@tanstack/react-query"; +import { fetchSceneBootstrapBundle } from "@/src/shared/api"; +import { MVP_PLACES } from "@/src/data/places"; +import type { PlacePackage } from "@/src/data/placePackages"; +import type { GeometryLiveMapping, SceneBootstrap } from "@/src/shared/contracts"; + +export type PlaceEntity = { + id: string; slug: string; - setStatus: (status: "idle" | "loading" | "ready" | "error") => void; - setProgress: (progress: number) => void; - setCurrentPlace: (place: { - id: string; - slug: string; - name: string; - lat: number; - lng: number; - city: string; - country: string; - } | null) => void; - setMode: (mode: "globe" | "loading" | "place") => void; + name: string; + lat: number; + lng: number; + city: string; + country: string; }; -type UsePlaceBootstrapResult = { - scenePkg: PlacePackage | null; - sceneBootstrap: SceneBootstrap | null; - sceneMapping: GeometryLiveMapping | null; +export type PlaceBootstrapData = { + pkg: PlacePackage; + bootstrap: SceneBootstrap; + mapping: GeometryLiveMapping; + placeMeta: PlaceEntity; }; -const logger = createLogger("scene:place-bootstrap"); - +/** slug → 표시 이름 변환 (fallback: slug를 제목형으로) */ function toPlaceLabel(slug: string) { return slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); } -export function usePlaceBootstrap(input: UsePlaceBootstrapInput): UsePlaceBootstrapResult { - const { slug, setStatus, setProgress, setCurrentPlace, setMode } = input; - - const [scenePkgBySlug, setScenePkgBySlug] = useState>({}); - const [sceneBootstrapBySlug, setSceneBootstrapBySlug] = useState>({}); - const [sceneMappingBySlug, setSceneMappingBySlug] = useState>({}); - - useEffect(() => { - let mounted = true; - let readyTimer: ReturnType | null = null; - - setStatus("loading"); - setProgress(APP_CONFIG.place.loading.initialProgress); - - void fetchSceneBootstrapBundle(slug) - .then(({ bootstrap, mapping, pkg: fetchedPkg }) => { - if (!mounted) { - return; - } - - setScenePkgBySlug((previous) => ({ - ...previous, - [slug]: fetchedPkg, - })); - setSceneBootstrapBySlug((previous) => ({ - ...previous, - [slug]: bootstrap, - })); - setSceneMappingBySlug((previous) => ({ - ...previous, - [slug]: mapping, - })); - - logger.info("Bootstrapping place scene", { - slug, - geometryId: bootstrap.geometryId, - bindingCount: mapping.bindings.length, - assetUrl: bootstrap.assetUrl, - }); - - setCurrentPlace({ - id: bootstrap.placeId, - slug: bootstrap.slug, - name: toPlaceLabel(bootstrap.slug), - lat: 0, - lng: 0, - city: "", - country: "", - }); - setMode("place"); - - readyTimer = setTimeout(() => { - if (!mounted) { - return; - } - - setProgress(APP_CONFIG.place.loading.completedProgress); - setStatus("ready"); - logger.info("Place scene ready", { - slug, - }); - }, APP_CONFIG.place.loading.readyDelayMs); - }) - .catch((error) => { - if (!mounted) { - return; - } - - setStatus("error"); - logger.error("Failed to bootstrap place scene", { - slug, - ...toErrorContext(error), - }); - }); - - return () => { - mounted = false; - if (readyTimer) { - clearTimeout(readyTimer); - } - }; - }, [setCurrentPlace, setMode, setProgress, setStatus, slug]); +/** MVP_PLACES에서 slug에 맞는 장소 메타데이터를 조회합니다. */ +function findPlaceMeta(slug: string) { + return MVP_PLACES.find((p) => p.slug === slug) ?? null; +} - return { - scenePkg: scenePkgBySlug[slug] ?? null, - sceneBootstrap: sceneBootstrapBySlug[slug] ?? null, - sceneMapping: sceneMappingBySlug[slug] ?? null, - }; +export function usePlaceBootstrap(slug: string) { + return useQuery({ + queryKey: ["place-bootstrap", slug], + queryFn: async () => { + const data = await fetchSceneBootstrapBundle(slug); + const knownPlace = findPlaceMeta(slug); + + const placeMeta: PlaceEntity = { + id: data.bootstrap.placeId, + slug: data.bootstrap.slug, + name: knownPlace?.name ?? toPlaceLabel(data.bootstrap.slug), + lat: knownPlace?.lat ?? 0, + lng: knownPlace?.lng ?? 0, + city: knownPlace?.city ?? "", + country: knownPlace?.country ?? "", + }; + + return { + pkg: data.pkg, + bootstrap: data.bootstrap, + mapping: data.mapping, + placeMeta, + }; + }, + staleTime: 5 * 60 * 1000, // 5 minutes + refetchOnWindowFocus: false, + }); } diff --git a/src/scene/place/useSceneLiveData.ts b/src/scene/place/useSceneLiveData.ts index 7f92c38..751950d 100644 --- a/src/scene/place/useSceneLiveData.ts +++ b/src/scene/place/useSceneLiveData.ts @@ -1,130 +1,49 @@ -import { useEffect, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; import { fetchLivePlaces, fetchLiveTraffic, fetchLiveWeather, -} from "../../shared/api"; -import { createLogger, toErrorContext } from "../../shared/logger"; -import type { SceneBootstrap } from "../../shared/contracts"; - -const logger = createLogger("scene:use-live-data"); -const LIVE_FETCH_MIN_INTERVAL_MS = 1200; +} from "@/src/shared/api"; +import { APP_CONFIG } from "@/src/shared/config"; +import type { SceneBootstrap } from "@/src/shared/contracts"; type UseSceneLiveDataInput = { slug: string; bootstrap: SceneBootstrap | null; normalizedHour: number; - currentWeather: "clear" | "cloudy" | "rain" | "snow"; currentPedestrianLevel: "low" | "medium" | "high"; - currentVehicleLevel: "low" | "medium" | "high"; - setWeather: (value: "clear" | "cloudy" | "rain" | "snow") => void; - setPedestrianLevel: (value: "low" | "medium" | "high") => void; - setVehicleLevel: (value: "low" | "medium" | "high") => void; }; export function useSceneLiveData(input: UseSceneLiveDataInput) { - const { - slug, - bootstrap, - normalizedHour, - currentWeather, - currentPedestrianLevel, - currentVehicleLevel, - setWeather, - setPedestrianLevel, - setVehicleLevel, - } = input; - - const latestWeatherRef = useRef(currentWeather); - const latestPedestrianLevelRef = useRef(currentPedestrianLevel); - const latestVehicleLevelRef = useRef(currentVehicleLevel); - const lastFetchRef = useRef<{ key: string; requestedAt: number } | null>(null); - - latestWeatherRef.current = currentWeather; - latestPedestrianLevelRef.current = currentPedestrianLevel; - latestVehicleLevelRef.current = currentVehicleLevel; + const { slug, bootstrap, normalizedHour, currentPedestrianLevel } = input; - useEffect(() => { - if (!bootstrap) { - return; - } - - const requestKey = [ - bootstrap.geometryId, + return useQuery({ + queryKey: [ + "scene-live-data", + slug, + bootstrap?.geometryId, normalizedHour, - currentWeather, currentPedestrianLevel, - currentVehicleLevel, - ].join("|"); - const now = Date.now(); - const lastFetch = lastFetchRef.current; - - if ( - lastFetch && - lastFetch.key === requestKey && - now - lastFetch.requestedAt < LIVE_FETCH_MIN_INTERVAL_MS - ) { - return; - } - - lastFetchRef.current = { - key: requestKey, - requestedAt: now, - }; - - let mounted = true; - - void Promise.all([ - fetchLiveTraffic(bootstrap.liveEndpoints.traffic, normalizedHour), - fetchLiveWeather(bootstrap.liveEndpoints.weather, normalizedHour), - fetchLivePlaces(bootstrap.liveEndpoints.places, currentPedestrianLevel), - ]) - .then(([trafficSnapshot, weatherSnapshot, placeSnapshot]) => { - if (!mounted) { - return; - } - - if (weatherSnapshot.condition !== latestWeatherRef.current) { - setWeather(weatherSnapshot.condition); - } - if (placeSnapshot.pedestrianDensity !== latestPedestrianLevelRef.current) { - setPedestrianLevel(placeSnapshot.pedestrianDensity); - } - if (placeSnapshot.vehicleDensity !== latestVehicleLevelRef.current) { - setVehicleLevel(placeSnapshot.vehicleDensity); - } - - logger.debug("Updated live scene snapshot", { - slug, - trafficDensity: trafficSnapshot.density, - weather: weatherSnapshot.condition, - pedestrianDensity: placeSnapshot.pedestrianDensity, - vehicleDensity: placeSnapshot.vehicleDensity, - }); - }) - .catch((error) => { - if (!mounted) { - return; - } - - logger.warn("Failed to fetch live scene snapshot", { - slug, - ...toErrorContext(error), - }); - }); - - return () => { - mounted = false; - }; - }, [ - bootstrap, - currentWeather, - currentPedestrianLevel, - currentVehicleLevel, - normalizedHour, - setPedestrianLevel, - setVehicleLevel, - setWeather, - slug, - ]); + ], + queryFn: async () => { + if (!bootstrap) { + throw new Error("Bootstrap is not available"); + } + + const [traffic, weather, places] = await Promise.all([ + fetchLiveTraffic(bootstrap.liveEndpoints.traffic, normalizedHour), + fetchLiveWeather(bootstrap.liveEndpoints.weather, normalizedHour), + fetchLivePlaces(bootstrap.liveEndpoints.places, currentPedestrianLevel), + ]); + + return { + traffic, + weather, + places, + }; + }, + enabled: !!bootstrap, + staleTime: APP_CONFIG.liveData.fetchMinIntervalMs, + refetchInterval: APP_CONFIG.liveData.fetchMinIntervalMs, + }); } diff --git a/src/shared/config/app.ts b/src/shared/config/app.ts index cfbc5f6..0c84aeb 100644 --- a/src/shared/config/app.ts +++ b/src/shared/config/app.ts @@ -6,6 +6,10 @@ export const APP_CONFIG = { readyDelayMs: 600, }, }, + liveData: { + /** 동일 키에 대한 중복 fetch 방지 최소 간격 (ms) */ + fetchMinIntervalMs: 1200, + }, scene: { light: { night: { diff --git a/src/shared/utils/cn.ts b/src/shared/utils/cn.ts new file mode 100644 index 0000000..cebbc60 --- /dev/null +++ b/src/shared/utils/cn.ts @@ -0,0 +1,13 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +/** + * Tailwind 클래스 충돌을 안전하게 병합하는 유틸리티입니다. + * clsx로 조건부 클래스를 처리하고, tailwind-merge로 중복 규칙을 제거합니다. + * + * @example + * cn("px-4 py-2", isActive && "bg-blue-500", className) + */ +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} diff --git a/src/stores/placeStore.ts b/src/stores/placeStore.ts index 3d20891..e7aaec9 100644 --- a/src/stores/placeStore.ts +++ b/src/stores/placeStore.ts @@ -1,50 +1,14 @@ import { create } from "zustand"; +import { createPlaceSlice, type PlaceSlice, type Place } from "./slices/placeSlice"; +import { createSceneLoadingSlice, type SceneLoadingSlice, type PlaceStatus } from "./slices/sceneLoadingSlice"; +import { createCameraSlice, type CameraSlice, type ViewMode, type InputPreset } from "./slices/cameraSlice"; -export type ViewMode = "top" | "walk"; -export type InputPreset = "precision" | "balanced" | "fast"; +export type { Place, PlaceStatus, ViewMode, InputPreset }; -export type PlaceStatus = "idle" | "loading" | "ready" | "error"; +export type PlaceStore = PlaceSlice & SceneLoadingSlice & CameraSlice; -export type Place = { - id: string; - slug: string; - name: string; - lat: number; - lng: number; - city: string; - country: string; -}; - -type PlaceStore = { - currentPlace: Place | null; - setCurrentPlace: (place: Place | null) => void; - - viewMode: ViewMode; - setViewMode: (mode: ViewMode) => void; - - inputPreset: InputPreset; - setInputPreset: (preset: InputPreset) => void; - - status: PlaceStatus; - setStatus: (status: PlaceStatus) => void; - - progress: number; - setProgress: (progress: number) => void; -}; - -export const usePlaceStore = create((set) => ({ - currentPlace: null, - setCurrentPlace: (place) => set({ currentPlace: place }), - - viewMode: "top", - setViewMode: (mode) => set({ viewMode: mode }), - - inputPreset: "balanced", - setInputPreset: (preset) => set({ inputPreset: preset }), - - status: "idle", - setStatus: (status) => set({ status }), - - progress: 0, - setProgress: (progress) => set({ progress }), +export const usePlaceStore = create()((...a) => ({ + ...createPlaceSlice(...a), + ...createSceneLoadingSlice(...a), + ...createCameraSlice(...a), })); diff --git a/src/stores/playbackStore.ts b/src/stores/playbackStore.ts index 33660a4..c9941a3 100644 --- a/src/stores/playbackStore.ts +++ b/src/stores/playbackStore.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { isNightTime, normalizeHour, toTimeOfDay } from "../shared/domains"; +import { normalizeHour } from "../shared/domains"; export type WeatherMode = "clear" | "cloudy" | "rain" | "snow"; export type TimeOfDay = "day" | "dusk" | "night"; @@ -25,12 +25,9 @@ type PlaybackStore = { vehicleLevel: VehicleLevel; setPedestrianLevel: (level: PedestrianLevel) => void; setVehicleLevel: (level: VehicleLevel) => void; - - getTimeOfDay: () => TimeOfDay; - isNight: () => boolean; }; -export const usePlaybackStore = create((set, get) => ({ +export const usePlaybackStore = create((set) => ({ currentTime: 12, setCurrentTime: (time) => set({ currentTime: normalizeHour(time) }), @@ -49,13 +46,4 @@ export const usePlaybackStore = create((set, get) => ({ vehicleLevel: "medium", setPedestrianLevel: (level) => set({ pedestrianLevel: level }), setVehicleLevel: (level) => set({ vehicleLevel: level }), - - getTimeOfDay: () => { - const { currentTime } = get(); - return toTimeOfDay(currentTime); - }, - isNight: () => { - const { currentTime } = get(); - return isNightTime(currentTime); - }, })); diff --git a/src/stores/selectors/playbackSelectors.ts b/src/stores/selectors/playbackSelectors.ts new file mode 100644 index 0000000..cbfa72f --- /dev/null +++ b/src/stores/selectors/playbackSelectors.ts @@ -0,0 +1,10 @@ +import { isNightTime, toTimeOfDay } from "@/src/shared/domains"; + +// We extract just the state shape we need to evaluate derived values +type PlaybackStateShape = { + currentTime: number; +}; + +export const selectIsNight = (state: PlaybackStateShape) => isNightTime(state.currentTime); + +export const selectTimeOfDay = (state: PlaybackStateShape) => toTimeOfDay(state.currentTime); diff --git a/src/stores/slices/cameraSlice.ts b/src/stores/slices/cameraSlice.ts new file mode 100644 index 0000000..35eefc6 --- /dev/null +++ b/src/stores/slices/cameraSlice.ts @@ -0,0 +1,20 @@ +import type { StateCreator } from "zustand"; + +export type ViewMode = "top" | "walk"; +export type InputPreset = "precision" | "balanced" | "fast"; + +export type CameraSlice = { + viewMode: ViewMode; + setViewMode: (mode: ViewMode) => void; + + inputPreset: InputPreset; + setInputPreset: (preset: InputPreset) => void; +}; + +export const createCameraSlice: StateCreator = (set) => ({ + viewMode: "top", + setViewMode: (mode) => set({ viewMode: mode }), + + inputPreset: "balanced", + setInputPreset: (preset) => set({ inputPreset: preset }), +}); diff --git a/src/stores/slices/placeSlice.ts b/src/stores/slices/placeSlice.ts new file mode 100644 index 0000000..fc1ed64 --- /dev/null +++ b/src/stores/slices/placeSlice.ts @@ -0,0 +1,21 @@ +import type { StateCreator } from "zustand"; + +export type Place = { + id: string; + slug: string; + name: string; + lat: number; + lng: number; + city: string; + country: string; +}; + +export type PlaceSlice = { + currentPlace: Place | null; + setCurrentPlace: (place: Place | null) => void; +}; + +export const createPlaceSlice: StateCreator = (set) => ({ + currentPlace: null, + setCurrentPlace: (place) => set({ currentPlace: place }), +}); diff --git a/src/stores/slices/sceneLoadingSlice.ts b/src/stores/slices/sceneLoadingSlice.ts new file mode 100644 index 0000000..2c50e26 --- /dev/null +++ b/src/stores/slices/sceneLoadingSlice.ts @@ -0,0 +1,19 @@ +import type { StateCreator } from "zustand"; + +export type PlaceStatus = "idle" | "loading" | "ready" | "error"; + +export type SceneLoadingSlice = { + status: PlaceStatus; + setStatus: (status: PlaceStatus) => void; + + progress: number; + setProgress: (progress: number) => void; +}; + +export const createSceneLoadingSlice: StateCreator = (set) => ({ + status: "idle", + setStatus: (status) => set({ status }), + + progress: 0, + setProgress: (progress) => set({ progress }), +}); From 4f07cf6e3eff2c56ce3c9d1b5272d437f3d4c2d6 Mon Sep 17 00:00:00 2001 From: ummsehun Date: Tue, 7 Apr 2026 08:38:29 +0900 Subject: [PATCH 28/28] feat:3D Optimization --- src/globe/GlobeScene.tsx | 37 +++++++++++++++++-------------------- src/shared/config/app.ts | 6 +++--- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/src/globe/GlobeScene.tsx b/src/globe/GlobeScene.tsx index 313d6be..71f8142 100644 --- a/src/globe/GlobeScene.tsx +++ b/src/globe/GlobeScene.tsx @@ -63,16 +63,10 @@ export default function GlobeScene({ places }: GlobeSceneProps) { shouldAnimate: true, skyAtmosphere: new Cesium.SkyAtmosphere(), requestRenderMode: true, - useBrowserRecommendedResolution: false, - terrainShadows: Cesium.ShadowMode.ENABLED, + useBrowserRecommendedResolution: true, // Retina 디스플레이 자동 최적화 + terrainShadows: Cesium.ShadowMode.DISABLED, // 극강의 FPS 확보를 위해 터레인 그림자 해제 }); - // 픽셀 밀도에 맞춰 해상도 강제 1:1 매칭 (retina 지원) - viewer.resolutionScale = typeof window !== "undefined" - ? Math.min(window.devicePixelRatio, 2) - : 1; - viewer.scene.shadowMap.maximumDistance = GLOBE.rendering.shadowMapMaxDistance; - // 카메라 입력 설정 const ssc = viewer.scene.screenSpaceCameraController; ssc.enableInputs = true; @@ -87,34 +81,37 @@ export default function GlobeScene({ places }: GlobeSceneProps) { ssc.minimumZoomDistance = GLOBE.zoom.min; ssc.maximumZoomDistance = GLOBE.zoom.max; - // 렌더링 품질 설정 + // 렌더링 품질 최적화 설정 viewer.scene.globe.enableLighting = true; viewer.scene.globe.showGroundAtmosphere = true; viewer.scene.globe.maximumScreenSpaceError = GLOBE.rendering.maximumScreenSpaceError; - viewer.scene.globe.shadows = Cesium.ShadowMode.RECEIVE_ONLY; + viewer.scene.globe.shadows = Cesium.ShadowMode.DISABLED; // 전지구적 그림자 처리 해제 + viewer.scene.fog.enabled = true; viewer.scene.fog.density = GLOBE.fog.density; viewer.scene.fog.screenSpaceErrorFactor = GLOBE.fog.screenSpaceErrorFactor; - viewer.scene.highDynamicRange = true; - viewer.scene.postProcessStages.fxaa.enabled = true; + + // 후처리(FXAA, HDR) 기능 해제를 통한 연산량 대폭 절감 + viewer.scene.highDynamicRange = false; + viewer.scene.postProcessStages.fxaa.enabled = false; + if (viewer.scene.moon) viewer.scene.moon.show = true; if (viewer.scene.sun) viewer.scene.sun.show = true; viewerRef.current = viewer; - // 3D 지형 및 전 세계 빌딩 (비동기 — 실패해도 graceful degradation) + // 3D 지형 (Mountains) 활성화 (OSM 빌딩은 성능을 위해 완전히 제거됨) try { - const [terrainProvider, osmBuildings] = await Promise.all([ - Cesium.createWorldTerrainAsync({ requestVertexNormals: true, requestWaterMask: true }), - Cesium.createOsmBuildingsAsync(), - ]); + const terrainProvider = await Cesium.createWorldTerrainAsync({ + requestVertexNormals: true, + requestWaterMask: true + }); + if (!isCancelled && viewerRef.current) { viewer.terrainProvider = terrainProvider; - osmBuildings.maximumScreenSpaceError = 1; - viewer.scene.primitives.add(osmBuildings); } } catch (e) { - logger.warn("Could not load world terrain or OSM buildings — degrading gracefully", toErrorContext(e)); + logger.warn("Could not load world terrain — degrading gracefully", toErrorContext(e)); } // 마커 추가 diff --git a/src/shared/config/app.ts b/src/shared/config/app.ts index 0c84aeb..9e3d9db 100644 --- a/src/shared/config/app.ts +++ b/src/shared/config/app.ts @@ -109,11 +109,11 @@ export const APP_CONFIG = { }, fog: { density: 0.0001, - screenSpaceErrorFactor: 2.0, + screenSpaceErrorFactor: 4.0, }, rendering: { - maximumScreenSpaceError: 1.0, - shadowMapMaxDistance: 5000.0, + maximumScreenSpaceError: 16.0, + shadowMapMaxDistance: 2000.0, }, scaleByDistance: { point: [1.5e2, 1.5, 8.0e6, 0.4] as const,