From 4b14e64782607a10e2943ec8302994bdfadd4c58 Mon Sep 17 00:00:00 2001
From: umsangdon
Date: Fri, 24 Jul 2026 14:53:09 +0900
Subject: [PATCH] 260723_2
---
B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts | 17 +-
.../B05_wf2_Route_Engine_Sections.py | 55 +++++
.../B05_wf2_Route_Engine_Sections_Core.py | 28 ++-
B05_wf2_Route/B05_wf2_Route_Router.py | 99 +++++++-
B05_wf2_Route/B05_wf2_Route_Schema.py | 37 +++
.../B05_wf2_Route_UI_IrregularStations.ts | 224 ++++++++++++++++++
B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 117 ++++++++-
B05_wf2_Route/B05_wf2_Route_UI_Panel.ts | 35 +--
.../B05_wf2_Route_UI_Profile_Panel.ts | 58 ++++-
.../B05_wf2_Route_UI_Profile_Table.ts | 48 +++-
B05_wf2_Route/B05_wf2_Route_UI_Style.css | 184 +++++++++++---
.../B06_wf3_ProfileCross_Api_Fetch.ts | 3 +-
12 files changed, 839 insertions(+), 66 deletions(-)
create mode 100644 B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts
diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts
index a91ba765..55afb957 100644
--- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts
+++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts
@@ -220,10 +220,23 @@ export async function saveProfileAlignment(
);
}
-/** 프로젝트의 최신 경로를 확정한다. */
-export async function confirmRoute(projectId: string): Promise {
+/** 확정 시 비정규 측점 횡단을 생성하기 위한 입력(빈 값이면 확정만 한다). */
+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 {
return requestJson(`/projects/${projectId}/route/confirm`, {
method: "POST",
+ body: JSON.stringify(body),
});
}
diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py
index 0b3d5661..7765e34b 100644
--- a/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py
+++ b/B05_wf2_Route/B05_wf2_Route_Engine_Sections.py
@@ -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
diff --git a/B05_wf2_Route/B05_wf2_Route_Engine_Sections_Core.py b/B05_wf2_Route/B05_wf2_Route_Engine_Sections_Core.py
index 5fcb956c..a62c7ca1 100644
--- a/B05_wf2_Route/B05_wf2_Route_Engine_Sections_Core.py
+++ b/B05_wf2_Route/B05_wf2_Route_Engine_Sections_Core.py
@@ -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),
diff --git a/B05_wf2_Route/B05_wf2_Route_Router.py b/B05_wf2_Route/B05_wf2_Route_Router.py
index b356bc75..a09ae85a 100644
--- a/B05_wf2_Route/B05_wf2_Route_Router.py
+++ b/B05_wf2_Route/B05_wf2_Route_Router.py
@@ -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(
diff --git a/B05_wf2_Route/B05_wf2_Route_Schema.py b/B05_wf2_Route/B05_wf2_Route_Schema.py
index 6a1ff953..3687f641 100644
--- a/B05_wf2_Route/B05_wf2_Route_Schema.py
+++ b/B05_wf2_Route/B05_wf2_Route_Schema.py
@@ -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):
"""경로 확정 결과."""
diff --git a/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts b/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts
new file mode 100644
index 00000000..c96bdb60
--- /dev/null
+++ b/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts
@@ -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([]);
+ },
+ };
+}
diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts
index 98856d5d..7116e2e6 100644
--- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts
+++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts
@@ -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 {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) {
@@ -107,9 +169,19 @@ export async function renderB05Route(root: HTMLElement): Promise {
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 {
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 {
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 {
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 {
}
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 {
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]);
diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts
index 83a01a7d..8932cb18 100644
--- a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts
+++ b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts
@@ -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) 자동 산출됩니다.
";
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,
diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts
index de12a2cf..f5ab0f7c 100644
--- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts
+++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts
@@ -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 {
diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts
index c37135a7..69546770 100644
--- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts
+++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts
@@ -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;
}
diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css
index b737a34b..080c4048 100644
--- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css
+++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css
@@ -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;
}
diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts
index 52a11ec0..7cf91918 100644
--- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts
+++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts
@@ -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;