Files
Aislo/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts
T
eomsangdonandClaude Opus 5.5 0ca0583995 feat(B07): 배수유역도 종류화 · 평면도 드롭다운 공용 꼴 · IP 표 차례 · 용지도 라벨 2배와 상자 밑 선 자름(109-1~4)
- 배수유역도 = 기본 종류(양식 watershed · 1/6,000 한 장 · 조각 없음) · 도면 id plan_watershed · × 지우기 · 「새 종류」 양식 고르기로 다시 · 옛 폴더에 한 번만 더함
- 평면도 칸 종류 · 양식 고르기 = 공용 createSelectField(compact)
- IP 표 첫 줄 맨 위 · 왼 → 오 · 위 → 아래(묶음 왼쪽 아래 기준 그대로)
- 용지도 필지 정보 글 3.6(상수 하나) · 상자 밑 등고선 · 계류 · 필지 · 읍면동 · 시군구 선 잘라 냄(clip_lines 일반화)
- 시험: 기본 종류 · 유역 한 장 · 지우기 · 다시 만들기 · IP 표 두 줄 · 상자 안 선 0

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

241 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B07_DesignDetail_UI_PlanKinds.ts
* 좌측 도면 목록의 「평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 ·
* 사용자 추가)별 도면 단추, 종류 지우기 · 추가 · 업데이트, 대피소 · 사토장 자리 (PLAN 60 · 67장).
*
* 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `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,
deletePlanKind,
fetchPlanKinds,
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<K extends keyof HTMLElementTagNameMap>(
tag: K,
className: string,
text = "",
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
node.className = className;
if (text) node.textContent = text;
return node;
}
/** 실패는 토스트로 알리고 삼킨다 — 성공했을 때만 화면을 다시 세운다. */
async function run(action: () => Promise<unknown>, controls: PlanKindsControls): Promise<void> {
try {
await action();
controls.onChanged();
} catch (error) {
showToast(error instanceof Error ? error.message : "평면도를 고치지 못했습니다.", "error");
}
}
/** 양식 — 평면도(조각 고름 · 1/1,200 장 나눔) · 유역도(1/6,000 한 장 · 그림 고정). */
const LAYOUTS: { value: PlanKindLayout; text: string }[] = [
{ value: "plan", text: "평면도" },
{ value: "watershed", text: "유역도" },
];
/** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 양식 고르고 [추가]. */
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(),
});
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 => {
pieceList.hidden = layout.select.value === "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;
void run(
() =>
kindId
? updatePlanKind(controls.projectId, kindId, label, pieces)
: addPlanKind(controls.projectId, label, pieces, chosen),
controls,
);
});
form.append(pick.root, layout.root, name, pieceList, action);
return form;
}
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<HTMLElement, () => 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;
}
/** 「평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 종류 · 대피소 자리. */
export function buildPlanSection(
items: DesignDrawingItem[],
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
controls?: PlanKindsControls,
): 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}`));
const kinds = new Map<string, DesignDrawingItem[]>();
for (const item of items) {
const key = item.plan_kind ?? item.id;
kinds.set(key, [...(kinds.get(key) ?? []), item]);
}
for (const [kindId, sheets] of kinds) {
const name = sheets[0].plan_kind_name ?? sheets[0].label;
const block = el("div", "b07-plan-kind");
block.dataset.planKind = kindId;
// 한 장이면 [도면 단추][×] 한 줄 · 여러 장이면 [이름][×] 아래 장 단추.
const head = el("div", "b07-plan-kind__head");
const buttons = sheets.map((drawing, index) =>
makeButton(drawing, sheets.length > 1 ? `${name} ${index + 1}장` : name),
);
head.append(sheets.length > 1 ? el("span", "b07-plan-kind__name", name) : buttons[0]);
if (controls) {
const remove = el("button", "b07-plan-tools__remove", "×");
remove.type = "button";
remove.title = `「${name}」 종류 지우기`;
remove.addEventListener("click", () => {
if (!window.confirm(`「${name}」 종류를 지웁니다. 다시 쓰려면 「새 종류」로 추가합니다.`))
return;
void run(() => deletePlanKind(controls.projectId, kindId), controls);
});
head.append(remove);
}
block.append(head, ...(sheets.length > 1 ? buttons : []));
section.append(block);
}
if (controls) {
// 종류 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다.
const tools = el("details", "b07-plan-tools");
tools.append(el("summary", "", "종류 추가 · 업데이트 · 대피소 자리"));
const load = (): void => {
if (!tools.open || tools.dataset.loaded) return;
tools.dataset.loaded = "true";
void fetchPlanKinds(controls.projectId)
.then((state) => tools.append(kindForm(state, controls), marksForm(state, controls)))
.catch((error: unknown) => {
delete tools.dataset.loaded;
showToast(
error instanceof Error ? error.message : "종류 목록을 받지 못했습니다.",
"error",
);
});
};
tools.addEventListener("toggle", load);
// 기본 펼침 — 종류마다 얹을 정보(사용자 지정)가 바로 보이게(93-6).
tools.open = true;
load();
section.append(tools);
}
return section;
}