Files
Aislo/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts
T
eomsangdonandClaude Opus 5 6bc9cc4080 fix(B06): 유토곡선 패널 리사이저가 잡히지 않던 문제 + 상한·눈금 보정
헤드리스 브라우저로 실화면을 확인하다 드래그가 전혀 먹지 않는 것을 발견했다.
손잡이 중심의 hit-test가 손잡이가 아니라 패널을 돌려주고 있었다.

- 손잡이 위치: 패널이 `overflow: hidden`이라 경계에 걸치게 둔 `bottom: -3px`는
  아래 절반이 잘려 나가 클릭 영역이 3px도 남지 않았다. `bottom: 0` + 높이 8px로
  안쪽에 붙여 전체가 잡히게 했다.
- 높이 상한: 부모 `.b06-profile__main`은 횡단 카드까지 담은 스크롤 콘텐츠라
  clientHeight가 5809px였고, 상한이 4647px로 잡혀 사실상 무제한이었다. 이 패널은
  화면 위에 sticky로 붙으므로 상한을 화면 높이 기준으로 바꿨다.
- Y축 눈금: 패널을 줄이면 유토곡선 그래프 몫이 60px대까지 내려가 눈금 5개의
  라벨이 서로 겹쳤다. 그래프가 낮을 때는 3개만 그린다.

실화면 검증(프로젝트 테스트1, route 48, 측점 24개 / 설계 23개):
- 기본 496px → SVG [220, 190]
- 아래로 드래그 694px → SVG [220, 378] (종단도 고정, 유토곡선이 늘어난 몫 흡수)
- 위로 드래그 392px → SVG [170, 147] (기본값 대비 비율만큼 둘 다 축소)
- 접기 35px / 펼치기 복원
- 종단도와 유토곡선의 첫 측점 x=62, 마지막 측점 x=1454로 세 높이 모두에서 일치
- 요약: 절토(자연) 216.1㎥ (토사 14.9 / 리핑암 201.2 / 발파암 0.0),
  절토(다짐환산) 244.8㎥, 성토 321.4㎥, 토취 76.6㎥
  → 14.9×0.90 + 201.2×1.15 = 244.79, 244.8 − 321.4 = −76.6 으로 수식과 일치

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 09:40:34 +09:00

395 lines
16 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 { attachCollapsible } from "@ui/ui_template_collapsible";
import type {
CrossSection,
EarthworkConversion,
SectionDetailResponse,
} from "./B06_wf3_ProfileCross_Api_Fetch";
import type { RockBoundaryControl } from "./B06_wf3_ProfileCross_UI_Cross_Design";
import {
createCrossSectionCard,
crossCardNaturalHeight,
} from "./B06_wf3_ProfileCross_UI_Cross_View";
import {
createLongitudinalProfile,
longitudinalMinimumWidth,
} from "./B06_wf3_ProfileCross_UI_Longitudinal";
import { computeMassHaul, type MassHaulResult } from "./B06_wf3_ProfileCross_UI_MassHaul";
import {
createMassHaulChart,
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 };
/** 헤더·요약줄·테두리가 먹는 세로 공간. 패널 높이에서 이만큼 빼야 그래프 몫이 된다. */
const PANEL_CHROME_PX = 96;
/** 손대지 않았을 때의 패널 높이 — 이 값이 축소 비례의 기준(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";
/**
* 패널 높이를 두 그래프에 나눈다 (2026-08-02 사용자 지시).
* 기본값 이상으로 키우면 → 종단도는 현재 높이 그대로 두고 늘어난 몫은 유토곡선이 먹는다.
* 기본값보다 줄이면 → 두 그래프가 같은 비율로 함께 작아진다.
*/
function chartHeights(panelHeightPx: number): { long: number; mass: number } {
if (!Number.isFinite(panelHeightPx) || panelHeightPx <= 0) {
return { long: LONG_HEIGHT, mass: MASS_HAUL_HEIGHT };
}
if (panelHeightPx >= BASE_PANEL_HEIGHT) {
return {
long: LONG_HEIGHT,
mass: Math.max(MASS_HAUL_MIN_HEIGHT, panelHeightPx - PANEL_CHROME_PX - LONG_HEIGHT),
};
}
const ratio = panelHeightPx / BASE_PANEL_HEIGHT;
return {
long: Math.max(MIN_LONG_HEIGHT, Math.round(LONG_HEIGHT * ratio)),
mass: Math.max(MASS_HAUL_MIN_HEIGHT, Math.round(MASS_HAUL_HEIGHT * ratio)),
};
}
/**
* 종단 렌더러가 돌려준 `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,
) => 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 currentExaggeration = 1;
let currentCrossHalfWidth: number | undefined;
let currentStationInterval: number | undefined;
let currentConversion: EarthworkConversion | undefined;
let renderWidth = 0;
let resizeTimer = 0;
let panelResizeTimer = 0;
// 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (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");
panel.className = "b06-section__panel ui-collapsible";
const panelHeader = document.createElement("header");
panelHeader.className = "ui-collapsible__title";
const panelTitle = document.createElement("h3");
const panelCount = document.createElement("span");
panelHeader.append(panelTitle, 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);
panel.append(panelHeader, panelBody);
attachCollapsible(panel);
// 접힘 상태는 세션에만 남긴다(리사이저와 같은 규칙).
if (sessionStorage.getItem(PANEL_COLLAPSED_KEY) === "true") panel.classList.add("is-collapsed");
// attachCollapsible보다 **뒤에** 붙여야 토글이 끝난 상태를 읽는다(헤더에 붙이면 토글 전 값이 저장된다).
panel.addEventListener("click", (event) => {
if (!(event.target as HTMLElement).closest(".ui-collapsible__title")) return;
sessionStorage.setItem(PANEL_COLLAPSED_KEY, String(panel.classList.contains("is-collapsed")));
});
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, 120);
},
});
panel.append(panelResizer.root);
const panelHeight = (): number => {
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;
};
const selectStation = (stationId: string, scroll: boolean): void => {
selectedStationId = stationId;
draw();
if (scroll) {
document
.getElementById(`cross-${stationId}`)
?.scrollIntoView({ behavior: "smooth", block: "center" });
}
};
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,
);
/** 상단 패널 내용(종단면도 + 유토곡선 + 요약)만 다시 그린다. */
function drawPanel(): void {
if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return;
// 차트를 갈아 끼우면 가로 스크롤이 0으로 돌아간다 — 측점 버튼 하나 눌렀다고
// 보던 자리가 맨 앞으로 튀면 못 쓴다. 위치를 잡아 뒀다 되돌린다.
const keepScrollLeft = chartWrap.scrollLeft;
const detail = currentDetail;
const heights = chartHeights(panelHeight());
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 massHaul: MassHaulResult | null = currentConversion
? computeMassHaul(detail.cross_sections, currentConversion)
: null;
if (massHaul) {
nodes.push(
createMassHaulChart(
massHaul,
detail.longitudinal,
selectedStationId,
cachedStationInterval,
chartWidth,
heights.mass,
minWidth,
(stationId) => selectStation(stationId, true),
),
);
}
chartWrap.replaceChildren(...nodes);
panelTitle.textContent = `${L("B06_Profile_View_Longitudinal")} · ${L("B06_MassHaul_Title")}`;
const summary = panel.querySelector(".b06-masshaul__summary");
summary?.remove();
if (massHaul) {
panelCount.textContent = "";
panelBody.append(createMassHaulSummary(massHaul));
} else {
panelCount.textContent = L("B06_MassHaul_Empty");
}
chartWrap.scrollLeft = keepScrollLeft;
}
const draw = (): void => {
if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return;
const detail = currentDetail;
cachedStationInterval =
currentStationInterval ?? inferStationInterval(detail.longitudinal.stations);
drawPanel();
const crossHeading = document.createElement("div");
crossHeading.className = "b06-section__heading";
const crossTitle = document.createElement("h3");
crossTitle.textContent = L("B06_Profile_View_Cross");
const crossCount = document.createElement("span");
crossCount.textContent = `${detail.cross_sections.length}${L("B06_Profile_View_CrossCountSuffix")}`;
crossHeading.append(crossTitle, crossCount);
const grid = document.createElement("div");
grid.className = "b06-section__grid";
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, crossHeading, 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) {
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;
selectedStationId ??= detail.longitudinal.stations[0]?.station_id ?? null;
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();
},
};
}