diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 2ffa158bc..0600b48e1 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -135,8 +135,6 @@ export interface RouteMassHaulDrawParams { widthPx: number; selectedStationId: string | null; onSelectStation: (stationId: string) => void; - /** 측점선이 아닌 빈 곳 클릭 — 선택 해제(2026-08-04 사용자 지시). */ - onClearSelection?: () => void; /** * 저장된 횡단이 지금 계획선과 어긋나 재계산이 예약된 상태. 그 사이의 면적은 **옛 * 계획고로 만든 값**이라 그리면 사용자가 옛 그림을 보게 된다(2026-09-03 사용자 확정: @@ -250,13 +248,6 @@ export function createRouteMassHaulPanel( pendingChip.hidden = true; overlay.append(resizer.root, bar, scroll, legendLayer, pendingChip); - // 측점선이 아닌 빈 곳 클릭 = 선택 해제 — 종단 그래프와 같은 규칙(2026-08-04 사용자 지시). - let clearSelection: (() => void) | undefined; - scroll.addEventListener("click", (event) => { - if ((event.target as HTMLElement).closest(".b06-chart__station")) return; - clearSelection?.(); - }); - // 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시). // scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로. attachWheelHorizontalScroll(scroll); @@ -368,7 +359,6 @@ export function createRouteMassHaulPanel( function draw(params: RouteMassHaulDrawParams): void { lastParams = params; - clearSelection = params.onClearSelection; overlay.hidden = !open; syncHandlePosition(); // 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다. diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index af5042efd..b940a155a 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -199,8 +199,8 @@ export function renderProfile(ctx: ProfileRenderContext): void { const chartWrap = document.createElement("div"); chartWrap.className = "b05-profile__chart"; chartWrap.style.height = `${chartHeight}px`; - // 측점선이 아닌 빈 곳을 누르면 선택 해제(2026-08-04 사용자 지시). - // 측점 마커·편집 버튼 클릭은 각자 처리하므로 여기까지 안 온다(closest·stopPropagation). + // 빈 곳 누름은 고름을 안 풂 — 풀기는 Esc · 같은 것 다시 누름 한 길(3D 와 같은 규칙 · 233-4). + // 도구([직선화] · [쉬프트] · [자유지정]) 중 빈 곳 누름은 그 도구 몫 그대로. chartWrap.addEventListener("click", (event) => { // [직선화]·[쉬프트] 모드에서는 그래프 클릭이 도구 선택으로 간다 — 측점선을 눌렀으면 // 그 측점, 빈 곳이면 그 x의 누가거리로 직선 구간을 고른다(2026-09-02). @@ -219,15 +219,6 @@ export function renderProfile(ctx: ProfileRenderContext): void { if (ctx.handleToolPick(Number.isFinite(chainage) ? chainage : null)) return; } } - if ((event.target as HTMLElement).closest(".b06-chart__station")) return; - if (ctx.selectedStationId() !== null) ctx.selectStation(null); - // 구조물 알약 선택도 함께 푼다 — 빈 공간 클릭 시 사이드 폼(구조물군·종류)까지 - // 리셋되는 해제 신호가 여기서만 나갈 수 있다(2026-08-18 사용자 보고). - if (ctx.selectedStructureId() !== null) { - ctx.setSelectedStructureId(null); - callbacks?.onStructureSelect?.(null); - ctx.redraw(); - } }); const designProfiles = alignment ? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])] @@ -454,9 +445,6 @@ export function renderProfile(ctx: ProfileRenderContext): void { widthPx: width, selectedStationId, onSelectStation: ctx.selectStation, - onClearSelection: () => { - if (ctx.selectedStationId() !== null) ctx.selectStation(null); - }, }; massHaul.draw(massHaulParams); // 유토곡선도 **같은 창**을 본다(2026-09-04 사용자 확정) — 스크롤할 때마다 곡선만 다시 diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index bc95835f8..88f3a9e64 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -418,10 +418,7 @@ export function createSectionView( // 적혀 있어 무엇을 보는지는 이미 분명하다. const grid = document.createElement("div"); grid.className = "b06-section__grid"; - // 카드 사이 빈 자리를 누르면 선택 해제 — 카드 안 클릭은 카드가 먼저 처리한다. - grid.addEventListener("click", (event) => { - if (!(event.target as HTMLElement).closest(".b06-cross-card")) clearSelection(); - }); + // 카드 사이 · 밖 빈 자리 누름은 고름을 안 풂 — 풀기는 Esc · 같은 카드 다시 누름(233-4) // 노면공 형상 누름 = 목록 고름(범위 카드 전부 · 형상 강조) — 카드 고름보다 먼저 가로챔(189-1) grid.addEventListener( "click", diff --git a/resources/tester/helper_233_4_empty_click.cjs b/resources/tester/helper_233_4_empty_click.cjs new file mode 100644 index 000000000..b474e6ed5 --- /dev/null +++ b/resources/tester/helper_233_4_empty_click.cjs @@ -0,0 +1,144 @@ +/* 233-4 빈 곳 누름 = 어디서도 안 풂 — 진짜 소스의 누름 처리 본문을 떼어(TS 그 자리 트랜스파일) 돌림. + * ① 종단 그래프 빈 곳 누름(`_Profile_Render` chartWrap click) ② 횡단 그리드 빈 곳 누름(`_Section_View` grid click) + * ③ 같은 것 다시 누름 = 풂(종단 측점선 `design_profile` selectStation · 횡단 카드 `_Section_View` selectStation) + * ④ Esc = 풂(`design_pick_escape` + `design_selection`). 표준출력 JSON 한 벌 · 짝 = test_233_4_empty_click.py. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +const read = (...parts) => fs.readFileSync(path.join(ROOT, ...parts), "utf8").replace(/\r/g, ""); +const run = (code, names, values) => { + const js = ts.transpileModule(code, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + return new Function(...names, js)(...values); +}; +/** `start` 줄부터 같은 들여쓰기로 닫는 `end` 줄까지 떼어 냄. */ +const block = (src, start, end) => { + const from = src.indexOf(start); + if (from < 0) throw new Error(`본문 없음: ${start}`); + const to = src.indexOf(end, from); + return src.slice(from, to + end.length); +}; + +const out = {}; +const target = (inStation = false, inCard = false) => ({ + closest: (sel) => + (sel.includes("b06-chart__station") && inStation) || (sel.includes("b06-cross-card") && inCard) + ? {} + : null, +}); + +/* ① 종단 그래프 빈 곳 누름 */ +const render = read("B05_Profile", "B05_Profile_UI_Profile_Render.ts"); +const chartClick = block(render, ` chartWrap.addEventListener("click", (event) => {`, "\n });"); +const calls = []; +let tool = false; +const ctx = { + toolActive: () => tool, + handleToolPick: (at) => (calls.push(`tool:${at}`), true), + selectedStationId: () => "s120", + selectedStructureId: () => "wall", + selectStation: (id) => calls.push(`station:${id}`), + setSelectedStructureId: (id) => calls.push(`structure:${id}`), + redraw: () => calls.push("redraw"), +}; +const callbacks = { onStructureSelect: (id) => calls.push(`onStructureSelect:${id}`) }; +let handler = null; +const chartWrap = { + addEventListener: (_type, fn) => (handler = fn), + getBoundingClientRect: () => ({ left: 0 }), + scrollLeft: 0, +}; +run( + chartClick, + ["ctx", "callbacks", "chartWrap", "chainageInverter", "longitudinal", "width", "layout"], + [ctx, callbacks, chartWrap, () => (x) => x, null, 100, { originOffset: 0 }], +); +handler({ target: target(), clientX: 42 }); +out.profileEmpty = calls.splice(0); +tool = true; // 도구 중 빈 곳 누름은 도구 몫 그대로 +handler({ target: target(), clientX: 42 }); +out.profileEmptyTool = calls.splice(0); + +/* ② 횡단 그리드 빈 곳 누름 — 그리드에 걸린 누름 듣개를 모두 돌림 */ +const view = read("B06_Section", "B06_Section_UI_Section_View.ts"); +const gridStart = view.indexOf(' grid.className = "b06-section__grid";'); +const gridCode = view.slice(gridStart, view.indexOf(" // 열 수 = 사용자 값", gridStart)); // 그리드 누름 듣개 전부 +const gridCalls = []; +const grid = { listeners: [], addEventListener: (_t, fn) => grid.listeners.push(fn) }; +run( + gridCode.replace(' grid.className = "b06-section__grid";', ""), + ["grid", "clearSelection", "pavementAt", "spanMarks", "isPavement", "pavementSelectListener"], + [grid, () => gridCalls.push("clearSelection"), () => null, [], () => false, null], +); +for (const fn of grid.listeners) + fn({ target: target(), stopPropagation() {}, stopImmediatePropagation() {} }); +out.gridListeners = grid.listeners.length; +out.gridEmpty = gridCalls.splice(0); + +/* ③ 같은 것 다시 누름 = 풂 */ +const dpSrc = read("A00_Common", "design", "design_profile.ts"); +const dpSelect = block( + dpSrc, + " function selectStation(stationId: string | null): void {", + "\n }", +); +const profileState = run( + `let selectedStationId = null; let selectedStructureId = null; + ${dpSelect} + return { selectStation, line: () => selectedStationId };`, + ["structureIdAtStation", "draw", "onSelectStation"], + [() => null, () => undefined, () => undefined], +); +profileState.selectStation("s120"); +out.profileFirst = profileState.line(); +profileState.selectStation("s120"); +out.profileAgain = profileState.line(); + +const svSelect = block( + view, + " const selectStation = (stationId: string, scroll: boolean): void => {", + "\n };", +); +const cardState = run( + `let selectedStationId = null; let activeAreaKey = null; + ${svSelect} + return { selectStation, card: () => selectedStationId };`, + ["applySelection", "revealCard"], + [() => undefined, () => undefined], +); +cardState.selectStation("s120", true); +out.cardFirst = cardState.card(); +cardState.selectStation("s120", true); +out.cardAgain = cardState.card(); + +/* ④ Esc = 풂 */ +const store = new Map(); +const loadTs = (file, stubs) => { + const box = { exports: {} }; + const js = ts.transpileModule(read(...file.split("/")), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + new Function("exports", "module", "require", js)(box.exports, box, (r) => stubs[r]); + return box.exports; +}; +const DSel = loadTs("A00_Common/design/design_selection.ts", { + "../b_page_state": { + readState: (k, p) => store.get(`${k}:${p}`) ?? null, + writeState: (k, v, p) => store.set(`${k}:${p}`, v), + }, +}); +let onKey = null; +globalThis.window = { addEventListener: (_t, fn) => (onKey = fn), removeEventListener() {} }; +globalThis.document = { querySelector: () => null }; +const ESC = loadTs("A00_Common/design/design_pick_escape.ts", { "./design_selection": DSel }); +ESC.bindEscapeRelease("p", { visible: () => true, onDispose() {} }); +DSel.selectDesign("p", { chainageM: 120, structureId: null }, { from: "b06" }); +out.beforeEsc = DSel.readSelection("p"); +onKey({ key: "Escape", defaultPrevented: false, target: { closest: () => null } }); +out.afterEsc = DSel.readSelection("p"); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_233_4_empty_click.py b/resources/tester/test_233_4_empty_click.py new file mode 100644 index 000000000..5c224ef55 --- /dev/null +++ b/resources/tester/test_233_4_empty_click.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +"""233-4 빈 곳 누름 = 어디서도 안 풂 — 3D(232-1)와 같은 한 규칙. + +까닭: 종단 그래프 · 유토곡선 빈 곳 누름(08-04)과 횡단 그리드(카드 사이 · 밖) 빈 곳 누름(08-02)은 고름을 풀었고 +3D 빈 곳 누름(232-1)만 안 풀어 화면마다 「해제」 뜻이 달랐음(조사 AF 5절 5). +고침: 빈 곳 누름 듣개를 뗌 · 풀기는 Esc(`design_pick_escape`) 또는 같은 것 다시 누름 한 길 · +도구([직선화] · [쉬프트] · [자유지정]) 중 빈 곳 누름은 도구 몫 그대로. +진짜 소스의 누름 처리 본문을 `helper_233_4_empty_click.cjs` 가 떼어 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle: + return handle.read() + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_233_4_empty_click.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_종단_빈_곳_누름은_고름_유지(out) -> None: + assert out["profileEmpty"] == [] # 옛 코드 = 측점 · 알약 · 폼 풂 + + +def test_종단_도구_중_빈_곳_누름은_도구_몫_그대로(out) -> None: + assert out["profileEmptyTool"] == ["tool:42"] + + +def test_횡단_그리드_빈_곳_누름은_고름_유지(out) -> None: + assert out["gridEmpty"] == [] # 옛 코드 = clearSelection + assert out["gridListeners"] == 1 # 노면공 형상 누름만 남음(189-1) + + +def test_같은_것_다시_누름은_풂(out) -> None: + assert (out["profileFirst"], out["profileAgain"]) == ("s120", None) + assert (out["cardFirst"], out["cardAgain"]) == ("s120", None) + + +def test_Esc는_풂(out) -> None: + assert out["beforeEsc"] == {"chainageM": 120, "structureId": None} + assert out["afterEsc"] is None + + +def test_유토곡선_빈_곳_누름도_안_풂() -> None: + mass = _read("B05_Profile", "B05_Profile_UI_Profile_MassHaul.ts") + render = _read("B05_Profile", "B05_Profile_UI_Profile_Render.ts") + assert "onClearSelection" not in mass and "onClearSelection" not in render