Merge branch 'feature/B06-페이지-개선'
This commit is contained in:
@@ -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열 구조 */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -145,6 +145,17 @@ export async function solveRoute(
|
||||
});
|
||||
}
|
||||
|
||||
/** 등고선 간격 재적용 값을 서버(stage 1 params)에 영속화한다. */
|
||||
export async function updateContourInterval(
|
||||
projectId: string,
|
||||
contourIntervalM: number,
|
||||
): Promise<{ status: string; contour_interval_m: number }> {
|
||||
return requestJson<{ status: string; contour_interval_m: number }>(
|
||||
`/projects/${projectId}/route/contour-interval`,
|
||||
{ method: "PUT", body: JSON.stringify({ contour_interval_m: contourIntervalM }) },
|
||||
);
|
||||
}
|
||||
|
||||
/** 프로젝트의 최신 경로를 확정한다. */
|
||||
export async function confirmRoute(projectId: string): Promise<RouteConfirmResponse> {
|
||||
return requestJson<RouteConfirmResponse>(`/projects/${projectId}/route/confirm`, {
|
||||
|
||||
@@ -91,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"])
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
@@ -24,6 +25,8 @@ from B05_wf2_Route.B05_wf2_Route_Repository import (
|
||||
insert_route_points,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Schema import (
|
||||
ContourIntervalUpdateRequest,
|
||||
ContourIntervalUpdateResponse,
|
||||
RouteConfirmResponse,
|
||||
RouteLatestResponse,
|
||||
RouteSolveRequest,
|
||||
@@ -32,10 +35,14 @@ from B05_wf2_Route.B05_wf2_Route_Schema import (
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
|
||||
create_longitudinal_section,
|
||||
delete_sections_for_route,
|
||||
get_latest_section_options,
|
||||
insert_cross_sections,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
from common_util.common_util_surface_confirmation import (
|
||||
get_surface_confirmation_params,
|
||||
update_contour_interval_param,
|
||||
)
|
||||
from common_util.common_util_workflow_state import (
|
||||
complete_stage,
|
||||
fail_stage,
|
||||
@@ -48,15 +55,25 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"])
|
||||
|
||||
|
||||
def _section_options(request: RouteSolveRequest) -> SectionGenerationOptions:
|
||||
def _section_options(
|
||||
request: RouteSolveRequest, stored_options: dict[str, Any] | None
|
||||
) -> SectionGenerationOptions:
|
||||
"""요청 값 → DB 저장 옵션(단일 소스) → config 기본값 순으로 결정한다."""
|
||||
defaults = SectionGenerationOptions()
|
||||
stored = stored_options or {}
|
||||
return SectionGenerationOptions(
|
||||
station_interval_m=request.station_interval_m or defaults.station_interval_m,
|
||||
cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m,
|
||||
cross_sample_interval_m=(
|
||||
request.cross_sample_interval_m or defaults.cross_sample_interval_m
|
||||
),
|
||||
long_sample_interval_m=request.long_sample_interval_m or defaults.long_sample_interval_m,
|
||||
station_interval_m=request.station_interval_m
|
||||
or stored.get("station_interval_m")
|
||||
or defaults.station_interval_m,
|
||||
cross_half_width_m=request.cross_half_width_m
|
||||
or stored.get("cross_half_width_m")
|
||||
or defaults.cross_half_width_m,
|
||||
cross_sample_interval_m=request.cross_sample_interval_m
|
||||
or stored.get("cross_sample_interval_m")
|
||||
or defaults.cross_sample_interval_m,
|
||||
long_sample_interval_m=request.long_sample_interval_m
|
||||
or stored.get("long_sample_interval_m")
|
||||
or defaults.long_sample_interval_m,
|
||||
include_endpoint=defaults.include_endpoint,
|
||||
)
|
||||
|
||||
@@ -206,6 +223,7 @@ async def solve_route(
|
||||
crs_epsg = await get_surface_crs_epsg(
|
||||
connection, project_id, request.surface_model_id
|
||||
)
|
||||
stored_options = await get_latest_section_options(connection, project_id)
|
||||
sections = await asyncio.to_thread(
|
||||
run_section_generation,
|
||||
project_root,
|
||||
@@ -213,7 +231,7 @@ async def solve_route(
|
||||
request.filter_key,
|
||||
request.method,
|
||||
request.smooth,
|
||||
options=_section_options(request),
|
||||
options=_section_options(request, stored_options),
|
||||
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
|
||||
)
|
||||
await connection.begin()
|
||||
@@ -281,6 +299,36 @@ async def solve_route(
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{project_id}/route/contour-interval", response_model=ContourIntervalUpdateResponse)
|
||||
async def update_contour_interval(
|
||||
project_id: UUID, request: ContourIntervalUpdateRequest
|
||||
) -> ContourIntervalUpdateResponse | JSONResponse:
|
||||
"""B05 등고선 간격 재적용 값을 stage 1 params(단일 소스)에 영속화한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
await update_contour_interval_param(
|
||||
connection, str(project_id), request.contour_interval_m
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
return ContourIntervalUpdateResponse(
|
||||
project_id=str(project_id), contour_interval_m=request.contour_interval_m
|
||||
)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B05 등고선 간격 저장 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "등고선 간격 저장 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/route/latest", response_model=RouteLatestResponse)
|
||||
async def read_latest_route(project_id: UUID) -> RouteLatestResponse | JSONResponse:
|
||||
"""최신 경로와 DB 렌더 좌표, WF1/WF2 입력 스냅샷을 반환한다."""
|
||||
|
||||
@@ -90,6 +90,22 @@ class RouteSolveRequest(BaseModel):
|
||||
}
|
||||
|
||||
|
||||
class ContourIntervalUpdateRequest(BaseModel):
|
||||
"""등고선 간격 재적용 영속화 요청."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
contour_interval_m: float = Field(gt=0)
|
||||
|
||||
|
||||
class ContourIntervalUpdateResponse(BaseModel):
|
||||
"""등고선 간격 영속화 결과."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
contour_interval_m: float
|
||||
|
||||
|
||||
class RouteSolveResponse(BaseModel):
|
||||
"""경로 탐색 실행 결과."""
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<GradeClass, number>;
|
||||
|
||||
const DEFAULT_ROAD_WIDTHS: RoadWidths = { trunk: 3, branch: 3, work: 2.5 };
|
||||
|
||||
async function fetchRoadWidths(projectId: string): Promise<RoadWidths> {
|
||||
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<RoadWidths> };
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
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<void> {
|
||||
);
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<SectionDetailResponse> {
|
||||
return requestJson<SectionDetailResponse>(
|
||||
`/projects/${projectId}/sections/${routeId}/regenerate`,
|
||||
{ method: "POST", body: JSON.stringify({ cross_half_width_m: crossHalfWidthM }) },
|
||||
);
|
||||
}
|
||||
|
||||
/** 경로의 종·횡단면을 확정한다. */
|
||||
export async function confirmSections(
|
||||
projectId: string,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
});
|
||||
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<void> {
|
||||
confirmButton.disabled = true;
|
||||
const actionRow = document.createElement("div");
|
||||
actionRow.className = "b06-profile__actions";
|
||||
actionRow.append(confirmButton);
|
||||
actionRow.append(recalcButton, confirmButton);
|
||||
|
||||
const leftForm = document.createElement("div");
|
||||
leftForm.className = "b06-profile__form";
|
||||
@@ -137,9 +151,46 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (!projectId || currentRouteId === null) return;
|
||||
@@ -147,6 +198,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
: 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<void> {
|
||||
}
|
||||
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"));
|
||||
|
||||
@@ -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<number, number>();
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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()
|
||||
|
||||
+27
-16
@@ -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))):
|
||||
|
||||
@@ -43,7 +43,7 @@ function el<K extends keyof HTMLElementTagNameMap>(
|
||||
* 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 {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user