Files
Aislo/B05_Profile/B05_Profile_UI_Profile_Panel.ts
T
eomsangdon 97dd7cb968 feat(B05): 구조물 전역 선택 동기화 — 어느 화면에서 골라도 부속 옵션 폼 개방
- 갭: 배관 부속 폼(시설 편집)이 배수유역도 마커 직접 클릭에서만 열림 —
  리스트·종단 그래프·3D·유역 클릭은 강조만 되고 폼 미개방 (2026-08-17 확인)
- DrainagePanel.selectPipeAtChainage 신설: 밖에서 관 선택 시 마커+폼+유역
  강조까지 (같으면 무시). onPipeSelected 콜백: 유역 없는 관도 전 화면 전파,
  같은 값 재알림 억제(재계산 후 선택 풀림 방지)
- SelectionSync.selectPipeForm 포트: 그래프·3D(syncIrregularSelection)·유역
  (selectStationOfPipe) 경로에서 폼 개방. 리스트(onPipeFacilitySelect) 배선
- 재진입은 기존 selectionSyncing 가드 차단. typecheck/build 통과
2026-08-17 14:27:56 +09:00

1099 lines
56 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Profile_Panel.ts
* 하단 종단면도 패널 — 그래프 + 도면 테이블 2단, 계획선 직접 편집.
*
* 화면 높이의 60%를 쓰며, 그래프와 12행 도면 테이블이 **하나의 가로 스크롤러** 안에
* 같은 폭으로 쌓여 X축이 자동으로 맞물린다(스크롤 동기화 코드 불필요).
* 본문 세로는 그래프 40% : 테이블 60%로 나눈다.
*
* 편집은 전부 프론트에서 즉시 계산해 다시 그리고, 영속화는 [확정] 시점에
* `saveProfileAlignment()`로 편집 델타만 보낸다.
* ========================================================================== */
import type {
LongitudinalSection,
SectionDetailResponse,
} from "../B06_Section/B06_Section_Api_Fetch";
import {
createLongitudinalProfile,
longitudinalMinimumWidth,
} from "../B06_Section/B06_Section_UI_Longitudinal";
import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createPanelResizer } from "@ui/ui_template_resizer";
import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel";
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
import { mountStructureMarks } from "./B05_Profile_UI_Structures_Marks";
import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures";
import {
createProfileTableOverlay,
TABLE_OVERLAY_MIN_HEIGHT,
} from "./B05_Profile_UI_Profile_TableOverlay";
import {
hasLegacyAlignment,
normalizedLongitudinal,
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 { previewCrossDesigns } from "../B06_Section/B06_Section_Api_Fetch";
import type {
AlignmentBase,
AlignmentEdits,
ProfileAlignment,
} from "./B05_Profile_UI_Profile_Alignment";
import {
adjustStation,
buildAlignment,
chainageKey,
emptyEdits,
setCurveRadius,
shiftSegment,
toAlignmentBase,
} from "./B05_Profile_UI_Profile_Alignment";
import { createEditOverlay, createProfileEditStore } from "./B05_Profile_UI_Profile_Edit";
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
import {
buildStickyYAxis,
createRouteMassHaulPanel,
MASSHAUL_MIN_HEIGHT,
type RouteMassHaulContext,
} from "./B05_Profile_UI_Profile_MassHaul";
import {
createProfileTable,
tableCellWidthFor,
TABLE_TARGET_FONT_PX,
} from "./B05_Profile_UI_Profile_Table";
import {
irregularLabel,
irregularStationId,
type IrregularStation,
} from "./B05_Profile_UI_IrregularStations";
import type { SectionStation } from "../B06_Section/B06_Section_Api_Fetch";
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";
const COLLAPSED_KEY = "b05-route-profile-collapsed";
/** 드래그로 조절한 하단 패널 높이(px) 보관 키 — 브라우저 세션 동안만 유지한다. */
const HEIGHT_KEY = "b05-route-profile-height";
/* 테이블 높이는 오버레이 서브패널(--b05-table-height 리사이저)이 관리한다 —
예전 4:6 고정 분할(TABLE_HEIGHT_KEY·CHART_HEIGHT_RATIO)은 폐지(2026-08-05). */
const MIN_CHART_HEIGHT = 100;
/** 접힌 유토곡선 손잡이(24px)가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게. */
const MASSHAUL_HANDLE_GUTTER_PX = 26;
/** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */
const MIN_PANEL_HEIGHT = 180;
/** 상한은 3D 뷰포트가 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */
const MAX_PANEL_HEIGHT_RATIO = 0.9;
/** 테이블 12행. 행 높이와 셀 폭에서 글자 크기를 정하는 데 쓴다. */
const TABLE_ROW_COUNT = 12;
/** 계획고 편집 후 횡단 재계산을 서버에 묻기까지 기다리는 시간(ms).
* ▲/▼ 길게 누르기(초당 10회)로 요청이 쏟아지지 않게 마지막 값만 보낸다. */
const CROSS_PREVIEW_DEBOUNCE_MS = 250;
/**
* 저장된 계획선 선형을 읽되 **모양을 먼저 검증한다**.
*
* 곡선 기준이 길이(L)에서 반경(R)으로 바뀌기 전에 만들어진 데이터는 `policy`에
* `default_curve_radius_m`가 없어 그대로 쓰면 계산 도중 터진다. 그런 데이터는
* 편집 기능을 끄고(지반선·계획선 차트만 표시) 재계산을 안내하는 편이 안전하다.
*/
/** 측점 사이 여백 — 이웃 셀끼리 붙어 보이지 않게 띄운다. */
const CELL_GAP_PX = 2;
/**
* 측점 한 칸의 **기준 폭(px)** — 목표 글자 크기(12px)로 7자리 값(`3000.00`)이 잘리지 않는 크기.
* 실제 기본 간격은 여기에 배수(`PROFILE_SPACING_MULTIPLIER`)를 곱한 값을 쓴다.
*/
const STATION_SPACING_PX = tableCellWidthFor(TABLE_TARGET_FONT_PX) + CELL_GAP_PX;
/**
* **측점 간격 기본 배수**. 기준 폭의 1.5배를 한 측점 칸의 기본 간격으로 삼는다.
*
* 이 배수로 펼친 폭이 **최소 폭**이다 — 브라우저가 이보다 넓으면 폭맞춤으로 늘리고, 좁으면
* 이 간격을 유지한 채 스크롤로 훑는다. 넉넉한 기본값은 다음 세션의 **비정규 측점**(`+18` 등)이
* 규칙 칸 안에서 chainage 비례로 자리 잡을 여유도 함께 확보한다.
*/
const PROFILE_SPACING_MULTIPLIER = 1.5;
/** 유효 표고 샘플 기준의 노선 최대 chainage(m). 그래프·테이블이 같은 값을 써야 X축이 맞물린다. */
function maxChainageOf(data: LongitudinalSection): number {
const samples = normalizedLongitudinal(data).samples.filter(
(sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN),
);
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,
width: number,
originOffset: number,
): (chainage: number) => number {
const maxChainage = maxChainageOf(data);
const plotWidth = width - LONG_PAD.left - LONG_PAD.right - 2 * originOffset;
return (chainage: number) => LONG_PAD.left + originOffset + (chainage / maxChainage) * plotWidth;
}
/** `chainageMapper`의 역변환 — 구조물 라인을 끌 때 화면 x를 누가거리로 되돌린다. */
function chainageInverter(
data: LongitudinalSection,
width: number,
originOffset: number,
): (px: number) => number {
const maxChainage = maxChainageOf(data);
const plotWidth = width - LONG_PAD.left - LONG_PAD.right - 2 * originOffset;
return (px: number) =>
plotWidth > 0 ? ((px - LONG_PAD.left - originOffset) / plotWidth) * maxChainage : 0;
}
interface ProfileLayout {
/** 캔버스 폭(px). 화면이 넓으면 폭맞춤으로, 좁으면 최소 폭으로. */
width: number;
/** 0측점·종점을 축 프레임 안으로 반 칸씩 들여쓰는 여백(px) — 그래프·테이블 공통. */
originOffset: number;
/** 이웃 측점과 겹치지 않는 테이블 셀 폭(px). 실제 측점 간격에 맞춰 함께 늘어난다. */
cellWidth: number;
}
/**
* 측점 간격 기본값(기준 폭 × 1.5)으로 노선을 펼치되, 화면이 더 넓으면 폭맞춤으로 늘린다.
*
* 매핑은 `x(c) = LONG_PAD.left + halfCell + c·pxPerMeter`이고, 좌우로 반 칸(halfCell)씩 띄워
* 0측점 셀이 이름표 열 밖으로, 종점 셀이 오른쪽 끝 밖으로 나오게 한다. 좌우 여백을 합치면
* 한 칸(측점간격)이므로 `width = pads + (maxChainage + interval)·pxPerMeter`가 되고, 이를 뒤집어
* pxPerMeter를 구하면 halfCell·셀 폭이 실제 간격과 항상 맞물린다.
*/
function computeProfileLayout(
data: LongitudinalSection,
stationIntervalM: number,
availableWidth: number,
): ProfileLayout {
const maxChainage = maxChainageOf(data);
const interval = Math.max(stationIntervalM, 1e-6);
const framePad = LONG_PAD.left + LONG_PAD.right;
const minSpacing = STATION_SPACING_PX * PROFILE_SPACING_MULTIPLIER;
// 기본 배수로 펼친 최소 폭 (좌우 반 칸 = 한 칸 여백 포함).
const minWidth = framePad + ((maxChainage + interval) / interval) * minSpacing;
const width = Math.max(minWidth, availableWidth);
const pxPerMeter = (width - framePad) / (maxChainage + interval);
const spacing = interval * pxPerMeter;
return { width, originOffset: spacing / 2, cellWidth: Math.max(1, spacing - CELL_GAP_PX) };
}
/** 종단 테이블 구조물 라인·배수유역도가 Page로 올려 보내는 알림. */
export interface RouteProfilePanelCallbacks {
/** 관 매설 목록이 바뀜 — 화면의 배관 투영·통합 목록을 이 목록으로 맞춘다.
* 유효직경(mm)은 관경 자동 지정, 시설 종류·구간은 통합 표시에 쓴다(2026-08-17). */
onPipesChanged?: (
pipes: Array<{
chainage_m: number;
effective_diameter_mm: number | null;
facility: PipeFacility;
start_m?: number;
end_m?: number;
}>,
) => void;
/** 테이블에서 구조물 라인을 끌어 옮김. */
onStructureMove?: (fromChainageM: number, toChainageM: number, station: IrregularStation) => void;
/** 테이블 우클릭으로 구조물(배관 포함)을 지움. */
onStructureRemove?: (station: IrregularStation) => void;
/** 테이블 빈 자리 우클릭으로 배관을 넣음. */
onPipeAdd?: (chainageM: number) => void;
/** 종단·배수유역도 우클릭으로 배관 외 구조물(기성막이/대피로/기타)을 넣음. */
onStructureAdd?: (chainageM: number, type: "기성막이" | "대피로" | "기타") => void;
/** 구조물 라인을 눌러 고름. */
onIrregularSelect?: (station: IrregularStation) => void;
/** 종단 그래프에서 구조물 서클마크를 고름(해제면 null). */
onStructureSelect?: (structureId: string | null) => void;
/** 종단 그래프에서 구조물 서클마크를 끌어 옮김. */
onStructureMarkMove?: (structureId: string, toChainageM: number) => void;
/** 종단 그래프 우클릭으로 레지스트리 타입을 지정해 구조물을 넣음. */
onStructureTypeAdd?: (chainageM: number, typeId: string) => void;
/** 배수유역도에서 유역을 고름 — 그 관의 누가거리(해제면 null). 그래프·사이드 패널을 맞춘다. */
onBasinSelected?: (chainageM: number | null) => void;
/** 배수유역도에서 관 마커를 고름(유역 없는 관 포함) — 전 화면 동기화용(2026-08-17). */
onPipeSelected?: (chainageM: number | null) => void;
}
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();
};
const massHaul = createRouteMassHaulPanel(subPanelChanged);
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";
// 관 목록이 바뀌면 종단 테이블의 "배관" 구조물 라인도 같이 맞춘다(정본은 관 지점 파일).
const drainagePanel = createDrainagePanel({
onPipesChanged: (pipes) => callbacks?.onPipesChanged?.(pipes),
onBasinSelected: (chainageM) => callbacks?.onBasinSelected?.(chainageM),
onPipeSelected: (chainageM) => callbacks?.onPipeSelected?.(chainageM),
onStructureAdd: (chainage, type) => callbacks?.onStructureAdd?.(chainage, type),
});
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 resizeTimer = 0;
let redrawPending = false;
let lightSyncPending = false;
/** 횡단 설계 프리뷰 디바운스 타이머와 최신 요청 번호(늦게 온 응답 버리기용). */
let crossPreviewTimer = 0;
let crossPreviewSeq = 0;
let lastWidth = 0;
let lastHeight = 0;
/**
* 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시).
* 그래프(종단·유토곡선) 클릭이 모두 이 경로를 탄다. 해제는 null로 Page에 알린다.
*/
function selectStation(stationId: string | null): void {
const next = stationId !== null && stationId === selectedStationId ? null : stationId;
selectedStationId = next;
draw();
onSelectStation(next);
}
function renderBalance(): void {
balanceBar.replaceChildren();
if (!alignment) {
if (detail && hasLegacyAlignment(detail.longitudinal)) {
const note = document.createElement("span");
note.className = "b05-route-profile__balance-warning";
note.textContent =
"⚠ 계획선 데이터가 구버전 형식입니다 — [최적 경로 계산]을 다시 실행하세요.";
balanceBar.append(note);
}
return;
}
const { balance, policy, violations } = alignment;
const entries: Array<[string, string, string?]> = [
["절토", `${balance.cut_area_m2.toFixed(1)} m²`, "cut"],
["성토", `${balance.fill_area_m2.toFixed(1)} m²`, "fill"],
[
"불균형",
`${balance.imbalance_percent.toFixed(1)} % / 허용 ${balance.tolerance_percent.toFixed(0)} %`,
balance.within_tolerance ? undefined : "over",
],
["변화점", `${alignment.pvi.length} 개`],
["종단곡선", `${alignment.curves.filter((curve) => !curve.omitted).length} 개`],
// 기준은 길이 L이다. 옛 저장분(L 없음)만 그 시절 기준인 R을 그대로 밝혀 적는다.
Number.isFinite(policy.default_curve_length_m)
? ["기본 곡선길이 L", `${(policy.default_curve_length_m as number).toFixed(1)} m`]
: ["기본 R(옛 저장분)", `${policy.default_curve_radius_m.toFixed(1)} m`],
];
const editedCount = Object.keys(alignment.edits.station_offsets).length;
if (editedCount) entries.push(["편집 측점", `${editedCount} 개`, "edited"]);
entries.forEach(([label, value, tone]) => {
const item = document.createElement("span");
item.className = `b05-route-profile__balance-item${tone ? ` is-${tone}` : ""}`;
const caption = document.createElement("em");
caption.textContent = label;
item.append(caption, document.createTextNode(value));
balanceBar.append(item);
});
if (violations.length) {
const warning = document.createElement("span");
warning.className = "b05-route-profile__balance-warning";
warning.textContent = `⚠ 종단기울기 초과 ${violations.length}개 구간`;
warning.title = violations
.map((item) => `구간 ${item.segment_index + 1}: ${item.value.toFixed(2)}% > ${item.limit}%`)
.join("\n");
balanceBar.append(warning);
}
if (store.edited() || irregularStations.length) {
const reset = document.createElement("button");
reset.type = "button";
reset.className = "b05-route-profile__balance-reset";
reset.textContent = "초기선 복원";
reset.title = "모든 편집과 추가한 비정규 측점을 지우고 자동 산출된 계획선으로 되돌립니다.";
reset.addEventListener("click", () => {
store.resetAll();
onResetAll?.();
});
balanceBar.append(reset);
}
if (store.dirty()) {
const badge = document.createElement("span");
badge.className = "b05-route-profile__balance-item is-unsaved";
badge.textContent = "미저장 (확정 시 반영)";
balanceBar.append(badge);
}
}
/** 편집을 적용한다. 법정 위반 정책이 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;
}
store.replace(next);
}
/**
* 편집 후 다시 그린다. 길게 누르기(초당 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();
});
}
/**
* 횡단 설계 프리뷰 요청 — 끌기 중에는 계속 호출되므로 마지막 값만 보낸다.
* 응답이 늦게 와도 그 사이 편집이 더 있었으면 버린다(seq 비교).
*/
function scheduleCrossPreview(): void {
if (!detail || routeId === null) return;
window.clearTimeout(crossPreviewTimer);
crossPreviewTimer = window.setTimeout(() => {
if (!detail || routeId === null) return;
const seq = (crossPreviewSeq += 1);
const targetRouteId = routeId;
void previewCrossDesigns(projectId, targetRouteId, store.edits())
.then((next) => {
if (seq !== crossPreviewSeq || !detail || routeId !== targetRouteId) return;
// 공유 캐시가 들고 있는 **같은 객체**를 제자리 갱신한다 — B06이 이 객체를 그대로
// 보므로 페이지를 넘어가도 다시 받을 필요가 없다. 응답에는 바뀌는 값(설계·계획선)만
// 오므로 지반선 샘플은 건드리지 않는다.
const designByChainage = new Map(
next.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]),
);
for (const section of detail.cross_sections) {
const patch = designByChainage.get(section.chainage_m.toFixed(3));
// 부분 갱신 — 온 필드만 덮어쓴다. 설계선 좌표 등 안 온 값은 B06이 상세를
// 받을 때 정확한 값으로 채워지므로 여기서 지우지 않는다.
if (patch && section.design) section.design = { ...section.design, ...patch };
}
draw();
})
.catch(() => {
/* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */
});
}, CROSS_PREVIEW_DEBOUNCE_MS);
}
/**
* 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지
* 줄고 → ② 테이블·유토곡선이 **같이**(비례) 각자 최소까지 줄고 → ③ 셋 다 최소면
* 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기
* 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(deficit 처리).
*
* draw()(전체 재구성)와 리사이즈 중 경량 동기화가 **같은 계산**을 쓴다 — 끌 때는
* 이 함수만 프레임마다 돌리고, 무거운 차트·테이블 재구성은 손을 뗀 뒤 한 번만 한다.
*/
function applyHeightCascade(allowGrow: boolean): { chartHeight: number } {
const available = Math.max(120, body.clientHeight);
const massOpen = !massHaul.overlay.hidden;
const tableOpen = tableOverlay.isOpen();
// 판정 기준은 항상 **저장 높이**(리사이저 변수·기본값)다 — 임시 축소가 반영된 화면
// 높이(offsetHeight)로 재면 "줄임 → 충분해 보임 → 풀림 → 부족 → 다시 줄임"이
// 호출마다 번갈아 도는 진동이 되고, 풀리는 호출의 deficit 처리가 메인 패널을
// 멋대로 키운다(2026-08-06 분석: 튐·자동 확대의 핵심 원인). 저장 높이 기준이면
// 몇 번을 호출해도 같은 답이 나온다(멱등).
const massDesired = massOpen ? massHaul.desiredHeight() : 0;
const tableDesired = tableOpen ? tableOverlay.desiredHeight() : 0;
let massHeight = massDesired;
let tableOverlayHeight = tableDesired;
const budget = Math.max(0, available - MIN_CHART_HEIGHT - MASSHAUL_HANDLE_GUTTER_PX);
// ── 메인 드래그 중: 3영역 비례 연동(2026-08-06 사용자 확정) ─────────────
// 드래그 시작 시점 비율(mainDragRef)대로 종단·유토곡선·테이블을 같이 늘리고
// 줄인다. 최소에 닿은 영역은 거기서 멈추고 남은 영역끼리 다시 비례 배분한다.
// 손을 떼면 clearDragFlags가 이 결과를 저장 높이로 확정한다.
if (mainPanelDragging && mainDragRef) {
const content = Math.max(0, available - MASSHAUL_HANDLE_GUTTER_PX);
const items = [
{ key: "chart", ref: Math.max(1, mainDragRef.chart), min: MIN_CHART_HEIGHT },
...(massOpen
? [{ key: "mass", ref: Math.max(1, mainDragRef.mass), min: MASSHAUL_MIN_HEIGHT }]
: []),
...(tableOpen
? [{ key: "table", ref: Math.max(1, mainDragRef.table), min: TABLE_OVERLAY_MIN_HEIGHT }]
: []),
];
// 고정 → 재배분 반복: 축소 배율로 최소를 뚫는 항목을 최소에 고정하고, 남은
// 공간을 나머지 항목끼리 원래 비율로 나눈다. 항목이 3개라 최대 3회에 끝난다.
const out: Record<string, number> = {};
let pool = content;
let active = items;
while (active.length) {
const refSum = active.reduce((sum, item) => sum + item.ref, 0);
const scale = pool / refSum;
const pinned = active.filter((item) => item.ref * scale < item.min);
if (!pinned.length) {
active.forEach((item) => (out[item.key] = Math.round(item.ref * scale)));
break;
}
pinned.forEach((item) => {
out[item.key] = item.min;
pool -= item.min;
});
active = active.filter((item) => !pinned.includes(item));
}
// 서브패널 상한(개별 리사이저 max와 같은 비율)은 안전상 유지 — 넘치면 종단이 흡수.
massHeight = massOpen ? Math.min(out.mass ?? 0, Math.round(available * 0.8)) : 0;
tableOverlayHeight = tableOpen ? Math.min(out.table ?? 0, Math.round(available * 0.75)) : 0;
if (massOpen) massHaul.overlay.style.height = `${massHeight}px`;
if (tableOpen) tableOverlay.overlay.style.height = `${tableOverlayHeight}px`;
// 손잡이는 바뀐 인라인 높이의 위 경계를 즉시 따라간다.
massHaul.syncHandle();
tableOverlay.setBottomOffset(massHeight);
return {
chartHeight: Math.max(MIN_CHART_HEIGHT, content - massHeight - tableOverlayHeight),
};
}
if (subPanelDragging) {
// 서브패널 리사이저를 끄는 중이면 그 높이가 **사용자 의도**다 — 임시 축소(인라인)를
// 걷어 변수(드래그 값)가 그대로 보이게 하고, 자리가 모자라면 아래 deficit 처리로
// 메인 패널을 키운다. 변수는 드래그마다 갱신되므로 저장 높이 = 드래그 값이다.
massHaul.overlay.style.height = "";
tableOverlay.overlay.style.height = "";
} else if (massDesired + tableDesired > budget) {
// ② 같이 줄이기 — 줄일 수 있는 여유분에 비례해 축소, 각자 최소 높이 하한.
// 자리가 다시 늘면 ratio가 매끄럽게 0으로 줄어 저장 높이로 연속 복귀한다.
const massMin = massOpen ? MASSHAUL_MIN_HEIGHT : 0;
const tableMin = tableOpen ? TABLE_OVERLAY_MIN_HEIGHT : 0;
const shrinkable = massDesired - massMin + (tableDesired - tableMin);
const over = massDesired + tableDesired - budget;
const ratio = shrinkable > 0 ? Math.min(1, over / shrinkable) : 1;
massHeight = Math.round(massDesired - (massDesired - massMin) * ratio);
tableOverlayHeight = Math.round(tableDesired - (tableDesired - tableMin) * ratio);
// 임시 축소는 인라인 높이로만 — 리사이저 저장값(--변수·세션)은 건드리지 않아
// 패널을 다시 키우면 원래 높이로 돌아온다.
if (massOpen) massHaul.overlay.style.height = `${massHeight}px`;
if (tableOpen) tableOverlay.overlay.style.height = `${tableOverlayHeight}px`;
} else {
// 공간 충분 — 임시 축소 해제(저장 높이로 복귀).
massHaul.overlay.style.height = "";
tableOverlay.overlay.style.height = "";
}
// 접힌 유토곡선 손잡이는 바닥 고정(순서상 테이블 아래에서 나옴), 접힌 테이블 손잡이는
// 열린 유토곡선 위 경계(TableOverlay.syncHandlePosition).
tableOverlay.setBottomOffset(massHeight);
// ③' 최소까지 줄여도 모자라면(서브패널 펼침·서브패널 드래그로 자리가 부족한 경우)
// 메인 패널을 키운다. 단 **메인 패널을 끄는 중과 손 뗀 직후 첫 재구성까지**는 절대
// 안 된다 — 포인터가 정한 높이를 되돌려 서로 밀고 당기는 진동이 생긴다.
const deficit =
MIN_CHART_HEIGHT + massHeight + tableOverlayHeight + MASSHAUL_HANDLE_GUTTER_PX - available;
if (deficit > 1 && allowGrow && !mainPanelDragging && !mainDragCooldown) {
// 상한은 메인 리사이저 max와 **같은 식**이어야 한다 — 예전 window 92% 상한은
// 리사이저 상한(부모 90%)보다 높아, 자동 확대 직후 손잡이를 잡는 순간 낮은
// 상한으로 clamp되며 패널이 뚝 떨어졌다(2026-08-06 분석).
const growCap = Math.round(
(root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO,
);
const grown = Math.min(root.offsetHeight + deficit, growCap);
if (grown > root.offsetHeight + 1) {
root.style.setProperty("--b05-profile-height", `${grown}px`);
// ResizeObserver가 새 높이로 draw를 다시 부른다 — 이번 프레임은 그대로 마저 그린다.
}
}
return {
chartHeight: Math.max(
MIN_CHART_HEIGHT,
available - massHeight - tableOverlayHeight - MASSHAUL_HANDLE_GUTTER_PX,
),
};
}
/** 경량 동기화를 다음 프레임에 1회 예약 — 메인 리사이즈(ResizeObserver)와
* 서브패널 드래그(onResize 알림)가 같은 스로틀을 공유한다. */
function scheduleLightSync(): void {
if (lightSyncPending) return;
lightSyncPending = true;
requestAnimationFrame(() => {
lightSyncPending = false;
syncHeightsLight();
});
}
/** 리사이즈 중 프레임당 경량 동기화 — 차트·테이블 재구성 없이 높이만 맞춰 끊김을 없앤다.
* SVG는 잠깐 세로 스케일되지만, 손을 떼면(디바운스) 전체 재그리기가 정확히 다시 그린다. */
function syncHeightsLight(): void {
if (!detail) return;
// 서브패널을 끄는 중엔 즉시 메인 패널을 밀어 올려야(grow) 드래그가 자연스럽다.
const { chartHeight } = applyHeightCascade(subPanelDragging);
const canvas = body.firstElementChild as HTMLElement | null;
if (!canvas || !canvas.classList.contains("b05-profile__canvas")) return;
canvas.style.height = `${chartHeight}px`;
const chartWrap = canvas.querySelector<HTMLElement>(".b05-profile__chart");
if (chartWrap) {
chartWrap.style.height = `${chartHeight}px`;
const svg = chartWrap.querySelector("svg");
if (svg) svg.style.height = `${chartHeight}px`;
}
}
function draw(): void {
if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return;
lastWidth = body.clientWidth;
lastHeight = body.clientHeight;
// 편집할 때마다 본문을 갈아끼우므로 보고 있던 가로 위치를 잃지 않게 되돌린다.
const scrollLeft = body.scrollLeft;
renderBalance();
const longitudinal = detail.longitudinal;
const availableWidth = Math.max(1, body.clientWidth - 15);
// 계획선(편집 가능) 상태에서는 측점 간격 기본값(기준×1.5)으로 펼치되 화면이 넓으면
// 폭맞춤으로 늘린다. 그 외(구버전·플레인 뷰)는 예전처럼 화면 폭에 맞춰 펼친다.
const stationIntervalM = stationInterval ?? alignment?.policy.station_interval_m;
const layout =
alignment && stationIntervalM
? computeProfileLayout(longitudinal, stationIntervalM, availableWidth)
: {
width: Math.max(
availableWidth,
longitudinalMinimumWidth(longitudinal, stationInterval),
),
originOffset: 0,
cellWidth: STATION_SPACING_PX - CELL_GAP_PX,
};
const { width, originOffset } = layout;
const canvas = document.createElement("div");
canvas.className = "b05-profile__canvas";
canvas.style.width = `${width}px`;
const x = chainageMapper(longitudinal, width, originOffset);
// 그래프 40% : 테이블 60% (상단 정보 라인은 본문 밖이라 애초에 빠져 있다).
// 가로 스크롤바를 `overflow-x: scroll`로 항상 띄우므로 clientHeight에서 이미 빠져 있다.
const { chartHeight } = applyHeightCascade(true);
// 전체 재구성이 한 번 돌면 배치가 확정된 것 — 다음 캐스케이드부터 grow를 다시 허용한다.
mainDragCooldown = false;
const tableHeight = tableOverlay.contentHeight();
const table =
alignment && tableOverlay.isOpen() && tableHeight > 0
? createProfileTable({
alignment,
stationInterval: stationInterval ?? alignment.policy.station_interval_m,
width,
height: tableHeight,
// 셀 폭은 실제 측점 간격에 맞춰 함께 늘어난다(폭맞춤 시 값이 넓게 퍼진다).
cellWidth: layout.cellWidth,
// 이름표 열을 그래프 좌측 여백과 같은 폭으로 맞춰야 그래프 시작점이 가려지지 않는다.
labelWidth: LONG_PAD.left,
rowCount: TABLE_ROW_COUNT,
x,
// 비정규 측점은 규칙 격자를 건드리지 않고, 선택된 측점만 값 열로 오버레이한다.
irregularStations: irregularStations.filter(
(entry) =>
entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6,
),
selectedStationId,
stationDisplay,
onCurveRadiusChange: (curve, radius) =>
applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)),
// 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인.
onAdjustStation: (chainage, delta) =>
base && applyEdits(adjustStation(base, store.edits(), chainage, delta)),
})
: null;
const chartWrap = document.createElement("div");
chartWrap.className = "b05-profile__chart";
chartWrap.style.height = `${chartHeight}px`;
// 측점선이 아닌 빈 곳을 누르면 선택 해제(2026-08-04 사용자 지시).
// 측점 마커·편집 버튼 클릭은 각자 처리하므로 여기까지 안 온다(closest·stopPropagation).
chartWrap.addEventListener("click", (event) => {
if ((event.target as HTMLElement).closest(".b06-chart__station")) return;
if (selectedStationId !== null) selectStation(null);
});
const designProfiles = alignment
? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])]
: (longitudinal.design_profiles ?? []);
// 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다.
const graphData = normalizedLongitudinal(longitudinal);
// 종단 정본(확정 시 병합분)에 들어 있는 비정규 측점은 걷어낸다 — 화면의 정본은 사이드바
// 목록이고, 관을 옮기면 그 목록만 따라온다. 둘을 겹쳐 그리면 옮기기 전 자리의 세로선이
// 그대로 남는다(2026-08-02 사용자 보고).
const regular = graphData.stations.filter((station) => station.kind !== "irregular");
const injected = irregularGraphStations(irregularStations, maxChainageOf(longitudinal));
const graphLongitudinal = {
...graphData,
stations: [...regular, ...injected].sort((a, b) => a.chainage_m - b.chainage_m),
};
let yAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
chartWrap.append(
createLongitudinalProfile(
graphLongitudinal,
selectedStationId,
1,
undefined,
selectStation,
stationInterval,
width,
chartHeight,
width,
designProfiles,
originOffset,
(axis) => {
yAxis = axis;
},
stationDisplay.station,
// 구조물 측점선을 끌어 옮긴다 — 배관이면 관 지점 정본을 거쳐 세부유역까지 다시 나뉜다.
(stationId, toChainage) => {
const target = irregularStations.find(
(entry) => irregularStationId(entry.id) === stationId,
);
if (target) callbacks?.onStructureMove?.(target.chainage_m, toChainage, target);
},
// 계획고 편집 ▼ 버튼(바닥 2px + 높이 17px)과 측점 라벨이 겹치지 않게 X축·라벨을
// 올린다. 19px는 라벨-버튼 사이가 너무 벌어져 70% 수준(15px)으로 줄였다
// (2026-08-04 사용자 지시). B06은 편집 버튼이 없어 0 유지.
15,
),
);
// 구조물 우클릭 메뉴 — 측점선 위면 삭제, 빈 자리면 배관 추가. 이동도 여기(측점선 끌기)서 한다.
mountStructureMenu(chartWrap, {
stations: irregularStations,
x,
chainageAt: chainageInverter(longitudinal, width, layout.originOffset),
maxChainageM: maxChainageOf(longitudinal),
onRemove: (station) => callbacks?.onStructureRemove?.(station),
onAddPipe: (chainage) => callbacks?.onPipeAdd?.(chainage),
onAddStructure: (chainage, type) => callbacks?.onStructureAdd?.(chainage, type),
// 우클릭 한 번으로 끝나는 타입만 메뉴에 올린다 — 계곡 통과 시설(managed_by)은
// 관 지점 정본 소관이라 위의 [배관 추가]가 따로 맡는다. 상세(detail) 필수는
// B06/B07 몫이라 막지 않고, B05 단계(b05) 필수만 사이드 폼으로 보낸다
// (2026-08-17 phase 분리 — B05는 유무·종류·위치 단계).
structureTypes: structureTypes
.filter(
(type) =>
!type.managed_by &&
!type.options.some((option) => option.required && (option.phase ?? "b05") !== "detail"),
)
.map((type) => ({ type_id: type.type_id, group: type.group, name: type.name })),
onAddStructureType: (chainage, typeId) => callbacks?.onStructureTypeAdd?.(chainage, typeId),
});
// 구조물 정본 서클마크 — 배치형태와 무관하게 마크 하나, 고르면 벌룬(+구간 띠).
mountStructureMarks(chartWrap, {
structures,
types: structureTypes,
x,
chainageAt: chainageInverter(longitudinal, width, layout.originOffset),
maxChainageM: maxChainageOf(longitudinal),
// 벌룬·툴팁 위치 표기는 측점번호+잔여거리(2026-08-17 사용자 확정).
stationIntervalM: stationInterval ?? 20,
selectedId: selectedStructureId,
onSelect: (structureId) => {
selectedStructureId = structureId;
callbacks?.onStructureSelect?.(structureId);
draw();
},
onMove: (structureId, toChainage) =>
callbacks?.onStructureMarkMove?.(structureId, toChainage),
});
// 가로 스크롤에도 고정되는 sticky Y축 오버레이(SVG와 같은 눈금·불투명 배경으로 값 누출 차단).
// 앵커(0크기 sticky)는 **첫 자식**이어야 한다 — SVG 뒤에 붙이면 흐름 위치가 차트
// 아래로 밀려 스크롤 시 축이 화면에 안 보인다(2026-08-04 확인, B06과 같은 규칙).
if (yAxis) chartWrap.prepend(buildStickyYAxis(yAxis, chartHeight));
if (alignment) {
chartWrap.append(
createEditOverlay({
alignment,
width,
x,
step: alignment.policy.edit_step_m,
// 비정규 측점도 규칙 측점처럼 ▲/▼ 버튼으로 계획고 조정(임의 chainage 변화점 승격).
irregularStations: irregularStations.filter(
(entry) =>
entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6,
),
onStation: (chainage, delta) =>
base && applyEdits(adjustStation(base, store.edits(), chainage, delta)),
onSegment: (segment, delta) =>
base && applyEdits(shiftSegment(base, store.edits(), segment, delta)),
onResetStation: (chainage) => store.resetStation(chainage),
// 구간 원복 = 양 끝 측점 오프셋 삭제(측점 원복 연산 ×2).
onResetSegment: (segment) => {
store.resetStation(segment.from_m);
store.resetStation(segment.to_m);
},
}),
);
}
// 캔버스 높이 = 종단도 영역만. 서브패널이 차지한 아래 공간을 덮지 않는다(밀어올리기).
canvas.style.height = `${chartHeight}px`;
canvas.append(chartWrap);
body.replaceChildren(canvas);
body.scrollLeft = scrollLeft;
// 테이블은 바닥 고정 오버레이에 담는다 — 폭은 종단 캔버스와 같아 세로선이 맞물린다.
if (table) table.style.width = `${width}px`;
tableOverlay.setTable(table);
// 유토곡선 오버레이 — 종단도·테이블 위를 덮는 서브패널(2026-08-04 사용자 확정).
// X 매핑(누가거리 최댓값·여백·폭)을 종단 그래프와 똑같이 넘겨야 측점 세로선이 맞물린다.
if (alignment && designProfiles[0]) {
massHaul.draw({
stationSource: graphLongitudinal,
// 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준
// 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다.
longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles },
crossSections: detail.cross_sections,
axis: {
maxChainageM: maxChainageOf(longitudinal),
// 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를
// 좌우 여백에 합쳐 넘긴다 — 어긋나면 같은 측점이 두 그래프에서 다른 자리에 선다.
padLeft: LONG_PAD.left + originOffset,
padRight: LONG_PAD.right + originOffset,
// 축 선·눈금은 위 종단 그래프의 축과 같은 자리에 — 축이 두 개로 보이지 않게.
axisX: LONG_PAD.left,
// 범례·기준 버튼 오버레이(top 34px)가 곡선 위에 떠서 그만큼 상단 여유를 준다
// (2026-08-05 사용자 보고: 버튼과 커브 겹침).
padTop: 40,
},
stationInterval: stationIntervalM ?? 1,
widthPx: width,
selectedStationId,
onSelectStation: selectStation,
onClearSelection: () => {
if (selectedStationId !== null) selectStation(null);
},
});
}
}
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
// 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다).
body.addEventListener(
"wheel",
(event) => {
if (event.shiftKey || event.deltaY === 0) return;
const delta = event.deltaY;
const limit = body.scrollWidth - body.clientWidth;
if (limit <= 0) return;
if ((delta < 0 && body.scrollLeft <= 0) || (delta > 0 && body.scrollLeft >= limit)) return;
body.scrollLeft += delta;
event.preventDefault();
},
{ passive: false },
);
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);
sessionStorage.setItem(COLLAPSED_KEY, String(collapsed));
if (!collapsed) requestAnimationFrame(draw);
}
panelHandle.root.addEventListener("click", () =>
setCollapsed(!root.classList.contains("is-collapsed")),
);
setCollapsed(sessionStorage.getItem(COLLAPSED_KEY) === "true");
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;
store = createProfileEditStore(routeId, stored?.edits ?? emptyEdits(), () => 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);
},
/**
* 계획 유토곡선 계산에 필요한 프로젝트 설정(토량환산계수·운반장비 경계·노반폭).
* Page가 `fetchSectionContext()` 응답에서 뽑아 넘긴다 — 프론트에 사본을 두지 않는다.
*/
setEarthworkContext(next: RouteMassHaulContext | null) {
massHaul.setContext(next);
draw();
},
setSelectedStation(stationId: string | null) {
selectedStationId = stationId;
draw();
},
/** 확정된 노선 평면 선형을 우측 배수유역 지도에 겹친다(사업지 좌표계 m). */
setRoutePolyline(points: ReadonlyArray<{ x: number; y: number }>) {
drainagePanel.setRoute(points);
},
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
setIrregularStations(stations: IrregularStation[]) {
irregularStations = stations;
draw();
},
/** 구조물 타입 레지스트리를 받아 마크 색·약호·우클릭 메뉴에 쓴다(최초 1회). */
setStructureTypes(types: StructureType[]) {
structureTypes = types;
draw();
},
/** 구조물 정본 목록을 반영해 그래프 서클마크를 다시 그린다. */
setStructures(next: StructureInstance[]) {
structures = next;
if (selectedStructureId && !next.some((s) => s.structure_id === selectedStructureId)) {
selectedStructureId = null;
}
draw();
},
/** 사이드 목록에서 고른 구조물을 그래프 마크 선택에 맞춘다. */
setSelectedStructure(structureId: string | null) {
selectedStructureId = 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);
window.clearTimeout(crossPreviewTimer);
resizeObserver.disconnect();
heightResizer.dispose();
window.removeEventListener("pointerup", clearDragFlags);
window.removeEventListener("pointercancel", clearDragFlags);
},
};
}