This commit is contained in:
2026-07-25 10:01:55 +09:00
parent 42de855ab3
commit 5d6c6aa1be
15 changed files with 845 additions and 62 deletions
+35 -21
View File
@@ -4,7 +4,7 @@ import {
type IrregularStation,
type IrregularStationsSection,
} from "./B05_wf2_Route_UI_IrregularStations";
import { type ButtonVariant, createButton } from "@ui/ui_template_elements";
import { type ButtonVariant, createButton, createSelectField } from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
@@ -218,18 +218,24 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
const conditions = section("임도 기준·옵션");
conditions.root.classList.add("is-collapsed");
const algorithm = document.createElement("select");
algorithm.innerHTML =
'<option value="dijkstra">Dijkstra</option><option value="ridge_valley">능선·계곡</option>';
const gradeClass = document.createElement("select");
gradeClass.innerHTML =
'<option value="trunk">간선</option><option value="branch">지선</option><option value="work">작업</option>';
const algorithmLabel = document.createElement("label");
algorithmLabel.className = "b05-route__field";
algorithmLabel.append(document.createTextNode("알고리즘"), algorithm);
const gradeLabel = document.createElement("label");
gradeLabel.className = "b05-route__field";
gradeLabel.append(document.createTextNode("임도 등급"), gradeClass);
// 드롭다운은 공통 컴포넌트(createSelectField) 재사용. `.select`로 받아 이하 로직 불변.
const algorithmField = createSelectField({
label: "알고리즘",
options: [
{ value: "dijkstra", text: "Dijkstra" },
{ value: "ridge_valley", text: "능선·계곡" },
],
});
const algorithm = algorithmField.select;
const gradeField = createSelectField({
label: "임도 등급",
options: [
{ value: "trunk", text: "간선" },
{ value: "branch", text: "지선" },
{ value: "work", text: "작업" },
],
});
const gradeClass = gradeField.select;
const minCurveRadius = numberField("최소 곡선반경 (m)");
const maxUphillGrade = numberField("오르막 경사 상한 (%)");
const maxDownhillGrade = numberField("내리막 경사 상한 (%)");
@@ -248,7 +254,13 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
minUphillGrade.wrapper,
minDownhillGrade.wrapper,
);
conditions.body.append(algorithmLabel, gradeLabel, paved.wrapper, avoidPass.wrapper, details);
conditions.body.append(
algorithmField.root,
gradeField.root,
paved.wrapper,
avoidPass.wrapper,
details,
);
const sectionOptions = section(L("B05_Route_Group_SectionOptions"));
sectionOptions.root.classList.add("is-collapsed");
@@ -262,12 +274,14 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
);
const gradeLine = section("종단 설계 기준");
const terrainType = document.createElement("select");
terrainType.innerHTML =
'<option value="normal">일반지형</option><option value="special">특수지형</option>';
const terrainLabel = document.createElement("label");
terrainLabel.className = "b05-route__field";
terrainLabel.append(document.createTextNode("지형 구분"), terrainType);
const terrainField = createSelectField({
label: "지형 구분",
options: [
{ value: "normal", text: "일반지형" },
{ value: "special", text: "특수지형" },
],
});
const terrainType = terrainField.select;
// 역기울기(5%) 상한 방향은 서버가 지반 형상에서 자동 판정(main_direction="auto")하므로
// 수동 선택 UI는 두지 않는다. 노선 균형 구역 길이도 자동 산출 기본값(전체 1구역)에 맡긴다.
const maxGradePct = numberField("최대 종단기울기 (%)");
@@ -288,7 +302,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
startElevationOffset.wrapper,
endElevationOffset.wrapper,
);
gradeLine.body.append(terrainLabel, criteriaNote, gradeAdvanced);
gradeLine.body.append(terrainField.root, criteriaNote, gradeAdvanced);
// 공사 시작점 — 이전 공사에 이어 시공할 때 0측점을 임의 측점/누가거리로 시작 표기한다.
// (내부 chainage는 0기준 유지, 측점 라벨·누가거리 "표시"만 이 값만큼 이동.) 기본값 0/0.
+1 -4
View File
@@ -708,10 +708,7 @@
color: rgb(37 99 235);
}
.b05-profile-table__irregular-col-cell.is-plan {
color: var(--color-royal-amethyst, rgb(109 40 217));
font-weight: var(--font-weight-medium);
}
/* 계획고(is-plan)는 오버레이에서 별도 강조 없이 다른 값과 동일한 스타일을 쓴다(작업 C-2). */
/* 값 열의 계획고 직접 입력 셀 — 열은 pointer-events:none이라 입력만 되살린다. */
.b05-profile-table__irregular-input {
@@ -192,6 +192,10 @@ export interface CrossDesign {
/** 측구 형식(일반/L형). 양성(측구 없음)은 null. */
ditch_type: DitchType | null;
cut_slope_ratio: number;
/** 2단계 절토의 토사(상단) 경사비. 암 지반에서만 의미. */
soil_cut_slope_ratio?: number;
/** 암반 경계 기준 2단계 경사 적용 여부(엔진이 실제 적용했는지). */
two_stage_slope?: boolean;
fill_slope_ratio: number;
roadbed_width_m: number;
carriageway_width_m: number;
@@ -234,6 +238,10 @@ export interface CrossDesignRequest {
ditch_type?: DitchType;
/** 포장 중첩 여부 — 횡단경사·포장층만 포장 그룹 값으로 계산된다. */
paved?: boolean;
/** 암 경계선 오프셋(m, 지면선 기준 하향 음수). 암 지반 2단계 절토 무릎 계산용. */
rock_boundary_offset_m?: number | null;
/** 암 지반 2단계 경사 적용 여부(기본 true, 토글로 해제). */
two_stage_slope?: boolean;
/** 설정 패널 편집값. 요청값 → config 기본값 순으로 우선한다. */
standard_cross_section?: StandardCrossSection;
}
@@ -334,3 +342,41 @@ export async function confirmSections(
body: JSON.stringify(body),
});
}
/** 같은 회사에서 표준횡단 설계값을 불러올 수 있는 프로젝트 항목. */
export interface CompanyStandardProject {
project_id: string;
name: string;
}
export interface CompanyStandardListResponse {
status: string;
projects: CompanyStandardProject[];
}
export interface CompanyStandardResponse {
status: string;
project_id: string;
standard_cross_section: StandardCrossSection;
}
/** 같은 회사에서 설계값을 불러올 수 있는 프로젝트 목록을 조회한다(회사 스코프). */
export async function listCompanyStandards(
projectId: string,
): Promise<CompanyStandardListResponse> {
return requestJson<CompanyStandardListResponse>(
`/projects/${projectId}/sections/company-standards`,
{ method: "GET" },
);
}
/** 특정 프로젝트의 표준횡단 설계값을 미리보기용으로 조회한다(적용 전, 현재 값 불변). */
export async function getCompanyStandard(
projectId: string,
sourceProjectId: string,
): Promise<CompanyStandardResponse> {
return requestJson<CompanyStandardResponse>(
`/projects/${projectId}/sections/company-standards/${sourceProjectId}`,
{ method: "GET" },
);
}
@@ -15,6 +15,7 @@
경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2).
"""
from collections.abc import Callable
from typing import Any
from config.config_system import (
@@ -170,15 +171,28 @@ class _SectionGeometry:
ditch_side: str,
ditch_type: str,
cross_slope_pct: float,
ground_at: Callable[[float], float] | None = None,
soil_cut_ratio: float | None = None,
rock_boundary_offset_m: float | None = None,
two_stage_slope: bool = False,
) -> None:
half_road = group["road_width_m"] / 2.0
self.left_extent = half_road + group["shoulder_left_m"] # 좌(+) 노면 끝
self.right_extent = half_road + group["shoulder_right_m"] # 우(-) 노면 끝
self.z_center = design_elevation_m
self.cut_ratio = max(group["cut_slope_ratio"], 1e-6)
self.cut_ratio = max(group["cut_slope_ratio"], 1e-6) # 암 구간(하단) 절토 경사
self.fill_ratio = max(group["fill_slope_ratio"], 1e-6)
self.left_role, self.right_role = _side_role(section_mode)
self.ditch_side = ditch_side
# 2단계 절토: 암반 경계선(지반선 + rock_boundary_offset) 아래는 암 경사(cut_ratio),
# 위는 토사 경사(soil_cut_ratio)를 쓴다. 경계 아래→위 전환점(무릎)을 측별로 미리 구한다.
self.soil_cut_ratio = max(soil_cut_ratio or group["cut_slope_ratio"], 1e-6)
self.two_stage = bool(
two_stage_slope and ground_at is not None and rock_boundary_offset_m is not None
)
self._ground_at = ground_at
self._rock_offset = rock_boundary_offset_m or 0.0
self._rock_knee: dict[str, tuple[float, float] | None] = {}
# 성토만(양성)이면 측구 없음(합의). 절토가 있는 단면만 측구를 판다.
self.has_ditch = section_mode != "both_fill"
self.ditch_type = ditch_type
@@ -186,6 +200,19 @@ class _SectionGeometry:
slope = cross_slope_pct / 100.0
self.slope_per_offset = -slope if ditch_side == "left" else slope
# 편절편성에서 계획고가 지반보다 많이 낮으면 성토측 지반이 노면 끝보다 높아
# 실제로는 양측 절토가 된다. 그 측 역할을 절토로 자동 전환한다(측구 위치는
# ditch_side 그대로 유지, 전환된 측도 2단 경사 대상). 양절·양성(사용자 명시)은
# 손대지 않는다.
if section_mode in ("left_cut", "right_cut") and ground_at is not None:
for side, edge in (("left", self.left_extent), ("right", -self.right_extent)):
role = self.left_role if side == "left" else self.right_role
if role == "fill" and ground_at(edge) > self.road_z(edge) + 1e-3:
if side == "left":
self.left_role = "cut"
else:
self.right_role = "cut"
# 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
self.ditch_points: list[tuple[float, float]] = []
edge_offset = self.left_extent if ditch_side == "left" else -self.right_extent
@@ -228,6 +255,48 @@ class _SectionGeometry:
return abs(outer[0]), outer[1]
return edge_offset, edge_z
def _rock_boundary_z(self, side: str, dist: float) -> float:
"""측·거리(절대 오프셋)에서 암반 경계선 표고 = 지반선 + 오프셋(음수=하향)."""
signed = dist if side == "left" else -dist
assert self._ground_at is not None # two_stage일 때만 호출
return self._ground_at(signed) + self._rock_offset
def knee(self, side: str) -> tuple[float, float] | None:
"""절토 사면이 암반 경계선을 지나는 전환점(무릎 거리, 표고)을 구한다(측별 캐시).
노면 끝(사면 시작)에서 암 경사(cut_ratio)로 올라가며 경계선을 만나면 그 지점부터
토사 경사로 완만해진다. 시작부터 경계 위면 무릎=시작(전부 토사), 끝까지 못 만나면
None(전부 암). 경계선은 지반을 따라 변하므로 세밀 행진으로 교차점을 찾는다.
"""
if not self.two_stage:
return None
if side in self._rock_knee:
return self._rock_knee[side]
start_dist, start_z = self._slope_start(side)
diff_prev = start_z - self._rock_boundary_z(side, start_dist)
result: tuple[float, float] | None
if diff_prev >= 0:
result = (start_dist, start_z) # 시작부터 토사(경계 위)
else:
result = None
step = 0.05
dist_prev = start_dist
dist = start_dist + step
while dist <= start_dist + 200.0:
z_rock = start_z + (dist - start_dist) / self.cut_ratio
diff = z_rock - self._rock_boundary_z(side, dist)
if diff >= 0:
span = diff - diff_prev
ratio = (-diff_prev) / span if abs(span) > 1e-9 else 0.0
knee_dist = dist_prev + (dist - dist_prev) * ratio
knee_z = start_z + (knee_dist - start_dist) / self.cut_ratio
result = (knee_dist, knee_z)
break
dist_prev, diff_prev = dist, diff
dist += step
self._rock_knee[side] = result
return result
def design_z(self, offset_m: float, ground_m: float) -> float:
"""offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종)."""
side = "left" if offset_m >= 0 else "right"
@@ -253,17 +322,32 @@ class _SectionGeometry:
return points[-1][1]
role = self.left_role if side == "left" else self.right_role
start_dist, start_z = self._slope_start(side)
run = abs(offset_m) - start_dist
dist = abs(offset_m)
run = dist - start_dist
if role == "cut":
slope_line = start_z + run / self.cut_ratio
knee = self.knee(side) if self.two_stage else None
if knee is not None:
knee_dist, knee_z = knee
if dist <= knee_dist: # 암반 구간(경계 아래): 암 경사
slope_line = start_z + (dist - start_dist) / self.cut_ratio
else: # 토사 구간(경계 위): 무릎에서 토사 경사로 완만
slope_line = knee_z + (dist - knee_dist) / self.soil_cut_ratio
else:
slope_line = start_z + run / self.cut_ratio
return min(slope_line, ground_m)
fill_line = start_z - run / self.fill_ratio
return max(fill_line, ground_m)
def breakpoints(self) -> list[float]:
"""적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록."""
"""적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎 포함)."""
points = [0.0, self.left_extent, -self.right_extent]
points.extend(offset for offset, _z in self.ditch_points)
if self.two_stage:
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
knee = self.knee(side) if role == "cut" else None
if knee is not None:
points.append(knee[0] if side == "left" else -knee[0])
return points
@@ -277,6 +361,8 @@ def compute_cross_design(
ditch_type: str = "standard",
paved: bool = False,
standard: dict[str, Any] | None = None,
rock_boundary_offset_m: float | None = None,
two_stage_slope: bool = True,
) -> dict[str, Any]:
"""측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다.
@@ -285,6 +371,8 @@ def compute_cross_design(
ditch_type: 일반(standard)/L형(l_type, 암 구간 전용).
paved: 포장 중첩 여부 — 횡단경사·포장층만 포장 그룹 값으로 바꾼다(기하는 지반유형).
standard: B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 순.
rock_boundary_offset_m: 암반 경계선 오프셋(지반선 기준, 음수=하향). 암 지반 2단계 절토용.
two_stage_slope: 암 지반에서 암반 경계 기준 2단계 경사 적용 여부(기본 True, 토글로 해제).
"""
if ground_type not in SECTION_GROUND_TYPE_PRESET:
raise ValueError(f"지원하지 않는 지반유형입니다: {ground_type}")
@@ -317,6 +405,12 @@ def compute_cross_design(
if len(valid) < 2:
raise ValueError("유효한 지반 샘플이 부족해 횡단 설계를 계산할 수 없습니다.")
ground_at = _ground_interpolator(valid)
# 2단계 절토는 암 프리셋에서만, 암반 경계 오프셋이 있을 때만 켠다.
enable_two_stage = (
preset_key == "rock" and two_stage_slope and rock_boundary_offset_m is not None
)
soil_cut_ratio = _resolve_group("soil", standard)["cut_slope_ratio"]
geometry = _SectionGeometry(
design_elevation_m=design_elevation_m,
group=group,
@@ -324,8 +418,11 @@ def compute_cross_design(
ditch_side=resolved_ditch_side,
ditch_type=ditch_type,
cross_slope_pct=cross_slope_pct,
ground_at=ground_at,
soil_cut_ratio=soil_cut_ratio,
rock_boundary_offset_m=rock_boundary_offset_m,
two_stage_slope=enable_two_stage,
)
ground_at = _ground_interpolator(valid)
# 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야
# 측구 모서리·노면 끝이 잘리지 않아 면적과 설계선이 정확해진다.
@@ -378,6 +475,8 @@ def compute_cross_design(
"ditch_side": resolved_ditch_side,
"ditch_type": ditch_type if geometry.has_ditch else None,
"cut_slope_ratio": round(geometry.cut_ratio, 4),
"soil_cut_slope_ratio": round(geometry.soil_cut_ratio, 4),
"two_stage_slope": bool(geometry.two_stage),
"fill_slope_ratio": round(geometry.fill_ratio, 4),
"roadbed_width_m": round(geometry.left_extent + geometry.right_extent, 4),
"carriageway_width_m": round(group["road_width_m"], 4),
@@ -403,6 +502,9 @@ def compute_cross_design(
}
if paved:
result["pavement_thickness_m"] = round(paved_group["pavement_thickness_m"], 4)
# 암 지반은 경계선 오프셋을 echo해 프론트가 세션값 없이도 오버레이·재계산에 쓰게 한다.
if preset_key == "rock" and rock_boundary_offset_m is not None:
result["rock_boundary_offset_m"] = round(float(rock_boundary_offset_m), 4)
return result
@@ -86,6 +86,63 @@ async def get_latest_section_options(
return options if isinstance(options, dict) else None
async def list_recent_company_projects(
connection: aiomysql.Connection, company_id: int, exclude_project_id: UUID
) -> list[dict[str, Any]]:
"""같은 회사의 최근 프로젝트 5개(최근 갱신순, 현재·삭제 프로젝트 제외).
설계값 보유 여부와 무관하게 보여준다 선택 설계값 조회에서 없으면 안내한다.
"""
async with connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""
SELECT p.id AS project_id, p.name AS name
FROM projects p
WHERE p.company_id = %s
AND p.id <> %s
AND p.deleted_at IS NULL
ORDER BY p.updated_at DESC
LIMIT 5
""",
(company_id, str(exclude_project_id)),
)
rows = await cursor.fetchall()
return [{"project_id": row["project_id"], "name": row["name"]} for row in rows]
async def get_project_standard_cross_section(
connection: aiomysql.Connection, company_id: int, project_id: UUID
) -> dict[str, Any] | None:
"""회사 스코프로 특정 프로젝트의 표준횡단 설정값을 반환한다(타 회사 접근 차단).
company_id 조건으로 남의 회사 프로젝트 값은 조회되지 않는다(권한 강제).
"""
async with connection.cursor() as cursor:
await cursor.execute(
"""
SELECT l.data
FROM longitudinal_sections l
JOIN projects p ON p.id = l.project_id
WHERE l.project_id = %s
AND p.company_id = %s
AND p.deleted_at IS NULL
AND JSON_EXTRACT(l.data, '$.options.standard_cross_section') IS NOT NULL
ORDER BY l.id DESC
LIMIT 1
""",
(str(project_id), company_id),
)
row = await cursor.fetchone()
if not row or not row[0]:
return None
data = row[0]
if isinstance(data, str):
data = json.loads(data)
options = data.get("options") if isinstance(data, dict) else None
standard = options.get("standard_cross_section") if isinstance(options, dict) else None
return standard if isinstance(standard, dict) else None
async def get_latest_grade_options(
connection: aiomysql.Connection, project_id: UUID
) -> dict[str, Any] | None:
@@ -274,11 +331,14 @@ async def update_cross_section_design(
route_id: int,
chainage_m: float,
design: dict[str, Any],
project_id: UUID | None = None,
) -> bool:
"""측점 하나의 data.design(잠정 설계 지정·단면적)을 병합 저장한다.
기존 data 요약을 보존하고 design 키만 갱신한다. 대상 측점을 chainage 근사로
찾으며(부동소수 오차 허용), 갱신 여부를 반환한다.
구조물(비정규) 측점은 B05 확정이 파일만 쓰고 DB 행을 만들지 않으므로, 행이 없고
project_id가 오면 행을 삽입한다(upsert 구조물 측점 설계 저장 보장).
"""
async with connection.cursor() as cursor:
await cursor.execute(
@@ -292,7 +352,21 @@ async def update_cross_section_design(
)
row = await cursor.fetchone()
if not row:
return False
if project_id is None:
return False
await cursor.execute(
"""
INSERT INTO cross_sections (project_id, route_id, chainage_m, data, status)
VALUES (%s, %s, %s, %s, 'DRAFT')
""",
(
str(project_id),
route_id,
chainage_m,
json.dumps({"design": design}, ensure_ascii=False),
),
)
return True
data = row[1]
if isinstance(data, str):
data = json.loads(data)
@@ -8,7 +8,7 @@ from typing import Any
from uuid import UUID
import aiomysql
from fastapi import APIRouter, Body
from fastapi import APIRouter, Body, Depends
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
@@ -32,13 +32,18 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
get_cross_sections_missing_design_chainages,
get_latest_section_options,
get_longitudinal_section,
get_project_standard_cross_section,
get_route_generation_source,
insert_cross_sections,
list_recent_company_projects,
merge_cross_section_design_patch,
merge_longitudinal_section_options,
update_cross_section_design,
)
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import (
CompanyStandardListResponse,
CompanyStandardProject,
CompanyStandardResponse,
CrossDesignRequest,
CrossDesignResponse,
SectionConfirmRequest,
@@ -49,6 +54,7 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import (
SectionRegenerateRequest,
SectionSummaryResponse,
)
from common_util.common_util_auth import verify_session
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_workflow_state import complete_stage, get_workflow_state
@@ -107,6 +113,57 @@ async def get_forest_road_min_widths(project_id: UUID) -> dict[str, dict[str, fl
return {"forest_road_min_width_m": FOREST_ROAD_MIN_WIDTH_M}
# 아래 두 엔드포인트는 `/sections/{route_id}`(int)보다 먼저 선언해 라우팅 충돌을 막는다.
@router.get(
"/{project_id}/sections/company-standards", response_model=CompanyStandardListResponse
)
async def list_company_standards(
project_id: UUID, session: dict[str, Any] = Depends(verify_session)
) -> CompanyStandardListResponse:
"""같은 회사의 최근 프로젝트 5개를 반환한다(설계값 보유 여부 무관)."""
company_id = session.get("company_id")
if company_id is None:
return CompanyStandardListResponse(projects=[])
pool = get_db_pool()
async with pool.acquire() as connection:
rows = await list_recent_company_projects(connection, company_id, project_id)
return CompanyStandardListResponse(
projects=[
CompanyStandardProject(project_id=str(row["project_id"]), name=row["name"])
for row in rows
]
)
@router.get(
"/{project_id}/sections/company-standards/{source_project_id}",
response_model=CompanyStandardResponse,
)
async def get_company_standard(
project_id: UUID,
source_project_id: UUID,
session: dict[str, Any] = Depends(verify_session),
) -> CompanyStandardResponse | JSONResponse:
"""특정 프로젝트의 표준횡단 설정값을 미리보기용으로 반환한다(회사 스코프 강제)."""
company_id = session.get("company_id")
if company_id is None:
return JSONResponse(
status_code=404, content={"status": "error", "message": "설계값을 찾을 수 없습니다."}
)
pool = get_db_pool()
async with pool.acquire() as connection:
standard = await get_project_standard_cross_section(
connection, company_id, source_project_id
)
if standard is None:
return JSONResponse(
status_code=404, content={"status": "error", "message": "설계값을 찾을 수 없습니다."}
)
return CompanyStandardResponse(
project_id=str(source_project_id), standard_cross_section=standard
)
@router.get("/{project_id}/sections/{route_id}", response_model=SectionSummaryResponse)
async def get_sections(project_id: UUID, route_id: int) -> SectionSummaryResponse | JSONResponse:
"""경로의 종단면 요약을 조회한다."""
@@ -475,6 +532,8 @@ async def compute_cross_section_design(
ditch_type=request.ditch_type,
paved=request.paved,
standard=request.standard_cross_section,
rock_boundary_offset_m=request.rock_boundary_offset_m,
two_stage_slope=request.two_stage_slope,
)
# B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다.
design["status"] = "provisional"
@@ -488,6 +547,7 @@ async def compute_cross_section_design(
route_id=route_id,
chainage_m=request.chainage_m,
design=design,
project_id=project_id,
)
await connection.commit()
except Exception:
@@ -553,7 +613,11 @@ async def confirm_sections(
try:
for chainage_m, design in default_designs:
await update_cross_section_design(
connection, route_id=route_id, chainage_m=chainage_m, design=design
connection,
route_id=route_id,
chainage_m=chainage_m,
design=design,
project_id=project_id,
)
if request and request.standard_cross_section:
await merge_longitudinal_section_options(
@@ -11,6 +11,28 @@ class SectionRegenerateRequest(BaseModel):
cross_half_width_m: float = Field(..., gt=0)
class CompanyStandardProject(BaseModel):
"""같은 회사의 표준횡단 설정값 보유 프로젝트 항목(조회용)."""
project_id: str
name: str
class CompanyStandardListResponse(BaseModel):
"""같은 회사에서 설계값을 불러올 수 있는 프로젝트 목록."""
status: str = "success"
projects: list[CompanyStandardProject]
class CompanyStandardResponse(BaseModel):
"""특정 프로젝트의 표준횡단 설정값(미리보기·적용용, 회사 스코프)."""
status: str = "success"
project_id: str
standard_cross_section: dict[str, Any]
class CrossDesignRequest(BaseModel):
"""측점 하나의 표준횡단 설계(지반유형·단면유형) 지정 요청.
@@ -26,6 +48,10 @@ class CrossDesignRequest(BaseModel):
ditch_type: Literal["standard", "l_type"] = "standard"
# 포장 중첩 여부 — 횡단경사·포장층만 포장 그룹 값으로 계산한다.
paved: bool = False
# 암 경계선 오프셋(m, 지면선 기준 하향 음수). 암 지반 2단계 절토 무릎 계산에 쓴다.
rock_boundary_offset_m: float | None = None
# 암 지반 2단계 경사(암반 경계 아래=암 경사, 위=토사 경사) 적용 여부. 토글로 해제 가능.
two_stage_slope: bool = True
# B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 기본값 순.
standard_cross_section: dict[str, Any] | None = None
@@ -56,6 +56,8 @@ export interface CrossDesignChange {
ditch_side: DitchSide | null;
ditch_type: DitchType;
paved: boolean;
/** 암 지반 2단계 경사(암반 경계 아래=암, 위=토사) 적용 여부. 기본 true, 토글로 해제. */
two_stage_slope: boolean;
}
/**
@@ -102,6 +104,32 @@ function segment<T extends string>(
return wrap;
}
/** 온/오프 토글 하나(포장·2단계 경사 공용). 세그먼트와 같은 컨테이너/버튼 스타일을 쓴다. */
function toggle(
legend: string,
on: boolean,
onLabel: string,
offLabel: string,
onToggle: () => void,
): { wrap: HTMLElement; button: HTMLButtonElement } {
const wrap = document.createElement("div");
wrap.className = "b06-design__seg";
const legendEl = document.createElement("span");
legendEl.className = "b06-design__seg-legend";
legendEl.textContent = legend;
const buttons = document.createElement("div");
buttons.className = "b06-design__seg-buttons";
const button = document.createElement("button");
button.type = "button";
button.className = `b06-design__btn${on ? " b06-design__btn--active" : ""}`;
button.textContent = on ? onLabel : offLabel;
button.setAttribute("aria-pressed", on ? "true" : "false");
button.addEventListener("click", onToggle);
buttons.append(button);
wrap.append(legendEl, buttons);
return { wrap, button };
}
/** 암 경계선 상/하/리셋 컨트롤(B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용). */
function rockBoundaryRow(section: CrossSection, control: RockBoundaryControl): HTMLElement {
const wrap = document.createElement("div");
@@ -163,12 +191,14 @@ export function buildDesignControls(
ditch: DitchSide | null;
ditchType: DitchType;
paved: boolean;
twoStage: boolean;
} = {
ground: design?.ground_type ?? "soil",
mode: design?.section_mode ?? (section.uphill_side === "right" ? "right_cut" : "left_cut"),
ditch: design?.ditch_side ?? null,
ditchType: design?.ditch_type ?? "standard",
paved: design?.paved ?? false,
twoStage: design?.two_stage_slope ?? true,
};
const bar = document.createElement("div");
bar.className = "b06-design";
@@ -186,6 +216,7 @@ export function buildDesignControls(
ditch_side: needsDitch() ? (state.ditch ?? "left") : null,
ditch_type: state.ditchType,
paved: state.paved,
two_stage_slope: state.twoStage,
});
};
@@ -215,36 +246,40 @@ export function buildDesignControls(
emit();
}),
);
// 2단계 경사 토글: 암 지반 + 절토가 있는 단면(양성 제외)에서만. 기본 활성, 해제 시 단일 암 경사.
const twoStage = toggle(
L("B06_Design_TwoStage_Legend"),
state.twoStage,
L("B06_Design_TwoStage_On"),
L("B06_Design_TwoStage_Off"),
() => {
state.twoStage = !state.twoStage;
emit();
},
);
bar.append(twoStage.wrap);
}
// 포장 토글: 지반유형과 중첩 적용(횡단경사·포장층만 변경).
const pavedWrap = document.createElement("div");
pavedWrap.className = "b06-design__seg";
const pavedLegend = document.createElement("span");
pavedLegend.className = "b06-design__seg-legend";
pavedLegend.textContent = L("B06_Design_Paved_Legend");
const pavedButtons = document.createElement("div");
pavedButtons.className = "b06-design__seg-buttons";
const pavedButton = document.createElement("button");
pavedButton.type = "button";
pavedButton.className = `b06-design__btn${state.paved ? " b06-design__btn--active" : ""}`;
pavedButton.textContent = state.paved ? L("B06_Design_Paved_On") : L("B06_Design_Paved_Off");
pavedButton.setAttribute("aria-pressed", state.paved ? "true" : "false");
pavedButton.addEventListener("click", () => {
state.paved = !state.paved;
emit();
});
pavedButtons.append(pavedButton);
pavedWrap.append(pavedLegend, pavedButtons);
const paved = toggle(
L("B06_Design_Paved_Legend"),
state.paved,
L("B06_Design_Paved_On"),
L("B06_Design_Paved_Off"),
() => {
state.paved = !state.paved;
emit();
},
);
// B05 법정 경사 분석이 포장을 제안한 측점은 근거 문구를 배지·툴팁으로 표기한다.
if (design?.pavement_suggested) {
pavedButton.title = L("B06_Design_Paved_Suggested");
paved.button.title = L("B06_Design_Paved_Suggested");
const badge = document.createElement("span");
badge.className = "b06-design__paved-badge";
badge.textContent = "⚠";
badge.title = L("B06_Design_Paved_Suggested");
pavedWrap.append(badge);
paved.wrap.append(badge);
}
bar.append(pavedWrap);
bar.append(paved.wrap);
// 암 경계선 제어: 암 지반에서만 노출(서버 재계산 없이 세션 보관, 확정 시 DB 병합).
if (rockBoundary && isRock(state.ground)) {
@@ -184,7 +184,9 @@ export function createCrossSectionCard(
const hasDesign = designElevation !== undefined && Number.isFinite(designElevation);
const valid = sourceSamples.filter(validElevation);
const exaggeration = Math.max(verticalExaggeration, 0.1);
const x = (offset: number) => CROSS_PAD.left + (offset - minOffset) * pixelsPerMeter;
// 좌표 규약(+offset=좌, -offset=우)을 표준 횡단면도 관례에 맞춘다: 진행방향을 바라보는
// 시점이라 좌측(+offset)이 화면 왼쪽에 와야 B05 3D 방향 표시와 측구 방향이 일치한다(작업 C-3).
const x = (offset: number) => CROSS_PAD.left + (maxOffset - offset) * pixelsPerMeter;
const y = (elevation: number) => CROSS_PAD.top + (displayMax - elevation) * pixelsPerMeter;
const svg = svgElement("svg", {
class: "b06-section__chart",
@@ -232,6 +232,17 @@ export function createLongitudinalProfile(
class: "b06-chart__station-label",
}),
);
// 구조물(비정규) 측점: 구조물 이름을 측점선 상단에 표기해 위치를 식별할 수 있게 한다.
if (station.structure) {
marker.append(
svgText(station.structure, {
x: stationX,
y: LONG_PAD.top + 12,
"text-anchor": "middle",
class: "b06-chart__structure-label",
}),
);
}
svg.append(marker);
}
@@ -123,17 +123,19 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
ditch_side: change.ditch_side ?? target.design.ditch_side,
ditch_type: change.ditch_type,
paved: change.paved,
two_stage_slope: change.two_stage_slope,
};
sectionView.refreshCard(chainageM);
}
// (2) 서버 계산 — 최신 요청만 반영.
// (2) 서버 계산 — 최신 요청만 반영. 암 경계 오프셋은 세션 우선값을 실어 2단계 무릎을 계산시킨다.
const seq = (designRequestSeq.get(chainageM) ?? 0) + 1;
designRequestSeq.set(chainageM, seq);
try {
const response = await computeCrossDesign(projectId, currentRouteId, {
chainage_m: chainageM,
...change,
rock_boundary_offset_m: rockBoundaryControl.offsetFor(target),
standard_cross_section: standardPanel?.getValues(),
});
if (designRequestSeq.get(chainageM) !== seq) return;
@@ -146,6 +148,29 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
}
}
/** 현재 design 값에서 재계산용 change를 복원한다(암 경계 오프셋 변경 시 재계산 트리거). */
function changeFromDesign(chainageM: number): CrossDesignChange | null {
const target = sectionDetail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
);
const design = target?.design;
if (!design) return null;
return {
ground_type: design.ground_type,
section_mode: design.section_mode,
ditch_side: design.ditch_side ?? null,
ditch_type: design.ditch_type ?? "standard",
paved: design.paved,
two_stage_slope: design.two_stage_slope ?? true,
};
}
/** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 서버 재계산한다. */
function recomputeIfRock(chainageM: number): void {
const change = changeFromDesign(chainageM);
if (change && change.ground_type !== "soil") void handleDesignChange(chainageM, change);
}
/* ()
* (sessionStorage) ,
* cross_patches로 DB(data.design.rock_boundary_offset_m) .
@@ -203,11 +228,13 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
rockOffsets.set(key, Math.round((current + deltaM) * 100) / 100);
persistRockOffsets();
sectionView.refreshCard(chainageM);
recomputeIfRock(chainageM); // 경계 이동 → 2단계 무릎·단면적 재계산
},
reset: (chainageM) => {
rockOffsets.set(rockKey(chainageM), rockBoundaryDefault);
persistRockOffsets();
sectionView.refreshCard(chainageM);
recomputeIfRock(chainageM);
},
};
@@ -0,0 +1,138 @@
/* =============================================================================
* B06_wf3_ProfileCross_UI_Standard_Diagram.ts
* "변수 위치 안내" .
*
* (···/ ·)
* SVG다. ,
* config ( ).
* ========================================================================== */
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
const SVG_NS = "http://www.w3.org/2000/svg";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
function node(tag: string, attrs: Record<string, string | number>, cls?: string): SVGElement {
const el = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
if (cls) el.setAttribute("class", cls);
return el;
}
function text(x: number, y: number, value: string, cls: string, anchor = "middle"): SVGElement {
const t = node("text", { x, y, "text-anchor": anchor }, cls);
t.textContent = value;
return t;
}
/**
* ( · ) . .
* (+offset=) ( ) .
*/
export function buildStandardDiagram(): HTMLElement {
const wrap = document.createElement("details");
wrap.className = "b06-std__diagram";
wrap.open = true;
const summary = document.createElement("summary");
summary.className = "b06-std__diagram-summary";
summary.textContent = L("B06_Std_Diagram_Title");
summary.title = L("B06_Std_Diagram_Toggle");
wrap.append(summary);
// 세로로 긴 형태(300×260) — 사면·측구·라벨 간 여백을 넉넉히 둔다.
const svg = node("svg", {
viewBox: "0 0 300 260",
role: "img",
"aria-label": L("B06_Std_Diagram_Title"),
}) as SVGSVGElement;
svg.setAttribute("class", "b06-std__diagram-svg");
// 지반선(원지반) — 좌측 높고 우측 낮은 사면. 절토/성토의 배경.
svg.append(
node(
"polyline",
{ points: "10,60 95,122 205,138 290,210", fill: "none" },
"b06-diag__ground",
),
);
// 노면(노견 포함): 좌 절토측이 측구 방향으로 살짝 낮게 기운다(횡단경사).
const roadLeft = 95;
const roadRight = 205;
const shoulderL = 108; // 노견 좌 경계
const shoulderR = 192; // 노견 우 경계
const roadYL = 128;
const roadYR = 122;
svg.append(
node(
"polyline",
{ points: `${roadLeft},${roadYL} ${roadRight},${roadYR}`, fill: "none" },
"b06-diag__road",
),
);
// 노견 경계 눈금(좌/우)
svg.append(
node(
"line",
{ x1: shoulderL, y1: roadYL - 5, x2: shoulderL, y2: roadYL + 5 },
"b06-diag__tick",
),
node(
"line",
{ x1: shoulderR, y1: roadYR - 5, x2: shoulderR, y2: roadYR + 5 },
"b06-diag__tick",
),
);
// 중심선(계획고): 노면 중앙 수직 파선.
const centerX = (roadLeft + roadRight) / 2;
svg.append(node("line", { x1: centerX, y1: 38, x2: centerX, y2: 162 }, "b06-diag__center"));
// 측구(절토측=좌): 노면 좌끝에서 아래로 파는 사다리꼴.
svg.append(
node(
"polygon",
{
points: `${roadLeft},${roadYL} ${roadLeft - 6},${roadYL + 16} ${roadLeft - 12},${roadYL + 16} ${roadLeft - 15},${roadYL}`,
},
"b06-diag__ditch",
),
);
// 절토 사면(좌): 측구 바깥에서 원지반까지 상향.
svg.append(node("line", { x1: roadLeft - 15, y1: roadYL, x2: 26, y2: 66 }, "b06-diag__cut"));
// 성토 사면(우): 노면 우끝에서 원지반까지 하향.
svg.append(node("line", { x1: roadRight, y1: roadYR, x2: 278, y2: 210 }, "b06-diag__fill"));
// 횡단경사 라벨·화살표(노면 위, 측구 방향). 노견 좌/우 라벨은 같은 평행선상 좌·우에 둔다.
const labelLineY = 104;
svg.append(
node(
"line",
{ x1: centerX + 14, y1: labelLineY + 8, x2: centerX - 14, y2: labelLineY + 12 },
"b06-diag__slope-arrow",
),
text(centerX, labelLineY, L("B06_Std_Diagram_CrossSlope"), "b06-diag__label"),
text(shoulderL - 6, labelLineY, L("B06_Std_Diagram_ShoulderL"), "b06-diag__label-sm", "end"),
text(shoulderR + 6, labelLineY, L("B06_Std_Diagram_ShoulderR"), "b06-diag__label-sm", "start"),
);
// 나머지 라벨.
svg.append(
text(centerX, 30, L("B06_Std_Diagram_Center"), "b06-diag__label"),
text(centerX, 148, L("B06_Std_Diagram_Road"), "b06-diag__label"),
text(roadLeft - 32, roadYL + 32, L("B06_Std_Diagram_Ditch"), "b06-diag__label-sm", "middle"),
text(46, 54, L("B06_Std_Diagram_Cut"), "b06-diag__label-sm", "middle"),
text(252, 196, L("B06_Std_Diagram_Fill"), "b06-diag__label-sm", "middle"),
);
wrap.append(svg);
const caption = document.createElement("p");
caption.className = "b06-std__diagram-caption";
caption.textContent = L("B06_Std_Diagram_Caption");
wrap.append(caption);
return wrap;
}
@@ -12,11 +12,14 @@
* ========================================================================== */
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createButton, createInputField } from "@ui/ui_template_elements";
import type {
StandardCrossGroup,
StandardCrossKey,
StandardCrossSection,
import { createButton, createInputField, createSelectField } from "@ui/ui_template_elements";
import { buildStandardDiagram } from "./B06_wf3_ProfileCross_UI_Standard_Diagram";
import {
getCompanyStandard,
listCompanyStandards,
type StandardCrossGroup,
type StandardCrossKey,
type StandardCrossSection,
} from "./B06_wf3_ProfileCross_Api_Fetch";
function L(key: keyof typeof ui_locales): string {
@@ -164,6 +167,9 @@ export function createStandardPanel(
const root = document.createElement("div");
root.className = "b06-std";
// 변수 위치 안내 모식도(고정 도형): 각 설정값이 횡단면 어느 위치인지 표시(작업 C-5).
root.append(buildStandardDiagram());
// 그룹 재구성(리셋 시) 편의를 위해 본문 컨테이너를 분리한다.
const body = document.createElement("div");
body.className = "b06-std__body";
@@ -214,6 +220,19 @@ export function createStandardPanel(
};
renderBody();
/** 소스 표준값을 현재 상태에 전부 덮어쓴다(사용자가 "적용"을 눌렀을 때만 호출). */
const applyStandard = (source: StandardCrossSection): void => {
(Object.keys(source) as StandardCrossKey[]).forEach((key) => {
if (source[key]) state[key] = JSON.parse(JSON.stringify(source[key])) as StandardCrossGroup;
});
persist();
renderBody();
};
// 다른 프로젝트에서 설계값 불러오기: 선택·미리보기만으로는 현재 값이 바뀌지 않고,
// "적용" 버튼을 눌러야만 반영된다(작업 C-6).
const loader = buildProjectLoader(projectId, applyStandard);
const resetButton = createButton({
label: L("B06_Std_Reset"),
variant: "ghost",
@@ -230,7 +249,7 @@ export function createStandardPanel(
actions.className = "b06-std__actions";
actions.append(resetButton);
root.append(body, actions);
root.append(body, loader, actions);
return {
root,
@@ -244,3 +263,75 @@ export function createStandardPanel(
},
};
}
/**
* "다른 프로젝트에서 불러오기" . 5 ,
* ( ) .
* (· )
* design으로 .
*/
function buildProjectLoader(
projectId: string,
onApply: (source: StandardCrossSection) => void,
): HTMLElement {
const wrap = document.createElement("details");
wrap.className = "b06-std__loader";
const summary = document.createElement("summary");
summary.className = "b06-std__loader-summary";
summary.textContent = L("B06_Std_Load_Title");
wrap.append(summary);
const status = document.createElement("p");
status.className = "b06-std__loader-status";
status.textContent = L("B06_Std_Load_Loading");
const field = createSelectField({
label: L("B06_Std_Load_Select"),
options: [{ value: "", text: L("B06_Std_Load_Placeholder") }],
onChange: (value) => void onSelect(value),
});
field.root.hidden = true;
async function onSelect(sourceId: string): Promise<void> {
if (!sourceId) {
status.textContent = "";
return;
}
status.textContent = L("B06_Std_Load_Loading");
try {
const response = await getCompanyStandard(projectId, sourceId);
onApply(response.standard_cross_section);
status.textContent = L("B06_Std_Load_Applied");
} catch (error) {
// 404 = 해당 프로젝트에 저장된 설계값 없음(목록은 보유 여부 무관 최근 5개).
const message = error instanceof Error ? error.message : "";
status.textContent = message.includes("찾을 수 없")
? L("B06_Std_Load_None")
: L("B06_Std_Load_Failed");
}
}
// 같은 회사 최근 프로젝트 5개 비동기 로드 → 셀렉트 채우기.
void (async () => {
try {
const response = await listCompanyStandards(projectId);
if (!response.projects.length) {
status.textContent = L("B06_Std_Load_Empty");
return;
}
for (const project of response.projects) {
const option = document.createElement("option");
option.value = project.project_id;
option.textContent = project.name;
field.select.append(option);
}
field.root.hidden = false;
status.textContent = "";
} catch {
status.textContent = L("B06_Std_Load_Failed");
}
})();
wrap.append(status, field.root);
return wrap;
}
@@ -92,6 +92,126 @@
gap: var(--spacing-16);
}
/* ─── 변수 위치 안내 모식도 (작업 C-5) ───────────────────────────── */
.b06-std__diagram {
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background-color: var(--color-surface);
padding: var(--spacing-8);
}
.b06-std__diagram-summary {
cursor: pointer;
font-size: var(--text-caption);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
user-select: none;
}
.b06-std__diagram-svg {
width: 100%;
height: auto;
margin-top: var(--spacing-8);
}
.b06-std__diagram-caption {
margin: var(--spacing-8) 0 0;
font-size: var(--text-caption);
color: var(--color-text-muted);
}
/* ─── 다른 프로젝트에서 불러오기 (작업 C-6) ───────────────────────── */
.b06-std__loader {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background-color: var(--color-surface);
padding: var(--spacing-8);
}
.b06-std__loader-summary {
cursor: pointer;
font-size: var(--text-caption);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
user-select: none;
}
.b06-std__loader-status {
margin: 0;
font-size: var(--text-caption);
color: var(--color-text-muted);
}
.b06-std__loader-preview {
margin: 0;
padding: var(--spacing-8);
border-radius: var(--radius-inputs);
background-color: var(--color-surface-raised);
color: var(--color-text-body);
font-size: var(--text-caption);
white-space: pre-wrap;
overflow-x: auto;
}
.b06-diag__ground {
fill: none;
stroke: var(--color-text-muted);
stroke-width: 1;
stroke-dasharray: 3 2;
}
.b06-diag__road {
fill: none;
stroke: var(--color-text-body);
stroke-width: 2;
}
.b06-diag__center {
stroke: var(--color-royal-amethyst, rgb(109 40 217));
stroke-width: 1;
stroke-dasharray: 4 3;
}
.b06-diag__tick {
stroke: var(--color-text-secondary);
stroke-width: 1;
}
.b06-diag__ditch {
fill: color-mix(in srgb, rgb(37 99 235) 20%, transparent);
stroke: rgb(37 99 235);
stroke-width: 1;
}
.b06-diag__cut {
stroke: rgb(220 38 38);
stroke-width: 1.5;
}
.b06-diag__fill {
stroke: rgb(37 99 235);
stroke-width: 1.5;
}
.b06-diag__slope-arrow {
stroke: var(--color-text-secondary);
stroke-width: 1;
marker-end: none;
}
.b06-diag__label {
fill: var(--color-text-body);
font-size: 9px;
}
.b06-diag__label-sm {
fill: var(--color-text-secondary);
font-size: 8px;
}
.b06-std__group {
display: flex;
flex-direction: column;
@@ -340,6 +460,13 @@
font-size: 10px;
}
/* 구조물(비정규) 측점 이름 — 종단면도 측점선 상단 표기. */
.b06-chart__structure-label {
font-size: 9px;
font-weight: var(--font-weight-medium);
fill: rgb(180 83 9);
}
.b06-chart__axis-label {
fill: var(--color-text-body);
font-size: var(--text-caption);
+29
View File
@@ -819,6 +819,9 @@ export const ui_locales = {
B06_Design_Paved_Legend: ["포장", "Pavement"],
B06_Design_Paved_On: ["포장", "Paved"],
B06_Design_Paved_Off: ["비포장", "Unpaved"],
B06_Design_TwoStage_Legend: ["2단계 경사", "Two-stage slope"],
B06_Design_TwoStage_On: ["적용", "On"],
B06_Design_TwoStage_Off: ["단일 경사", "Single slope"],
B06_Design_Paved_Suggested: [
"종단경사 법정 상한 초과 — 포장 권장 (임도설치 및 관리 등에 관한 규정 별표 1-2)",
"Grade exceeds legal limit — pavement recommended (Forest Road Regulation, Annex 1-2)",
@@ -855,6 +858,32 @@ export const ui_locales = {
"L-type ditch is chosen per cross-section drawing.",
],
B06_Std_Reset: ["기본값 복원", "Restore defaults"],
B06_Std_Load_Title: ["다른 프로젝트에서 불러오기", "Load from another project"],
B06_Std_Load_Select: ["프로젝트 선택", "Select project"],
B06_Std_Load_Placeholder: ["— 프로젝트 선택 —", "— Select a project —"],
B06_Std_Load_Empty: ["같은 회사에 불러올 설계값이 없습니다.", "No saved designs in your company."],
B06_Std_Load_Loading: ["불러오는 중…", "Loading…"],
B06_Std_Load_Apply: ["현재 설정에 적용", "Apply to current settings"],
B06_Std_Load_Applied: ["적용되었습니다.", "Applied."],
B06_Std_Load_Failed: ["설계값을 불러오지 못했습니다.", "Failed to load design values."],
B06_Std_Load_None: [
"선택한 프로젝트에 저장된 설계값이 없습니다.",
"The selected project has no saved design values.",
],
B06_Std_Diagram_Title: ["변수 위치 안내", "Variable position guide"],
B06_Std_Diagram_Toggle: ["단면 그림 보기/숨기기", "Show/hide section guide"],
B06_Std_Diagram_Road: ["노폭", "Road"],
B06_Std_Diagram_ShoulderL: ["노견 좌", "Shoulder L"],
B06_Std_Diagram_ShoulderR: ["노견 우", "Shoulder R"],
B06_Std_Diagram_Ditch: ["측구", "Ditch"],
B06_Std_Diagram_Cut: ["절토경사", "Cut slope"],
B06_Std_Diagram_Fill: ["성토경사", "Fill slope"],
B06_Std_Diagram_CrossSlope: ["횡단경사", "Cross slope"],
B06_Std_Diagram_Center: ["중심선(계획고)", "Centerline (design elev.)"],
B06_Std_Diagram_Caption: [
"표준 편절편성 단면 모식도 — 각 설정값의 위치를 나타냅니다(실제 비율 아님).",
"Standard cut-fill section schematic — shows where each value applies (not to scale).",
],
/* --- B07_wf4_DesignDetail 상세 설계 --- */
B07_Design_Title: ["4차 · 상세 설계", "Step 4 · Detailed Design"],