Files
Aislo/B06_Section/B06_Section_UI_Cross_Center.ts
T
eomsangdonandClaude Opus 5.5 fe025b6db2 feat(B06 횡단): 암 경계선 고름 · 방향키 · 세로 끌기 · 아래 띠 공용 칸 (370-3)
- 새 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
2026-10-17 12:14:43 +09:00

185 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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 — 놓으면 반영`;
}