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/.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..e1d5e7a 100644 --- a/bun.lock +++ b/bun.lock @@ -13,23 +13,25 @@ "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", + "tailwind-merge": "^3.5.0", "three": "^0.183.2", "zod": "^4.3.6", "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 +796,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=="], @@ -992,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 f9b8888..efaef85 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,11 @@ "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", + "tailwind-merge": "^3.5.0", "three": "^0.183.2", "zod": "^4.3.6", "zustand": "^5.0.12" 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/app/explorer/page.tsx b/src/app/explorer/page.tsx new file mode 100644 index 0000000..76d5729 --- /dev/null +++ b/src/app/explorer/page.tsx @@ -0,0 +1,98 @@ +"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, 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 - Redesigned to match the dark theme */} +
+ + {/* Back Button */} + + + Dashboard + + +
+
+
+ +
+
+

{t('explorer.engine')}

+

{t('explorer.title')}

+
+
+ +
+ + {t('explorer.search')} +
+ +
+
+ {t('explorer.systemStatus')} +
+ + {t('explorer.online')} +
+
+
+
+
+ + {/* Bottom Place Selector */} +
+
+ {MVP_PLACES.map((place: Place) => ( + +
+
+ + {t('explorer.travelTo', { name: place.name })} +
+ {/* Tooltip triangle */} +
+
+ +
+
+
+
+ +
+
+

{place.city}

+

{place.name}

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

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

+
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..c559124 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -20,7 +20,108 @@ } 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); + } + + .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; + } + + .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/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 36730a0..5d5d096 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,20 +1,348 @@ -import GlobeScene from "../globe/GlobeScene"; -import { MVP_PLACES } from "../data/places"; +'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 '@/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' | 'archived'>('projects'); + const [popover, setPopover] = useState(null); + + const togglePopover = (id: string) => + setPopover((prev) => (prev === id ? null : id)); + + 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', + ); -export default function Home() { return ( -
- - -
-

- WorMap · Globe MVP -

-

장소를 클릭해 진입하세요

-

- 마커 3개(Shibuya / Times Square / Gangnam) 준비됨 -

-
-
+
setPopover(null)} + > + {/* ── Sidebar ──────────────────────────────────────────────────────────── */} + + + {/* ── Main ─────────────────────────────────────────────────────────────── */} +
+ + {/* Header */} +
+
+

{t('header.title')}

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

{t('popovers.notifications')}

+
+ )} +
+ + {/* Settings */} +
+ + {popover === 'settings' && ( +
e.stopPropagation()} + > + +
+ )} +
+ + {/* User Profile */} +
+ + {popover === 'user' && ( +
e.stopPropagation()} + > +
+ {t('popovers.profile')} +
+ + +
+ )} +
+
+
+ + {/* Scrollable Content */} +
+ + {/* Banner */} +
+
+ {t('banner.release')} +

{t('banner.title')}

+

{t('banner.description')}

+
+ + {t('banner.button')} + +
+
+ + {/* 배경 패턴 — 배열 기반으로 반복 렌더링 */} +
+
+ {BANNER_PATTERN_ICONS.map((Icon, i) => ( + + ))} +
+
+
+
+ + {/* Controls Bar */} +
+
+ {(['projects', 'archived'] as const).map((tab) => ( + + ))} +
+
+ + +
+
+ + {/* Inventory Table */} +
+
+

{t('inventory.title')}

+ + {t('inventory.activeProjects', { count: MOCK_INVENTORY_ITEMS.length })} + +
+ +
+ + + + + + + + + + + + {MOCK_INVENTORY_ITEMS.map((item) => { + const meta = PROJECT_TYPE_META[item.type]; + const Icon = meta.icon; + return ( + + + + + + + + ); + })} + +
{t('inventory.colName')}{t('inventory.colOwner')}{t('inventory.colStorage')}{t('inventory.colLastModified')}{t('inventory.colActions')}
+ +
+ +
+ + {item.name} + + +
{item.owner}{item.size}{item.date} + + {popover === `action-${item.id}` && ( +
e.stopPropagation()} + > + + +
+ +
+ )} +
+
+
+
+ + {/* FAB */} +
+ + + {t('footer.newProject')} + +
+ + {/* Footer */} +
+
+
+ + + {t('footer.systemOnline')}: {t('dashboard.serverNode')} + +
+
+ + + {t('dashboard.storageUsed')} / {t('dashboard.storageTotal')} {t('footer.used')} + +
+
+
+ + + {t('footer.lat')}: 34.05° N, {t('footer.lon')}: 118.24° W + +
+
+
+
); } 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/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/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 new file mode 100644 index 0000000..e48cc85 --- /dev/null +++ b/src/components/hud/PerformanceHUD.tsx @@ -0,0 +1,49 @@ +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; + + return ( +
+ +
+ + + {isWarning ? t('scene.performance.watch') : t('scene.performance.healthy')} + +
+ +
+ +
+
+ +

{snapshot.fps}

+
+
+ +

+ {snapshot.frameTimeMs.toFixed(1)}ms +

+
+
+ +
+ ); +} diff --git a/src/components/hud/PlaybackHUD.tsx b/src/components/hud/PlaybackHUD.tsx index f77fba5..3164239 100644 --- a/src/components/hud/PlaybackHUD.tsx +++ b/src/components/hud/PlaybackHUD.tsx @@ -1,19 +1,14 @@ -"use client"; - 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 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 { Panel } from "../ui/Panel"; +import { + InputPresetSection, + PlaybackControlSection, + TimeControlSection, + ViewModeToggleSection, + WalkModeHint, + WeatherControlSection, +} from "./playbackSections"; export default function PlaybackHUD() { const { isPlaying, speed, weather, currentTime, setIsPlaying, setSpeed, setWeather, setCurrentTime } = @@ -24,99 +19,39 @@ export default function PlaybackHUD() { const setInputPreset = usePlaceStore((s) => s.setInputPreset); return ( -
-
- - -
- {SPEED_OPTIONS.map((s) => ( - - ))} -
- -
+
+ + setIsPlaying(!isPlaying)} + onSetSpeed={setSpeed} + /> -
- {WEATHER_OPTIONS.map((w) => ( - - ))} -
+
-
+ -
- Time - {formatTime(currentTime)} -
+
-
- - -
+ -
+
- + setViewMode(viewMode === "top" ? "walk" : "top")} + /> -
+
-
- {INPUT_PRESET_OPTIONS.map((preset) => ( - - ))} -
-
+ + {viewMode === "walk" && ( -

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

+ + + )}
); diff --git a/src/components/hud/SceneInfoHUD.tsx b/src/components/hud/SceneInfoHUD.tsx index ec36088..0af387b 100644 --- a/src/components/hud/SceneInfoHUD.tsx +++ b/src/components/hud/SceneInfoHUD.tsx @@ -1,17 +1,15 @@ -"use client"; - import { useMemo } from "react"; -import { usePlaceStore } from "../../stores/placeStore"; -import { usePlaybackStore } from "../../stores/playbackStore"; - -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 { 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); @@ -19,21 +17,77 @@ 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 + ? `${t('scene.status.running')} · ${speed}x` + : t('scene.status.paused'); + const statusTone = isPlaying ? "active" : "paused"; return ( -
-

WorMap · Place MVP

-

{placeLabel}

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

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

+
+ +

{placeLabel}

+

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

+ +
+
+ + + {statusLabel} + +
+ +
+ +
+
+ +
+ + {t(`scene.weather.${weather}`)} +
+
+
+ +
+ + {formatTime(currentTime)} +
+
+
+
+ + + + +

+ {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 new file mode 100644 index 0000000..d1b1e25 --- /dev/null +++ b/src/components/hud/playbackSections.tsx @@ -0,0 +1,184 @@ +import { + Play, Pause, Cloud, CloudRain, Snowflake, Sun, + 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"; +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"]; + +type PlaybackControlSectionProps = { + isPlaying: boolean; + speed: number; + onTogglePlayback: () => void; + onSetSpeed: (speed: number) => void; +}; + +export function PlaybackControlSection(props: PlaybackControlSectionProps) { + const { isPlaying, speed, onTogglePlayback, onSetSpeed } = props; + const { t } = useTranslation(); + + return ( +
+ + +
+ {SPEED_OPTIONS.map((itemSpeed) => ( + onSetSpeed(itemSpeed)} + size="chip" + tone={speed === itemSpeed ? "active" : "default"} + > + {itemSpeed}× + + ))} +
+
+ ); +} + +type WeatherControlSectionProps = { + 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 }) => ( + onSetWeather(value)} + size="icon-md" + tone={weather === value ? "active" : "default"} + title={t(`scene.weather.${value}`)} + > + + + ))} +
+ ); +} + +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="-1h"> + + + onSetCurrentTime(currentTime + 1)} size="icon-sm" title="+1h"> + + +
+
+ ); +} + +type ViewModeToggleSectionProps = { + viewMode: "top" | "walk"; + onToggleViewMode: () => void; +}; + +export function ViewModeToggleSection(props: ViewModeToggleSectionProps) { + const { viewMode, onToggleViewMode } = props; + const { t } = useTranslation(); + + 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/components/ui/ControlButton.tsx b/src/components/ui/ControlButton.tsx new file mode 100644 index 0000000..3bba3e5 --- /dev/null +++ b/src/components/ui/ControlButton.tsx @@ -0,0 +1,42 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import { cn } from "@/src/shared/utils/cn"; + +type ControlButtonTone = "default" | "active"; +type ControlButtonSize = "icon-sm" | "icon-md" | "chip"; + +type ControlButtonProps = { + className?: string; + tone?: ControlButtonTone; + size?: ControlButtonSize; + children: ReactNode; +} & ButtonHTMLAttributes; + +const toneClasses: Record = { + default: "", + active: "glass-button-active", +}; + +const sizeClasses: Record = { + "icon-sm": "h-8 w-8", + "icon-md": "h-9 w-9", + chip: "h-8 px-3 text-[11px] font-bold", +}; + +export function ControlButton(props: ControlButtonProps) { + const { + className, + tone = "default", + size = "icon-md", + children, + ...buttonProps + } = props; + + return ( + + ); +} diff --git a/src/components/ui/Label.tsx b/src/components/ui/Label.tsx new file mode 100644 index 0000000..6141932 --- /dev/null +++ b/src/components/ui/Label.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from "react"; +import { cn } from "@/src/shared/utils/cn"; + +type LabelTone = "default" | "muted" | "accent"; +type LabelSize = "xs" | "sm"; + +type LabelProps = { + className?: string; + tone?: LabelTone; + size?: LabelSize; + children: ReactNode; +}; + +const toneClasses: Record = { + default: "text-foreground-strong", + muted: "text-muted-soft", + accent: "text-accent-primary", +}; + +const sizeClasses: Record = { + xs: "text-[10px]", + sm: "text-xs", +}; + +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 new file mode 100644 index 0000000..9ae9265 --- /dev/null +++ b/src/components/ui/Panel.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react"; +import { cn } from "@/src/shared/utils/cn"; + +type PanelTone = "default" | "subtle"; + +type PanelProps = { + className?: string; + tone?: PanelTone; + children: ReactNode; +}; + +const toneClasses: Record = { + default: "glass-panel", + subtle: "bg-white/5", +}; + +export function Panel({ tone = "default", className, children }: PanelProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/ui/StatusBadge.tsx b/src/components/ui/StatusBadge.tsx new file mode 100644 index 0000000..4acd06e --- /dev/null +++ b/src/components/ui/StatusBadge.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from "react"; +import { cn } from "@/src/shared/utils/cn"; + +type StatusBadgeTone = "active" | "paused" | "neutral"; + +type StatusBadgeProps = { + className?: string; + tone?: StatusBadgeTone; + pulse?: boolean; + children: ReactNode; +}; + +const dotBaseClass = "status-dot"; + +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 ( + + + {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 cadb695..71f8142 100644 --- a/src/globe/GlobeScene.tsx +++ b/src/globe/GlobeScene.tsx @@ -1,13 +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 { APP_CONFIG } from "../shared/config"; -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[]; @@ -21,40 +21,29 @@ 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()); - const markerSummary = useMemo( - () => places.map((place) => place.name).join(" · "), - [places], - ); - useEffect(() => { let isCancelled = false; const slugMap = entityMapRef.current; 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; @@ -69,12 +58,63 @@ 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: true, // Retina 디스플레이 자동 최적화 + terrainShadows: Cesium.ShadowMode.DISABLED, // 극강의 FPS 확보를 위해 터레인 그림자 해제 }); + // 카메라 입력 설정 + const ssc = viewer.scene.screenSpaceCameraController; + ssc.enableInputs = true; + ssc.enableZoom = true; + ssc.zoomEventTypes = [ + Cesium.CameraEventType.RIGHT_DRAG, + Cesium.CameraEventType.WHEEL, + Cesium.CameraEventType.PINCH, + ]; + ssc.zoomFactor = GLOBE.zoom.factor; + ssc.inertiaZoom = GLOBE.zoom.inertia; + 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.DISABLED; // 전지구적 그림자 처리 해제 + + viewer.scene.fog.enabled = true; + viewer.scene.fog.density = GLOBE.fog.density; + viewer.scene.fog.screenSpaceErrorFactor = GLOBE.fog.screenSpaceErrorFactor; + + // 후처리(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 지형 (Mountains) 활성화 (OSM 빌딩은 성능을 위해 완전히 제거됨) + try { + const terrainProvider = await Cesium.createWorldTerrainAsync({ + requestVertexNormals: true, + requestWaterMask: true + }); + + if (!isCancelled && viewerRef.current) { + viewer.terrainProvider = terrainProvider; + } + } catch (e) { + logger.warn("Could not load world terrain — degrading gracefully", toErrorContext(e)); + } + + // 마커 추가 slugMap.clear(); placeNameMap.clear(); @@ -84,32 +124,32 @@ 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, MARKER.markerAltitude), point: { - pixelSize: APP_CONFIG.cesium.marker.pixelSize, - color: Cesium.Color.CYAN, + pixelSize: MARKER.pixelSize, + color: Cesium.Color.fromCssColorString(MARKER.pointColor), outlineColor: Cesium.Color.WHITE, - outlineWidth: APP_CONFIG.cesium.marker.outlineWidth, + outlineWidth: MARKER.outlineWidth, disableDepthTestDistance: Number.POSITIVE_INFINITY, + scaleByDistance: new Cesium.NearFarScalar(...GLOBE.scaleByDistance.point), }, label: { - text: place.name, - font: APP_CONFIG.cesium.marker.labelFont, - style: Cesium.LabelStyle.FILL_AND_OUTLINE, + text: place.name.toUpperCase(), + font: MARKER.labelFont, + style: Cesium.LabelStyle.FILL, 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], - ), + pixelOffset: new Cesium.Cartesian2(...MARKER.labelOffset), disableDepthTestDistance: Number.POSITIVE_INFINITY, + showBackground: true, + 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), }, }); @@ -118,66 +158,59 @@ export default function GlobeScene({ places }: GlobeSceneProps) { placeNameMap.set(entityId, place.name); } - viewer.camera.flyHome(APP_CONFIG.cesium.marker.flyHomeDuration); + // 초기 카메라 위치 + viewer.camera.setView({ + destination: Cesium.Cartesian3.fromDegrees( + GLOBE.initialView.lng, + GLOBE.initialView.lat, + GLOBE.initialView.altitude, + ), + orientation: { + heading: 0, + pitch: Cesium.Math.toRadians(-90), + roll: 0, + }, + }); + // 클릭 핸들러 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(); }; }, [places, router, setMode, setSelectedPlaceId]); return ( -
-
-
-

Markers: {markerSummary}

-

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

-
+
+
); } diff --git a/src/i18n/en.ts b/src/i18n/en.ts new file mode 100644 index 0000000..45bbf49 --- /dev/null +++ b/src/i18n/en.ts @@ -0,0 +1,112 @@ +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: "한국어로 변경" + }, + 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" + }, + 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" + }, + 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" + } +}; + +export type Dictionary = typeof en; diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts new file mode 100644 index 0000000..fb0fef5 --- /dev/null +++ b/src/i18n/ko.ts @@ -0,0 +1,112 @@ +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" + }, + loading: { + title: "공간 데이터 동기화 중...", + subtitle: "지형 및 메타데이터를 로드하고 있습니다.", + tooltips: [ + "WorMap의 보행자는 날씨와 시간에 영향을 받습니다.", + "차량의 트래픽 양은 시뮬레이션 설정에 따라 조절됩니다.", + "밤이 되면 도시의 네온사인 기능이 활성화됩니다.", + "워크뷰(Walk View) 모드에서 WASD 키로 이동할 수 있습니다." + ] + }, + explorer: { + engine: "WorMap 엔진", + title: "글로벌 탐색", + search: "좌표 또는 장소 검색...", + systemStatus: "시스템 상태", + online: "온라인", + travelTo: "{name} (으)로 이동", + coordinates: "좌표" + }, + scene: { + info: { + tag: "WorMap 라이브 씬", + unknown: "알 수 없는 장소", + discoveryMode: "탐색 모드", + status: "상태", + environment: "환경", + localTime: "현지 시간", + camera: "카메라", + overview: "오버뷰", + street: "스트리트" + }, + status: { + running: "실행 중", + paused: "일시 정지" + }, + weather: { + clear: "맑음", + 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/loading/LoadingScene.tsx b/src/scene/loading/LoadingScene.tsx new file mode 100644 index 0000000..90871c0 --- /dev/null +++ b/src/scene/loading/LoadingScene.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useProgress } from "@react-three/drei"; +import { useEffect, useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { useTranslation } from "@/src/stores/useI18nStore"; + +export default function LoadingScene() { + const { progress, active } = useProgress(); + const { t, tArray } = useTranslation(); + + const tooltips = tArray('loading.tooltips'); + 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/CameraController.tsx b/src/scene/place/CameraController.tsx index b7b51a7..53fbb4b 100644 --- a/src/scene/place/CameraController.tsx +++ b/src/scene/place/CameraController.tsx @@ -6,6 +6,15 @@ 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"; +import { + applyLookQuaternion as applyCameraLookQuaternion, + applyWalkMovementFrame, + computeCameraBounds, + type CameraBounds, +} from "./cameraMath"; +import { useCameraModeTransitions } from "./useCameraModeTransitions"; +import { useCameraInputHandlers } from "./useCameraInputHandlers"; type CameraControllerProps = { pkg: PlacePackage; @@ -17,18 +26,10 @@ type PlaceStoreState = { 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(); @@ -41,17 +42,40 @@ export default function CameraController({ pkg }: CameraControllerProps) { const isMouseDraggingRef = useRef(false); const yawRef = useRef(0); const pitchRef = useRef(0); - - const boundsRef = useRef({ min: -48, max: 48 }); + const touchPrevRef = useRef<{ x: number; y: number } | null>(null); + 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 pinchScalePrevRef = useRef(null); + + 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); - 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( + (deltaX: number, deltaY: number, multiplier = 1) => { + const lookSensitivity = PRESET_CONFIG[inputPreset].lookSensitivity * multiplier; + yawRef.current -= deltaX * lookSensitivity; + pitchRef.current -= deltaY * lookSensitivity; + pitchRef.current = THREE.MathUtils.clamp( + pitchRef.current, + -CAMERA_CONFIG.maxPitchRadians, + CAMERA_CONFIG.maxPitchRadians, + ); + applyLookQuaternion(); + }, + [applyLookQuaternion, inputPreset], + ); + 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]); @@ -64,77 +88,31 @@ export default function CameraController({ pkg }: CameraControllerProps) { }, [camera, pkg.walkStartPosition, applyLookQuaternion]); 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, - ); - boundsRef.current = { min: -maxAbs - 6, max: maxAbs + 6 }; + 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 === "v") { - setViewMode(viewMode === "top" ? "walk" : "top"); - } - - if (key === "escape" && 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; - - 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); + useCameraModeTransitions({ + viewMode, + switchToTop, + switchToWalk, + isWalkingRef, + }); - applyLookQuaternion(); - }; - - window.addEventListener("keydown", onKeyDown); - window.addEventListener("keyup", onKeyUp); - window.addEventListener("mousedown", onMouseDown); - window.addEventListener("mouseup", onMouseUp); - window.addEventListener("mousemove", onMouseMove); - - return () => { - window.removeEventListener("keydown", onKeyDown); - window.removeEventListener("keyup", onKeyUp); - window.removeEventListener("mousedown", onMouseDown); - window.removeEventListener("mouseup", onMouseUp); - window.removeEventListener("mousemove", onMouseMove); - }; - }, [viewMode, inputPreset, setViewMode, applyLookQuaternion]); + useCameraInputHandlers({ + viewMode, + inputPreset, + setViewMode, + camera, + cameraConfig: CAMERA_CONFIG, + cameraKeybind: CAMERA_KEYBIND, + boundsRef, + keysRef, + isMouseDraggingRef, + touchPrevRef, + pinchScalePrevRef, + zoomForwardRef, + applyLookDelta, + }); useFrame((_state, delta) => { if (!isWalkingRef.current) { @@ -143,46 +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 = new THREE.Vector3( - Number(keys.has("d")) - Number(keys.has("a")), - 0, - Number(keys.has("s")) - Number(keys.has("w")), - ); - - if (moveInput.lengthSq() > 0) { - moveInput.normalize().multiplyScalar(moveDistance); - - const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion); - forward.y = 0; - forward.normalize(); - - const right = new THREE.Vector3(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("e")) { - nextY += verticalDistance; - } - - if (keys.has("r")) { - 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(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/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/PerformanceSystem.tsx b/src/scene/place/PerformanceSystem.tsx new file mode 100644 index 0000000..c70e50c --- /dev/null +++ b/src/scene/place/PerformanceSystem.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useRef } from "react"; +import { useFrame } from "@react-three/fiber"; +import { APP_CONFIG } from "../../shared/config"; +import { usePerformanceStore } from "../../stores/performanceStore"; + +export default function PerformanceSystem() { + const setSnapshot = usePerformanceStore((s) => s.setSnapshot); + const elapsedMsRef = useRef(0); + const framesRef = useRef(0); + + useFrame((_state, delta) => { + const deltaMs = delta * 1000; + + elapsedMsRef.current += deltaMs; + framesRef.current += 1; + + if (elapsedMsRef.current < APP_CONFIG.scene.performance.sampleIntervalMs) { + return; + } + + const fps = Math.round((framesRef.current * 1000) / elapsedMsRef.current); + const frameTimeMs = elapsedMsRef.current / framesRef.current; + + setSnapshot({ + fps, + frameTimeMs, + sampledAt: Date.now(), + }); + + elapsedMsRef.current = 0; + framesRef.current = 0; + }); + + return null; +} diff --git a/src/scene/place/PlaceScene.tsx b/src/scene/place/PlaceScene.tsx index 5a9e882..0dd2259 100644 --- a/src/scene/place/PlaceScene.tsx +++ b/src/scene/place/PlaceScene.tsx @@ -7,7 +7,11 @@ import { usePlaceStore } from "../../stores/placeStore"; import PlaceSceneContent from "./PlaceSceneContent"; import PlaybackHUD from "../../components/hud/PlaybackHUD"; 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 = { slug: string; @@ -15,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]"; @@ -37,10 +41,13 @@ export default function PlaceScene({ slug }: PlaceSceneProps) { > + + +
); } diff --git a/src/scene/place/PlaceSceneContent.tsx b/src/scene/place/PlaceSceneContent.tsx index e5d522b..3060435 100644 --- a/src/scene/place/PlaceSceneContent.tsx +++ b/src/scene/place/PlaceSceneContent.tsx @@ -1,16 +1,12 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect } from "react"; import { usePlaceStore } from "../../stores/placeStore"; import { useAppStore } from "../../stores/appStore"; -import type { PlacePackage } from "../../data/placePackages"; import { usePlaybackStore } from "../../stores/playbackStore"; +import { selectIsNight } from "../../stores/selectors/playbackSelectors"; 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"; @@ -18,17 +14,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); @@ -38,116 +29,69 @@ 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 isNight = usePlaybackStore(selectIsNight); - 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; - }, [currentTime]); + const { data, isLoading, isError } = usePlaceBootstrap(slug); 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({ + 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 = normalizeHour(Math.floor(currentTime)); + + const { data: liveData } = useSceneLiveData({ slug, - bootstrap: sceneBootstrap, + bootstrap: data?.bootstrap ?? null, normalizedHour, - currentWeather, + 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 45bef2a..d883ad6 100644 --- a/src/scene/place/StaticEnvironmentFallback.tsx +++ b/src/scene/place/StaticEnvironmentFallback.tsx @@ -1,7 +1,9 @@ "use client"; +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 = { @@ -18,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 ( @@ -53,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 ( @@ -76,15 +78,27 @@ function NeonSigns({ pkg }: { pkg: PlacePackage }) { } export default function StaticEnvironmentFallback({ pkg }: StaticEnvironmentFallbackProps) { + const buildingNodes = useMemo( + () => + pkg.buildings.map((b) => ( + + )), + [pkg.buildings], + ); + + const roadNodes = useMemo( + () => + pkg.roads.map((r) => ( + + )), + [pkg.roads], + ); + return ( - {pkg.buildings.map((b) => ( - - ))} - {pkg.roads.map((r) => ( - - ))} + {buildingNodes} + {roadNodes} ); 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/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]); +} diff --git a/src/scene/place/usePlaceBootstrap.ts b/src/scene/place/usePlaceBootstrap.ts new file mode 100644 index 0000000..80dcf43 --- /dev/null +++ b/src/scene/place/usePlaceBootstrap.ts @@ -0,0 +1,61 @@ +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; + name: string; + lat: number; + lng: number; + city: string; + country: string; +}; + +export type PlaceBootstrapData = { + pkg: PlacePackage; + bootstrap: SceneBootstrap; + mapping: GeometryLiveMapping; + placeMeta: PlaceEntity; +}; + +/** slug → 표시 이름 변환 (fallback: slug를 제목형으로) */ +function toPlaceLabel(slug: string) { + return slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +/** MVP_PLACES에서 slug에 맞는 장소 메타데이터를 조회합니다. */ +function findPlaceMeta(slug: string) { + return MVP_PLACES.find((p) => p.slug === 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 b2e4d66..751950d 100644 --- a/src/scene/place/useSceneLiveData.ts +++ b/src/scene/place/useSceneLiveData.ts @@ -1,97 +1,49 @@ -import { useEffect } 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"); +} 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; - - useEffect(() => { - if (!bootstrap) { - return; - } - - 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 !== currentWeather) { - setWeather(weatherSnapshot.condition); - } - if (placeSnapshot.pedestrianDensity !== currentPedestrianLevel) { - setPedestrianLevel(placeSnapshot.pedestrianDensity); - } - if (placeSnapshot.vehicleDensity !== currentVehicleLevel) { - 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, - currentPedestrianLevel, - currentVehicleLevel, - currentWeather, - normalizedHour, - setPedestrianLevel, - setVehicleLevel, - setWeather, - slug, - ]); + const { slug, bootstrap, normalizedHour, currentPedestrianLevel } = input; + + return useQuery({ + queryKey: [ + "scene-live-data", + slug, + bootstrap?.geometryId, + normalizedHour, + currentPedestrianLevel, + ], + 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 1b2f1b9..9e3d9db 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: { @@ -22,14 +26,100 @@ 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, + topViewZoomRange: { + minZ: 32, + maxZ: 140, + }, + topViewWheelZoomMultiplier: 0.028, + maxPitchRadians: Math.PI * 0.46, + boundsFallbackMaxAbs: 48, + boundsPadding: 6, + walkVerticalClampOffset: { + min: -0.4, + max: 3.2, + }, + gesture: { + wheelLookMultiplier: 0.32, + touchLookMultiplier: 0.9, + wheelDominantAxisRatio: 1.2, + wheelZoomMultiplier: 0.012, + pinchZoomMultiplier: 0.02, + pinchGestureZoomMultiplier: 5, + }, + 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", + }, + }, + performance: { + sampleIntervalMs: 500, + warningFps: 45, + warningFrameTimeMs: 22, + }, }, 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: 4.0, + }, + rendering: { + maximumScreenSpaceError: 16.0, + shadowMapMaxDistance: 2000.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/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..1fdb4b3 --- /dev/null +++ b/src/shared/domains/time.ts @@ -0,0 +1,38 @@ +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"; +} + +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/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/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/performanceStore.ts b/src/stores/performanceStore.ts new file mode 100644 index 0000000..1aec781 --- /dev/null +++ b/src/stores/performanceStore.ts @@ -0,0 +1,23 @@ +import { create } from "zustand"; + +export type PerformanceSnapshot = { + fps: number; + frameTimeMs: number; + sampledAt: number; +}; + +type PerformanceStore = { + snapshot: PerformanceSnapshot; + setSnapshot: (snapshot: PerformanceSnapshot) => void; +}; + +const INITIAL_SNAPSHOT: PerformanceSnapshot = { + fps: 60, + frameTimeMs: 16.67, + sampledAt: 0, +}; + +export const usePerformanceStore = create((set) => ({ + snapshot: INITIAL_SNAPSHOT, + setSnapshot: (snapshot) => set({ snapshot }), +})); 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 66ed758..c9941a3 100644 --- a/src/stores/playbackStore.ts +++ b/src/stores/playbackStore.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import { normalizeHour } from "../shared/domains"; export type WeatherMode = "clear" | "cloudy" | "rain" | "snow"; export type TimeOfDay = "day" | "dusk" | "night"; @@ -24,24 +25,9 @@ type PlaybackStore = { vehicleLevel: VehicleLevel; setPedestrianLevel: (level: PedestrianLevel) => void; setVehicleLevel: (level: VehicleLevel) => void; - - getTimeOfDay: () => TimeOfDay; - 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) => ({ +export const usePlaybackStore = create((set) => ({ currentTime: 12, setCurrentTime: (time) => set({ currentTime: normalizeHour(time) }), @@ -60,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 hourToTimeOfDay(currentTime); - }, - isNight: () => { - const { currentTime } = get(); - return hourToTimeOfDay(currentTime) === "night"; - }, })); 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 }), +}); diff --git a/src/stores/useI18nStore.ts b/src/stores/useI18nStore.ts new file mode 100644 index 0000000..dcaad27 --- /dev/null +++ b/src/stores/useI18nStore.ts @@ -0,0 +1,53 @@ +import { create } from 'zustand'; +import { en } from '@/src/i18n/en'; +import { ko } from '@/src/i18n/ko'; + +export type Language = 'en' | 'ko'; + +interface I18nState { + lang: Language; + setLang: (lang: Language) => void; +} + +export const useI18nStore = create((set) => ({ + 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; + + /** 문자열 번역. 미경로일 경우 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, tArray, lang, setLang: useI18nStore.getState().setLang }; +}