Files
Aislo/B06_Section/B06_Section_UI_Cross_Panel_Base.ts
T
eomsangdonandClaude Opus 5.5 42d0d725a5 feat(B06): 횡단도 안 조정창 = 제목 · 단추만 · 그림 끌기 두 축 함께 + 놓을 때 패널 걸음 맞춤 · 미리 보기 = 맞춘 자리(108-1 · 108-2)
- 조정창(오버레이): 읽기 줄 · 줄 이름 · 머리줄 집수정 ↺ 지움 · 끄는 중 미리 보기 글만 · 좌측 사본 그대로
- 끌기: 벽 · 집수정 · 세월교 = 수평 + 1:1.2 사면 · 다단 · 독립 벽 = 수평 + 수직 · BOX = 길이 + 표고
- 한계(±10 m · 노견 안쪽 · 노면 위 · 다단 선반 · BOX 길이 0)면 가장 가까운 허용 자리 · 반영 한 번
- 시험 test_108_drag_box_hit(끌기 · 걸음 · 미리 보기 = placePipeWall 결과 · 한계 · BOX 판정면)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-08 16:22:21 +09:00

280 lines
12 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Cross_Panel_Base.ts
* 구조물 조정 오버레이 창 **공용 부품**(2026-08-25 사용자: 세월교·BOX암거 창을
* 기슭막이 창(`_Cross_Structure_Panel.ts`)과 같은 템플릿으로 일원화).
*
* 템플릿 규약:
* · 머리줄(`__head`) = 제목 + ✕ 닫기.
* · 항목 행 = 1행 이름 라벨 + 2행 값 조작(2026-08-22 확정 2행 구조).
* · **십자(D-pad)·9키 조작 세트는 항상 창 맨 아래**(2026-08-24·08-25 사용자).
* · 키보드 방향키 = 십자 버튼(2026-08-23) — 셀렉트·입력 포커스 중엔 양보.
* ========================================================================== */
import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const";
/** 조작 버튼 하나 — 클릭이 카드 선택·팬으로 번지지 않게 막는다. */
export function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "b06-structure-panel__btn";
button.textContent = label;
button.title = title;
button.addEventListener("click", (event) => {
// 카드 선택·팬으로 번지면 도면이 다시 그려져 맞춰 둔 배율이 날아간다.
event.stopPropagation();
onClick();
});
return button;
}
/** 항목 행 — 1행 이름 라벨 + 2행 값 조작. */
export function makeRow(labelText: string): { row: HTMLElement; controls: HTMLElement } {
const row = document.createElement("div");
row.className = "b06-structure-panel__struct";
const label = document.createElement("span");
label.className = "b06-structure-panel__label";
label.textContent = labelText;
const controls = document.createElement("div");
controls.className = "b06-structure-panel__controls";
row.append(label, controls);
return { row, controls };
}
/** D-pad 슬롯 버튼 — 방향키 바인딩이 슬롯 클래스로 버튼을 찾는다. */
export function dpadButton(
label: string,
slot: "up" | "down" | "left" | "right" | "reset" | "equal",
title: string,
onClick: () => void,
): HTMLButtonElement {
const button = makeButton(label, title, onClick);
button.classList.add(`b06-structure-panel__btn--${slot}`);
return button;
}
/** 창 뼈대 — root(.b06-structure-panel, 숨김 시작) + 머리줄(제목·닫기). */
export function buildPanelShell(onClose: () => void): {
root: HTMLElement;
head: HTMLElement;
title: HTMLSpanElement;
} {
const root = document.createElement("div");
root.className = "b06-structure-panel is-hidden";
root.addEventListener("click", (event) => event.stopPropagation());
const title = document.createElement("span");
title.className = "b06-structure-panel__title";
const closeButton = makeButton("✕", "닫기", onClose);
closeButton.classList.add("b06-structure-panel__close");
const head = document.createElement("div");
head.className = "b06-structure-panel__head";
head.append(title, closeButton);
root.append(head);
return { root, head, title };
}
/** 마지막으로 누른 자리 — 방향키는 **초점 있는 쪽만** 받는다(계획서 24-3b). 횡단 보기에서 조정창과
* 종단도(▲▼ 계획고)가 한 화면에 함께 있어, 종단도를 누른 뒤에는 조정창이 방향키를 넘긴다. */
let lastPointer: Element | null = null;
document.addEventListener(
"pointerdown",
(event) => {
lastPointer = event.target instanceof Element ? event.target : null;
},
true,
);
/** 방향키가 종단도 몫인가 — 마지막으로 누른 자리가 종단도 안이면 참. */
export function arrowsBelongToProfile(): boolean {
return !!lastPointer?.closest(".b05-route-profile");
}
/** 고른 구조물의 도형 — 벽 · 집수정 · 측벽은 `culvert` 계열, BOX 끝은 판정면이 켜진다. */
const ACTIVE_SHAPES = '[class*="culvert"].is-active, .b06-chart__box-hit.is-active';
/** 끈 자리를 패널 걸음 · 한계로 맞춘 결과 — 화면 이동(m · 왼쪽 + · 아래 +) · 미리 보기 글 · 반영. */
export interface DragSnap {
leftM: number;
downM: number;
text: string;
apply: () => void;
}
/** 끌기 손잡이가 조정 창에 묻는 것. */
export interface DragHandleDeps {
/** 끈 거리(m · 화면 왼쪽 + · 실제 아래 +) → 맞춘 자리. null = 지금 고른 것은 끌 수 없다. */
snap: (leftM: number, downM: number) => DragSnap | null;
/** 끄는 동안 창에 적을 미리 보기(빈 글이면 지운다). */
preview: (text: string) => void;
}
const round1 = (value: number): number => Math.round(value * 10) / 10;
const clampTo = (value: number, low: number, high: number): number =>
Math.min(Math.max(value, low), high);
/** 십자 두 축 끌기를 맞추는 데 필요한 것 — 걸음 · ▲▼ 축 뜻 · 한계 · 반영. */
export interface AxisDragSpec {
stepM: number;
/** ▲▼ 한 칸이 1:1.2 사면(바깥 d · 아래 d/1.2)이면 사면 바깥의 화면 방향(왼쪽 = 1) · 순수 수직이면 null. */
slopeOutward: number | null;
/** 걸음 맞춘 축 이동(◀ + · ▼ +) → 한계 안 가장 가까운 축 이동. */
clamp: (left: number, down: number) => [number, number];
apply: (left: number, down: number) => void;
}
/** 1:1.2 사면 — 사면 축 한 칸 = 바깥 d · 아래 d/1.2(`_Culvert_Solve.placePipeWall` · 집수정과 같음). */
const SLOPE_RUN = FILL_SLOPE_RATIO_MIN;
/**
* 끈 거리를 십자 두 축으로 나눠 걸음에 맞춘다(108-2) — ◀▶ 축 + ▲▼ 축을 함께.
* 사면 축이면 아래 성분이 사면 칸을 정하고, 남은 가로가 ◀▶ 칸이 된다. 한계에 걸리면
* `clamp` 가 가장 가까운 허용 자리로 자르고, 미리 보기도 그 자리(놓은 뒤 결과와 같음)다.
*/
export function snapAxisDrag(leftM: number, downM: number, spec: AxisDragSpec): DragSnap {
const { stepM, slopeOutward: outward } = spec;
const snapStep = (value: number): number => round1(Math.round(value / stepM) * stepM);
const wantDown = snapStep(outward === null ? downM : downM * SLOPE_RUN);
const wantLeft = snapStep(leftM - (outward ?? 0) * wantDown);
const [left, down] = spec.clamp(wantLeft, wantDown).map(round1);
return {
leftM: left + (outward ?? 0) * down,
downM: outward === null ? down : down / SLOPE_RUN,
text: moveText(left, down),
apply: () => spec.apply(left, down),
};
}
/** 미리 보기 글 — 십자 단추 뜻 그대로(◀▶ m · ▲▼ m). */
function moveText(left: number, down: number): string {
return [
left ? `${left > 0 ? "◀" : "▶"} ${Math.abs(left).toFixed(1)}m` : "",
down ? `${down > 0 ? "▼" : "▲"} ${Math.abs(down).toFixed(1)}m` : "",
]
.filter(Boolean)
.join(" · ");
}
/** 축 한계 — 지금 값 `from` 에서 `move` 만큼 가되 [low, high] 안(조정값 공간). */
export function clampAxis(from: number, move: number, low: number, high: number): number {
return round1(clampTo(from + move, low, high) - from);
}
/**
* 그림 위 손잡이(계획서 24-4a) — 고른 구조물 도형을 누른 채 끌면 십자와 같은 걸음으로 옮긴다.
* 끄는 동안은 도형만 맞춘 자리로 옮겨 보이고(미리 보기) 놓을 때 한 번 반영한다 — 반영하면 카드가
* 다시 그려진다. 좌우 · 상하 두 축을 함께 쓴다(108-2). 축척은 카드 도면이 적어 둔 `data-px-per-m`.
*/
export function bindDragHandle(
root: HTMLElement,
deps: DragHandleDeps,
): { attach: () => void; detach: () => void } {
const onDown = (event: PointerEvent): void => {
if (!root.isConnected) {
document.removeEventListener("pointerdown", onDown, true);
return;
}
if (event.button !== 0 || root.classList.contains("is-hidden")) return;
const svg = root.parentElement?.querySelector<SVGSVGElement>("svg.b06-section__chart");
const layer = svg?.querySelector<SVGGraphicsElement>(".b06-chart__plot-layer");
if (!svg || !layer || !svg.contains(event.target as Node)) return;
const shapes = [...svg.querySelectorAll<SVGGraphicsElement>(ACTIVE_SHAPES)];
const onShape = shapes.some((shape) => {
const box = shape.getBoundingClientRect();
return (
event.clientX >= box.left - 3 &&
event.clientX <= box.right + 3 &&
event.clientY >= box.top - 3 &&
event.clientY <= box.bottom + 3
);
});
const pxPerM = Number(svg.dataset.pxPerM);
const exaggeration = Number(svg.dataset.exaggeration) || 1;
const ctm = layer.getScreenCTM();
if (!onShape || !deps.snap(0, 0) || !(pxPerM > 0) || !ctm) return;
event.preventDefault();
event.stopPropagation();
const [startX, startY] = [event.clientX, event.clientY];
let snapped: DragSnap | null = null;
const move = (next: PointerEvent): void => {
const leftM = -(next.clientX - startX) / (pxPerM * ctm.a);
const downM = (next.clientY - startY) / (pxPerM * exaggeration * ctm.d);
snapped = deps.snap(leftM, downM);
const tx = -(snapped?.leftM ?? 0) * pxPerM;
const ty = (snapped?.downM ?? 0) * pxPerM * exaggeration;
shapes.forEach((shape) => shape.setAttribute("transform", `translate(${tx} ${ty})`));
deps.preview(snapped?.text ?? "");
};
const up = (): void => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", up);
shapes.forEach((shape) => shape.removeAttribute("transform"));
deps.preview("");
if (!snapped?.text) return;
// 놓은 자리의 click 이 「같은 구조물 다시 누름 = 선택 해제」로 번지지 않게 한 번 막는다.
const swallow = (click: Event): void => click.stopPropagation();
window.addEventListener("click", swallow, { capture: true, once: true });
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
snapped.apply();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", up);
};
return {
attach: () => document.addEventListener("pointerdown", onDown, true),
detach: () => document.removeEventListener("pointerdown", onDown, true),
};
}
/** 끄는 동안 쓰는 미리 보기 글 자리 — 창 읽기 줄 아래. */
export function dragReadout(): HTMLSpanElement {
const line = document.createElement("span");
line.className = "b06-structure-panel__drag";
return line;
}
const ARROW_SLOT: Record<string, string> = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
};
/**
* 키보드 방향키 = 십자 버튼. `containers` 중 숨겨지지 않은 첫 행의 슬롯 버튼을
* 누른다. 창이 숨겨지면 리스너를 떼고, 카드가 통째로 사라지면 스스로 해제한다.
* 반환: show/hide 때 부를 (attach)와 (detach).
*/
export function bindArrowKeys(
root: HTMLElement,
containers: () => HTMLElement[],
): { attach: () => void; detach: () => void } {
const onArrowKey = (event: KeyboardEvent): void => {
if (!root.isConnected) {
document.removeEventListener("keydown", onArrowKey);
return;
}
const slot = ARROW_SLOT[event.key];
// 보기가 숨었으면(한 페이지 두 보기 · 계획서 24-3a) 다른 보기의 방향키를 먹지 않는다.
if (!slot || root.classList.contains("is-hidden") || !root.getClientRects().length) return;
if (arrowsBelongToProfile()) return;
const focus = event.target as HTMLElement | null;
if (focus && (focus.tagName === "SELECT" || focus.tagName === "INPUT")) return;
for (const container of containers()) {
if (container.classList.contains("is-hidden")) continue;
const button = container.querySelector<HTMLButtonElement>(
`.b06-structure-panel__btn--${slot}`,
);
if (button) {
event.preventDefault();
button.click();
return;
}
}
};
return {
attach: () => document.addEventListener("keydown", onArrowKey),
detach: () => document.removeEventListener("keydown", onArrowKey),
};
}