fix(design): 종단 · 유토곡선 · 횡단 그리드 빈 곳 누름도 고름 안 풂 (233-4)

- 빈 곳 누름 해제 듣개 뗌 — 3D 와 같은 한 규칙
- 풀기는 Esc · 같은 것 다시 누름 한 길 · 도구 중 빈 곳 누름은 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
2026-10-10 19:18:19 +09:00
co-authored by Claude Opus 5.5
parent 0c0d7f7f7f
commit 78bab9eeda
5 changed files with 219 additions and 28 deletions
@@ -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();
// 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다.
+2 -14
View File
@@ -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 사용자 확정) — 스크롤할 때마다 곡선만 다시
+1 -4
View File
@@ -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",
@@ -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));
@@ -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