Files
Aislo/B06_Section/B06_Section_UI_Adjust_Dock.ts
T
eomsangdonandClaude Opus 5.5 d7faee1cd2 feat(structure): 65-1~5 세월교 · 박스암거 유입 · 유출 흙막이 양식 공통 · 없음 = 램프 뺌 · 세월교 기본값 · 물넘이 월류 폭 · 높이
- 흙막이 양식 한 벌(design_structure_guard) — 배관 · 박스 · 세월교가 같은 칸(형태 · 길이 · 높이 · 측점 전후) · 추가 흙막이 · 도수로/돌붙임 상자
- 세월교 · 박스 유입 · 유출: 흙막이 고르면 흙막이 칸(키 {side}_revet_* · 기본 찰 10 · 1.5 · 5 · 5) · [연동][경사] 옵션 줄
- B06 측벽 높이 · 좌우 · 상하 · 바닥판 읽기 줄을 세월교 · 박스 유입 · 유출 칸에 안 옮김(강조만)
- 유입 · 유출 「없음」 = 목록 램프 · 구조물 조합 잇기 줄 없음(명세 none)
- 세월교: 수량(련) 관경 바로 뒤 · 련 기본 2 · 새 시설 빈 월류 높이 = 유량 오면 필요 수심 · 종류 바꿀 때 유량 넘김
- 물넘이 포장: 경사각 기본 3° · 월류 폭 · 높이 칸(세월교와 같은 키 ford_width_m · ford_height_m · 같은 계산)
- 구조물정의 판 18(판_17 이력) · 이름 표 · 집계표 선택 컨테이너 짝 · 저장 병합 키
- 시험: 세월교 · 박스 · 물넘이 명세 기대값 · 흙막이 짝 자리 옮김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
2026-10-06 23:09:38 +09:00

266 lines
13 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Adjust_Dock.ts
* 좌측 [구조물 배치] 섹션 안 **[횡단 조정] 하위 컨테이너**(2026-08-29 사용자:
* B05/B06 일원화 — 오버레이 조정창 항목을 좌측에도 보이되, 기존 폼과 구분되게
* 접이식 하위 컨테이너에 담는다. 9키·십자 위치제어는 오버레이 전용).
*
* 카드가 조정창을 만들 때 같은 deps로 **사본 창**을 하나 더 만들어 이 슬롯에
* 세운다(adopt). 값·동작이 같은 함수라 오버레이·좌측 어느 쪽을 만져도 카드
* 재렌더에서 두 창이 함께 새 값으로 그려진다 — 별도 동기화 코드가 없다.
* 카드가 여럿이어도 슬롯은 하나 — 마지막으로 고른 구조물의 사본만 선다.
*
* 값 칸은 좌측 폼 하나(계획서 24-4a) — 사본의 줄은 **모두** 폼 칸(유입구 · 유출구 · 독립 좌우 ·
* 날개벽 · 추가 기슭막이)으로 옮겨 붙고, 폼과 같은 값의 줄은 사본이 아예 만들지 않는다.
* 그래서 [횡단 조정] 상자 자체는 화면에 서지 않는다(사본을 담아 두는 자리로만 남음).
* ========================================================================== */
let dockRoot: HTMLDetailsElement | null = null;
let slot: HTMLElement | null = null;
let placeholder: HTMLElement | null = null;
/** 유입구·유출구·추가 기슭막이 그룹의 이식 자리(2026-08-29 지시 4, 2026-08-30 지시 4).
* B06 패널이 등록한다. */
interface SideSlots {
inlet: HTMLElement;
outlet: HTMLElement;
extra: HTMLElement;
/** 독립 기슭막이 좌·우 칸 — 배관 유입구·유출구 칸이 숨어 있을 때 이쪽으로 간다. */
revetInlet: HTMLElement;
revetOutlet: HTMLElement;
/** 세월교 날개벽(유입·유출) 칸 — 세월교 측점에서는 배관·독립 칸이 모두 숨어 있어
* 이쪽이 그 측의 자리다(2026-08-30 사용자 지시 1). */
wingInlet: HTMLElement;
wingOutlet: HTMLElement;
}
let sideSlots: SideSlots | null = null;
/** 행을 옮겨 붙일 자리를 등록한다(계곡 통과 시설 폼의 유입구·유출구·추가 기슭막이). */
export function setAdjustSideSlots(next: SideSlots): void {
sideSlots = next;
}
/** 다단 벽에서 옮겨 오는 행 — 폼에 자기 칸이 없어 형태·높이·구간값이 온다.
* 단 수·옵션은 부모 벽의 값이라 여기 오지 않고 부모 칸에 남는다(2026-08-30 사용자). */
const EXTRA_ROWS = [
".b06-structure-panel__struct--readout",
".b06-structure-panel__struct--form",
".b06-structure-panel__struct--height",
".b06-structure-panel__struct--span",
].join(", ");
/** 유입구·유출구·날개벽 칸은 폼에 없는 줄만 받는다 — 읽기(좌우 · 상하 · 구체 길이 · 표고) ·
* 단 수 · 옵션 · 집수정 내공 · 세월교 측벽 높이(24-4a). */
const SIDE_ROWS = [
".b06-structure-panel__struct--readout",
".b06-structure-panel__struct--tier",
".b06-structure-panel__struct--options",
".b06-structure-panel__struct--basin-size",
".b06-structure-panel__struct--wall-height",
].join(", ");
/** 배관 칸 = 폼에 자리 없는 집수정 내공 · 유입 단 수만 — 목록에서 골라도 같은 글(54-29).
* 유출 단 수는 폼 「추가 흙막이(단)」 칸(54-27) · 읽기 · 옵션 줄은 안 받음. */
const PIPE_INLET_ROWS = [
".b06-structure-panel__struct--tier",
".b06-structure-panel__struct--basin-size",
].join(", ");
const PIPE_OUTLET_ROWS = ".b06-structure-panel__struct--basin-size";
/** 받을 칸에 맞는 줄 — 배관 유입 · 유출 칸이 아니면 옛 줄 그대로 · 세월교 · 박스 유입 · 유출 칸은 안 받음
* (높이 · 측벽 좌우 · 상하 · 바닥판 읽기는 칸에 안 둠 · 강조만 · 65-1). */
function rowsFor(slot: HTMLElement): string | null {
if (slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null;
if (slot === sideSlots?.outlet) return PIPE_OUTLET_ROWS;
return slot === sideSlots?.inlet ? PIPE_INLET_ROWS : SIDE_ROWS;
}
/** 이식 자리가 지금 화면에 서 있는지 — 그 위 어느 줄이든 숨었으면(다른 시설 명세 폼 등) 옮기지 않는다. */
function slotUsable(target: HTMLElement | undefined): target is HTMLElement {
return !!target?.closest("fieldset") && !target.closest("[hidden]");
}
/** 배관이면 유입구·유출구 칸, 독립 기슭막이면 좌·우 칸, 세월교면 날개벽 칸 —
* 지금 서 있는 쪽을 준다(같은 측의 자리가 시설 종류마다 다른 칸에 있다). */
function slotOf(side: "inlet" | "outlet"): HTMLElement {
const slots = sideSlots!;
const revet = side === "inlet" ? slots.revetInlet : slots.revetOutlet;
const wing = side === "inlet" ? slots.wingInlet : slots.wingOutlet;
if (slotUsable(slots[side])) return slots[side];
return slotUsable(revet) ? revet : wing;
}
/** 옮겨 온 행과 그 **제자리 표식** — 되돌릴 때 원래 순서로 꽂기 위해 짝으로 둔다.
* 표식 없이 지우면 행 DOM이 사라져, 같은 창에서 다른 벽을 고를 때 그 행이 다시는
* 나오지 않는다(2026-08-29 사용자 보고: 단 수·옵션이 사라짐). */
const movedRows: Array<{ row: HTMLElement; anchor: Comment }> = [];
/** 그룹 이름표 — 원래 문구를 한 번만 적어 두고, 선택에 따라 괄호 표기를 붙였다 뗀다. */
function setGroupLabel(group: HTMLElement, suffix: string): void {
const legend = group.querySelector<HTMLElement>("legend");
if (!legend || legend.dataset.fixedLabel) return;
legend.dataset.baseLabel ??= legend.textContent ?? "";
const base = legend.dataset.baseLabel;
legend.textContent = suffix ? `${base} (${suffix})` : base;
}
/** 옮겨 온 행을 **원래 자리로 되돌리고** 슬롯을 비운다. 그룹 강조도 끈다. */
function clearSideSlots(): void {
while (movedRows.length) {
const { row, anchor } = movedRows.pop()!;
anchor.parentNode?.replaceChild(row, anchor);
}
for (const target of [
sideSlots?.inlet,
sideSlots?.outlet,
sideSlots?.extra,
sideSlots?.revetInlet,
sideSlots?.revetOutlet,
sideSlots?.wingInlet,
sideSlots?.wingOutlet,
]) {
target?.replaceChildren();
const group = target?.closest("fieldset");
if (!group) continue;
group.classList.remove("is-active");
setGroupLabel(group, "");
// 추가 기슭막이 칸은 고른 다단 벽이 있을 때만 서 있는다 — 비면 그룹째 내린다.
if (target === sideSlots?.extra) group.hidden = true;
}
// 칸이 다른 명세로 옮겨 간 뒤 옛 상자에 남은 강조까지 끔(53-1)
document
.querySelectorAll<HTMLElement>("fieldset.is-active > legend[data-base-label]")
.forEach((legend) => {
legend.parentElement!.classList.remove("is-active");
setGroupLabel(legend.parentElement!, "");
});
}
/** 행 DOM을 제자리 표식과 함께 옮긴다 — 숨은 행은 그대로 둔다. */
function moveRows(panelRoot: HTMLElement, selector: string | null, target: HTMLElement): void {
if (!selector) return;
panelRoot.querySelectorAll<HTMLElement>(selector).forEach((row) => {
if (row.classList.contains("is-hidden")) return;
// 제자리에 표식을 남기고 옮긴다 — 선택이 바뀌면 이 자리로 되돌린다.
const anchor = document.createComment("adjust-row");
row.parentNode?.insertBefore(anchor, row);
movedRows.push({ row, anchor });
target.append(row);
});
}
/**
* 조정창 사본의 단 수·옵션 행을 고른 벽 쪽(유입/유출) 그룹으로 옮긴다. 행 DOM을
* 그대로 옮기므로 배선·값 동기는 손대지 않는다. 자리가 없으면 사본에 그대로 둔다.
*/
function relocateSideRows(panelRoot: HTMLElement, scroll = true): void {
clearSideSlots();
const side = panelRoot.dataset.side;
if (!sideSlots || (side !== "inlet" && side !== "outlet" && side !== "extra")) return;
const target = side === "extra" ? sideSlots.extra : slotOf(side);
// 추가 기슭막이 칸은 평소 숨어 있다 — 고른 벽이 다단이면 **그 단이 딸린 칸 바로
// 아래**로 옮겨 세운다(2026-08-30 사용자: 좌/우 각각의 하단으로).
if (side === "extra") {
const group = target.closest<HTMLElement>("fieldset");
if (!group) return;
const ownerSlot = slotOf(panelRoot.dataset.extraSide === "inlet" ? "inlet" : "outlet");
// 배관 유출 다단 = 그 단의 「흙막이」 컨테이너(54-10) — 옛 추가 기슭막이 칸 대신 그 상자를 켬 ·
// 단 수 · 옵션은 부모(유출) 칸에 그대로
const soil =
ownerSlot === sideSlots.outlet && slotUsable(ownerSlot)
? ownerSlot
.closest(".b05-structure__spec")
?.querySelectorAll<HTMLElement>('fieldset[data-spec-container="soil_add"]')[
Number(panelRoot.dataset.extraIndex)
]
: undefined;
if (soil) {
moveRows(panelRoot, PIPE_OUTLET_ROWS, ownerSlot);
setGroupLabel(soil, "");
soil.classList.add("is-active");
if (scroll) soil.scrollIntoView({ block: "nearest" });
return;
}
const owner = ownerSlot.closest("fieldset");
if (owner && owner !== group) owner.after(group);
group.hidden = false;
moveRows(panelRoot, EXTRA_ROWS, target);
// 단 수·옵션은 그 단이 딸린 부모 벽의 값이라 부모 칸에 그대로 세운다 — 다단을
// 고를 때 그 두 행이 사라지던 것을 막는다(2026-08-30 사용자).
if (slotUsable(ownerSlot)) moveRows(panelRoot, rowsFor(ownerSlot), ownerSlot);
} else {
if (!slotUsable(target)) return;
moveRows(panelRoot, rowsFor(target), target);
}
// 고른 구조물이 어느 쪽인지 그룹 테두리로 알린다(2026-08-29 사용자 지시 3).
const group = target.closest<HTMLElement>("fieldset");
if (!group) return;
group.classList.add("is-active");
// 이름표에 지금 만지는 구조물을 적는다 — "유입구 (기슭막이)", "유출구 (기슭막이(독립))".
// 추가 기슭막이 칸은 이름 자체가 그 단이라 괄호 안에 단 번호가 온다(2026-08-30 지시 1).
// 배관 칸은 이름표를 안 바꿈 — 목록에서 고른 글과 같게 · 강조만(54-29).
const pipe = target === sideSlots.inlet || target === sideSlots.outlet;
setGroupLabel(group, pipe ? "" : (panelRoot.dataset.panelTitle ?? ""));
// 고른 칸이 화면 밖일 수 있다 — 그 자리로 스크롤한다(지시 2 · 유입 · 유출도 53-1).
if (scroll) group.scrollIntoView({ block: "nearest" });
}
function ensureDock(): void {
if (dockRoot) return;
dockRoot = document.createElement("details");
dockRoot.className = "b06-adjust-dock";
dockRoot.open = true;
// 줄은 모두 폼 칸으로 옮겨 가므로 상자는 서지 않는다(24-4a).
dockRoot.hidden = true;
const summary = document.createElement("summary");
summary.textContent = "횡단 조정";
placeholder = document.createElement("p");
placeholder.className = "b06-adjust-dock__empty";
placeholder.textContent = "횡단도에서 구조물(벽·구체)을 고르면 조정 항목이 여기에 뜹니다.";
slot = document.createElement("div");
slot.className = "b06-adjust-dock__slot";
dockRoot.append(summary, placeholder, slot);
}
/** 하위 컨테이너 루트 — 페이지가 [구조물 배치] 섹션 본문에 붙인다. */
export function adjustDockRoot(): HTMLElement {
ensureDock();
return dockRoot!;
}
/** 페이지 진입 시 초기 상태(빈 슬롯 + 안내문)로 되돌린다. */
export function resetAdjustDock(): void {
ensureDock();
slot!.replaceChildren();
clearSideSlots();
placeholder!.hidden = false;
}
/** 조정창 사본을 슬롯에 세운다 — 이미 다른 사본이 있으면 갈아 끼운다. */
export function adoptAdjustPanel(panelRoot: HTMLElement): void {
ensureDock();
panelRoot.classList.add("b06-structure-panel--dock");
if (slot!.firstChild !== panelRoot || slot!.childNodes.length !== 1) {
slot!.replaceChildren(panelRoot);
}
placeholder!.hidden = true;
relocateSideRows(panelRoot);
}
/**
* 이식을 **다시 한 번** 돌린다 — 폼이 다른 시설로 갈아 끼워진 뒤에 부른다.
* 벽 선택은 카드가 먼저 처리하고 폼 교체가 뒤따르므로, 처음 이식할 때는 목적지
* 칸(배관 유입구·유출구 / 독립 좌·우)이 아직 이전 시설 것이다(2026-08-30 사용자:
* 다른 기슭막이를 고른 뒤 배관 유출을 고르면 강조가 없다).
*/
export function refreshAdjustSlots(scroll = true): void {
const panel = slot?.firstElementChild as HTMLElement | null;
if (panel) relocateSideRows(panel, scroll);
}
/** 이 사본이 슬롯에 서 있으면 내린다 — 다른 사본이 이미 대신 서 있으면 손대지 않는다. */
export function releaseAdjustPanel(panelRoot: HTMLElement): void {
if (!slot || !slot.contains(panelRoot)) return;
slot.replaceChildren();
clearSideSlots();
placeholder!.hidden = false;
}