- 맨 위 [종류 ▼] 상세 폼 · × 대신 폼 안 [지우기] - 평면도 장 = 종류마다 한 줄 [≡] 이름 [1][2] · 장 단추 누르면 미리보기 + 드롭다운 따라감 - 줄 끌어 놓기 = 종류 순서 · 놓는 즉시 PUT plan-kinds-order(reorder_kinds) · 번호 바뀐 장 확정 풂 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
/* =============================================================================
|
||
* 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,
|
||
deletePlanKind,
|
||
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;
|
||
}
|
||
|
||
/** 실패는 토스트로 알리고 삼킨다 — 성공했을 때만 화면을 다시 세운다. */
|
||
export 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/6,000 한 장 · 그림 고정). */
|
||
const LAYOUTS: { value: PlanKindLayout; text: string }[] = [
|
||
{ value: "plan", text: "평면도" },
|
||
{ value: "watershed", text: "유역도" },
|
||
];
|
||
|
||
/** 종류 폼 — 밖(장 단추)에서 고른 종류로 폼을 맞추는 `pick` 과 함께. */
|
||
export interface KindForm {
|
||
root: HTMLElement;
|
||
pick: (kindId: string) => void;
|
||
}
|
||
|
||
/** 종류 — 맨 위 [종류 ▼] 아래 상세. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · [지우기] ·
|
||
* 「새 종류」면 양식 고르고 [추가]. onPick = 드롭다운으로 고른 종류(미리보기 따라감 · 140-2). */
|
||
export function kindForm(
|
||
state: PlanKindsState,
|
||
controls: PlanKindsControls,
|
||
selected = "",
|
||
onPick?: (kindId: string) => void,
|
||
): KindForm {
|
||
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();
|
||
if (pick.select.value) onPick?.(pick.select.value);
|
||
},
|
||
});
|
||
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 remove = el("button", "b07-plan-tools__button", "지우기");
|
||
remove.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 ? "업데이트" : "추가";
|
||
remove.hidden = !kind;
|
||
showPieces();
|
||
};
|
||
if (state.kinds.some((kind) => kind.id === selected)) pick.select.value = selected;
|
||
show();
|
||
remove.addEventListener("click", () => {
|
||
const kind = state.kinds.find((item) => item.id === pick.select.value);
|
||
if (!kind) return;
|
||
if (!window.confirm(`「${kind.name}」 종류를 지웁니다. 다시 쓰려면 「새 종류」로 추가합니다.`))
|
||
return;
|
||
void run(() => deletePlanKind(controls.projectId, kind.id), controls);
|
||
});
|
||
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,
|
||
);
|
||
});
|
||
const actions = el("div", "b07-plan-tools__actions");
|
||
actions.append(action, remove);
|
||
form.append(pick.root, layout.root, scale.root, name, pieceList, actions);
|
||
return {
|
||
root: form,
|
||
pick: (kindId) => {
|
||
pick.select.value = kindId;
|
||
show();
|
||
},
|
||
};
|
||
}
|
||
|
||
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<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;
|
||
}
|
||
|
||
/** 장을 종류별로 묶음 — 여러 장이면 「이름 N장」 · 한 장이면 이름(좌측 목록 · 관리 모달 공용). */
|
||
export function groupPlanSheets(
|
||
items: DesignDrawingItem[],
|
||
): { kindId: string; name: string; sheets: { drawing: DesignDrawingItem; label: string }[] }[] {
|
||
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]);
|
||
}
|
||
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;
|
||
}
|