- 새 B06_Section_UI_Cross_Line_Cell.ts — 아래 띠 왼쪽 공용 칸(제목 · ◀▲↺▼▶ 한 벌 · 값 한 줄 말줄임 · 길게 누름 반복) · 숨은 버튼은 방향키 슬롯 뗌 - Design_Pick — 암 점선 위 판정 선(키 rock · ▲▼↺ · ◀▶ 막음 · 방향키 = 암만) · 떠 있는 선 조정창 · 우측 위 치수 라벨 걷음 · 안 고르면 암 칸 · 고르면 그 선(노면 · 사면 · 소단 · 중심점 · 교차점 · 암) - Center.ts bindAxisDrag(한 축 끌기 · 놓을 때 한 번 · click 한 번 막음) — 중심점 · 암 세로 끌기 같이 씀 - buildRockBoundaryControl = 공용 칸 「안 고름」 꼴(Cross_Design) · Card_Chrome 띠 왼쪽 = 공용 칸 · Detail_Pick 표 강조에 암 판정 선 뺌 - CSS(Cross.css 공용 칸 · Areas.css 암 고름 띠 · Controls.css 주석) · 시험 test_370_3 새로 · 137 · 139_6 · 179_1 · 193_1 · 194_1 · 318 · 356 · 370_2 고쳐 씀 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
185 lines
7.4 KiB
TypeScript
185 lines
7.4 KiB
TypeScript
/* =============================================================================
|
||
* 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<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));
|
||
};
|
||
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 — 놓으면 반영`;
|
||
}
|