- 한몸으로 동작하는 두 페이지라 한 커밋으로 처리 (상호 참조 다수) - B05 37파일 + B06 20파일 접두사 개명 (git mv, 이력 보존) - 참조 치환 91파일: import 경로, 라우트 슬러그(b05-profile/b06-section), 라우트 키(B05_PROFILE/B06_SECTION), B03 자동 체인, storage 상수, pyproject 제외 경로 - 로직 변경 없음. typecheck·백엔드 import 검증 통과 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
402 lines
16 KiB
TypeScript
402 lines
16 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_UI_Cross_Areas.ts
|
|
* 횡단 절·성토 면적 readout과 면적 영역 하이라이트 밴드.
|
|
*
|
|
* 절토는 암반 경계선(지면선 평행 복사)을 기준으로 위=토사, 아래=암반으로 갈린다. 경계선
|
|
* 위치가 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 여기 표시되는
|
|
* 면적과 유토곡선이 함께 움직인다. 면적값 산출의 정본은 백엔드
|
|
* (`B06_Section_Engine_Design._split_cut_areas`)이고, 이 모듈은 **같은 규칙으로
|
|
* 영역만 다시 그린다**(수치를 여기서 다시 계산해 표시하지 않는다 — 이중 정의 금지).
|
|
*
|
|
* 하이라이트 규칙 (2026-08-02 사용자 지시):
|
|
* - 선택된 횡단도에서만 동작한다. 다른 측점을 고르면 카드가 다시 그려지며 자동 해제된다.
|
|
* - 값 칩 또는 면적 영역을 누르면 그 영역만 강조하고, 다시 누르면 해제한다.
|
|
*
|
|
* 700줄 제한 대응으로 `_UI_Cross_Design`(588줄)에서 면적 readout을 이 파일로 옮겨 왔다.
|
|
* ========================================================================== */
|
|
|
|
import type { CrossDesign, SectionSample } from "./B06_Section_Api_Fetch";
|
|
import { L, svgElement } from "./B06_Section_UI_Section_Common";
|
|
|
|
/**
|
|
* 강조 대상 = 면적표의 칸. `cut_total`은 토사·암반 두 밴드를 함께 켠다.
|
|
* `cut_rr`/`cut_br`은 표에서는 늘 따로 서지만 그림에서는 암반 밴드 **하나**를 가리키므로,
|
|
* 그 측점의 암종과 맞는 쪽을 눌렀을 때만 밴드가 켜진다(값이 0인 쪽은 눌려도 그림 변화 없음).
|
|
*/
|
|
export type CrossAreaKey = "cut_soil" | "cut_rr" | "cut_br" | "cut_total" | "fill";
|
|
|
|
/** 그림에 실제로 깔리는 밴드. 표의 칸(`CrossAreaKey`)보다 적다. */
|
|
type BandKey = "cut_soil" | "cut_rock" | "fill";
|
|
|
|
export type AreaHighlightSetter = (key: CrossAreaKey | null) => void;
|
|
|
|
/** 두께가 0 이하인 구간을 잘라내는 판정 여유(㎡ 아닌 m 단위 종거). */
|
|
const EPSILON = 1e-9;
|
|
|
|
function interpolator(points: Array<{ offset: number; value: number }>) {
|
|
return (offset: number): number => {
|
|
if (offset <= points[0].offset) return points[0].value;
|
|
const last = points[points.length - 1];
|
|
if (offset >= last.offset) return last.value;
|
|
for (let index = 1; index < points.length; index += 1) {
|
|
if (offset > points[index].offset) continue;
|
|
const a = points[index - 1];
|
|
const b = points[index];
|
|
const span = b.offset - a.offset;
|
|
if (span <= 0) return b.value;
|
|
return a.value + (b.value - a.value) * ((offset - a.offset) / span);
|
|
}
|
|
return last.value;
|
|
};
|
|
}
|
|
|
|
/** 밴드 조각 하나. `from`/`to`는 그 조각이 덮는 편거리(offset) 범위다. */
|
|
interface BandPiece {
|
|
points: string;
|
|
from: number;
|
|
to: number;
|
|
}
|
|
|
|
/**
|
|
* 성토 밴드는 **중심선에서 바깥으로 나가다 지면과 처음 만나는 곳까지**만 유효하다.
|
|
* 그 너머에서 지면이 다시 설계선 아래로 내려가 생기는 조각은 성토부가 아니므로 버린다
|
|
* (2026-08-02 사용자 지시). 좌·우 각각 중심선에 가장 가까운 조각만 남긴다.
|
|
*/
|
|
function keepInnermost(pieces: BandPiece[]): BandPiece[] {
|
|
let left: BandPiece | null = null;
|
|
let right: BandPiece | null = null;
|
|
const kept: BandPiece[] = [];
|
|
for (const piece of pieces) {
|
|
if (piece.from <= 0 && piece.to >= 0) {
|
|
// 중심선을 물고 있는 조각은 좌우 어느 쪽으로도 첫 조각이라 그대로 둔다.
|
|
kept.push(piece);
|
|
continue;
|
|
}
|
|
if (piece.to < 0) {
|
|
if (!left || piece.to > left.to) left = piece;
|
|
} else if (!right || piece.from < right.from) right = piece;
|
|
}
|
|
if (left) kept.push(left);
|
|
if (right) kept.push(right);
|
|
return kept;
|
|
}
|
|
|
|
/**
|
|
* `top > bottom`인 구간만 잘라 폴리곤 점 문자열을 만든다.
|
|
* 두께가 0이 되는 지점을 끼워 넣어 밴드 끝이 지면선·설계선 교점에서 정확히 닫히게 한다.
|
|
*/
|
|
function bandPolygons(
|
|
xs: number[],
|
|
top: number[],
|
|
bottom: number[],
|
|
px: (offset: number) => number,
|
|
py: (elevation: number) => number,
|
|
): BandPiece[] {
|
|
const polygons: BandPiece[] = [];
|
|
let upper: string[] = [];
|
|
let lower: string[] = [];
|
|
let from = 0;
|
|
let to = 0;
|
|
const flush = (): void => {
|
|
if (upper.length >= 2)
|
|
polygons.push({ points: [...upper, ...lower.reverse()].join(" "), from, to });
|
|
upper = [];
|
|
lower = [];
|
|
};
|
|
const add = (offset: number, high: number, low: number): void => {
|
|
if (!upper.length) from = offset;
|
|
to = offset;
|
|
upper.push(`${px(offset)},${py(high)}`);
|
|
lower.push(`${px(offset)},${py(low)}`);
|
|
};
|
|
const crossing = (index: number): void => {
|
|
const gapA = top[index - 1] - bottom[index - 1];
|
|
const gapB = top[index] - bottom[index];
|
|
if (gapA === gapB) return;
|
|
const ratio = gapA / (gapA - gapB);
|
|
const offset = xs[index - 1] + (xs[index] - xs[index - 1]) * ratio;
|
|
const level = top[index - 1] + (top[index] - top[index - 1]) * ratio;
|
|
add(offset, level, level);
|
|
};
|
|
for (let index = 0; index < xs.length; index += 1) {
|
|
const gap = top[index] - bottom[index];
|
|
const previousGap = index > 0 ? top[index - 1] - bottom[index - 1] : 0;
|
|
if (gap > EPSILON) {
|
|
if (index > 0 && previousGap <= EPSILON) crossing(index);
|
|
add(xs[index], top[index], bottom[index]);
|
|
} else if (index > 0 && previousGap > EPSILON) {
|
|
crossing(index);
|
|
flush();
|
|
}
|
|
}
|
|
flush();
|
|
return polygons;
|
|
}
|
|
|
|
/**
|
|
* 그 측점 절토의 암종. 분리 필드가 없는 **구 데이터**는 지반유형을 그대로 쓴다 —
|
|
* 표(`cutBreakdown`)는 그 규칙으로 RR/BR에 값을 넣는데 여기서만 null로 보면
|
|
* 암반 밴드가 아예 안 만들어져 **표에는 값이 있는데 그림이 안 켜진다**(2026-08-02 사용자 지적).
|
|
*/
|
|
function rockKindOf(design: CrossDesign): CrossDesign["cut_rock_kind"] {
|
|
if (design.cut_rock_kind) return design.cut_rock_kind;
|
|
return design.ground_type === "soil" ? null : design.ground_type;
|
|
}
|
|
|
|
/** 토사층 두께(m). 토사 측점은 암반 경계선이 없어 null, 분리 근거가 없는 암 측점은 0(전량 암). */
|
|
function soilDepth(design: CrossDesign): number | null {
|
|
if (!rockKindOf(design)) return null;
|
|
// 구 데이터(분리 면적 없음)는 경계선을 신뢰할 수 없으므로 전량 암으로 본다 — 표와 같은 규칙.
|
|
if (design.cut_soil_area_m2 === undefined || design.cut_rock_area_m2 === undefined) return 0;
|
|
return Math.abs(design.rock_boundary_offset_m ?? 0);
|
|
}
|
|
|
|
/**
|
|
* 절토(토사)·절토(암반)·성토 영역을 SVG에 깔고, 강조 토글 함수를 돌려준다.
|
|
* 밴드는 평소 투명에 가깝게 두되 클릭 대상은 되도록 남겨 "면적을 눌러도 강조"가 성립한다.
|
|
*/
|
|
export function appendCrossAreaBands(
|
|
svg: SVGElement,
|
|
design: CrossDesign,
|
|
groundSamples: SectionSample[],
|
|
x: (offset: number) => number,
|
|
toDisplayY: (elevation: number) => number,
|
|
onSelect: (key: CrossAreaKey) => void,
|
|
): AreaHighlightSetter {
|
|
const ground = groundSamples
|
|
.filter((sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN))
|
|
.map((sample) => ({ offset: sample.offset_m ?? 0, value: sample.elevation_m as number }))
|
|
.sort((a, b) => a.offset - b.offset);
|
|
const line = (design.design_line ?? [])
|
|
.filter((point) => Number.isFinite(point.offset_m) && Number.isFinite(point.elevation_m))
|
|
.map((point) => ({ offset: point.offset_m, value: point.elevation_m }))
|
|
.sort((a, b) => a.offset - b.offset);
|
|
if (ground.length < 2 || line.length < 2) return () => undefined;
|
|
|
|
const groundAt = interpolator(ground);
|
|
const designAt = interpolator(line);
|
|
// 설계선이 덮는 범위 밖에는 절·성토가 정의되지 않는다 — 두 범위의 교집합만 그린다.
|
|
const minOffset = Math.max(ground[0].offset, line[0].offset);
|
|
const maxOffset = Math.min(ground[ground.length - 1].offset, line[line.length - 1].offset);
|
|
if (!(maxOffset > minOffset)) return () => undefined;
|
|
const xs = [
|
|
...new Set(
|
|
[
|
|
...ground.map((point) => point.offset),
|
|
...line.map((point) => point.offset),
|
|
minOffset,
|
|
maxOffset,
|
|
]
|
|
.filter((offset) => offset >= minOffset && offset <= maxOffset)
|
|
.map((offset) => Math.round(offset * 1e6) / 1e6),
|
|
),
|
|
].sort((a, b) => a - b);
|
|
|
|
const groundLevels = xs.map(groundAt);
|
|
const designLevels = xs.map(designAt);
|
|
const depth = soilDepth(design);
|
|
const rockLevels = depth === null ? null : groundLevels.map((level) => level - depth);
|
|
|
|
const bands: Array<{ key: BandKey; top: number[]; bottom: number[] }> = [
|
|
{ key: "fill", top: designLevels, bottom: groundLevels },
|
|
];
|
|
if (rockLevels) {
|
|
bands.push({
|
|
key: "cut_soil",
|
|
top: groundLevels,
|
|
bottom: designLevels.map((level, index) => Math.max(level, rockLevels[index])),
|
|
});
|
|
bands.push({ key: "cut_rock", top: rockLevels, bottom: designLevels });
|
|
} else {
|
|
bands.push({ key: "cut_soil", top: groundLevels, bottom: designLevels });
|
|
}
|
|
|
|
// 표에서 암반 칸을 눌렀을 때 이 측점의 암종과 맞는 쪽만 밴드를 켠다.
|
|
const rockCell: CrossAreaKey = rockKindOf(design) === "blasting_rock" ? "cut_br" : "cut_rr";
|
|
const groups = new Map<BandKey, SVGGElement>();
|
|
for (const band of bands) {
|
|
let pieces = bandPolygons(xs, band.top, band.bottom, x, toDisplayY);
|
|
if (band.key === "fill") pieces = keepInnermost(pieces);
|
|
if (!pieces.length) continue;
|
|
// 암반 밴드는 암종까지 클래스에 실어 색을 RR/BR로 갈라 준다 — 표 글자색과 짝이 맞아야
|
|
// 어느 숫자를 켰는지 색으로 바로 읽힌다.
|
|
const variant = band.key === "cut_rock" ? ` b06-chart__area--${rockCell}` : "";
|
|
const group = svgElement("g", {
|
|
class: `b06-chart__area b06-chart__area--${band.key}${variant}`,
|
|
});
|
|
for (const piece of pieces) group.append(svgElement("polygon", { points: piece.points }));
|
|
group.addEventListener("click", (event) => {
|
|
// 카드까지 올라가면 측점 선택이 다시 걸려 카드가 통째로 다시 그려진다 — 방금 켠 강조가 사라진다.
|
|
event.stopPropagation();
|
|
onSelect(band.key === "cut_rock" ? rockCell : band.key);
|
|
});
|
|
groups.set(band.key, group);
|
|
svg.append(group);
|
|
}
|
|
|
|
return (key) => {
|
|
for (const [bandKey, group] of groups) {
|
|
const active =
|
|
key === "cut_total"
|
|
? bandKey === "cut_soil" || bandKey === "cut_rock"
|
|
: bandKey === "cut_rock"
|
|
? key === rockCell
|
|
: key === bandKey;
|
|
group.classList.toggle("is-active", active);
|
|
}
|
|
};
|
|
}
|
|
|
|
/** 절토 단면적을 도면 표기(EA 토사 / RR 리핑암 / BR 발파암)로 갈라 낸다. */
|
|
function cutBreakdown(design: CrossDesign): {
|
|
ea: number;
|
|
rr: number;
|
|
br: number;
|
|
total: number;
|
|
} {
|
|
const total = design.cut_area_m2;
|
|
// 구 데이터(분리 필드 없음)는 절토 전량을 측점 지반유형으로 돌린다 — 유토곡선 폴백과 같은 규칙.
|
|
if (design.cut_soil_area_m2 === undefined || design.cut_rock_area_m2 === undefined) {
|
|
const ground = design.ground_type;
|
|
return {
|
|
ea: ground === "soil" ? total : 0,
|
|
rr: ground === "ripping_rock" ? total : 0,
|
|
br: ground === "blasting_rock" ? total : 0,
|
|
total,
|
|
};
|
|
}
|
|
const rock = design.cut_rock_area_m2;
|
|
const kind = rockKindOf(design);
|
|
return {
|
|
ea: design.cut_soil_area_m2,
|
|
rr: kind === "ripping_rock" ? rock : 0,
|
|
br: kind === "blasting_rock" ? rock : 0,
|
|
total,
|
|
};
|
|
}
|
|
|
|
/** 표 한 칸. `key`가 없으면 강조 대상이 아닌 칸(성토의 EA/RR/BR 자리)이다. */
|
|
interface AreaCell {
|
|
key: CrossAreaKey | null;
|
|
value: number | null;
|
|
variant: string;
|
|
}
|
|
|
|
function appendCell(
|
|
row: HTMLTableRowElement,
|
|
cell: AreaCell,
|
|
onSelect: ((key: CrossAreaKey) => void) | undefined,
|
|
cells: Map<CrossAreaKey, HTMLElement[]>,
|
|
): void {
|
|
const td = document.createElement("td");
|
|
td.className = `b06-design__area b06-design__area--${cell.variant}`;
|
|
const text = cell.value === null ? "—" : cell.value.toFixed(2);
|
|
// 값이 0이어도 칸은 남긴다 — 측점마다 항목 수가 달라지면 카드끼리 비교가 안 된다.
|
|
if (!cell.key || !onSelect || cell.value === null) {
|
|
td.textContent = text;
|
|
row.append(td);
|
|
return;
|
|
}
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.textContent = text;
|
|
button.title = L("B06_Design_Area_Highlight");
|
|
button.setAttribute("aria-pressed", "false");
|
|
button.addEventListener("click", (event) => {
|
|
// 카드까지 올라가면 측점 선택이 다시 걸려 카드가 통째로 다시 그려진다 — 강조가 즉시 사라진다.
|
|
event.stopPropagation();
|
|
onSelect(cell.key as CrossAreaKey);
|
|
});
|
|
cells.set(cell.key, [...(cells.get(cell.key) ?? []), button]);
|
|
td.append(button);
|
|
row.append(td);
|
|
}
|
|
|
|
/**
|
|
* 절·성토 면적값 오버레이(E-4) — 그래프 중상단에 표(절토/성토 × EA·RR·BR·계)로 표시한다.
|
|
* 항목 이름은 도면 표기를 그대로 쓴다(EA 토사 / RR 리핑암 / BR 발파암).
|
|
* `onSelect`가 오면 값 칸이 강조 토글 버튼이 된다(선택된 카드에서만 넘어온다).
|
|
*/
|
|
export function buildAreaReadout(
|
|
design: CrossDesign | undefined,
|
|
onSelect?: (key: CrossAreaKey) => void,
|
|
): { root: HTMLElement; setActive: AreaHighlightSetter } {
|
|
if (!design) {
|
|
const unset = document.createElement("div");
|
|
unset.className = "b06-cross-card__areas b06-design__area--unset";
|
|
unset.textContent = L("B06_Design_Unset");
|
|
return { root: unset, setActive: () => undefined };
|
|
}
|
|
|
|
const table = document.createElement("table");
|
|
table.className = "b06-cross-card__areas";
|
|
const head = document.createElement("tr");
|
|
// 좌상단(행제목 × 열제목 교차) 칸은 단위 자리다 — 값 칸마다 단위를 붙이지 않는다.
|
|
for (const [text, tip] of [
|
|
[L("B06_Design_Area_Unit"), ""],
|
|
[L("B06_Design_Area_EA"), L("B06_Design_Ground_Soil")],
|
|
[L("B06_Design_Area_RR"), L("B06_Design_Ground_Ripping")],
|
|
[L("B06_Design_Area_BR"), L("B06_Design_Ground_Blasting")],
|
|
[L("B06_Design_Area_Total"), ""],
|
|
]) {
|
|
const th = document.createElement("th");
|
|
th.textContent = text;
|
|
if (tip) th.title = tip;
|
|
head.append(th);
|
|
}
|
|
const thead = document.createElement("thead");
|
|
thead.append(head);
|
|
|
|
const cells = new Map<CrossAreaKey, HTMLElement[]>();
|
|
const cut = cutBreakdown(design);
|
|
const tbody = document.createElement("tbody");
|
|
const rows: Array<{ label: string; variant: string; cells: AreaCell[] }> = [
|
|
{
|
|
label: L("B06_Design_Cut_Area"),
|
|
variant: "cut_total",
|
|
// 값이 0이어도 누를 수 있다(2026-08-02 사용자 지시) — 그림에 없는 면적이면 강조만 켜지고
|
|
// 밴드는 그대로다. 칸마다 눌리는 자리가 달라지면 어느 칸이 눌리는지 매번 확인해야 한다.
|
|
cells: [
|
|
{ key: "cut_soil", value: cut.ea, variant: "cut_soil" },
|
|
{ key: "cut_rr", value: cut.rr, variant: "cut_rr" },
|
|
{ key: "cut_br", value: cut.br, variant: "cut_br" },
|
|
{ key: "cut_total", value: cut.total, variant: "cut_total" },
|
|
],
|
|
},
|
|
{
|
|
// 성토는 완성 단면(다짐 상태)이라 지반유형으로 갈리지 않는다 — 계 한 칸만 쓴다.
|
|
label: L("B06_Design_Fill_Area"),
|
|
variant: "fill",
|
|
cells: [
|
|
{ key: null, value: null, variant: "fill" },
|
|
{ key: null, value: null, variant: "fill" },
|
|
{ key: null, value: null, variant: "fill" },
|
|
{ key: "fill", value: design.fill_area_m2, variant: "fill" },
|
|
],
|
|
},
|
|
];
|
|
for (const row of rows) {
|
|
const tr = document.createElement("tr");
|
|
const th = document.createElement("th");
|
|
th.className = `b06-design__area b06-design__area--${row.variant}`;
|
|
th.textContent = row.label;
|
|
tr.append(th);
|
|
for (const cell of row.cells) appendCell(tr, cell, onSelect, cells);
|
|
tbody.append(tr);
|
|
}
|
|
table.append(thead, tbody);
|
|
|
|
return {
|
|
root: table,
|
|
setActive: (key) => {
|
|
for (const [cellKey, elements] of cells) {
|
|
const active = cellKey === key;
|
|
for (const element of elements) {
|
|
element.classList.toggle("is-active", active);
|
|
element.setAttribute("aria-pressed", String(active));
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|