/* ============================================================================= * B08_Quantity_Summary_Data.ts * 구조물 집계표의 줄 · 열 고르기(화면 없음 · PLAN 61-1). * * 줄 = 구조물 · 배관이 놓인 측점(같은 측점은 한 줄 · 오름차순) * 열 = 저장된 구조물 조합(본체 `summary_item` + 그룹 `group_items`) — 같은 층 · 주인 · 키는 한 열 * 조합 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · 서버 `linked_snapshots` · PLAN 143-1) * 미연결 시설 = 서버 `unlinked`(셈한 칸 기준) — 그 측점도 줄(값 빈칸 · 「미연결」 · PLAN 154-1) * * 읽기만 — B05/B06 저장 자료(structures.json · pipe_points.json)를 고치지 않는다. * 런타임 import 는 `common_util_summary_item` 하나 — 시험이 두 파일을 이어 붙여 돌림. * ========================================================================== */ import { GROUP_JOIN, readGroupItems, readSummaryItem, type SummaryItem, } from "../common_util/common_util_summary_item"; /** 구조물 · 관 지점 한 건 — 필요한 칸만. 구간형은 기준점(`chainage_m`) · 없으면 시점. */ export interface PlacedItem { /** 복사본 폴더를 찾는 구조물 id(관 지점은 `pipe@측점`) — 없으면 복사본 없이 원본 */ structure_id?: string | null; chainage_m?: number | null; start_m?: number | null; summary_item?: unknown; group_items?: unknown; } /** 연결된 사본 한 건(서버 `M02_Item_Snapshot.linked_snapshots` · 143-1) — `경로` = 가져온 때 M02 경로. */ export interface LinkedSnapshot { 항목: SummaryItem; 폴더: string; /** 있는 부분(도면 · 면적 · 일위대가) */ 부분: string[]; 경로: string[]; } /** 사본 있는 조합만 남김 — 본체 = `{구조물 id}` · 그룹 = `{구조물 id}__{그룹}` 사본 · 하나도 없으면 뺌. */ export function linkedOnly( things: readonly T[], linked: Readonly>, ): T[] { const out: T[] = []; for (const thing of things) { const id = thing.structure_id ?? ""; const own = id in linked ? readSummaryItem(thing.summary_item) : null; const groups = Object.fromEntries( Object.entries(readGroupItems(thing.group_items)).filter( ([gid]) => `${id}${GROUP_JOIN}${gid}` in linked, ), ); if (own || Object.keys(groups).length) out.push({ ...thing, summary_item: own, group_items: groups }); } return out; } /** 미연결 시설 한 칸(서버 `unlinked_facilities` · 154) — 본체 `group` = null. */ export interface UnlinkedSlot { /** 사본 id(본체 = 시설 id · 그룹 = `{id}__{그룹}`) */ copy: string; group: string | null; item: SummaryItem | null; linked: boolean; } /** 미연결 시설 — 칸 전부 + 연결 여부 · `facility` = 관 지점 종류(구조물은 null). */ export interface UnlinkedFacility { id: string; type_id: string; name: string; facility: string | null; chainage_m: number; slots: UnlinkedSlot[]; } /** 측점(1cm 안 같은 측점)의 미연결 시설. */ export const unlinkedAt = ( facilities: readonly UnlinkedFacility[], station: number, ): UnlinkedFacility[] => facilities.filter((f) => Math.round(f.chainage_m * 100) === Math.round(station * 100)); /** M02 조합 정보에서 읽은 구분 · 이름 — 못 찾으면 null(저장된 이름으로 보임). */ export interface ComboLabel { group: string; name: string; } export interface ComboColumn { /** `{층}|{주인}|{키}` — 항목 고르기 `rowId` 와 같은 꼴 */ id: string; item: SummaryItem; group: string; name: string; /** M02 에 그 조합이 없음 */ missing: boolean; } export const comboId = (item: SummaryItem): string => `${item.layer}|${item.owner ?? ""}|${item.key}`; function stationOf(thing: PlacedItem): number | null { const value = thing.chainage_m ?? thing.start_m; return typeof value === "number" && Number.isFinite(value) ? value : null; } /** 놓인 측점 — 1cm 안이면 같은 측점 · 오름차순. */ export function summaryStations(things: readonly PlacedItem[]): number[] { const seen = new Map(); for (const thing of things) { const station = stationOf(thing); if (station !== null) seen.set(Math.round(station * 100), station); } return [...seen.values()].sort((a, b) => a - b); } /** 저장된 조합 열 — 구분 · 이름 차례(같은 구분은 붙어 머리 칸을 합침). */ export function summaryColumns( things: readonly PlacedItem[], describe: (item: SummaryItem) => ComboLabel | null, ): ComboColumn[] { const found = new Map(); for (const thing of things) { const own = readSummaryItem(thing.summary_item); for (const item of [...(own ? [own] : []), ...Object.values(readGroupItems(thing.group_items))]) if (!found.has(comboId(item))) found.set(comboId(item), item); } const columns = [...found].map(([id, item]): ComboColumn => { const label = describe(item); return label ? { id, item, ...label, missing: false } : { id, item, group: "", name: item.name || item.key, missing: true }; }); return columns.sort( (a, b) => a.group.localeCompare(b.group, "ko") || a.name.localeCompare(b.name, "ko") || a.id.localeCompare(b.id), ); } /** 한 측점에 놓인 조합 한 건 — 어느 구조물 것인지 · 사본 id(본체 = 구조물 id · 그룹 = `{id}__{그룹}`). */ export interface StationCombo { id: string; item: SummaryItem; structureId: string | null; copyId: string | null; } /** 측점(1cm 안 같은 측점)에 놓인 조합들 — 구조물마다 본체 → 그룹 차례 · 같은 구조물의 같은 조합은 한 건. */ export function combosAt(things: readonly PlacedItem[], station: number): StationCombo[] { const seen = new Set(); const found: StationCombo[] = []; for (const thing of things) { const at = stationOf(thing); if (at === null || Math.round(at * 100) !== Math.round(station * 100)) continue; const own = readSummaryItem(thing.summary_item); const structureId = thing.structure_id ?? null; for (const [gid, item] of [ ...(own ? [["", own] as const] : []), ...Object.entries(readGroupItems(thing.group_items)), ]) { const key = `${structureId} ${comboId(item)}`; const copyId = structureId && (gid ? `${structureId}${GROUP_JOIN}${gid}` : structureId); if (!seen.has(key)) { seen.add(key); found.push({ id: comboId(item), item, structureId, copyId }); } } } return found; } /** 글 폭 추정 — 한글 12px · 그 밖 7px + 여백 16(글 12px · 칸 여백 8 × 2). */ export const textWidth = (text: string): number => [...text].reduce((sum, ch) => sum + (/[ㄱ-ㆎ가-힣]/.test(ch) ? 12 : 7), 16); /** 열 폭 — 맨 아래 단 글 · 칸 글 중 가장 긴 것 · 44~100(넘는 머리는 줄바꿈 · 120-2). */ export const widthOf = (...texts: string[]): number => Math.min(100, Math.max(44, ...texts.map(textWidth))); /** 머리 첫 줄 — 이어진 같은 구분을 한 칸으로 `[구분, 열 수]`. */ export function groupSpans(columns: readonly ComboColumn[]): Array<[string, number]> { const spans: Array<[string, number]> = []; for (const column of columns) { const last = spans[spans.length - 1]; if (last && last[0] === column.group) last[1] += 1; else spans.push([column.group, 1]); } return spans; }