/* ============================================================================= * B06_Section_UI_Cross_Center.ts * 횡단 카드 중심점 고름(356-3) — 보라 선 고름(`_Cross_Design_Pick`)의 끝 조각(키 `center`). * * · 판정 원 = 중심 십자 자리 · 노면 판정보다 위(`stackPickTargets`) · 다른 고름과 같은 칸(하나만) * · 고르면 ▲▼ = 종단 계획고(지금 길 · 종단도 듣개) · ◀▶ · 가로 끌기 = 그 측점 노선을 좌우로 * · 끄는 동안 · 키를 잇는 동안은 십자만 옮겨 보이고 · 놓을 때(키는 멈춘 뒤) 한 번 보냄 * · 한 축 끌기(`bindAxisDrag`)는 암 경계선 세로 끌기(370-3)도 씀 * · 좌우 = 노선 편집기가 등록한 host(`setRouteShiftHost` · B05 `_RouteEdit_Shift`) — B06 을 바로 열어 * 편집기가 없으면 종단 보기를 숨긴 채 세움(`setRouteShiftOpener` · 페이지가 닮 · 알림도 페이지 것) * ========================================================================== */ const SVG_NS = "http://www.w3.org/2000/svg"; /** 판정 원 클래스 — 고르기 차례가 보라 선 위로 올림. */ export const CENTER_HIT_CLASS = "b06-chart__center-hit"; /** 판정 원 반지름(px). */ const HIT_RADIUS_PX = 8; /** 좌우 걸음(m). */ export const CENTER_STEP_M = 0.1; /** 키를 멈추고 이만큼 지나면 보냄(ms) — 잇단 누름 = 한 번. */ const KEY_SETTLE_MS = 450; /** 편집기를 기다리는 한계(ms) — 종단 보기 · 노선 자료 읽기. */ const HOST_WAIT_MS = 20000; /** 측점 · 좌(+)우(−) m → 못 옮긴 까닭(null = 옮김). */ export type RouteShiftHost = (chainageM: number, deltaM: number) => Promise; let host: RouteShiftHost | null = null; const waiters = new Set<(ready: RouteShiftHost | null) => void>(); let opener: (() => Promise) | null = null; type Notice = (text: string, kind: "info" | "error") => void; let notice: Notice = () => undefined; /** 노선 편집기가 단다(떠나면 null). */ export function setRouteShiftHost(next: RouteShiftHost | null): void { host = next; if (!next) return; waiters.forEach((resolve) => resolve(next)); waiters.clear(); } /** 편집기를 세우는 길 · 알림 — 페이지가 단다(종단 보기 숨긴 채 세움 · 토스트). */ export function setRouteShiftOpener(next: (() => Promise) | null, toast?: Notice): void { opener = next; if (toast) notice = toast; } async function readyHost(): Promise { if (host) return host; const waiting = new Promise((resolve) => { waiters.add(resolve); setTimeout(() => (waiters.delete(resolve), resolve(null)), HOST_WAIT_MS); }); await opener?.().catch(() => undefined); return host ?? waiting; } /** 그 측점 노선을 좌우로 — 편집기가 없으면 세워서. 옮겼으면 true. */ export async function shiftRouteAt(chainageM: number, deltaM: number): Promise { const ready = await readyHost(); if (!ready) { notice("노선 편집기를 못 열어 좌우로 옮기지 못했습니다.", "error"); return false; } const problem = await ready(chainageM, deltaM); if (problem) notice(problem, "info"); return !problem; } /** 판정 원(중심 십자 자리). */ export function centerHit(cx: number, cy: number): SVGCircleElement { const hit = document.createElementNS(SVG_NS, "circle"); hit.setAttribute("cx", String(cx)); hit.setAttribute("cy", String(cy)); hit.setAttribute("r", String(HIT_RADIUS_PX)); hit.setAttribute("class", CENTER_HIT_CLASS); return hit; } const snapStep = (valueM: number): number => Math.round(Math.round(valueM / CENTER_STEP_M) * CENTER_STEP_M * 10) / 10; export interface CenterShift { /** 화면 ◀(−1) · ▶(+1) 한 걸음 — 멈추면 모아 한 번 보냄. */ step: (screenDir: -1 | 1) => void; } /** * 좌우 이동 배선 — `kx` = 화면 x / offset(m) 기울기 · `active` = 중심점을 골랐나 · `preview` = 미리 볼 * 이동(m · 0 = 지움). 끌기는 고른 뒤에만(안 고른 원을 끌면 카드 팬 그대로). */ export function bindCenterShift(input: { hit: SVGElement; layer: SVGGraphicsElement; chainageM: number; kx: number; active: () => boolean; preview: (deltaM: number) => void; }): CenterShift { const { hit, layer, chainageM, kx, active } = input; const preview = (deltaM: number): void => { const shift = deltaM ? `${deltaM * kx}px 0px` : ""; for (const el of [hit, layer.querySelector("g.b06-chart__center")]) el?.style.setProperty("translate", shift); input.preview(deltaM); }; const send = (deltaM: number): void => { if (!deltaM) return preview(0); void shiftRouteAt(chainageM, deltaM).then((moved) => moved || preview(0)); }; bindAxisDrag({ hit, layer, axis: "x", pxPerM: kx, active, preview, send, }); let pending = 0; let timer = 0; return { step: (screenDir) => { pending = snapStep(pending + screenDir * Math.sign(kx) * CENTER_STEP_M); preview(pending); clearTimeout(timer); timer = window.setTimeout(() => { const deltaM = pending; pending = 0; send(deltaM); }, KEY_SETTLE_MS); }, }; } /** * 고른 판정을 한 축으로 끌기(356-3 중심점 가로 · 370-3 암 경계선 세로) — 고른 뒤에만(안 고른 것을 끌면 카드 * 팬 그대로) · 걸음 0.1 m · 끄는 동안 `preview` · 놓을 때 움직였으면 한 번 `send`(안 움직이면 `preview(0)`) · * 놓은 자리 click 은 한 번 막음(같은 것 다시 누름 = 풂으로 안 번짐). `pxPerM` = 그 축 화면 px / m(layer 좌표). */ export function bindAxisDrag(input: { hit: SVGElement; layer: SVGGraphicsElement; axis: "x" | "y"; pxPerM: number; active: () => boolean; preview: (deltaM: number) => void; send: (deltaM: number) => void; }): void { const { hit, layer, axis, pxPerM, active } = input; const at = (event: PointerEvent): number => (axis === "x" ? event.clientX : event.clientY); hit.addEventListener("pointerdown", (event) => { const ctm = layer.getScreenCTM(); if (event.button !== 0 || !active() || !ctm) return; event.preventDefault(); event.stopPropagation(); // 카드 팬으로 안 번짐 const start = at(event); const scale = (axis === "x" ? ctm.a : ctm.d) * pxPerM; let deltaM = 0; const move = (next: PointerEvent): void => { deltaM = snapStep((at(next) - start) / scale); input.preview(deltaM); }; const up = (): void => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); window.removeEventListener("pointercancel", up); if (!deltaM) return input.preview(0); const swallow = (click: Event): void => click.stopPropagation(); window.addEventListener("click", swallow, { capture: true, once: true }); setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0); input.send(deltaM); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); window.addEventListener("pointercancel", up); }); } /** 조정창 읽기 글 — 미리 볼 이동(m · 0 = 안내). */ export function centerPreviewText(deltaM: number): string { if (!deltaM) return "▲▼ = 종단 계획고 · ◀▶ · 끌기 = 이 측점 노선 좌우"; return `노선 ${deltaM > 0 ? "좌" : "우"} ${Math.abs(deltaM).toFixed(1)}m — 놓으면 반영`; }