feat(B05/B02): 표시 정보 정비 + 설계속도 축 도입

- 종단 상단 표시줄을 법정 판정 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 19:46:03 +09:00
co-authored by Claude Fable 5
parent 0cb772a351
commit 8812657022
20 changed files with 278 additions and 84 deletions
+4 -2
View File
@@ -57,13 +57,15 @@ export function renderB02ProjRegister(root: HTMLElement): void {
placeholder: L("B02_Proj_Field_Region_Placeholder"), placeholder: L("B02_Proj_Field_Region_Placeholder"),
required: true, required: true,
}); });
// 임도 종류 3종 — 현행 규칙 기준(지선임도 폐지, 계류보전은 사방사업 구분이라 제외).
// 이 값이 B05 계획선의 법정 기준(설계속도 선택지·종단기울기 상한)을 정한다
// (2026-08-19 사용자 확정).
const roadTypeField = createSelectField({ const roadTypeField = createSelectField({
label: L("B02_Proj_Field_RoadType"), label: L("B02_Proj_Field_RoadType"),
options: [ options: [
{ value: "main", text: L("B02_Proj_RoadType_Main") }, { 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: "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(); const currentYear = new Date().getFullYear();
+2
View File
@@ -50,6 +50,8 @@ export interface RouteSolveRequest {
cross_sample_interval_m?: number | null; cross_sample_interval_m?: number | null;
long_sample_interval_m?: number | null; long_sample_interval_m?: number | null;
terrain_type?: string; terrain_type?: string;
/** 설계속도(km/h) — 종단 법정 기준 축. 임도 기본 20(2026-08-19). */
design_speed_kph?: number | null;
main_direction?: string; main_direction?: string;
max_grade_pct?: number | null; max_grade_pct?: number | null;
min_vertical_radius_m?: number | null; min_vertical_radius_m?: number | null;
+22 -4
View File
@@ -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( 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: ) -> float:
"""설계속도 × 지형 구분에 따른 법정 종단기울기 상한(%). """설계속도 × 지형 구분에 따른 법정 종단기울기 상한(%).
@@ -108,7 +125,7 @@ def legal_grade_limit_pct(
""" """
criteria = FOREST_ROAD_PROFILE_CRITERIA criteria = FOREST_ROAD_PROFILE_CRITERIA
terrain = terrain_type if terrain_type in GRADE_TERRAIN_TYPES else "normal" 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]) legal_max = float(criteria["design_speed"][speed]["max_grade_pct"][terrain])
if paved and terrain == "special": if paved and terrain == "special":
return float(criteria["paved_exception_grade_pct"]) return float(criteria["paved_exception_grade_pct"])
@@ -129,6 +146,7 @@ def resolve_grade_options(
terrain_type: str = "normal", terrain_type: str = "normal",
paved: bool = False, paved: bool = False,
main_direction: str = "auto", main_direction: str = "auto",
design_speed_kph: int | None = None,
requested: dict[str, Any] | None = None, requested: dict[str, Any] | None = None,
stored: dict[str, Any] | None = None, stored: dict[str, Any] | None = None,
) -> GradeDesignOptions: ) -> GradeDesignOptions:
@@ -142,10 +160,10 @@ def resolve_grade_options(
stored = stored or {} stored = stored or {}
criteria = FOREST_ROAD_PROFILE_CRITERIA criteria = FOREST_ROAD_PROFILE_CRITERIA
terrain = terrain_type if terrain_type in GRADE_TERRAIN_TYPES else "normal" 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 = criteria["design_speed"][speed]
legal_max = float(legal["max_grade_pct"][terrain]) 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)) max_grade = float(_pick(requested.get("max_grade_pct"), stored.get("max_grade_pct"), legal_max))
warnings: list[str] = [] warnings: list[str] = []
+1
View File
@@ -124,6 +124,7 @@ def _grade_options(
terrain_type=request.terrain_type, terrain_type=request.terrain_type,
paved=request.paved, paved=request.paved,
main_direction=request.main_direction, main_direction=request.main_direction,
design_speed_kph=request.design_speed_kph,
requested=request.grade_options(), requested=request.grade_options(),
stored=stored_grade_options, stored=stored_grade_options,
) )
+7
View File
@@ -92,6 +92,10 @@ class RouteSolveRequest(BaseModel):
# 종단 계획선(계획고) 설계 옵션. 빈 값은 null로 두어 config 기본값을 쓴다. # 종단 계획선(계획고) 설계 옵션. 빈 값은 null로 두어 config 기본값을 쓴다.
terrain_type: str = Field(default="normal", description="지형 구분 (normal/special)") 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( main_direction: str = Field(
default="auto", description="주 진행방향 (auto/ascending/descending/none)" default="auto", description="주 진행방향 (auto/ascending/descending/none)"
) )
@@ -108,6 +112,8 @@ class RouteSolveRequest(BaseModel):
raise ValueError(f"임도 등급은 {ROUTE_GRADE_CLASSES} 중 하나여야 합니다.") raise ValueError(f"임도 등급은 {ROUTE_GRADE_CLASSES} 중 하나여야 합니다.")
if self.algorithm not in ("dijkstra", "ridge_valley"): if self.algorithm not in ("dijkstra", "ridge_valley"):
raise ValueError("경로 알고리즘은 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: if self.terrain_type not in GRADE_TERRAIN_TYPES:
raise ValueError(f"지형 구분은 {GRADE_TERRAIN_TYPES} 중 하나여야 합니다.") raise ValueError(f"지형 구분은 {GRADE_TERRAIN_TYPES} 중 하나여야 합니다.")
if self.main_direction not in GRADE_MAIN_DIRECTIONS: if self.main_direction not in GRADE_MAIN_DIRECTIONS:
@@ -142,6 +148,7 @@ class RouteSolveRequest(BaseModel):
"grade_class": self.grade_class, "grade_class": self.grade_class,
"paved": self.paved, "paved": self.paved,
"terrain_type": self.terrain_type, "terrain_type": self.terrain_type,
"design_speed_kph": self.design_speed_kph,
"main_direction": self.main_direction, "main_direction": self.main_direction,
"min_curve_radius_m": self.min_curve_radius_m, "min_curve_radius_m": self.min_curve_radius_m,
"max_uphill_grade": self.max_uphill_grade, "max_uphill_grade": self.max_uphill_grade,
@@ -38,6 +38,7 @@ export interface DrainageChromeLabels {
analyze: string; analyze: string;
analyzeTip: string; analyzeTip: string;
deleteSelected: string; deleteSelected: string;
deleteSelectedTip: string;
auto: string; auto: string;
autoTip: string; autoTip: string;
imageAlt: string; imageAlt: string;
@@ -85,6 +86,7 @@ export function createDrainageChrome(
deleteButton.type = "button"; deleteButton.type = "button";
deleteButton.className = "b05-drainage__analyze b05-drainage__tool"; deleteButton.className = "b05-drainage__analyze b05-drainage__tool";
deleteButton.textContent = labels.deleteSelected; deleteButton.textContent = labels.deleteSelected;
deleteButton.title = labels.deleteSelectedTip;
deleteButton.disabled = true; deleteButton.disabled = true;
// 자동 제안으로 되돌리기 — 편집한 배관 배치를 버리고 백엔드 자동 제안으로 재산정. // 자동 제안으로 되돌리기 — 편집한 배관 배치를 버리고 백엔드 자동 제안으로 재산정.
const autoButton = document.createElement("button"); const autoButton = document.createElement("button");
@@ -129,6 +129,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
analyze: L("B05_Drainage_Btn_Analyze"), analyze: L("B05_Drainage_Btn_Analyze"),
analyzeTip: L("B05_Drainage_Btn_Analyze_Tip"), analyzeTip: L("B05_Drainage_Btn_Analyze_Tip"),
deleteSelected: L("B05_Drainage_Btn_DeleteSelected"), deleteSelected: L("B05_Drainage_Btn_DeleteSelected"),
deleteSelectedTip: L("B05_Drainage_Btn_DeleteSelected_Tip"),
auto: L("B05_Drainage_Btn_Auto"), auto: L("B05_Drainage_Btn_Auto"),
autoTip: L("B05_Drainage_Btn_Auto_Tip"), autoTip: L("B05_Drainage_Btn_Auto_Tip"),
imageAlt: L("B05_Drainage_ImageAlt"), imageAlt: L("B05_Drainage_ImageAlt"),
+11
View File
@@ -58,6 +58,7 @@ import {
restorePoints, restorePoints,
routePoint, routePoint,
toBounds, toBounds,
toGradeClass,
} from "./B05_Profile_UI_Page_Helpers"; } from "./B05_Profile_UI_Page_Helpers";
function L(key: keyof typeof ui_locales): string { function L(key: keyof typeof ui_locales): string {
@@ -341,6 +342,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
crossSampleInterval: next.route_params?.cross_sample_interval_m ?? undefined, crossSampleInterval: next.route_params?.cross_sample_interval_m ?? undefined,
longSampleInterval: next.route_params?.long_sample_interval_m ?? undefined, longSampleInterval: next.route_params?.long_sample_interval_m ?? undefined,
terrainType: options.terrain_type as RoutePanelValues["terrainType"] | 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, maxGradePct: next.route_params?.max_grade_pct ?? undefined,
minVerticalRadius: next.route_params?.min_vertical_radius_m ?? undefined, minVerticalRadius: next.route_params?.min_vertical_radius_m ?? undefined,
minTangentLength: next.route_params?.min_tangent_length_m ?? undefined, minTangentLength: next.route_params?.min_tangent_length_m ?? undefined,
@@ -387,6 +389,10 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
function renderSections(detail: SectionDetailResponse, routeId?: number): void { function renderSections(detail: SectionDetailResponse, routeId?: number): void {
currentSectionDetail = detail; currentSectionDetail = detail;
profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId); profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId);
// 저장된 계획선의 정책은 만들 당시 기준이라 지금 설정(임도 종류·설계속도·지형)과
// 다를 수 있다 — 그릴 때마다 현재 기준으로 맞춘다(2026-08-19). 이렇게 하지 않으면
// 진입 직후 상단 표시줄이 옛 상한을 그대로 보여 준다.
profilePanel.setGradeLimit(panel.gradeLimitPct());
profilePanel.setStationDisplay(panel.stationDisplayOffset()); profilePanel.setStationDisplay(panel.stationDisplayOffset());
profilePanel.setIrregularStations(bridge.irregularStations()); profilePanel.setIrregularStations(bridge.irregularStations());
renderStationLines(detail); renderStationLines(detail);
@@ -514,6 +520,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
cross_sample_interval_m: values.crossSampleInterval, cross_sample_interval_m: values.crossSampleInterval,
long_sample_interval_m: values.longSampleInterval, long_sample_interval_m: values.longSampleInterval,
terrain_type: values.terrainType, terrain_type: values.terrainType,
design_speed_kph: values.designSpeed,
max_grade_pct: values.maxGradePct, max_grade_pct: values.maxGradePct,
min_vertical_radius_m: values.minVerticalRadius, min_vertical_radius_m: values.minVerticalRadius,
min_tangent_length_m: values.minTangentLength, min_tangent_length_m: values.minTangentLength,
@@ -649,6 +656,10 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
stationInterval: sectionContext.defaults.station_interval_m, stationInterval: sectionContext.defaults.station_interval_m,
crossSampleInterval: sectionContext.defaults.cross_sample_interval_m, crossSampleInterval: sectionContext.defaults.cross_sample_interval_m,
longSampleInterval: sectionContext.defaults.long_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 // 유토곡선용 설정 — 토량환산계수·운반장비 경계·자연방토 판정 경사를 config_system
// 정의 그대로 받아 B06과 같은 계산을 태운다(프론트 사본 금지). // 정의 그대로 받아 B06과 같은 계산을 태운다(프론트 사본 금지).
+13 -1
View File
@@ -26,7 +26,19 @@ import {
type GradeClass = RoutePanelValues["gradeClass"]; type GradeClass = RoutePanelValues["gradeClass"];
export type RoadWidths = Record<GradeClass, number>; export type RoadWidths = Record<GradeClass, number>;
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<RoadWidths> { export async function fetchRoadWidths(projectId: string): Promise<RoadWidths> {
const response = await fetch(`/api/projects/${projectId}/sections/road-widths`); const response = await fetch(`/api/projects/${projectId}/sections/road-widths`);
+86 -18
View File
@@ -9,7 +9,13 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
export interface RoutePanelValues { export interface RoutePanelValues {
contourInterval: number; contourInterval: number;
algorithm: "dijkstra" | "ridge_valley"; 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; paved: boolean;
minCurveRadius: number | null; minCurveRadius: number | null;
maxUphillGrade: number | null; maxUphillGrade: number | null;
@@ -51,12 +57,28 @@ const STATION_INTERVAL_M = 20;
* 60/30/15 (_총괄 §4). * 60/30/15 (_총괄 §4).
* . */ * . */
const PROFILE_CRITERIA: Record< const PROFILE_CRITERIA: Record<
RoutePanelValues["gradeClass"], RoutePanelValues["designSpeed"],
{ speed: number; grade: Record<RoutePanelValues["terrainType"], number>; radius: number } { grade: Record<RoutePanelValues["terrainType"], number>; radius: number }
> = { > = {
trunk: { speed: 40, grade: { normal: 7, special: 10 }, radius: 450 }, 40: { grade: { normal: 7, special: 10 }, radius: 450 },
branch: { speed: 30, grade: { normal: 8, special: 12 }, radius: 250 }, 30: { grade: { normal: 8, special: 12 }, radius: 250 },
work: { speed: 20, grade: { normal: 9, special: 14 }, radius: 100 }, 20: { grade: { normal: 9, special: 14 }, radius: 100 },
};
/** 임도 종류별 표시 이름 — 프로젝트 등록(B02)과 같은 말을 쓴다(2026-08-19 확정). */
const ROAD_TYPE_LABELS: Record<string, string> = {
trunk: "간선임도",
fire: "산불진화임도",
work: "작업임도",
branch: "작업임도(구 지선)",
};
/** 종류별로 고를 수 있는 설계속도 — 작업임도는 20 이하라 20만 둔다(별표2 .3). */
const SPEED_CHOICES: Record<string, Array<RoutePanelValues["designSpeed"]>> = {
trunk: [20, 30, 40],
fire: [20, 30, 40],
work: [20],
branch: [20],
}; };
interface PanelCallbacks { interface PanelCallbacks {
@@ -265,15 +287,30 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
], ],
}); });
const algorithm = algorithmField.select; const algorithm = algorithmField.select;
// 임도 종류는 **프로젝트 등록(B02)에서 정한 값**을 그대로 쓴다 — 여기서 고치는
// 값이 아니다(2026-08-19 사용자 확정). 화면에는 읽기 전용으로 보여만 준다.
const gradeField = createSelectField({ const gradeField = createSelectField({
label: "임도 등급", label: "임도 종류 (프로젝트 등록값)",
options: [ options: [
{ value: "trunk", text: "간선" }, { value: "trunk", text: ROAD_TYPE_LABELS.trunk },
{ value: "branch", text: "지선" }, { value: "fire", text: ROAD_TYPE_LABELS.fire },
{ value: "work", text: "작업" }, { value: "work", text: ROAD_TYPE_LABELS.work },
{ value: "branch", text: ROAD_TYPE_LABELS.branch },
], ],
}); });
const gradeClass = gradeField.select; 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 minCurveRadius = numberField("최소 곡선반경 (m)");
const maxUphillGrade = numberField("오르막 경사 상한 (%)"); const maxUphillGrade = numberField("오르막 경사 상한 (%)");
const maxDownhillGrade = numberField("내리막 경사 상한 (%)"); const maxDownhillGrade = numberField("내리막 경사 상한 (%)");
@@ -295,7 +332,6 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled"); const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled");
routeCalc.body.append( routeCalc.body.append(
algorithmField.root, algorithmField.root,
gradeField.root,
paved.wrapper, paved.wrapper,
avoidPass.wrapper, avoidPass.wrapper,
details, details,
@@ -349,6 +385,10 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
settingsDivider1, settingsDivider1,
contourRow, contourRow,
settingsDivider2, settingsDivider2,
// 임도 종류(등록값) → 설계속도 → 지형 구분 순 — 법정 기준이 정해지는 순서다
// (2026-08-19 사용자 확정: 종류는 B02에서, 속도·지형은 여기서).
gradeField.root,
speedField.root,
terrainField.root, terrainField.root,
criteriaNote, criteriaNote,
gradeAdvanced, gradeAdvanced,
@@ -393,30 +433,51 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
/** (%) , /** (%) ,
* · . * · .
* (2026-08-19 6). + (18%) . */ * (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 { 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 terrain = terrainType.value as RoutePanelValues["terrainType"];
const typed = parseOptional(maxGradePct); const typed = parseOptional(maxGradePct);
return typed != null && typed > 0 ? typed : criteria.grade[terrain]; 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 { 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"]; const terrain = terrainType.value as RoutePanelValues["terrainType"];
maxGradePct.placeholder = String(criteria.grade[terrain]); maxGradePct.placeholder = String(criteria.grade[terrain]);
minVerticalRadius.placeholder = String(criteria.radius); minVerticalRadius.placeholder = String(criteria.radius);
minTangentLength.placeholder = "20"; minTangentLength.placeholder = "20";
const roadType = ROAD_TYPE_LABELS[gradeClass.value] ?? gradeClass.value;
criteriaNote.textContent = criteriaNote.textContent =
`설계속도 ${criteria.speed}km/h 기준 — 종단기울기 ${criteria.grade[terrain]}% 이하, ` + `${roadType} · 설계속도 ${speed}km/h · ${terrain === "special" ? "특수지형" : "일반지형"} 기준 — ` +
`종단곡선 반경 ${criteria.radius}m 이상. 비워두면 이 기준이 적용됩니다.`; `종단기울기 ${criteria.grade[terrain]}% 이하, 종단곡선 반경 ${criteria.radius}m 이상. ` +
"비워두면 이 기준이 적용됩니다.";
} }
// 지형·등급뿐 아니라 기울기 세부 입력까지 — 바꾸는 즉시 안내문과 위반 기준이 // 설계속도·지형뿐 아니라 기울기 세부 입력까지 — 바꾸는 즉시 안내문과 위반 기준이
// 따라온다(2026-08-19 사용자 지시 6). onInputChange가 그래프 쪽 갱신을 잇는다. // 따라온다(2026-08-19 사용자 지시 6). onInputChange가 그래프 쪽 갱신을 잇는다.
[gradeClass, terrainType, maxGradePct].forEach((input) => [designSpeed, terrainType, maxGradePct].forEach((input) =>
input.addEventListener("change", syncCriteria), input.addEventListener("change", syncCriteria),
); );
maxGradePct.addEventListener("input", syncCriteria); maxGradePct.addEventListener("input", syncCriteria);
syncSpeedChoices();
syncCriteria(); syncCriteria();
// 하단 고정 액션 행: [초기화][임시저장][횡단 이동] — 경로 확정 개념 폐지, 종·횡 통합 // 하단 고정 액션 행: [초기화][임시저장][횡단 이동] — 경로 확정 개념 폐지, 종·횡 통합
@@ -449,6 +510,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
minDownhillGrade, minDownhillGrade,
crossSampleInterval, crossSampleInterval,
longSampleInterval, longSampleInterval,
designSpeed,
terrainType, terrainType,
maxGradePct, maxGradePct,
minVerticalRadius, minVerticalRadius,
@@ -496,6 +558,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
stationInterval: STATION_INTERVAL_M, stationInterval: STATION_INTERVAL_M,
crossSampleInterval: parseOptional(crossSampleInterval), crossSampleInterval: parseOptional(crossSampleInterval),
longSampleInterval: parseOptional(longSampleInterval), longSampleInterval: parseOptional(longSampleInterval),
designSpeed: currentSpeed(),
terrainType: terrainType.value as RoutePanelValues["terrainType"], terrainType: terrainType.value as RoutePanelValues["terrainType"],
maxGradePct: parseOptional(maxGradePct), maxGradePct: parseOptional(maxGradePct),
minVerticalRadius: parseOptional(minVerticalRadius), minVerticalRadius: parseOptional(minVerticalRadius),
@@ -520,6 +583,11 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
crossSampleInterval.value = String(values.crossSampleInterval); crossSampleInterval.value = String(values.crossSampleInterval);
if (values.longSampleInterval != null) if (values.longSampleInterval != null)
longSampleInterval.value = String(values.longSampleInterval); 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.terrainType) terrainType.value = values.terrainType;
if (values.maxGradePct != null) maxGradePct.value = String(values.maxGradePct); if (values.maxGradePct != null) maxGradePct.value = String(values.maxGradePct);
if (values.minVerticalRadius != null) if (values.minVerticalRadius != null)
+45 -25
View File
@@ -1,7 +1,14 @@
/* ============================================================================= /* =============================================================================
* B05_Profile_UI_Profile_Balance.ts * 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 . * (B05_Profile_UI_Profile_Panel) 700 .
* · . * · .
@@ -40,21 +47,30 @@ export function renderBalanceBar(params: BalanceBarParams): void {
} }
const { alignment } = params; const { alignment } = params;
const { balance, policy, violations } = alignment; 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?]> = [ const entries: Array<[string, string, string?]> = [
["절토", `${balance.cut_area_m2.toFixed(1)}`, "cut"],
["성토", `${balance.fill_area_m2.toFixed(1)}`, "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.imbalance_percent.toFixed(1)} % / 허용 ${balance.tolerance_percent.toFixed(0)} %`,
balance.within_tolerance ? undefined : "over", 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; const editedCount = Object.keys(alignment.edits.station_offsets).length;
if (editedCount) entries.push(["편집 측점", `${editedCount}`, "edited"]); if (editedCount) entries.push(["편집 측점", `${editedCount}`, "edited"]);
entries.forEach(([label, value, tone]) => { entries.forEach(([label, value, tone]) => {
@@ -63,27 +79,31 @@ export function renderBalanceBar(params: BalanceBarParams): void {
const caption = document.createElement("em"); const caption = document.createElement("em");
caption.textContent = label; caption.textContent = label;
item.append(caption, document.createTextNode(value)); 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); 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) { if (params.edited || params.hasIrregularStations) {
const reset = document.createElement("button"); const reset = document.createElement("button");
reset.type = "button"; reset.type = "button";
reset.className = "b05-route-profile__balance-reset"; reset.className = "b05-route-profile__balance-reset";
// 복원 목표는 **마지막 저장된 계획선**이다 — 임시저장을 거치면 편집이 정본에 // 이 버튼이 지우는 것은 **화면에 쌓인 편집 델타**다(계획선 변화점 이동·비정규
// 반영되므로 최초 자동 계산선과 다를 수 있다(2026-08-19 사용자 확인). 최초 // 측점). 임시저장을 거치면 그 편집이 정본에 반영되므로 결과적으로 "마지막 저장
// 파이프라인 결과로의 완전 복귀는 좌측 하단 [초기화](B05·B06 재계산)가 맡는다. // 상태"가 되는 것뿐, 저장 지점으로 되돌아가는 기능이 아니다. 최초 자동 계산
reset.textContent = "저장선 복원"; // 상태로의 복귀는 좌측 하단 [초기화](B05·B06 재계산)가 맡는다(2026-08-19 정정).
// 자리도 값 칩들 뒤 — 편집 상태(미저장 배지) 옆이 맥락에 맞다.
reset.textContent = "편집 되돌리기";
reset.title = reset.title =
"저장 후의 편집과 추가한 비정규 측점을 지우고 마지막 저장된 계획선으로 되돌립니다.\n" + "화면에서 수정한 계획선 변화점과 추가한 비정규 측점을 지웁니다.\n" +
"이미 임시저장한 내용은 정본에 반영돼 있어 그대로 남습니다.\n" +
"최초 자동 계산 상태로 되돌리려면 좌측 하단 [초기화]를 쓰세요."; "최초 자동 계산 상태로 되돌리려면 좌측 하단 [초기화]를 쓰세요.";
reset.addEventListener("click", () => { reset.addEventListener("click", () => {
params.onResetAll(); params.onResetAll();
+3 -1
View File
@@ -411,7 +411,9 @@ export function createRouteViewer(): RouteViewer {
fit("top"); fit("top");
markers.renderMarkers(); markers.renderMarkers();
await reloadContours(interval); await reloadContours(interval);
status.textContent = "지형을 클릭하거나 팔레트 포인트를 드래그해 배치하세요."; // 로딩이 끝나면 안내문을 지운다 — 조작법 설명이 화면에 계속 떠 있을 이유가
// 없다(2026-08-19 사용자 지시). 로딩·이동 중 안내는 그대로 쓴다.
status.textContent = "";
}, },
reloadContours, reloadContours,
setSurfaceVisible(visible) { setSurfaceVisible(visible) {
+2
View File
@@ -74,6 +74,8 @@ export interface SectionContextResponse {
method: string | null; method: string | null;
smooth: boolean | null; smooth: boolean | null;
crs_epsg: number | null; crs_epsg: number | null;
/** 프로젝트 등록(B02)에서 정한 임도 종류 — B05 계획선 법정 기준의 출발점(2026-08-19). */
road_type: string | null;
defaults: SectionOptionDefaults; defaults: SectionOptionDefaults;
/** 표준 횡단면 설정 패널(토사/암/포장) 기본값. */ /** 표준 횡단면 설정 패널(토사/암/포장) 기본값. */
standard_cross_section: StandardCrossSection; standard_cross_section: StandardCrossSection;
+11
View File
@@ -79,6 +79,13 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON
async with pool.acquire() as connection: async with pool.acquire() as connection:
route_context = await get_confirmed_route_context(connection, project_id) route_context = await get_confirmed_route_context(connection, project_id)
surface_params = await get_surface_confirmation_params(connection, str(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() defaults = SectionGenerationOptions()
return SectionContextResponse( 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, method=surface_params["method"] if route_context else None,
smooth=bool(surface_params["smooth"]) 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, crs_epsg=route_context["crs_epsg"] if route_context else None,
road_type=road_type,
defaults=SectionOptionDefaults( defaults=SectionOptionDefaults(
station_interval_m=defaults.station_interval_m, station_interval_m=defaults.station_interval_m,
cross_half_width_m=defaults.cross_half_width_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 if stage_params.get(key) is not None
} }
# 설계속도는 B05가 저장해 둔 값을 그대로 따른다 — 없으면 종류별 기본(20).
speed = options.get("design_speed_kph")
return resolve_grade_options( return resolve_grade_options(
str(grade_class), str(grade_class),
terrain_type=str(options.get("terrain_type") or "normal"), terrain_type=str(options.get("terrain_type") or "normal"),
paved=bool(options.get("paved", False)), paved=bool(options.get("paved", False)),
design_speed_kph=int(speed) if speed is not None else None,
stored=stored, stored=stored,
) )
+3
View File
@@ -138,6 +138,9 @@ class SectionContextResponse(BaseModel):
method: str | None = None method: str | None = None
smooth: bool | None = None smooth: bool | None = None
crs_epsg: int | None = None crs_epsg: int | None = None
# 프로젝트 등록(B02)에서 정한 임도 종류 — B05 계획선 법정 기준의 출발점이다.
# 화면은 이 값을 읽기만 하고 고치지 않는다(2026-08-19 사용자 확정).
road_type: str | None = None
defaults: SectionOptionDefaults defaults: SectionOptionDefaults
# 표준 횡단면 설정 패널(토사/암/포장) 기본값. config STANDARD_CROSS_SECTION 사본. # 표준 횡단면 설정 패널(토사/암/포장) 기본값. config STANDARD_CROSS_SECTION 사본.
standard_cross_section: dict[str, Any] = Field(default_factory=dict) standard_cross_section: dict[str, Any] = Field(default_factory=dict)
@@ -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 []; if (!plan) return [];
const bands = plan.blocks.reduce((sum, block) => sum + block.bands.length, 0); const bands = plan.blocks.reduce((sum, block) => sum + block.bands.length, 0);
const moved = plan.hauled_m3 + plan.transferred_m3; const moved = plan.hauled_m3 + plan.transferred_m3;
@@ -500,16 +500,18 @@ export function haulPlanChips(plan: HaulPlan | null): Array<[string, string]> {
// (그 노선은 절토 우세라 토취가 0이었다). // (그 노선은 절토 우세라 토취가 0이었다).
const gap = moved + plan.borrow_m3 - plan.fill_total_m3; 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 ratio = plan.fill_total_m3 > 0 ? Math.abs(gap) / plan.fill_total_m3 : 0;
const chips: Array<[string, string]> = [ /* (2026-08-19) · ·
[L("B06_MassHaul_HaulTotal"), `${compactVolume(moved)}`], * . "얼마나 옮기는가" . */
[L("B06_MassHaul_BlockCount"), `${plan.blocks.length} / ${bands}`], const haulTip = [`${L("B06_MassHaul_BlockCount")} ${plan.blocks.length} / ${bands}`];
];
if (plan.transfers.length) { 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) { 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([ chips.push([
L("B06_MassHaul_Check"), L("B06_MassHaul_Check"),
ratio <= 0.005 ratio <= 0.005
+23 -9
View File
@@ -632,16 +632,29 @@ export function createMassHaulSummary(
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "b06-masshaul__summary"; row.className = "b06-masshaul__summary";
const cut = result.cut_natural_m3; 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"), 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( chips.push(
@@ -649,11 +662,12 @@ export function createMassHaulSummary(
? [L("B06_MassHaul_Shortage"), `${formatVolume(result.shortage_m3)}`] ? [L("B06_MassHaul_Shortage"), `${formatVolume(result.shortage_m3)}`]
: [L("B06_MassHaul_Surplus"), `${formatVolume(result.surplus_m3)}`], : [L("B06_MassHaul_Surplus"), `${formatVolume(result.surplus_m3)}`],
); );
// 토량 분배를 켜 두면 운반 총량·블록 수가 뒤에 붙는다(사토·토취는 발생 지점 기준 합계). // 토량 분배를 켜 두면 운반 총량·검산이 뒤에 붙는다(사토·토취는 발생 지점 기준 합계).
chips.push(...haulPlanChips(haulPlan)); chips.push(...haulPlanChips(haulPlan));
for (const [label, value] of chips) { for (const [label, value, tip] of chips) {
const chip = document.createElement("span"); const chip = document.createElement("span");
chip.className = "b06-masshaul__chip"; chip.className = "b06-masshaul__chip";
if (tip) chip.title = tip;
const name = document.createElement("em"); const name = document.createElement("em");
name.textContent = label; name.textContent = label;
const amount = document.createElement("strong"); const amount = document.createElement("strong");
+15 -6
View File
@@ -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_DEFAULT_GRADE_CLASS = os.getenv("ROUTE_DEFAULT_GRADE_CLASS", "trunk")
ROUTE_MAX_COST_CELLS = int(os.getenv("ROUTE_MAX_COST_CELLS", "4000000")) 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_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_MAX_GRADE = {"trunk": 0.26, "fire": 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_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")) 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, "min_curve_length_m": 20.0,
}, },
}, },
# 임도등급 → 기본 설계속도. 작업임도는 별표에 종단 기준이 없어 20km/h를 준용한다. # 임도 종류 → **기본** 설계속도(km/h). 임도는 속도를 낼 수 없는 노선이라 20이
"grade_to_design_speed": {"trunk": 40, "branch": 30, "work": 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, "paved_exception_grade_pct": 18.0,
# 비포장 도로이면서 종단기울기 대수차가 이 값 이하이면 종단곡선을 두지 않는다 # 비포장 도로이면서 종단기울기 대수차가 이 값 이하이면 종단곡선을 두지 않는다
+5 -4
View File
@@ -200,10 +200,11 @@ export const ui_locales_b1 = {
B02_Proj_Field_Region: ["사업 지역", "Region"], B02_Proj_Field_Region: ["사업 지역", "Region"],
B02_Proj_Field_Region_Placeholder: ["예: 울진군 금강송면", "e.g. Uljin-gun"], B02_Proj_Field_Region_Placeholder: ["예: 울진군 금강송면", "e.g. Uljin-gun"],
B02_Proj_Field_RoadType: ["임도 종류", "Road type"], B02_Proj_Field_RoadType: ["임도 종류", "Road type"],
B02_Proj_RoadType_Main: ["간선임도", "Main road"], // 현행 「임도설치 및 관리 등에 관한 규정」의 3종 — 지선임도는 폐지(작업임도로
B02_Proj_RoadType_Branch: ["지선임도", "Branch road"], // 간주), 계류보전은 임도 종류가 아니라 사방사업 구분이라 뺐다(2026-08-19 확정).
B02_Proj_RoadType_Fire: ["산불진화임도", "Fire-suppression road"], B02_Proj_RoadType_Main: ["간선임도", "Main forest road"],
B02_Proj_RoadType_Stream: ["계류보전", "Stream conservation"], B02_Proj_RoadType_Fire: ["산불진화임도", "Fire-suppression forest road"],
B02_Proj_RoadType_Work: ["작업임도", "Work forest road"],
B02_Proj_Field_Year: ["사업 연도", "Project year"], B02_Proj_Field_Year: ["사업 연도", "Project year"],
B02_Proj_Field_Length: ["예상 연장 (m)", "Estimated length (m)"], B02_Proj_Field_Length: ["예상 연장 (m)", "Estimated length (m)"],
B02_Proj_Field_Length_Placeholder: ["예상 노선 길이", "Estimated route length"], B02_Proj_Field_Length_Placeholder: ["예상 노선 길이", "Estimated route length"],
+13 -7
View File
@@ -30,16 +30,22 @@ export const ui_locales_b2 = {
"유역 안쪽 상류 세류망을 굵게 강조합니다.", "유역 안쪽 상류 세류망을 굵게 강조합니다.",
"Emphasize the upstream stream network inside the basin.", "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: [ B05_Drainage_Btn_Analyze_Tip: [
"B04에서 분석해 둔 배수유역을 불러와 관을 보충하고 세부유역을 나눕니다. 분석 결과가 없으면 B04에서 먼저 실행해야 합니다.", "B04에서 분석해 둔 배수유역을 불러와, 지금 배치된 배관을 기준으로 세부유역(관이 담당하는 구역)을 다시 나눕니다. 관이 부족한 구간은 자동으로 보충합니다. B04 분석 결과가 없으면 B04에서 먼저 실행해야 합니다.",
"Loads the drainage analysis from B04, fills in culverts, and splits sub-basins. Run the analysis in B04 first if none exists.", "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_DeleteSelected: ["선택한 관 삭제", "Delete selected culvert"],
B05_Drainage_Btn_Auto: ["초기화", "Reset"], 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: [ 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_Add: ["배수관 추가", "Add culvert"],
B05_Drainage_Menu_Delete: ["배수관 삭제", "Remove culvert"], B05_Drainage_Menu_Delete: ["배수관 삭제", "Remove culvert"],