- 중심점 = 보라 선 고름 끝 조각(판정 원 · 보라 선 위 · 다른 고름과 하나만) · 십자는 누름 통과 - 좌우 = 노선 편집기가 등록한 host(B05_Profile_UI_RouteEdit_Shift) — 0.5 m 안 노드 옮김 · 곡선 안 = 교각점 · 아니면 끼움 · 고정 노드 안 옮김 - 끄는 동안 · 키를 잇는 동안 십자만 옮겨 보임 · 놓을 때 한 번 커밋 - 뒤 측점은 계산 묶음이 조용히 · 그 측점 계획고 = 옮기기 전 값(계획고 편집) · 노선 + 계획고 = 되돌리기 한 걸음 - B06 을 바로 열어 편집기가 없으면 종단 보기를 숨긴 채 세움(design_page 등록) - 시험 test_356 · helper_356(노드 고름 · 뒤 측점 범위 · 높이 그대로 · 되돌리기 한 걸음 · 바로 열기) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
152 lines
6.1 KiB
TypeScript
152 lines
6.1 KiB
TypeScript
/* =============================================================================
|
||
* B06_Section_UI_Cross_Center.ts
|
||
* 횡단 카드 중심점 고름(356-3) — 보라 선 고름(`_Cross_Design_Pick`)의 끝 조각(키 `center`).
|
||
*
|
||
* · 판정 원 = 중심 십자 자리 · 노면 판정보다 위(`stackPickTargets`) · 다른 고름과 같은 칸(하나만)
|
||
* · 고르면 ▲▼ = 종단 계획고(지금 길 · 종단도 듣개) · ◀▶ · 가로 끌기 = 그 측점 노선을 좌우로
|
||
* · 끄는 동안 · 키를 잇는 동안은 십자만 옮겨 보이고 · 놓을 때(키는 멈춘 뒤) 한 번 보냄
|
||
* · 좌우 = 노선 편집기가 등록한 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<string | null>;
|
||
|
||
let host: RouteShiftHost | null = null;
|
||
const waiters = new Set<(ready: RouteShiftHost | null) => void>();
|
||
let opener: (() => Promise<void>) | 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<void>) | null, toast?: Notice): void {
|
||
opener = next;
|
||
if (toast) notice = toast;
|
||
}
|
||
|
||
async function readyHost(): Promise<RouteShiftHost | null> {
|
||
if (host) return host;
|
||
const waiting = new Promise<RouteShiftHost | null>((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<boolean> {
|
||
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<SVGElement>("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));
|
||
};
|
||
hit.addEventListener("pointerdown", (event) => {
|
||
const ctm = layer.getScreenCTM();
|
||
if (event.button !== 0 || !active() || !ctm) return;
|
||
event.preventDefault();
|
||
event.stopPropagation(); // 카드 팬으로 안 번짐
|
||
const startX = event.clientX;
|
||
let deltaM = 0;
|
||
const move = (next: PointerEvent): void => {
|
||
deltaM = snapStep((next.clientX - startX) / (ctm.a * kx));
|
||
preview(deltaM);
|
||
};
|
||
const up = (): void => {
|
||
window.removeEventListener("pointermove", move);
|
||
window.removeEventListener("pointerup", up);
|
||
window.removeEventListener("pointercancel", up);
|
||
send(deltaM);
|
||
};
|
||
window.addEventListener("pointermove", move);
|
||
window.addEventListener("pointerup", up);
|
||
window.addEventListener("pointercancel", up);
|
||
});
|
||
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);
|
||
},
|
||
};
|
||
}
|
||
|
||
/** 조정창 읽기 글 — 미리 볼 이동(m · 0 = 안내). */
|
||
export function centerPreviewText(deltaM: number): string {
|
||
if (!deltaM) return "▲▼ = 종단 계획고 · ◀▶ · 끌기 = 이 측점 노선 좌우";
|
||
return `노선 ${deltaM > 0 ? "좌" : "우"} ${Math.abs(deltaM).toFixed(1)}m — 놓으면 반영`;
|
||
}
|