From 8812657022baba444fff7dfbc1e7c2345f660e47 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 19 Aug 2026 19:46:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05/B02):=20=ED=91=9C=EC=8B=9C=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A0=95=EB=B9=84=20+=20=EC=84=A4=EA=B3=84?= =?UTF-8?q?=EC=86=8D=EB=8F=84=20=EC=B6=95=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 종단 상단 표시줄을 법정 판정 3항목으로 축소: 최대 기울기/상한(별표2 Ⅰ.2.라), 절·성토 불균형(Ⅰ.1.나.(4)(다)), 종단곡선 필요 n곳(Ⅰ.2.마 — 대수차 5% 초과인데 곡선이 빠진 변화점). 변화점·종단곡선 개수·기본 곡선길이 L·중복 경고칩은 제거. - [초기선 복원] → [편집 되돌리기] — 실제 동작은 화면 편집 델타 삭제이지 저장 지점 복귀가 아니다. 툴팁에 동작과 [초기화]와의 차이를 적었다. - 3D 뷰포트 안내 라벨 삭제(로딩 완료 후 남던 조작법 문구). - 배수유역 버튼 정비: 유역 다시 나누기 / 선택한 관 삭제 / 배관 배치 초기화 + 각 툴팁에 실제 동작 명시(선택 삭제는 툴팁 자체가 없었다). - 유토곡선 요약 12칩 → 5칩(절토·성토·잉여|부족·운반·검산). 토질 3종 내역·다짐 환산·블록 수·장거리 운반·사토는 해당 칩 툴팁으로. B05·B06 공용 함수라 동시 반영. - 설계속도 축 도입(임도는 속도를 낼 수 없는 노선 — 기본 20km/h): · 임도 종류는 프로젝트 등록값(projects.road_type)을 읽어 B05가 읽기 전용 표시 (main→간선임도, fire→산불진화임도, 그 외→작업임도). 화면에서 고치지 않는다. · 설계속도 선택(간선·산불진화 20/30/40, 작업 20 고정) 신설 — 종단기울기 상한과 종단곡선 반경이 등급이 아니라 이 값으로 정해진다(지식DB 설계제원_총괄 §6·§7). · B02 임도 종류 선택지를 현행 3종으로 정리(지선 폐지·계류보전은 사방 구분). · 백엔드: ROUTE_GRADE_CLASSES 3종+branch 호환, selectable_design_speeds 신설, resolve_design_speed 신설, legal_grade_limit_pct·resolve_grade_options에 설계속도 인자, sections/context가 road_type 제공(B06도 저장값 승계). · 계획선 정책은 그릴 때마다 현재 기준으로 동기화 — 저장분의 옛 상한이 남지 않는다. - 검증: pytest 125 통과(설계속도 12건 신규), tsc, 헤드 브라우저 — 진입 시 상한 9%, 40km/h 전환 시 7% 즉시 반영, 원복 9%, 안내문 '간선임도 · 설계속도 20km/h · 일반지형', 유토곡선 5칩, 유역 버튼 새 이름·툴팁, 3D 라벨 빈 문자열 확인. Co-Authored-By: Claude Fable 5 --- B02_ProjRegister/B02_ProjRegister_UI_Page.ts | 6 +- B05_Profile/B05_Profile_Api_Fetch.ts | 2 + B05_Profile/B05_Profile_Engine_Grade.py | 26 ++++- B05_Profile/B05_Profile_Router.py | 1 + B05_Profile/B05_Profile_Schema.py | 7 ++ B05_Profile/B05_Profile_UI_Drainage_Chrome.ts | 2 + B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 1 + B05_Profile/B05_Profile_UI_Page.ts | 11 ++ B05_Profile/B05_Profile_UI_Page_Helpers.ts | 14 ++- B05_Profile/B05_Profile_UI_Panel.ts | 104 +++++++++++++++--- B05_Profile/B05_Profile_UI_Profile_Balance.ts | 70 +++++++----- B05_Profile/B05_Profile_UI_Viewer.ts | 4 +- B06_Section/B06_Section_Api_Fetch.ts | 2 + B06_Section/B06_Section_Router.py | 11 ++ B06_Section/B06_Section_Schema.py | 3 + .../common_util_mass_haul_balance_view.ts | 16 +-- common_util/common_util_mass_haul_view.ts | 32 ++++-- config/config_system.py | 21 +++- ui_template/ui_template_locale_b1.ts | 9 +- ui_template/ui_template_locale_b2.ts | 20 ++-- 20 files changed, 278 insertions(+), 84 deletions(-) diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts index 5b3cdf78..7aa195ae 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts +++ b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts @@ -57,13 +57,15 @@ export function renderB02ProjRegister(root: HTMLElement): void { placeholder: L("B02_Proj_Field_Region_Placeholder"), required: true, }); + // 임도 종류 3종 — 현행 규칙 기준(지선임도 폐지, 계류보전은 사방사업 구분이라 제외). + // 이 값이 B05 계획선의 법정 기준(설계속도 선택지·종단기울기 상한)을 정한다 + // (2026-08-19 사용자 확정). const roadTypeField = createSelectField({ label: L("B02_Proj_Field_RoadType"), options: [ { value: "main", text: L("B02_Proj_RoadType_Main") }, - { value: "branch", text: L("B02_Proj_RoadType_Branch") }, { value: "fire", text: L("B02_Proj_RoadType_Fire") }, - { value: "stream", text: L("B02_Proj_RoadType_Stream") }, + { value: "work", text: L("B02_Proj_RoadType_Work") }, ], }); const currentYear = new Date().getFullYear(); diff --git a/B05_Profile/B05_Profile_Api_Fetch.ts b/B05_Profile/B05_Profile_Api_Fetch.ts index eb8052e2..ada2b857 100644 --- a/B05_Profile/B05_Profile_Api_Fetch.ts +++ b/B05_Profile/B05_Profile_Api_Fetch.ts @@ -50,6 +50,8 @@ export interface RouteSolveRequest { cross_sample_interval_m?: number | null; long_sample_interval_m?: number | null; terrain_type?: string; + /** 설계속도(km/h) — 종단 법정 기준 축. 임도 기본 20(2026-08-19). */ + design_speed_kph?: number | null; main_direction?: string; max_grade_pct?: number | null; min_vertical_radius_m?: number | null; diff --git a/B05_Profile/B05_Profile_Engine_Grade.py b/B05_Profile/B05_Profile_Engine_Grade.py index 57394dab..e6eb47a3 100644 --- a/B05_Profile/B05_Profile_Engine_Grade.py +++ b/B05_Profile/B05_Profile_Engine_Grade.py @@ -98,8 +98,25 @@ class GradeDesignOptions: } +def resolve_design_speed(grade_class: str, design_speed_kph: int | None = None) -> int: + """적용할 설계속도(km/h). 사용자가 고른 값이 있으면 그 값, 없으면 종류별 기본. + + 임도는 속도를 낼 수 없는 노선이라 기본은 20이고, 간선·산불진화만 30·40을 고를 + 수 있다(별표2, 2026-08-19 사용자 확정). 허용되지 않는 값이 오면 기본으로 돌린다. + """ + criteria = FOREST_ROAD_PROFILE_CRITERIA + default_speed = int(criteria["grade_to_design_speed"].get(grade_class, 20)) + allowed = criteria["selectable_design_speeds"].get(grade_class, (20,)) + if design_speed_kph in allowed: + return int(design_speed_kph) + return default_speed + + def legal_grade_limit_pct( - grade_class: str, terrain_type: str = "normal", paved: bool = False + grade_class: str, + terrain_type: str = "normal", + paved: bool = False, + design_speed_kph: int | None = None, ) -> float: """설계속도 × 지형 구분에 따른 법정 종단기울기 상한(%). @@ -108,7 +125,7 @@ def legal_grade_limit_pct( """ criteria = FOREST_ROAD_PROFILE_CRITERIA terrain = terrain_type if terrain_type in GRADE_TERRAIN_TYPES else "normal" - speed = criteria["grade_to_design_speed"].get(grade_class, 20) + speed = resolve_design_speed(grade_class, design_speed_kph) legal_max = float(criteria["design_speed"][speed]["max_grade_pct"][terrain]) if paved and terrain == "special": return float(criteria["paved_exception_grade_pct"]) @@ -129,6 +146,7 @@ def resolve_grade_options( terrain_type: str = "normal", paved: bool = False, main_direction: str = "auto", + design_speed_kph: int | None = None, requested: dict[str, Any] | None = None, stored: dict[str, Any] | None = None, ) -> GradeDesignOptions: @@ -142,10 +160,10 @@ def resolve_grade_options( stored = stored or {} criteria = FOREST_ROAD_PROFILE_CRITERIA terrain = terrain_type if terrain_type in GRADE_TERRAIN_TYPES else "normal" - speed = criteria["grade_to_design_speed"].get(grade_class, 20) + speed = resolve_design_speed(grade_class, design_speed_kph) legal = criteria["design_speed"][speed] legal_max = float(legal["max_grade_pct"][terrain]) - ceiling = legal_grade_limit_pct(grade_class, terrain, paved) + ceiling = legal_grade_limit_pct(grade_class, terrain, paved, design_speed_kph) max_grade = float(_pick(requested.get("max_grade_pct"), stored.get("max_grade_pct"), legal_max)) warnings: list[str] = [] diff --git a/B05_Profile/B05_Profile_Router.py b/B05_Profile/B05_Profile_Router.py index 51be0066..1414aa63 100644 --- a/B05_Profile/B05_Profile_Router.py +++ b/B05_Profile/B05_Profile_Router.py @@ -124,6 +124,7 @@ def _grade_options( terrain_type=request.terrain_type, paved=request.paved, main_direction=request.main_direction, + design_speed_kph=request.design_speed_kph, requested=request.grade_options(), stored=stored_grade_options, ) diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index 267a66fb..2aa6e751 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -92,6 +92,10 @@ class RouteSolveRequest(BaseModel): # 종단 계획선(계획고) 설계 옵션. 빈 값은 null로 두어 config 기본값을 쓴다. terrain_type: str = Field(default="normal", description="지형 구분 (normal/special)") + # 설계속도(km/h) — 법정 종단 기준을 정하는 축이다. 임도는 속도를 낼 수 없어 20이 + # 기본이며, 간선·산불진화만 30·40을 고를 수 있다(2026-08-19 사용자 확정). + # 미지정(None)이면 종류별 기본 설계속도(grade_to_design_speed)를 쓴다. + design_speed_kph: int | None = Field(default=None, description="설계속도 (20/30/40)") main_direction: str = Field( default="auto", description="주 진행방향 (auto/ascending/descending/none)" ) @@ -108,6 +112,8 @@ class RouteSolveRequest(BaseModel): raise ValueError(f"임도 등급은 {ROUTE_GRADE_CLASSES} 중 하나여야 합니다.") if self.algorithm not in ("dijkstra", "ridge_valley"): raise ValueError("경로 알고리즘은 dijkstra 또는 ridge_valley여야 합니다.") + if self.design_speed_kph is not None and self.design_speed_kph not in (20, 30, 40): + raise ValueError("설계속도는 20/30/40 중 하나여야 합니다.") if self.terrain_type not in GRADE_TERRAIN_TYPES: raise ValueError(f"지형 구분은 {GRADE_TERRAIN_TYPES} 중 하나여야 합니다.") if self.main_direction not in GRADE_MAIN_DIRECTIONS: @@ -142,6 +148,7 @@ class RouteSolveRequest(BaseModel): "grade_class": self.grade_class, "paved": self.paved, "terrain_type": self.terrain_type, + "design_speed_kph": self.design_speed_kph, "main_direction": self.main_direction, "min_curve_radius_m": self.min_curve_radius_m, "max_uphill_grade": self.max_uphill_grade, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts b/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts index d8081564..1978588a 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts @@ -38,6 +38,7 @@ export interface DrainageChromeLabels { analyze: string; analyzeTip: string; deleteSelected: string; + deleteSelectedTip: string; auto: string; autoTip: string; imageAlt: string; @@ -85,6 +86,7 @@ export function createDrainageChrome( deleteButton.type = "button"; deleteButton.className = "b05-drainage__analyze b05-drainage__tool"; deleteButton.textContent = labels.deleteSelected; + deleteButton.title = labels.deleteSelectedTip; deleteButton.disabled = true; // 자동 제안으로 되돌리기 — 편집한 배관 배치를 버리고 백엔드 자동 제안으로 재산정. const autoButton = document.createElement("button"); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index f7ebcc26..9af19b04 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -129,6 +129,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra analyze: L("B05_Drainage_Btn_Analyze"), analyzeTip: L("B05_Drainage_Btn_Analyze_Tip"), deleteSelected: L("B05_Drainage_Btn_DeleteSelected"), + deleteSelectedTip: L("B05_Drainage_Btn_DeleteSelected_Tip"), auto: L("B05_Drainage_Btn_Auto"), autoTip: L("B05_Drainage_Btn_Auto_Tip"), imageAlt: L("B05_Drainage_ImageAlt"), diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 0385323a..1e330efe 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -58,6 +58,7 @@ import { restorePoints, routePoint, toBounds, + toGradeClass, } from "./B05_Profile_UI_Page_Helpers"; function L(key: keyof typeof ui_locales): string { @@ -341,6 +342,7 @@ export async function renderB05Route(root: HTMLElement): Promise { crossSampleInterval: next.route_params?.cross_sample_interval_m ?? undefined, longSampleInterval: next.route_params?.long_sample_interval_m ?? undefined, terrainType: options.terrain_type as RoutePanelValues["terrainType"] | undefined, + designSpeed: options.design_speed_kph as RoutePanelValues["designSpeed"] | undefined, maxGradePct: next.route_params?.max_grade_pct ?? undefined, minVerticalRadius: next.route_params?.min_vertical_radius_m ?? undefined, minTangentLength: next.route_params?.min_tangent_length_m ?? undefined, @@ -387,6 +389,10 @@ export async function renderB05Route(root: HTMLElement): Promise { function renderSections(detail: SectionDetailResponse, routeId?: number): void { currentSectionDetail = detail; profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId); + // 저장된 계획선의 정책은 만들 당시 기준이라 지금 설정(임도 종류·설계속도·지형)과 + // 다를 수 있다 — 그릴 때마다 현재 기준으로 맞춘다(2026-08-19). 이렇게 하지 않으면 + // 진입 직후 상단 표시줄이 옛 상한을 그대로 보여 준다. + profilePanel.setGradeLimit(panel.gradeLimitPct()); profilePanel.setStationDisplay(panel.stationDisplayOffset()); profilePanel.setIrregularStations(bridge.irregularStations()); renderStationLines(detail); @@ -514,6 +520,7 @@ export async function renderB05Route(root: HTMLElement): Promise { cross_sample_interval_m: values.crossSampleInterval, long_sample_interval_m: values.longSampleInterval, terrain_type: values.terrainType, + design_speed_kph: values.designSpeed, max_grade_pct: values.maxGradePct, min_vertical_radius_m: values.minVerticalRadius, min_tangent_length_m: values.minTangentLength, @@ -649,6 +656,10 @@ export async function renderB05Route(root: HTMLElement): Promise { stationInterval: sectionContext.defaults.station_interval_m, crossSampleInterval: sectionContext.defaults.cross_sample_interval_m, longSampleInterval: sectionContext.defaults.long_sample_interval_m, + // 임도 종류는 프로젝트 등록(B02)이 정본이다 — 화면에서 고르지 않는다 + // (2026-08-19 사용자 확정). B02의 main/branch/fire/stream을 계획선 기준 + // 코드로 옮긴다: main=간선, fire=산불진화, 그 밖(구 지선·계류보전)은 작업. + gradeClass: toGradeClass(sectionContext.road_type), }); // 유토곡선용 설정 — 토량환산계수·운반장비 경계·자연방토 판정 경사를 config_system // 정의 그대로 받아 B06과 같은 계산을 태운다(프론트 사본 금지). diff --git a/B05_Profile/B05_Profile_UI_Page_Helpers.ts b/B05_Profile/B05_Profile_UI_Page_Helpers.ts index 038954f4..ce217c27 100644 --- a/B05_Profile/B05_Profile_UI_Page_Helpers.ts +++ b/B05_Profile/B05_Profile_UI_Page_Helpers.ts @@ -26,7 +26,19 @@ import { type GradeClass = RoutePanelValues["gradeClass"]; export type RoadWidths = Record; -export const DEFAULT_ROAD_WIDTHS: RoadWidths = { trunk: 3, branch: 3, work: 2.5 }; +/** 임도 종류별 유효너비(m) — 간선·산불진화 3m, 작업 2.5m(설계제원_총괄 §3). + * branch(지선)는 폐지됐지만 기존 저장분이 남아 있어 값은 유지한다(2026-08-19). */ +export const DEFAULT_ROAD_WIDTHS: RoadWidths = { trunk: 3, fire: 3, branch: 3, work: 2.5 }; + +/** 프로젝트 등록(B02)의 임도 종류 값을 계획선 기준 코드로 옮긴다(2026-08-19). + * B02 값: main(간선)·fire(산불진화)·branch(구 지선)·stream(계류보전). + * 현행 규칙의 3종은 간선·산불진화·작업이고 지선은 폐지됐으므로, 간선·산불진화가 + * 아닌 값은 모두 작업임도로 본다(별표2 부칙 경과조치와 같은 방향). */ +export function toGradeClass(roadType: string | null | undefined): GradeClass { + if (roadType === "main" || roadType === "trunk") return "trunk"; + if (roadType === "fire") return "fire"; + return "work"; +} export async function fetchRoadWidths(projectId: string): Promise { const response = await fetch(`/api/projects/${projectId}/sections/road-widths`); diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index 0a7a2ed3..4d46e298 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -9,7 +9,13 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; export interface RoutePanelValues { contourInterval: number; algorithm: "dijkstra" | "ridge_valley"; - gradeClass: "trunk" | "branch" | "work"; + /** 임도 종류 — 현행 3종(간선·산불진화·작업). branch(지선)는 폐지됐으나 기존 + * 저장분 호환을 위해 값으로는 받는다(2026-08-19). 값의 원천은 프로젝트 등록 + * 정보(projects.road_type)이며 화면에서는 고르지 않는다. */ + gradeClass: "trunk" | "fire" | "branch" | "work"; + /** 설계속도(km/h) — 임도는 속도를 낼 수 없는 노선이라 기본 20이다. 간선·산불진화만 + * 20~40에서 고를 수 있고 작업임도는 20 고정(별표2, 2026-08-19 사용자 확정). */ + designSpeed: 20 | 30 | 40; paved: boolean; minCurveRadius: number | null; maxUphillGrade: number | null; @@ -51,12 +57,28 @@ const STATION_INTERVAL_M = 20; * 평면 최소곡선반지름은 일반지형 60/30/15 (설계제원_총괄 §4). * 값을 임의로 바꾸지 않고 남긴다 — 기본값 확정은 사용자 협의 영역이다. */ const PROFILE_CRITERIA: Record< - RoutePanelValues["gradeClass"], - { speed: number; grade: Record; radius: number } + RoutePanelValues["designSpeed"], + { grade: Record; radius: number } > = { - trunk: { speed: 40, grade: { normal: 7, special: 10 }, radius: 450 }, - branch: { speed: 30, grade: { normal: 8, special: 12 }, radius: 250 }, - work: { speed: 20, grade: { normal: 9, special: 14 }, radius: 100 }, + 40: { grade: { normal: 7, special: 10 }, radius: 450 }, + 30: { grade: { normal: 8, special: 12 }, radius: 250 }, + 20: { grade: { normal: 9, special: 14 }, radius: 100 }, +}; + +/** 임도 종류별 표시 이름 — 프로젝트 등록(B02)과 같은 말을 쓴다(2026-08-19 확정). */ +const ROAD_TYPE_LABELS: Record = { + trunk: "간선임도", + fire: "산불진화임도", + work: "작업임도", + branch: "작업임도(구 지선)", +}; + +/** 종류별로 고를 수 있는 설계속도 — 작업임도는 20 이하라 20만 둔다(별표2 Ⅰ.3). */ +const SPEED_CHOICES: Record> = { + trunk: [20, 30, 40], + fire: [20, 30, 40], + work: [20], + branch: [20], }; interface PanelCallbacks { @@ -265,15 +287,30 @@ export function createRoutePanel(callbacks: PanelCallbacks) { ], }); const algorithm = algorithmField.select; + // 임도 종류는 **프로젝트 등록(B02)에서 정한 값**을 그대로 쓴다 — 여기서 고치는 + // 값이 아니다(2026-08-19 사용자 확정). 화면에는 읽기 전용으로 보여만 준다. const gradeField = createSelectField({ - label: "임도 등급", + label: "임도 종류 (프로젝트 등록값)", options: [ - { value: "trunk", text: "간선" }, - { value: "branch", text: "지선" }, - { value: "work", text: "작업" }, + { value: "trunk", text: ROAD_TYPE_LABELS.trunk }, + { value: "fire", text: ROAD_TYPE_LABELS.fire }, + { value: "work", text: ROAD_TYPE_LABELS.work }, + { value: "branch", text: ROAD_TYPE_LABELS.branch }, ], }); const gradeClass = gradeField.select; + gradeClass.disabled = true; + gradeClass.title = "프로젝트 등록 정보의 임도 종류입니다. 변경은 대시보드에서 하세요."; + // 설계속도 — 임도는 속도를 낼 수 없어 기본 20km/h다. 종류에 따라 선택지가 갈린다. + const speedField = createSelectField({ + label: "설계속도 (km/h)", + options: [20, 30, 40].map((value) => ({ value: String(value), text: `${value} km/h` })), + }); + const designSpeed = speedField.select; + designSpeed.value = "20"; + designSpeed.title = + "종단기울기·종단곡선의 법정 기준은 설계속도로 정해집니다(별표2).\n" + + "간선·산불진화임도는 20~40, 작업임도는 20만 쓸 수 있습니다."; const minCurveRadius = numberField("최소 곡선반경 (m)"); const maxUphillGrade = numberField("오르막 경사 상한 (%)"); const maxDownhillGrade = numberField("내리막 경사 상한 (%)"); @@ -295,7 +332,6 @@ export function createRoutePanel(callbacks: PanelCallbacks) { const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled"); routeCalc.body.append( algorithmField.root, - gradeField.root, paved.wrapper, avoidPass.wrapper, details, @@ -349,6 +385,10 @@ export function createRoutePanel(callbacks: PanelCallbacks) { settingsDivider1, contourRow, settingsDivider2, + // 임도 종류(등록값) → 설계속도 → 지형 구분 순 — 법정 기준이 정해지는 순서다 + // (2026-08-19 사용자 확정: 종류는 B02에서, 속도·지형은 여기서). + gradeField.root, + speedField.root, terrainField.root, criteriaNote, gradeAdvanced, @@ -393,30 +433,51 @@ export function createRoutePanel(callbacks: PanelCallbacks) { /** 지금 적용되는 종단기울기 상한(%) — 사용자가 값을 넣었으면 그 값, 비웠으면 * 등급·지형의 법정 기준. 종단 그래프의 위반 판정이 이 값을 즉시 따라온다 * (2026-08-19 사용자 지시 6). 특수지형+포장 예외(18%)는 서버가 최종 판정한다. */ + function currentSpeed(): RoutePanelValues["designSpeed"] { + const value = Number(designSpeed.value); + return (value === 40 || value === 30 ? value : 20) as RoutePanelValues["designSpeed"]; + } + function gradeLimitPct(): number { - const criteria = PROFILE_CRITERIA[gradeClass.value as RoutePanelValues["gradeClass"]]; + const criteria = PROFILE_CRITERIA[currentSpeed()]; const terrain = terrainType.value as RoutePanelValues["terrainType"]; const typed = parseOptional(maxGradePct); return typed != null && typed > 0 ? typed : criteria.grade[terrain]; } - /** 등급·지형 선택에 맞춰 법정 기준값을 placeholder와 안내문에 반영한다. */ + /** 임도 종류에 맞춰 설계속도 선택지를 갈아 끼운다 — 작업임도는 20만 쓸 수 있다. */ + function syncSpeedChoices(): void { + const allowed = SPEED_CHOICES[gradeClass.value] ?? [20]; + const keep = allowed.includes(currentSpeed()) ? currentSpeed() : allowed[0]; + designSpeed.replaceChildren( + ...allowed.map((value) => new Option(`${value} km/h`, String(value))), + ); + designSpeed.value = String(keep); + // 고를 것이 하나뿐이면 잠근다 — 열어 두면 바꿀 수 있다는 오해를 준다. + designSpeed.disabled = allowed.length < 2; + } + + /** 설계속도·지형 선택에 맞춰 법정 기준값을 placeholder와 안내문에 반영한다. */ function syncCriteria(): void { - const criteria = PROFILE_CRITERIA[gradeClass.value as RoutePanelValues["gradeClass"]]; + const speed = currentSpeed(); + const criteria = PROFILE_CRITERIA[speed]; const terrain = terrainType.value as RoutePanelValues["terrainType"]; maxGradePct.placeholder = String(criteria.grade[terrain]); minVerticalRadius.placeholder = String(criteria.radius); minTangentLength.placeholder = "20"; + const roadType = ROAD_TYPE_LABELS[gradeClass.value] ?? gradeClass.value; criteriaNote.textContent = - `설계속도 ${criteria.speed}km/h 기준 — 종단기울기 ${criteria.grade[terrain]}% 이하, ` + - `종단곡선 반경 ${criteria.radius}m 이상. 비워두면 이 기준이 적용됩니다.`; + `${roadType} · 설계속도 ${speed}km/h · ${terrain === "special" ? "특수지형" : "일반지형"} 기준 — ` + + `종단기울기 ${criteria.grade[terrain]}% 이하, 종단곡선 반경 ${criteria.radius}m 이상. ` + + "비워두면 이 기준이 적용됩니다."; } - // 지형·등급뿐 아니라 기울기 세부 입력까지 — 바꾸는 즉시 안내문과 위반 기준이 + // 설계속도·지형뿐 아니라 기울기 세부 입력까지 — 바꾸는 즉시 안내문과 위반 기준이 // 따라온다(2026-08-19 사용자 지시 6). onInputChange가 그래프 쪽 갱신을 잇는다. - [gradeClass, terrainType, maxGradePct].forEach((input) => + [designSpeed, terrainType, maxGradePct].forEach((input) => input.addEventListener("change", syncCriteria), ); maxGradePct.addEventListener("input", syncCriteria); + syncSpeedChoices(); syncCriteria(); // 하단 고정 액션 행: [초기화][임시저장][횡단 이동] — 경로 확정 개념 폐지, 종·횡 통합 @@ -449,6 +510,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { minDownhillGrade, crossSampleInterval, longSampleInterval, + designSpeed, terrainType, maxGradePct, minVerticalRadius, @@ -496,6 +558,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { stationInterval: STATION_INTERVAL_M, crossSampleInterval: parseOptional(crossSampleInterval), longSampleInterval: parseOptional(longSampleInterval), + designSpeed: currentSpeed(), terrainType: terrainType.value as RoutePanelValues["terrainType"], maxGradePct: parseOptional(maxGradePct), minVerticalRadius: parseOptional(minVerticalRadius), @@ -520,6 +583,11 @@ export function createRoutePanel(callbacks: PanelCallbacks) { crossSampleInterval.value = String(values.crossSampleInterval); if (values.longSampleInterval != null) longSampleInterval.value = String(values.longSampleInterval); + if (values.gradeClass) { + gradeClass.value = values.gradeClass; + syncSpeedChoices(); + } + if (values.designSpeed) designSpeed.value = String(values.designSpeed); if (values.terrainType) terrainType.value = values.terrainType; if (values.maxGradePct != null) maxGradePct.value = String(values.maxGradePct); if (values.minVerticalRadius != null) diff --git a/B05_Profile/B05_Profile_UI_Profile_Balance.ts b/B05_Profile/B05_Profile_UI_Profile_Balance.ts index 4c97f9e9..46b589a6 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Balance.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Balance.ts @@ -1,7 +1,14 @@ /* ============================================================================= * B05_Profile_UI_Profile_Balance.ts - * 종단 패널 상단 균형 표시줄 — 절·성토 면적, 불균형률, 변화점·종단곡선 개수, - * 종단기울기 위반 경고, [초기선 복원] 버튼, 미저장 배지. + * 종단 패널 상단 표시줄 — **법정 판정에 쓰이는 값만** 남긴다(2026-08-19 재편). + * + * 표시 항목(별표2 근거): + * 최대 기울기 x.x% (상한 y%) — Ⅰ.2.라: 설계속도·지형별 상한 준수 판정 + * 불균형 z% / 허용 w% — Ⅰ.1.나.(4)(다): 시공계획고 절·성토 균형 + * 곡선 필요 n곳 — Ⅰ.2.마: 대수차 5% 초과 변화점의 종단곡선 삽입 + * 절·성토 면적은 하단 유토곡선이 부피로 더 정확히 보여 주고, 변화점·종단곡선 개수와 + * 기본 곡선길이 L은 판단 기준이 없어 뺐다(2026-08-19 사용자 지적 + 지식DB 대조). + * [저장선 복원]은 상단에서 내려 그래프 조작부로 옮겼다. * * 패널 본체(B05_Profile_UI_Profile_Panel)가 700줄 한계에 닿아 분리했다. * 표시줄은 상태를 갖지 않는다 — 그릴 때마다 현재 선형·편집 상태를 인자로 받는다. @@ -40,21 +47,30 @@ export function renderBalanceBar(params: BalanceBarParams): void { } const { alignment } = params; const { balance, policy, violations } = alignment; + // 최대 종단기울기 — 법정 상한 대비가 이 화면의 첫 판정 항목이다(별표2 Ⅰ.2.라). + const steepest = alignment.segments.reduce( + (worst, segment) => Math.max(worst, Math.abs(segment.grade_percent)), + 0, + ); + // 종단곡선이 필요한데 빠진 변화점 — 생략은 비포장 & 대수차 5% 이하만 허용된다 + // (별표2 Ⅰ.2.마 / 지식DB 종단선형 §3). skip_allowed가 그 판정 결과다. + const curvesNeeded = alignment.curves.filter( + (curve) => curve.omitted && !curve.skip_allowed, + ).length; const entries: Array<[string, string, string?]> = [ - ["절토", `${balance.cut_area_m2.toFixed(1)} m²`, "cut"], - ["성토", `${balance.fill_area_m2.toFixed(1)} m²`, "fill"], [ - "불균형", + "최대 기울기", + `${steepest.toFixed(1)} % / 상한 ${policy.max_grade_pct.toFixed(1)} %`, + violations.length ? "over" : undefined, + ], + [ + "절·성토 불균형", `${balance.imbalance_percent.toFixed(1)} % / 허용 ${balance.tolerance_percent.toFixed(0)} %`, balance.within_tolerance ? undefined : "over", ], - ["변화점", `${alignment.pvi.length} 개`], - ["종단곡선", `${alignment.curves.filter((curve) => !curve.omitted).length} 개`], - // 기준은 길이 L이다. 옛 저장분(L 없음)만 그 시절 기준인 R을 그대로 밝혀 적는다. - Number.isFinite(policy.default_curve_length_m) - ? ["기본 곡선길이 L", `${(policy.default_curve_length_m as number).toFixed(1)} m`] - : ["기본 R(옛 저장분)", `${policy.default_curve_radius_m.toFixed(1)} m`], ]; + // 필요한 곳이 없으면 적지 않는다 — "0곳"은 화면 폭만 먹는다. + if (curvesNeeded) entries.push(["종단곡선 필요", `${curvesNeeded} 곳`, "over"]); const editedCount = Object.keys(alignment.edits.station_offsets).length; if (editedCount) entries.push(["편집 측점", `${editedCount} 개`, "edited"]); entries.forEach(([label, value, tone]) => { @@ -63,27 +79,31 @@ export function renderBalanceBar(params: BalanceBarParams): void { const caption = document.createElement("em"); caption.textContent = label; item.append(caption, document.createTextNode(value)); + // 상한 초과 구간의 내역은 별도 경고 칩 대신 이 항목의 툴팁으로 붙인다 — + // 같은 사실을 두 번 적지 않는다(2026-08-19 재편). + if (label === "최대 기울기" && violations.length) { + item.title = violations + .map( + (entry) => + `구간 ${entry.segment_index + 1}: ${entry.value.toFixed(2)}% > ${entry.limit}%`, + ) + .join("\n"); + } params.balanceBar.append(item); }); - if (violations.length) { - const warning = document.createElement("span"); - warning.className = "b05-route-profile__balance-warning"; - warning.textContent = `⚠ 종단기울기 초과 ${violations.length}개 구간`; - warning.title = violations - .map((item) => `구간 ${item.segment_index + 1}: ${item.value.toFixed(2)}% > ${item.limit}%`) - .join("\n"); - params.balanceBar.append(warning); - } if (params.edited || params.hasIrregularStations) { const reset = document.createElement("button"); reset.type = "button"; reset.className = "b05-route-profile__balance-reset"; - // 복원 목표는 **마지막 저장된 계획선**이다 — 임시저장을 거치면 편집이 정본에 - // 반영되므로 최초 자동 계산선과 다를 수 있다(2026-08-19 사용자 확인). 최초 - // 파이프라인 결과로의 완전 복귀는 좌측 하단 [초기화](B05·B06 재계산)가 맡는다. - reset.textContent = "저장선 복원"; + // 이 버튼이 지우는 것은 **화면에 쌓인 편집 델타**다(계획선 변화점 이동·비정규 + // 측점). 임시저장을 거치면 그 편집이 정본에 반영되므로 결과적으로 "마지막 저장 + // 상태"가 되는 것뿐, 저장 지점으로 되돌아가는 기능이 아니다. 최초 자동 계산 + // 상태로의 복귀는 좌측 하단 [초기화](B05·B06 재계산)가 맡는다(2026-08-19 정정). + // 자리도 값 칩들 뒤 — 편집 상태(미저장 배지) 옆이 맥락에 맞다. + reset.textContent = "편집 되돌리기"; reset.title = - "저장 후의 편집과 추가한 비정규 측점을 지우고 마지막 저장된 계획선으로 되돌립니다.\n" + + "화면에서 수정한 계획선 변화점과 추가한 비정규 측점을 지웁니다.\n" + + "이미 임시저장한 내용은 정본에 반영돼 있어 그대로 남습니다.\n" + "최초 자동 계산 상태로 되돌리려면 좌측 하단 [초기화]를 쓰세요."; reset.addEventListener("click", () => { params.onResetAll(); diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 91caa0d5..0698f41f 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -411,7 +411,9 @@ export function createRouteViewer(): RouteViewer { fit("top"); markers.renderMarkers(); await reloadContours(interval); - status.textContent = "지형을 클릭하거나 팔레트 포인트를 드래그해 배치하세요."; + // 로딩이 끝나면 안내문을 지운다 — 조작법 설명이 화면에 계속 떠 있을 이유가 + // 없다(2026-08-19 사용자 지시). 로딩·이동 중 안내는 그대로 쓴다. + status.textContent = ""; }, reloadContours, setSurfaceVisible(visible) { diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 6a2a173e..4f2a1d73 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -74,6 +74,8 @@ export interface SectionContextResponse { method: string | null; smooth: boolean | null; crs_epsg: number | null; + /** 프로젝트 등록(B02)에서 정한 임도 종류 — B05 계획선 법정 기준의 출발점(2026-08-19). */ + road_type: string | null; defaults: SectionOptionDefaults; /** 표준 횡단면 설정 패널(토사/암/포장) 기본값. */ standard_cross_section: StandardCrossSection; diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index cf09d306..48310ed9 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -79,6 +79,13 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON async with pool.acquire() as connection: route_context = await get_confirmed_route_context(connection, project_id) surface_params = await get_surface_confirmation_params(connection, str(project_id)) + # 임도 종류(projects.road_type) — B05가 계획선 법정 기준을 정하는 데 쓴다. + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT road_type FROM projects WHERE id = %s", (str(project_id),) + ) + row = await cursor.fetchone() + road_type = row[0] if row else None defaults = SectionGenerationOptions() return SectionContextResponse( @@ -88,6 +95,7 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON method=surface_params["method"] if route_context else None, smooth=bool(surface_params["smooth"]) if route_context else None, crs_epsg=route_context["crs_epsg"] if route_context else None, + road_type=road_type, defaults=SectionOptionDefaults( station_interval_m=defaults.station_interval_m, cross_half_width_m=defaults.cross_half_width_m, @@ -327,10 +335,13 @@ def _regeneration_grade_options(stage_params: dict[str, Any] | None) -> GradeDes ) if stage_params.get(key) is not None } + # 설계속도는 B05가 저장해 둔 값을 그대로 따른다 — 없으면 종류별 기본(20). + speed = options.get("design_speed_kph") return resolve_grade_options( str(grade_class), terrain_type=str(options.get("terrain_type") or "normal"), paved=bool(options.get("paved", False)), + design_speed_kph=int(speed) if speed is not None else None, stored=stored, ) diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index f0425287..9165cf42 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -138,6 +138,9 @@ class SectionContextResponse(BaseModel): method: str | None = None smooth: bool | None = None crs_epsg: int | None = None + # 프로젝트 등록(B02)에서 정한 임도 종류 — B05 계획선 법정 기준의 출발점이다. + # 화면은 이 값을 읽기만 하고 고치지 않는다(2026-08-19 사용자 확정). + road_type: str | None = None defaults: SectionOptionDefaults # 표준 횡단면 설정 패널(토사/암/포장) 기본값. config STANDARD_CROSS_SECTION 사본. standard_cross_section: dict[str, Any] = Field(default_factory=dict) diff --git a/common_util/common_util_mass_haul_balance_view.ts b/common_util/common_util_mass_haul_balance_view.ts index 58eec917..dca257ff 100644 --- a/common_util/common_util_mass_haul_balance_view.ts +++ b/common_util/common_util_mass_haul_balance_view.ts @@ -490,7 +490,7 @@ export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: Bala * 사토·토취는 요약줄이 이미 곡선 기준으로 적고 있고 계단 단차의 합이 그 값과 같으므로 * 여기서 또 적지 않는다(같은 수치가 두 번 뜨면 서로 다른 값으로 오해한다). */ -export function haulPlanChips(plan: HaulPlan | null): Array<[string, string]> { +export function haulPlanChips(plan: HaulPlan | null): Array<[string, string, string?]> { if (!plan) return []; const bands = plan.blocks.reduce((sum, block) => sum + block.bands.length, 0); const moved = plan.hauled_m3 + plan.transferred_m3; @@ -500,16 +500,18 @@ export function haulPlanChips(plan: HaulPlan | null): Array<[string, string]> { // (그 노선은 절토 우세라 토취가 0이었다). const gap = moved + plan.borrow_m3 - plan.fill_total_m3; const ratio = plan.fill_total_m3 > 0 ? Math.abs(gap) / plan.fill_total_m3 : 0; - const chips: Array<[string, string]> = [ - [L("B06_MassHaul_HaulTotal"), `${compactVolume(moved)}㎥`], - [L("B06_MassHaul_BlockCount"), `${plan.blocks.length} / ${bands}`], - ]; + /* 요약 줄 압축(2026-08-19) — 블록·띠 수와 장거리 운반·사토는 칩으로 두지 않고 + * 운반 총량 칩의 툴팁으로 내린다. 판단에 쓰는 값은 "얼마나 옮기는가"와 검산이다. */ + const haulTip = [`${L("B06_MassHaul_BlockCount")} ${plan.blocks.length} / ${bands}`]; if (plan.transfers.length) { - chips.push([L("B06_MassHaul_Transfer"), `${compactVolume(plan.transferred_m3)}㎥`]); + haulTip.push(`${L("B06_MassHaul_Transfer")} ${compactVolume(plan.transferred_m3)}㎥`); } if (plan.natural_spoil_m3 > 0.5) { - chips.push([L("B06_MassHaul_NaturalSpoil"), `${compactVolume(plan.natural_spoil_m3)}㎥`]); + haulTip.push(`${L("B06_MassHaul_NaturalSpoil")} ${compactVolume(plan.natural_spoil_m3)}㎥`); } + const chips: Array<[string, string, string?]> = [ + [L("B06_MassHaul_HaulTotal"), `${compactVolume(moved)}㎥`, haulTip.join(" · ")], + ]; chips.push([ L("B06_MassHaul_Check"), ratio <= 0.005 diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index 84498fef..957dd1c2 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -632,16 +632,29 @@ export function createMassHaulSummary( const row = document.createElement("div"); row.className = "b06-masshaul__summary"; const cut = result.cut_natural_m3; - const chips: Array<[string, string]> = [ + const cutTotal = cut.soil + cut.ripping_rock + cut.blasting_rock; + /* 요약 줄이 12칩까지 늘어 한눈에 안 읽혔다(2026-08-19 사용자 지적). 판단에 쓰는 + * 값만 칩으로 남기고, 세부 내역은 그 칩의 툴팁으로 내린다. + * 절토(자연) — 토질 3종 내역은 툴팁 + * 성토(다짐) — 절토 다짐 환산은 툴팁(토량환산계수 f 적용 결과) + * 잉여 또는 부족 — 둘은 동시에 성립하지 않는다 + * 운반·검산 — 토량 분배를 켰을 때만(haulPlanChips) + * B05 종단 패널과 B06 횡단 화면이 같은 함수를 쓰므로 양쪽이 함께 바뀐다. */ + const chips: Array<[string, string, string?]> = [ [ L("B06_MassHaul_CutNatural"), - `${formatVolume(cut.soil + cut.ripping_rock + cut.blasting_rock)}㎥`, + `${formatVolume(cutTotal)}㎥`, + [ + `${L("B06_Design_Ground_Soil")} ${formatVolume(cut.soil)}㎥`, + `${L("B06_Design_Ground_Ripping")} ${formatVolume(cut.ripping_rock)}㎥`, + `${L("B06_Design_Ground_Blasting")} ${formatVolume(cut.blasting_rock)}㎥`, + ].join(" · "), + ], + [ + L("B06_MassHaul_Fill"), + `${formatVolume(result.fill_compacted_m3)}㎥`, + `${L("B06_MassHaul_CutCompacted")} ${formatVolume(result.cut_compacted_m3)}㎥`, ], - [L("B06_Design_Ground_Soil"), `${formatVolume(cut.soil)}㎥`], - [L("B06_Design_Ground_Ripping"), `${formatVolume(cut.ripping_rock)}㎥`], - [L("B06_Design_Ground_Blasting"), `${formatVolume(cut.blasting_rock)}㎥`], - [L("B06_MassHaul_CutCompacted"), `${formatVolume(result.cut_compacted_m3)}㎥`], - [L("B06_MassHaul_Fill"), `${formatVolume(result.fill_compacted_m3)}㎥`], ]; // 잉여와 부족은 동시에 성립하지 않으므로 해당하는 쪽만 보여 준다. chips.push( @@ -649,11 +662,12 @@ export function createMassHaulSummary( ? [L("B06_MassHaul_Shortage"), `${formatVolume(result.shortage_m3)}㎥`] : [L("B06_MassHaul_Surplus"), `${formatVolume(result.surplus_m3)}㎥`], ); - // 토량 분배를 켜 두면 운반 총량·블록 수가 뒤에 붙는다(사토·토취는 발생 지점 기준 합계). + // 토량 분배를 켜 두면 운반 총량·검산이 뒤에 붙는다(사토·토취는 발생 지점 기준 합계). chips.push(...haulPlanChips(haulPlan)); - for (const [label, value] of chips) { + for (const [label, value, tip] of chips) { const chip = document.createElement("span"); chip.className = "b06-masshaul__chip"; + if (tip) chip.title = tip; const name = document.createElement("em"); name.textContent = label; const amount = document.createElement("strong"); diff --git a/config/config_system.py b/config/config_system.py index c6fff498..d616e1b5 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -243,11 +243,13 @@ ROUTE_AVOID_DEFAULT_RADIUS_M = float(os.getenv("ROUTE_AVOID_DEFAULT_RADIUS_M", " ROUTE_DEFAULT_GRADE_CLASS = os.getenv("ROUTE_DEFAULT_GRADE_CLASS", "trunk") ROUTE_MAX_COST_CELLS = int(os.getenv("ROUTE_MAX_COST_CELLS", "4000000")) ROUTE_REQUIRED_POINT_TOLERANCE_M = float(os.getenv("ROUTE_REQUIRED_POINT_TOLERANCE_M", "1.0")) -ROUTE_GRADE_CLASSES = ("trunk", "branch", "work") +# 임도 종류 — 현행 규칙(별표2)의 3종. `branch`(지선)는 규칙에서 폐지됐으나 기존 +# 저장분이 남아 있어 값으로는 계속 받는다(화면 선택지에서는 뺀다, 2026-08-19). +ROUTE_GRADE_CLASSES = ("trunk", "fire", "work", "branch") -# 임도 등급별 종단경사 한계 및 최소 곡선반지름 -FOREST_ROAD_MAX_GRADE = {"trunk": 0.26, "branch": 0.28, "work": 0.40} -FOREST_ROAD_MIN_CURVE_R_M = {"trunk": 12.0, "branch": 10.0, "work": 6.0} +# 임도 등급별 종단경사 한계 및 최소 곡선반지름 (경로탐색 제약 — 계획선 법정 기준과 별개) +FOREST_ROAD_MAX_GRADE = {"trunk": 0.26, "fire": 0.26, "branch": 0.28, "work": 0.40} +FOREST_ROAD_MIN_CURVE_R_M = {"trunk": 12.0, "fire": 12.0, "branch": 10.0, "work": 6.0} # 대안(정속경사) 파라미터 ROUTE_ALT_MIN_GRADE = float(os.getenv("ROUTE_ALT_MIN_GRADE", "0.08")) @@ -640,8 +642,15 @@ FOREST_ROAD_PROFILE_CRITERIA = { "min_curve_length_m": 20.0, }, }, - # 임도등급 → 기본 설계속도. 작업임도는 별표에 종단 기준이 없어 20km/h를 준용한다. - "grade_to_design_speed": {"trunk": 40, "branch": 30, "work": 20}, + # 임도 종류 → **기본** 설계속도(km/h). 임도는 속도를 낼 수 없는 노선이라 20이 + # 기본이다(2026-08-19 사용자 확정). 별표2상 간선·산불진화는 20~40 범위에서 + # 설계자가 고르고, 작업임도는 20 이하이므로 20 고정이다. 사용자가 화면에서 고른 + # 설계속도가 오면 그 값이 우선한다(design_speed_kph). + # trunk = 간선임도 / fire = 산불진화임도 / work = 작업임도 + # branch(지선)는 현행 규칙에서 폐지 — 기존 데이터 호환용으로만 남긴다. + "grade_to_design_speed": {"trunk": 20, "fire": 20, "work": 20, "branch": 20}, + # 화면에서 고를 수 있는 설계속도 — 작업임도는 20만 허용된다(별표2 Ⅰ.3). + "selectable_design_speeds": {"trunk": (20, 30, 40), "fire": (20, 30, 40), "work": (20,)}, # 특수지형에서 기준 적용이 어려운 경우 노면포장 시에 한하여 허용되는 상한 "paved_exception_grade_pct": 18.0, # 비포장 도로이면서 종단기울기 대수차가 이 값 이하이면 종단곡선을 두지 않는다 diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index 3f2db8f6..c4857696 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -200,10 +200,11 @@ export const ui_locales_b1 = { B02_Proj_Field_Region: ["사업 지역", "Region"], B02_Proj_Field_Region_Placeholder: ["예: 울진군 금강송면", "e.g. Uljin-gun"], B02_Proj_Field_RoadType: ["임도 종류", "Road type"], - B02_Proj_RoadType_Main: ["간선임도", "Main road"], - B02_Proj_RoadType_Branch: ["지선임도", "Branch road"], - B02_Proj_RoadType_Fire: ["산불진화임도", "Fire-suppression road"], - B02_Proj_RoadType_Stream: ["계류보전", "Stream conservation"], + // 현행 「임도설치 및 관리 등에 관한 규정」의 3종 — 지선임도는 폐지(작업임도로 + // 간주), 계류보전은 임도 종류가 아니라 사방사업 구분이라 뺐다(2026-08-19 확정). + B02_Proj_RoadType_Main: ["간선임도", "Main forest road"], + B02_Proj_RoadType_Fire: ["산불진화임도", "Fire-suppression forest road"], + B02_Proj_RoadType_Work: ["작업임도", "Work forest road"], B02_Proj_Field_Year: ["사업 연도", "Project year"], B02_Proj_Field_Length: ["예상 연장 (m)", "Estimated length (m)"], B02_Proj_Field_Length_Placeholder: ["예상 노선 길이", "Estimated route length"], diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index d70a2b94..cfe33a1d 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -30,16 +30,22 @@ export const ui_locales_b2 = { "유역 안쪽 상류 세류망을 굵게 강조합니다.", "Emphasize the upstream stream network inside the basin.", ], - B05_Drainage_Btn_Analyze: ["세부유역 산정", "Compute sub-basins"], + // 버튼 이름만으로 무엇이 바뀌는지 알기 어려워 동작을 이름·툴팁에 함께 적는다 + // (2026-08-19 사용자 지시 4). + B05_Drainage_Btn_Analyze: ["유역 다시 나누기", "Recompute sub-basins"], B05_Drainage_Btn_Analyze_Tip: [ - "B04에서 분석해 둔 배수유역을 불러와 관을 보충하고 세부유역을 나눕니다. 분석 결과가 없으면 B04에서 먼저 실행해야 합니다.", - "Loads the drainage analysis from B04, fills in culverts, and splits sub-basins. Run the analysis in B04 first if none exists.", + "B04에서 분석해 둔 배수유역을 불러와, 지금 배치된 배관을 기준으로 세부유역(관이 담당하는 구역)을 다시 나눕니다. 관이 부족한 구간은 자동으로 보충합니다. B04 분석 결과가 없으면 B04에서 먼저 실행해야 합니다.", + "Reloads the B04 drainage analysis and re-splits sub-basins around the current culverts, adding culverts where spacing requires. Run the analysis in B04 first if none exists.", ], - B05_Drainage_Btn_DeleteSelected: ["선택 삭제", "Delete selected"], - B05_Drainage_Btn_Auto: ["초기화", "Reset"], + B05_Drainage_Btn_DeleteSelected: ["선택한 관 삭제", "Delete selected culvert"], + B05_Drainage_Btn_DeleteSelected_Tip: [ + "지도에서 고른 배관 한 개를 지웁니다. 관을 먼저 눌러 고른 뒤에 쓸 수 있습니다.", + "Removes the culvert selected on the map. Select a culvert marker first.", + ], + B05_Drainage_Btn_Auto: ["배관 배치 초기화", "Reset culvert layout"], B05_Drainage_Btn_Auto_Tip: [ - "손으로 넣거나 옮긴 배관을 버리고 기본 관 + 자동 보충 배치로 되돌립니다.", - "Discards manual culvert edits and restores the automatic placement.", + "손으로 넣거나 옮기거나 지운 배관을 모두 버리고, 계곡부·간격 기준으로 프로그램이 자동 배치한 상태로 되돌립니다. 유역 구획도 함께 다시 나뉩니다.", + "Discards every manual culvert edit and restores the automatic placement (valley crossings + spacing rule); sub-basins are re-split as well.", ], B05_Drainage_Menu_Add: ["배수관 추가", "Add culvert"], B05_Drainage_Menu_Delete: ["배수관 삭제", "Remove culvert"],