fix(494-1 구조물 측점 횡단 형상): B06 화면 밖 카드 = 쉬는 틈 한도 · B05 횡단 판 = 들어올 때 고른 측점

- 원인 B06 = 구조물을 놓으면 새 측점 카드가 빈 설계로 먼저 서고 다시 셈 뒤 화면 밖 카드는 쉬는 틈 줄에서만 갈아 끼움 · 시간 제한이 없어 틈이 안 오는 창(ORCA 내장 · 가려진 창)에서는 설계선 · 옹벽 없는 카드가 그대로 남음(검증_sub1 옹벽 7+5 · 7+10 재현 · 다시 셈 값은 맞음)
- 고침 = createIdleQueue 쉬는 틈 한도 200 ms(늦어도 그 안에 그림)
- 원인 B05 = 「횡단」 판이 고름 바뀜만 들어 고른 구조물을 들고 들어오면 빈 판 · 다시 누르면 고름이 풀림
- 고침 = 판 만들 때 지금 고름을 한 번 따라감(판은 안 폄)
- 시험 test_494_1_idle_queue(쉬는 틈 없는 창 흉내 · 들어올 때 고름 · 고치기 전 빨강) · helper_411_a 에 readSelection 흉내 · 들어옴 장면

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 12:54:12 +09:00
co-authored by Claude Opus 5.5
parent 7f204e3ded
commit 7978579313
5 changed files with 145 additions and 6 deletions
+14 -5
View File
@@ -30,6 +30,7 @@ import type {
} from "../A00_Common/design/logic/design_pipeline_types";
import {
nearestWithin,
readSelection,
selectDesign,
subscribeSelection,
type DesignSelection,
@@ -316,8 +317,13 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
stopPick();
};
/** 그 측점 횡단을 위 판에 · 아래 판은 비움(전후 비교는 노선을 고쳤을 때만) · 접힌 판은 폄. */
function showStation(key: string | null, chainageM: number, structure = false): void {
/** 그 측점 횡단을 위 판에 · 아래 판은 비움(전후 비교는 노선을 고쳤을 때만) · 접힌 판은 폄(`open`). */
function showStation(
key: string | null,
chainageM: number,
structure = false,
open = true,
): void {
// 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은
// 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다.
previous.show(null, interval());
@@ -326,11 +332,11 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
shown = null;
redraw();
const panels = sidePanelsOf.get(params.side);
if (panels && panels.current() === null) panels.open("cross", false); // 열되 기억 안 함(393-1)
if (open && panels && panels.current() === null) panels.open("cross", false); // 열되 기억 안 함(393-1)
}
/** 다른 보기(종단 · 3D · 목록 · 횡단 카드 · 평면 마커) 고름 → 그 측점 횡단(구조물 · 관도 · 411-A). */
function follow(selection: DesignSelection): void {
function follow(selection: DesignSelection, open = true): void {
attach();
const at = selection.chainageM;
if (at === null) {
@@ -343,12 +349,15 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
}
const station = nearestWithin(stationsNow(), at, (entry) => entry.chainageM);
const structure = !!selection.structureId || (!!station && !isGridStation(station));
showStation(station?.key ?? null, station?.chainageM ?? at, structure);
showStation(station?.key ?? null, station?.chainageM ?? at, structure, open);
}
const stopPick = subscribeSelection(params.projectId, (selection, from) => {
if (from !== PLAN_PICK_FROM) follow(selection);
});
attach();
// 들어올 때 이미 고른 것 — 판에 그 측점 횡단(494-1 · 안 그리면 고른 채 빈 판 · 다시 누르면 고름이 풀림) · 판은 안 폄
const picked = readSelection(params.projectId);
if (picked) follow(picked, false);
/** 그 측점 발자국 기록(열쇠 · 없으면 누가거리) — 발자국 단계 전이면 없음. */
const footprintAt = (key: string | null, chainageM: number): FootprintRecord | undefined => {
+9 -1
View File
@@ -127,16 +127,24 @@ export function staleKey(
.join("|");
}
/** 화면 밖 카드 줄이 쉬는 틈을 기다리는 한도(ms). */
const IDLE_TIMEOUT_MS = 200;
/**
* 쉬는 틈 일거리 줄(346-2) — 화면 밖 횡단 카드를 몇 장씩 갈아 끼움(카드 하나 ≈ 8 ms · 0.54 s / 66).
* 줄에 든 측점은 `run` 이 꺼내 그림 · 통째로 다시 그리면 `queue.clear()`.
* 쉬는 틈이 안 오는 창(ORCA 내장 · 가려진 창)도 늦어도 `IDLE_TIMEOUT_MS` 에 그림 — 안 그러면 새 구조물 측점
* 카드가 빈 설계 그대로 남음(494-1).
*/
export function createIdleQueue(run: (chainageM: number) => void, batch = 6) {
const queue = new Set<number>();
let waiting = 0;
const drain = (): void => {
if (waiting || !queue.size) return;
const idle = window.requestIdleCallback ?? ((next: () => void) => window.setTimeout(next, 16));
const idle = (next: () => void): number =>
window.requestIdleCallback
? window.requestIdleCallback(next, { timeout: IDLE_TIMEOUT_MS })
: window.setTimeout(next, 16);
waiting = idle(() => {
waiting = 0;
for (const chainageM of [...queue].slice(0, batch)) {
@@ -36,6 +36,8 @@ global.ResizeObserver = class {
};
let listener = null;
/** 들어올 때 이미 고른 것(`readSelection` · 494-1). */
let stored = null;
let stations = [];
const route = { id: 1 };
const subscribers = [];
@@ -57,6 +59,7 @@ const STUBS = {
if (Math.abs(of(item) - at) < gap) [best, gap] = [item, Math.abs(of(item) - at)];
return best;
},
readSelection: () => stored,
selectDesign() {},
subscribeSelection: (_projectId, fn) => ((listener = fn), () => {}),
},
@@ -157,4 +160,19 @@ out.latchedMoved = shownNow();
// ⑦ 다른 보기가 풂(null · b05) = 판 비움(382-2 그대로)
pick(null, null, "b05");
out.otherRelease = { ...shownNow(), watched: preview.watched() };
// ⑧ 고른 구조물을 들고 들어옴(494-1) — 판을 만들자마자 그 측점 횡단(빈 판 아님)
stored = { chainageM: 45, structureId: "A" };
stations = [...grid, station("structure:A", 45, "structure", "A")];
const entrySide = fakeElement();
const entry = cross.createCrossPreview({
projectId: "p1",
side: entrySide,
request: () => ({ vertices: [], min_radius_m: 15, station_interval_m: 20 }),
});
const entryPane = entrySide.children[0];
out.entryPick = {
station: entryPane.querySelector(".b05-routeedit__cross-station").textContent,
foot: entryPane.querySelector(".b05-routeedit__cross-foot").textContent,
structure: entry.watched()?.structure ?? null,
};
console.log(JSON.stringify(out));
@@ -0,0 +1,51 @@
/* PLAN 494-1 단위검증 헬퍼 — 화면 밖 횡단 카드 줄(`createIdleQueue`)이 쉬는 틈이 안 와도 그려지는가.
* `B06_Section_UI_Section_Common.ts` 에서 그 함수만 꺼내 트랜스파일해 Node 로 돌린다(다른 import 안 끎).
* 가짜 창 = 쉬는 틈이 영영 안 오는 브라우저(ORCA 내장 · 가려진 창) — 시간 제한을 준 부탁만 그 시간에 불림.
* 결과는 표준출력에 JSON 한 벌.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(__dirname, "..", "..", "B06_Section", "B06_Section_UI_Section_Common.ts");
const src = fs.readFileSync(file, "utf-8").split("\r\n").join("\n");
const body = src.match(/export function createIdleQueue[\s\S]*?\n}\n/);
if (!body) throw new Error("createIdleQueue 못 찾음");
// 함수가 쓰는 파일 머리 상수(대문자 이름 한 줄)
const consts = [...src.matchAll(/^const [A-Z_]+ = [^;\n]+;$/gm)].map((m) => m[0]).join("\n");
const js = ts.transpileModule(`${consts}\n${body[0]}`, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
let now = 0;
const timers = []; // { at, fn }
const window = {
requestIdleCallback: (fn, options) => {
// 쉬는 틈 없음 — 시간 제한이 있을 때만 그 시간에 부름(didTimeout)
if (options && Number.isFinite(options.timeout))
timers.push({
at: now + options.timeout,
fn: () => fn({ didTimeout: true, timeRemaining: () => 0 }),
});
return timers.length + 1;
},
setTimeout: (fn, ms) => (timers.push({ at: now + (ms || 0), fn }), timers.length + 1),
};
const mod = { exports: {} };
new Function("module", "exports", "window", js)(mod, mod.exports, window);
const { createIdleQueue } = mod.exports;
const ran = [];
const q = createIdleQueue((m) => ran.push(m), 2);
[145, 150, 155, 160, 165].forEach((m) => q.queue.add(m));
q.drain();
// 2 초 흐름 — 그 사이 불릴 것 모두 부름
for (;;) {
timers.sort((a, b) => a.at - b.at);
const next = timers[0];
if (!next || next.at > 2000) break;
timers.shift();
now = next.at;
next.fn();
}
process.stdout.write(JSON.stringify({ ran, left: [...q.queue], elapsedMs: now }));
+53
View File
@@ -0,0 +1,53 @@
"""PLAN 494-1 — B06 화면 밖 횡단 카드가 쉬는 틈이 안 와도 다시 그려지는가.
구조물을 놓으면 새 측점 카드가 빈 설계로 먼저 서고, 다시 셈 뒤 화면 밖 카드는 쉬는 틈 줄
(`createIdleQueue`)에서 갈아 끼움. 쉬는 틈이 안 오는 창(ORCA 내장 · 가려진 창)에서는 줄이 영영 안
비어 설계선 · 구조물 형상이 없는 카드가 남았음. 짝: `B06_Section/B06_Section_UI_Section_Common.ts`.
실제 TS 를 `helper_494_idle_queue.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
HERE = os.path.dirname(__file__)
def _run() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_494_idle_queue.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_쉬는_틈이_없어도_줄의_카드를_모두_그린다() -> None:
out = _run()
assert out["ran"] == [145, 150, 155, 160, 165]
assert out["left"] == []
def test_늦어도_1초_안에_다_그린다() -> None:
"""카드 다섯 · 두 장씩 = 세 번 — 시간 제한 안에 끝남."""
assert _run()["elapsedMs"] <= 1000
def test_B05_평면_횡단판은_들어올_때_이미_고른_측점을_그린다() -> None:
"""B05 「횡단」 판 — 고른 구조물을 들고 들어오면 빈 판이었음(다시 누르면 고름이 풀림).
`_RouteEdit_Cross` 진짜를 `helper_411_a_cross_structure.cjs` 가 돌림(⑧)."""
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_411_a_cross_structure.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
got = json.loads(proc.stdout)["entryPick"]
assert got == {"station": "45", "foot": "횡단 45", "structure": True}