Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
178 lines
7.5 KiB
TypeScript
178 lines
7.5 KiB
TypeScript
/* =============================================================================
|
||
* B08_Quantity_Options_Calc.ts
|
||
* 좌측 패널 옵션 — 조작 중 브라우저가 바로 다시 세는 식(화면 없음 · PLAN 122).
|
||
*
|
||
* 짝(파이썬 · 같은 식) — 거울 시험 `test_122_b08_options_mirror`:
|
||
* couplings · recountCouplings ↔ `B08_Quantity_Router_Summary.py` couplings(관마다 `pipes`)
|
||
* rockSplit · splitRockColumns · splitRockSummary
|
||
* ↔ `B08_Quantity_Engine_Earthwork_Summary.py` rock_split ·
|
||
* split_rock_columns · summary_rows(연암 · 경암 네 줄)
|
||
* mixedRockC ↔ `common_util_project_settings.py` mixed_rock_c(125-1)
|
||
* recountRockC · recountAdjustedSummary
|
||
* ↔ `B08_Quantity_Engine_Earthwork.py` build_rows · totals ·
|
||
* summary_rows(보정량계 · 유용토 · 잔토처리 줄)
|
||
* [저장] 뒤에는 서버가 정본으로 다시 셈 — 여기 값을 받아 적지 않음.
|
||
* import 없음 — 시험이 이 파일만 옮겨 돌림.
|
||
* ========================================================================== */
|
||
|
||
/** 커플링 = 올림(L ÷ 1본) − 1(0 아래 없음) · 길이나 1본이 빈칸이면 null. */
|
||
export function couplings(lengthM: number | null | undefined, oneM: number | null): number | null {
|
||
if (!lengthM || !oneM) return null;
|
||
return Math.max(Math.ceil(lengthM / oneM - 1e-9) - 1, 0);
|
||
}
|
||
|
||
export interface PipeLength {
|
||
chainage_m: number;
|
||
/** 커플링 열 id(`pipe:cp|관경`) */
|
||
column: string;
|
||
length_m: number | null;
|
||
}
|
||
|
||
interface CouplingSummary {
|
||
rows: Array<{ chainage_m: number; values: Record<string, number> }>;
|
||
total: Record<string, number>;
|
||
pipes: PipeLength[];
|
||
}
|
||
|
||
/** 커플링 열만 새 1본 길이로 — 같은 측점(1 cm)의 같은 열은 더함 · 계 = 줄 합. */
|
||
export function recountCouplings<S extends CouplingSummary>(summary: S, oneM: number | null): S {
|
||
const at = (chainage: number): number => Math.round(chainage * 100);
|
||
const columns = new Set(summary.pipes.map((pipe) => pipe.column));
|
||
const sums = new Map<string, number>();
|
||
for (const pipe of summary.pipes) {
|
||
const count = couplings(pipe.length_m, oneM);
|
||
if (count === null) continue;
|
||
const key = `${at(pipe.chainage_m)}|${pipe.column}`;
|
||
sums.set(key, (sums.get(key) ?? 0) + count);
|
||
}
|
||
const keep = (values: Record<string, number>): Record<string, number> =>
|
||
Object.fromEntries(Object.entries(values).filter(([id]) => !columns.has(id)));
|
||
const rows = summary.rows.map((row) => {
|
||
const values = keep(row.values);
|
||
for (const column of columns) {
|
||
const count = sums.get(`${at(row.chainage_m)}|${column}`);
|
||
if (count !== undefined) values[column] = count;
|
||
}
|
||
return { ...row, values };
|
||
});
|
||
const total = keep(summary.total);
|
||
for (const row of rows)
|
||
for (const column of columns)
|
||
if (row.values[column] !== undefined)
|
||
total[column] = (total[column] ?? 0) + row.values[column];
|
||
return { ...summary, rows, total };
|
||
}
|
||
|
||
/** 0~100 으로 자름 · 숫자가 아니면 null. */
|
||
export function clampPct(value: number): number | null {
|
||
return Number.isFinite(value) ? Math.min(Math.max(value, 0), 100) : null;
|
||
}
|
||
|
||
/** 암 → [연암, 경암] — 연암 = 암 × 비율(0~100 자름) · 경암 = 암 − 연암. */
|
||
export function rockSplit(value: number, softPct: number): [number, number] {
|
||
const soft = (value * Math.min(Math.max(softPct, 0), 100)) / 100;
|
||
return [soft, value - soft];
|
||
}
|
||
|
||
const ROCK_SPLIT = [
|
||
["cut_rock", "cut_soft_rock", "cut_hard_rock"],
|
||
["ditch_rock", "ditch_soft_rock", "ditch_hard_rock"],
|
||
] as const;
|
||
const ROCK_SUFFIXES = ["area_m2", "volume_m3", "adjusted_m3"] as const;
|
||
|
||
/** 줄 · 합계의 암 칸마다 연암 · 경암 칸을 덮어씀(숫자인 칸만). */
|
||
export function splitRockColumns(record: Record<string, unknown>, softPct: number): void {
|
||
for (const [base, softKey, hardKey] of ROCK_SPLIT)
|
||
for (const suffix of ROCK_SUFFIXES) {
|
||
const value = record[`${base}_${suffix}`];
|
||
if (typeof value !== "number") continue;
|
||
const [soft, hard] = rockSplit(value, softPct);
|
||
record[`${softKey}_${suffix}`] = soft;
|
||
record[`${hardKey}_${suffix}`] = hard;
|
||
}
|
||
}
|
||
|
||
/** 토공집계표 연암 · 경암 줄 — 흙깍기 = 절토 암 · 측구 터파기 = 측구 암 합계 × 비율 · 반올림 정수. */
|
||
const ROCK_ROWS: Record<string, string> = {
|
||
흙깍기: "cut_rock_volume_m3",
|
||
"측구 터파기": "ditch_rock_volume_m3",
|
||
};
|
||
|
||
export function splitRockSummary<R extends { group: string; kind: string; value: number | null }>(
|
||
rows: readonly R[],
|
||
totals: Record<string, number>,
|
||
softPct: number,
|
||
): R[] {
|
||
return rows.map((row) => {
|
||
const base = ROCK_ROWS[row.group];
|
||
if (!base || (row.kind !== "연암" && row.kind !== "경암")) return row;
|
||
const [soft, hard] = rockSplit(totals[base] ?? 0, softPct);
|
||
return { ...row, value: Math.round(row.kind === "연암" ? soft : hard) };
|
||
});
|
||
}
|
||
|
||
/** 암 C = 연암 % × 리핑암 C + 경암 % × 발파암 C(0~100 자름) — 연암 = 리핑 · 경암 = 발파 고정(125-1). */
|
||
export function mixedRockC(softPct: number, rippingC: number, blastingC: number): number {
|
||
const soft = Math.min(Math.max(softPct, 0), 100) / 100;
|
||
return soft * rippingC + (1 - soft) * blastingC;
|
||
}
|
||
|
||
const num = (value: unknown): number => (typeof value === "number" ? value : 0);
|
||
const RECOUNT_TOTALS = [
|
||
"cut_rock_adjusted_m3",
|
||
"ditch_rock_adjusted_m3",
|
||
"adjusted_total_m3",
|
||
"diverted_m3",
|
||
"balance_m3",
|
||
] as const;
|
||
|
||
/** 토적표를 새 암 C 로 — 암 보정량 · 보정량계 · 유용토 · 차인 · 누가 · 합계(줄 · 합계 덮어씀). */
|
||
export function recountRockC(
|
||
rows: Array<Record<string, unknown>>,
|
||
totals: Record<string, number>,
|
||
c: number,
|
||
): void {
|
||
let cumulative = 0;
|
||
for (const row of rows) {
|
||
const cutRock = num(row.cut_rock_volume_m3) * c;
|
||
const adjusted = num(row.cut_soil_adjusted_m3) + cutRock;
|
||
const fill = num(row.fill_volume_m3);
|
||
cumulative += adjusted - fill;
|
||
Object.assign(row, {
|
||
cut_rock_adjusted_m3: cutRock,
|
||
ditch_rock_adjusted_m3: num(row.ditch_rock_volume_m3) * c,
|
||
adjusted_total_m3: adjusted,
|
||
diverted_m3: Math.min(adjusted, fill),
|
||
balance_m3: adjusted - fill,
|
||
cumulative_m3: cumulative,
|
||
});
|
||
}
|
||
const sum = (key: string, picked = rows): number =>
|
||
picked.reduce((total, row) => total + num(row[key]), 0);
|
||
for (const key of RECOUNT_TOTALS) totals[key] = sum(key);
|
||
for (const kind of ["ripping_rock", "blasting_rock"])
|
||
totals[`cut_${kind}_adjusted_m3`] = sum(
|
||
"cut_rock_adjusted_m3",
|
||
rows.filter((row) => row.cut_rock_kind === kind),
|
||
);
|
||
}
|
||
|
||
/** 엑셀 ROUND(정수) — 반은 0 에서 먼 쪽. */
|
||
const excelRound = (value: number): number => Math.sign(value) * Math.round(Math.abs(value));
|
||
|
||
/** 토공집계표 보정량계 · 유용토 소계 · 횡방향유용토 · 잔토처리 줄 — 합계에서 다시(종방향 무대 Q 는 그대로). */
|
||
export function recountAdjustedSummary<R extends { group: string; value: number | null }>(
|
||
rows: readonly R[],
|
||
totals: Record<string, number>,
|
||
freeQ: number,
|
||
): R[] {
|
||
const diverted = totals.diverted_m3 ?? 0;
|
||
const values: Record<string, number> = {
|
||
보정량계: excelRound(totals.adjusted_total_m3 ?? 0),
|
||
"유용토 소계": diverted + freeQ,
|
||
횡방향유용토: diverted,
|
||
잔토처리: excelRound(totals.balance_m3 ?? 0),
|
||
};
|
||
return rows.map((row) => (row.group in values ? { ...row, value: values[row.group] } : row));
|
||
}
|