feat(평면): 353-2 등고선 하이라이트 자물쇠 — 호버 · 고른 직후 단추 · 누르면 그 높이 고정 · 다시 누르면 풂
- 등고선 위를 지나거나 고른 직후 커서 옆에 자물쇠 단추(캔버스 위 DOM · aria-pressed · 호버 판정 = 집기와 같은 hitPreparedLayer) - 잠긴 동안 빈 곳 · 다른 가닥을 눌러도 고른 것 그대로 · 잠긴 높이 위에서만 눌린 단추가 떠 풂 - 높이값으로 쥠 — 분석 지형을 바꿔도 같은 높이 가닥을 다시 짚음 · 페이지 동안만(저장 안 함) - 새 파일 logic/B05_Profile_ContourLock(규칙) · UI_RouteEdit_ContourLock(단추) · 모양 css · RouteEdit.ts 는 고르기 · 지형 바꿈 두 줄 + 세우기만(700줄) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
This commit is contained in:
@@ -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<ReturnType<typeof loadBackground>>): 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
|
||||
|
||||
@@ -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 = `<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
|
||||
<rect x="3" y="7" width="10" height="7" rx="1.5" fill="currentColor"/>
|
||||
<path class="b05-contourlock__shackle" d="M5 7V5a3 3 0 0 1 6 0v2" fill="none"
|
||||
stroke="currentColor" stroke-width="1.6"/></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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<number | null>;
|
||||
|
||||
/** 자물쇠 단추 — 보일지 · 눌린 모양인지. */
|
||||
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;
|
||||
@@ -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 # 페이지 동안만
|
||||
Reference in New Issue
Block a user