/* ============================================================================= * B06_Section_UI_Section_View_Draw.ts * B06 상단 패널의 **종단 그래프 한 벌**을 세운다 — 그래프·구조물 알약 레인·우클릭 메뉴· * 누가토량 배지·고정 Y축, 그리고 가로 스크롤에 따라붙는 세로 창 갱신기까지. * * 왜 갈랐나 — 종단 화면 본체(`_Section_View`)가 700줄을 넘겨(CLAUDE.md 4장) 기능별로 * 나눠야 했다. 이 파일은 **그리는 몫만** 맡고, 높이 재기·되먹임 보정·선택 상태는 본체가 * 그대로 쥔다. 넘겨받는 값은 전부 인자라 여기에는 화면 상태가 남지 않는다. * ========================================================================== */ import type { EarthworkConversion, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures"; import { buildStructureLane } from "../B05_Profile/B05_Profile_UI_Structures_Marks"; import { buildStickyYAxis } from "../B05_Profile/B05_Profile_UI_Profile_MassHaul"; import { computeMassHaulSeries } from "@util/common_util_mass_haul"; import { badgeValuesFrom } from "@util/common_util_mass_haul_badge"; import { applyElevationWindow, needsFullRedraw, Y_AXIS_WINDOW_CLASS, type ElevationWindowResult, } from "@util/common_util_chart_ywindow"; import { LONG_PAD } from "./B06_Section_UI_Section_Common"; import { buildLongitudinalChart, visibleChainageRange, visibleElevationRange, } from "./B06_Section_UI_Section_View_Chart"; import { mountSectionStructureMenu, moveMarkById, type SectionStructureEdit, } from "./B06_Section_UI_Section_View_Menu"; import { createEditOverlay } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; import type { ProfileAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; /** 그래프 한 벌을 세우는 데 필요한 값 — 본체가 재고 고른 것을 그대로 넘긴다. */ export interface LongitudinalPanelInput { detail: SectionDetailResponse; /** 그래프가 들어앉는 칸. 내용은 이 함수가 통째로 갈아 끼운다. */ chartWrap: HTMLElement; /** 본체가 잰 그릴 폭·높이. */ renderWidth: number; chartHeight: number; /** 갈아 끼우기 전 가로 스크롤 자리 — 되돌려 놓는다. */ keepScrollLeft: number; selectedStationId: string | null; exaggeration: number; stationInterval: number; structures: ReadonlyArray; types: ReadonlyArray; /** 구조물을 넣고 빼고 옮기는 길(없으면 우클릭·끌기를 안 붙인다). */ edit: SectionStructureEdit | null; conversion?: EarthworkConversion; naturalSpoilSlope?: number; selectStation: (stationId: string) => void; /** 세로 창 가로채기 — 계획고를 만지는 동안 창을 고정한다(B05 와 같은 장치). */ holdRange?: ( next: { min: number; max: number } | null, ) => { min: number; max: number } | undefined; /** 계획선 편집 — 넘기면 종단 그래프 위에 ▲/▼ 버튼층이 선다(B05 와 같은 부품). * 안 넘기면 버튼이 없고 그래프만 보인다(옛 저장분처럼 선형이 없는 노선). */ grade?: { alignment: ProfileAlignment; /** 한 번 누를 때 오르내리는 양(m) — 선형 정책값. */ stepM: number; onStation: (chainageM: number, delta: number) => void; } | null; /** 좌측 상단 누가토량 배지 — 값이 없으면 null 로 지운다. */ setMassBadge: (values: ReturnType | null) => void; } /** 알약 레인이 설 높이(px) — 본체가 그릴 높이를 셈할 때 같은 값을 써야 한다. */ export { STRUCTURE_LANE_HEIGHT_PX } from "../B05_Profile/B05_Profile_UI_Structures_Marks"; /** * 종단 그래프 한 벌을 세우고 **세로 창 갱신기**를 돌려준다. * 갱신기는 가로 스크롤마다 불러 보이는 구간에 세로를 맞추는 데 쓴다. */ export function drawLongitudinalPanel( input: LongitudinalPanelInput, ): () => ElevationWindowResult | null { const { detail, chartWrap, structures, types, edit } = input; const chartWidth = input.renderWidth; /** 측점선·알약을 끌어 놓았을 때 — 관인지 구조물인지는 공용 판별이 가른다. */ const moveMark = (markId: string, toChainageM: number): void => { if (!edit) return; moveMarkById(markId, toChainageM, { structures, stations: detail.longitudinal.stations ?? [], edit, }); }; const chart = buildLongitudinalChart({ detail, selectedStationId: input.selectedStationId, exaggeration: input.exaggeration, selectStation: input.selectStation, stationInterval: input.stationInterval, chartWidth, chartHeight: input.chartHeight, minWidth: chartWidth, scrollLeft: input.keepScrollLeft, viewportWidth: chartWrap.clientWidth || chartWidth, // 계획고 편집 ▼ 버튼이 바닥에 붙으므로 X축·측점 라벨을 그만큼 밀어 올린다 // (B05 와 같은 값 15px). 버튼이 없으면 0 — 종전 여백 그대로다. bottomInsetPx: input.grade ? 15 : 0, holdRange: input.holdRange, // 측점선을 끌면 그 구조물이 옮겨 간다 — 관은 예약 이동, 구조물은 정본 이동. onDragStation: edit ? moveMark : undefined, }); const nodes: Element[] = [chart.node]; // 구조물 알약 레인 — B05 종단과 **같은 부품**을 그대로 쓴다. 배수관·세월교도 같은 // 알약으로 선다(2026-09-07 사용자 지시 4 표시 통일). if (structures.length && types.length) { nodes.push( buildStructureLane({ structures, types, x: chart.toX, chainageAt: chart.toChainage, maxChainageM: chart.maxChainageM, widthPx: chartWidth, axisWidthPx: LONG_PAD.left, stationIntervalM: input.stationInterval, selectedId: null, // 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙. onSelect: (structureId) => { if (!structureId) return; const hit = structures.find((entry) => entry.structure_id === structureId); if (!hit) return; const at = structureAnchorM(hit); let best: { id: string; gap: number } | null = null; for (const section of detail.cross_sections) { const gap = Math.abs(section.chainage_m - at); if (!best || gap < best.gap) best = { id: section.station_id, gap }; } if (best) input.selectStation(best.id); }, // 알약 끌기도 B05 와 같게 받는다(2026-09-12 일원화) — 정본은 [저장]·[확정]에서. onMove: moveMark, }), ); } chartWrap.replaceChildren(...nodes); // 계획선 편집 버튼층 — B05 와 **같은 부품**(`createEditOverlay`)이다(2026-09-12 사용자: // B05·B06 은 한 페이지인데 B06 에만 버튼이 없었다). 누른 값은 B05 와 같은 세션 초안에 // 쌓이고 [저장]·[확정]에서 종단 정본으로 나간다. if (input.grade) { const editLayer = createEditOverlay({ alignment: input.grade.alignment, width: chartWidth, x: chart.toX, step: input.grade.stepM, onStation: input.grade.onStation, }); // 버튼층은 `inset: 0` 으로 부모를 꽉 채운다 — B05 는 부모가 그래프뿐이지만 B06 은 // **알약 레인도 같은 칸 안**에 있어, 그대로 두면 ▼ 가 레인 위로 밀려난다(실측: // 그래프 바닥 231px 인데 버튼이 270px). 그래프 높이로 잘라 B05 와 같은 자리에 세운다. editLayer.style.height = `${input.chartHeight}px`; editLayer.style.bottom = "auto"; chartWrap.append(editLayer); } // 종단 그래프 우클릭 — B05 와 같은 메뉴다(가까운 구조물이 있으면 삭제, 없으면 구조물군 // → 종류 2단 추가). 그래프를 다시 그릴 때마다 붙인다(상태를 안 남긴다). if (edit) { mountSectionStructureMenu(chartWrap, { structures, types, x: chart.toX, chainageAt: chart.toChainage, maxChainageM: chart.maxChainageM, edit, }); } // 유토곡선 그래프는 B06 에서 **그리지 않는다**(2026-09-06 사용자 지시) — 자리를 많이 // 먹는데 정작 필요한 값은 마지막 지점 누가토량 하나다. 곡선은 B05 유토곡선 패널에서 // 펼쳐 본다. 여기서는 같은 계산으로 값만 내 좌측 상단 배지에 올린다(기준: 횡단). if (input.conversion) { const series = computeMassHaulSeries( detail.longitudinal, detail.cross_sections, input.conversion, input.naturalSpoilSlope, ); const cross = series.find((entry) => entry.basis === "cross") ?? series[0]; input.setMassBadge(cross ? badgeValuesFrom(cross.result) : null); } else { input.setMassBadge(null); } // 종단 고정 Y축 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너 // 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다). if (chart.axis) { const overlay = buildStickyYAxis(chart.axis, input.chartHeight); // 세로 창을 따라 움직이는 축임을 표시한다(유토곡선 축과 구분). overlay.classList.add(Y_AXIS_WINDOW_CLASS); chartWrap.prepend(overlay); } chartWrap.scrollLeft = input.keepScrollLeft; // 세로 창 갱신기 — 스크롤마다 종단을 변환으로 따라오게 한다(다시 그리지 않는다). return () => { const { fromM, toM } = visibleChainageRange( chart.toChainage, chart.maxChainageM, chartWrap.scrollLeft, chartWrap.clientWidth || chartWidth, ); const next = visibleElevationRange(detail, fromM, toM); return applyElevationWindow( chartWrap, input.holdRange ? input.holdRange(next) : (next ?? undefined), ); }; } /** 패널을 통째로 다시 그리기까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ const SCROLL_SETTLE_MS = 80; /** * 가로 스크롤에 세로 창을 따라붙인다 — **한 번만** 부른다(리스너를 남긴다). * * 세로 맞춤은 그리기가 아니라 **변환**이다(2026-09-04 사용자 확정, B05 와 같은 규칙). * 변환으로 못 살리는 자리에서만 스크롤이 멎은 뒤 한 번 다시 그린다. */ export function attachWindowScroll( chartWrap: HTMLElement, updater: () => (() => ElevationWindowResult | null) | null, redraw: () => void, ): void { let settledScrollLeft = 0; let timer = 0; chartWrap.addEventListener("scroll", () => { const fitted = updater()?.() ?? null; window.clearTimeout(timer); if (!needsFullRedraw(fitted)) return; timer = window.setTimeout(() => { if (Math.abs(chartWrap.scrollLeft - settledScrollLeft) < 1) return; settledScrollLeft = chartWrap.scrollLeft; redraw(); }, SCROLL_SETTLE_MS); }); } /** * 계획고 ▲▼ 를 **누르고 있는 동안 세로 창을 고정**한다(B05 와 같은 장치). * * 안 그러면 창이 새 계획선에 맞춰 다시 잡혀, 값은 바뀌는데 선은 제자리에 있는 것처럼 * 보인다(2026-09-12 사용자 보고: 「버튼을 눌러도 계획 종단선이 안 바뀐다」). 손을 뗀 뒤 * 한 번만 다시 맞춘다. */ export function createElevationHold( chartWrap: HTMLElement, redraw: () => void, ): (next: { min: number; max: number } | null) => { min: number; max: number } | undefined { let editing = false; let held: { min: number; max: number } | undefined; chartWrap.addEventListener("pointerdown", (event) => { if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return; editing = true; const release = (): void => { editing = false; window.removeEventListener("pointerup", release); window.removeEventListener("pointercancel", release); redraw(); }; window.addEventListener("pointerup", release); window.addEventListener("pointercancel", release); }); return (next) => { if (editing) return held; held = next ?? undefined; return held; }; }