260725_1
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user