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 # 다시 열 때 가둠