Merge branch 'sub_laptop_7' of https://gitea.chemifactory.com/eomsangdon/Aislo into main_laptop_1

This commit is contained in:
2026-10-20 19:56:54 +09:00
11 changed files with 168 additions and 23 deletions
@@ -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) 밑 */
@@ -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 {
<div class="b05-crossmodal__head">
<strong>임시 횡단</strong>
<span class="b05-crossmodal__station"></span>
<div class="b05-crossmodal__actions">
<button type="button" class="ui-button" data-act="add">측점 추가</button>
<button type="button" class="ui-button" data-act="remove">삭제</button>
<button type="button" class="ui-button" data-act="close">닫기</button>
</div>
</div>
<div class="b05-crossmodal__box"></div>
<div class="b05-crossmodal__foot"></div>
<div class="b05-crossmodal__actions">
<button type="button" class="ui-button" data-act="add">측점 추가</button>
<button type="button" class="ui-button" data-act="remove">삭제</button>
<button type="button" class="ui-button" data-act="close">닫기</button>
</div>`;
<div class="b05-crossmodal__foot"></div>`;
const $ = <T extends HTMLElement>(selector: string): T => root.querySelector<T>(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);
@@ -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 · 안 그리면 고른 채 빈 판 · 다시 누르면 고름이 풀림) · 판은 안 폄
@@ -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;
@@ -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 });
@@ -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;
@@ -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 없음)
@@ -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
@@ -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:
+6 -6
View File
@@ -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]
@@ -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('<div class="b05-crossmodal__head">') : modal.index(
'<div class="b05-crossmodal__box">'
)
]
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 # 다시 열 때 가둠