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:
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user