This commit is contained in:
2026-07-18 22:04:20 +09:00
parent 173d88be7f
commit 0589078054
7 changed files with 285 additions and 75 deletions
@@ -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<string, unknown> | 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<SectionContextResponse> {
return requestJson<SectionContextResponse>(`/projects/${projectId}/sections/context`, {
method: "GET",
});
}
/** 경로의 종단면 요약을 조회한다. */
export async function getSections(
projectId: string,
@@ -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:
@@ -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)
@@ -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
@@ -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<void> {
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<void> {
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<void> {
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<void> {
);
}
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<void> {
return projectId;
}
async function onB06_Profile_Generate_Click(): Promise<void> {
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;
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<void> {
async function onB06_Profile_Confirm_Click(): Promise<void> {
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<void> {
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<void> {
},
});
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");
}
}
@@ -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;
}
/* --- 액션 버튼 행 --- */
+10 -2
View File
@@ -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"],