diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts index 2a3aacbf..92d6b979 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts @@ -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 = - ''; - const gradeClass = document.createElement("select"); - gradeClass.innerHTML = - ''; - 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 = - ''; - 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. diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index a1ff6d4e..b9cd8f16 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -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 { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index 9648419b..1f6c7fac 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -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 { + return requestJson( + `/projects/${projectId}/sections/company-standards`, + { method: "GET" }, + ); +} + +/** 특정 프로젝트의 표준횡단 설계값을 미리보기용으로 조회한다(적용 전, 현재 값 불변). */ +export async function getCompanyStandard( + projectId: string, + sourceProjectId: string, +): Promise { + return requestJson( + `/projects/${projectId}/sections/company-standards/${sourceProjectId}`, + { method: "GET" }, + ); +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py index eb3c5888..2a9b50d1 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py @@ -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 diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py index 7163aebe..522a4e6a 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py @@ -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) diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index 1cd17df3..cc258675 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -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( diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py index 876b0fbf..49506624 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py @@ -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 diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts index 17c6c352..fb5356a9 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts @@ -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( 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)) { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts index d5fe2953..610a794c 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts @@ -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", diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts index 63024693..c3dbacf8 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts @@ -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); } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index a3470c6b..d0bb6430 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -123,17 +123,19 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { 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 { } } + /** 현재 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 { 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); }, }; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Diagram.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Diagram.ts new file mode 100644 index 00000000..e6f6f2b7 --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Diagram.ts @@ -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, 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; +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Panel.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Panel.ts index b4c60083..6b01a57a 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Panel.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Standard_Panel.ts @@ -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 { + 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; +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css index 463b3b2d..d42c1dac 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css @@ -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); diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index bb4c1824..3e0dfc1a 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -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"],