Files
Aislo/B05_Profile/B05_Profile_UI_Profile_Table_Selected.ts
T
eomsangdonandClaude Sonnet 5.5 381f3d0573 refactor(512-5 700 줄 나눔 TS): 유토 배분 결과 타입 · 사토 잔토 · 공제 · 종단 표 고른 측점 열을 새 파일로 — 동작 · 값 그대로
- common_util_mass_haul_balance 842 → 467 (_types · _apply 분리 · 원래 파일에서 타입 다시 내보냄)
- B05_Profile_UI_Profile_Table 702 → 548 (_Table_Selected 분리)
- test_507_3 는 옮긴 글을 새 파일에서 찾음

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 19:46:32 +09:00

174 lines
8.4 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Profile_Table_Selected.ts
* 종단면도 하단 표 — 고른 측점의 **값 열 오버레이**(`buildSelectedColumn`)와 표 본체가 같이 쓰는 조각
* (`element` · `displayStationLabel` · `isStraightCurve`). 표 본체는 `B05_Profile_UI_Profile_Table`.
* ========================================================================== */
import type {
AlignmentCurve,
AlignmentSegment,
ProfileAlignment,
} from "./logic/B05_Profile_UI_Profile_Alignment";
import { stationLabel } from "../B06_Section/B06_Section_UI_Section_Common";
/**
* 대수차가 이 값보다 작으면 **직선**으로 본다(%).
*
* 직선화하면 그 자리의 좌·우 기울기가 같아져 대수차가 0 이 되고, `L = R × |대수차|` 를
* 뒤집은 R 은 수억 m 로 튄다. 직선 구간에는 반경이 없으므로 표에는 빈 칸으로 둔다
* (2026-09-04 사용자 지시). 진짜 완만한 변화점(대수차 0.9% 수준)은 그대로 남는다.
*/
const STRAIGHT_DELTA_PCT = 1e-4;
/** 직선화로 대수차가 사라진 자리인가 — 표기 대상에서 뺀다. */
export function isStraightCurve(curve: AlignmentCurve): boolean {
return Math.abs(curve.delta_pct) < STRAIGHT_DELTA_PCT;
}
export function element(tag: string, className: string, text?: string): HTMLElement {
const node = document.createElement(tag);
node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
/** 이어 공사 시작 기준을 반영한 측점 표기 — 측점번호에 시작 측점을 더한다(잔여거리는 그대로). */
export function displayStationLabel(
chainageM: number,
interval: number,
stationOffset: number,
): string {
const base = stationLabel(chainageM, interval); // "X+YY.Y"
if (!stationOffset) return base;
const [number, remainder] = base.split("+");
return `${Number(number) + stationOffset}+${remainder}`;
}
/** 정렬된 계획선 샘플에서 chainage 위치 값을 선형보간한다(범위 밖은 양 끝 클램프). */
function interpolateSample(
samples: ProfileAlignment["samples"],
chainageM: number,
key: "elevation_m" | "ground_elevation_m",
): number | null {
if (!samples.length) return null;
if (chainageM <= samples[0].chainage_m) return samples[0][key];
const last = samples[samples.length - 1];
if (chainageM >= last.chainage_m) return last[key];
for (let index = 1; index < samples.length; index += 1) {
const current = samples[index];
if (current.chainage_m < chainageM) continue;
const previous = samples[index - 1];
const span = current.chainage_m - previous.chainage_m;
if (span <= 1e-9) return current[key];
const ratio = (chainageM - previous.chainage_m) / span;
return previous[key] + (current[key] - previous[key]) * ratio;
}
return last[key];
}
/** 값 열 한 칸의 내용: 단일 값이거나, 변화점에서 좌/우로 갈린 두 값. */
type CellValue = { text: string } | { left: string; right: string };
/**
* 선택된 측점(규칙·비정규 공용)의 **값 열 오버레이**. 12행 순서 그대로 값(계획고·지반고·누가거리·
* 측점·구배·곡선 등)을 쌓아 테이블 위에 겹친다. 규칙 측점을 눌러도 비정규와 **같은 오버레이 방식**으로
* 눈에 잘 들어오게 한다. `centerX`는 호출부가 정한다 — 종점처럼 셀이 우측 이동된 규칙 측점은 그 이동된
* 중심을, 비정규 측점은 `x(chainage)`를 넘긴다.
*/
export function buildSelectedColumn(
chainage: number,
alignment: ProfileAlignment,
centerX: number,
cellWidth: number,
interval: number,
display: { station: number; cumulative: number },
onAdjustStation?: (chainageM: number, deltaM: number) => void,
isIrregular = false,
mc = false,
): HTMLElement {
const plan = interpolateSample(alignment.samples, chainage, "elevation_m");
const ground = interpolateSample(alignment.samples, chainage, "ground_elevation_m");
const cut = plan !== null && ground !== null && ground - plan > 0.005 ? ground - plan : null;
const fill = plan !== null && ground !== null && plan - ground > 0.005 ? plan - ground : null;
// 구배(L/H/S): 이 측점이 구간 안에 있으면 그 구간 값을 그대로(중복이어도) 표기하고,
// 변화점이라 좌우 구간이 갈리면 세로 구분선으로 셀을 반 나눠 좌/우 값을 각각 넣는다.
const inside = alignment.segments.find(
(segment) => segment.from_m + 1e-6 < chainage && chainage < segment.to_m - 1e-6,
);
const leftSeg = alignment.segments.find((segment) => Math.abs(segment.to_m - chainage) < 0.01);
const rightSeg = alignment.segments.find((segment) => Math.abs(segment.from_m - chainage) < 0.01);
const gradeCell = (pick: (segment: AlignmentSegment) => number, digits: number): CellValue => {
if (inside) return { text: pick(inside).toFixed(digits) };
const left = leftSeg ? pick(leftSeg).toFixed(digits) : "";
const right = rightSeg ? pick(rightSeg).toFixed(digits) : "";
return left && right ? { left, right } : { text: left || right || "" };
};
const curveAt = alignment.curves.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.01);
// 값 열 오버레이도 같은 규칙 — 직선화된 자리는 곡선 L·R 을 비운다.
const curve = curveAt && !isStraightCurve(curveAt) ? curveAt : undefined;
// 테이블 행 순서(구배 1 · 측점값 3 · 곡선 2)와 정확히 같게 채운다. 없는 값은 공백.
// `plan: true`인 계획고 행만 직접 입력 셀로 만든다(값 열은 오버레이라 실제 6행 격자는 불변).
// 절토·성토고는 행이 빠졌어도 값 자체는 툴팁에 남긴다 — 고른 측점에서 흔히 찾는 값이다.
const rows: Array<{ value: CellValue; modifier: string; plan?: boolean }> = [
{ value: gradeCell((segment) => segment.grade_percent, 2), modifier: "grade" }, // 구배 S
{ value: { text: plan !== null ? plan.toFixed(2) : "" }, modifier: "plan", plan: true }, // 계획고
{ value: { text: ground !== null ? ground.toFixed(2) : "" }, modifier: "" }, // 지반고
{
value: {
text: displayStationLabel(chainage, interval, display.station) + (mc ? " (MC)" : ""),
},
modifier: mc ? "mc" : "",
}, // 측점
{ value: { text: curve ? curve.l_m.toFixed(2) : "" }, modifier: "" }, // 곡선 L
{ value: { text: curve ? curve.r_m.toFixed(1) : "" }, modifier: "" }, // 곡선 R
];
const column = element(
"div",
`b05-profile-table__irregular-col${isIrregular ? " is-floating" : ""}`,
);
column.style.left = `${centerX}px`;
column.style.width = `${cellWidth}px`;
rows.forEach((row) => {
const cell = element(
"div",
`b05-profile-table__irregular-col-cell${row.modifier ? ` is-${row.modifier}` : ""}`,
);
if (row.plan && onAdjustStation && plan !== null) {
// 계획고 직접 입력 → (입력값 − 현재 계획고) delta로 station_offset 반영(규칙·비정규 공용).
const input = document.createElement("input");
input.type = "number";
input.step = "any";
input.className = "b05-profile-table__irregular-input b05-profile-table__no-spin";
input.value = plan.toFixed(2);
input.title = `계획고 직접 입력 (현재 ${plan.toFixed(2)}m)`;
input.addEventListener("change", () => {
const target = Number.parseFloat(input.value);
if (Number.isFinite(target)) onAdjustStation(chainage, target - plan);
});
cell.append(input);
} else if ("left" in row.value) {
// 좌우 값을 세로 구분선으로 반씩 나눠 넣는다(각 반쪽에서 폰트를 줄여 맞춘다).
cell.classList.add("is-split");
cell.append(
element("span", "b05-profile-table__irregular-col-half", row.value.left),
element("span", "b05-profile-table__irregular-col-half", row.value.right),
);
} else {
cell.textContent = row.value.text;
}
column.append(cell);
});
// 행에서 뺀 값(절토고·성토고·누가거리)은 툴팁에 남긴다 — 고른 측점에서 흔히 찾는 값이다.
const cutFillText =
cut !== null
? ` · 절토 ${cut.toFixed(2)}m`
: fill !== null
? ` · 성토 ${fill.toFixed(2)}m`
: "";
column.title = `${displayStationLabel(chainage, interval, display.station)} · 누가거리 ${(
chainage + display.cumulative
).toFixed(2)}m${cutFillText}`;
return column;
}