260723_2
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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([]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user