/* ============================================================================= * B07_DesignDetail_UI_PlanKinds.ts * 좌측 도면 목록의 「평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 · * 사용자 추가)별 도면 단추 + [평면도 관리] 단추 (PLAN 60 · 67 · 130장). * 종류 추가 · 업데이트 폼 · 대피소 · 사토장 자리 폼은 [평면도 관리] 모달(`_UI_PlanManage.ts`)이 씀. * * 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다. * 드롭다운 · 체크박스는 공용(`createSelectField` · `ui_template_checkbox`) · 측점 입력은 B05 구조물 폼과 같은 측점 NO + 거리 두 칸. * ========================================================================== */ import { createCheckbox } from "@ui/ui_template_checkbox"; import { createSelectField, showToast } from "@ui/ui_template_elements"; import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields"; import { addPlanKind, savePlanMarks, updatePlanKind, type DesignDrawingItem, type PlanKindLayout, type PlanKindsState, type PlanMark, } from "./B07_DesignDetail_Api_Fetch"; /** 종단이 없어 측점 간격을 못 받았을 때 — 서버 `infer_station_interval` 기본값과 같음. */ const DEFAULT_STATION_INTERVAL_M = 20; export interface PlanKindsControls { projectId: string; /** 종류 · 대피소 자리를 고친 뒤 — 목록과 도면을 다시 받는다. */ onChanged: () => void; } function el( tag: K, className: string, text = "", ): HTMLElementTagNameMap[K] { const node = document.createElement(tag); node.className = className; if (text) node.textContent = text; return node; } /** 실패는 토스트로 알리고 삼킨다 — 성공했을 때만 화면을 다시 세운다. */ export async function run( action: () => Promise, controls: PlanKindsControls, ): Promise { try { await action(); controls.onChanged(); } catch (error) { showToast(error instanceof Error ? error.message : "평면도를 고치지 못했습니다.", "error"); } } /** 양식 — 평면도(조각 고름 · 축척 고름 · 장 나눔) · 유역도(1/6,000 한 장 · 그림 고정). */ const LAYOUTS: { value: PlanKindLayout; text: string }[] = [ { value: "plan", text: "평면도" }, { value: "watershed", text: "유역도" }, ]; /** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 양식 고르고 [추가]. */ export function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { const form = el("div", "b07-plan-tools__block"); form.append(el("strong", "", "종류")); const pick = createSelectField({ options: [ ...state.kinds.map((kind) => ({ value: kind.id, text: kind.name })), { value: "", text: "새 종류" }, ], compact: true, ariaLabel: "종류", onChange: () => show(), }); const layout = createSelectField({ options: LAYOUTS, compact: true, ariaLabel: "양식", onChange: () => showPieces(), }); // 축척 — 목록은 서버 `scales` 한 곳 · 유역도 양식은 1/6,000 고정(잠금). const scaleText = (value: number): string => `1/${value.toLocaleString("en-US")}`; const scale = createSelectField({ options: [], compact: true, ariaLabel: "축척" }); const name = el("input", "b07-plan-tools__name"); name.type = "text"; name.placeholder = "종류 이름"; name.maxLength = 40; const boxes = state.pieces.map((piece) => createCheckbox(piece.name, { value: piece.id })); const pieceList = el("div", "b07-plan-tools__pieces"); pieceList.append(...boxes.map((box) => box.root)); const action = el("button", "b07-plan-tools__button"); action.type = "button"; // 유역도 양식은 그림이 정해져 있어 얹을 정보를 고르지 않는다. const showPieces = (): void => { const watershed = layout.select.value === "watershed"; pieceList.hidden = watershed; const kind = state.kinds.find((item) => item.id === pick.select.value); if (watershed) { const fixed = String(state.watershed_scale); scale.setOptions( [{ value: fixed, text: `${scaleText(state.watershed_scale)} (고정)` }], fixed, ); } else { scale.setOptions( state.scales.map((value) => ({ value: String(value), text: scaleText(value) })), String(kind?.layout === "plan" ? kind.scale : state.default_scale), ); } scale.select.disabled = watershed; }; const show = (): void => { const kind = state.kinds.find((item) => item.id === pick.select.value); name.value = kind?.name ?? ""; boxes.forEach(({ input }) => (input.checked = kind?.pieces.includes(input.value) ?? false)); // 양식은 새로 만들 때만 고른다 — 있는 종류는 그 양식을 보이기만. layout.select.value = kind?.layout ?? "plan"; layout.select.disabled = Boolean(kind); action.textContent = kind ? "업데이트" : "추가"; showPieces(); }; show(); action.addEventListener("click", () => { const pieces = boxes.filter((box) => box.input.checked).map((box) => box.input.value); const kindId = pick.select.value; const label = name.value.trim(); const chosen = layout.select.value as PlanKindLayout; const denominator = Number(scale.select.value); void run( () => kindId ? updatePlanKind(controls.projectId, kindId, label, pieces, denominator) : addPlanKind(controls.projectId, label, pieces, chosen, denominator), controls, ); }); form.append(pick.root, layout.root, scale.root, name, pieceList, action); return form; } export function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { const form = el("div", "b07-plan-tools__block"); form.append(el("strong", "", "대피소 · 사토장 자리")); form.append( el( "p", "b07-plan-tools__hint", "구조물로 놓은 대피소 · 사토장은 저절로 실립니다. 더 놓을 측점(NO + 거리)만 적으세요.", ), ); const interval = state.station_interval_m || DEFAULT_STATION_INTERVAL_M; const rows = el("div", "b07-plan-tools__marks"); const readers = new Map PlanMark | null>(); const addRow = (mark: PlanMark): void => { const row = el("div", "b07-plan-tools__mark"); const station = stationFields("측점", () => interval); station.write(mark.chainage_m, interval); const refuge = createCheckbox("대피소", { checked: mark.refuge }); const spoil = createCheckbox("사토장", { checked: mark.spoil }); const remove = el("button", "b07-plan-tools__remove", "×"); remove.type = "button"; remove.title = "이 자리 빼기"; remove.addEventListener("click", () => { readers.delete(row); row.remove(); }); row.append(station.wrap, refuge.root, spoil.root, remove); readers.set(row, () => { const chainage = station.read(interval, true); return chainage === null ? null : { chainage_m: chainage, refuge: refuge.input.checked, spoil: spoil.input.checked }; }); rows.append(row); }; state.marks.forEach(addRow); const more = el("button", "b07-plan-tools__button", "자리 추가"); more.type = "button"; more.addEventListener("click", () => addRow({ chainage_m: 0, refuge: true, spoil: false })); const save = el("button", "b07-plan-tools__button", "업데이트"); save.type = "button"; save.addEventListener("click", () => { const marks = [...readers.values()].map((read) => read()); if (marks.some((mark) => mark === null)) { showToast("측점 NO + 거리를 바르게 적어 주세요.", "error"); return; } void run(() => savePlanMarks(controls.projectId, marks as PlanMark[]), controls); }); const actions = el("div", "b07-plan-tools__actions"); actions.append(more, save); form.append(rows, actions); return form; } /** 장을 종류별로 묶음 — 여러 장이면 「이름 N장」 · 한 장이면 이름(좌측 목록 · 관리 모달 공용). */ export function groupPlanSheets( items: DesignDrawingItem[], ): { kindId: string; name: string; sheets: { drawing: DesignDrawingItem; label: string }[] }[] { const kinds = new Map(); for (const item of items) { const key = item.plan_kind ?? item.id; kinds.set(key, [...(kinds.get(key) ?? []), item]); } return [...kinds].map(([kindId, sheets]) => { const name = sheets[0].plan_kind_name ?? sheets[0].label; return { kindId, name, sheets: sheets.map((drawing, index) => ({ drawing, label: sheets.length > 1 ? `${name} ${index + 1}장` : name, })), }; }); } /** 「평면도」 묶음 — 종류마다 이름 줄 + 장 단추, 아래에 [평면도 관리](130-1). */ export function buildPlanSection( items: DesignDrawingItem[], makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement, onManage?: () => void, ): HTMLElement { const section = el("section", "b07-drawing-group b07-plan ui-collapsible ui-sidebar-section"); section.dataset.kind = "plan"; section.append(el("h3", "ui-collapsible__title", `평면도 ${items.length}`)); for (const { kindId, name, sheets } of groupPlanSheets(items)) { const block = el("div", "b07-plan-kind"); block.dataset.planKind = kindId; // 한 장이면 [도면 단추] 한 줄 · 여러 장이면 [이름] 아래 장 단추. const buttons = sheets.map(({ drawing, label }) => makeButton(drawing, label)); if (sheets.length > 1) block.append(el("span", "b07-plan-kind__name", name)); block.append(...buttons); section.append(block); } if (onManage) { // 종류 · 대피소 자리 · 장 미리보기는 모달 한 곳(좌측 공간 · 접근 혼돈 — 130-1). const manage = el("button", "b07-plan-tools__button b07-plan-manage-open", "평면도 관리"); manage.type = "button"; manage.addEventListener("click", onManage); section.append(manage); } return section; }