Merge pull request 'Feature/b06 페이지 개선' (#3) from feature/B06-페이지-개선 into main
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
@@ -45,6 +45,10 @@ export interface RouteSolveRequest {
|
||||
min_uphill_grade?: number | null;
|
||||
min_downhill_grade?: number | null;
|
||||
allow_avoid_pass_through?: boolean;
|
||||
station_interval_m?: number | null;
|
||||
cross_half_width_m?: number | null;
|
||||
cross_sample_interval_m?: number | null;
|
||||
long_sample_interval_m?: number | null;
|
||||
}
|
||||
|
||||
/** 경로 탐색 실행 결과 (RouteSolveResponse) */
|
||||
@@ -56,6 +60,8 @@ export interface RouteSolveResponse {
|
||||
metrics: Record<string, unknown>;
|
||||
required_points_ok: boolean;
|
||||
route_data_path: string;
|
||||
longitudinal_length_m: number | null;
|
||||
cross_section_count: number | null;
|
||||
}
|
||||
|
||||
/** 경로 확정 결과 (RouteConfirmResponse) */
|
||||
@@ -97,6 +103,10 @@ export interface RouteLatestResponse {
|
||||
};
|
||||
options?: Record<string, unknown>;
|
||||
algorithm?: string;
|
||||
station_interval_m?: number | null;
|
||||
cross_half_width_m?: number | null;
|
||||
cross_sample_interval_m?: number | null;
|
||||
long_sample_interval_m?: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
+6
-4
@@ -1,4 +1,4 @@
|
||||
"""B06 종횡단 생성 엔진 오케스트레이터.
|
||||
"""B05 경로 계산 후 종횡단을 생성하는 엔진 오케스트레이터.
|
||||
|
||||
확정 경로 GeoJSON과 확정 지표면 모델 sampler로 종단·횡단을 생성하고, 종단은
|
||||
longitudinal/, 각 횡단은 cross_sections/ 아래 파일로 저장한다. DB 기록용
|
||||
@@ -10,11 +10,11 @@ import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Sampler import build_surface_sampler
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Section import (
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import (
|
||||
SectionGenerationOptions,
|
||||
generate_sections,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Sampler import build_surface_sampler
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
_STAGE_SUBDIR = Path("B06_wf3_ProfileCross")
|
||||
@@ -36,7 +36,9 @@ def _load_route_polyline(project_root: Path, route_data_path: str) -> list[list[
|
||||
def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]:
|
||||
"""횡단면 상세에서 DB data 컬럼에 저장할 요약을 만든다."""
|
||||
samples = cross_section.get("samples", [])
|
||||
valid_z = [s["elevation_m"] for s in samples if s.get("valid") and s.get("elevation_m") is not None]
|
||||
valid_z = [
|
||||
s["elevation_m"] for s in samples if s.get("valid") and s.get("elevation_m") is not None
|
||||
]
|
||||
return {
|
||||
"chainage_m": cross_section.get("chainage_m"),
|
||||
"center_z": cross_section.get("center_z"),
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
"""B06 종단·횡단 원시 데이터 생성.
|
||||
"""B05 경로 기반 종단·횡단 원시 데이터 생성.
|
||||
|
||||
확정 경로 폴리라인과 표고 sampler로 CAD 인계 가능한 종단(longitudinal)·횡단
|
||||
(cross) 데이터를 생성한다. BP(0m)부터 station_interval 간격 측점을 만들고
|
||||
@@ -11,7 +11,7 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Sampler import SurfaceElevationSampler
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Sampler import SurfaceElevationSampler
|
||||
from config.config_system import (
|
||||
SECTION_CROSS_HALF_WIDTH_M,
|
||||
SECTION_CROSS_SAMPLE_INTERVAL_M,
|
||||
+2
-4
@@ -1,4 +1,4 @@
|
||||
"""B06 지표면 표고 sampler.
|
||||
"""B05 종횡단 계산용 지표면 표고 sampler.
|
||||
|
||||
종·횡단 생성기가 의존하는 최소 표고 조회 인터페이스와, 확정된 지표면 모델
|
||||
(B04_wf1_Surface/models)을 일괄 XY 표고 sampler로 여는 팩토리를 제공한다.
|
||||
@@ -69,9 +69,7 @@ class DtmGridSampler:
|
||||
if not len(xy):
|
||||
return np.empty(0, dtype=np.float64), np.empty(0, dtype=bool)
|
||||
|
||||
z = np.asarray(
|
||||
self._interpolator(np.column_stack([xy[:, 1], xy[:, 0]])), dtype=np.float64
|
||||
)
|
||||
z = np.asarray(self._interpolator(np.column_stack([xy[:, 1], xy[:, 0]])), dtype=np.float64)
|
||||
ix = np.searchsorted(self.x, xy[:, 0], side="right") - 1
|
||||
iy = np.searchsorted(self.y, xy[:, 1], side="right") - 1
|
||||
inside = (ix >= 0) & (iy >= 0) & (ix < len(self.x) - 1) & (iy < len(self.y) - 1)
|
||||
@@ -206,3 +206,29 @@ async def confirm_route(connection: aiomysql.Connection, route_id: int) -> None:
|
||||
"UPDATE routes SET status = 'CONFIRMED' WHERE id = %s",
|
||||
(route_id,),
|
||||
)
|
||||
|
||||
|
||||
async def get_surface_crs_epsg(
|
||||
connection: aiomysql.Connection, project_id: UUID, surface_model_id: int
|
||||
) -> int | None:
|
||||
"""종횡단 메타데이터용 좌표계를 조회한다.
|
||||
|
||||
지표면 모델 crs_epsg가 NULL이면 같은 프로젝트 input_files의 감지된
|
||||
좌표계로 폴백한다 (B06 get_confirmed_route_context와 동일 규칙).
|
||||
"""
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT COALESCE(
|
||||
(SELECT sm.crs_epsg FROM surface_models sm WHERE sm.id = %s),
|
||||
(SELECT f.crs_epsg
|
||||
FROM input_files f
|
||||
WHERE f.project_id = %s AND f.crs_epsg IS NOT NULL
|
||||
ORDER BY f.id DESC
|
||||
LIMIT 1)
|
||||
) AS crs_epsg
|
||||
""",
|
||||
(surface_model_id, str(project_id)),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row["crs_epsg"]) if row and row["crs_epsg"] is not None else None
|
||||
|
||||
@@ -12,12 +12,15 @@ from fastapi.responses import JSONResponse
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_wf2_Route.B05_wf2_Route_Debug import log_b05_debug
|
||||
from B05_wf2_Route.B05_wf2_Route_Engine import run_route_design
|
||||
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 B05_wf2_Route.B05_wf2_Route_Repository import (
|
||||
confirm_route,
|
||||
create_route,
|
||||
create_route_statistics,
|
||||
get_latest_route,
|
||||
get_route_points,
|
||||
get_surface_crs_epsg,
|
||||
insert_route_points,
|
||||
)
|
||||
from B05_wf2_Route.B05_wf2_Route_Schema import (
|
||||
@@ -26,6 +29,11 @@ from B05_wf2_Route.B05_wf2_Route_Schema import (
|
||||
RouteSolveRequest,
|
||||
RouteSolveResponse,
|
||||
)
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import (
|
||||
create_longitudinal_section,
|
||||
delete_sections_for_route,
|
||||
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_workflow_state import (
|
||||
@@ -40,6 +48,19 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"])
|
||||
|
||||
|
||||
def _section_options(request: RouteSolveRequest) -> SectionGenerationOptions:
|
||||
defaults = SectionGenerationOptions()
|
||||
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,
|
||||
include_endpoint=defaults.include_endpoint,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/solve", response_model=RouteSolveResponse)
|
||||
async def solve_route(
|
||||
project_id: UUID, request: RouteSolveRequest
|
||||
@@ -55,6 +76,10 @@ async def solve_route(
|
||||
"options": request.options(),
|
||||
"algorithm": request.algorithm,
|
||||
"surface_model_id": request.surface_model_id,
|
||||
"station_interval_m": request.station_interval_m,
|
||||
"cross_half_width_m": request.cross_half_width_m,
|
||||
"cross_sample_interval_m": request.cross_sample_interval_m,
|
||||
"long_sample_interval_m": request.long_sample_interval_m,
|
||||
}
|
||||
log_b05_debug(
|
||||
logger,
|
||||
@@ -174,6 +199,52 @@ async def solve_route(
|
||||
)
|
||||
raise
|
||||
|
||||
# 종횡단 생성 실패는 저장된 경로를 무효화하지 않으므로 비치명적으로 처리한다.
|
||||
longitudinal_length_m: float | None = None
|
||||
cross_section_count: int | None = None
|
||||
try:
|
||||
crs_epsg = await get_surface_crs_epsg(
|
||||
connection, project_id, request.surface_model_id
|
||||
)
|
||||
sections = await asyncio.to_thread(
|
||||
run_section_generation,
|
||||
project_root,
|
||||
design["route_data_path"],
|
||||
request.filter_key,
|
||||
request.method,
|
||||
request.smooth,
|
||||
options=_section_options(request),
|
||||
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
|
||||
longitudinal_length_m = sections["longitudinal"]["data"]["length_m"]
|
||||
cross_section_count = len(sections["cross_sections"])
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"B05 종횡단 생성 실패 (경로는 저장됨): project_id=%s route_id=%s",
|
||||
project_id,
|
||||
route_id,
|
||||
)
|
||||
|
||||
return RouteSolveResponse(
|
||||
project_id=str(project_id),
|
||||
route_id=route_id,
|
||||
@@ -181,6 +252,8 @@ async def solve_route(
|
||||
metrics=metrics,
|
||||
required_points_ok=solver["required_points_ok"],
|
||||
route_data_path=design["route_data_path"],
|
||||
longitudinal_length_m=longitudinal_length_m,
|
||||
cross_section_count=cross_section_count,
|
||||
)
|
||||
except LookupError as exc:
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
|
||||
@@ -54,6 +54,10 @@ class RouteSolveRequest(BaseModel):
|
||||
min_downhill_grade: float | None = None
|
||||
weights: dict[str, float] | None = None
|
||||
allow_avoid_pass_through: bool = Field(default=False)
|
||||
station_interval_m: float | None = Field(default=None, gt=0)
|
||||
cross_half_width_m: float | None = Field(default=None, gt=0)
|
||||
cross_sample_interval_m: float | None = Field(default=None, gt=0)
|
||||
long_sample_interval_m: float | None = Field(default=None, gt=0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_choices(self) -> "RouteSolveRequest":
|
||||
@@ -96,6 +100,9 @@ class RouteSolveResponse(BaseModel):
|
||||
metrics: dict[str, Any]
|
||||
required_points_ok: bool
|
||||
route_data_path: str
|
||||
# 종횡단 생성 실패 시 None (경로 자체는 저장됨)
|
||||
longitudinal_length_m: float | None = None
|
||||
cross_section_count: int | None = None
|
||||
|
||||
|
||||
class RouteConfirmResponse(BaseModel):
|
||||
|
||||
@@ -25,6 +25,13 @@ export interface ModelBounds {
|
||||
z: [number, number];
|
||||
}
|
||||
|
||||
export interface SectionStationMarker {
|
||||
center_x: number;
|
||||
center_y: number;
|
||||
center_z: number | null;
|
||||
frame: { left_xy: [number, number] };
|
||||
}
|
||||
|
||||
const COLORS: Record<RoutePointKind, number> = {
|
||||
bp: 0x10b981,
|
||||
ep: 0xef4444,
|
||||
@@ -65,7 +72,8 @@ export function sceneToModel(point: THREE.Vector3, bounds: ModelBounds) {
|
||||
export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBounds | null) {
|
||||
const markerGroup = new THREE.Group();
|
||||
const routeGroup = new THREE.Group();
|
||||
scene.add(markerGroup, routeGroup);
|
||||
const stationGroup = new THREE.Group();
|
||||
scene.add(markerGroup, routeGroup, stationGroup);
|
||||
let points = emptyPoints();
|
||||
let selectedId: string | null = null;
|
||||
let changeListener: ((points: RouteDesignPoints) => void) | undefined;
|
||||
@@ -216,6 +224,34 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
modelToScene(
|
||||
{ x: center.x + leftX * halfWidth, y: center.y + leftY * halfWidth, z: center.z },
|
||||
bounds,
|
||||
),
|
||||
modelToScene(
|
||||
{ x: center.x - leftX * halfWidth, y: center.y - leftY * halfWidth, z: center.z },
|
||||
bounds,
|
||||
),
|
||||
);
|
||||
});
|
||||
stationGroup.add(
|
||||
new THREE.LineSegments(
|
||||
new THREE.BufferGeometry().setFromPoints(points),
|
||||
new THREE.LineBasicMaterial({ color: 0xa855f7 }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
group: markerGroup,
|
||||
getPoints: () => points,
|
||||
@@ -239,6 +275,10 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
|
||||
},
|
||||
renderMarkers,
|
||||
renderRoute,
|
||||
renderStationLines,
|
||||
setStationLinesVisible(visible: boolean) {
|
||||
stationGroup.visible = visible;
|
||||
},
|
||||
onChange(listener: (next: RouteDesignPoints) => void) {
|
||||
changeListener = listener;
|
||||
},
|
||||
@@ -248,7 +288,8 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
|
||||
dispose() {
|
||||
disposeGroup(markerGroup);
|
||||
disposeGroup(routeGroup);
|
||||
scene.remove(markerGroup, routeGroup);
|
||||
disposeGroup(stationGroup);
|
||||
scene.remove(markerGroup, routeGroup, stationGroup);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,7 +27,13 @@ import {
|
||||
type RoutePointKind,
|
||||
} from "./B05_wf2_Route_UI_Markers";
|
||||
import { createRoutePanel, type RoutePanelValues } from "./B05_wf2_Route_UI_Panel";
|
||||
import { createRouteProfilePanel } from "./B05_wf2_Route_UI_Profile_Panel";
|
||||
import { createRouteViewer } from "./B05_wf2_Route_UI_Viewer";
|
||||
import {
|
||||
fetchSectionContext,
|
||||
fetchSectionDetail,
|
||||
type SectionDetailResponse,
|
||||
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch";
|
||||
import "./B05_wf2_Route_UI_Style.css";
|
||||
|
||||
function toBounds(bounds: {
|
||||
@@ -88,8 +94,10 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
const activeProjectId: string = projectId;
|
||||
|
||||
const viewer = createRouteViewer();
|
||||
const profilePanel = createRouteProfilePanel();
|
||||
let confirmedSurface: SurfaceModelSummary | null = null;
|
||||
let latest: RouteLatestResponse | null = null;
|
||||
let defaultCrossHalfWidth = 0;
|
||||
let routeReady = false;
|
||||
let stale = false;
|
||||
let restoring = true;
|
||||
@@ -101,6 +109,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
onSurfaceVisible: viewer.setSurfaceVisible,
|
||||
onContoursVisible: viewer.setContoursVisible,
|
||||
onAxesVisible: viewer.setAxesVisible,
|
||||
onStationLinesVisible: viewer.setStationLinesVisible,
|
||||
onView: viewer.setView,
|
||||
onResetView: () => viewer.setView("top"),
|
||||
onMovePoint: viewer.beginMoveSelected,
|
||||
@@ -136,10 +145,31 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
minUphillGrade: options.min_uphill_grade as number | undefined,
|
||||
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);
|
||||
viewer.renderStationLines(
|
||||
detail.longitudinal.stations,
|
||||
panel.values().crossHalfWidth ?? defaultCrossHalfWidth,
|
||||
);
|
||||
}
|
||||
|
||||
async function restoreSections(routeId: number): Promise<void> {
|
||||
try {
|
||||
renderSections(await fetchSectionDetail(activeProjectId, routeId));
|
||||
} catch {
|
||||
profilePanel.clear();
|
||||
viewer.renderStationLines([], 0);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLatest(next: RouteLatestResponse): void {
|
||||
latest = next;
|
||||
routeReady = Boolean(next.route && next.route_points.length > 1);
|
||||
@@ -188,7 +218,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
const values = panel.values();
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await solveRoute(activeProjectId, {
|
||||
const solved = await solveRoute(activeProjectId, {
|
||||
filter_key: latest.surface_params.source_filter,
|
||||
method: latest.surface_params.method,
|
||||
smooth: latest.surface_params.smooth,
|
||||
@@ -207,9 +237,18 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
min_uphill_grade: values.minUphillGrade,
|
||||
min_downhill_grade: values.minDownhillGrade,
|
||||
allow_avoid_pass_through: values.allowAvoidPassThrough,
|
||||
station_interval_m: values.stationInterval,
|
||||
cross_half_width_m: values.crossHalfWidth,
|
||||
cross_sample_interval_m: values.crossSampleInterval,
|
||||
long_sample_interval_m: values.longSampleInterval,
|
||||
});
|
||||
renderLatest(await fetchLatestRoute(activeProjectId));
|
||||
showToast("최적 경로 계산이 완료되었습니다.", "success");
|
||||
await restoreSections(solved.route_id);
|
||||
if (solved.cross_section_count === null) {
|
||||
showToast("경로는 저장되었지만 종횡단 생성에 실패했습니다.", "error");
|
||||
} else {
|
||||
showToast("최적 경로 계산이 완료되었습니다.", "success");
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : "경로 계산에 실패했습니다.", "error");
|
||||
} finally {
|
||||
@@ -232,12 +271,14 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const [workflowState, models, latestResponse] = await Promise.all([
|
||||
const [workflowState, models, latestResponse, sectionContext] = await Promise.all([
|
||||
fetchWorkflowState(activeProjectId),
|
||||
listSurfaceModels(activeProjectId),
|
||||
fetchLatestRoute(activeProjectId),
|
||||
fetchSectionContext(activeProjectId),
|
||||
]);
|
||||
confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null;
|
||||
defaultCrossHalfWidth = sectionContext.defaults.cross_half_width_m;
|
||||
if (!confirmedSurface) {
|
||||
showToast("확정된 지표면 모델이 없습니다.", "error");
|
||||
} else {
|
||||
@@ -245,6 +286,12 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
activeProjectId,
|
||||
latestResponse.surface_params.source_filter,
|
||||
);
|
||||
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,
|
||||
});
|
||||
restorePanel(latestResponse);
|
||||
await viewer.loadSurface(
|
||||
activeProjectId,
|
||||
@@ -255,16 +302,20 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
toBounds(cloud.bounds),
|
||||
);
|
||||
renderLatest(latestResponse);
|
||||
if (latestResponse.route) await restoreSections(latestResponse.route.id);
|
||||
}
|
||||
latest = latestResponse;
|
||||
restoring = false;
|
||||
|
||||
const mainContent = document.createElement("div");
|
||||
mainContent.className = "b05-route__main";
|
||||
mainContent.append(viewer.root, profilePanel.root);
|
||||
const layout = createWorkflowLayout({
|
||||
title: "노선 설계",
|
||||
steps: workflowSteps(),
|
||||
activeStep: 2,
|
||||
leftPanel: panel.root,
|
||||
mainContent: viewer.root,
|
||||
mainContent,
|
||||
stages: workflowState.stages,
|
||||
currentStage: workflowState.current_stage,
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PlacedRoutePoint, RoutePointKind } from "./B05_wf2_Route_UI_Markers";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
|
||||
export interface RoutePanelValues {
|
||||
contourInterval: number;
|
||||
@@ -11,6 +12,10 @@ export interface RoutePanelValues {
|
||||
minUphillGrade: number | null;
|
||||
minDownhillGrade: number | null;
|
||||
allowAvoidPassThrough: boolean;
|
||||
stationInterval: number | null;
|
||||
crossHalfWidth: number | null;
|
||||
crossSampleInterval: number | null;
|
||||
longSampleInterval: number | null;
|
||||
}
|
||||
|
||||
interface PanelCallbacks {
|
||||
@@ -20,6 +25,7 @@ interface PanelCallbacks {
|
||||
onSurfaceVisible: (visible: boolean) => void;
|
||||
onContoursVisible: (visible: boolean) => void;
|
||||
onAxesVisible: (visible: boolean) => void;
|
||||
onStationLinesVisible: (visible: boolean) => void;
|
||||
onView: (view: "iso" | "top" | "front" | "side") => void;
|
||||
onResetView: () => void;
|
||||
onMovePoint: () => void;
|
||||
@@ -30,6 +36,10 @@ interface PanelCallbacks {
|
||||
|
||||
type WrappedInput = HTMLInputElement & { wrapper: HTMLLabelElement };
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
function section(title: string): { root: HTMLElement; body: HTMLElement } {
|
||||
const root = document.createElement("section");
|
||||
root.className = "b05-route__panel-section";
|
||||
@@ -92,6 +102,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
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),
|
||||
);
|
||||
@@ -99,11 +110,15 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
callbacks.onContoursVisible(contoursVisible.checked),
|
||||
);
|
||||
axesVisible.addEventListener("change", () => callbacks.onAxesVisible(axesVisible.checked));
|
||||
stationLinesVisible.addEventListener("change", () =>
|
||||
callbacks.onStationLinesVisible(stationLinesVisible.checked),
|
||||
);
|
||||
view.body.append(
|
||||
viewButtons,
|
||||
surfaceVisible.wrapper,
|
||||
contoursVisible.wrapper,
|
||||
axesVisible.wrapper,
|
||||
stationLinesVisible.wrapper,
|
||||
button("뷰 초기화", callbacks.onResetView),
|
||||
);
|
||||
|
||||
@@ -190,6 +205,18 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
help,
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
const result = section("경로 설계 산출 결과");
|
||||
const stale = document.createElement("span");
|
||||
stale.className = "b05-route__stale";
|
||||
@@ -217,6 +244,10 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
maxDownhillGrade,
|
||||
minUphillGrade,
|
||||
minDownhillGrade,
|
||||
stationInterval,
|
||||
crossHalfWidth,
|
||||
crossSampleInterval,
|
||||
longSampleInterval,
|
||||
];
|
||||
inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange));
|
||||
root.append(
|
||||
@@ -225,6 +256,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
palette.root,
|
||||
selected.root,
|
||||
conditions.root,
|
||||
sectionOptions.root,
|
||||
result.root,
|
||||
actionRow,
|
||||
);
|
||||
@@ -243,6 +275,10 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
minUphillGrade: parseOptional(minUphillGrade),
|
||||
minDownhillGrade: parseOptional(minDownhillGrade),
|
||||
allowAvoidPassThrough: avoidPass.checked,
|
||||
stationInterval: parseOptional(stationInterval),
|
||||
crossHalfWidth: parseOptional(crossHalfWidth),
|
||||
crossSampleInterval: parseOptional(crossSampleInterval),
|
||||
longSampleInterval: parseOptional(longSampleInterval),
|
||||
};
|
||||
},
|
||||
restore(values: Partial<RoutePanelValues>) {
|
||||
@@ -256,6 +292,12 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
if (values.minUphillGrade != null) minUphillGrade.value = String(values.minUphillGrade);
|
||||
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)
|
||||
longSampleInterval.value = String(values.longSampleInterval);
|
||||
},
|
||||
setSelected(point: PlacedRoutePoint | null) {
|
||||
selected.root.hidden = !point;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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 "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css";
|
||||
|
||||
const COLLAPSED_KEY = "b05-route-profile-collapsed";
|
||||
|
||||
function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection {
|
||||
return {
|
||||
...data,
|
||||
samples: data.samples.map((sample) => ({
|
||||
...sample,
|
||||
elevation_m: sample.elevation_m ?? sample.z ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function createRouteProfilePanel() {
|
||||
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 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);
|
||||
|
||||
function setCollapsed(collapsed: boolean): void {
|
||||
root.classList.toggle("is-collapsed", collapsed);
|
||||
toggle.textContent = collapsed ? "펼치기" : "접기";
|
||||
toggle.setAttribute("aria-expanded", String(!collapsed));
|
||||
sessionStorage.setItem(COLLAPSED_KEY, String(collapsed));
|
||||
}
|
||||
|
||||
toggle.addEventListener("click", () => setCollapsed(!root.classList.contains("is-collapsed")));
|
||||
setCollapsed(sessionStorage.getItem(COLLAPSED_KEY) === "true");
|
||||
|
||||
return {
|
||||
root,
|
||||
render(detail: SectionDetailResponse) {
|
||||
body.replaceChildren(
|
||||
createLongitudinalProfile(
|
||||
normalizedLongitudinal(detail.longitudinal),
|
||||
null,
|
||||
1,
|
||||
undefined,
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
},
|
||||
clear() {
|
||||
body.replaceChildren(empty);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -15,15 +15,75 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.b05-route__viewport {
|
||||
position: relative;
|
||||
.b05-route__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.b05-route__viewport {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b05-route-profile {
|
||||
flex: 0 0 250px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
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;
|
||||
}
|
||||
|
||||
.b05-route-profile__body {
|
||||
height: calc(100% - 42px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.b05-route-profile.is-collapsed .b05-route-profile__body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-route-profile__empty {
|
||||
margin: 0;
|
||||
padding: var(--spacing-24);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b05-route-profile .b06-section__chart {
|
||||
max-height: 205px;
|
||||
}
|
||||
|
||||
.b05-route__viewport canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ModelBounds,
|
||||
type RouteMarkers,
|
||||
type RoutePointKind,
|
||||
type SectionStationMarker,
|
||||
} from "./B05_wf2_Route_UI_Markers";
|
||||
|
||||
function disposeObject(object: THREE.Object3D | null): void {
|
||||
@@ -40,6 +41,8 @@ export interface RouteViewer {
|
||||
setSurfaceVisible: (visible: boolean) => void;
|
||||
setContoursVisible: (visible: boolean) => void;
|
||||
setAxesVisible: (visible: boolean) => void;
|
||||
setStationLinesVisible: (visible: boolean) => void;
|
||||
renderStationLines: (stations: SectionStationMarker[], halfWidth: number) => void;
|
||||
setView: (view: "iso" | "top" | "front" | "side") => void;
|
||||
beginMoveSelected: () => void;
|
||||
dispose: () => void;
|
||||
@@ -243,6 +246,8 @@ export function createRouteViewer(): RouteViewer {
|
||||
setAxesVisible(visible) {
|
||||
axes.visible = visible;
|
||||
},
|
||||
setStationLinesVisible: markers.setStationLinesVisible,
|
||||
renderStationLines: markers.renderStationLines,
|
||||
setView: fit,
|
||||
beginMoveSelected() {
|
||||
movingSelected = true;
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* 3차 워크플로우(종·횡단 생성) API 클라이언트
|
||||
*
|
||||
* 백엔드 계약 (B06_wf3_ProfileCross_Router.py):
|
||||
* POST /api/projects/{project_id}/sections/generate → 종횡단 생성 + DB 기록
|
||||
* GET /api/projects/{project_id}/sections/context → 확정 경로 + 기본 옵션
|
||||
* GET /api/projects/{project_id}/sections/{route_id} → 종단 요약 조회
|
||||
* GET /api/projects/{project_id}/sections/{route_id}/detail → 종횡단 원시 샘플 조회
|
||||
* POST /api/projects/{project_id}/sections/{route_id}/confirm → 종횡단 확정
|
||||
*
|
||||
* 규칙:
|
||||
@@ -14,28 +15,22 @@
|
||||
|
||||
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
|
||||
/** 종횡단 생성 실행 요청 (SectionGenerateRequest) */
|
||||
export interface SectionGenerateRequest {
|
||||
route_id: number;
|
||||
filter_key: string;
|
||||
method?: string;
|
||||
smooth?: boolean;
|
||||
crs?: string | null;
|
||||
station_interval_m?: number | null;
|
||||
cross_half_width_m?: number | null;
|
||||
cross_sample_interval_m?: number | null;
|
||||
long_sample_interval_m?: number | null;
|
||||
export interface SectionOptionDefaults {
|
||||
station_interval_m: number;
|
||||
cross_half_width_m: number;
|
||||
cross_sample_interval_m: number;
|
||||
long_sample_interval_m: number;
|
||||
vertical_exaggeration: number;
|
||||
}
|
||||
|
||||
/** 종횡단 생성 결과 (SectionGenerateResponse) */
|
||||
export interface SectionGenerateResponse {
|
||||
status: string;
|
||||
export interface SectionContextResponse {
|
||||
project_id: string;
|
||||
route_id: number;
|
||||
longitudinal_id: number;
|
||||
cross_section_count: number;
|
||||
length_m: number;
|
||||
longitudinal_file_path: string;
|
||||
route_id: number | null;
|
||||
filter_key: string | null;
|
||||
method: string | null;
|
||||
smooth: boolean | null;
|
||||
crs_epsg: number | null;
|
||||
defaults: SectionOptionDefaults;
|
||||
}
|
||||
|
||||
/** 종단 요약 조회 결과 (SectionSummaryResponse) */
|
||||
@@ -44,6 +39,43 @@ export interface SectionSummaryResponse {
|
||||
project_id: string;
|
||||
route_id: number;
|
||||
longitudinal: Record<string, unknown> | null;
|
||||
length_m: number | null;
|
||||
cross_section_count: number;
|
||||
}
|
||||
|
||||
export interface SectionSample {
|
||||
chainage_m?: number;
|
||||
offset_m?: number;
|
||||
elevation_m?: number | null;
|
||||
z?: number | null;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export interface SectionStation {
|
||||
station_id: string;
|
||||
chainage_m: number;
|
||||
label: string;
|
||||
kind: "bp" | "ep" | "regular";
|
||||
center_z: number | null;
|
||||
azimuth_deg: number | null;
|
||||
center_x: number;
|
||||
center_y: number;
|
||||
frame: { left_xy: [number, number] };
|
||||
}
|
||||
|
||||
export interface LongitudinalSection {
|
||||
length_m: number;
|
||||
samples: SectionSample[];
|
||||
stations: SectionStation[];
|
||||
}
|
||||
|
||||
export interface CrossSection extends SectionStation {
|
||||
samples: SectionSample[];
|
||||
}
|
||||
|
||||
export interface SectionDetailResponse {
|
||||
longitudinal: LongitudinalSection;
|
||||
cross_sections: CrossSection[];
|
||||
}
|
||||
|
||||
/** 종횡단 확정 결과 (SectionConfirmResponse) */
|
||||
@@ -78,14 +110,10 @@ async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 확정 경로에서 종·횡단을 생성한다 (측점 배열 → 종단 프로필 → 횡단 샘플). */
|
||||
export async function generateSections(
|
||||
projectId: string,
|
||||
request: SectionGenerateRequest,
|
||||
): Promise<SectionGenerateResponse> {
|
||||
return requestJson<SectionGenerateResponse>(`/projects/${projectId}/sections/generate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(request),
|
||||
/** 최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 조회한다. */
|
||||
export async function fetchSectionContext(projectId: string): Promise<SectionContextResponse> {
|
||||
return requestJson<SectionContextResponse>(`/projects/${projectId}/sections/context`, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,6 +127,16 @@ export async function getSections(
|
||||
});
|
||||
}
|
||||
|
||||
/** 경로의 SVG 렌더링용 종단·횡단 원시 샘플을 조회한다. */
|
||||
export async function fetchSectionDetail(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
): Promise<SectionDetailResponse> {
|
||||
return requestJson<SectionDetailResponse>(`/projects/${projectId}/sections/${routeId}/detail`, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
/** 경로의 종·횡단면을 확정한다. */
|
||||
export async function confirmSections(
|
||||
projectId: string,
|
||||
|
||||
@@ -24,6 +24,43 @@ def _validate_stage_path(relative_path: str) -> str:
|
||||
return normalized.as_posix()
|
||||
|
||||
|
||||
async def get_confirmed_route_context(
|
||||
connection: aiomysql.Connection, project_id: UUID
|
||||
) -> dict[str, Any] | None:
|
||||
"""프로젝트의 최신 확정 경로와 연결된 지표면 좌표계를 조회한다.
|
||||
|
||||
surface_models.crs_epsg가 NULL이면(분석에 사용한 입력 파일에 좌표계가
|
||||
없던 경우) 같은 프로젝트 input_files의 감지된 좌표계로 폴백한다.
|
||||
"""
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT r.id AS route_id,
|
||||
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.project_id = %s AND r.status = 'CONFIRMED'
|
||||
ORDER BY r.computed_at DESC, r.id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(str(project_id),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"route_id": int(row["route_id"]),
|
||||
"crs_epsg": int(row["crs_epsg"]) if row["crs_epsg"] is not None else None,
|
||||
}
|
||||
|
||||
|
||||
async def delete_sections_for_route(connection: aiomysql.Connection, route_id: int) -> None:
|
||||
"""경로 재생성 전에 기존 종횡단 레코드를 삭제한다 (멱등 재실행)."""
|
||||
async with connection.cursor() as cursor:
|
||||
@@ -113,7 +150,7 @@ async def get_longitudinal_section(
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT id, longitudinal_file_path, status, computed_at
|
||||
SELECT id, data, longitudinal_file_path, status, computed_at
|
||||
FROM longitudinal_sections
|
||||
WHERE project_id = %s AND route_id = %s
|
||||
ORDER BY id DESC
|
||||
@@ -124,14 +161,26 @@ async def get_longitudinal_section(
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
data = row[1]
|
||||
if isinstance(data, str):
|
||||
data = json.loads(data)
|
||||
return {
|
||||
"id": int(row[0]),
|
||||
"longitudinal_file_path": row[1],
|
||||
"status": row[2],
|
||||
"computed_at": row[3].isoformat() if row[3] else None,
|
||||
"data": data if isinstance(data, dict) else None,
|
||||
"longitudinal_file_path": row[2],
|
||||
"status": row[3],
|
||||
"computed_at": row[4].isoformat() if row[4] else None,
|
||||
}
|
||||
|
||||
|
||||
async def count_cross_sections(connection: aiomysql.Connection, route_id: int) -> int:
|
||||
"""경로에 저장된 횡단면 개수를 반환한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute("SELECT COUNT(*) FROM cross_sections WHERE route_id = %s", (route_id,))
|
||||
row = await cursor.fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
async def confirm_sections_for_route(connection: aiomysql.Connection, route_id: int) -> None:
|
||||
"""경로의 종횡단면 상태를 CONFIRMED로 변경한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""B06 종횡단 생성 FastAPI 라우터."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
@@ -9,142 +10,60 @@ 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_Repository import get_latest_route
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine import run_section_generation
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Section import SectionGenerationOptions
|
||||
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,
|
||||
create_longitudinal_section,
|
||||
delete_sections_for_route,
|
||||
count_cross_sections,
|
||||
get_confirmed_route_context,
|
||||
get_longitudinal_section,
|
||||
insert_cross_sections,
|
||||
)
|
||||
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import (
|
||||
SectionConfirmResponse,
|
||||
SectionGenerateRequest,
|
||||
SectionGenerateResponse,
|
||||
SectionContextResponse,
|
||||
SectionDetailResponse,
|
||||
SectionOptionDefaults,
|
||||
SectionSummaryResponse,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_workflow_state import complete_stage, fail_stage, start_stage
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
from common_util.common_util_workflow_state import complete_stage
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system import SECTION_VERTICAL_EXAGGERATION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
|
||||
|
||||
|
||||
def _build_options(request: SectionGenerateRequest) -> SectionGenerationOptions:
|
||||
"""요청의 옵션(미지정은 config 기본값)으로 SectionGenerationOptions를 만든다."""
|
||||
defaults = SectionGenerationOptions()
|
||||
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,
|
||||
include_endpoint=defaults.include_endpoint,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/sections/generate", response_model=SectionGenerateResponse)
|
||||
async def generate_sections(
|
||||
project_id: UUID, request: SectionGenerateRequest
|
||||
) -> SectionGenerateResponse | JSONResponse:
|
||||
"""확정 경로에서 종횡단을 생성·저장하고 DB에 기록한다."""
|
||||
@router.get("/{project_id}/sections/context", response_model=SectionContextResponse)
|
||||
async def get_section_context(project_id: UUID) -> SectionContextResponse | JSONResponse:
|
||||
"""최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 반환한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
params = {
|
||||
"route_id": request.route_id,
|
||||
"filter_key": request.filter_key,
|
||||
"method": request.method,
|
||||
"smooth": request.smooth,
|
||||
"station_interval_m": request.station_interval_m,
|
||||
"cross_half_width_m": request.cross_half_width_m,
|
||||
"cross_sample_interval_m": request.cross_sample_interval_m,
|
||||
"long_sample_interval_m": request.long_sample_interval_m,
|
||||
"crs": request.crs,
|
||||
}
|
||||
async with pool.acquire() as connection:
|
||||
async with connection.cursor() as cursor:
|
||||
await start_stage(cursor, str(project_id), 3, params)
|
||||
await connection.commit()
|
||||
route_context = await get_confirmed_route_context(connection, project_id)
|
||||
surface_params = await get_surface_confirmation_params(connection, str(project_id))
|
||||
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
|
||||
latest = await get_latest_route(connection, project_id)
|
||||
if not latest or latest["id"] != request.route_id:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "대상 경로를 찾을 수 없습니다."},
|
||||
)
|
||||
route_data_path = latest["route_data_path"]
|
||||
|
||||
options = _build_options(request)
|
||||
design = await asyncio.to_thread(
|
||||
run_section_generation,
|
||||
project_root,
|
||||
route_data_path,
|
||||
request.filter_key,
|
||||
request.method,
|
||||
request.smooth,
|
||||
options=options,
|
||||
crs=request.crs,
|
||||
)
|
||||
|
||||
await connection.begin()
|
||||
try:
|
||||
await delete_sections_for_route(connection, request.route_id)
|
||||
longitudinal_id = await create_longitudinal_section(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
route_id=request.route_id,
|
||||
data=design["longitudinal"]["data"],
|
||||
longitudinal_file_path=design["longitudinal"]["file_path"],
|
||||
)
|
||||
await insert_cross_sections(
|
||||
connection,
|
||||
project_id=project_id,
|
||||
route_id=request.route_id,
|
||||
sections=design["cross_sections"],
|
||||
)
|
||||
async with connection.cursor() as cursor:
|
||||
await complete_stage(cursor, str(project_id), 3)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
|
||||
return SectionGenerateResponse(
|
||||
defaults = SectionGenerationOptions()
|
||||
return SectionContextResponse(
|
||||
project_id=str(project_id),
|
||||
route_id=request.route_id,
|
||||
longitudinal_id=longitudinal_id,
|
||||
cross_section_count=len(design["cross_sections"]),
|
||||
length_m=design["longitudinal"]["data"]["length_m"],
|
||||
longitudinal_file_path=design["longitudinal"]["file_path"],
|
||||
route_id=route_context["route_id"] if route_context else None,
|
||||
filter_key=surface_params["source_filter"] if route_context else None,
|
||||
method=surface_params["method"] if route_context else None,
|
||||
smooth=bool(surface_params["smooth"]) if route_context else None,
|
||||
crs_epsg=route_context["crs_epsg"] if route_context else None,
|
||||
defaults=SectionOptionDefaults(
|
||||
station_interval_m=defaults.station_interval_m,
|
||||
cross_half_width_m=defaults.cross_half_width_m,
|
||||
cross_sample_interval_m=defaults.cross_sample_interval_m,
|
||||
long_sample_interval_m=defaults.long_sample_interval_m,
|
||||
vertical_exaggeration=SECTION_VERTICAL_EXAGGERATION,
|
||||
),
|
||||
)
|
||||
except LookupError as exc:
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await fail_stage(cursor, str(project_id), 3, str(exc))
|
||||
await connection.commit()
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except FileNotFoundError as exc:
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await fail_stage(cursor, str(project_id), 3, str(exc))
|
||||
await connection.commit()
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, ValueError) as exc:
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await fail_stage(cursor, str(project_id), 3, str(exc))
|
||||
await connection.commit()
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception as exc:
|
||||
logger.exception("B06 종횡단 생성 실패: project_id=%s", project_id)
|
||||
async with pool.acquire() as connection, connection.cursor() as cursor:
|
||||
await fail_stage(cursor, str(project_id), 3, str(exc))
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
logger.exception("B06 종횡단 컨텍스트 조회 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "종횡단 생성 처리 중 오류가 발생했습니다."},
|
||||
content={"status": "error", "message": "종횡단 컨텍스트 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@@ -155,8 +74,15 @@ async def get_sections(project_id: UUID, route_id: int) -> SectionSummaryRespons
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
longitudinal = await get_longitudinal_section(connection, project_id, route_id)
|
||||
cross_section_count = await count_cross_sections(connection, route_id)
|
||||
data = longitudinal.get("data") if longitudinal else None
|
||||
length_m = data.get("length_m") if isinstance(data, dict) else None
|
||||
return SectionSummaryResponse(
|
||||
project_id=str(project_id), route_id=route_id, longitudinal=longitudinal
|
||||
project_id=str(project_id),
|
||||
route_id=route_id,
|
||||
longitudinal=longitudinal,
|
||||
length_m=length_m,
|
||||
cross_section_count=cross_section_count,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B06 종횡단 조회 실패: project_id=%s", project_id)
|
||||
@@ -166,6 +92,77 @@ async def get_sections(project_id: UUID, route_id: int) -> SectionSummaryRespons
|
||||
)
|
||||
|
||||
|
||||
def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dict:
|
||||
"""검증된 프로젝트 루트 안의 종단 및 횡단 JSON을 읽는다."""
|
||||
root = project_root.resolve()
|
||||
longitudinal_path = (root / longitudinal_file_path).resolve()
|
||||
if root not in longitudinal_path.parents:
|
||||
raise ValueError("종단면 파일 경로가 프로젝트 저장소를 벗어났습니다.")
|
||||
if not longitudinal_path.is_file():
|
||||
raise FileNotFoundError("종단면 상세 파일을 찾을 수 없습니다.")
|
||||
|
||||
stage_root = longitudinal_path.parent.parent
|
||||
cross_dir = stage_root / "cross_sections"
|
||||
if not cross_dir.is_dir():
|
||||
raise FileNotFoundError("횡단면 상세 파일을 찾을 수 없습니다.")
|
||||
|
||||
longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8"))
|
||||
cross_sections = [
|
||||
json.loads(path.read_text(encoding="utf-8"))
|
||||
for path in sorted(cross_dir.glob("cross_*.json"))
|
||||
]
|
||||
if not isinstance(longitudinal, dict) or not all(
|
||||
isinstance(section, dict) for section in cross_sections
|
||||
):
|
||||
raise ValueError("종횡단 상세 파일 형식이 올바르지 않습니다.")
|
||||
return {"longitudinal": longitudinal, "cross_sections": cross_sections}
|
||||
|
||||
|
||||
@router.get("/{project_id}/sections/{route_id}/detail", response_model=SectionDetailResponse)
|
||||
async def get_section_detail(
|
||||
project_id: UUID, route_id: int
|
||||
) -> SectionDetailResponse | JSONResponse:
|
||||
"""경로의 SVG 렌더링용 종단·횡단 원시 샘플을 반환한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
longitudinal = await get_longitudinal_section(connection, project_id, route_id)
|
||||
if not longitudinal:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "종횡단 상세 결과가 없습니다."},
|
||||
)
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
detail = await asyncio.to_thread(
|
||||
_read_section_detail,
|
||||
project_root,
|
||||
str(longitudinal["longitudinal_file_path"]),
|
||||
)
|
||||
return SectionDetailResponse(**detail)
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
logger.warning(
|
||||
"B06 종횡단 상세 파일 조회 실패: project_id=%s route_id=%s error=%s",
|
||||
project_id,
|
||||
route_id,
|
||||
exc,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "종횡단 상세 파일을 읽지 못했습니다."},
|
||||
)
|
||||
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
|
||||
|
||||
@@ -1,38 +1,8 @@
|
||||
"""B06 종횡단 생성 요청·응답 검증 모델."""
|
||||
"""B06 종횡단 조회·확정 응답 검증 모델."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class SectionGenerateRequest(BaseModel):
|
||||
"""종횡단 생성 실행 요청."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
route_id: int = Field(gt=0, description="종횡단을 생성할 확정 경로 routes.id")
|
||||
filter_key: str = Field(description="지면 필터 키 (grid_min_z/csf/pmf)")
|
||||
method: str = Field(default="dtm", description="지표면 표현")
|
||||
smooth: bool = Field(default=False)
|
||||
crs: str | None = Field(default=None, description="좌표계 (예: EPSG:5178)")
|
||||
|
||||
# 측점/횡단 옵션 (미지정 시 config 기본값)
|
||||
station_interval_m: float | None = Field(default=None, gt=0)
|
||||
cross_half_width_m: float | None = Field(default=None, gt=0)
|
||||
cross_sample_interval_m: float | None = Field(default=None, gt=0)
|
||||
long_sample_interval_m: float | None = Field(default=None, gt=0)
|
||||
|
||||
|
||||
class SectionGenerateResponse(BaseModel):
|
||||
"""종횡단 생성 결과."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
route_id: int
|
||||
longitudinal_id: int
|
||||
cross_section_count: int
|
||||
length_m: float
|
||||
longitudinal_file_path: str
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SectionConfirmResponse(BaseModel):
|
||||
@@ -44,6 +14,28 @@ class SectionConfirmResponse(BaseModel):
|
||||
confirmed: bool = True
|
||||
|
||||
|
||||
class SectionOptionDefaults(BaseModel):
|
||||
"""config에서 읽은 종횡단 생성 기본 옵션."""
|
||||
|
||||
station_interval_m: float
|
||||
cross_half_width_m: float
|
||||
cross_sample_interval_m: float
|
||||
long_sample_interval_m: float
|
||||
vertical_exaggeration: float
|
||||
|
||||
|
||||
class SectionContextResponse(BaseModel):
|
||||
"""B06 진입 시 필요한 확정 경로 컨텍스트와 기본 옵션."""
|
||||
|
||||
project_id: str
|
||||
route_id: int | None = None
|
||||
filter_key: str | None = None
|
||||
method: str | None = None
|
||||
smooth: bool | None = None
|
||||
crs_epsg: int | None = None
|
||||
defaults: SectionOptionDefaults
|
||||
|
||||
|
||||
class SectionSummaryResponse(BaseModel):
|
||||
"""종횡단 요약 조회 결과."""
|
||||
|
||||
@@ -51,3 +43,12 @@ class SectionSummaryResponse(BaseModel):
|
||||
project_id: str
|
||||
route_id: int
|
||||
longitudinal: dict[str, Any] | None = None
|
||||
length_m: float | None = None
|
||||
cross_section_count: int = 0
|
||||
|
||||
|
||||
class SectionDetailResponse(BaseModel):
|
||||
"""종단·횡단 SVG 렌더링에 필요한 원시 샘플 데이터."""
|
||||
|
||||
longitudinal: dict[str, Any]
|
||||
cross_sections: list[dict[str, Any]]
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
/* =============================================================================
|
||||
* B06_wf3_ProfileCross_UI_Page.ts
|
||||
* 로그인 후 06: 3차 워크플로우 (종·횡단 생성)
|
||||
*
|
||||
* 3단 레이아웃 (frontend.md §2):
|
||||
* 상단: 페이지 타이틀 + 진행 단계 스텝바 (createWorkflowLayout)
|
||||
* 좌측: 대상 경로 ID + 지표면 참조 + 측점/횡단 옵션 폼
|
||||
* 우측: 종·횡단 생성 결과(연장·횡단 개수·파일) 카드
|
||||
*
|
||||
* 이벤트 핸들러 명명 (frontend.md §4): onB06_Profile_[기능]_[액션]
|
||||
* 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3).
|
||||
* ========================================================================== */
|
||||
|
||||
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import {
|
||||
@@ -30,212 +17,135 @@ import {
|
||||
} from "../A00_Common/b_workflow_nav";
|
||||
import {
|
||||
confirmSections,
|
||||
generateSections,
|
||||
type SectionGenerateResponse,
|
||||
fetchSectionContext,
|
||||
fetchSectionDetail,
|
||||
getSections,
|
||||
type SectionContextResponse,
|
||||
type SectionDetailResponse,
|
||||
type SectionSummaryResponse,
|
||||
} from "./B06_wf3_ProfileCross_Api_Fetch";
|
||||
import { createSectionView } from "./B06_wf3_ProfileCross_UI_Section_View";
|
||||
import "./B06_wf3_ProfileCross_UI_Style.css";
|
||||
|
||||
/** locale 헬퍼 */
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/** 라벨 + fieldset 그룹 컨테이너 생성. */
|
||||
function buildGroup(legend: string): HTMLElement {
|
||||
const group = document.createElement("fieldset");
|
||||
group.className = "b06-profile__group";
|
||||
const legendEl = document.createElement("legend");
|
||||
legendEl.className = "b06-profile__group-legend";
|
||||
legendEl.textContent = legend;
|
||||
group.append(legendEl);
|
||||
const legendElement = document.createElement("legend");
|
||||
legendElement.className = "b06-profile__group-legend";
|
||||
legendElement.textContent = legend;
|
||||
group.append(legendElement);
|
||||
return group;
|
||||
}
|
||||
|
||||
/** 숫자 입력값을 파싱. 빈 값이면 null. */
|
||||
function parseNumber(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
function buildInfoLine(label: string): { root: HTMLElement; value: HTMLElement } {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b06-profile__info-line";
|
||||
const key = document.createElement("span");
|
||||
key.textContent = label;
|
||||
const value = document.createElement("strong");
|
||||
value.textContent = "-";
|
||||
root.append(key, value);
|
||||
return { root, value };
|
||||
}
|
||||
|
||||
function metricRow(label: string, value: string): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "b06-profile__metric";
|
||||
const key = document.createElement("span");
|
||||
key.className = "b06-profile__metric-key";
|
||||
key.textContent = label;
|
||||
const metricValue = document.createElement("span");
|
||||
metricValue.className = "b06-profile__metric-val";
|
||||
metricValue.textContent = value;
|
||||
row.append(key, metricValue);
|
||||
return row;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/* ---- 좌측: 대상 경로 ---- */
|
||||
const routeGroup = buildGroup(L("B06_Profile_Group_Route"));
|
||||
const routeIdField = createInputField({
|
||||
label: L("B06_Profile_Field_RouteId"),
|
||||
type: "number",
|
||||
min: 1,
|
||||
});
|
||||
const filterField = createInputField({ label: L("B06_Profile_Field_Filter"), type: "text" });
|
||||
const methodField = createInputField({
|
||||
label: L("B06_Profile_Field_Method"),
|
||||
type: "text",
|
||||
value: "dtm",
|
||||
});
|
||||
const crsField = createInputField({
|
||||
label: L("B06_Profile_Field_Crs"),
|
||||
type: "text",
|
||||
placeholder: "EPSG:5178",
|
||||
});
|
||||
routeGroup.append(routeIdField.root, filterField.root, methodField.root, crsField.root);
|
||||
|
||||
/* ---- 좌측: 측점/횡단 옵션 ---- */
|
||||
const optionGroup = buildGroup(L("B06_Profile_Group_Options"));
|
||||
const stationField = createInputField({
|
||||
label: L("B06_Profile_Field_StationInterval"),
|
||||
type: "number",
|
||||
});
|
||||
const halfWidthField = createInputField({
|
||||
label: L("B06_Profile_Field_CrossHalfWidth"),
|
||||
type: "number",
|
||||
});
|
||||
const crossSampleField = createInputField({
|
||||
label: L("B06_Profile_Field_CrossSample"),
|
||||
type: "number",
|
||||
});
|
||||
const longSampleField = createInputField({
|
||||
label: L("B06_Profile_Field_LongSample"),
|
||||
type: "number",
|
||||
});
|
||||
|
||||
const smoothLabel = document.createElement("label");
|
||||
smoothLabel.className = "b06-profile__check";
|
||||
const smoothBox = document.createElement("input");
|
||||
smoothBox.type = "checkbox";
|
||||
const smoothText = document.createElement("span");
|
||||
smoothText.textContent = L("B06_Profile_Field_Smooth");
|
||||
smoothLabel.append(smoothBox, smoothText);
|
||||
|
||||
optionGroup.append(
|
||||
stationField.root,
|
||||
halfWidthField.root,
|
||||
crossSampleField.root,
|
||||
longSampleField.root,
|
||||
smoothLabel,
|
||||
const routeIdInfo = buildInfoLine(L("B06_Profile_Field_RouteId"));
|
||||
const filterInfo = buildInfoLine(L("B06_Profile_Field_Filter"));
|
||||
const methodInfo = buildInfoLine(L("B06_Profile_Field_Method"));
|
||||
const smoothInfo = buildInfoLine(L("B06_Profile_Field_Smooth"));
|
||||
const crsInfo = buildInfoLine(L("B06_Profile_Field_Crs"));
|
||||
routeGroup.append(
|
||||
routeIdInfo.root,
|
||||
filterInfo.root,
|
||||
methodInfo.root,
|
||||
smoothInfo.root,
|
||||
crsInfo.root,
|
||||
);
|
||||
|
||||
const generateButton = createButton({
|
||||
label: L("B06_Profile_Btn_Generate"),
|
||||
variant: "filled",
|
||||
onClick: () => void onB06_Profile_Generate_Click(),
|
||||
const resultGroup = buildGroup(L("B06_Profile_Result_Title"));
|
||||
const resultBody = document.createElement("div");
|
||||
resultBody.className = "b06-profile__result-body";
|
||||
resultGroup.append(resultBody);
|
||||
|
||||
const displayGroup = buildGroup(L("B06_Profile_Group_Display"));
|
||||
const verticalExaggerationField = createInputField({
|
||||
label: L("B06_Profile_Field_VerticalExaggeration"),
|
||||
type: "number",
|
||||
});
|
||||
verticalExaggerationField.input.min = "0.1";
|
||||
verticalExaggerationField.input.step = "0.1";
|
||||
displayGroup.append(verticalExaggerationField.root);
|
||||
|
||||
const confirmButton = createButton({
|
||||
label: L("B06_Profile_Btn_Confirm"),
|
||||
variant: "ghost",
|
||||
onClick: () => void onB06_Profile_Confirm_Click(),
|
||||
variant: "filled",
|
||||
onClick: () => void confirmCurrentSections(),
|
||||
});
|
||||
confirmButton.disabled = true;
|
||||
const actionRow = document.createElement("div");
|
||||
actionRow.className = "b06-profile__actions";
|
||||
actionRow.append(generateButton, confirmButton);
|
||||
actionRow.append(confirmButton);
|
||||
|
||||
const leftForm = document.createElement("div");
|
||||
leftForm.className = "b06-profile__form";
|
||||
leftForm.append(routeGroup, optionGroup, actionRow);
|
||||
leftForm.append(routeGroup, resultGroup, displayGroup, actionRow);
|
||||
const sectionView = createSectionView();
|
||||
|
||||
/* ---- 우측: 결과 ---- */
|
||||
const resultTitle = document.createElement("h3");
|
||||
resultTitle.className = "b06-profile__result-title";
|
||||
resultTitle.textContent = L("B06_Profile_Result_Title");
|
||||
const resultBody = document.createElement("div");
|
||||
resultBody.className = "b06-profile__result-body";
|
||||
|
||||
function renderEmptyResult(): void {
|
||||
resultBody.replaceChildren();
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b06-profile__empty";
|
||||
empty.textContent = L("B06_Profile_Result_Empty");
|
||||
resultBody.append(empty);
|
||||
function renderMessage(message: string): void {
|
||||
const text = document.createElement("p");
|
||||
text.className = "b06-profile__empty";
|
||||
text.textContent = message;
|
||||
resultBody.replaceChildren(text);
|
||||
}
|
||||
|
||||
function metricRow(label: string, value: string): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "b06-profile__metric";
|
||||
const key = document.createElement("span");
|
||||
key.className = "b06-profile__metric-key";
|
||||
key.textContent = label;
|
||||
const val = document.createElement("span");
|
||||
val.className = "b06-profile__metric-val";
|
||||
val.textContent = value;
|
||||
row.append(key, val);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderResult(result: SectionGenerateResponse): void {
|
||||
resultBody.replaceChildren();
|
||||
resultBody.append(
|
||||
metricRow(L("B06_Profile_Result_Length"), result.length_m.toFixed(2)),
|
||||
function renderSummary(result: SectionSummaryResponse): void {
|
||||
const path = result.longitudinal?.longitudinal_file_path;
|
||||
resultBody.replaceChildren(
|
||||
metricRow(
|
||||
L("B06_Profile_Result_Length"),
|
||||
result.length_m === null ? "-" : result.length_m.toFixed(2),
|
||||
),
|
||||
metricRow(L("B06_Profile_Result_CrossCount"), String(result.cross_section_count)),
|
||||
metricRow(L("B06_Profile_Result_Path"), result.longitudinal_file_path),
|
||||
metricRow(L("B06_Profile_Result_Path"), typeof path === "string" ? path : "-"),
|
||||
);
|
||||
}
|
||||
|
||||
const resultCard = document.createElement("div");
|
||||
resultCard.className = "b06-profile__result";
|
||||
resultCard.append(resultTitle, resultBody);
|
||||
|
||||
/* ---- 이벤트 핸들러 ---- */
|
||||
function getProjectId(): string | null {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
if (!projectId) showToast(L("B06_Profile_Error_Project"), "error");
|
||||
return projectId;
|
||||
function verticalExaggeration(): number {
|
||||
const parsed = Number(verticalExaggerationField.input.value);
|
||||
return Number.isFinite(parsed) && parsed >= 0.1 ? parsed : 1;
|
||||
}
|
||||
|
||||
async function onB06_Profile_Generate_Click(): Promise<void> {
|
||||
const projectId = getProjectId();
|
||||
if (!projectId) return;
|
||||
|
||||
const routeId = parseNumber(routeIdField.input.value);
|
||||
if (routeId === null || !Number.isInteger(routeId) || routeId <= 0) {
|
||||
routeIdField.setError(L("B06_Profile_Error_RouteId"));
|
||||
return;
|
||||
}
|
||||
routeIdField.setError();
|
||||
|
||||
const filterKey = filterField.input.value.trim();
|
||||
if (!filterKey) {
|
||||
filterField.setError(L("B06_Profile_Error_Filter"));
|
||||
return;
|
||||
}
|
||||
filterField.setError();
|
||||
verticalExaggerationField.input.addEventListener("input", () => {
|
||||
if (sectionDetail) sectionView.render(sectionDetail, verticalExaggeration());
|
||||
});
|
||||
|
||||
async function confirmCurrentSections(): Promise<void> {
|
||||
if (!projectId || currentRouteId === null) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const result = await generateSections(projectId, {
|
||||
route_id: routeId,
|
||||
filter_key: filterKey,
|
||||
method: methodField.input.value.trim() || "dtm",
|
||||
smooth: smoothBox.checked,
|
||||
crs: crsField.input.value.trim() || null,
|
||||
station_interval_m: parseNumber(stationField.input.value),
|
||||
cross_half_width_m: parseNumber(halfWidthField.input.value),
|
||||
cross_sample_interval_m: parseNumber(crossSampleField.input.value),
|
||||
long_sample_interval_m: parseNumber(longSampleField.input.value),
|
||||
});
|
||||
currentRouteId = result.route_id;
|
||||
renderResult(result);
|
||||
showToast(L("B06_Profile_Generate_Success"), "success");
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : L("B06_Profile_Generate_Failed");
|
||||
showToast(`${L("B06_Profile_Generate_Failed")} ${detail}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function onB06_Profile_Confirm_Click(): Promise<void> {
|
||||
const projectId = getProjectId();
|
||||
if (!projectId) return;
|
||||
const routeId = currentRouteId ?? parseNumber(routeIdField.input.value);
|
||||
if (routeId === null || !Number.isInteger(routeId) || routeId <= 0) {
|
||||
routeIdField.setError(L("B06_Profile_Error_RouteId"));
|
||||
return;
|
||||
}
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await confirmSections(projectId, routeId);
|
||||
await confirmSections(projectId, currentRouteId);
|
||||
showToast(L("B06_Profile_Confirm_Success"), "success");
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed");
|
||||
@@ -245,16 +155,16 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
renderEmptyResult();
|
||||
|
||||
let workflowState: WorkflowState | undefined;
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
let context: SectionContextResponse | null = null;
|
||||
if (projectId) {
|
||||
try {
|
||||
workflowState = await fetchWorkflowState(projectId);
|
||||
} catch {
|
||||
/* 조회 실패 시 stages 미전달 → 전체 이동 허용 */
|
||||
}
|
||||
const [contextResult, workflowResult] = await Promise.allSettled([
|
||||
fetchSectionContext(projectId),
|
||||
fetchWorkflowState(projectId),
|
||||
]);
|
||||
if (contextResult.status === "fulfilled") context = contextResult.value;
|
||||
else showToast(L("B06_Profile_Context_Failed"), "error");
|
||||
if (workflowResult.status === "fulfilled") workflowState = workflowResult.value;
|
||||
}
|
||||
|
||||
const layout = createWorkflowLayout({
|
||||
@@ -262,15 +172,53 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
steps: workflowSteps(),
|
||||
activeStep: 3,
|
||||
leftPanel: leftForm,
|
||||
mainContent: resultCard,
|
||||
mainContent: sectionView.root,
|
||||
stages: workflowState?.stages,
|
||||
currentStage: workflowState?.current_stage,
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
onStepClick: (stepIndex) => {
|
||||
if (projectId) {
|
||||
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
}
|
||||
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
},
|
||||
});
|
||||
root.replaceChildren(layout.root);
|
||||
|
||||
if (!projectId) {
|
||||
renderMessage(L("B06_Profile_Error_Project"));
|
||||
return;
|
||||
}
|
||||
if (!context) {
|
||||
renderMessage(L("B06_Profile_Context_Failed"));
|
||||
return;
|
||||
}
|
||||
|
||||
routeIdInfo.value.textContent = context.route_id === null ? "-" : String(context.route_id);
|
||||
filterInfo.value.textContent = context.filter_key ?? "-";
|
||||
methodInfo.value.textContent = context.method ?? "-";
|
||||
smoothInfo.value.textContent = context.smooth
|
||||
? L("B06_Profile_Smooth_On")
|
||||
: L("B06_Profile_Smooth_Off");
|
||||
crsInfo.value.textContent = context.crs_epsg === null ? "-" : `EPSG:${context.crs_epsg}`;
|
||||
verticalExaggerationField.input.value = String(context.defaults.vertical_exaggeration);
|
||||
|
||||
if (context.route_id === null) {
|
||||
renderMessage(L("B06_Profile_Calculate_In_B05"));
|
||||
return;
|
||||
}
|
||||
|
||||
currentRouteId = context.route_id;
|
||||
try {
|
||||
const existing = await getSections(projectId, context.route_id);
|
||||
if (!existing.longitudinal) {
|
||||
renderMessage(L("B06_Profile_Calculate_In_B05"));
|
||||
return;
|
||||
}
|
||||
renderSummary(existing);
|
||||
sectionDetail = await fetchSectionDetail(projectId, context.route_id);
|
||||
sectionView.render(sectionDetail, verticalExaggeration());
|
||||
confirmButton.disabled = false;
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
renderMessage(L("B06_Profile_Calculate_In_B05"));
|
||||
showToast(`${L("B06_Profile_Detail_Failed")}${detail}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import type {
|
||||
CrossSection,
|
||||
LongitudinalSection,
|
||||
SectionDetailResponse,
|
||||
SectionSample,
|
||||
} from "./B06_wf3_ProfileCross_Api_Fetch";
|
||||
|
||||
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 LONG_PAD = { left: 62, right: 24, top: 30, bottom: 52 };
|
||||
const CROSS_PAD = { left: 58, right: 20, top: 20, bottom: 52 };
|
||||
|
||||
interface YScaleOptions {
|
||||
pixelsPerMeter: number;
|
||||
globalMinElevation: number;
|
||||
globalMaxElevation: number;
|
||||
}
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
function svgElement<K extends keyof SVGElementTagNameMap>(
|
||||
tag: K,
|
||||
attributes: Record<string, string | number> = {},
|
||||
): SVGElementTagNameMap[K] {
|
||||
const element = document.createElementNS(SVG_NS, tag);
|
||||
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value)));
|
||||
return element;
|
||||
}
|
||||
|
||||
function svgText(value: string, attributes: Record<string, string | number>): SVGTextElement {
|
||||
const text = svgElement("text", attributes);
|
||||
text.textContent = value;
|
||||
return text;
|
||||
}
|
||||
|
||||
function validElevation(sample: SectionSample): sample is SectionSample & { elevation_m: number } {
|
||||
return (
|
||||
sample.valid !== false && sample.elevation_m !== null && Number.isFinite(sample.elevation_m)
|
||||
);
|
||||
}
|
||||
|
||||
function calculateYScale(detail: SectionDetailResponse): YScaleOptions | undefined {
|
||||
const elevations = [
|
||||
...detail.longitudinal.samples.map((sample) => sample.elevation_m),
|
||||
...detail.cross_sections.flatMap((section) =>
|
||||
section.samples.map((sample) => sample.elevation_m),
|
||||
),
|
||||
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
||||
if (!elevations.length) return undefined;
|
||||
const globalMinElevation = Math.min(...elevations);
|
||||
const globalMaxElevation = Math.max(...elevations);
|
||||
const plotHeight = LONG_HEIGHT - LONG_PAD.top - LONG_PAD.bottom;
|
||||
return {
|
||||
pixelsPerMeter: plotHeight / Math.max(globalMaxElevation - globalMinElevation, 1),
|
||||
globalMinElevation,
|
||||
globalMaxElevation,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyView(message: string): HTMLElement {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "b06-section__empty";
|
||||
empty.textContent = message;
|
||||
return empty;
|
||||
}
|
||||
|
||||
export function createLongitudinalProfile(
|
||||
data: LongitudinalSection,
|
||||
selectedStationId: string | null,
|
||||
verticalExaggeration: number,
|
||||
yScaleOptions: YScaleOptions | undefined,
|
||||
onSelectStation: (stationId: string) => void,
|
||||
): HTMLElement {
|
||||
const samples = data.samples.filter(validElevation);
|
||||
if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal"));
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "b06-section__chart-wrap";
|
||||
const svg = svgElement("svg", {
|
||||
class: "b06-section__chart",
|
||||
viewBox: `0 0 ${LONG_WIDTH} ${LONG_HEIGHT}`,
|
||||
role: "img",
|
||||
"aria-label": L("B06_Profile_View_Longitudinal"),
|
||||
});
|
||||
svg.append(
|
||||
svgElement("rect", { width: LONG_WIDTH, height: LONG_HEIGHT, 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);
|
||||
const rawMin = yScaleOptions?.globalMinElevation ?? Math.min(...elevations);
|
||||
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 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;
|
||||
|
||||
for (const ratio of [0, 0.25, 0.5, 0.75, 1]) {
|
||||
const gridY = LONG_PAD.top + ratio * plotHeight;
|
||||
const displayed = elevationMid + elevationSpan / 2 - ratio * elevationSpan;
|
||||
const rawValue = elevationMid + (displayed - elevationMid) / exaggeration;
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: LONG_PAD.left,
|
||||
y1: gridY,
|
||||
x2: LONG_WIDTH - LONG_PAD.right,
|
||||
y2: gridY,
|
||||
class: "b06-chart__grid",
|
||||
}),
|
||||
svgText(`${rawValue.toFixed(1)}m`, {
|
||||
x: LONG_PAD.left - 9,
|
||||
y: gridY + 4,
|
||||
"text-anchor": "end",
|
||||
class: "b06-chart__tick",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const station of data.stations) {
|
||||
const stationX = x(station.chainage_m);
|
||||
const selected = station.station_id === selectedStationId;
|
||||
const marker = svgElement("g", {
|
||||
class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`,
|
||||
tabindex: "0",
|
||||
role: "button",
|
||||
"aria-label": `${station.label} ${station.chainage_m.toFixed(1)}m`,
|
||||
});
|
||||
marker.addEventListener("click", () => onSelectStation(station.station_id));
|
||||
marker.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") onSelectStation(station.station_id);
|
||||
});
|
||||
marker.append(
|
||||
svgElement("line", {
|
||||
x1: stationX,
|
||||
y1: LONG_PAD.top,
|
||||
x2: stationX,
|
||||
y2: LONG_HEIGHT - LONG_PAD.bottom + 8,
|
||||
class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`,
|
||||
}),
|
||||
svgText(station.label, {
|
||||
x: stationX,
|
||||
y: LONG_HEIGHT - 23,
|
||||
"text-anchor": "middle",
|
||||
class: "b06-chart__station-label",
|
||||
}),
|
||||
);
|
||||
svg.append(marker);
|
||||
}
|
||||
|
||||
const points = samples
|
||||
.map((sample) => {
|
||||
const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration;
|
||||
return `${x(sample.chainage_m ?? 0)},${y(elevated)}`;
|
||||
})
|
||||
.join(" ");
|
||||
svg.append(
|
||||
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,
|
||||
class: "b06-chart__axis",
|
||||
}),
|
||||
svgElement("line", {
|
||||
x1: LONG_PAD.left,
|
||||
y1: LONG_PAD.top,
|
||||
x2: LONG_PAD.left,
|
||||
y2: LONG_HEIGHT - LONG_PAD.bottom,
|
||||
class: "b06-chart__axis",
|
||||
}),
|
||||
svgText(L("B06_Profile_View_LongitudinalXAxis"), {
|
||||
x: LONG_WIDTH / 2,
|
||||
y: LONG_HEIGHT - 4,
|
||||
"text-anchor": "middle",
|
||||
class: "b06-chart__axis-label",
|
||||
}),
|
||||
svgText(L("B06_Profile_View_ElevationAxis"), {
|
||||
x: 15,
|
||||
y: LONG_HEIGHT / 2,
|
||||
"text-anchor": "middle",
|
||||
transform: `rotate(-90 15 ${LONG_HEIGHT / 2})`,
|
||||
class: "b06-chart__axis-label",
|
||||
}),
|
||||
);
|
||||
wrapper.append(svg);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
export function createCrossSectionCard(
|
||||
section: CrossSection,
|
||||
selected: boolean,
|
||||
verticalExaggeration: number,
|
||||
yScaleOptions: YScaleOptions | undefined,
|
||||
onSelect: (stationId: string) => void,
|
||||
): HTMLElement {
|
||||
const card = document.createElement("article");
|
||||
card.id = `cross-${section.station_id}`;
|
||||
card.className = `b06-cross-card${selected ? " b06-cross-card--selected" : ""}`;
|
||||
card.tabIndex = 0;
|
||||
card.addEventListener("click", () => onSelect(section.station_id));
|
||||
card.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") onSelect(section.station_id);
|
||||
});
|
||||
|
||||
const header = document.createElement("header");
|
||||
const title = document.createElement("div");
|
||||
const label = document.createElement("strong");
|
||||
label.textContent = section.label;
|
||||
const chainage = document.createElement("span");
|
||||
chainage.textContent = `${section.chainage_m.toFixed(1)}m`;
|
||||
title.append(label, chainage);
|
||||
const kind = document.createElement("span");
|
||||
kind.textContent =
|
||||
section.kind === "ep"
|
||||
? L("B06_Profile_View_Kind_EP")
|
||||
: section.kind === "bp"
|
||||
? L("B06_Profile_View_Kind_BP")
|
||||
: L("B06_Profile_View_Kind_Station");
|
||||
header.append(title, kind);
|
||||
card.append(header);
|
||||
|
||||
const valid = section.samples.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 minOffset = Math.min(...offsets, -1);
|
||||
const maxOffset = Math.max(...offsets, 1);
|
||||
const elevations = valid.map((sample) => sample.elevation_m);
|
||||
const rawMin = Math.min(...elevations);
|
||||
const rawMax = Math.max(...elevations);
|
||||
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 displaySpan = yScaleOptions
|
||||
? plotHeight / yScaleOptions.pixelsPerMeter
|
||||
: Math.max((rawMax - rawMin + padding * 2) * exaggeration, 1);
|
||||
const displayMin = elevationMid - displaySpan / 2;
|
||||
const displayMax = elevationMid + displaySpan / 2;
|
||||
const x = (offset: number) =>
|
||||
CROSS_PAD.left + ((offset - minOffset) / Math.max(maxOffset - minOffset, 1)) * plotWidth;
|
||||
const y = (elevation: number) =>
|
||||
CROSS_PAD.top +
|
||||
((displayMax - elevation) / Math.max(displayMax - displayMin, 1)) * plotHeight;
|
||||
const svg = svgElement("svg", {
|
||||
class: "b06-section__chart",
|
||||
viewBox: `0 0 ${CROSS_WIDTH} ${CROSS_HEIGHT}`,
|
||||
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" }),
|
||||
);
|
||||
|
||||
const xTicks = Array.from(
|
||||
{ length: 7 },
|
||||
(_, index) => minOffset + ((maxOffset - minOffset) * index) / 6,
|
||||
);
|
||||
for (const tick of xTicks) {
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: x(tick),
|
||||
y1: CROSS_PAD.top,
|
||||
x2: x(tick),
|
||||
y2: CROSS_HEIGHT - 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,
|
||||
"text-anchor": "middle",
|
||||
class: "b06-chart__tick",
|
||||
}),
|
||||
);
|
||||
}
|
||||
const rawDisplaySpan = displaySpan / exaggeration;
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
const tick = elevationMid - rawDisplaySpan / 2 + (rawDisplaySpan * index) / 4;
|
||||
const displayTick = elevationMid + (tick - elevationMid) * exaggeration;
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: CROSS_PAD.left,
|
||||
y1: y(displayTick),
|
||||
x2: CROSS_WIDTH - CROSS_PAD.right,
|
||||
y2: y(displayTick),
|
||||
class: "b06-chart__grid",
|
||||
}),
|
||||
svgText(tick.toFixed(1), {
|
||||
x: CROSS_PAD.left - 7,
|
||||
y: y(displayTick) + 3,
|
||||
"text-anchor": "end",
|
||||
class: "b06-chart__tick",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const segments: string[] = [];
|
||||
let current: string[] = [];
|
||||
for (const sample of section.samples) {
|
||||
if (!validElevation(sample)) {
|
||||
if (current.length > 1) segments.push(current.join(" "));
|
||||
current = [];
|
||||
continue;
|
||||
}
|
||||
const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration;
|
||||
current.push(`${x(sample.offset_m ?? 0)},${y(elevated)}`);
|
||||
}
|
||||
if (current.length > 1) segments.push(current.join(" "));
|
||||
segments.forEach((points) =>
|
||||
svg.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })),
|
||||
);
|
||||
|
||||
const centerSample = valid.reduce<(typeof valid)[number] | null>((nearest, sample) => {
|
||||
if (!nearest || Math.abs(sample.offset_m ?? 0) < Math.abs(nearest.offset_m ?? 0))
|
||||
return sample;
|
||||
return nearest;
|
||||
}, null);
|
||||
const centerX = x(0);
|
||||
const centerY = centerSample
|
||||
? y(elevationMid + (centerSample.elevation_m - elevationMid) * exaggeration)
|
||||
: CROSS_HEIGHT / 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,
|
||||
class: "b06-chart__axis",
|
||||
}),
|
||||
svgElement("line", {
|
||||
x1: CROSS_PAD.left,
|
||||
y1: CROSS_PAD.top,
|
||||
x2: CROSS_PAD.left,
|
||||
y2: CROSS_HEIGHT - CROSS_PAD.bottom,
|
||||
class: "b06-chart__axis",
|
||||
}),
|
||||
svgElement("line", {
|
||||
x1: centerX,
|
||||
y1: centerY - 18,
|
||||
x2: centerX,
|
||||
y2: centerY + 18,
|
||||
class: "b06-chart__center-marker",
|
||||
}),
|
||||
svgElement("line", {
|
||||
x1: centerX - 18,
|
||||
y1: centerY,
|
||||
x2: centerX + 18,
|
||||
y2: centerY,
|
||||
class: "b06-chart__center-marker",
|
||||
}),
|
||||
svgText(L("B06_Profile_View_CrossXAxis"), {
|
||||
x: CROSS_WIDTH / 2,
|
||||
y: CROSS_HEIGHT - 8,
|
||||
"text-anchor": "middle",
|
||||
class: "b06-chart__axis-label",
|
||||
}),
|
||||
svgText(L("B06_Profile_View_ElevationAxis"), {
|
||||
x: 13,
|
||||
y: CROSS_HEIGHT / 2,
|
||||
"text-anchor": "middle",
|
||||
transform: `rotate(-90 13 ${CROSS_HEIGHT / 2})`,
|
||||
class: "b06-chart__axis-label",
|
||||
}),
|
||||
);
|
||||
card.append(svg);
|
||||
}
|
||||
|
||||
const footer = document.createElement("footer");
|
||||
const center = document.createElement("span");
|
||||
center.textContent = `${L("B06_Profile_View_CenterElevation")} ${section.center_z?.toFixed(2) ?? "-"}m`;
|
||||
const azimuth = document.createElement("span");
|
||||
azimuth.textContent = `${L("B06_Profile_View_Azimuth")} ${section.azimuth_deg?.toFixed(1) ?? "-"}°`;
|
||||
footer.append(center, azimuth);
|
||||
card.append(footer);
|
||||
return card;
|
||||
}
|
||||
|
||||
export interface SectionViewController {
|
||||
root: HTMLElement;
|
||||
render: (detail: SectionDetailResponse, verticalExaggeration: number) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export function createSectionView(): SectionViewController {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b06-section";
|
||||
let currentDetail: SectionDetailResponse | null = null;
|
||||
let selectedStationId: string | null = null;
|
||||
let currentExaggeration = 1;
|
||||
|
||||
const draw = (): void => {
|
||||
root.replaceChildren();
|
||||
if (!currentDetail) return;
|
||||
const detail = currentDetail;
|
||||
const yScale = calculateYScale(detail);
|
||||
const selectStation = (stationId: string, scroll: boolean): void => {
|
||||
selectedStationId = stationId;
|
||||
draw();
|
||||
if (scroll) {
|
||||
document
|
||||
.getElementById(`cross-${stationId}`)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
longitudinalPanel.append(
|
||||
longitudinalHeader,
|
||||
createLongitudinalProfile(
|
||||
detail.longitudinal,
|
||||
selectedStationId,
|
||||
currentExaggeration,
|
||||
yScale,
|
||||
(stationId) => selectStation(stationId, true),
|
||||
),
|
||||
);
|
||||
|
||||
const crossHeading = document.createElement("div");
|
||||
crossHeading.className = "b06-section__heading";
|
||||
const crossTitle = document.createElement("h3");
|
||||
crossTitle.textContent = L("B06_Profile_View_Cross");
|
||||
const crossCount = document.createElement("span");
|
||||
crossCount.textContent = `${detail.cross_sections.length}${L("B06_Profile_View_CrossCountSuffix")}`;
|
||||
crossHeading.append(crossTitle, crossCount);
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "b06-section__grid";
|
||||
if (detail.cross_sections.length) {
|
||||
detail.cross_sections.forEach((section) =>
|
||||
grid.append(
|
||||
createCrossSectionCard(
|
||||
section,
|
||||
section.station_id === selectedStationId,
|
||||
currentExaggeration,
|
||||
yScale,
|
||||
(stationId) => selectStation(stationId, false),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
grid.append(emptyView(L("B06_Profile_View_NoCross")));
|
||||
}
|
||||
root.append(longitudinalPanel, crossHeading, grid);
|
||||
};
|
||||
|
||||
return {
|
||||
root,
|
||||
render(detail, verticalExaggeration) {
|
||||
currentDetail = detail;
|
||||
currentExaggeration = Math.max(verticalExaggeration, 0.1);
|
||||
selectedStationId ??= detail.longitudinal.stations[0]?.station_id ?? null;
|
||||
draw();
|
||||
},
|
||||
clear() {
|
||||
currentDetail = null;
|
||||
selectedStationId = null;
|
||||
root.replaceChildren();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -32,18 +32,23 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* --- 체크박스 --- */
|
||||
.b06-profile__check {
|
||||
/* --- 읽기 전용 경로 컨텍스트 --- */
|
||||
.b06-profile__info-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-body);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b06-profile__check input {
|
||||
accent-color: var(--color-primary);
|
||||
.b06-profile__info-line span {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b06-profile__info-line strong {
|
||||
font-family: var(--font-mono);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* --- 액션 버튼 행 --- */
|
||||
@@ -99,3 +104,196 @@
|
||||
word-break: break-all;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* --- 종·횡단 도면 --- */
|
||||
.b06-profile__main,
|
||||
.b06-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-24);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b06-section__panel,
|
||||
.b06-cross-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.b06-section__panel > header,
|
||||
.b06-cross-card > header,
|
||||
.b06-cross-card > footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8) var(--spacing-16);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b06-section__panel > header {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b06-section__panel > header h3,
|
||||
.b06-section__heading h3 {
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.b06-section__chart-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.b06-section__chart {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 520px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.b06-section__heading {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-16);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b06-section__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b06-cross-card {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.b06-cross-card:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.b06-cross-card--selected {
|
||||
border-color: var(--color-danger);
|
||||
box-shadow: 0 0 0 2px var(--color-danger);
|
||||
}
|
||||
|
||||
.b06-cross-card > header {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b06-cross-card > header div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b06-cross-card > header strong {
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b06-cross-card > footer {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b06-section__empty {
|
||||
display: flex;
|
||||
min-height: 180px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-24);
|
||||
border: 1px dashed var(--color-text-muted);
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* SVG 차트 색상은 테마 토큰만 사용한다. */
|
||||
.b06-chart__bg {
|
||||
fill: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.b06-chart__grid {
|
||||
stroke: var(--color-border);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.b06-chart__axis {
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.b06-chart__tick,
|
||||
.b06-chart__station-label,
|
||||
.b06-chart__axis-label {
|
||||
fill: var(--color-text-secondary);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.b06-chart__tick,
|
||||
.b06-chart__station-label {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.b06-chart__axis-label {
|
||||
fill: var(--color-text-body);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.b06-chart__profile {
|
||||
fill: none;
|
||||
stroke: var(--color-chart-0);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.b06-chart__cross-profile {
|
||||
fill: none;
|
||||
stroke: var(--color-chart-1);
|
||||
stroke-width: 2.4;
|
||||
}
|
||||
|
||||
.b06-chart__station {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b06-chart__station-line {
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
.b06-chart__station-line--bp,
|
||||
.b06-chart__station-line--regular {
|
||||
stroke: var(--color-warning);
|
||||
}
|
||||
|
||||
.b06-chart__station-line--ep {
|
||||
stroke: var(--color-accent);
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
.b06-chart__station-line--selected,
|
||||
.b06-chart__center-marker {
|
||||
stroke: var(--color-danger);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.b06-chart__station--selected .b06-chart__station-label {
|
||||
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");
|
||||
|
||||
@@ -21,7 +21,7 @@ SERVER_PORT = int(os.getenv("SERVER_PORT", "8000"))
|
||||
DEBUG = os.getenv("DEBUG", "False").lower() == "true"
|
||||
|
||||
# 정적 파일 서빙 (프론트엔드 빌드 결과)
|
||||
STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "node_modules", ".build")
|
||||
STATIC_DIR = os.path.join(os.path.dirname(__file__), "node_modules", ".build")
|
||||
STATIC_URL = "/static"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -239,6 +239,7 @@ SECTION_STATION_INTERVAL_M = float(os.getenv("SECTION_STATION_INTERVAL_M", "20.0
|
||||
SECTION_CROSS_HALF_WIDTH_M = float(os.getenv("SECTION_CROSS_HALF_WIDTH_M", "15.0"))
|
||||
SECTION_CROSS_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_CROSS_SAMPLE_INTERVAL_M", "0.5"))
|
||||
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"
|
||||
|
||||
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
{
|
||||
"docs/wiki/AGENTS.md": {
|
||||
"mtime": 1783834983.0,
|
||||
"ast_hash": "74d04caee1f70d588da20af0f2224049",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/CLAUDE.md": {
|
||||
"mtime": 1783834983.0,
|
||||
"ast_hash": "74d04caee1f70d588da20af0f2224049",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/a00_app_shell_framework.md": {
|
||||
"mtime": 1784259049.8622963,
|
||||
"ast_hash": "56c3c5418a6a54f89afe3732cadaaaec",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/a00_app_shell_framework_scaffold.md": {
|
||||
"mtime": 1783844389.0,
|
||||
"ast_hash": "b08d53673fa62ab2ae17105341f6beda",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/api_common.md": {
|
||||
"mtime": 1783849575.0,
|
||||
"ast_hash": "aa36d7b826c4ac0349cd64a64cde5fe4",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/auth_rbac.md": {
|
||||
"mtime": 1784259833.4644027,
|
||||
"ast_hash": "2c6ad199f866257eca3bec738fcf5f3d",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/common_util.md": {
|
||||
"mtime": 1784259848.5701928,
|
||||
"ast_hash": "941f7272498ffc25ba1deeb94c9c37b3",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/db_schema/files_surface.md": {
|
||||
"mtime": 1784267731.9194815,
|
||||
"ast_hash": "7025d41c372c169b7929ed6baca3b07f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/db_schema/logs_monitoring.md": {
|
||||
"mtime": 1784259355.6293466,
|
||||
"ast_hash": "d394ba4d2cb09a6651f574352750eba0",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/db_schema/overview.md": {
|
||||
"mtime": 1784259347.9511518,
|
||||
"ast_hash": "8f35d8e4f659eee10ef9e01411cdc079",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/db_schema/projects.md": {
|
||||
"mtime": 1784259359.3317523,
|
||||
"ast_hash": "f037e166e7b72002dfa3c7552a0eae43",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/db_schema/route_profile.md": {
|
||||
"mtime": 1784259366.082059,
|
||||
"ast_hash": "9ba725f36208f26e1c8d0d9f6179325c",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/db_schema/structure_output.md": {
|
||||
"mtime": 1784259369.8867612,
|
||||
"ast_hash": "92adeedeb94bb3b75b0ba4a42c75ea15",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/db_schema/unconfirmed/README.md": {
|
||||
"mtime": 1784259342.919075,
|
||||
"ast_hash": "14b964e21ad8705d3fb2c6c818f71750",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/db_schema/users_auth.md": {
|
||||
"mtime": 1784259859.5247,
|
||||
"ast_hash": "4252d7048a05615e6420fb2853567323",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/dependencies.md": {
|
||||
"mtime": 1783850549.0,
|
||||
"ast_hash": "93732554454116e3543ac2269b9d1f0d",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/design.md": {
|
||||
"mtime": 1784260030.4069157,
|
||||
"ast_hash": "85aec17d904c548e299a4e3810669cc0",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/schema_common.md": {
|
||||
"mtime": 1783844389.0,
|
||||
"ast_hash": "ebde162a912c33c17bd42e3edb469bc6",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/storage_paths.md": {
|
||||
"mtime": 1784276227.6785357,
|
||||
"ast_hash": "a0fe28424a580b6dce5ddbcb66f886e5",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/ui_templates.md": {
|
||||
"mtime": 1784264934.0988722,
|
||||
"ast_hash": "6cb21cb237d90125d149b1db904a2d74",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/concepts/workflow_state.md": {
|
||||
"mtime": 1784267747.180053,
|
||||
"ast_hash": "c1fa859f4d19cea7059daf828b29a41e",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/index.md": {
|
||||
"mtime": 1784273610.479596,
|
||||
"ast_hash": "635b063e8e9d1e504fa08e04f6e6673f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/log.md": {
|
||||
"mtime": 1784281064.938371,
|
||||
"ast_hash": "d0a674d4bde81eea7e8bd2edf76d6287",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/A01_Home/A01_components.md": {
|
||||
"mtime": 1783849656.0,
|
||||
"ast_hash": "af5724435a2da0845f42aed322971f7a",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/A01_Home/A01_frontend.md": {
|
||||
"mtime": 1783849659.0,
|
||||
"ast_hash": "59d5bcdf4d88a0e576c55c5752579a23",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/A02_ProgDetail/A02_components.md": {
|
||||
"mtime": 1783849775.0,
|
||||
"ast_hash": "d663c36e0202f099a2602d631c2f962d",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/A02_ProgDetail/A02_frontend.md": {
|
||||
"mtime": 1783849775.0,
|
||||
"ast_hash": "b590aa6c5a13b2478592e23803f5398d",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/A03_CompDetail/A03_frontend.md": {
|
||||
"mtime": 1783844088.0,
|
||||
"ast_hash": "833dd9355fb96b46b0f94271a642c216",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/A04_NewsHistory/A04_frontend.md": {
|
||||
"mtime": 1783844098.0,
|
||||
"ast_hash": "5bb83dca8f9f11f0122f5911b295e279",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/A05_EduDetail/A05_frontend.md": {
|
||||
"mtime": 1783844108.0,
|
||||
"ast_hash": "fadf785b9ebad689862cb8cfa9827fbd",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/A06_Login/A06_backend.md": {
|
||||
"mtime": 1784259816.2009697,
|
||||
"ast_hash": "8ff447e9f595b09ab8706e6b2153c2e4",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/A06_Login/A06_frontend.md": {
|
||||
"mtime": 1784259825.8575397,
|
||||
"ast_hash": "b77699e691f04c97e2676b56a978d441",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/A07_Register/A07_backend.md": {
|
||||
"mtime": 1784259843.9720101,
|
||||
"ast_hash": "b2ba632dc7648b9925828c57fb94073f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/A07_Register/A07_frontend.md": {
|
||||
"mtime": 1783849775.0,
|
||||
"ast_hash": "0c30b980601414cefa02c05ba50a9008",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/A08_Support/A08_backend.md": {
|
||||
"mtime": 1783849900.0,
|
||||
"ast_hash": "d2dd1028a837d8c17429179c4ffd7135",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/A08_Support/A08_frontend.md": {
|
||||
"mtime": 1783849775.0,
|
||||
"ast_hash": "f2ebae60bb02320db4ac39e0a66b9d69",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/A09_Security/A09_backend.md": {
|
||||
"mtime": 1783849775.0,
|
||||
"ast_hash": "f96ba36247f269672317bc352c705a7c",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/A09_Security/A09_frontend.md": {
|
||||
"mtime": 1783849775.0,
|
||||
"ast_hash": "d90b0617abec1376bd32e140b0eb1f63",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B01_Dashboard/B01_api.md": {
|
||||
"mtime": 1784259230.2413194,
|
||||
"ast_hash": "cec23a7fa764aa9cb89ca556386a9d1e",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B01_Dashboard/B01_backend.md": {
|
||||
"mtime": 1784259865.0189633,
|
||||
"ast_hash": "d3a676bc366985ef5ec949ae15a3d3cf",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B01_Dashboard/B01_db.md": {
|
||||
"mtime": 1783850631.0,
|
||||
"ast_hash": "e7467e7ab25de8a576244c8595fefca4",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B01_Dashboard/B01_dependencies.md": {
|
||||
"mtime": 1784259237.7637663,
|
||||
"ast_hash": "b772aa70b5f90deb37c5324f93d79a80",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B01_Dashboard/B01_frontend.md": {
|
||||
"mtime": 1784264944.284991,
|
||||
"ast_hash": "720d69c49dce55121af9846763aa3258",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B02_ProjRegister/B02_backend.md": {
|
||||
"mtime": 1783859694.0,
|
||||
"ast_hash": "ec7d0edddc708060b31ca0058630cd1c",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B02_ProjRegister/B02_db.md": {
|
||||
"mtime": 1783849775.0,
|
||||
"ast_hash": "ca17bc76462201e2ce50e754fbe7c816",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B02_ProjRegister/B02_frontend.md": {
|
||||
"mtime": 1783849775.0,
|
||||
"ast_hash": "407638de4f1ed56a3ba72c5c244d8ebd",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B03_FileInput/B03_api.md": {
|
||||
"mtime": 1784259243.9075582,
|
||||
"ast_hash": "96a8eb8ea278e761ddf0a3a0e2693728",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B03_FileInput/B03_backend.md": {
|
||||
"mtime": 1784259247.156907,
|
||||
"ast_hash": "2955910cfa45dad1e46c7a4141c779d6",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B03_FileInput/B03_db.md": {
|
||||
"mtime": 1783850637.0,
|
||||
"ast_hash": "229bc7bb89bc7fb3a745fa7a1eb56640",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B03_FileInput/B03_dependencies.md": {
|
||||
"mtime": 1784259157.4293892,
|
||||
"ast_hash": "8ba6d765120bd79067f87dad58605dba",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B03_FileInput/B03_frontend.md": {
|
||||
"mtime": 1784264939.1710696,
|
||||
"ast_hash": "ff5b954b7136ca7f73c842965240400c",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B04_wf1_Surface/B04_api.md": {
|
||||
"mtime": 1784281082.294356,
|
||||
"ast_hash": "688e57fa31f595d8a2eb50fe1534bae8",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B04_wf1_Surface/B04_backend.md": {
|
||||
"mtime": 1784281075.9390342,
|
||||
"ast_hash": "179006cfa5579947f12703de677b9178",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B04_wf1_Surface/B04_db.md": {
|
||||
"mtime": 1784267041.957495,
|
||||
"ast_hash": "50541e6a765c96c40cb34856acc29900",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B04_wf1_Surface/B04_dependencies.md": {
|
||||
"mtime": 1783849775.0,
|
||||
"ast_hash": "15bd0faa0411264c14b0bbd89f7afd46",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B04_wf1_Surface/B04_frontend.md": {
|
||||
"mtime": 1784278834.8752944,
|
||||
"ast_hash": "bb89b16cc5a0d9763cb26c174a537f54",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B05_wf2_Route/B05_api.md": {
|
||||
"mtime": 1784259165.6230073,
|
||||
"ast_hash": "1eeb9b6e80f8a2122b61714669b85e1b",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B05_wf2_Route/B05_backend.md": {
|
||||
"mtime": 1784259169.115432,
|
||||
"ast_hash": "50c0d1a2d67fbe66e3885cabae81d6cf",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B05_wf2_Route/B05_db.md": {
|
||||
"mtime": 1783850658.0,
|
||||
"ast_hash": "c45788b5db47a7a4f246b0714a2c11e3",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B05_wf2_Route/B05_dependencies.md": {
|
||||
"mtime": 1784259171.929299,
|
||||
"ast_hash": "96757fcd55e66155d680aa611106b5ce",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B05_wf2_Route/B05_frontend.md": {
|
||||
"mtime": 1784267722.8724308,
|
||||
"ast_hash": "f72755ed7acf8a7450feac8323fad91c",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B06_wf3_ProfileCross/B06_api.md": {
|
||||
"mtime": 1784259175.4055898,
|
||||
"ast_hash": "a5989dca5de549ea7084f4291b554275",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B06_wf3_ProfileCross/B06_backend.md": {
|
||||
"mtime": 1784259178.6757984,
|
||||
"ast_hash": "5a0495ebd2ba0f22c5e87703572ad1ae",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B06_wf3_ProfileCross/B06_db.md": {
|
||||
"mtime": 1783850703.0,
|
||||
"ast_hash": "63b1c8f0287b4b277633da8c4617b03f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B06_wf3_ProfileCross/B06_dependencies.md": {
|
||||
"mtime": 1784259182.2659357,
|
||||
"ast_hash": "1033c52fc6823211db0690aafa706a83",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B06_wf3_ProfileCross/B06_frontend.md": {
|
||||
"mtime": 1784264958.5574062,
|
||||
"ast_hash": "edc082e6e035ebea9f7427393c8c863f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B07_wf4_DesignDetail/B07_frontend.md": {
|
||||
"mtime": 1783844389.0,
|
||||
"ast_hash": "d4bd4b2f000f33a76bfd5d2f9b1a2850",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B08_wf5_Quantity/B08_db.md": {
|
||||
"mtime": 1783850708.0,
|
||||
"ast_hash": "00ca815731f29da6f062f6e5884430de",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B08_wf5_Quantity/B08_frontend.md": {
|
||||
"mtime": 1784280350.6155877,
|
||||
"ast_hash": "82f0c3c8358a8f6b16395623d73f049f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B09_wf6_Estimation/B09_frontend.md": {
|
||||
"mtime": 1783844389.0,
|
||||
"ast_hash": "c17bf17a317f2190131be87d2e23e9bb",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B10_Payment/B10_frontend.md": {
|
||||
"mtime": 1784264923.3316023,
|
||||
"ast_hash": "922e565933ffcf90a3fa96307ae4dee3",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/wiki/pages/B11_Status/B11_frontend.md": {
|
||||
"mtime": 1784264926.0308616,
|
||||
"ast_hash": "2cf11aa66d6346818c14c62fa8e2b12a",
|
||||
"semantic_hash": ""
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,16 @@ logger = logging.getLogger(__name__)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _frontend_process_env(root_dir: Path) -> dict[str, str]:
|
||||
"""config/node_modules를 사용하는 프론트엔드 프로세스 환경을 만든다."""
|
||||
env = os.environ.copy()
|
||||
node_modules = root_dir / "config" / "node_modules"
|
||||
bin_dir = node_modules / ".bin"
|
||||
env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}"
|
||||
env["NODE_PATH"] = str(node_modules)
|
||||
return env
|
||||
|
||||
|
||||
def build_frontend() -> bool:
|
||||
"""프론트엔드 빌드 (npm run build from project root)"""
|
||||
root_dir = Path(__file__).resolve().parent
|
||||
@@ -69,6 +79,7 @@ def build_frontend() -> bool:
|
||||
"npm run build",
|
||||
shell=True,
|
||||
cwd=str(root_dir),
|
||||
env=_frontend_process_env(root_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
@@ -103,6 +114,7 @@ def serve_frontend_dev() -> None:
|
||||
"npm run dev",
|
||||
shell=True,
|
||||
cwd=str(root_dir),
|
||||
env=_frontend_process_env(root_dir),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
@@ -203,11 +215,12 @@ app.add_middleware(
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 정적 파일 서빙 (프론트엔드)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
if os.path.isdir(STATIC_DIR):
|
||||
app.mount(STATIC_URL, StaticFiles(directory=STATIC_DIR), name="static")
|
||||
logger.info(f"✓ 정적 파일 서빙: {STATIC_URL} → {STATIC_DIR}")
|
||||
else:
|
||||
logger.warning(f"⚠ 정적 파일 디렉토리 없음: {STATIC_DIR}")
|
||||
app.mount(
|
||||
STATIC_URL,
|
||||
StaticFiles(directory=STATIC_DIR, check_dir=False),
|
||||
name="static",
|
||||
)
|
||||
logger.info(f"✓ 정적 파일 서빙 경로 등록: {STATIC_URL} → {STATIC_DIR}")
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 기본 엔드포인트
|
||||
|
||||
+5
-5
@@ -4,11 +4,11 @@
|
||||
"description": "임도 설계 및 견적 자동화 프로그램 (프론트엔드)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write \"../**/*.{ts,css,html}\""
|
||||
"dev": "node ./config/node_modules/vite/bin/vite.js --configLoader runner",
|
||||
"build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner",
|
||||
"preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner",
|
||||
"typecheck": "node ./config/node_modules/typescript/bin/tsc --noEmit",
|
||||
"format": "node ./config/node_modules/prettier/bin/prettier.cjs --write \"../**/*.{ts,css,html}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"maplibre-gl": "^5.24.0",
|
||||
|
||||
+107
-144
@@ -1,157 +1,120 @@
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# 위키 루트 경로 설정
|
||||
wiki_root = Path(r"D:\02_Software_Prog\임도설계 및 견적자동화 프로그램 개발\docs\wiki")
|
||||
raw_root = Path(r"D:\02_Software_Prog\임도설계 및 견적자동화 프로그램 개발\docs\raw")
|
||||
WIKI_DIR = r"C:\Program_coding\임도설계 및 견적자동화 프로그램 개발\docs\wiki"
|
||||
PAGES_DIR = os.path.join(WIKI_DIR, "pages")
|
||||
CONCEPTS_DIR = os.path.join(WIKI_DIR, "concepts")
|
||||
INDEX_PATH = os.path.join(WIKI_DIR, "index.md")
|
||||
|
||||
def load_frontmatter(file_path):
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
# Frontmatter regex
|
||||
match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL)
|
||||
if match:
|
||||
fm_text = match.group(1)
|
||||
fm = {}
|
||||
# Simple YAML key-value parser for basic string/list properties
|
||||
for line in fm_text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if ":" in line:
|
||||
key, val = line.split(":", 1)
|
||||
key = key.strip()
|
||||
val = val.strip().strip("'\"")
|
||||
fm[key] = val
|
||||
return fm, content[match.end():]
|
||||
return None, content
|
||||
errors = []
|
||||
warnings = []
|
||||
all_wiki_files = {} # relative_path -> absolute_path
|
||||
|
||||
def extract_wiki_links(text):
|
||||
# Matches [[link]] or [[link|alias]] or [[link#section]] or [[link#section|alias]]
|
||||
links = re.findall(r"\[\[(.*?)\]\]", text)
|
||||
cleaned_links = []
|
||||
for l in links:
|
||||
# Split alias
|
||||
if "|" in l:
|
||||
l = l.split("|")[0]
|
||||
# Split section
|
||||
if "#" in l:
|
||||
l = l.split("#")[0]
|
||||
l = l.strip()
|
||||
if l:
|
||||
cleaned_links.append(l)
|
||||
return cleaned_links
|
||||
# Collect all markdown files in wiki/ (ignoring graphify-out and memory directories)
|
||||
for root, dirs, files in os.walk(WIKI_DIR):
|
||||
# skip graphify-out or memory
|
||||
if "graphify-out" in root or "memory" in root:
|
||||
continue
|
||||
for file in files:
|
||||
if file.endswith(".md"):
|
||||
abs_path = os.path.join(root, file)
|
||||
rel_path = os.path.relpath(abs_path, WIKI_DIR).replace("\\", "/")
|
||||
all_wiki_files[rel_path] = abs_path
|
||||
simple_name = os.path.splitext(rel_path)[0]
|
||||
all_wiki_files[simple_name] = abs_path
|
||||
|
||||
def run_lint():
|
||||
print("=== Start Wiki Linting (No PyYAML, Filtered) ===")
|
||||
|
||||
all_files = list(wiki_root.glob("**/*.md"))
|
||||
index_file = wiki_root / "index.md"
|
||||
log_file = wiki_root / "log.md"
|
||||
|
||||
# 1. 파일 목록화 및 Frontmatter 체크
|
||||
pages = {}
|
||||
concepts = {}
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
for f in all_files:
|
||||
rel_path = f.relative_to(wiki_root).as_posix()
|
||||
# Skip output files generated by graphify in graphify-out
|
||||
if rel_path.startswith("graphify-out/") or rel_path in ["index.md", "log.md", "AGENTS.md", "CLAUDE.md"]:
|
||||
continue
|
||||
|
||||
fm, body = load_frontmatter(f)
|
||||
# Read index.md content
|
||||
with open(INDEX_PATH, "r", encoding="utf-8") as f:
|
||||
index_content = f.read()
|
||||
|
||||
# Pattern for wikilinks: [[link]] or [[link|label]]
|
||||
wikilink_pat = re.compile(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]")
|
||||
|
||||
for rel_path, abs_path in list(all_wiki_files.items()):
|
||||
if "/" not in rel_path and rel_path != "index" and rel_path != "log":
|
||||
continue
|
||||
if rel_path in ["index", "log", "index.md", "log.md"]:
|
||||
continue
|
||||
|
||||
# 기본 규칙 검사
|
||||
if not fm:
|
||||
errors.append(f"Missing Frontmatter: {rel_path}")
|
||||
continue
|
||||
|
||||
# status 값 검사
|
||||
status = fm.get("status")
|
||||
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"Invalid status '{status}' in {rel_path}. 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.")
|
||||
|
||||
# 100줄 제한 검사 (10% 즉 110줄 허용)
|
||||
lines_count = len(f.read_text(encoding="utf-8").splitlines())
|
||||
if lines_count > 110:
|
||||
warnings.append(f"File exceeds 110 lines ({lines_count} lines): {rel_path}")
|
||||
# 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.")
|
||||
|
||||
# 페이지와 컨셉 분류
|
||||
if "pages/" in rel_path:
|
||||
pages[rel_path] = {
|
||||
"fm": fm,
|
||||
"body": body,
|
||||
"path": f,
|
||||
"links": extract_wiki_links(body)
|
||||
}
|
||||
# 파일명 형식 규칙 5 검사: {page_id}_{기능명}.md
|
||||
filename = f.name
|
||||
page_id = fm.get("page_id")
|
||||
if not page_id:
|
||||
errors.append(f"Missing page_id in page: {rel_path}")
|
||||
else:
|
||||
expected_prefix = page_id.split("_")[0] # e.g. A01
|
||||
if not filename.startswith(expected_prefix):
|
||||
warnings.append(f"Filename does not match page_id format: {rel_path} (page_id: {page_id})")
|
||||
else:
|
||||
concepts[rel_path] = {
|
||||
"fm": fm,
|
||||
"body": body,
|
||||
"path": f,
|
||||
"links": extract_wiki_links(body)
|
||||
}
|
||||
|
||||
# 2. 위키 링크 정합성 검증 (Broken Links)
|
||||
# 개념/페이지 맵 구성
|
||||
available_links = {}
|
||||
for p_rel in pages:
|
||||
name_no_ext = Path(p_rel).with_suffix("").as_posix()
|
||||
available_links[name_no_ext] = p_rel
|
||||
# Also map short form if distinct
|
||||
short_name = Path(p_rel).name[:-3]
|
||||
available_links[short_name] = p_rel
|
||||
# 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
|
||||
if not link_clean:
|
||||
continue
|
||||
found = False
|
||||
|
||||
for c_rel in concepts:
|
||||
name_no_ext = Path(c_rel).with_suffix("").as_posix()
|
||||
available_links[name_no_ext] = c_rel
|
||||
# Short form
|
||||
short_name = Path(c_rel).name[:-3]
|
||||
available_links[short_name] = c_rel
|
||||
# Subdirectories for concepts like db_schema/*
|
||||
if "concepts/" in name_no_ext:
|
||||
available_links[name_no_ext.replace("concepts/", "")] = c_rel
|
||||
# 1. Absolute link from vault root (e.g. concepts/storage_paths)
|
||||
if link_clean in all_wiki_files:
|
||||
found = True
|
||||
# 2. Simple name match (e.g. storage_paths, B01_frontend)
|
||||
elif link_clean.split("/")[-1] in all_wiki_files:
|
||||
found = True
|
||||
# 3. Handle subpages like [[B01_Dashboard/B01_frontend]]
|
||||
elif f"pages/{link_clean}" in all_wiki_files:
|
||||
found = True
|
||||
elif f"concepts/{link_clean}" in all_wiki_files:
|
||||
found = True
|
||||
# 4. Handle nested directory patterns
|
||||
elif link_clean.startswith("../"):
|
||||
# Resolve relative link
|
||||
curr_dir = os.path.dirname(rel_path)
|
||||
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}]]")
|
||||
|
||||
# 링크 검사
|
||||
for rel_path, info in {**pages, **concepts}.items():
|
||||
for link in info["links"]:
|
||||
if link.startswith("http://") or link.startswith("https://") or link.startswith("file:///"):
|
||||
continue
|
||||
normalized_link = link.replace("\\", "/")
|
||||
if normalized_link not in available_links and f"concepts/{normalized_link}" not in available_links and f"pages/{normalized_link}" not in available_links:
|
||||
warnings.append(f"Broken Link in {rel_path}: [[{link}]]")
|
||||
# Check if this file is registered in index.md (avoid orphans)
|
||||
file_base = os.path.basename(abs_path)
|
||||
file_name_no_ext = os.path.splitext(file_base)[0]
|
||||
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.")
|
||||
|
||||
# 3. index.md 등록 상태 확인
|
||||
index_content = index_file.read_text(encoding="utf-8") if index_file.exists() else ""
|
||||
for rel_path in pages:
|
||||
short_name = Path(rel_path).name[:-3]
|
||||
dir_name = Path(rel_path).parent.name
|
||||
expected_ref_dir = f"{dir_name}/{short_name}"
|
||||
if expected_ref_dir not in index_content and f"[[{short_name}]]" not in index_content and short_name not in index_content:
|
||||
warnings.append(f"Page not indexed in index.md: {rel_path} (Expected link to [[{expected_ref_dir}]] or [[{short_name}]])")
|
||||
|
||||
# 4. 결과 출력
|
||||
print(f"\nScanning completed: {len(all_files)} total markdown files.")
|
||||
print(f"Detected {len(pages)} pages and {len(concepts)} concept documents.")
|
||||
|
||||
print(f"\n--- Errors ({len(errors)}) ---")
|
||||
for e in errors:
|
||||
print(f"[ERROR] {e}")
|
||||
|
||||
print(f"\n--- Warnings ({len(warnings)}) ---")
|
||||
for w in warnings:
|
||||
print(f"[WARN] {w}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_lint()
|
||||
print("=== LINT ERRORS ===")
|
||||
for e in sorted(list(set(errors))):
|
||||
print(e)
|
||||
print("\n=== LINT WARNINGS ===")
|
||||
for w in sorted(list(set(warnings))):
|
||||
print(w)
|
||||
print("\nLint completed.")
|
||||
|
||||
+7
-11
@@ -12,7 +12,8 @@
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["node"],
|
||||
"types": ["node", "vite/client"],
|
||||
"typeRoots": ["./config/node_modules/@types", "./config/node_modules"],
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
|
||||
@@ -25,17 +26,12 @@
|
||||
"paths": {
|
||||
"@ui/*": ["ui_template/*"],
|
||||
"@config/*": ["config/*"],
|
||||
"@util/*": ["common_util/*"]
|
||||
"@util/*": ["common_util/*"],
|
||||
"three": ["config/node_modules/@types/three"],
|
||||
"three/*": ["config/node_modules/@types/three/*"],
|
||||
"maplibre-gl": ["config/node_modules/maplibre-gl"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"A00_Common",
|
||||
"A0*",
|
||||
"B0*",
|
||||
"B1*",
|
||||
"ui_template",
|
||||
"config/**/*",
|
||||
"common_util"
|
||||
],
|
||||
"include": ["A00_Common", "A0*", "B0*", "B1*", "ui_template", "config/**/*", "common_util"],
|
||||
"exclude": ["0_old", "node_modules", "venv", "dist"]
|
||||
}
|
||||
|
||||
@@ -725,6 +725,12 @@ export const ui_locales = {
|
||||
B05_Route_Solve_Failed: ["경로 탐색에 실패했습니다.", "Route solve failed."],
|
||||
B05_Route_Confirm_Success: ["경로를 확정했습니다.", "Route confirmed."],
|
||||
B05_Route_Confirm_Failed: ["경로 확정에 실패했습니다.", "Route confirm failed."],
|
||||
B05_Route_Group_SectionOptions: ["측점·횡단 옵션", "Station & Cross Options"],
|
||||
B05_Route_Field_StationInterval: ["측점 간격(m)", "Station interval (m)"],
|
||||
B05_Route_Field_CrossHalfWidth: ["횡단 반폭(m)", "Cross half-width (m)"],
|
||||
B05_Route_Field_CrossSample: ["횡단 샘플 간격(m)", "Cross sample interval (m)"],
|
||||
B05_Route_Field_LongSample: ["종단 샘플 간격(m)", "Long sample interval (m)"],
|
||||
B05_Route_Field_StationLines: ["측점 가로선", "Station cross lines"],
|
||||
|
||||
/* --- B06_wf3_ProfileCross 종·횡단 생성 --- */
|
||||
B06_Profile_Title: ["3차 · 종·횡단 생성", "Step 3 · Profile & Cross-section"],
|
||||
@@ -732,27 +738,55 @@ export const ui_locales = {
|
||||
B06_Profile_Field_RouteId: ["경로 ID (routes.id)", "Route ID"],
|
||||
B06_Profile_Field_Filter: ["지면 필터", "Ground filter"],
|
||||
B06_Profile_Field_Method: ["지표면 표현", "Surface method"],
|
||||
B06_Profile_Field_Crs: ["좌표계 (선택)", "CRS (optional)"],
|
||||
B06_Profile_Group_Options: ["측점·횡단 옵션", "Station & Cross Options"],
|
||||
B06_Profile_Field_StationInterval: ["측점 간격(m)", "Station interval (m)"],
|
||||
B06_Profile_Field_CrossHalfWidth: ["횡단 반폭(m)", "Cross half-width (m)"],
|
||||
B06_Profile_Field_CrossSample: ["횡단 샘플 간격(m)", "Cross sample interval (m)"],
|
||||
B06_Profile_Field_LongSample: ["종단 샘플 간격(m)", "Long sample interval (m)"],
|
||||
B06_Profile_Field_Crs: ["좌표계", "CRS"],
|
||||
B06_Profile_Group_Display: ["표시 옵션", "Display Options"],
|
||||
B06_Profile_Field_VerticalExaggeration: ["높이 배율", "Vertical exaggeration"],
|
||||
B06_Profile_Field_Smooth: ["지표면 스무딩", "Smooth surface"],
|
||||
B06_Profile_Btn_Generate: ["종·횡단 생성", "Generate Sections"],
|
||||
B06_Profile_Smooth_On: ["사용", "On"],
|
||||
B06_Profile_Smooth_Off: ["미사용", "Off"],
|
||||
B06_Profile_Btn_Confirm: ["종·횡단 확정", "Confirm Sections"],
|
||||
B06_Profile_Result_Title: ["종·횡단 생성 결과", "Section Result"],
|
||||
B06_Profile_Result_Empty: ["아직 생성된 종·횡단이 없습니다.", "No sections generated yet."],
|
||||
B06_Profile_Context_Failed: [
|
||||
"경로 정보를 불러오지 못했습니다. 서버 상태를 확인하세요.",
|
||||
"Failed to load route context. Check the server status.",
|
||||
],
|
||||
B06_Profile_Calculate_In_B05: [
|
||||
"저장된 종·횡단이 없습니다. B05에서 경로를 계산하세요.",
|
||||
"No saved sections. Calculate the route in B05.",
|
||||
],
|
||||
B06_Profile_Result_Length: ["종단 연장(m)", "Longitudinal length (m)"],
|
||||
B06_Profile_Result_CrossCount: ["횡단 개수", "Cross-section count"],
|
||||
B06_Profile_Result_Path: ["종단 파일", "Longitudinal file"],
|
||||
B06_Profile_Error_Project: ["먼저 프로젝트를 선택하세요.", "Select a project first."],
|
||||
B06_Profile_Error_RouteId: ["유효한 경로 ID를 입력하세요.", "Enter a valid route ID."],
|
||||
B06_Profile_Error_Filter: ["지면 필터 키를 입력하세요.", "Enter a ground filter key."],
|
||||
B06_Profile_Generate_Success: ["종·횡단을 생성했습니다.", "Sections generated."],
|
||||
B06_Profile_Generate_Failed: ["종·횡단 생성에 실패했습니다.", "Section generation failed."],
|
||||
B06_Profile_Confirm_Success: ["종·횡단을 확정했습니다.", "Sections confirmed."],
|
||||
B06_Profile_Confirm_Failed: ["종·횡단 확정에 실패했습니다.", "Section confirm failed."],
|
||||
B06_Profile_Detail_Failed: [
|
||||
"종·횡단 도면 데이터를 불러오지 못했습니다.",
|
||||
"Failed to load section drawing data.",
|
||||
],
|
||||
B06_Profile_View_Longitudinal: ["종단면도", "Longitudinal profile"],
|
||||
B06_Profile_View_Cross: ["횡단면도", "Cross sections"],
|
||||
B06_Profile_View_StationCount: ["횡단 측점", "Cross stations"],
|
||||
B06_Profile_View_CrossCountSuffix: ["개 도면", " drawings"],
|
||||
B06_Profile_View_LongitudinalXAxis: [
|
||||
"BP 기준 누적거리 (횡단 측점)",
|
||||
"Chainage from BP (cross stations)",
|
||||
],
|
||||
B06_Profile_View_CrossXAxis: ["중심선 기준 편거리 (m)", "Offset from centerline (m)"],
|
||||
B06_Profile_View_ElevationAxis: ["지반고 (m)", "Elevation (m)"],
|
||||
B06_Profile_View_CenterElevation: ["중심고", "Center elevation"],
|
||||
B06_Profile_View_Azimuth: ["방위각", "Azimuth"],
|
||||
B06_Profile_View_Kind_BP: ["BP", "BP"],
|
||||
B06_Profile_View_Kind_EP: ["EP", "EP"],
|
||||
B06_Profile_View_Kind_Station: ["일반 측점", "Station"],
|
||||
B06_Profile_View_NoLongitudinal: [
|
||||
"표시할 종단면 데이터가 없습니다.",
|
||||
"No longitudinal profile data to display.",
|
||||
],
|
||||
B06_Profile_View_NoCross: [
|
||||
"표시할 횡단면 데이터가 없습니다.",
|
||||
"No cross-section data to display.",
|
||||
],
|
||||
|
||||
/* --- B07_wf4_DesignDetail 상세 설계 --- */
|
||||
B07_Design_Title: ["4차 · 상세 설계", "Step 4 · Detailed Design"],
|
||||
|
||||
+6
-4
@@ -1,4 +1,3 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -11,14 +10,17 @@ const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||
* - 페이지 폴더(A01_Home 등)는 ../를 통해 접근
|
||||
* - 0_old(구형 코드), venv(파이썬)는 빌드에서 제외
|
||||
*/
|
||||
export default defineConfig({
|
||||
export default {
|
||||
root: "A00_Common",
|
||||
publicDir: false,
|
||||
cacheDir: "config/node_modules/.vite",
|
||||
resolve: {
|
||||
alias: {
|
||||
"@ui": resolve(__dirname, "./ui_template"),
|
||||
"@config": resolve(__dirname, "./config"),
|
||||
"@util": resolve(__dirname, "./common_util"),
|
||||
three: resolve(__dirname, "./config/node_modules/three"),
|
||||
"maplibre-gl": resolve(__dirname, "./config/node_modules/maplibre-gl"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
@@ -33,11 +35,11 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "../node_modules/.build",
|
||||
outDir: "../config/node_modules/.build",
|
||||
emptyOutDir: true,
|
||||
target: "es2022",
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ["0_old"],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user