From d35b9b74d459d770640585d1a91e852bd0c14cc7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 20 Oct 2026 19:42:49 +0900 Subject: [PATCH] =?UTF-8?q?fix(500-1=20=EA=B3=A1=EC=84=A0=20=EB=88=84?= =?UTF-8?q?=EB=A6=84=20=EA=B3=A0=EB=A6=84=20=EC=9C=A0=EC=A7=80):=20B05=20?= =?UTF-8?q?=ED=8F=89=EB=A9=B4=20=EA=B5=90=EA=B0=81=EC=A0=90=20=C2=B7=20?= =?UTF-8?q?=EC=86=90=EC=9E=A1=EC=9D=B4=20=C2=B7=20=ED=98=B8=20=EB=88=84?= =?UTF-8?q?=EB=A6=84=20=3D=20=EC=B8=A1=EC=A0=90=20=EA=B3=A0=EB=A6=84=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80=20=C2=B7=20=ED=92=80=EB=A6=BC=20=3D=20?= =?UTF-8?q?=EC=98=A4=EB=A5=B8=EC=AA=BD=20=ED=8C=90=EB=8F=84=20=EB=B9=84?= =?UTF-8?q?=EC=9B=80=20=C2=B7=20=EC=9E=84=EC=8B=9C=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EB=9D=A0=20=C2=B112=20px=20=C2=B7=20=EC=B0=BD=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=EC=A0=9C=EB=AA=A9=20=EC=A4=84=20=C2=B7=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20=EC=A4=84=20=EB=81=8C=EC=96=B4=20=EC=98=AE=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../B05_Profile_UI_Profile_CrossModal.css | 10 ++- .../B05_Profile_UI_Profile_CrossModal.ts | 58 ++++++++++++-- B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts | 3 +- .../B05_Profile_UI_RouteEdit_Pointer.ts | 7 +- ...B05_Profile_UI_RouteEdit_StructureMarks.ts | 4 +- .../logic/B05_Profile_Profile_CrossPick.ts | 2 +- .../tester/helper_411_a_cross_structure.cjs | 2 +- .../tester/test_409_mark_top_deselect.py | 5 +- .../tester/test_411_a_cross_structure.py | 8 +- resources/tester/test_490_1_cross_pick.py | 12 +-- .../tester/test_500_1_curve_keeps_pick.py | 80 +++++++++++++++++++ 11 files changed, 168 insertions(+), 23 deletions(-) create mode 100644 resources/tester/test_500_1_curve_keeps_pick.py diff --git a/B05_Profile/B05_Profile_UI_Profile_CrossModal.css b/B05_Profile/B05_Profile_UI_Profile_CrossModal.css index b78246e7c..471b4f913 100644 --- a/B05_Profile/B05_Profile_UI_Profile_CrossModal.css +++ b/B05_Profile/B05_Profile_UI_Profile_CrossModal.css @@ -22,8 +22,12 @@ .b05-crossmodal__head { display: flex; - align-items: baseline; + flex-wrap: wrap; + align-items: center; gap: var(--spacing-8, 8px); + cursor: move; + touch-action: none; + user-select: none; } .b05-crossmodal__station { @@ -45,10 +49,12 @@ line-height: 1.5; } +/* 단추 = 제목 줄 안 오른쪽 맞춤(500-1) */ .b05-crossmodal__actions { display: flex; - justify-content: flex-end; gap: var(--spacing-8, 8px); + margin-left: auto; + cursor: default; } /* 임시 횡단이 열리는 자리 가까이 표시(490-1) — 누름은 아래 그래프 몫(통과) · 세로 축(5) 밑 */ diff --git a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts index b89a16345..2e40dfdd2 100644 --- a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts +++ b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts @@ -130,6 +130,50 @@ export function mountCrossHint( chart.addEventListener("pointerleave", hide); } +/** 제목 줄 끌어 창 옮김(500-1) — 종단 칸 · 화면 안으로 가둠 · 단추 누름은 단추 몫 · 돌려줌 = 끌어 둔 자리 다시 가둠. */ +function bindHeadDrag(root: HTMLElement, head: HTMLElement): () => void { + let start: { id: number; x: number; y: number; left: number; top: number } | null = null; + const put = (left: number, top: number): void => { + const box = root.parentElement!.getBoundingClientRect(); + const now = root.getBoundingClientRect(); + const [dx, dy] = [left - root.offsetLeft, top - root.offsetTop]; + const [minX, maxX] = [ + Math.max(box.left, 0) - now.left, + Math.min(box.right, innerWidth) - now.right, + ]; + const [minY, maxY] = [ + Math.max(box.top, 0) - now.top, + Math.min(box.bottom, innerHeight) - now.bottom, + ]; + root.style.left = `${root.offsetLeft + Math.max(minX, Math.min(dx, maxX))}px`; + root.style.top = `${root.offsetTop + Math.max(minY, Math.min(dy, maxY))}px`; + root.style.right = "auto"; + }; + head.addEventListener("pointerdown", (event) => { + if (event.button !== 0 || (event.target as Element).closest("button")) return; + start = { + id: event.pointerId, + x: event.clientX, + y: event.clientY, + left: root.offsetLeft, + top: root.offsetTop, + }; + head.setPointerCapture(event.pointerId); + }); + head.addEventListener("pointermove", (event) => { + if (start?.id !== event.pointerId) return; + put(start.left + event.clientX - start.x, start.top + event.clientY - start.y); + }); + const end = (event: PointerEvent): void => { + if (start?.id === event.pointerId) start = null; + }; + head.addEventListener("pointerup", end); + head.addEventListener("pointercancel", end); + return () => { + if (root.style.left) put(root.offsetLeft, root.offsetTop); + }; +} + interface CrossModal { open(request: CrossModalOpen): void; } @@ -145,14 +189,14 @@ function createCrossModal(host: HTMLElement): CrossModal {
임시 횡단 +
+ + + +
-
-
- - - -
`; +
`; const $ = (selector: string): T => root.querySelector(selector)!; const station = $(".b05-crossmodal__station"); const box = $(".b05-crossmodal__box"); @@ -164,6 +208,7 @@ function createCrossModal(host: HTMLElement): CrossModal { root.addEventListener(type, (event) => event.stopPropagation()), ); host.append(root); + const refit = bindHeadDrag(root, $(".b05-crossmodal__head")); let current: CrossModalOpen | null = null; let turn = 0; @@ -272,6 +317,7 @@ function createCrossModal(host: HTMLElement): CrossModal { current = request; station.textContent = stationLabel(request.chainageM, request.intervalM); root.hidden = false; + refit(); syncButtons(); watchPipe(); void draw(request.chainageM); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index e53421e4c..07faa1632 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -352,7 +352,8 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind showStation(station?.key ?? null, station?.chainageM ?? at, structure, open); } const stopPick = subscribeSelection(params.projectId, (selection, from) => { - if (from !== PLAN_PICK_FROM) follow(selection); + // 평면 이름 = 눈금 누름(제가 이미 그림)만 거름 · 마커 고름 풂(값 없음)은 따라 비움(500-1) + if (from !== PLAN_PICK_FROM || selection.chainageM === null) follow(selection); }); attach(); // 들어올 때 이미 고른 것 — 판에 그 측점 횡단(494-1 · 안 그리면 고른 채 빈 판 · 다시 누르면 고름이 풀림) · 판은 안 폄 diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index 38013b006..40edb8fbd 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -243,8 +243,13 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP canvas.setPointerCapture(event.pointerId); return; } - marks.deselect(); // 마커 아닌 곳 = 마커 고름 풂(오른쪽 횡단 판은 그대로) const pick = choice.pick; + // 교각점 · 손잡이 · 호(곡선 고름) = 마커 고름 유지(500-1) · 그 밖 마커 아닌 곳 = 풂(오른쪽 판도 비움) + const onCurve = + (!!pick && "node" in pick) || + choice.handle !== null || + (!(pick && "chainage" in pick) && arcAtScreen(state.curveInfo, toScreen, px, py) >= 0); + if (!onCurve) marks.deselect(); // 노드 가운데(6px)는 노드 — 헤어핀처럼 곡선이 몰린 데서도 노드를 집음(2026-09-07 사용자 지적 ④) · // 그 밖은 횡단 눈금 · 손잡이와 가까운 쪽(381-3 `pickAt` · 433-14 ③ 낮은 확대 R 12 손잡이). const hitNode = pick && "node" in pick ? pick.node : -1; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts index e93283042..9a59ceafc 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts @@ -9,7 +9,7 @@ * · 끌기 · 방향키(마커 고른 때 · 교각점 안 고른 때) = 노선 따라 정수 m · 1 m 씩 · 시점 · 종점 밖 안 감 · * 놓을 때 · 뗄 때 한 번 옮김(`move` = 알약 끌기와 같은 길 · 관 · 구조물 초안 · [저장]이 정본). * · 그림 · 집기 차례 = 맨 위 · 맨 앞(409 · 재기 다음) — `_Render` · `_Pointer` 가 부름. - * · 고름 풀기 = 마커 아닌 곳 누름 · Esc(409) — 공용 고름을 `PLAN_PICK_FROM` 으로 비움(오른쪽 횡단 판 그대로 · + * · 고름 풀기 = 마커 아닌 곳 누름 · Esc(409) — 공용 고름을 `PLAN_PICK_FROM` 으로 비움(오른쪽 횡단 판도 비움 500-1 · * 좌측 목록 강조 · 폼은 풂 409-2). * 목록 · 고름 · 옮김은 종단 보기가 `setPlanMarkSource` 로 건넴. 걸음 셈은 `logic/…_StructureMarks`. * ========================================================================== */ @@ -333,7 +333,7 @@ export function bindStructureMarks( return moving; }; - /** 고름 풂 — 공용 고름을 비움 · 오른쪽 횡단 판은 제 이름(`PLAN_PICK_FROM`)이라 안 비움. */ + /** 고름 풂 — 공용 고름을 비움 · 오른쪽 횡단 판도 값 없음이라 따라 비움(500-1). */ const deselect = (): boolean => { if (!planMarks(canvas)?.picked) return false; selectDesign(projectId, NO_SELECTION, { from: PLAN_PICK_FROM }); diff --git a/B05_Profile/logic/B05_Profile_Profile_CrossPick.ts b/B05_Profile/logic/B05_Profile_Profile_CrossPick.ts index 1e51415c5..430cc6789 100644 --- a/B05_Profile/logic/B05_Profile_Profile_CrossPick.ts +++ b/B05_Profile/logic/B05_Profile_Profile_CrossPick.ts @@ -7,7 +7,7 @@ * ========================================================================== */ /** 원지반선을 눌렀다고 볼 위아래 여유(px). */ -export const GROUND_SLACK_PX = 8; +export const GROUND_SLACK_PX = 12; /** 포인터 아래 = 추가 측점 선(그 누가거리) · 다른 측점선 · 단추("other") · 빈 곳(null). */ export type CrossPickHit = { plusM: number } | "other" | null; diff --git a/resources/tester/helper_411_a_cross_structure.cjs b/resources/tester/helper_411_a_cross_structure.cjs index 224a624ce..b31414518 100644 --- a/resources/tester/helper_411_a_cross_structure.cjs +++ b/resources/tester/helper_411_a_cross_structure.cjs @@ -141,7 +141,7 @@ out.structureMoved = { ...shownNow(), at: preview.watched()?.chainageM ?? null } // ③ 관 고름(누가거리만) = 그 측점 횡단 pick(80.17, null, "b05"); out.pipePick = { ...shownNow(), structure: preview.watched()?.structure ?? null }; -// ④ 평면 마커 고름 풂(b05-plan) = 판 그대로 +// ④ 평면 마커 고름 풂(b05-plan) = 판도 비움(500-1) pick(null, null, "b05-plan"); out.planRelease = shownNow(); // ⑤ 일반측점 고름 = 그 측점 횡단 · 법선(structure 없음) diff --git a/resources/tester/test_409_mark_top_deselect.py b/resources/tester/test_409_mark_top_deselect.py index c6066095b..4171c874f 100644 --- a/resources/tester/test_409_mark_top_deselect.py +++ b/resources/tester/test_409_mark_top_deselect.py @@ -78,7 +78,10 @@ def test_맴돔_커서_마커_먼저() -> None: def test_오른쪽_판은_제_이름_알림을_거름() -> None: cross = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts") - assert "if (from !== PLAN_PICK_FROM) follow(selection);" in cross + # 500-1 — 눈금 누름(값 있음)만 거름 · 마커 고름 풂(값 없음)은 따라 비움 + assert ( + "if (from !== PLAN_PICK_FROM || selection.chainageM === null) follow(selection);" in cross + ) marks = _read("B05_Profile", "B05_Profile_UI_RouteEdit_StructureMarks.ts") assert "selectDesign(projectId, NO_SELECTION, { from: PLAN_PICK_FROM });" in marks diff --git a/resources/tester/test_411_a_cross_structure.py b/resources/tester/test_411_a_cross_structure.py index f8abb0637..1cc0b1289 100644 --- a/resources/tester/test_411_a_cross_structure.py +++ b/resources/tester/test_411_a_cross_structure.py @@ -40,8 +40,12 @@ def test_관_고름_그_측점_횡단(got) -> None: assert got["pipePick"] == {"station": "80.17", "foot": "횡단 80.17", "structure": True} -def test_평면_마커_풂은_판_그대로(got) -> None: - assert got["planRelease"] == {"station": "80.17", "foot": "횡단 80.17"} +def test_평면_마커_풂도_판_비움(got) -> None: + # 500-1(요구 300) — 고름 풀림 = 오른쪽 판도 비움(옛 409 「판 그대로」 걷음) + assert got["planRelease"] == { + "station": "", + "foot": "측점 눈금을 누르면 그 측점 횡단이 뜹니다.", + } def test_일반측점_그대로(got) -> None: diff --git a/resources/tester/test_490_1_cross_pick.py b/resources/tester/test_490_1_cross_pick.py index 05d2c783d..05fb89e29 100644 --- a/resources/tester/test_490_1_cross_pick.py +++ b/resources/tester/test_490_1_cross_pick.py @@ -1,6 +1,6 @@ """490-1 B05 종단 — 임시 횡단이 열리는 자리 가까이 표시 = 누름과 같은 판정(`crossPickAt`). -판정 함수 = 옛 누름 판정(원지반선 위아래 8 px · 정수 m · 추가 측점 선 = 그 측점 · 측점선 · 단추 = null)과 +판정 함수 = 옛 누름 판정(원지반선 위아래 12 px · 500-1 8 → 12 · 정수 m · 추가 측점 선 = 그 측점 · 측점선 · 단추 = null)과 같은 값 · 허용 밖 null. TS 쪽 = `helper_490_1_cross_pick.cjs`(Node). 화면 조작은 ORCA 로 확인. """ @@ -39,14 +39,14 @@ def _old_pick(hit, chainage: float, pointer_y: float) -> int | None: a, b = valid[after - 1], valid[after] t = (chainage - a["chainage_m"]) / (b["chainage_m"] - a["chainage_m"] or 1) ground = a["elevation_m"] + (b["elevation_m"] - a["elevation_m"]) * t - return round(chainage) if abs(pointer_y - _ground_y(ground)) <= 8 else None + return round(chainage) if abs(pointer_y - _ground_y(ground)) <= 12 else None CASES = [ {"hit": None, "chainageM": 10.2, "pointerY": 69.8}, # 지반 y = 69.8 · 딱 위 - {"hit": None, "chainageM": 10.2, "pointerY": 77.8}, # 8 px 끝 - {"hit": None, "chainageM": 10.2, "pointerY": 78.3}, # 허용 밖 - {"hit": None, "chainageM": 10.2, "pointerY": 61.3}, # 위쪽 허용 밖 + {"hit": None, "chainageM": 10.2, "pointerY": 81.8}, # 12 px 끝(옛 8 px 밖) + {"hit": None, "chainageM": 10.2, "pointerY": 82.3}, # 허용 밖 + {"hit": None, "chainageM": 10.2, "pointerY": 57.3}, # 위쪽 허용 밖 {"hit": None, "chainageM": 50.0, "pointerY": 93.0}, # 빈 표고 건너 20→60 보간 {"hit": None, "chainageM": 0.0, "pointerY": 80.0}, # 첫 표본 = null(옛 판정 그대로) {"hit": None, "chainageM": 70.0, "pointerY": 100.0}, # 끝 뒤 @@ -72,7 +72,7 @@ def _run() -> dict: def test_판정_함수_옛_누름과_같은_값() -> None: result = _run() - assert result["slack"] == 8 + assert result["slack"] == 12 picks = [row["pick"] for row in result["out"]] assert picks == [_old_pick(c["hit"], c["chainageM"], c["pointerY"]) for c in CASES] assert picks == [10, 10, None, None, 50, None, None, 34, None] diff --git a/resources/tester/test_500_1_curve_keeps_pick.py b/resources/tester/test_500_1_curve_keeps_pick.py new file mode 100644 index 000000000..892c17b8d --- /dev/null +++ b/resources/tester/test_500_1_curve_keeps_pick.py @@ -0,0 +1,80 @@ +"""500-1 B05 평면 — 곡선(교각점 · 손잡이 · 호) 누름 = 측점 고름 유지 · 풀림 = 오른쪽 판도 비움 · 임시 횡단 창 단추 = 제목 줄 · 제목 줄 끌기. + +./venv/Scripts/python.exe -m pytest resources/tester/test_500_1_curve_keeps_pick.py -q +""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def _read(*parts: str) -> str: + return ROOT.joinpath(*parts).read_text(encoding="utf-8") + + +def _down() -> str: + """평면 누름 — 마커 갈래 뒤 ~ 교각점 갈래 앞.""" + pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts") + start = pointer.index("if (choice.mark && marks.press(px, py)) {") + return pointer[start : pointer.index("if (hitNode >= 0) {", start)] + + +# 누름별 — 고름 유지(풂 건너뜀) 조건 한 줄씩 +KEEP = { + "교각점": '(!!pick && "node" in pick)', + "손잡이": "choice.handle !== null", + "호(눈금 아님)": '(!(pick && "chainage" in pick) && arcAtScreen(state.curveInfo, toScreen, px, py) >= 0)', +} + + +def test_곡선_누름은_고름_유지() -> None: + down = _down() + for name, cond in KEEP.items(): + assert cond in down, name + assert "if (!onCurve) marks.deselect();" in down # 그 밖(빈 땅 · 등고선 · 눈금) = 풂 + assert down.count("marks.deselect()") == 1 # 무조건 풂 걷힘 + + +def test_차례_마커_다음_교각점_앞() -> None: + down = _down() + assert down.index("canvas.setPointerCapture(event.pointerId);") < down.index("const onCurve") + assert down.index("const onCurve") < down.index("if (!onCurve) marks.deselect();") + + +def test_풀림은_오른쪽_판도_비움() -> None: + cross = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts") + line = "if (from !== PLAN_PICK_FROM || selection.chainageM === null) follow(selection);" + assert line in cross + follow = cross[cross.index("function follow(") : cross.index(line)] + assert "watching = null;" in follow and "current.show(null, interval());" in follow + # 풂(값 없음 · 평면 이름)을 내는 곳 = 마커 풂 하나 · Esc 도 같은 길 + marks = _read("B05_Profile", "B05_Profile_UI_RouteEdit_StructureMarks.ts") + assert marks.count("selectDesign(projectId, NO_SELECTION, { from: PLAN_PICK_FROM });") == 1 + assert "!activeMoving(canvas) && deselect()" in marks + + +def test_임시_횡단_단추_제목_줄_오른쪽() -> None: + modal = _read("B05_Profile", "B05_Profile_UI_Profile_CrossModal.ts") + head = modal[ + modal.index('
') : modal.index( + '
' + ) + ] + for act in ("add", "remove", "close"): + assert f'data-act="{act}"' in head + assert head.index("임시 횡단") < head.index("b05-crossmodal__actions") + css = _read("B05_Profile", "B05_Profile_UI_Profile_CrossModal.css") + actions = css[css.index(".b05-crossmodal__actions {") :] + assert "margin-left: auto;" in actions[: actions.index("}")] + + +def test_제목_줄_끌기_화면_안() -> None: + modal = _read("B05_Profile", "B05_Profile_UI_Profile_CrossModal.ts") + assert 'bindHeadDrag(root, $(".b05-crossmodal__head"))' in modal + drag = modal[modal.index("function bindHeadDrag(") : modal.index("interface CrossModal {")] + assert '.closest("button")' in drag # 단추 누름은 단추 몫 + assert "innerWidth" in drag and "innerHeight" in drag # 화면 밖 안 나감 + assert "root.parentElement!.getBoundingClientRect()" in drag # 종단 칸 안 + assert "refit();" in modal # 다시 열 때 가둠