/* ============================================================================= * 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 { 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)을 함께 올려 관경 자동 지정에 쓴다. */ onPipesChanged?: ( pipes: Array<{ chainage_m: number; effective_diameter_mm: number | null }>, ) => 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). 그래프·사이드 패널을 맞춘다. */ onBasinSelected?: (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), 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[] = []; // 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 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 = {}; 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(".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), }); // 가로 스크롤에도 고정되는 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(); }, /** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */ 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 { 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); }, }; }