/* ============================================================================= * D03_PlanAnalysis_UI_Props.ts * D03 고름 속성 판(PLAN 585-2 · 598 · 요구 463 13·16 · 11) — 오토캐드 특성 창 꼴 · 판 하나가 고름 꼴마다 몸을 바꿈. * * 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 · ✕ = 풂. * 노드 — 경위도 · 지반고 · 측점 · 앞 선 길이 · 기울기 · [노드 지움] [지점으로](그 자리에 지점 · 이미 있으면 숨김) · * [잇기](선 끝 · 638-3). * 선(토막) — 측점 · 길이 · 지반 기울기 · 세트 · [다시 긋기](하나 · 599) [선 지움](가운데 = 구역 둘). * 구역 — 길이 · 노드 수 · 세트(구역 모든 구간) · 구간 줄 · [구역 지움]. * 전체 — 길이 · 구역 수 · 해마다 길이 · [전체 지움](되돌리기 됨). * 세트 구간(왼쪽 해 · 세트 · 구간 줄) — 세트(모두 같으면 그 세트 · 다르면 「여러 세트」) · 구간 줄 · 마커 — 앞뒤 세트 · [마커 지움]. * 세트 고름(638-3 · 요구 547) = 있는 세트 + 해마다 「새 세트」(`setChoices`) → `assignSet`(고른 범위 끝에서 나눔 → * 세트 바꿈 → 이웃 같은 세트 하나로) = 되돌리기 한 걸음 · 보기만 계정 = 값만. * 좌측 「구역 · 연도」 목록 = `UI_Pieces.ts`(페이지가 붙임). * ========================================================================== */ import { createButton, showToast } from "@ui/ui_template_elements"; import { createFloatPanel } from "@ui/ui_template_float_panel"; import { createSelectField } from "@ui/ui_template_input"; import { createKv, type KvRow } from "@ui/ui_template_kv"; import { stationLabel } from "../common_util/common_util_station"; import { groundSlopePct, lineLength, type HeightAt } from "./logic/D03_PlanAnalysis_Draw"; import { assignSet, setChoices, setKey, type LineRange } from "./logic/D03_PlanAnalysis_Group"; import { lineNames, samePoint, type PlanNetwork, type PlanPiece, type Vertex, } from "./logic/D03_PlanAnalysis_Plan"; import { placeNode, segmentPath, segmentPieces, segmentRange, } from "./logic/D03_PlanAnalysis_Select"; import { nodeOff, nodeRecord } from "./logic/D03_PlanAnalysis_Span"; import { createDrawKindFields, DRAW_DEFAULT_LENGTH_M, DRAW_DEFAULT_SLOPE_PCT, type DrawKindValue, } from "./D03_PlanAnalysis_UI_DrawPanel"; import type { PlanEdit, PlanSelection } from "./D03_PlanAnalysis_UI_Edit"; import { featureOn, featureShown, type PlanPage } from "./D03_PlanAnalysis_UI_Features"; import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; import { selectionLength } from "./D03_PlanAnalysis_UI_Select"; import "./D03_PlanAnalysis_UI_DrawParts.css"; /** 새 세트 고를 해 — 사업연도 시작부터. */ const YEAR_SPAN = 10; const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M, 1); const meters = (m: number): string => `${m.toFixed(1)} m`; const percent = (pct: number | null): string => (pct === null ? "-" : `${pct.toFixed(1)} %`); export interface PlanPropsParams { /** 기능 표 페이지 — 연도 · 구간 줄 · [지점으로]. */ page: PlanPage; network: () => PlanNetwork; edit: PlanEdit; editable: () => boolean; /** 사업연도 시작(연도 고름 · 표 「years」 사용 페이지). */ yearStart?: () => number; commit: (next: PlanNetwork) => void; heightAt: () => HeightAt; /** 작업 좌표 → 경위도(없음 = 경위도 칸 숨김). */ lonLat?: (x: number, y: number) => [number, number] | null; /** 지점(노드 판 [지점으로] — 이미 있으면 숨김 · 표 「points」 켠 페이지만). */ points?: () => Vertex[]; addPoint?: (point: Vertex) => void; /** 노드 판 방식 · 값 바꿈 — 노드 · 그 자리 지점 한 걸음. */ commitPoints: (points: Vertex[], next: PlanNetwork) => void; /** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */ respan: (line: string, from: number, to: number) => boolean; } export interface PlanProps { /** 지도 위 속성 판(지도 칸에 붙임). */ panel: HTMLElement; /** 고름 · 꼴이 바뀜 — 판 다시. */ render: () => void; /** 고를 세트 — 있는 세트 + 해마다 「새 세트」(값 = 세트 열쇠). */ setOptions: () => Array<{ value: string; text: string }>; /** 이 범위를 고른 세트로(되돌리기 한 걸음). */ assign: (ranges: LineRange[], key: string) => void; } export function createPlanProps(params: PlanPropsParams): PlanProps { const { edit, page } = params; const points = (): Vertex[] => params.points?.() ?? []; /** 연도 줄 보임 — 표 「years」 사용 · 보기만 + 조각 있음. */ const yearsShown = (network: PlanNetwork): boolean => featureShown(page, "years") && network.pieces.length > 0; const float = createFloatPanel({ title: "고름", draggable: true, onClose: () => edit.select([]), }); float.root.classList.add("d03-plan__props"); float.root.dataset.role = "plan-props"; float.root.hidden = true; /** 고를 세트 — 사업연도 시작부터 열 해(새 세트) + 있는 세트. */ function choices(): ReturnType { const start = params.yearStart?.() ?? new Date().getFullYear(); const years = Array.from({ length: YEAR_SPAN }, (_, i) => start + i); return setChoices(params.network(), years); } const setOptions = (): Array<{ value: string; text: string }> => choices().map((c) => ({ value: c.key, text: c.fresh ? `${c.year} 새 세트` : c.key })); function assign(ranges: LineRange[], key: string): void { const target = choices().find((c) => c.key === key); if (target && ranges.length) params.commit(assignSet(params.network(), ranges, target)); } const button = (label: string, role: string, onClick: () => void): HTMLButtonElement => { const b = createButton({ label, variant: "ghost", size: 24, onClick }); b.dataset.role = role; return b; }; /** 세트 고름 — 고른 범위 모두 그 세트(되돌리기 한 걸음) · 범위 = 토막 · 구간. */ function setField(pieces: PlanPiece[], ranges: LineRange[]): HTMLElement { const keys = new Set(pieces.map(setKey)); const options = setOptions(); const field = createSelectField({ label: "세트", options: keys.size > 1 ? [{ value: "", text: "여러 세트" }, ...options] : options, value: keys.size > 1 ? "" : ([...keys][0] ?? ""), size: 24, ariaLabel: "고른 구간 세트", disabled: !params.editable() || !featureOn(page, "set"), onChange: (value) => value && assign(ranges, value), }); field.select.dataset.role = "props-set"; return field.root; } const rangesOf = (pieces: PlanPiece[]): LineRange[] => pieces.map(({ line, from_m, to_m }) => ({ line, from_m, to_m })); /** 구간 줄 — 누름 = 그 구간만. */ function pieceRows(network: PlanNetwork, pieces: PlanPiece[]): HTMLElement { const names = lineNames(network.lines); const rows = document.createElement("div"); rows.className = "d03-plan__props-rows"; for (const piece of pieces) { const index = network.lines.findIndex((line) => line.id === piece.line); const row = button( `${names[index]} ${station(piece.from_m)}~${station(piece.to_m)} · ` + `${meters(piece.to_m - piece.from_m)} · ${setKey(piece)}`, "props-piece", () => edit.select([piece.id]), ); row.dataset.piece = piece.id; rows.append(row); } return rows; } /** 노드 방식 · 값(598-4 · 599-1 부품) — 방식 고름 = 바로 · 수 칸 = Enter · 칸 떠날 때 · 앞 노드 기준 다시 놓음. */ function nodeKind(line: number, vertex: number): HTMLElement { const record = params.network().lines[line].nodes?.[vertex] ?? null; let value: DrawKindValue = { kind: record?.kind ?? "free", lengthM: record?.length_m ?? DRAW_DEFAULT_LENGTH_M, slopePct: record?.slope_pct ?? DRAW_DEFAULT_SLOPE_PCT, }; const apply = (): void => { const network = params.network(); const next = nodeRecord(value.kind, value.lengthM, value.slopePct); const out = placeNode(network, points(), line, vertex, next, params.heightAt()); if (typeof out === "string") return void showToast(out, "info"); if (JSON.stringify(out.network.lines) !== JSON.stringify(network.lines)) params.commitPoints(out.points, out.network); }; const fields = createDrawKindFields({ value, onChange: (next) => { const kindChanged = next.kind !== value.kind; value = next; if (kindChanged) apply(); }, }); fields.root.addEventListener("change", apply); fields.root.dataset.role = "props-node-kind"; fields.setDisabled(!params.editable()); return fields.root; } /** 고름 꼴마다 — 머리 글 · 몸 · 단추. */ function body( picked: PlanSelection, network: PlanNetwork, ): { title: string; parts: HTMLElement[]; actions: HTMLButtonElement[] } { const names = lineNames(network.lines); const nameOf = (id: string | null): string => names[network.lines.findIndex((line) => line.id === id)] ?? ""; const z = params.heightAt(); const pieces = network.pieces.filter((piece) => picked.pieces.includes(piece.id)); const length = selectionLength(edit.paths()); if (picked.kind === "node") { const { line, vertex } = picked.node!; const vertices = network.lines.find((l) => l.id === line)!.vertices; const point = vertices[vertex]; const lonLat = params.lonLat?.(point[0], point[1]) ?? null; const height = z(point[0], point[1]); const rows: KvRow[] = [ { label: "지반고", value: Number.isFinite(height) ? `${height.toFixed(2)} m` : null }, { label: "측점", value: station(lineLength(vertices.slice(0, vertex + 1))) }, ]; if (params.lonLat) rows.unshift({ label: "경위도", value: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : null, }); if (vertex > 0) { const [a, b] = [vertices[vertex - 1], vertices[vertex]]; rows.push( { label: "앞 선 길이", value: meters(lineLength([a, b])) }, { label: "앞 선 기울기", value: percent(groundSlopePct(a, b, z)) }, ); } const entry = network.lines.find((l) => l.id === line)!; if (nodeOff(entry, vertex, z)) rows.push({ label: "기록", value: "⚠ 지금 자리와 어긋남" }); const parts: HTMLElement[] = [createKv(rows)]; if (vertex > 0) parts.push(nodeKind(network.lines.indexOf(entry), vertex)); const toPoint = button("지점으로", "props-to-point", () => params.addPoint?.([point[0], point[1]]), ); toPoint.hidden = !featureOn(page, "points") || !params.addPoint || points().some((p) => samePoint(p, point)); return { title: `${nameOf(line)} · 노드 ${vertex + 1}`, parts, actions: [ button("노드 지움", "props-drop-node", () => edit.dropNode(picked.node!)), toPoint, ...(featureOn(page, "join") && edit.isEnd(picked.node!) ? [button("잇기", "props-join", () => edit.startJoin(picked.node!))] : []), ], }; } if (picked.kind === "segment") { const single = picked.segments.length === 1 ? picked.segments[0] : null; const ids = segmentPieces(network, picked.segments); const inSegments = network.pieces.filter((p) => ids.includes(p.id)); const ranges = picked.segments.flatMap((seg) => { const range = segmentRange(network, seg); return range ? [{ line: seg.line, from_m: range[0], to_m: range[1] }] : []; }); const rows: KvRow[] = [{ label: "길이", value: meters(length) }]; if (single) { const [from, to] = segmentRange(network, single)!; const [a, b] = segmentPath(network, single)!; rows.unshift({ label: "측점", value: `${station(from)} ~ ${station(to)}` }); rows.push({ label: "지반 기울기", value: percent(groundSlopePct(a, b, z)) }); } const parts: HTMLElement[] = [createKv(rows)]; if (yearsShown(network)) parts.push(setField(inSegments, ranges)); const actions = [button("선 지움", "props-drop-segment", edit.dropSelected)]; if (single) actions.unshift( button("다시 긋기", "props-respan", () => params.respan(single.line, single.index, single.index + 1), ), ); return { title: single ? `${nameOf(single.line)} · 선 ${single.index + 1}` : `선 ${picked.segments.length}개`, parts, actions, }; } if (picked.kind === "line") { const vertices = network.lines.find((l) => l.id === picked.line)?.vertices ?? []; return { title: nameOf(picked.line), parts: [ createKv([ { label: "길이", value: meters(length) }, { label: "노드", value: `${vertices.length}개` }, ]), ...(yearsShown(network) ? [setField(pieces, rangesOf(pieces)), pieceRows(network, pieces)] : []), ], actions: [button("구역 지움", "props-drop-line", () => edit.dropRegion(picked.line!))], }; } if (picked.kind === "all") { const byYear = new Map(); for (const p of network.pieces) byYear.set(p.year, (byYear.get(p.year) ?? 0) + p.to_m - p.from_m); return { title: "전체", parts: [ createKv([ { label: "길이", value: meters(length) }, { label: "구역", value: `${network.lines.length}개` }, ...[...byYear] .sort(([a], [b]) => a - b) .map(([year, m]) => ({ label: `${year}년`, value: meters(m) })), ]), ], actions: [button("전체 지움", "props-drop-all", edit.dropAll)], }; } // 연도 구간 · 마커 const lineIds = new Set(pieces.map((piece) => piece.line)); const total = pieces.reduce((sum, piece) => sum + piece.to_m - piece.from_m, 0); const parts: HTMLElement[] = [setField(pieces, rangesOf(pieces)), pieceRows(network, pieces)]; const actions: HTMLButtonElement[] = []; const mark = network.pieces.find((p) => p.id === picked.mark); if (mark) { const before = network.pieces.find( (p) => p.line === mark.line && Math.abs(p.to_m - mark.from_m) < 1e-3, ); parts.unshift( createKv([ { label: "측점", value: station(mark.from_m) }, { label: "앞 · 뒤 세트", value: `${before ? setKey(before) : "-"} · ${setKey(mark)}` }, ]), ); actions.push(button("마커 지움", "props-drop-mark", edit.dropMark)); } return { title: `구간 ${pieces.length} · 구역 ${lineIds.size} · ${meters(total)}`, parts, actions, }; } function renderPanel(): void { const picked = edit.selected(); float.root.hidden = !picked; if (!picked) return float.body.replaceChildren(); const { title, parts, actions } = body(picked, params.network()); const head = float.root.querySelector(".ui-float-panel__title"); if (head) head.textContent = title; float.root.dataset.kind = picked.kind; const row = document.createElement("div"); row.className = "d03-plan__row"; if (params.editable()) row.append(...actions); float.body.replaceChildren(...parts, row); } return { panel: float.root, render() { renderPanel(); }, setOptions, assign, }; }