PLAN 8-4b 열 명세를 화면에 세움. 일감 2 의 마지막 덩어리(엔진 → API → 화면). 왜 실무 서식 그대로인가 이 화면의 첫 사용자는 「프로그램이 맞나」를 확인하려는 설계자임. 보기 좋게 재배치하면 실무 산출서와 눈으로 대조를 못 함. 열 순서·머리글 문구를 실무 토적표(= 오솔길 1.BOM 36열)에 맞춤. 소수 자리는 표기 규칙일 뿐 (PLAN 8-16) 서버는 전정밀 값을 주고 자르는 것은 화면뿐임. 실무 시트 관측대로 단면적·체적 2자리 · 보정량계·유용토·차인·누가 1자리 · 거리 정수. 원가 쪽(줄마다 원 단위 절사)과 규칙이 반대라 그 코드를 옮기지 말 것. 구성 좌측 = 산출 조건 읽기 전용(산출법·토량환산계수). 계수는 서버 상수가 유일 정의처라 화면이 값을 다시 적지 않음. 우측 = 시트 탭 + 표. 지금 서는 장은 토적표 하나, 나머지 6장은 차례로 붙임. 표는 한 번만 받아 좌측·우측이 함께 씀(중복 호출 없음). 측점 열·머리글은 sticky — 가로로 굴러도 어느 줄인지 보임. locale 은 B08 키 8개만 추가 (다른 창과 겹치는 파일이라 기존 줄 무접촉). 검증 — 공용 브라우저 실화면. 타입검사 오류 0. 탭 「토적표」, 3단 머리글 3행(9/5/14셀), 본문 65행 x 20열, 합계행 표시. 둘째 줄이 API 값과 일치: NO.1 거리 20 · 절토토사 1.96/22.34/20.10 · 암 2.54/25.43/29.24 · 측구 0.08/2.58/2.33 + 0.10/1.02/1.17 · 보정량계 52.8 · 성토 4.82/48.79 · 유용 48.8 · 차인 4.0 · 누가 4.0. ⚠ 어두운 테마에서 머리글이 묻히던 것을 프로젝트 테마 변수로 바꿔 고침 — 대비 실측 머리글 10.51:1 · 본문 11.32:1 (기준 4.5:1). 조작으로 바꾼 화면은 사용자가 보던 주소로 되돌려 놓음. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
234 lines
8.1 KiB
TypeScript
234 lines
8.1 KiB
TypeScript
/* =============================================================================
|
|
* B08_Quantity_UI_EarthworkGrid.ts
|
|
* 토적표 그리드 — 실무 토적표(3단 머리글)를 그대로 그린다 (PLAN 8-4b).
|
|
*
|
|
* 왜 실무 서식 그대로인가
|
|
* 이 화면의 첫 사용자는 「프로그램이 맞나」를 확인하려는 설계자다. 보기 좋게 재배치하면
|
|
* 실무 산출서와 눈으로 대조를 못 한다. 열 순서·머리글 문구를 실무 시트에 맞춘다.
|
|
*
|
|
* ⚠ 소수 자리는 표기 규칙일 뿐이다 (PLAN 8-16)
|
|
* 서버는 전정밀 값을 준다. 자르는 것은 여기(화면)뿐이다. 실무 시트 관측 그대로
|
|
* 단면적·체적 2자리 · 보정량계·유용토·차인·누가 1자리 · 거리 정수로 보인다.
|
|
* 원가 쪽(줄마다 원 단위 절사)과 규칙이 반대이므로 그 코드를 여기로 옮기지 말 것.
|
|
* ========================================================================== */
|
|
|
|
/** 서버가 주는 토적표 한 줄. 이름은 엔진(`B08_Quantity_Engine_EarthworkTable.py`)과 같다. */
|
|
export interface EarthworkRow {
|
|
chainage_m: number;
|
|
distance_m: number;
|
|
cut_soil_area_m2: number;
|
|
cut_soil_volume_m3: number;
|
|
cut_soil_adjusted_m3: number;
|
|
cut_rock_area_m2: number;
|
|
cut_rock_volume_m3: number;
|
|
cut_rock_adjusted_m3: number;
|
|
ditch_soil_area_m2: number;
|
|
ditch_soil_volume_m3: number;
|
|
ditch_soil_adjusted_m3: number;
|
|
ditch_rock_area_m2: number;
|
|
ditch_rock_volume_m3: number;
|
|
ditch_rock_adjusted_m3: number;
|
|
adjusted_total_m3: number;
|
|
fill_area_m2: number;
|
|
fill_volume_m3: number;
|
|
diverted_m3: number;
|
|
balance_m3: number;
|
|
cumulative_m3: number;
|
|
}
|
|
|
|
export interface EarthworkTable {
|
|
method: string;
|
|
station_count: number;
|
|
route_id?: number;
|
|
rows: EarthworkRow[];
|
|
totals: Record<string, number>;
|
|
}
|
|
|
|
/** 열 하나. `digits` 는 **표기 자리**이며 값 자체는 자르지 않는다. */
|
|
interface Column {
|
|
key: keyof EarthworkRow;
|
|
digits: number;
|
|
/** 합계행에 낼지 — 단면적은 합이 뜻이 없어 비운다(실무 시트도 비어 있다). */
|
|
sum?: boolean;
|
|
}
|
|
|
|
/** 실무 토적표 3단 머리글. 대분류 → 중분류 → 소분류 순서가 곧 열 순서다. */
|
|
const GROUPS: { label: string; sub: { label: string; cols: Column[] }[] }[] = [
|
|
{ label: "", sub: [{ label: "측 점", cols: [{ key: "chainage_m", digits: 0 }] }] },
|
|
{ label: "", sub: [{ label: "거 리", cols: [{ key: "distance_m", digits: 0, sum: true }] }] },
|
|
{
|
|
label: "절 토",
|
|
sub: [
|
|
{
|
|
label: "토 사",
|
|
cols: [
|
|
{ key: "cut_soil_area_m2", digits: 2 },
|
|
{ key: "cut_soil_volume_m3", digits: 2, sum: true },
|
|
{ key: "cut_soil_adjusted_m3", digits: 2, sum: true },
|
|
],
|
|
},
|
|
{
|
|
label: "암 석",
|
|
cols: [
|
|
{ key: "cut_rock_area_m2", digits: 2 },
|
|
{ key: "cut_rock_volume_m3", digits: 2, sum: true },
|
|
{ key: "cut_rock_adjusted_m3", digits: 2, sum: true },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: "측 구 터 파 기",
|
|
sub: [
|
|
{
|
|
label: "토 사",
|
|
cols: [
|
|
{ key: "ditch_soil_area_m2", digits: 2 },
|
|
{ key: "ditch_soil_volume_m3", digits: 2, sum: true },
|
|
{ key: "ditch_soil_adjusted_m3", digits: 2, sum: true },
|
|
],
|
|
},
|
|
{
|
|
label: "암 석",
|
|
cols: [
|
|
{ key: "ditch_rock_area_m2", digits: 2 },
|
|
{ key: "ditch_rock_volume_m3", digits: 2, sum: true },
|
|
{ key: "ditch_rock_adjusted_m3", digits: 2, sum: true },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
label: "",
|
|
sub: [{ label: "보정량계", cols: [{ key: "adjusted_total_m3", digits: 1, sum: true }] }],
|
|
},
|
|
{
|
|
label: "성 토",
|
|
sub: [
|
|
{
|
|
label: "",
|
|
cols: [
|
|
{ key: "fill_area_m2", digits: 2 },
|
|
{ key: "fill_volume_m3", digits: 2, sum: true },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{ label: "", sub: [{ label: "유 용 토", cols: [{ key: "diverted_m3", digits: 1, sum: true }] }] },
|
|
{ label: "", sub: [{ label: "차인토량", cols: [{ key: "balance_m3", digits: 1, sum: true }] }] },
|
|
{ label: "", sub: [{ label: "누가토량", cols: [{ key: "cumulative_m3", digits: 1 }] }] },
|
|
];
|
|
|
|
/** 소분류가 「단면적·입적·보정량」 삼중조일 때 붙는 3단 머리글 문구. */
|
|
const TRIPLE_LABELS = ["단면적", "입 적", "보정량"];
|
|
const PAIR_LABELS = ["단면적", "입 적"];
|
|
|
|
const flatColumns = (): Column[] => GROUPS.flatMap((g) => g.sub.flatMap((s) => s.cols));
|
|
|
|
/** 측점 표기 — `20` → `NO.1`, `25` → `NO.1+5`. 실무 토적표가 이 모양이다. */
|
|
function stationLabel(chainage: number, interval = 20): string {
|
|
const no = Math.floor(chainage / interval);
|
|
const plus = chainage - no * interval;
|
|
const rounded = Math.round(plus * 100) / 100;
|
|
return rounded === 0 ? `NO.${no}` : `NO.${no}+${rounded}`;
|
|
}
|
|
|
|
function cell(value: number | undefined, digits: number): string {
|
|
if (value === undefined || value === null || Number.isNaN(value)) return "";
|
|
if (value === 0) return "";
|
|
return value.toLocaleString("ko-KR", {
|
|
minimumFractionDigits: digits,
|
|
maximumFractionDigits: digits,
|
|
});
|
|
}
|
|
|
|
function buildHead(): HTMLTableSectionElement {
|
|
const head = document.createElement("thead");
|
|
const r1 = document.createElement("tr");
|
|
const r2 = document.createElement("tr");
|
|
const r3 = document.createElement("tr");
|
|
|
|
for (const group of GROUPS) {
|
|
const span = group.sub.reduce((n, s) => n + s.cols.length, 0);
|
|
if (group.label) {
|
|
const th = document.createElement("th");
|
|
th.colSpan = span;
|
|
th.textContent = group.label;
|
|
r1.append(th);
|
|
for (const sub of group.sub) {
|
|
const th2 = document.createElement("th");
|
|
th2.colSpan = sub.cols.length;
|
|
th2.textContent = sub.label;
|
|
r2.append(th2);
|
|
const labels = sub.cols.length === 3 ? TRIPLE_LABELS : PAIR_LABELS;
|
|
sub.cols.forEach((_, index) => {
|
|
const th3 = document.createElement("th");
|
|
th3.textContent = labels[index] ?? "";
|
|
r3.append(th3);
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
// 대분류가 없는 열(측점·거리·보정량계·유용토·…)은 세 줄을 하나로 합친다.
|
|
for (const sub of group.sub) {
|
|
const th = document.createElement("th");
|
|
th.colSpan = sub.cols.length;
|
|
th.rowSpan = 3;
|
|
th.textContent = sub.label;
|
|
r1.append(th);
|
|
}
|
|
}
|
|
head.append(r1, r2, r3);
|
|
return head;
|
|
}
|
|
|
|
function buildBody(rows: EarthworkRow[]): HTMLTableSectionElement {
|
|
const body = document.createElement("tbody");
|
|
const columns = flatColumns();
|
|
for (const row of rows) {
|
|
const tr = document.createElement("tr");
|
|
columns.forEach((column, index) => {
|
|
const td = document.createElement("td");
|
|
td.textContent =
|
|
index === 0 ? stationLabel(row.chainage_m) : cell(row[column.key], column.digits);
|
|
if (index === 0) td.className = "b08-grid__station";
|
|
tr.append(td);
|
|
});
|
|
body.append(tr);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
function buildFoot(totals: Record<string, number>): HTMLTableSectionElement {
|
|
const foot = document.createElement("tfoot");
|
|
const tr = document.createElement("tr");
|
|
flatColumns().forEach((column, index) => {
|
|
const td = document.createElement("td");
|
|
if (index === 0) td.textContent = "계";
|
|
else if (column.sum) td.textContent = cell(totals[column.key], column.digits);
|
|
tr.append(td);
|
|
});
|
|
foot.append(tr);
|
|
return foot;
|
|
}
|
|
|
|
/** 토적표 하나를 그린다. 넓은 표라 스스로 가로 스크롤한다. */
|
|
export function renderEarthworkGrid(table: EarthworkTable): HTMLElement {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "b08-grid";
|
|
|
|
const caption = document.createElement("p");
|
|
caption.className = "b08-grid__caption";
|
|
caption.textContent = `측점 ${table.station_count}곳 · 평균단면적법`;
|
|
wrap.append(caption);
|
|
|
|
const scroller = document.createElement("div");
|
|
scroller.className = "b08-grid__scroll";
|
|
const element = document.createElement("table");
|
|
element.className = "b08-grid__table";
|
|
element.append(buildHead(), buildBody(table.rows), buildFoot(table.totals));
|
|
scroller.append(element);
|
|
wrap.append(scroller);
|
|
return wrap;
|
|
}
|