Files
Aislo/B06_Section/B06_Section_UI_Cross_View.ts
T

676 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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 CrossDesignChange,
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).
const lineDesign = section.design;
linePick = attachDesignLinePick({
layer: plotLayer,
design: lineDesign,
x,
toDisplayY,
onPick: pickLine,
// 고른 선 쪽 노견 확장(139-6) — 카드 버튼과 같은 창구(세션 · 브라우저 재계산)
shift: onDesignChange
? {
valueOf: (side) => lineDesign[`shoulder_extend_${side}_m`] ?? 0,
set: (side, valueM) =>
onDesignChange(section.chainage_m, {
...(lineDesign as unknown as CrossDesignChange),
shoulder_extend: { side, valueM },
}),
}
: undefined,
});
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;
}