Files
Aislo/B08_Quantity/B08_Quantity_Summary_Data.ts
T

227 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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;
}
/** 관 지점 구조물 id — 서버가 낸 id(사본 · 시설 · `pipe@{측점 셋째 자리}`) 가운데 같은 측점(±6mm)의 것 ·
* 없으면 null. 관 지점 응답 측점은 둘째 자리라(B04 `round(…, 2)`) 셋째 자리 id 와 어긋남(167-1). */
export function pipeOwnerId(chainage: number, ids: Iterable<string>): string | null {
for (const id of ids) {
const owner = id.split(GROUP_JOIN)[0];
const at = Number(owner.slice("pipe@".length));
if (owner.startsWith("pipe@") && Math.abs(at - chainage) <= 0.006) return owner;
}
return null;
}
/** 미연결 시설 한 칸(서버 `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;
}