fix(B06): 구간 물량 말풍선화, 패널 제목·캐럿 정리, 곡선 2종 축소
- 횡단 카드 측점 종류 표기에서 "일반 측점" 제거(BP/EP만 남김). - 구간 물량을 패널 아래 칩 줄에서 유토곡선 위 둥근 사각 말풍선으로 옮긴다. 말풍선이 놓일 가로 구간의 곡선 세로 범위를 실측해 위/아래 빈 곳 중 들어가는 쪽에 놓고, 좌우 후보를 모두 재서 여유가 큰 조합을 고른다. HTML 오버레이 대신 SVG 안에 그려 X축 정렬을 지킨다. - 패널 제목 문구 삭제. 헤더는 캐럿만 남긴 손잡이 행이며, 캐럿은 좌측 사이드 패널과 같은 공용 규칙을 쓰되 방향만 상하(펼침 ▴ / 접힘 ▾)로 바꿨다. - 측점 라벨을 종단 그래프 바닥에 붙이고 유토곡선 상하 여백을 좁혀 두 그래프가 측점 표기를 공유하는 것처럼 보이게 한다. 좌우 여백(LONG_PAD)은 X축 정렬 때문에 그대로 둔다. - 패널을 줄이면 그래프 폭까지 줄던 문제: SVG가 flex 기본값으로 세로로 눌리면서 preserveAspectRatio(meet)가 가로도 같은 비율로 축소한 것. flex: none으로 막고 패널에 최소 높이를 건다. - 토사 단일 환산·환산 없음 곡선 삭제. 절토가 암반 경계선으로 갈려 나온 뒤로는 부피비를 단일값이나 1로 넣은 곡선에 정보가 없다. 곡선 키 형식이 바뀌어 세션 저장 키를 v2로 올렸다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -245,13 +245,6 @@ export function createCrossSectionCard(
|
||||
modePill.textContent = sectionModeLabel(section.design?.section_mode);
|
||||
modePill.title = L("B06_Design_Mode_Legend");
|
||||
|
||||
const kind = document.createElement("span");
|
||||
kind.textContent =
|
||||
section.kind === "ep"
|
||||
? L("B06_Profile_View_Kind_EP")
|
||||
: section.kind === "bp"
|
||||
? L("B06_Profile_View_Kind_BP")
|
||||
: L("B06_Profile_View_Kind_Station");
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "b06-cross-card__meta";
|
||||
if (section.structure) {
|
||||
@@ -261,7 +254,14 @@ export function createCrossSectionCard(
|
||||
structure.title = `구조물: ${section.structure}`;
|
||||
meta.append(structure);
|
||||
}
|
||||
meta.append(kind);
|
||||
// 측점 종류는 시·종점(BP/EP)만 적는다 — "일반측점"은 대다수라 정보가 없다(2026-08-02 사용자 지시).
|
||||
if (section.kind === "bp" || section.kind === "ep") {
|
||||
const kind = document.createElement("span");
|
||||
kind.textContent = L(
|
||||
section.kind === "ep" ? "B06_Profile_View_Kind_EP" : "B06_Profile_View_Kind_BP",
|
||||
);
|
||||
meta.append(kind);
|
||||
}
|
||||
|
||||
// 단면유형 pill은 지반유형 우측·우측 맞춤으로 배치(1번). 좌측 구분선은 지반유형 세그먼트에 준다(3번).
|
||||
modePill.classList.add("b06-cross-card__mode--right");
|
||||
|
||||
@@ -284,9 +284,11 @@ export function createLongitudinalProfile(
|
||||
y2: heightPx - LONG_PAD.bottom + 8,
|
||||
class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`,
|
||||
}),
|
||||
// 측점 라벨은 그래프 바닥에 붙인다 — 바로 아래 유토곡선과의 틈을 줄여 두 그래프가
|
||||
// 측점 표기를 공유하는 것처럼 보이게 한다(2026-08-02 사용자 지시).
|
||||
svgText(offsetStationLabel(station.chainage_m, stationInterval, stationNumberOffset), {
|
||||
x: stationX,
|
||||
y: heightPx - 23,
|
||||
y: heightPx - 7,
|
||||
"text-anchor": "middle",
|
||||
class: "b06-chart__station-label",
|
||||
}),
|
||||
|
||||
@@ -26,8 +26,9 @@
|
||||
* 나눈 `cut_soil_area_m2` / `cut_rock_area_m2`를 각각 자기 환산계수로 곱해 합산한다.
|
||||
* 참고 도면(3공구)의 작업별 EA:RR 비율이 34:66~71:29로 흩어지는 것이 이 구조의 근거다.
|
||||
*
|
||||
* 지반유형 처리 3종(`ground_type`/`soil_only`/`natural`)은 환산 방식이 결과를 얼마나
|
||||
* 흔드는지 눈으로 대조하기 위한 비교군으로 남겨 둔다.
|
||||
* 예전에 있던 비교군 2종(토사 단일 환산 / 환산 없음)은 걷어냈다(2026-08-02 사용자 지시).
|
||||
* 절토면적이 암반 경계선으로 갈리기 전에는 환산계수 차이를 눈으로 대조할 필요가 있었지만,
|
||||
* 이제 지형이 면적으로 갈려 나오므로 **부피비를 단일값이나 1로 밀어 넣은 곡선은 정보가 없다.**
|
||||
*
|
||||
* 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md
|
||||
* ========================================================================== */
|
||||
@@ -87,56 +88,27 @@ export interface MassHaulResult {
|
||||
/** 토량을 어디서 뽑았는지. `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;
|
||||
/** 표시 토글 저장 키이자 CSS 변형 클래스 접미사(= `basis`). */
|
||||
key: MassHaulBasis;
|
||||
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" },
|
||||
];
|
||||
/** 곡선을 그릴 산출 기준. 배열 순서가 곧 범례 순서다. */
|
||||
const SERIES_BASES: MassHaulBasis[] = ["cross", "longitudinal"];
|
||||
|
||||
/** 기본으로 켜 두는 곡선 — 두 기준의 정식 환산만 보이고 나머지는 사용자가 켠다. */
|
||||
export const MASS_HAUL_DEFAULT_VISIBLE = ["cross_ground_type", "longitudinal_ground_type"];
|
||||
|
||||
export function massHaulSeriesKey(basis: MassHaulBasis, groundMode: MassHaulGroundMode): string {
|
||||
return `${basis}_${groundMode}`;
|
||||
}
|
||||
/** 기본으로 켜 두는 곡선. */
|
||||
export const MASS_HAUL_DEFAULT_VISIBLE: MassHaulBasis[] = ["cross", "longitudinal"];
|
||||
|
||||
/** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */
|
||||
function compactedFactor(conversion: EarthworkConversion, ground: GroundType): number {
|
||||
function factorFor(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 };
|
||||
}
|
||||
@@ -193,11 +165,7 @@ function splitCut(design: CrossDesign | undefined): {
|
||||
* 절반(`단면적 × Δd / 2`)을 자기 지반유형으로 가져간다. 두 절반을 합치면
|
||||
* `(A_i + A_i+1) / 2 × Δd`가 되어 평균단면법과 정확히 일치한다.
|
||||
*/
|
||||
function integrate(
|
||||
samples: AreaSample[],
|
||||
conversion: EarthworkConversion,
|
||||
groundMode: MassHaulGroundMode,
|
||||
): MassHaulResult | null {
|
||||
function integrate(samples: AreaSample[], conversion: EarthworkConversion): MassHaulResult | null {
|
||||
if (samples.length < 2) return null;
|
||||
|
||||
const points: MassHaulPoint[] = [
|
||||
@@ -237,12 +205,12 @@ function integrate(
|
||||
if (soilM3 > 0) {
|
||||
cutNatural.soil += soilM3;
|
||||
segmentCutSoil += soilM3;
|
||||
segmentCut += soilM3 * factorFor(conversion, "soil", groundMode);
|
||||
segmentCut += soilM3 * factorFor(conversion, "soil");
|
||||
}
|
||||
if (rockM3 > 0 && end.rock_kind) {
|
||||
cutNatural[end.rock_kind] += rockM3;
|
||||
segmentCutRock += rockM3;
|
||||
segmentCut += rockM3 * factorFor(conversion, end.rock_kind, groundMode);
|
||||
segmentCut += rockM3 * factorFor(conversion, end.rock_kind);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,12 +331,12 @@ export function computeMassHaul(
|
||||
crossSections: CrossSection[],
|
||||
conversion: EarthworkConversion,
|
||||
): MassHaulResult | null {
|
||||
return integrate(crossAreaSamples(designedSections(crossSections)), conversion, "ground_type");
|
||||
return integrate(crossAreaSamples(designedSections(crossSections)), conversion);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비교용 곡선 묶음. 기준 2종 × 지반유형 처리 3종을 모두 계산한다.
|
||||
* 계산이 불가능한 조합(설계 측점 부족, 종단 계획선 없음)은 조용히 빠진다.
|
||||
* 비교용 곡선 묶음. 산출 기준 2종(횡단/종단)을 계산한다.
|
||||
* 계산이 불가능한 기준(설계 측점 부족, 종단 계획선 없음)은 조용히 빠진다.
|
||||
*/
|
||||
export function computeMassHaulSeries(
|
||||
longitudinal: LongitudinalSection,
|
||||
@@ -381,10 +349,9 @@ export function computeMassHaulSeries(
|
||||
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 });
|
||||
for (const basis of SERIES_BASES) {
|
||||
const result = integrate(sampleSets[basis], conversion);
|
||||
if (result) series.push({ key: basis, basis, result });
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
* (LONG_PAD)·누가거리 최댓값(longitudinalMaxChainage)을 종단도와 동일하게 받아,
|
||||
* 같은 측점이 두 그래프에서 같은 가로 위치에 오도록 맞춘다.
|
||||
*
|
||||
* 기준(종단/횡단) 2종 × 지반유형 처리 3종의 곡선을 한 축에 겹쳐 그리고, 그래프 상단
|
||||
* 오버레이 범례 버튼으로 켜고 끈다. **Y 스케일은 표시 여부와 무관하게 항상 전체 곡선
|
||||
* 기준으로 잡는다** — 곡선을 껐다 켤 때마다 축이 움직이면 비교 자체가 불가능해진다.
|
||||
* 산출 기준 2종(횡단/종단)의 곡선을 한 축에 겹쳐 그리고, 그래프 아래 범례 버튼으로 켜고
|
||||
* 끈다. **Y 스케일은 표시 여부와 무관하게 항상 전체 곡선 기준으로 잡는다** — 곡선을 껐다
|
||||
* 켤 때마다 축이 움직이면 비교 자체가 불가능해진다.
|
||||
*
|
||||
* Y축은 데이터 범위 기반 자동 스케일이다. 참고 도면의 수직축척이 공구마다 다르므로
|
||||
* (1공구 V=1:10,000 / 3공구 V=1:50,000) 고정 축척을 쓰면 안 된다.
|
||||
@@ -18,7 +18,6 @@
|
||||
import type { LongitudinalSection } from "./B06_wf3_ProfileCross_Api_Fetch";
|
||||
import type {
|
||||
MassHaulBasis,
|
||||
MassHaulGroundMode,
|
||||
MassHaulPoint,
|
||||
MassHaulSeries,
|
||||
} from "./B06_wf3_ProfileCross_UI_MassHaul";
|
||||
@@ -37,24 +36,27 @@ export const MASS_HAUL_MIN_HEIGHT = 90;
|
||||
/** 패널을 건드리지 않았을 때의 유토곡선 높이. */
|
||||
export const MASS_HAUL_HEIGHT = 190;
|
||||
|
||||
/**
|
||||
* 유토곡선 전용 상하 여백. 좌우는 종단면도와 **반드시 같아야**(LONG_PAD) X축이 맞으므로
|
||||
* 건드리지 않고, 위아래만 좁힌다. 위쪽을 좁히는 이유는 바로 위 종단면도의 측점 라벨과
|
||||
* 붙여 두 그래프가 측점 표기를 공유하는 것처럼 보이게 하기 위해서다(2026-08-02 사용자 지시).
|
||||
* 아래쪽은 X축 제목을 종단면도 상단으로 합치면서 비었다.
|
||||
*/
|
||||
const MASS_PAD_TOP = 12;
|
||||
const MASS_PAD_BOTTOM = 16;
|
||||
|
||||
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])}`;
|
||||
return L(BASIS_LABEL[series.basis]);
|
||||
}
|
||||
|
||||
function seriesClass(series: MassHaulSeries, base: string): string {
|
||||
return `${base} ${base}--${series.basis} ${base}--${series.groundMode}`;
|
||||
return `${base} ${base}--${series.basis}`;
|
||||
}
|
||||
|
||||
/** 누가토량 값 표기 — 천 단위 구분 + 소수점 1자리. */
|
||||
@@ -108,6 +110,118 @@ export function massHaulPointAt(
|
||||
return bestGap <= 0.5 ? best : null;
|
||||
}
|
||||
|
||||
interface CalloutPlacement {
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
/** 주어진 가로 구간에서 표시 중인 곡선이 지나는 세로 범위(없으면 null). */
|
||||
curveYAt: (fromPx: number, toPx: number) => { top: number; bottom: number } | null;
|
||||
}
|
||||
|
||||
const CALLOUT_LINE_H = 14;
|
||||
const CALLOUT_PAD_X = 9;
|
||||
const CALLOUT_PAD_Y = 7;
|
||||
/** 말풍선과 곡선 사이 최소 틈(px). */
|
||||
const CALLOUT_CLEARANCE = 6;
|
||||
|
||||
/** SVG는 자동 크기가 없어 폭을 어림해야 한다. 한글은 라틴 글자보다 넓다. */
|
||||
function textWidth(value: string): number {
|
||||
let width = 0;
|
||||
for (const char of value) width += /[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(char) ? 11 : 6.1;
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 측점의 구간 물량 말풍선(둥근 사각형)을 **곡선과 겹치지 않는 자리에** 그린다.
|
||||
*
|
||||
* 말풍선이 놓일 가로 구간에서 곡선이 실제로 지나는 세로 범위를 먼저 재고, 그 위/아래
|
||||
* 빈 곳 중 들어갈 수 있는 쪽에 놓는다(2026-08-02 사용자 지시). 오른쪽·왼쪽 자리를 모두
|
||||
* 재서 여유가 큰 쪽을 고르며, 어느 쪽에도 안 들어가면 여유가 더 큰 쪽 끝에 붙인다.
|
||||
*/
|
||||
function appendVolumeCallout(
|
||||
svg: SVGSVGElement,
|
||||
point: MassHaulPoint,
|
||||
stationInterval: number,
|
||||
box: CalloutPlacement,
|
||||
): void {
|
||||
const lines = [
|
||||
`${stationLabel(point.chainage_m, stationInterval)}` +
|
||||
` · ${L("B06_MassHaul_Station_Net")} ${formatVolume(point.net_volume_m3)}` +
|
||||
` · ${L("B06_MassHaul_Station_Cumulative")} ${formatVolume(point.cumulative_volume_m3)}`,
|
||||
`${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)} (㎥)`,
|
||||
];
|
||||
const width = Math.min(
|
||||
Math.max(...lines.map(textWidth)) + CALLOUT_PAD_X * 2,
|
||||
Math.max(box.right - box.left, 80),
|
||||
);
|
||||
const height = lines.length * CALLOUT_LINE_H + CALLOUT_PAD_Y * 2;
|
||||
|
||||
const clampX = (value: number): number =>
|
||||
Math.min(Math.max(value, box.left), Math.max(box.left, box.right - width));
|
||||
// 오른쪽 먼저, 안 되면 왼쪽 — 각 자리마다 위/아래 여유를 재서 가장 넉넉한 조합을 고른다.
|
||||
let best: { x: number; y: number; room: number; fits: boolean } | null = null;
|
||||
for (const candidateX of [clampX(box.anchorX + 14), clampX(box.anchorX - 14 - width)]) {
|
||||
const band = box.curveYAt(candidateX, candidateX + width);
|
||||
const roomAbove = (band ? band.top : box.bottom) - box.top;
|
||||
const roomBelow = box.bottom - (band ? band.bottom : box.top);
|
||||
for (const [room, top] of [
|
||||
[roomAbove, (band ? band.top : box.bottom) - height - CALLOUT_CLEARANCE],
|
||||
[roomBelow, (band ? band.bottom : box.top) + CALLOUT_CLEARANCE],
|
||||
] as Array<[number, number]>) {
|
||||
const fits = room >= height + CALLOUT_CLEARANCE;
|
||||
const better = !best || (fits && !best.fits) || (fits === best.fits && room > best.room);
|
||||
if (better) best = { x: candidateX, y: top, room, fits };
|
||||
}
|
||||
}
|
||||
if (!best) return;
|
||||
const boxY = Math.min(
|
||||
Math.max(best.y, box.top + 2),
|
||||
Math.max(box.top + 2, box.bottom - height - 2),
|
||||
);
|
||||
|
||||
const group = svgElement("g", { class: "b06-masshaul__callout" });
|
||||
// 꼬리는 앵커가 말풍선 가로 범위 안에 있을 때만 — 밖이면 지시선으로 잇는다.
|
||||
const tailX = Math.min(Math.max(box.anchorX, best.x + 12), best.x + width - 12);
|
||||
const below = boxY > box.anchorY;
|
||||
if (box.anchorX >= best.x && box.anchorX <= best.x + width) {
|
||||
const edge = below ? boxY : boxY + height;
|
||||
const tip = below ? edge - 7 : edge + 7;
|
||||
group.append(
|
||||
svgElement("polygon", {
|
||||
points: `${tailX - 6},${edge} ${tailX + 6},${edge} ${tailX},${tip}`,
|
||||
class: "b06-masshaul__callout-tail",
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
group.append(
|
||||
svgElement("line", {
|
||||
x1: box.anchorX,
|
||||
y1: box.anchorY,
|
||||
x2: box.anchorX < best.x ? best.x : best.x + width,
|
||||
y2: boxY + height / 2,
|
||||
class: "b06-masshaul__callout-leader",
|
||||
}),
|
||||
);
|
||||
}
|
||||
group.append(
|
||||
svgElement("rect", { x: best.x, y: boxY, width, height, rx: 8, ry: 8 }),
|
||||
...lines.map((line, index) =>
|
||||
svgText(line, {
|
||||
x: best.x + CALLOUT_PAD_X,
|
||||
y: boxY + CALLOUT_PAD_Y + CALLOUT_LINE_H * (index + 0.8),
|
||||
class: "b06-masshaul__callout-text",
|
||||
}),
|
||||
),
|
||||
);
|
||||
svg.append(group);
|
||||
}
|
||||
|
||||
export function createMassHaulChart(
|
||||
series: MassHaulSeries[],
|
||||
visibleKeys: ReadonlySet<string>,
|
||||
@@ -134,17 +248,17 @@ export function createMassHaulChart(
|
||||
// 종단면도와 완전히 같은 X 매핑 — 여백·최댓값 모두 종단 렌더러와 공유한다.
|
||||
const maxChainage = longitudinalMaxChainage(longitudinal);
|
||||
const plotWidth = widthPx - LONG_PAD.left - LONG_PAD.right;
|
||||
const plotHeight = heightPx - LONG_PAD.top - LONG_PAD.bottom;
|
||||
const plotHeight = heightPx - MASS_PAD_TOP - MASS_PAD_BOTTOM;
|
||||
const x = (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth;
|
||||
|
||||
const { min, max } = volumeRange(series);
|
||||
const span = Math.max(max - min, 1e-6);
|
||||
const y = (volume: number) => LONG_PAD.top + ((max - volume) / span) * plotHeight;
|
||||
const y = (volume: number) => MASS_PAD_TOP + ((max - volume) / span) * plotHeight;
|
||||
|
||||
// 패널을 줄이면 그래프 몫이 60px대까지 내려간다 — 눈금 5개를 그대로 두면 라벨이 서로 겹친다.
|
||||
const tickRatios = plotHeight < 110 ? [0, 0.5, 1] : [0, 0.25, 0.5, 0.75, 1];
|
||||
for (const ratio of tickRatios) {
|
||||
const gridY = LONG_PAD.top + ratio * plotHeight;
|
||||
const gridY = MASS_PAD_TOP + ratio * plotHeight;
|
||||
const value = max - ratio * span;
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
@@ -201,16 +315,16 @@ export function createMassHaulChart(
|
||||
marker.append(
|
||||
svgElement("line", {
|
||||
x1: stationX,
|
||||
y1: LONG_PAD.top,
|
||||
y1: MASS_PAD_TOP,
|
||||
x2: stationX,
|
||||
y2: heightPx - LONG_PAD.bottom,
|
||||
y2: heightPx - MASS_PAD_BOTTOM,
|
||||
class: "b06-chart__station-hit",
|
||||
}),
|
||||
svgElement("line", {
|
||||
x1: stationX,
|
||||
y1: LONG_PAD.top,
|
||||
y1: MASS_PAD_TOP,
|
||||
x2: stationX,
|
||||
y2: heightPx - LONG_PAD.bottom,
|
||||
y2: heightPx - MASS_PAD_BOTTOM,
|
||||
class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`,
|
||||
}),
|
||||
);
|
||||
@@ -240,25 +354,49 @@ export function createMassHaulChart(
|
||||
);
|
||||
}
|
||||
|
||||
// 선택 측점 강조 — 첫 표시 곡선 위에 점을 찍어 아래 물량 표기 줄과 시선을 잇는다.
|
||||
// 선택 측점 강조 — 곡선 위 점 + 구간 물량 말풍선.
|
||||
const focus = banded ? massHaulPointAt(banded, longitudinal, selectedStationId) : null;
|
||||
if (focus) {
|
||||
const focusX = x(focus.chainage_m);
|
||||
svg.append(
|
||||
svgElement("circle", {
|
||||
cx: x(focus.chainage_m),
|
||||
cx: focusX,
|
||||
cy: y(focus.cumulative_volume_m3),
|
||||
r: 4,
|
||||
class: "b06-masshaul__focus",
|
||||
}),
|
||||
);
|
||||
appendVolumeCallout(svg, focus, stationInterval, {
|
||||
anchorX: focusX,
|
||||
anchorY: y(focus.cumulative_volume_m3),
|
||||
left: LONG_PAD.left,
|
||||
right: widthPx - LONG_PAD.right,
|
||||
top: MASS_PAD_TOP,
|
||||
bottom: heightPx - MASS_PAD_BOTTOM,
|
||||
curveYAt: (fromPx, toPx) => {
|
||||
// 말풍선이 놓일 가로 구간에서 곡선들이 실제로 지나는 세로 범위.
|
||||
let lowest = Number.POSITIVE_INFINITY;
|
||||
let highest = Number.NEGATIVE_INFINITY;
|
||||
for (const entry of visible) {
|
||||
for (const point of entry.result.points) {
|
||||
const px = x(point.chainage_m);
|
||||
if (px < fromPx || px > toPx) continue;
|
||||
const py = y(point.cumulative_volume_m3);
|
||||
lowest = Math.min(lowest, py);
|
||||
highest = Math.max(highest, py);
|
||||
}
|
||||
}
|
||||
return Number.isFinite(lowest) ? { top: lowest, bottom: highest } : null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: LONG_PAD.left,
|
||||
y1: LONG_PAD.top,
|
||||
y1: MASS_PAD_TOP,
|
||||
x2: LONG_PAD.left,
|
||||
y2: heightPx - LONG_PAD.bottom,
|
||||
y2: heightPx - MASS_PAD_BOTTOM,
|
||||
class: "b06-chart__axis",
|
||||
}),
|
||||
// X축 제목은 종단면도 상단 하나로 합쳤다 — 여기서 또 적으면 두 그래프가 별개 축으로 읽힌다.
|
||||
@@ -310,43 +448,6 @@ 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,7 +37,6 @@ import {
|
||||
import {
|
||||
createMassHaulChart,
|
||||
createMassHaulLegend,
|
||||
createMassHaulStationInfo,
|
||||
createMassHaulSummary,
|
||||
MASS_HAUL_HEIGHT,
|
||||
MASS_HAUL_MIN_HEIGHT,
|
||||
@@ -60,11 +59,11 @@ import {
|
||||
export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl };
|
||||
|
||||
/**
|
||||
* 헤더·범례줄·선택측점 물량줄·요약줄·테두리가 먹는 세로 공간. 패널 높이에서 이만큼 빼야
|
||||
* 그래프 몫이 된다. (`.b06-section__panel-body`가 `overflow: hidden`이라 모자라면 아래가 잘린다.
|
||||
* 헤더(캐럿 행)·범례줄·요약줄·테두리가 먹는 세로 공간. 패널 높이에서 이만큼 빼야 그래프
|
||||
* 몫이 된다. (`.b06-section__panel-body`가 `overflow: hidden`이라 모자라면 아래가 잘린다.
|
||||
* 범례는 CSS에서 한 줄 고정 가로 스크롤이라 개수가 늘어도 이 값이 흔들리지 않는다.)
|
||||
*/
|
||||
const PANEL_CHROME_PX = 160;
|
||||
const PANEL_CHROME_PX = 130;
|
||||
/** 손대지 않았을 때의 패널 높이 — 이 값이 축소 비례의 기준(H₀)이 된다. */
|
||||
const BASE_PANEL_HEIGHT = LONG_HEIGHT + MASS_HAUL_HEIGHT + PANEL_CHROME_PX;
|
||||
const MIN_LONG_HEIGHT = 110;
|
||||
@@ -72,7 +71,9 @@ 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";
|
||||
// 곡선 키가 `${기준}_${환산}`에서 기준 하나로 바뀌었다 — 옛 세션값을 읽으면 전부 꺼진 것처럼
|
||||
// 보이므로 저장 키의 판을 올려 갈아탄다.
|
||||
const MASS_HAUL_VISIBLE_KEY = "b06:masshaul-visible-v2";
|
||||
|
||||
/** 켜 둔 곡선 목록은 세션에만 남긴다(패널 높이·접힘과 같은 규칙). */
|
||||
function readVisibleSeries(): Set<string> {
|
||||
@@ -164,11 +165,15 @@ export function createSectionView(
|
||||
* 매 렌더마다 새로 만들면 리사이저 핸들과 접힘 상태가 초기화된다. */
|
||||
const panel = document.createElement("section");
|
||||
panel.className = "b06-section__panel ui-collapsible";
|
||||
// 최소 높이는 TS 상수 하나로만 정의한다(세션에 저장된 옛 높이가 더 작아도 여기서 걸린다).
|
||||
panel.style.minHeight = `${MIN_PANEL_HEIGHT}px`;
|
||||
// 제목 문구는 없앴다(2026-08-02 사용자 지시). 헤더 행은 접기/펼치기 손잡이 겸 상태 표시줄이며,
|
||||
// 캐럿은 공용 `ui-collapsible` 규칙이 그린다(방향만 B06 CSS에서 상하로 바꾼다).
|
||||
const panelHeader = document.createElement("header");
|
||||
panelHeader.className = "ui-collapsible__title";
|
||||
const panelTitle = document.createElement("h3");
|
||||
panelHeader.setAttribute("aria-label", L("B06_Profile_View_Longitudinal"));
|
||||
const panelCount = document.createElement("span");
|
||||
panelHeader.append(panelTitle, panelCount);
|
||||
panelHeader.append(panelCount);
|
||||
const chartWrap = document.createElement("div");
|
||||
chartWrap.className = "b06-section__chart-wrap";
|
||||
const panelBody = document.createElement("div");
|
||||
@@ -338,24 +343,15 @@ export function createSectionView(
|
||||
}
|
||||
chartWrap.replaceChildren(...nodes);
|
||||
|
||||
panelTitle.textContent = `${L("B06_Profile_View_Longitudinal")} · ${L("B06_MassHaul_Title")}`;
|
||||
panel.querySelector(".b06-masshaul__legend")?.remove();
|
||||
panel.querySelector(".b06-masshaul__summary")?.remove();
|
||||
panel.querySelector(".b06-masshaul__station")?.remove();
|
||||
// 범례는 그래프 아래, 스크롤 컨테이너 밖에 둔다(곡선 폭에 관여하지 않게).
|
||||
if (series.length) panelBody.append(createMassHaulLegend(series, visibleSeries, toggleSeries));
|
||||
// 요약 수치는 켜 둔 곡선 중 첫 번째 것 — 곡선이 여러 개라 어느 것인지 요약 끝에 밝힌다.
|
||||
// (선택 측점의 구간 물량은 곡선 위 말풍선이 맡는다.)
|
||||
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");
|
||||
|
||||
@@ -81,11 +81,23 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 제목 문구 없이 접기/펼치기 손잡이만 남은 행 — 캐럿을 가운데 두고 높이를 낮춘다. */
|
||||
.b06-section__panel > header {
|
||||
justify-content: center;
|
||||
padding-block: 2px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b06-section__panel > header h3,
|
||||
/* 좌측 사이드 패널과 같은 캐럿을 쓰되 **방향만 상하로** 바꾼다(이 패널은 위로 접힌다).
|
||||
펼침 = 위로 접는다(▴) / 접힘 = 아래로 편다(▾). */
|
||||
.b06-section__panel > .ui-collapsible__title::after {
|
||||
content: "▴";
|
||||
}
|
||||
|
||||
.b06-section__panel.is-collapsed > .ui-collapsible__title::after {
|
||||
content: "▾";
|
||||
}
|
||||
|
||||
.b06-section__heading h3 {
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
@@ -106,6 +118,13 @@
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* 상단 패널의 두 그래프는 세로로 눌리면 안 된다. flex 기본값(shrink: 1)으로 두면 패널을
|
||||
줄였을 때 렌더 높이가 height 속성보다 작아지고, SVG는 preserveAspectRatio 기본값이 meet라
|
||||
**가로까지 같은 비율로 축소**된다(그래프 폭이 줄어 보이던 원인). */
|
||||
.b06-section__chart-wrap > .b06-section__chart {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* 횡단도 줌·팬 영역(E-5) — 휠 줌/드래그 팬. */
|
||||
.b06-cross-card__chart-wrap {
|
||||
position: relative;
|
||||
|
||||
@@ -74,16 +74,6 @@
|
||||
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 {
|
||||
@@ -135,22 +125,35 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/* 선택 측점 구간 물량 말풍선. 렌더러가 곡선이 지나지 않는 자리를 골라 놓지만, 배치가
|
||||
빡빡한 화면에서도 곡선이 비쳐 보이도록 배경을 완전 불투명으로 두지 않는다. */
|
||||
.b06-masshaul__callout > rect {
|
||||
fill: color-mix(in srgb, var(--color-surface-raised) 94%, transparent);
|
||||
stroke: var(--color-royal-amethyst);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.b06-masshaul__callout-tail {
|
||||
fill: color-mix(in srgb, var(--color-surface-raised) 94%, transparent);
|
||||
stroke: var(--color-royal-amethyst);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.b06-masshaul__callout-leader {
|
||||
stroke: var(--color-royal-amethyst);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 3 2;
|
||||
}
|
||||
|
||||
.b06-masshaul__callout-text {
|
||||
fill: var(--color-text-body);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -200,7 +200,6 @@ export const ui_locales_b2 = {
|
||||
B06_Profile_Zoom_Reset: ["원래 크기", "Reset zoom"],
|
||||
B06_Profile_View_Kind_BP: ["BP", "BP"],
|
||||
B06_Profile_View_Kind_EP: ["EP", "EP"],
|
||||
B06_Profile_View_Kind_Station: ["일반 측점", "Station"],
|
||||
B06_Profile_View_NoLongitudinal: [
|
||||
"표시할 종단면 데이터가 없습니다.",
|
||||
"No longitudinal profile data to display.",
|
||||
@@ -213,7 +212,6 @@ export const ui_locales_b2 = {
|
||||
/* --- B06 유토곡선 (종단면도 아래 상단 패널) --- */
|
||||
B06_MassHaul_Title: ["유토곡선", "Mass Haul Diagram"],
|
||||
B06_MassHaul_YAxis: ["누가토량 (㎥)", "Cumulative volume (㎥)"],
|
||||
B06_MassHaul_XAxis: ["BP 기준 누적거리", "Chainage from BP"],
|
||||
B06_MassHaul_Empty: [
|
||||
"측점에 지반유형을 지정하면 유토곡선이 표시됩니다.",
|
||||
"The mass haul diagram appears once ground types are assigned to stations.",
|
||||
@@ -233,15 +231,10 @@ export const ui_locales_b2 = {
|
||||
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_MassHaul_Station_Segment: ["구간 물량", "Segment volume"],
|
||||
/* 선택 측점의 구간 물량 말풍선 — 직전 측점부터 이 측점까지의 몫이다. */
|
||||
B06_MassHaul_Station_Net: ["순토량", "Net volume"],
|
||||
B06_MassHaul_Station_Cumulative: ["누가토량", "Cumulative"],
|
||||
|
||||
|
||||
Reference in New Issue
Block a user