B06_횡단종단 생성 초안 완료

This commit is contained in:
2026-07-18 22:51:44 +09:00
parent 0589078054
commit 7e51cf4f14
10 changed files with 930 additions and 29 deletions
@@ -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<SectionDetailResponse> {
return requestJson<SectionDetailResponse>(`/projects/${projectId}/sections/${routeId}/detail`, {
method: "GET",
});
}
/** 경로의 종·횡단면을 확정한다. */
export async function confirmSections(
projectId: string,
@@ -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'
@@ -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
@@ -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]]
@@ -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<void> {
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<void> {
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<void> {
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<void> {
return projectId;
}
async function loadSectionDetail(projectId: string, routeId: number): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const existing = await getSections(projectId, context.route_id);
if (existing.longitudinal) {
renderSummary(existing);
await loadSectionDetail(projectId, context.route_id);
confirmButton.disabled = false;
return;
}
@@ -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<K extends keyof SVGElementTagNameMap>(
tag: K,
attributes: Record<string, string | number> = {},
): SVGElementTagNameMap[K] {
const element = document.createElementNS(SVG_NS, tag);
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value)));
return element;
}
function svgText(value: string, attributes: Record<string, string | number>): SVGTextElement {
const text = svgElement("text", attributes);
text.textContent = value;
return text;
}
function validElevation(sample: SectionSample): sample is SectionSample & { elevation_m: number } {
return (
sample.valid !== false && sample.elevation_m !== null && Number.isFinite(sample.elevation_m)
);
}
function calculateYScale(detail: SectionDetailResponse): YScaleOptions | undefined {
const elevations = [
...detail.longitudinal.samples.map((sample) => sample.elevation_m),
...detail.cross_sections.flatMap((section) =>
section.samples.map((sample) => sample.elevation_m),
),
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
if (!elevations.length) return undefined;
const globalMinElevation = Math.min(...elevations);
const globalMaxElevation = Math.max(...elevations);
const plotHeight = LONG_HEIGHT - LONG_PAD.top - LONG_PAD.bottom;
return {
pixelsPerMeter: plotHeight / Math.max(globalMaxElevation - globalMinElevation, 1),
globalMinElevation,
globalMaxElevation,
};
}
function emptyView(message: string): HTMLElement {
const empty = document.createElement("div");
empty.className = "b06-section__empty";
empty.textContent = message;
return empty;
}
export function createLongitudinalProfile(
data: LongitudinalSection,
selectedStationId: string | null,
verticalExaggeration: number,
yScaleOptions: YScaleOptions | undefined,
onSelectStation: (stationId: string) => void,
): HTMLElement {
const samples = data.samples.filter(validElevation);
if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal"));
const wrapper = document.createElement("div");
wrapper.className = "b06-section__chart-wrap";
const svg = svgElement("svg", {
class: "b06-section__chart",
viewBox: `0 0 ${LONG_WIDTH} ${LONG_HEIGHT}`,
role: "img",
"aria-label": L("B06_Profile_View_Longitudinal"),
});
svg.append(
svgElement("rect", { width: LONG_WIDTH, height: LONG_HEIGHT, class: "b06-chart__bg" }),
);
const maxChainage = Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1);
const elevations = samples.map((sample) => sample.elevation_m);
const rawMin = yScaleOptions?.globalMinElevation ?? Math.min(...elevations);
const rawMax = yScaleOptions?.globalMaxElevation ?? Math.max(...elevations);
const elevationMid = (rawMin + rawMax) / 2;
const exaggeration = Math.max(verticalExaggeration, 0.1);
const plotWidth = LONG_WIDTH - LONG_PAD.left - LONG_PAD.right;
const plotHeight = LONG_HEIGHT - LONG_PAD.top - LONG_PAD.bottom;
const elevationSpan = yScaleOptions
? plotHeight / yScaleOptions.pixelsPerMeter
: Math.max(rawMax - rawMin, 1);
const x = (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth;
const y = (elevation: number) =>
LONG_PAD.top + ((elevationMid + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight;
for (const ratio of [0, 0.25, 0.5, 0.75, 1]) {
const gridY = LONG_PAD.top + ratio * plotHeight;
const displayed = elevationMid + elevationSpan / 2 - ratio * elevationSpan;
const rawValue = elevationMid + (displayed - elevationMid) / exaggeration;
svg.append(
svgElement("line", {
x1: LONG_PAD.left,
y1: gridY,
x2: LONG_WIDTH - LONG_PAD.right,
y2: gridY,
class: "b06-chart__grid",
}),
svgText(`${rawValue.toFixed(1)}m`, {
x: LONG_PAD.left - 9,
y: gridY + 4,
"text-anchor": "end",
class: "b06-chart__tick",
}),
);
}
for (const station of data.stations) {
const stationX = x(station.chainage_m);
const selected = station.station_id === selectedStationId;
const marker = svgElement("g", {
class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`,
tabindex: "0",
role: "button",
"aria-label": `${station.label} ${station.chainage_m.toFixed(1)}m`,
});
marker.addEventListener("click", () => onSelectStation(station.station_id));
marker.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") onSelectStation(station.station_id);
});
marker.append(
svgElement("line", {
x1: stationX,
y1: LONG_PAD.top,
x2: stationX,
y2: LONG_HEIGHT - LONG_PAD.bottom + 8,
class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`,
}),
svgText(station.label, {
x: stationX,
y: LONG_HEIGHT - 23,
"text-anchor": "middle",
class: "b06-chart__station-label",
}),
);
svg.append(marker);
}
const points = samples
.map((sample) => {
const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration;
return `${x(sample.chainage_m ?? 0)},${y(elevated)}`;
})
.join(" ");
svg.append(
svgElement("polyline", { points, class: "b06-chart__profile" }),
svgElement("line", {
x1: LONG_PAD.left,
y1: LONG_HEIGHT - LONG_PAD.bottom,
x2: LONG_WIDTH - LONG_PAD.right,
y2: LONG_HEIGHT - LONG_PAD.bottom,
class: "b06-chart__axis",
}),
svgElement("line", {
x1: LONG_PAD.left,
y1: LONG_PAD.top,
x2: LONG_PAD.left,
y2: LONG_HEIGHT - LONG_PAD.bottom,
class: "b06-chart__axis",
}),
svgText(L("B06_Profile_View_LongitudinalXAxis"), {
x: LONG_WIDTH / 2,
y: LONG_HEIGHT - 4,
"text-anchor": "middle",
class: "b06-chart__axis-label",
}),
svgText(L("B06_Profile_View_ElevationAxis"), {
x: 15,
y: LONG_HEIGHT / 2,
"text-anchor": "middle",
transform: `rotate(-90 15 ${LONG_HEIGHT / 2})`,
class: "b06-chart__axis-label",
}),
);
wrapper.append(svg);
return wrapper;
}
export function createCrossSectionCard(
section: CrossSection,
selected: boolean,
verticalExaggeration: number,
yScaleOptions: YScaleOptions | undefined,
onSelect: (stationId: string) => void,
): HTMLElement {
const card = document.createElement("article");
card.id = `cross-${section.station_id}`;
card.className = `b06-cross-card${selected ? " b06-cross-card--selected" : ""}`;
card.tabIndex = 0;
card.addEventListener("click", () => onSelect(section.station_id));
card.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") onSelect(section.station_id);
});
const header = document.createElement("header");
const title = document.createElement("div");
const label = document.createElement("strong");
label.textContent = section.label;
const chainage = document.createElement("span");
chainage.textContent = `${section.chainage_m.toFixed(1)}m`;
title.append(label, chainage);
const kind = document.createElement("span");
kind.textContent =
section.kind === "ep"
? L("B06_Profile_View_Kind_EP")
: section.kind === "bp"
? L("B06_Profile_View_Kind_BP")
: L("B06_Profile_View_Kind_Station");
header.append(title, kind);
card.append(header);
const valid = section.samples.filter(validElevation);
if (!valid.length) {
card.append(emptyView(L("B06_Profile_View_NoCross")));
} else {
const offsets = section.samples.map((sample) => sample.offset_m ?? 0);
const minOffset = Math.min(...offsets, -1);
const maxOffset = Math.max(...offsets, 1);
const elevations = valid.map((sample) => sample.elevation_m);
const rawMin = Math.min(...elevations);
const rawMax = Math.max(...elevations);
const elevationMid = (rawMin + rawMax) / 2;
const padding = rawMax > rawMin ? (rawMax - rawMin) * 0.08 : 0.5;
const exaggeration = Math.max(verticalExaggeration, 0.1);
const plotWidth = CROSS_WIDTH - CROSS_PAD.left - CROSS_PAD.right;
const plotHeight = CROSS_HEIGHT - CROSS_PAD.top - CROSS_PAD.bottom;
const displaySpan = yScaleOptions
? plotHeight / yScaleOptions.pixelsPerMeter
: Math.max((rawMax - rawMin + padding * 2) * exaggeration, 1);
const displayMin = elevationMid - displaySpan / 2;
const displayMax = elevationMid + displaySpan / 2;
const x = (offset: number) =>
CROSS_PAD.left + ((offset - minOffset) / Math.max(maxOffset - minOffset, 1)) * plotWidth;
const y = (elevation: number) =>
CROSS_PAD.top +
((displayMax - elevation) / Math.max(displayMax - displayMin, 1)) * plotHeight;
const svg = svgElement("svg", {
class: "b06-section__chart",
viewBox: `0 0 ${CROSS_WIDTH} ${CROSS_HEIGHT}`,
role: "img",
"aria-label": `${section.label} ${L("B06_Profile_View_Cross")}`,
});
svg.append(
svgElement("rect", { width: CROSS_WIDTH, height: CROSS_HEIGHT, class: "b06-chart__bg" }),
);
const xTicks = Array.from(
{ length: 7 },
(_, index) => minOffset + ((maxOffset - minOffset) * index) / 6,
);
for (const tick of xTicks) {
svg.append(
svgElement("line", {
x1: x(tick),
y1: CROSS_PAD.top,
x2: x(tick),
y2: CROSS_HEIGHT - CROSS_PAD.bottom,
class: "b06-chart__grid",
}),
svgText(Math.abs(tick) < 1e-6 ? "0" : tick.toFixed(0), {
x: x(tick),
y: CROSS_HEIGHT - CROSS_PAD.bottom + 16,
"text-anchor": "middle",
class: "b06-chart__tick",
}),
);
}
const rawDisplaySpan = displaySpan / exaggeration;
for (let index = 0; index < 5; index += 1) {
const tick = elevationMid - rawDisplaySpan / 2 + (rawDisplaySpan * index) / 4;
const displayTick = elevationMid + (tick - elevationMid) * exaggeration;
svg.append(
svgElement("line", {
x1: CROSS_PAD.left,
y1: y(displayTick),
x2: CROSS_WIDTH - CROSS_PAD.right,
y2: y(displayTick),
class: "b06-chart__grid",
}),
svgText(tick.toFixed(1), {
x: CROSS_PAD.left - 7,
y: y(displayTick) + 3,
"text-anchor": "end",
class: "b06-chart__tick",
}),
);
}
const segments: string[] = [];
let current: string[] = [];
for (const sample of section.samples) {
if (!validElevation(sample)) {
if (current.length > 1) segments.push(current.join(" "));
current = [];
continue;
}
const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration;
current.push(`${x(sample.offset_m ?? 0)},${y(elevated)}`);
}
if (current.length > 1) segments.push(current.join(" "));
segments.forEach((points) =>
svg.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })),
);
const centerSample = valid.reduce<(typeof valid)[number] | null>((nearest, sample) => {
if (!nearest || Math.abs(sample.offset_m ?? 0) < Math.abs(nearest.offset_m ?? 0))
return sample;
return nearest;
}, null);
const centerX = x(0);
const centerY = centerSample
? y(elevationMid + (centerSample.elevation_m - elevationMid) * exaggeration)
: CROSS_HEIGHT / 2;
svg.append(
svgElement("line", {
x1: CROSS_PAD.left,
y1: CROSS_HEIGHT - CROSS_PAD.bottom,
x2: CROSS_WIDTH - CROSS_PAD.right,
y2: CROSS_HEIGHT - CROSS_PAD.bottom,
class: "b06-chart__axis",
}),
svgElement("line", {
x1: CROSS_PAD.left,
y1: CROSS_PAD.top,
x2: CROSS_PAD.left,
y2: CROSS_HEIGHT - CROSS_PAD.bottom,
class: "b06-chart__axis",
}),
svgElement("line", {
x1: centerX,
y1: centerY - 18,
x2: centerX,
y2: centerY + 18,
class: "b06-chart__center-marker",
}),
svgElement("line", {
x1: centerX - 18,
y1: centerY,
x2: centerX + 18,
y2: centerY,
class: "b06-chart__center-marker",
}),
svgText(L("B06_Profile_View_CrossXAxis"), {
x: CROSS_WIDTH / 2,
y: CROSS_HEIGHT - 8,
"text-anchor": "middle",
class: "b06-chart__axis-label",
}),
svgText(L("B06_Profile_View_ElevationAxis"), {
x: 13,
y: CROSS_HEIGHT / 2,
"text-anchor": "middle",
transform: `rotate(-90 13 ${CROSS_HEIGHT / 2})`,
class: "b06-chart__axis-label",
}),
);
card.append(svg);
}
const footer = document.createElement("footer");
const center = document.createElement("span");
center.textContent = `${L("B06_Profile_View_CenterElevation")} ${section.center_z?.toFixed(2) ?? "-"}m`;
const azimuth = document.createElement("span");
azimuth.textContent = `${L("B06_Profile_View_Azimuth")} ${section.azimuth_deg?.toFixed(1) ?? "-"}°`;
footer.append(center, azimuth);
card.append(footer);
return card;
}
export interface SectionViewController {
root: HTMLElement;
render: (detail: SectionDetailResponse, verticalExaggeration: number) => void;
clear: () => void;
}
export function createSectionView(): SectionViewController {
const root = document.createElement("div");
root.className = "b06-section";
let currentDetail: SectionDetailResponse | null = null;
let selectedStationId: string | null = null;
let currentExaggeration = 1;
const draw = (): void => {
root.replaceChildren();
if (!currentDetail) return;
const detail = currentDetail;
const yScale = calculateYScale(detail);
const selectStation = (stationId: string, scroll: boolean): void => {
selectedStationId = stationId;
draw();
if (scroll) {
document
.getElementById(`cross-${stationId}`)
?.scrollIntoView({ behavior: "smooth", block: "center" });
}
};
const longitudinalPanel = document.createElement("section");
longitudinalPanel.className = "b06-section__panel";
const longitudinalHeader = document.createElement("header");
const longitudinalTitle = document.createElement("h3");
longitudinalTitle.textContent = L("B06_Profile_View_Longitudinal");
const stationCount = document.createElement("span");
stationCount.textContent = `${L("B06_Profile_View_StationCount")} ${detail.longitudinal.stations.length}`;
longitudinalHeader.append(longitudinalTitle, stationCount);
longitudinalPanel.append(
longitudinalHeader,
createLongitudinalProfile(
detail.longitudinal,
selectedStationId,
currentExaggeration,
yScale,
(stationId) => selectStation(stationId, true),
),
);
const crossHeading = document.createElement("div");
crossHeading.className = "b06-section__heading";
const crossTitle = document.createElement("h3");
crossTitle.textContent = L("B06_Profile_View_Cross");
const crossCount = document.createElement("span");
crossCount.textContent = `${detail.cross_sections.length}${L("B06_Profile_View_CrossCountSuffix")}`;
crossHeading.append(crossTitle, crossCount);
const grid = document.createElement("div");
grid.className = "b06-section__grid";
if (detail.cross_sections.length) {
detail.cross_sections.forEach((section) =>
grid.append(
createCrossSectionCard(
section,
section.station_id === selectedStationId,
currentExaggeration,
yScale,
(stationId) => selectStation(stationId, false),
),
),
);
} else {
grid.append(emptyView(L("B06_Profile_View_NoCross")));
}
root.append(longitudinalPanel, crossHeading, grid);
};
return {
root,
render(detail, verticalExaggeration) {
currentDetail = detail;
currentExaggeration = Math.max(verticalExaggeration, 0.1);
selectedStationId ??= detail.longitudinal.stations[0]?.station_id ?? null;
draw();
},
clear() {
currentDetail = null;
selectedStationId = null;
root.replaceChildren();
},
};
}
@@ -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;
}
}
+1
View File
@@ -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"
+24 -24
View File
@@ -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": {
+34
View File
@@ -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"],