Files
Aislo/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts
T
eomsangdonandClaude Opus 5 b0fe443269 feat(B06): 자연방토 반영 — 사토를 자연방토/사토장 운반으로 가름
판정 기준은 성토측 자연 지반 경사(사용자 확정). 지반이 임계보다 가파르면 부어 놓은 흙이
쌓이지 않고 스스로 흘러내리므로 운반비를 세지 않는다.

계상 방식은 참고 도면을 근거로 정했다. 도면의 사토 balloon은 L=(운반거리) 없이
M.N=측점만 적는다 - 그 노선의 사토가 이미 운반비 없이 처리되고 있다는 뜻이고,
자연방토라는 이름을 안 썼을 뿐 처리 방식이 자연방토다. 그래서 새 항목을 만들지 않고
사토 안에서 가른다. 판정 통과분은 도면과 똑같이 M.N만, 실패분에만 "사토장=X㎥"를 더한다.
수량은 그대로 두고 성토로 흡수하지 않아 유토곡선 모양과 누가토량은 안 바뀐다.

- config NATURAL_SPOIL_MIN_GROUND_SLOPE = 1/1.5 (rise/run, 약 33.7도) 신설.
  유일한 정의처이며 프론트는 context로 받아 쓴다. 못 받으면 자연방토 없음(보수적)
- 엔진 fill_ground_slope(): 노면 끝~성토 사면의 첫 지반 교차까지 평균 경사.
  양쪽이 다 성토면 완만한 쪽을 택한다(보수적)
- AreaSample/MassHaulPoint에 natural_spoil - 양 끝 측점이 둘 다 가능할 때만 참
- HaulResidual.natural_m3 - 잔량 구간에서 자연방토 가능한 길이 비율만큼 계상.
  장거리 상쇄로 깎이면 안분·자연방토도 같은 비율로 감소
- HaulPlan.natural_spoil_m3 + 요약 칩

700줄 초과 2건 분리: _Engine_Areas.py(면적 적분), _UI_MassHaul_Settle.ts(안분·장비·상쇄·
직렬화). 의존은 한 방향(Balance -> Settle 함수, Settle -> Balance 타입만)이라 순환 없음.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 18:18:08 +09:00

670 lines
32 KiB
TypeScript

/* =============================================================================
* B06_wf3_ProfileCross_UI_Section_View.ts
* 종·횡단 도면 뷰 컨트롤러: 상단 패널(종단면도 + 유토곡선)과 횡단 카드 그리드를 조립하고,
* 측점 선택/스크롤, 반응형 폭 측정(ResizeObserver), 패널 높이 조절, 같은 행 높이 통일,
* 단건 카드 갱신을 관장한다.
*
* 700줄 제한 대응으로 렌더러 본체는 분리했다:
* - 공통 상수·유틸: `_UI_Section_Common`
* - 종단 렌더러: `_UI_Longitudinal`
* - 유토곡선 계산: `_UI_MassHaul`
* - 유토곡선 렌더러: `_UI_MassHaul_View`
* - 횡단 카드 렌더러: `_UI_Cross_View`
* 외부(Page)에는 `createSectionView`와 `CrossDesignChange` 타입만 노출한다.
* ========================================================================== */
import { createPanelResizer } from "@ui/ui_template_resizer";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import type {
CrossSection,
EarthworkConversion,
HaulEquipmentLimit,
SectionDetailResponse,
} from "./B06_wf3_ProfileCross_Api_Fetch";
import type { RockBoundaryControl } from "./B06_wf3_ProfileCross_UI_Cross_Design";
import type { CrossAreaKey } from "./B06_wf3_ProfileCross_UI_Cross_Areas";
import {
createCrossSectionCard,
crossCardNaturalHeight,
type CrossCardElement,
} from "./B06_wf3_ProfileCross_UI_Cross_View";
import {
createLongitudinalProfile,
longitudinalMinimumWidth,
} from "./B06_wf3_ProfileCross_UI_Longitudinal";
import {
computeMassHaulSeries,
MASS_HAUL_BALANCE_KEY,
MASS_HAUL_DEFAULT_VISIBLE,
type MassHaulSeries,
} from "./B06_wf3_ProfileCross_UI_MassHaul";
import { computeHaulPlan } from "./B06_wf3_ProfileCross_UI_MassHaul_Balance";
import {
configureBalloonOffsets,
resetBalloonOffsets,
} from "./B06_wf3_ProfileCross_UI_MassHaul_Balance_View";
import {
createMassHaulChart,
createMassHaulLegend,
createMassHaulSummary,
MASS_HAUL_HEIGHT,
MASS_HAUL_MIN_HEIGHT,
} from "./B06_wf3_ProfileCross_UI_MassHaul_View";
import {
calculateYScale,
CROSS_GRID_GAP,
CROSS_GRID_MIN_WIDTH,
CROSS_WIDTH,
type CrossDesignChange,
type DesignChangeHandler,
designElevationAt,
emptyView,
inferStationInterval,
L,
LONG_HEIGHT,
type YScaleOptions,
} from "./B06_wf3_ProfileCross_UI_Section_Common";
export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl };
/**
* 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**.
*
* 평소에는 쓰지 않는다 — 그래프 몫은 `chartWrap`을 직접 재서 정한다(어림값이 실제보다 크면
* 유토곡선 아래에 빈 공간이 남는다). 화면에 붙기 전이라 잴 수 없는 첫 렌더에서만 쓰는
* 출발값이고, 최소 패널 높이 계산의 기준이기도 하다.
*/
const PANEL_CHROME_PX = 102;
/** 손대지 않았을 때의 패널 높이 — 이 값이 축소 비례의 기준(H₀)이 된다. */
const BASE_PANEL_HEIGHT = LONG_HEIGHT + MASS_HAUL_HEIGHT + PANEL_CHROME_PX;
const MIN_LONG_HEIGHT = 110;
const MIN_PANEL_HEIGHT = MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT + PANEL_CHROME_PX;
const MAX_PANEL_HEIGHT_RATIO = 0.8;
const PANEL_HEIGHT_KEY = "b06:profile-panel-height";
const PANEL_COLLAPSED_KEY = "b06:profile-panel-collapsed";
// 곡선 키·기본값이 바뀔 때마다 저장 키의 판을 올려 갈아탄다(옛 세션값을 읽으면 의도와 다른
// 곡선이 켜진다). v2: 키가 `${기준}_${환산}` → `${기준}`. v3: 기본 표시가 횡단 기준 하나로.
// v4: 토량 분배 레이어(`balance`)가 범례에 합류.
const MASS_HAUL_VISIBLE_KEY = "b06:masshaul-visible-v4";
/** 곡선 기준 + 토량 분배 레이어. 저장값이 없을 때 켜 두는 항목. */
const DEFAULT_VISIBLE_KEYS: string[] = [...MASS_HAUL_DEFAULT_VISIBLE, MASS_HAUL_BALANCE_KEY];
/** 켜 둔 곡선 목록은 세션에만 남긴다(패널 높이·접힘과 같은 규칙). */
function readVisibleSeries(): Set<string> {
try {
const raw = sessionStorage.getItem(MASS_HAUL_VISIBLE_KEY);
if (!raw) return new Set(DEFAULT_VISIBLE_KEYS);
const parsed: unknown = JSON.parse(raw);
// 전부 끈 상태(빈 배열)도 사용자의 선택이므로 기본값으로 되돌리지 않는다.
return Array.isArray(parsed) ? new Set(parsed.map(String)) : new Set(DEFAULT_VISIBLE_KEYS);
} catch {
return new Set(DEFAULT_VISIBLE_KEYS);
}
}
/**
* 패널 높이를 두 그래프에 나눈다 (2026-08-02 사용자 지시).
* 기본값 이상으로 키우면 → 종단도는 현재 높이 그대로 두고 늘어난 몫은 유토곡선이 먹는다.
* 기본값보다 줄이면 → 두 그래프가 같은 비율로 함께 작아진다.
*/
function chartHeights(availableHeightPx: number): { long: number; mass: number } {
if (!Number.isFinite(availableHeightPx) || availableHeightPx <= 0) {
return { long: LONG_HEIGHT, mass: MASS_HAUL_HEIGHT };
}
// 두 그래프가 나눠 가질 실제 몫. **합이 이 값과 정확히 같아야** 한다 — 넘으면 세로 스크롤이,
// 모자라면 유토곡선 아래에 빈 공간이 남는다(2026-08-02 사용자 지적).
const available = Math.max(availableHeightPx, MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT);
// 기본 높이 이상이면 종단도는 고정, 늘어난 몫은 유토곡선이 전부 흡수한다.
if (available >= LONG_HEIGHT + MASS_HAUL_HEIGHT) {
return { long: LONG_HEIGHT, mass: available - LONG_HEIGHT };
}
const ratio = available / (LONG_HEIGHT + MASS_HAUL_HEIGHT);
// 유토곡선 몫을 먼저 잡되 종단도 하한을 남겨 둔다 — 그래야 두 하한이 동시에 걸려도 합이 넘지 않는다.
const mass = Math.min(
Math.max(MASS_HAUL_MIN_HEIGHT, Math.round(MASS_HAUL_HEIGHT * ratio)),
available - MIN_LONG_HEIGHT,
);
return { long: available - mass, mass };
}
/**
* 종단 렌더러가 돌려준 `chart-wrap`에서 SVG만 꺼낸다.
* 종단도와 유토곡선이 **하나의 가로 스크롤 컨테이너**를 공유해야 X축 정렬이 유지된다
* (각자 wrap을 가지면 스크롤이 따로 놀아 같은 측점이 어긋나 보인다).
*/
function unwrapChart(node: HTMLElement): Element {
return node.classList.contains("b06-section__chart-wrap") && node.firstElementChild
? node.firstElementChild
: node;
}
export interface SectionViewController {
root: HTMLElement;
render: (
detail: SectionDetailResponse,
verticalExaggeration: number,
crossHalfWidth?: number,
stationInterval?: number,
earthworkConversion?: EarthworkConversion,
haulEquipmentLimits?: HaulEquipmentLimit[],
/** balloon 위치 캐시를 가르는 키(프로젝트+경로). 노선이 다르면 위치가 섞이면 안 된다. */
balloonScope?: string,
/** 자연방토 판정 경사(config). 못 받으면 자연방토 없음으로 본다. */
naturalSpoilMinSlope?: number,
) => void;
/** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */
refreshCard: (chainageM: number) => void;
clear: () => void;
dispose: () => void;
}
export function createSectionView(
onDesignChange?: DesignChangeHandler,
rockBoundary?: RockBoundaryControl,
): SectionViewController {
const root = document.createElement("div");
root.className = "b06-section";
let currentDetail: SectionDetailResponse | null = null;
let selectedStationId: string | null = null;
/** 선택 카드에서 강조 중인 면적. 카드를 다시 그려도 그대로 살아나야 하므로 여기 둔다. */
let activeAreaKey: CrossAreaKey | null = null;
let currentExaggeration = 1;
let currentCrossHalfWidth: number | undefined;
let currentStationInterval: number | undefined;
let currentConversion: EarthworkConversion | undefined;
let currentHaulLimits: HaulEquipmentLimit[] | undefined;
let currentNaturalSpoilSlope: number | undefined;
let renderWidth = 0;
let resizeTimer = 0;
let panelResizeTimer = 0;
// 유토곡선 표시 곡선 집합. 토글해도 Y 스케일은 전체 곡선 기준이라 축이 움직이지 않는다.
const visibleSeries = readVisibleSeries();
// 그래프 상자의 마지막 실측 높이와 보정 예약 플래그(빈 공간·세로 스크롤 둘 다 막는다).
let lastChartAvailable = 0;
let chartFitScheduled = false;
// 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신)
let cachedYScale: YScaleOptions | undefined;
let cachedStationInterval = 1;
let cachedCardWidth = CROSS_WIDTH;
// 같은 행 카드는 같은 높이가 되도록 draw에서 측점별 행 높이를 계산해 둔다(단건 갱신도 이 값 재사용).
const cachedRowHeight = new Map<string, number>();
/* ── 상단 슬라이드 패널(종단면도 + 유토곡선) ─────────────────────────
* 패널·리사이저는 한 번만 만들고 draw()에서는 차트 내용만 갈아 끼운다.
* 매 렌더마다 새로 만들면 리사이저 핸들과 접힘 상태가 초기화된다. */
const panel = document.createElement("section");
// 접기/펴기는 B05 하단 패널과 같은 방식이다 — 전역 `ui-collapsible` 규칙에 기대지 않고
// `is-collapsed`에 걸 자체 CSS로 높이를 0으로 접고 자식을 숨긴다(손잡이만 남는다).
panel.className = "b06-section__panel";
// 최소 높이는 TS 상수 하나로만 정의하되 **접힘 상태에는 걸리지 않아야** 한다 — 인라인
// min-height를 두면 접어도 그 높이만큼 빈 배경이 남는다. CSS 변수로 넘겨 펼친 상태에만 건다.
panel.style.setProperty("--b06-profile-min-height", `${MIN_PANEL_HEIGHT}px`);
// 제목 문구는 없앴다(2026-08-02 사용자 지시). 헤더 행은 상태 문구만 담고, 접기/펼치기는
// 패널 하단 가운데 손잡이가 맡는다.
const panelHeader = document.createElement("header");
panelHeader.className = "b06-section__panel-status";
const panelCount = document.createElement("span");
panelHeader.append(panelCount);
const chartWrap = document.createElement("div");
chartWrap.className = "b06-section__chart-wrap";
const panelBody = document.createElement("div");
panelBody.className = "b06-section__panel-body";
panelBody.append(chartWrap);
// 접기 손잡이는 패널 **하단 가운데**에 둔다(2026-08-02 사용자 지시). 모양은 좌측 사이드
// 패널과 같은 삼각형 손잡이를 90° 돌린 것이며, 위로 접히는 패널이라 `collapseToward: "up"`이다.
//
// 공용 `ui-collapsible__title`은 **쓰지 않는다**. 그 클래스를 달면 전역 규칙이 캐럿(▾/▸)을
// 하나 더 그리는데, 접힘 상태 전역 선택자(`.ui-collapsible.is-collapsed > .ui-collapsible__title`)가
// B06 쪽 무력화 규칙보다 특정도가 높아 캐럿이 삼각형 아이콘과 겹쳐 보였다. 토글은 여기서 직접 한다.
const panelToggle = createWorkflowPanelHandle("bottom", "up");
panelToggle.root.classList.add("b06-section__panel-toggle");
panel.append(panelHeader, panelBody, panelToggle.root);
// 접힘 상태는 세션에만 남긴다(리사이저와 같은 규칙).
if (sessionStorage.getItem(PANEL_COLLAPSED_KEY) === "true") panel.classList.add("is-collapsed");
panelToggle.setOpen(!panel.classList.contains("is-collapsed"));
panelToggle.root.addEventListener("click", () => {
const collapsed = panel.classList.toggle("is-collapsed");
sessionStorage.setItem(PANEL_COLLAPSED_KEY, String(collapsed));
panelToggle.setOpen(!collapsed);
// 펼칠 때는 레이아웃이 잡힌 **다음 프레임**에 그린다 — 그래프 몫을 실측으로 잡기 때문에
// 같은 프레임에 그리면 접혀 있던 0 높이를 읽는다(B05 하단 패널과 같은 처리).
if (!collapsed) requestAnimationFrame(drawPanel);
});
const panelResizer = createPanelResizer({
axis: "vertical",
target: panel,
cssVar: "--b06-profile-height",
// 손잡이가 패널 아래쪽에 있으므로 아래로 끌면 커진다.
direction: 1,
min: MIN_PANEL_HEIGHT,
// 부모(`.b06-profile__main`)는 횡단 카드까지 담은 스크롤 콘텐츠라 높이가 수천 px다.
// 이 패널은 화면 위에 sticky로 붙어 있으니 상한은 화면 높이 기준이어야 한다.
max: () => window.innerHeight * MAX_PANEL_HEIGHT_RATIO,
storageKey: PANEL_HEIGHT_KEY,
onResize: () => {
// 끄는 동안 매 프레임 SVG를 다시 그리면 손이 늦게 따라온다 — 멈춘 뒤 한 번만 그린다.
window.clearTimeout(panelResizeTimer);
panelResizeTimer = window.setTimeout(() => {
drawPanel();
// 패널이 커지면 선택 카드가 그 아래로 들어간다 — 새 패널 높이에 맞춰 다시 끌어올린다.
revealCard(selectedStationId, "auto");
}, 120);
},
});
panel.append(panelResizer.root);
/**
* 패널 **어디에서든** 굴린 휠은 페이지가 아니라 그래프를 좌우로 민다(2026-08-02 사용자 지시).
*
* 끝에 닿았다고 기본 동작을 넘기면 아래 횡단도 목록이 대신 스크롤돼 화면이 튄다 — 패널 위에
* 커서가 있는 동안은 세로 스크롤을 통째로 막는다. 캡처 단계에서 잡는 이유는 패널 안의 다른
* 가로 스크롤 상자(범례 줄)가 먼저 먹어 버리지 않게 하기 위해서다.
*/
panel.addEventListener(
"wheel",
(event) => {
if (event.ctrlKey) return; // 브라우저 확대 제스처는 건드리지 않는다
event.preventDefault();
const room = chartWrap.scrollWidth - chartWrap.clientWidth;
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
if (room <= 1 || !delta) return;
chartWrap.scrollLeft = Math.min(Math.max(chartWrap.scrollLeft + delta, 0), room);
},
{ passive: false, capture: true },
);
/**
* 가운데 버튼 드래그로 그래프를 좌우로 민다(2026-08-02 사용자 지시). 좌클릭은 측점 선택 몫이다.
* 가운데 버튼을 누르면 브라우저가 자동 스크롤을 켜는데, 그건 `mousedown` 기본동작이라
* `pointerdown`에서는 못 막는다 — `mousedown`/`auxclick`에서 막아야 팬만 남는다.
*/
let panPointerId: number | null = null;
let panLastX = 0;
chartWrap.addEventListener("mousedown", (event) => {
if (event.button === 1) event.preventDefault();
});
chartWrap.addEventListener("auxclick", (event) => {
if (event.button === 1) event.preventDefault();
});
chartWrap.addEventListener("pointerdown", (event) => {
if (event.button !== 1) return;
event.preventDefault();
panPointerId = event.pointerId;
panLastX = event.clientX;
chartWrap.classList.add("is-panning");
chartWrap.setPointerCapture(event.pointerId);
});
chartWrap.addEventListener("pointermove", (event) => {
if (panPointerId !== event.pointerId) return;
chartWrap.scrollLeft -= event.clientX - panLastX;
panLastX = event.clientX;
});
const endChartPan = (event: PointerEvent): void => {
if (panPointerId !== event.pointerId) return;
panPointerId = null;
chartWrap.classList.remove("is-panning");
try {
chartWrap.releasePointerCapture(event.pointerId);
} catch {
/* 이미 해제됨 */
}
};
chartWrap.addEventListener("pointerup", endChartPan);
chartWrap.addEventListener("pointercancel", endChartPan);
const panelHeight = (): number => {
// 첫 렌더 때 panel은 아직 root에 붙기 전(detached)이라 getComputedStyle이 빈 값을 준다.
// 리사이저가 복원해 둔 인라인 변수부터 읽어야 저장된 높이가 첫 화면부터 반영된다.
// (computed만 읽으면 기본값으로 폴백해 종단도가 잘린 채로 그려진다 — C05 검증 F1)
const inline = Number.parseFloat(panel.style.getPropertyValue("--b06-profile-height"));
if (Number.isFinite(inline) && inline > 0) return inline;
const raw = Number.parseFloat(getComputedStyle(panel).getPropertyValue("--b06-profile-height"));
return Number.isFinite(raw) && raw > 0 ? raw : BASE_PANEL_HEIGHT;
};
const contentWidth = (): number => {
// 요소가 DOM 밖(detached)이면 computed padding이 ""라 parseFloat이 NaN을 만든다.
// NaN이 renderWidth로 흘러가면 종단 SVG 전 좌표가 NaN이 되므로 0으로 방어한다.
const style = getComputedStyle(root);
const pad = (value: string): number => Number.parseFloat(value) || 0;
const width = root.clientWidth - pad(style.paddingLeft) - pad(style.paddingRight);
return Number.isFinite(width) ? Math.max(0, width) : 0;
};
/**
* 선택 카드를 화면에 남긴다. 상단 패널이 sticky라 `center`로 보내면 패널이 커졌을 때
* 그 아래로 숨는다 — `nearest` + `scroll-margin-top`(패널 높이)이면 가려질 때만,
* 가려지지 않을 만큼만 움직인다.
*/
const revealCard = (stationId: string | null, behavior: ScrollBehavior): void => {
if (!stationId) return;
document.getElementById(`cross-${stationId}`)?.scrollIntoView({ behavior, block: "nearest" });
};
/** 카드 하나의 선택 표시를 갱신한다 — **다시 만들지 않는다**(줌·팬이 살아남아야 한다). */
const markCard = (stationId: string | null): void => {
if (!stationId) return;
const card = document.getElementById(`cross-${stationId}`) as CrossCardElement | null;
card?.applySelection?.(stationId === selectedStationId, activeAreaKey);
};
/**
* 선택이 바뀐 흔적만 화면에 반영한다 — **재렌더 금지**.
* 카드를 새로 만들면(전체든 한 장이든) 사용자가 맞춰 둔 휠 줌·팬(viewBox)이 초기화된다
* (2026-08-02 사용자 지적). 선택 표시가 바뀌는 카드는 이전·현재 둘뿐이고, 그 둘도 클래스와
* 강조 상태만 갈아 끼운다.
*/
const applySelection = (previousStationId: string | null): void => {
markCard(previousStationId);
if (selectedStationId !== previousStationId) markCard(selectedStationId);
drawPanel();
};
/** 같은 측점을 다시 고르면 선택을 푼다(2026-08-02 사용자 지시) — 카드·측점선 어느 쪽이든. */
const selectStation = (stationId: string, scroll: boolean): void => {
const previous = selectedStationId;
const deselect = selectedStationId === stationId;
selectedStationId = deselect ? null : stationId;
// 측점이 바뀌면 면적 강조는 따라가지 않는다(다른 측점의 강조를 물려받으면 오해를 부른다).
activeAreaKey = null;
applySelection(previous);
if (scroll && !deselect) revealCard(stationId, "smooth");
};
/**
* 횡단 카드의 면적 값을 눌렀을 때. **선택되지 않은 카드에서 눌러도** 그 카드를 고르면서
* 누른 면적까지 한 번에 강조한다(2026-08-02 사용자 지시).
* 이미 선택된 카드라면 카드가 자기 DOM에서 강조를 켜고 끄므로 여기서는 상태만 받아 둔다
* (다시 그릴 때 그대로 되살리기 위해).
*/
const selectArea = (stationId: string, key: CrossAreaKey | null): void => {
if (selectedStationId === stationId) {
activeAreaKey = key;
return;
}
const previous = selectedStationId;
selectedStationId = stationId;
activeAreaKey = key;
applySelection(previous);
revealCard(stationId, "smooth");
};
/** 카드 바깥(그리드 빈 자리)을 누르면 선택을 푼다(2026-08-02 사용자 지시). */
const clearSelection = (): void => {
if (!selectedStationId && !activeAreaKey) return;
const previous = selectedStationId;
selectedStationId = null;
activeAreaKey = null;
applySelection(previous);
};
const buildCrossCard = (section: CrossSection, forcedHeightPx?: number): HTMLElement =>
createCrossSectionCard(
section,
section.station_id === selectedStationId,
currentExaggeration,
(stationId) => selectStation(stationId, false),
cachedStationInterval,
currentCrossHalfWidth,
cachedCardWidth,
forcedHeightPx,
currentDetail
? designElevationAt(currentDetail.longitudinal.design_profiles, section.chainage_m)
: undefined,
onDesignChange,
rockBoundary,
section.station_id === selectedStationId ? activeAreaKey : null,
selectArea,
);
/** 범례 버튼 — 곡선 하나를 켜고 끈다. 축은 전체 곡선 기준이라 여기서 움직이지 않는다. */
const toggleSeries = (key: string): void => {
if (visibleSeries.has(key)) visibleSeries.delete(key);
else visibleSeries.add(key);
sessionStorage.setItem(MASS_HAUL_VISIBLE_KEY, JSON.stringify([...visibleSeries]));
drawPanel();
};
/** 상단 패널 내용(종단면도 + 유토곡선 + 요약)만 다시 그린다. */
function drawPanel(): void {
if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return;
// 차트를 갈아 끼우면 가로 스크롤이 0으로 돌아간다 — 측점 버튼 하나 눌렀다고
// 보던 자리가 맨 앞으로 튀면 못 쓴다. 위치를 잡아 뒀다 되돌린다.
const keepScrollLeft = chartWrap.scrollLeft;
const detail = currentDetail;
// 그래프 몫은 **추정하지 않고 잰다**. `chartWrap`은 `flex: 1 / min-height: 0`이라 높이가
// 내용이 아니라 패널에서 정해지므로, 재서 쓰면 되먹임 없이 한 번에 수렴한다.
// 화면에 붙기 전(detached)에는 잴 수 없으니 그때만 `PANEL_CHROME_PX` 추정치로 시작한다.
const measured = chartWrap.clientHeight;
lastChartAvailable =
measured > 0
? measured
: Math.max(panelHeight() - PANEL_CHROME_PX, MIN_LONG_HEIGHT + MASS_HAUL_MIN_HEIGHT);
const heights = chartHeights(lastChartAvailable);
const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval);
const chartWidth = Math.max(renderWidth, minWidth);
// 종단도 높이가 줄면 Y스케일도 그 높이로 다시 잡아야 표고가 잘리지 않는다.
cachedYScale = calculateYScale(detail, heights.long);
const nodes: Element[] = [
unwrapChart(
createLongitudinalProfile(
detail.longitudinal,
selectedStationId,
currentExaggeration,
cachedYScale,
(stationId) => selectStation(stationId, true),
cachedStationInterval,
chartWidth,
heights.long,
minWidth,
detail.longitudinal.design_profiles ?? [],
),
),
];
const series: MassHaulSeries[] = currentConversion
? computeMassHaulSeries(
detail.longitudinal,
detail.cross_sections,
currentConversion,
currentNaturalSpoilSlope,
)
: [];
// 토량 분배는 **면을 깐 곡선 하나**(= 켜 둔 첫 곡선)에만 얹는다 — 곡선마다 평형선을
// 그리면 계단이 서로 엇갈려 어느 쪽 배분인지 읽히지 않는다.
const bandedSeries = series.find((entry) => visibleSeries.has(entry.key));
const haulPlan =
bandedSeries && visibleSeries.has(MASS_HAUL_BALANCE_KEY)
? computeHaulPlan(bandedSeries.result, currentHaulLimits)
: null;
if (series.length) {
// 유토곡선 SVG는 종단면도와 **같은 부모의 형제**여야 한다. 감싸는 상자를 하나라도 끼우면
// 그 상자가 스크롤 컨테이너 폭 계산에 끼어들어 두 그래프의 측점 세로선이 어긋난다.
nodes.push(
createMassHaulChart(
series,
visibleSeries,
detail.longitudinal,
selectedStationId,
cachedStationInterval,
chartWidth,
heights.mass,
minWidth,
(stationId) => selectStation(stationId, true),
haulPlan,
),
);
}
chartWrap.replaceChildren(...nodes);
panel.querySelector(".b06-masshaul__legend")?.remove();
panel.querySelector(".b06-masshaul__summary")?.remove();
// 범례는 유토곡선 우측 상단에 겹쳐 놓는다(2026-08-02 사용자 지시). 다만 **스크롤 컨테이너
// 밖**에서 절대 위치로 띄운다 — 안에 넣으면 컨테이너 폭 계산에 끼어들어 두 그래프의
// 측점 세로선이 어긋난다(1차 수정에서 겪은 문제). 세로 위치는 종단면도 높이로 잡는다.
if (series.length) {
const legend = createMassHaulLegend(series, visibleSeries, toggleSeries, () => {
resetBalloonOffsets();
drawPanel();
});
legend.style.top = `${heights.long + 6}px`;
panelBody.append(legend);
}
// 요약 수치는 켜 둔 곡선 중 첫 번째 것 — 곡선이 여러 개라 어느 것인지 요약 끝에 밝힌다.
// (선택 측점의 구간 물량은 곡선 위 말풍선이 맡는다.)
const summarySeries = bandedSeries;
if (summarySeries) {
panelCount.textContent = "";
panelBody.append(createMassHaulSummary(summarySeries, haulPlan));
} else {
panelCount.textContent = L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty");
}
chartWrap.scrollLeft = keepScrollLeft;
// 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다.
// 접기 손잡이가 패널 아래 24px 밖으로 나와 있으므로 그 몫까지 더한다.
const panelPx = panel.getBoundingClientRect().height || panelHeight();
root.style.setProperty("--b06-cross-scroll-margin", `${Math.round(panelPx + 32)}px`);
// 첫 렌더(detached)나 패널 높이 변경 직후에는 위에서 잰 값이 아직 옛 레이아웃일 수 있다.
// 다음 프레임에 한 번만 다시 재서 어긋나면 고쳐 그린다. `chartWrap` 높이는 내용과 무관하니
// 되먹임 없이 한 번에 수렴한다.
if (!chartFitScheduled) {
chartFitScheduled = true;
requestAnimationFrame(() => {
chartFitScheduled = false;
const now = chartWrap.clientHeight;
if (now > 0 && Math.abs(now - lastChartAvailable) > 2) drawPanel();
});
}
}
const draw = (): void => {
if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return;
const detail = currentDetail;
cachedStationInterval =
currentStationInterval ?? inferStationInterval(detail.longitudinal.stations);
drawPanel();
// 횡단면도 제목·개수 표기는 두지 않는다(2026-08-02 사용자 지시) — 상단 패널과 카드 사이
// 공간만 먹고, 카드마다 측점이 적혀 있어 무엇을 보는지는 이미 분명하다.
const grid = document.createElement("div");
grid.className = "b06-section__grid";
// 카드 사이 빈 자리를 누르면 선택 해제 — 카드 안 클릭은 카드가 먼저 처리한다.
grid.addEventListener("click", (event) => {
if (!(event.target as HTMLElement).closest(".b06-cross-card")) clearSelection();
});
const columnCount = Math.max(
1,
Math.floor((renderWidth + CROSS_GRID_GAP) / (CROSS_GRID_MIN_WIDTH + CROSS_GRID_GAP)),
);
// CSS auto-fill의 자체 열 수와 JS columnCount가 어긋나면 같은 행 높이 그룹핑이 실제
// 렌더 행과 달라져 카드 내부가 뒤죽박죽된다(N-2-6). 열 수를 JS가 명시해 일치시킨다.
grid.style.gridTemplateColumns = `repeat(${columnCount}, minmax(0, 1fr))`;
cachedCardWidth = (renderWidth - (columnCount - 1) * CROSS_GRID_GAP) / columnCount;
cachedRowHeight.clear();
if (detail.cross_sections.length) {
// 1차: 각 카드의 자연 높이(250px 바닥 적용) 측정 → 같은 행(columnCount 단위) 최댓값을 행 높이로.
const sections = detail.cross_sections;
const naturalHeights = sections.map((section) =>
crossCardNaturalHeight(
section,
currentExaggeration,
cachedCardWidth,
currentCrossHalfWidth,
designElevationAt(detail.longitudinal.design_profiles, section.chainage_m),
),
);
// 2차: 행별 최댓값으로 강제 높이를 정해 같은 행 카드를 동일 높이로 렌더한다.
for (let start = 0; start < sections.length; start += columnCount) {
const rowHeight = Math.max(...naturalHeights.slice(start, start + columnCount));
for (
let index = start;
index < Math.min(start + columnCount, sections.length);
index += 1
) {
cachedRowHeight.set(sections[index].station_id, rowHeight);
grid.append(buildCrossCard(sections[index], rowHeight));
}
}
} else {
grid.append(emptyView(L("B06_Profile_View_NoCross")));
}
// panel은 이미 root의 자식이라 replaceChildren이 떼었다 붙이면서 스크롤을 잃는다.
const keepScrollLeft = chartWrap.scrollLeft;
root.replaceChildren(panel, grid);
chartWrap.scrollLeft = keepScrollLeft;
};
const refreshCard = (chainageM: number): void => {
if (!currentDetail) return;
const section = currentDetail.cross_sections.find(
(candidate) => Math.abs(candidate.chainage_m - chainageM) < 0.01,
);
if (!section) return;
const existing = document.getElementById(`cross-${section.station_id}`);
// 단건 갱신은 draw에서 정해둔 행 높이를 재사용해 같은 행 카드와 높이를 유지한다.
if (existing)
existing.replaceWith(buildCrossCard(section, cachedRowHeight.get(section.station_id)));
// 단면적이 바뀌면 유토곡선도 함께 흔들리므로 상단 패널만 다시 그린다(카드 전체 재렌더 없음).
drawPanel();
};
const resizeObserver = new ResizeObserver(() => {
const nextWidth = contentWidth();
if (!Number.isFinite(nextWidth) || nextWidth <= 0 || Math.abs(nextWidth - renderWidth) < 1)
return;
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
renderWidth = nextWidth;
draw();
}, 150);
});
resizeObserver.observe(root);
return {
root,
render(
detail,
verticalExaggeration,
crossHalfWidth,
stationInterval,
earthworkConversion,
haulEquipmentLimits,
balloonScope,
naturalSpoilMinSlope,
) {
currentDetail = detail;
currentExaggeration = Math.max(verticalExaggeration, 0.1);
currentCrossHalfWidth =
crossHalfWidth !== undefined && crossHalfWidth > 0 ? crossHalfWidth : undefined;
currentStationInterval =
stationInterval !== undefined && stationInterval > 0 ? stationInterval : undefined;
if (earthworkConversion) currentConversion = earthworkConversion;
if (haulEquipmentLimits?.length) currentHaulLimits = haulEquipmentLimits;
if (Number.isFinite(naturalSpoilMinSlope)) currentNaturalSpoilSlope = naturalSpoilMinSlope;
// balloon 위치는 **영구저장소 값이 이긴다** — 다른 브라우저에서 옮긴 자리를 그대로 받는다.
configureBalloonOffsets(balloonScope ?? "default", detail.balloon_offsets ?? undefined);
// 진입 시 측점을 자동으로 고르지 않는다(2026-08-02 사용자 지시) — 0측점이 선택된 채로
// 시작하면 사용자가 고르지도 않은 카드가 강조돼 있고 유토곡선 말풍선도 떠 있다.
renderWidth = contentWidth();
draw();
if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(root));
},
refreshCard,
clear() {
currentDetail = null;
selectedStationId = null;
root.replaceChildren();
},
dispose() {
window.clearTimeout(resizeTimer);
window.clearTimeout(panelResizeTimer);
panelResizer.dispose();
resizeObserver.disconnect();
},
};
}