Files
Aislo/B06_Section/B06_Section_UI_Page_Structures_Selection.ts
T
eomsangdonandClaude Opus 5.5 d3d038349b fix(b06): 고름 강조 정리 — 측점 길은 형상 안 켬 · 연동 관 콘크리트만 · 측점 없는 구조물 스크롤 없음 (307)
- 노면공 포장층 강조 = 포장층을 직접 누른 턴만(paveShapeId) · 알약 · 목록 · 측점선 고름은 범위 테두리만
- 물넘이 콘크리트(pipe-…)는 측점 길 강조(highlightShapes)에서 뺌 — 형상 누름 「관」 고름만 켬 · 맹암거 그대로
- 벽 고름 「관」에 콘크리트 표식(형상 누름 ownerM · 관 없는 카드) · 카드 「관」 setter 를 관 그림 / 콘크리트로 가름(splitPipePick)
- 연동 관 카드 콘크리트 누름 = 관 형상 안 켬 · 콘크리트 ↔ 관 바꿔 누르면 풀지 않고 옮김 · 좌측 하위 컨테이너 켬 그대로
- 측점 없는 구조물 = 카드 고름 · 스크롤 없음(revealStation 스크롤 걷음 · 넘김 focusPicked)
- 시험: test_307_pick_highlight 새로 · 118 · 177_4 · 287 · 294_6 · 294_7 기대값 307 규칙으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-13 18:18:20 +09:00

254 lines
13 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Page_Structures_Selection.ts
* 횡단도 선택 → 좌측 폼 연동 배선(`wireStructureSelection`) — `_Page_Structures_Panel.ts`
* 에서 떼어 냄(700줄). 폼 · 목록 패널 자체는 그 파일 몫.
* ========================================================================== */
import { refreshAdjustSlots } from "./B06_Section_UI_Adjust_Dock";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { StationControls } from "./B06_Section_UI_Page_Station_Controls";
import type { RevetPick } from "./B06_Section_UI_Cross_Culvert";
import type { B06StructuresPanel } from "./B06_Section_UI_Page_Structures_Panel";
import { setBermPickHost } from "./B06_Section_UI_Cross_Design_Pick";
import { BERM_TYPE_ID } from "./B06_Section_UI_Page_Persist";
/**
* 횡단도 선택 → 좌측 폼 연동 배선. 벽(기슭막이)·세월교·BOX 선택이 일어나면 그
* 구조물의 **소유 측점** 시설을 좌측 폼에 올린다(2026-08-29 일원화 3단계).
* 제어 객체의 select를 감싸기만 한다 — 조작·저장 경로는 그대로다.
*/
/** 지금 처리 중인 선택이 **도면에서 벽·구체를 찍은 것**인가 — 카드 선택 리스너가
* 이 값으로 "카드만 고른 경우"를 가려낸다(2026-08-29 사용자: 측점 선택 때 구조물이
* 자동으로 골라지면 안 된다). 같은 턴에서만 참이다. */
let wallSelecting = false;
/** 이번 턴에 찍은 부재가 폼에 올린 시설 자리 — 안 골라진 카드 첫 누름에 카드 고름이 연동 관으로 다시 옮기지 않게(294-8). */
let wallOwner: number | null = null;
export function isWallSelecting(): boolean {
return wallSelecting;
}
function markWallSelecting(owner: number | null = null): void {
wallSelecting = true;
wallOwner = owner;
queueMicrotask(() => {
wallSelecting = false;
wallOwner = null;
});
}
export function wireStructureSelection(
stationControls: StationControls,
detail: () => SectionDetailResponse | null,
panel: B06StructuresPanel,
/** 고른 부재를 세션에 남긴다(2026-09-04). 해제(null)는 부르지 않는다 — 조정창 닫기가
* 카드(측점) 선택까지 지우면 안 된다. 측점 해제는 카드 선택 리스너가 따로 적는다. */
remember?: (chainageM: number, key: string) => void,
/** 폼에서 고른 벽을 카드 조정창에도 세움(54-30 · 목록 넘김 `applyStructurePick` 과 같은 차례). */
refreshCard?: (chainageM: number) => void,
/** 폼에 올린 시설로 보기를 옮김 — 목록에서 고른 길(54-47). */
reveal?: (chainageM: number) => void,
): { syncInletStructure: () => void; formChainageOf: (target: CrossSection) => number } {
const ownerChainageOf = (chainageM: number, key?: RevetPick): number => {
const sectionAt = detail()?.cross_sections.find(
(entry) => Math.abs(entry.chainage_m - chainageM) < 0.01,
);
// 자기 관 없는 카드의 「관」 = 물넘이 콘크리트 — 걸친 연동 관보다 앞(294-7 · 되살림 길도)
const fordM = sectionAt?.ford_pavement?.chainage_m;
if (key === "pipe" && fordM !== undefined && !sectionAt?.culvert) return fordM;
// 물넘이포장 범위 옆 카드 = 그 물넘이 자리(287-5 · 형상 누름이 그 시설을 엶)
return (
(sectionAt &&
(stationControls.structureSpan.ownerOf(sectionAt)?.chainage_m ??
sectionAt.ford_pavement?.chainage_m)) ??
chainageM
);
};
// 유입구 "구조" ↔ B06 유입측 형식 — 폼에서 고르면 조정값에 반영하고, 시설을 폼에
// 올릴 때는 조정값을 폼으로 되읽는다(2026-08-29 지시 5 병합).
const sectionAt = (chainageM: number) =>
detail()?.cross_sections.find((entry) => Math.abs(entry.chainage_m - chainageM) < 0.01);
panel.facility.onInletStructureChange((value) => {
const chainageM = panel.currentChainage();
if (chainageM === null) return;
stationControls.inletStructure.set(chainageM, value);
});
/** 폼에 올라온 시설의 유입측 형식을 폼 드롭다운에 되싣는다. */
const syncInletStructure = (): void => {
const chainageM = panel.currentChainage();
const target = chainageM === null ? undefined : sectionAt(chainageM);
if (target) panel.facility.setInletStructure(stationControls.inletStructure.valueFor(target));
};
const origRevet = stationControls.revetOffset.select;
stationControls.revetOffset.select = (chainageM, key, ownerM) => {
// 다른 카드 부재를 고르는 중에 옛 카드가 제 선택을 푸는 것 — 폼 · 선택 칸은 새 것 몫(53-1)
const nested = wallSelecting;
// 도수로 띠는 자기 측점 시설 — 이웃 관 연장 안이어도 그 관 폼을 안 엶(152-4)
const owner =
ownerM ?? (key === "chute" ? chainageM : ownerChainageOf(chainageM, key ?? undefined));
// 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1)
if (key) markWallSelecting(owner);
origRevet(chainageM, key, ownerM); // ownerM = 콘크리트 형상 누름 표식(307)
if (key) remember?.(owner, key);
if (key) panel.showAtChainage(owner);
else if (!nested) {
remember?.(owner, ""); // 푼 부재가 B05 ↔ B06 오갈 때 다시 안 열림
panel.releasePipe();
}
if (key) syncInletStructure();
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 목적지 칸이 그제야 선다.
refreshAdjustSlots();
};
// 관 상자 칸을 고치거나 고르면 그 부분을 고름 — 횡단에서 누른 것과 같은 길(54-30)
const keyOf = (box: HTMLElement): RevetPick | null => {
const id = box.dataset.specContainer;
if (id === "inlet" || id === "outlet" || id === "pipe") return id;
if (id === "chute_add" || id === "chute") return "chute"; // 도수로 띠(152-4)
return id === "soil_add" ? `extra${Number(box.dataset.specIndex)}` : null;
};
const pickBox = (box: HTMLElement): void => {
const chainageM = panel.currentChainage();
const target = chainageM === null ? undefined : sectionAt(chainageM);
const key = keyOf(box);
const chute =
target?.culvert?.chute ??
target?.box?.chute ??
target?.ford?.chute ??
target?.ford_pavement?.chute;
if (!key || !target || !(key === "chute" ? chute : target.culvert)) return;
if (stationControls.revetOffset.highlightFor(target) === key) return;
stationControls.revetOffset.select(target.chainage_m, key);
refreshCard?.(target.chainage_m);
};
// 그 측점 카드 · 알약으로 감(목록에서 고른 길 · 54-47) — 같은 칸을 잇달아 고치면 한 번만(다시 그려도 칸 이름으로)
const fieldOf = (el: HTMLElement): string => {
const box = el.closest<HTMLElement>("fieldset[data-spec-container]");
const item = el.closest<HTMLElement>("[data-spec-item]")?.dataset.specItem;
return `${box?.dataset.specContainer}:${box?.dataset.specIndex}:${item}`;
};
let revealed: string | null = null;
const revealAt = (chainageM: number, field: string | null): void => {
if (field && field === revealed) return;
revealed = field;
reveal?.(chainageM);
};
const pickAt = (el: HTMLElement): void => {
const box = el.closest<HTMLElement>("fieldset[data-spec-container]");
const chainageM = panel.currentChainage();
if (!box || chainageM === null) return;
revealAt(chainageM, fieldOf(el));
// 단 수 칸은 고른 단을 지킴(단 수 바꿈 뒤 강조 유지) — 새 단은 다시 그린 뒤 아래에서 고름
if (!el.closest('[data-spec-item="outlet.tiers"]')) pickBox(box);
};
// 값을 안 바꾸고 상자를 누르거나 칸에 초점만 줘도 같은 고름(191-1) — 칸 제 동작(누름 · 값 반영) 뒤에
// 돌림 · 고름이 폼을 다시 그려 초점 칸이 사라지면 같은 이름 새 칸에 초점을 되살림
const pressAt = (el: HTMLElement): void => {
setTimeout(() => {
const focused = document.activeElement as HTMLElement | null;
const named = focused?.closest("[data-spec-item]") && panel.root.contains(focused);
const field = named ? fieldOf(focused!) : null;
pickAt(el);
if (!field || focused!.isConnected) return;
// ponytail: 같은 칸 이름 · 같은 태그 첫 칸 — 한 칸에 같은 태그가 여럿이면 그 차례까지 맞출 것
[...panel.root.querySelectorAll<HTMLElement>(focused!.tagName)]
.find((next) => fieldOf(next) === field)
?.focus();
}, 0);
};
let pointing = false; // 누름이 준 초점은 click 이 맡음 — 초점 고름은 키보드(Tab) 몫
panel.root.addEventListener("pointerdown", () => (pointing = true));
panel.root.addEventListener("keydown", () => (pointing = false));
panel.root.addEventListener("click", ({ target: el }) => pressAt(el as HTMLElement));
panel.root.addEventListener("focusin", (event) => {
if (fieldOf(event.target as HTMLElement) !== revealed) revealed = null;
if (!pointing) pressAt(event.target as HTMLElement);
});
panel.root.addEventListener("change", ({ target: el }) => pickAt(el as HTMLElement));
// 시설을 폼에 여는 동안의 그림(종류 칸 → 값 채움 두 번)은 새 상자가 아님 — 마지막 상자(도수로)를
// 고르면 누른 선 · 형상을 덮음(294-8 · 295-4)
let opening = false;
const origShow = panel.showAtChainage;
panel.showAtChainage = (chainageM) => {
opening = true;
try {
return origShow(chainageM);
} finally {
opening = false;
}
};
// 상자가 새로 서면(단 늘림 · [추가]) 그 상자를 고름 — 같은 시설을 다시 그린 때만
let painted: { chainageM: number | null; keys: Set<string> } | null = null;
panel.root.addEventListener("specpaint", () => {
const chainageM = panel.currentChainage();
const boxes = [...panel.root.querySelectorAll<HTMLElement>("fieldset[data-spec-container]")];
const before = painted?.chainageM === chainageM ? painted.keys : null;
painted = { chainageM, keys: new Set(boxes.map(fieldOf)) };
const born = before ? boxes.filter((box) => !before.has(fieldOf(box))).at(-1) : undefined;
if (!born || chainageM === null || opening) return;
revealAt(chainageM, null);
pickBox(born);
});
// 횡단도 소단 수평 단 고름 → 좌측 폼에 그 소단 · 「상세 내용」 상자 켬(194-2 · 191 상자 고름 짝)
const BERM_BOX = "fieldset.b05-structure__optgroup";
setBermPickHost((chainageM) => {
panel.root
.querySelectorAll<HTMLElement>(`${BERM_BOX}.is-active`)
.forEach((box) => box.classList.remove("is-active"));
if (chainageM === null) return;
const berm = panel.getStructures().find((item) => {
if (item.type_id !== BERM_TYPE_ID) return false;
const start = item.start_m ?? item.chainage_m ?? -1;
const end = item.end_m ?? item.chainage_m ?? -1;
return chainageM >= Math.min(start, end) - 1e-6 && chainageM <= Math.max(start, end) + 1e-6;
});
if (!berm?.structure_id) return;
panel.selectStructure(berm.structure_id);
const box = [...panel.root.querySelectorAll<HTMLElement>(BERM_BOX)].find(
(el) => !el.closest("[hidden]"),
);
box?.classList.add("is-active");
box?.scrollIntoView({ block: "nearest" });
});
const origFord = stationControls.ford.select;
stationControls.ford.select = (chainageM, role) => {
const nested = wallSelecting;
if (role) markWallSelecting();
origFord(chainageM, role);
if (role) remember?.(chainageM, role);
if (role) panel.showAtChainage(chainageM);
else if (!nested) {
remember?.(chainageM, "");
panel.releasePipe();
}
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다
// (배관 기슭막이와 같은 순서, 2026-08-30 사용자 지시 1).
refreshAdjustSlots();
};
const origBox = stationControls.box.select;
stationControls.box.select = (chainageM, role) => {
const nested = wallSelecting;
if (role) markWallSelecting();
origBox(chainageM, role);
if (role) remember?.(chainageM, role);
if (role) panel.showAtChainage(chainageM);
else if (!nested) {
remember?.(chainageM, "");
panel.releasePipe();
}
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다.
refreshAdjustSlots();
};
/** 카드 고름이 폼에 올릴 자리 — 찍은 부재가 연 시설 먼저(걸친 연동 관 아님 · 294-8) · 그다음 소유 측점(79-6) ·
* 카드만 고르면 그 측점. */
const formChainageOf = (target: CrossSection): number =>
(wallSelecting
? (wallOwner ?? stationControls.structureSpan.ownerOf(target)?.chainage_m)
: undefined) ?? target.chainage_m;
return { syncInletStructure, formChainageOf };
}