Files
Aislo/B06_Section/B06_Section_UI_Cross_Detail_Table.ts
T

413 lines
17 KiB
TypeScript

/* =============================================================================
* 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<string, CrossDetailTable>;
const tableKey = (chainageM: number): string => chainageM.toFixed(2);
/** [저장]이 patch 로 싣는 표 손질 — 측점(m) → 표 손질. */
export function readDetailTables(
projectId: string,
routeId: number,
): Map<number, CrossDetailTable> {
const raw = readState<Drafts>("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<Drafts>("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<Drafts>("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<ReadonlyArray<string | null>>, 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<Record<string, string>> = {
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<string, unknown>;
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<typeof createMapContextMenu> | 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<HTMLElement>(`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<HTMLElement>(
"[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);
};
}