This commit is contained in:
2026-07-18 23:59:28 +09:00
parent 7e51cf4f14
commit eed809748b
19 changed files with 431 additions and 1640 deletions
+10
View File
@@ -45,6 +45,10 @@ export interface RouteSolveRequest {
min_uphill_grade?: number | null;
min_downhill_grade?: number | null;
allow_avoid_pass_through?: boolean;
station_interval_m?: number | null;
cross_half_width_m?: number | null;
cross_sample_interval_m?: number | null;
long_sample_interval_m?: number | null;
}
/** 경로 탐색 실행 결과 (RouteSolveResponse) */
@@ -56,6 +60,8 @@ export interface RouteSolveResponse {
metrics: Record<string, unknown>;
required_points_ok: boolean;
route_data_path: string;
longitudinal_length_m: number;
cross_section_count: number;
}
/** 경로 확정 결과 (RouteConfirmResponse) */
@@ -97,6 +103,10 @@ export interface RouteLatestResponse {
};
options?: Record<string, unknown>;
algorithm?: string;
station_interval_m?: number | null;
cross_half_width_m?: number | null;
cross_sample_interval_m?: number | null;
long_sample_interval_m?: number | null;
} | null;
}
@@ -1,4 +1,4 @@
"""B06 종횡단 생성 엔진 오케스트레이터.
"""B05 경로 계산 후 종횡단 생성하는 엔진 오케스트레이터.
확정 경로 GeoJSON과 확정 지표면 모델 sampler로 종단·횡단을 생성하고, 종단은
longitudinal/, 횡단은 cross_sections/ 아래 파일로 저장한다. DB 기록용
@@ -10,11 +10,11 @@ import json
from pathlib import Path
from typing import Any
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Sampler import build_surface_sampler
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Section import (
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import (
SectionGenerationOptions,
generate_sections,
)
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Sampler import build_surface_sampler
from common_util.common_util_json import atomic_write_json
_STAGE_SUBDIR = Path("B06_wf3_ProfileCross")
@@ -36,7 +36,9 @@ def _load_route_polyline(project_root: Path, route_data_path: str) -> list[list[
def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]:
"""횡단면 상세에서 DB data 컬럼에 저장할 요약을 만든다."""
samples = cross_section.get("samples", [])
valid_z = [s["elevation_m"] for s in samples if s.get("valid") and s.get("elevation_m") is not None]
valid_z = [
s["elevation_m"] for s in samples if s.get("valid") and s.get("elevation_m") is not None
]
return {
"chainage_m": cross_section.get("chainage_m"),
"center_z": cross_section.get("center_z"),
@@ -1,4 +1,4 @@
"""B06 종단·횡단 원시 데이터 생성.
"""B05 경로 기반 종단·횡단 원시 데이터 생성.
확정 경로 폴리라인과 표고 sampler로 CAD 인계 가능한 종단(longitudinal)·횡단
(cross) 데이터를 생성한다. BP(0m)부터 station_interval 간격 측점을 만들고
@@ -11,7 +11,7 @@ from typing import Any
import numpy as np
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Sampler import SurfaceElevationSampler
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Sampler import SurfaceElevationSampler
from config.config_system import (
SECTION_CROSS_HALF_WIDTH_M,
SECTION_CROSS_SAMPLE_INTERVAL_M,
@@ -1,4 +1,4 @@
"""B06 지표면 표고 sampler.
"""B05 종횡단 계산용 지표면 표고 sampler.
·횡단 생성기가 의존하는 최소 표고 조회 인터페이스와, 확정된 지표면 모델
(B04_wf1_Surface/models) 일괄 XY 표고 sampler로 여는 팩토리를 제공한다.
@@ -69,9 +69,7 @@ class DtmGridSampler:
if not len(xy):
return np.empty(0, dtype=np.float64), np.empty(0, dtype=bool)
z = np.asarray(
self._interpolator(np.column_stack([xy[:, 1], xy[:, 0]])), dtype=np.float64
)
z = np.asarray(self._interpolator(np.column_stack([xy[:, 1], xy[:, 0]])), dtype=np.float64)
ix = np.searchsorted(self.x, xy[:, 0], side="right") - 1
iy = np.searchsorted(self.y, xy[:, 1], side="right") - 1
inside = (ix >= 0) & (iy >= 0) & (ix < len(self.x) - 1) & (iy < len(self.y) - 1)
+56
View File
@@ -12,6 +12,8 @@ from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_wf2_Route.B05_wf2_Route_Debug import log_b05_debug
from B05_wf2_Route.B05_wf2_Route_Engine import run_route_design
from B05_wf2_Route.B05_wf2_Route_Engine_Sections import run_section_generation
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions
from B05_wf2_Route.B05_wf2_Route_Repository import (
confirm_route,
create_route,
@@ -26,6 +28,11 @@ from B05_wf2_Route.B05_wf2_Route_Schema import (
RouteSolveRequest,
RouteSolveResponse,
)
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
create_longitudinal_section,
delete_sections_for_route,
insert_cross_sections,
)
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_workflow_state import (
@@ -40,6 +47,19 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"])
def _section_options(request: RouteSolveRequest) -> SectionGenerationOptions:
defaults = SectionGenerationOptions()
return SectionGenerationOptions(
station_interval_m=request.station_interval_m or defaults.station_interval_m,
cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m,
cross_sample_interval_m=(
request.cross_sample_interval_m or defaults.cross_sample_interval_m
),
long_sample_interval_m=request.long_sample_interval_m or defaults.long_sample_interval_m,
include_endpoint=defaults.include_endpoint,
)
@router.post("/{project_id}/route/solve", response_model=RouteSolveResponse)
async def solve_route(
project_id: UUID, request: RouteSolveRequest
@@ -55,6 +75,10 @@ async def solve_route(
"options": request.options(),
"algorithm": request.algorithm,
"surface_model_id": request.surface_model_id,
"station_interval_m": request.station_interval_m,
"cross_half_width_m": request.cross_half_width_m,
"cross_sample_interval_m": request.cross_sample_interval_m,
"long_sample_interval_m": request.long_sample_interval_m,
}
log_b05_debug(
logger,
@@ -174,6 +198,36 @@ async def solve_route(
)
raise
sections = await asyncio.to_thread(
run_section_generation,
project_root,
design["route_data_path"],
request.filter_key,
request.method,
request.smooth,
options=_section_options(request),
)
await connection.begin()
try:
await delete_sections_for_route(connection, route_id)
await create_longitudinal_section(
connection,
project_id=project_id,
route_id=route_id,
data=sections["longitudinal"]["data"],
longitudinal_file_path=sections["longitudinal"]["file_path"],
)
await insert_cross_sections(
connection,
project_id=project_id,
route_id=route_id,
sections=sections["cross_sections"],
)
await connection.commit()
except Exception:
await connection.rollback()
raise
return RouteSolveResponse(
project_id=str(project_id),
route_id=route_id,
@@ -181,6 +235,8 @@ async def solve_route(
metrics=metrics,
required_points_ok=solver["required_points_ok"],
route_data_path=design["route_data_path"],
longitudinal_length_m=sections["longitudinal"]["data"]["length_m"],
cross_section_count=len(sections["cross_sections"]),
)
except LookupError as exc:
async with pool.acquire() as connection, connection.cursor() as cursor:
+6
View File
@@ -54,6 +54,10 @@ class RouteSolveRequest(BaseModel):
min_downhill_grade: float | None = None
weights: dict[str, float] | None = None
allow_avoid_pass_through: bool = Field(default=False)
station_interval_m: float | None = Field(default=None, gt=0)
cross_half_width_m: float | None = Field(default=None, gt=0)
cross_sample_interval_m: float | None = Field(default=None, gt=0)
long_sample_interval_m: float | None = Field(default=None, gt=0)
@model_validator(mode="after")
def validate_choices(self) -> "RouteSolveRequest":
@@ -96,6 +100,8 @@ class RouteSolveResponse(BaseModel):
metrics: dict[str, Any]
required_points_ok: bool
route_data_path: str
longitudinal_length_m: float
cross_section_count: int
class RouteConfirmResponse(BaseModel):
+43 -2
View File
@@ -25,6 +25,13 @@ export interface ModelBounds {
z: [number, number];
}
export interface SectionStationMarker {
center_x: number;
center_y: number;
center_z: number | null;
frame: { left_xy: [number, number] };
}
const COLORS: Record<RoutePointKind, number> = {
bp: 0x10b981,
ep: 0xef4444,
@@ -65,7 +72,8 @@ export function sceneToModel(point: THREE.Vector3, bounds: ModelBounds) {
export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBounds | null) {
const markerGroup = new THREE.Group();
const routeGroup = new THREE.Group();
scene.add(markerGroup, routeGroup);
const stationGroup = new THREE.Group();
scene.add(markerGroup, routeGroup, stationGroup);
let points = emptyPoints();
let selectedId: string | null = null;
let changeListener: ((points: RouteDesignPoints) => void) | undefined;
@@ -216,6 +224,34 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
);
}
function renderStationLines(stations: SectionStationMarker[], halfWidth: number): void {
disposeGroup(stationGroup);
const bounds = getBounds();
if (!bounds || halfWidth <= 0) return;
const points: THREE.Vector3[] = [];
stations.forEach((station) => {
if (station.center_z === null) return;
const [leftX, leftY] = station.frame.left_xy;
const center = { x: station.center_x, y: station.center_y, z: station.center_z + 0.45 };
points.push(
modelToScene(
{ x: center.x + leftX * halfWidth, y: center.y + leftY * halfWidth, z: center.z },
bounds,
),
modelToScene(
{ x: center.x - leftX * halfWidth, y: center.y - leftY * halfWidth, z: center.z },
bounds,
),
);
});
stationGroup.add(
new THREE.LineSegments(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({ color: 0xa855f7 }),
),
);
}
return {
group: markerGroup,
getPoints: () => points,
@@ -239,6 +275,10 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
},
renderMarkers,
renderRoute,
renderStationLines,
setStationLinesVisible(visible: boolean) {
stationGroup.visible = visible;
},
onChange(listener: (next: RouteDesignPoints) => void) {
changeListener = listener;
},
@@ -248,7 +288,8 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
dispose() {
disposeGroup(markerGroup);
disposeGroup(routeGroup);
scene.remove(markerGroup, routeGroup);
disposeGroup(stationGroup);
scene.remove(markerGroup, routeGroup, stationGroup);
},
};
}
+45 -3
View File
@@ -27,7 +27,13 @@ import {
type RoutePointKind,
} from "./B05_wf2_Route_UI_Markers";
import { createRoutePanel, type RoutePanelValues } from "./B05_wf2_Route_UI_Panel";
import { createRouteProfilePanel } from "./B05_wf2_Route_UI_Profile_Panel";
import { createRouteViewer } from "./B05_wf2_Route_UI_Viewer";
import {
fetchSectionContext,
fetchSectionDetail,
type SectionDetailResponse,
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch";
import "./B05_wf2_Route_UI_Style.css";
function toBounds(bounds: {
@@ -88,6 +94,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
const activeProjectId: string = projectId;
const viewer = createRouteViewer();
const profilePanel = createRouteProfilePanel();
let confirmedSurface: SurfaceModelSummary | null = null;
let latest: RouteLatestResponse | null = null;
let routeReady = false;
@@ -101,6 +108,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
onSurfaceVisible: viewer.setSurfaceVisible,
onContoursVisible: viewer.setContoursVisible,
onAxesVisible: viewer.setAxesVisible,
onStationLinesVisible: viewer.setStationLinesVisible,
onView: viewer.setView,
onResetView: () => viewer.setView("top"),
onMovePoint: viewer.beginMoveSelected,
@@ -136,10 +144,28 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
minUphillGrade: options.min_uphill_grade as number | undefined,
minDownhillGrade: options.min_downhill_grade as number | undefined,
allowAvoidPassThrough: options.allow_avoid_pass_through as boolean | undefined,
stationInterval: next.route_params?.station_interval_m ?? undefined,
crossHalfWidth: next.route_params?.cross_half_width_m ?? undefined,
crossSampleInterval: next.route_params?.cross_sample_interval_m ?? undefined,
longSampleInterval: next.route_params?.long_sample_interval_m ?? undefined,
});
viewer.markers.setPoints(restorePoints(next));
}
function renderSections(detail: SectionDetailResponse): void {
profilePanel.render(detail);
viewer.renderStationLines(detail.longitudinal.stations, panel.values().crossHalfWidth);
}
async function restoreSections(routeId: number): Promise<void> {
try {
renderSections(await fetchSectionDetail(activeProjectId, routeId));
} catch {
profilePanel.clear();
viewer.renderStationLines([], 0);
}
}
function renderLatest(next: RouteLatestResponse): void {
latest = next;
routeReady = Boolean(next.route && next.route_points.length > 1);
@@ -188,7 +214,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
const values = panel.values();
showLoadingOverlay();
try {
await solveRoute(activeProjectId, {
const solved = await solveRoute(activeProjectId, {
filter_key: latest.surface_params.source_filter,
method: latest.surface_params.method,
smooth: latest.surface_params.smooth,
@@ -207,8 +233,13 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
min_uphill_grade: values.minUphillGrade,
min_downhill_grade: values.minDownhillGrade,
allow_avoid_pass_through: values.allowAvoidPassThrough,
station_interval_m: values.stationInterval,
cross_half_width_m: values.crossHalfWidth,
cross_sample_interval_m: values.crossSampleInterval,
long_sample_interval_m: values.longSampleInterval,
});
renderLatest(await fetchLatestRoute(activeProjectId));
renderSections(await fetchSectionDetail(activeProjectId, solved.route_id));
showToast("최적 경로 계산이 완료되었습니다.", "success");
} catch (error) {
showToast(error instanceof Error ? error.message : "경로 계산에 실패했습니다.", "error");
@@ -232,10 +263,11 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
}
}
const [workflowState, models, latestResponse] = await Promise.all([
const [workflowState, models, latestResponse, sectionContext] = await Promise.all([
fetchWorkflowState(activeProjectId),
listSurfaceModels(activeProjectId),
fetchLatestRoute(activeProjectId),
fetchSectionContext(activeProjectId),
]);
confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null;
if (!confirmedSurface) {
@@ -245,6 +277,12 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
activeProjectId,
latestResponse.surface_params.source_filter,
);
panel.restore({
stationInterval: sectionContext.defaults.station_interval_m,
crossHalfWidth: sectionContext.defaults.cross_half_width_m,
crossSampleInterval: sectionContext.defaults.cross_sample_interval_m,
longSampleInterval: sectionContext.defaults.long_sample_interval_m,
});
restorePanel(latestResponse);
await viewer.loadSurface(
activeProjectId,
@@ -255,16 +293,20 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
toBounds(cloud.bounds),
);
renderLatest(latestResponse);
if (latestResponse.route) await restoreSections(latestResponse.route.id);
}
latest = latestResponse;
restoring = false;
const mainContent = document.createElement("div");
mainContent.className = "b05-route__main";
mainContent.append(viewer.root, profilePanel.root);
const layout = createWorkflowLayout({
title: "노선 설계",
steps: workflowSteps(),
activeStep: 2,
leftPanel: panel.root,
mainContent: viewer.root,
mainContent,
stages: workflowState.stages,
currentStage: workflowState.current_stage,
routes: WORKFLOW_STEP_ROUTES,
+42
View File
@@ -1,4 +1,5 @@
import type { PlacedRoutePoint, RoutePointKind } from "./B05_wf2_Route_UI_Markers";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
export interface RoutePanelValues {
contourInterval: number;
@@ -11,6 +12,10 @@ export interface RoutePanelValues {
minUphillGrade: number | null;
minDownhillGrade: number | null;
allowAvoidPassThrough: boolean;
stationInterval: number;
crossHalfWidth: number;
crossSampleInterval: number;
longSampleInterval: number;
}
interface PanelCallbacks {
@@ -20,6 +25,7 @@ interface PanelCallbacks {
onSurfaceVisible: (visible: boolean) => void;
onContoursVisible: (visible: boolean) => void;
onAxesVisible: (visible: boolean) => void;
onStationLinesVisible: (visible: boolean) => void;
onView: (view: "iso" | "top" | "front" | "side") => void;
onResetView: () => void;
onMovePoint: () => void;
@@ -30,6 +36,10 @@ interface PanelCallbacks {
type WrappedInput = HTMLInputElement & { wrapper: HTMLLabelElement };
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
function section(title: string): { root: HTMLElement; body: HTMLElement } {
const root = document.createElement("section");
root.className = "b05-route__panel-section";
@@ -92,6 +102,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
const surfaceVisible = checkbox("지표면", true);
const contoursVisible = checkbox("등고선", true);
const axesVisible = checkbox("축 표시", false);
const stationLinesVisible = checkbox(L("B05_Route_Field_StationLines"), true);
surfaceVisible.addEventListener("change", () =>
callbacks.onSurfaceVisible(surfaceVisible.checked),
);
@@ -99,11 +110,15 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
callbacks.onContoursVisible(contoursVisible.checked),
);
axesVisible.addEventListener("change", () => callbacks.onAxesVisible(axesVisible.checked));
stationLinesVisible.addEventListener("change", () =>
callbacks.onStationLinesVisible(stationLinesVisible.checked),
);
view.body.append(
viewButtons,
surfaceVisible.wrapper,
contoursVisible.wrapper,
axesVisible.wrapper,
stationLinesVisible.wrapper,
button("뷰 초기화", callbacks.onResetView),
);
@@ -190,6 +205,18 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
help,
);
const sectionOptions = section(L("B05_Route_Group_SectionOptions"));
const stationInterval = numberField(L("B05_Route_Field_StationInterval"));
const crossHalfWidth = numberField(L("B05_Route_Field_CrossHalfWidth"));
const crossSampleInterval = numberField(L("B05_Route_Field_CrossSample"));
const longSampleInterval = numberField(L("B05_Route_Field_LongSample"));
sectionOptions.body.append(
stationInterval.wrapper,
crossHalfWidth.wrapper,
crossSampleInterval.wrapper,
longSampleInterval.wrapper,
);
const result = section("경로 설계 산출 결과");
const stale = document.createElement("span");
stale.className = "b05-route__stale";
@@ -217,6 +244,10 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
maxDownhillGrade,
minUphillGrade,
minDownhillGrade,
stationInterval,
crossHalfWidth,
crossSampleInterval,
longSampleInterval,
];
inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange));
root.append(
@@ -225,6 +256,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
palette.root,
selected.root,
conditions.root,
sectionOptions.root,
result.root,
actionRow,
);
@@ -243,6 +275,10 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
minUphillGrade: parseOptional(minUphillGrade),
minDownhillGrade: parseOptional(minDownhillGrade),
allowAvoidPassThrough: avoidPass.checked,
stationInterval: Number(stationInterval.value),
crossHalfWidth: Number(crossHalfWidth.value),
crossSampleInterval: Number(crossSampleInterval.value),
longSampleInterval: Number(longSampleInterval.value),
};
},
restore(values: Partial<RoutePanelValues>) {
@@ -256,6 +292,12 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
if (values.minUphillGrade != null) minUphillGrade.value = String(values.minUphillGrade);
if (values.minDownhillGrade != null) minDownhillGrade.value = String(values.minDownhillGrade);
if (values.allowAvoidPassThrough != null) avoidPass.checked = values.allowAvoidPassThrough;
if (values.stationInterval != null) stationInterval.value = String(values.stationInterval);
if (values.crossHalfWidth != null) crossHalfWidth.value = String(values.crossHalfWidth);
if (values.crossSampleInterval != null)
crossSampleInterval.value = String(values.crossSampleInterval);
if (values.longSampleInterval != null)
longSampleInterval.value = String(values.longSampleInterval);
},
setSelected(point: PlacedRoutePoint | null) {
selected.root.hidden = !point;
@@ -0,0 +1,65 @@
import type {
LongitudinalSection,
SectionDetailResponse,
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch";
import { createLongitudinalProfile } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View";
import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css";
const COLLAPSED_KEY = "b05-route-profile-collapsed";
function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection {
return {
...data,
samples: data.samples.map((sample) => ({
...sample,
elevation_m: sample.elevation_m ?? sample.z ?? null,
})),
};
}
export function createRouteProfilePanel() {
const root = document.createElement("section");
root.className = "b05-route-profile";
const header = document.createElement("header");
const title = document.createElement("strong");
title.textContent = "종단면도";
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "b05-route-profile__toggle";
const body = document.createElement("div");
body.className = "b05-route-profile__body";
const empty = document.createElement("p");
empty.className = "b05-route-profile__empty";
empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다.";
body.append(empty);
header.append(title, toggle);
root.append(header, body);
function setCollapsed(collapsed: boolean): void {
root.classList.toggle("is-collapsed", collapsed);
toggle.textContent = collapsed ? "펼치기" : "접기";
toggle.setAttribute("aria-expanded", String(!collapsed));
sessionStorage.setItem(COLLAPSED_KEY, String(collapsed));
}
toggle.addEventListener("click", () => setCollapsed(!root.classList.contains("is-collapsed")));
setCollapsed(sessionStorage.getItem(COLLAPSED_KEY) === "true");
return {
root,
render(detail: SectionDetailResponse) {
body.replaceChildren(
createLongitudinalProfile(
normalizedLongitudinal(detail.longitudinal),
null,
1,
undefined,
() => undefined,
),
);
},
clear() {
body.replaceChildren(empty);
},
};
}
+62 -2
View File
@@ -15,15 +15,75 @@
overflow: hidden;
}
.b05-route__viewport {
position: relative;
.b05-route__main {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
.b05-route__viewport {
position: relative;
width: 100%;
flex: 1 1 auto;
height: auto;
min-height: 0;
overflow: hidden;
background: var(--color-surface);
}
.b05-route-profile {
flex: 0 0 250px;
min-height: 0;
overflow: hidden;
border-top: 1px solid var(--color-border);
background: var(--color-surface-raised);
transition: flex-basis var(--transition-fast);
}
.b05-route-profile.is-collapsed {
flex-basis: 42px;
}
.b05-route-profile > header {
display: flex;
height: 42px;
align-items: center;
justify-content: space-between;
padding: 0 var(--spacing-16);
border-bottom: 1px solid var(--color-border);
color: var(--color-text);
}
.b05-route-profile__toggle {
border: 0;
background: transparent;
color: var(--color-primary);
cursor: pointer;
}
.b05-route-profile__body {
height: calc(100% - 42px);
overflow: auto;
}
.b05-route-profile.is-collapsed .b05-route-profile__body {
display: none;
}
.b05-route-profile__empty {
margin: 0;
padding: var(--spacing-24);
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.b05-route-profile .b06-section__chart {
max-height: 205px;
}
.b05-route__viewport canvas {
display: block;
width: 100%;
+5
View File
@@ -9,6 +9,7 @@ import {
type ModelBounds,
type RouteMarkers,
type RoutePointKind,
type SectionStationMarker,
} from "./B05_wf2_Route_UI_Markers";
function disposeObject(object: THREE.Object3D | null): void {
@@ -40,6 +41,8 @@ export interface RouteViewer {
setSurfaceVisible: (visible: boolean) => void;
setContoursVisible: (visible: boolean) => void;
setAxesVisible: (visible: boolean) => void;
setStationLinesVisible: (visible: boolean) => void;
renderStationLines: (stations: SectionStationMarker[], halfWidth: number) => void;
setView: (view: "iso" | "top" | "front" | "side") => void;
beginMoveSelected: () => void;
dispose: () => void;
@@ -243,6 +246,8 @@ export function createRouteViewer(): RouteViewer {
setAxesVisible(visible) {
axes.visible = visible;
},
setStationLinesVisible: markers.setStationLinesVisible,
renderStationLines: markers.renderStationLines,
setView: fit,
beginMoveSelected() {
movingSelected = true;
@@ -3,7 +3,6 @@
* 3차 워크플로우(종·횡단 생성) API 클라이언트
*
* 백엔드 계약 (B06_wf3_ProfileCross_Router.py):
* POST /api/projects/{project_id}/sections/generate → 종횡단 생성 + DB 기록
* GET /api/projects/{project_id}/sections/context → 확정 경로 + 기본 옵션
* GET /api/projects/{project_id}/sections/{route_id} → 종단 요약 조회
* GET /api/projects/{project_id}/sections/{route_id}/detail → 종횡단 원시 샘플 조회
@@ -16,30 +15,6 @@
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
/** 종횡단 생성 실행 요청 (SectionGenerateRequest) */
export interface SectionGenerateRequest {
route_id: number;
filter_key: string;
method?: string;
smooth?: boolean;
crs?: string | null;
station_interval_m?: number | null;
cross_half_width_m?: number | null;
cross_sample_interval_m?: number | null;
long_sample_interval_m?: number | null;
}
/** 종횡단 생성 결과 (SectionGenerateResponse) */
export interface SectionGenerateResponse {
status: string;
project_id: string;
route_id: number;
longitudinal_id: number;
cross_section_count: number;
length_m: number;
longitudinal_file_path: string;
}
export interface SectionOptionDefaults {
station_interval_m: number;
cross_half_width_m: number;
@@ -71,7 +46,8 @@ export interface SectionSummaryResponse {
export interface SectionSample {
chainage_m?: number;
offset_m?: number;
elevation_m: number | null;
elevation_m?: number | null;
z?: number | null;
valid: boolean;
}
@@ -82,6 +58,9 @@ export interface SectionStation {
kind: "bp" | "ep" | "regular";
center_z: number | null;
azimuth_deg: number | null;
center_x: number;
center_y: number;
frame: { left_xy: [number, number] };
}
export interface LongitudinalSection {
@@ -131,17 +110,6 @@ async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
}
}
/** 확정 경로에서 종·횡단을 생성한다 (측점 배열 → 종단 프로필 → 횡단 샘플). */
export async function generateSections(
projectId: string,
request: SectionGenerateRequest,
): Promise<SectionGenerateResponse> {
return requestJson<SectionGenerateResponse>(`/projects/${projectId}/sections/generate`, {
method: "POST",
body: JSON.stringify(request),
});
}
/** 최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 조회한다. */
export async function fetchSectionContext(projectId: string): Promise<SectionContextResponse> {
return requestJson<SectionContextResponse>(`/projects/${projectId}/sections/context`, {
@@ -10,30 +10,23 @@ from fastapi import APIRouter
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_wf2_Route.B05_wf2_Route_Repository import get_latest_route
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine import run_section_generation
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Section import SectionGenerationOptions
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
confirm_sections_for_route,
count_cross_sections,
create_longitudinal_section,
delete_sections_for_route,
get_confirmed_route_context,
get_longitudinal_section,
insert_cross_sections,
)
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import (
SectionConfirmResponse,
SectionContextResponse,
SectionDetailResponse,
SectionGenerateRequest,
SectionGenerateResponse,
SectionOptionDefaults,
SectionSummaryResponse,
)
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_workflow_state import complete_stage, fail_stage, start_stage
from common_util.common_util_workflow_state import complete_stage
from config.config_db import get_db_pool
from config.config_system import SECTION_VERTICAL_EXAGGERATION
@@ -41,121 +34,6 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
def _build_options(request: SectionGenerateRequest) -> SectionGenerationOptions:
"""요청의 옵션(미지정은 config 기본값)으로 SectionGenerationOptions를 만든다."""
defaults = SectionGenerationOptions()
return SectionGenerationOptions(
station_interval_m=request.station_interval_m or defaults.station_interval_m,
cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m,
cross_sample_interval_m=request.cross_sample_interval_m or defaults.cross_sample_interval_m,
long_sample_interval_m=request.long_sample_interval_m or defaults.long_sample_interval_m,
include_endpoint=defaults.include_endpoint,
)
@router.post("/{project_id}/sections/generate", response_model=SectionGenerateResponse)
async def generate_sections(
project_id: UUID, request: SectionGenerateRequest
) -> SectionGenerateResponse | JSONResponse:
"""확정 경로에서 종횡단을 생성·저장하고 DB에 기록한다."""
pool = get_db_pool()
try:
params = {
"route_id": request.route_id,
"filter_key": request.filter_key,
"method": request.method,
"smooth": request.smooth,
"station_interval_m": request.station_interval_m,
"cross_half_width_m": request.cross_half_width_m,
"cross_sample_interval_m": request.cross_sample_interval_m,
"long_sample_interval_m": request.long_sample_interval_m,
"crs": request.crs,
}
async with pool.acquire() as connection:
async with connection.cursor() as cursor:
await start_stage(cursor, str(project_id), 3, params)
await connection.commit()
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
latest = await get_latest_route(connection, project_id)
if not latest or latest["id"] != request.route_id:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "대상 경로를 찾을 수 없습니다."},
)
route_data_path = latest["route_data_path"]
options = _build_options(request)
design = await asyncio.to_thread(
run_section_generation,
project_root,
route_data_path,
request.filter_key,
request.method,
request.smooth,
options=options,
crs=request.crs,
)
await connection.begin()
try:
await delete_sections_for_route(connection, request.route_id)
longitudinal_id = await create_longitudinal_section(
connection,
project_id=project_id,
route_id=request.route_id,
data=design["longitudinal"]["data"],
longitudinal_file_path=design["longitudinal"]["file_path"],
)
await insert_cross_sections(
connection,
project_id=project_id,
route_id=request.route_id,
sections=design["cross_sections"],
)
async with connection.cursor() as cursor:
await complete_stage(cursor, str(project_id), 3)
await connection.commit()
except Exception:
await connection.rollback()
raise
return SectionGenerateResponse(
project_id=str(project_id),
route_id=request.route_id,
longitudinal_id=longitudinal_id,
cross_section_count=len(design["cross_sections"]),
length_m=design["longitudinal"]["data"]["length_m"],
longitudinal_file_path=design["longitudinal"]["file_path"],
)
except LookupError as exc:
async with pool.acquire() as connection, connection.cursor() as cursor:
await fail_stage(cursor, str(project_id), 3, str(exc))
await connection.commit()
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except FileNotFoundError as exc:
async with pool.acquire() as connection, connection.cursor() as cursor:
await fail_stage(cursor, str(project_id), 3, str(exc))
await connection.commit()
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except (OSError, ValueError) as exc:
async with pool.acquire() as connection, connection.cursor() as cursor:
await fail_stage(cursor, str(project_id), 3, str(exc))
await connection.commit()
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception as exc:
logger.exception("B06 종횡단 생성 실패: project_id=%s", project_id)
async with pool.acquire() as connection, connection.cursor() as cursor:
await fail_stage(cursor, str(project_id), 3, str(exc))
await connection.commit()
return JSONResponse(
status_code=500,
content={"status": "error", "message": "종횡단 생성 처리 중 오류가 발생했습니다."},
)
@router.get("/{project_id}/sections/context", response_model=SectionContextResponse)
async def get_section_context(project_id: UUID) -> SectionContextResponse | JSONResponse:
"""최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 반환한다."""
@@ -240,9 +118,7 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
return {"longitudinal": longitudinal, "cross_sections": cross_sections}
@router.get(
"/{project_id}/sections/{route_id}/detail", response_model=SectionDetailResponse
)
@router.get("/{project_id}/sections/{route_id}/detail", response_model=SectionDetailResponse)
async def get_section_detail(
project_id: UUID, route_id: int
) -> SectionDetailResponse | JSONResponse:
@@ -265,9 +141,7 @@ async def get_section_detail(
)
return SectionDetailResponse(**detail)
except FileNotFoundError as exc:
return JSONResponse(
status_code=404, content={"status": "error", "message": str(exc)}
)
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except (OSError, ValueError, json.JSONDecodeError) as exc:
logger.warning(
"B06 종횡단 상세 파일 조회 실패: project_id=%s route_id=%s error=%s",
@@ -1,38 +1,8 @@
"""B06 종횡단 생성 요청·응답 검증 모델."""
"""B06 종횡단 조회·확정 응답 검증 모델."""
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class SectionGenerateRequest(BaseModel):
"""종횡단 생성 실행 요청."""
model_config = ConfigDict(extra="forbid")
route_id: int = Field(gt=0, description="종횡단을 생성할 확정 경로 routes.id")
filter_key: str = Field(description="지면 필터 키 (grid_min_z/csf/pmf)")
method: str = Field(default="dtm", description="지표면 표현")
smooth: bool = Field(default=False)
crs: str | None = Field(default=None, description="좌표계 (예: EPSG:5178)")
# 측점/횡단 옵션 (미지정 시 config 기본값)
station_interval_m: float | None = Field(default=None, gt=0)
cross_half_width_m: float | None = Field(default=None, gt=0)
cross_sample_interval_m: float | None = Field(default=None, gt=0)
long_sample_interval_m: float | None = Field(default=None, gt=0)
class SectionGenerateResponse(BaseModel):
"""종횡단 생성 결과."""
status: str = "success"
project_id: str
route_id: int
longitudinal_id: int
cross_section_count: int
length_m: float
longitudinal_file_path: str
from pydantic import BaseModel
class SectionConfirmResponse(BaseModel):
@@ -1,16 +1,3 @@
/* =============================================================================
* B06_wf3_ProfileCross_UI_Page.ts
* 로그인 후 06: 3차 워크플로우 (종·횡단 생성)
*
* 3단 레이아웃 (frontend.md §2):
* 상단: 페이지 타이틀 + 진행 단계 스텝바 (createWorkflowLayout)
* 좌측: 대상 경로 ID + 지표면 참조 + 측점/횡단 옵션 폼
* 우측: 종·횡단 생성 결과(연장·횡단 개수·파일) 카드
*
* 이벤트 핸들러 명명 (frontend.md §4): onB06_Profile_[기능]_[액션]
* 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3).
* ========================================================================== */
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
@@ -30,36 +17,30 @@ import {
} from "../A00_Common/b_workflow_nav";
import {
confirmSections,
fetchSectionDetail,
fetchSectionContext,
generateSections,
fetchSectionDetail,
getSections,
type SectionContextResponse,
type SectionDetailResponse,
type SectionGenerateRequest,
type SectionGenerateResponse,
type SectionSummaryResponse,
} from "./B06_wf3_ProfileCross_Api_Fetch";
import { createSectionView } from "./B06_wf3_ProfileCross_UI_Section_View";
import "./B06_wf3_ProfileCross_UI_Style.css";
/** locale 헬퍼 */
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 라벨 + fieldset 그룹 컨테이너 생성. */
function buildGroup(legend: string): HTMLElement {
const group = document.createElement("fieldset");
group.className = "b06-profile__group";
const legendEl = document.createElement("legend");
legendEl.className = "b06-profile__group-legend";
legendEl.textContent = legend;
group.append(legendEl);
const legendElement = document.createElement("legend");
legendElement.className = "b06-profile__group-legend";
legendElement.textContent = legend;
group.append(legendElement);
return group;
}
/** 읽기 전용 경로 컨텍스트 표시 행. */
function buildInfoLine(label: string): { root: HTMLElement; value: HTMLElement } {
const root = document.createElement("div");
root.className = "b06-profile__info-line";
@@ -71,20 +52,24 @@ function buildInfoLine(label: string): { root: HTMLElement; value: HTMLElement }
return { root, value };
}
/** 숫자 입력값을 파싱. 빈 값이면 null. */
function parseNumber(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
function metricRow(label: string, value: string): HTMLElement {
const row = document.createElement("div");
row.className = "b06-profile__metric";
const key = document.createElement("span");
key.className = "b06-profile__metric-key";
key.textContent = label;
const metricValue = document.createElement("span");
metricValue.className = "b06-profile__metric-val";
metricValue.textContent = value;
row.append(key, metricValue);
return row;
}
export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
let currentRouteId: number | null = null;
let sectionContext: SectionContextResponse | null = null;
let sectionDetail: SectionDetailResponse | null = null;
/* ---- 좌측: 대상 경로 ---- */
const routeGroup = buildGroup(L("B06_Profile_Group_Route"));
const routeIdInfo = buildInfoLine(L("B06_Profile_Field_RouteId"));
const filterInfo = buildInfoLine(L("B06_Profile_Field_Filter"));
@@ -99,106 +84,45 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
crsInfo.root,
);
/* ---- 좌측: 측점/횡단 옵션 ---- */
const optionGroup = buildGroup(L("B06_Profile_Group_Options"));
const stationField = createInputField({
label: L("B06_Profile_Field_StationInterval"),
type: "number",
});
const halfWidthField = createInputField({
label: L("B06_Profile_Field_CrossHalfWidth"),
type: "number",
});
const crossSampleField = createInputField({
label: L("B06_Profile_Field_CrossSample"),
type: "number",
});
const longSampleField = createInputField({
label: L("B06_Profile_Field_LongSample"),
type: "number",
});
const resultGroup = buildGroup(L("B06_Profile_Result_Title"));
const resultBody = document.createElement("div");
resultBody.className = "b06-profile__result-body";
resultGroup.append(resultBody);
const displayGroup = buildGroup(L("B06_Profile_Group_Display"));
const verticalExaggerationField = createInputField({
label: L("B06_Profile_Field_VerticalExaggeration"),
type: "number",
});
verticalExaggerationField.input.min = "0.1";
verticalExaggerationField.input.step = "0.1";
displayGroup.append(verticalExaggerationField.root);
optionGroup.append(
stationField.root,
halfWidthField.root,
crossSampleField.root,
longSampleField.root,
verticalExaggerationField.root,
);
const generateButton = createButton({
label: L("B06_Profile_Btn_Generate"),
variant: "filled",
onClick: () => void onB06_Profile_Generate_Click(),
});
const confirmButton = createButton({
label: L("B06_Profile_Btn_Confirm"),
variant: "ghost",
onClick: () => void onB06_Profile_Confirm_Click(),
variant: "filled",
onClick: () => void confirmCurrentSections(),
});
confirmButton.disabled = true;
const actionRow = document.createElement("div");
actionRow.className = "b06-profile__actions";
actionRow.append(generateButton, confirmButton);
actionRow.append(confirmButton);
const leftForm = document.createElement("div");
leftForm.className = "b06-profile__form";
leftForm.append(routeGroup, optionGroup, actionRow);
leftForm.append(routeGroup, resultGroup, displayGroup, actionRow);
const sectionView = createSectionView();
/* ---- 우측: 결과 ---- */
const resultTitle = document.createElement("h3");
resultTitle.className = "b06-profile__result-title";
resultTitle.textContent = L("B06_Profile_Result_Title");
const resultBody = document.createElement("div");
resultBody.className = "b06-profile__result-body";
function renderEmptyResult(): void {
resultBody.replaceChildren();
const empty = document.createElement("p");
empty.className = "b06-profile__empty";
empty.textContent = L("B06_Profile_Result_Empty");
resultBody.append(empty);
}
function renderResultMessage(message: string): void {
resultBody.replaceChildren();
function renderMessage(message: string): void {
const text = document.createElement("p");
text.className = "b06-profile__empty";
text.textContent = message;
resultBody.append(text);
}
function metricRow(label: string, value: string): HTMLElement {
const row = document.createElement("div");
row.className = "b06-profile__metric";
const key = document.createElement("span");
key.className = "b06-profile__metric-key";
key.textContent = label;
const val = document.createElement("span");
val.className = "b06-profile__metric-val";
val.textContent = value;
row.append(key, val);
return row;
}
function renderResult(result: SectionGenerateResponse): void {
resultBody.replaceChildren();
resultBody.append(
metricRow(L("B06_Profile_Result_Length"), result.length_m.toFixed(2)),
metricRow(L("B06_Profile_Result_CrossCount"), String(result.cross_section_count)),
metricRow(L("B06_Profile_Result_Path"), result.longitudinal_file_path),
);
resultBody.replaceChildren(text);
}
function renderSummary(result: SectionSummaryResponse): void {
const path = result.longitudinal?.longitudinal_file_path;
resultBody.replaceChildren();
resultBody.append(
resultBody.replaceChildren(
metricRow(
L("B06_Profile_Result_Length"),
result.length_m === null ? "-" : result.length_m.toFixed(2),
@@ -208,94 +132,20 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
);
}
const resultCard = document.createElement("div");
resultCard.className = "b06-profile__result";
resultCard.append(resultTitle, resultBody);
const sectionView = createSectionView();
const mainContent = document.createElement("div");
mainContent.className = "b06-profile__main";
mainContent.append(resultCard, sectionView.root);
function verticalExaggeration(): number {
const parsed = Number(verticalExaggerationField.input.value);
return Number.isFinite(parsed) && parsed >= 0.1 ? parsed : 1;
}
verticalExaggerationField.input.addEventListener("input", () => {
const exaggeration = parseNumber(verticalExaggerationField.input.value);
if (sectionDetail && exaggeration !== null && exaggeration >= 0.1) {
sectionView.render(sectionDetail, exaggeration);
}
if (sectionDetail) sectionView.render(sectionDetail, verticalExaggeration());
});
/* ---- 이벤트 핸들러 ---- */
function getProjectId(): string | null {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) showToast(L("B06_Profile_Error_Project"), "error");
return projectId;
}
async function loadSectionDetail(projectId: string, routeId: number): Promise<void> {
try {
sectionDetail = await fetchSectionDetail(projectId, routeId);
sectionView.render(sectionDetail, parseNumber(verticalExaggerationField.input.value) ?? 1);
} catch (error) {
sectionDetail = null;
sectionView.clear();
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L("B06_Profile_Detail_Failed")}${detail}`, "error");
}
}
function buildGenerateRequest(): SectionGenerateRequest | null {
if (
sectionContext?.route_id === null ||
!sectionContext?.route_id ||
!sectionContext.filter_key ||
!sectionContext.method
) {
return null;
}
return {
route_id: sectionContext.route_id,
filter_key: sectionContext.filter_key,
method: sectionContext.method,
smooth: sectionContext.smooth ?? false,
crs: sectionContext.crs_epsg === null ? null : `EPSG:${sectionContext.crs_epsg}`,
station_interval_m: parseNumber(stationField.input.value),
cross_half_width_m: parseNumber(halfWidthField.input.value),
cross_sample_interval_m: parseNumber(crossSampleField.input.value),
long_sample_interval_m: parseNumber(longSampleField.input.value),
};
}
async function onB06_Profile_Generate_Click(): Promise<boolean> {
const projectId = getProjectId();
if (!projectId) return false;
const request = buildGenerateRequest();
if (!request) return false;
async function confirmCurrentSections(): Promise<void> {
if (!projectId || currentRouteId === null) return;
showLoadingOverlay();
try {
const result = await generateSections(projectId, request);
currentRouteId = result.route_id;
renderResult(result);
await loadSectionDetail(projectId, result.route_id);
confirmButton.disabled = false;
showToast(L("B06_Profile_Generate_Success"), "success");
return true;
} catch (error) {
const detail = error instanceof Error ? error.message : L("B06_Profile_Generate_Failed");
showToast(`${L("B06_Profile_Generate_Failed")} ${detail}`, "error");
return false;
} finally {
hideLoadingOverlay();
}
}
async function onB06_Profile_Confirm_Click(): Promise<void> {
const projectId = getProjectId();
if (!projectId) return;
const routeId = currentRouteId;
if (routeId === null) return;
showLoadingOverlay();
try {
await confirmSections(projectId, routeId);
await confirmSections(projectId, currentRouteId);
showToast(L("B06_Profile_Confirm_Success"), "success");
} catch (error) {
const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed");
@@ -305,22 +155,15 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
}
}
renderEmptyResult();
let workflowState: WorkflowState | undefined;
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
let context: SectionContextResponse | null = null;
if (projectId) {
const [contextResult, workflowResult] = await Promise.allSettled([
fetchSectionContext(projectId),
fetchWorkflowState(projectId),
]);
if (contextResult.status === "fulfilled") {
sectionContext = contextResult.value;
} else {
const reason = contextResult.reason;
const detail = reason instanceof Error ? ` ${reason.message}` : "";
showToast(`${L("B06_Profile_Context_Failed")}${detail}`, "error");
}
if (contextResult.status === "fulfilled") context = contextResult.value;
else showToast(L("B06_Profile_Context_Failed"), "error");
if (workflowResult.status === "fulfilled") workflowState = workflowResult.value;
}
@@ -329,64 +172,53 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
steps: workflowSteps(),
activeStep: 3,
leftPanel: leftForm,
mainContent,
mainContent: sectionView.root,
stages: workflowState?.stages,
currentStage: workflowState?.current_stage,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (projectId) {
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
}
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
},
});
root.replaceChildren(layout.root);
if (!projectId || !sectionContext) {
generateButton.disabled = true;
confirmButton.disabled = true;
if (projectId) renderResultMessage(L("B06_Profile_Context_Failed"));
if (!projectId) {
renderMessage(L("B06_Profile_Error_Project"));
return;
}
if (!context) {
renderMessage(L("B06_Profile_Context_Failed"));
return;
}
const context = sectionContext;
routeIdInfo.value.textContent = context.route_id === null ? "-" : String(context.route_id);
filterInfo.value.textContent = context.filter_key ?? "-";
methodInfo.value.textContent = context.method ?? "-";
smoothInfo.value.textContent =
context.smooth === null
? "-"
: context.smooth
? L("B06_Profile_Smooth_On")
: L("B06_Profile_Smooth_Off");
smoothInfo.value.textContent = context.smooth
? L("B06_Profile_Smooth_On")
: L("B06_Profile_Smooth_Off");
crsInfo.value.textContent = context.crs_epsg === null ? "-" : `EPSG:${context.crs_epsg}`;
stationField.input.value = String(context.defaults.station_interval_m);
halfWidthField.input.value = String(context.defaults.cross_half_width_m);
crossSampleField.input.value = String(context.defaults.cross_sample_interval_m);
longSampleField.input.value = String(context.defaults.long_sample_interval_m);
verticalExaggerationField.input.value = String(context.defaults.vertical_exaggeration);
if (context.route_id === null) {
generateButton.disabled = true;
confirmButton.disabled = true;
renderResultMessage(L("B06_Profile_No_Confirmed_Route"));
renderMessage(L("B06_Profile_Calculate_In_B05"));
return;
}
currentRouteId = context.route_id;
confirmButton.disabled = true;
try {
const existing = await getSections(projectId, context.route_id);
if (existing.longitudinal) {
renderSummary(existing);
await loadSectionDetail(projectId, context.route_id);
confirmButton.disabled = false;
if (!existing.longitudinal) {
renderMessage(L("B06_Profile_Calculate_In_B05"));
return;
}
renderResultMessage(L("B06_Profile_Auto_Generating"));
const generated = await onB06_Profile_Generate_Click();
confirmButton.disabled = !generated;
renderSummary(existing);
sectionDetail = await fetchSectionDetail(projectId, context.route_id);
sectionView.render(sectionDetail, verticalExaggeration());
confirmButton.disabled = false;
} catch (error) {
const detail = error instanceof Error ? error.message : L("B06_Profile_Generate_Failed");
showToast(`${L("B06_Profile_Generate_Failed")} ${detail}`, "error");
const detail = error instanceof Error ? ` ${error.message}` : "";
renderMessage(L("B06_Profile_Calculate_In_B05"));
showToast(`${L("B06_Profile_Detail_Failed")}${detail}`, "error");
}
}
@@ -8,7 +8,7 @@ import type {
const SVG_NS = "http://www.w3.org/2000/svg";
const LONG_WIDTH = 1200;
const LONG_HEIGHT = 310;
const LONG_HEIGHT = 220;
const CROSS_WIDTH = 560;
const CROSS_HEIGHT = 260;
const LONG_PAD = { left: 62, right: 24, top: 30, bottom: 52 };
File diff suppressed because it is too large Load Diff
+10 -18
View File
@@ -725,6 +725,12 @@ export const ui_locales = {
B05_Route_Solve_Failed: ["경로 탐색에 실패했습니다.", "Route solve failed."],
B05_Route_Confirm_Success: ["경로를 확정했습니다.", "Route confirmed."],
B05_Route_Confirm_Failed: ["경로 확정에 실패했습니다.", "Route confirm failed."],
B05_Route_Group_SectionOptions: ["측점·횡단 옵션", "Station & Cross Options"],
B05_Route_Field_StationInterval: ["측점 간격(m)", "Station interval (m)"],
B05_Route_Field_CrossHalfWidth: ["횡단 반폭(m)", "Cross half-width (m)"],
B05_Route_Field_CrossSample: ["횡단 샘플 간격(m)", "Cross sample interval (m)"],
B05_Route_Field_LongSample: ["종단 샘플 간격(m)", "Long sample interval (m)"],
B05_Route_Field_StationLines: ["측점 가로선", "Station cross lines"],
/* --- B06_wf3_ProfileCross 종·횡단 생성 --- */
B06_Profile_Title: ["3차 · 종·횡단 생성", "Step 3 · Profile & Cross-section"],
@@ -733,39 +739,25 @@ export const ui_locales = {
B06_Profile_Field_Filter: ["지면 필터", "Ground filter"],
B06_Profile_Field_Method: ["지표면 표현", "Surface method"],
B06_Profile_Field_Crs: ["좌표계", "CRS"],
B06_Profile_Group_Options: ["측점·횡단 옵션", "Station & Cross Options"],
B06_Profile_Field_StationInterval: ["측점 간격(m)", "Station interval (m)"],
B06_Profile_Field_CrossHalfWidth: ["횡단 반폭(m)", "Cross half-width (m)"],
B06_Profile_Field_CrossSample: ["횡단 샘플 간격(m)", "Cross sample interval (m)"],
B06_Profile_Field_LongSample: ["종단 샘플 간격(m)", "Long sample interval (m)"],
B06_Profile_Group_Display: ["표시 옵션", "Display Options"],
B06_Profile_Field_VerticalExaggeration: ["높이 배율", "Vertical exaggeration"],
B06_Profile_Field_Smooth: ["지표면 스무딩", "Smooth surface"],
B06_Profile_Smooth_On: ["사용", "On"],
B06_Profile_Smooth_Off: ["미사용", "Off"],
B06_Profile_Btn_Generate: ["종·횡단 재생성", "Regenerate Sections"],
B06_Profile_Btn_Confirm: ["종·횡단 확정", "Confirm Sections"],
B06_Profile_Result_Title: ["종·횡단 생성 결과", "Section Result"],
B06_Profile_Result_Empty: ["아직 생성된 종·횡단이 없습니다.", "No sections generated yet."],
B06_Profile_Context_Failed: [
"경로 정보를 불러오지 못했습니다. 서버 상태를 확인하세요.",
"Failed to load route context. Check the server status.",
],
B06_Profile_No_Confirmed_Route: [
"확정된 경로가 없습니다. 먼저 경로를 확정하세요.",
"No confirmed route. Confirm a route first.",
],
B06_Profile_Auto_Generating: [
"기본 옵션으로 종·횡단을 자동 생성하고 있습니다.",
"Generating sections automatically with the default options.",
B06_Profile_Calculate_In_B05: [
"저장된 종·횡단이 없습니다. B05에서 경로를 계산하세요.",
"No saved sections. Calculate the route in B05.",
],
B06_Profile_Result_Length: ["종단 연장(m)", "Longitudinal length (m)"],
B06_Profile_Result_CrossCount: ["횡단 개수", "Cross-section count"],
B06_Profile_Result_Path: ["종단 파일", "Longitudinal file"],
B06_Profile_Error_Project: ["먼저 프로젝트를 선택하세요.", "Select a project first."],
B06_Profile_Error_RouteId: ["유효한 경로 ID를 입력하세요.", "Enter a valid route ID."],
B06_Profile_Error_Filter: ["지면 필터 키를 입력하세요.", "Enter a ground filter key."],
B06_Profile_Generate_Success: ["종·횡단을 생성했습니다.", "Sections generated."],
B06_Profile_Generate_Failed: ["종·횡단 생성에 실패했습니다.", "Section generation failed."],
B06_Profile_Confirm_Success: ["종·횡단을 확정했습니다.", "Sections confirmed."],
B06_Profile_Confirm_Failed: ["종·횡단 확정에 실패했습니다.", "Section confirm failed."],
B06_Profile_Detail_Failed: [