From 6e16817611a360e5e18a55a0ff254c66d4c22c06 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 11:49:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05=20=ED=8F=89=EB=A9=B4):=20366-1=20?= =?UTF-8?q?=EB=93=B1=EA=B3=A0=EC=84=A0=20=EC=9E=90=EB=AC=BC=EC=87=A0=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B8=20=EB=95=8C=EB=A7=8C=20=E2=80=94=20=EC=A7=80?= =?UTF-8?q?=EB=82=98=EA=B8=B0=EB=A7=8C=20=ED=95=98=EB=A9=B4=20=EC=95=88=20?= =?UTF-8?q?=EB=9C=B8=20=C2=B7=20=EA=B3=A0=EB=A5=B8=20=EA=B0=80=EB=8B=A5?= =?UTF-8?q?=EC=9D=84=20=EB=88=84=EB=A5=B8=20=EC=9E=90=EB=A6=AC=20=EC=98=86?= =?UTF-8?q?=20=C2=B7=20=EC=9E=A0=EA=B9=80=EC=9D=B4=EB=A9=B4=20=EC=9E=A0?= =?UTF-8?q?=EA=B7=BC=20=EB=86=92=EC=9D=B4=20=EC=95=84=EB=AC=B4=20=EA=B0=80?= =?UTF-8?q?=EB=8B=A5=20=EB=88=84=EB=A5=BC=20=EB=95=8C=20=ED=92=80=EA=B8=B0?= =?UTF-8?q?=20=EB=8B=A8=EC=B6=94=20=C2=B7=20=EB=8B=A4=EC=9D=8C=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=C2=B7=20=ED=9C=A0=20=C2=B7=20=EB=96=A0=EB=82=A8=20?= =?UTF-8?q?0.7=20s=20=EC=97=90=20=EC=88=A8=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 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 2 +- .../B05_Profile_UI_RouteEdit_ContourLock.ts | 61 +++++++++++-------- B05_Profile/logic/B05_Profile_ContourLock.ts | 10 +-- resources/tester/test_353_2_contour_lock.py | 29 ++++++--- 4 files changed, 61 insertions(+), 41 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index f08e4e559..2a9cc77b8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -194,7 +194,7 @@ export async function mountRouteEditor( /** 등고선 자물쇠(353-2) — 잠긴 동안 빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜. */ const contourLock = createContourLock( canvas, - () => contours && { layer: contours.layer, view, stepM: contourStepM(), rotation }, + () => contours && { ...contours, view, rotation, stepM: contourStepM(), picked: pickedContour }, (index) => ((pickedContour = index), draw()), ); /** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts b/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts index e8e52b173..146717a3c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts @@ -1,10 +1,11 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_ContourLock.ts - * 평면 등고선 자물쇠(353-2) — 등고선 위를 지나거나 고른 가닥 위에 뜨는 작은 단추. + * 평면 등고선 자물쇠(353-2) — 가닥을 고른 때만 누른 자리 옆에 뜨는 작은 단추(366-1 · 지나기만 = 안 뜸). * - * 누르면 그 높이를 고정(빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜) · 다시 누르면 풂. + * 누르면 그 높이를 고정(빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜) · 잠근 높이 가닥을 다시 + * 누르면 풀기 단추 · 다음 클릭 · 휠 · 떠남(0.7 s)에 숨김. * 높이값으로 쥐어 지형을 바꿔도 같은 높이를 다시 짚음 · 페이지 동안만(저장 안 함). - * 규칙은 `logic/B05_Profile_ContourLock` · 호버 판정은 집기와 같은 `hitPreparedLayer`. + * 규칙은 `logic/B05_Profile_ContourLock` · 누름 판정은 집기와 같은 `hitPreparedLayer`. * ========================================================================== */ import { pickPx } from "../A00_Common/touch_bridge"; @@ -22,8 +23,10 @@ import { import "./B05_Profile_UI_Style_RouteEdit_ContourLock.css"; /** 집기(`_Pointer` CONTOUR_HIT_PX)와 같은 거리(px). */ -const HOVER_HIT_PX = 6; -/** 커서가 선을 떠난 뒤 단추가 남는 시간(ms) — 단추로 옮겨 갈 틈. */ +const HIT_PX = 6; +/** 누름 ~ 뗌 사이 이만큼(px) 넘게 움직이면 끌기 — 고르기 아님. */ +const MOVE_PX = 4; +/** 커서가 지도를 떠난 뒤 단추가 남는 시간(ms) — 단추로 옮겨 갈 틈. */ const HIDE_DELAY_MS = 700; /** 단추는 커서 오른쪽 위로 이만큼(px) — 선과 커서를 안 가림. */ const OFFSET_PX = 12; @@ -33,12 +36,14 @@ const LOCK_SVG = ``; -/** 호버 판정에 쓰는 지금 화면 — 등고선이 없으면 null. */ +/** 누름 판정에 쓰는 지금 화면 — 등고선이 없으면 null. */ export interface ContourLockScene { layer: PreparedLayer; view: ViewState; - /** 지금 화면 등고선 간격 — 그리지 않은 줄은 호버도 안 됨. */ + /** 지금 화면 등고선 간격 — 그리지 않은 줄은 안 잡힘. */ stepM: number; + /** 고른 가닥 번호(없으면 -1) — 안 잠김이면 이 가닥을 누를 때만 뜸. */ + picked: number; rotation: { unrotate: (px: number, py: number) => [number, number] }; } @@ -68,8 +73,8 @@ export function createContourLock( /** 단추가 가리키는 가닥 — 누르면 이 가닥을 잠금. */ let target = -1; let hideTimer = 0; - let frame = 0; - let last: PointerEvent | null = null; + let downX = 0; + let downY = 0; const levelOf = (index: number): number | null => index >= 0 ? (scene()?.layer.features[index]?.labelValue ?? null) : null; @@ -96,17 +101,24 @@ export function createContourLock( hideTimer = 0; }; - const hover = (event: PointerEvent): void => { + const hide = (): void => { + keep(); + button.hidden = true; + }; + + /** 누름을 뗀 자리 — 고른 가닥(잠김이면 잠근 높이 가닥)일 때만 그 옆에 띄움. */ + const showAt = (event: PointerEvent): void => { const now = scene(); - // 단추를 누른 채(노드 · 손잡이 끌기 · 화면 이동) 지나가면 안 봄 - if (!now || event.buttons !== 0) return hideLater(); + // 끌기 · 화면 이동 · 재기(Shift)는 고르기가 아님 + const moved = Math.hypot(event.clientX - downX, event.clientY - downY) > MOVE_PX; + if (!now || event.button !== 0 || event.shiftKey || moved) return; const rect = canvas.getBoundingClientRect(); const [px, py] = now.rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top); - const hitPx = pickPx(HOVER_HIT_PX); + const hitPx = pickPx(HIT_PX); const index = hitPreparedLayer(now.layer, now.view, px, py, hitPx, now.stepM); - const state = lockButtonState(levelOf(index), lockedLevel); - if (!state.visible) return hideLater(); - keep(); + if (!lockButtonState(levelOf(index), lockedLevel, index >= 0 && index === now.picked).visible) { + return; + } target = index; const origin = wrap.getBoundingClientRect(); button.style.left = `${Math.round(event.clientX - origin.left + OFFSET_PX)}px`; @@ -115,17 +127,14 @@ export function createContourLock( paint(); }; - // 한 프레임에 한 번만 잼 — 도엽 등고선은 수천 가닥. - canvas.addEventListener("pointermove", (event) => { - last = event; - if (frame) return; - frame = requestAnimationFrame(() => { - frame = 0; - if (last) hover(last); - }); + // 다음 클릭 · 휠에 숨김 — 집기(`_Pointer`)보다 먼저 달려 있어 고른 뒤 pointerup 이 다시 띄움. + canvas.addEventListener("pointerdown", (event) => { + downX = event.clientX; + downY = event.clientY; + hide(); }); - // 고른 직후(누름을 뗀 자리)에도 띄움 — 터치는 호버가 없음. - canvas.addEventListener("pointerup", hover); + canvas.addEventListener("pointerup", showAt); + canvas.addEventListener("wheel", hide, { passive: true }); canvas.addEventListener("pointerleave", hideLater); button.addEventListener("pointerenter", keep); button.addEventListener("pointerleave", hideLater); diff --git a/B05_Profile/logic/B05_Profile_ContourLock.ts b/B05_Profile/logic/B05_Profile_ContourLock.ts index 85fffd545..62242854a 100644 --- a/B05_Profile/logic/B05_Profile_ContourLock.ts +++ b/B05_Profile/logic/B05_Profile_ContourLock.ts @@ -21,13 +21,15 @@ export function featureOfLevel(levels: ContourLevels, level: number | null): num return levels.findIndex((value) => value === level); } -/** 커서 아래 가닥 높이 · 잠근 높이 → 단추 — 안 잠김 = 높이 있는 가닥 위에서 · 잠김 = 그 높이 위에서만. */ +/** 누른 가닥 높이 · 잠근 높이 · 고른 가닥인지 → 단추(366-1) — 안 잠김 = 높이 있는 고른 가닥을 + * 누를 때만 · 잠김 = 잠근 높이의 아무 가닥을 누를 때(풀기). 지나가기만 하면 안 뜸. */ export function lockButtonState( - hoverLevel: number | null, + hitLevel: number | null, lockedLevel: number | null, + selected: boolean, ): LockButtonState { - if (lockedLevel !== null) return { visible: hoverLevel === lockedLevel, pressed: true }; - return { visible: hoverLevel !== null, pressed: false }; + if (lockedLevel !== null) return { visible: hitLevel === lockedLevel, pressed: true }; + return { visible: hitLevel !== null && selected, pressed: false }; } /** 단추 누름 — 잠김이면 풂(null) · 아니면 그 가닥 높이로 잠금. */ diff --git a/resources/tester/test_353_2_contour_lock.py b/resources/tester/test_353_2_contour_lock.py index 9dc0a4c58..38ba30392 100644 --- a/resources/tester/test_353_2_contour_lock.py +++ b/resources/tester/test_353_2_contour_lock.py @@ -26,10 +26,11 @@ new Function("exports", "module", js)(m.exports, m); const L = m.exports; const levels = [101, 102, 105, 102, null]; console.log(JSON.stringify({ - hoverFree: L.lockButtonState(102, null), - hoverNone: L.lockButtonState(null, null), - hoverLocked: L.lockButtonState(102, 102), - hoverOther: L.lockButtonState(105, 102), + hitPicked: L.lockButtonState(102, null, true), + hitOnly: L.lockButtonState(102, null, false), + hitNone: L.lockButtonState(null, null, true), + hitLocked: L.lockButtonState(102, 102, false), + hitOther: L.lockButtonState(105, 102, true), lock: L.toggleLock(null, 102), unlock: L.toggleLock(102, 105), pickFree: L.canPickContour(null), @@ -50,14 +51,16 @@ def got() -> dict: return json.loads(run.stdout) -def test_안_잠김_단추는_높이_있는_가닥_위에서(got) -> None: - assert got["hoverFree"] == {"visible": True, "pressed": False} - assert got["hoverNone"]["visible"] is False +def test_안_잠김_단추는_고른_가닥을_누를_때만(got) -> None: + """366-1 — 지나기만 · 고르지 않은 가닥 = 안 뜸.""" + assert got["hitPicked"] == {"visible": True, "pressed": False} + assert got["hitOnly"]["visible"] is False + assert got["hitNone"]["visible"] is False -def test_잠김_단추는_그_높이_위에서만_눌린_모양(got) -> None: - assert got["hoverLocked"] == {"visible": True, "pressed": True} - assert got["hoverOther"]["visible"] is False +def test_잠김_단추는_잠근_높이_아무_가닥을_누를_때_풀기(got) -> None: + assert got["hitLocked"] == {"visible": True, "pressed": True} + assert got["hitOther"]["visible"] is False def test_누르면_잠금_다시_누르면_풂(got) -> None: @@ -79,3 +82,9 @@ def test_편집기_이음() -> None: lock = (B05 / "B05_Profile_UI_RouteEdit_ContourLock.ts").read_text("utf-8") assert "hitPreparedLayer(" in lock and 'setAttribute("aria-pressed"' in lock assert "sessionStorage" not in lock and "writeState" not in lock # 페이지 동안만 + # 366-1 — 지나기(pointermove)로 안 띄움 · 뗀 자리 · 다음 클릭 · 휠 · 떠남에 숨김 + assert '"pointermove"' not in lock + assert 'canvas.addEventListener("pointerup", showAt);' in lock + assert 'canvas.addEventListener("wheel", hide' in lock + assert 'canvas.addEventListener("pointerleave", hideLater);' in lock + assert "picked: pickedContour" in edit