- 새 B06_Section_UI_Cross_Design_Pick.ts — 조각 합침 · 벽 · 본체 밖 조각 · 투명 판정 선 · 치수 · crossZoneAt(139-6 용) · 페이지 한 칸 고름 - 폴리곤 자르기 = A00_Common/b_polygon_clip.ts 공용(B07 Cross_Overlay 에서 옮김 · 바깥 조각 갈래) - 카드 배선: 선 고르면 벽 · 본체 · 면적 풂 + 카드 고름 · 면적 고름도 본체 · 선 풂 - 시험 test_137_design_pick.py + helper_137_design_pick.cjs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
663 lines
31 KiB
TypeScript
663 lines
31 KiB
TypeScript
/* =============================================================================
|
||
* B06_Section_UI_Cross_View.ts
|
||
* 개별 횡단면 카드 SVG 렌더러와 X:Y 1:1 스케일 지표 계산.
|
||
*
|
||
* 700줄 제한 대응으로 기존 단일 렌더러 파일에서 횡단 카드 렌더링만 분리했다. 공통 상수·유틸은
|
||
* `_UI_Section_Common`, 설계 지정 컨트롤/설계선 오버레이는 `_UI_Cross_Design`을 참조한다.
|
||
* 횡단은 종/횡 공통 Y스케일을 쓰지 않고 카드마다 자체 1:1 ppm으로 스케일한다.
|
||
* ========================================================================== */
|
||
|
||
import { withoutGuardedFill } from "./B06_Section_UI_Cross_Body_Guard";
|
||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||
import {
|
||
appendCrossAreaBands,
|
||
type AreaHighlightSetter,
|
||
buildAreaReadout,
|
||
type CrossAreaKey,
|
||
} from "./B06_Section_UI_Cross_Areas";
|
||
import {
|
||
appendFordPavementOverlay,
|
||
hideFordPavedRoad,
|
||
appendFordSurfaceDropPlan,
|
||
} from "./B06_Section_UI_Cross_Ford_Pavement";
|
||
import { culvertWallsStandAt } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||
import { raiseWalls } from "./B06_Section_UI_Cross_Wall";
|
||
import { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill";
|
||
import { appendRevetmentOverlay, computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment";
|
||
import {
|
||
appendCrossDesignOverlay,
|
||
appendPavementOverlay,
|
||
appendRockBoundaryOverlay,
|
||
type RockBoundaryControl,
|
||
} from "./B06_Section_UI_Cross_Design";
|
||
import { appendCardFooter, appendCardHeader } from "./B06_Section_UI_Cross_Card_Chrome";
|
||
import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert";
|
||
import type { CulvertDesignTrim } from "./B06_Section_UI_Cross_Culvert";
|
||
import { appendBoxOverlay, computeBoxLayout } from "./B06_Section_UI_Cross_Box";
|
||
import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel";
|
||
import { createBodyWiring } from "./B06_Section_UI_Cross_View_Bodies";
|
||
import {
|
||
appendCardFrame,
|
||
appendGroundProfile,
|
||
applyStructureAreas,
|
||
drawnContentBounds,
|
||
} from "./B06_Section_UI_Cross_View_Frame";
|
||
import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford";
|
||
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
|
||
import { computeCardCulvert } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||
import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||
import type {
|
||
ExtraWallControl,
|
||
InletStructureControl,
|
||
RevetLinkControl,
|
||
RevetOffsetControl,
|
||
StructureSpanControl,
|
||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||
import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure";
|
||
import { buildCardPanels, emptyCardCulvertState } from "./B06_Section_UI_Cross_View_Panels";
|
||
import {
|
||
attachDesignLinePick,
|
||
DESIGN_LINE_ATTR,
|
||
type DesignLinePick,
|
||
pickedLineAt,
|
||
setPickedLine,
|
||
} from "./B06_Section_UI_Cross_Design_Pick";
|
||
import { attachZoomPan, buildZoomControls, cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom";
|
||
import type { CrossWidthActions } from "./B06_Section_UI_Cross_View_Zoom";
|
||
import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
|
||
import { createCrossAxes, IDENTITY_VIEW } from "./B06_Section_UI_Cross_Axes";
|
||
import type { RevetHighlightSetter, RevetPick } from "./B06_Section_UI_Cross_Culvert";
|
||
import { REVET_FORM_DEFAULT } from "./B06_Section_UI_Cross_Culvert_Const";
|
||
import {
|
||
CROSS_PAD,
|
||
CROSS_WIDTH,
|
||
type DesignChangeHandler,
|
||
emptyView,
|
||
L,
|
||
svgElement,
|
||
validElevation,
|
||
} from "./B06_Section_UI_Section_Common";
|
||
import {
|
||
type CrossPlotBase,
|
||
crossPlotFromBase,
|
||
crossPlotMetrics,
|
||
effectiveCardHalfWidth,
|
||
} from "./B06_Section_UI_Cross_View_Metrics";
|
||
|
||
/**
|
||
* 횡단 카드 요소. 선택 표시와 면적 강조를 **카드를 다시 만들지 않고** 갈아 끼우는 핸들을 단다
|
||
* — 카드를 새로 만들면 사용자가 맞춰 둔 휠 줌·팬(viewBox)이 초기화되기 때문이다.
|
||
*/
|
||
export interface CrossCardElement extends HTMLElement {
|
||
applySelection?: (selected: boolean, areaKey: CrossAreaKey | null) => void;
|
||
/** 벽 강조만 갈아 끼운다 — 연동으로 이어진 카드도 같이 켜진다(2026-08-30 사용자).
|
||
* 카드를 다시 만들지 않으므로 줌·팬이 살아남는다. */
|
||
applyRevetHighlight?: (key: RevetPick | null) => void;
|
||
}
|
||
|
||
/**
|
||
* 카드 SVG에 확대·축소 + 드래그 팬 + 더블클릭 원복을 붙인다(E-5).
|
||
*
|
||
* 확대·축소 대상은 **플롯 안 도형(지반선·설계선·면적 밴드·중심 십자선)뿐**이다. 축·눈금·축
|
||
* 이름은 제자리에 남고, 데이터 레이어(`plotLayer`)의 transform만 바뀐다(2026-08-08 사용자
|
||
* 지시). 예전에는 viewBox를 조작해 축과 글자까지 같이 커졌다.
|
||
* 선 굵기는 `vector-effect: non-scaling-stroke`(CSS)라 배율과 무관하게 유지된다.
|
||
* 최대 8배까지, 축소는 원배율까지만 허용한다.
|
||
*/
|
||
/**
|
||
* 측점 **개별 표시 반폭** 세션 제어기(2026-08-06 사용자 지시).
|
||
* Page가 세션 보관·저장값 복원·카드 갱신·확정 저장(cross_patches)을 연결해 구현한다.
|
||
*/
|
||
export interface StationWidthControl {
|
||
/** 개별값(세션→저장값) 우선. 없으면 undefined — 카드가 전역 반폭으로 그린다. */
|
||
widthFor: (section: CrossSection) => number | undefined;
|
||
/**
|
||
* 이 측점 표시 반폭을 지정한다(2026-08-23 개편). 계산 반폭(기본 20m)을 넘는 값이면
|
||
* 페이지가 백엔드 재생성으로 샘플을 넓힌 뒤 이 카드만 그 폭으로 다시 그린다.
|
||
*/
|
||
request: (chainageM: number, widthM: number) => void;
|
||
}
|
||
|
||
// 기슭막이·유입 구조물·다단 제어 인터페이스는 Wire에 있다(700줄 제한) — 재수출.
|
||
export type {
|
||
ExtraWallControl,
|
||
InletStructureControl,
|
||
RevetLinkControl,
|
||
RevetOffsetControl,
|
||
SpanRole,
|
||
SpanValues,
|
||
StructureSpanControl,
|
||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||
|
||
export function createCrossSectionCard(
|
||
section: CrossSection,
|
||
selected: boolean,
|
||
verticalExaggeration: number,
|
||
onSelect: (stationId: string) => void,
|
||
stationInterval: number,
|
||
crossHalfWidth?: number,
|
||
widthPx = CROSS_WIDTH,
|
||
forcedHeightPx?: number,
|
||
designElevation?: number,
|
||
onDesignChange?: DesignChangeHandler,
|
||
rockBoundary?: RockBoundaryControl,
|
||
/** 이 카드가 선택 상태일 때 되살릴 면적 강조(없으면 강조 없음). */
|
||
initialAreaKey?: CrossAreaKey | null,
|
||
/** 면적 값을 눌렀을 때. 선택되지 않은 카드에서도 눌릴 수 있어 측점 id를 함께 넘긴다. */
|
||
onAreaSelect?: (stationId: string, key: CrossAreaKey | null) => void,
|
||
/** 개별 표시 반폭 제어 — 있으면 우측 상단 줌 버튼이 원배율에서 반폭을 조절한다. */
|
||
stationWidth?: StationWidthControl,
|
||
/** 기슭막이 X 자리 제어 — 있으면 벽이 선택 가능해지고 선택 시 ◀/▶/↺가 뜬다. */
|
||
revetOffset?: RevetOffsetControl,
|
||
/** 유입측 구조물 형식 선택(드롭다운 — 2026-08-22). */
|
||
inletStructure?: InletStructureControl,
|
||
/** 유출측 추가 기슭막이 개수 제어(2026-08-22). */
|
||
extraWalls?: ExtraWallControl,
|
||
/** 옆 측점 구조물이 이 측점까지 넘어온 경우의 링크(2026-08-24). */
|
||
culvertLink?: CulvertLink,
|
||
/** 기슭막이·집수정 구간값(길이·전·후) 제어(2026-08-24). */
|
||
structureSpan?: StructureSpanControl,
|
||
/** 연동·종단경사 반영 제어(2026-08-24). */
|
||
revetLink?: RevetLinkControl,
|
||
/** 세월교 측벽·BOX암거 구체 조작 제어(2026-08-25). */
|
||
ford?: FordControl,
|
||
box?: BoxControl,
|
||
/** 행 높이를 재며 이미 만들어 둔 기하 — 있으면 다시 계산하지 않는다(2026-09-06). */
|
||
plotBase?: CrossPlotBase | null,
|
||
/** 측점별 암 절토 경사 입력(2026-09-07) — 암 측점 하단 정보행에 선다. */
|
||
cutSlope?: CutSlopeControl,
|
||
): CrossCardElement {
|
||
// 링크 카드 = 구조물이 옆 측점에 서 있고 그 연장이 여기까지 온 경우. 관만 숨기고
|
||
// 선택·조정은 연다 — 연동을 풀어 이 측점 위치를 따로 잡을 수 있어야 한다
|
||
// (2026-08-24 사용자). 길이·전/후 같은 구간 값은 소유 측점 한 곳에서 관리한다.
|
||
const isLinkedCulvert = !section.culvert && !!culvertLink;
|
||
const effectiveHalfWidth = effectiveCardHalfWidth(
|
||
section,
|
||
stationWidth?.widthFor(section) ?? crossHalfWidth,
|
||
);
|
||
const card: CrossCardElement = document.createElement("article");
|
||
card.id = `cross-${section.station_id}`;
|
||
card.className = `b06-cross-card${selected ? " b06-cross-card--selected" : ""}`;
|
||
card.tabIndex = 0;
|
||
card.addEventListener("click", () => onSelect(section.station_id));
|
||
card.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter" || event.key === " ") onSelect(section.station_id);
|
||
});
|
||
|
||
// 선택 표시·면적 강조는 **DOM만 갈아 끼운다**. 카드를 새로 만들면 휠 줌·팬(viewBox)이
|
||
// 초기화돼 사용자가 맞춰 둔 배율이 날아간다(2026-08-02 사용자 지적).
|
||
let isSelected = selected;
|
||
let activeArea: CrossAreaKey | null = selected ? (initialAreaKey ?? null) : null;
|
||
let setBandActive: AreaHighlightSetter = () => undefined;
|
||
let setChipActive: AreaHighlightSetter = () => undefined;
|
||
let activeRevet: RevetPick | null = revetOffset?.selectedFor(section) ?? null;
|
||
/** 이 카드에서 **강조**할 벽. 연동으로 이어진 옆 측점이 고른 벽도 켠다 —
|
||
* 조정창(`activeRevet`)은 고른 카드 하나에만 뜬다(2026-08-30 사용자). */
|
||
let highlightRevet: RevetPick | null = revetOffset?.highlightFor(section) ?? activeRevet;
|
||
let setRevetActive: RevetHighlightSetter = () => undefined;
|
||
let showRevetControl: (visible: boolean) => void = () => undefined;
|
||
/** 조정창이 읽는 마지막 계산 결과(벽 제원 · 단 수 등) — `_Cross_View_Panels`. */
|
||
const culvertState = emptyCardCulvertState();
|
||
/** 보라 선 고름(137-2) — 고름은 페이지 한 칸(`_Cross_Design_Pick`) · 강조는 그린 뒤 붙음. */
|
||
let setLineActive: (index: number | null) => void = () => undefined;
|
||
const dropLine = (): void => {
|
||
setPickedLine(section.station_id, null);
|
||
setLineActive(null);
|
||
};
|
||
/**
|
||
* 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를
|
||
* 고르면 다른 하나는 풀린다(2026-08-21 사용자). 또 구조물을 고르면 그 **측점 카드도
|
||
* 같이 선택**된다 — 이미 선택된 카드 안에서 다른 구조물을 고를 때는 선택이 옮겨만
|
||
* 가고 카드 선택은 건드리지 않는다.
|
||
*/
|
||
// 구체 구조물(세월교·BOX암거) 선택·조정창 배선 — 한 모듈로 묶었다(2026-08-25).
|
||
const bodies = createBodyWiring({
|
||
section,
|
||
ford,
|
||
box,
|
||
isCardSelected: () => isSelected,
|
||
clearArea: () => {
|
||
dropLine(); // 고름 하나만(137-3)
|
||
if (activeRevet !== null) toggleRevet(activeRevet); // 벽 · 본체 동시 고름 금지(107-1)
|
||
if (activeArea === null) return;
|
||
activeArea = null;
|
||
setBandActive(null);
|
||
setChipActive(null);
|
||
onAreaSelect?.(section.station_id, null);
|
||
},
|
||
selectCard: () => onSelect(section.station_id),
|
||
});
|
||
const toggleRevet = (key: RevetPick): void => {
|
||
const wasSelected = isSelected;
|
||
activeRevet = activeRevet === key ? null : key;
|
||
showRevetControl(activeRevet !== null);
|
||
// 강조는 **연동된 카드 전부**라 제어기를 거친다(제어기가 목록에 다시 칠하기를
|
||
// 알린다 — 2026-08-30 사용자). 배선이 없어도 이 카드만은 즉시 맞춘다.
|
||
revetOffset?.select(section.chainage_m, activeRevet);
|
||
card.applyRevetHighlight?.(revetOffset?.highlightFor(section) ?? activeRevet);
|
||
if (activeRevet !== null && activeArea !== null) {
|
||
activeArea = null;
|
||
setBandActive(null);
|
||
setChipActive(null);
|
||
}
|
||
if (activeRevet !== null) {
|
||
dropLine(); // 고름 하나만(137-3)
|
||
bodies.clearSelection(); // 세월교 · 박스 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1)
|
||
// 미선택 카드는 **카드 선택만** 부른다. 면적 비우기(onAreaSelect)를 먼저 부르면 그쪽이
|
||
// 카드를 선택해 버려 뒤이은 onSelect가 "같은 측점 재클릭 = 해제"로 먹힌다(2026-08-21
|
||
// 화면 확인). 카드 선택 경로가 부모의 면적 강조를 이미 비운다.
|
||
if (wasSelected) onAreaSelect?.(section.station_id, null);
|
||
else onSelect(section.station_id);
|
||
}
|
||
};
|
||
card.applySelection = (nextSelected, areaKey) => {
|
||
isSelected = nextSelected;
|
||
card.classList.toggle("b06-cross-card--selected", nextSelected);
|
||
activeArea = nextSelected ? (areaKey ?? null) : null;
|
||
setBandActive(activeArea);
|
||
setChipActive(activeArea);
|
||
// 면적이 켜졌거나 카드가 선택에서 빠지면 구조물 선택은 풀린다(같은 레벨).
|
||
if ((activeArea !== null || !nextSelected) && activeRevet !== null) {
|
||
// 전역 선택이 **아직 이 카드 것일 때만** 비운다 — 옆 카드에서 방금 고른 벽까지
|
||
// 지우면 그리로 옮겨 간 조정창이 곧바로 닫힌다(2026-08-30 링크 강조).
|
||
const owns = revetOffset?.selectedFor(section) === activeRevet;
|
||
activeRevet = null;
|
||
showRevetControl(false);
|
||
if (owns) revetOffset?.select(section.chainage_m, null);
|
||
else card.applyRevetHighlight?.(revetOffset?.highlightFor(section) ?? null);
|
||
}
|
||
if (activeArea !== null || !nextSelected) {
|
||
bodies.clearSelection();
|
||
dropLine();
|
||
}
|
||
};
|
||
card.applyRevetHighlight = (key) => {
|
||
highlightRevet = key;
|
||
setRevetActive(key);
|
||
// 옆 측점에서 고른 연동 구조물 = 주 횡단도도 강조(79-6) — 주인 카드만 세트를 가짐.
|
||
card.classList.toggle(
|
||
"b06-cross-card--link-owner",
|
||
!!key && !!section.culvert && key !== activeRevet,
|
||
);
|
||
};
|
||
card.applyRevetHighlight(highlightRevet);
|
||
const toggleArea = (key: CrossAreaKey): void => {
|
||
if (!isSelected) {
|
||
// 아직 선택되지 않은 카드 — 부모가 카드를 고르면서 이 면적까지 한 번에 켠다.
|
||
onAreaSelect?.(section.station_id, key);
|
||
return;
|
||
}
|
||
activeArea = activeArea === key ? null : key;
|
||
if (activeArea !== null && activeRevet !== null) {
|
||
activeRevet = null;
|
||
setRevetActive(null);
|
||
showRevetControl(false);
|
||
revetOffset?.select(section.chainage_m, null);
|
||
}
|
||
if (activeArea !== null) {
|
||
bodies.clearSelection(); // 고름 하나만 — 면적 강조도 본체 · 선과 같은 칸(137-3)
|
||
dropLine();
|
||
}
|
||
setBandActive(activeArea);
|
||
setChipActive(activeArea);
|
||
onAreaSelect?.(section.station_id, activeArea);
|
||
};
|
||
/** 보라 선 고름 — 다른 고름(벽 · 본체 · 면적)을 풀고 카드도 고름(137-3). 다시 누르면 풂. */
|
||
const pickLine = (index: number): void => {
|
||
const next = pickedLineAt(section.station_id) === index ? null : index;
|
||
setPickedLine(section.station_id, next);
|
||
if (next !== null) {
|
||
if (activeRevet !== null) toggleRevet(activeRevet);
|
||
bodies.clearSelection();
|
||
if (activeArea !== null) {
|
||
activeArea = null;
|
||
setBandActive(null);
|
||
setChipActive(null);
|
||
onAreaSelect?.(section.station_id, null);
|
||
}
|
||
if (!isSelected) onSelect(section.station_id);
|
||
}
|
||
setLineActive(next);
|
||
};
|
||
// 고른 카드 · 다른 고름이 없을 때만 되살림 — 바깥 길(목록 · 3D)이 구조물을 골랐으면 선을 풂.
|
||
if (!selected || activeRevet !== null || activeArea !== null)
|
||
setPickedLine(section.station_id, null);
|
||
|
||
appendCardHeader(card, section, stationInterval, onDesignChange);
|
||
|
||
// 행 높이를 재며 만든 기하가 있으면 그대로 쓴다 — 같은 측점의 기하를 두 번 계산하던
|
||
// 것이 B06 진입에서 `draw` 자기 시간의 대부분이었다(2026-09-06 CPU 프로파일).
|
||
const metrics = plotBase
|
||
? crossPlotFromBase(plotBase, forcedHeightPx)
|
||
: crossPlotMetrics(
|
||
section,
|
||
verticalExaggeration,
|
||
widthPx,
|
||
effectiveHalfWidth,
|
||
designElevation,
|
||
forcedHeightPx,
|
||
);
|
||
if (!metrics) {
|
||
card.append(emptyView(L("B06_Profile_View_NoCross")));
|
||
} else {
|
||
const { sourceSamples, maxOffset, elevationMid, displayMax, pixelsPerMeter, heightPx } =
|
||
metrics;
|
||
const hasDesign = designElevation !== undefined && Number.isFinite(designElevation);
|
||
const valid = sourceSamples.filter(validElevation);
|
||
const exaggeration = Math.max(verticalExaggeration, 0.1);
|
||
// 좌표 규약(+offset=좌, -offset=우)을 표준 횡단면도 관례에 맞춘다: 진행방향을 바라보는
|
||
// 시점이라 좌측(+offset)이 화면 왼쪽에 와야 B05 3D 방향 표시와 측구 방향이 일치한다(작업 C-3).
|
||
const x = (offset: number) => CROSS_PAD.left + (maxOffset - offset) * pixelsPerMeter;
|
||
const y = (elevation: number) => CROSS_PAD.top + (displayMax - elevation) * pixelsPerMeter;
|
||
const svg = svgElement("svg", {
|
||
class: "b06-section__chart",
|
||
width: widthPx,
|
||
height: heightPx,
|
||
viewBox: `0 0 ${widthPx} ${heightPx}`,
|
||
// 조정 창 끌기 손잡이의 축척(계획서 24-4a · `bindDragHandle`).
|
||
"data-px-per-m": pixelsPerMeter,
|
||
"data-exaggeration": exaggeration,
|
||
role: "img",
|
||
"aria-label": `${section.label} ${L("B06_Profile_View_Cross")}`,
|
||
});
|
||
svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" }));
|
||
|
||
// 축·격자는 별도 레이어에 그리고, 줌·팬이 바뀔 때마다 **보이는 범위로 다시** 그린다
|
||
// (2026-08-23 사용자 보고: 확대하면 축 값과 도형이 어긋남). 눈금 개수·간격도 그때
|
||
// 보이는 범위에 맞춰 다시 고른다.
|
||
const axisLayer = svgElement("g", { class: "b06-chart__axes" }) as SVGGElement;
|
||
svg.append(axisLayer);
|
||
const renderAxes = createCrossAxes(axisLayer, {
|
||
widthPx,
|
||
heightPx,
|
||
pixelsPerMeter,
|
||
maxOffset,
|
||
displayMax,
|
||
elevationMid,
|
||
exaggeration,
|
||
});
|
||
renderAxes(cardZoomStates.get(section.station_id) ?? IDENTITY_VIEW);
|
||
|
||
// 확대·축소·팬이 닿는 범위 = 이 레이어 안(2026-08-08 사용자 지시. 축·눈금·축 이름은 제자리).
|
||
// clip 그룹은 고정하고 안쪽 plotLayer만 transform으로 움직인다 — clip을 transform
|
||
// 요소에 직접 걸면 자르는 창까지 확대된다. 표시 반폭 밖 잘라내기도 이 clip 몫.
|
||
const clipId = `b06-cross-clip-${section.station_id}`;
|
||
const clip = svgElement("clipPath", { id: clipId });
|
||
clip.append(
|
||
svgElement("rect", {
|
||
x: CROSS_PAD.left,
|
||
y: CROSS_PAD.top,
|
||
width: Math.max(widthPx - CROSS_PAD.left - CROSS_PAD.right, 1),
|
||
height: Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1),
|
||
}),
|
||
);
|
||
const clipGroup = svgElement("g", { "clip-path": `url(#${clipId})` });
|
||
const plotLayer = svgElement("g", { class: "b06-chart__plot-layer" });
|
||
clipGroup.append(plotLayer);
|
||
svg.append(clip, clipGroup);
|
||
|
||
// 그리기는 **캐시 보유 전 구간**으로 한다 — 표시 반폭 밖은 clip으로 가려 두고, 확대
|
||
// 상태의 가운데 버튼 팬으로 끌어다 본다(2026-08-23 사용자 지시). 축 눈금은 팬을 따라
|
||
// 다시 그려지므로(_UI_Cross_Axes) 값이 어긋나지 않는다.
|
||
const drawSamples = section.samples;
|
||
const toDisplayY = (elevation: number): number =>
|
||
y(elevationMid + (elevation - elevationMid) * exaggeration);
|
||
appendGroundProfile(plotLayer, drawSamples, x, toDisplayY);
|
||
let linePick: DesignLinePick | null = null;
|
||
|
||
if (section.design) {
|
||
// 면적 밴드는 지면선 위·설계선 아래에 깔아 선이 밴드에 가리지 않게 한다.
|
||
// 선택 여부와 무관하게 항상 깐다 — 평소에는 투명이고, 선택이 바뀔 때 카드를 새로 만들지
|
||
// 않고 클래스만 켜면 되므로 줌·팬이 살아남는다.
|
||
setBandActive = appendCrossAreaBands(
|
||
plotLayer,
|
||
section.design,
|
||
drawSamples,
|
||
x,
|
||
toDisplayY,
|
||
toggleArea,
|
||
);
|
||
// 배수관 세트 기하 계산 + 이동량 되받기(토스트) — 분리 파일(Wire, 700줄 제한).
|
||
// 계산은 **표시 반폭으로 자르지 않은 전체 샘플**로 한다(2026-08-22 사용자 확정) —
|
||
// 표시 반폭은 보기용이고, 사면 끝·지반 교차 판정은 계산용 전 구간이 필요하다.
|
||
const culvertLayout = computeCardCulvert(
|
||
section,
|
||
section.samples,
|
||
activeRevet,
|
||
revetOffset,
|
||
inletStructure,
|
||
extraWalls,
|
||
culvertLink,
|
||
revetLink,
|
||
);
|
||
// 세월교·BOX암거 구체 — 배수관과 그림이 달라 계산·그리기를 따로 탄다(2026-08-25).
|
||
// 흙막이(73-4)가 선 쪽 본체 성토선은 걷음 — 그 쪽 성토선은 흙막이 벽이 냄.
|
||
const boxLayout = withoutGuardedFill(
|
||
computeBoxLayout(section, section.samples, bodies.boxAdjust()),
|
||
culvertLayout,
|
||
);
|
||
const fordLayout = withoutGuardedFill(
|
||
computeFordLayout(section, section.samples, bodies.fordAdjust()),
|
||
culvertLayout,
|
||
);
|
||
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
|
||
// 물넘이포장은 노면 자체가 파인 자리라 포장층도 그 바닥을 따른다 — 그렸으면 일반
|
||
// 포장 박스는 건너뛴다(두 겹으로 깔리면 색 구분이 사라진다).
|
||
const fordPaved = appendFordPavementOverlay(
|
||
plotLayer,
|
||
section.ford_pavement,
|
||
section.design,
|
||
x,
|
||
toDisplayY,
|
||
);
|
||
if (!fordPaved)
|
||
appendPavementOverlay(plotLayer, section.design, x, toDisplayY, section.pavement);
|
||
// 세월교 측점 — 계획고는 이미 월류 높이만큼 내려와 있다. "월류가 없었다면"
|
||
// 노면을 점선으로 남겨 얼마나 내려앉았는지 읽히게 한다(2026-08-30 사용자).
|
||
appendFordSurfaceDropPlan(plotLayer, section.design, x, toDisplayY, fordLayout);
|
||
// 독립 기슭막이(구 D군 경로) — 이 측점에 배관/숨김 기슭막이 세트(section.culvert)가
|
||
// 붙으면 그쪽(배관 경로)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 그린다
|
||
// (2026-08-28 이관: pipe_points 숨김 세트로 옮기는 중 — 이중 그리기 방지 가드).
|
||
let ownDesignTrim: CulvertDesignTrim | undefined;
|
||
// 이 측점에 배관/숨김 기슭막이 세트가 직접 붙었거나(section.culvert) **연동으로
|
||
// 옆에서 이어져 온**(culvertLink) 경우엔 배관 경로가 그린다 — 옛 D경로는 건너뛴다
|
||
// (둘 다 그리면 이웃 카드에 벽이 겹친다). 가드는 **그 링크의 벽이 이 카드에 실제로
|
||
// 설 때만** 막는다(2026-09-09 — 링크만 있어도 막으면 구조물이 안 보였다).
|
||
const linkedWallsHere = !!culvertLink && culvertWallsStandAt(culvertLink.source, section);
|
||
if (!section.culvert && !linkedWallsHere) {
|
||
const ownAdjust = revetOffset?.adjustFor(section, "own");
|
||
const ownLayout = computeRevetmentLayout(section, ownAdjust);
|
||
ownDesignTrim = ownLayout?.designTrim;
|
||
const setOwnActive = appendRevetmentOverlay(
|
||
plotLayer,
|
||
ownLayout,
|
||
x,
|
||
toDisplayY,
|
||
revetOffset ? () => toggleRevet("own") : undefined,
|
||
);
|
||
if (ownLayout && setOwnActive) {
|
||
// 한계에 잘린 실제 적용값을 조작 저장소에 되받는다(배관 벽과 같은 규칙) —
|
||
// 눌러도 안 움직이는데 숫자만 커지는 것 방지.
|
||
revetOffset?.syncApplied(section.chainage_m, "own", ownLayout.appliedAdjust);
|
||
// 재질은 구조물 옵션(형태)이 정본이라 조정창에서 숨긴다 — 자리만 채운다.
|
||
culvertState.wallSpecs.set("own", {
|
||
height: ownLayout.heightM,
|
||
form: section.revetment?.form ?? REVET_FORM_DEFAULT,
|
||
});
|
||
culvertState.appliedD.set("own", ownLayout.appliedAdjust.d ?? 0);
|
||
const baseSetActive = setRevetActive;
|
||
setRevetActive = (key) => {
|
||
baseSetActive(key);
|
||
setOwnActive(key === "own");
|
||
};
|
||
if (highlightRevet === "own") setOwnActive(true);
|
||
}
|
||
}
|
||
const designTrim =
|
||
culvertLayout?.designTrim ??
|
||
fordLayout?.designTrim ??
|
||
boxLayout?.designTrim ??
|
||
ownDesignTrim;
|
||
// 구조물이 선 측점의 절·성토 면적 — **그려지는 폐회로**의 넓이로 고쳐 든다
|
||
// (2026-09-06 사용자 확정: 구조물 자체 면적을 빼는 것이 아니다). 그림과 면적이
|
||
// 같은 트림 값을 쓰므로 "그림은 이런데 수량은 저렇다"가 생기지 않는다.
|
||
applyStructureAreas(section, designTrim);
|
||
const designFrom = plotLayer.childElementCount;
|
||
appendCrossDesignOverlay(plotLayer, section.design, x, toDisplayY, drawSamples, designTrim, {
|
||
layer: svg,
|
||
x: (CROSS_PAD.left + widthPx - CROSS_PAD.right) / 2,
|
||
y: heightPx - CROSS_PAD.bottom - 4,
|
||
});
|
||
// 설계선 오버레이가 그린 보라 선 표시 — 고름 구역에서 구조물 트림 선과 가름(137-2).
|
||
[...plotLayer.children]
|
||
.slice(designFrom)
|
||
.forEach((el) => el.setAttribute(DESIGN_LINE_ATTR, ""));
|
||
// 물넘이 자리 = 노면 · 측구 설계선 걷음(66-2 · 66-3 — 옛 계획고 점선이 드러남)
|
||
if (fordPaved && section.ford_pavement)
|
||
hideFordPavedRoad(
|
||
plotLayer,
|
||
designFrom,
|
||
section.design,
|
||
section.ford_pavement,
|
||
x,
|
||
toDisplayY,
|
||
);
|
||
// 사토장(유용토운반작업장) — 교본 6장 3절이 횡단도 표시를 요구한다. 값은 설계 결과에서
|
||
// 그대로 오고 여기서 다시 세지 않는다. 선 종류는 터파기 파선과 갈라 둔다.
|
||
appendSpoilFillOverlay(plotLayer, section.design, x, toDisplayY);
|
||
// 암 경계선 = 지면선 복사 + 오프셋(계획선 기준 아님).
|
||
if (rockBoundary && section.design.geometry_preset === "rock") {
|
||
appendRockBoundaryOverlay(
|
||
plotLayer,
|
||
drawSamples,
|
||
rockBoundary.offsetFor(section),
|
||
x,
|
||
toDisplayY,
|
||
);
|
||
}
|
||
// 배수관 세트 — 조정창이 쓸 카드 상태를 받아 둔다(2026-08-19) · 링크 카드도 채운다(2026-08-24).
|
||
culvertState.inletIsBasin = !!culvertLayout?.basin;
|
||
culvertState.hiddenPipe = culvertLayout?.culvert.hidden_pipe === true;
|
||
if (culvertLayout) Object.assign(culvertState, culvertCardState(culvertLayout));
|
||
if (boxLayout) {
|
||
bodies.attachBox(
|
||
appendBoxOverlay(plotLayer, boxLayout, x, toDisplayY, box ? bodies.toggleBox : undefined),
|
||
boxLayout,
|
||
);
|
||
}
|
||
if (fordLayout) {
|
||
bodies.attachFord(
|
||
appendFordOverlay(
|
||
plotLayer,
|
||
fordLayout,
|
||
x,
|
||
toDisplayY,
|
||
ford ? bodies.toggleFord : undefined,
|
||
),
|
||
fordLayout,
|
||
);
|
||
}
|
||
if (culvertLayout) {
|
||
setRevetActive = appendCulvertOverlay(
|
||
plotLayer,
|
||
culvertLayout,
|
||
x,
|
||
toDisplayY,
|
||
revetOffset ? toggleRevet : undefined,
|
||
// 독립 기슭막이(hidden_pipe)는 관을 그리지 않는다 — 연동 링크 카드와 같은 규칙.
|
||
isLinkedCulvert || culvertLayout.culvert.hidden_pipe === true,
|
||
isLinkedCulvert,
|
||
);
|
||
if (highlightRevet) setRevetActive(highlightRevet);
|
||
}
|
||
raiseWalls(plotLayer); // 흙막이 · 옹벽 안을 지나는 다른 선 가림(66-5)
|
||
// 보라 선 고름 — 가림 · 판정면 차례가 정해진 뒤(137-2).
|
||
linePick = attachDesignLinePick({
|
||
layer: plotLayer,
|
||
design: section.design,
|
||
x,
|
||
toDisplayY,
|
||
onPick: pickLine,
|
||
});
|
||
setLineActive = linePick.setActive;
|
||
setLineActive(pickedLineAt(section.station_id));
|
||
}
|
||
|
||
// 십자선(계획 노선 자리) · 축선 · 높이 축 이름 — `_Cross_View_Frame`(700줄 제한).
|
||
const scaleCenter = appendCardFrame(svg, plotLayer, {
|
||
valid,
|
||
centerX: x(0),
|
||
designElevation: hasDesign ? (designElevation as number) : null,
|
||
toY: toDisplayY,
|
||
widthPx,
|
||
heightPx,
|
||
});
|
||
// 확대·축소 버튼, 드래그 팬, 더블클릭 원복(E-5). 도형 레이어 transform + non-scaling-stroke.
|
||
const chartWrap = document.createElement("div");
|
||
chartWrap.className = "b06-cross-card__chart-wrap";
|
||
chartWrap.style.setProperty("--b06-plot-left", `${CROSS_PAD.left}px`);
|
||
// 팬 한계로 쓸 **그려진 범위** — 캐시 전 구간의 좌우 끝과 표고 상·하단.
|
||
const contentBounds = drawnContentBounds(drawSamples, x, toDisplayY);
|
||
const zoomPan = attachZoomPan(
|
||
svg,
|
||
plotLayer,
|
||
widthPx,
|
||
heightPx,
|
||
cardZoomStates.get(section.station_id),
|
||
(state) => {
|
||
cardZoomStates.set(section.station_id, state);
|
||
renderAxes(state);
|
||
scaleCenter(state.scale);
|
||
},
|
||
contentBounds,
|
||
);
|
||
scaleCenter(cardZoomStates.get(section.station_id)?.scale ?? 1);
|
||
// 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 값 칸은 항상 강조 토글이다.
|
||
const readout = buildAreaReadout(section.design, toggleArea);
|
||
setChipActive = readout.setActive;
|
||
// 배수관 · 흙막이 조정창 두 벌(카드 안 · 좌측 사본) — `_Cross_View_Panels`(137-1).
|
||
const panel = buildCardPanels({
|
||
section,
|
||
culvertLink,
|
||
isLinkedCulvert,
|
||
revetOffset,
|
||
inletStructure,
|
||
extraWalls,
|
||
structureSpan,
|
||
revetLink,
|
||
state: culvertState,
|
||
activeRevet: () => activeRevet,
|
||
setActiveRevet: (key) => {
|
||
activeRevet = key;
|
||
},
|
||
toggleRevet,
|
||
});
|
||
showRevetControl = (visible) => panel.show(visible ? activeRevet : null);
|
||
showRevetControl(activeRevet !== null);
|
||
// 세월교 조정창 — 배수관 창과 조작 축이 달라 따로 만든다(2026-08-25 사용자).
|
||
// 우측 상단 줌 버튼이 원배율에서 표시 반폭까지 다룬다(하단 ◀/▶/↺ 폐지, 2026-08-23).
|
||
const widthActions: CrossWidthActions | undefined = stationWidth && {
|
||
step: (deltaM) =>
|
||
stationWidth.request(
|
||
section.chainage_m,
|
||
Math.round((effectiveHalfWidth ?? maxOffset) + deltaM),
|
||
),
|
||
// ⤢ = 초기화 — **전역 횡단 반폭 보기값**으로 되돌린다. 사면 교차점이 그 밖이면
|
||
// 팬으로 끌어다 본다(PLAN 79-2 · 교차점 폭으로 넓히던 것 폐지).
|
||
fit: () => stationWidth.request(section.chainage_m, crossHalfWidth ?? maxOffset),
|
||
};
|
||
const zoomBar = buildZoomControls(zoomPan, widthActions);
|
||
if (linePick) zoomBar.prepend(linePick.label); // 치수 = [+ − ⤢] 왼쪽(137-2)
|
||
chartWrap.append(svg, readout.root, zoomBar, panel.root);
|
||
for (const panel of bodies.panels) chartWrap.append(panel.root);
|
||
if (activeArea) {
|
||
setBandActive(activeArea);
|
||
setChipActive(activeArea);
|
||
}
|
||
card.append(chartWrap);
|
||
}
|
||
|
||
appendCardFooter(card, section, rockBoundary, cutSlope);
|
||
return card;
|
||
}
|