목록에만 있고 화면에 없으면 사용자는 그것이 잠정인 줄도 모름. 셋 다 「지금 어떤 값으로 돌고 있는지 + 왜 잠정인지」를 함께 보임. - 콘크리트 타설 방식을 좌측 패널 칸으로 냄. ⚠ 금액에 바로 걸리는 값이라 기본값으로 돌고 있으면 「기본값 「레디믹스트」로 계산 중 — 아직 안 정한 값」 안내를 띄움. 설정 기본을 None 으로 바꿔 「안 정함」과 「일부러 레디믹스트를 고른 것」을 가름 — 값을 미리 넣으면 그 구별이 사라짐. 되돌리기도 됨. - 물구멍 근거에 잠정값을 적음 — 「관 Ø 미정(법 3~6㎝ / 실무 Ø50) · 간격 2.0㎡당 1개소(법 2~3㎡당 1개소 이상)」. 「미확정」만으로는 무엇을 정해야 하는지 모름. - 준비공의 벌목 줄에 공종 미확정 사유와 후보를 함께 적음(수확베기·단목베기· 위험목 베기 중 어느 것인지 원본이 말하지 않음). 검증 — 전체 580 passed, tsc 오류 0. 화면에서 셋 다 뜨는 것과 타설 방식 저장·되돌리기까지 확인 후 검증으로 바꾼 값은 원래대로 복원. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
410 lines
15 KiB
TypeScript
410 lines
15 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;
|
|
}
|
|
|
|
/** 사면 4계열 — 계열별 (거리, 면적). 키는 `면고르기_성토면` 식으로 엔진과 같다. */
|
|
export interface SlopeRow {
|
|
chainage_m: number;
|
|
distance_m: number;
|
|
berm_width_m: number;
|
|
unclosed: boolean;
|
|
lengths: Record<string, number>;
|
|
areas: Record<string, number>;
|
|
}
|
|
|
|
export interface SlopeTable {
|
|
rows: SlopeRow[];
|
|
totals: Record<string, number>;
|
|
ratios: Record<string, number>;
|
|
unclosed_stations: number[];
|
|
}
|
|
|
|
/** 산출 조건 — `project_settings.json` 의 `quantity` 구획. */
|
|
export interface QuantitySettings {
|
|
rock_class_set?: string;
|
|
rock_classes?: string[];
|
|
rock_ratios_pct?: Record<string, number>;
|
|
application_ratios_pct?: Record<string, number>;
|
|
/** 갈래별 시공법 — `"ripping"`·`"blasting"`. 안 정한 갈래는 아예 없다. */
|
|
rock_methods?: Record<string, string>;
|
|
/** 자재별 관급/사급 — 표 안에서 줄마다 고른 값. */
|
|
material_supply?: Record<string, { supply: string; install_by: string | null }>;
|
|
/** 콘크리트 타설 방식. `null`·없음이면 **아직 안 정한 것**이고 화면이 기본값 안내를 띄운다. */
|
|
concrete_placing_method?: string | null;
|
|
}
|
|
|
|
export interface EarthworkTable {
|
|
method: string;
|
|
station_count: number;
|
|
route_id?: number;
|
|
rows: EarthworkRow[];
|
|
totals: Record<string, number>;
|
|
conversion_factors?: Record<string, { compacted: number }>;
|
|
slope?: SlopeTable;
|
|
/** 토공집계표·운반표는 같은 응답에 실려 온다 — 나눠 부르지 않는다. */
|
|
summary?: import("./B08_Quantity_UI_SummaryGrid").SummaryTable;
|
|
haul?: import("./B08_Quantity_UI_SummaryGrid").HaulTable;
|
|
/** 운반계획은 [저장]·[확정]에서 정본에 남는 값 — 아직 없으면 false. */
|
|
haul_available?: boolean;
|
|
settings?: QuantitySettings;
|
|
}
|
|
|
|
/** 열 하나. `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 }] }] },
|
|
];
|
|
|
|
/** 사면 4계열 — 실무 토적표 오른쪽 절반(V~AI). 계열마다 (거리, 면적) 쌍이다.
|
|
* 키는 엔진과 같은 이름을 쓴다 — 이름이 어긋나면 값이 조용히 빈다. */
|
|
const SLOPE_GROUPS: { label: string; faces: { key: string; label: string }[] }[] = [
|
|
{ label: "층 따 기", faces: [{ key: "bench_cut_fill", label: "성 토 면" }] },
|
|
{
|
|
label: "면고르기",
|
|
faces: [
|
|
{ key: "face_dressing_fill", label: "성 토 면" },
|
|
{ key: "face_dressing_cut", label: "절 토 면" },
|
|
],
|
|
},
|
|
{
|
|
label: "법 면 보 호 공",
|
|
faces: [
|
|
{ key: "slope_protection_fill", label: "종자파종(성토)" },
|
|
{ key: "slope_protection_cut", label: "종자파종(절토)" },
|
|
],
|
|
},
|
|
{
|
|
label: "지 장 목 제 거",
|
|
faces: [
|
|
{ key: "tree_removal_fill", label: "성 토 면" },
|
|
{ key: "tree_removal_cut", label: "절 토 면" },
|
|
],
|
|
},
|
|
];
|
|
|
|
/** 사면 계열의 소분류 머리글 — 거리(사면길이)와 면적 두 칸. */
|
|
const SLOPE_LABELS = ["거 리", "면 적"];
|
|
|
|
/** 소분류가 「단면적·입적·보정량」 삼중조일 때 붙는 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);
|
|
}
|
|
}
|
|
|
|
// 사면 4계열 — 대분류 / 면(성토·절토) / (거리·면적) 3단으로 같은 모양을 이어 붙인다.
|
|
for (const group of SLOPE_GROUPS) {
|
|
const th = document.createElement("th");
|
|
th.colSpan = group.faces.length * 2;
|
|
th.textContent = group.label;
|
|
r1.append(th);
|
|
for (const face of group.faces) {
|
|
const th2 = document.createElement("th");
|
|
th2.colSpan = 2;
|
|
th2.textContent = face.label;
|
|
r2.append(th2);
|
|
for (const label of SLOPE_LABELS) {
|
|
const th3 = document.createElement("th");
|
|
th3.textContent = label;
|
|
r3.append(th3);
|
|
}
|
|
}
|
|
}
|
|
|
|
head.append(r1, r2, r3);
|
|
return head;
|
|
}
|
|
|
|
function buildBody(rows: EarthworkRow[], slope?: SlopeTable): HTMLTableSectionElement {
|
|
const body = document.createElement("tbody");
|
|
const columns = flatColumns();
|
|
const slopeByChainage = new Map((slope?.rows ?? []).map((row) => [row.chainage_m, row]));
|
|
|
|
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);
|
|
});
|
|
|
|
const slopeRow = slopeByChainage.get(row.chainage_m);
|
|
// 사면이 원지반을 못 만난 측점은 값이 잘려 있다 — 줄에 표시를 남긴다(PLAN 8-4b).
|
|
if (slopeRow?.unclosed) tr.classList.add("is-unclosed");
|
|
for (const group of SLOPE_GROUPS) {
|
|
for (const face of group.faces) {
|
|
for (const source of [slopeRow?.lengths, slopeRow?.areas]) {
|
|
const td = document.createElement("td");
|
|
td.textContent = cell(source?.[face.key], 1);
|
|
tr.append(td);
|
|
}
|
|
}
|
|
}
|
|
body.append(tr);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
function buildFoot(totals: Record<string, number>, slope?: SlopeTable): 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);
|
|
});
|
|
// 사면은 면적만 합한다 — 거리(사면길이)는 합이 뜻이 없다.
|
|
for (const group of SLOPE_GROUPS) {
|
|
for (const face of group.faces) {
|
|
tr.append(document.createElement("td"));
|
|
const td = document.createElement("td");
|
|
td.textContent = cell(slope?.totals?.[face.key], 1);
|
|
tr.append(td);
|
|
}
|
|
}
|
|
foot.append(tr);
|
|
return foot;
|
|
}
|
|
|
|
/** 잘린 측점 안내 — 한 덩어리로 묶고, 목록은 접어 둔다.
|
|
*
|
|
* 왜 붉은 오류가 아닌가
|
|
* 실측 발생률이 21~26 %(랩탑 route 169 는 22/105, 이 노선은 17/65)라 **늘 뜨는 안내**다.
|
|
* 매번 요란하면 곧 무시당한다. 그래서 **주의 표시 + 접히는 목록**으로 둔다.
|
|
*
|
|
* 왜 한 덩어리인가
|
|
* 절·성토 면적 · 사면적 · 사면길이가 **전부 같은 사유로** 잘린다. 항목마다 따로 띄우면
|
|
* 사용자가 세 번 읽게 된다.
|
|
*
|
|
* 왜 안 넓히나 (B06 담당 확인, 2026-09-07)
|
|
* 미교차의 절반 이상이 계곡·절벽처럼 **지형이 설계 사면에서 멀어지는 자리**라 반폭을
|
|
* 늘려도 영원히 안 닫힌다. 닫히는 쪽도 중앙값 +3m 인데 꼬리가 +292m 이라 전역 확대는
|
|
* 값이 안 나온다. 그래서 경고로 대체한다(2026-09-03 사용자 확정).
|
|
*/
|
|
function buildUnclosedNotice(slope: SlopeTable, table: HTMLTableElement): HTMLElement | null {
|
|
const stations = slope.unclosed_stations ?? [];
|
|
if (!stations.length) return null;
|
|
|
|
const box = document.createElement("details");
|
|
box.className = "b08-grid__warning";
|
|
|
|
const summary = document.createElement("summary");
|
|
summary.className = "b08-grid__warning-summary";
|
|
summary.textContent =
|
|
`주의 — ${stations.length}개 측점에서 사면이 원지반을 만나지 못했습니다. ` +
|
|
"그 측점의 절·성토 면적 · 사면길이 · 사면적이 함께 잘려 있어 실제보다 작습니다.";
|
|
box.append(summary);
|
|
|
|
const list = document.createElement("div");
|
|
list.className = "b08-grid__warning-list";
|
|
for (const chainage of stations) {
|
|
const link = document.createElement("button");
|
|
link.type = "button";
|
|
link.className = "b08-grid__warning-station";
|
|
link.textContent = stationLabel(chainage);
|
|
link.addEventListener("click", () => {
|
|
const row = table.querySelector<HTMLElement>(
|
|
`tbody tr:nth-child(${slopeRowIndex(slope, chainage) + 1})`,
|
|
);
|
|
row?.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
row?.classList.add("is-highlighted");
|
|
window.setTimeout(() => row?.classList.remove("is-highlighted"), 1600);
|
|
});
|
|
list.append(link);
|
|
}
|
|
box.append(list);
|
|
return box;
|
|
}
|
|
|
|
function slopeRowIndex(slope: SlopeTable, chainage: number): number {
|
|
return slope.rows.findIndex((row) => row.chainage_m === chainage);
|
|
}
|
|
|
|
/** 토적표 하나를 그린다. 넓은 표라 스스로 가로 스크롤한다. */
|
|
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, table.slope),
|
|
buildFoot(table.totals, table.slope),
|
|
);
|
|
|
|
if (table.slope) {
|
|
const notice = buildUnclosedNotice(table.slope, element);
|
|
if (notice) wrap.append(notice);
|
|
}
|
|
scroller.append(element);
|
|
wrap.append(scroller);
|
|
return wrap;
|
|
}
|