260719_3
This commit is contained in:
@@ -145,6 +145,17 @@ export async function solveRoute(
|
||||
});
|
||||
}
|
||||
|
||||
/** 등고선 간격 재적용 값을 서버(stage 1 params)에 영속화한다. */
|
||||
export async function updateContourInterval(
|
||||
projectId: string,
|
||||
contourIntervalM: number,
|
||||
): Promise<{ status: string; contour_interval_m: number }> {
|
||||
return requestJson<{ status: string; contour_interval_m: number }>(
|
||||
`/projects/${projectId}/route/contour-interval`,
|
||||
{ method: "PUT", body: JSON.stringify({ contour_interval_m: contourIntervalM }) },
|
||||
);
|
||||
}
|
||||
|
||||
/** 프로젝트의 최신 경로를 확정한다. */
|
||||
export async function confirmRoute(projectId: string): Promise<RouteConfirmResponse> {
|
||||
return requestJson<RouteConfirmResponse>(`/projects/${projectId}/route/confirm`, {
|
||||
|
||||
@@ -91,6 +91,8 @@ def run_section_generation(
|
||||
"length_m": result["longitudinal"]["length_m"],
|
||||
"station_count": result["summary"]["station_count"],
|
||||
"invalid_samples": result["summary"]["invalid_longitudinal_samples"],
|
||||
# 사용자 선택값의 단일 소스(DB): 재생성·재탐색 시 이 값을 우선 사용한다.
|
||||
"options": result["options"],
|
||||
}
|
||||
|
||||
# 측점별 횡단면 저장 (detail 조회가 폴더 전체를 glob하므로 이전 실행 잔재를 먼저 비운다)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
@@ -24,6 +25,8 @@ from B05_wf2_Route.B05_wf2_Route_Repository import (
|
||||
insert_route_points,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Schema import (
|
||||
ContourIntervalUpdateRequest,
|
||||
ContourIntervalUpdateResponse,
|
||||
RouteConfirmResponse,
|
||||
RouteLatestResponse,
|
||||
RouteSolveRequest,
|
||||
@@ -32,10 +35,14 @@ from B05_wf2_Route.B05_wf2_Route_Schema import (
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
|
||||
create_longitudinal_section,
|
||||
delete_sections_for_route,
|
||||
get_latest_section_options,
|
||||
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_surface_confirmation import (
|
||||
get_surface_confirmation_params,
|
||||
update_contour_interval_param,
|
||||
)
|
||||
from common_util.common_util_workflow_state import (
|
||||
complete_stage,
|
||||
fail_stage,
|
||||
@@ -48,15 +55,25 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"])
|
||||
|
||||
|
||||
def _section_options(request: RouteSolveRequest) -> SectionGenerationOptions:
|
||||
def _section_options(
|
||||
request: RouteSolveRequest, stored_options: dict[str, Any] | None
|
||||
) -> SectionGenerationOptions:
|
||||
"""요청 값 → DB 저장 옵션(단일 소스) → config 기본값 순으로 결정한다."""
|
||||
defaults = SectionGenerationOptions()
|
||||
stored = stored_options or {}
|
||||
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,
|
||||
station_interval_m=request.station_interval_m
|
||||
or stored.get("station_interval_m")
|
||||
or defaults.station_interval_m,
|
||||
cross_half_width_m=request.cross_half_width_m
|
||||
or stored.get("cross_half_width_m")
|
||||
or defaults.cross_half_width_m,
|
||||
cross_sample_interval_m=request.cross_sample_interval_m
|
||||
or stored.get("cross_sample_interval_m")
|
||||
or defaults.cross_sample_interval_m,
|
||||
long_sample_interval_m=request.long_sample_interval_m
|
||||
or stored.get("long_sample_interval_m")
|
||||
or defaults.long_sample_interval_m,
|
||||
include_endpoint=defaults.include_endpoint,
|
||||
)
|
||||
|
||||
@@ -206,6 +223,7 @@ async def solve_route(
|
||||
crs_epsg = await get_surface_crs_epsg(
|
||||
connection, project_id, request.surface_model_id
|
||||
)
|
||||
stored_options = await get_latest_section_options(connection, project_id)
|
||||
sections = await asyncio.to_thread(
|
||||
run_section_generation,
|
||||
project_root,
|
||||
@@ -213,7 +231,7 @@ async def solve_route(
|
||||
request.filter_key,
|
||||
request.method,
|
||||
request.smooth,
|
||||
options=_section_options(request),
|
||||
options=_section_options(request, stored_options),
|
||||
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
|
||||
)
|
||||
await connection.begin()
|
||||
@@ -281,6 +299,36 @@ async def solve_route(
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{project_id}/route/contour-interval", response_model=ContourIntervalUpdateResponse)
|
||||
async def update_contour_interval(
|
||||
project_id: UUID, request: ContourIntervalUpdateRequest
|
||||
) -> ContourIntervalUpdateResponse | JSONResponse:
|
||||
"""B05 등고선 간격 재적용 값을 stage 1 params(단일 소스)에 영속화한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
await update_contour_interval_param(
|
||||
connection, str(project_id), request.contour_interval_m
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
return ContourIntervalUpdateResponse(
|
||||
project_id=str(project_id), contour_interval_m=request.contour_interval_m
|
||||
)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B05 등고선 간격 저장 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "등고선 간격 저장 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/route/latest", response_model=RouteLatestResponse)
|
||||
async def read_latest_route(project_id: UUID) -> RouteLatestResponse | JSONResponse:
|
||||
"""최신 경로와 DB 렌더 좌표, WF1/WF2 입력 스냅샷을 반환한다."""
|
||||
|
||||
@@ -90,6 +90,22 @@ class RouteSolveRequest(BaseModel):
|
||||
}
|
||||
|
||||
|
||||
class ContourIntervalUpdateRequest(BaseModel):
|
||||
"""등고선 간격 재적용 영속화 요청."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
contour_interval_m: float = Field(gt=0)
|
||||
|
||||
|
||||
class ContourIntervalUpdateResponse(BaseModel):
|
||||
"""등고선 간격 영속화 결과."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
contour_interval_m: float
|
||||
|
||||
|
||||
class RouteSolveResponse(BaseModel):
|
||||
"""경로 탐색 실행 결과."""
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
confirmRoute,
|
||||
fetchLatestRoute,
|
||||
solveRoute,
|
||||
updateContourInterval,
|
||||
type CirclePoint,
|
||||
type RouteLatestResponse,
|
||||
type RoutePoint,
|
||||
@@ -223,6 +224,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await viewer.reloadContours(interval);
|
||||
await updateContourInterval(activeProjectId, interval);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error");
|
||||
} finally {
|
||||
|
||||
@@ -61,6 +61,31 @@ async def get_confirmed_route_context(
|
||||
}
|
||||
|
||||
|
||||
async def get_latest_section_options(
|
||||
connection: aiomysql.Connection, project_id: UUID
|
||||
) -> dict[str, Any] | None:
|
||||
"""프로젝트 최신 종단면 data에 저장된 생성 옵션 스냅샷을 반환한다 (없으면 None)."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT data
|
||||
FROM longitudinal_sections
|
||||
WHERE project_id = %s
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(str(project_id),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row or not row[0]:
|
||||
return None
|
||||
data = row[0]
|
||||
if isinstance(data, str):
|
||||
data = json.loads(data)
|
||||
options = data.get("options") if isinstance(data, dict) else None
|
||||
return options if isinstance(options, dict) else None
|
||||
|
||||
|
||||
async def get_route_generation_source(
|
||||
connection: aiomysql.Connection, project_id: UUID, route_id: int
|
||||
) -> dict[str, Any] | None:
|
||||
|
||||
@@ -20,6 +20,7 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
|
||||
create_longitudinal_section,
|
||||
delete_sections_for_route,
|
||||
get_confirmed_route_context,
|
||||
get_latest_section_options,
|
||||
get_longitudinal_section,
|
||||
get_route_generation_source,
|
||||
insert_cross_sections,
|
||||
@@ -178,18 +179,23 @@ async def get_section_detail(
|
||||
|
||||
|
||||
def _regeneration_options(
|
||||
stage_params: dict[str, Any] | None, cross_half_width_m: float
|
||||
stored_options: dict[str, Any] | None,
|
||||
stage_params: dict[str, Any] | None,
|
||||
cross_half_width_m: float,
|
||||
) -> SectionGenerationOptions:
|
||||
"""solve 시점의 stage 2 측점 옵션을 유지하고 횡단 반폭만 교체한다."""
|
||||
"""DB 저장 옵션(단일 소스) → stage 2 params → config 순으로 유지하고 반폭만 교체한다."""
|
||||
defaults = SectionGenerationOptions()
|
||||
stored = stored_options or {}
|
||||
params = stage_params or {}
|
||||
|
||||
def pick(key: str, default: float) -> float:
|
||||
return stored.get(key) or params.get(key) or default
|
||||
|
||||
return SectionGenerationOptions(
|
||||
station_interval_m=params.get("station_interval_m") or defaults.station_interval_m,
|
||||
station_interval_m=pick("station_interval_m", defaults.station_interval_m),
|
||||
cross_half_width_m=cross_half_width_m,
|
||||
cross_sample_interval_m=params.get("cross_sample_interval_m")
|
||||
or defaults.cross_sample_interval_m,
|
||||
long_sample_interval_m=params.get("long_sample_interval_m")
|
||||
or defaults.long_sample_interval_m,
|
||||
cross_sample_interval_m=pick("cross_sample_interval_m", defaults.cross_sample_interval_m),
|
||||
long_sample_interval_m=pick("long_sample_interval_m", defaults.long_sample_interval_m),
|
||||
include_endpoint=defaults.include_endpoint,
|
||||
)
|
||||
|
||||
@@ -210,6 +216,7 @@ async def regenerate_sections(
|
||||
)
|
||||
surface_params = await get_surface_confirmation_params(connection, str(project_id))
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
stored_options = await get_latest_section_options(connection, project_id)
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
workflow = await get_workflow_state(cursor, str(project_id))
|
||||
route_stage = next(
|
||||
@@ -226,6 +233,7 @@ async def regenerate_sections(
|
||||
surface_params["method"],
|
||||
bool(surface_params["smooth"]),
|
||||
options=_regeneration_options(
|
||||
stored_options,
|
||||
route_stage.get("params") if route_stage else None,
|
||||
request.cross_half_width_m,
|
||||
),
|
||||
|
||||
@@ -106,6 +106,12 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
crossHalfWidthField.input.step = "0.1";
|
||||
displayGroup.append(crossHalfWidthField.root, verticalExaggerationField.root);
|
||||
|
||||
const recalcButton = createButton({
|
||||
label: L("B06_Profile_Btn_Recalc"),
|
||||
variant: "ghost",
|
||||
onClick: () => void applyCrossHalfWidth(),
|
||||
});
|
||||
recalcButton.disabled = true;
|
||||
const confirmButton = createButton({
|
||||
label: L("B06_Profile_Btn_Confirm"),
|
||||
variant: "filled",
|
||||
@@ -114,7 +120,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
confirmButton.disabled = true;
|
||||
const actionRow = document.createElement("div");
|
||||
actionRow.className = "b06-profile__actions";
|
||||
actionRow.append(confirmButton);
|
||||
actionRow.append(recalcButton, confirmButton);
|
||||
|
||||
const leftForm = document.createElement("div");
|
||||
leftForm.className = "b06-profile__form";
|
||||
@@ -150,6 +156,16 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
let appliedHalfWidth: number | undefined;
|
||||
|
||||
/** 반폭 미적용 상태에서는 [재계산]만 활성, 적용 완료 상태에서는 [확정]만 활성. */
|
||||
function updateActionState(): void {
|
||||
const width = crossHalfWidth();
|
||||
const stale = sectionDetail !== null && width !== undefined && width !== appliedHalfWidth;
|
||||
recalcButton.disabled = !stale;
|
||||
confirmButton.disabled = sectionDetail === null || stale;
|
||||
}
|
||||
|
||||
function renderSectionDetail(): void {
|
||||
if (sectionDetail)
|
||||
sectionView.render(sectionDetail, verticalExaggeration(), crossHalfWidth(), stationInterval);
|
||||
@@ -157,24 +173,24 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
|
||||
async function applyCrossHalfWidth(): Promise<void> {
|
||||
const width = crossHalfWidth();
|
||||
if (!projectId || currentRouteId === null || width === undefined) {
|
||||
renderSectionDetail();
|
||||
return;
|
||||
}
|
||||
if (!projectId || currentRouteId === null || width === undefined) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
sectionDetail = await regenerateSections(projectId, currentRouteId, width);
|
||||
appliedHalfWidth = width;
|
||||
renderSectionDetail();
|
||||
showToast(L("B06_Profile_Regenerate_Success"), "success");
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`${L("B06_Profile_Regenerate_Failed")}${detail}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
updateActionState();
|
||||
}
|
||||
}
|
||||
|
||||
verticalExaggerationField.input.addEventListener("input", renderSectionDetail);
|
||||
crossHalfWidthField.input.addEventListener("change", () => void applyCrossHalfWidth());
|
||||
crossHalfWidthField.input.addEventListener("input", updateActionState);
|
||||
|
||||
async function confirmCurrentSections(): Promise<void> {
|
||||
if (!projectId || currentRouteId === null) return;
|
||||
@@ -251,15 +267,26 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
renderSummary(existing);
|
||||
sectionDetail = await fetchSectionDetail(projectId, context.route_id);
|
||||
const storedHalfWidth = Math.max(
|
||||
0,
|
||||
...sectionDetail.cross_sections.flatMap((section) =>
|
||||
section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)),
|
||||
),
|
||||
);
|
||||
// 단일 소스(DB data.options) 우선, options 스냅샷이 없는 과거 데이터는 샘플 최대 offset으로 추정
|
||||
const summaryData = existing.longitudinal.data as {
|
||||
options?: { cross_half_width_m?: number; station_interval_m?: number };
|
||||
} | null;
|
||||
const storedOptions = summaryData?.options;
|
||||
const storedHalfWidth =
|
||||
storedOptions?.cross_half_width_m && storedOptions.cross_half_width_m > 0
|
||||
? storedOptions.cross_half_width_m
|
||||
: Math.max(
|
||||
0,
|
||||
...sectionDetail.cross_sections.flatMap((section) =>
|
||||
section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)),
|
||||
),
|
||||
);
|
||||
if (storedHalfWidth > 0) crossHalfWidthField.input.value = storedHalfWidth.toFixed(1);
|
||||
if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0)
|
||||
stationInterval = storedOptions.station_interval_m;
|
||||
appliedHalfWidth = crossHalfWidth();
|
||||
renderSectionDetail();
|
||||
confirmButton.disabled = false;
|
||||
updateActionState();
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
renderMessage(L("B06_Profile_Calculate_In_B05"));
|
||||
|
||||
@@ -63,6 +63,37 @@ async def get_surface_confirmation_params(
|
||||
return resolved
|
||||
|
||||
|
||||
async def update_contour_interval_param(
|
||||
connection: aiomysql.Connection,
|
||||
project_id: str,
|
||||
contour_interval_m: float,
|
||||
) -> None:
|
||||
"""stage 1 params의 등고선 간격만 갱신한다 (B05 재적용 영속화)."""
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT params
|
||||
FROM project_workflow_stages
|
||||
WHERE project_id = %s AND stage_no = 1
|
||||
FOR UPDATE
|
||||
""",
|
||||
(project_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
raise LookupError("WF1 단계 상태를 찾을 수 없습니다.")
|
||||
params = _decode_params(row.get("params") if row else None)
|
||||
params["contour_interval_m"] = float(contour_interval_m)
|
||||
await cursor.execute(
|
||||
"""
|
||||
UPDATE project_workflow_stages
|
||||
SET params = %s
|
||||
WHERE project_id = %s AND stage_no = 1
|
||||
""",
|
||||
(json.dumps(params, ensure_ascii=False), project_id),
|
||||
)
|
||||
|
||||
|
||||
async def merge_surface_confirmation_params(
|
||||
connection: aiomysql.Connection,
|
||||
project_id: str,
|
||||
|
||||
@@ -768,6 +768,11 @@ export const ui_locales = {
|
||||
"횡단 반폭 재생성에 실패했습니다.",
|
||||
"Failed to regenerate sections with the new half-width.",
|
||||
],
|
||||
B06_Profile_Regenerate_Success: [
|
||||
"횡단 반폭을 반영해 종·횡단을 재생성했습니다.",
|
||||
"Sections regenerated with the new half-width.",
|
||||
],
|
||||
B06_Profile_Btn_Recalc: ["재계산", "Recalculate"],
|
||||
B06_Profile_View_Longitudinal: ["종단면도", "Longitudinal profile"],
|
||||
B06_Profile_View_Cross: ["횡단면도", "Cross sections"],
|
||||
B06_Profile_View_StationCount: ["횡단 측점", "Cross stations"],
|
||||
|
||||
Reference in New Issue
Block a user