feat(B05): 측점 눈금을 누르면 그 측점 횡단 미리보기 창

- 원지반 횡단선·기본 계획 횡단선·성토사면 길이 셋만 보임, 구조물은 안 그림 (계획서 0-9 ⑧)
- 서버 `POST /route/cross-preview` 가 `generate_sections`+`compute_cross_design` 정본을 재사용
- 계획고는 편집 중에 없어 그 측점 지반고를 그대로 놓음(지반 추종) — 창에 그렇게 적음
- 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths` 를 그대로 부름
- 700줄 규정에 맞춰 곡선 패널 배선을 `_CurveBar.ts` 로 분리

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX
This commit is contained in:
2026-09-12 14:39:35 +09:00
co-authored by Claude Opus 5
parent d02f7cf6e2
commit 332948694e
7 changed files with 705 additions and 87 deletions
+38
View File
@@ -148,6 +148,44 @@ export async function fetchRouteElevations(
return payload.z;
}
/** 횡단 미리보기 한 장 — 고치던 노선 그대로 그 측점만 서버가 셈해 준다(계획서 0-9 ⑧). */
export interface CrossPreviewResponse {
status: string;
chainage_m: number;
label: string | null;
uphill_side: string | null;
plan_radius_m: number | null;
curve_widening_m: number | null;
/** 원지반 횡단 샘플. */
samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>;
/** 기본 계획 횡단 — B06 `compute_cross_design` 이 낸 것. 계획고를 못 세우면 null. */
design: {
design_line: Array<{ offset_m: number; elevation_m: number }>;
cut_area_m2: number;
fill_area_m2: number;
[key: string]: unknown;
} | null;
}
export interface CrossPreviewRequest {
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
chainage_m: number;
min_radius_m: number;
station_interval_m: number;
}
/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */
export async function fetchCrossPreview(
projectId: string,
request: CrossPreviewRequest,
): Promise<CrossPreviewResponse> {
return requestJson<CrossPreviewResponse>(
`/projects/${projectId}/route/cross-preview`,
{ method: "POST", body: JSON.stringify(request) },
120000,
);
}
/** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */
export async function resetRoutePlan(projectId: string): Promise<RouteReplanResponse> {
return requestJson<RouteReplanResponse>(
+128 -1
View File
@@ -8,7 +8,8 @@
표고 조회는 종·횡단 생성기가 쓰는 것과 **같은 sampler**(`build_surface_sampler`)를 연다.
두 화면이 다른 표고를 보면 같은 자리의 기울기가 갈린다.
POST /api/projects/{id}/route/elevations → 점 묶음의 지반고
POST /api/projects/{id}/route/elevations → 점 묶음의 지반고
POST /api/projects/{id}/route/cross-preview → 고치던 노선의 한 측점 횡단 미리보기
"""
import asyncio
@@ -22,6 +23,12 @@ from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_Sections_Core import (
SectionGenerationOptions,
generate_sections,
)
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
from common_util.common_util_route_polyline import build_planned_polyline
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_sampler import build_surface_sampler
@@ -92,3 +99,123 @@ async def read_route_elevations(project_id: UUID, request: ElevationRequest) ->
"z": [None if not ok else round(float(value), 3) for value, ok in zip(z, valid)],
"valid": [bool(ok) for ok in valid],
}
class PreviewVertex(BaseModel):
"""편집 중인 꺾임점 하나 — `RouteVertexInput` 과 같은 꼴."""
x: float
y: float
curve: bool = True
radius_m: float | None = None
class CrossPreviewRequest(BaseModel):
"""고치던 노선 그대로 한 측점의 횡단을 미리 본다."""
vertices: list[PreviewVertex] = Field(..., min_length=2)
chainage_m: float = Field(..., ge=0)
#: 법정 최소곡선반지름(m) — 화면이 `/route/plan` 에서 받은 값을 그대로 돌려준다.
min_radius_m: float = Field(12.0, gt=0)
station_interval_m: float | None = None
def _cross_preview(
project_root: Path,
params: dict,
request: CrossPreviewRequest,
) -> dict | None:
"""고치던 노선으로 종·횡단을 한 번 돌려 그 측점 한 장을 뽑는다.
**B05·B06 의 정본 로직을 그대로 재사용한다**(2026-09-12 사용자 확정 「기본 로직은 B06에
존재함. 재사용」) — `generate_sections` 가 측점·접선·지반 샘플을, `compute_cross_design`
이 설계선을 만든다. 여기서 기하를 새로 짜지 않는다.
⚠ **계획고는 아직 없다.** 계획고는 [확인] 뒤 전 체인이 낳는 값이라 편집 중에는 존재하지
않는다. 그래서 그 측점의 **지반고를 그대로 계획고로 놓는다**(지반 추종) — 절·성토가 사면
기울기만으로 서는 「기본 계획 횡단」이며, 사용자가 보기로 한 것도 그것이다.
"""
try:
sampler = build_surface_sampler(
project_root / _MODELS_SUBDIR,
str(params["source_filter"]),
str(params["method"]),
bool(params["smooth"]),
)
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
logger.warning("횡단 미리보기: 지표면을 열지 못했습니다 — %s", exc)
return None
built = build_planned_polyline(
[(vertex.x, vertex.y) for vertex in request.vertices],
min_radius_m=request.min_radius_m,
# 화면이 준 노드는 이미 꺾임점이다 — 다시 뽑으면 선이 깎인다(`_write_planned_polyline`).
simplify=False,
curve_flags=[vertex.curve for vertex in request.vertices],
radii=[vertex.radius_m for vertex in request.vertices],
)
interval = request.station_interval_m
options = (
SectionGenerationOptions(station_interval_m=float(interval))
if interval and interval > 0
else SectionGenerationOptions()
)
result = generate_sections(built.vertices, sampler, options)
sections = result["cross_sections"]
if not sections:
return None
section = min(sections, key=lambda row: abs(float(row["chainage_m"]) - request.chainage_m))
design = None
center_z = section.get("center_z")
if center_z is not None:
# 단면유형 기본값은 B06 화면과 같다 — 등고가 높은 쪽을 절토로 본다.
section_mode = "right_cut" if section.get("uphill_side") == "right" else "left_cut"
design = compute_cross_design(
section["samples"],
float(center_z),
ground_type="soil",
section_mode=section_mode,
**curve_widening_args(section),
)
return {
"chainage_m": round(float(section["chainage_m"]), 3),
"label": section.get("label"),
"uphill_side": section.get("uphill_side"),
"plan_radius_m": section.get("plan_radius_m"),
"curve_widening_m": section.get("curve_widening_m"),
"samples": section["samples"],
"design": design,
"total_length_m": round(float(result["longitudinal"]["total_length_m"]), 3)
if result.get("longitudinal", {}).get("total_length_m") is not None
else None,
}
@router.post("/{project_id}/route/cross-preview", response_model=None)
async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) -> dict | JSONResponse:
"""고치던 계획노선의 **한 측점 횡단**을 돌려준다(계획서 0-9 ⑧).
정본을 건드리지 않는다 — 파일도 DB 도 쓰지 않고 그 자리에서 셈해 돌려주기만 한다.
"""
pool = get_db_pool()
async with pool.acquire() as connection:
stored = await get_project_storage_relative_path(connection, project_id)
if not stored:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
)
params = await get_surface_confirmation_params(connection, str(project_id))
project_root = Path(resolve_stored_project_path(stored))
preview = await asyncio.to_thread(_cross_preview, project_root, params, request)
if preview is None:
return JSONResponse(
status_code=409,
content={
"status": "error",
"message": "확정된 지표면이 없어 횡단을 미리 볼 수 없습니다.",
},
)
return {"status": "success", "project_id": str(project_id), **preview}
+58 -86
View File
@@ -42,13 +42,11 @@ import {
handleAtScreen,
nodeAtScreen,
segmentAtScreen,
stationAtScreen,
} from "./B05_Profile_UI_RouteEdit_Input";
import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross";
import { createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure";
import {
centerDirectionOf,
createCurveLabel,
deflectionRad,
} from "./B05_Profile_UI_RouteEdit_Label";
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
import {
applyArcLocks,
applyCurveLimits,
@@ -72,6 +70,8 @@ const NODE_HIT_PX = 9;
const SEGMENT_HIT_PX = 12;
/** 등고선을 집었다고 볼 거리(px) — 노드·손잡이보다 **좁게** 둔다(노선 편집이 먼저). */
const CONTOUR_HIT_PX = 6;
/** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */
const STATION_HIT_PX = 11;
type Vertex = [number, number];
@@ -100,7 +100,8 @@ export async function openRouteEditModal(
<strong>계획노선 편집</strong>
<span class="b05-routeedit__hint">
노드 끌기 = 옮기기 · 노드 클릭 = R 라벨 · 선 두 번 클릭 = 노드 추가 ·
노드 오른쪽 클릭 = 삭제 · <b>Shift+클릭 = 두 점 사이 거리·기울기</b> ·
노드 오른쪽 클릭 = 삭제 · <b>측점 눈금 클릭 = 횡단 미리보기</b> ·
<b>Shift+클릭 = 두 점 사이 거리·기울기</b> ·
가운데(휠) 버튼 끌기 = 지도 이동 · 휠 = 확대
</span>
<button type="button" class="b05-routeedit__close" aria-label="닫기">✕</button>
@@ -162,6 +163,21 @@ export async function openRouteEditModal(
let otherSheets: PreparedLayer[] = [];
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
let pickedContour = -1;
/** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */
const crossPreview = createCrossPreview({
projectId,
bounds: () => canvas.getBoundingClientRect(),
request: () => ({
vertices: planned.map(([x, y], index) => ({
x,
y,
curve: curveOn[index] !== false,
radius_m: curveRadius[index] ?? null,
})),
min_radius_m: minRadiusM || 12,
station_interval_m: stationIntervalM,
}),
});
/** 구간 재기 — Shift+클릭으로 두 점을 찍는다. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */
const measure = createMeasureTool({
projectId,
@@ -190,6 +206,7 @@ export async function openRouteEditModal(
window.removeEventListener("resize", resize);
historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다.
curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다.
crossPreview.destroy();
overlay.remove();
};
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close);
@@ -340,88 +357,26 @@ export async function openRouteEditModal(
fresh: nodeInfo.length === 0,
});
// ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ──
const curveLabelBox = createCurveLabel({
onRadius: (value) => {
if (picked < 0) return;
curveRadius[picked] = value;
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
},
onArcLength: (value) => {
if (picked < 0) return;
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
curveArc[picked] = value;
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.");
},
onLock: (lock) => {
if (picked < 0) return;
curveLock[picked] = lock;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
if (lock === "arc") {
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
}
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
applyEdit(
lock === "radius"
? "반지름을 고정했습니다."
: lock === "arc"
? "곡선 길이를 고정했습니다."
: "고정을 풀었습니다.",
);
},
onCurveOn: (on) => {
if (picked < 0) return;
curveOn[picked] = on;
applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
},
});
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
function syncCurveBar(): void {
if (!(picked > 0 && picked < planned.length - 1)) {
curveLabelBox.hide();
return;
}
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const rect = canvas.getBoundingClientRect();
const [screenX, screenY] = toScreen(planned[picked]);
curveLabelBox.show({
seat: picked,
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다.
at: [screenX + rect.left, screenY + rect.top],
// 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을
// 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨).
bounds: {
left: rect.left + 8,
top: rect.top + 8,
right: rect.right - 8,
bottom: rect.bottom - 8,
},
centerDirection: pickedCurve
? centerDirectionOf(
toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
toScreen(pickedCurve.start),
toScreen(pickedCurve.end),
)
: null,
curveOn: curveOn[picked] !== false,
radiusShown: shown,
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
lock: curveLock[picked] ?? null,
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
// ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ──
const curveBar = createCurveBar({
canvas,
state: () => ({
picked,
planned,
nodeInfo,
curveInfo,
curveOn,
curveRadius,
curveLock,
curveArc,
limitRadiusM,
limitArcM,
});
}
}),
toScreen: (vertex) => toScreen(vertex),
applyEdit: (message) => applyEdit(message),
});
const curveLabelBox = curveBar.label;
const syncCurveBar = curveBar.sync;
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */
function applyEdit(message: string, record = true): void {
@@ -485,6 +440,23 @@ export async function openRouteEditModal(
picked = dragHandle.node;
syncCurveBar();
draw();
} else if (
// 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다.
(() => {
const chainage = stationAtScreen(
plannedLine.length ? plannedLine : planned,
toScreen,
stationIntervalM,
px,
py,
STATION_HIT_PX,
);
if (chainage === null) return false;
void crossPreview.open(chainage);
return true;
})()
) {
/* 횡단 창이 떴다 — 더 집지 않는다. */
} else if (contours) {
// 노드도 손잡이도 아니면 **등고선**을 집는다 — 노선 편집이 늘 먼저다(계획서 0-9 ⑦).
// 빈 자리를 누르면 -1 이 되어 고른 것이 풀린다.
@@ -0,0 +1,234 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Cross.ts
* 계획노선 편집 중 **한 측점의 횡단 미리보기** — 따로 뜨는 작은 창(계획서 0-9 ⑧).
*
* 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 기본 계획 횡단선 ·
* 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다.
*
* ⚠ **계획고는 편집 중에 없다** — [확인] 뒤 전 체인이 낳는 값이다. 그래서 서버가 그 측점의
* 지반고를 그대로 계획고로 놓고(지반 추종) 사면만 세운 「기본 계획 횡단」을 낸다. 확정 뒤의
* 횡단과 다를 수 있고, 창 머리에 그렇게 적어 둔다.
*
* 셈은 **B05·B06 정본을 그대로 재사용**한다 — 측점·지반 샘플은 `generate_sections`, 설계선은
* `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`(여기).
* ========================================================================== */
import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch";
import { fillSlopeLengths } from "./../B06_Section/B06_Section_UI_Cross_Fit";
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
/** 그림 가장자리 여백(px). */
const PAD = 28;
export interface CrossPreviewParams {
projectId: string;
/** 창을 처음 띄울 테두리(화면 좌표) — 보통 모달의 지도 칸. */
bounds: () => DOMRect;
/** 지금 편집값 — 누른 순간에 읽어 서버로 보낸다. */
request: () => {
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
min_radius_m: number;
station_interval_m: number;
};
}
export interface CrossPreviewWindow {
/** 그 측점의 횡단을 띄운다. 이미 떠 있으면 내용만 갈아 끼운다. */
open: (chainageM: number) => Promise<void>;
/** 모달을 닫을 때 — 몸통이 `document.body` 에 붙어 있어 스스로 안 사라진다. */
destroy: () => void;
}
export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow {
const root = document.createElement("div");
root.className = "b05-routeedit__cross";
root.hidden = true;
root.innerHTML = `
<div class="b05-routeedit__cross-head">
<strong class="b05-routeedit__cross-title">횡단 미리보기</strong>
<button type="button" class="b05-routeedit__cross-close" aria-label="닫기">✕</button>
</div>
<canvas class="b05-routeedit__cross-canvas" width="420" height="260"></canvas>
<div class="b05-routeedit__cross-foot"></div>`;
document.body.append(root);
const head = root.querySelector<HTMLElement>(".b05-routeedit__cross-head")!;
const title = root.querySelector<HTMLElement>(".b05-routeedit__cross-title")!;
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
const canvas = root.querySelector<HTMLCanvasElement>(".b05-routeedit__cross-canvas")!;
const context = canvas.getContext("2d")!;
root.querySelector(".b05-routeedit__cross-close")!.addEventListener("click", () => {
root.hidden = true;
});
// 창 위에서 누른 것이 지도로 새어 나가면 노드가 딸려 움직인다.
for (const type of ["pointerdown", "dblclick", "contextmenu", "wheel"] as const) {
root.addEventListener(type, (event) => event.stopPropagation());
}
// ── 머리를 잡아 옮기기 — 노선을 가리면 손으로 치울 수 있어야 한다 ──
let dragFrom: { x: number; y: number; left: number; top: number } | null = null;
head.addEventListener("pointerdown", (event) => {
if ((event.target as HTMLElement).closest("button")) return;
dragFrom = { x: event.clientX, y: event.clientY, left: root.offsetLeft, top: root.offsetTop };
head.setPointerCapture(event.pointerId);
event.preventDefault();
});
head.addEventListener("pointermove", (event) => {
if (!dragFrom) return;
root.style.left = `${Math.round(dragFrom.left + event.clientX - dragFrom.x)}px`;
root.style.top = `${Math.round(dragFrom.top + event.clientY - dragFrom.y)}px`;
});
const stopDrag = (event: PointerEvent): void => {
if (head.hasPointerCapture(event.pointerId)) head.releasePointerCapture(event.pointerId);
dragFrom = null;
};
head.addEventListener("pointerup", stopDrag);
head.addEventListener("pointercancel", stopDrag);
/** 이번에 물은 측점 — 늦게 온 응답을 옛 자리에 적지 않으려고 들고 있는다. */
let asked = -1;
return {
async open(chainageM) {
asked = chainageM;
root.hidden = false;
if (!root.style.left) {
// 처음 열 때만 자리를 잡는다 — 그 뒤에는 사용자가 옮긴 자리를 지킨다.
// 지도 칸 **오른쪽 아래**에 붙인다 — 모달 머리·하단 정보행을 가리지 않는 자리다.
const box = params.bounds();
root.style.left = `${Math.round(box.right - root.offsetWidth - 16)}px`;
root.style.top = `${Math.round(box.bottom - root.offsetHeight - 16)}px`;
}
title.textContent = "횡단 미리보기 — 읽는 중…";
foot.textContent = "";
context.clearRect(0, 0, canvas.width, canvas.height);
let preview: CrossPreviewResponse;
try {
preview = await fetchCrossPreview(params.projectId, {
...params.request(),
chainage_m: chainageM,
});
} catch (error) {
if (asked !== chainageM) return;
title.textContent = "횡단 미리보기";
foot.textContent = error instanceof Error ? error.message : "횡단을 읽지 못했습니다.";
return;
}
if (asked !== chainageM || root.hidden) return;
title.textContent = `횡단 미리보기 — ${preview.label ?? `${preview.chainage_m}m`}`;
drawCross(context, canvas, preview);
foot.textContent = summarize(preview);
},
destroy() {
root.remove();
},
};
}
/** 성토사면 길이·절성토 면적 한 줄. 계획고가 없다는 것도 여기 적는다. */
function summarize(preview: CrossPreviewResponse): string {
const design = preview.design;
if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다.";
// 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를
// 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다.
const lengths = fillSlopeLengths({
samples: preview.samples,
design,
} as unknown as CrossSection);
const sides = (["left", "right"] as const)
.filter((side) => lengths[side] !== null)
.map((side) => {
const value = lengths[side]!;
const label = side === "left" ? "좌" : "우";
// 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다.
return `${label} ${value.open ? "≥" : ""}${value.lengthM.toFixed(2)}m`;
});
const slope = sides.length ? `성토사면 ${sides.join(" · ")}` : "성토측 없음";
return (
`${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}` +
" · 계획고는 [확인] 뒤에 정해지므로 지반을 따라 세운 기본 계획임"
);
}
/** 원지반선과 기본 계획 횡단선을 한 판에 그린다. 좌(+)가 왼쪽에 오게 눕힌다. */
function drawCross(
context: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
preview: CrossPreviewResponse,
): void {
const ground = preview.samples
.filter((sample) => sample.valid && sample.elevation_m !== null)
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number]);
const design = (preview.design?.design_line ?? []).map(
(point) => [point.offset_m, point.elevation_m] as [number, number],
);
const all = [...ground, ...design];
context.clearRect(0, 0, canvas.width, canvas.height);
if (all.length < 2) return;
const offsets = all.map((point) => point[0]);
const heights = all.map((point) => point[1]);
const minOffset = Math.min(...offsets);
const maxOffset = Math.max(...offsets);
const minZ = Math.min(...heights);
const maxZ = Math.max(...heights);
const spanX = maxOffset - minOffset || 1;
const spanZ = maxZ - minZ || 1;
// **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는
// 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다).
const scale = Math.min((canvas.width - PAD * 2) / spanX, (canvas.height - PAD * 2) / spanZ);
const centerOffset = (minOffset + maxOffset) / 2;
const centerZ = (minZ + maxZ) / 2;
// 좌(+offset)가 화면 왼쪽 — 횡단도 규약(generate_sections cad_exchange)과 같은 방향이다.
const toScreen = (point: [number, number]): [number, number] => [
canvas.width / 2 + (centerOffset - point[0]) * scale,
canvas.height / 2 + (centerZ - point[1]) * scale,
];
const stroke = (points: Array<[number, number]>, color: string, width: number): void => {
if (points.length < 2) return;
context.beginPath();
points.forEach((point, index) => {
const [x, y] = toScreen(point);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.strokeStyle = color;
context.lineWidth = width;
context.stroke();
};
// 중심선 — 어디가 노선 가운데인지 먼저 보이게.
const [centerX] = toScreen([0, centerZ]);
context.save();
context.setLineDash([4, 4]);
context.strokeStyle = "rgba(148,163,184,0.7)";
context.lineWidth = 1;
context.beginPath();
context.moveTo(centerX, PAD / 2);
context.lineTo(centerX, canvas.height - PAD / 2);
context.stroke();
context.restore();
stroke(ground, "#94a3b8", 1.6); // 원지반
stroke(design, "#f97316", 2.2); // 기본 계획 횡단
context.font = "11px system-ui, sans-serif";
context.textBaseline = "top";
context.fillStyle = "#94a3b8";
context.textAlign = "left";
context.fillText("원지반", PAD, 6);
context.fillStyle = "#f97316";
context.textAlign = "right";
context.fillText("기본 계획 횡단", canvas.width - PAD, 6);
context.fillStyle = "#94a3b8";
context.textAlign = "center";
context.textBaseline = "bottom";
context.fillText(
`${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` +
` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`,
canvas.width / 2,
canvas.height - 4,
);
}
@@ -0,0 +1,152 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_CurveBar.ts
* 곡선 조작 패널의 **배선** — 어느 꺾임점을 만질지 정하고, 칸에서 들어온 값을 편집값에
* 옮겨 적는다. 패널을 그리고 자리를 잡는 일은 `_Label` 몫이다.
*
* `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 본문 로직과
* 수치는 그대로이고, 모달 클로저가 쥐고 있던 값만 `state()` 로 받는다.
*
* **R 과 곡선 길이는 한 쌍**(L = R·Δ) — 어느 쪽으로 들어와도 **반지름 한 값**으로 바꿔
* 들고 간다. 두 벌로 두면 교각이 바뀔 때 서로 어긋난다(`_Edits.ts` 설명 참고).
* ========================================================================== */
import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
import type { CurveLock } from "./B05_Profile_UI_RouteEdit_Edits";
import {
centerDirectionOf,
createCurveLabel,
deflectionRad,
type CurveLabel,
} from "./B05_Profile_UI_RouteEdit_Label";
/** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */
export interface CurveBarState {
picked: number;
planned: Vertex[];
nodeInfo: EditedNode[];
curveInfo: EditedCurve[];
curveOn: boolean[];
curveRadius: Array<number | null>;
curveLock: CurveLock[];
curveArc: Array<number | null>;
/** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
limitRadiusM: number;
limitArcM: number;
}
export interface CurveBarParams {
canvas: HTMLCanvasElement;
state: () => CurveBarState;
toScreen: (vertex: Vertex) => [number, number];
/** 한 번의 편집을 마무리한다 — 다시 그리고 되돌리기에 쌓는다. */
applyEdit: (message: string) => void;
}
export interface CurveBar {
label: CurveLabel;
/** 고른 자리에 맞춰 패널을 옮겨 그린다. */
sync: () => void;
}
export function createCurveBar(params: CurveBarParams): CurveBar {
const label = createCurveLabel({
onRadius: (value) => {
const { picked, curveRadius } = params.state();
if (picked < 0) return;
curveRadius[picked] = value;
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
params.applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
},
onArcLength: (value) => {
const { picked, nodeInfo, curveArc, curveRadius } = params.state();
if (picked < 0) return;
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
curveArc[picked] = value;
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
params.applyEdit(
value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.",
);
},
onLock: (lock) => {
const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state();
if (picked < 0) return;
curveLock[picked] = lock;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
if (lock === "arc") {
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
}
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
params.applyEdit(
lock === "radius"
? "반지름을 고정했습니다."
: lock === "arc"
? "곡선 길이를 고정했습니다."
: "고정을 풀었습니다.",
);
},
onCurveOn: (on) => {
const { picked, curveOn } = params.state();
if (picked < 0) return;
curveOn[picked] = on;
params.applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
},
});
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
function sync(): void {
const {
picked,
planned,
nodeInfo,
curveInfo,
curveOn,
curveRadius,
curveLock,
limitRadiusM,
limitArcM,
} = params.state();
if (!(picked > 0 && picked < planned.length - 1)) {
label.hide();
return;
}
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const rect = params.canvas.getBoundingClientRect();
const [screenX, screenY] = params.toScreen(planned[picked]);
label.show({
seat: picked,
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다.
at: [screenX + rect.left, screenY + rect.top],
// 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을
// 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨).
bounds: {
left: rect.left + 8,
top: rect.top + 8,
right: rect.right - 8,
bottom: rect.bottom - 8,
},
centerDirection: pickedCurve
? centerDirectionOf(
params.toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
params.toScreen(pickedCurve.start),
params.toScreen(pickedCurve.end),
)
: null,
curveOn: curveOn[picked] !== false,
radiusShown: shown,
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
lock: curveLock[picked] ?? null,
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
limitRadiusM,
limitArcM,
});
}
return { label, sync };
}
@@ -228,6 +228,51 @@ export function routePointAtScreen(
return best;
}
/**
* 클릭에 가장 가까운 **규칙 측점**의 누가거리(m). 그만큼 안에 없으면 null(계획서 0-9 ⑧).
*
* 눈금을 그리는 `drawStationTicks` 와 **같은 자리**를 짚는다 — 선분 길이를 누적해 측점 간격
* 마다 한 점씩 보간한다. 눈금이 보이는 자리를 눌렀는데 안 잡히면 안 되기 때문이다.
*/
export function stationAtScreen(
line: Array<[number, number]>,
toScreen: ScreenOf,
intervalM: number,
px: number,
py: number,
maxPx: number,
): number | null {
if (line.length < 2 || !(intervalM > 0)) return null;
const cumulative: number[] = [0];
for (let index = 1; index < line.length; index += 1) {
cumulative.push(
cumulative[index - 1] +
Math.hypot(line[index][0] - line[index - 1][0], line[index][1] - line[index - 1][1]),
);
}
const total = cumulative[cumulative.length - 1];
let best: number | null = null;
let bestDistance = maxPx;
let cursor = 1;
for (let chainage = 0; chainage <= total; chainage += intervalM) {
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
const back = line[cursor - 1];
const front = line[cursor];
const segment = cumulative[cursor] - cumulative[cursor - 1] || 1;
const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment));
const [x, y] = toScreen([
back[0] + (front[0] - back[0]) * ratio,
back[1] + (front[1] - back[1]) * ratio,
]);
const distance = Math.hypot(x - px, y - py);
if (distance < bestDistance) {
bestDistance = distance;
best = chainage;
}
}
return best;
}
/** 등고선을 보일 화면 사각형 — 노선 경계에 `bandM` 를 두른 것. 노선이 없으면 null.
*
* **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 띠 자체는
@@ -241,3 +241,53 @@
color: var(--color-text-secondary);
line-height: 1.35;
}
/* ── 측점 횡단 미리보기 창 (계획서 0-9 ⑧) ────────────────────────────────
곡선 조작 패널과 같은 까닭으로 `document.body` 에 띄운다 — 모달이 `overflow: hidden`
이라 안에 두면 가장자리에서 잘린다. 머리를 잡아 옮길 수 있다. */
.b05-routeedit__cross {
position: fixed;
z-index: calc(var(--z-modal, 1000) + 2);
display: flex;
flex-direction: column;
gap: var(--spacing-8, 8px);
width: 452px;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-8, 6px);
background: var(--color-surface-raised);
box-shadow: 0 8px 28px rgb(0 0 0 / 40%);
}
.b05-routeedit__cross-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8, 8px);
cursor: move;
touch-action: none;
}
.b05-routeedit__cross-close {
padding: 0 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-4, 4px);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
}
.b05-routeedit__cross-canvas {
display: block;
width: 100%;
height: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-4, 4px);
background: var(--color-surface);
}
.b05-routeedit__cross-foot {
color: var(--color-text-secondary);
font-size: var(--text-caption);
line-height: 1.5;
}