PLAN 2-5 (2026-09-07 사용자 지시 4 「구조물 표시 통일」). B06 종단에는 구조물 마크가
아예 없었고 배수관·세월교만 다른 계통으로 그려졌음.
- B05 가 쓰는 **같은 부품**(`buildStructureLane`)을 B06 종단 그래프 아래에 붙임.
이름·중복 번호·툴팁·약호가 전부 B05 규칙 그대로 따라옴.
- 종단 차트가 `toX`(누가거리 → px)를 내보내 레인이 그래프와 같은 자리를 씀.
- 좌측 「구조물 배치」가 목록을 받을 때마다 `setStructureMarks` 로 넘김(구조물 정본 +
관 정본). 관을 가상 구조물로 바꾸는 `pipesToStructureMarks` 는 B05 안에 있던 것을
공용 자리(`B05_Profile_Api_Structures`)로 올려 **한 벌만** 둠.
- 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고름(좌측 목록 클릭과 같은 규칙).
끌기는 B05 몫이라 여기서는 자리만 보임(배수유역 재분할이 걸림).
- 검증 훅 `window.__b06Marks` 추가(`__corridorBuild` 등과 같은 용도).
자체검증 — 실화면 B06 종단에 레인 1줄·알약 10개(「배수관 1~8 · 세월교 1~2」)가 B05 와
같은 표기로 뜸. 훅 값 `{structures:10, types:36}`. tsc 통과.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
770 lines
37 KiB
TypeScript
770 lines
37 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_UI_Section_View.ts
|
|
* 종·횡단 도면 뷰 컨트롤러: 상단 패널(종단면도 + 유토곡선)과 횡단 카드 그리드를 조립하고,
|
|
* 측점 선택/스크롤, 반응형 폭 측정(ResizeObserver), 패널 높이 조절, 같은 행 높이 통일,
|
|
* 단건 카드 갱신을 관장한다.
|
|
*
|
|
* 700줄 제한 대응으로 렌더러 본체는 분리했다:
|
|
* - 공통 상수·유틸: `_UI_Section_Common`
|
|
* - 종단 렌더러: `_UI_Longitudinal`
|
|
* - 유토곡선 계산: `_UI_MassHaul`
|
|
* - 유토곡선 렌더러: `_UI_MassHaul_View`
|
|
* - 횡단 카드 렌더러: `_UI_Cross_View`
|
|
* 외부(Page)에는 `createSectionView`와 `CrossDesignChange` 타입만 노출한다.
|
|
* ========================================================================== */
|
|
|
|
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,
|
|
HaulEquipmentLimit,
|
|
SectionDetailResponse,
|
|
} from "./B06_Section_Api_Fetch";
|
|
import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design";
|
|
import type { CrossAreaKey } from "./B06_Section_UI_Cross_Areas";
|
|
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
|
|
import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel";
|
|
import {
|
|
createCrossSectionCard,
|
|
type ExtraWallControl,
|
|
type InletStructureControl,
|
|
type RevetOffsetControl,
|
|
type RevetLinkControl,
|
|
type StructureSpanControl,
|
|
type CrossCardElement,
|
|
type StationWidthControl,
|
|
} from "./B06_Section_UI_Cross_View";
|
|
import {
|
|
type CrossPlotBase,
|
|
crossPlotBase,
|
|
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 { buildStructureLane } from "../B05_Profile/B05_Profile_UI_Structures_Marks";
|
|
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 {
|
|
BASE_PANEL_HEIGHT,
|
|
chartHeights,
|
|
MAX_PANEL_HEIGHT_RATIO,
|
|
MIN_LONG_HEIGHT,
|
|
MIN_PANEL_HEIGHT,
|
|
PANEL_CHROME_PX,
|
|
PANEL_HEIGHT_KEY,
|
|
} from "./B06_Section_UI_Section_View_Panel";
|
|
import {
|
|
CROSS_GRID_GAP,
|
|
CROSS_GRID_MIN_WIDTH,
|
|
CROSS_WIDTH,
|
|
type CrossDesignChange,
|
|
type DesignChangeHandler,
|
|
designElevationAt,
|
|
emptyView,
|
|
inferStationInterval,
|
|
L,
|
|
} from "./B06_Section_UI_Section_Common";
|
|
|
|
export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl };
|
|
|
|
/**
|
|
* 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**.
|
|
*
|
|
* 평소에는 쓰지 않는다 — 그래프 몫은 `chartWrap`을 직접 재서 정한다(어림값이 실제보다 크면
|
|
* 유토곡선 아래에 빈 공간이 남는다). 화면에 붙기 전이라 잴 수 없는 첫 렌더에서만 쓰는
|
|
* 출발값이고, 최소 패널 높이 계산의 기준이기도 하다.
|
|
*/
|
|
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;
|
|
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
|
|
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). */
|
|
focusStation: (stationId: string) => void;
|
|
/** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을
|
|
* 올린다(2026-08-29 일원화). null = 선택 해제. */
|
|
setStationSelectListener: (listener: (stationId: string | null) => void) => void;
|
|
clear: () => void;
|
|
/** 종단 아래 구조물 알약 레인에 쓸 목록 — B05 와 같은 부품·같은 표기(2026-09-07 사용자
|
|
* 지시 4 「구조물 표시 통일」). 좌측 「구조물 배치」가 목록을 받을 때마다 넘겨준다. */
|
|
setStructureMarks: (
|
|
structures: ReadonlyArray<StructureInstance>,
|
|
types: ReadonlyArray<StructureType>,
|
|
) => void;
|
|
dispose: () => void;
|
|
}
|
|
|
|
export function createSectionView(
|
|
onDesignChange?: DesignChangeHandler,
|
|
rockBoundary?: RockBoundaryControl,
|
|
/** 측점 개별 표시 반폭(2026-08-06)·기슭막이 X 자리(2026-08-21) 제어. */
|
|
stationWidth?: StationWidthControl,
|
|
revetOffset?: RevetOffsetControl,
|
|
inletStructure?: InletStructureControl, // 유입측 구조물 형식(2026-08-22)
|
|
extraWalls?: ExtraWallControl, // 유출측 추가 기슭막이 단 수(2026-08-22)
|
|
/** 구간값·연동(2026-08-24), 세월교 측벽·BOX암거 구체(2026-08-25) 제어. */
|
|
structureSpan?: StructureSpanControl,
|
|
revetLink?: RevetLinkControl,
|
|
ford?: FordControl,
|
|
box?: BoxControl,
|
|
): 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 currentNaturalSpoilSlope: number | undefined;
|
|
let markStructures: ReadonlyArray<StructureInstance> = [];
|
|
let markTypes: ReadonlyArray<StructureType> = [];
|
|
let renderWidth = 0;
|
|
let resizeTimer = 0;
|
|
let panelResizeTimer = 0;
|
|
// 그래프 상자의 마지막 실측 높이와 보정 예약 플래그(빈 공간·세로 스크롤 둘 다 막는다).
|
|
let lastChartAvailable = 0;
|
|
let chartFitScheduled = false;
|
|
// 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신)
|
|
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 (readStateRaw("section-panel-collapsed") === "true") panel.classList.add("is-collapsed");
|
|
panelToggle.setOpen(!panel.classList.contains("is-collapsed"));
|
|
panelToggle.root.addEventListener("click", () => {
|
|
const collapsed = panel.classList.toggle("is-collapsed");
|
|
writeStateRaw("section-panel-collapsed", 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라 카드 스크롤 정지선은 `scroll-margin-top`(패널 높이 + 손잡이 몫)이다.
|
|
* 패널은 리사이저·접기 손잡이로 수시로 높이가 변하고, 접을 때는 drawPanel이 돌지 않으므로
|
|
* **스크롤 직전에 실측**해야 정지선이 현재 패널과 맞는다.
|
|
*/
|
|
const syncScrollMargin = (): void => {
|
|
// 접기 손잡이가 패널 아래 24px 밖으로 나와 있고, 선택 강조(빨간 테두리 + 2px ring)가
|
|
// 잘리지 않도록 8px 여유를 더한다.
|
|
const panelPx = panel.getBoundingClientRect().height || panelHeight();
|
|
root.style.setProperty("--b06-cross-scroll-margin", `${Math.round(panelPx + 32)}px`);
|
|
};
|
|
|
|
/**
|
|
* 선택 카드를 화면에 남긴다. `nearest`는 카드가 아래에 있을 때 카드 **하단**을 브라우저
|
|
* 바닥에 맞춰 멈추는데, 그러면 카드 상단(측점 라벨)이 화면 밖이다(2026-08-04 사용자 지적).
|
|
* `start`로 카드 **상단**을 접기 손잡이 바로 아래 정지선에 붙인다. 목록 맨 끝 카드는
|
|
* 스크롤이 바닥에 걸려 그 자리까지 못 올라오며, 그대로 둔다(브라우저가 알아서 멈춘다).
|
|
*/
|
|
const revealCard = (stationId: string | null, behavior: ScrollBehavior): void => {
|
|
if (!stationId) return;
|
|
syncScrollMargin();
|
|
document.getElementById(`cross-${stationId}`)?.scrollIntoView({ behavior, block: "start" });
|
|
};
|
|
|
|
/** 카드 하나의 선택 표시를 갱신한다 — **다시 만들지 않는다**(줌·팬이 살아남아야 한다). */
|
|
const markCard = (stationId: string | null): void => {
|
|
if (!stationId) return;
|
|
const card = document.getElementById(`cross-${stationId}`) as CrossCardElement | null;
|
|
card?.applySelection?.(stationId === selectedStationId, activeAreaKey);
|
|
};
|
|
|
|
/**
|
|
* 연동으로 이어진 카드들의 **벽 강조**를 다시 칠한다(2026-08-30 사용자: 한 기슭막이는
|
|
* 그 연장 안 모든 횡단도에서 같이 강조된다). 조정창은 고른 카드 하나에만 남는다.
|
|
* 카드는 다시 만들지 않고 클래스만 갈아 끼운다 — 줌·팬 보존.
|
|
*/
|
|
const syncRevetHighlights = (): void => {
|
|
if (!currentDetail || !revetOffset) return;
|
|
for (const section of currentDetail.cross_sections) {
|
|
const card = document.getElementById(
|
|
`cross-${section.station_id}`,
|
|
) as CrossCardElement | null;
|
|
card?.applyRevetHighlight?.(revetOffset.highlightFor(section));
|
|
}
|
|
};
|
|
if (revetOffset) revetOffset.onSelectionChange = syncRevetHighlights;
|
|
|
|
/**
|
|
* 선택이 바뀐 흔적만 화면에 반영한다 — **재렌더 금지**.
|
|
* 카드를 새로 만들면(전체든 한 장이든) 사용자가 맞춰 둔 휠 줌·팬(viewBox)이 초기화된다
|
|
* (2026-08-02 사용자 지적). 선택 표시가 바뀌는 카드는 이전·현재 둘뿐이고, 그 둘도 클래스와
|
|
* 강조 상태만 갈아 끼운다.
|
|
*/
|
|
/** 카드 선택 변경 수신자(좌측 구조물 폼). 페이지가 등록한다. */
|
|
let stationSelectListener: ((stationId: string | null) => void) | null = null;
|
|
|
|
const applySelection = (previousStationId: string | null): void => {
|
|
markCard(previousStationId);
|
|
if (selectedStationId !== previousStationId) markCard(selectedStationId);
|
|
drawPanel();
|
|
if (selectedStationId !== previousStationId) stationSelectListener?.(selectedStationId);
|
|
};
|
|
|
|
/** 같은 측점을 다시 고르면 선택을 푼다(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);
|
|
};
|
|
|
|
/** 십자선이 설 계획고 — 세월교 측점은 구체 위 노면이 월류 높이만큼 낮게 앉으므로
|
|
* 그만큼 내려 찍는다(2026-08-30 사용자 확정). 하강량은 백엔드가 계획고를 내려
|
|
* 잡으며 실어 준 값(`design.surface_drop_m`)이고, 원래 노면은 점선이 남긴다. */
|
|
const cardDesignElevation = (
|
|
detail: SectionDetailResponse,
|
|
section: CrossSection,
|
|
): number | undefined => {
|
|
const planZ = designElevationAt(detail.longitudinal.design_profiles, section.chainage_m);
|
|
if (planZ === undefined) return undefined;
|
|
return planZ - (section.design?.surface_drop_m ?? 0);
|
|
};
|
|
|
|
const buildCrossCard = (
|
|
section: CrossSection,
|
|
forcedHeightPx?: number,
|
|
plotBase?: CrossPlotBase | null,
|
|
): HTMLElement =>
|
|
createCrossSectionCard(
|
|
section,
|
|
section.station_id === selectedStationId,
|
|
currentExaggeration,
|
|
(stationId) => selectStation(stationId, false),
|
|
cachedStationInterval,
|
|
currentCrossHalfWidth,
|
|
cachedCardWidth,
|
|
forcedHeightPx,
|
|
currentDetail ? cardDesignElevation(currentDetail, section) : undefined,
|
|
onDesignChange,
|
|
rockBoundary,
|
|
section.station_id === selectedStationId ? activeAreaKey : null,
|
|
selectArea,
|
|
stationWidth,
|
|
revetOffset,
|
|
inletStructure,
|
|
extraWalls,
|
|
culvertLinkFor(section),
|
|
structureSpan,
|
|
revetLink,
|
|
ford,
|
|
box,
|
|
plotBase,
|
|
);
|
|
|
|
/**
|
|
* 옆 측점 구조물이 이 측점까지 넘어왔는지 — 기슭막이 기본 연장 10m(전 5·후 5)면
|
|
* 배수관 측점 하나로 끝나지 않는다(2026-08-24 사용자). 3D는 이미 그 구간을 소유
|
|
* 측점 단면으로 채우므로 횡단도도 같은 규칙으로 링크한다.
|
|
*/
|
|
const culvertLinkFor = (section: CrossSection): CulvertLink | undefined => {
|
|
const detail = currentDetail;
|
|
if (!detail) return undefined;
|
|
return culvertLink(
|
|
section,
|
|
detail.cross_sections,
|
|
(chainageM) => designElevationAt(detail.longitudinal.design_profiles, chainageM) ?? null,
|
|
);
|
|
};
|
|
|
|
/** 유토곡선만 새 창으로 다시 그리는 함수 — 그릴 때마다 새로 만든다(붙인 노드는 그 안). */
|
|
/** 최종 누가토량 배지 — 종단 그래프 좌측 상단(2026-09-06 사용자 지시). */
|
|
const massBadge = createMassHaulBadge();
|
|
panelBody.append(massBadge.root);
|
|
/** 세로 창 갱신기 — 종단은 변환, 유토곡선은 곡선만. */
|
|
let updateChartWindow: (() => ElevationWindowResult | null) | null = null;
|
|
|
|
/** 상단 패널 내용(종단면도 + 유토곡선 + 요약)만 다시 그린다. */
|
|
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);
|
|
const heights = chartHeights(lastChartAvailable);
|
|
const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval);
|
|
const chartWidth = Math.max(renderWidth, minWidth);
|
|
const chart = buildLongitudinalChart({
|
|
detail,
|
|
selectedStationId,
|
|
exaggeration: currentExaggeration,
|
|
selectStation: (stationId) => selectStation(stationId, true),
|
|
stationInterval: cachedStationInterval,
|
|
chartWidth,
|
|
chartHeight: heights.long,
|
|
minWidth,
|
|
scrollLeft: keepScrollLeft,
|
|
viewportWidth: chartWrap.clientWidth || chartWidth,
|
|
});
|
|
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 몫이다(배수유역 재분할이 걸린다) — 여기서는 자리만 보여 준다.
|
|
onMove: () => undefined,
|
|
}),
|
|
);
|
|
}
|
|
|
|
chartWrap.replaceChildren(...nodes);
|
|
// 유토곡선 그래프는 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();
|
|
|
|
// 첫 렌더(detached)나 패널 높이 변경 직후에는 위에서 잰 값이 아직 옛 레이아웃일 수 있다.
|
|
// 다음 프레임에 한 번만 다시 재서 어긋나면 고쳐 그린다. `chartWrap` 높이는 내용과 무관하니
|
|
// 되먹임 없이 한 번에 수렴한다.
|
|
if (!chartFitScheduled) {
|
|
chartFitScheduled = true;
|
|
requestAnimationFrame(() => {
|
|
chartFitScheduled = false;
|
|
const now = chartWrap.clientHeight;
|
|
if (now > 0 && Math.abs(now - lastChartAvailable) > 2) drawPanel();
|
|
});
|
|
}
|
|
}
|
|
|
|
/** 상단 패널을 통째로 다시 그리기까지 기다리는 시간(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);
|
|
});
|
|
|
|
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;
|
|
// 여기서 만든 기하를 2차에서 **그대로 넘긴다** — 예전에는 같은 측점을 두 번 계산했고
|
|
// 그것이 진입에서 `draw` 자기 시간의 대부분이었다(2026-09-06 CPU 프로파일).
|
|
// 반폭도 카드가 실제로 쓰는 값(`effectiveCardHalfWidth`, 자동 줌아웃 포함)으로 맞춰
|
|
// 재 둔다 — 카드·행 높이가 어긋나지 않는다.
|
|
const plotBases = sections.map((section) =>
|
|
crossPlotBase(
|
|
section,
|
|
currentExaggeration,
|
|
cachedCardWidth,
|
|
effectiveCardHalfWidth(section, stationWidth?.widthFor(section) ?? currentCrossHalfWidth),
|
|
designElevationAt(detail.longitudinal.design_profiles, section.chainage_m),
|
|
),
|
|
);
|
|
const naturalHeights = plotBases.map((base) =>
|
|
Math.max(base?.naturalHeight ?? CROSS_HEIGHT, CROSS_HEIGHT),
|
|
);
|
|
// 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, plotBases[index]));
|
|
}
|
|
}
|
|
} 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,
|
|
// 운반 장비 한계는 곡선(운반 블록)에만 쓰던 값이라 B06 에서는 더 받지 않는다
|
|
// (2026-09-06 유토곡선 그래프 제거). 인자 자리는 호출부 호환을 위해 남긴다.
|
|
_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 (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,
|
|
focusStation(stationId) {
|
|
if (selectedStationId !== stationId) selectStation(stationId, true);
|
|
else revealCard(stationId, "smooth");
|
|
},
|
|
setStationSelectListener(listener) {
|
|
stationSelectListener = listener;
|
|
},
|
|
setStructureMarks(structures, types) {
|
|
markStructures = structures;
|
|
markTypes = types;
|
|
// 검증용 훅 — 알약 레인이 무엇을 받았는지 화면 밖에서 수치로 본다
|
|
// (`__corridorBuild` 등과 같은 용도).
|
|
(window as unknown as { __b06Marks?: unknown }).__b06Marks = {
|
|
structures: structures.length,
|
|
types: types.length,
|
|
drawn: !!currentDetail,
|
|
};
|
|
if (currentDetail) drawPanel();
|
|
},
|
|
clear() {
|
|
currentDetail = null;
|
|
selectedStationId = null;
|
|
root.replaceChildren();
|
|
},
|
|
dispose() {
|
|
window.clearTimeout(resizeTimer);
|
|
window.clearTimeout(panelResizeTimer);
|
|
panelResizer.dispose();
|
|
resizeObserver.disconnect();
|
|
},
|
|
};
|
|
}
|