/* ============================================================================= * 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`. * 고르기: 면적 = 면적 띠 · 사토장(띠 → 표 키) 또는 닫힌 선 조각들 · 길이 = 선 조각(들) — * 조각 = 선이 만나는 점에서 나눈 것(209-1 · `_Cross_Detail_Select`) · 저장 = 조각 두 끝 목록. * 길이는 한 번 누름 = 바로 칸에 · Shift/Ctrl+누름 = 여럿 → Enter · [칸에 넣기]. * ========================================================================== */ 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 { SectionSample } from "./B06_Section_Api_Types"; import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; import { pickLines, piecesValue } from "./B06_Section_UI_Cross_Detail_Geom"; import { attachDetailSelect, PIECE_HIT_CLASS } from "./B06_Section_UI_Cross_Detail_Select"; /** 서버 `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 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; } /* ── 칸 값(짝 ① · 서버 `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. * `samples` = 측점 원지반(조각 검산 · 209-1). */ export function localCellValue( source: CrossCellSource, design: CrossDesign | undefined, samples?: ReadonlyArray, ) { const record = (design ?? {}) as unknown as Record; if ("pieces" in source) return piecesValue(source.src, source.pieces, pickLines(design, samples)); 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: "면적 띠를 누름 · 또는 닫힌 영역의 선들을 차례로 누름 → Enter · Esc 취소", length: "선을 누름 · Shift/Ctrl+누름 = 여럿 → Enter · Esc 취소", } as const; /** 표 우클릭 · 그래프 고르기 · 더한 칸 글 손질을 검. detach = 떼기(모달 닫힐 때) · * redraw = 그림을 새로 그린 뒤(선 조각 · 판정 선 다시 · 209-1). */ export function attachDetailTable(host: DetailTableHost): { detach: () => void; redraw: () => 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; select.setMode(null); document.removeEventListener("keydown", onKey, true); }; /** 고른 출처를 칸에 — 칸 글 즉시(짝 ①) · 세션 초안 → 서버 표 다시 받음. */ const commit = (source: CrossCellSource | null): void => { if (!picking || !source) return; const { key } = picking; stopPick(); select.clear(); const section = host.section(); const shown = localCellValue(source, section.design, section.samples); 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)); }; const commitSelection = (): void => { if (picking) commit(select.source(picking.kind)); }; const onKey = (event: KeyboardEvent): void => { if (event.key === "Enter") { event.preventDefault(); commitSelection(); return; } 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]; select.setMode(kind); document.addEventListener("keydown", onKey, true); }; // 선 조각 고름(209-1) — 길이 한 번 누름(더하기 아님) = 바로 칸에 const select = attachDetailSelect({ cad, design: () => host.section().design, axes: host.axes, picked: (additive) => { if (picking?.kind === "length" && !additive) commitSelection(); }, commit: commitSelection, }); // 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로) — 선 조각 누름은 위가 맡음 const onPick = (event: MouseEvent): void => { if (picking?.kind !== "area" || (event.target as Element).closest(`.${PIECE_HIT_CLASS}`)) return; const hit = document .elementsFromPoint(event.clientX, event.clientY) .find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR)); const source = hit ? areaSourceOf(hit) : null; if (!source) return; event.stopPropagation(); commit(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 { detach: () => { stopPick(); ctxMenu.close(); select.detach(); cad.removeEventListener("click", onPick); tableBox.removeEventListener("contextmenu", onMenu); tableBox.removeEventListener("focusout", onBlur); tableBox.removeEventListener("keydown", onEnter); }, redraw: () => select.redraw(), }; }