/* ============================================================================= * D03_PlanAnalysis_UI_Pieces.ts * D03 좌측 「구역 · 연도」 목록 몸(PLAN 597 · 요구 463 14) — 구역 = 그은 꺾은선 하나(본선 · 갈래). * 해 줄(그 해 모두) · 그 밑 세트 줄(「2027-1」 · 길이 · 구간 수 · 덩이 수 = 마지막 서버 셈 · 같은 세트 고름) · * 구역 줄(색 띠 = 지도 선과 같은 `routeColor(차례)` · 이름 칸 · 세트 고름 · 지움) · * 구간 줄(측점 글 작게 · 세트 고름). 누름 = 고름 · 세트 고침 · 이름 · 지움은 한 걸음(되돌리기 · 캐시 = 페이지). * 세트 고름은 속성 판과 같은 셈(`props.assign` = `assignSet` · 638-3) · 보기만 계정 = 값만. * ========================================================================== */ import { createIconButton } from "@ui/ui_template_button"; import { createInputField, createSelectField } from "@ui/ui_template_input"; import { createListRow } from "@ui/ui_template_list_row"; import { askConfirm } from "@ui/ui_template_modal"; import { routeColor } from "@ui/ui_template_palette"; import { stationLabel } from "../common_util/common_util_station"; import { lineLength } from "./logic/D03_PlanAnalysis_Draw"; import { setKey, type LineRange } from "./logic/D03_PlanAnalysis_Group"; import { lineNames, nameError, type PlanNetwork } from "./logic/D03_PlanAnalysis_Plan"; import { setLineName } from "./logic/D03_PlanAnalysis_PointEdit"; import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit"; import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M, 1); const meters = (m: number): string => `${m.toFixed(1)} m`; export interface PlanPiecesParams { network: () => PlanNetwork; edit: PlanEdit; editable: () => boolean; /** 고를 세트(속성 판과 같음) · 범위를 그 세트로(한 걸음). */ setOptions: () => Array<{ value: string; text: string }>; assign: (ranges: LineRange[], key: string) => void; commit: (next: PlanNetwork) => void; /** 마지막 서버 세트 셈(덩이 수 · 고친 뒤 = null). */ sets?: () => ReadonlyArray<{ key: string; parts: number }> | null; } export interface PlanPieces { /** 좌측 「구역 · 연도」 목록 몸. */ list: HTMLElement; render: () => void; } export function createPlanPieces(params: PlanPiecesParams): PlanPieces { const { edit } = params; const list = document.createElement("div"); list.className = "d03-plan__pieces"; let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기) let syncs: Array<() => void> = []; /** 줄 안 칸 눌림은 줄 고름으로 안 번지게. */ const isControl = (event: Event): boolean => !!(event.target as Element).closest("input, select, button, .ui-list-row__trailing"); /** 줄 하나 — 누름 = 고름(열쇠 Enter 도) · 줄 안 칸은 제외. */ const pickable = (row: HTMLElement, onPick: () => void): HTMLElement => { row.tabIndex = 0; row.addEventListener("click", (event) => isControl(event) || onPick()); row.addEventListener("keydown", (event) => { if (event.key === "Enter" && event.target === row) onPick(); }); return row; }; /** 세트 고름 칸 — 범위 구간들 세트 · 섞이면 「여러 세트」. */ function setSelect( pieces: () => PlanNetwork["pieces"], label: string, ): { root: HTMLElement; sync: () => void } { const field = createSelectField({ options: [], size: 24, ariaLabel: label, disabled: !params.editable(), onChange: (value) => value && params.assign( pieces().map(({ line, from_m, to_m }) => ({ line, from_m, to_m })), value, ), }); const sync = (): void => { const keys = new Set(pieces().map(setKey)); const options = params.setOptions(); field.setOptions( keys.size > 1 ? [{ value: "", text: "여러 세트" }, ...options] : options, keys.size > 1 ? "" : String([...keys][0]), ); }; sync(); return { root: field.root, sync }; } function render(): void { const network = params.network(); const names = lineNames(network.lines); const picked = new Set(edit.selected()?.pieces ?? []); const scope = edit.selected()?.scope; const editable = params.editable(); const parts = new Map((params.sets?.() ?? []).map((s) => [s.key, s.parts])); // 구간 꼴 · 세트 · 덩이까지 열쇠 — 되돌리기 뒤 길이 · 세트 글이 옛 값으로 남지 않게 const key = JSON.stringify([ network.lines.map((line) => [line.id, lineLength(line.vertices).toFixed(1)]), network.pieces.map((p) => [p.id, p.line, p.from_m.toFixed(1), p.to_m.toFixed(1), setKey(p)]), [...picked], scope, editable, params.setOptions().length, [...parts], ]); if (key === shown) { syncs.forEach((sync) => sync()); return; } shown = key; syncs = []; const rows: HTMLElement[] = []; const total = (pieces: typeof network.pieces): string => meters(pieces.reduce((sum, p) => sum + p.to_m - p.from_m, 0)); const group = (pieces: typeof network.pieces, of: (p: (typeof pieces)[number]) => K) => { const out = new Map(); for (const piece of pieces) out.set(of(piece), [...(out.get(of(piece)) ?? []), piece]); return out; }; const byYear = group(network.pieces, (p) => p.year); for (const [year, pieces] of [...byYear].sort(([a], [b]) => a - b)) { const bySet = group(pieces, (p) => p.set); const row = createListRow({ label: `${year}년`, sub: `세트 ${bySet.size} · ${total(pieces)}`, size: 28, active: scope === "year" && pieces.every((p) => picked.has(p.id)), onClick: () => { edit.select([pieces[0].id]); edit.setScope("year"); }, }); row.dataset.year = String(year); rows.push(row); for (const [, own] of [...bySet].sort(([a], [b]) => a - b)) { const name = setKey(own[0]); const lumps = parts.get(name); const setRow = createListRow({ label: name, sub: `${total(own)} · 구간 ${own.length}${lumps ? ` · 덩이 ${lumps}` : ""}`, size: 28, active: scope === "set" && own.every((p) => picked.has(p.id)), onClick: () => { edit.select([own[0].id]); edit.setScope("set"); }, }); setRow.classList.add("d03-plan__piece-row"); setRow.dataset.set = name; rows.push(setRow); } } network.lines.forEach((line, index) => { const pieces = network.pieces.filter((piece) => piece.line === line.id); if (!pieces.length) return; const lineNow = (): (typeof network.lines)[number] => params.network().lines.find((entry) => entry.id === line.id) ?? line; const piecesNow = (): typeof pieces => params.network().pieces.filter((piece) => piece.line === line.id); const nameField = createInputField({ size: 24, placeholder: names[index] }); nameField.input.value = line.name ?? ""; nameField.input.setAttribute("aria-label", "구역 이름"); nameField.input.dataset.role = "line-name"; nameField.input.disabled = !editable; nameField.input.addEventListener("input", () => nameField.setError(nameError(nameField.input.value) ?? undefined), ); nameField.input.addEventListener("change", () => { const error = nameError(nameField.input.value); nameField.setError(error ?? undefined); const text = nameField.input.value.trim(); if (!error && text !== (lineNow().name ?? "")) params.commit(setLineName(params.network(), line.id, text)); }); const year = setSelect(piecesNow, "구역 세트"); year.root.dataset.role = "line-set"; const drop = createIconButton({ icon: "trash", title: "구역 지움", size: 24, onClick: async () => { const ok = await askConfirm({ message: `「${lineNames(params.network().lines)[index]}」 구역을 지울까요?`, okLabel: "지움", danger: true, }); if (!ok) return; edit.dropRegion(line.id); }, }); drop.dataset.role = "line-drop"; drop.hidden = !editable; const tail = document.createElement("span"); tail.className = "d03-plan__line-tail"; tail.append(year.root, drop); const head = createListRow({ label: "", sub: `구간 ${pieces.length} · ${meters(lineLength(line.vertices))}`, stripe: routeColor(index), size: 28, trailing: tail, active: scope === "line" && pieces.every((p) => picked.has(p.id)), }); head.querySelector(".ui-list-row__label")?.replaceChildren(nameField.root); head.dataset.line = line.id; rows.push( pickable(head, () => { edit.select([pieces[0].id]); edit.setScope("line"); }), ); syncs.push(() => { year.sync(); nameField.input.placeholder = lineNames(params.network().lines)[index] ?? ""; if (document.activeElement !== nameField.input) nameField.input.value = lineNow().name ?? ""; const sub = head.querySelector(".ui-list-row__sub"); if (sub) sub.textContent = `구간 ${piecesNow().length} · ${meters(lineLength(lineNow().vertices))}`; }); for (const piece of pieces) { const pieceNow = (): (typeof pieces)[number] => params.network().pieces.find((entry) => entry.id === piece.id) ?? piece; const pieceYear = setSelect(() => [pieceNow()], "구간 세트"); const row = createListRow({ label: `${station(piece.from_m)} ~ ${station(piece.to_m)}`, sub: meters(piece.to_m - piece.from_m), size: 28, trailing: pieceYear.root, active: picked.has(piece.id), }); row.classList.add("d03-plan__piece-row"); row.dataset.piece = piece.id; rows.push(pickable(row, () => edit.select([piece.id]))); syncs.push(pieceYear.sync); } }); list.replaceChildren(...rows); } return { list, render }; }