/* ============================================================================= * B06_Section_UI_Cross_Detail_Table.ts * [상세] 수량표 손질(179-4) — 칸 우클릭 메뉴 · 그래프에서 고르기 · 행 · 열 더함 / 지움 · 세션 초안. * * 칸 출처 = 횡단 설계 patch `detail_table`(세션 초안 `detail-table` → [저장] patch → 서버). * 값은 서버(`B07_DesignDetail_Engine_Cross_Table_Edit`)가 그릴 때마다 다시 셈 — 모달 · B07 도면 표 한 벌. * 조작 직후 그 칸 글만 `localCellValue` 로 즉시 — 짝(① 파이썬 · TS) · 거울 시험 * `resources/tester/test_179_4_detail_table.py`. * 고르기: 면적 = 면적 띠 · 사토장(띠 → 표 키) · 길이 = 보라 판정 선 두 끝(화면 px → m · 축 옵션). * ========================================================================== */ import { readState, writeState } from "../A00_Common/b_page_state"; import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { CrossCellSource, CrossDesign, CrossDetailTable, CrossSection, } from "./B06_Section_Api_Fetch"; import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; import { CROSS_PAD } from "./B06_Section_UI_Section_Common"; /** 서버 `MAX_ROWS` · `MAX_COLUMNS` · `LINE_TOLERANCE_M` 와 같은 값. */ export const MAX_ROWS = 12; export const MAX_COLUMNS = 4; export const LINE_TOLERANCE_M = 0.05; /* ── 세션 초안(측점키 0.01 m → 표 손질) ─────────────────────────────────── */ type Drafts = Record; const tableKey = (chainageM: number): string => chainageM.toFixed(2); /** [저장]이 patch 로 싣는 표 손질 — 측점(m) → 표 손질. */ export function readDetailTables( projectId: string, routeId: number, ): Map { const raw = readState("detail-table", projectId, routeId) ?? {}; return new Map(Object.entries(raw).map(([key, table]) => [Number(key), table])); } /** 그 측점 표 손질 — 세션 초안 → 캐시 설계 → 빈 것(복사본). */ export function detailTableOf( section: CrossSection, projectId?: string, routeId?: number | null, ): CrossDetailTable { const draft = projectId && routeId !== null && routeId !== undefined ? readState("detail-table", projectId, routeId)?.[tableKey(section.chainage_m)] : undefined; const table = draft ?? section.design?.detail_table; return table ? structuredClone(table) : { cells: {}, rows: [], columns: [] }; } /** 캐시 설계에도 얹음(모달 표 요청 · B05 [저장]이 같은 값을 실음) + 세션 초안. */ export function writeDetailTable( section: CrossSection, table: CrossDetailTable, projectId?: string, routeId?: number | null, ): void { if (section.design) section.design.detail_table = table; if (!projectId || routeId === null || routeId === undefined) return; const all = readState("detail-table", projectId, routeId) ?? {}; all[tableKey(section.chainage_m)] = table; writeState("detail-table", all, projectId, routeId); } /** 표 요청에 실을 설계 — 표 손질을 얹음(카드가 다시 그려져 설계 객체가 바뀌어도 초안 그대로). */ export function designForTable( section: CrossSection, projectId?: string, routeId?: number | null, ): CrossDesign | undefined { if (!section.design) return undefined; return { ...section.design, detail_table: detailTableOf(section, projectId, routeId) }; } /* ── 표 손질(순수) ──────────────────────────────────────────────────────── */ const nextId = (prefix: string, used: string[]): string => { let n = 1; while (used.includes(`${prefix}${n}`)) n += 1; return `${prefix}${n}`; }; export function setCell(table: CrossDetailTable, key: string, source: CrossCellSource | null) { if (source) table.cells[key] = source; else delete table.cells[key]; } /** 행 더함 — `after` 줄 아래(null = 표 끝) · grouped = 그 줄 묶음 안. 새 id. */ export function addRow(table: CrossDetailTable, after: string | null, grouped: boolean): string { const id = nextId( "r", table.rows.map((row) => row.id), ); table.rows.push({ id, after, grouped, labels: ["", "", ""] }); return id; } export function addColumn(table: CrossDetailTable): string { const id = nextId( "c", table.columns.map((column) => column.id), ); table.columns.push({ id, label: "" }); return id; } /** 더한 행 지움 — 그 행 칸 · 열 칸도 지우고 그 아래 붙던 행은 지운 행 자리로. */ export function removeRow(table: CrossDetailTable, rowId: string): void { const removed = table.rows.find((row) => row.id === rowId); if (!removed) return; table.rows = table.rows .filter((row) => row.id !== rowId) .map((row) => (row.after === rowId ? { ...row, after: removed.after } : row)); for (const key of Object.keys(table.cells)) if (key.startsWith(`r:${rowId}:`) || (key.startsWith("c:") && key.endsWith(`:${rowId}`))) delete table.cells[key]; } export function removeColumn(table: CrossDetailTable, columnId: string): void { table.columns = table.columns.filter((column) => column.id !== columnId); for (const key of Object.keys(table.cells)) if (key.startsWith(`c:${columnId}:`)) delete table.cells[key]; } export function setRowLabel(table: CrossDetailTable, rowId: string, slot: number, text: string) { const row = table.rows.find((entry) => entry.id === rowId); if (!row) return; const labels = [0, 1, 2].map((index) => row.labels[index] ?? ""); labels[slot] = text; row.labels = labels; } export function setColumnLabel(table: CrossDetailTable, columnId: string, text: string) { const column = table.columns.find((entry) => entry.id === columnId); if (column) column.label = text; } /** 묶음 이름 칸이 덮는 줄 수 — 서버 `group_span` 과 같은 규칙. */ export function groupSpan(slots: ReadonlyArray>, index: number) { let span = 1; while ( index + span < slots.length && slots[index + span][0] === null && slots[index + span][1] !== null ) span += 1; return span; } /* ── 그래프 고르기 → 출처 ───────────────────────────────────────────────── */ /** 띠 · 사토장 클래스 → 표 키(서버 `AREA_KEYS` 의 표 키). */ const AREA_BANDS: ReadonlyArray<[string, string]> = [ ["b06-chart__area--cut_soil", "cut_soil"], ["b06-chart__area--cut_rock", "cut_rock"], ["b06-chart__area--fill", "embankment"], ["b06-chart__spoil-fill", "spoil_fill"], ]; export const AREA_PICK_SELECTOR = ".b06-chart__area, .b06-chart__spoil-fill"; export const LENGTH_PICK_SELECTOR = ".b06-chart__design-hit"; export function areaSourceOf(target: Element): CrossCellSource | null { const hit = target.closest(AREA_PICK_SELECTOR); const band = hit && AREA_BANDS.find(([name]) => hit.classList.contains(name)); return band ? { src: "area", key: band[1] } : null; } const round4 = (value: number): number => Math.round(value * 1e4) / 1e4; /** 판정 선 두 끝 → [offset, 표고](m) — 도형 층 좌표(카드 `x` · `toDisplayY` 의 역). */ export function lengthSourceOf(target: Element, axes: CrossAxesOptions): CrossCellSource | null { const hit = target.closest(LENGTH_PICK_SELECTOR); const points = (hit?.getAttribute("points") ?? "") .trim() .split(/\s+/) .map((pair) => pair.split(",").map(Number)); if (points.length < 2 || points.some((p) => p.length !== 2 || !p.every(Number.isFinite))) return null; const toM = ([px, py]: number[]): [number, number] => { const display = axes.displayMax - (py - CROSS_PAD.top) / axes.pixelsPerMeter; return [ round4(axes.maxOffset - (px - CROSS_PAD.left) / axes.pixelsPerMeter), round4(axes.elevationMid + (display - axes.elevationMid) / axes.exaggeration), ]; }; return { src: "length", start: toM(points[0]), end: toM(points[points.length - 1]) }; } /* ── 칸 값(짝 ① · 서버 `cell_value` 거울) ─────────────────────────────────── */ const AREA_DESIGN: Readonly> = { cut_soil: "cut_soil_area_m2", cut_rock: "cut_rock_area_m2", embankment: "fill_area_m2", ditch_soil: "ditch_soil_area_m2", ditch_rock: "ditch_rock_area_m2", spoil_fill: "spoil_fill_area_m2", }; const finite = (value: unknown): number | null => typeof value === "number" && Number.isFinite(value) ? value : null; function onLine([px, py]: [number, number], line: ReadonlyArray<[number, number]>): boolean { for (let i = 0; i + 1 < line.length; i++) { const [ax, ay] = line[i]; const [bx, by] = line[i + 1]; const dx = bx - ax; const dy = by - ay; const length2 = dx * dx + dy * dy; const t = length2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / length2)); if (Math.hypot(px - (ax + t * dx), py - (ay + t * dy)) <= LINE_TOLERANCE_M) return true; } return false; } /** 조작 직후 칸 숫자(서버가 [저장] · 다시 받을 때 정본으로 다시 셈) — 글 · 못 셈 = null. */ export function localCellValue(source: CrossCellSource, design: CrossDesign | undefined) { const record = (design ?? {}) as unknown as Record; if (source.src === "area") return finite(record[AREA_DESIGN[source.key] ?? ""]); if (source.src !== "length") return null; const line = (design?.design_line ?? []) .map((p): [number, number] => [p.offset_m, p.elevation_m]) .filter((p) => p.every(Number.isFinite)); const { start, end } = source; if (!onLine(start, line) || !onLine(end, line)) return null; return Math.hypot(end[0] - start[0], end[1] - start[1]); } /* ── 우클릭 메뉴 ───────────────────────────────────────────────────────── */ /** 우클릭한 칸 — 값 칸 키 · 본문 줄 id · 더한 열 id. */ export interface CellTarget { key: string | null; rowId: string | null; columnId: string | null; } export interface CellActions { pick: (kind: "area" | "length") => void; auto: () => void; addRow: (after: string | null, grouped: boolean) => void; addColumn: () => void; removeRow: (rowId: string) => void; removeColumn: (columnId: string) => void; } export function cellMenuItems( table: CrossDetailTable, target: CellTarget, act: CellActions, ): MapContextMenuItem[] { const items: MapContextMenuItem[] = []; const { key, rowId, columnId } = target; if (key) { items.push( ["그래프에서 고르기 — 면적", () => act.pick("area")], ["그래프에서 고르기 — 길이", () => act.pick("length")], ); if (table.cells[key]) items.push(["자동값으로", act.auto]); } items.push("-"); if (table.rows.length < MAX_ROWS) { if (rowId) items.push(["행 더함 — 이 행 아래(묶음 안)", () => act.addRow(rowId, true)]); items.push(["행 더함 — 표 끝", () => act.addRow(null, false)]); } if (table.columns.length < MAX_COLUMNS) items.push(["열 더함 — 오른쪽 끝", act.addColumn]); items.push("-"); if (rowId?.startsWith("r")) items.push(["이 행 지움", () => act.removeRow(rowId)]); if (columnId) items.push(["이 열 지움", () => act.removeColumn(columnId)]); return items; } /* ── 모달 배선 ─────────────────────────────────────────────────────────── */ export interface DetailTableHost { tableBox: HTMLElement; cad: HTMLElement; section: () => CrossSection; projectId: string | undefined; routeId: () => number | null | undefined; axes: () => CrossAxesOptions | null; /** 표 손질이 바뀜 — 서버 표를 다시 받음. */ changed: () => void; } let menu: ReturnType | null = null; const PICK_HINT = { area: "면적 띠를 누름 · Esc 취소", length: "보라 선을 누름 · Esc 취소", } as const; /** 표 우클릭 · 그래프 고르기 · 더한 칸 글 손질을 검. 돌려준 함수 = 떼기(모달 닫힐 때). */ export function attachDetailTable(host: DetailTableHost): () => void { menu ??= createMapContextMenu("b06-detail"); const ctxMenu = menu; if (!ctxMenu.element.isConnected) { ctxMenu.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y) · 모달 위 document.body.append(ctxMenu.element); } const { tableBox, cad } = host; const edit = (change: (table: CrossDetailTable) => void): void => { const section = host.section(); const table = detailTableOf(section, host.projectId, host.routeId()); change(table); writeDetailTable(section, table, host.projectId, host.routeId()); host.changed(); }; let picking: { kind: "area" | "length"; key: string } | null = null; const stopPick = (): void => { picking = null; delete cad.dataset.pick; delete cad.dataset.pickHint; document.removeEventListener("keydown", onKey, true); }; const onKey = (event: KeyboardEvent): void => { if (event.key !== "Escape") return; event.stopPropagation(); // 모달은 그대로 — 고르기만 취소 stopPick(); }; const startPick = (kind: "area" | "length", key: string): void => { picking = { kind, key }; cad.dataset.pick = kind; cad.dataset.pickHint = PICK_HINT[kind]; document.addEventListener("keydown", onKey, true); }; const onPick = (event: MouseEvent): void => { if (!picking) return; const selector = picking.kind === "area" ? AREA_PICK_SELECTOR : LENGTH_PICK_SELECTOR; // 겹친 요소(띠 위 판정 선 등) 아래까지 — 그 자리 맨 위 대상 const hit = document .elementsFromPoint(event.clientX, event.clientY) .find((el) => cad.contains(el) && el.closest(selector)); const axes = host.axes(); const source = !hit ? null : picking.kind === "area" ? areaSourceOf(hit) : axes && lengthSourceOf(hit, axes); if (!source) return; event.stopPropagation(); const { key } = picking; stopPick(); const shown = localCellValue(source, host.section().design); const td = tableBox.querySelector(`td[data-cell="${CSS.escape(key)}"]`); if (td) td.textContent = shown === null ? "다시 고름" : shown.toFixed(2); edit((table) => setCell(table, key, source)); }; cad.addEventListener("click", onPick); const onMenu = (event: MouseEvent): void => { const el = (event.target as Element).closest( "[data-cell], [data-row], [data-col]", ); if (!el || !tableBox.contains(el)) return; event.preventDefault(); const table = detailTableOf(host.section(), host.projectId, host.routeId()); const key = el.dataset.cell ?? null; const items = cellMenuItems( table, { key, rowId: el.dataset.row ?? null, columnId: el.dataset.col ?? null }, { pick: (kind) => key && startPick(kind, key), auto: () => key && edit((next) => setCell(next, key, null)), addRow: (after, grouped) => edit((next) => void addRow(next, after, grouped)), addColumn: () => edit((next) => void addColumn(next)), removeRow: (rowId) => edit((next) => removeRow(next, rowId)), removeColumn: (columnId) => edit((next) => removeColumn(next, columnId)), }, ); ctxMenu.open(event.clientX, event.clientY, items); }; tableBox.addEventListener("contextmenu", onMenu); // 더한 칸 글 — 손 뗄 때(포커스 잃음) 한 번 · Enter = 끝 const onBlur = (event: FocusEvent): void => { const el = event.target as HTMLElement; if (!el.isContentEditable) return; const text = (el.textContent ?? "").trim(); if (text === (el.dataset.shown ?? "")) return; // 안 고침 const { label, colLabel, cell } = el.dataset; if (label) { const [rowId, slot] = label.split(":"); edit((table) => setRowLabel(table, rowId, Number(slot), text)); } else if (colLabel) edit((table) => setColumnLabel(table, colLabel, text)); else if (cell) edit((table) => setCell(table, cell, text ? { src: "text", text } : null)); }; const onEnter = (event: KeyboardEvent): void => { if (event.key === "Enter" && (event.target as HTMLElement).isContentEditable) { event.preventDefault(); (event.target as HTMLElement).blur(); } }; tableBox.addEventListener("focusout", onBlur); tableBox.addEventListener("keydown", onEnter); return () => { stopPick(); ctxMenu.close(); cad.removeEventListener("click", onPick); tableBox.removeEventListener("contextmenu", onMenu); tableBox.removeEventListener("focusout", onBlur); tableBox.removeEventListener("keydown", onEnter); }; }