/* ============================================================================= * B05_Profile_UI_Profile_MassHaul.ts * B05 유토곡선 — 종단면 패널 바닥에 붙는 **오버레이 서브패널**. * * ── B06과 같은 그림이어야 한다 (2026-08-03 사용자 확정) ────────────── * 이 프로그램은 파일 입력 시 기본 설정값으로 B06 몫까지 미리 계산해 둔다 — 종횡단 상세 * 조회(`fetchSectionDetail`)가 지정 설계 없는 측점에 기본 설계를 즉석 계산해 얹으므로 * (`_attach_default_designs`), B05 진입 시점에 이미 **횡단 단면적 기반 정식 곡선**을 그릴 * 수 있다. 그래서 B06과 똑같이 `computeMassHaulSeries`(횡단 기준 + 종단 개략 비교)를 쓰고, * 범례·토량 분배·balloon·요약도 전부 공용 모듈 그대로 쓴다. * * 표시 토글 sessionStorage 키도 B06과 **공유**한다 — 두 화면은 같은 그림의 두 창이라 * 한쪽에서 켠 곡선이 다른 쪽에서도 켜져 있어야 한다. 데이터 자체도 같은 상세 API와 * 같은 영구저장소(`longitudinal_sections.data.mass_haul`)를 읽으므로, B06에서 횡단을 * 고쳐 임시저장/확정하면 B05는 재조회만으로 같은 곡선을 받는다. * * ── 배치 (2026-08-04 사용자 확정: 오버레이 전환) ───────────────────── * 예전에는 펼치면 종단 그래프와 세로 1:1 분할 + 도면 테이블 숨김이었다. 이제는 종단도· * 테이블 배치를 **건드리지 않고**, 패널 바닥에 붙는 오버레이가 그 위를 덮는다. 높이는 * 오버레이 위 경계의 리사이저(메인 하단 패널과 같은 `createPanelResizer`)로 조절하고 * 세션 동안 유지한다. 곡선 SVG는 오버레이 안 자체 가로 스크롤러에 들어가며, 종단 * 스크롤러와 scrollLeft를 양방향 동기화해 측점 세로선이 어긋나지 않게 한다. * ========================================================================== */ import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; import type { EarthworkConversion, HaulEquipmentLimit, MassHaulLongitudinal, MassHaulSection, MassHaulStationSource, } from "@util/common_util_mass_haul_types"; import type { MassHaulSeries } from "@util/common_util_mass_haul"; import type { HaulPlan } from "@util/common_util_mass_haul_balance"; import type { HaulPlanPrefetch } from "./B05_Profile_Api_HaulPlan"; import type { MassHaulAxis } from "@util/common_util_mass_haul_view"; import { applyLegendToggle, computeMassHaulSeries, MASS_HAUL_BALANCE_KEY, MASS_HAUL_DEFAULT_VISIBLE, normalizeVisibleBasis, } from "@util/common_util_mass_haul"; import { badgeValuesFrom, type MassHaulBadgeValues } from "@util/common_util_mass_haul_badge"; import { resetBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; import { createMassHaulChart, createMassHaulLegend, createMassHaulWindowState, MASS_HAUL_MIN_HEIGHT, scheduleMassHaulSettle, } from "@util/common_util_mass_haul_view"; import { Y_WINDOW_BAKED_ATTR } from "../B06_Section/B06_Section_UI_Longitudinal"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import "@util/common_util_mass_haul.css"; import "./B05_Profile_UI_Style_MassHaul.css"; import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel"; /** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */ /* 펼침 상태·높이·범례는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */ /** 오버레이 높이(px) 세션 키 — 메인 하단 패널 높이와 같은 수명. */ const OVERLAY_HEIGHT_KEY = stateKey("masshaul-height") ?? ""; /** 오버레이 높이 하한/기본값(px). 곡선 + 요약 막대가 읽히는 최소 크기다. */ const OVERLAY_MIN_HEIGHT = 160; /** 유토곡선 오버레이 최소 높이 — 메인 패널 하한 계산(Profile_Panel)이 함께 쓴다. */ export const MASSHAUL_MIN_HEIGHT = OVERLAY_MIN_HEIGHT; const OVERLAY_DEFAULT_HEIGHT = 280; /** 전체 맞춤(토량 분배) 보기의 우측 여백(px) — 마지막 점의 값 글자가 잘리지 않을 만큼만. */ const MASS_FIT_PAD_RIGHT = 12; /** 오버레이가 패널 본문을 다 덮지 않게 남기는 상한 비율. */ const OVERLAY_MAX_RATIO = 0.8; /** 리사이저가 높이를 담는 CSS 변수 — CSS 기본값(280px)은 OVERLAY_DEFAULT_HEIGHT와 같아야 한다. */ const HEIGHT_VAR = "--b05-masshaul-height"; /** * 표시 곡선·레이어 키 — **B06 유토곡선과 같은 키**를 쓴다. 두 화면은 같은 그림의 두 창이라 * 표시 상태가 갈리면 "같은 곡선인데 왜 다르게 보이나"가 된다(2026-08-03 사용자 확정). * 정의처는 B06 `_UI_Section_View`와 이 파일 두 곳뿐이며 값이 반드시 같아야 한다. */ /** 처음 열 때는 **곡선만** 보인다 — 토량 분배(평형선·운반 블록)는 범례에서 켠다 * (2026-09-06 사용자 지시). 판을 v4 → v5 로 올려 이미 켜 둔 브라우저도 새 기본값으로 * 시작하게 한다. */ const DEFAULT_VISIBLE: string[] = [...MASS_HAUL_DEFAULT_VISIBLE]; /** 유토곡선 계산에 필요한 프로젝트 설정 — B05 Page가 `fetchSectionContext()`에서 받아 넘긴다. */ export interface RouteMassHaulContext { conversion: EarthworkConversion; haulLimits?: HaulEquipmentLimit[]; /** 자연방토 판정 경사(rise/run). 못 받으면 전부 불가(보수적) — B06과 같은 규약. */ naturalSpoilMinSlope?: number; } /** 가로 스크롤에도 좌측에 고정되는 Y축 오버레이(표고·누가토량 공용). * * SVG와 **같은 눈금**(렌더러가 콜백으로 넘김)을 쓰고, 불투명 배경으로 스크롤되는 그래프가 * 새어 보이지 않게 가린다. 레이아웃에 영향을 주지 않도록 0크기 sticky 앵커 위에 축을 * 절대배치한다. 종단 그래프(Panel)와 유토곡선(이 모듈)이 함께 쓰므로 여기가 정의처다. */ export function buildStickyYAxis( axis: { padLeft: number; ticks: Array<{ y: number; label: string }> }, chartHeight: number, ): HTMLElement { const anchor = document.createElement("div"); anchor.className = "b05-profile__yaxis"; const inner = document.createElement("div"); inner.className = "b05-profile__yaxis-inner"; inner.style.width = `${axis.padLeft}px`; inner.style.height = `${chartHeight}px`; axis.ticks.forEach(({ y, label }) => { const tick = document.createElement("span"); tick.className = "b05-profile__yaxis-tick"; tick.style.top = `${y}px`; // 종단 그래프의 세로 창 변환이 이 눈금도 같이 옮긴다 — 그릴 때의 y를 남겨 둔다 // (`_UI_Profile_YWindow`, 2026-09-04). 유토곡선 축에는 갱신이 오지 않아 무해하다. tick.setAttribute(Y_WINDOW_BAKED_ATTR, String(y)); tick.textContent = label; inner.append(tick); }); anchor.append(inner); return anchor; } export interface RouteMassHaulDrawParams { /** 측점선을 세울 목록 — 종단 그래프에 넣은 것과 **같은 배열**이어야 자리가 맞는다. */ stationSource: MassHaulStationSource; /** 종단 개략 곡선 입력 — 편집이 반영된 현재 계획선을 담은 종단 데이터. */ longitudinal: MassHaulLongitudinal; /** 횡단 정식 곡선 입력 — 상세 조회가 내려준 측점별 설계(기본 프리뷰 포함). */ crossSections: MassHaulSection[]; /** 종단 그래프와 같은 X 매핑(누가거리 최댓값·좌우 여백). */ axis: MassHaulAxis; stationInterval: number; /** 종단 캔버스와 같은 콘텐츠 폭(px) — 같아야 측점 세로선이 맞물린다. */ widthPx: number; selectedStationId: string | null; onSelectStation: (stationId: string) => void; /** 측점선이 아닌 빈 곳 클릭 — 선택 해제(2026-08-04 사용자 지시). */ onClearSelection?: () => void; /** * 저장된 횡단이 지금 계획선과 어긋나 재계산이 예약된 상태. 그 사이의 면적은 **옛 * 계획고로 만든 값**이라 그리면 사용자가 옛 그림을 보게 된다(2026-09-03 사용자 확정: * 「새 값만 보여주기」). Panel이 `hasStaleDesigns`로 판정해 넘긴다. */ pendingRecalc?: boolean; } export interface RouteMassHaulPanel { /** 종단면 패널 바닥에 붙는 2차 슬라이드 손잡이(펼치기/접기). */ handle: HTMLElement; /** 오버레이 서브패널 — 호출한 쪽이 `position: relative`인 패널 본문 래퍼에 붙인다. */ overlay: HTMLElement; isOpen(): boolean; /** 저장된 오버레이 높이(px) — 리사이저 변수(세션)나 기본값. 임시 축소(인라인)는 무시한다. * Panel의 높이 캐스케이드가 호출마다 같은 판정을 내리는 기준(2026-08-06 진동 수정). */ desiredHeight(): number; /** 저장 높이를 확정한다(리사이저와 같은 변수+세션 기록) — 메인 패널 비례 연동이 * 드래그를 마친 높이를 앞으로의 기준으로 못 박는 데 쓴다(2026-08-06 사용자 확정). */ commitHeight(px: number): void; /** 손잡이를 오버레이 위 경계로 다시 맞춘다 — 캐스케이드가 인라인 높이를 바꾼 직후 호출. */ syncHandle(): void; setContext(next: RouteMassHaulContext | null): void; /** 종단 가로 스크롤러와 scrollLeft를 양방향 동기화한다(설치 1회). */ attachScrollSync(main: HTMLElement): void; /** 오버레이 내용을 다시 그린다. 접혀 있으면 오버레이만 숨기고 끝낸다. */ draw(params: RouteMassHaulDrawParams): void; } function readVisible(): Set { try { const raw = readStateRaw("masshaul-visible"); if (!raw) return new Set(DEFAULT_VISIBLE); const parsed = JSON.parse(raw) as unknown; // 기준 키는 라디오라 항상 하나로 눌러 맞춘다(B06 읽기와 같은 규칙). return Array.isArray(parsed) ? normalizeVisibleBasis(new Set(parsed.map(String))) : new Set(DEFAULT_VISIBLE); } catch { return new Set(DEFAULT_VISIBLE); } } /** * @param onChanged 펼침·범례 토글·높이 조절로 다시 그려야 할 때 호출된다(패널 전체 redraw). */ /** * 종단 상단줄이 **접힌 상태에서도** 보여 주는 유토곡선 총괄값(2026-09-06 사용자 지시). * 곡선 자체는 접으면 사라져도 이 값은 계획선을 만지며 계속 봐야 한다. */ export type MassHaulSummaryValues = MassHaulBadgeValues; export function createRouteMassHaulPanel( onChanged: () => void, /** 총괄값이 새로 나올 때마다 부른다 — 종단 상단줄이 받아 적는다. */ onSummary?: (summary: MassHaulSummaryValues | null) => void, /** 유토 배분 선반입기 — **배분은 서버가 만든다**(2026-09-06). 없으면 분배는 안 그린다. */ haulPrefetch?: HaulPlanPrefetch, ): RouteMassHaulPanel { // 손잡이는 다른 패널과 **같은 양식**의 표준 삼각형 손잡이 하나만 쓴다(2026-08-04 사용자 // 지시 — 예전 풀폭 바 + "유토곡선" 캡션은 다른 패널들과 모양이 달랐다). 무엇의 손잡이인지는 // 툴팁으로 밝힌다. const handleControl = createWorkflowPanelHandle("bottom", "down", "유토곡선"); const handle = document.createElement("div"); handle.className = "b05-profile__masshaul-handle"; handle.append(handleControl.root); handleControl.root.setAttribute("aria-label", "유토곡선 패널"); // 오버레이 뼈대 — 위 경계 리사이저 + 안내 문구 자리 + 가로 스크롤러 + 범례. // (요약 막대는 2026-09-06 종단 상단줄로 올라갔다.) const overlay = document.createElement("div"); overlay.className = "b05-profile__masshaul-overlay"; const bar = document.createElement("div"); bar.className = "b05-profile__masshaul-bar"; const scroll = document.createElement("div"); scroll.className = "b05-profile__masshaul-scroll"; const legendLayer = document.createElement("div"); legendLayer.className = "b05-profile__masshaul-legend"; // 상태 선언은 반드시 리사이저 생성보다 **먼저** — createPanelResizer가 세션에 저장된 // 높이를 복원하며 생성 중에 onResize → syncHandlePosition을 부르는데, `open`이 그 아래 // 있으면 TDZ ReferenceError로 B05 페이지 전체가 죽는다(2026-08-04 사용자 보고 — // 저장된 높이가 있는 브라우저에서만 재현되는 이유). let open = readStateRaw("masshaul-open") === "true"; let context: RouteMassHaulContext | null = null; // 높이 조절 — 메인 하단 패널과 같은 공용 리사이저(위 경계, 위로 끌면 커짐, 세션 보존). let resizeRedrawPending = false; const resizer = createPanelResizer({ axis: "vertical", target: overlay, cssVar: HEIGHT_VAR, direction: -1, min: OVERLAY_MIN_HEIGHT, max: () => (overlay.parentElement?.clientHeight ?? window.innerHeight) * OVERLAY_MAX_RATIO, storageKey: OVERLAY_HEIGHT_KEY, onResize: () => { // 손잡이는 경계를 즉시 따라가고(가벼움), 전체 redraw는 프레임당 한 번만. syncHandlePosition(); if (resizeRedrawPending) return; resizeRedrawPending = true; requestAnimationFrame(() => { resizeRedrawPending = false; onChanged(); }); }, }); // 재계산 대기 표시 — 곡선 영역 우측 상단에 **떠 있는** 알림이다. 예전에는 요약 막대에 // 붙였는데 막대가 줄바꿈되며 곡선을 아래로 밀어냈다(2026-09-04 사용자 보고: 「창 밀림」). const pendingChip = document.createElement("span"); pendingChip.className = "b05-profile__masshaul-pending"; pendingChip.textContent = "다시 계산 중…"; pendingChip.hidden = true; overlay.append(resizer.root, bar, scroll, legendLayer, pendingChip); // 측점선이 아닌 빈 곳 클릭 = 선택 해제 — 종단 그래프와 같은 규칙(2026-08-04 사용자 지시). let clearSelection: (() => void) | undefined; scroll.addEventListener("click", (event) => { if ((event.target as HTMLElement).closest(".b06-chart__station")) return; clearSelection?.(); }); // 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시). // scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로. attachWheelHorizontalScroll(scroll); /** * 손잡이를 오버레이 **위 경계**에 태운다(2026-08-04 사용자 지시 — 펼치면 같이 올라와 * 테이블 영역을 침범해도 된다). 접혀 있으면 패널 바닥으로 돌아온다 — 유토곡선은 서브패널 * 순서상 맨 아래라 테이블이 열려 있어도 버튼은 바닥에 남는다(2026-08-05 사용자 확정). */ function syncHandlePosition(): void { handle.style.bottom = open ? `${overlay.offsetHeight}px` : "0px"; } function applyOpen(next: boolean): void { open = next; writeStateRaw("masshaul-open", String(next)); handleControl.setOpen(next); // 툴팁은 setOpen이 일반 문구로 덮으므로 매번 유토곡선용으로 다시 밝힌다. handleControl.root.title = next ? "유토곡선 접기" : "유토곡선 펼치기"; handle.classList.toggle("is-open", next); overlay.hidden = !next; syncHandlePosition(); onChanged(); } handleControl.setOpen(open); handleControl.root.title = open ? "유토곡선 접기" : "유토곡선 펼치기"; handle.classList.toggle("is-open", open); overlay.hidden = !open; handleControl.root.addEventListener("click", () => applyOpen(!open)); function toggleSeries(key: string): void { // 곡선 기준(횡단/종단)은 라디오 — 하나를 고르면 그 기준 그래프만 전체 영역에 보인다. const visible = applyLegendToggle(readVisible(), key); writeStateRaw("masshaul-visible", JSON.stringify([...visible])); onChanged(); } // 종단 스크롤러와의 양방향 동기화 — 재진입 루프는 플래그로 끊는다. let syncing = false; function mirror(from: HTMLElement, to: HTMLElement): void { from.addEventListener("scroll", () => { if (syncing) return; syncing = true; to.scrollLeft = from.scrollLeft; syncing = false; }); } let syncedMain: HTMLElement | null = null; function attachScrollSync(main: HTMLElement): void { if (syncedMain === main) return; syncedMain = main; mirror(main, scroll); mirror(scroll, main); } /** 곡선을 한 번이라도 그렸는지 — 재계산 대기 중 화면을 비우지 않기 위한 표시. */ let drawnOnce = false; function note(message: string): void { drawnOnce = false; pendingChip.hidden = true; bar.replaceChildren(); scroll.replaceChildren(); legendLayer.replaceChildren(); const empty = document.createElement("span"); empty.className = "b05-profile__masshaul-empty"; empty.textContent = message; bar.append(empty); } // 세로창 버티기·부드러운 이동 상태 — 이 패널이 사는 동안 하나만 둔다(2026-09-04). const windowState = createMassHaulWindowState(); /** 마지막으로 그린 입력 — 이동이 아직 안 끝났으면 다음 프레임에 이걸로 한 번 더 그린다. */ let lastParams: RouteMassHaulDrawParams | null = null; /** 이미 낸 계열에서 총괄값만 꺼낸다 — 계산은 하지 않는다. */ function emitSummary(series: MassHaulSeries[]): void { if (!onSummary) return; if (!series.length) return onSummary(null); // 총괄값 기준은 **횡단 고정**이다(2026-09-06 사용자: 종단 기준 곡선은 뒤에 없앨 예정). // 범례에서 무엇을 켜 두었든 배지·상단줄이 흔들리지 않는다. const picked = series.find((entry) => entry.basis === "cross") ?? series[0]; // 배분 선반입도 **여기서** 건다 — 접힘 여부와 무관하게 지나는 자리다(2026-09-06). // 아래 `draw` 안에 두었더니 접힘 가드 뒤라 한 번도 안 불렸고, 그래서 「편집이 멈추면 // 조용히 받아 둔다」가 성립하지 않았다(펼친 뒤에야 첫 요청이 나갔다). haulPrefetch?.schedule(picked.result); onSummary(badgeValuesFrom(picked.result)); } /** 총괄값만 따로 낸다 — 패널을 접어도 상단줄이 값을 잃지 않게(2026-09-06). * **횡단 기준만** 적분한다 — 접힌 상태에서 종단 기준까지 내던 것은 버려질 값이었다 * (2026-09-06 조작 실측: 계획고 ▲ → 라벨 갱신 중앙 130ms 중 낭비분). */ function reportSummary(params: RouteMassHaulDrawParams): void { if (!onSummary) return; if (!context || params.pendingRecalc) return; emitSummary( computeMassHaulSeries( params.longitudinal, params.crossSections, context.conversion, context.naturalSpoilMinSlope, ["cross"], ), ); } function draw(params: RouteMassHaulDrawParams): void { lastParams = params; clearSelection = params.onClearSelection; overlay.hidden = !open; syncHandlePosition(); // 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다. // 펼친 경우에는 아래에서 두 기준을 내므로 그 결과로 총괄값을 낸다(계산 한 번). if (!open) { reportSummary(params); return; } if (!context) { note("토량환산계수를 불러오지 못해 유토곡선을 계산할 수 없습니다."); return; } // 저장된 횡단이 지금 계획선과 어긋나 있으면 그 면적은 옛 계획고로 만든 값이다 — // 새 값이 올 때까지 기다린다(2026-09-03 사용자 확정: 「새 값만 보여주기」). // // 다만 **곡선을 지우지는 않는다**. 계획고를 연속으로 누르면(초당 10회) 매번 곡선이 // 사라졌다 다시 서서 화면이 깜빡였다(2026-09-03 사용자 보고). 재계산은 브라우저 // 안에서 끝나 수십 ms면 끝나므로, 그동안 **직전 곡선을 그대로 두고** 상태줄로만 // 알린다. 아직 한 번도 못 그렸을 때만 문구로 자리를 채운다. if (params.pendingRecalc) { if (drawnOnce) pendingChip.hidden = false; else note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다."); return; } pendingChip.hidden = true; // B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다. // 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는 // 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림). const series: MassHaulSeries[] = computeMassHaulSeries( params.longitudinal, params.crossSections, context.conversion, context.naturalSpoilMinSlope, ); // 총괄값은 방금 낸 계열에서 꺼낸다 — 접힘 경로처럼 따로 적분하지 않는다. emitSummary(series); if (!series.length) { note("횡단 설계가 아직 없어 유토곡선을 그릴 수 없습니다."); return; } bar.replaceChildren(); legendLayer.replaceChildren(); const visible = readVisible(); // 토량 분배는 켜 둔 첫 곡선(정식 우선)에만 얹는다 — B06과 같은 규칙. const banded = series.find((entry) => visible.has(entry.key)); // 배분은 **서버가 만든다** — 그 산식을 번들에 남기지 않으려고 옮겼다(2026-09-06). // 요청은 `emitSummary` 가 걸어 두므로(접힘 여부와 무관) 펼칠 때는 이미 도착해 있다. // 아직 못 받았으면 곡선만 그린다(분배 도형은 값이 오면 다시 그려진다). const haulPlan: HaulPlan | null = banded && visible.has(MASS_HAUL_BALANCE_KEY) ? (haulPrefetch?.current() ?? null) : null; // 요약 막대는 2026-09-06 사용자 지시로 뺐다 — 총괄값은 종단 상단줄이 늘 보여 주고, // 이 자리는 곡선이 넓게 쓴다. `bar` 는 안내 문구 자리로만 남는다. // 곡선 몫 = 스크롤러의 **안쪽 높이**(clientHeight = 가로 스크롤바 제외). 오버레이 전체 // 높이로 잡으면 스크롤바가 곡선 바닥(Y축 -200 라벨)을 덮는다(2026-08-04 사용자 보고). // 첫 렌더(레이아웃 전)에는 0이 나올 수 있어 기본 높이로 시작한다 — Panel이 다음 // 프레임에 한 번 더 그려 실측으로 수렴한다. const scrollHeight = scroll.clientHeight || (overlay.clientHeight || OVERLAY_DEFAULT_HEIGHT) - bar.offsetHeight - 12; const chartHeight = Math.max(scrollHeight, MASS_HAUL_MIN_HEIGHT); // 토량 분배를 켜면 **곡선 전체가 한 화면에** 들어와야 한다(2026-09-06 사용자 지시). // 평형선·운반 블록은 노선 전체를 놓고 읽는 그림이라, 종단 그래프와의 X 맞춤(가로 // 스크롤)과 보이는 구간만 보는 Y 창을 둘 다 버리고 스크롤러 안쪽 폭·전 구간 Y로 그린다. // 종단과 맞물릴 필요가 없으므로 반 칸 들여쓰기(originOffset)도 쓰지 않는다 — 그만큼 // 좌우가 비어 곡선이 가운데로 쪼그라들었다(2026-09-06 사용자 보고: 「우측 여유 과다」). const fitAll = haulPlan !== null; const widthPx = fitAll ? scroll.clientWidth || params.widthPx : params.widthPx; const chartAxis = fitAll ? { ...params.axis, padLeft: params.axis.axisX ?? params.axis.padLeft, padRight: MASS_FIT_PAD_RIGHT, viewRange: undefined, window: undefined, hideStations: true, } : { ...params.axis, window: windowState }; let massAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; const chart = createMassHaulChart( series, visible, params.stationSource, chartAxis, params.selectedStationId, params.stationInterval, widthPx, chartHeight, widthPx, params.onSelectStation, haulPlan, (axis) => { massAxis = axis; }, ); // 종단 캔버스와 같은 폭의 래퍼 — sticky Y축 앵커의 기준(position: relative)이 된다. const wrap = document.createElement("div"); wrap.className = "b05-profile__chart b05-profile__masshaul-chart"; wrap.style.width = `${widthPx}px`; wrap.style.height = `${chartHeight}px`; wrap.append(chart); // 앵커는 첫 자식 — SVG 뒤면 흐름 위치가 밀려 스크롤 시 축이 안 보인다(B06과 같은 규칙). if (massAxis) wrap.prepend(buildStickyYAxis(massAxis, chartHeight)); scroll.replaceChildren(wrap); // 종단 스크롤러가 이미 옆으로 가 있으면 새로 그린 곡선도 같은 자리에서 시작한다. // 전체 맞춤일 때는 스크롤할 것이 없으므로 왼쪽 끝에 둔다. if (fitAll) scroll.scrollLeft = 0; else if (syncedMain) scroll.scrollLeft = syncedMain.scrollLeft; legendLayer.append( createMassHaulLegend(series, visible, toggleSeries, () => { resetBalloonOffsets(); onChanged(); }), ); drawnOnce = true; // 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다. scheduleMassHaulSettle(windowState, () => { if (lastParams) draw(lastParams); }); } return { handle, overlay, isOpen: () => open, desiredHeight() { const raw = parseFloat(overlay.style.getPropertyValue(HEIGHT_VAR)); return Number.isFinite(raw) && raw > 0 ? raw : OVERLAY_DEFAULT_HEIGHT; }, commitHeight(px) { const next = Math.round(px); overlay.style.setProperty(HEIGHT_VAR, `${next}px`); writeStateRaw("masshaul-height", String(next)); syncHandlePosition(); }, syncHandle: () => syncHandlePosition(), setContext(next) { context = next; }, attachScrollSync, draw, }; }