diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index 83588539..dced1bc8 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -6,6 +6,7 @@ * POST /api/projects/{project_id}/sections/generate → 종횡단 생성 + DB 기록 * GET /api/projects/{project_id}/sections/context → 확정 경로 + 기본 옵션 * GET /api/projects/{project_id}/sections/{route_id} → 종단 요약 조회 + * GET /api/projects/{project_id}/sections/{route_id}/detail → 종횡단 원시 샘플 조회 * POST /api/projects/{project_id}/sections/{route_id}/confirm → 종횡단 확정 * * 규칙: @@ -44,6 +45,7 @@ export interface SectionOptionDefaults { cross_half_width_m: number; cross_sample_interval_m: number; long_sample_interval_m: number; + vertical_exaggeration: number; } export interface SectionContextResponse { @@ -66,6 +68,37 @@ export interface SectionSummaryResponse { cross_section_count: number; } +export interface SectionSample { + chainage_m?: number; + offset_m?: number; + elevation_m: number | null; + valid: boolean; +} + +export interface SectionStation { + station_id: string; + chainage_m: number; + label: string; + kind: "bp" | "ep" | "regular"; + center_z: number | null; + azimuth_deg: number | null; +} + +export interface LongitudinalSection { + length_m: number; + samples: SectionSample[]; + stations: SectionStation[]; +} + +export interface CrossSection extends SectionStation { + samples: SectionSample[]; +} + +export interface SectionDetailResponse { + longitudinal: LongitudinalSection; + cross_sections: CrossSection[]; +} + /** 종횡단 확정 결과 (SectionConfirmResponse) */ export interface SectionConfirmResponse { status: string; @@ -126,6 +159,16 @@ export async function getSections( }); } +/** 경로의 SVG 렌더링용 종단·횡단 원시 샘플을 조회한다. */ +export async function fetchSectionDetail( + projectId: string, + routeId: number, +): Promise { + return requestJson(`/projects/${projectId}/sections/${routeId}/detail`, { + method: "GET", + }); +} + /** 경로의 종·횡단면을 확정한다. */ export async function confirmSections( projectId: string, diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py index c72e0ed6..587799a4 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py @@ -27,11 +27,23 @@ def _validate_stage_path(relative_path: str) -> str: async def get_confirmed_route_context( connection: aiomysql.Connection, project_id: UUID ) -> dict[str, Any] | None: - """프로젝트의 최신 확정 경로와 연결된 지표면 좌표계를 조회한다.""" + """프로젝트의 최신 확정 경로와 연결된 지표면 좌표계를 조회한다. + + surface_models.crs_epsg가 NULL이면(분석에 사용한 입력 파일에 좌표계가 + 없던 경우) 같은 프로젝트 input_files의 감지된 좌표계로 폴백한다. + """ async with connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( """ - SELECT r.id AS route_id, sm.crs_epsg + SELECT r.id AS route_id, + COALESCE( + sm.crs_epsg, + (SELECT f.crs_epsg + FROM input_files f + WHERE f.project_id = r.project_id AND f.crs_epsg IS NOT NULL + ORDER BY f.id DESC + LIMIT 1) + ) AS crs_epsg FROM routes r LEFT JOIN surface_models sm ON sm.id = r.surface_model_id WHERE r.project_id = %s AND r.status = 'CONFIRMED' diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index 81bbb387..137043e8 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -1,6 +1,7 @@ """B06 종횡단 생성 FastAPI 라우터.""" import asyncio +import json import logging from pathlib import Path from uuid import UUID @@ -24,6 +25,7 @@ from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Repository import ( from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Schema import ( SectionConfirmResponse, SectionContextResponse, + SectionDetailResponse, SectionGenerateRequest, SectionGenerateResponse, SectionOptionDefaults, @@ -33,6 +35,7 @@ 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, fail_stage, start_stage from config.config_db import get_db_pool +from config.config_system import SECTION_VERTICAL_EXAGGERATION logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"]) @@ -175,6 +178,7 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON cross_half_width_m=defaults.cross_half_width_m, cross_sample_interval_m=defaults.cross_sample_interval_m, long_sample_interval_m=defaults.long_sample_interval_m, + vertical_exaggeration=SECTION_VERTICAL_EXAGGERATION, ), ) except Exception: @@ -210,6 +214,81 @@ async def get_sections(project_id: UUID, route_id: int) -> SectionSummaryRespons ) +def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dict: + """검증된 프로젝트 루트 안의 종단 및 횡단 JSON을 읽는다.""" + root = project_root.resolve() + longitudinal_path = (root / longitudinal_file_path).resolve() + if root not in longitudinal_path.parents: + raise ValueError("종단면 파일 경로가 프로젝트 저장소를 벗어났습니다.") + if not longitudinal_path.is_file(): + raise FileNotFoundError("종단면 상세 파일을 찾을 수 없습니다.") + + stage_root = longitudinal_path.parent.parent + cross_dir = stage_root / "cross_sections" + if not cross_dir.is_dir(): + raise FileNotFoundError("횡단면 상세 파일을 찾을 수 없습니다.") + + longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8")) + cross_sections = [ + json.loads(path.read_text(encoding="utf-8")) + for path in sorted(cross_dir.glob("cross_*.json")) + ] + if not isinstance(longitudinal, dict) or not all( + isinstance(section, dict) for section in cross_sections + ): + raise ValueError("종횡단 상세 파일 형식이 올바르지 않습니다.") + return {"longitudinal": longitudinal, "cross_sections": cross_sections} + + +@router.get( + "/{project_id}/sections/{route_id}/detail", response_model=SectionDetailResponse +) +async def get_section_detail( + project_id: UUID, route_id: int +) -> SectionDetailResponse | JSONResponse: + """경로의 SVG 렌더링용 종단·횡단 원시 샘플을 반환한다.""" + pool = get_db_pool() + try: + async with pool.acquire() as connection: + longitudinal = await get_longitudinal_section(connection, project_id, route_id) + if not longitudinal: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "종횡단 상세 결과가 없습니다."}, + ) + stored_path = await get_project_storage_relative_path(connection, project_id) + project_root = Path(resolve_stored_project_path(stored_path)) + detail = await asyncio.to_thread( + _read_section_detail, + project_root, + str(longitudinal["longitudinal_file_path"]), + ) + return SectionDetailResponse(**detail) + except FileNotFoundError as exc: + return JSONResponse( + status_code=404, content={"status": "error", "message": str(exc)} + ) + except (OSError, ValueError, json.JSONDecodeError) as exc: + logger.warning( + "B06 종횡단 상세 파일 조회 실패: project_id=%s route_id=%s error=%s", + project_id, + route_id, + exc, + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "종횡단 상세 파일을 읽지 못했습니다."}, + ) + except Exception: + logger.exception( + "B06 종횡단 상세 조회 실패: project_id=%s route_id=%s", project_id, route_id + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "종횡단 상세 조회 중 오류가 발생했습니다."}, + ) + + @router.post("/{project_id}/sections/{route_id}/confirm", response_model=SectionConfirmResponse) async def confirm_sections( project_id: UUID, route_id: int diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py index 425c42ea..5b8ea25d 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py @@ -51,6 +51,7 @@ class SectionOptionDefaults(BaseModel): cross_half_width_m: float cross_sample_interval_m: float long_sample_interval_m: float + vertical_exaggeration: float class SectionContextResponse(BaseModel): @@ -74,3 +75,10 @@ class SectionSummaryResponse(BaseModel): longitudinal: dict[str, Any] | None = None length_m: float | None = None cross_section_count: int = 0 + + +class SectionDetailResponse(BaseModel): + """종단·횡단 SVG 렌더링에 필요한 원시 샘플 데이터.""" + + longitudinal: dict[str, Any] + cross_sections: list[dict[str, Any]] diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index 3202b0ae..059ca52b 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -30,14 +30,17 @@ import { } from "../A00_Common/b_workflow_nav"; import { confirmSections, + fetchSectionDetail, fetchSectionContext, generateSections, getSections, type SectionContextResponse, + type SectionDetailResponse, type SectionGenerateRequest, type SectionGenerateResponse, type SectionSummaryResponse, } from "./B06_wf3_ProfileCross_Api_Fetch"; +import { createSectionView } from "./B06_wf3_ProfileCross_UI_Section_View"; import "./B06_wf3_ProfileCross_UI_Style.css"; /** locale 헬퍼 */ @@ -79,6 +82,7 @@ function parseNumber(value: string): number | null { export async function renderB06ProfileCross(root: HTMLElement): Promise { let currentRouteId: number | null = null; let sectionContext: SectionContextResponse | null = null; + let sectionDetail: SectionDetailResponse | null = null; /* ---- 좌측: 대상 경로 ---- */ const routeGroup = buildGroup(L("B06_Profile_Group_Route")); @@ -113,12 +117,19 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { label: L("B06_Profile_Field_LongSample"), type: "number", }); + const verticalExaggerationField = createInputField({ + label: L("B06_Profile_Field_VerticalExaggeration"), + type: "number", + }); + verticalExaggerationField.input.min = "0.1"; + verticalExaggerationField.input.step = "0.1"; optionGroup.append( stationField.root, halfWidthField.root, crossSampleField.root, longSampleField.root, + verticalExaggerationField.root, ); const generateButton = createButton({ @@ -200,6 +211,17 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const resultCard = document.createElement("div"); resultCard.className = "b06-profile__result"; resultCard.append(resultTitle, resultBody); + const sectionView = createSectionView(); + const mainContent = document.createElement("div"); + mainContent.className = "b06-profile__main"; + mainContent.append(resultCard, sectionView.root); + + verticalExaggerationField.input.addEventListener("input", () => { + const exaggeration = parseNumber(verticalExaggerationField.input.value); + if (sectionDetail && exaggeration !== null && exaggeration >= 0.1) { + sectionView.render(sectionDetail, exaggeration); + } + }); /* ---- 이벤트 핸들러 ---- */ function getProjectId(): string | null { @@ -208,6 +230,18 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { return projectId; } + async function loadSectionDetail(projectId: string, routeId: number): Promise { + try { + sectionDetail = await fetchSectionDetail(projectId, routeId); + sectionView.render(sectionDetail, parseNumber(verticalExaggerationField.input.value) ?? 1); + } catch (error) { + sectionDetail = null; + sectionView.clear(); + const detail = error instanceof Error ? ` ${error.message}` : ""; + showToast(`${L("B06_Profile_Detail_Failed")}${detail}`, "error"); + } + } + function buildGenerateRequest(): SectionGenerateRequest | null { if ( sectionContext?.route_id === null || @@ -241,6 +275,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const result = await generateSections(projectId, request); currentRouteId = result.route_id; renderResult(result); + await loadSectionDetail(projectId, result.route_id); + confirmButton.disabled = false; showToast(L("B06_Profile_Generate_Success"), "success"); return true; } catch (error) { @@ -278,7 +314,13 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { fetchSectionContext(projectId), fetchWorkflowState(projectId), ]); - if (contextResult.status === "fulfilled") sectionContext = contextResult.value; + if (contextResult.status === "fulfilled") { + sectionContext = contextResult.value; + } else { + const reason = contextResult.reason; + const detail = reason instanceof Error ? ` ${reason.message}` : ""; + showToast(`${L("B06_Profile_Context_Failed")}${detail}`, "error"); + } if (workflowResult.status === "fulfilled") workflowState = workflowResult.value; } @@ -287,7 +329,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { steps: workflowSteps(), activeStep: 3, leftPanel: leftForm, - mainContent: resultCard, + mainContent, stages: workflowState?.stages, currentStage: workflowState?.current_stage, routes: WORKFLOW_STEP_ROUTES, @@ -302,6 +344,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { if (!projectId || !sectionContext) { generateButton.disabled = true; confirmButton.disabled = true; + if (projectId) renderResultMessage(L("B06_Profile_Context_Failed")); return; } @@ -309,12 +352,18 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { routeIdInfo.value.textContent = context.route_id === null ? "-" : String(context.route_id); filterInfo.value.textContent = context.filter_key ?? "-"; methodInfo.value.textContent = context.method ?? "-"; - smoothInfo.value.textContent = context.smooth === null ? "-" : context.smooth ? "ON" : "OFF"; + smoothInfo.value.textContent = + context.smooth === null + ? "-" + : context.smooth + ? L("B06_Profile_Smooth_On") + : L("B06_Profile_Smooth_Off"); crsInfo.value.textContent = context.crs_epsg === null ? "-" : `EPSG:${context.crs_epsg}`; stationField.input.value = String(context.defaults.station_interval_m); halfWidthField.input.value = String(context.defaults.cross_half_width_m); crossSampleField.input.value = String(context.defaults.cross_sample_interval_m); longSampleField.input.value = String(context.defaults.long_sample_interval_m); + verticalExaggerationField.input.value = String(context.defaults.vertical_exaggeration); if (context.route_id === null) { generateButton.disabled = true; @@ -329,6 +378,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const existing = await getSections(projectId, context.route_id); if (existing.longitudinal) { renderSummary(existing); + await loadSectionDetail(projectId, context.route_id); confirmButton.disabled = false; return; } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts new file mode 100644 index 00000000..3d25c301 --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -0,0 +1,481 @@ +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import type { + CrossSection, + LongitudinalSection, + SectionDetailResponse, + SectionSample, +} from "./B06_wf3_ProfileCross_Api_Fetch"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +const LONG_WIDTH = 1200; +const LONG_HEIGHT = 310; +const CROSS_WIDTH = 560; +const CROSS_HEIGHT = 260; +const LONG_PAD = { left: 62, right: 24, top: 30, bottom: 52 }; +const CROSS_PAD = { left: 58, right: 20, top: 20, bottom: 52 }; + +interface YScaleOptions { + pixelsPerMeter: number; + globalMinElevation: number; + globalMaxElevation: number; +} + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +function svgElement( + tag: K, + attributes: Record = {}, +): SVGElementTagNameMap[K] { + const element = document.createElementNS(SVG_NS, tag); + Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value))); + return element; +} + +function svgText(value: string, attributes: Record): SVGTextElement { + const text = svgElement("text", attributes); + text.textContent = value; + return text; +} + +function validElevation(sample: SectionSample): sample is SectionSample & { elevation_m: number } { + return ( + sample.valid !== false && sample.elevation_m !== null && Number.isFinite(sample.elevation_m) + ); +} + +function calculateYScale(detail: SectionDetailResponse): YScaleOptions | undefined { + const elevations = [ + ...detail.longitudinal.samples.map((sample) => sample.elevation_m), + ...detail.cross_sections.flatMap((section) => + section.samples.map((sample) => sample.elevation_m), + ), + ].filter((value): value is number => typeof value === "number" && Number.isFinite(value)); + if (!elevations.length) return undefined; + const globalMinElevation = Math.min(...elevations); + const globalMaxElevation = Math.max(...elevations); + const plotHeight = LONG_HEIGHT - LONG_PAD.top - LONG_PAD.bottom; + return { + pixelsPerMeter: plotHeight / Math.max(globalMaxElevation - globalMinElevation, 1), + globalMinElevation, + globalMaxElevation, + }; +} + +function emptyView(message: string): HTMLElement { + const empty = document.createElement("div"); + empty.className = "b06-section__empty"; + empty.textContent = message; + return empty; +} + +export function createLongitudinalProfile( + data: LongitudinalSection, + selectedStationId: string | null, + verticalExaggeration: number, + yScaleOptions: YScaleOptions | undefined, + onSelectStation: (stationId: string) => void, +): HTMLElement { + const samples = data.samples.filter(validElevation); + if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal")); + + const wrapper = document.createElement("div"); + wrapper.className = "b06-section__chart-wrap"; + const svg = svgElement("svg", { + class: "b06-section__chart", + viewBox: `0 0 ${LONG_WIDTH} ${LONG_HEIGHT}`, + role: "img", + "aria-label": L("B06_Profile_View_Longitudinal"), + }); + svg.append( + svgElement("rect", { width: LONG_WIDTH, height: LONG_HEIGHT, class: "b06-chart__bg" }), + ); + + const maxChainage = Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1); + const elevations = samples.map((sample) => sample.elevation_m); + const rawMin = yScaleOptions?.globalMinElevation ?? Math.min(...elevations); + const rawMax = yScaleOptions?.globalMaxElevation ?? Math.max(...elevations); + const elevationMid = (rawMin + rawMax) / 2; + const exaggeration = Math.max(verticalExaggeration, 0.1); + const plotWidth = LONG_WIDTH - LONG_PAD.left - LONG_PAD.right; + const plotHeight = LONG_HEIGHT - LONG_PAD.top - LONG_PAD.bottom; + const elevationSpan = yScaleOptions + ? plotHeight / yScaleOptions.pixelsPerMeter + : Math.max(rawMax - rawMin, 1); + const x = (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth; + const y = (elevation: number) => + LONG_PAD.top + ((elevationMid + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight; + + for (const ratio of [0, 0.25, 0.5, 0.75, 1]) { + const gridY = LONG_PAD.top + ratio * plotHeight; + const displayed = elevationMid + elevationSpan / 2 - ratio * elevationSpan; + const rawValue = elevationMid + (displayed - elevationMid) / exaggeration; + svg.append( + svgElement("line", { + x1: LONG_PAD.left, + y1: gridY, + x2: LONG_WIDTH - LONG_PAD.right, + y2: gridY, + class: "b06-chart__grid", + }), + svgText(`${rawValue.toFixed(1)}m`, { + x: LONG_PAD.left - 9, + y: gridY + 4, + "text-anchor": "end", + class: "b06-chart__tick", + }), + ); + } + + for (const station of data.stations) { + const stationX = x(station.chainage_m); + const selected = station.station_id === selectedStationId; + const marker = svgElement("g", { + class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`, + tabindex: "0", + role: "button", + "aria-label": `${station.label} ${station.chainage_m.toFixed(1)}m`, + }); + marker.addEventListener("click", () => onSelectStation(station.station_id)); + marker.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") onSelectStation(station.station_id); + }); + marker.append( + svgElement("line", { + x1: stationX, + y1: LONG_PAD.top, + x2: stationX, + y2: LONG_HEIGHT - LONG_PAD.bottom + 8, + class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`, + }), + svgText(station.label, { + x: stationX, + y: LONG_HEIGHT - 23, + "text-anchor": "middle", + class: "b06-chart__station-label", + }), + ); + svg.append(marker); + } + + const points = samples + .map((sample) => { + const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration; + return `${x(sample.chainage_m ?? 0)},${y(elevated)}`; + }) + .join(" "); + svg.append( + svgElement("polyline", { points, class: "b06-chart__profile" }), + svgElement("line", { + x1: LONG_PAD.left, + y1: LONG_HEIGHT - LONG_PAD.bottom, + x2: LONG_WIDTH - LONG_PAD.right, + y2: LONG_HEIGHT - LONG_PAD.bottom, + class: "b06-chart__axis", + }), + svgElement("line", { + x1: LONG_PAD.left, + y1: LONG_PAD.top, + x2: LONG_PAD.left, + y2: LONG_HEIGHT - LONG_PAD.bottom, + class: "b06-chart__axis", + }), + svgText(L("B06_Profile_View_LongitudinalXAxis"), { + x: LONG_WIDTH / 2, + y: LONG_HEIGHT - 4, + "text-anchor": "middle", + class: "b06-chart__axis-label", + }), + svgText(L("B06_Profile_View_ElevationAxis"), { + x: 15, + y: LONG_HEIGHT / 2, + "text-anchor": "middle", + transform: `rotate(-90 15 ${LONG_HEIGHT / 2})`, + class: "b06-chart__axis-label", + }), + ); + wrapper.append(svg); + return wrapper; +} + +export function createCrossSectionCard( + section: CrossSection, + selected: boolean, + verticalExaggeration: number, + yScaleOptions: YScaleOptions | undefined, + onSelect: (stationId: string) => void, +): HTMLElement { + const card = document.createElement("article"); + card.id = `cross-${section.station_id}`; + card.className = `b06-cross-card${selected ? " b06-cross-card--selected" : ""}`; + card.tabIndex = 0; + card.addEventListener("click", () => onSelect(section.station_id)); + card.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") onSelect(section.station_id); + }); + + const header = document.createElement("header"); + const title = document.createElement("div"); + const label = document.createElement("strong"); + label.textContent = section.label; + const chainage = document.createElement("span"); + chainage.textContent = `${section.chainage_m.toFixed(1)}m`; + title.append(label, chainage); + const kind = document.createElement("span"); + kind.textContent = + section.kind === "ep" + ? L("B06_Profile_View_Kind_EP") + : section.kind === "bp" + ? L("B06_Profile_View_Kind_BP") + : L("B06_Profile_View_Kind_Station"); + header.append(title, kind); + card.append(header); + + const valid = section.samples.filter(validElevation); + if (!valid.length) { + card.append(emptyView(L("B06_Profile_View_NoCross"))); + } else { + const offsets = section.samples.map((sample) => sample.offset_m ?? 0); + const minOffset = Math.min(...offsets, -1); + const maxOffset = Math.max(...offsets, 1); + const elevations = valid.map((sample) => sample.elevation_m); + const rawMin = Math.min(...elevations); + const rawMax = Math.max(...elevations); + const elevationMid = (rawMin + rawMax) / 2; + const padding = rawMax > rawMin ? (rawMax - rawMin) * 0.08 : 0.5; + const exaggeration = Math.max(verticalExaggeration, 0.1); + const plotWidth = CROSS_WIDTH - CROSS_PAD.left - CROSS_PAD.right; + const plotHeight = CROSS_HEIGHT - CROSS_PAD.top - CROSS_PAD.bottom; + const displaySpan = yScaleOptions + ? plotHeight / yScaleOptions.pixelsPerMeter + : Math.max((rawMax - rawMin + padding * 2) * exaggeration, 1); + const displayMin = elevationMid - displaySpan / 2; + const displayMax = elevationMid + displaySpan / 2; + const x = (offset: number) => + CROSS_PAD.left + ((offset - minOffset) / Math.max(maxOffset - minOffset, 1)) * plotWidth; + const y = (elevation: number) => + CROSS_PAD.top + + ((displayMax - elevation) / Math.max(displayMax - displayMin, 1)) * plotHeight; + const svg = svgElement("svg", { + class: "b06-section__chart", + viewBox: `0 0 ${CROSS_WIDTH} ${CROSS_HEIGHT}`, + role: "img", + "aria-label": `${section.label} ${L("B06_Profile_View_Cross")}`, + }); + svg.append( + svgElement("rect", { width: CROSS_WIDTH, height: CROSS_HEIGHT, class: "b06-chart__bg" }), + ); + + const xTicks = Array.from( + { length: 7 }, + (_, index) => minOffset + ((maxOffset - minOffset) * index) / 6, + ); + for (const tick of xTicks) { + svg.append( + svgElement("line", { + x1: x(tick), + y1: CROSS_PAD.top, + x2: x(tick), + y2: CROSS_HEIGHT - CROSS_PAD.bottom, + class: "b06-chart__grid", + }), + svgText(Math.abs(tick) < 1e-6 ? "0" : tick.toFixed(0), { + x: x(tick), + y: CROSS_HEIGHT - CROSS_PAD.bottom + 16, + "text-anchor": "middle", + class: "b06-chart__tick", + }), + ); + } + const rawDisplaySpan = displaySpan / exaggeration; + for (let index = 0; index < 5; index += 1) { + const tick = elevationMid - rawDisplaySpan / 2 + (rawDisplaySpan * index) / 4; + const displayTick = elevationMid + (tick - elevationMid) * exaggeration; + svg.append( + svgElement("line", { + x1: CROSS_PAD.left, + y1: y(displayTick), + x2: CROSS_WIDTH - CROSS_PAD.right, + y2: y(displayTick), + class: "b06-chart__grid", + }), + svgText(tick.toFixed(1), { + x: CROSS_PAD.left - 7, + y: y(displayTick) + 3, + "text-anchor": "end", + class: "b06-chart__tick", + }), + ); + } + + const segments: string[] = []; + let current: string[] = []; + for (const sample of section.samples) { + if (!validElevation(sample)) { + if (current.length > 1) segments.push(current.join(" ")); + current = []; + continue; + } + const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration; + current.push(`${x(sample.offset_m ?? 0)},${y(elevated)}`); + } + if (current.length > 1) segments.push(current.join(" ")); + segments.forEach((points) => + svg.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })), + ); + + const centerSample = valid.reduce<(typeof valid)[number] | null>((nearest, sample) => { + if (!nearest || Math.abs(sample.offset_m ?? 0) < Math.abs(nearest.offset_m ?? 0)) + return sample; + return nearest; + }, null); + const centerX = x(0); + const centerY = centerSample + ? y(elevationMid + (centerSample.elevation_m - elevationMid) * exaggeration) + : CROSS_HEIGHT / 2; + svg.append( + svgElement("line", { + x1: CROSS_PAD.left, + y1: CROSS_HEIGHT - CROSS_PAD.bottom, + x2: CROSS_WIDTH - CROSS_PAD.right, + y2: CROSS_HEIGHT - CROSS_PAD.bottom, + class: "b06-chart__axis", + }), + svgElement("line", { + x1: CROSS_PAD.left, + y1: CROSS_PAD.top, + x2: CROSS_PAD.left, + y2: CROSS_HEIGHT - CROSS_PAD.bottom, + class: "b06-chart__axis", + }), + svgElement("line", { + x1: centerX, + y1: centerY - 18, + x2: centerX, + y2: centerY + 18, + class: "b06-chart__center-marker", + }), + svgElement("line", { + x1: centerX - 18, + y1: centerY, + x2: centerX + 18, + y2: centerY, + class: "b06-chart__center-marker", + }), + svgText(L("B06_Profile_View_CrossXAxis"), { + x: CROSS_WIDTH / 2, + y: CROSS_HEIGHT - 8, + "text-anchor": "middle", + class: "b06-chart__axis-label", + }), + svgText(L("B06_Profile_View_ElevationAxis"), { + x: 13, + y: CROSS_HEIGHT / 2, + "text-anchor": "middle", + transform: `rotate(-90 13 ${CROSS_HEIGHT / 2})`, + class: "b06-chart__axis-label", + }), + ); + card.append(svg); + } + + const footer = document.createElement("footer"); + const center = document.createElement("span"); + center.textContent = `${L("B06_Profile_View_CenterElevation")} ${section.center_z?.toFixed(2) ?? "-"}m`; + const azimuth = document.createElement("span"); + azimuth.textContent = `${L("B06_Profile_View_Azimuth")} ${section.azimuth_deg?.toFixed(1) ?? "-"}°`; + footer.append(center, azimuth); + card.append(footer); + return card; +} + +export interface SectionViewController { + root: HTMLElement; + render: (detail: SectionDetailResponse, verticalExaggeration: number) => void; + clear: () => void; +} + +export function createSectionView(): SectionViewController { + const root = document.createElement("div"); + root.className = "b06-section"; + let currentDetail: SectionDetailResponse | null = null; + let selectedStationId: string | null = null; + let currentExaggeration = 1; + + const draw = (): void => { + root.replaceChildren(); + if (!currentDetail) return; + const detail = currentDetail; + const yScale = calculateYScale(detail); + const selectStation = (stationId: string, scroll: boolean): void => { + selectedStationId = stationId; + draw(); + if (scroll) { + document + .getElementById(`cross-${stationId}`) + ?.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }; + + const longitudinalPanel = document.createElement("section"); + longitudinalPanel.className = "b06-section__panel"; + const longitudinalHeader = document.createElement("header"); + const longitudinalTitle = document.createElement("h3"); + longitudinalTitle.textContent = L("B06_Profile_View_Longitudinal"); + const stationCount = document.createElement("span"); + stationCount.textContent = `${L("B06_Profile_View_StationCount")} ${detail.longitudinal.stations.length}`; + longitudinalHeader.append(longitudinalTitle, stationCount); + longitudinalPanel.append( + longitudinalHeader, + createLongitudinalProfile( + detail.longitudinal, + selectedStationId, + currentExaggeration, + yScale, + (stationId) => selectStation(stationId, true), + ), + ); + + const crossHeading = document.createElement("div"); + crossHeading.className = "b06-section__heading"; + const crossTitle = document.createElement("h3"); + crossTitle.textContent = L("B06_Profile_View_Cross"); + const crossCount = document.createElement("span"); + crossCount.textContent = `${detail.cross_sections.length}${L("B06_Profile_View_CrossCountSuffix")}`; + crossHeading.append(crossTitle, crossCount); + const grid = document.createElement("div"); + grid.className = "b06-section__grid"; + if (detail.cross_sections.length) { + detail.cross_sections.forEach((section) => + grid.append( + createCrossSectionCard( + section, + section.station_id === selectedStationId, + currentExaggeration, + yScale, + (stationId) => selectStation(stationId, false), + ), + ), + ); + } else { + grid.append(emptyView(L("B06_Profile_View_NoCross"))); + } + root.append(longitudinalPanel, crossHeading, grid); + }; + + return { + root, + render(detail, verticalExaggeration) { + currentDetail = detail; + currentExaggeration = Math.max(verticalExaggeration, 0.1); + selectedStationId ??= detail.longitudinal.stations[0]?.station_id ?? null; + draw(); + }, + clear() { + currentDetail = null; + selectedStationId = null; + root.replaceChildren(); + }, + }; +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css index ec205fa6..2deee323 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css @@ -104,3 +104,196 @@ word-break: break-all; text-align: right; } + +/* --- 종·횡단 도면 --- */ +.b06-profile__main, +.b06-section { + display: flex; + flex-direction: column; + gap: var(--spacing-24); + min-width: 0; +} + +.b06-section__panel, +.b06-cross-card { + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); +} + +.b06-section__panel > header, +.b06-cross-card > header, +.b06-cross-card > footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-16); + background: var(--color-surface); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b06-section__panel > header { + border-bottom: 1px solid var(--color-border); +} + +.b06-section__panel > header h3, +.b06-section__heading h3 { + font-size: var(--text-body); +} + +.b06-section__chart-wrap { + width: 100%; + overflow-x: auto; +} + +.b06-section__chart { + display: block; + width: 100%; + min-width: 520px; + height: auto; +} + +.b06-section__heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: var(--spacing-16); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b06-section__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-16); +} + +.b06-cross-card { + cursor: pointer; + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast); +} + +.b06-cross-card:hover { + border-color: var(--color-text-muted); + box-shadow: var(--shadow-sm); +} + +.b06-cross-card--selected { + border-color: var(--color-danger); + box-shadow: 0 0 0 2px var(--color-danger); +} + +.b06-cross-card > header { + border-bottom: 1px solid var(--color-border); +} + +.b06-cross-card > header div { + display: flex; + align-items: baseline; + gap: var(--spacing-8); +} + +.b06-cross-card > header strong { + color: var(--color-text); + font-size: var(--text-body-sm); +} + +.b06-cross-card > footer { + border-top: 1px solid var(--color-border); +} + +.b06-section__empty { + display: flex; + min-height: 180px; + align-items: center; + justify-content: center; + padding: var(--spacing-24); + border: 1px dashed var(--color-text-muted); + color: var(--color-text-muted); + background: var(--color-surface); +} + +/* SVG 차트 색상은 테마 토큰만 사용한다. */ +.b06-chart__bg { + fill: var(--color-surface-raised); +} + +.b06-chart__grid { + stroke: var(--color-border); + stroke-width: 1; +} + +.b06-chart__axis { + stroke: var(--color-text-secondary); + stroke-width: 1; +} + +.b06-chart__tick, +.b06-chart__station-label, +.b06-chart__axis-label { + fill: var(--color-text-secondary); + font-family: var(--font-body); +} + +.b06-chart__tick, +.b06-chart__station-label { + font-size: 10px; +} + +.b06-chart__axis-label { + fill: var(--color-text-body); + font-size: var(--text-caption); + font-weight: var(--font-weight-medium); +} + +.b06-chart__profile { + fill: none; + stroke: var(--color-chart-0); + stroke-width: 2.5; +} + +.b06-chart__cross-profile { + fill: none; + stroke: var(--color-chart-1); + stroke-width: 2.4; +} + +.b06-chart__station { + cursor: pointer; +} + +.b06-chart__station-line { + stroke-width: 1.2; +} + +.b06-chart__station-line--bp, +.b06-chart__station-line--regular { + stroke: var(--color-warning); +} + +.b06-chart__station-line--ep { + stroke: var(--color-accent); + stroke-dasharray: 4 3; +} + +.b06-chart__station-line--selected, +.b06-chart__center-marker { + stroke: var(--color-danger); + stroke-width: 2.5; +} + +.b06-chart__station--selected .b06-chart__station-label { + fill: var(--color-danger); + font-weight: var(--font-weight-bold); +} + +@media (max-width: 900px) { + .b06-section__grid { + grid-template-columns: 1fr; + } +} diff --git a/config/config_system.py b/config/config_system.py index e870fa96..d8cd3f55 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -239,6 +239,7 @@ SECTION_STATION_INTERVAL_M = float(os.getenv("SECTION_STATION_INTERVAL_M", "20.0 SECTION_CROSS_HALF_WIDTH_M = float(os.getenv("SECTION_CROSS_HALF_WIDTH_M", "15.0")) SECTION_CROSS_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_CROSS_SAMPLE_INTERVAL_M", "0.5")) SECTION_LONG_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_LONG_SAMPLE_INTERVAL_M", "1.0")) +SECTION_VERTICAL_EXAGGERATION = float(os.getenv("SECTION_VERTICAL_EXAGGERATION", "1.0")) SECTION_INCLUDE_ENDPOINT = os.getenv("SECTION_INCLUDE_ENDPOINT", "True").lower() == "true" diff --git a/graphify-out/manifest.json b/graphify-out/manifest.json index 9eb7cbbd..255689d0 100644 --- a/graphify-out/manifest.json +++ b/graphify-out/manifest.json @@ -860,8 +860,8 @@ "semantic_hash": "" }, "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts": { - "mtime": 1784375574.3483717, - "ast_hash": "c963ea01b4e47fda7288da2466413faa", + "mtime": 1784379630.3386846, + "ast_hash": "52c3a9ee9585954231e15ae041b8f06b", "semantic_hash": "" }, "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine.py": { @@ -880,23 +880,23 @@ "semantic_hash": "" }, "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Repository.py": { - "mtime": 1784375574.350428, - "ast_hash": "f6ecffc9f54db3e1ea7fa682551c134b", + "mtime": 1784380743.010188, + "ast_hash": "54e86ecb1802a13476f93b41085fe3d9", "semantic_hash": "" }, "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py": { - "mtime": 1784375574.3514285, - "ast_hash": "7d31dd3ca17998ee0d05d53fd16c8a7b", + "mtime": 1784379682.786804, + "ast_hash": "e05a792607a34e36ad14deaadfcef175", "semantic_hash": "" }, "B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py": { - "mtime": 1784375574.3534284, - "ast_hash": "be78faf37c307783ffc32cdaa3332a46", + "mtime": 1784379656.886007, + "ast_hash": "12de528aa1281f841c583435305bee92", "semantic_hash": "" }, "B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts": { - "mtime": 1784375574.3549345, - "ast_hash": "c6902c6082e97f2e794223b03f2dfba6", + "mtime": 1784380760.2568946, + "ast_hash": "0bd7cd13aa999ac1b74481359b3d8da9", "semantic_hash": "" }, "B07_wf4_DesignDetail/B07_wf4_DesignDetail_UI_Page.ts": { @@ -995,8 +995,8 @@ "semantic_hash": "" }, "config/config_system.py": { - "mtime": 1784371862.7494447, - "ast_hash": "5aa2b09d55040942e73976a11ee8249a", + "mtime": 1784377093.9173894, + "ast_hash": "f4a83c9879f6d2bf6d9b3a84a05b5fd2", "semantic_hash": "" }, "db_management/001_create_schema.sql": { @@ -1050,8 +1050,8 @@ "semantic_hash": "" }, "main.py": { - "mtime": 1784357162.0, - "ast_hash": "944ef36ad7b18e4180a1f49413a22cbd", + "mtime": 1784377236.4697044, + "ast_hash": "4852786a0f34ac197fc45b009a5ef34f", "semantic_hash": "" }, "migrations/001_create_upload_tables.sql": { @@ -1060,8 +1060,8 @@ "semantic_hash": "" }, "package.json": { - "mtime": 1783417102.0, - "ast_hash": "21958685f75d3d89d497cf8da68ee15d", + "mtime": 1784377181.9267156, + "ast_hash": "5143268e06d1faeaa7e9e1acb7ef4b20", "semantic_hash": "" }, "pyproject.toml": { @@ -1075,13 +1075,13 @@ "semantic_hash": "" }, "scratch/wiki_linter.py": { - "mtime": 1784371862.7568798, - "ast_hash": "ef1b873d05b130e30dd461cced7d62c1", + "mtime": 1784376278.8101668, + "ast_hash": "76c8805b5ade82b52388c8e58af69a7b", "semantic_hash": "" }, "tsconfig.json": { - "mtime": 1783417261.0, - "ast_hash": "36f7ffbea3824d3bd1a9f8900244e806", + "mtime": 1784377132.7051294, + "ast_hash": "b7eaa646b12fd1e7f38bfd68d0befe96", "semantic_hash": "" }, "ui_template/ui_template_elements.ts": { @@ -1100,8 +1100,8 @@ "semantic_hash": "" }, "ui_template/ui_template_locale.ts": { - "mtime": 1784375574.3572495, - "ast_hash": "e1934d1e709bc56259272166b9e2aca5", + "mtime": 1784380749.5154142, + "ast_hash": "3559a748646ef02c8fbf3fe2002ff99d", "semantic_hash": "" }, "ui_template/ui_template_overlay.ts": { @@ -1115,8 +1115,8 @@ "semantic_hash": "" }, "vite.config.ts": { - "mtime": 1783419145.0, - "ast_hash": "ca2c8f6b3896b7d4636fcf147005c153", + "mtime": 1784377182.994571, + "ast_hash": "50c1973601cae4e83c36c6cbd9e67203", "semantic_hash": "" }, "A00_Common/index.html": { diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index b2553bb6..f428cb33 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -738,11 +738,18 @@ export const ui_locales = { B06_Profile_Field_CrossHalfWidth: ["횡단 반폭(m)", "Cross half-width (m)"], B06_Profile_Field_CrossSample: ["횡단 샘플 간격(m)", "Cross sample interval (m)"], B06_Profile_Field_LongSample: ["종단 샘플 간격(m)", "Long sample interval (m)"], + B06_Profile_Field_VerticalExaggeration: ["높이 배율", "Vertical exaggeration"], B06_Profile_Field_Smooth: ["지표면 스무딩", "Smooth surface"], + B06_Profile_Smooth_On: ["사용", "On"], + B06_Profile_Smooth_Off: ["미사용", "Off"], B06_Profile_Btn_Generate: ["종·횡단 재생성", "Regenerate Sections"], B06_Profile_Btn_Confirm: ["종·횡단 확정", "Confirm Sections"], B06_Profile_Result_Title: ["종·횡단 생성 결과", "Section Result"], B06_Profile_Result_Empty: ["아직 생성된 종·횡단이 없습니다.", "No sections generated yet."], + B06_Profile_Context_Failed: [ + "경로 정보를 불러오지 못했습니다. 서버 상태를 확인하세요.", + "Failed to load route context. Check the server status.", + ], B06_Profile_No_Confirmed_Route: [ "확정된 경로가 없습니다. 먼저 경로를 확정하세요.", "No confirmed route. Confirm a route first.", @@ -761,6 +768,33 @@ export const ui_locales = { B06_Profile_Generate_Failed: ["종·횡단 생성에 실패했습니다.", "Section generation failed."], B06_Profile_Confirm_Success: ["종·횡단을 확정했습니다.", "Sections confirmed."], B06_Profile_Confirm_Failed: ["종·횡단 확정에 실패했습니다.", "Section confirm failed."], + B06_Profile_Detail_Failed: [ + "종·횡단 도면 데이터를 불러오지 못했습니다.", + "Failed to load section drawing data.", + ], + B06_Profile_View_Longitudinal: ["종단면도", "Longitudinal profile"], + B06_Profile_View_Cross: ["횡단면도", "Cross sections"], + B06_Profile_View_StationCount: ["횡단 측점", "Cross stations"], + B06_Profile_View_CrossCountSuffix: ["개 도면", " drawings"], + B06_Profile_View_LongitudinalXAxis: [ + "BP 기준 누적거리 (횡단 측점)", + "Chainage from BP (cross stations)", + ], + B06_Profile_View_CrossXAxis: ["중심선 기준 편거리 (m)", "Offset from centerline (m)"], + B06_Profile_View_ElevationAxis: ["지반고 (m)", "Elevation (m)"], + B06_Profile_View_CenterElevation: ["중심고", "Center elevation"], + B06_Profile_View_Azimuth: ["방위각", "Azimuth"], + B06_Profile_View_Kind_BP: ["BP", "BP"], + B06_Profile_View_Kind_EP: ["EP", "EP"], + B06_Profile_View_Kind_Station: ["일반 측점", "Station"], + B06_Profile_View_NoLongitudinal: [ + "표시할 종단면 데이터가 없습니다.", + "No longitudinal profile data to display.", + ], + B06_Profile_View_NoCross: [ + "표시할 횡단면 데이터가 없습니다.", + "No cross-section data to display.", + ], /* --- B07_wf4_DesignDetail 상세 설계 --- */ B07_Design_Title: ["4차 · 상세 설계", "Step 4 · Detailed Design"],