Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 53 additions & 9 deletions src/features/simulation/model/sim-domain-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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 단위) ────────────────────────────
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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 후크에서 트리거.
Expand Down
66 changes: 56 additions & 10 deletions src/features/simulation/model/use-sim-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<string>();
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,
Expand Down Expand Up @@ -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<string>();

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).
Expand Down Expand Up @@ -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();
}
Expand Down
Loading