/* ============================================================================= * D03_PlanAnalysis_UI_Pieces.ts * D03 좌측 「구역 · 연도」 목록 몸(PLAN 597 · 요구 463 14) — 구역 = 그은 꺾은선 하나(본선 · 갈래). * 해 줄(같은 해) · 구역 줄(색 띠 = 지도 선과 같은 `routeColor(차례)` · 이름 칸 · 연도 고름 · 지움) · * 구간 줄(측점 글 작게 · 연도 고름). 누름 = 고름 · 해 고침 · 이름 · 지움은 한 걸음(되돌리기 · 캐시 = 페이지). * 연도 고름은 속성 판과 같은 셈(`setLineYear` · `setPiecesYear`) · 보기만 계정 = 값만. * ========================================================================== */ 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 { lineNames, nameError, setLineYear, setPiecesYear, 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; /** 고를 해 — 사업연도 시작부터 열 해 + 조각에 있는 해. */ yearOptions: () => Array<{ value: string; text: string }>; commit: (next: PlanNetwork) => void; } 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 yearSelect( years: () => number[], label: string, pick: (year: number) => void, ): { root: HTMLElement; sync: () => void } { const field = createSelectField({ options: [], size: 24, ariaLabel: label, disabled: !params.editable(), onChange: (value) => value && pick(Number(value)), }); const sync = (): void => { const set = new Set(years()); const options = params.yearOptions(); field.setOptions( set.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options, set.size > 1 ? "" : String([...set][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 key = JSON.stringify([ network.lines.map((line) => line.id), network.pieces.map((piece) => [piece.id, piece.line]), [...picked], scope, editable, params.yearOptions().length, ]); if (key === shown) { syncs.forEach((sync) => sync()); return; } shown = key; syncs = []; const rows: HTMLElement[] = []; const byYear = new Map(); for (const piece of network.pieces) byYear.set(piece.year, [...(byYear.get(piece.year) ?? []), piece]); for (const [year, pieces] of [...byYear].sort(([a], [b]) => a - b)) { const length = pieces.reduce((sum, p) => sum + p.to_m - p.from_m, 0); const row = createListRow({ label: `${year}년`, sub: `구간 ${pieces.length} · ${meters(length)}`, 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); } 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 = yearSelect( () => piecesNow().map((piece) => piece.year), "구역 연도", (value) => params.commit(setLineYear(params.network(), line.id, value)), ); year.root.dataset.role = "line-year"; 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 = yearSelect( () => [pieceNow().year], "구간 연도", (value) => params.commit(setPiecesYear(params.network(), [piece.id], value)), ); 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 }; }