feat(B06): 절토면적을 암반 경계선 기준 토사/암반으로 분리하고 유토곡선에 배선
절토부의 토사·암반 구분은 환산계수가 아니라 횡단도 기하로 갈린다. 지표면~암반 경계선이 토사, 그 아래가 암이다. 기존에는 측점당 절토 단면적이 하나뿐이라 리핑암/발파암 측점의 상단 토사층까지 암 환산계수로 곱해졌다(실데이터 기준 다짐환산 +21% 과대). - Engine_Design: `_split_cut_areas()` 신설. `d=0`, `d=t0` 교차점을 브레이크포인트로 넣어 정확 적분(무작위 2,000회 검산 `soil+rock==cut` 최대오차 1.4e-14). `cut_soil_area_m2` / `cut_rock_area_m2` / `cut_rock_kind` 3필드 추가(기존 키 불변). - UI_Cross_Areas(신규): 면적 readout을 4칩으로 확장하고 절토(토사)/절토(암반)/성토 밴드를 그린다. 선택된 카드에서만 붙어 다른 측점을 고르면 자동 해제된다. - UI_MassHaul: `AreaSample`을 2분할로 교체해 부분별 환산계수를 물리고, 측점별 구간 물량 내역(토사/암반/다짐환산/성토)을 남긴다. 구 데이터는 전량 측점 지반유형으로 폴백. - UI_MassHaul_View: 선택 측점의 구간 물량 표기 줄과 곡선 위 강조점 추가. 측점 매칭은 id가 아니라 누가거리로 한다(종단 기준 곡선의 점 id는 파생값이라 형태가 다름). 암반 경계선 오프셋을 바꾸면 분리 면적과 유토곡선 환산량이 함께 움직인다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -237,6 +237,15 @@ export interface CrossDesign {
|
||||
};
|
||||
design_elevation_m: number;
|
||||
cut_area_m2: number;
|
||||
/**
|
||||
* 절토 내역(합 = `cut_area_m2`). 지표면~암반 경계선이 토사, 그 아래가 암이다.
|
||||
* 경계선을 올리내리면 두 값이 함께 바뀌고 유토곡선도 따라 움직인다.
|
||||
* 구 데이터에는 없으므로 optional — 없으면 `cut_area_m2` 전량을 지반유형으로 본다.
|
||||
*/
|
||||
cut_soil_area_m2?: number;
|
||||
cut_rock_area_m2?: number;
|
||||
/** 암반부에 적용할 지반유형(`ripping_rock`/`blasting_rock`). 토사 측점은 null. */
|
||||
cut_rock_kind?: GroundType | null;
|
||||
fill_area_m2: number;
|
||||
ditch_area_m2: number;
|
||||
design_line: Array<{ offset_m: number; elevation_m: number }>;
|
||||
|
||||
@@ -136,6 +136,45 @@ def _trapezoid_areas(offsets: list[float], diffs: list[float]) -> tuple[float, f
|
||||
return cut_area, fill_area
|
||||
|
||||
|
||||
def _split_cut_areas(
|
||||
offsets: list[float], diffs: list[float], soil_depth_m: float
|
||||
) -> tuple[float, float]:
|
||||
"""절토 면적을 암반 경계선 기준으로 (토사, 암반)으로 나눈다.
|
||||
|
||||
암반 경계선은 지반선 평행 복사(`지반고 + rock_boundary_offset_m`)이므로 토사층 두께
|
||||
`t0`가 절토 구간 전체에서 균일하다. 따라서 오프셋별 절토 종거 `d = 지반고 - 설계고`에
|
||||
대해 토사분은 `min(max(d, 0), t0)`, 암반분은 `max(d - t0, 0)`이며 두 값의 합은 항상
|
||||
`max(d, 0)`이라 `_trapezoid_areas`의 절토 면적과 정확히 일치한다.
|
||||
|
||||
두 함수 모두 `d = 0`과 `d = t0`에서 꺾이므로 그 교차점을 구간 분할점으로 넣어야
|
||||
사다리꼴 적분이 근사가 아닌 정확값이 된다.
|
||||
"""
|
||||
t0 = max(float(soil_depth_m), 0.0)
|
||||
soil_area = 0.0
|
||||
rock_area = 0.0
|
||||
for index in range(1, len(offsets)):
|
||||
x0, x1 = offsets[index - 1], offsets[index]
|
||||
d0, d1 = diffs[index - 1], diffs[index]
|
||||
width = x1 - x0
|
||||
if width <= 0:
|
||||
continue
|
||||
ratios = [0.0, 1.0]
|
||||
for level in (0.0, t0):
|
||||
if (d0 - level) * (d1 - level) < 0:
|
||||
ratios.append((level - d0) / (d1 - d0))
|
||||
ratios.sort()
|
||||
for step in range(1, len(ratios)):
|
||||
ratio_a, ratio_b = ratios[step - 1], ratios[step]
|
||||
span = width * (ratio_b - ratio_a)
|
||||
if span <= 0:
|
||||
continue
|
||||
d_a = d0 + (d1 - d0) * ratio_a
|
||||
d_b = d0 + (d1 - d0) * ratio_b
|
||||
soil_area += (min(max(d_a, 0.0), t0) + min(max(d_b, 0.0), t0)) / 2.0 * span
|
||||
rock_area += (max(d_a - t0, 0.0) + max(d_b - t0, 0.0)) / 2.0 * span
|
||||
return soil_area, rock_area
|
||||
|
||||
|
||||
def _ground_interpolator(valid: list[tuple[float, float]]):
|
||||
"""정렬된 (offset, 지반고) 샘플의 선형 보간 함수를 만든다(범위 밖 끝값 클램프)."""
|
||||
|
||||
@@ -501,6 +540,22 @@ def compute_cross_design(
|
||||
# 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음 — 이중계상 방지).
|
||||
cut_area, fill_area = _trapezoid_areas(offsets, diffs)
|
||||
|
||||
# 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암이다. 경계선 위치가
|
||||
# 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 이 값이 함께 바뀐다.
|
||||
# 토사 지반은 암반 경계선 자체가 없어 전량 토사, 암 지반인데 경계선 값이 없으면(구 데이터)
|
||||
# 분리 근거가 없으므로 전량 암으로 둔다(기존 단일 지반유형 환산과 같은 결과).
|
||||
if preset_key != "rock":
|
||||
cut_soil_area, cut_rock_area = cut_area, 0.0
|
||||
cut_rock_kind: str | None = None
|
||||
elif rock_boundary_offset_m is None:
|
||||
cut_soil_area, cut_rock_area = 0.0, cut_area
|
||||
cut_rock_kind = ground_type
|
||||
else:
|
||||
cut_soil_area, cut_rock_area = _split_cut_areas(
|
||||
offsets, diffs, abs(float(rock_boundary_offset_m))
|
||||
)
|
||||
cut_rock_kind = ground_type
|
||||
|
||||
# 측구 공칭 단면적(수량 산출 참고용): 일반=사다리꼴, L형=직각삼각형 근사.
|
||||
if not geometry.has_ditch:
|
||||
ditch_area = 0.0
|
||||
@@ -575,6 +630,10 @@ def compute_cross_design(
|
||||
},
|
||||
"design_elevation_m": round(float(design_elevation_m), 4),
|
||||
"cut_area_m2": round(cut_area, 4),
|
||||
# 절토 내역(합=cut_area_m2). 유토곡선이 지반유형별 환산계수를 물리는 단위다.
|
||||
"cut_soil_area_m2": round(cut_soil_area, 4),
|
||||
"cut_rock_area_m2": round(cut_rock_area, 4),
|
||||
"cut_rock_kind": cut_rock_kind,
|
||||
"fill_area_m2": round(fill_area, 4),
|
||||
"ditch_area_m2": round(ditch_area, 4),
|
||||
"design_line": design_line,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/* =============================================================================
|
||||
* B06_wf3_ProfileCross_UI_Cross_Areas.ts
|
||||
* 횡단 절·성토 면적 readout과 면적 영역 하이라이트 밴드.
|
||||
*
|
||||
* 절토는 암반 경계선(지면선 평행 복사)을 기준으로 위=토사, 아래=암반으로 갈린다. 경계선
|
||||
* 위치가 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 여기 표시되는
|
||||
* 면적과 유토곡선이 함께 움직인다. 면적값 산출의 정본은 백엔드
|
||||
* (`B06_wf3_ProfileCross_Engine_Design._split_cut_areas`)이고, 이 모듈은 **같은 규칙으로
|
||||
* 영역만 다시 그린다**(수치를 여기서 다시 계산해 표시하지 않는다 — 이중 정의 금지).
|
||||
*
|
||||
* 하이라이트 규칙 (2026-08-02 사용자 지시):
|
||||
* - 선택된 횡단도에서만 동작한다. 다른 측점을 고르면 카드가 다시 그려지며 자동 해제된다.
|
||||
* - 값 칩 또는 면적 영역을 누르면 그 영역만 강조하고, 다시 누르면 해제한다.
|
||||
*
|
||||
* 700줄 제한 대응으로 `_UI_Cross_Design`(588줄)에서 면적 readout을 이 파일로 옮겨 왔다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossDesign, SectionSample } from "./B06_wf3_ProfileCross_Api_Fetch";
|
||||
import { L, svgElement } from "./B06_wf3_ProfileCross_UI_Section_Common";
|
||||
|
||||
/** 강조 대상. `cut_total`은 토사·암반 두 밴드를 함께 켠다. */
|
||||
export type CrossAreaKey = "cut_soil" | "cut_rock" | "cut_total" | "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;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `top > bottom`인 구간만 잘라 폴리곤 점 문자열을 만든다.
|
||||
* 두께가 0이 되는 지점을 끼워 넣어 밴드 끝이 지면선·설계선 교점에서 정확히 닫히게 한다.
|
||||
*/
|
||||
function bandPolygons(
|
||||
xs: number[],
|
||||
top: number[],
|
||||
bottom: number[],
|
||||
px: (offset: number) => number,
|
||||
py: (elevation: number) => number,
|
||||
): string[] {
|
||||
const polygons: string[] = [];
|
||||
let upper: string[] = [];
|
||||
let lower: string[] = [];
|
||||
const flush = (): void => {
|
||||
if (upper.length >= 2) polygons.push([...upper, ...lower.reverse()].join(" "));
|
||||
upper = [];
|
||||
lower = [];
|
||||
};
|
||||
const add = (offset: number, high: number, low: number): void => {
|
||||
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;
|
||||
}
|
||||
|
||||
/** 토사층 두께(m). 토사 측점은 암반 경계선이 없어 null, 경계선 값이 없는 암 측점은 0(전량 암). */
|
||||
function soilDepth(design: CrossDesign): number | null {
|
||||
if (!design.cut_rock_kind) return null;
|
||||
return Math.abs(design.rock_boundary_offset_m ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 절토(토사)·절토(암반)·성토 영역을 SVG에 깔고, 강조 토글 함수를 돌려준다.
|
||||
* 밴드는 평소 투명에 가깝게 두되 클릭 대상은 되도록 남겨 "면적을 눌러도 강조"가 성립한다.
|
||||
*/
|
||||
export function appendCrossAreaBands(
|
||||
svg: SVGSVGElement,
|
||||
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: CrossAreaKey; 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 groups = new Map<CrossAreaKey, SVGGElement>();
|
||||
for (const band of bands) {
|
||||
const polygons = bandPolygons(xs, band.top, band.bottom, x, toDisplayY);
|
||||
if (!polygons.length) continue;
|
||||
const group = svgElement("g", { class: `b06-chart__area b06-chart__area--${band.key}` });
|
||||
for (const points of polygons) group.append(svgElement("polygon", { points }));
|
||||
group.addEventListener("click", () => onSelect(band.key));
|
||||
groups.set(band.key, group);
|
||||
svg.append(group);
|
||||
}
|
||||
|
||||
return (key) => {
|
||||
for (const [bandKey, group] of groups) {
|
||||
const active =
|
||||
key === bandKey ||
|
||||
(key === "cut_total" && (bandKey === "cut_soil" || bandKey === "cut_rock"));
|
||||
group.classList.toggle("is-active", active);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 절·성토 면적값 오버레이(E-4) — 그래프 중상단에 표시한다.
|
||||
* `onSelect`가 오면 칩이 강조 토글 버튼이 된다(선택된 카드에서만 넘어온다).
|
||||
*/
|
||||
export function buildAreaReadout(
|
||||
design: CrossDesign | undefined,
|
||||
onSelect?: (key: CrossAreaKey) => void,
|
||||
): { root: HTMLElement; setActive: AreaHighlightSetter } {
|
||||
const readout = document.createElement("div");
|
||||
readout.className = "b06-cross-card__areas";
|
||||
if (!design) {
|
||||
const unset = document.createElement("span");
|
||||
unset.className = "b06-design__area b06-design__area--unset";
|
||||
unset.textContent = L("B06_Design_Unset");
|
||||
readout.append(unset);
|
||||
return { root: readout, setActive: () => undefined };
|
||||
}
|
||||
|
||||
// 토사/암반 칩은 암 측점에서만 의미가 있다(토사 측점은 절토계와 같은 값이라 중복 표기).
|
||||
const entries: Array<{ key: CrossAreaKey; label: string; value: number }> = [];
|
||||
if (design.cut_rock_kind) {
|
||||
entries.push(
|
||||
{
|
||||
key: "cut_soil",
|
||||
label: L("B06_Design_Cut_Soil_Area"),
|
||||
value: design.cut_soil_area_m2 ?? 0,
|
||||
},
|
||||
{
|
||||
key: "cut_rock",
|
||||
label: L("B06_Design_Cut_Rock_Area"),
|
||||
value: design.cut_rock_area_m2 ?? 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
entries.push(
|
||||
{ key: "cut_total", label: L("B06_Design_Cut_Area"), value: design.cut_area_m2 },
|
||||
{ key: "fill", label: L("B06_Design_Fill_Area"), value: design.fill_area_m2 },
|
||||
);
|
||||
|
||||
const chips = new Map<CrossAreaKey, HTMLElement>();
|
||||
for (const entry of entries) {
|
||||
const text = `${entry.label} ${entry.value.toFixed(2)}㎡`;
|
||||
if (!onSelect) {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = `b06-design__area b06-design__area--${entry.key}`;
|
||||
chip.textContent = text;
|
||||
readout.append(chip);
|
||||
continue;
|
||||
}
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = `b06-design__area b06-design__area--${entry.key}`;
|
||||
chip.textContent = text;
|
||||
chip.title = L("B06_Design_Area_Highlight");
|
||||
chip.setAttribute("aria-pressed", "false");
|
||||
chip.addEventListener("click", () => onSelect(entry.key));
|
||||
chips.set(entry.key, chip);
|
||||
readout.append(chip);
|
||||
}
|
||||
|
||||
return {
|
||||
root: readout,
|
||||
setActive: (key) => {
|
||||
for (const [chipKey, chip] of chips) {
|
||||
const active = chipKey === key;
|
||||
chip.classList.toggle("is-active", active);
|
||||
chip.setAttribute("aria-pressed", String(active));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -432,27 +432,6 @@ export function buildDesignControls(
|
||||
return { bar, groundSegment };
|
||||
}
|
||||
|
||||
/** 절·성토 면적값 오버레이(E-4) — 그래프 중상단에 배경색과 함께 표시한다. */
|
||||
export function buildAreaReadout(design: CrossDesign | undefined): HTMLElement {
|
||||
const readout = document.createElement("div");
|
||||
readout.className = "b06-cross-card__areas";
|
||||
if (design) {
|
||||
const cut = document.createElement("span");
|
||||
cut.className = "b06-design__area b06-design__area--cut";
|
||||
cut.textContent = `${L("B06_Design_Cut_Area")} ${design.cut_area_m2.toFixed(2)}㎡`;
|
||||
const fill = document.createElement("span");
|
||||
fill.className = "b06-design__area b06-design__area--fill";
|
||||
fill.textContent = `${L("B06_Design_Fill_Area")} ${design.fill_area_m2.toFixed(2)}㎡`;
|
||||
readout.append(cut, fill);
|
||||
} else {
|
||||
const unset = document.createElement("span");
|
||||
unset.className = "b06-design__area b06-design__area--unset";
|
||||
unset.textContent = L("B06_Design_Unset");
|
||||
readout.append(unset);
|
||||
}
|
||||
return readout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 횡단 SVG에 표준단면 설계선을 겹쳐 그린다. 지면선과 겹치는 구간(사면이 지반을 추종하는
|
||||
* 부분)만 점선으로 그려 뒤에 깔린 지표선이 비쳐 보이게 하고, 나머지는 실선으로 둔다.
|
||||
|
||||
@@ -8,11 +8,16 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection, SectionSample } from "./B06_wf3_ProfileCross_Api_Fetch";
|
||||
import {
|
||||
appendCrossAreaBands,
|
||||
type AreaHighlightSetter,
|
||||
buildAreaReadout,
|
||||
type CrossAreaKey,
|
||||
} from "./B06_wf3_ProfileCross_UI_Cross_Areas";
|
||||
import {
|
||||
appendCrossDesignOverlay,
|
||||
appendPavementOverlay,
|
||||
appendRockBoundaryOverlay,
|
||||
buildAreaReadout,
|
||||
buildDesignControls,
|
||||
buildRockBoundaryControl,
|
||||
sectionModeLabel,
|
||||
@@ -369,9 +374,30 @@ export function createCrossSectionCard(
|
||||
svg.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })),
|
||||
);
|
||||
|
||||
// 면적 강조는 선택된 카드에서만 붙인다 — 다른 측점을 고르면 카드가 다시 그려지며 해제된다.
|
||||
let activeArea: CrossAreaKey | null = null;
|
||||
let setBandActive: AreaHighlightSetter = () => undefined;
|
||||
let setChipActive: AreaHighlightSetter = () => undefined;
|
||||
const toggleArea = (key: CrossAreaKey): void => {
|
||||
activeArea = activeArea === key ? null : key;
|
||||
setBandActive(activeArea);
|
||||
setChipActive(activeArea);
|
||||
};
|
||||
|
||||
if (section.design) {
|
||||
const toDisplayY = (elevation: number): number =>
|
||||
y(elevationMid + (elevation - elevationMid) * exaggeration);
|
||||
// 면적 밴드는 지면선 위·설계선 아래에 깔아 선이 밴드에 가리지 않게 한다.
|
||||
if (selected) {
|
||||
setBandActive = appendCrossAreaBands(
|
||||
svg,
|
||||
section.design,
|
||||
sourceSamples,
|
||||
x,
|
||||
toDisplayY,
|
||||
toggleArea,
|
||||
);
|
||||
}
|
||||
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
|
||||
appendPavementOverlay(svg, section.design, x, toDisplayY);
|
||||
appendCrossDesignOverlay(svg, section.design, x, toDisplayY, sourceSamples);
|
||||
@@ -449,8 +475,13 @@ export function createCrossSectionCard(
|
||||
const chartWrap = document.createElement("div");
|
||||
chartWrap.className = "b06-cross-card__chart-wrap";
|
||||
attachZoomPan(svg, widthPx, heightPx);
|
||||
// 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4).
|
||||
chartWrap.append(svg, buildAreaReadout(section.design));
|
||||
// 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 선택된 카드의 칩은 강조 토글이 된다.
|
||||
const readout = buildAreaReadout(
|
||||
section.design,
|
||||
selected && section.design ? toggleArea : undefined,
|
||||
);
|
||||
setChipActive = readout.setActive;
|
||||
chartWrap.append(svg, readout.root);
|
||||
// 암 경계선 제어는 그래프 X축 제목 행 우측에 배치(E-7). 암 지반에서만.
|
||||
if (rockBoundary && section.design?.geometry_preset === "rock") {
|
||||
const rockControl = buildRockBoundaryControl(section, rockBoundary);
|
||||
|
||||
@@ -19,17 +19,21 @@
|
||||
* 편절·편성이 지배적이므로 두 기준이 크게 어긋날 수 있다. 어느 쪽을 정식 기준으로 삼을지
|
||||
* 사용자가 그래프를 보고 판단할 수 있게, 두 기준을 같은 축에 겹쳐 그린다.
|
||||
*
|
||||
* ── 지반유형 처리를 3가지로 내는 이유 ────────────────────────────────
|
||||
* ── 절토를 토사/암반으로 나눠 환산하는 이유 ──────────────────────────
|
||||
* 같은 기준 2.다.(4)(다)는 "절토부분은 토사·암반으로 구분하되, 암반부분은 추정선으로
|
||||
* 기입한다"고 정한다. 그러나 현재 `compute_cross_design()`은 측점당 절토 단면적을
|
||||
* **하나만** 내므로 암반 경계선 위아래를 나누지 못하고, 측점 전체가 단일 지반유형으로
|
||||
* 환산된다(미결사항 1번). 분리 로직이 서기 전까지 환산 방식이 결과를 얼마나 흔드는지
|
||||
* 눈으로 확인할 수 있도록 세 가지를 모두 계산해 둔다.
|
||||
* 기입한다"고 정한다. 절토면적은 계수로 나누는 것이 아니라 **횡단도 기하로 갈린다** —
|
||||
* 지표면~암반 경계선이 토사, 그 아래가 암이다. 엔진(`compute_cross_design()`)이 그 경계로
|
||||
* 나눈 `cut_soil_area_m2` / `cut_rock_area_m2`를 각각 자기 환산계수로 곱해 합산한다.
|
||||
* 참고 도면(3공구)의 작업별 EA:RR 비율이 34:66~71:29로 흩어지는 것이 이 구조의 근거다.
|
||||
*
|
||||
* 지반유형 처리 3종(`ground_type`/`soil_only`/`natural`)은 환산 방식이 결과를 얼마나
|
||||
* 흔드는지 눈으로 대조하기 위한 비교군으로 남겨 둔다.
|
||||
*
|
||||
* 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
CrossDesign,
|
||||
CrossSection,
|
||||
EarthworkConversion,
|
||||
GroundType,
|
||||
@@ -43,7 +47,7 @@ export interface GroundVolumes {
|
||||
blasting_rock: number;
|
||||
}
|
||||
|
||||
/** 측점 하나의 유토곡선 좌표. */
|
||||
/** 측점 하나의 유토곡선 좌표. 구간 물량은 **직전 측점부터 이 측점까지**의 몫이다(첫 측점은 0). */
|
||||
export interface MassHaulPoint {
|
||||
station_id: string;
|
||||
chainage_m: number;
|
||||
@@ -51,6 +55,13 @@ export interface MassHaulPoint {
|
||||
net_volume_m3: number;
|
||||
/** 시점부터의 누가토량(㎥). */
|
||||
cumulative_volume_m3: number;
|
||||
/** 구간 절토량(자연상태) — 토사분/암반분. 측점 선택 시 물량 표기에 그대로 쓴다. */
|
||||
cut_soil_m3: number;
|
||||
cut_rock_m3: number;
|
||||
/** 구간 절토량(환산 후) — 곡선에 실제로 들어간 값. */
|
||||
cut_compacted_m3: number;
|
||||
/** 구간 성토량(다짐상태 = 설계 물량). */
|
||||
fill_m3: number;
|
||||
}
|
||||
|
||||
export interface MassHaulResult {
|
||||
@@ -138,13 +149,41 @@ function normalizeGround(value: GroundType | undefined): GroundType {
|
||||
return value && GROUND_TYPES.includes(value) ? value : "soil";
|
||||
}
|
||||
|
||||
/** 적분 입력 한 점. 기준이 달라도 여기까지 오면 같은 코드를 탄다. */
|
||||
/**
|
||||
* 적분 입력 한 점. 기준이 달라도 여기까지 오면 같은 코드를 탄다.
|
||||
* 절토는 암반 경계선으로 이미 갈린 상태로 들어온다(토사분 / 암반분).
|
||||
*/
|
||||
interface AreaSample {
|
||||
station_id: string;
|
||||
chainage_m: number;
|
||||
cut_area_m2: number;
|
||||
cut_soil_area_m2: number;
|
||||
cut_rock_area_m2: number;
|
||||
/** 암반분에 물릴 지반유형. 토사 측점은 null(암반분 0). */
|
||||
rock_kind: GroundType | null;
|
||||
fill_area_m2: number;
|
||||
ground: GroundType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진이 낸 절토 분리값을 읽는다. 구 데이터(분리 필드 없음)는 절토 전량을 측점
|
||||
* 지반유형으로 돌려 **기존 단일 지반유형 환산과 같은 결과**를 유지한다.
|
||||
*/
|
||||
function splitCut(design: CrossDesign | undefined): {
|
||||
soil: number;
|
||||
rock: number;
|
||||
rock_kind: GroundType | null;
|
||||
} {
|
||||
const ground = normalizeGround(design?.ground_type);
|
||||
const total = finiteArea(design?.cut_area_m2);
|
||||
if (design?.cut_soil_area_m2 === undefined || design?.cut_rock_area_m2 === undefined) {
|
||||
return ground === "soil"
|
||||
? { soil: total, rock: 0, rock_kind: null }
|
||||
: { soil: 0, rock: total, rock_kind: ground };
|
||||
}
|
||||
return {
|
||||
soil: finiteArea(design.cut_soil_area_m2),
|
||||
rock: finiteArea(design.cut_rock_area_m2),
|
||||
rock_kind: design.cut_rock_kind ?? (ground === "soil" ? null : ground),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,6 +206,10 @@ function integrate(
|
||||
chainage_m: samples[0].chainage_m,
|
||||
net_volume_m3: 0,
|
||||
cumulative_volume_m3: 0,
|
||||
cut_soil_m3: 0,
|
||||
cut_rock_m3: 0,
|
||||
cut_compacted_m3: 0,
|
||||
fill_m3: 0,
|
||||
},
|
||||
];
|
||||
const cutNatural = emptyVolumes();
|
||||
@@ -184,13 +227,23 @@ function integrate(
|
||||
if (!(spanM > 0)) continue;
|
||||
const halfSpan = spanM / 2;
|
||||
|
||||
// 절토: 양 끝 점이 각자 자기 지반유형으로 절반씩 가져간다.
|
||||
// 절토: 양 끝 점이 각자 절반씩 가져가고, 각 점 안에서 토사분·암반분이 따로 환산된다.
|
||||
let segmentCut = 0;
|
||||
let segmentCutSoil = 0;
|
||||
let segmentCutRock = 0;
|
||||
for (const end of [previous, current]) {
|
||||
const naturalM3 = end.cut_area_m2 * halfSpan;
|
||||
if (naturalM3 <= 0) continue;
|
||||
cutNatural[end.ground] += naturalM3;
|
||||
segmentCut += naturalM3 * factorFor(conversion, end.ground, groundMode);
|
||||
const soilM3 = end.cut_soil_area_m2 * halfSpan;
|
||||
const rockM3 = end.cut_rock_area_m2 * halfSpan;
|
||||
if (soilM3 > 0) {
|
||||
cutNatural.soil += soilM3;
|
||||
segmentCutSoil += soilM3;
|
||||
segmentCut += soilM3 * factorFor(conversion, "soil", groundMode);
|
||||
}
|
||||
if (rockM3 > 0 && end.rock_kind) {
|
||||
cutNatural[end.rock_kind] += rockM3;
|
||||
segmentCutRock += rockM3;
|
||||
segmentCut += rockM3 * factorFor(conversion, end.rock_kind, groundMode);
|
||||
}
|
||||
}
|
||||
|
||||
// 성토: 설계 성토량이 곧 다짐상태 물량이라 환산하지 않는다.
|
||||
@@ -207,6 +260,10 @@ function integrate(
|
||||
chainage_m: current.chainage_m,
|
||||
net_volume_m3: net,
|
||||
cumulative_volume_m3: cumulative,
|
||||
cut_soil_m3: segmentCutSoil,
|
||||
cut_rock_m3: segmentCutRock,
|
||||
cut_compacted_m3: segmentCut,
|
||||
fill_m3: segmentFill,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -235,13 +292,17 @@ function designedSections(crossSections: CrossSection[]): CrossSection[] {
|
||||
|
||||
/** 횡단 기준 — 엔진이 낸 실측 단면적을 그대로 쓴다. */
|
||||
function crossAreaSamples(sections: CrossSection[]): AreaSample[] {
|
||||
return sections.map((section) => ({
|
||||
station_id: section.station_id,
|
||||
chainage_m: section.chainage_m,
|
||||
cut_area_m2: finiteArea(section.design?.cut_area_m2),
|
||||
fill_area_m2: finiteArea(section.design?.fill_area_m2),
|
||||
ground: normalizeGround(section.design?.ground_type),
|
||||
}));
|
||||
return sections.map((section) => {
|
||||
const cut = splitCut(section.design);
|
||||
return {
|
||||
station_id: section.station_id,
|
||||
chainage_m: section.chainage_m,
|
||||
cut_soil_area_m2: cut.soil,
|
||||
cut_rock_area_m2: cut.rock,
|
||||
rock_kind: cut.rock_kind,
|
||||
fill_area_m2: finiteArea(section.design?.fill_area_m2),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,6 +312,9 @@ function crossAreaSamples(sections: CrossSection[]): AreaSample[] {
|
||||
* (`B05_wf2_Route_UI_Profile_Alignment.ts`가 이미 쓰는 부호 규약과 같다).
|
||||
* 노반폭과 지반유형은 종단 샘플에 없으므로 **가장 가까운 설계 측점**에서 가져온다.
|
||||
* 설계된 측점이 하나도 없으면 노반폭을 알 수 없어 곡선을 만들지 않는다.
|
||||
*
|
||||
* 개략 단면적에는 암반 경계선이 없으므로, **가장 가까운 측점의 토사:암반 면적비**를 그대로
|
||||
* 물려 두 기준이 같은 환산을 타게 한다(비율을 못 구하면 전량 토사로 둔다).
|
||||
*/
|
||||
function longitudinalAreaSamples(
|
||||
longitudinal: LongitudinalSection,
|
||||
@@ -276,12 +340,17 @@ function longitudinalAreaSamples(
|
||||
const nearest = sections[cursor];
|
||||
const width = finiteArea(nearest.design?.roadbed_width_m);
|
||||
const height = sample.difference_m;
|
||||
const cutArea = height < 0 ? -height * width : 0;
|
||||
const cut = splitCut(nearest.design);
|
||||
const total = cut.soil + cut.rock;
|
||||
const rockRatio = total > 0 ? cut.rock / total : 0;
|
||||
return {
|
||||
station_id: `${nearest.station_id}@${index}`,
|
||||
chainage_m: sample.chainage_m,
|
||||
cut_area_m2: height < 0 ? -height * width : 0,
|
||||
cut_soil_area_m2: cutArea * (1 - rockRatio),
|
||||
cut_rock_area_m2: cutArea * rockRatio,
|
||||
rock_kind: cut.rock_kind,
|
||||
fill_area_m2: height > 0 ? height * width : 0,
|
||||
ground: normalizeGround(nearest.design?.ground_type),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -341,6 +410,10 @@ export function massHaulPayload(result: MassHaulResult): Record<string, unknown>
|
||||
chainage_m: round(point.chainage_m),
|
||||
net_volume_m3: round(point.net_volume_m3),
|
||||
cumulative_volume_m3: round(point.cumulative_volume_m3),
|
||||
// 구간 절토 내역(자연상태) — B08 내역서가 EA/RR/BR로 되받는 단위.
|
||||
cut_soil_m3: round(point.cut_soil_m3),
|
||||
cut_rock_m3: round(point.cut_rock_m3),
|
||||
fill_m3: round(point.fill_m3),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { LongitudinalSection } from "./B06_wf3_ProfileCross_Api_Fetch";
|
||||
import type {
|
||||
MassHaulBasis,
|
||||
MassHaulGroundMode,
|
||||
MassHaulPoint,
|
||||
MassHaulSeries,
|
||||
} from "./B06_wf3_ProfileCross_UI_MassHaul";
|
||||
import type { LocaleKey } from "@ui/ui_template_locale";
|
||||
@@ -81,6 +82,32 @@ function volumeRange(series: MassHaulSeries[]): { min: number; max: number } {
|
||||
return { min: rawMin - padding, max: rawMax + padding };
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택된 측점의 곡선 점을 찾는다.
|
||||
*
|
||||
* 측점 id로 직접 맞추지 않고 **누가거리로 맞춘다** — 종단 기준 곡선의 점 id는 종단 샘플
|
||||
* 인덱스가 붙은 파생값(`${station_id}@${index}`)이라 측점 id와 형태가 다르기 때문이다.
|
||||
*/
|
||||
export function massHaulPointAt(
|
||||
series: MassHaulSeries,
|
||||
longitudinal: LongitudinalSection,
|
||||
stationId: string | null,
|
||||
): MassHaulPoint | null {
|
||||
if (!stationId) return null;
|
||||
const station = longitudinal.stations.find((entry) => entry.station_id === stationId);
|
||||
if (!station) return null;
|
||||
let best: MassHaulPoint | null = null;
|
||||
let bestGap = Number.POSITIVE_INFINITY;
|
||||
for (const point of series.result.points) {
|
||||
const gap = Math.abs(point.chainage_m - station.chainage_m);
|
||||
if (gap < bestGap) {
|
||||
bestGap = gap;
|
||||
best = point;
|
||||
}
|
||||
}
|
||||
return bestGap <= 0.5 ? best : null;
|
||||
}
|
||||
|
||||
export function createMassHaulChart(
|
||||
series: MassHaulSeries[],
|
||||
visibleKeys: ReadonlySet<string>,
|
||||
@@ -213,6 +240,19 @@ export function createMassHaulChart(
|
||||
);
|
||||
}
|
||||
|
||||
// 선택 측점 강조 — 첫 표시 곡선 위에 점을 찍어 아래 물량 표기 줄과 시선을 잇는다.
|
||||
const focus = banded ? massHaulPointAt(banded, longitudinal, selectedStationId) : null;
|
||||
if (focus) {
|
||||
svg.append(
|
||||
svgElement("circle", {
|
||||
cx: x(focus.chainage_m),
|
||||
cy: y(focus.cumulative_volume_m3),
|
||||
r: 4,
|
||||
class: "b06-masshaul__focus",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: LONG_PAD.left,
|
||||
@@ -274,6 +314,43 @@ export function createMassHaulLegend(
|
||||
return legend;
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 측점의 **구간 물량** 표기 줄 (2026-08-02 사용자 지시).
|
||||
* 횡단도를 고르든 종단도 측점을 고르든 같은 선택 경로를 타므로 여기 한 곳만 그리면 된다.
|
||||
* 물량은 "직전 측점 → 이 측점" 구간 몫이며, 횡단도 카드는 면적(㎡)만 표시한다(역할 분리).
|
||||
*/
|
||||
export function createMassHaulStationInfo(
|
||||
series: MassHaulSeries,
|
||||
longitudinal: LongitudinalSection,
|
||||
selectedStationId: string | null,
|
||||
stationInterval: number,
|
||||
): HTMLElement | null {
|
||||
const point = massHaulPointAt(series, longitudinal, selectedStationId);
|
||||
if (!point) return null;
|
||||
const row = document.createElement("div");
|
||||
row.className = "b06-masshaul__station";
|
||||
const chips: Array<[string, string]> = [
|
||||
[L("B06_MassHaul_Station_Segment"), stationLabel(point.chainage_m, stationInterval)],
|
||||
[L("B06_Design_Cut_Soil_Area"), `${formatVolume(point.cut_soil_m3)}㎥`],
|
||||
[L("B06_Design_Cut_Rock_Area"), `${formatVolume(point.cut_rock_m3)}㎥`],
|
||||
[L("B06_MassHaul_CutCompacted"), `${formatVolume(point.cut_compacted_m3)}㎥`],
|
||||
[L("B06_MassHaul_Fill"), `${formatVolume(point.fill_m3)}㎥`],
|
||||
[L("B06_MassHaul_Station_Net"), `${formatVolume(point.net_volume_m3)}㎥`],
|
||||
[L("B06_MassHaul_Station_Cumulative"), `${formatVolume(point.cumulative_volume_m3)}㎥`],
|
||||
];
|
||||
for (const [label, value] of chips) {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "b06-masshaul__chip";
|
||||
const name = document.createElement("em");
|
||||
name.textContent = label;
|
||||
const amount = document.createElement("strong");
|
||||
amount.textContent = value;
|
||||
chip.append(name, amount);
|
||||
row.append(chip);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 곡선 아래 총괄 요약 줄. 절토는 자연상태(내역 기준)와 환산 후(곡선 기준)를 함께 적는다.
|
||||
* 곡선이 여러 개라 수치가 어느 곡선의 것인지 끝에 반드시 밝힌다.
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import {
|
||||
createMassHaulChart,
|
||||
createMassHaulLegend,
|
||||
createMassHaulStationInfo,
|
||||
createMassHaulSummary,
|
||||
MASS_HAUL_HEIGHT,
|
||||
MASS_HAUL_MIN_HEIGHT,
|
||||
@@ -58,8 +59,11 @@ import {
|
||||
|
||||
export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl };
|
||||
|
||||
/** 헤더·요약줄·테두리가 먹는 세로 공간. 패널 높이에서 이만큼 빼야 그래프 몫이 된다. */
|
||||
const PANEL_CHROME_PX = 96;
|
||||
/**
|
||||
* 헤더·요약줄·선택측점 물량줄·테두리가 먹는 세로 공간. 패널 높이에서 이만큼 빼야 그래프 몫이 된다.
|
||||
* (`.b06-section__panel-body`가 `overflow: hidden`이라 이 값이 모자라면 아래 줄이 잘린다.)
|
||||
*/
|
||||
const PANEL_CHROME_PX = 128;
|
||||
/** 손대지 않았을 때의 패널 높이 — 이 값이 축소 비례의 기준(H₀)이 된다. */
|
||||
const BASE_PANEL_HEIGHT = LONG_HEIGHT + MASS_HAUL_HEIGHT + PANEL_CHROME_PX;
|
||||
const MIN_LONG_HEIGHT = 110;
|
||||
@@ -309,12 +313,20 @@ export function createSectionView(
|
||||
chartWrap.replaceChildren(...nodes);
|
||||
|
||||
panelTitle.textContent = `${L("B06_Profile_View_Longitudinal")} · ${L("B06_MassHaul_Title")}`;
|
||||
const summary = panel.querySelector(".b06-masshaul__summary");
|
||||
summary?.remove();
|
||||
panel.querySelector(".b06-masshaul__summary")?.remove();
|
||||
panel.querySelector(".b06-masshaul__station")?.remove();
|
||||
// 요약 수치는 켜 둔 곡선 중 첫 번째 것 — 곡선이 여러 개라 어느 것인지 요약 끝에 밝힌다.
|
||||
const summarySeries = series.find((entry) => visibleSeries.has(entry.key));
|
||||
if (summarySeries) {
|
||||
panelCount.textContent = "";
|
||||
// 선택 측점의 구간 물량은 요약 위에 붙인다(횡단도는 면적만, 물량은 여기서 본다).
|
||||
const stationInfo = createMassHaulStationInfo(
|
||||
summarySeries,
|
||||
detail.longitudinal,
|
||||
selectedStationId,
|
||||
cachedStationInterval,
|
||||
);
|
||||
if (stationInfo) panelBody.append(stationInfo);
|
||||
panelBody.append(createMassHaulSummary(summarySeries));
|
||||
} else {
|
||||
panelCount.textContent = L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty");
|
||||
|
||||
@@ -515,10 +515,20 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b06-design__area--cut {
|
||||
.b06-design__area--cut,
|
||||
.b06-design__area--cut_total {
|
||||
color: rgb(220 38 38);
|
||||
}
|
||||
|
||||
/* 절토 내역 — 토사(지표면~암반 경계선)와 암반(경계선 아래)을 색으로 갈라 둔다. */
|
||||
.b06-design__area--cut_soil {
|
||||
color: rgb(217 119 6);
|
||||
}
|
||||
|
||||
.b06-design__area--cut_rock {
|
||||
color: rgb(71 85 105);
|
||||
}
|
||||
|
||||
.b06-design__area--fill {
|
||||
color: rgb(37 99 235);
|
||||
}
|
||||
@@ -527,6 +537,50 @@
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 선택된 카드에서만 칩이 버튼이 된다 — 부모가 pointer-events: none이라 여기서 되살린다. */
|
||||
button.b06-design__area {
|
||||
padding: 0 2px;
|
||||
border: none;
|
||||
border-radius: var(--radius-inputs);
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 켠 칩은 글자색을 유지한 채 같은 색으로 옅게 칠하고 테두리를 둘러 대비를 살린다. */
|
||||
button.b06-design__area.is-active {
|
||||
background: color-mix(in srgb, currentcolor 18%, transparent);
|
||||
box-shadow: inset 0 0 0 1px currentcolor;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
/* 면적 강조 밴드 — 평소엔 클릭 대상으로만 남고, 켜질 때만 채워진다. */
|
||||
.b06-chart__area > polygon {
|
||||
fill: transparent;
|
||||
stroke: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b06-chart__area--cut_soil.is-active > polygon {
|
||||
fill: color-mix(in srgb, rgb(217 119 6) 28%, transparent);
|
||||
stroke: rgb(217 119 6);
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
.b06-chart__area--cut_rock.is-active > polygon {
|
||||
fill: color-mix(in srgb, rgb(71 85 105) 28%, transparent);
|
||||
stroke: rgb(71 85 105);
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
.b06-chart__area--fill.is-active > polygon {
|
||||
fill: color-mix(in srgb, rgb(37 99 235) 28%, transparent);
|
||||
stroke: rgb(37 99 235);
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
/* 횡단 표준단면 설계선 오버레이. 기본은 실선. */
|
||||
.b06-chart__design-cross {
|
||||
fill: none;
|
||||
|
||||
@@ -140,3 +140,23 @@
|
||||
margin-inline-start: auto;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 선택 측점의 구간 물량 줄 — 총괄 요약과 구분되게 배경을 옅게 깐다. */
|
||||
.b06-masshaul__station {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
padding: var(--spacing-8) var(--spacing-16);
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: color-mix(in srgb, var(--color-royal-amethyst) 7%, transparent);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 선택 측점이 곡선의 어디인지 찍는 점 — 물량 줄과 시선을 잇는다. */
|
||||
.b06-masshaul__focus {
|
||||
fill: var(--color-royal-amethyst);
|
||||
stroke: var(--color-surface);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
@@ -240,6 +240,10 @@ export const ui_locales_b2 = {
|
||||
B06_MassHaul_Legend_Title: ["표시할 유토곡선", "Visible mass haul curves"],
|
||||
B06_MassHaul_Legend_Show: ["표시", "Show"],
|
||||
B06_MassHaul_Legend_Hide: ["숨김", "Hide"],
|
||||
/* 선택 측점의 구간 물량 — 직전 측점부터 이 측점까지의 몫이다. */
|
||||
B06_MassHaul_Station_Segment: ["구간 물량", "Segment volume"],
|
||||
B06_MassHaul_Station_Net: ["순토량", "Net volume"],
|
||||
B06_MassHaul_Station_Cumulative: ["누가토량", "Cumulative"],
|
||||
|
||||
/* --- B06 측점 표준횡단 설계 지정 --- */
|
||||
B06_Design_Ground_Legend: ["지반유형", "Ground type"],
|
||||
@@ -255,6 +259,10 @@ export const ui_locales_b2 = {
|
||||
B06_Design_Ditch_Left: ["좌", "Left"],
|
||||
B06_Design_Ditch_Right: ["우", "Right"],
|
||||
B06_Design_Cut_Area: ["절토", "Cut"],
|
||||
/* 절토 내역 — 지표면~암반 경계선이 토사, 그 아래가 암반(합=절토). */
|
||||
B06_Design_Cut_Soil_Area: ["절토(토사)", "Cut (soil)"],
|
||||
B06_Design_Cut_Rock_Area: ["절토(암반)", "Cut (rock)"],
|
||||
B06_Design_Area_Highlight: ["누르면 해당 면적을 강조합니다", "Click to highlight this area"],
|
||||
B06_Design_Fill_Area: ["성토", "Fill"],
|
||||
B06_Design_Unset: ["미지정", "Not set"],
|
||||
B06_Design_DitchType_Legend: ["측구형식", "Ditch type"],
|
||||
|
||||
Reference in New Issue
Block a user