From 333df15e2bc0343832628aecb67b9d2947179ee5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 12 Sep 2026 16:46:37 +0900 Subject: [PATCH] =?UTF-8?q?refactor(b06):=20=EC=A2=85=EB=8B=A8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=B3=B8=EC=B2=B4=EB=A5=BC=20700=EC=A4=84=20?= =?UTF-8?q?=EC=95=84=EB=9E=98=EB=A1=9C=20=E2=80=94=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=8A=94=20=EB=AA=AB=EC=9D=84=20=EB=94=B0=EB=A1=9C=20=EB=BA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `B06_Section_UI_Section_View.ts` 가 817줄로 한도를 넘겼음. 종단 그래프·구조물 알약 레인· 우클릭 메뉴·누가토량 배지·고정 Y축을 세우는 몫과 가로 스크롤 세로창 배선을 `B06_Section_UI_Section_View_Draw.ts` 로 옮겨 693줄로 줄임. 높이 재기·되먹임 보정·선택 상태는 본체가 그대로 쥐고, 옮긴 쪽은 넘겨받은 인자만 쓰므로 화면 상태가 두 곳에 남지 않음. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR --- B06_Section/B06_Section_UI_Section_View.ts | 171 ++------------ .../B06_Section_UI_Section_View_Draw.ts | 210 ++++++++++++++++++ 2 files changed, 232 insertions(+), 149 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Section_View_Draw.ts diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 51c76845..ba24ec2d 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -17,8 +17,6 @@ import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; -// 가로 스크롤 고정 Y축 — B05와 같은 오버레이를 쓴다(정의처: B05 MassHaul 모듈 + 그 CSS). -import { buildStickyYAxis } from "../B05_Profile/B05_Profile_UI_Profile_MassHaul"; import type { CrossSection, EarthworkConversion, @@ -45,38 +43,19 @@ import { effectiveCardHalfWidth, } from "./B06_Section_UI_Cross_View_Metrics"; import { CROSS_HEIGHT } from "./B06_Section_UI_Section_Common"; -import { LONG_PAD } from "./B06_Section_UI_Section_Common"; +import { STRUCTURE_LANE_HEIGHT_PX } from "../B05_Profile/B05_Profile_UI_Structures_Marks"; +import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu"; +import { attachWindowScroll, drawLongitudinalPanel } from "./B06_Section_UI_Section_View_Draw"; import { - buildStructureLane, - STRUCTURE_LANE_HEIGHT_PX, -} from "../B05_Profile/B05_Profile_UI_Structures_Marks"; -import { - mountSectionStructureMenu, - moveMarkById, - type SectionStructureEdit, -} from "./B06_Section_UI_Section_View_Menu"; -import { - structureAnchorM, type StructureInstance, type StructureType, } from "../B05_Profile/B05_Profile_Api_Structures"; import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import { longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal"; -import { - buildLongitudinalChart, - visibleChainageRange, - visibleElevationRange, -} from "./B06_Section_UI_Section_View_Chart"; import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; -import { computeMassHaulSeries } from "@util/common_util_mass_haul"; -import { badgeValuesFrom, createMassHaulBadge } from "@util/common_util_mass_haul_badge"; -import { - applyElevationWindow, - needsFullRedraw, - Y_AXIS_WINDOW_CLASS, - type ElevationWindowResult, -} from "@util/common_util_chart_ywindow"; +import { createMassHaulBadge } from "@util/common_util_mass_haul_badge"; +import { type ElevationWindowResult } from "@util/common_util_chart_ywindow"; import { BASE_PANEL_HEIGHT, chartHeights, @@ -512,119 +491,24 @@ export function createSectionView( // 생겨 패널이 16,000px 까지 부푼다(2026-09-07 실측). 빼 두면 한 번에 수렴한다. const laneHeight = markStructures.length && markTypes.length ? STRUCTURE_LANE_HEIGHT_PX : 0; const heights = chartHeights(Math.max(lastChartAvailable - laneHeight, MIN_LONG_HEIGHT)); - const moveStationMark = (markId: string, toChainageM: number): void => { - if (!structureEdit) return; - moveMarkById(markId, toChainageM, { - structures: markStructures, - stations: detail.longitudinal.stations ?? [], - edit: structureEdit, - }); - }; const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval); - const chartWidth = Math.max(renderWidth, minWidth); - const chart = buildLongitudinalChart({ + updateChartWindow = drawLongitudinalPanel({ detail, + chartWrap, + renderWidth: Math.max(renderWidth, minWidth), + chartHeight: heights.long, + keepScrollLeft, selectedStationId, exaggeration: currentExaggeration, - selectStation: (stationId) => selectStation(stationId, true), stationInterval: cachedStationInterval, - chartWidth, - chartHeight: heights.long, - minWidth, - scrollLeft: keepScrollLeft, - viewportWidth: chartWrap.clientWidth || chartWidth, - // 측점선을 끌면 그 구조물이 옮겨 간다 — 관은 예약 이동, 구조물은 정본 이동. - onDragStation: structureEdit - ? (stationId, toChainageM) => moveStationMark(stationId, toChainageM) - : undefined, + structures: markStructures, + types: markTypes, + edit: structureEdit, + conversion: currentConversion, + naturalSpoilSlope: currentNaturalSpoilSlope, + selectStation: (stationId) => selectStation(stationId, true), + setMassBadge: (values) => massBadge.set(values), }); - const longAxis = chart.axis; - const nodes: Element[] = [chart.node]; - // 구조물 알약 레인 — B05 종단과 **같은 부품**(`buildStructureLane`)을 그대로 쓴다. - // 배수관·세월교도 여기 같은 알약으로 선다(2026-09-07 사용자 지시 4 표시 통일). - if (markStructures.length && markTypes.length) { - nodes.push( - buildStructureLane({ - structures: markStructures, - types: markTypes, - x: chart.toX, - chainageAt: chart.toChainage, - maxChainageM: chart.maxChainageM, - widthPx: chartWidth, - axisWidthPx: LONG_PAD.left, - stationIntervalM: cachedStationInterval, - selectedId: null, - // B06 에서 알약을 누르면 그 측점 카드를 고른다 — 목록 클릭과 같은 규칙. - onSelect: (structureId) => { - if (!structureId) return; - const hit = markStructures.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 currentDetail?.cross_sections ?? []) { - const gap = Math.abs(section.chainage_m - at); - if (!best || gap < best.gap) best = { id: section.station_id, gap }; - } - if (best) selectStation(best.id, true); - }, - // 알약 끌기도 B05 와 같게 받는다(2026-09-12 일원화) — 정본은 [저장]·[확정]에서. - onMove: (structureId, toChainageM) => moveStationMark(structureId, toChainageM), - }), - ); - } - - chartWrap.replaceChildren(...nodes); - // 종단 그래프 우클릭 — B05 와 같은 메뉴다(가까운 구조물이 있으면 삭제, 없으면 - // 구조물군 → 종류 2단 추가). 그래프를 다시 그릴 때마다 붙인다(상태를 안 남긴다). - if (structureEdit) { - mountSectionStructureMenu(chartWrap, { - structures: markStructures, - types: markTypes, - x: chart.toX, - chainageAt: chart.toChainage, - maxChainageM: chart.maxChainageM, - edit: structureEdit, - }); - } - // 유토곡선 그래프는 B06 에서 **그리지 않는다**(2026-09-06 사용자 지시) — 자리를 많이 - // 먹는데 정작 필요한 값은 마지막 지점 누가토량 하나다. 곡선은 B05 유토곡선 패널에서 - // 펼쳐 본다. 여기서는 같은 계산으로 값만 내 좌측 상단 배지에 올린다(기준: 횡단). - if (currentConversion) { - const series = computeMassHaulSeries( - detail.longitudinal, - detail.cross_sections, - currentConversion, - currentNaturalSpoilSlope, - ); - const cross = series.find((entry) => entry.basis === "cross") ?? series[0]; - massBadge.set(cross ? badgeValuesFrom(cross.result) : null); - } else { - massBadge.set(null); - } - // 종단 고정 Y축 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너 - // 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다). - if (longAxis) { - const overlay = buildStickyYAxis(longAxis, heights.long); - // 세로 창을 따라 움직이는 축임을 표시한다(유토곡선 축과 구분). - overlay.classList.add(Y_AXIS_WINDOW_CLASS); - chartWrap.prepend(overlay); - } - - // 세로 창 갱신기 — 스크롤마다 종단은 변환으로, 유토곡선은 곡선만 다시 그려 따라온다. - updateChartWindow = () => { - const { fromM, toM } = visibleChainageRange( - chart.toChainage, - chart.maxChainageM, - chartWrap.scrollLeft, - chartWrap.clientWidth || chartWidth, - ); - return applyElevationWindow( - chartWrap, - visibleElevationRange(detail, fromM, toM) ?? undefined, - ); - }; - chartWrap.scrollLeft = keepScrollLeft; // 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다. syncScrollMargin(); @@ -641,22 +525,11 @@ export function createSectionView( } } - /** 상단 패널을 통째로 다시 그리기까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */ - const SCROLL_SETTLE_MS = 80; - let settledScrollLeft = 0; - let scrollSettleTimer = 0; - // 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정, B05와 같은 규칙) — - // 스크롤마다 겹 하나의 변환만 갈아 끼우므로 실시간으로 따라온다. - chartWrap.addEventListener("scroll", () => { - const fitted = updateChartWindow?.() ?? null; - window.clearTimeout(scrollSettleTimer); - if (!needsFullRedraw(fitted)) return; - scrollSettleTimer = window.setTimeout(() => { - if (Math.abs(chartWrap.scrollLeft - settledScrollLeft) < 1) return; - settledScrollLeft = chartWrap.scrollLeft; - drawPanel(); - }, SCROLL_SETTLE_MS); - }); + attachWindowScroll( + chartWrap, + () => updateChartWindow, + () => drawPanel(), + ); const draw = (): void => { if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return; diff --git a/B06_Section/B06_Section_UI_Section_View_Draw.ts b/B06_Section/B06_Section_UI_Section_View_Draw.ts new file mode 100644 index 00000000..8888e8f1 --- /dev/null +++ b/B06_Section/B06_Section_UI_Section_View_Draw.ts @@ -0,0 +1,210 @@ +/* ============================================================================= + * 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"; + +/** 그래프 한 벌을 세우는 데 필요한 값 — 본체가 재고 고른 것을 그대로 넘긴다. */ +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; + /** 좌측 상단 누가토량 배지 — 값이 없으면 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, + // 측점선을 끌면 그 구조물이 옮겨 간다 — 관은 예약 이동, 구조물은 정본 이동. + 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 와 같은 메뉴다(가까운 구조물이 있으면 삭제, 없으면 구조물군 + // → 종류 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, + ); + return applyElevationWindow(chartWrap, visibleElevationRange(detail, fromM, toM) ?? 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); + }); +}