Files
Aislo/B06_Section/B06_Section_UI_Cross_Detail_Table.ts
T
eomsangdonandClaude Opus 5.5 10eb54f5e5 feat(B06): [상세] 그래프 고르기 — 교차점 분할 · 여러 고름(원지반 포함) · 닫힌 넓이 · 고른 것 값 · 서버 조각 검산 (209-1)
- 선(설계 · 구조물 · 원지반 · 사토장)을 만나는 점에서 조각으로 나눔 · 조각마다 판정 선
- 누름 = 하나 · Shift/Ctrl+누름 = 더하기/빼기 · [+ − ⤢] 왼쪽 값 두 줄(길이 합 · 면적 · 닫히지 않음)
- 칸 넣기 = 우클릭 고르기 길 그대로 · 길이 한 번 누름 = 바로 · 여럿 = Enter · [칸에 넣기]
- 저장 = 조각 두 끝 목록(pieces) · 서버가 설계선 · 원지반선 · 사토장선 위 검산 · 구조물 선은 두 끝 직선 · 옛 두 끝 꼴 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-10 15:21:50 +09:00

431 lines
18 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`.
* 고르기: 면적 = 면적 띠 · 사토장(띠 → 표 키) 또는 닫힌 선 조각들 · 길이 = 선 조각(들) —
* 조각 = 선이 만나는 점에서 나눈 것(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<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 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<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.
* `samples` = 측점 원지반(조각 검산 · 209-1). */
export function localCellValue(
source: CrossCellSource,
design: CrossDesign | undefined,
samples?: ReadonlyArray<SectionSample>,
) {
const record = (design ?? {}) as unknown as Record<string, unknown>;
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<typeof createMapContextMenu> | 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<HTMLElement>(`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<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 {
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(),
};
}