/* ============================================================================= * B05_Profile_UI_Profile_Render.ts * 종단 패널 본문 재구성 — 캔버스·그래프·구조물 레인·테이블·유토곡선을 한 번에 그린다. * * 패널 본체(B05_Profile_UI_Profile_Panel)가 700줄 한계에 닿아 분리했다. * 상태는 여전히 본체가 들고 있고 여기서는 컨텍스트로 받아 **그리기만** 한다 — * 그리는 도중 상태를 바꾸는 자리(선택 변경·드래그 쿨다운 해제)는 컨텍스트의 세터를 * 거친다. 그래프·테이블·유토곡선이 같은 X 매핑을 쓰는 규칙은 그대로다. * ========================================================================== */ import { dropStationsNear } from "./B05_Profile_Util_Station"; import { createLongitudinalProfile, longitudinalMinimumWidth, } from "../B06_Section/B06_Section_UI_Longitudinal"; import { hasStaleDesigns, LONG_PAD, windowElevationRange, } from "../B06_Section/B06_Section_UI_Section_Common"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import { normalizedLongitudinal, toDesignProfile } from "./B05_Profile_UI_Profile_Data"; import { adjustStation, setCurveRadius, type AlignmentBase, type AlignmentEdits, type ProfileAlignment, } from "./B05_Profile_UI_Profile_Alignment"; import { createEditOverlay } from "./B05_Profile_UI_Profile_Edit"; import { createRunHighlight } from "./B05_Profile_UI_Profile_RunHighlight"; import { applyElevationWindow, Y_AXIS_WINDOW_CLASS, type ElevationWindowResult, } from "@util/common_util_chart_ywindow"; import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten"; import type { MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover"; import { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul"; import type { ProfileZoomState } from "./B05_Profile_UI_Profile_Zoom"; import { createProfileTable } from "./B05_Profile_UI_Profile_Table"; import { CELL_GAP_PX, chainageInverter, chainageMapper, computeProfileLayout, irregularGraphStations, maxChainageOf, STATION_SPACING_PX, TABLE_ROW_COUNT, } from "./B05_Profile_UI_Profile_Layout"; import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations"; import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures"; import { buildStructureLane, STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks"; import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel"; /** 본체가 들고 있는 상태를 읽고, 그리는 도중 바뀌는 것만 되돌려 주는 통로. */ export interface ProfileRenderContext { body: HTMLElement; callbacks: RouteProfilePanelCallbacks | undefined; massHaul: { draw: (params: RouteMassHaulDrawParams) => void }; tableOverlay: { isOpen: () => boolean; contentHeight: () => number; setTable: (table: HTMLElement | null) => void; }; store: { edits: () => AlignmentEdits; resetStation: (chainageM: number) => void; }; detail: () => SectionDetailResponse | null; alignment: () => ProfileAlignment | null; base: () => AlignmentBase | null; stationInterval: () => number | undefined; irregularStations: () => IrregularStation[]; /** 횡단배수 최소 계획고 대상(시설·제원 반영) — 요약줄 경고 표시에 쓴다. * 편집 차단 가드는 `_Profile_Panel.applyEdits` 한 곳으로 옮겼다(2026-09-02). */ minCoverTargets: () => MinCoverPoint[]; structures: () => StructureInstance[]; structureTypes: () => StructureType[]; selectedStationId: () => string | null; setSelectedStationId: (value: string | null) => void; selectedStructureId: () => string | null; setSelectedStructureId: (value: string | null) => void; stationDisplay: () => { station: number; cumulative: number }; /** 본문 크기 기록 — 다음 리사이즈에서 재구성이 필요한지 판단하는 값. */ setLastSize: (width: number, height: number) => void; renderBalance: () => void; applyHeightCascade: (allowGrow: boolean) => { chartHeight: number }; /** 전체 재구성이 한 번 돌면 배치가 확정된 것 — 자동 확대를 다시 허용한다. */ clearMainDragCooldown: () => void; selectStation: (stationId: string | null) => void; applyEdits: (next: AlignmentEdits) => void; /** [직선화]·[쉬프트] 도구가 그래프 클릭을 먼저 먹는지(먹었으면 기본 선택을 건너뛴다). */ handleToolPick: (chainageM: number | null) => boolean; /** 가로 폭 배수(줌 조작구 상태) — 세로는 자동이라 배율이 없다(2026-09-04). */ zoom: () => ProfileZoomState; /** * 세로 자동 맞춤의 Y 창을 넘겨 주고 **실제로 쓸 창**을 돌려받는다. 계획고를 끌어 올리는 * 동안에는 본체가 직전 창을 붙잡아 돌려준다 — 축이 손 따라 움직이면 조작 감각이 깨진다. */ holdElevationRange: ( next: { min: number; max: number } | null, ) => { min: number; max: number } | undefined; /** 그래프 x → chainage 역변환이 필요한 도구 판정용 — 클릭 지점의 누가거리. */ toolActive: () => boolean; /** [쉬프트]로 고른 직선 구간 — 그래프에 빨갛게 강조한다(2026-09-03). */ selectedRuns: () => StraightRun[]; stationIdAtStructure: (structureId: string | null) => string | null; /** 알약을 골랐을 때처럼 그리는 도중 다시 그려야 하는 자리. */ redraw: () => void; /** 세로 창을 **다시 그리지 않고** 옮기는 갱신기를 패널에 넘긴다(2026-09-04). * 가로 스크롤마다 이것만 부르면 그래프 재구성(실측 34ms)이 사라진다. */ setElevationWindowUpdater: (update: (() => ElevationWindowResult | null) | null) => void; } /** 본문을 통째로 다시 그린다. 상세가 없거나 본문이 아직 0크기면 아무것도 하지 않는다. */ export function renderProfile(ctx: ProfileRenderContext): void { const { body, callbacks, massHaul, tableOverlay, store } = ctx; const detail = ctx.detail(); if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return; const alignment = ctx.alignment(); const base = ctx.base(); const stationInterval = ctx.stationInterval(); const irregularStations = ctx.irregularStations(); const structures = ctx.structures(); const structureTypes = ctx.structureTypes(); const selectedStationId = ctx.selectedStationId(); const selectedStructureId = ctx.selectedStructureId(); const stationDisplay = ctx.stationDisplay(); if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return; ctx.setLastSize(body.clientWidth, body.clientHeight); // 편집할 때마다 본문을 갈아끼우므로 보고 있던 가로 위치를 잃지 않게 되돌린다. const scrollLeft = body.scrollLeft; ctx.renderBalance(); const longitudinal = detail.longitudinal; const zoom = ctx.zoom(); // 가로 줌은 **폭 배수**다 — 캔버스가 넓어지고 가로 스크롤로 훑는다. 그래프·테이블· // 편집 버튼층·구조물 레인이 같은 매핑을 쓰므로 폭 하나만 키우면 넷이 함께 늘어난다. 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, zoom.x) : { width: Math.max(availableWidth, longitudinalMinimumWidth(longitudinal, stationInterval)) * zoom.x, 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 } = ctx.applyHeightCascade(true); // 전체 재구성이 한 번 돌면 배치가 확정된 것 — 다음 캐스케이드부터 grow를 다시 허용한다. ctx.clearMainDragCooldown(); 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) => ctx.applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)), // 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인. onAdjustStation: (chainage, delta) => base && ctx.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) => { // [직선화]·[쉬프트] 모드에서는 그래프 클릭이 도구 선택으로 간다 — 측점선을 눌렀으면 // 그 측점, 빈 곳이면 그 x의 누가거리로 직선 구간을 고른다(2026-09-02). if (ctx.toolActive()) { const marker = (event.target as HTMLElement).closest(".b06-chart__station"); const raw = marker?.getAttribute("data-chainage"); if (raw !== null && raw !== undefined) { if (ctx.handleToolPick(Number(raw))) return; } else { const rect = chartWrap.getBoundingClientRect(); const chainage = chainageInverter( longitudinal, width, layout.originOffset, )(event.clientX - rect.left + chartWrap.scrollLeft); if (ctx.handleToolPick(Number.isFinite(chainage) ? chainage : null)) return; } } if ((event.target as HTMLElement).closest(".b06-chart__station")) return; if (ctx.selectedStationId() !== null) ctx.selectStation(null); // 구조물 알약 선택도 함께 푼다 — 빈 공간 클릭 시 사이드 폼(구조물군·종류)까지 // 리셋되는 해제 신호가 여기서만 나갈 수 있다(2026-08-18 사용자 보고). if (ctx.selectedStructureId() !== null) { ctx.setSelectedStructureId(null); callbacks?.onStructureSelect?.(null); ctx.redraw(); } }); 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)); // 구조물 측점과 0.1m 안에서 겹치는 규칙 측점은 지운다 — 세로선·라벨이 두 겹으로 // 겹쳐 읽히지 않는다(2026-09-06). 남는 쪽은 구조물 측점이다. const graphLongitudinal = { ...graphData, stations: [...dropStationsNear(regular, injected), ...injected].sort( (a, b) => a.chainage_m - b.chainage_m, ), }; // 세로 자동 맞춤 — 지금 화면에 보이는 누가거리 구간만 보고 Y 창을 잡는다(2026-09-04 // 사용자 확정). 가로 스크롤 위치(`scrollLeft`)와 본문 폭이 곧 보이는 구간이다. const toChainage = chainageInverter(longitudinal, width, originOffset); const maxChainageM = maxChainageOf(longitudinal); const viewFromM = Math.max(0, toChainage(scrollLeft)); const viewToM = Math.min(maxChainageM, toChainage(scrollLeft + body.clientWidth)); const elevationRange = ctx.holdElevationRange( windowElevationRange( [graphLongitudinal.samples, ...designProfiles.map((profile) => profile.samples)], viewFromM, viewToM, ) ?? null, ); let yAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; chartWrap.append( createLongitudinalProfile( graphLongitudinal, selectedStationId, // 세로 배율은 1 고정 — 확대·축소 몫은 아래 `elevationRange`(자동 맞춤)가 맡는다. 1, undefined, ctx.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, // 창 중심 이동은 쓰지 않는다 — 보이는 구간에 맞춘 Y 창이 이미 가운데다. 0, // 보이는 구간의 지반·계획선 범위(위아래 10% 여유는 렌더러가 붙인다). elevationRange, ), ); // 구조물 우클릭 메뉴 — 측점선 위면 삭제, 빈 자리면 배관 추가. 이동도 여기(측점선 끌기)서 한다. 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), // 사이드 「구조물 배치」와 같은 전체 목록(A군 포함) — 선택 = 즉시 추가가 아니라 // 폼 자동 지정이므로 필수 옵션·관리 주체로 거를 이유가 없어졌다(2026-08-18 일원화). structureTypes: structureTypes.map((type) => ({ type_id: type.type_id, group: type.group, name: type.name, })), onAddStructureType: (chainage, typeId) => callbacks?.onStructureTypeAdd?.(chainage, typeId), }); // 구조물 알약 레인 — 그래프 아래 별도 줄(2026-08-17 사용자 지시 1·3). 자동 배수관도 // 세로 점선이 아니라 같은 알약으로 여기에 놓인다. const structureLane = buildStructureLane({ structures, types: structureTypes, x, chainageAt: chainageInverter(longitudinal, width, layout.originOffset), maxChainageM: maxChainageOf(longitudinal), widthPx: width, // 그래프 sticky Y축과 같은 폭으로 레인 축을 이어 붙인다(2026-08-17 지시 2). axisWidthPx: LONG_PAD.left, // 벌룬·툴팁 위치 표기는 측점번호+잔여거리(2026-08-17 사용자 확정). stationIntervalM: stationInterval ?? 20, selectedId: selectedStructureId, onSelect: (structureId) => { ctx.setSelectedStructureId(structureId); // 같은 자리 측점 세로선도 함께 켜고 끈다 — 알약과 세로선은 한 구조물이다. ctx.setSelectedStationId(ctx.stationIdAtStructure(structureId)); callbacks?.onStructureSelect?.(structureId); ctx.redraw(); }, onMove: (structureId, toChainage) => callbacks?.onStructureMarkMove?.(structureId, toChainage), }); // 세로 창 갱신기 — 가로로 스크롤할 때마다 패널이 이것을 부른다. 도형은 그대로 두고 // 겹 하나의 변환만 갈아 끼우므로 매 프레임 불러도 된다(2026-09-04 사용자 확정). // 유토곡선 갱신기 — 아래에서 유토곡선을 그린 뒤 채워진다(그리기 순서상 여기서는 아직 없다). let massHaulUpdater: ((fromM: number, toM: number) => void) | null = null; ctx.setElevationWindowUpdater(() => { const from = Math.max(0, toChainage(body.scrollLeft)); const to = Math.min(maxChainageM, toChainage(body.scrollLeft + body.clientWidth)); const next = ctx.holdElevationRange( windowElevationRange( [graphLongitudinal.samples, ...designProfiles.map((profile) => profile.samples)], from, to, ) ?? null, ); massHaulUpdater?.(from, to); return applyElevationWindow(chartWrap, next); }); // 가로 스크롤에도 고정되는 sticky Y축 오버레이(SVG와 같은 눈금·불투명 배경으로 값 누출 차단). // 앵커(0크기 sticky)는 **첫 자식**이어야 한다 — SVG 뒤에 붙이면 흐름 위치가 차트 // 아래로 밀려 스크롤 시 축이 화면에 안 보인다(2026-08-04 확인, B06과 같은 규칙). if (yAxis) { const axisOverlay = buildStickyYAxis(yAxis, chartHeight); // 세로 창을 따라 움직이는 축임을 표시한다(유토곡선 축과 구분). axisOverlay.classList.add(Y_AXIS_WINDOW_CLASS); chartWrap.prepend(axisOverlay); } // 쉬프트로 고른 구간 강조 — 편집 버튼층보다 아래에 깔아 버튼을 가리지 않는다. if (alignment) { const highlight = createRunHighlight({ alignment, runs: ctx.selectedRuns(), x, axis: yAxis, widthPx: width, heightPx: chartHeight, }); if (highlight) chartWrap.append(highlight); } 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, ), // 최소고 가드는 `_Profile_Panel.applyEdits` 한 곳에 있다 — 여기 따로 걸면 // 다른 편집 경로(직선화·쉬프트·틸팅·방향키)와 규칙이 갈린다(2026-09-02). onStation: (chainage, delta) => { if (!base) return; ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta)); }, }), ); } // 캔버스 높이 = 종단도 영역만. 서브패널이 차지한 아래 공간을 덮지 않는다(밀어올리기). // 구조물 알약 레인이 그래프 밑에 한 줄 붙으므로 그만큼 캔버스가 커진다(2026-08-17 지시 6). canvas.style.height = `${chartHeight + STRUCTURE_LANE_HEIGHT_PX}px`; canvas.append(chartWrap, structureLane); body.replaceChildren(canvas); body.scrollLeft = scrollLeft; // 테이블은 바닥 고정 오버레이에 담는다 — 폭은 종단 캔버스와 같아 세로선이 맞물린다. if (table) table.style.width = `${width}px`; tableOverlay.setTable(table); // 유토곡선 오버레이 — 종단도·테이블 위를 덮는 서브패널(2026-08-04 사용자 확정). // X 매핑(누가거리 최댓값·여백·폭)을 종단 그래프와 똑같이 넘겨야 측점 세로선이 맞물린다. if (alignment && designProfiles[0]) { const massHaulParams: RouteMassHaulDrawParams = { stationSource: graphLongitudinal, // 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준 // 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다. longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles }, crossSections: detail.cross_sections, // 횡단이 지금 계획선과 어긋나면 그 면적은 옛 계획고로 만든 값이다 — Panel이 // 재계산을 예약해 두므로 유토곡선은 그 결과만 그린다(2026-09-03 사용자 확정). // 비교 대상은 **편집이 반영된 계획선**(`designProfiles`)이다. 저장분 // (`detail.longitudinal.design_profiles`)과 견주면 편집 중에는 영원히 어긋난 것으로 // 나와 곡선이 계속 빈 화면이 된다(2026-09-03 사용자 보고). pendingRecalc: hasStaleDesigns({ longitudinal: { design_profiles: designProfiles }, cross_sections: detail.cross_sections, }), axis: { maxChainageM: maxChainageOf(longitudinal), // 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를 // 좌우 여백에 합쳐 넘긴다 — 어긋나면 같은 측점이 두 그래프에서 다른 자리에 선다. padLeft: LONG_PAD.left + originOffset, padRight: LONG_PAD.right + originOffset, // 축 선·눈금은 위 종단 그래프의 축과 같은 자리에 — 축이 두 개로 보이지 않게. axisX: LONG_PAD.left, // 범례·기준 버튼 오버레이(top 4px)가 곡선 위에 떠서 그 높이만큼만 여유를 준다 // (2026-08-05: 버튼과 커브 겹침 / 2026-09-06: 요약 막대가 빠져 상단이 너무 비었음). padTop: 30, // 유토곡선 Y 도 종단과 같은 창을 본다 — 전 구간 최대 토량으로 고정하면 확대해도 // 곡선이 납작하게 눌린다(2026-09-04 사용자 지시). viewRange: { fromM: viewFromM, toM: viewToM }, }, stationInterval: stationIntervalM ?? 1, widthPx: width, selectedStationId, onSelectStation: ctx.selectStation, onClearSelection: () => { if (ctx.selectedStationId() !== null) ctx.selectStation(null); }, }; massHaul.draw(massHaulParams); // 유토곡선도 **같은 창**을 본다(2026-09-04 사용자 확정) — 스크롤할 때마다 곡선만 다시 // 그린다. 종단처럼 변환으로 옮기지 않는 이유: 곡선 위에 앉는 말풍선·EP 표·측점 점이 // 세로로 늘어나면 안 되는 것들이라, 그것만 따로 옮기는 값이 곡선 자체를 다시 그리는 // 값보다 크다(요소 수가 종단의 1/3). massHaulUpdater = (fromM, toM) => { massHaul.draw({ ...massHaulParams, axis: { ...massHaulParams.axis, viewRange: { fromM, toM } }, }); }; } }