Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
161 lines
6.3 KiB
TypeScript
161 lines
6.3 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_RouteEdit_ContourLock.ts
|
|
* 평면 등고선 자물쇠(353-2) — 가닥을 고른 때만 누른 자리 옆에 뜨는 작은 단추(366-1 · 지나기만 = 안 뜸).
|
|
*
|
|
* 누르면 그 높이를 고정(빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜) · 잠근 높이 가닥을 다시
|
|
* 누르면 풀기 단추 · 다음 클릭 · 휠 · 떠남(0.7 s)에 숨김.
|
|
* 높이값으로 쥐어 지형을 바꿔도 같은 높이를 다시 짚음 · 페이지 동안만(저장 안 함).
|
|
* 규칙은 `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 HIT_PX = 6;
|
|
/** 누름 ~ 뗌 사이 이만큼(px) 넘게 움직이면 끌기 — 고르기 아님. */
|
|
const MOVE_PX = 4;
|
|
/** 커서가 지도를 떠난 뒤 단추가 남는 시간(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;
|
|
/** 고른 가닥 번호(없으면 -1) — 안 잠김이면 이 가닥을 누를 때만 뜸. */
|
|
picked: 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 downX = 0;
|
|
let downY = 0;
|
|
|
|
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 hide = (): void => {
|
|
keep();
|
|
button.hidden = true;
|
|
};
|
|
|
|
/** 누름을 뗀 자리 — 고른 가닥(잠김이면 잠근 높이 가닥)일 때만 그 옆에 띄움. */
|
|
const showAt = (event: PointerEvent): void => {
|
|
const now = scene();
|
|
// 끌기 · 화면 이동 · 재기(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(HIT_PX);
|
|
const index = hitPreparedLayer(now.layer, now.view, px, py, hitPx, now.stepM);
|
|
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`;
|
|
button.style.top = `${Math.round(event.clientY - origin.top - OFFSET_PX)}px`;
|
|
button.hidden = false;
|
|
paint();
|
|
};
|
|
|
|
// 다음 클릭 · 휠에 숨김 — 집기(`_Pointer`)보다 먼저 달려 있어 고른 뒤 pointerup 이 다시 띄움.
|
|
canvas.addEventListener("pointerdown", (event) => {
|
|
downX = event.clientX;
|
|
downY = event.clientY;
|
|
hide();
|
|
});
|
|
canvas.addEventListener("pointerup", showAt);
|
|
canvas.addEventListener("wheel", hide, { passive: true });
|
|
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,
|
|
);
|
|
},
|
|
};
|
|
}
|