feat(B06): 유토곡선 다중 곡선화 — 종단/횡단 기준 x 지반유형 처리 3종 + 오버레이 범례
임도는 산복 사면을 따라가 편절·편성이 지배적이라, 종단 계획고로 본 절성토 균형과 실제 횡단 단면적으로 본 균형이 크게 어긋난다. 어느 쪽을 정식 기준으로 삼을지 사용자가 그래프를 보고 결정할 수 있도록 두 기준을 한 축에 겹쳐 그린다. 지반유형 처리도 암반 경계선 분리 전까지는 미결이라 세 가지를 함께 낸다. 법령 근거([임도의 설계 및 시설기준] 별표): - 2.다.(3)(나) "시공계획고는 절토량과 성토량이 균형을 이루게 하되" → 종단 기준 곡선 - 2.다.(4)(마) 측점마다 절토고·성토고·단면적 기입 → 횡단 기준 곡선 - 2.다.(4)(다) "절토부분은 토사·암반으로 구분" → 암반 분리(미결 1번)의 근거 같은 별표에 토공 운반장비 선정거리 기준은 없다. 장비 경계값의 근거는 법령이 아니라 3공구 도면 실측치다. 곡선 6종 = 기준 2(횡단/종단) x 환산 3(지반유형별/토사 단일/환산 없음). 색이 기준, 선모양이 환산 방식을 뜻한다. - _UI_MassHaul.ts: 공통 적분 함수 integrate(samples, conversion, groundMode)로 통일. 두 기준이 AreaSample[]까지 정규화된 뒤 같은 코드를 탄다 — 평균단면법·환산 로직을 이중으로 두지 않는다. computeMassHaulSeries() 신설. computeMassHaul()은 시그니처·결과를 그대로 유지해 _UI_Page.ts의 확정 저장 경로는 건드리지 않는다. 종단 기준: difference_m(= 계획고 - 지반고, 음수가 절토) x 노반폭. 노반폭·지반유형은 종단 샘플에 없으므로 최근접 설계 측점에서 상속한다. - _UI_MassHaul_View.ts: 다중 곡선 렌더 + 그래프 상단 오버레이 범례. Y 스케일은 표시 여부와 무관하게 항상 전체 곡선 기준으로 잡는다 — 곡선을 껐다 켤 때마다 축이 움직이면 비교 자체가 불가능하다. 면(band)은 맨 앞 곡선 하나에만 칠한다(여러 개면 서로 가려 못 읽는다). 곡선은 범례 역순으로 그려 첫 곡선이 맨 위에 온다. - _UI_Section_View.ts: 표시 집합을 sessionStorage에 보존. 전부 끈 상태도 사용자의 선택이므로 기본값으로 되돌리지 않는다. 요약 수치는 켜 둔 첫 곡선의 것이며, 어느 곡선인지 요약 끝에 반드시 밝힌다. - _UI_Style_MassHaul.css(신규): _UI_Style_Cross.css가 742줄로 700줄 제한을 넘어 유토곡선 블록을 분리했다(분리 후 606줄). 범례는 sticky + height:0이라 가로 스크롤에도 왼쪽에 남으면서 곡선 위로 뜬다. - ui_template_locale_b2.ts: 곡선명·범례 문구 9개 키. 하드코딩 없음. 검증(테스트1 / route 48 / 24측점 중 23측점 설계): - 계산 엔진 단위 테스트 18건 통과, tsc --noEmit 통과, 전 파일 700줄 이하 - 범례 6개 생성, 토글 2 -> 6 -> 0 -> 2 정상 - Y 눈금이 세 상태 모두 60.5 / -46.3 / -153.0 으로 동일 (축 고정 확인) - X축 정렬: 종단도 측점선 [62 ... 1454] 24개 = 유토곡선 [62 ... 1454] 24개 - 새로고침 후 켜 둔 곡선 복원 확인 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,9 +2,9 @@
|
||||
* B06_wf3_ProfileCross_UI_MassHaul.ts
|
||||
* 유토곡선(Mass Haul Diagram) 계산 엔진 — 순수 함수, DOM 의존 없음.
|
||||
*
|
||||
* 측점별 절·성토 단면적(백엔드 `compute_cross_design` 산출값)에서 구간 토량을 만들고,
|
||||
* 토량환산계수로 기준을 통일해 누가토량을 누적한다. 결과는 B08 수량산출·B09 견적이
|
||||
* 그대로 재사용할 수 있도록 계산과 렌더링을 분리해 둔다(렌더러는 `_UI_MassHaul_View`).
|
||||
* 구간 토량을 평균단면법으로 만들고 토량환산계수로 기준을 통일해 누가토량을 누적한다.
|
||||
* 결과는 B08 수량산출·B09 견적이 그대로 재사용할 수 있도록 계산과 렌더링을 분리해
|
||||
* 둔다(렌더러는 `_UI_MassHaul_View`).
|
||||
*
|
||||
* ── 계산 기준: 다짐상태 ──────────────────────────────────────────────
|
||||
* "운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, 내역서에 적용하는
|
||||
@@ -12,6 +12,20 @@
|
||||
* 유토곡선은 운반계획 도면이므로 다짐상태를 기준상태로 삼는다. 내역서용 자연상태
|
||||
* 수량은 `cut_natural_m3`로 함께 보존해 B08이 되돌려 쓸 수 있게 한다.
|
||||
*
|
||||
* ── 두 기준(종단/횡단)을 함께 내는 이유 ──────────────────────────────
|
||||
* [임도의 설계 및 시설기준] 2.다.(3)(나)는 "시공계획고는 절토량과 성토량이 균형을
|
||||
* 이루게 하되"라고 정해 **종단 계획고**를 균형 판정 대상으로 삼는다. 반면 실제 물량은
|
||||
* 같은 기준 2.다.(4)(마)의 측점별 횡단 단면적에서 나온다. 임도는 산복 사면을 따라가
|
||||
* 편절·편성이 지배적이므로 두 기준이 크게 어긋날 수 있다. 어느 쪽을 정식 기준으로 삼을지
|
||||
* 사용자가 그래프를 보고 판단할 수 있게, 두 기준을 같은 축에 겹쳐 그린다.
|
||||
*
|
||||
* ── 지반유형 처리를 3가지로 내는 이유 ────────────────────────────────
|
||||
* 같은 기준 2.다.(4)(다)는 "절토부분은 토사·암반으로 구분하되, 암반부분은 추정선으로
|
||||
* 기입한다"고 정한다. 그러나 현재 `compute_cross_design()`은 측점당 절토 단면적을
|
||||
* **하나만** 내므로 암반 경계선 위아래를 나누지 못하고, 측점 전체가 단일 지반유형으로
|
||||
* 환산된다(미결사항 1번). 분리 로직이 서기 전까지 환산 방식이 결과를 얼마나 흔드는지
|
||||
* 눈으로 확인할 수 있도록 세 가지를 모두 계산해 둔다.
|
||||
*
|
||||
* 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -19,6 +33,7 @@ import type {
|
||||
CrossSection,
|
||||
EarthworkConversion,
|
||||
GroundType,
|
||||
LongitudinalSection,
|
||||
} from "./B06_wf3_ProfileCross_Api_Fetch";
|
||||
|
||||
/** 지반유형별 토량(㎥). 도면 표기 EA(토사)/RR(리핑암)/BR(발파암)에 대응한다. */
|
||||
@@ -32,17 +47,17 @@ export interface GroundVolumes {
|
||||
export interface MassHaulPoint {
|
||||
station_id: string;
|
||||
chainage_m: number;
|
||||
/** 직전 측점부터 이 측점까지 구간의 순토량(다짐상태 기준, ㎥). 첫 측점은 0. */
|
||||
/** 직전 측점부터 이 측점까지 구간의 순토량(㎥). 첫 측점은 0. */
|
||||
net_volume_m3: number;
|
||||
/** 시점부터의 누가토량(다짐상태 기준, ㎥). */
|
||||
/** 시점부터의 누가토량(㎥). */
|
||||
cumulative_volume_m3: number;
|
||||
}
|
||||
|
||||
export interface MassHaulResult {
|
||||
points: MassHaulPoint[];
|
||||
/** 총 절토량(자연상태, 지반유형별) — 내역서 기준값. */
|
||||
/** 총 절토량(자연상태, 지반유형별) — 내역서 기준값. 환산 방식과 무관하게 실제 지반유형으로 쌓는다. */
|
||||
cut_natural_m3: GroundVolumes;
|
||||
/** 총 절토량(다짐 환산 후 합계) — 곡선 기준값. */
|
||||
/** 총 절토량(환산 후 합계) — 곡선 기준값. */
|
||||
cut_compacted_m3: number;
|
||||
/** 총 성토량(설계 성토량 = 다짐상태). */
|
||||
fill_compacted_m3: number;
|
||||
@@ -58,14 +73,59 @@ export interface MassHaulResult {
|
||||
conversion: EarthworkConversion;
|
||||
}
|
||||
|
||||
/** 토량을 어디서 뽑았는지. `cross`가 정식(실측 단면적), `longitudinal`은 계획고 기반 개략값. */
|
||||
export type MassHaulBasis = "cross" | "longitudinal";
|
||||
|
||||
/**
|
||||
* 절토량에 어떤 환산계수를 물릴지.
|
||||
* `ground_type` — 측점 지반유형별 C (현행 정식)
|
||||
* `soil_only` — 전 구간 토사 C 단일 적용 (암 판정을 신뢰하지 않는 하한 케이스)
|
||||
* `natural` — 환산 미적용. 절토 자연상태 그대로 성토 설계값과 뺀다(무보정 대조군)
|
||||
*/
|
||||
export type MassHaulGroundMode = "ground_type" | "soil_only" | "natural";
|
||||
|
||||
export interface MassHaulSeries {
|
||||
/** `${basis}_${groundMode}` — 표시 토글 저장 키이자 CSS 변형 클래스 접미사. */
|
||||
key: string;
|
||||
basis: MassHaulBasis;
|
||||
groundMode: MassHaulGroundMode;
|
||||
result: MassHaulResult;
|
||||
}
|
||||
|
||||
const GROUND_TYPES: GroundType[] = ["soil", "ripping_rock", "blasting_rock"];
|
||||
|
||||
/** 곡선을 그릴 기준·환산 조합. 배열 순서가 곧 범례 순서다. */
|
||||
const SERIES_MATRIX: Array<{ basis: MassHaulBasis; groundMode: MassHaulGroundMode }> = [
|
||||
{ basis: "cross", groundMode: "ground_type" },
|
||||
{ basis: "longitudinal", groundMode: "ground_type" },
|
||||
{ basis: "cross", groundMode: "soil_only" },
|
||||
{ basis: "longitudinal", groundMode: "soil_only" },
|
||||
{ basis: "cross", groundMode: "natural" },
|
||||
{ basis: "longitudinal", groundMode: "natural" },
|
||||
];
|
||||
|
||||
/** 기본으로 켜 두는 곡선 — 두 기준의 정식 환산만 보이고 나머지는 사용자가 켠다. */
|
||||
export const MASS_HAUL_DEFAULT_VISIBLE = ["cross_ground_type", "longitudinal_ground_type"];
|
||||
|
||||
export function massHaulSeriesKey(basis: MassHaulBasis, groundMode: MassHaulGroundMode): string {
|
||||
return `${basis}_${groundMode}`;
|
||||
}
|
||||
|
||||
/** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */
|
||||
function compactedFactor(conversion: EarthworkConversion, ground: GroundType): number {
|
||||
const factor = conversion?.[ground]?.compacted;
|
||||
return Number.isFinite(factor) && factor > 0 ? factor : 1;
|
||||
}
|
||||
|
||||
function factorFor(
|
||||
conversion: EarthworkConversion,
|
||||
ground: GroundType,
|
||||
groundMode: MassHaulGroundMode,
|
||||
): number {
|
||||
if (groundMode === "natural") return 1;
|
||||
return compactedFactor(conversion, groundMode === "soil_only" ? "soil" : ground);
|
||||
}
|
||||
|
||||
function emptyVolumes(): GroundVolumes {
|
||||
return { soil: 0, ripping_rock: 0, blasting_rock: 0 };
|
||||
}
|
||||
@@ -74,29 +134,37 @@ function finiteArea(value: number | undefined): number {
|
||||
return Number.isFinite(value) && (value as number) > 0 ? (value as number) : 0;
|
||||
}
|
||||
|
||||
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;
|
||||
fill_area_m2: number;
|
||||
ground: GroundType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 측점별 설계 결과에서 유토곡선을 계산한다.
|
||||
* 단면적 시퀀스를 누가토량으로 적분한다.
|
||||
*
|
||||
* 구간 토량은 평균단면법으로 구한다. 지반유형은 측점마다 다를 수 있으므로,
|
||||
* 각 측점이 자기 쪽 절반(`단면적 × Δd / 2`)을 자기 지반유형으로 가져간다.
|
||||
* 두 절반을 합치면 `(A_i + A_i+1) / 2 × Δd`가 되어 평균단면법과 정확히 일치한다.
|
||||
*
|
||||
* 설계(`design`)가 없는 측점은 단면적을 모르므로 건너뛴다. 유효 측점이 2개 미만이면
|
||||
* 곡선을 만들 수 없어 null을 반환한다.
|
||||
* 구간 토량은 평균단면법으로 구한다. 지반유형은 점마다 다를 수 있으므로, 각 점이 자기 쪽
|
||||
* 절반(`단면적 × Δd / 2`)을 자기 지반유형으로 가져간다. 두 절반을 합치면
|
||||
* `(A_i + A_i+1) / 2 × Δd`가 되어 평균단면법과 정확히 일치한다.
|
||||
*/
|
||||
export function computeMassHaul(
|
||||
crossSections: CrossSection[],
|
||||
function integrate(
|
||||
samples: AreaSample[],
|
||||
conversion: EarthworkConversion,
|
||||
groundMode: MassHaulGroundMode,
|
||||
): MassHaulResult | null {
|
||||
const sections = crossSections
|
||||
.filter((section) => section.design && Number.isFinite(section.chainage_m))
|
||||
.sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
if (sections.length < 2) return null;
|
||||
if (samples.length < 2) return null;
|
||||
|
||||
const points: MassHaulPoint[] = [
|
||||
{
|
||||
station_id: sections[0].station_id,
|
||||
chainage_m: sections[0].chainage_m,
|
||||
station_id: samples[0].station_id,
|
||||
chainage_m: samples[0].chainage_m,
|
||||
net_volume_m3: 0,
|
||||
cumulative_volume_m3: 0,
|
||||
},
|
||||
@@ -108,33 +176,28 @@ export function computeMassHaul(
|
||||
let minCumulative = 0;
|
||||
let maxCumulative = 0;
|
||||
|
||||
for (let index = 1; index < sections.length; index += 1) {
|
||||
const previous = sections[index - 1];
|
||||
const current = sections[index];
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
const previous = samples[index - 1];
|
||||
const current = samples[index];
|
||||
const spanM = current.chainage_m - previous.chainage_m;
|
||||
// 같은 위치에 측점이 겹쳐 들어오면 구간 부피가 0이라 곡선에 기여하지 않는다.
|
||||
// 같은 위치에 점이 겹쳐 들어오면 구간 부피가 0이라 곡선에 기여하지 않는다.
|
||||
if (!(spanM > 0)) continue;
|
||||
const halfSpan = spanM / 2;
|
||||
|
||||
// 절토: 양 끝 측점이 각자 자기 지반유형으로 절반씩 가져간다.
|
||||
let segmentCompacted = 0;
|
||||
// 절토: 양 끝 점이 각자 자기 지반유형으로 절반씩 가져간다.
|
||||
let segmentCut = 0;
|
||||
for (const end of [previous, current]) {
|
||||
const design = end.design;
|
||||
if (!design) continue;
|
||||
const naturalM3 = finiteArea(design.cut_area_m2) * halfSpan;
|
||||
const naturalM3 = end.cut_area_m2 * halfSpan;
|
||||
if (naturalM3 <= 0) continue;
|
||||
const ground = GROUND_TYPES.includes(design.ground_type) ? design.ground_type : "soil";
|
||||
cutNatural[ground] += naturalM3;
|
||||
segmentCompacted += naturalM3 * compactedFactor(conversion, ground);
|
||||
cutNatural[end.ground] += naturalM3;
|
||||
segmentCut += naturalM3 * factorFor(conversion, end.ground, groundMode);
|
||||
}
|
||||
|
||||
// 성토: 설계 성토량이 곧 다짐상태 물량이라 환산하지 않는다.
|
||||
const segmentFill =
|
||||
(finiteArea(previous.design?.fill_area_m2) + finiteArea(current.design?.fill_area_m2)) *
|
||||
halfSpan;
|
||||
const segmentFill = (previous.fill_area_m2 + current.fill_area_m2) * halfSpan;
|
||||
|
||||
const net = segmentCompacted - segmentFill;
|
||||
cutCompacted += segmentCompacted;
|
||||
const net = segmentCut - segmentFill;
|
||||
cutCompacted += segmentCut;
|
||||
fillCompacted += segmentFill;
|
||||
cumulative += net;
|
||||
minCumulative = Math.min(minCumulative, cumulative);
|
||||
@@ -163,6 +226,100 @@ export function computeMassHaul(
|
||||
};
|
||||
}
|
||||
|
||||
/** 설계가 지정된 측점만 누가거리 순으로 모은다. 두 기준이 공통으로 쓰는 기준점 목록. */
|
||||
function designedSections(crossSections: CrossSection[]): CrossSection[] {
|
||||
return crossSections
|
||||
.filter((section) => section.design && Number.isFinite(section.chainage_m))
|
||||
.sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
}
|
||||
|
||||
/** 횡단 기준 — 엔진이 낸 실측 단면적을 그대로 쓴다. */
|
||||
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),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 종단 기준 — 계획고와 지반고의 차이(절토고/성토고)에 노반폭을 곱한 개략 단면적.
|
||||
*
|
||||
* `difference_m = 계획고 − 지반고`이므로 음수면 절토, 양수면 성토다
|
||||
* (`B05_wf2_Route_UI_Profile_Alignment.ts`가 이미 쓰는 부호 규약과 같다).
|
||||
* 노반폭과 지반유형은 종단 샘플에 없으므로 **가장 가까운 설계 측점**에서 가져온다.
|
||||
* 설계된 측점이 하나도 없으면 노반폭을 알 수 없어 곡선을 만들지 않는다.
|
||||
*/
|
||||
function longitudinalAreaSamples(
|
||||
longitudinal: LongitudinalSection,
|
||||
sections: CrossSection[],
|
||||
): AreaSample[] {
|
||||
const profile = longitudinal.design_profiles?.[0];
|
||||
if (!profile || !sections.length) return [];
|
||||
const samples = profile.samples
|
||||
.filter((sample) => Number.isFinite(sample.chainage_m) && Number.isFinite(sample.difference_m))
|
||||
.sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
if (samples.length < 2) return [];
|
||||
|
||||
// 두 배열 모두 누가거리 순이라 포인터 하나로 최근접 측점을 훑는다.
|
||||
let cursor = 0;
|
||||
return samples.map((sample, index) => {
|
||||
while (
|
||||
cursor + 1 < sections.length &&
|
||||
Math.abs(sections[cursor + 1].chainage_m - sample.chainage_m) <=
|
||||
Math.abs(sections[cursor].chainage_m - sample.chainage_m)
|
||||
) {
|
||||
cursor += 1;
|
||||
}
|
||||
const nearest = sections[cursor];
|
||||
const width = finiteArea(nearest.design?.roadbed_width_m);
|
||||
const height = sample.difference_m;
|
||||
return {
|
||||
station_id: `${nearest.station_id}@${index}`,
|
||||
chainage_m: sample.chainage_m,
|
||||
cut_area_m2: height < 0 ? -height * width : 0,
|
||||
fill_area_m2: height > 0 ? height * width : 0,
|
||||
ground: normalizeGround(nearest.design?.ground_type),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 측점별 설계 결과에서 유토곡선을 계산한다(횡단 기준·지반유형별 환산).
|
||||
* 확정 저장과 B08 인계에 쓰는 **정식 곡선**이라 시그니처를 그대로 유지한다.
|
||||
*/
|
||||
export function computeMassHaul(
|
||||
crossSections: CrossSection[],
|
||||
conversion: EarthworkConversion,
|
||||
): MassHaulResult | null {
|
||||
return integrate(crossAreaSamples(designedSections(crossSections)), conversion, "ground_type");
|
||||
}
|
||||
|
||||
/**
|
||||
* 비교용 곡선 묶음. 기준 2종 × 지반유형 처리 3종을 모두 계산한다.
|
||||
* 계산이 불가능한 조합(설계 측점 부족, 종단 계획선 없음)은 조용히 빠진다.
|
||||
*/
|
||||
export function computeMassHaulSeries(
|
||||
longitudinal: LongitudinalSection,
|
||||
crossSections: CrossSection[],
|
||||
conversion: EarthworkConversion,
|
||||
): MassHaulSeries[] {
|
||||
const sections = designedSections(crossSections);
|
||||
const sampleSets: Record<MassHaulBasis, AreaSample[]> = {
|
||||
cross: crossAreaSamples(sections),
|
||||
longitudinal: longitudinalAreaSamples(longitudinal, sections),
|
||||
};
|
||||
const series: MassHaulSeries[] = [];
|
||||
for (const { basis, groundMode } of SERIES_MATRIX) {
|
||||
const result = integrate(sampleSets[basis], conversion, groundMode);
|
||||
if (result)
|
||||
series.push({ key: massHaulSeriesKey(basis, groundMode), basis, groundMode, result });
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
/** 확정 시 DB(`longitudinal_sections.data.mass_haul`)에 넣을 직렬화 형태로 정리한다. */
|
||||
export function massHaulPayload(result: MassHaulResult): Record<string, unknown> {
|
||||
const round = (value: number): number => Math.round(value * 100) / 100;
|
||||
|
||||
@@ -6,13 +6,22 @@
|
||||
* (LONG_PAD)·누가거리 최댓값(longitudinalMaxChainage)을 종단도와 동일하게 받아,
|
||||
* 같은 측점이 두 그래프에서 같은 가로 위치에 오도록 맞춘다.
|
||||
*
|
||||
* 기준(종단/횡단) 2종 × 지반유형 처리 3종의 곡선을 한 축에 겹쳐 그리고, 그래프 상단
|
||||
* 오버레이 범례 버튼으로 켜고 끈다. **Y 스케일은 표시 여부와 무관하게 항상 전체 곡선
|
||||
* 기준으로 잡는다** — 곡선을 껐다 켤 때마다 축이 움직이면 비교 자체가 불가능해진다.
|
||||
*
|
||||
* Y축은 데이터 범위 기반 자동 스케일이다. 참고 도면의 수직축척이 공구마다 다르므로
|
||||
* (1공구 V=1:10,000 / 3공구 V=1:50,000) 고정 축척을 쓰면 안 된다.
|
||||
* 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md 3.2절
|
||||
* ========================================================================== */
|
||||
|
||||
import type { LongitudinalSection } from "./B06_wf3_ProfileCross_Api_Fetch";
|
||||
import type { MassHaulResult } from "./B06_wf3_ProfileCross_UI_MassHaul";
|
||||
import type {
|
||||
MassHaulBasis,
|
||||
MassHaulGroundMode,
|
||||
MassHaulSeries,
|
||||
} from "./B06_wf3_ProfileCross_UI_MassHaul";
|
||||
import type { LocaleKey } from "@ui/ui_template_locale";
|
||||
import {
|
||||
L,
|
||||
LONG_PAD,
|
||||
@@ -27,6 +36,26 @@ export const MASS_HAUL_MIN_HEIGHT = 90;
|
||||
/** 패널을 건드리지 않았을 때의 유토곡선 높이. */
|
||||
export const MASS_HAUL_HEIGHT = 190;
|
||||
|
||||
const BASIS_LABEL: Record<MassHaulBasis, LocaleKey> = {
|
||||
cross: "B06_MassHaul_Basis_Cross",
|
||||
longitudinal: "B06_MassHaul_Basis_Long",
|
||||
};
|
||||
|
||||
const GROUND_MODE_LABEL: Record<MassHaulGroundMode, LocaleKey> = {
|
||||
ground_type: "B06_MassHaul_Ground_ByType",
|
||||
soil_only: "B06_MassHaul_Ground_SoilOnly",
|
||||
natural: "B06_MassHaul_Ground_Natural",
|
||||
};
|
||||
|
||||
/** 범례·곡선에 함께 쓰는 이름. 색은 기준, 선 모양은 환산 방식을 뜻한다. */
|
||||
export function massHaulSeriesLabel(series: MassHaulSeries): string {
|
||||
return `${L(BASIS_LABEL[series.basis])} · ${L(GROUND_MODE_LABEL[series.groundMode])}`;
|
||||
}
|
||||
|
||||
function seriesClass(series: MassHaulSeries, base: string): string {
|
||||
return `${base} ${base}--${series.basis} ${base}--${series.groundMode}`;
|
||||
}
|
||||
|
||||
/** 누가토량 값 표기 — 천 단위 구분 + 소수점 1자리. */
|
||||
export function formatVolume(value: number): string {
|
||||
return value.toLocaleString(undefined, {
|
||||
@@ -38,16 +67,23 @@ export function formatVolume(value: number): string {
|
||||
/**
|
||||
* Y축 상·하한을 잡는다. 0선은 항상 보이도록 범위에 포함하고, 위아래로 5% 여유를 준다.
|
||||
* 값이 전부 0이면 폭이 0이 되어 나눗셈이 깨지므로 최소 폭을 씌운다.
|
||||
*
|
||||
* 범위는 **표시 중인 곡선이 아니라 전체 곡선**에서 잡는다(축 고정).
|
||||
*/
|
||||
function volumeRange(result: MassHaulResult): { min: number; max: number } {
|
||||
const rawMin = Math.min(0, result.min_cumulative_m3);
|
||||
const rawMax = Math.max(0, result.max_cumulative_m3);
|
||||
function volumeRange(series: MassHaulSeries[]): { min: number; max: number } {
|
||||
let rawMin = 0;
|
||||
let rawMax = 0;
|
||||
for (const entry of series) {
|
||||
rawMin = Math.min(rawMin, entry.result.min_cumulative_m3);
|
||||
rawMax = Math.max(rawMax, entry.result.max_cumulative_m3);
|
||||
}
|
||||
const padding = Math.max((rawMax - rawMin) * 0.05, 1);
|
||||
return { min: rawMin - padding, max: rawMax + padding };
|
||||
}
|
||||
|
||||
export function createMassHaulChart(
|
||||
result: MassHaulResult,
|
||||
series: MassHaulSeries[],
|
||||
visibleKeys: ReadonlySet<string>,
|
||||
longitudinal: LongitudinalSection,
|
||||
selectedStationId: string | null,
|
||||
stationInterval: number,
|
||||
@@ -74,7 +110,7 @@ export function createMassHaulChart(
|
||||
const plotHeight = heightPx - LONG_PAD.top - LONG_PAD.bottom;
|
||||
const x = (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth;
|
||||
|
||||
const { min, max } = volumeRange(result);
|
||||
const { min, max } = volumeRange(series);
|
||||
const span = Math.max(max - min, 1e-6);
|
||||
const y = (volume: number) => LONG_PAD.top + ((max - volume) / span) * plotHeight;
|
||||
|
||||
@@ -100,21 +136,25 @@ export function createMassHaulChart(
|
||||
);
|
||||
}
|
||||
|
||||
// 곡선과 0선 사이를 채워 절토 우세(위)·성토 우세(아래) 구간을 눈으로 가른다.
|
||||
const zeroY = y(0);
|
||||
const points = result.points;
|
||||
const areaTop: string[] = [];
|
||||
const areaBottom: string[] = [];
|
||||
for (const point of points) {
|
||||
areaTop.push(`${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`);
|
||||
areaBottom.unshift(`${x(point.chainage_m)},${zeroY}`);
|
||||
const visible = series.filter((entry) => visibleKeys.has(entry.key));
|
||||
|
||||
// 면(band)은 첫 번째 표시 곡선에만 깐다 — 여러 곡선에 겹쳐 칠하면 서로 가려 못 읽는다.
|
||||
const banded = visible[0];
|
||||
if (banded) {
|
||||
const areaTop: string[] = [];
|
||||
const areaBottom: string[] = [];
|
||||
for (const point of banded.result.points) {
|
||||
areaTop.push(`${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`);
|
||||
areaBottom.unshift(`${x(point.chainage_m)},${zeroY}`);
|
||||
}
|
||||
svg.append(
|
||||
svgElement("polygon", {
|
||||
points: [...areaTop, ...areaBottom].join(" "),
|
||||
class: seriesClass(banded, "b06-masshaul__band"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
svg.append(
|
||||
svgElement("polygon", {
|
||||
points: [...areaTop, ...areaBottom].join(" "),
|
||||
class: "b06-masshaul__band",
|
||||
}),
|
||||
);
|
||||
|
||||
// 측점선 — 종단도와 같은 자리에 서고, 눌러서 측점을 고를 수 있다(3자 선택 동기화).
|
||||
// 라벨은 바로 위 종단면도가 이미 달고 있어 여기서는 생략한다(중복 표기 방지).
|
||||
@@ -150,8 +190,8 @@ export function createMassHaulChart(
|
||||
svg.append(marker);
|
||||
}
|
||||
|
||||
// 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그린다.
|
||||
svg.append(
|
||||
// 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그린다.
|
||||
svgElement("line", {
|
||||
x1: LONG_PAD.left,
|
||||
y1: zeroY,
|
||||
@@ -159,12 +199,21 @@ export function createMassHaulChart(
|
||||
y2: zeroY,
|
||||
class: "b06-masshaul__zero",
|
||||
}),
|
||||
svgElement("polyline", {
|
||||
points: points
|
||||
.map((point) => `${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`)
|
||||
.join(" "),
|
||||
class: "b06-masshaul__curve",
|
||||
}),
|
||||
);
|
||||
|
||||
// 뒤에 그린 곡선이 위로 오므로 범례 순서의 역순으로 그려 첫 곡선(정식)을 가장 위에 둔다.
|
||||
for (const entry of [...visible].reverse()) {
|
||||
svg.append(
|
||||
svgElement("polyline", {
|
||||
points: entry.result.points
|
||||
.map((point) => `${x(point.chainage_m)},${y(point.cumulative_volume_m3)}`)
|
||||
.join(" "),
|
||||
class: seriesClass(entry, "b06-masshaul__curve"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: LONG_PAD.left,
|
||||
y1: LONG_PAD.top,
|
||||
@@ -189,8 +238,48 @@ export function createMassHaulChart(
|
||||
return svg;
|
||||
}
|
||||
|
||||
/** 곡선 아래 총괄 요약 줄. 절토는 자연상태(내역 기준)와 다짐환산(곡선 기준)을 함께 적는다. */
|
||||
export function createMassHaulSummary(result: MassHaulResult): HTMLElement {
|
||||
/**
|
||||
* 그래프 상단에 겹쳐 놓는 표시/숨김 버튼 줄.
|
||||
*
|
||||
* 가로 스크롤 컨테이너 안이라 CSS에서 `position: sticky; left: 0`으로 왼쪽에 고정하고,
|
||||
* `height: 0`으로 흐름 높이를 먹지 않게 해 곡선 위로 떠 있게 만든다(오버레이).
|
||||
*/
|
||||
export function createMassHaulLegend(
|
||||
series: MassHaulSeries[],
|
||||
visibleKeys: ReadonlySet<string>,
|
||||
onToggle: (key: string) => void,
|
||||
): HTMLElement {
|
||||
const legend = document.createElement("div");
|
||||
legend.className = "b06-masshaul__legend";
|
||||
legend.setAttribute("role", "group");
|
||||
legend.setAttribute("aria-label", L("B06_MassHaul_Legend_Title"));
|
||||
for (const entry of series) {
|
||||
const shown = visibleKeys.has(entry.key);
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = seriesClass(entry, "b06-masshaul__legend-item");
|
||||
button.classList.toggle("is-off", !shown);
|
||||
button.setAttribute("aria-pressed", String(shown));
|
||||
const label = massHaulSeriesLabel(entry);
|
||||
button.title = `${label} — ${L(shown ? "B06_MassHaul_Legend_Hide" : "B06_MassHaul_Legend_Show")}`;
|
||||
const swatch = document.createElement("span");
|
||||
swatch.className = "b06-masshaul__legend-swatch";
|
||||
swatch.setAttribute("aria-hidden", "true");
|
||||
const text = document.createElement("span");
|
||||
text.textContent = label;
|
||||
button.append(swatch, text);
|
||||
button.addEventListener("click", () => onToggle(entry.key));
|
||||
legend.append(button);
|
||||
}
|
||||
return legend;
|
||||
}
|
||||
|
||||
/**
|
||||
* 곡선 아래 총괄 요약 줄. 절토는 자연상태(내역 기준)와 환산 후(곡선 기준)를 함께 적는다.
|
||||
* 곡선이 여러 개라 수치가 어느 곡선의 것인지 끝에 반드시 밝힌다.
|
||||
*/
|
||||
export function createMassHaulSummary(series: MassHaulSeries): HTMLElement {
|
||||
const result = series.result;
|
||||
const row = document.createElement("div");
|
||||
row.className = "b06-masshaul__summary";
|
||||
const cut = result.cut_natural_m3;
|
||||
@@ -222,8 +311,8 @@ export function createMassHaulSummary(result: MassHaulResult): HTMLElement {
|
||||
row.append(chip);
|
||||
}
|
||||
const basis = document.createElement("span");
|
||||
basis.className = "b06-masshaul__basis";
|
||||
basis.textContent = L("B06_MassHaul_Basis");
|
||||
basis.className = seriesClass(series, "b06-masshaul__basis");
|
||||
basis.textContent = massHaulSeriesLabel(series);
|
||||
row.append(basis);
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from "./B06_wf3_ProfileCross_UI_Standard_Panel";
|
||||
import "./B06_wf3_ProfileCross_UI_Style.css";
|
||||
import "./B06_wf3_ProfileCross_UI_Style_Cross.css";
|
||||
import "./B06_wf3_ProfileCross_UI_Style_MassHaul.css";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
|
||||
@@ -29,9 +29,14 @@ import {
|
||||
createLongitudinalProfile,
|
||||
longitudinalMinimumWidth,
|
||||
} from "./B06_wf3_ProfileCross_UI_Longitudinal";
|
||||
import { computeMassHaul, type MassHaulResult } from "./B06_wf3_ProfileCross_UI_MassHaul";
|
||||
import {
|
||||
computeMassHaulSeries,
|
||||
MASS_HAUL_DEFAULT_VISIBLE,
|
||||
type MassHaulSeries,
|
||||
} from "./B06_wf3_ProfileCross_UI_MassHaul";
|
||||
import {
|
||||
createMassHaulChart,
|
||||
createMassHaulLegend,
|
||||
createMassHaulSummary,
|
||||
MASS_HAUL_HEIGHT,
|
||||
MASS_HAUL_MIN_HEIGHT,
|
||||
@@ -62,6 +67,20 @@ const MIN_PANEL_HEIGHT = MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT + PANEL_CHROME_P
|
||||
const MAX_PANEL_HEIGHT_RATIO = 0.8;
|
||||
const PANEL_HEIGHT_KEY = "b06:profile-panel-height";
|
||||
const PANEL_COLLAPSED_KEY = "b06:profile-panel-collapsed";
|
||||
const MASS_HAUL_VISIBLE_KEY = "b06:masshaul-visible";
|
||||
|
||||
/** 켜 둔 곡선 목록은 세션에만 남긴다(패널 높이·접힘과 같은 규칙). */
|
||||
function readVisibleSeries(): Set<string> {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(MASS_HAUL_VISIBLE_KEY);
|
||||
if (!raw) return new Set(MASS_HAUL_DEFAULT_VISIBLE);
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
// 전부 끈 상태(빈 배열)도 사용자의 선택이므로 기본값으로 되돌리지 않는다.
|
||||
return Array.isArray(parsed) ? new Set(parsed.map(String)) : new Set(MASS_HAUL_DEFAULT_VISIBLE);
|
||||
} catch {
|
||||
return new Set(MASS_HAUL_DEFAULT_VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 패널 높이를 두 그래프에 나눈다 (2026-08-02 사용자 지시).
|
||||
@@ -126,6 +145,8 @@ export function createSectionView(
|
||||
let renderWidth = 0;
|
||||
let resizeTimer = 0;
|
||||
let panelResizeTimer = 0;
|
||||
// 유토곡선 표시 곡선 집합. 토글해도 Y 스케일은 전체 곡선 기준이라 축이 움직이지 않는다.
|
||||
const visibleSeries = readVisibleSeries();
|
||||
// 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신)
|
||||
let cachedYScale: YScaleOptions | undefined;
|
||||
let cachedStationInterval = 1;
|
||||
@@ -218,6 +239,14 @@ export function createSectionView(
|
||||
rockBoundary,
|
||||
);
|
||||
|
||||
/** 범례 버튼 — 곡선 하나를 켜고 끈다. 축은 전체 곡선 기준이라 여기서 움직이지 않는다. */
|
||||
const toggleSeries = (key: string): void => {
|
||||
if (visibleSeries.has(key)) visibleSeries.delete(key);
|
||||
else visibleSeries.add(key);
|
||||
sessionStorage.setItem(MASS_HAUL_VISIBLE_KEY, JSON.stringify([...visibleSeries]));
|
||||
drawPanel();
|
||||
};
|
||||
|
||||
/** 상단 패널 내용(종단면도 + 유토곡선 + 요약)만 다시 그린다. */
|
||||
function drawPanel(): void {
|
||||
if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return;
|
||||
@@ -248,13 +277,19 @@ export function createSectionView(
|
||||
),
|
||||
];
|
||||
|
||||
const massHaul: MassHaulResult | null = currentConversion
|
||||
? computeMassHaul(detail.cross_sections, currentConversion)
|
||||
: null;
|
||||
if (massHaul) {
|
||||
nodes.push(
|
||||
const series: MassHaulSeries[] = currentConversion
|
||||
? computeMassHaulSeries(detail.longitudinal, detail.cross_sections, currentConversion)
|
||||
: [];
|
||||
if (series.length) {
|
||||
// 범례는 곡선 위에 겹쳐야 해서 SVG와 한 상자에 넣는다(상자 CSS가 오버레이를 만든다).
|
||||
const frame = document.createElement("div");
|
||||
frame.className = "b06-masshaul__frame";
|
||||
frame.style.minWidth = `${minWidth}px`;
|
||||
frame.append(
|
||||
createMassHaulLegend(series, visibleSeries, toggleSeries),
|
||||
createMassHaulChart(
|
||||
massHaul,
|
||||
series,
|
||||
visibleSeries,
|
||||
detail.longitudinal,
|
||||
selectedStationId,
|
||||
cachedStationInterval,
|
||||
@@ -264,17 +299,20 @@ export function createSectionView(
|
||||
(stationId) => selectStation(stationId, true),
|
||||
),
|
||||
);
|
||||
nodes.push(frame);
|
||||
}
|
||||
chartWrap.replaceChildren(...nodes);
|
||||
|
||||
panelTitle.textContent = `${L("B06_Profile_View_Longitudinal")} · ${L("B06_MassHaul_Title")}`;
|
||||
const summary = panel.querySelector(".b06-masshaul__summary");
|
||||
summary?.remove();
|
||||
if (massHaul) {
|
||||
// 요약 수치는 켜 둔 곡선 중 첫 번째 것 — 곡선이 여러 개라 어느 것인지 요약 끝에 밝힌다.
|
||||
const summarySeries = series.find((entry) => visibleSeries.has(entry.key));
|
||||
if (summarySeries) {
|
||||
panelCount.textContent = "";
|
||||
panelBody.append(createMassHaulSummary(massHaul));
|
||||
panelBody.append(createMassHaulSummary(summarySeries));
|
||||
} else {
|
||||
panelCount.textContent = L("B06_MassHaul_Empty");
|
||||
panelCount.textContent = L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty");
|
||||
}
|
||||
chartWrap.scrollLeft = keepScrollLeft;
|
||||
}
|
||||
|
||||
@@ -97,61 +97,6 @@
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* ── 유토곡선 (Mass Haul Diagram) ─────────────────────────────────────
|
||||
색상은 theme.css 변수만 쓴다. 누가토량 곡선은 참고 도면의 자주색에 대응하는
|
||||
브랜드 보라(계획고 마커와 같은 계열)로 둔다. */
|
||||
.b06-masshaul__curve {
|
||||
fill: none;
|
||||
stroke: var(--color-royal-amethyst);
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* 곡선과 0선 사이 — 절토 우세/성토 우세 구간을 한눈에 가르는 옅은 면. */
|
||||
.b06-masshaul__band {
|
||||
fill: var(--color-royal-amethyst);
|
||||
opacity: 0.12;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
/* 0선: 절토 우세와 성토 우세의 경계라 격자보다 진하게. */
|
||||
.b06-masshaul__zero {
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1.2;
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
.b06-masshaul__summary {
|
||||
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);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b06-masshaul__chip {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b06-masshaul__chip em {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.b06-masshaul__chip strong {
|
||||
color: var(--color-text-body);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.b06-masshaul__basis {
|
||||
margin-inline-start: auto;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.b06-section__chart {
|
||||
display: block;
|
||||
max-width: none;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/* =============================================================================
|
||||
* B06_wf3_ProfileCross_UI_Style_MassHaul.css
|
||||
* 유토곡선(Mass Haul Diagram) 스타일 — 곡선·오버레이 범례·요약줄.
|
||||
* 700줄 제한 대응으로 _UI_Style_Cross.css에서 분리(종·횡단 도면 본체는 원본 유지).
|
||||
*
|
||||
* 곡선이 여러 개라 읽는 규칙을 둘로 나눈다:
|
||||
* 색 = 산출 기준 (횡단 = 브랜드 보라 / 종단 = 성공색 초록)
|
||||
* 선모양 = 환산 방식 (실선 = 지반유형별 / 파선 = 토사 단일 / 점선 = 환산 없음)
|
||||
* 색상은 theme.css 변수만 쓴다. 횡단 기준의 보라는 참고 도면의 자주색에 대응한다.
|
||||
* ========================================================================== */
|
||||
|
||||
.b06-masshaul__frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 범례는 그래프 위에 겹친다. `height: 0`이라 세로 흐름을 밀지 않아 곡선 위로 떠 있고,
|
||||
가로 스크롤 컨테이너 안에서도 `sticky; left: 0`이라 옆으로 밀어도 왼쪽에 남는다. */
|
||||
.b06-masshaul__legend {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-4) var(--spacing-8);
|
||||
height: 0;
|
||||
padding-inline-start: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b06-masshaul__legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pills);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b06-masshaul__legend-item.is-off {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 스와치는 곡선을 그대로 축소한 것 — 색과 선모양이 그래프와 같아야 범례 구실을 한다. */
|
||||
.b06-masshaul__legend-swatch {
|
||||
width: 18px;
|
||||
height: 0;
|
||||
border-top: 2px solid var(--color-royal-amethyst);
|
||||
}
|
||||
|
||||
.b06-masshaul__legend-item--longitudinal .b06-masshaul__legend-swatch {
|
||||
border-top-color: var(--color-success);
|
||||
}
|
||||
|
||||
.b06-masshaul__legend-item--soil_only .b06-masshaul__legend-swatch {
|
||||
border-top-style: dashed;
|
||||
}
|
||||
|
||||
.b06-masshaul__legend-item--natural .b06-masshaul__legend-swatch {
|
||||
border-top-style: dotted;
|
||||
}
|
||||
|
||||
.b06-masshaul__legend-item.is-off .b06-masshaul__legend-swatch {
|
||||
border-top-color: var(--color-smoke);
|
||||
}
|
||||
|
||||
.b06-masshaul__curve {
|
||||
fill: none;
|
||||
stroke: var(--color-royal-amethyst);
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.b06-masshaul__curve--longitudinal {
|
||||
stroke: var(--color-success);
|
||||
}
|
||||
|
||||
.b06-masshaul__curve--soil_only {
|
||||
stroke-width: 1.6;
|
||||
stroke-dasharray: 7 4;
|
||||
}
|
||||
|
||||
.b06-masshaul__curve--natural {
|
||||
stroke-width: 1.6;
|
||||
stroke-dasharray: 2 3;
|
||||
}
|
||||
|
||||
/* 곡선과 0선 사이 — 절토 우세/성토 우세 구간을 한눈에 가르는 옅은 면.
|
||||
여러 곡선에 겹쳐 칠하면 서로 가리므로 렌더러가 맨 앞 곡선 하나에만 붙인다. */
|
||||
.b06-masshaul__band {
|
||||
fill: var(--color-royal-amethyst);
|
||||
opacity: 0.12;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.b06-masshaul__band--longitudinal {
|
||||
fill: var(--color-success);
|
||||
}
|
||||
|
||||
/* 0선: 절토 우세와 성토 우세의 경계라 격자보다 진하게. */
|
||||
.b06-masshaul__zero {
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1.2;
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
.b06-masshaul__summary {
|
||||
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);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b06-masshaul__chip {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b06-masshaul__chip em {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.b06-masshaul__chip strong {
|
||||
color: var(--color-text-body);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 요약 수치가 어느 곡선의 것인지 밝히는 꼬리표. */
|
||||
.b06-masshaul__basis {
|
||||
margin-inline-start: auto;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
@@ -225,6 +225,21 @@ export const ui_locales_b2 = {
|
||||
B06_MassHaul_Shortage: ["토취", "Borrow"],
|
||||
/* 운반계획 도면은 다짐상태 기준으로 계산한다(국도건설공사 설계실무 요령). */
|
||||
B06_MassHaul_Basis: ["다짐상태 기준", "Compacted basis"],
|
||||
B06_MassHaul_AllHidden: [
|
||||
"표시할 곡선을 하나 이상 켜 주세요.",
|
||||
"Turn on at least one curve to display.",
|
||||
],
|
||||
/* 토량 산출 기준 — 임도의 설계 및 시설기준 2.다.(3)(나)는 종단 계획고를,
|
||||
2.다.(4)(마)는 측점별 횡단 단면적을 각각 근거로 삼는다. */
|
||||
B06_MassHaul_Basis_Cross: ["횡단 기준", "Cross-section basis"],
|
||||
B06_MassHaul_Basis_Long: ["종단 기준", "Profile basis"],
|
||||
/* 절토량에 물리는 토량환산계수 처리 방식 (암반 경계선 분리 전 비교용). */
|
||||
B06_MassHaul_Ground_ByType: ["지반유형별 환산", "Per ground type"],
|
||||
B06_MassHaul_Ground_SoilOnly: ["토사 단일 환산", "Soil factor only"],
|
||||
B06_MassHaul_Ground_Natural: ["환산 없음", "No conversion"],
|
||||
B06_MassHaul_Legend_Title: ["표시할 유토곡선", "Visible mass haul curves"],
|
||||
B06_MassHaul_Legend_Show: ["표시", "Show"],
|
||||
B06_MassHaul_Legend_Hide: ["숨김", "Hide"],
|
||||
|
||||
/* --- B06 측점 표준횡단 설계 지정 --- */
|
||||
B06_Design_Ground_Legend: ["지반유형", "Ground type"],
|
||||
|
||||
Reference in New Issue
Block a user