refactor(B06): 700줄 초과 파일 분리 — Router·Cross_View·Section_View·Style

- B06_Section_Router.py(824) → Router_Design.py(139) 분리
- Cross_View → Cross_View_Metrics.ts(75) 분리
- Cross_Design → Cross_Design_Surface.ts(54) 분리
- Section_View → Section_View_Panel.ts(53) 분리
- Page → Page_Common.ts(15) 분리
- Style_Cross.css → Style_Cross_Controls.css(174) 분리
- B06_Section/ 전체 700줄 초과 0건

검증: tsc --noEmit 통과 · ruff check 통과(import 정렬·포맷 적용) ·
prettier 적용 · pytest tmp/tests/ 148 passed, 7 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 17:04:08 +09:00
co-authored by Claude Opus 5
parent be7503d741
commit e54da341ea
14 changed files with 553 additions and 600 deletions
+18 -163
View File
@@ -36,6 +36,24 @@ from B06_Section.B06_Section_Repository import (
list_recent_company_projects,
update_cross_section_design,
)
from B06_Section.B06_Section_Router_Design import (
PREVIEW_DESIGN_FIELDS as _PREVIEW_DESIGN_FIELDS,
)
from B06_Section.B06_Section_Router_Design import (
attach_default_designs as _attach_default_designs,
)
from B06_Section.B06_Section_Router_Design import (
default_section_modes as _default_section_modes,
)
from B06_Section.B06_Section_Router_Design import (
pavement_suggestions as _pavement_suggestions,
)
from B06_Section.B06_Section_Router_Design import (
read_cross_design_inputs as _read_cross_design_inputs,
)
from B06_Section.B06_Section_Router_Design import (
recompute_designs_for_alignment as _recompute_designs_for_alignment,
)
from B06_Section.B06_Section_Schema import (
CompanyStandardListResponse,
CompanyStandardProject,
@@ -451,169 +469,6 @@ async def regenerate_sections(
)
def _read_cross_design_inputs(
project_root: Path, longitudinal_file_path: str, chainage_m: float
) -> tuple[list[dict], float | None, bool]:
"""측점 하나의 지반 샘플·계획고·포장 제안 여부를 파일에서 읽는다 (경로 이탈 검증 포함)."""
root = project_root.resolve()
longitudinal_path = (root / longitudinal_file_path).resolve()
if root not in longitudinal_path.parents:
raise ValueError("종단면 파일 경로가 프로젝트 저장소를 벗어났습니다.")
if not longitudinal_path.is_file():
raise FileNotFoundError("종단면 상세 파일을 찾을 수 없습니다.")
longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8"))
design_elevation = design_elevation_from_longitudinal(longitudinal, chainage_m)
pavement_suggested = _pavement_suggestions(longitudinal).get(round(chainage_m, 3), False)
cross_dir = longitudinal_path.parent.parent / "cross_sections"
cross_path = (cross_dir / cross_filename(chainage_m)).resolve()
if cross_dir.resolve() not in cross_path.parents or not cross_path.is_file():
raise FileNotFoundError("해당 측점의 횡단 상세 파일을 찾을 수 없습니다.")
cross = json.loads(cross_path.read_text(encoding="utf-8"))
samples = cross.get("samples") if isinstance(cross, dict) else None
if not isinstance(samples, list):
raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.")
return samples, design_elevation, pavement_suggested
def _pavement_suggestions(longitudinal: dict[str, Any]) -> dict[float, bool]:
"""측점별 포장 제안(B05 solve가 법정 경사 기준으로 판정) 매핑을 만든다."""
stations = longitudinal.get("stations")
mapping: dict[float, bool] = {}
if isinstance(stations, list):
for station in stations:
suggested = station.get("pavement_suggested")
if isinstance(suggested, bool):
mapping[round(float(station.get("chainage_m", 0.0)), 3)] = suggested
return mapping
def _default_section_modes(longitudinal: dict[str, Any]) -> dict[float, str]:
"""측점별 기본 단면유형 매핑: 상단측(uphill_side)이 절토측이 되는 편절편성.
B05 solve가 자동 판정하고 사용자가 3D 램프로 바꾼 값(확정 시 정본 병합)을 그대로
소비한다. 판정 불가 측점은 매핑에서 빠지고 호출부가 좌절토로 폴백한다.
"""
stations = longitudinal.get("stations")
mapping: dict[float, str] = {}
if isinstance(stations, list):
for station in stations:
side = station.get("uphill_side")
if side in ("left", "right"):
mapping[round(float(station.get("chainage_m", 0.0)), 3)] = f"{side}_cut"
return mapping
def _attach_default_designs(
longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]]
) -> None:
"""지정 설계가 없는 횡단에 기본값(리핑암 + 암반 경계 0.5m + 상단측 절토) 프리뷰를 얹는다.
detail 조회가 이미 읽어온 samples와 종단 계획선을 그대로 써서 추가 파일 I/O 없이
전 측점 프리뷰를 만든다(미저장). 계산 불가 측점은 건너뛴다.
기본 지반을 토사가 아니라 **리핑암 + 지표 아래 0.5m 암반 경계**로 두는 이유(2026-08-03
사용자 확정): 산지 절토는 대부분 표토 아래에서 암이 나오므로, 전량 토사 가정은 물량이
낙관적으로 나온다. 지표 0.5m까지 토사·그 아래 리핑암인 2단 단면이 안전한 출발값이고,
발파암이 있으면 사용자가 B06에서 측점별로 고친다.
"""
default_modes = _default_section_modes(longitudinal)
pavement = _pavement_suggestions(longitudinal)
for section in cross_sections:
if section.get("design"):
continue
try:
chainage_m = float(section.get("chainage_m", 0.0))
suggested = pavement.get(round(chainage_m, 3), False)
design = compute_cross_design(
section.get("samples", []),
design_elevation_from_longitudinal(longitudinal, chainage_m),
ground_type="ripping_rock",
section_mode=default_modes.get(round(chainage_m, 3), "left_cut"),
paved=suggested,
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
)
design["status"] = "provisional"
design["pavement_suggested"] = suggested
section["design"] = design
except (ValueError, KeyError):
continue
#: 계획선 프리뷰 응답에 실을 설계 필드 — 유토곡선 계산(면적·지반유형·자연방토)과
#: 계획고 변경 감지에 쓰는 것만. 나머지(설계선 좌표 등)는 B06이 상세를 받을 때 온다.
_PREVIEW_DESIGN_FIELDS = (
"ground_type",
"roadbed_width_m",
"cut_area_m2",
"cut_soil_area_m2",
"cut_rock_area_m2",
"cut_rock_kind",
"fill_area_m2",
"fill_ground_slope",
"design_elevation_m",
)
def _recompute_designs_for_alignment(
longitudinal: dict[str, Any],
cross_sections: list[dict[str, Any]],
stored_designs: list[dict[str, Any]],
standard: dict[str, Any] | None,
rock_boundary_offsets: dict[str, float] | None = None,
) -> None:
"""새 계획선 기준으로 전 측점 횡단 설계를 다시 계산해 얹는다(메모리 프리뷰).
사용자가 이미 고른 지반유형·단면유형·측구·암 경계는 **그대로 유지**하고 계획고만
새 선형 값으로 바꿔 넣는다 — 계획선을 끌었다고 측점 선택이 초기화되면 안 된다.
지정이 없는 측점은 기본값(리핑암 + 암반 경계 0.5m + 상단측 절토)으로 계산한다.
`rock_boundary_offsets`(B06 세션값)가 오면 DB 저장분보다 우선한다.
"""
default_modes = _default_section_modes(longitudinal)
pavement = _pavement_suggestions(longitudinal)
stored_by_chainage = {
round(float(record["chainage_m"]), 3): (record.get("design") or {})
for record in stored_designs
}
# 세션 오프셋 키는 프론트 표기(소수 자릿수)가 제각각일 수 있어 수치로 정규화한다.
session_offsets: dict[float, float] = {}
for raw_key, offset in (rock_boundary_offsets or {}).items():
try:
session_offsets[round(float(raw_key), 3)] = float(offset)
except (TypeError, ValueError):
continue
for section in cross_sections:
chainage_m = float(section.get("chainage_m", 0.0))
key = round(chainage_m, 3)
stored = stored_by_chainage.get(key) or {}
suggested = pavement.get(key, False)
rock_offset = session_offsets.get(
key, stored.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M)
)
try:
design = compute_cross_design(
section.get("samples", []),
design_elevation_from_longitudinal(longitudinal, chainage_m),
ground_type=str(stored.get("ground_type") or "ripping_rock"),
section_mode=str(stored.get("section_mode") or default_modes.get(key, "left_cut")),
ditch_side=stored.get("ditch_side"),
ditch_type=str(stored.get("ditch_type") or "standard"),
paved=bool(stored.get("paved", suggested)),
standard=standard,
rock_boundary_offset_m=rock_offset,
two_stage_slope=bool(stored.get("two_stage_slope", True)),
ditch_enabled=stored.get("ditch_enabled"),
)
except (ValueError, KeyError):
continue
design["status"] = "provisional"
design["pavement_suggested"] = suggested
# 표시 설정(측점 개별 반폭)은 계산과 무관 — 재계산이 지우면 안 된다(2026-08-06).
if stored.get("display_half_width_m") is not None:
design["display_half_width_m"] = stored["display_half_width_m"]
section["design"] = design
@router.post(
"/{project_id}/sections/{route_id}/cross-design/preview",
response_model=CrossDesignPreviewResponse,
+139
View File
@@ -0,0 +1,139 @@
"""B06 라우터의 횡단 설계 파일 읽기와 프리뷰 계산."""
import json
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Engine_Sections import cross_filename
from B06_Section.B06_Section_Engine_Design import compute_cross_design
from common_util.common_util_route_profile import design_elevation_from_longitudinal
from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
PREVIEW_DESIGN_FIELDS = (
"ground_type",
"roadbed_width_m",
"cut_area_m2",
"cut_soil_area_m2",
"cut_rock_area_m2",
"cut_rock_kind",
"fill_area_m2",
"fill_ground_slope",
"design_elevation_m",
)
def pavement_suggestions(longitudinal: dict[str, Any]) -> dict[float, bool]:
mapping: dict[float, bool] = {}
stations = longitudinal.get("stations")
for station in stations if isinstance(stations, list) else []:
suggested = station.get("pavement_suggested")
if isinstance(suggested, bool):
mapping[round(float(station.get("chainage_m", 0.0)), 3)] = suggested
return mapping
def default_section_modes(longitudinal: dict[str, Any]) -> dict[float, str]:
mapping: dict[float, str] = {}
stations = longitudinal.get("stations")
for station in stations if isinstance(stations, list) else []:
side = station.get("uphill_side")
if side in ("left", "right"):
mapping[round(float(station.get("chainage_m", 0.0)), 3)] = f"{side}_cut"
return mapping
def read_cross_design_inputs(
project_root: Path, longitudinal_file_path: str, chainage_m: float
) -> tuple[list[dict], float | None, bool]:
root = project_root.resolve()
longitudinal_path = (root / longitudinal_file_path).resolve()
if root not in longitudinal_path.parents:
raise ValueError("종단면 파일 경로가 프로젝트 저장소를 벗어났습니다.")
if not longitudinal_path.is_file():
raise FileNotFoundError("종단면 상세 파일을 찾을 수 없습니다.")
longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8"))
design_elevation = design_elevation_from_longitudinal(longitudinal, chainage_m)
suggested = pavement_suggestions(longitudinal).get(round(chainage_m, 3), False)
cross_dir = longitudinal_path.parent.parent / "cross_sections"
cross_path = (cross_dir / cross_filename(chainage_m)).resolve()
if cross_dir.resolve() not in cross_path.parents or not cross_path.is_file():
raise FileNotFoundError("해당 측점의 횡단 상세 파일을 찾을 수 없습니다.")
cross = json.loads(cross_path.read_text(encoding="utf-8"))
samples = cross.get("samples") if isinstance(cross, dict) else None
if not isinstance(samples, list):
raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.")
return samples, design_elevation, suggested
def attach_default_designs(
longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]]
) -> None:
modes = default_section_modes(longitudinal)
pavement = pavement_suggestions(longitudinal)
for section in cross_sections:
if section.get("design"):
continue
try:
chainage = float(section.get("chainage_m", 0.0))
suggested = pavement.get(round(chainage, 3), False)
design = compute_cross_design(
section.get("samples", []),
design_elevation_from_longitudinal(longitudinal, chainage),
ground_type="ripping_rock",
section_mode=modes.get(round(chainage, 3), "left_cut"),
paved=suggested,
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
)
design.update(status="provisional", pavement_suggested=suggested)
section["design"] = design
except (ValueError, KeyError):
continue
def recompute_designs_for_alignment(
longitudinal: dict[str, Any],
cross_sections: list[dict[str, Any]],
stored_designs: list[dict[str, Any]],
standard: dict[str, Any] | None,
rock_boundary_offsets: dict[str, float] | None = None,
) -> None:
modes = default_section_modes(longitudinal)
pavement = pavement_suggestions(longitudinal)
stored_by_chainage = {
round(float(record["chainage_m"]), 3): (record.get("design") or {})
for record in stored_designs
}
session_offsets: dict[float, float] = {}
for raw_key, offset in (rock_boundary_offsets or {}).items():
try:
session_offsets[round(float(raw_key), 3)] = float(offset)
except (TypeError, ValueError):
continue
for section in cross_sections:
chainage = float(section.get("chainage_m", 0.0))
key = round(chainage, 3)
stored = stored_by_chainage.get(key) or {}
suggested = pavement.get(key, False)
try:
design = compute_cross_design(
section.get("samples", []),
design_elevation_from_longitudinal(longitudinal, chainage),
ground_type=str(stored.get("ground_type") or "ripping_rock"),
section_mode=str(stored.get("section_mode") or modes.get(key, "left_cut")),
ditch_side=stored.get("ditch_side"),
ditch_type=str(stored.get("ditch_type") or "standard"),
paved=bool(stored.get("paved", suggested)),
standard=standard,
rock_boundary_offset_m=session_offsets.get(
key,
stored.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M),
),
two_stage_slope=bool(stored.get("two_stage_slope", True)),
ditch_enabled=stored.get("ditch_enabled"),
)
except (ValueError, KeyError):
continue
design.update(status="provisional", pavement_suggested=suggested)
if stored.get("display_half_width_m") is not None:
design["display_half_width_m"] = stored["display_half_width_m"]
section["design"] = design
@@ -35,6 +35,7 @@ import type {
BasinShape,
CulvertLayout,
EndFace,
InletStructureChoice,
OffsetPoint,
PipeEnd,
WallAdjust,
@@ -69,9 +70,6 @@ import {
STRAY_LIMIT_M,
} from "./B06_Section_UI_Cross_Culvert_Solve";
/** 유입측 구조물 사용자 선택(2026-08-22) — auto = 규칙(사면≤3m → 집수정 ㄴ형). */
export type InletStructureChoice = "auto" | "revet" | "I" | "L" | "U";
/** 배수관 세트 기하 계산. 부족한 입력이면 null — 그리기와 분리(설계선 트림이 먼저 쓴다). */
export function computeCulvertLayout(
section: CrossSection,
@@ -180,3 +180,5 @@ export interface CulvertLayout {
*/
inletOptions: { revetAllowed: boolean; basinLUAllowed: boolean };
}
/** 유입측 구조물 사용자 선택 — auto = 규칙 기반 자동 선택. */
export type InletStructureChoice = "auto" | "revet" | "I" | "L" | "U";
+4 -50
View File
@@ -24,6 +24,10 @@ import type {
SectionMode,
SectionSample,
} from "./B06_Section_Api_Fetch";
export {
appendPavementOverlay,
appendRockBoundaryOverlay,
} from "./B06_Section_UI_Cross_Design_Surface";
const SVG_NS = "http://www.w3.org/2000/svg";
@@ -638,53 +642,3 @@ export function appendCrossDesignOverlay(
* 리핑암·발파암 지반에서만 호출한다. offsetM 음수 = 하향.
* 무효 샘플 구간은 지반선과 동일하게 선을 끊어 그린다.
*/
export function appendRockBoundaryOverlay(
svg: SVGElement,
groundSamples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>,
offsetM: number,
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
): void {
const segments: string[][] = [];
let current: string[] = [];
for (const sample of groundSamples) {
const elevation = sample.elevation_m;
if (sample.valid === false || elevation === null || !Number.isFinite(elevation ?? NaN)) {
if (current.length > 1) segments.push(current);
current = [];
continue;
}
current.push(`${x(sample.offset_m ?? 0)},${toDisplayY((elevation as number) + offsetM)}`);
}
if (current.length > 1) segments.push(current);
for (const points of segments) {
const polyline = document.createElementNS(SVG_NS, "polyline");
polyline.setAttribute("points", points.join(" "));
polyline.setAttribute("class", "b06-chart__rock-boundary");
svg.append(polyline);
}
}
/** 포장 측점의 노면 포장층 박스를 겹쳐 그린다 (노면 양 끝점 기준, 두께만큼 하향). */
export function appendPavementOverlay(
svg: SVGElement,
design: CrossDesign,
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
): void {
// 포장은 차도(노견 제외)만 덮는다(D-5). 구 데이터 폴백으로 road_edges를 쓴다.
const edges = design.carriageway_edges ?? design.road_edges;
if (!design.paved || !edges) return;
const thickness = design.pavement_thickness_m ?? 0.2;
const { left, right } = edges;
const points = [
`${x(left.offset_m)},${toDisplayY(left.elevation_m)}`,
`${x(right.offset_m)},${toDisplayY(right.elevation_m)}`,
`${x(right.offset_m)},${toDisplayY(right.elevation_m - thickness)}`,
`${x(left.offset_m)},${toDisplayY(left.elevation_m - thickness)}`,
];
const polygon = document.createElementNS(SVG_NS, "polygon");
polygon.setAttribute("points", points.join(" "));
polygon.setAttribute("class", "b06-chart__pavement");
svg.append(polygon);
}
@@ -0,0 +1,54 @@
import type { CrossDesign } from "./B06_Section_Api_Fetch";
const SVG_NS = "http://www.w3.org/2000/svg";
export function appendRockBoundaryOverlay(
svg: SVGElement,
samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>,
offsetM: number,
x: (offset: number) => number,
y: (elevation: number) => number,
): void {
const segments: string[][] = [];
let current: string[] = [];
for (const sample of samples) {
const elevation = sample.elevation_m;
if (sample.valid === false || elevation === null || !Number.isFinite(elevation ?? NaN)) {
if (current.length > 1) segments.push(current);
current = [];
continue;
}
current.push(`${x(sample.offset_m ?? 0)},${y((elevation as number) + offsetM)}`);
}
if (current.length > 1) segments.push(current);
for (const points of segments) {
const polyline = document.createElementNS(SVG_NS, "polyline");
polyline.setAttribute("points", points.join(" "));
polyline.setAttribute("class", "b06-chart__rock-boundary");
svg.append(polyline);
}
}
export function appendPavementOverlay(
svg: SVGElement,
design: CrossDesign,
x: (offset: number) => number,
y: (elevation: number) => number,
): void {
const edges = design.carriageway_edges ?? design.road_edges;
if (!design.paved || !edges) return;
const thickness = design.pavement_thickness_m ?? 0.2;
const { left, right } = edges;
const polygon = document.createElementNS(SVG_NS, "polygon");
polygon.setAttribute(
"points",
[
`${x(left.offset_m)},${y(left.elevation_m)}`,
`${x(right.offset_m)},${y(right.elevation_m)}`,
`${x(right.offset_m)},${y(right.elevation_m - thickness)}`,
`${x(left.offset_m)},${y(left.elevation_m - thickness)}`,
].join(" "),
);
polygon.setAttribute("class", "b06-chart__pavement");
svg.append(polygon);
}
+3 -92
View File
@@ -7,7 +7,7 @@
* / Y스케일을 1:1 ppm으로 .
* ========================================================================== */
import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
import type { CrossSection } from "./B06_Section_Api_Fetch";
import {
appendCrossAreaBands,
type AreaHighlightSetter,
@@ -37,7 +37,6 @@ import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culv
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import {
CROSS_HEIGHT,
CROSS_PAD,
CROSS_WIDTH,
type DesignChangeHandler,
@@ -48,6 +47,8 @@ import {
svgText,
validElevation,
} from "./B06_Section_UI_Section_Common";
import { crossPlotMetrics } from "./B06_Section_UI_Cross_View_Metrics";
export { crossCardNaturalHeight } from "./B06_Section_UI_Cross_View_Metrics";
/**
* . ** **
@@ -57,96 +58,6 @@ export interface CrossCardElement extends HTMLElement {
applySelection?: (selected: boolean, areaKey: CrossAreaKey | null) => void;
}
// 절·성토 값 오버레이(그래프 상단 고정)가 계획고 선을 가리는 드문 경우를 대비해, Y 플롯
// 최대값에 더하는 상단 여유(px). 오버레이·라벨은 손대지 않고 데이터를 그만큼 아래로 내린다.
// 오버레이가 칩 한 줄에서 표(머리글 + 절토 + 성토 3행)로 바뀌어 그만큼 키웠다.
const AREA_OVERLAY_HEADROOM_PX = 58;
interface CrossPlotMetrics {
sourceSamples: SectionSample[];
minOffset: number;
maxOffset: number;
elevationMid: number;
displaySpan: number;
displayMax: number;
pixelsPerMeter: number;
heightPx: number;
}
/**
* X:Y 1:1 .
* X pixels-per-meter는 (offset ) , ppm을 Y에도
* (=1 1:1).
* `CROSS_HEIGHT`(250px) , `forcedHeightPx` ( ).
* displaySpan을 plotHeight에 ppm(=1:1) ,
* elevationMid를 .
*/
function crossPlotMetrics(
section: CrossSection,
verticalExaggeration: number,
widthPx: number,
crossHalfWidth?: number,
designElevation?: number,
forcedHeightPx?: number,
): CrossPlotMetrics | null {
const sourceSamples = section.samples.filter(
(sample) =>
crossHalfWidth === undefined || Math.abs(sample.offset_m ?? 0) <= crossHalfWidth + 1e-6,
);
const valid = sourceSamples.filter(validElevation);
if (!valid.length) return null;
const offsets = sourceSamples.map((sample) => sample.offset_m ?? 0);
const minOffset = Math.min(...offsets, -1);
const maxOffset = Math.max(...offsets, 1);
const hasDesign = designElevation !== undefined && Number.isFinite(designElevation);
const elevations = valid.map((sample) => sample.elevation_m);
if (hasDesign) elevations.push(designElevation as number);
const rawMin = Math.min(...elevations);
const rawMax = Math.max(...elevations);
const elevationMid = (rawMin + rawMax) / 2;
const padding = rawMax > rawMin ? (rawMax - rawMin) * 0.08 : 0.5;
const exaggeration = Math.max(verticalExaggeration, 0.1);
const plotWidth = widthPx - CROSS_PAD.left - CROSS_PAD.right;
const xSpan = Math.max(maxOffset - minOffset, 1e-6);
const pixelsPerMeter = plotWidth / xSpan;
const rawSpan = Math.max((rawMax - rawMin + padding * 2) * exaggeration, 1e-6);
// 상단 오버레이 여유(headroom)를 자연 높이에 더해 카드가 그만큼 커지게 한다.
const naturalHeight =
rawSpan * pixelsPerMeter + CROSS_PAD.top + CROSS_PAD.bottom + AREA_OVERLAY_HEADROOM_PX;
// 강제 높이가 있으면 그것을, 없으면 자연 높이에 250px 바닥 적용.
const heightPx = forcedHeightPx ?? Math.max(naturalHeight, CROSS_HEIGHT);
const plotHeight = Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1e-6);
// 실제 plotHeight에 맞춰 표시 표고폭을 되계산 → ppm(1:1) 보존, 여유분은 상하 대칭 여백.
const displaySpan = plotHeight / pixelsPerMeter;
// 늘린 세로 여유의 절반을 위쪽에 몰아 데이터·계획선을 오버레이 아래로 내린다(라벨 위치 불변).
const headroomM = AREA_OVERLAY_HEADROOM_PX / pixelsPerMeter;
const displayMax = elevationMid + displaySpan / 2 + headroomM / 2;
return {
sourceSamples,
minOffset,
maxOffset,
elevationMid,
displaySpan,
displayMax,
pixelsPerMeter,
heightPx,
};
}
/** 같은 행 높이 통일을 위해 카드를 만들지 않고 자연(바닥 적용) 높이만 계산한다. */
export function crossCardNaturalHeight(
section: CrossSection,
verticalExaggeration: number,
widthPx: number,
crossHalfWidth?: number,
designElevation?: number,
): number {
return (
crossPlotMetrics(section, verticalExaggeration, widthPx, crossHalfWidth, designElevation)
?.heightPx ?? CROSS_HEIGHT
);
}
/**
* SVG에 · + + (E-5).
*
@@ -0,0 +1,75 @@
import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
import { CROSS_HEIGHT, CROSS_PAD, validElevation } from "./B06_Section_UI_Section_Common";
const AREA_OVERLAY_HEADROOM_PX = 58;
interface CrossPlotMetrics {
sourceSamples: SectionSample[];
minOffset: number;
maxOffset: number;
elevationMid: number;
displaySpan: number;
displayMax: number;
pixelsPerMeter: number;
heightPx: number;
}
export function crossPlotMetrics(
section: CrossSection,
verticalExaggeration: number,
widthPx: number,
crossHalfWidth?: number,
designElevation?: number,
forcedHeightPx?: number,
): CrossPlotMetrics | null {
const sourceSamples = section.samples.filter(
(sample) =>
crossHalfWidth === undefined || Math.abs(sample.offset_m ?? 0) <= crossHalfWidth + 1e-6,
);
const valid = sourceSamples.filter(validElevation);
if (!valid.length) return null;
const offsets = sourceSamples.map((sample) => sample.offset_m ?? 0);
const minOffset = Math.min(...offsets, -1);
const maxOffset = Math.max(...offsets, 1);
const elevations = valid.map((sample) => sample.elevation_m);
if (designElevation !== undefined && Number.isFinite(designElevation))
elevations.push(designElevation);
const rawMin = Math.min(...elevations);
const rawMax = Math.max(...elevations);
const elevationMid = (rawMin + rawMax) / 2;
const padding = rawMax > rawMin ? (rawMax - rawMin) * 0.08 : 0.5;
const pixelsPerMeter =
(widthPx - CROSS_PAD.left - CROSS_PAD.right) / Math.max(maxOffset - minOffset, 1e-6);
const rawSpan = Math.max(
(rawMax - rawMin + padding * 2) * Math.max(verticalExaggeration, 0.1),
1e-6,
);
const naturalHeight =
rawSpan * pixelsPerMeter + CROSS_PAD.top + CROSS_PAD.bottom + AREA_OVERLAY_HEADROOM_PX;
const heightPx = forcedHeightPx ?? Math.max(naturalHeight, CROSS_HEIGHT);
const displaySpan = Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1e-6) / pixelsPerMeter;
const displayMax = elevationMid + displaySpan / 2 + AREA_OVERLAY_HEADROOM_PX / pixelsPerMeter / 2;
return {
sourceSamples,
minOffset,
maxOffset,
elevationMid,
displaySpan,
displayMax,
pixelsPerMeter,
heightPx,
};
}
export function crossCardNaturalHeight(
section: CrossSection,
verticalExaggeration: number,
widthPx: number,
crossHalfWidth?: number,
designElevation?: number,
): number {
return (
crossPlotMetrics(section, verticalExaggeration, widthPx, crossHalfWidth, designElevation)
?.heightPx ?? CROSS_HEIGHT
);
}
+2 -17
View File
@@ -1,7 +1,6 @@
import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
import { navigateTo } from "../A00_Common/router";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
@@ -46,26 +45,12 @@ import { designElevationAt } from "./B06_Section_UI_Section_Common";
import { createStandardPanel, type StandardPanelController } from "./B06_Section_UI_Standard_Panel";
import "./B06_Section_UI_Style.css";
import "./B06_Section_UI_Style_Cross.css";
import "./B06_Section_UI_Style_Cross_Controls.css";
import "./B06_Section_UI_Style_Cross_Areas.css";
import { loadSectionDetail, replaceSectionDetail } from "./B06_Section_Section_Store";
import { buildGroup, L } from "./B06_Section_UI_Page_Common";
import "@util/common_util_mass_haul.css";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
// B05 사이드패널 접기 컨테이너 템플릿 재사용(N-4-1): 제목 행 클릭 토글, 우측 ▾/▸ 캐럿.
function buildGroup(legend: string, collapsed = false): HTMLElement {
const group = document.createElement("section");
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
group.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
const legendElement = document.createElement("h3");
legendElement.className = "b06-profile__group-legend ui-collapsible__title";
legendElement.textContent = legend;
group.append(legendElement);
return group;
}
export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
let currentRouteId: number | null = null;
+15
View File
@@ -0,0 +1,15 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
export function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export function buildGroup(legend: string, collapsed = false): HTMLElement {
const group = document.createElement("section");
group.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
const title = document.createElement("h3");
title.className = "b06-profile__group-legend ui-collapsible__title";
title.textContent = legend;
group.append(title);
return group;
}
+13 -71
View File
@@ -39,8 +39,6 @@ import {
applyLegendToggle,
computeMassHaulSeries,
MASS_HAUL_BALANCE_KEY,
MASS_HAUL_DEFAULT_VISIBLE,
normalizeVisibleBasis,
type MassHaulSeries,
} from "@util/common_util_mass_haul";
import { computeHaulPlan } from "@util/common_util_mass_haul_balance";
@@ -52,9 +50,21 @@ import {
createMassHaulChart,
createMassHaulLegend,
createMassHaulSummary,
MASS_HAUL_HEIGHT,
MASS_HAUL_MIN_HEIGHT,
} from "@util/common_util_mass_haul_view";
import {
BASE_PANEL_HEIGHT,
chartHeights,
MASS_HAUL_VISIBLE_KEY,
MAX_PANEL_HEIGHT_RATIO,
MIN_LONG_HEIGHT,
MIN_PANEL_HEIGHT,
PANEL_CHROME_PX,
PANEL_COLLAPSED_KEY,
PANEL_HEIGHT_KEY,
readVisibleSeries,
unwrapChart,
} from "./B06_Section_UI_Section_View_Panel";
import {
calculateYScale,
CROSS_GRID_GAP,
@@ -66,7 +76,6 @@ import {
emptyView,
inferStationInterval,
L,
LONG_HEIGHT,
longitudinalMaxChainage,
LONG_PAD,
type YScaleOptions,
@@ -81,73 +90,6 @@ export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl };
* ).
* , .
*/
const PANEL_CHROME_PX = 102;
/** 손대지 않았을 때의 패널 높이 — 이 값이 축소 비례의 기준(H₀)이 된다. */
const BASE_PANEL_HEIGHT = LONG_HEIGHT + MASS_HAUL_HEIGHT + PANEL_CHROME_PX;
const MIN_LONG_HEIGHT = 110;
const MIN_PANEL_HEIGHT = MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT + PANEL_CHROME_PX;
const MAX_PANEL_HEIGHT_RATIO = 0.8;
const PANEL_HEIGHT_KEY = "b06:profile-panel-height";
const PANEL_COLLAPSED_KEY = "b06:profile-panel-collapsed";
// 곡선 키·기본값이 바뀔 때마다 저장 키의 판을 올려 갈아탄다(옛 세션값을 읽으면 의도와 다른
// 곡선이 켜진다). v2: 키가 `${기준}_${환산}` → `${기준}`. v3: 기본 표시가 횡단 기준 하나로.
// v4: 토량 분배 레이어(`balance`)가 범례에 합류.
const MASS_HAUL_VISIBLE_KEY = "b06:masshaul-visible-v4";
/** 곡선 기준 + 토량 분배 레이어. 저장값이 없을 때 켜 두는 항목. */
const DEFAULT_VISIBLE_KEYS: string[] = [...MASS_HAUL_DEFAULT_VISIBLE, MASS_HAUL_BALANCE_KEY];
/** 켜 둔 곡선 목록은 세션에만 남긴다(패널 높이·접힘과 같은 규칙). */
function readVisibleSeries(): Set<string> {
try {
const raw = sessionStorage.getItem(MASS_HAUL_VISIBLE_KEY);
if (!raw) return new Set(DEFAULT_VISIBLE_KEYS);
const parsed: unknown = JSON.parse(raw);
// 기준 키는 라디오라 항상 하나로 눌러 맞춘다(분배 레이어 켜짐 여부는 저장값 그대로).
return Array.isArray(parsed)
? normalizeVisibleBasis(new Set(parsed.map(String)))
: new Set(DEFAULT_VISIBLE_KEYS);
} catch {
return new Set(DEFAULT_VISIBLE_KEYS);
}
}
/**
* (2026-08-02 ).
* .
* .
*/
function chartHeights(availableHeightPx: number): { long: number; mass: number } {
if (!Number.isFinite(availableHeightPx) || availableHeightPx <= 0) {
return { long: LONG_HEIGHT, mass: MASS_HAUL_HEIGHT };
}
// 두 그래프가 나눠 가질 실제 몫. **합이 이 값과 정확히 같아야** 한다 — 넘으면 세로 스크롤이,
// 모자라면 유토곡선 아래에 빈 공간이 남는다(2026-08-02 사용자 지적).
const available = Math.max(availableHeightPx, MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT);
// 기본 높이 이상이면 종단도는 고정, 늘어난 몫은 유토곡선이 전부 흡수한다.
if (available >= LONG_HEIGHT + MASS_HAUL_HEIGHT) {
return { long: LONG_HEIGHT, mass: available - LONG_HEIGHT };
}
const ratio = available / (LONG_HEIGHT + MASS_HAUL_HEIGHT);
// 유토곡선 몫을 먼저 잡되 종단도 하한을 남겨 둔다 — 그래야 두 하한이 동시에 걸려도 합이 넘지 않는다.
const mass = Math.min(
Math.max(MASS_HAUL_MIN_HEIGHT, Math.round(MASS_HAUL_HEIGHT * ratio)),
available - MIN_LONG_HEIGHT,
);
return { long: available - mass, mass };
}
/**
* `chart-wrap` SVG만 .
* ** ** X축
* ( wrap을 ).
*/
function unwrapChart(node: HTMLElement): Element {
return node.classList.contains("b06-section__chart-wrap") && node.firstElementChild
? node.firstElementChild
: node;
}
export interface SectionViewController {
root: HTMLElement;
render: (
@@ -0,0 +1,53 @@
import {
MASS_HAUL_BALANCE_KEY,
MASS_HAUL_DEFAULT_VISIBLE,
normalizeVisibleBasis,
} from "@util/common_util_mass_haul";
import { MASS_HAUL_HEIGHT, MASS_HAUL_MIN_HEIGHT } from "@util/common_util_mass_haul_view";
import { LONG_HEIGHT } from "./B06_Section_UI_Section_Common";
export const PANEL_CHROME_PX = 102;
export const BASE_PANEL_HEIGHT = LONG_HEIGHT + MASS_HAUL_HEIGHT + PANEL_CHROME_PX;
export const MIN_LONG_HEIGHT = 110;
export const MIN_PANEL_HEIGHT = MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT + PANEL_CHROME_PX;
export const MAX_PANEL_HEIGHT_RATIO = 0.8;
export const PANEL_HEIGHT_KEY = "b06:profile-panel-height";
export const PANEL_COLLAPSED_KEY = "b06:profile-panel-collapsed";
export const MASS_HAUL_VISIBLE_KEY = "b06:masshaul-visible-v4";
const DEFAULT_VISIBLE_KEYS = [...MASS_HAUL_DEFAULT_VISIBLE, MASS_HAUL_BALANCE_KEY];
export function readVisibleSeries(): Set<string> {
try {
const raw = sessionStorage.getItem(MASS_HAUL_VISIBLE_KEY);
if (!raw) return new Set(DEFAULT_VISIBLE_KEYS);
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed)
? normalizeVisibleBasis(new Set(parsed.map(String)))
: new Set(DEFAULT_VISIBLE_KEYS);
} catch {
return new Set(DEFAULT_VISIBLE_KEYS);
}
}
export function chartHeights(availableHeightPx: number): { long: number; mass: number } {
if (!Number.isFinite(availableHeightPx) || availableHeightPx <= 0) {
return { long: LONG_HEIGHT, mass: MASS_HAUL_HEIGHT };
}
const available = Math.max(availableHeightPx, MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT);
if (available >= LONG_HEIGHT + MASS_HAUL_HEIGHT) {
return { long: LONG_HEIGHT, mass: available - LONG_HEIGHT };
}
const ratio = available / (LONG_HEIGHT + MASS_HAUL_HEIGHT);
const mass = Math.min(
Math.max(MASS_HAUL_MIN_HEIGHT, Math.round(MASS_HAUL_HEIGHT * ratio)),
available - MIN_LONG_HEIGHT,
);
return { long: available - mass, mass };
}
export function unwrapChart(node: HTMLElement): Element {
return node.classList.contains("b06-section__chart-wrap") && node.firstElementChild
? node.firstElementChild
: node;
}
-204
View File
@@ -562,207 +562,3 @@
gap: 2px;
margin-left: auto;
}
/* 구조물 위치 조정 오버레이 (2026-08-21 사용자 확정) **횡단도 ** 뜬다.
버튼이 우측 상단, 면적표가 중상단이라 창은 좌측 하단에 둔다. */
.b06-structure-panel {
position: absolute;
bottom: var(--spacing-8);
left: var(--spacing-8);
z-index: 3;
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--spacing-8);
border: 1px solid var(--color-danger);
border-radius: var(--radius-inputs);
background: color-mix(in srgb, var(--color-surface-raised) 94%, transparent);
}
.b06-structure-panel.is-hidden {
display: none;
}
.b06-structure-panel__head {
display: flex;
align-items: center;
gap: var(--spacing-8);
justify-content: space-between;
}
.b06-structure-panel__title {
color: var(--color-danger);
font-size: var(--text-caption);
font-weight: var(--font-weight-medium);
}
.b06-structure-panel__value span.is-hidden {
display: none;
}
.b06-structure-panel__value {
display: flex;
flex-direction: column;
gap: 1px;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
/* 이동 조작 = 십자(D-pad) 배치(2026-08-22 사용자) 중앙이 초기화.
__controls(flex)보다 뒤에 선언된 것과 무관하게 이기도록 복합 선택자. */
.b06-structure-panel__controls.b06-structure-panel__buttons {
display: grid;
grid-template-areas:
". up ."
"left reset right"
". down equal";
/* 십자 그룹은 패널 폭 기준 수평 가운데(2026-08-22 사용자) — 버튼 크기는 유지. */
justify-content: center;
gap: 2px;
}
.b06-structure-panel__btn--up {
grid-area: up;
}
.b06-structure-panel__btn--down {
grid-area: down;
}
.b06-structure-panel__btn--left {
grid-area: left;
}
.b06-structure-panel__btn--right {
grid-area: right;
}
.b06-structure-panel__btn--reset {
grid-area: reset;
}
.b06-structure-panel__btn--equal {
grid-area: equal;
}
.b06-structure-panel__btn.is-hidden {
display: none;
}
/* 조작 버튼은 정사각 고정 높이· ·십자 이동의 행열이 맞는다
(2026-08-22 사용자 버튼 행열 정렬). */
.b06-structure-panel__controls .b06-structure-panel__btn {
box-sizing: border-box;
width: 26px;
height: 22px;
padding: 0;
text-align: center;
line-height: 1;
}
.b06-structure-panel__hval {
box-sizing: border-box;
width: 3.5em;
text-align: center;
color: var(--color-text-body);
font-size: var(--text-caption);
align-self: center;
}
.b06-structure-panel__buttons.is-hidden {
display: none;
}
/* 유입 구조물 형식 드롭다운(2026-08-22 사용자) — 라벨 + select 한 줄. */
/* 항목 행 = 2행 구조(2026-08-22 사용자): 1행 이름 라벨, 2행 값 조작. */
.b06-structure-panel__struct {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 2px;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b06-structure-panel__label {
color: var(--color-text-muted);
font-size: var(--text-caption);
line-height: 1.2;
}
.b06-structure-panel__controls {
display: flex;
align-items: center;
gap: 2px;
}
.b06-structure-panel__struct.is-hidden {
display: none;
}
.b06-structure-panel__select {
flex: 1;
padding: 1px var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--text-caption);
}
.b06-structure-panel__btn {
padding: 0 var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-surface);
color: var(--color-text-secondary);
cursor: pointer;
font-size: var(--text-caption);
line-height: 1.6;
}
.b06-structure-panel__btn:hover {
border-color: var(--color-danger);
color: var(--color-danger);
}
.b06-structure-panel__close {
padding: 0 4px;
border-color: transparent;
background: transparent;
}
/* 줌 버튼 — 그래프 우측 상단 오버레이. 휠 대신 쓰는 조작구다(2026-08-02 사용자 확정). */
.b06-cross-card__zoom {
position: absolute;
top: var(--spacing-4);
right: var(--spacing-4);
z-index: 2;
display: flex;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent);
}
.b06-cross-card__zoom-btn {
width: 22px;
height: 22px;
padding: 0;
border: none;
border-left: 1px solid var(--color-border);
background: none;
color: var(--color-text-secondary);
font-size: 0.8rem;
line-height: 1;
cursor: pointer;
}
.b06-cross-card__zoom-btn:first-child {
border-left: none;
}
.b06-cross-card__zoom-btn:hover {
color: var(--color-text);
background: var(--color-surface);
}
@@ -0,0 +1,174 @@
/* 구조물 위치 조정 오버레이 창. */
.b06-structure-panel {
position: absolute;
bottom: var(--spacing-8);
left: var(--spacing-8);
z-index: 3;
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--spacing-8);
border: 1px solid var(--color-danger);
border-radius: var(--radius-inputs);
background: color-mix(in srgb, var(--color-surface-raised) 94%, transparent);
}
.b06-structure-panel.is-hidden,
.b06-structure-panel__value span.is-hidden,
.b06-structure-panel__btn.is-hidden,
.b06-structure-panel__buttons.is-hidden,
.b06-structure-panel__struct.is-hidden {
display: none;
}
.b06-structure-panel__head {
display: flex;
align-items: center;
gap: var(--spacing-8);
justify-content: space-between;
}
.b06-structure-panel__title {
color: var(--color-danger);
font-size: var(--text-caption);
font-weight: var(--font-weight-medium);
}
.b06-structure-panel__value {
display: flex;
flex-direction: column;
gap: 1px;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b06-structure-panel__controls.b06-structure-panel__buttons {
display: grid;
grid-template-areas: ". up ." "left reset right" ". down equal";
justify-content: center;
gap: 2px;
}
.b06-structure-panel__btn--up {
grid-area: up;
}
.b06-structure-panel__btn--down {
grid-area: down;
}
.b06-structure-panel__btn--left {
grid-area: left;
}
.b06-structure-panel__btn--right {
grid-area: right;
}
.b06-structure-panel__btn--reset {
grid-area: reset;
}
.b06-structure-panel__btn--equal {
grid-area: equal;
}
.b06-structure-panel__controls .b06-structure-panel__btn {
box-sizing: border-box;
width: 26px;
height: 22px;
padding: 0;
text-align: center;
line-height: 1;
}
.b06-structure-panel__hval {
box-sizing: border-box;
width: 3.5em;
text-align: center;
color: var(--color-text-body);
font-size: var(--text-caption);
align-self: center;
}
.b06-structure-panel__struct {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 2px;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b06-structure-panel__label {
color: var(--color-text-muted);
font-size: var(--text-caption);
line-height: 1.2;
}
.b06-structure-panel__controls {
display: flex;
align-items: center;
gap: 2px;
}
.b06-structure-panel__select {
flex: 1;
padding: 1px var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--text-caption);
}
.b06-structure-panel__btn {
padding: 0 var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-surface);
color: var(--color-text-secondary);
cursor: pointer;
font-size: var(--text-caption);
line-height: 1.6;
}
.b06-structure-panel__btn:hover {
border-color: var(--color-danger);
color: var(--color-danger);
}
.b06-structure-panel__close {
padding: 0 4px;
border-color: transparent;
background: transparent;
}
.b06-cross-card__zoom {
position: absolute;
top: var(--spacing-4);
right: var(--spacing-4);
z-index: 2;
display: flex;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent);
}
.b06-cross-card__zoom-btn {
width: 22px;
height: 22px;
padding: 0;
border: none;
border-left: 1px solid var(--color-border);
background: none;
color: var(--color-text-secondary);
font-size: 0.8rem;
line-height: 1;
cursor: pointer;
}
.b06-cross-card__zoom-btn:first-child {
border-left: none;
}
.b06-cross-card__zoom-btn:hover {
color: var(--color-text);
background: var(--color-surface);
}