From 408de047d0b10b417713c8a21586a52a519b0fae Mon Sep 17 00:00:00 2001 From: ummsehun Date: Sun, 5 Apr 2026 23:28:02 +0900 Subject: [PATCH 1/3] 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 2/3] 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 4669bb4cf695802d61afe7ef367175ffc743035d Mon Sep 17 00:00:00 2001 From: ummsehun Date: Sun, 5 Apr 2026 23:40:12 +0900 Subject: [PATCH 3/3] refactor: split place scene bootstrap and lighting from content Extract scene bootstrapping/live-data orchestration and lighting derivation into dedicated hooks so PlaceSceneContent remains a runtime composition layer with lower coupling. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/scene/place/PlaceSceneContent.tsx | 148 +++------------------- src/scene/place/usePlaceSceneBootstrap.ts | 144 +++++++++++++++++++++ src/scene/place/useSceneLighting.ts | 31 +++++ 3 files changed, 191 insertions(+), 132 deletions(-) create mode 100644 src/scene/place/usePlaceSceneBootstrap.ts create mode 100644 src/scene/place/useSceneLighting.ts diff --git a/src/scene/place/PlaceSceneContent.tsx b/src/scene/place/PlaceSceneContent.tsx index e5d522b..4f2462b 100644 --- a/src/scene/place/PlaceSceneContent.tsx +++ b/src/scene/place/PlaceSceneContent.tsx @@ -1,171 +1,55 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import { usePlaceStore } from "../../stores/placeStore"; -import { useAppStore } from "../../stores/appStore"; -import type { PlacePackage } from "../../data/placePackages"; +import { useMemo } from "react"; 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 { normalizeHour } from "../../shared/domains"; import StaticEnvironment from "./StaticEnvironment"; import CameraController from "./CameraController"; import PlaybackSystem from "./PlaybackSystem"; import RainEffect from "./RainEffect"; import PedestrianSystem from "./PedestrianSystem"; import VehicleSystem from "./VehicleSystem"; -import { useSceneLiveData } from "./useSceneLiveData"; +import { usePlaceSceneBootstrap } from "./usePlaceSceneBootstrap"; +import { useSceneLighting } from "./useSceneLighting"; 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); - const setCurrentPlace = usePlaceStore((s) => s.setCurrentPlace); - const setMode = useAppStore((s) => s.setMode); - const setWeather = usePlaybackStore((s) => s.setWeather); - 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 currentPedestrianLevel = usePlaybackStore((s) => s.pedestrianLevel); - const currentVehicleLevel = usePlaybackStore((s) => s.vehicleLevel); 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 normalizedHour = useMemo(() => { - const raw = Math.floor(currentTime); - return ((raw % 24) + 24) % 24; + return normalizeHour(Math.floor(currentTime)); }, [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({ + const { scenePkg, sceneBootstrap, sceneMapping } = usePlaceSceneBootstrap({ slug, - bootstrap: sceneBootstrap, normalizedHour, - currentWeather, - currentPedestrianLevel, - currentVehicleLevel, - setWeather, - setPedestrianLevel, - setVehicleLevel, }); if (!scenePkg) { return null; } - const ambientIntensity = isNight - ? APP_CONFIG.scene.light.night.ambientIntensity - : APP_CONFIG.scene.light.day.ambientIntensity; - const ambientColor = isNight ? APP_CONFIG.scene.light.night.ambientColor : scenePkg.ambientColor; - const directionalIntensity = isNight - ? APP_CONFIG.scene.light.night.directionalIntensity - : APP_CONFIG.scene.light.day.directionalIntensity; - const directionalColor = isNight - ? APP_CONFIG.scene.light.night.directionalColor - : APP_CONFIG.scene.light.day.directionalColor; + const sceneLighting = useSceneLighting({ + scenePkg, + isNight, + }); return ( <> - + diff --git a/src/scene/place/usePlaceSceneBootstrap.ts b/src/scene/place/usePlaceSceneBootstrap.ts new file mode 100644 index 0000000..2cd0280 --- /dev/null +++ b/src/scene/place/usePlaceSceneBootstrap.ts @@ -0,0 +1,144 @@ +import { useEffect, useState } from "react"; +import type { PlacePackage } from "../../data/placePackages"; +import { useAppStore } from "../../stores/appStore"; +import { usePlaceStore } from "../../stores/placeStore"; +import { usePlaybackStore } from "../../stores/playbackStore"; +import { fetchSceneBootstrapBundle } from "../../shared/api"; +import { APP_CONFIG } from "../../shared/config"; +import type { GeometryLiveMapping, SceneBootstrap } from "../../shared/contracts"; +import { createLogger, toErrorContext } from "../../shared/logger"; +import { useSceneLiveData } from "./useSceneLiveData"; + +const logger = createLogger("scene:place-bootstrap"); + +function toPlaceLabel(slug: string) { + return slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +type UsePlaceSceneBootstrapInput = { + slug: string; + normalizedHour: number; +}; + +type UsePlaceSceneBootstrapOutput = { + scenePkg: PlacePackage | null; + sceneBootstrap: SceneBootstrap | null; + sceneMapping: GeometryLiveMapping | null; +}; + +export function usePlaceSceneBootstrap( + input: UsePlaceSceneBootstrapInput, +): UsePlaceSceneBootstrapOutput { + const { slug, normalizedHour } = input; + + const setStatus = usePlaceStore((s) => s.setStatus); + const setProgress = usePlaceStore((s) => s.setProgress); + const setCurrentPlace = usePlaceStore((s) => s.setCurrentPlace); + const setMode = useAppStore((s) => s.setMode); + const setWeather = usePlaybackStore((s) => s.setWeather); + const setPedestrianLevel = usePlaybackStore((s) => s.setPedestrianLevel); + const setVehicleLevel = usePlaybackStore((s) => s.setVehicleLevel); + const currentWeather = usePlaybackStore((s) => s.weather); + const currentPedestrianLevel = usePlaybackStore((s) => s.pedestrianLevel); + const currentVehicleLevel = usePlaybackStore((s) => s.vehicleLevel); + + 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; + + 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, + normalizedHour, + currentWeather, + currentPedestrianLevel, + currentVehicleLevel, + setWeather, + setPedestrianLevel, + setVehicleLevel, + }); + + return { + scenePkg, + sceneBootstrap, + sceneMapping, + }; +} diff --git a/src/scene/place/useSceneLighting.ts b/src/scene/place/useSceneLighting.ts new file mode 100644 index 0000000..217eda7 --- /dev/null +++ b/src/scene/place/useSceneLighting.ts @@ -0,0 +1,31 @@ +import type { PlacePackage } from "../../data/placePackages"; +import { APP_CONFIG } from "../../shared/config"; + +type UseSceneLightingInput = { + scenePkg: PlacePackage; + isNight: boolean; +}; + +type UseSceneLightingOutput = { + ambientIntensity: number; + ambientColor: string; + directionalIntensity: number; + directionalColor: string; +}; + +export function useSceneLighting(input: UseSceneLightingInput): UseSceneLightingOutput { + const { scenePkg, isNight } = input; + + return { + ambientIntensity: isNight + ? APP_CONFIG.scene.light.night.ambientIntensity + : APP_CONFIG.scene.light.day.ambientIntensity, + ambientColor: isNight ? APP_CONFIG.scene.light.night.ambientColor : scenePkg.ambientColor, + directionalIntensity: isNight + ? APP_CONFIG.scene.light.night.directionalIntensity + : APP_CONFIG.scene.light.day.directionalIntensity, + directionalColor: isNight + ? APP_CONFIG.scene.light.night.directionalColor + : APP_CONFIG.scene.light.day.directionalColor, + }; +}