- 옹벽 · 세월교 · BOX 조정 창의 값 칸(셀렉트 · −/+) 걷음 — 읽기 · 십자 · 그림 끌기 손잡이만(input 0) - 그림 끌기 손잡이: 고른 구조물을 끌면 십자와 같은 걸음 · 같은 동작 · 끄는 동안 미리 보기 - 좌측 [횡단 조정] 사본 상자 없앰 — 폼에 없던 줄(읽기 · 단 수 · 옵션 · 집수정 내공 · 세월교 측벽 높이 · 다단 벽)만 폼 칸에 옮겨 붙음 - 집수정 기준측점 전 · 후를 폼 칸으로(옵션 보이기 따름) · 옮겨 붙은 줄도 옵션 보이기 규칙 - 폼 값 = 칸 전부: 비운 칸은 그림 자동 · 가게(pipes)에서도 지움 · 조정 예약(culvertopt)에 따로 담지 않음 - 시험 test_b06_adjust_single_form 6건 · 거울 시험(집수정 전 · 후 폼 칸) 고침 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
231 lines
9.7 KiB
TypeScript
231 lines
9.7 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) — 셀렉트·입력 포커스 중엔 양보.
|
|
* ========================================================================== */
|
|
|
|
/** 조작 버튼 하나 — 클릭이 카드 선택·팬으로 번지지 않게 막는다. */
|
|
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)이 null 이면 지금 고른 것은 끌 수 없다. */
|
|
export interface DragHandleDeps {
|
|
stepM: () => number | null;
|
|
/** 끄는 동안 창에 적을 미리 보기(빈 글이면 지운다). */
|
|
preview: (text: string) => void;
|
|
/** 놓을 때 한 번 — 화면 왼쪽(◀) 걸음 수 · 아래(▼) 걸음 수. 둘 중 하나만 0 이 아니다. */
|
|
commit: (stepsLeft: number, stepsDown: number) => void;
|
|
}
|
|
|
|
/**
|
|
* 그림 위 손잡이(계획서 24-4a) — 고른 구조물 도형을 누른 채 끌면 십자와 같은 걸음으로 옮긴다.
|
|
* 끄는 동안은 도형만 옮겨 보이고(미리 보기) 놓을 때 한 번 반영한다 — 반영하면 카드가 다시 그려진다.
|
|
* 좌우 · 상하 중 많이 끈 쪽 하나만 쓴다(십자와 같음). 축척은 카드 도면이 적어 둔 `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 stepM = deps.stepM();
|
|
const pxPerM = Number(svg.dataset.pxPerM);
|
|
const exaggeration = Number(svg.dataset.exaggeration) || 1;
|
|
const ctm = layer.getScreenCTM();
|
|
if (!onShape || !stepM || !(pxPerM > 0) || !ctm) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const [startX, startY] = [event.clientX, event.clientY];
|
|
let left = 0;
|
|
let down = 0;
|
|
const move = (next: PointerEvent): void => {
|
|
const dx = next.clientX - startX;
|
|
const dy = next.clientY - startY;
|
|
const horizontal = Math.abs(dx) >= Math.abs(dy);
|
|
left = horizontal ? Math.round(-dx / (pxPerM * ctm.a) / stepM) : 0;
|
|
down = horizontal ? 0 : Math.round(dy / (pxPerM * exaggeration * ctm.d) / stepM);
|
|
const tx = -left * stepM * pxPerM;
|
|
const ty = down * stepM * pxPerM * exaggeration;
|
|
shapes.forEach((shape) => shape.setAttribute("transform", `translate(${tx} ${ty})`));
|
|
const meters = (Math.abs(left || down) * stepM).toFixed(1);
|
|
deps.preview(
|
|
left
|
|
? `${left > 0 ? "◀" : "▶"} ${meters}m`
|
|
: down
|
|
? `${down > 0 ? "▼" : "▲"} ${meters}m`
|
|
: "",
|
|
);
|
|
};
|
|
const up = (): void => {
|
|
window.removeEventListener("pointermove", move);
|
|
window.removeEventListener("pointerup", up);
|
|
window.removeEventListener("pointercancel", up);
|
|
shapes.forEach((shape) => shape.removeAttribute("transform"));
|
|
deps.preview("");
|
|
if (!left && !down) 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);
|
|
deps.commit(left, down);
|
|
};
|
|
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),
|
|
};
|
|
}
|