This commit is contained in:
2026-07-24 14:53:09 +09:00
parent 1154e169ca
commit 4b14e64782
12 changed files with 839 additions and 66 deletions
+15 -2
View File
@@ -220,10 +220,23 @@ export async function saveProfileAlignment(
);
}
/** 프로젝트의 최신 경로를 확정한다. */
export async function confirmRoute(projectId: string): Promise<RouteConfirmResponse> {
/** 확정 시 비정규 측점 횡단을 생성하기 위한 입력(빈 값이면 확정한다). */
export interface RouteConfirmRequest {
filter_key?: string;
method?: string;
smooth?: boolean;
surface_model_id?: number;
irregular_stations?: Array<{ chainage_m: number; structure: string }>;
}
/** 프로젝트의 최신 경로를 확정한다. 비정규 측점이 있으면 그 횡단까지 생성한다. */
export async function confirmRoute(
projectId: string,
body: RouteConfirmRequest = {},
): Promise<RouteConfirmResponse> {
return requestJson<RouteConfirmResponse>(`/projects/${projectId}/route/confirm`, {
method: "POST",
body: JSON.stringify(body),
});
}
@@ -195,3 +195,58 @@ def run_section_generation(
"cross_sections": cross_records,
"result": result,
}
def generate_irregular_sections(
project_root: Path,
route_data_path: str,
filter_key: str,
method: str,
smooth: bool,
*,
extra_stations: tuple[tuple[float, str], ...],
options: SectionGenerationOptions | None = None,
crs: str | None = None,
) -> list[dict[str, Any]]:
"""비정규 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다.
기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다. 격자 측점과 똑같은 접선·오프셋
샘플링을 거치되(`generate_sections` 재사용) kind="irregular"인 것만 골라내 각각
`cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로
필터링하므로, 이 파일들 + 종단 stations 병합만으로 다음 페이지에 횡단이 나타난다(DB 불필요).
"""
if not extra_stations:
return []
base = options or SectionGenerationOptions()
merged_options = SectionGenerationOptions(
station_interval_m=base.station_interval_m,
cross_half_width_m=base.cross_half_width_m,
cross_sample_interval_m=base.cross_sample_interval_m,
long_sample_interval_m=base.long_sample_interval_m,
include_endpoint=base.include_endpoint,
extra_stations=tuple(extra_stations),
)
polyline = _load_route_polyline(project_root, route_data_path)
sampler = build_surface_sampler(project_root / _MODELS_SUBDIR, filter_key, method, smooth)
result = generate_sections(
polyline,
sampler,
merged_options,
source_snapshot={"filter": filter_key, "method": method, "smooth": smooth},
crs=crs,
)
irregular_stations = [
station
for station in result["longitudinal"]["stations"]
if station.get("kind") == "irregular"
]
if not irregular_stations:
return []
cross_dir = project_root / _STAGE_SUBDIR / "cross_sections"
cross_dir.mkdir(parents=True, exist_ok=True)
for cross_section in result["cross_sections"]:
if cross_section.get("kind") != "irregular":
continue
cross_file = cross_dir / cross_filename(float(cross_section["chainage_m"]))
atomic_write_json(cross_file, cross_section)
return irregular_stations
@@ -30,6 +30,9 @@ class SectionGenerationOptions:
cross_sample_interval_m: float = SECTION_CROSS_SAMPLE_INTERVAL_M
long_sample_interval_m: float = SECTION_LONG_SAMPLE_INTERVAL_M
include_endpoint: bool = SECTION_INCLUDE_ENDPOINT
# 규칙 격자 밖 사용자 추가 측점(구조물용). (chainage_m, 구조물 텍스트) 튜플의 튜플.
# 격자 측점과 같은 방식으로 횡단을 샘플링하되 kind="irregular"로 표기한다.
extra_stations: tuple[tuple[float, str], ...] = ()
def validate(self) -> None:
values = {
@@ -132,6 +135,17 @@ def generate_sections(
long_z, long_valid = sampler.sample_xy(long_xy)
station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint)
# 비정규 측점(구조물)을 격자 측점 배열에 병합한다. 정렬·중복 제거만 하면 이후 접선·횡단
# 샘플링이 전부 이 배열을 따라 자동으로 확장된다. 어느 측점이 비정규인지는 chainage로 판별.
extras = {
round(float(chainage), 6): str(structure)
for chainage, structure in options.extra_stations
if 0.0 <= float(chainage) <= total
}
if extras:
station_chainage = np.unique(
np.r_[station_chainage, np.array(sorted(extras), dtype=np.float64)]
)
station_xy = _interpolate_xy(points, route_chainage, station_chainage)
tangents = np.vstack(
[
@@ -164,7 +178,15 @@ def generate_sections(
cross_sections: list[dict[str, Any]] = []
for index, value in enumerate(station_chainage):
station_id = f"station_{int(round(float(value) * 1000)):012d}"
kind = "bp" if index == 0 else "ep" if abs(float(value) - total) <= 1e-6 else "regular"
rounded = round(float(value), 6)
if index == 0:
kind = "bp"
elif abs(float(value) - total) <= 1e-6:
kind = "ep"
elif rounded in extras:
kind = "irregular"
else:
kind = "regular"
center_index = int(np.argmin(np.abs(offsets)))
center_z = all_cross_z[index, center_index]
tangent = tangents[index]
@@ -191,6 +213,8 @@ def generate_sections(
"azimuth_deg": round(azimuth, 6),
"frame": frame,
}
if kind == "irregular":
station["structure"] = extras[rounded]
stations.append(station)
samples = []
@@ -250,6 +274,8 @@ def generate_sections(
"cross_sample_interval_m": options.cross_sample_interval_m,
"long_sample_interval_m": options.long_sample_interval_m,
"include_endpoint": options.include_endpoint,
# 비정규 측점 단일 소스(DB): 재생성·재탐색·복원 시 이 값을 우선 사용한다.
"extra_stations": [list(entry) for entry in options.extra_stations],
},
"longitudinal": {
"length_m": round(total, 6),
+96 -3
View File
@@ -16,7 +16,10 @@ from B05_wf2_Route.B05_wf2_Route_Debug import log_b05_debug
from B05_wf2_Route.B05_wf2_Route_Engine import run_route_design
from B05_wf2_Route.B05_wf2_Route_Engine_Grade import GradeDesignOptions, resolve_grade_options
from B05_wf2_Route.B05_wf2_Route_Engine_Grade_Profile import rebuild_alignment_profile
from B05_wf2_Route.B05_wf2_Route_Engine_Sections import run_section_generation
from B05_wf2_Route.B05_wf2_Route_Engine_Sections import (
generate_irregular_sections,
run_section_generation,
)
from B05_wf2_Route.B05_wf2_Route_Engine_Sections_Core import SectionGenerationOptions
from B05_wf2_Route.B05_wf2_Route_Repository import (
confirm_route,
@@ -34,6 +37,7 @@ from B05_wf2_Route.B05_wf2_Route_Schema import (
ContourIntervalUpdateResponse,
ProfileAlignmentSaveRequest,
ProfileAlignmentSaveResponse,
RouteConfirmRequest,
RouteConfirmResponse,
RouteLatestResponse,
RouteSolveRequest,
@@ -109,6 +113,79 @@ def _normalized_route_params(params: dict[str, Any] | None) -> dict[str, Any] |
}
def _section_options_from_stored(stored: dict[str, Any] | None) -> SectionGenerationOptions:
"""저장된 종횡단 옵션(단일 소스)만으로 옵션을 재구성한다(확정 시 비정규 측점 샘플링용)."""
defaults = SectionGenerationOptions()
stored = stored or {}
return SectionGenerationOptions(
station_interval_m=stored.get("station_interval_m") or defaults.station_interval_m,
cross_half_width_m=stored.get("cross_half_width_m") or defaults.cross_half_width_m,
cross_sample_interval_m=stored.get("cross_sample_interval_m")
or defaults.cross_sample_interval_m,
long_sample_interval_m=stored.get("long_sample_interval_m")
or defaults.long_sample_interval_m,
include_endpoint=defaults.include_endpoint,
)
def _merge_irregular_into_longitudinal(
project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]]
) -> None:
"""종단 정본 파일의 stations에 비정규 측점을 병합한다(기존 비정규는 교체·정렬).
B06 상세는 종단 파일의 stations를 읽고 그에 맞는 cross 파일만 노출하므로, 이 병합과
(엔진이 이미 쓴) cross 파일만으로 다음 페이지에 횡단이 나타난다. 계획선·규칙 측점·표고
샘플은 손대지 않는다. 파일 기반이라 재확정해도 중복이 생기지 않는다.
"""
root = project_root.resolve()
path = (root / longitudinal_file_path).resolve()
if root not in path.parents or not path.is_file():
return
data = json.loads(path.read_text(encoding="utf-8"))
stations = data.get("stations")
if not isinstance(stations, list):
return
regular = [station for station in stations if station.get("kind") != "irregular"]
merged = regular + list(irregular_stations)
merged.sort(key=lambda station: float(station.get("chainage_m", 0.0)))
data["stations"] = merged
atomic_write_json(path, data)
async def _append_irregular_cross_sections(
connection: aiomysql.Connection,
project_id: UUID,
route: dict[str, Any],
request: RouteConfirmRequest,
) -> None:
"""확정 시 비정규 측점의 횡단을 생성해 종단 파일에 병합한다(파일 기반, 비치명적 호출용)."""
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
stored_options = await get_latest_section_options(connection, project_id)
crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id)
irregular_stations = await asyncio.to_thread(
generate_irregular_sections,
project_root,
str(route["route_data_path"]),
request.filter_key,
request.method,
request.smooth,
extra_stations=request.extra_stations(),
options=_section_options_from_stored(stored_options),
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
)
if not irregular_stations:
return
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
if longitudinal:
await asyncio.to_thread(
_merge_irregular_into_longitudinal,
project_root,
str(longitudinal["longitudinal_file_path"]),
irregular_stations,
)
def _grade_options(
request: RouteSolveRequest, stored_grade_options: dict[str, Any] | None
) -> GradeDesignOptions:
@@ -491,8 +568,15 @@ async def read_latest_route(project_id: UUID) -> RouteLatestResponse | JSONRespo
@router.post("/{project_id}/route/confirm", response_model=RouteConfirmResponse)
async def confirm_latest_route(project_id: UUID) -> RouteConfirmResponse | JSONResponse:
"""프로젝트의 최신 경로를 확정(CONFIRMED)한다."""
async def confirm_latest_route(
project_id: UUID, request: RouteConfirmRequest | None = None
) -> RouteConfirmResponse | JSONResponse:
"""프로젝트의 최신 경로를 확정(CONFIRMED)한다.
비정규 측점(구조물)이 있으면 확정 시 해당 측점의 횡단을 생성해 종단 파일에 병합한다.
이 생성은 **비치명적**이다 — 실패해도 경로 확정(다음 단계 진행)은 그대로 진행한다.
"""
request = request or RouteConfirmRequest()
pool = get_db_pool()
try:
async with pool.acquire() as connection:
@@ -502,6 +586,15 @@ async def confirm_latest_route(project_id: UUID) -> RouteConfirmResponse | JSONR
status_code=404,
content={"status": "error", "message": "확정할 경로가 없습니다."},
)
if request.can_regenerate():
try:
await _append_irregular_cross_sections(connection, project_id, latest, request)
except Exception:
logger.exception(
"B05 비정규 측점 횡단 생성 실패 (경로 확정은 진행): project_id=%s route_id=%s",
project_id,
latest["id"],
)
await connection.begin()
try:
log_b05_debug(
+37
View File
@@ -223,6 +223,43 @@ class RouteSolveResponse(BaseModel):
grade_summary: dict[str, Any] | None = None
class IrregularStationInput(BaseModel):
"""비정규 측점(구조물). 규칙 격자 밖 chainage 위치에 구조물 정보를 담는다."""
model_config = ConfigDict(extra="forbid")
chainage_m: float = Field(ge=0)
structure: str = Field(default="")
class RouteConfirmRequest(BaseModel):
"""경로 확정 요청.
확정 시 비정규 측점의 **횡단을 함께 생성**하기 위해, 지표 샘플러 재구성에 필요한 값
(solve 때 쓰던 필터/방법/모델)을 함께 받는다. 비정규 측점이 없으면 재생성 없이 확정만 한다.
모든 필드가 선택이라 빈 본문(`{}`)이면 기존 확정 동작과 동일하다.
"""
model_config = ConfigDict(extra="forbid")
filter_key: str | None = None
method: str | None = None
smooth: bool = False
surface_model_id: int | None = None
irregular_stations: list[IrregularStationInput] = Field(default_factory=list)
def extra_stations(self) -> tuple[tuple[float, str], ...]:
return tuple((item.chainage_m, item.structure) for item in self.irregular_stations)
def can_regenerate(self) -> bool:
return bool(
self.irregular_stations
and self.filter_key
and self.method
and self.surface_model_id is not None
)
class RouteConfirmResponse(BaseModel):
"""경로 확정 결과."""
@@ -0,0 +1,224 @@
/* =============================================================================
* B05_wf2_Route_UI_IrregularStations.ts
* 비정규 측점(구조물 측점) 입력·목록·편집 사이드바 섹션.
*
* 사용자가 규칙 격자(측점 간격) 밖의 임의 위치(측점번호 X + 잔여거리 XX)에 구조물 측점을
* 추가한다. 지금은 구조물을 자유 텍스트로 받고, 값은 클라이언트에만 보관한다(백엔드 미영속).
* 추가/수정/삭제/리셋이 일어날 때마다 `onChange`로 전체 목록을 알려 Page가 그래프·테이블·3D에
* 반영한다. 목록에서 항목을 고르면 `onSelect`로 chainage를 알려 하이라이트에 쓴다.
*
* chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 `getInterval()`로 실시간 조회한다.
* ========================================================================== */
export interface IrregularStation {
id: string;
/** 측점번호 X. */
station: number;
/** 잔여거리 XX (m). */
remainder: number;
/** = station × 측점간격 + remainder. 그래프·테이블·3D의 X축 기준. */
chainage_m: number;
/** 구조물 설명 (당분간 자유 텍스트). */
structure: string;
}
export interface IrregularStationsSection {
root: HTMLElement;
getStations: () => IrregularStation[];
/** chainage로 목록 항목을 골라 폼에 로드한다(그래프·3D에서 선택 시). null이면 선택 해제. */
selectByChainage: (chainageM: number | null) => void;
clear: () => void;
}
interface IrregularStationsCallbacks {
/** 측점간격(m). chainage 환산에 쓴다. */
getInterval: () => number;
/** 목록이 바뀔 때(추가·수정·삭제·리셋) 전체 목록을 넘긴다. */
onChange: (stations: IrregularStation[]) => void;
/** 목록에서 항목을 선택/해제할 때 해당 측점(또는 null)을 넘긴다. */
onSelect: (station: IrregularStation | null) => void;
}
/** 비정규 측점의 그래프·3D·테이블 공용 식별자. 주입 측점의 station_id로도 쓴다. */
export function irregularStationId(id: string): string {
return `irregular:${id}`;
}
/** 측점번호+잔여거리 표기 (예: 3+18.0). */
export function irregularLabel(station: IrregularStation): string {
return `${station.station}+${station.remainder.toFixed(1)}`;
}
function field(labelText: string, input: HTMLInputElement): HTMLLabelElement {
const wrapper = document.createElement("label");
wrapper.className = "b05-route__field";
const caption = document.createElement("span");
caption.textContent = labelText;
wrapper.append(caption, input);
return wrapper;
}
function numberInput(step: string, min: string): HTMLInputElement {
const input = document.createElement("input");
input.type = "number";
input.step = step;
input.min = min;
return input;
}
export function createIrregularStationsSection(
callbacks: IrregularStationsCallbacks,
): IrregularStationsSection {
const root = document.createElement("section");
root.className = "b05-route__panel-section";
const heading = document.createElement("h3");
heading.textContent = "비정규 측점 (구조물)";
const body = document.createElement("div");
body.className = "b05-route__panel-body";
root.append(heading, body);
const stationField = numberInput("1", "0");
stationField.placeholder = "측점번호";
const remainderField = numberInput("0.1", "0");
remainderField.placeholder = "잔여거리";
const structureField = document.createElement("input");
structureField.type = "text";
structureField.placeholder = "구조물 (예: 배수구조물, 옹벽)";
const stationRow = document.createElement("div");
stationRow.className = "b05-route__irregular-row";
stationRow.append(field("측점번호", stationField), field("잔여거리 (m)", remainderField));
const primary = document.createElement("button");
primary.type = "button";
primary.className = "b05-route__irregular-btn is-primary";
const remove = document.createElement("button");
remove.type = "button";
remove.className = "b05-route__irregular-btn is-danger";
remove.textContent = "삭제";
const reset = document.createElement("button");
reset.type = "button";
reset.className = "b05-route__irregular-btn";
reset.textContent = "리셋";
const actions = document.createElement("div");
actions.className = "b05-route__irregular-actions";
actions.append(primary, remove, reset);
const list = document.createElement("ul");
list.className = "b05-route__irregular-list";
const help = document.createElement("p");
help.className = "b05-route__note";
help.textContent =
"구조물을 설치할 위치를 측점번호+잔여거리로 추가합니다. 목록에서 고르면 수정·삭제할 수 있습니다.";
body.append(field("구조물", structureField), stationRow, actions, list, help);
const stations: IrregularStation[] = [];
let editingId: string | null = null;
let nextId = 1;
function chainageOf(station: number, remainder: number): number {
const interval = callbacks.getInterval();
return station * (interval > 0 ? interval : 20) + remainder;
}
function syncButtons(): void {
primary.textContent = editingId ? "수정" : "추가";
remove.disabled = editingId === null;
}
function loadForm(target: IrregularStation | null): void {
editingId = target?.id ?? null;
stationField.value = target ? String(target.station) : "";
remainderField.value = target ? String(target.remainder) : "";
structureField.value = target?.structure ?? "";
syncButtons();
callbacks.onSelect(target ?? null);
}
function renderList(): void {
list.replaceChildren();
if (!stations.length) {
const empty = document.createElement("li");
empty.className = "b05-route__irregular-empty";
empty.textContent = "추가된 비정규 측점이 없습니다.";
list.append(empty);
return;
}
[...stations]
.sort((a, b) => a.chainage_m - b.chainage_m)
.forEach((station) => {
const item = document.createElement("li");
item.className = "b05-route__irregular-item";
item.classList.toggle("is-selected", station.id === editingId);
const name = document.createElement("strong");
name.textContent = irregularLabel(station);
const info = document.createElement("span");
info.textContent = station.structure || "(구조물 미입력)";
item.append(name, info);
item.addEventListener("click", () => loadForm(station));
list.append(item);
});
}
function commit(): void {
const station = Number.parseInt(stationField.value, 10);
const remainder = Number.parseFloat(remainderField.value || "0");
if (!Number.isFinite(station) || station < 0) {
stationField.focus();
return;
}
const safeRemainder = Number.isFinite(remainder) && remainder >= 0 ? remainder : 0;
const structure = structureField.value.trim();
const chainage_m = chainageOf(station, safeRemainder);
if (editingId) {
const target = stations.find((entry) => entry.id === editingId);
if (target)
Object.assign(target, { station, remainder: safeRemainder, chainage_m, structure });
} else {
stations.push({
id: String(nextId++),
station,
remainder: safeRemainder,
chainage_m,
structure,
});
}
loadForm(null);
renderList();
callbacks.onChange([...stations]);
}
primary.addEventListener("click", commit);
remove.addEventListener("click", () => {
if (!editingId) return;
const index = stations.findIndex((entry) => entry.id === editingId);
if (index >= 0) stations.splice(index, 1);
loadForm(null);
renderList();
callbacks.onChange([...stations]);
});
reset.addEventListener("click", () => loadForm(null));
syncButtons();
renderList();
return {
root,
getStations: () => [...stations],
selectByChainage(chainageM) {
if (chainageM === null) {
loadForm(null);
return;
}
const target = stations.find((entry) => Math.abs(entry.chainage_m - chainageM) < 1e-6);
loadForm(target ?? null);
},
clear() {
stations.length = 0;
loadForm(null);
renderList();
callbacks.onChange([]);
},
};
}
+111 -6
View File
@@ -30,10 +30,16 @@ import {
import { createRoutePanel, type RoutePanelValues } from "./B05_wf2_Route_UI_Panel";
import { createRouteProfilePanel } from "./B05_wf2_Route_UI_Profile_Panel";
import { createRouteViewer } from "./B05_wf2_Route_UI_Viewer";
import {
irregularLabel,
irregularStationId,
type IrregularStation,
} from "./B05_wf2_Route_UI_IrregularStations";
import {
fetchSectionContext,
fetchSectionDetail,
type SectionDetailResponse,
type SectionStation,
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch";
import "./B05_wf2_Route_UI_Style.css";
@@ -98,6 +104,62 @@ function circlePoint(point: PlacedRoutePoint): CirclePoint {
return { ...routePoint(point), radius_m: point.radius_m ?? 25 };
}
/**
* 비정규 측점을 규칙 측점 좌표 사이 chainage로 선형보간해 `SectionStation`(월드 좌표·프레임 포함)으로
* 만든다. 백엔드가 아직 이 측점의 횡단을 생성하지 않으므로, 3D 표시에 필요한 위치만 근사한다.
* 노선 범위를 벗어난 chainage는 제외한다.
*/
function interpolateIrregularStations(
base: SectionStation[],
list: IrregularStation[],
maxChainage: number,
): SectionStation[] {
const sorted = [...base].sort((a, b) => a.chainage_m - b.chainage_m);
if (!sorted.length) return [];
const anchorAt = (chainage: number): SectionStation => {
if (chainage <= sorted[0].chainage_m) return sorted[0];
const last = sorted[sorted.length - 1];
if (chainage >= last.chainage_m) return last;
let lo = sorted[0];
let hi = last;
for (let index = 1; index < sorted.length; index += 1) {
if (sorted[index].chainage_m >= chainage) {
lo = sorted[index - 1];
hi = sorted[index];
break;
}
}
const span = hi.chainage_m - lo.chainage_m;
const t = span > 1e-9 ? (chainage - lo.chainage_m) / span : 0;
const lerp = (a: number, b: number): number => a + (b - a) * t;
const centerZ =
lo.center_z !== null && hi.center_z !== null
? lerp(lo.center_z, hi.center_z)
: (lo.center_z ?? hi.center_z);
return {
...lo,
center_x: lerp(lo.center_x, hi.center_x),
center_y: lerp(lo.center_y, hi.center_y),
center_z: centerZ,
frame: {
left_xy: [
lerp(lo.frame.left_xy[0], hi.frame.left_xy[0]),
lerp(lo.frame.left_xy[1], hi.frame.left_xy[1]),
],
},
};
};
return list
.filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6)
.map((entry) => ({
...anchorAt(entry.chainage_m),
station_id: irregularStationId(entry.id),
chainage_m: entry.chainage_m,
label: irregularLabel(entry),
kind: "irregular" as const,
}));
}
export async function renderB05Route(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) {
@@ -107,9 +169,19 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
const activeProjectId: string = projectId;
const viewer = createRouteViewer();
const profilePanel = createRouteProfilePanel(activeProjectId, (stationId) =>
viewer.markers.selectStation(stationId),
);
const profilePanel = createRouteProfilePanel(activeProjectId, (stationId) => {
viewer.markers.selectStation(stationId);
syncIrregularSelection(stationId);
});
/** 그래프·3D에서 비정규 측점을 고르면 사이드바 입력 폼에 로드해 수정/삭제할 수 있게 한다. */
function syncIrregularSelection(stationId: string | null): void {
const prefix = irregularStationId("");
if (!stationId?.startsWith(prefix)) return;
const id = stationId.slice(prefix.length);
const station = irregularStations.find((entry) => entry.id === id);
panel.irregularStations.selectByChainage(station ? station.chainage_m : null);
}
let confirmedSurface: SurfaceModelSummary | null = null;
let latest: RouteLatestResponse | null = null;
let roadWidths = DEFAULT_ROAD_WIDTHS;
@@ -117,6 +189,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
let routeReady = false;
let stale = false;
let restoring = true;
let irregularStations: IrregularStation[] = [];
const panel = createRoutePanel({
onSolve: () => void solve(),
@@ -132,6 +205,12 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
onDeletePoint: viewer.markers.deleteSelected,
onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }),
onInputChange: markStale,
onIrregularChange: (stations) => applyIrregularStations(stations),
onIrregularSelect: (station) => {
const id = station ? irregularStationId(station.id) : null;
viewer.markers.selectStation(id);
profilePanel.setSelectedStation(id);
},
});
function updateConfirmGate(): void {
@@ -148,7 +227,10 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
viewer.markers.onChange(markStale);
viewer.markers.onSelectionChange(panel.setSelected);
viewer.markers.onStationSelectionChange(profilePanel.setSelectedStation);
viewer.markers.onStationSelectionChange((stationId) => {
profilePanel.setSelectedStation(stationId);
syncIrregularSelection(stationId);
});
viewer.root.append(panel.viewControls);
function restorePanel(next: RouteLatestResponse): void {
@@ -178,15 +260,28 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
}
function renderStationLines(detail: SectionDetailResponse): void {
viewer.renderStationLines(
const injected = interpolateIrregularStations(
detail.longitudinal.stations,
irregularStations,
detail.longitudinal.length_m,
);
viewer.renderStationLines(
[...detail.longitudinal.stations, ...injected],
roadWidths[panel.values().gradeClass] / 2,
);
}
/** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */
function applyIrregularStations(stations: IrregularStation[]): void {
irregularStations = stations;
if (currentSectionDetail) renderStationLines(currentSectionDetail);
profilePanel.setIrregularStations(stations);
}
function renderSections(detail: SectionDetailResponse, routeId?: number): void {
currentSectionDetail = detail;
profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId);
profilePanel.setIrregularStations(irregularStations);
renderStationLines(detail);
}
@@ -316,7 +411,17 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
try {
// 종단 계획선 편집은 화면에서만 계산해 두었으므로 확정 직전에 영속화한다.
await profilePanel.save();
await confirmRoute(activeProjectId);
// 비정규 측점(구조물)이 있으면 확정 시 그 횡단까지 생성하도록 지표 샘플러 입력을 함께 보낸다.
await confirmRoute(activeProjectId, {
filter_key: latest?.surface_params.source_filter,
method: latest?.surface_params.method,
smooth: latest?.surface_params.smooth,
surface_model_id: confirmedSurface?.id,
irregular_stations: irregularStations.map((station) => ({
chainage_m: station.chainage_m,
structure: station.structure,
})),
});
renderLatest(await fetchLatestRoute(activeProjectId));
showToast("경로를 확정했습니다.", "success");
goToWorkflowStage(activeProjectId, WORKFLOW_STEP_ROUTES[3]);
+18 -17
View File
@@ -1,4 +1,9 @@
import type { PlacedRoutePoint, RoutePointKind } from "./B05_wf2_Route_UI_Markers";
import {
createIrregularStationsSection,
type IrregularStation,
type IrregularStationsSection,
} from "./B05_wf2_Route_UI_IrregularStations";
import { type ButtonVariant, createButton } from "@ui/ui_template_elements";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
@@ -52,6 +57,10 @@ interface PanelCallbacks {
onDeletePoint: () => void;
onRadiusChange: (radius: number) => void;
onInputChange: () => void;
/** 비정규 측점 목록이 바뀔 때(추가·수정·삭제·리셋). */
onIrregularChange: (stations: IrregularStation[]) => void;
/** 비정규 측점을 목록에서 선택/해제할 때 해당 측점(또는 null). */
onIrregularSelect: (station: IrregularStation | null) => void;
}
type WrappedInput = HTMLInputElement & { wrapper: HTMLLabelElement };
@@ -287,21 +296,13 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
"절토량과 성토량이 균형을 이루도록(적분값 0) 자동 산출됩니다.</p>";
gradeLine.body.append(terrainLabel, criteriaNote, gradeAdvanced, gradeHelp);
// 비정규 측점(구조물 설치용) — 사용자가 판단해 X+XX 위치에 구조물 정보를 적어 두는 컨테이너.
// 지금은 자유 텍스트 초안 입력만 받는다(구조물 형식·옵션 미확정). 추후 선택+값 입력으로 대체 예정.
// 값을 재탐색 트리거(inputElements)에 넣지 않아 텍스트를 고쳐도 "재탐색 필요"가 뜨지 않는다.
const irregular = section("비정규 측점 (구조물)");
const irregularStations = document.createElement("textarea");
irregularStations.className = "b05-route__textarea";
irregularStations.rows = 4;
irregularStations.placeholder =
"예)\n0+15 배수구조물\n2+18 옹벽\n(측점 위치 + 구조물, 한 줄에 하나)";
const irregularHelp = document.createElement("p");
irregularHelp.className = "b05-route__note";
irregularHelp.textContent =
"구조물 설치가 필요한 지점을 측점(X+XX) 위치로 적어 두는 초안 입력입니다. " +
"지금은 자유 텍스트만 받고, 이후 구조물 선택·값 입력으로 발전시킵니다.";
irregular.body.append(irregularStations, irregularHelp);
// 비정규 측점(구조물 측점) — 측점번호+잔여거리로 추가/수정/삭제. 목록 변경은 Page로 올려
// 그래프·테이블·3D에 반영한다. chainage 환산 기준인 측점간격은 실시간 조회한다.
const irregular = createIrregularStationsSection({
getInterval: () => Number(stationInterval.value) || 20,
onChange: callbacks.onIrregularChange,
onSelect: callbacks.onIrregularSelect,
});
/** 등급·지형 선택에 맞춰 법정 기준값을 placeholder와 안내문에 반영한다. */
function syncCriteria(): void {
@@ -371,8 +372,8 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
return {
root,
viewControls,
/** 비정규 측점(구조물) 초안 텍스트. 아직 백엔드로 보내지 않는다(형식 확정 전 임시 보관용). */
irregularStationsText: () => irregularStations.value,
/** 비정규 측점 섹션 API(목록 조회·선택·초기화). 아직 백엔드로 보내지 않는다(프론트 프리뷰). */
irregularStations: irregular as IrregularStationsSection,
values(): RoutePanelValues {
return {
contourInterval: Number(contourInterval.value) || 1,
@@ -4,7 +4,7 @@
*
* 60% , 12 ** **
* X축이 ( ).
* 30% : 70% .
* 40% : 60% .
*
* , []
* `saveProfileAlignment()` .
@@ -42,11 +42,17 @@ import {
tableCellWidthFor,
TABLE_TARGET_FONT_PX,
} from "./B05_wf2_Route_UI_Profile_Table";
import {
irregularLabel,
irregularStationId,
type IrregularStation,
} from "./B05_wf2_Route_UI_IrregularStations";
import type { SectionStation } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch";
import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css";
const COLLAPSED_KEY = "b05-route-profile-collapsed";
/** 정보 라인을 뺀 본문 세로를 그래프 30% : 테이블 70%로 나눈다. */
const CHART_HEIGHT_RATIO = 0.3;
/** 정보 라인을 뺀 본문 세로를 그래프 40% : 테이블 60%로 나눈다(4:6, 6이 테이블). */
const CHART_HEIGHT_RATIO = 0.4;
const MIN_CHART_HEIGHT = 100;
/** 테이블 12행. 행 높이와 셀 폭에서 글자 크기를 정하는 데 쓴다. */
const TABLE_ROW_COUNT = 12;
@@ -150,6 +156,26 @@ function maxChainageOf(data: LongitudinalSection): number {
return Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1);
}
/**
* `SectionStation` . chainage··kind만
* 0 (3D Page가 ). .
*/
function irregularGraphStations(list: IrregularStation[], maxChainage: number): SectionStation[] {
return list
.filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6)
.map((entry) => ({
station_id: irregularStationId(entry.id),
chainage_m: entry.chainage_m,
label: irregularLabel(entry),
kind: "irregular" as const,
center_z: null,
azimuth_deg: null,
center_x: 0,
center_y: 0,
frame: { left_xy: [0, 0] as [number, number] },
}));
}
/** 종단면도 렌더러와 **같은** chainage → x(px) 매핑을 만든다 (테이블·버튼 정렬 기준). */
function chainageMapper(
data: LongitudinalSection,
@@ -216,6 +242,7 @@ export function createRouteProfilePanel(
let selectedStationId: string | null = null;
let stationInterval: number | undefined;
let routeId: number | null = null;
let irregularStations: IrregularStation[] = [];
let base: AlignmentBase | null = null;
let alignment: ProfileAlignment | null = null;
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
@@ -346,7 +373,7 @@ export function createRouteProfilePanel(
canvas.style.width = `${width}px`;
const x = chainageMapper(longitudinal, width, originOffset);
// 그래프 30% : 테이블 70% (상단 정보 라인은 본문 밖이라 애초에 빠져 있다).
// 그래프 40% : 테이블 60% (상단 정보 라인은 본문 밖이라 애초에 빠져 있다).
// 가로 스크롤바를 `overflow-x: scroll`로 항상 띄우므로 clientHeight에서 이미 빠져 있다.
const available = Math.max(120, body.clientHeight);
const chartHeight = alignment
@@ -366,6 +393,11 @@ export function createRouteProfilePanel(
labelWidth: LONG_PAD.left,
rowCount: TABLE_ROW_COUNT,
x,
// 비정규 측점은 규칙 격자를 건드리지 않고 주석(파선 세로선+라벨+구조물)으로 얹는다.
irregularStations: irregularStations.filter(
(entry) =>
entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6,
),
onCurveRadiusChange: (curve, radius) =>
applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)),
})
@@ -377,9 +409,20 @@ export function createRouteProfilePanel(
const designProfiles = alignment
? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])]
: (longitudinal.design_profiles ?? []);
// 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다.
const graphData = normalizedLongitudinal(longitudinal);
const injected = irregularGraphStations(irregularStations, maxChainageOf(longitudinal));
const graphLongitudinal = injected.length
? {
...graphData,
stations: [...graphData.stations, ...injected].sort(
(a, b) => a.chainage_m - b.chainage_m,
),
}
: graphData;
chartWrap.append(
createLongitudinalProfile(
normalizedLongitudinal(longitudinal),
graphLongitudinal,
selectedStationId,
1,
undefined,
@@ -474,6 +517,11 @@ export function createRouteProfilePanel(
selectedStationId = stationId;
draw();
},
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
setIrregularStations(stations: IrregularStation[]) {
irregularStations = stations;
draw();
},
isDirty: () => store.dirty(),
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
async save(): Promise<void> {
@@ -18,6 +18,7 @@ import type {
ProfileAlignment,
} from "./B05_wf2_Route_UI_Profile_Alignment";
import { stationLabel } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common";
import { irregularLabel, type IrregularStation } from "./B05_wf2_Route_UI_IrregularStations";
export interface ProfileTableOptions {
alignment: ProfileAlignment;
@@ -32,6 +33,8 @@ export interface ProfileTableOptions {
rowCount: number;
/** 종단면도와 공유하는 chainage → x(px) 매핑. */
x: (chainageM: number) => number;
/** 규칙 격자 밖 비정규 측점(구조물). 격자를 건드리지 않고 주석으로 얹는다. */
irregularStations?: IrregularStation[];
onCurveRadiusChange: (curve: AlignmentCurve, radiusM: number | null) => void;
}
@@ -50,13 +53,14 @@ interface SegmentRowSpec {
const FONT_MIN_PX = 9;
const FONT_MAX_PX = 16;
/** 행 높이 대비 글자 크기 비율 (위아래 여백 확보). */
const FONT_PER_ROW_HEIGHT = 0.52;
/** 행 높이 대비 글자 크기 비율. 위아래 여백을 줄여 글자를 행에 더 꽉 채운다. */
const FONT_PER_ROW_HEIGHT = 0.64;
/** 한 셀에 들어가는 가장 긴 값의 글자 수 — 누가거리 `3000.00`, 측점 `150+0.0`. */
const MAX_VALUE_CHARS = 7;
/** 숫자 한 글자의 대략적인 폭 (em 단위). 대부분의 산세리프에서 0.55~0.6em이다. */
const CHAR_WIDTH_EM = 0.6;
const CELL_PADDING_PX = 4;
/** 셀 좌우 여백(px). 좁혀서 값이 셀을 더 넉넉히 쓰게 한다. */
const CELL_PADDING_PX = 2;
/** 가로 여유가 있을 때 목표로 삼는 글자 크기. 캔버스 최소 폭 산정의 기준이 된다. */
export const TABLE_TARGET_FONT_PX = 12;
@@ -273,6 +277,41 @@ function buildCurveRows(options: ProfileTableOptions, centers: number[]): HTMLEl
return [lengthRow, radiusRow];
}
/**
* 주석층: 규칙 + () .
* · 2 (2 ).
*/
function buildIrregularAnnotations(
stations: IrregularStation[],
x: (chainageM: number) => number,
cellWidth: number,
): HTMLElement[] {
const sorted = [...stations].sort((a, b) => a.chainage_m - b.chainage_m);
const nodes: HTMLElement[] = [];
let previousX = -Infinity;
let row = 0;
sorted.forEach((station) => {
const centerX = x(station.chainage_m);
const line = element("div", "b05-profile-table__irregular-line");
line.style.left = `${centerX}px`;
nodes.push(line);
row = centerX - previousX < cellWidth ? (row + 1) % 2 : 0;
previousX = centerX;
const tag = element("div", `b05-profile-table__irregular-tag is-row-${row}`);
tag.style.left = `${centerX}px`;
tag.append(
element("span", "b05-profile-table__irregular-name", irregularLabel(station)),
element("span", "b05-profile-table__irregular-desc", station.structure || "구조물"),
);
tag.title = `비정규 측점 ${irregularLabel(station)} · ${station.chainage_m.toFixed(2)}m${
station.structure ? `\n구조물: ${station.structure}` : ""
}`;
nodes.push(tag);
});
return nodes;
}
export function createProfileTable(options: ProfileTableOptions): HTMLElement {
const { alignment, stationInterval, width, height, cellWidth, labelWidth, rowCount, x } = options;
const table = element("div", "b05-profile-table");
@@ -304,5 +343,8 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
table.append(row);
});
table.append(...buildCurveRows(options, centers));
if (options.irregularStations?.length) {
table.append(...buildIrregularAnnotations(options.irregularStations, x, cellWidth));
}
return table;
}
+156 -28
View File
@@ -109,6 +109,13 @@
transform: translateY(-9px);
}
/* 비정규 측점(구조물)은 그래프에서도 파선 세로선으로 구분해 규칙 측점과 헷갈리지 않게 한다. */
.b05-route-profile .b06-chart__station-line--irregular {
stroke: var(--color-royal-amethyst, rgb(139 92 246));
stroke-width: 1.4;
stroke-dasharray: 5 3;
}
.b05-route__viewport canvas {
display: block;
width: 100%;
@@ -224,12 +231,87 @@
color: var(--color-text-body);
}
/* 비정규 측점 초안 입력 — 세로 리사이즈만 허용(가로는 컨테이너 폭 고정). */
.b05-route__textarea {
box-sizing: border-box;
resize: vertical;
font: inherit;
line-height: 1.4;
/* ─── 비정규 측점(구조물) 섹션 ─────────────────────────────────────────── */
.b05-route__irregular-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--spacing-8);
}
.b05-route__irregular-actions {
display: flex;
gap: var(--spacing-8);
}
.b05-route__irregular-btn {
flex: 1 1 0;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--text-caption);
cursor: pointer;
}
.b05-route__irregular-btn.is-primary {
border-color: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent);
color: var(--color-text);
}
.b05-route__irregular-btn.is-danger {
border-color: color-mix(in srgb, var(--color-danger) 50%, transparent);
color: var(--color-danger);
}
.b05-route__irregular-btn:disabled {
opacity: 0.45;
cursor: default;
}
.b05-route__irregular-list {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: 0;
padding: 0;
list-style: none;
}
.b05-route__irregular-item {
display: flex;
align-items: baseline;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-surface);
cursor: pointer;
}
.b05-route__irregular-item.is-selected {
border-color: var(--color-royal-amethyst, rgb(109 40 217));
background: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 12%, transparent);
}
.b05-route__irregular-item strong {
color: var(--color-text);
font-variant-numeric: tabular-nums;
}
.b05-route__irregular-item span {
overflow: hidden;
color: var(--color-text-muted);
font-size: var(--text-caption);
text-overflow: ellipsis;
white-space: nowrap;
}
.b05-route__irregular-empty {
padding: var(--spacing-4) 0;
color: var(--color-text-muted);
font-size: var(--text-caption);
list-style: none;
}
.b05-route__check {
@@ -498,7 +580,58 @@
text-align: center;
}
/* ─── 계획고 편집 버튼 (평시 투명, 패널 hover 시 노출) ───────────────────── */
/* ─── 비정규 측점 주석 (규칙 격자 위에 얹는 파선 세로선 + 라벨) ──────────── */
.b05-profile-table__irregular-line {
position: absolute;
z-index: 6;
top: 0;
bottom: 0;
width: 0;
border-left: 1px dashed var(--color-royal-amethyst, rgb(139 92 246));
pointer-events: none;
}
.b05-profile-table__irregular-tag {
position: absolute;
z-index: 7;
display: flex;
flex-direction: column;
align-items: center;
max-width: 84px;
padding: 1px 4px;
border: 1px solid
color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent);
border-radius: 3px;
background: var(--color-surface-raised);
line-height: 1.15;
transform: translateX(-50%);
}
/* 2행 스태거: 가까운 라벨은 아래 슬롯으로 내려 겹침을 피한다. */
.b05-profile-table__irregular-tag.is-row-0 {
top: 2px;
}
.b05-profile-table__irregular-tag.is-row-1 {
top: calc(2px + 2.4em);
}
.b05-profile-table__irregular-name {
color: var(--color-royal-amethyst, rgb(109 40 217));
font-weight: var(--font-weight-medium);
font-variant-numeric: tabular-nums;
}
.b05-profile-table__irregular-desc {
max-width: 100%;
overflow: hidden;
color: var(--color-text-body);
font-size: 0.85em;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ─── 계획고 편집 버튼 (크기 유지·상시 표시·밝은 글자) ───────────────────── */
.b05-profile-edit {
position: absolute;
z-index: 4;
@@ -506,28 +639,24 @@
pointer-events: none;
}
/* 상시 보이게 둔다(예전엔 패널 hover 시에만 노출). 크기(18×15)는 유지하고 글자는 밝게. */
.b05-profile-edit__btn {
position: absolute;
width: 18px;
height: 15px;
padding: 0;
border: 1px solid transparent;
border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent);
border-radius: 3px;
background: transparent;
color: transparent;
background: color-mix(in srgb, var(--color-surface-raised) 80%, transparent);
color: var(--color-text);
font-size: 9px;
line-height: 1;
cursor: pointer;
opacity: 0;
opacity: 0.9;
pointer-events: auto;
transition: opacity var(--transition-fast);
}
.b05-route-profile:hover .b05-profile-edit__btn {
border-color: color-mix(in srgb, var(--color-border) 60%, transparent);
background: color-mix(in srgb, var(--color-surface) 70%, transparent);
color: var(--color-text-muted, var(--color-plum-velvet));
opacity: 0.45;
transition:
opacity var(--transition-fast),
background var(--transition-fast);
}
.b05-route-profile .b05-profile-edit__btn:hover,
@@ -547,17 +676,16 @@
}
/* 구간 시프트 버튼은 측점 버튼과 같은 (is-up/is-down) 놓이고, 겹치는 자리에서만
렌더러가 좌우로 비켜 배치한다. 구분을 위해 색만 달리한다. */
.b05-route-profile:hover .b05-profile-edit__btn.is-segment {
border-color: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 40%, transparent);
color: var(--color-royal-amethyst, rgb(109 40 217));
렌더러가 좌우로 비켜 배치한다. 구분을 위해 밝은 자수정색으로 표시한다. */
.b05-profile-edit__btn.is-segment {
border-color: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent);
color: color-mix(in srgb, var(--color-royal-amethyst, rgb(139 92 246)) 85%, var(--color-text));
}
.b05-profile-edit__btn.is-reset,
.b05-route-profile:hover .b05-profile-edit__btn.is-reset {
.b05-profile-edit__btn.is-reset {
top: 20px;
border-color: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 45%, transparent);
border-color: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent);
background: var(--color-surface-raised);
color: var(--color-royal-amethyst, rgb(109 40 217));
opacity: 0.9;
color: color-mix(in srgb, var(--color-royal-amethyst, rgb(139 92 246)) 85%, var(--color-text));
opacity: 0.95;
}
@@ -56,7 +56,8 @@ export interface SectionStation {
station_id: string;
chainage_m: number;
label: string;
kind: "bp" | "ep" | "regular";
/** irregular = 사용자가 구조물용으로 추가한 비정규 측점(프론트 주입, 백엔드 미영속). */
kind: "bp" | "ep" | "regular" | "irregular";
center_z: number | null;
azimuth_deg: number | null;
center_x: number;