Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts
T
eomsangdonandClaude Opus 5.5 fb94f3d686 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
2026-10-16 20:50:13 +09:00

152 lines
5.8 KiB
TypeScript

/* =============================================================================
* 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,
);
},
};
}