Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
216 lines
8.4 KiB
TypeScript
216 lines
8.4 KiB
TypeScript
/* =============================================================================
|
||
* B08_Quantity_Summary_Data.ts
|
||
* 구조물 집계표의 줄 · 열 고르기(화면 없음 · PLAN 61-1).
|
||
*
|
||
* 줄 = 구조물 · 배관이 놓인 측점(같은 측점은 한 줄 · 오름차순)
|
||
* 열 = 저장된 구조물 조합(본체 `summary_item` + 그룹 `group_items`) — 같은 층 · 주인 · 키는 한 열
|
||
* 조합 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · 서버 `linked_snapshots` · PLAN 143-1)
|
||
* 미연결 시설 = 서버 `unlinked`(셈한 칸 기준) — 그 측점도 줄(값 빈칸 · 「미연결」 · PLAN 154-1)
|
||
* 「미지정」 열 글 · 같은 조합 열 칸 = 빈 조합 칸(`unassignedText` · `unassignedCombos` · 160-2)
|
||
*
|
||
* 읽기만 — 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<T extends PlacedItem>(
|
||
things: readonly T[],
|
||
linked: Readonly<Record<string, LinkedSnapshot>>,
|
||
): 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}`;
|
||
|
||
/** 측점 「미지정」 칸 글 — 시설마다 「이름: 빈 칸 · 빈 칸」(칸 이름 = 그룹 · 본체 = `body` · 160-2). */
|
||
export const unassignedText = (facilities: readonly UnlinkedFacility[], body: string): string =>
|
||
facilities
|
||
.map((f) => {
|
||
const slots = f.slots.filter((slot) => !slot.linked).map((slot) => slot.group ?? body);
|
||
return slots.length ? `${f.name}: ${slots.join(" · ")}` : f.name;
|
||
})
|
||
.join(" / ");
|
||
|
||
/** 빈 칸에 고른 조합 id — 같은 조합 열이 있으면 그 칸에 「미지정」(160-2). */
|
||
export function unassignedCombos(facilities: readonly UnlinkedFacility[]): Set<string> {
|
||
const ids = new Set<string>();
|
||
for (const f of facilities)
|
||
for (const slot of f.slots) {
|
||
const item = slot.linked ? null : readSummaryItem(slot.item);
|
||
if (item) ids.add(comboId(item));
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
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<number, number>();
|
||
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<string, SummaryItem>();
|
||
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<string>();
|
||
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;
|
||
}
|