From a612100cfbb37c2df96531387af56718fe9635e8 Mon Sep 17 00:00:00 2001 From: seoJing Date: Thu, 11 Jun 2026 19:47:51 +0900 Subject: [PATCH] fix: preview next simulation loop before boundary --- .../simulation/model/sim-domain-adapter.ts | 62 ++++++++++++++--- src/features/simulation/model/use-sim-run.ts | 66 ++++++++++++++++--- 2 files changed, 109 insertions(+), 19 deletions(-) diff --git a/src/features/simulation/model/sim-domain-adapter.ts b/src/features/simulation/model/sim-domain-adapter.ts index 8429317..293c336 100644 --- a/src/features/simulation/model/sim-domain-adapter.ts +++ b/src/features/simulation/model/sim-domain-adapter.ts @@ -231,8 +231,9 @@ function shiftSeedForCycle( seed: SpotLifecycleSeed, cycle: number, loopPeriod: number, + cycleOffsetTicks = 0, ): SpotLifecycleSeed { - const offset = cycle * loopPeriod; + const offset = cycle * loopPeriod + cycleOffsetTicks; return { ...seed, spotId: renderId(cycle, seed.spotId), @@ -249,17 +250,51 @@ function shiftSeedForCycle( }; } +function previewOverlapTicks(manifest: SimManifest): number { + const declaredTail = manifest.projection_tail_ticks ?? 0; + if (declaredTail <= 0) return 0; + return Math.min(MAX_PREVIEW_OVERLAP_TICKS, declaredTail); +} + +function shouldPreviewNextCycle( + manifest: SimManifest, + currentTick: number, +): boolean { + const previewTicks = previewOverlapTicks(manifest); + if (previewTicks <= 0) return false; + return currentTick >= loopPeriodTicks(manifest) - previewTicks; +} + +function renderCyclesForTick( + manifest: SimManifest, + currentCycle: number, + currentTick: number, +): number[] { + const cycles = [Math.max(0, currentCycle - 1), currentCycle]; + if (shouldPreviewNextCycle(manifest, currentTick)) { + cycles.push(currentCycle + 1); + } + return [...new Set(cycles)]; +} + function loopedSpotLifecycleSeeds( manifest: SimManifest, baseSeeds: SpotLifecycleSeed[], currentCycle: number, + currentTick: number, ): SpotLifecycleSeed[] { const loopPeriod = loopPeriodTicks(manifest); - const cycles = [...new Set([Math.max(0, currentCycle - 1), currentCycle])]; + const cycles = renderCyclesForTick(manifest, currentCycle, currentTick); + const previewTicks = previewOverlapTicks(manifest); + const previewNext = shouldPreviewNextCycle(manifest, currentTick); const loopSeeds = baseSeeds.filter((seed) => seed.createdTick < loopPeriod); - return cycles.flatMap((cycle) => - loopSeeds.map((seed) => shiftSeedForCycle(seed, cycle, loopPeriod)), - ); + return cycles.flatMap((cycle) => { + const offset = + previewNext && cycle === currentCycle + 1 ? -previewTicks : 0; + return loopSeeds.map((seed) => + shiftSeedForCycle(seed, cycle, loopPeriod, offset), + ); + }); } // ─── seed (tick 단위) → SpotLifecycle (ms 단위) ──────────────────────────── @@ -306,6 +341,7 @@ export type SimDomainResult = { const ARRIVAL_THRESHOLD_DEG = 0.0005; // ~55m const BIRTH_PULSE_TICKS = 1.2; const DEATH_GRACE_TICKS = 0.8; +const MAX_PREVIEW_OVERLAP_TICKS = 24; type UseSimDomainOptions = { manifest: SimManifest | null; @@ -349,8 +385,11 @@ export function useSimDomain(options: UseSimDomainOptions): SimDomainResult { } = options; const renderCycles = useMemo( - () => [...new Set([Math.max(0, currentCycle - 1), currentCycle])], - [currentCycle], + () => + manifest + ? renderCyclesForTick(manifest, currentCycle, currentTick) + : [], + [manifest, currentCycle, currentTick], ); const personas = useMemo( @@ -367,10 +406,15 @@ export function useSimDomain(options: UseSimDomainOptions): SimDomainResult { bufferedLifecycleEventsRef.current, bufferedMovementsRef.current, ); - return loopedSpotLifecycleSeeds(manifest, baseSeeds, currentCycle); + return loopedSpotLifecycleSeeds( + manifest, + baseSeeds, + currentCycle, + currentTick, + ); // refs 자체는 안정적이므로 새 청크 도착 신호인 bufferedChunkVersion 으로 재계산한다. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [manifest, isReady, bufferedChunkVersion, currentCycle]); + }, [manifest, isReady, bufferedChunkVersion, currentCycle, currentTick]); // currentTick + positionsRef 기반 cluster/lifecycle 빌드. // 매 emit 마다 호출되도록 subscribe 후크에서 트리거. diff --git a/src/features/simulation/model/use-sim-run.ts b/src/features/simulation/model/use-sim-run.ts index 2f00882..c570546 100644 --- a/src/features/simulation/model/use-sim-run.ts +++ b/src/features/simulation/model/use-sim-run.ts @@ -101,6 +101,12 @@ const DEFAULT_PREFETCH_AHEAD = 6; const DEFAULT_DWELL_JITTER_M = 20; const DEFAULT_SPAWN_SCATTER_M = 180; const MIN_EMPTY_SKIP_TICKS = 3; +const MAX_PREVIEW_OVERLAP_TICKS = 24; + +type RenderCycleTick = { + cycle: number; + rawTick: number; +}; function loopPeriodTicks(manifest: SimManifest): number { return Math.max(1, manifest.loop_period_ticks ?? manifest.total_ticks); @@ -128,6 +134,45 @@ function tickInCycle(tFloat: number, period: number): number { return ((tFloat % period) + period) % period; } +function previewOverlapTicks(manifest: SimManifest): number { + const declaredTail = manifest.projection_tail_ticks ?? 0; + if (declaredTail <= 0) return 0; + return Math.min(MAX_PREVIEW_OVERLAP_TICKS, declaredTail); +} + +function renderCycleTicks( + tFloat: number, + manifest: SimManifest, +): RenderCycleTick[] { + const loopPeriod = loopPeriodTicks(manifest); + const dataWindow = dataWindowTicks(manifest); + const currentCycle = cycleForTick(tFloat, loopPeriod); + const ticks: RenderCycleTick[] = []; + + for (const cycle of [Math.max(0, currentCycle - 1), currentCycle]) { + const rawTick = tFloat - cycle * loopPeriod; + if (rawTick >= 0 && rawTick < dataWindow) { + ticks.push({ cycle, rawTick }); + } + } + + const previewTicks = previewOverlapTicks(manifest); + const rawInCycle = tickInCycle(tFloat, loopPeriod); + const previewStart = loopPeriod - previewTicks; + if (previewTicks > 0 && rawInCycle >= previewStart) { + const nextRawTick = rawInCycle - previewStart; + ticks.push({ cycle: currentCycle + 1, rawTick: nextRawTick }); + } + + const seen = new Set(); + return ticks.filter((tick) => { + const key = `${tick.cycle}:${Math.floor(tick.rawTick * 1000)}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + export function useSimRun(options: UseSimRunOptions = {}): UseSimRunResult { const { runId = DEMO_RUN_ID, @@ -423,19 +468,15 @@ export function useSimRun(options: UseSimRunOptions = {}): UseSimRunResult { const next = positionsRef.current; const m = manifest; const loopPeriod = m ? loopPeriodTicks(m) : Number.POSITIVE_INFINITY; - const dataWindow = m ? dataWindowTicks(m) : loopPeriod; const currentCycleValue = Number.isFinite(loopPeriod) ? cycleForTick(tFloat, loopPeriod) : 0; - const cycleTicks = Number.isFinite(loopPeriod) - ? [Math.max(0, currentCycleValue - 1), currentCycleValue] - : [0]; + const cycleTicks = m + ? renderCycleTicks(tFloat, m) + : [{ cycle: 0, rawTick: tFloat }]; const activeKeys = new Set(); - for (const cycle of cycleTicks) { - const rawTick = tFloat - cycle * loopPeriod; - if (rawTick < 0 || rawTick >= dataWindow) continue; - + for (const { cycle, rawTick } of cycleTicks) { for (const a of agents) { if (a.agent_role === 'background') { // 서버 movement 가 없는 background 는 시각화에서 제외(어댑터에서 hide). @@ -509,8 +550,13 @@ export function useSimRun(options: UseSimRunOptions = {}): UseSimRunResult { lastEmittedTickRef.current = tickInt; setCurrentTick(tickInt); setCurrentCycle(currentCycleValue); - const lc = lifecycleByTickRef.current.get(tickInt) ?? []; - setCurrentLifecycleEvents(lc); + const lifecycleTicks = [ + ...new Set(cycleTicks.map((tick) => Math.floor(tick.rawTick))), + ]; + const lifecycleEvents = lifecycleTicks.flatMap( + (tick) => lifecycleByTickRef.current.get(tick) ?? [], + ); + setCurrentLifecycleEvents(lifecycleEvents); } notify(); }