diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index a4a16473..83588539 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -4,6 +4,7 @@ * * 백엔드 계약 (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} → 종단 요약 조회 * POST /api/projects/{project_id}/sections/{route_id}/confirm → 종횡단 확정 * @@ -38,12 +39,31 @@ export interface SectionGenerateResponse { longitudinal_file_path: string; } +export interface SectionOptionDefaults { + station_interval_m: number; + cross_half_width_m: number; + cross_sample_interval_m: number; + long_sample_interval_m: number; +} + +export interface SectionContextResponse { + project_id: string; + route_id: number | null; + filter_key: string | null; + method: string | null; + smooth: boolean | null; + crs_epsg: number | null; + defaults: SectionOptionDefaults; +} + /** 종단 요약 조회 결과 (SectionSummaryResponse) */ export interface SectionSummaryResponse { status: string; project_id: string; route_id: number; longitudinal: Record | null; + length_m: number | null; + cross_section_count: number; } /** 종횡단 확정 결과 (SectionConfirmResponse) */ @@ -89,6 +109,13 @@ export async function generateSections( }); } +/** 최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 조회한다. */ +export async function fetchSectionContext(projectId: string): Promise { + return requestJson(`/projects/${projectId}/sections/context`, { + method: "GET", + }); +} + /** 경로의 종단면 요약을 조회한다. */ export async function getSections( projectId: string, diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py index afa9c890..c72e0ed6 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py @@ -24,6 +24,31 @@ def _validate_stage_path(relative_path: str) -> str: return normalized.as_posix() +async def get_confirmed_route_context( + connection: aiomysql.Connection, project_id: UUID +) -> dict[str, Any] | None: + """프로젝트의 최신 확정 경로와 연결된 지표면 좌표계를 조회한다.""" + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT r.id AS route_id, sm.crs_epsg + FROM routes r + LEFT JOIN surface_models sm ON sm.id = r.surface_model_id + WHERE r.project_id = %s AND r.status = 'CONFIRMED' + ORDER BY r.computed_at DESC, r.id DESC + LIMIT 1 + """, + (str(project_id),), + ) + row = await cursor.fetchone() + if not row: + return None + return { + "route_id": int(row["route_id"]), + "crs_epsg": int(row["crs_epsg"]) if row["crs_epsg"] is not None else None, + } + + async def delete_sections_for_route(connection: aiomysql.Connection, route_id: int) -> None: """경로 재생성 전에 기존 종횡단 레코드를 삭제한다 (멱등 재실행).""" async with connection.cursor() as cursor: @@ -113,7 +138,7 @@ async def get_longitudinal_section( async with connection.cursor() as cursor: await cursor.execute( """ - SELECT id, longitudinal_file_path, status, computed_at + SELECT id, data, longitudinal_file_path, status, computed_at FROM longitudinal_sections WHERE project_id = %s AND route_id = %s ORDER BY id DESC @@ -124,14 +149,26 @@ async def get_longitudinal_section( row = await cursor.fetchone() if not row: return None + data = row[1] + if isinstance(data, str): + data = json.loads(data) return { "id": int(row[0]), - "longitudinal_file_path": row[1], - "status": row[2], - "computed_at": row[3].isoformat() if row[3] else None, + "data": data if isinstance(data, dict) else None, + "longitudinal_file_path": row[2], + "status": row[3], + "computed_at": row[4].isoformat() if row[4] else None, } +async def count_cross_sections(connection: aiomysql.Connection, route_id: int) -> int: + """경로에 저장된 횡단면 개수를 반환한다.""" + async with connection.cursor() as cursor: + await cursor.execute("SELECT COUNT(*) FROM cross_sections WHERE route_id = %s", (route_id,)) + row = await cursor.fetchone() + return int(row[0]) if row else 0 + + async def confirm_sections_for_route(connection: aiomysql.Connection, route_id: int) -> None: """경로의 종횡단면 상태를 CONFIRMED로 변경한다.""" async with connection.cursor() as cursor: diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index 4f07375b..81bbb387 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -14,18 +14,23 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine import run_section_generat from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Section 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, 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 config.config_db import get_db_pool @@ -148,6 +153,38 @@ async def generate_sections( ) +@router.get("/{project_id}/sections/context", response_model=SectionContextResponse) +async def get_section_context(project_id: UUID) -> SectionContextResponse | JSONResponse: + """최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 반환한다.""" + pool = get_db_pool() + try: + 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)) + + defaults = SectionGenerationOptions() + return SectionContextResponse( + project_id=str(project_id), + route_id=route_context["route_id"] if route_context else None, + filter_key=surface_params["source_filter"] if route_context else None, + 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, + defaults=SectionOptionDefaults( + station_interval_m=defaults.station_interval_m, + cross_half_width_m=defaults.cross_half_width_m, + cross_sample_interval_m=defaults.cross_sample_interval_m, + long_sample_interval_m=defaults.long_sample_interval_m, + ), + ) + except Exception: + logger.exception("B06 종횡단 컨텍스트 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "종횡단 컨텍스트 조회 중 오류가 발생했습니다."}, + ) + + @router.get("/{project_id}/sections/{route_id}", response_model=SectionSummaryResponse) async def get_sections(project_id: UUID, route_id: int) -> SectionSummaryResponse | JSONResponse: """경로의 종단면 요약을 조회한다.""" @@ -155,8 +192,15 @@ async def get_sections(project_id: UUID, route_id: int) -> SectionSummaryRespons try: async with pool.acquire() as connection: longitudinal = await get_longitudinal_section(connection, project_id, route_id) + cross_section_count = await count_cross_sections(connection, route_id) + data = longitudinal.get("data") if longitudinal else None + length_m = data.get("length_m") if isinstance(data, dict) else None return SectionSummaryResponse( - project_id=str(project_id), route_id=route_id, longitudinal=longitudinal + project_id=str(project_id), + route_id=route_id, + longitudinal=longitudinal, + length_m=length_m, + cross_section_count=cross_section_count, ) except Exception: logger.exception("B06 종횡단 조회 실패: project_id=%s", project_id) diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py index 85e088e9..425c42ea 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py @@ -44,6 +44,27 @@ class SectionConfirmResponse(BaseModel): confirmed: bool = True +class SectionOptionDefaults(BaseModel): + """config에서 읽은 종횡단 생성 기본 옵션.""" + + station_interval_m: float + cross_half_width_m: float + cross_sample_interval_m: float + long_sample_interval_m: float + + +class SectionContextResponse(BaseModel): + """B06 진입 시 필요한 확정 경로 컨텍스트와 기본 옵션.""" + + project_id: str + route_id: int | None = None + filter_key: str | None = None + method: str | None = None + smooth: bool | None = None + crs_epsg: int | None = None + defaults: SectionOptionDefaults + + class SectionSummaryResponse(BaseModel): """종횡단 요약 조회 결과.""" @@ -51,3 +72,5 @@ class SectionSummaryResponse(BaseModel): project_id: str route_id: int longitudinal: dict[str, Any] | None = None + length_m: float | None = None + cross_section_count: int = 0 diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index e4a54374..3202b0ae 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -30,8 +30,13 @@ import { } from "../A00_Common/b_workflow_nav"; import { confirmSections, + fetchSectionContext, generateSections, + getSections, + type SectionContextResponse, + type SectionGenerateRequest, type SectionGenerateResponse, + type SectionSummaryResponse, } from "./B06_wf3_ProfileCross_Api_Fetch"; import "./B06_wf3_ProfileCross_UI_Style.css"; @@ -51,6 +56,18 @@ function buildGroup(legend: string): HTMLElement { return group; } +/** 읽기 전용 경로 컨텍스트 표시 행. */ +function buildInfoLine(label: string): { root: HTMLElement; value: HTMLElement } { + const root = document.createElement("div"); + root.className = "b06-profile__info-line"; + const key = document.createElement("span"); + key.textContent = label; + const value = document.createElement("strong"); + value.textContent = "-"; + root.append(key, value); + return { root, value }; +} + /** 숫자 입력값을 파싱. 빈 값이면 null. */ function parseNumber(value: string): number | null { const trimmed = value.trim(); @@ -61,26 +78,22 @@ function parseNumber(value: string): number | null { export async function renderB06ProfileCross(root: HTMLElement): Promise { let currentRouteId: number | null = null; + let sectionContext: SectionContextResponse | null = null; /* ---- 좌측: 대상 경로 ---- */ const routeGroup = buildGroup(L("B06_Profile_Group_Route")); - const routeIdField = createInputField({ - label: L("B06_Profile_Field_RouteId"), - type: "number", - min: 1, - }); - const filterField = createInputField({ label: L("B06_Profile_Field_Filter"), type: "text" }); - const methodField = createInputField({ - label: L("B06_Profile_Field_Method"), - type: "text", - value: "dtm", - }); - const crsField = createInputField({ - label: L("B06_Profile_Field_Crs"), - type: "text", - placeholder: "EPSG:5178", - }); - routeGroup.append(routeIdField.root, filterField.root, methodField.root, crsField.root); + const routeIdInfo = buildInfoLine(L("B06_Profile_Field_RouteId")); + const filterInfo = buildInfoLine(L("B06_Profile_Field_Filter")); + const methodInfo = buildInfoLine(L("B06_Profile_Field_Method")); + const smoothInfo = buildInfoLine(L("B06_Profile_Field_Smooth")); + const crsInfo = buildInfoLine(L("B06_Profile_Field_Crs")); + routeGroup.append( + routeIdInfo.root, + filterInfo.root, + methodInfo.root, + smoothInfo.root, + crsInfo.root, + ); /* ---- 좌측: 측점/횡단 옵션 ---- */ const optionGroup = buildGroup(L("B06_Profile_Group_Options")); @@ -101,20 +114,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { type: "number", }); - const smoothLabel = document.createElement("label"); - smoothLabel.className = "b06-profile__check"; - const smoothBox = document.createElement("input"); - smoothBox.type = "checkbox"; - const smoothText = document.createElement("span"); - smoothText.textContent = L("B06_Profile_Field_Smooth"); - smoothLabel.append(smoothBox, smoothText); - optionGroup.append( stationField.root, halfWidthField.root, crossSampleField.root, longSampleField.root, - smoothLabel, ); const generateButton = createButton({ @@ -150,6 +154,14 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { resultBody.append(empty); } + function renderResultMessage(message: string): void { + resultBody.replaceChildren(); + 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"; @@ -172,6 +184,19 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { ); } + function renderSummary(result: SectionSummaryResponse): void { + const path = result.longitudinal?.longitudinal_file_path; + resultBody.replaceChildren(); + resultBody.append( + metricRow( + L("B06_Profile_Result_Length"), + result.length_m === null ? "-" : result.length_m.toFixed(2), + ), + metricRow(L("B06_Profile_Result_CrossCount"), String(result.cross_section_count)), + metricRow(L("B06_Profile_Result_Path"), typeof path === "string" ? path : "-"), + ); + } + const resultCard = document.createElement("div"); resultCard.className = "b06-profile__result"; resultCard.append(resultTitle, resultBody); @@ -183,43 +208,45 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { return projectId; } - async function onB06_Profile_Generate_Click(): Promise { + 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 { const projectId = getProjectId(); - if (!projectId) return; - - const routeId = parseNumber(routeIdField.input.value); - if (routeId === null || !Number.isInteger(routeId) || routeId <= 0) { - routeIdField.setError(L("B06_Profile_Error_RouteId")); - return; - } - routeIdField.setError(); - - const filterKey = filterField.input.value.trim(); - if (!filterKey) { - filterField.setError(L("B06_Profile_Error_Filter")); - return; - } - filterField.setError(); + if (!projectId) return false; + const request = buildGenerateRequest(); + if (!request) return false; showLoadingOverlay(); try { - const result = await generateSections(projectId, { - route_id: routeId, - filter_key: filterKey, - method: methodField.input.value.trim() || "dtm", - smooth: smoothBox.checked, - crs: crsField.input.value.trim() || null, - 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), - }); + const result = await generateSections(projectId, request); currentRouteId = result.route_id; renderResult(result); 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(); } @@ -228,11 +255,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { async function onB06_Profile_Confirm_Click(): Promise { const projectId = getProjectId(); if (!projectId) return; - const routeId = currentRouteId ?? parseNumber(routeIdField.input.value); - if (routeId === null || !Number.isInteger(routeId) || routeId <= 0) { - routeIdField.setError(L("B06_Profile_Error_RouteId")); - return; - } + const routeId = currentRouteId; + if (routeId === null) return; showLoadingOverlay(); try { await confirmSections(projectId, routeId); @@ -250,11 +274,12 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { let workflowState: WorkflowState | undefined; const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (projectId) { - try { - workflowState = await fetchWorkflowState(projectId); - } catch { - /* 조회 실패 시 stages 미전달 → 전체 이동 허용 */ - } + const [contextResult, workflowResult] = await Promise.allSettled([ + fetchSectionContext(projectId), + fetchWorkflowState(projectId), + ]); + if (contextResult.status === "fulfilled") sectionContext = contextResult.value; + if (workflowResult.status === "fulfilled") workflowState = workflowResult.value; } const layout = createWorkflowLayout({ @@ -273,4 +298,45 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { }, }); root.replaceChildren(layout.root); + + if (!projectId || !sectionContext) { + generateButton.disabled = true; + confirmButton.disabled = true; + 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 ? "ON" : "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); + + if (context.route_id === null) { + generateButton.disabled = true; + confirmButton.disabled = true; + renderResultMessage(L("B06_Profile_No_Confirmed_Route")); + return; + } + + currentRouteId = context.route_id; + confirmButton.disabled = true; + try { + const existing = await getSections(projectId, context.route_id); + if (existing.longitudinal) { + renderSummary(existing); + confirmButton.disabled = false; + return; + } + renderResultMessage(L("B06_Profile_Auto_Generating")); + const generated = await onB06_Profile_Generate_Click(); + confirmButton.disabled = !generated; + } catch (error) { + const detail = error instanceof Error ? error.message : L("B06_Profile_Generate_Failed"); + showToast(`${L("B06_Profile_Generate_Failed")} ${detail}`, "error"); + } } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css index db059be9..ec205fa6 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css @@ -32,18 +32,23 @@ color: var(--color-text-secondary); } -/* --- 체크박스 --- */ -.b06-profile__check { +/* --- 읽기 전용 경로 컨텍스트 --- */ +.b06-profile__info-line { display: flex; align-items: center; + justify-content: space-between; gap: var(--spacing-8); font-size: var(--text-body-sm); color: var(--color-text-body); - cursor: pointer; } -.b06-profile__check input { - accent-color: var(--color-primary); +.b06-profile__info-line span { + color: var(--color-text-secondary); +} + +.b06-profile__info-line strong { + font-family: var(--font-mono); + text-align: right; } /* --- 액션 버튼 행 --- */ diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index d2de37e4..b2553bb6 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -732,17 +732,25 @@ export const ui_locales = { B06_Profile_Field_RouteId: ["경로 ID (routes.id)", "Route ID"], B06_Profile_Field_Filter: ["지면 필터", "Ground filter"], B06_Profile_Field_Method: ["지표면 표현", "Surface method"], - B06_Profile_Field_Crs: ["좌표계 (선택)", "CRS (optional)"], + 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_Field_Smooth: ["지표면 스무딩", "Smooth surface"], - B06_Profile_Btn_Generate: ["종·횡단 생성", "Generate Sections"], + 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_No_Confirmed_Route: [ + "확정된 경로가 없습니다. 먼저 경로를 확정하세요.", + "No confirmed route. Confirm a route first.", + ], + B06_Profile_Auto_Generating: [ + "기본 옵션으로 종·횡단을 자동 생성하고 있습니다.", + "Generating sections automatically with the default options.", + ], B06_Profile_Result_Length: ["종단 연장(m)", "Longitudinal length (m)"], B06_Profile_Result_CrossCount: ["횡단 개수", "Cross-section count"], B06_Profile_Result_Path: ["종단 파일", "Longitudinal file"],