Merge branch 'feature/B06-페이지-개선'

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