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"),
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();
+2
View File
@@ -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;
+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(
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] = []
+1
View File
@@ -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,
)
+7
View File
@@ -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,
@@ -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");
@@ -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"),
+11
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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과 같은 계산을 태운다(프론트 사본 금지).
+13 -1
View File
@@ -26,7 +26,19 @@ import {
type GradeClass = RoutePanelValues["gradeClass"];
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> {
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 {
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<RoutePanelValues["terrainType"], number>; radius: number }
RoutePanelValues["designSpeed"],
{ grade: Record<RoutePanelValues["terrainType"], number>; 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<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 {
@@ -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)
+45 -25
View File
@@ -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)}`, "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.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();
+3 -1
View File
@@ -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) {
+2
View File
@@ -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;
+11
View File
@@ -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,
)
+3
View File
@@ -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)
@@ -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
+23 -9
View File
@@ -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");
+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_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,
# 비포장 도로이면서 종단기울기 대수차가 이 값 이하이면 종단곡선을 두지 않는다
+5 -4
View File
@@ -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"],
+13 -7
View File
@@ -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"],