diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index bb795b76..2726521f 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -1,6 +1,10 @@ .b03-file { min-height: calc(100vh - var(--app-header-height, 0px)); - background: linear-gradient(180deg, var(--color-canvas, #ffffff) 0%, var(--color-mist-violet, #edecff) 100%); + background: linear-gradient( + 180deg, + var(--color-canvas, #ffffff) 0%, + var(--color-mist-violet, #edecff) 100% + ); padding: 0 var(--spacing-24) var(--spacing-48) var(--spacing-24); /* 상단 여백은 main-layout 마진으로 조정하므로 padding-top은 0 */ } @@ -52,7 +56,10 @@ gap: var(--spacing-8); cursor: pointer; text-align: center; - transition: background var(--transition-base, 0.2s), border-color var(--transition-base, 0.2s), box-shadow var(--transition-base, 0.2s); + transition: + background var(--transition-base, 0.2s), + border-color var(--transition-base, 0.2s), + box-shadow var(--transition-base, 0.2s); } .b03-file__dropzone:hover { @@ -140,7 +147,10 @@ display: flex; flex-direction: column; gap: var(--spacing-20); - transition: transform var(--transition-base, 0.2s), border-color var(--transition-base, 0.2s), box-shadow var(--transition-base, 0.2s); + transition: + transform var(--transition-base, 0.2s), + border-color var(--transition-base, 0.2s), + box-shadow var(--transition-base, 0.2s); } .b03-file__card:hover { @@ -258,7 +268,9 @@ font-size: var(--text-body-sm, 14px); cursor: pointer; margin-bottom: var(--spacing-8); /* 파일 선택 버튼 하단 마진 추가 */ - transition: background var(--transition-base, 0.2s), border-color var(--transition-base, 0.2s); + transition: + background var(--transition-base, 0.2s), + border-color var(--transition-base, 0.2s); } .b03-file__file-info { @@ -373,4 +385,3 @@ grid-template-columns: 1fr; /* 모바일에서는 1행 1열 구조 */ } } - diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts index fb41ae3d..7b359fd7 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Camera.ts @@ -1,6 +1,8 @@ import type { SurfaceBounds } from "./B04_wf1_Surface_Api_Fetch"; export const SURFACE_CAMERA_FOV = 50; +const LIGHT_VIEWER_BACKGROUND = 0xf5f7f9; +const DARK_VIEWER_BACKGROUND = 0x251f38; export interface SurfaceCameraState { direction: [number, number, number]; @@ -40,3 +42,36 @@ export function niceScaleDistance(roughMeters: number): number { const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10; return step * base; } + +export function bindSurfaceViewerTheme( + applyBackground: (color: string | number) => void, +): () => void { + const systemDarkTheme = window.matchMedia("(prefers-color-scheme: dark)"); + const update = (): void => { + const theme = document.documentElement.getAttribute("data-theme"); + const dark = theme === "dark" || (theme !== "light" && systemDarkTheme.matches); + if (!dark) { + applyBackground(LIGHT_VIEWER_BACKGROUND); + return; + } + const surfaceRaised = getComputedStyle(document.documentElement) + .getPropertyValue("--color-surface-raised") + .trim(); + applyBackground( + surfaceRaised && CSS.supports("color", surfaceRaised) + ? surfaceRaised + : DARK_VIEWER_BACKGROUND, + ); + }; + const observer = new MutationObserver(update); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + systemDarkTheme.addEventListener("change", update); + update(); + return () => { + observer.disconnect(); + systemDarkTheme.removeEventListener("change", update); + }; +} diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css index f452dcf9..eb960f2b 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Style.css @@ -460,26 +460,31 @@ pointer-events: none; } -.b04-surface__scale { +.b04-surface__scale, +.b04-map__scale { position: absolute; bottom: var(--spacing-16); left: var(--spacing-16); z-index: 2; height: var(--spacing-8); - border: 2px solid var(--color-text); + border: 2px solid var(--color-text-body); border-top: 0; - color: var(--color-text); + color: var(--color-text-body); + filter: drop-shadow(0 1px 1px var(--color-surface-raised)); pointer-events: none; } -.b04-surface__scale span { +.b04-surface__scale span, +.b04-map__scale span { position: absolute; bottom: var(--spacing-8); left: 50%; transform: translateX(-50%); white-space: nowrap; + font-family: var(--font-body); font-size: var(--text-caption); font-weight: var(--font-weight-semibold); + text-shadow: 0 1px 2px var(--color-surface-raised); } /* --- 하단 2D 지도 --- */ @@ -619,28 +624,6 @@ background: var(--color-surface-raised); } -.b04-map__scale { - position: absolute; - bottom: var(--spacing-16); - left: var(--spacing-16); - z-index: 2; - height: var(--spacing-8); - border: 2px solid var(--color-text); - border-top: 0; - color: var(--color-text); - pointer-events: none; -} - -.b04-map__scale span { - position: absolute; - bottom: var(--spacing-8); - left: 50%; - transform: translateX(-50%); - white-space: nowrap; - font-size: var(--text-caption); - font-weight: var(--font-weight-semibold); -} - @media (max-width: 760px) { .b04-map__header { align-items: flex-start; diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index 4d34198f..87a7f162 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -5,6 +5,7 @@ import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; import type { SurfaceBounds, SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch"; import { + bindSurfaceViewerTheme, getTopFitDistance, niceScaleDistance, SURFACE_CAMERA_FOV, @@ -180,7 +181,9 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { let loadGeneration = 0; const scene = new THREE.Scene(); - scene.background = new THREE.Color(0xf5f7f9); + const releaseTheme = bindSurfaceViewerTheme((color) => { + scene.background = new THREE.Color(color); + }); const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.01, 100000); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); @@ -543,6 +546,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { cancelAnimationFrame(animationFrameId); clearMesh(); clearContours(); + releaseTheme(); controls.dispose(); renderer.dispose(); } @@ -670,6 +674,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { dispose() { cancelAnimationFrame(animationFrameId); resizeObserver.disconnect(); + releaseTheme(); clearMesh(); clearContours(); controls.dispose(); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts index f3000f81..a9ad9012 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Viewer.ts @@ -3,6 +3,7 @@ import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import type { SurfaceBounds, SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetch"; import { + bindSurfaceViewerTheme, getReferenceCenter, getTopFitDistance, niceScaleDistance, @@ -106,7 +107,9 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, RENDER_OPTIONS.maxPixelRatio)); const scene = new THREE.Scene(); - scene.background = new THREE.Color(0xf5f7f9); + const releaseTheme = bindSurfaceViewerTheme((color) => { + scene.background = new THREE.Color(color); + }); const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.1, 22000); const orbit = new OrbitControls(camera, canvas); orbit.enableDamping = true; @@ -268,6 +271,7 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer { if (disposed) return; disposed = true; cancelAnimationFrame(animationFrame); + releaseTheme(); clearPoints(); orbit.dispose(); renderer.dispose(); diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index b33fda70..c3adb5a7 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -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 { return requestJson(`/projects/${projectId}/route/confirm`, { diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py index 6c53a932..f94278a2 100644 --- a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py +++ b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py @@ -91,9 +91,13 @@ 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하므로 이전 실행 잔재를 먼저 비운다) + for stale_file in cross_dir.glob("cross_*.json"): + stale_file.unlink() cross_records: list[dict[str, Any]] = [] for seq, cross_section in enumerate(result["cross_sections"]): chainage = float(cross_section["chainage_m"]) diff --git a/B05_wf2_Route/B05_wf2_Route_Router.py b/B05_wf2_Route/B05_wf2_Route_Router.py index 2da7ea5f..7e487a26 100644 --- a/B05_wf2_Route/B05_wf2_Route_Router.py +++ b/B05_wf2_Route/B05_wf2_Route_Router.py @@ -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 입력 스냅샷을 반환한다.""" diff --git a/B05_wf2_Route/B05_wf2_Route_Schema.py b/B05_wf2_Route/B05_wf2_Route_Schema.py index 985594de..baf7652c 100644 --- a/B05_wf2_Route/B05_wf2_Route_Schema.py +++ b/B05_wf2_Route/B05_wf2_Route_Schema.py @@ -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): """경로 탐색 실행 결과.""" diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts b/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts index 85198f34..e2ca6dd1 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts @@ -26,6 +26,7 @@ export interface ModelBounds { } export interface SectionStationMarker { + station_id: string; center_x: number; center_y: number; center_z: number | null; @@ -70,14 +71,18 @@ export function sceneToModel(point: THREE.Vector3, bounds: ModelBounds) { } export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBounds | null) { + const interactionGroup = new THREE.Group(); const markerGroup = new THREE.Group(); const routeGroup = new THREE.Group(); const stationGroup = new THREE.Group(); - scene.add(markerGroup, routeGroup, stationGroup); + interactionGroup.add(markerGroup, stationGroup); + scene.add(interactionGroup, routeGroup); let points = emptyPoints(); let selectedId: string | null = null; let changeListener: ((points: RouteDesignPoints) => void) | undefined; let selectionListener: ((point: PlacedRoutePoint | null) => void) | undefined; + let stationSelectionListener: ((stationId: string | null) => void) | undefined; + let selectedStationId: string | null = null; function allPoints(): PlacedRoutePoint[] { return [points.bp, points.ep, ...points.cp, ...points.ap, ...points.fp].filter( @@ -94,11 +99,20 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou const bounds = getBounds(); if (!bounds) return; allPoints().forEach((point) => { + const pointIndex = + point.type === "bp" || point.type === "ep" + ? 0 + : points[point.type].findIndex((candidate) => candidate.id === point.id); + const interactionData = { + routePointId: point.id, + routePointKind: point.type, + routePointIndex: pointIndex, + }; const material = new THREE.MeshBasicMaterial({ color: COLORS[point.type] }); const marker = new THREE.Mesh(new THREE.SphereGeometry(1.6, 18, 12), material); marker.position.copy(modelToScene(point, bounds)); marker.position.y += 1.6; - marker.userData.routePointId = point.id; + Object.assign(marker.userData, interactionData); if (point.id === selectedId) marker.scale.setScalar(1.35); markerGroup.add(marker); if ((point.type === "ap" || point.type === "fp") && point.radius_m) { @@ -113,6 +127,7 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou ); zone.position.copy(modelToScene(point, bounds)); zone.position.y += 0.2; + Object.assign(zone.userData, interactionData); markerGroup.add(zone); } }); @@ -150,6 +165,19 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou notify(); } + function movePoint(id: string, model: { x: number; y: number; z: number }): void { + const point = allPoints().find((candidate) => candidate.id === id); + if (!point) return; + const update = (candidate: PlacedRoutePoint) => + candidate.id === id ? { ...candidate, ...model } : candidate; + if (point.type === "bp" || point.type === "ep") { + points = { ...points, [point.type]: update(point) }; + } else { + points = { ...points, [point.type]: points[point.type].map(update) }; + } + notify(); + } + function deleteSelected(): void { const current = selected(); if (!current) return; @@ -166,7 +194,6 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou function renderRoute( polyline: Array<{ x: number; y: number; z?: number }>, - gradeClass: string, warnings: Array<{ polyline_start_index: number; polyline_end_index: number }> = [], ): void { disposeGroup(routeGroup); @@ -204,36 +231,17 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou routeGroup.add(marker); } }); - const width = gradeClass === "trunk" ? 4 : gradeClass === "branch" ? 3 : 2.5; - const perpendiculars: THREE.Vector3[] = []; - for (let index = 0; index < linePoints.length; index += 10) { - const previous = linePoints[Math.max(0, index - 1)]; - const next = linePoints[Math.min(linePoints.length - 1, index + 1)]; - const direction = next.clone().sub(previous).normalize(); - const perpendicular = new THREE.Vector3(-direction.z, 0, direction.x); - perpendiculars.push( - linePoints[index].clone().addScaledVector(perpendicular, width / 2), - linePoints[index].clone().addScaledVector(perpendicular, -width / 2), - ); - } - routeGroup.add( - new THREE.LineSegments( - new THREE.BufferGeometry().setFromPoints(perpendiculars), - new THREE.LineBasicMaterial({ color: 0xfacc15 }), - ), - ); } 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( + const points = [ modelToScene( { x: center.x + leftX * halfWidth, y: center.y + leftY * halfWidth, z: center.z }, bounds, @@ -242,18 +250,32 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou { x: center.x - leftX * halfWidth, y: center.y - leftY * halfWidth, z: center.z }, bounds, ), - ); - }); - stationGroup.add( - new THREE.LineSegments( + ]; + const selected = station.station_id === selectedStationId; + const line = new THREE.Line( new THREE.BufferGeometry().setFromPoints(points), - new THREE.LineBasicMaterial({ color: 0xa855f7 }), - ), - ); + new THREE.LineBasicMaterial({ color: selected ? 0xef4444 : 0xfacc15 }), + ); + line.userData.stationId = station.station_id; + if (selected) line.material.linewidth = 2; + stationGroup.add(line); + }); + } + + function selectStation(stationId: string | null): void { + selectedStationId = stationId; + stationGroup.children.forEach((object) => { + if (!(object instanceof THREE.Line)) return; + const selected = object.userData.stationId === selectedStationId; + const material = object.material as THREE.LineBasicMaterial; + material.color.set(selected ? 0xef4444 : 0xfacc15); + material.linewidth = selected ? 3 : 1; + }); + stationSelectionListener?.(selectedStationId); } return { - group: markerGroup, + group: interactionGroup, getPoints: () => points, getSelected: selected, setPoints(next: RouteDesignPoints) { @@ -265,17 +287,34 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou moveSelected(model: { x: number; y: number; z: number }) { updateSelected(model); }, + movePoint, updateSelected, deleteSelected, selectObject(object: THREE.Object3D | undefined) { + if (typeof object?.userData.stationId === "string") { + selectStation(object.userData.stationId); + selectionListener?.(null); + return; + } selectedId = typeof object?.userData.routePointId === "string" ? object.userData.routePointId : null; renderMarkers(); selectionListener?.(selected()); }, + pointIdForObject(object: THREE.Object3D | undefined) { + return typeof object?.userData.routePointId === "string" + ? (object.userData.routePointId as string) + : null; + }, + selectPoint(id: string) { + selectedId = allPoints().some((point) => point.id === id) ? id : null; + renderMarkers(); + selectionListener?.(selected()); + }, renderMarkers, renderRoute, renderStationLines, + selectStation, setStationLinesVisible(visible: boolean) { stationGroup.visible = visible; }, @@ -285,11 +324,13 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou onSelectionChange(listener: (point: PlacedRoutePoint | null) => void) { selectionListener = listener; }, + onStationSelectionChange(listener: (stationId: string | null) => void) { + stationSelectionListener = listener; + }, dispose() { - disposeGroup(markerGroup); + disposeGroup(interactionGroup); disposeGroup(routeGroup); - disposeGroup(stationGroup); - scene.remove(markerGroup, routeGroup, stationGroup); + scene.remove(interactionGroup, routeGroup); }, }; } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 509c2d4a..315b6efa 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -16,6 +16,7 @@ import { confirmRoute, fetchLatestRoute, solveRoute, + updateContourInterval, type CirclePoint, type RouteLatestResponse, type RoutePoint, @@ -36,6 +37,18 @@ import { } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch"; import "./B05_wf2_Route_UI_Style.css"; +type GradeClass = RoutePanelValues["gradeClass"]; +type RoadWidths = Record; + +const DEFAULT_ROAD_WIDTHS: RoadWidths = { trunk: 3, branch: 3, work: 2.5 }; + +async function fetchRoadWidths(projectId: string): Promise { + const response = await fetch(`/api/projects/${projectId}/sections/road-widths`); + if (!response.ok) return DEFAULT_ROAD_WIDTHS; + const payload = (await response.json()) as { forest_road_min_width_m?: Partial }; + return { ...DEFAULT_ROAD_WIDTHS, ...payload.forest_road_min_width_m }; +} + function toBounds(bounds: { x_min: number; x_max: number; @@ -94,10 +107,13 @@ export async function renderB05Route(root: HTMLElement): Promise { const activeProjectId: string = projectId; const viewer = createRouteViewer(); - const profilePanel = createRouteProfilePanel(); + const profilePanel = createRouteProfilePanel((stationId) => + viewer.markers.selectStation(stationId), + ); let confirmedSurface: SurfaceModelSummary | null = null; let latest: RouteLatestResponse | null = null; - let defaultCrossHalfWidth = 0; + let roadWidths = DEFAULT_ROAD_WIDTHS; + let currentSectionDetail: SectionDetailResponse | null = null; let routeReady = false; let stale = false; let restoring = true; @@ -127,10 +143,13 @@ export async function renderB05Route(root: HTMLElement): Promise { stale = true; panel.setStale(true); updateConfirmGate(); + if (currentSectionDetail) renderStationLines(currentSectionDetail); } viewer.markers.onChange(markStale); viewer.markers.onSelectionChange(panel.setSelected); + viewer.markers.onStationSelectionChange(profilePanel.setSelectedStation); + viewer.root.append(panel.viewControls); function restorePanel(next: RouteLatestResponse): void { const options = next.route_params?.options ?? {}; @@ -146,25 +165,30 @@ export async function renderB05Route(root: HTMLElement): Promise { 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); + function renderStationLines(detail: SectionDetailResponse): void { viewer.renderStationLines( detail.longitudinal.stations, - panel.values().crossHalfWidth ?? defaultCrossHalfWidth, + roadWidths[panel.values().gradeClass] / 2, ); } + function renderSections(detail: SectionDetailResponse): void { + currentSectionDetail = detail; + profilePanel.render(detail, panel.values().stationInterval ?? undefined); + renderStationLines(detail); + } + async function restoreSections(routeId: number): Promise { try { renderSections(await fetchSectionDetail(activeProjectId, routeId)); } catch { + currentSectionDetail = null; profilePanel.clear(); viewer.renderStationLines([], 0); } @@ -187,7 +211,6 @@ export async function renderB05Route(root: HTMLElement): Promise { panel.renderMetrics(metrics); viewer.markers.renderRoute( next.route_points, - panel.values().gradeClass, (stored.curve_warning_segments as Array<{ polyline_start_index: number; polyline_end_index: number; @@ -201,6 +224,7 @@ export async function renderB05Route(root: HTMLElement): Promise { showLoadingOverlay(); try { await viewer.reloadContours(interval); + await updateContourInterval(activeProjectId, interval); } catch (error) { showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error"); } finally { @@ -238,7 +262,7 @@ export async function renderB05Route(root: HTMLElement): Promise { min_downhill_grade: values.minDownhillGrade, allow_avoid_pass_through: values.allowAvoidPassThrough, station_interval_m: values.stationInterval, - cross_half_width_m: values.crossHalfWidth, + cross_half_width_m: null, cross_sample_interval_m: values.crossSampleInterval, long_sample_interval_m: values.longSampleInterval, }); @@ -271,14 +295,16 @@ export async function renderB05Route(root: HTMLElement): Promise { } } - const [workflowState, models, latestResponse, sectionContext] = await Promise.all([ - fetchWorkflowState(activeProjectId), - listSurfaceModels(activeProjectId), - fetchLatestRoute(activeProjectId), - fetchSectionContext(activeProjectId), - ]); + const [workflowState, models, latestResponse, sectionContext, configuredRoadWidths] = + await Promise.all([ + fetchWorkflowState(activeProjectId), + listSurfaceModels(activeProjectId), + fetchLatestRoute(activeProjectId), + fetchSectionContext(activeProjectId), + fetchRoadWidths(activeProjectId), + ]); + roadWidths = configuredRoadWidths; confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null; - defaultCrossHalfWidth = sectionContext.defaults.cross_half_width_m; if (!confirmedSurface) { showToast("확정된 지표면 모델이 없습니다.", "error"); } else { @@ -288,7 +314,6 @@ export async function renderB05Route(root: HTMLElement): Promise { ); 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, }); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts index d88435e5..1409aa42 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts @@ -1,4 +1,5 @@ import type { PlacedRoutePoint, RoutePointKind } from "./B05_wf2_Route_UI_Markers"; +import { type ButtonVariant, createButton } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; export interface RoutePanelValues { @@ -13,7 +14,6 @@ export interface RoutePanelValues { minDownhillGrade: number | null; allowAvoidPassThrough: boolean; stationInterval: number | null; - crossHalfWidth: number | null; crossSampleInterval: number | null; longSampleInterval: number | null; } @@ -51,13 +51,12 @@ function section(title: string): { root: HTMLElement; body: HTMLElement } { return { root, body }; } -function button(label: string, onClick: () => void, className = ""): HTMLButtonElement { - const element = document.createElement("button"); - element.type = "button"; - element.className = `b05-route__button ${className}`.trim(); - element.textContent = label; - element.addEventListener("click", onClick); - return element; +function button( + label: string, + onClick: () => void, + variant: ButtonVariant = "ghost", +): HTMLButtonElement { + return createButton({ label, variant, onClick: () => onClick() }); } function numberField(label: string, value = ""): WrappedInput { @@ -83,6 +82,26 @@ function checkbox(label: string, checked: boolean): WrappedInput { return Object.assign(input, { wrapper }); } +function toggleButton( + label: string, + checked: boolean, + onChange: (checked: boolean) => void, +): HTMLButtonElement { + const element = button( + label, + () => { + const active = !element.classList.contains("is-active"); + element.classList.toggle("is-active", active); + element.setAttribute("aria-pressed", String(active)); + onChange(active); + }, + "glass", + ); + element.classList.toggle("is-active", checked); + element.setAttribute("aria-pressed", String(checked)); + return element; +} + function parseOptional(input: HTMLInputElement): number | null { if (!input.value.trim()) return null; const value = Number(input.value); @@ -93,41 +112,41 @@ export function createRoutePanel(callbacks: PanelCallbacks) { const root = document.createElement("div"); root.className = "b05-route__panel"; - const view = section("뷰 컨트롤"); + const viewControls = document.createElement("div"); + viewControls.className = "b05-route__view-controls"; const viewButtons = document.createElement("div"); - viewButtons.className = "b05-route__button-grid"; + viewButtons.className = "b05-route__view-group"; (["iso", "top", "front", "side"] as const).forEach((preset) => - viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset))), + viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")), ); - 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), + const visibilityButtons = document.createElement("div"); + visibilityButtons.className = "b05-route__view-group"; + visibilityButtons.append( + toggleButton("지표면", true, callbacks.onSurfaceVisible), + toggleButton("등고선", true, callbacks.onContoursVisible), + toggleButton("축 표시", false, callbacks.onAxesVisible), + toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible), ); - contoursVisible.addEventListener("change", () => - callbacks.onContoursVisible(contoursVisible.checked), - ); - axesVisible.addEventListener("change", () => callbacks.onAxesVisible(axesVisible.checked)); - stationLinesVisible.addEventListener("change", () => - callbacks.onStationLinesVisible(stationLinesVisible.checked), - ); - view.body.append( + const separator1 = document.createElement("span"); + separator1.className = "b05-route__view-separator"; + const separator2 = separator1.cloneNode() as HTMLSpanElement; + viewControls.append( viewButtons, - surfaceVisible.wrapper, - contoursVisible.wrapper, - axesVisible.wrapper, - stationLinesVisible.wrapper, - button("뷰 초기화", callbacks.onResetView), + separator1, + visibilityButtons, + separator2, + button("뷰 초기화", callbacks.onResetView, "glass"), ); const contour = section("등고선 간격"); - const contourInterval = numberField("간격 (m)", "1"); - contour.body.append( + const contourInterval = numberField("간격 (m), 최소 0.5m", "1"); + const contourRow = document.createElement("div"); + contourRow.className = "b05-route__contour-row"; + contourRow.append( contourInterval.wrapper, - button("등고선 재적용", () => callbacks.onContourApply(Number(contourInterval.value) || 1)), + button("재적용", () => callbacks.onContourApply(Number(contourInterval.value) || 1)), ); + contour.body.append(contourRow); const palette = section("포인트 팔레트"); const paletteGrid = document.createElement("div"); @@ -158,7 +177,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { selectedActions.className = "b05-route__actions"; selectedActions.append( button("위치 이동", callbacks.onMovePoint), - button("삭제", callbacks.onDeletePoint, "is-danger"), + button("삭제", callbacks.onDeletePoint, "danger"), ); selected.body.append(selectedName, radius.wrapper, selectedActions); @@ -207,12 +226,10 @@ export function createRoutePanel(callbacks: PanelCallbacks) { 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, ); @@ -227,7 +244,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { metrics.textContent = "경로를 계산하면 결과가 표시됩니다."; result.body.append(stale, metrics); - const solveButton = button("최적 경로 계산", callbacks.onSolve, "is-primary"); + const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled"); const confirmButton = button("경로 확정", callbacks.onConfirm); confirmButton.disabled = true; const actionRow = document.createElement("div"); @@ -245,13 +262,11 @@ export function createRoutePanel(callbacks: PanelCallbacks) { minUphillGrade, minDownhillGrade, stationInterval, - crossHalfWidth, crossSampleInterval, longSampleInterval, ]; inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange)); root.append( - view.root, contour.root, palette.root, selected.root, @@ -263,6 +278,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { return { root, + viewControls, values(): RoutePanelValues { return { contourInterval: Number(contourInterval.value) || 1, @@ -276,7 +292,6 @@ export function createRoutePanel(callbacks: PanelCallbacks) { minDownhillGrade: parseOptional(minDownhillGrade), allowAvoidPassThrough: avoidPass.checked, stationInterval: parseOptional(stationInterval), - crossHalfWidth: parseOptional(crossHalfWidth), crossSampleInterval: parseOptional(crossSampleInterval), longSampleInterval: parseOptional(longSampleInterval), }; @@ -293,7 +308,6 @@ export function createRoutePanel(callbacks: PanelCallbacks) { 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) diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 5985bd4f..7026ce4a 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -2,10 +2,15 @@ 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 { + createLongitudinalProfile, + longitudinalMinimumWidth, +} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View"; +import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css"; const COLLAPSED_KEY = "b05-route-profile-collapsed"; +const HORIZONTAL_SCROLLBAR_HEIGHT = 16; function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection { return { @@ -17,29 +22,65 @@ function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection }; } -export function createRouteProfilePanel() { +export function createRouteProfilePanel(onSelectStation: (stationId: string) => void) { 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 panelHandle = createWorkflowPanelHandle("bottom"); + const toggle = panelHandle.root; 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); + root.append(toggle, body); + let detail: SectionDetailResponse | null = null; + let selectedStationId: string | null = null; + let stationInterval: number | undefined; + let resizeTimer = 0; + let lastWidth = 0; + let lastHeight = 0; + + function draw(): void { + if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return; + const availableWidth = Math.max(1, body.clientWidth - 30); + const height = Math.max(1, body.clientHeight - HORIZONTAL_SCROLLBAR_HEIGHT); + const minimumWidth = longitudinalMinimumWidth(detail.longitudinal, stationInterval); + const width = Math.max(availableWidth, minimumWidth); + lastWidth = body.clientWidth; + lastHeight = height; + body.replaceChildren( + createLongitudinalProfile( + normalizedLongitudinal(detail.longitudinal), + selectedStationId, + 1, + undefined, + onSelectStation, + stationInterval, + width, + height, + minimumWidth, + ), + ); + } + + const resizeObserver = new ResizeObserver(() => { + if ( + body.clientWidth <= 0 || + body.clientHeight <= 0 || + (Math.abs(body.clientWidth - lastWidth) < 1 && Math.abs(body.clientHeight - lastHeight) < 1) + ) + return; + window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(draw, 150); + }); + resizeObserver.observe(body); function setCollapsed(collapsed: boolean): void { root.classList.toggle("is-collapsed", collapsed); - toggle.textContent = collapsed ? "펼치기" : "접기"; - toggle.setAttribute("aria-expanded", String(!collapsed)); + panelHandle.setOpen(!collapsed); sessionStorage.setItem(COLLAPSED_KEY, String(collapsed)); + if (!collapsed) requestAnimationFrame(draw); } toggle.addEventListener("click", () => setCollapsed(!root.classList.contains("is-collapsed"))); @@ -47,19 +88,24 @@ export function createRouteProfilePanel() { return { root, - render(detail: SectionDetailResponse) { - body.replaceChildren( - createLongitudinalProfile( - normalizedLongitudinal(detail.longitudinal), - null, - 1, - undefined, - () => undefined, - ), - ); + render(nextDetail: SectionDetailResponse, nextStationInterval?: number) { + detail = nextDetail; + stationInterval = nextStationInterval; + draw(); + requestAnimationFrame(draw); + }, + setSelectedStation(stationId: string | null) { + selectedStationId = stationId; + draw(); }, clear() { + detail = null; + selectedStationId = null; body.replaceChildren(empty); }, + dispose() { + window.clearTimeout(resizeTimer); + resizeObserver.disconnect(); + }, }; } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index 7e60ffa1..711d87c3 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -35,38 +35,26 @@ } .b05-route-profile { + position: relative; flex: 0 0 250px; - min-height: 0; - overflow: hidden; + min-height: 250px; + overflow: visible; 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; + flex-basis: 0; + min-height: 0; } .b05-route-profile__body { - height: calc(100% - 42px); - overflow: auto; + box-sizing: border-box; + height: 100%; + overflow-x: auto; + overflow-y: hidden; + padding-inline: 15px; } .b05-route-profile.is-collapsed .b05-route-profile__body { @@ -80,8 +68,8 @@ font-size: var(--text-body-sm); } -.b05-route-profile .b06-section__chart { - max-height: 205px; +.b05-route-profile .b06-section__chart-wrap { + height: 100%; } .b05-route__viewport canvas { @@ -103,6 +91,32 @@ pointer-events: none; } +.b05-route__view-controls { + position: absolute; + z-index: 2; + top: 58px; + left: var(--spacing-16); + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.b05-route__view-group { + display: flex; + gap: var(--spacing-4); +} + +.b05-route__view-controls .ui-btn { + height: 34px; + padding: 0 var(--spacing-8); +} + +.b05-route__view-separator { + width: 1px; + height: 24px; + background: color-mix(in srgb, var(--color-border) 75%, transparent); +} + .b05-route__panel { display: flex; flex-direction: column; @@ -139,6 +153,20 @@ gap: var(--spacing-8); } +.b05-route__contour-row { + display: flex; + align-items: end; + gap: var(--spacing-8); +} + +.b05-route__contour-row .b05-route__field { + flex: 1; +} + +.b05-route__contour-row .ui-btn { + flex: 0 0 auto; +} + .b05-route__field, .b05-route__check, .b05-route__metrics { @@ -172,7 +200,6 @@ gap: var(--spacing-8); } -.b05-route__button, .b05-route__chip { padding: var(--spacing-8); border: 1px solid var(--color-border); @@ -183,17 +210,6 @@ cursor: pointer; } -.b05-route__button:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.b05-route__button.is-primary { - background: var(--color-primary); - color: var(--color-text-on-primary); -} - -.b05-route__button.is-danger, .b05-route__chip.is-ep, .b05-route__chip.is-fp { color: var(--color-danger); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts b/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts index 64809eb7..5f3f5f47 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts @@ -12,6 +12,9 @@ import { type SectionStationMarker, } from "./B05_wf2_Route_UI_Markers"; +const LIGHT_VIEWER_BACKGROUND = 0xf5f7fa; +const DARK_VIEWER_BACKGROUND = 0x251f38; + function disposeObject(object: THREE.Object3D | null): void { object?.traverse((child) => { if ( @@ -58,7 +61,32 @@ export function createRouteViewer(): RouteViewer { root.append(canvas, status); const scene = new THREE.Scene(); - scene.background = new THREE.Color(0xf5f7fa); + const systemDarkTheme = window.matchMedia("(prefers-color-scheme: dark)"); + + function updateSceneBackground(): void { + const theme = document.documentElement.getAttribute("data-theme"); + const dark = theme === "dark" || (theme !== "light" && systemDarkTheme.matches); + if (!dark) { + scene.background = new THREE.Color(LIGHT_VIEWER_BACKGROUND); + return; + } + const surfaceRaised = getComputedStyle(document.documentElement) + .getPropertyValue("--color-surface-raised") + .trim(); + scene.background = new THREE.Color( + surfaceRaised && CSS.supports("color", surfaceRaised) + ? surfaceRaised + : DARK_VIEWER_BACKGROUND, + ); + } + + const themeObserver = new MutationObserver(updateSceneBackground); + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + systemDarkTheme.addEventListener("change", updateSceneBackground); + updateSceneBackground(); const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100000); camera.position.set(100, 120, 100); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); @@ -80,6 +108,9 @@ export function createRouteViewer(): RouteViewer { let current: { projectId: string; modelId: number; smooth: boolean; interval: number } | null = null; let movingSelected = false; + let dragCandidate: { id: string; pointerId: number; x: number; y: number } | null = null; + let draggingMarker = false; + let lastDragPoint: { x: number; y: number; z: number } | null = null; const markers = createRouteMarkers(scene, () => bounds); function clearContours(): void { @@ -173,7 +204,7 @@ export function createRouteViewer(): RouteViewer { const point = terrainPoint(event); if (point && ["bp", "ep", "cp", "ap", "fp"].includes(kind)) markers.place(kind, point); }); - canvas.addEventListener("pointerdown", (event) => { + function markerHit(event: PointerEvent): THREE.Object3D | undefined { const rect = canvas.getBoundingClientRect(); const pointer = new THREE.Vector2( ((event.clientX - rect.left) / rect.width) * 2 - 1, @@ -181,9 +212,43 @@ export function createRouteViewer(): RouteViewer { ); const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(pointer, camera); - const markerHit = raycaster.intersectObject(markers.group, true)[0]; - if (markerHit) { - markers.selectObject(markerHit.object); + return raycaster.intersectObject(markers.group, true)[0]?.object; + } + + function finishMarkerInteraction(selectCandidate: boolean): void { + if (dragCandidate && (draggingMarker || selectCandidate)) { + markers.selectPoint(dragCandidate.id); + } + if (dragCandidate && canvas.hasPointerCapture(dragCandidate.pointerId)) { + canvas.releasePointerCapture(dragCandidate.pointerId); + } + controls.enabled = true; + dragCandidate = null; + draggingMarker = false; + lastDragPoint = null; + } + + function handlePointerDown(event: PointerEvent): void { + if (event.button !== 0) return; + const hit = markerHit(event); + const pointId = markers.pointIdForObject(hit); + if (pointId) { + dragCandidate = { + id: pointId, + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + }; + draggingMarker = false; + lastDragPoint = null; + canvas.setPointerCapture(event.pointerId); + event.preventDefault(); + event.stopPropagation(); + return; + } + if (hit) { + markers.selectObject(hit); + event.stopPropagation(); return; } if (movingSelected) { @@ -193,7 +258,49 @@ export function createRouteViewer(): RouteViewer { } else { markers.selectObject(undefined); } - }); + } + + function handlePointerMove(event: PointerEvent): void { + if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + if ( + !draggingMarker && + Math.hypot(event.clientX - dragCandidate.x, event.clientY - dragCandidate.y) > 3 + ) { + draggingMarker = true; + controls.enabled = false; + } + if (!draggingMarker) return; + const point = terrainPoint(event); + if (!point) return; + lastDragPoint = point; + markers.movePoint(dragCandidate.id, point); + } + + function handlePointerUp(event: PointerEvent): void { + if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + if (draggingMarker) { + const point = terrainPoint(event) ?? lastDragPoint; + if (point) markers.movePoint(dragCandidate.id, point); + } + finishMarkerInteraction(!draggingMarker); + } + + function handlePointerExit(event: PointerEvent): void { + if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + finishMarkerInteraction(false); + } + + canvas.addEventListener("pointerdown", handlePointerDown, true); + canvas.addEventListener("pointermove", handlePointerMove, true); + canvas.addEventListener("pointerup", handlePointerUp, true); + canvas.addEventListener("pointerleave", handlePointerExit, true); + canvas.addEventListener("pointercancel", handlePointerExit, true); let frame = 0; function animate(): void { @@ -256,6 +363,13 @@ export function createRouteViewer(): RouteViewer { dispose() { cancelAnimationFrame(frame); resizeObserver.disconnect(); + themeObserver.disconnect(); + systemDarkTheme.removeEventListener("change", updateSceneBackground); + canvas.removeEventListener("pointerdown", handlePointerDown, true); + canvas.removeEventListener("pointermove", handlePointerMove, true); + canvas.removeEventListener("pointerup", handlePointerUp, true); + canvas.removeEventListener("pointerleave", handlePointerExit, true); + canvas.removeEventListener("pointercancel", handlePointerExit, true); markers.dispose(); clearContours(); disposeObject(terrain); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index 2c749862..70c99638 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -6,6 +6,7 @@ * GET /api/projects/{project_id}/sections/context → 확정 경로 + 기본 옵션 * GET /api/projects/{project_id}/sections/{route_id} → 종단 요약 조회 * GET /api/projects/{project_id}/sections/{route_id}/detail → 종횡단 원시 샘플 조회 + * POST /api/projects/{project_id}/sections/{route_id}/regenerate → 횡단 반폭 재생성 * POST /api/projects/{project_id}/sections/{route_id}/confirm → 종횡단 확정 * * 규칙: @@ -137,6 +138,18 @@ export async function fetchSectionDetail( }); } +/** 횡단 반폭을 반영해 종횡단을 재생성·저장하고 갱신된 상세를 반환한다. */ +export async function regenerateSections( + projectId: string, + routeId: number, + crossHalfWidthM: number, +): Promise { + return requestJson( + `/projects/${projectId}/sections/${routeId}/regenerate`, + { method: "POST", body: JSON.stringify({ cross_half_width_m: crossHalfWidthM }) }, + ); +} + /** 경로의 종·횡단면을 확정한다. */ export async function confirmSections( projectId: string, diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py index 587799a4..fe04565c 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py @@ -61,6 +61,63 @@ 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: + """종횡단 재생성에 필요한 경로 GeoJSON 경로와 좌표계를 route_id로 조회한다.""" + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + """ + SELECT r.route_data_path, + COALESCE( + sm.crs_epsg, + (SELECT f.crs_epsg + FROM input_files f + WHERE f.project_id = r.project_id AND f.crs_epsg IS NOT NULL + ORDER BY f.id DESC + LIMIT 1) + ) AS crs_epsg + FROM routes r + LEFT JOIN surface_models sm ON sm.id = r.surface_model_id + WHERE r.id = %s AND r.project_id = %s + LIMIT 1 + """, + (route_id, str(project_id)), + ) + row = await cursor.fetchone() + if not row or not row["route_data_path"]: + return None + return { + "route_data_path": str(row["route_data_path"]), + "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: diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index 4732ec62..46535c22 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -4,31 +4,40 @@ import asyncio import json import logging from pathlib import Path +from typing import Any from uuid import UUID +import aiomysql 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_Engine_Sections import run_section_generation 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_latest_section_options, get_longitudinal_section, + get_route_generation_source, + insert_cross_sections, ) from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import ( SectionConfirmResponse, SectionContextResponse, SectionDetailResponse, SectionOptionDefaults, + SectionRegenerateRequest, 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 +from common_util.common_util_workflow_state import complete_stage, get_workflow_state from config.config_db import get_db_pool -from config.config_system import SECTION_VERTICAL_EXAGGERATION +from config.config_system import FOREST_ROAD_MIN_WIDTH_M, SECTION_VERTICAL_EXAGGERATION logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"]) @@ -67,6 +76,12 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON ) +@router.get("/{project_id}/sections/road-widths") +async def get_forest_road_min_widths(project_id: UUID) -> dict[str, dict[str, float]]: + """B05 측점 가로선에 적용할 임도 등급별 법정 최소너비를 반환한다.""" + return {"forest_road_min_width_m": FOREST_ROAD_MIN_WIDTH_M} + + @router.get("/{project_id}/sections/{route_id}", response_model=SectionSummaryResponse) async def get_sections(project_id: UUID, route_id: int) -> SectionSummaryResponse | JSONResponse: """경로의 종단면 요약을 조회한다.""" @@ -163,6 +178,103 @@ async def get_section_detail( ) +def _regeneration_options( + stored_options: dict[str, Any] | None, + stage_params: dict[str, Any] | None, + cross_half_width_m: float, +) -> SectionGenerationOptions: + """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=pick("station_interval_m", defaults.station_interval_m), + cross_half_width_m=cross_half_width_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, + ) + + +@router.post("/{project_id}/sections/{route_id}/regenerate", response_model=SectionDetailResponse) +async def regenerate_sections( + project_id: UUID, route_id: int, request: SectionRegenerateRequest +) -> SectionDetailResponse | JSONResponse: + """표시 옵션의 횡단 반폭으로 종횡단을 재생성해 저장하고 상세를 반환한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + source = await get_route_generation_source(connection, project_id, route_id) + if not source: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "재생성할 경로가 없습니다."}, + ) + 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( + (stage for stage in workflow["stages"] if stage["stage_no"] == 2), + None, + ) + project_root = Path(resolve_stored_project_path(stored_path)) + crs_epsg = source["crs_epsg"] + sections = await asyncio.to_thread( + run_section_generation, + project_root, + source["route_data_path"], + surface_params["source_filter"], + 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, + ), + crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None, + ) + 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 + result = sections["result"] + return SectionDetailResponse( + longitudinal=result["longitudinal"], cross_sections=result["cross_sections"] + ) + except FileNotFoundError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except ValueError as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B06 종횡단 재생성 실패: project_id=%s route_id=%s", project_id, route_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "종횡단 재생성 처리 중 오류가 발생했습니다."}, + ) + + @router.post("/{project_id}/sections/{route_id}/confirm", response_model=SectionConfirmResponse) async def confirm_sections( project_id: UUID, route_id: int diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py index 2da30548..974cdd34 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py @@ -2,7 +2,13 @@ from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, Field + + +class SectionRegenerateRequest(BaseModel): + """표시 옵션의 횡단 반폭 변경에 따른 종횡단 재생성 요청.""" + + cross_half_width_m: float = Field(..., gt=0) class SectionConfirmResponse(BaseModel): diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index 0ac748fe..e822490f 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -20,6 +20,7 @@ import { fetchSectionContext, fetchSectionDetail, getSections, + regenerateSections, type SectionContextResponse, type SectionDetailResponse, type SectionSummaryResponse, @@ -69,6 +70,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); let currentRouteId: number | null = null; let sectionDetail: SectionDetailResponse | null = null; + let stationInterval: number | undefined; const routeGroup = buildGroup(L("B06_Profile_Group_Route")); const routeIdInfo = buildInfoLine(L("B06_Profile_Field_RouteId")); @@ -96,8 +98,20 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { }); verticalExaggerationField.input.min = "0.1"; verticalExaggerationField.input.step = "0.1"; - displayGroup.append(verticalExaggerationField.root); + const crossHalfWidthField = createInputField({ + label: L("B05_Route_Field_CrossHalfWidth"), + type: "number", + }); + crossHalfWidthField.input.min = "0.1"; + 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", @@ -106,7 +120,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { 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"; @@ -137,9 +151,46 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { return Number.isFinite(parsed) && parsed >= 0.1 ? parsed : 1; } - verticalExaggerationField.input.addEventListener("input", () => { - if (sectionDetail) sectionView.render(sectionDetail, verticalExaggeration()); - }); + function crossHalfWidth(): number | undefined { + const parsed = Number(crossHalfWidthField.input.value); + 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); + } + + async function applyCrossHalfWidth(): Promise { + const width = crossHalfWidth(); + 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("input", updateActionState); async function confirmCurrentSections(): Promise { if (!projectId || currentRouteId === null) return; @@ -147,6 +198,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { try { await confirmSections(projectId, currentRouteId); showToast(L("B06_Profile_Confirm_Success"), "success"); + goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[4]); } catch (error) { const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed"); showToast(`${L("B06_Profile_Confirm_Failed")} ${detail}`, "error"); @@ -180,6 +232,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); }, }); + layout.root.classList.add("b06-profile-layout"); root.replaceChildren(layout.root); if (!projectId) { @@ -199,6 +252,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { : L("B06_Profile_Smooth_Off"); crsInfo.value.textContent = context.crs_epsg === null ? "-" : `EPSG:${context.crs_epsg}`; verticalExaggerationField.input.value = String(context.defaults.vertical_exaggeration); + crossHalfWidthField.input.value = String(context.defaults.cross_half_width_m); + stationInterval = context.defaults.station_interval_m; if (context.route_id === null) { renderMessage(L("B06_Profile_Calculate_In_B05")); @@ -214,8 +269,26 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { } renderSummary(existing); sectionDetail = await fetchSectionDetail(projectId, context.route_id); - sectionView.render(sectionDetail, verticalExaggeration()); - confirmButton.disabled = false; + // 단일 소스(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(); + updateActionState(); } catch (error) { const detail = error instanceof Error ? ` ${error.message}` : ""; renderMessage(L("B06_Profile_Calculate_In_B05")); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts index d34a520e..85c4fed2 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -10,7 +10,9 @@ const SVG_NS = "http://www.w3.org/2000/svg"; const LONG_WIDTH = 1200; const LONG_HEIGHT = 220; const CROSS_WIDTH = 560; -const CROSS_HEIGHT = 260; +const CROSS_HEIGHT = 250; +const CROSS_GRID_MIN_WIDTH = 480; +const CROSS_GRID_GAP = 16; const LONG_PAD = { left: 62, right: 24, top: 30, bottom: 52 }; const CROSS_PAD = { left: 58, right: 20, top: 20, bottom: 52 }; @@ -70,12 +72,56 @@ function emptyView(message: string): HTMLElement { return empty; } +function inferStationInterval(stations: Array<{ chainage_m: number }>): number { + const counts = new Map(); + for (let index = 1; index < stations.length; index += 1) { + const difference = stations[index].chainage_m - stations[index - 1].chainage_m; + if (difference <= 0) continue; + const rounded = Math.round(difference * 10) / 10; + counts.set(rounded, (counts.get(rounded) ?? 0) + 1); + } + return ( + [...counts.entries()].sort( + ([intervalA, countA], [intervalB, countB]) => countB - countA || intervalB - intervalA, + )[0]?.[0] ?? 1 + ); +} + +function stationLabel(chainage: number, interval: number): string { + const safeInterval = interval > 0 ? interval : 1; + let stationNumber = Math.floor((chainage + 1e-6) / safeInterval); + let remainder = chainage - stationNumber * safeInterval; + if (Math.abs(remainder) < 0.05) remainder = 0; + if (remainder >= safeInterval - 0.05) { + stationNumber += 1; + remainder = 0; + } + return `${stationNumber}+${remainder.toFixed(1)}`; +} + +export function longitudinalMinimumWidth( + data: LongitudinalSection, + configuredStationInterval?: number, +): number { + const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations); + const longestLabelLength = Math.max( + 1, + ...data.stations.map((station) => stationLabel(station.chainage_m, stationInterval).length), + ); + const labelWidth = Math.max(48, longestLabelLength * 6 + 16); + return LONG_PAD.left + LONG_PAD.right + Math.max(1, data.stations.length) * labelWidth; +} + export function createLongitudinalProfile( data: LongitudinalSection, selectedStationId: string | null, verticalExaggeration: number, yScaleOptions: YScaleOptions | undefined, onSelectStation: (stationId: string) => void, + configuredStationInterval?: number, + widthPx = LONG_WIDTH, + heightPx = LONG_HEIGHT, + minimumWidthPx = widthPx, ): HTMLElement { const samples = data.samples.filter(validElevation); if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal")); @@ -84,13 +130,15 @@ export function createLongitudinalProfile( wrapper.className = "b06-section__chart-wrap"; const svg = svgElement("svg", { class: "b06-section__chart", - viewBox: `0 0 ${LONG_WIDTH} ${LONG_HEIGHT}`, + width: widthPx, + height: heightPx, + viewBox: `0 0 ${widthPx} ${heightPx}`, role: "img", "aria-label": L("B06_Profile_View_Longitudinal"), }); - svg.append( - svgElement("rect", { width: LONG_WIDTH, height: LONG_HEIGHT, class: "b06-chart__bg" }), - ); + svg.style.width = "100%"; + svg.style.minWidth = `${minimumWidthPx}px`; + svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" })); const maxChainage = Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1); const elevations = samples.map((sample) => sample.elevation_m); @@ -98,14 +146,15 @@ export function createLongitudinalProfile( const rawMax = yScaleOptions?.globalMaxElevation ?? Math.max(...elevations); const elevationMid = (rawMin + rawMax) / 2; const exaggeration = Math.max(verticalExaggeration, 0.1); - const plotWidth = LONG_WIDTH - LONG_PAD.left - LONG_PAD.right; - const plotHeight = LONG_HEIGHT - LONG_PAD.top - LONG_PAD.bottom; + const plotWidth = widthPx - LONG_PAD.left - LONG_PAD.right; + const plotHeight = heightPx - LONG_PAD.top - LONG_PAD.bottom; const elevationSpan = yScaleOptions ? plotHeight / yScaleOptions.pixelsPerMeter : Math.max(rawMax - rawMin, 1); const x = (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth; const y = (elevation: number) => LONG_PAD.top + ((elevationMid + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight; + const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations); for (const ratio of [0, 0.25, 0.5, 0.75, 1]) { const gridY = LONG_PAD.top + ratio * plotHeight; @@ -115,7 +164,7 @@ export function createLongitudinalProfile( svgElement("line", { x1: LONG_PAD.left, y1: gridY, - x2: LONG_WIDTH - LONG_PAD.right, + x2: widthPx - LONG_PAD.right, y2: gridY, class: "b06-chart__grid", }), @@ -135,7 +184,7 @@ export function createLongitudinalProfile( class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`, tabindex: "0", role: "button", - "aria-label": `${station.label} ${station.chainage_m.toFixed(1)}m`, + "aria-label": `${stationLabel(station.chainage_m, stationInterval)} ${station.chainage_m.toFixed(1)}m`, }); marker.addEventListener("click", () => onSelectStation(station.station_id)); marker.addEventListener("keydown", (event) => { @@ -146,12 +195,19 @@ export function createLongitudinalProfile( x1: stationX, y1: LONG_PAD.top, x2: stationX, - y2: LONG_HEIGHT - LONG_PAD.bottom + 8, + y2: heightPx - LONG_PAD.bottom + 8, + class: "b06-chart__station-hit", + }), + svgElement("line", { + x1: stationX, + y1: LONG_PAD.top, + x2: stationX, + y2: heightPx - LONG_PAD.bottom + 8, class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`, }), - svgText(station.label, { + svgText(stationLabel(station.chainage_m, stationInterval), { x: stationX, - y: LONG_HEIGHT - 23, + y: heightPx - 23, "text-anchor": "middle", class: "b06-chart__station-label", }), @@ -169,29 +225,29 @@ export function createLongitudinalProfile( svgElement("polyline", { points, class: "b06-chart__profile" }), svgElement("line", { x1: LONG_PAD.left, - y1: LONG_HEIGHT - LONG_PAD.bottom, - x2: LONG_WIDTH - LONG_PAD.right, - y2: LONG_HEIGHT - LONG_PAD.bottom, + y1: heightPx - LONG_PAD.bottom, + x2: widthPx - LONG_PAD.right, + y2: heightPx - LONG_PAD.bottom, class: "b06-chart__axis", }), svgElement("line", { x1: LONG_PAD.left, y1: LONG_PAD.top, x2: LONG_PAD.left, - y2: LONG_HEIGHT - LONG_PAD.bottom, + y2: heightPx - LONG_PAD.bottom, class: "b06-chart__axis", }), svgText(L("B06_Profile_View_LongitudinalXAxis"), { - x: LONG_WIDTH / 2, - y: LONG_HEIGHT - 4, + x: widthPx / 2, + y: heightPx - 4, "text-anchor": "middle", class: "b06-chart__axis-label", }), svgText(L("B06_Profile_View_ElevationAxis"), { x: 15, - y: LONG_HEIGHT / 2, + y: heightPx / 2, "text-anchor": "middle", - transform: `rotate(-90 15 ${LONG_HEIGHT / 2})`, + transform: `rotate(-90 15 ${heightPx / 2})`, class: "b06-chart__axis-label", }), ); @@ -205,6 +261,10 @@ export function createCrossSectionCard( verticalExaggeration: number, yScaleOptions: YScaleOptions | undefined, onSelect: (stationId: string) => void, + stationInterval: number, + crossHalfWidth?: number, + widthPx = CROSS_WIDTH, + heightPx = CROSS_HEIGHT, ): HTMLElement { const card = document.createElement("article"); card.id = `cross-${section.station_id}`; @@ -218,7 +278,7 @@ export function createCrossSectionCard( const header = document.createElement("header"); const title = document.createElement("div"); const label = document.createElement("strong"); - label.textContent = section.label; + label.textContent = stationLabel(section.chainage_m, stationInterval); const chainage = document.createElement("span"); chainage.textContent = `${section.chainage_m.toFixed(1)}m`; title.append(label, chainage); @@ -232,11 +292,15 @@ export function createCrossSectionCard( header.append(title, kind); card.append(header); - const valid = section.samples.filter(validElevation); + const sourceSamples = section.samples.filter( + (sample) => + crossHalfWidth === undefined || Math.abs(sample.offset_m ?? 0) <= crossHalfWidth + 1e-6, + ); + const valid = sourceSamples.filter(validElevation); if (!valid.length) { card.append(emptyView(L("B06_Profile_View_NoCross"))); } else { - const offsets = section.samples.map((sample) => sample.offset_m ?? 0); + const offsets = sourceSamples.map((sample) => sample.offset_m ?? 0); const minOffset = Math.min(...offsets, -1); const maxOffset = Math.max(...offsets, 1); const elevations = valid.map((sample) => sample.elevation_m); @@ -245,8 +309,8 @@ export function createCrossSectionCard( const elevationMid = (rawMin + rawMax) / 2; const padding = rawMax > rawMin ? (rawMax - rawMin) * 0.08 : 0.5; const exaggeration = Math.max(verticalExaggeration, 0.1); - const plotWidth = CROSS_WIDTH - CROSS_PAD.left - CROSS_PAD.right; - const plotHeight = CROSS_HEIGHT - CROSS_PAD.top - CROSS_PAD.bottom; + const plotWidth = widthPx - CROSS_PAD.left - CROSS_PAD.right; + const plotHeight = heightPx - CROSS_PAD.top - CROSS_PAD.bottom; const displaySpan = yScaleOptions ? plotHeight / yScaleOptions.pixelsPerMeter : Math.max((rawMax - rawMin + padding * 2) * exaggeration, 1); @@ -259,13 +323,13 @@ export function createCrossSectionCard( ((displayMax - elevation) / Math.max(displayMax - displayMin, 1)) * plotHeight; const svg = svgElement("svg", { class: "b06-section__chart", - viewBox: `0 0 ${CROSS_WIDTH} ${CROSS_HEIGHT}`, + width: widthPx, + height: heightPx, + viewBox: `0 0 ${widthPx} ${heightPx}`, role: "img", "aria-label": `${section.label} ${L("B06_Profile_View_Cross")}`, }); - svg.append( - svgElement("rect", { width: CROSS_WIDTH, height: CROSS_HEIGHT, class: "b06-chart__bg" }), - ); + svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" })); const xTicks = Array.from( { length: 7 }, @@ -277,12 +341,12 @@ export function createCrossSectionCard( x1: x(tick), y1: CROSS_PAD.top, x2: x(tick), - y2: CROSS_HEIGHT - CROSS_PAD.bottom, + y2: heightPx - CROSS_PAD.bottom, class: "b06-chart__grid", }), svgText(Math.abs(tick) < 1e-6 ? "0" : tick.toFixed(0), { x: x(tick), - y: CROSS_HEIGHT - CROSS_PAD.bottom + 16, + y: heightPx - CROSS_PAD.bottom + 16, "text-anchor": "middle", class: "b06-chart__tick", }), @@ -296,7 +360,7 @@ export function createCrossSectionCard( svgElement("line", { x1: CROSS_PAD.left, y1: y(displayTick), - x2: CROSS_WIDTH - CROSS_PAD.right, + x2: widthPx - CROSS_PAD.right, y2: y(displayTick), class: "b06-chart__grid", }), @@ -311,7 +375,7 @@ export function createCrossSectionCard( const segments: string[] = []; let current: string[] = []; - for (const sample of section.samples) { + for (const sample of sourceSamples) { if (!validElevation(sample)) { if (current.length > 1) segments.push(current.join(" ")); current = []; @@ -333,20 +397,20 @@ export function createCrossSectionCard( const centerX = x(0); const centerY = centerSample ? y(elevationMid + (centerSample.elevation_m - elevationMid) * exaggeration) - : CROSS_HEIGHT / 2; + : heightPx / 2; svg.append( svgElement("line", { x1: CROSS_PAD.left, - y1: CROSS_HEIGHT - CROSS_PAD.bottom, - x2: CROSS_WIDTH - CROSS_PAD.right, - y2: CROSS_HEIGHT - CROSS_PAD.bottom, + y1: heightPx - CROSS_PAD.bottom, + x2: widthPx - CROSS_PAD.right, + y2: heightPx - CROSS_PAD.bottom, class: "b06-chart__axis", }), svgElement("line", { x1: CROSS_PAD.left, y1: CROSS_PAD.top, x2: CROSS_PAD.left, - y2: CROSS_HEIGHT - CROSS_PAD.bottom, + y2: heightPx - CROSS_PAD.bottom, class: "b06-chart__axis", }), svgElement("line", { @@ -364,16 +428,16 @@ export function createCrossSectionCard( class: "b06-chart__center-marker", }), svgText(L("B06_Profile_View_CrossXAxis"), { - x: CROSS_WIDTH / 2, - y: CROSS_HEIGHT - 8, + x: widthPx / 2, + y: heightPx - 8, "text-anchor": "middle", class: "b06-chart__axis-label", }), svgText(L("B06_Profile_View_ElevationAxis"), { x: 13, - y: CROSS_HEIGHT / 2, + y: heightPx / 2, "text-anchor": "middle", - transform: `rotate(-90 13 ${CROSS_HEIGHT / 2})`, + transform: `rotate(-90 13 ${heightPx / 2})`, class: "b06-chart__axis-label", }), ); @@ -392,8 +456,14 @@ export function createCrossSectionCard( export interface SectionViewController { root: HTMLElement; - render: (detail: SectionDetailResponse, verticalExaggeration: number) => void; + render: ( + detail: SectionDetailResponse, + verticalExaggeration: number, + crossHalfWidth?: number, + stationInterval?: number, + ) => void; clear: () => void; + dispose: () => void; } export function createSectionView(): SectionViewController { @@ -402,12 +472,26 @@ export function createSectionView(): SectionViewController { let currentDetail: SectionDetailResponse | null = null; let selectedStationId: string | null = null; let currentExaggeration = 1; + let currentCrossHalfWidth: number | undefined; + let currentStationInterval: number | undefined; + let renderWidth = 0; + let resizeTimer = 0; + + const contentWidth = (): number => { + const style = getComputedStyle(root); + return Math.max( + 0, + root.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight), + ); + }; const draw = (): void => { + if (!currentDetail || renderWidth <= 0) return; root.replaceChildren(); - if (!currentDetail) return; const detail = currentDetail; const yScale = calculateYScale(detail); + const stationInterval = + currentStationInterval ?? inferStationInterval(detail.longitudinal.stations); const selectStation = (stationId: string, scroll: boolean): void => { selectedStationId = stationId; draw(); @@ -420,20 +504,18 @@ export function createSectionView(): SectionViewController { const longitudinalPanel = document.createElement("section"); longitudinalPanel.className = "b06-section__panel"; - const longitudinalHeader = document.createElement("header"); - const longitudinalTitle = document.createElement("h3"); - longitudinalTitle.textContent = L("B06_Profile_View_Longitudinal"); - const stationCount = document.createElement("span"); - stationCount.textContent = `${L("B06_Profile_View_StationCount")} ${detail.longitudinal.stations.length}`; - longitudinalHeader.append(longitudinalTitle, stationCount); + const longitudinalMinWidth = longitudinalMinimumWidth(detail.longitudinal, stationInterval); longitudinalPanel.append( - longitudinalHeader, createLongitudinalProfile( detail.longitudinal, selectedStationId, currentExaggeration, yScale, (stationId) => selectStation(stationId, true), + stationInterval, + Math.max(renderWidth, longitudinalMinWidth), + LONG_HEIGHT, + longitudinalMinWidth, ), ); @@ -446,6 +528,11 @@ export function createSectionView(): SectionViewController { crossHeading.append(crossTitle, crossCount); const grid = document.createElement("div"); grid.className = "b06-section__grid"; + const columnCount = Math.max( + 1, + Math.floor((renderWidth + CROSS_GRID_GAP) / (CROSS_GRID_MIN_WIDTH + CROSS_GRID_GAP)), + ); + const cardWidth = (renderWidth - (columnCount - 1) * CROSS_GRID_GAP) / columnCount; if (detail.cross_sections.length) { detail.cross_sections.forEach((section) => grid.append( @@ -455,6 +542,10 @@ export function createSectionView(): SectionViewController { currentExaggeration, yScale, (stationId) => selectStation(stationId, false), + stationInterval, + currentCrossHalfWidth, + cardWidth, + CROSS_HEIGHT, ), ), ); @@ -464,18 +555,39 @@ export function createSectionView(): SectionViewController { root.append(longitudinalPanel, crossHeading, grid); }; + const resizeObserver = new ResizeObserver(() => { + const nextWidth = contentWidth(); + if (nextWidth <= 0 || Math.abs(nextWidth - renderWidth) < 1) return; + window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => { + renderWidth = nextWidth; + draw(); + }, 150); + }); + resizeObserver.observe(root); + return { root, - render(detail, verticalExaggeration) { + render(detail, verticalExaggeration, crossHalfWidth, stationInterval) { currentDetail = detail; currentExaggeration = Math.max(verticalExaggeration, 0.1); + currentCrossHalfWidth = + crossHalfWidth !== undefined && crossHalfWidth > 0 ? crossHalfWidth : undefined; + currentStationInterval = + stationInterval !== undefined && stationInterval > 0 ? stationInterval : undefined; selectedStationId ??= detail.longitudinal.stations[0]?.station_id ?? null; + renderWidth = contentWidth(); draw(); + if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(root)); }, clear() { currentDetail = null; selectedStationId = null; root.replaceChildren(); }, + dispose() { + window.clearTimeout(resizeTimer); + resizeObserver.disconnect(); + }, }; } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css index 2deee323..f01181b8 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css @@ -8,6 +8,23 @@ * ========================================================================== */ /* --- 좌측 입력 폼 --- */ +.b06-profile-layout { + height: calc(100vh - var(--spacing-64)); + height: calc(100dvh - var(--spacing-64)); + min-height: 0; + overflow: hidden; +} + +.b06-profile-layout .ui-workflow-layout__body, +.b06-profile-layout .ui-workflow-layout__main { + height: 100%; + min-height: 0; +} + +.b06-profile-layout .ui-workflow-layout__main { + overflow: auto; +} + .b06-profile__form { display: flex; flex-direction: column; @@ -57,6 +74,11 @@ gap: var(--spacing-8); } +.b06-profile__actions > * { + flex: 1 1 0; + min-width: 0; +} + /* --- 우측 결과 --- */ .b06-profile__result { display: flex; @@ -114,6 +136,11 @@ min-width: 0; } +.b06-section { + box-sizing: border-box; + padding-inline: var(--spacing-24); +} + .b06-section__panel, .b06-cross-card { overflow: hidden; @@ -122,6 +149,12 @@ background: var(--color-surface-raised); } +.b06-section__panel { + position: sticky; + z-index: 2; + top: 0; +} + .b06-section__panel > header, .b06-cross-card > header, .b06-cross-card > footer { @@ -151,9 +184,11 @@ .b06-section__chart { display: block; + max-width: none; +} + +.b06-cross-card > .b06-section__chart { width: 100%; - min-width: 520px; - height: auto; } .b06-section__heading { @@ -167,7 +202,7 @@ .b06-section__grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(480px, 100%), 1fr)); gap: var(--spacing-16); } @@ -271,6 +306,12 @@ stroke-width: 1.2; } +.b06-chart__station-hit { + stroke: transparent; + stroke-width: 14; + pointer-events: stroke; +} + .b06-chart__station-line--bp, .b06-chart__station-line--regular { stroke: var(--color-warning); @@ -291,9 +332,3 @@ fill: var(--color-danger); font-weight: var(--font-weight-bold); } - -@media (max-width: 900px) { - .b06-section__grid { - grid-template-columns: 1fr; - } -} diff --git a/B10_Payment/B10_Payment_UI_Page.ts b/B10_Payment/B10_Payment_UI_Page.ts index 957ead6f..3a6ddb7e 100644 --- a/B10_Payment/B10_Payment_UI_Page.ts +++ b/B10_Payment/B10_Payment_UI_Page.ts @@ -18,7 +18,7 @@ export function renderB10Payment(root: HTMLElement): void { const invoiceSection = section(L("B10_Payment_Invoice_Title"), invoiceBody, true, [ createButton({ label: L("B10_Payment_Invoice_Request"), disabled: true }), ]); - + const depositNote = document.createElement("p"); depositNote.className = "b10-payment__note"; depositNote.textContent = L("B10_Payment_Deposit_Note"); diff --git a/common_util/common_util_surface_confirmation.py b/common_util/common_util_surface_confirmation.py index d3a68753..193d4a38 100644 --- a/common_util/common_util_surface_confirmation.py +++ b/common_util/common_util_surface_confirmation.py @@ -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, diff --git a/config/config_system.py b/config/config_system.py index d8cd3f55..9dae5b5d 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -241,6 +241,7 @@ SECTION_CROSS_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_CROSS_SAMPLE_INTERVAL SECTION_LONG_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_LONG_SAMPLE_INTERVAL_M", "1.0")) SECTION_VERTICAL_EXAGGERATION = float(os.getenv("SECTION_VERTICAL_EXAGGERATION", "1.0")) SECTION_INCLUDE_ENDPOINT = os.getenv("SECTION_INCLUDE_ENDPOINT", "True").lower() == "true" +FOREST_ROAD_MIN_WIDTH_M = {"trunk": 3.0, "branch": 3.0, "work": 2.5} # ───────────────────────────────────────────────────────────────────────── diff --git a/scratch/test_crs_verification.py b/scratch/test_crs_verification.py index 6fdb188f..66acc689 100644 --- a/scratch/test_crs_verification.py +++ b/scratch/test_crs_verification.py @@ -1,20 +1,27 @@ import sys -import os from pathlib import Path # Add project root to sys.path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from B03_FileInput.B03_FileInput_Engine_Analyze import ( + analyze_las_metadata, analyze_prj_metadata, analyze_tif_metadata, - analyze_las_metadata, ) BASE_DIR = Path("D:/02_Software_Prog/임도설계 및 견적자동화 프로그램 개발") -PRJ_PATH = BASE_DIR / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B03_FileInput/input/prj/result.prj" -TIF_PATH = BASE_DIR / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B03_FileInput/input/tif/result.tif" -LAS_PATH = BASE_DIR / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B03_FileInput/input/las/cloud_merged.las" +PRJ_PATH = ( + BASE_DIR / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B03_FileInput/input/prj/result.prj" +) +TIF_PATH = ( + BASE_DIR / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B03_FileInput/input/tif/result.tif" +) +LAS_PATH = ( + BASE_DIR + / "storage/1/3/acb9170b-9ac8-49b3-82a0-51cfa32bb42d/B03_FileInput/input/las/cloud_merged.las" +) + def run_tests(): print("--- 1. Testing PRJ Metadata Extraction ---") @@ -23,12 +30,20 @@ def run_tests(): print("PRJ Metadata:") for k, v in prj_meta.items(): print(f" {k}: {v}") - + # Assertions based on PLAN.md expectations - assert prj_meta.get("epsg") == 5187, f"Expected horizontal EPSG to be 5187, got {prj_meta.get('epsg')}" - assert prj_meta.get("crs_status") == "custom_vertical_crs", f"Expected custom_vertical_crs, got {prj_meta.get('crs_status')}" - assert prj_meta.get("vertical_crs") is not None, "Expected vertical_crs metadata to be present" - assert "KNGeoid24" in prj_meta["vertical_crs"]["name"], f"Expected KNGeoid24 in vertical crs name, got {prj_meta['vertical_crs']['name']}" + assert prj_meta.get("epsg") == 5187, ( + f"Expected horizontal EPSG to be 5187, got {prj_meta.get('epsg')}" + ) + assert prj_meta.get("crs_status") == "custom_vertical_crs", ( + f"Expected custom_vertical_crs, got {prj_meta.get('crs_status')}" + ) + assert prj_meta.get("vertical_crs") is not None, ( + "Expected vertical_crs metadata to be present" + ) + assert "KNGeoid24" in prj_meta["vertical_crs"]["name"], ( + f"Expected KNGeoid24 in vertical crs name, got {prj_meta['vertical_crs']['name']}" + ) print("PRJ test passed successfully.") else: print(f"PRJ file not found at {PRJ_PATH}") @@ -39,9 +54,11 @@ def run_tests(): print("TIF Metadata:") for k, v in tif_meta.items(): print(f" {k}: {v}") - + assert tif_meta.get("epsg") == 5187, f"Expected EPSG to be 5187, got {tif_meta.get('epsg')}" - assert tif_meta.get("crs_status") == "identified", f"Expected identified, got {tif_meta.get('crs_status')}" + assert tif_meta.get("crs_status") == "identified", ( + f"Expected identified, got {tif_meta.get('crs_status')}" + ) assert tif_meta.get("vertical_crs") is None, "Expected no vertical crs for TIF file" print("TIF test passed successfully.") else: @@ -57,14 +74,21 @@ def run_tests(): print(f" {k}: id={v.get('id')}, num_dimensions={len(v.get('dimensions', []))}") else: print(f" {k}: {v}") - + assert las_meta.get("epsg") == 5187, f"Expected EPSG to be 5187, got {las_meta.get('epsg')}" - assert las_meta.get("crs_status") == "custom_vertical_crs", f"Expected custom_vertical_crs, got {las_meta.get('crs_status')}" - assert las_meta.get("vertical_crs") is not None, "Expected vertical_crs metadata to be present" - assert "KNGeoid24" in las_meta["vertical_crs"]["name"], f"Expected KNGeoid24 in vertical crs name, got {las_meta['vertical_crs']['name']}" + assert las_meta.get("crs_status") == "custom_vertical_crs", ( + f"Expected custom_vertical_crs, got {las_meta.get('crs_status')}" + ) + assert las_meta.get("vertical_crs") is not None, ( + "Expected vertical_crs metadata to be present" + ) + assert "KNGeoid24" in las_meta["vertical_crs"]["name"], ( + f"Expected KNGeoid24 in vertical crs name, got {las_meta['vertical_crs']['name']}" + ) print("LAS test passed successfully.") else: print(f"LAS file not found at {LAS_PATH}") + if __name__ == "__main__": run_tests() diff --git a/scratch/wiki_linter.py b/scratch/wiki_linter.py index aee6382d..263b502c 100644 --- a/scratch/wiki_linter.py +++ b/scratch/wiki_linter.py @@ -35,51 +35,57 @@ for rel_path, abs_path in list(all_wiki_files.items()): continue if rel_path in ["index", "log", "index.md", "log.md"]: continue - + with open(abs_path, "r", encoding="utf-8") as f: lines = f.readlines() - + line_count = len(lines) content = "".join(lines) - + # Rule 14: Max 100 lines if line_count > 100: warnings.append(f"[Line Count] `{rel_path}.md` exceeds 100 lines ({line_count} lines).") - + # Check YAML Frontmatter via simple regex frontmatter_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL) if not frontmatter_match: errors.append(f"[Frontmatter] `{rel_path}.md` has no valid YAML frontmatter.") continue - + fm_text = frontmatter_match.group(1) - + # Simple regex parsing for status & page_id status_match = re.search(r"^status:\s*(\w+)", fm_text, re.MULTILINE) page_id_match = re.search(r"^page_id:\s*([^\n\r]+)", fm_text, re.MULTILINE) - + if not status_match: errors.append(f"[Status] `{rel_path}.md` has no status field in frontmatter.") else: status = status_match.group(1).strip() if status not in ["draft", "stable", "stale"]: - errors.append(f"[Status] `{rel_path}.md` has invalid status '{status}'. Must be draft, stable, or stale.") + errors.append( + f"[Status] `{rel_path}.md` has invalid status '{status}'. Must be draft, stable, or stale." + ) elif status == "stale": - warnings.append(f"[Stale Page] `{rel_path}.md` is marked as stale and needs updates from raw inputs.") - + warnings.append( + f"[Stale Page] `{rel_path}.md` is marked as stale and needs updates from raw inputs." + ) + # Rule 5: page_id required for pages/ if "pages/" in rel_path: if not page_id_match: errors.append(f"[page_id] `{rel_path}.md` is in pages/ but lacks a 'page_id' field.") - + # Check for broken wikilinks in content links = wikilink_pat.findall(content) for link in links: - link_clean = link.strip().replace("\\", "/").split("#")[0] # ignore anchor for file existence check + link_clean = ( + link.strip().replace("\\", "/").split("#")[0] + ) # ignore anchor for file existence check if not link_clean: continue found = False - + # 1. Absolute link from vault root (e.g. concepts/storage_paths) if link_clean in all_wiki_files: found = True @@ -98,7 +104,7 @@ for rel_path, abs_path in list(all_wiki_files.items()): resolved = os.path.normpath(os.path.join(curr_dir, link_clean)).replace("\\", "/") if resolved in all_wiki_files or resolved.replace("pages/", "") in all_wiki_files: found = True - + if not found: errors.append(f"[Broken Link] `{rel_path}.md` contains broken wikilink: [[{link}]]") @@ -108,8 +114,13 @@ for rel_path, abs_path in list(all_wiki_files.items()): if file_name_no_ext not in index_content: # check if relative path is in index rel_path_no_ext = rel_path.replace(".md", "") - if rel_path_no_ext not in index_content and rel_path_no_ext.split("/")[-1] not in index_content: - warnings.append(f"[Orphan Page] `{rel_path}.md` is not linked or mentioned in index.md.") + if ( + rel_path_no_ext not in index_content + and rel_path_no_ext.split("/")[-1] not in index_content + ): + warnings.append( + f"[Orphan Page] `{rel_path}.md` is not linked or mentioned in index.md." + ) print("=== LINT ERRORS ===") for e in sorted(list(set(errors))): diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index 4e08ab9c..50a6c4e9 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -43,7 +43,7 @@ function el( * 1. 버튼 (Button) — design.md: Filled Brand / Ghost Outlined / Pill Nav * -------------------------------------------------------------------------- */ -export type ButtonVariant = "filled" | "ghost" | "pill" | "danger"; +export type ButtonVariant = "filled" | "ghost" | "pill" | "danger" | "glass"; export interface ButtonOptions { /** 표시 텍스트 (i18n 결과 문자열을 전달) */ @@ -552,6 +552,21 @@ const BASE_CSS = ` color: var(--color-canvas); } +/* 3D 뷰포트 오버레이용: 배경 위에 떠 있어 반투명 + 블러 필요 */ +.ui-btn--glass { + border-color: color-mix(in srgb, var(--color-border) 65%, transparent); + background-color: color-mix(in srgb, var(--color-surface-raised) 72%, transparent); + color: var(--color-text-body); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} +.ui-btn--glass:hover:not(:disabled), +.ui-btn--glass.is-active { + border-color: var(--color-primary); + background-color: color-mix(in srgb, var(--color-primary) 82%, transparent); + color: var(--color-primary-text); +} + /* --- Input Field --- */ .ui-field { display: flex; flex-direction: column; gap: var(--spacing-4); } .ui-field__label { diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 782de85b..45bf8b49 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -764,6 +764,15 @@ export const ui_locales = { "종·횡단 도면 데이터를 불러오지 못했습니다.", "Failed to load section drawing data.", ], + B06_Profile_Regenerate_Failed: [ + "횡단 반폭 재생성에 실패했습니다.", + "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"], diff --git a/ui_template/ui_template_overlay.css b/ui_template/ui_template_overlay.css index fcc8da8d..587e20ea 100644 --- a/ui_template/ui_template_overlay.css +++ b/ui_template/ui_template_overlay.css @@ -81,17 +81,9 @@ cursor: pointer; } -/* title 패널 우측 가장자리 세로 중앙 핸들 — 닫히면 이 버튼만 화면 왼쪽에 남음 */ .ui-workflow-overlay__handle { position: absolute; - top: 50%; - right: calc(-1 * var(--spacing-24)); - width: var(--spacing-24); - height: var(--spacing-64); - transform: translateY(-50%); border: 1px solid var(--color-border); - border-left: 0; - border-radius: 0 var(--radius-buttons) var(--radius-buttons) 0; background: var(--color-surface-raised); color: var(--color-text); box-shadow: var(--shadow-lg); @@ -99,6 +91,40 @@ pointer-events: auto; } +.ui-workflow-overlay__handle-icon { + display: block; + width: 0; + height: 0; + margin: auto; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 7px solid currentcolor; + transition: transform var(--transition-fast); +} + +/* title 패널 우측 가장자리 세로 중앙 핸들 — 닫히면 이 버튼만 화면 왼쪽에 남음 */ +.ui-workflow-overlay__handle--side { + top: 50%; + right: calc(-1 * var(--spacing-24)); + width: var(--spacing-24); + height: var(--spacing-64); + transform: translateY(-50%); + border-left: 0; + border-radius: 0 var(--radius-buttons) var(--radius-buttons) 0; +} + +/* 하단 도킹 패널 상단 중앙 핸들 */ +.ui-workflow-overlay__handle--bottom { + z-index: 2; + top: calc(-1 * var(--spacing-24)); + left: 50%; + width: var(--spacing-64); + height: var(--spacing-24); + transform: translateX(-50%); + border-bottom: 0; + border-radius: var(--radius-buttons) var(--radius-buttons) 0 0; +} + .ui-workflow-overlay__body { overflow-y: auto; padding: 0 var(--spacing-16) var(--spacing-16); diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index 7a9c76fd..b157e005 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -18,6 +18,30 @@ export interface WorkflowOverlayHandle { setProgressOpen: (isOpen: boolean) => void; } +export interface WorkflowPanelHandle { + root: HTMLButtonElement; + setOpen: (isOpen: boolean) => void; +} + +export function createWorkflowPanelHandle(placement: "side" | "bottom"): WorkflowPanelHandle { + const root = document.createElement("button"); + root.type = "button"; + root.className = `ui-workflow-overlay__handle ui-workflow-overlay__handle--${placement}`; + const icon = document.createElement("span"); + icon.className = "ui-workflow-overlay__handle-icon"; + root.append(icon); + + function setOpen(isOpen: boolean): void { + const rotation = placement === "side" ? (isOpen ? 180 : 0) : isOpen ? 90 : -90; + icon.style.transform = `rotate(${rotation}deg)`; + root.title = t(isOpen ? "Workflow_Overlay_Collapse" : "Workflow_Overlay_Expand"); + root.setAttribute("aria-label", root.title); + root.setAttribute("aria-expanded", String(isOpen)); + } + + return { root, setOpen }; +} + function readOpenState(key: string): boolean { return sessionStorage.getItem(key) !== "false"; } @@ -40,9 +64,12 @@ function createPanel( const title = document.createElement("h2"); title.className = "ui-workflow-overlay__title"; title.textContent = titleText; - const toggle = document.createElement("button"); - toggle.type = "button"; - toggle.className = isSidebar ? "ui-workflow-overlay__handle" : "ui-workflow-overlay__toggle"; + const panelHandle = isSidebar ? createWorkflowPanelHandle("side") : null; + const toggle = panelHandle?.root ?? document.createElement("button"); + if (!panelHandle) { + toggle.type = "button"; + toggle.className = "ui-workflow-overlay__toggle"; + } if (isSidebar) { header.append(title); @@ -58,10 +85,13 @@ function createPanel( function setOpen(isOpen: boolean): void { root.classList.toggle("is-collapsed", !isOpen); - toggle.textContent = isSidebar ? (isOpen ? "◀" : "▶") : isOpen ? "−" : "+"; - toggle.title = t(isOpen ? "Workflow_Overlay_Collapse" : "Workflow_Overlay_Expand"); - toggle.setAttribute("aria-label", toggle.title); - toggle.setAttribute("aria-expanded", String(isOpen)); + if (panelHandle) panelHandle.setOpen(isOpen); + else { + toggle.textContent = isOpen ? "−" : "+"; + toggle.title = t(isOpen ? "Workflow_Overlay_Collapse" : "Workflow_Overlay_Expand"); + toggle.setAttribute("aria-label", toggle.title); + toggle.setAttribute("aria-expanded", String(isOpen)); + } sessionStorage.setItem(storageKey, String(isOpen)); onOpenChange?.(isOpen); }