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;