diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 224bbfb60..50fb2d7fc 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -28,6 +28,7 @@ import { loadRouteEditBackground, type RouteEditContours, } from "./B05_Profile_UI_RouteEdit_Contour"; +import { createContourLock } from "./B05_Profile_UI_RouteEdit_ContourLock"; import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { analysisTerrain, @@ -189,6 +190,12 @@ export async function mountRouteEditor( let otherSheets: PreparedLayer[] = []; /** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */ let pickedContour = -1; + /** 등고선 자물쇠(353-2) — 잠긴 동안 빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜. */ + const contourLock = createContourLock( + canvas, + () => contours && { layer: contours.layer, view, stepM: contourStepM(), rotation }, + (index) => ((pickedContour = index), draw()), + ); /** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */ const crossPreview = createCrossPreview({ projectId, @@ -521,7 +528,7 @@ export async function mountRouteEditor( picked = index; }, setPickedContour: (index) => { - pickedContour = index; + if (contourLock.canPick()) pickedContour = index; }, toScreen, toMetric, @@ -652,7 +659,7 @@ export async function mountRouteEditor( const useBackground = (background: Awaited>): void => { otherSheets = background.otherSheets; contours = background.contours; - pickedContour = -1; + pickedContour = contourLock.reattach(contours?.layer ?? null); // 1m 선이 있는 자료(LAS)일 때만 단추를 연다 — 도엽에는 1m 선이 없다. minorContourButton.disabled = !contours?.minor; minorContourButton.title = contours?.minor diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts b/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts new file mode 100644 index 000000000..e8e52b173 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts @@ -0,0 +1,151 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_ContourLock.ts + * 평면 등고선 자물쇠(353-2) — 등고선 위를 지나거나 고른 가닥 위에 뜨는 작은 단추. + * + * 누르면 그 높이를 고정(빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜) · 다시 누르면 풂. + * 높이값으로 쥐어 지형을 바꿔도 같은 높이를 다시 짚음 · 페이지 동안만(저장 안 함). + * 규칙은 `logic/B05_Profile_ContourLock` · 호버 판정은 집기와 같은 `hitPreparedLayer`. + * ========================================================================== */ + +import { pickPx } from "../A00_Common/touch_bridge"; +import { + hitPreparedLayer, + type PreparedLayer, + type ViewState, +} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import { + canPickContour, + featureOfLevel, + lockButtonState, + toggleLock, +} from "./logic/B05_Profile_ContourLock"; +import "./B05_Profile_UI_Style_RouteEdit_ContourLock.css"; + +/** 집기(`_Pointer` CONTOUR_HIT_PX)와 같은 거리(px). */ +const HOVER_HIT_PX = 6; +/** 커서가 선을 떠난 뒤 단추가 남는 시간(ms) — 단추로 옮겨 갈 틈. */ +const HIDE_DELAY_MS = 700; +/** 단추는 커서 오른쪽 위로 이만큼(px) — 선과 커서를 안 가림. */ +const OFFSET_PX = 12; + +const LOCK_SVG = ``; + +/** 호버 판정에 쓰는 지금 화면 — 등고선이 없으면 null. */ +export interface ContourLockScene { + layer: PreparedLayer; + view: ViewState; + /** 지금 화면 등고선 간격 — 그리지 않은 줄은 호버도 안 됨. */ + stepM: number; + rotation: { unrotate: (px: number, py: number) => [number, number] }; +} + +export interface ContourLock { + /** 고른 가닥을 바꿔도 되는지(잠긴 동안 false). */ + canPick: () => boolean; + /** 지형을 바꿔 새 가닥들이 왔다 — 잠근 높이의 가닥 번호(없으면 -1). */ + reattach: (layer: PreparedLayer | null) => number; +} + +/** 자물쇠 단추를 캔버스 칸 위에 세움 · `pick` = 잠글 때 그 가닥을 고른 것으로(다시 그리기까지). */ +export function createContourLock( + canvas: HTMLCanvasElement, + scene: () => ContourLockScene | null, + pick: (index: number) => void, +): ContourLock { + const wrap = canvas.parentElement ?? document.body; + const button = document.createElement("button"); + button.type = "button"; + button.className = "ui-btn ui-btn--glass b05-contourlock"; + button.dataset.act = "contour-lock"; + button.hidden = true; + button.innerHTML = LOCK_SVG; + wrap.append(button); + + let lockedLevel: number | null = null; + /** 단추가 가리키는 가닥 — 누르면 이 가닥을 잠금. */ + let target = -1; + let hideTimer = 0; + let frame = 0; + let last: PointerEvent | null = null; + + const levelOf = (index: number): number | null => + index >= 0 ? (scene()?.layer.features[index]?.labelValue ?? null) : null; + + const paint = (): void => { + const pressed = lockedLevel !== null; + button.setAttribute("aria-pressed", String(pressed)); + const label = pressed + ? `등고선 ${lockedLevel}m 고정 풀기 — 풀면 다른 등고선을 고를 수 있음` + : `등고선 ${levelOf(target) ?? ""}m 고정 — 빈 곳 · 다른 등고선을 눌러도 안 바뀜`; + button.setAttribute("aria-label", label); + button.title = label; + }; + + const hideLater = (): void => { + if (hideTimer) return; + hideTimer = window.setTimeout(() => { + hideTimer = 0; + button.hidden = true; + }, HIDE_DELAY_MS); + }; + const keep = (): void => { + window.clearTimeout(hideTimer); + hideTimer = 0; + }; + + const hover = (event: PointerEvent): void => { + const now = scene(); + // 단추를 누른 채(노드 · 손잡이 끌기 · 화면 이동) 지나가면 안 봄 + if (!now || event.buttons !== 0) return hideLater(); + 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 index = hitPreparedLayer(now.layer, now.view, px, py, hitPx, now.stepM); + const state = lockButtonState(levelOf(index), lockedLevel); + if (!state.visible) return hideLater(); + keep(); + target = index; + const origin = wrap.getBoundingClientRect(); + button.style.left = `${Math.round(event.clientX - origin.left + OFFSET_PX)}px`; + button.style.top = `${Math.round(event.clientY - origin.top - OFFSET_PX)}px`; + button.hidden = false; + paint(); + }; + + // 한 프레임에 한 번만 잼 — 도엽 등고선은 수천 가닥. + canvas.addEventListener("pointermove", (event) => { + last = event; + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + if (last) hover(last); + }); + }); + // 고른 직후(누름을 뗀 자리)에도 띄움 — 터치는 호버가 없음. + canvas.addEventListener("pointerup", hover); + canvas.addEventListener("pointerleave", hideLater); + button.addEventListener("pointerenter", keep); + button.addEventListener("pointerleave", hideLater); + button.addEventListener("pointerdown", (event) => event.stopPropagation()); + button.addEventListener("click", (event) => { + event.stopPropagation(); + lockedLevel = toggleLock(lockedLevel, levelOf(target)); + if (lockedLevel !== null) pick(target); + paint(); + }); + + return { + canPick: () => canPickContour(lockedLevel), + reattach: (layer) => { + target = -1; + button.hidden = true; + return featureOfLevel( + layer?.features.map((feature) => feature.labelValue) ?? [], + lockedLevel, + ); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit_ContourLock.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit_ContourLock.css new file mode 100644 index 000000000..6a2f493b0 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit_ContourLock.css @@ -0,0 +1,28 @@ +/* 평면 등고선 자물쇠(353-2) — 커서 옆에 뜨는 작은 단추 · 자리는 `_ContourLock.ts` 가 잡음. */ +.b05-contourlock { + position: absolute; + z-index: 3; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border-radius: 999px; +} + +.b05-contourlock[hidden] { + display: none; +} + +/* 풀림 = 고리가 들림 · 잠김 = 고른 등고선 색(보라)으로 채움 */ +.b05-contourlock[aria-pressed="false"] .b05-contourlock__shackle { + transform: translate(2.5px, -1px) rotate(18deg); + transform-origin: 11px 7px; +} + +.b05-contourlock[aria-pressed="true"] { + border-color: var(--map-flow-arrow); + background-color: var(--map-flow-arrow); + color: #fff; +} diff --git a/B05_Profile/logic/B05_Profile_ContourLock.ts b/B05_Profile/logic/B05_Profile_ContourLock.ts new file mode 100644 index 000000000..85fffd545 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_ContourLock.ts @@ -0,0 +1,39 @@ +/* ============================================================================= + * B05_Profile/logic/B05_Profile_ContourLock.ts + * 평면 등고선 고정(353-2) — 고른 높이 하나를 쥐고, 잠긴 동안 다른 지정을 막는 규칙. + * + * 높이값으로 쥔다(가닥 번호는 지형을 바꾸면 달라짐) · 페이지 동안만(저장 안 함). + * 순수 계산 — 화면 · three · fetch · sessionStorage 없음. 단추는 `B05_Profile_UI_RouteEdit_ContourLock`. + * ========================================================================== */ + +/** 가닥마다 높이(모르면 null). */ +export type ContourLevels = ReadonlyArray; + +/** 자물쇠 단추 — 보일지 · 눌린 모양인지. */ +export interface LockButtonState { + visible: boolean; + pressed: boolean; +} + +/** 그 높이의 첫 가닥 번호 — 없으면 -1(지형을 바꾼 뒤 잠근 높이 다시 짚기). */ +export function featureOfLevel(levels: ContourLevels, level: number | null): number { + if (level === null) return -1; + return levels.findIndex((value) => value === level); +} + +/** 커서 아래 가닥 높이 · 잠근 높이 → 단추 — 안 잠김 = 높이 있는 가닥 위에서 · 잠김 = 그 높이 위에서만. */ +export function lockButtonState( + hoverLevel: number | null, + lockedLevel: number | null, +): LockButtonState { + if (lockedLevel !== null) return { visible: hoverLevel === lockedLevel, pressed: true }; + return { visible: hoverLevel !== null, pressed: false }; +} + +/** 단추 누름 — 잠김이면 풂(null) · 아니면 그 가닥 높이로 잠금. */ +export function toggleLock(lockedLevel: number | null, targetLevel: number | null): number | null { + return lockedLevel !== null ? null : targetLevel; +} + +/** 잠긴 동안은 고른 가닥이 안 바뀜(다른 가닥 · 빈 곳 눌러도). */ +export const canPickContour = (lockedLevel: number | null): boolean => lockedLevel === null; diff --git a/resources/tester/test_353_2_contour_lock.py b/resources/tester/test_353_2_contour_lock.py new file mode 100644 index 000000000..9dc0a4c58 --- /dev/null +++ b/resources/tester/test_353_2_contour_lock.py @@ -0,0 +1,81 @@ +"""353-2 평면 등고선 자물쇠 — 규칙(`logic/B05_Profile_ContourLock.ts`)을 Node 로 돌려 판정 + +편집기 이음(고르기 막음 · 지형 바꿔도 같은 높이 · 호버 = 집기와 같은 hitPreparedLayer). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_353_2_contour_lock.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +B05 = ROOT / "B05_Profile" +LOGIC = B05 / "logic" / "B05_Profile_ContourLock.ts" +TS = ROOT / "config" / "node_modules" / "typescript" + +DRIVER = r""" +const fs = require("fs"); +const ts = require(process.argv[1]); +const js = ts.transpileModule(fs.readFileSync(process.argv[2], "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const m = { exports: {} }; +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), + lock: L.toggleLock(null, 102), + unlock: L.toggleLock(102, 105), + pickFree: L.canPickContour(null), + pickLocked: L.canPickContour(102), + again: L.featureOfLevel(levels, 102), + gone: L.featureOfLevel(levels, 110), + none: L.featureOfLevel(levels, null), +})); +""" + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", "-e", DRIVER, str(TS), str(LOGIC)], capture_output=True, text=True, timeout=60 + ) + assert run.returncode == 0, run.stderr + 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: + assert got["hoverLocked"] == {"visible": True, "pressed": True} + assert got["hoverOther"]["visible"] is False + + +def test_누르면_잠금_다시_누르면_풂(got) -> None: + assert got["lock"] == 102 and got["unlock"] is None + + +def test_잠긴_동안_고르기_막음(got) -> None: + assert got["pickFree"] is True and got["pickLocked"] is False + + +def test_지형_바꿔도_같은_높이를_다시_짚음(got) -> None: + assert got["again"] == 1 and got["gone"] == -1 and got["none"] == -1 + + +def test_편집기_이음() -> None: + edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text("utf-8") + assert "if (contourLock.canPick()) pickedContour = index;" in edit + assert "pickedContour = contourLock.reattach(contours?.layer ?? null);" in edit + 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 # 페이지 동안만