diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index 177be2ab0..e53421e4c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -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 => { diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index d85a2ee28..d476c03cf 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -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(); 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)) { diff --git a/resources/tester/helper_411_a_cross_structure.cjs b/resources/tester/helper_411_a_cross_structure.cjs index d5a6c4f98..224a624ce 100644 --- a/resources/tester/helper_411_a_cross_structure.cjs +++ b/resources/tester/helper_411_a_cross_structure.cjs @@ -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)); diff --git a/resources/tester/helper_494_idle_queue.cjs b/resources/tester/helper_494_idle_queue.cjs new file mode 100644 index 000000000..da3f79094 --- /dev/null +++ b/resources/tester/helper_494_idle_queue.cjs @@ -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 })); diff --git a/resources/tester/test_494_1_idle_queue.py b/resources/tester/test_494_1_idle_queue.py new file mode 100644 index 000000000..d67a800cc --- /dev/null +++ b/resources/tester/test_494_1_idle_queue.py @@ -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}