사용자 지시(2026-09-07). 계획서 3-6 마지막 항목 — 산마루측구·도수로·옹벽처럼 **구간**으로 놓이는 시설이 종단에는 띠로 보이는데 **평면(배수유역도)에는 아무 표시가 없었음**. **「부품이 없어 새로 짜야 함」이 사실이 아니었음** — 계획선 표본(`strengthSamples`)이 **1m 간격** 이라 **배열 인덱스가 곧 누가거리**임(`resampleRoute`). 구간 → 화면 선은 그 토막을 잘라 굵게 긋기만 하면 됨. 새 파일 하나(88줄)로 끝남. - `B05_Profile_UI_Drainage_Spans.ts` — 띠 그리기(`drawRouteSpans`)와 자료 만들기 (`routeSpansFromStructures`). 구간형만 띠가 되고, 색은 **레지스트리 표시색**을 그대로 씀. - 그리는 자리 — **계획선 바로 위, 강도 색칠·마커 아래**. 굵기 7px·투명도 0.45 라 계획선을 덮지 않음. 이름은 안 적음(종단에 이미 있고, 지도 글자는 눈금·유역 번호와 겹침). - 목록이 바뀌면 따라옴 — `setStructures`·`setStructureTypes` 가 띠를 다시 넘김. - 저장분에 노선보다 긴 구간이 남아 있어도 잘라서 그리고, 시작=끝인 구간도 한 칸은 보임. ⚠ 프로필 패널이 이미 700줄을 넘겨 있어(843) **매핑 로직은 띠 모듈로 뺐음** — 패널에는 3줄만 남김(827줄). 그 파일의 초과는 이전부터임. 시험 5건 — 부품 존재 · 범위 밖 구간 안전 · **쌓는 순서**(계획선 → 띠 → 강도 색칠) · 종단과 같은 자료에서 뽑는지 · 목록 변화가 따라오는지. ⚠ 실화면 확인은 아직임 — 공용 브라우저가 소단 무한루프(별건, `80db17d4` 로 고침) 로 멈춰 있어 사용자 새로고침 대기 중임. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
828 lines
41 KiB
TypeScript
828 lines
41 KiB
TypeScript
/* =============================================================================
|
||
* B05_Profile_UI_Profile_Panel.ts
|
||
* 하단 종단면도 패널 — 그래프 + 도면 테이블 2단, 계획선 직접 편집.
|
||
*
|
||
* 화면 높이의 60%를 쓰며, 그래프와 12행 도면 테이블이 **하나의 가로 스크롤러** 안에
|
||
* 같은 폭으로 쌓여 X축이 자동으로 맞물린다(스크롤 동기화 코드 불필요).
|
||
* 본문 세로는 그래프 40% : 테이블 60%로 나눈다.
|
||
*
|
||
* 편집은 전부 프론트에서 즉시 계산해 다시 그리고, 영속화는 [확정] 시점에
|
||
* `saveProfileAlignment()`로 편집 델타만 보낸다.
|
||
* ========================================================================== */
|
||
|
||
import { routeSpansFromStructures } from "./B05_Profile_UI_Drainage_Spans";
|
||
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||
import { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
|
||
import { createHaulPlanPrefetch } from "./B05_Profile_Api_HaulPlan";
|
||
import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel";
|
||
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
|
||
import {
|
||
createProfileTableOverlay,
|
||
TABLE_OVERLAY_MIN_HEIGHT,
|
||
} from "./B05_Profile_UI_Profile_TableOverlay";
|
||
import { hasLegacyAlignment, readAlignment, toDesignProfile } from "./B05_Profile_UI_Profile_Data";
|
||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||
import { showToast } from "@ui/ui_template_elements";
|
||
import { saveProfileAlignment } from "./B05_Profile_Api_Fetch";
|
||
import { hasStaleDesigns } from "../B06_Section/B06_Section_UI_Section_Common";
|
||
import {
|
||
blocksMinCover,
|
||
findMinCoverViolations,
|
||
minCoverPoints,
|
||
type MinCoverPoint,
|
||
} from "./B05_Profile_UI_Profile_MinCover";
|
||
import type {
|
||
AlignmentBase,
|
||
AlignmentEdits,
|
||
ProfileAlignment,
|
||
} from "./B05_Profile_UI_Profile_Alignment";
|
||
import {
|
||
buildAlignment,
|
||
chainageKey,
|
||
emptyEdits,
|
||
toAlignmentBase,
|
||
} from "./B05_Profile_UI_Profile_Alignment";
|
||
import { createProfileEditStore } from "./B05_Profile_UI_Profile_Edit";
|
||
import { structureGroupMenuItems } from "./B05_Profile_UI_Profile_Structures";
|
||
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
|
||
import {
|
||
createRouteMassHaulPanel,
|
||
type MassHaulSummaryValues,
|
||
MASSHAUL_MIN_HEIGHT,
|
||
type RouteMassHaulContext,
|
||
} from "./B05_Profile_UI_Profile_MassHaul";
|
||
import { renderBalanceBar } from "./B05_Profile_UI_Profile_Balance";
|
||
import { createHeightCascade } from "./B05_Profile_UI_Profile_Heights";
|
||
import { renderProfile } from "./B05_Profile_UI_Profile_Render";
|
||
import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||
import { createCrossPreview } from "./B05_Profile_UI_Profile_Preview";
|
||
import { createPanelTools } from "./B05_Profile_UI_Profile_Panel_Tools";
|
||
import { createProfileZoom } from "./B05_Profile_UI_Profile_Zoom";
|
||
import {
|
||
stationIdAtStructure as stationIdAtStructureOf,
|
||
structureIdAtStation as structureIdAtStationOf,
|
||
} from "./B05_Profile_UI_Profile_Structures";
|
||
import "../B06_Section/B06_Section_UI_Style.css";
|
||
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
|
||
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
|
||
import "../B06_Section/B06_Section_UI_Style_Cross.css";
|
||
import "../B06_Section/B06_Section_UI_Style_Cross_Areas.css";
|
||
|
||
/** 접힘·높이는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */
|
||
const HEIGHT_KEY = stateKey("profile-height") ?? "";
|
||
/* 테이블 높이는 오버레이 서브패널(--b05-table-height 리사이저)이 관리한다 —
|
||
예전 4:6 고정 분할(TABLE_HEIGHT_KEY·CHART_HEIGHT_RATIO)은 폐지(2026-08-05). */
|
||
const MIN_CHART_HEIGHT = 100;
|
||
/** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게.
|
||
* 구조물 알약 레인이 그 위에 붙은 뒤로는 사이가 떠 보여 절반으로 줄였다
|
||
* (2026-08-17 사용자 지시 2). */
|
||
const MASSHAUL_HANDLE_GUTTER_PX = 13;
|
||
/** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */
|
||
const MIN_PANEL_HEIGHT = 180;
|
||
/** 상한은 3D 뷰포트가 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */
|
||
const MAX_PANEL_HEIGHT_RATIO = 0.9;
|
||
/** 계획고 편집 후 횡단을 다시 계산하기까지 기다리는 시간(ms).
|
||
* ▲/▼ 길게 누르기(초당 10회)·끌기에서 매 프레임 다시 돌지 않게 마지막 값만 계산한다.
|
||
* 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 —
|
||
* 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다.
|
||
* 60→16ms(2026-09-06) — ▲ 한 번에 라벨이 서기까지 중앙 130ms 를 쟀는데 그중 60ms 가
|
||
* 이 대기였다. 한 프레임(16ms)이면 연속 입력은 여전히 마지막 값만 계산한다. */
|
||
const CROSS_PREVIEW_DEBOUNCE_MS = 16;
|
||
/** 구조물 측점을 종단 정본 측점으로 맞춰 주는 허용 오차(m). 층마다 반올림이 달라 생기는
|
||
* mm~cm 어긋남만 흡수할 크기다 — 이보다 멀면 서로 다른 측점이다. */
|
||
const STATION_SNAP_TOLERANCE_M = 0.1;
|
||
|
||
/**
|
||
* 저장된 계획선 선형을 읽되 **모양을 먼저 검증한다**.
|
||
*
|
||
* 곡선 기준이 길이(L)에서 반경(R)으로 바뀌기 전에 만들어진 데이터는 `policy`에
|
||
* `default_curve_radius_m`가 없어 그대로 쓰면 계산 도중 터진다. 그런 데이터는
|
||
* 편집 기능을 끄고(지반선·계획선 차트만 표시) 재계산을 안내하는 편이 안전하다.
|
||
*/
|
||
|
||
/** 콜백 타입은 700줄 제한으로 `_Panel_Types` 로 옮겼다 — 옛 임포트 경로가 그대로
|
||
* 동작하도록 여기서 다시 내보낸다(2026-09-04). */
|
||
import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types";
|
||
import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel";
|
||
import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow";
|
||
export type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types";
|
||
|
||
export function createRouteProfilePanel(
|
||
projectId: string,
|
||
/** 측점 선택 변경 — 같은 측점 재선택·빈 곳 클릭이면 null(해제)로 알린다. */
|
||
onSelectStation: (stationId: string | null) => void,
|
||
/** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 Page가 넘긴다). */
|
||
onResetAll?: () => void,
|
||
callbacks?: RouteProfilePanelCallbacks,
|
||
) {
|
||
const root = document.createElement("section");
|
||
root.className = "b05-route-profile";
|
||
// 이름표 "종단" — 무엇의 손잡이인지 화살표 옆에 밝힌다(2026-08-04 사용자 지시).
|
||
const panelHandle = createWorkflowPanelHandle("bottom", "down", "종단");
|
||
const balanceBar = document.createElement("div");
|
||
balanceBar.className = "b05-route-profile__balance";
|
||
const body = document.createElement("div");
|
||
body.className = "b05-route-profile__body";
|
||
const empty = document.createElement("p");
|
||
empty.className = "b05-route-profile__empty";
|
||
empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다.";
|
||
body.append(empty);
|
||
// 종단면 본문 + 우측 배수유역 패널을 나란히 놓는 2단 구성.
|
||
// 배수유역 패널이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시).
|
||
// 그래프 영역 정중앙 로딩 서클 — 종단면 자료가 도착할 때까지 빈 안내만 보인다.
|
||
// body는 그릴 때마다 자식이 통째로 교체되므로 서클은 감싸는 칸에 둔다.
|
||
const progress = createProgressCircle({ overlay: true });
|
||
progress.root.hidden = true;
|
||
const bodyWrap = document.createElement("div");
|
||
bodyWrap.className = "b05-route-profile__body-wrap";
|
||
// 절토·성토 요약 줄은 **종단 쪽 열 안**에 둔다 — 패널 전체 폭에 걸치면 우측 배수유역도
|
||
// 구분선이 그 높이만큼 끊겨 보인다(2026-08-04 사용자 보고).
|
||
bodyWrap.append(balanceBar, body, progress.root);
|
||
// 유토곡선 — 패널 바닥에 붙는 오버레이 서브패널(2026-08-04 사용자 확정).
|
||
// 종단도·테이블 배치는 건드리지 않고 그 위를 덮으며, 위 경계 리사이저로 높이를 조절한다.
|
||
// 서브패널 리사이저를 끄는 동안엔 메인 드래그와 같은 **경량 동기화**만 프레임마다 돌리고
|
||
// (차트 SVG·테이블 재생성은 무거워 덜컹였다 — 2026-08-06 분석 원인 3), 전체 재구성은
|
||
// 손을 뗀 뒤(clearDragFlags) 1회만 한다. 펼침·범례 토글 등 드래그 밖 알림은 전체 draw.
|
||
const subPanelChanged = (): void => {
|
||
if (subPanelDragging) scheduleLightSync();
|
||
else draw();
|
||
};
|
||
/** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시).
|
||
* 유토곡선을 접어도, B06 처럼 곡선을 아예 안 그려도 이 값은 남아야 한다. */
|
||
const massBadge = createMassHaulBadge();
|
||
// B05 는 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다.
|
||
massBadge.root.style.setProperty("--mass-haul-badge-top", "34px");
|
||
bodyWrap.append(massBadge.root);
|
||
let massHaulSummary: MassHaulSummaryValues | null = null;
|
||
// 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면
|
||
// 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다.
|
||
const haulPrefetch = createHaulPlanPrefetch(
|
||
projectId,
|
||
() => routeId ?? undefined,
|
||
subPanelChanged,
|
||
);
|
||
const massHaul = createRouteMassHaulPanel(
|
||
subPanelChanged,
|
||
(summary) => {
|
||
const before = massHaulSummary;
|
||
massHaulSummary = summary;
|
||
if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary);
|
||
},
|
||
haulPrefetch,
|
||
);
|
||
bodyWrap.append(massHaul.overlay, massHaul.handle);
|
||
// 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
|
||
massHaul.attachScrollSync(body);
|
||
// 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시).
|
||
const tableOverlay = createProfileTableOverlay(subPanelChanged);
|
||
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
|
||
tableOverlay.attachScrollSync(body);
|
||
// 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시).
|
||
// 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다.
|
||
body.addEventListener("contextmenu", (event) => event.preventDefault());
|
||
massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault());
|
||
const content = document.createElement("div");
|
||
content.className = "b05-route-profile__content";
|
||
// 관 목록이 바뀌면 종단 테이블의 "배관" 구조물 라인도 같이 맞춘다(정본은 관 지점 파일).
|
||
/** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */
|
||
let minCoverTargets: MinCoverPoint[] = [];
|
||
/** 최소고를 편집에서 강제할지 — 사이드 「페이지 설정」 체크박스를 따라온다(기본 해제). */
|
||
let enforceMinCover = false;
|
||
const drainagePanel = createDrainagePanel({
|
||
onPipesChanged: (pipes) => {
|
||
// 횡단배수 최소 계획고(2026-08-23) — 관경·구체높이가 바뀌면 경고도 다시 본다.
|
||
minCoverTargets = minCoverPoints(pipes);
|
||
renderBalance();
|
||
callbacks?.onPipesChanged?.(pipes);
|
||
},
|
||
onBasinSelected: (chainageM) => callbacks?.onBasinSelected?.(chainageM),
|
||
onPipeSelected: (chainageM) => callbacks?.onPipeSelected?.(chainageM),
|
||
// 배수유역도 우클릭 빈 자리 메뉴 = 종단그래프와 같은 구조물군 → 종류 2단 목록
|
||
// (2026-08-18 일원화). 선택하면 사이드 폼 자동 지정(addAt 경로).
|
||
structureMenuItems: (chainage) =>
|
||
structureGroupMenuItems(structureTypes, (typeId) =>
|
||
callbacks?.onStructureTypeAdd?.(chainage, typeId),
|
||
),
|
||
});
|
||
content.append(bodyWrap, drainagePanel.root);
|
||
// 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은
|
||
// 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다.
|
||
const heightResizer = createPanelResizer({
|
||
axis: "vertical",
|
||
target: root,
|
||
cssVar: "--b05-profile-height",
|
||
direction: -1,
|
||
// 동적 하한: 종단 최소 + 열린 서브패널들의 최소 높이 합(2026-08-05 사용자 확정).
|
||
// 패널을 아래로 끌면 종단이 먼저 줄고 → 서브패널들이 같이 줄고(draw의 캐스케이드) →
|
||
// 셋 다 최소가 되면 여기서 더는 못 내려가게 막는다(뒤로 넘어가는 역전 차단).
|
||
min: () => {
|
||
const chrome = Math.max(0, root.offsetHeight - body.clientHeight);
|
||
const massMin = massHaul.overlay.hidden ? 0 : MASSHAUL_MIN_HEIGHT;
|
||
const tableMin = tableOverlay.isOpen() ? TABLE_OVERLAY_MIN_HEIGHT : 0;
|
||
return Math.max(
|
||
MIN_PANEL_HEIGHT,
|
||
chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + massMin + tableMin,
|
||
);
|
||
},
|
||
max: () => (root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO,
|
||
storageKey: HEIGHT_KEY,
|
||
});
|
||
root.append(panelHandle.root, heightResizer.root, content);
|
||
drainagePanel.load(projectId);
|
||
|
||
/* ── 리사이저 드래그 출처 추적(2026-08-05 진동·복귀 버그 수정) ──────────────
|
||
* 메인 패널을 끄는 중엔 grow(자동 확장)가 포인터와 싸우면 안 되고, 서브패널을 끄는
|
||
* 중엔 캐스케이드의 임시 축소(인라인)가 드래그 변수 값을 덮으면 안 된다. */
|
||
let mainPanelDragging = false;
|
||
let subPanelDragging = false;
|
||
/** 메인 드래그 손을 뗀 뒤 **첫 전체 재구성(draw)까지** grow 금지 — 120ms 지연 draw는
|
||
* 플래그가 이미 풀린 채 돌아, 드래그 중 금지했던 자동 확대가 손 떼는 순간 발동해
|
||
* 포인터가 정한 높이를 되돌렸다(2026-08-06 분석 원인 4). draw()가 끝나며 푼다. */
|
||
let mainDragCooldown = false;
|
||
/** 메인 드래그 시작 시점의 3영역(종단·유토곡선·테이블) 실제 높이 — 비례 연동 기준.
|
||
* 드래그 중 이 비율대로 같이 늘리고 줄인다(2026-08-06 사용자 확정). */
|
||
let mainDragRef: { chart: number; mass: number; table: number } | null = null;
|
||
heightResizer.root.addEventListener("pointerdown", () => {
|
||
mainPanelDragging = true;
|
||
const massH = massHaul.overlay.hidden ? 0 : massHaul.overlay.offsetHeight;
|
||
const tableH = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0;
|
||
mainDragRef = {
|
||
chart: Math.max(
|
||
MIN_CHART_HEIGHT,
|
||
body.clientHeight - massH - tableH - MASSHAUL_HANDLE_GUTTER_PX,
|
||
),
|
||
mass: massH,
|
||
table: tableH,
|
||
};
|
||
});
|
||
[massHaul.overlay, tableOverlay.overlay].forEach((overlay) =>
|
||
overlay.querySelector(".ui-resizer")?.addEventListener("pointerdown", () => {
|
||
subPanelDragging = true;
|
||
}),
|
||
);
|
||
const clearDragFlags = (): void => {
|
||
if (mainPanelDragging) mainDragCooldown = true;
|
||
if (mainPanelDragging && mainDragRef) {
|
||
// 비례 연동 결과를 서브패널 저장 높이로 확정한다 — 이후 모든 redraw의 판정
|
||
// 기준이 이 값이 되어 멱등성이 유지된다(확정 없이는 손을 떼는 순간 예전
|
||
// 저장 높이로 튄다). 확정 후 인라인은 걷는다(변수가 같은 값을 담는다).
|
||
if (!massHaul.overlay.hidden) massHaul.commitHeight(massHaul.overlay.offsetHeight);
|
||
if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight);
|
||
massHaul.overlay.style.height = "";
|
||
tableOverlay.overlay.style.height = "";
|
||
}
|
||
mainDragRef = null;
|
||
// 드래그 중엔 경량 동기화만 하므로, 손을 뗀 뒤 전체 재구성 1회를 예약한다.
|
||
if (mainPanelDragging || subPanelDragging) {
|
||
window.clearTimeout(resizeTimer);
|
||
resizeTimer = window.setTimeout(draw, 120);
|
||
}
|
||
mainPanelDragging = false;
|
||
subPanelDragging = false;
|
||
};
|
||
window.addEventListener("pointerup", clearDragFlags);
|
||
window.addEventListener("pointercancel", clearDragFlags);
|
||
|
||
let detail: SectionDetailResponse | null = null;
|
||
let selectedStationId: string | null = null;
|
||
let stationInterval: number | undefined;
|
||
let routeId: number | null = null;
|
||
let irregularStations: IrregularStation[] = [];
|
||
// 구조물 정본(structures.json) 목록과 타입 레지스트리 — 그래프 서클마크·벌룬에 쓴다.
|
||
let structures: StructureInstance[] = [];
|
||
let structureTypes: StructureType[] = [];
|
||
let selectedStructureId: string | null = null;
|
||
// 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지).
|
||
let stationDisplay = { station: 0, cumulative: 0 };
|
||
let base: AlignmentBase | null = null;
|
||
let alignment: ProfileAlignment | null = null;
|
||
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
|
||
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
|
||
let savedEdits: AlignmentEdits = emptyEdits();
|
||
let resizeTimer = 0;
|
||
let redrawPending = false;
|
||
let lastWidth = 0;
|
||
let lastHeight = 0;
|
||
|
||
/**
|
||
* 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시).
|
||
* 그래프(종단·유토곡선) 클릭이 모두 이 경로를 탄다. 해제는 null로 Page에 알린다.
|
||
*/
|
||
/** 측점선 ↔ 알약(구조물) 짝짓기는 `_Profile_Structures` 로 옮겼다(700줄 한계). */
|
||
const structureIdAtStation = (stationId: string | null): string | null =>
|
||
structureIdAtStationOf(stationId, irregularStations, structures);
|
||
const stationIdAtStructure = (structureId: string | null): string | null =>
|
||
stationIdAtStructureOf(structureId, irregularStations, structures);
|
||
|
||
/**
|
||
* 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시).
|
||
* 그래프(종단·유토곡선) 클릭이 모두 이 경로를 탄다. 해제는 null로 Page에 알린다.
|
||
* 같은 자리 알약도 함께 켜고 끈다(2026-08-17 사용자 지시 — 둘은 한 구조물이다).
|
||
*/
|
||
function selectStation(stationId: string | null): void {
|
||
const next = stationId !== null && stationId === selectedStationId ? null : stationId;
|
||
selectedStationId = next;
|
||
selectedStructureId = structureIdAtStation(next);
|
||
draw();
|
||
onSelectStation(next);
|
||
}
|
||
|
||
/** 계획선 샘플에서 chainage로 계획고·지반고를 되짚는 보간기(최소고 판정 입력). */
|
||
function sampleAt(chainageM: number, field: "elevation_m" | "ground_elevation_m"): number | null {
|
||
const samples = alignment?.samples;
|
||
if (!samples?.length) return null;
|
||
if (chainageM <= samples[0].chainage_m) return samples[0][field];
|
||
for (let i = 1; i < samples.length; i += 1) {
|
||
if (chainageM <= samples[i].chainage_m) {
|
||
const span = samples[i].chainage_m - samples[i - 1].chainage_m;
|
||
const t = span <= 1e-12 ? 0 : (chainageM - samples[i - 1].chainage_m) / span;
|
||
return samples[i - 1][field] + (samples[i][field] - samples[i - 1][field]) * t;
|
||
}
|
||
}
|
||
return samples[samples.length - 1][field];
|
||
}
|
||
|
||
/**
|
||
* 법정 평면 최소곡선반지름을 밑도는 측점을 센다(별표2 Ⅰ.2.다.(1), 2026-09-06).
|
||
* 반경은 횡단 측점마다 실려 오고(`plan_radius_m`), 하한은 계획선 정책이 들고 있다.
|
||
* **경고만** 낸다 — 노선을 고칠지는 사용자 판단이다.
|
||
*/
|
||
function planCurveWarning(): {
|
||
count: number;
|
||
worstRadiusM: number;
|
||
limitM: number;
|
||
hairpin: number;
|
||
} | null {
|
||
const limit = alignment?.policy.min_plan_radius_m ?? 0;
|
||
const sections = detail?.cross_sections ?? [];
|
||
if (!limit || !sections.length) return null;
|
||
const hairpinLimit = alignment?.policy.hairpin_min_radius_m ?? 0;
|
||
let count = 0;
|
||
let hairpin = 0;
|
||
let worst = Number.POSITIVE_INFINITY;
|
||
sections.forEach((section) => {
|
||
const radius = section.plan_radius_m;
|
||
if (typeof radius !== "number" || !Number.isFinite(radius) || radius >= limit) return;
|
||
count += 1;
|
||
worst = Math.min(worst, radius);
|
||
if (hairpinLimit > 0 && radius < hairpinLimit) hairpin += 1;
|
||
});
|
||
return count ? { count, worstRadiusM: worst, limitM: limit, hairpin } : null;
|
||
}
|
||
|
||
function renderBalance(): void {
|
||
const minCoverViolations = findMinCoverViolations(
|
||
minCoverTargets,
|
||
(chainageM) => sampleAt(chainageM, "ground_elevation_m"),
|
||
(chainageM) => sampleAt(chainageM, "elevation_m"),
|
||
);
|
||
renderBalanceBar({
|
||
planCurve: planCurveWarning(),
|
||
minCoverViolations,
|
||
balanceBar,
|
||
tools: tools.render(),
|
||
trailing: profileZoom.bar,
|
||
alignment,
|
||
legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal),
|
||
edited: store.edited(),
|
||
hasIrregularStations: irregularStations.length > 0,
|
||
dirty: store.dirty(),
|
||
onResetAll: () => {
|
||
store.resetAll();
|
||
onResetAll?.();
|
||
},
|
||
});
|
||
}
|
||
|
||
/* 줌 조작구(가로 배율) — 상태를 페이지가 들고 있어 편집·재계산으로 다시 그려도 유지된다.
|
||
세로는 보이는 구간에 맞춰 자동이라 사람이 맞출 것이 없다(2026-09-04 사용자 확정). */
|
||
const profileZoom = createProfileZoom(() => draw());
|
||
|
||
/**
|
||
* 구조물 측점의 누가거리를 **종단 정본 값으로 갈아 끼운다**(2026-09-04).
|
||
*
|
||
* 같은 구조물이 층마다 다른 누가거리를 들고 있었다 — 횡단 측점 `264.054626`,
|
||
* 종단 정본 변화점 `264.055`, 배수 정본 파일 `264.06`(cm 반올림). 사이드 목록이 든
|
||
* 값으로 계획고를 편집하면 정본 옆 mm 자리에 **가짜 변화점**이 하나 더 서고, 둘 사이
|
||
* 종단곡선이 mm 로 쭈그러들어 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고).
|
||
*
|
||
* 2026-09-03 에 ▲▼ 버튼 경로만 막았는데 측점 테이블·도구 경로가 남아 다시 터졌다.
|
||
* 값이 화면에 들어오는 **이 한 자리**에서 정본으로 맞춰 모든 경로를 함께 막는다.
|
||
*/
|
||
function snapToMasterStations(stations: IrregularStation[]): IrregularStation[] {
|
||
const master = alignment?.stations;
|
||
if (!master?.length) return stations;
|
||
return stations.map((station) => {
|
||
let best: number | null = null;
|
||
let gap = STATION_SNAP_TOLERANCE_M;
|
||
for (const node of master) {
|
||
const distance = Math.abs(node.chainage_m - station.chainage_m);
|
||
if (distance <= gap) {
|
||
gap = distance;
|
||
best = node.chainage_m;
|
||
}
|
||
}
|
||
return best === null || best === station.chainage_m
|
||
? station
|
||
: { ...station, chainage_m: best };
|
||
});
|
||
}
|
||
|
||
/** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */
|
||
let elevationWindow: { min: number; max: number } | undefined;
|
||
/** 세로 창을 **다시 그리지 않고** 옮기는 갱신기 — 그릴 때마다 렌더러가 새로 준다. */
|
||
let updateElevationWindow: (() => ElevationWindowResult | null) | null = null;
|
||
/** 계획고 편집 버튼(▲▼)을 누르고 있는 중인가 — 그동안 Y 축을 고정한다. */
|
||
let heightEditing = false;
|
||
const holdElevationRange = (
|
||
next: { min: number; max: number } | null,
|
||
): { min: number; max: number } | undefined => {
|
||
if (heightEditing) return elevationWindow;
|
||
elevationWindow = next ?? undefined;
|
||
return elevationWindow;
|
||
};
|
||
// 끌어 올리는 동안 축까지 따라 움직이면 조작 감각이 깨진다 — 손을 뗀 뒤 한 번만 다시 맞춘다.
|
||
body.addEventListener("pointerdown", (event) => {
|
||
if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return;
|
||
heightEditing = true;
|
||
const release = (): void => {
|
||
heightEditing = false;
|
||
window.removeEventListener("pointerup", release);
|
||
window.removeEventListener("pointercancel", release);
|
||
draw();
|
||
};
|
||
window.addEventListener("pointerup", release);
|
||
window.addEventListener("pointercancel", release);
|
||
});
|
||
|
||
/* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 로 뺐다(700줄 한계). */
|
||
const { tools, history, handleToolPick } = createPanelTools({
|
||
root,
|
||
base: () => base,
|
||
alignment: () => alignment,
|
||
edits: () => store.edits(),
|
||
applyEdits: (next) => applyEdits(next),
|
||
selectedStationId: () => selectedStationId,
|
||
irregularStations: () => irregularStations,
|
||
stationIdOf: (station) => irregularStationId(station.id),
|
||
moveStation: (station, toChainageM) =>
|
||
callbacks?.onStructureMove?.(station.chainage_m, toChainageM, station),
|
||
drainage: drainagePanel,
|
||
restore: () => {
|
||
// 세션이 정본이므로 편집 초안을 다시 읽어 그린다.
|
||
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
|
||
rebuild();
|
||
},
|
||
restoreSaved: () => store.restoreSaved(),
|
||
canRestoreSaved: () => store.canRestoreSaved(),
|
||
refresh: () => renderBalance(),
|
||
});
|
||
|
||
/** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */
|
||
function applyEdits(next: AlignmentEdits): void {
|
||
if (!base || !alignment) return;
|
||
const candidate = buildAlignment(base, next);
|
||
if (
|
||
base.policy.grade_violation_policy === "block" &&
|
||
candidate.violations.length > alignment.violations.length
|
||
) {
|
||
showToast(
|
||
`종단기울기 상한 ${base.policy.max_grade_pct.toFixed(1)}%를 넘어 편집을 적용하지 않았습니다.`,
|
||
"error",
|
||
);
|
||
return;
|
||
}
|
||
// 최소고 가드 — 편집 경로가 전부 여기로 모인다(2026-09-02 직선화·쉬프트·틸팅·방향키 누락 수정).
|
||
if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return;
|
||
store.replace(next);
|
||
history.record();
|
||
}
|
||
|
||
/**
|
||
* 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만
|
||
* 실제 렌더링하도록 모은다.
|
||
*/
|
||
function rebuild(): void {
|
||
if (base) alignment = buildAlignment(base, store.edits());
|
||
// 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 서버에 한 번 물어 전 측점을
|
||
// 다시 계산해 공유 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선이 따라 움직인다
|
||
// (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음).
|
||
scheduleCrossPreview();
|
||
if (redrawPending) return;
|
||
redrawPending = true;
|
||
requestAnimationFrame(() => {
|
||
redrawPending = false;
|
||
draw();
|
||
});
|
||
}
|
||
|
||
/** 횡단 설계 프리뷰는 `_Profile_Preview` 로 뺐다(700줄 한계). */
|
||
const crossPreview = createCrossPreview({
|
||
projectId,
|
||
detail: () => detail,
|
||
routeId: () => routeId,
|
||
edits: () => store.edits(),
|
||
debounceMs: CROSS_PREVIEW_DEBOUNCE_MS,
|
||
onApplied: () => {
|
||
draw();
|
||
callbacks?.onCrossDesignsUpdated?.();
|
||
},
|
||
});
|
||
const scheduleCrossPreview = (): void => crossPreview.schedule();
|
||
|
||
/**
|
||
* 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지
|
||
* 줄고 → ② 테이블·유토곡선이 **같이**(비례) 각자 최소까지 줄고 → ③ 셋 다 최소면
|
||
* 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기
|
||
* 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(deficit 처리).
|
||
*
|
||
* draw()(전체 재구성)와 리사이즈 중 경량 동기화가 **같은 계산**을 쓴다 — 끌 때는
|
||
* 이 함수만 프레임마다 돌리고, 무거운 차트·테이블 재구성은 손을 뗀 뒤 한 번만 한다.
|
||
*/
|
||
const heights = createHeightCascade({
|
||
root,
|
||
body,
|
||
massHaul,
|
||
tableOverlay,
|
||
isMainDragging: () => mainPanelDragging,
|
||
isSubDragging: () => subPanelDragging,
|
||
mainDragRef: () => mainDragRef,
|
||
isMainDragCooldown: () => mainDragCooldown,
|
||
hasDetail: () => detail !== null,
|
||
});
|
||
const applyHeightCascade = (allowGrow: boolean): { chartHeight: number } =>
|
||
heights.apply(allowGrow);
|
||
const scheduleLightSync = (): void => heights.scheduleLightSync();
|
||
|
||
function draw(): void {
|
||
renderProfile({
|
||
body,
|
||
callbacks,
|
||
massHaul,
|
||
tableOverlay,
|
||
store,
|
||
detail: () => detail,
|
||
alignment: () => alignment,
|
||
base: () => base,
|
||
stationInterval: () => stationInterval,
|
||
irregularStations: () => irregularStations,
|
||
minCoverTargets: () => minCoverTargets,
|
||
structures: () => structures,
|
||
structureTypes: () => structureTypes,
|
||
selectedStationId: () => selectedStationId,
|
||
setSelectedStationId: (value) => {
|
||
selectedStationId = value;
|
||
},
|
||
selectedStructureId: () => selectedStructureId,
|
||
setSelectedStructureId: (value) => {
|
||
selectedStructureId = value;
|
||
},
|
||
stationDisplay: () => stationDisplay,
|
||
setLastSize: (width, height) => {
|
||
lastWidth = width;
|
||
lastHeight = height;
|
||
},
|
||
renderBalance,
|
||
applyHeightCascade,
|
||
clearMainDragCooldown: () => {
|
||
mainDragCooldown = false;
|
||
},
|
||
selectStation,
|
||
applyEdits,
|
||
handleToolPick,
|
||
zoom: profileZoom.state,
|
||
holdElevationRange,
|
||
toolActive: () => tools.mode() !== "none",
|
||
selectedRuns: () => tools.selectedRuns(),
|
||
stationIdAtStructure,
|
||
redraw: draw,
|
||
setElevationWindowUpdater: (update) => {
|
||
updateElevationWindow = update;
|
||
},
|
||
});
|
||
}
|
||
|
||
/** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
|
||
const SCROLL_SETTLE_MS = 80;
|
||
/** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */
|
||
let settledScrollLeft = 0;
|
||
let scrollSettleTimer = 0;
|
||
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의
|
||
// 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만
|
||
// 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다.
|
||
body.addEventListener("scroll", () => {
|
||
if (heightEditing) return;
|
||
const fitted = updateElevationWindow?.() ?? null;
|
||
window.clearTimeout(scrollSettleTimer);
|
||
if (!needsFullRedraw(fitted)) return;
|
||
scrollSettleTimer = window.setTimeout(() => {
|
||
if (heightEditing || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return;
|
||
settledScrollLeft = body.scrollLeft;
|
||
draw();
|
||
}, SCROLL_SETTLE_MS);
|
||
});
|
||
|
||
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
|
||
// 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다).
|
||
attachWheelHorizontalScroll(body);
|
||
|
||
const resizeObserver = new ResizeObserver(() => {
|
||
if (
|
||
body.clientWidth <= 0 ||
|
||
body.clientHeight <= 0 ||
|
||
(Math.abs(body.clientWidth - lastWidth) < 1 && Math.abs(body.clientHeight - lastHeight) < 1)
|
||
)
|
||
return;
|
||
// 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번.
|
||
// (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고)
|
||
scheduleLightSync();
|
||
window.clearTimeout(resizeTimer);
|
||
resizeTimer = window.setTimeout(draw, 120);
|
||
});
|
||
resizeObserver.observe(body);
|
||
|
||
function setCollapsed(collapsed: boolean): void {
|
||
root.classList.toggle("is-collapsed", collapsed);
|
||
panelHandle.setOpen(!collapsed);
|
||
writeStateRaw("profile-collapsed", String(collapsed));
|
||
if (!collapsed) requestAnimationFrame(draw);
|
||
}
|
||
|
||
panelHandle.root.addEventListener("click", () =>
|
||
setCollapsed(!root.classList.contains("is-collapsed")),
|
||
);
|
||
setCollapsed(readStateRaw("profile-collapsed") === "true");
|
||
|
||
/** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */
|
||
function syncDrainageSpans(): void {
|
||
drainagePanel.setIntervalSpans(routeSpansFromStructures(structures, structureTypes));
|
||
}
|
||
|
||
return {
|
||
root,
|
||
render(nextDetail: SectionDetailResponse, nextStationInterval?: number, nextRouteId?: number) {
|
||
detail = nextDetail;
|
||
stationInterval = nextStationInterval;
|
||
const stored = readAlignment(nextDetail.longitudinal);
|
||
// 재탐색으로 경로(routeId)가 바뀔 때, 사용자가 조작한 편집이 있으면 **새 경로에 이월**한다.
|
||
// 편집은 chainage 키라 새 base에 그대로 재적용된다(범위 밖·미매칭 변화점은 best-effort로 드롭).
|
||
const routeChanged = nextRouteId !== routeId;
|
||
const carried = routeChanged && store.edited() ? store.edits() : null;
|
||
// 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다.
|
||
if (routeChanged || !store.dirty()) {
|
||
routeId = nextRouteId ?? routeId;
|
||
savedEdits = stored?.edits ?? emptyEdits();
|
||
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
|
||
}
|
||
base = stored ? toAlignmentBase(stored) : null;
|
||
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
|
||
if (carried) store.replace(carried);
|
||
alignment = base ? buildAlignment(base, store.edits()) : null;
|
||
// 유토곡선 balloon 위치 캐시 — B06과 **같은 scope**를 써서 두 화면이 같은 자리를 공유한다.
|
||
// 영구저장소 값(detail.balloon_offsets)이 있으면 그것이 이긴다(B06과 같은 규칙).
|
||
configureBalloonOffsets(
|
||
`${projectId}:${routeId ?? "-"}`,
|
||
nextDetail.balloon_offsets ?? undefined,
|
||
);
|
||
draw();
|
||
requestAnimationFrame(draw);
|
||
// 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 —
|
||
// 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D
|
||
// 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인).
|
||
//
|
||
// 비교 대상은 **편집이 반영된 계획선**이다. 저장분끼리 견주면, 세션에 미저장 편집이
|
||
// 남은 채로 새로고침했을 때 「어긋남 없음」으로 나와 재계산이 예약되지 않고, 그리기
|
||
// 쪽은 편집분 기준으로 어긋났다고 보아 유토곡선이 영영 빈 화면이 됐다
|
||
// (2026-09-03 사용자 보고 — 편집 측점 2개가 세션에 남은 상태에서 재현).
|
||
if (
|
||
hasStaleDesigns({
|
||
longitudinal: {
|
||
design_profiles: alignment
|
||
? [toDesignProfile(alignment, nextDetail.longitudinal.design_profiles?.[0])]
|
||
: nextDetail.longitudinal.design_profiles,
|
||
},
|
||
cross_sections: nextDetail.cross_sections,
|
||
})
|
||
) {
|
||
scheduleCrossPreview();
|
||
}
|
||
},
|
||
/** 라이브 계획선 샘플(편집 반영분) — 3D 측점 바·코리도가 이걸 본다(2026-08-23).
|
||
* 편집 전·base 없음이면 null — 호출부는 정본 design_profiles로 폴백한다. */
|
||
alignmentSamples: () => alignment?.samples ?? null,
|
||
/**
|
||
* 계획 유토곡선 계산에 필요한 프로젝트 설정(토량환산계수·운반장비 경계·노반폭).
|
||
* Page가 `fetchSectionContext()` 응답에서 뽑아 넘긴다 — 프론트에 사본을 두지 않는다.
|
||
*/
|
||
setEarthworkContext(next: RouteMassHaulContext | null) {
|
||
massHaul.setContext(next);
|
||
draw();
|
||
},
|
||
setSelectedStation(stationId: string | null) {
|
||
selectedStationId = stationId;
|
||
// 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17).
|
||
selectedStructureId = structureIdAtStation(stationId);
|
||
draw();
|
||
},
|
||
/** 종단기울기 상한(%)을 즉시 갈아 끼운다 — 페이지 설정의 지형 구분·등급·세부
|
||
* 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19
|
||
* 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */
|
||
/** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */
|
||
setEnforceMinCover(enforce: boolean) {
|
||
enforceMinCover = enforce;
|
||
},
|
||
setGradeLimit(maxGradePct: number) {
|
||
if (!base || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
|
||
if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return;
|
||
base = { ...base, policy: { ...base.policy, max_grade_pct: maxGradePct } };
|
||
alignment = buildAlignment(base, store.edits());
|
||
draw();
|
||
},
|
||
/** 확정된 노선 평면 선형을 우측 배수유역 지도에 겹친다(사업지 좌표계 m). */
|
||
setRoutePolyline(points: ReadonlyArray<{ x: number; y: number }>) {
|
||
drainagePanel.setRoute(points);
|
||
},
|
||
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
|
||
setIrregularStations(stations: IrregularStation[]) {
|
||
irregularStations = snapToMasterStations(stations);
|
||
draw();
|
||
},
|
||
/** 구조물 타입 레지스트리를 받아 마크 색·약호·우클릭 메뉴에 쓴다(최초 1회). */
|
||
setStructureTypes(types: StructureType[]) {
|
||
structureTypes = types;
|
||
syncDrainageSpans();
|
||
draw();
|
||
},
|
||
/** 구조물 정본 목록을 반영해 그래프 서클마크를 다시 그린다. */
|
||
setStructures(next: StructureInstance[]) {
|
||
structures = next;
|
||
if (selectedStructureId && !next.some((s) => s.structure_id === selectedStructureId)) {
|
||
selectedStructureId = null;
|
||
}
|
||
syncDrainageSpans();
|
||
draw();
|
||
},
|
||
/** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */
|
||
setSelectedStructure(structureId: string | null) {
|
||
selectedStructureId = structureId;
|
||
selectedStationId = stationIdAtStructure(structureId);
|
||
draw();
|
||
},
|
||
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
|
||
setStationDisplay(next: { station: number; cumulative: number }) {
|
||
stationDisplay = next;
|
||
draw();
|
||
},
|
||
/**
|
||
* 특정 chainage의 계획고 편집(station_offset·curve_radii)을 지운다.
|
||
* 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다.
|
||
*/
|
||
resetStationEdit(chainageM: number) {
|
||
const key = chainageKey(chainageM);
|
||
const edits = store.edits();
|
||
if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return;
|
||
store.resetStation(chainageM);
|
||
},
|
||
isDirty: () => store.dirty(),
|
||
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
|
||
async save(): Promise<void> {
|
||
if (!routeId || !store.dirty()) return;
|
||
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
|
||
const next = saved.profile_alignment as ProfileAlignment | undefined;
|
||
if (next?.base_pvi?.length) {
|
||
base = toAlignmentBase(next);
|
||
alignment = next;
|
||
if (detail) detail.longitudinal.profile_alignment = next;
|
||
}
|
||
store.markSaved();
|
||
draw();
|
||
},
|
||
clear() {
|
||
detail = null;
|
||
base = null;
|
||
alignment = null;
|
||
selectedStationId = null;
|
||
balanceBar.replaceChildren();
|
||
body.replaceChildren(empty);
|
||
},
|
||
/** 배수유역도 패널 — 경로 확정 흐름에서 유역선 편집 저장 여부를 묻는 데 쓴다. */
|
||
drainage: drainagePanel,
|
||
/** 그래프 영역 로딩 서클. 문구를 주면 켜고 null이면 끈다. */
|
||
setLoading(label: string | null) {
|
||
progress.root.hidden = label === null;
|
||
if (label !== null) progress.set(null, label);
|
||
},
|
||
dispose() {
|
||
window.clearTimeout(resizeTimer);
|
||
crossPreview.dispose();
|
||
resizeObserver.disconnect();
|
||
heightResizer.dispose();
|
||
window.removeEventListener("pointerup", clearDragFlags);
|
||
window.removeEventListener("pointercancel", clearDragFlags);
|
||
},
|
||
};
|
||
}
|