diff --git a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts index 2e40dfdd2..aff64491e 100644 --- a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts +++ b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts @@ -61,7 +61,13 @@ export function crossPickFromEvent( ? Number(marker.getAttribute("data-chainage")) : null; const hit: CrossPickHit = - plusRaw !== null ? { plusM: plusRaw } : marker || target.closest("button") ? "other" : null; + plusRaw !== null + ? { plusM: plusRaw } + : target.closest("button") + ? "other" + : marker + ? "station" + : null; const svg = chart.querySelector(`svg[${Y_WINDOW_ATTRS.auto}="1"]`); const read = (name: string): number => Number(svg?.getAttribute(name)); const view: ChartWindow = { @@ -93,7 +99,7 @@ export function crossPickFromEvent( } /** 임시 횡단이 열리는 자리 가까이 = 작은 원 · 세로 점선 · 측점 글 · 손가락 커서(490-1). - * 멀어짐 · 측점선 · 단추 · 도구 중 · 끌기 · 스크롤 = 안 그림. */ + * 멀어짐 · 띠 밖 측점선 · 단추 · 도구 중 · 끌기 · 스크롤 = 안 그림. */ export function mountCrossHint( chart: HTMLElement, scroller: HTMLElement, diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 5d8ece274..c868e575e 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -308,36 +308,44 @@ export function renderProfile(ctx: ProfileRenderContext): void { chartWrap.style.height = `${chartHeight}px`; // 빈 곳 누름은 고름을 안 풂 — 풀기는 Esc · 같은 것 다시 누름 한 길(3D 와 같은 규칙 · 233-4). // 도구([직선화] · [쉬프트] · [자유지정]) 중 빈 곳 누름은 그 도구 몫 그대로. - chartWrap.addEventListener("click", (event) => { - // [직선화]·[쉬프트] 모드에서는 그래프 클릭이 도구 선택으로 간다 — 측점선을 눌렀으면 - // 그 측점, 빈 곳이면 그 x의 누가거리로 직선 구간을 고른다(2026-09-02). - if (ctx.toolActive()) { - const marker = (event.target as HTMLElement).closest(".b06-chart__station"); - const raw = marker?.getAttribute("data-chainage"); - if (raw !== null && raw !== undefined) { - if (ctx.handleToolPick(Number(raw))) return; - } else { - const rect = chartWrap.getBoundingClientRect(); - const chainage = chainageInverter( - longitudinal, - width, - layout.originOffset, - )(event.clientX - rect.left + chartWrap.scrollLeft); - if (ctx.handleToolPick(Number.isFinite(chainage) ? chainage : null)) return; + // 잡는 단계(capture) — 띠 안 측점선 누름은 임시 횡단이 먼저라 측점선 자기 고름을 막아야 함(500-2). + chartWrap.addEventListener( + "click", + (event) => { + // [직선화]·[쉬프트] 모드에서는 그래프 클릭이 도구 선택으로 간다 — 측점선을 눌렀으면 + // 그 측점, 빈 곳이면 그 x의 누가거리로 직선 구간을 고른다(2026-09-02). + if (ctx.toolActive()) { + const marker = (event.target as HTMLElement).closest(".b06-chart__station"); + const raw = marker?.getAttribute("data-chainage"); + if (raw !== null && raw !== undefined) { + if (ctx.handleToolPick(Number(raw))) return; + } else { + const rect = chartWrap.getBoundingClientRect(); + const chainage = chainageInverter( + longitudinal, + width, + layout.originOffset, + )(event.clientX - rect.left + chartWrap.scrollLeft); + if (ctx.handleToolPick(Number.isFinite(chainage) ? chainage : null)) return; + } + return; } - return; - } - // 원지반선 근처 · 추가 측점 선 누름 = 그 자리 임시 횡단 작은 창(368-3) - // 다른 측점선 · 단추 누름은 그 몫(측점 고름 · ▲▼) - const toChainageAt = chainageInverter(longitudinal, width, layout.originOffset); - const at = crossPickFromEvent(chartWrap, event, toChainageAt, graphData.samples); - if (at === null) return; - openCrossModal(body.parentElement ?? body, { - chainageM: at.chainageM, - stations: graphLongitudinal.stations.map((station) => station.chainage_m), - intervalM: stationIntervalM ?? 20, - }); - }); + // 원지반선 근처 · 추가 측점 선 누름 = 그 자리 임시 횡단 작은 창(368-3) + // 띠 밖 측점선 · 단추 누름은 그 몫(측점 고름 · ▲▼) — 띠 안 측점선은 임시 횡단이 먼저(500-2) + const toChainageAt = chainageInverter(longitudinal, width, layout.originOffset); + const at = crossPickFromEvent(chartWrap, event, toChainageAt, graphData.samples); + if (at === null) return; + const marker = (event.target as Element).closest(".b06-chart__station"); + if (marker && !marker.querySelector(".b06-chart__station-line--plus")) + event.stopPropagation(); + openCrossModal(body.parentElement ?? body, { + chainageM: at.chainageM, + stations: graphLongitudinal.stations.map((station) => station.chainage_m), + intervalM: stationIntervalM ?? 20, + }); + }, + true, + ); const derived = deriveProfile(ctx, detail, alignment, irregularStations); const { designProfiles, graphData, graphLongitudinal } = derived; // 세로 자동 맞춤 — 지금 화면에 보이는 누가거리 구간만 보고 Y 창을 잡는다(2026-09-04 diff --git a/B05_Profile/logic/B05_Profile_Profile_CrossPick.ts b/B05_Profile/logic/B05_Profile_Profile_CrossPick.ts index 430cc6789..f15d73cb3 100644 --- a/B05_Profile/logic/B05_Profile_Profile_CrossPick.ts +++ b/B05_Profile/logic/B05_Profile_Profile_CrossPick.ts @@ -2,15 +2,15 @@ * logic/B05_Profile_Profile_CrossPick.ts * 종단 그래프 한 점 → 임시 횡단을 열 누가거리(정수 m) · 아니면 null(368-3 · 490-1). * - * 누름(작은 창 열기)과 가까이 표시가 같은 판정을 씀 — 추가 측점 선 = 그 측점 · 다른 측점선 · 단추 = - * 그 몫(null) · 빈 곳 = 원지반선 위아래 `GROUND_SLACK_PX` 안. + * 누름(작은 창 열기)과 가까이 표시가 같은 판정을 씀 — 추가 측점 선 = 그 측점 · 단추 = 그 몫(null) · + * 다른 측점선 · 빈 곳 = 원지반선 위아래 `GROUND_SLACK_PX` 안(측점선은 띠 밖이면 측점 고름 몫 · 500-2). * ========================================================================== */ /** 원지반선을 눌렀다고 볼 위아래 여유(px). */ export const GROUND_SLACK_PX = 12; -/** 포인터 아래 = 추가 측점 선(그 누가거리) · 다른 측점선 · 단추("other") · 빈 곳(null). */ -export type CrossPickHit = { plusM: number } | "other" | null; +/** 포인터 아래 = 추가 측점 선(그 누가거리) · 다른 측점선("station") · 단추("other") · 빈 곳(null). */ +export type CrossPickHit = { plusM: number } | "station" | "other" | null; export type GroundSample = { chainage_m?: number; elevation_m?: number | null }; @@ -44,9 +44,10 @@ export function groundElevationAt( /** 임시 횡단을 열 누가거리(정수 m) · 아니면 null. */ export function crossPickAt(input: CrossPickInput): number | null { if (input.hit === "other") return null; - if (input.hit) return Math.round(input.hit.plusM); + if (typeof input.hit === "object" && input.hit) return Math.round(input.hit.plusM); const ground = groundElevationAt(input.samples, input.chainageM); if (ground === null) return null; + // "station"·null 모두 띠 안이면 임시 횡단 — 측점선은 띠 밖에서만 측점 고름 몫. return Math.abs(input.pointerY - input.groundY(ground)) <= GROUND_SLACK_PX ? Math.round(input.chainageM) : null; diff --git a/resources/tester/test_490_1_cross_pick.py b/resources/tester/test_490_1_cross_pick.py index 05fb89e29..432e761ad 100644 --- a/resources/tester/test_490_1_cross_pick.py +++ b/resources/tester/test_490_1_cross_pick.py @@ -1,7 +1,7 @@ """490-1 B05 종단 — 임시 횡단이 열리는 자리 가까이 표시 = 누름과 같은 판정(`crossPickAt`). -판정 함수 = 옛 누름 판정(원지반선 위아래 12 px · 500-1 8 → 12 · 정수 m · 추가 측점 선 = 그 측점 · 측점선 · 단추 = null)과 -같은 값 · 허용 밖 null. TS 쪽 = `helper_490_1_cross_pick.cjs`(Node). 화면 조작은 ORCA 로 확인. +판정 함수 = 옛 누름 판정(원지반선 위아래 12 px · 500-1 8 → 12 · 정수 m · 추가 측점 선 = 그 측점 · 단추 = null)과 +같은 값 · 허용 밖 null. 500-2 = 다른 측점선("station")은 띠 안이면 임시 횡단 · 띠 밖이면 null(측점 고름 몫). TS 쪽 = `helper_490_1_cross_pick.cjs`(Node). 화면 조작은 ORCA 로 확인. """ from __future__ import annotations @@ -30,7 +30,7 @@ def _old_pick(hit, chainage: float, pointer_y: float) -> int | None: """옛 `groundPickAt` + 누름 처리(Render.ts 329~346) 그대로.""" if hit == "other": return None - if hit: + if isinstance(hit, dict): return round(hit["plusM"]) valid = [s for s in SAMPLES if s["elevation_m"] is not None] after = next((i for i, s in enumerate(valid) if s["chainage_m"] >= chainage), -1) @@ -51,7 +51,11 @@ CASES = [ {"hit": None, "chainageM": 0.0, "pointerY": 80.0}, # 첫 표본 = null(옛 판정 그대로) {"hit": None, "chainageM": 70.0, "pointerY": 100.0}, # 끝 뒤 {"hit": {"plusM": 33.6}, "chainageM": 10.0, "pointerY": 0.0}, # 추가 측점 선 - {"hit": "other", "chainageM": 10.4, "pointerY": 70.0}, # 측점선 · 단추 + {"hit": "other", "chainageM": 10.4, "pointerY": 70.0}, # 단추 + {"hit": "station", "chainageM": 10.4, "pointerY": 69.0}, # 측점선 · 띠 안 = 임시 횡단 + {"hit": "station", "chainageM": 10.4, "pointerY": 81.0}, # 측점선 · 띠 끝 안 + {"hit": "station", "chainageM": 10.4, "pointerY": 90.0}, # 측점선 · 띠 밖 = 측점 고름 + {"hit": "station", "chainageM": 0.0, "pointerY": 80.0}, # 측점선 · 첫 표본 = null ] @@ -75,7 +79,7 @@ def test_판정_함수_옛_누름과_같은_값() -> None: 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] + assert picks == [10, 10, None, None, 50, None, None, 34, None, 10, 10, None, None] def test_누름_표시_같은_길() -> None: diff --git a/resources/tester/test_500_1_curve_keeps_pick.py b/resources/tester/test_500_1_curve_keeps_pick.py index 892c17b8d..231a51d22 100644 --- a/resources/tester/test_500_1_curve_keeps_pick.py +++ b/resources/tester/test_500_1_curve_keeps_pick.py @@ -78,3 +78,18 @@ def test_제목_줄_끌기_화면_안() -> None: assert "innerWidth" in drag and "innerHeight" in drag # 화면 밖 안 나감 assert "root.parentElement!.getBoundingClientRect()" in drag # 종단 칸 안 assert "refit();" in modal # 다시 열 때 가둠 + + +def test_500_2_측점선_띠_안_임시_횡단_먼저() -> None: + modal = _read("B05_Profile", "B05_Profile_UI_Profile_CrossModal.ts") + # 단추 = other 먼저 · 추가 측점선 = plusM · 그 밖 측점선 = station(띠 안이면 임시 횡단) + flat = " ".join(modal.split()) + assert 'target.closest("button") ? "other" : marker ? "station" : null' in flat + logic = _read("B05_Profile", "logic", "B05_Profile_Profile_CrossPick.ts") + assert '"station"' in logic and 'input.hit === "other"' in logic + # 누름 = 잡는 단계에서 띠 안 측점선의 자기 고름을 막고 임시 횡단을 엶 + render = _read("B05_Profile", "B05_Profile_UI_Profile_Render.ts") + start = render.index("// 잡는 단계(capture)") + click = render[start : render.index("const derived = deriveProfile", start)] + assert "event.stopPropagation()" in click and "openCrossModal(" in click + assert " ".join(click.split()).endswith("}, true, );")