/* ============================================================================= * B06_Section_UI_Cross_View.ts * 개별 횡단면 카드 SVG 렌더러와 X:Y 1:1 스케일 지표 계산. * * 700줄 제한 대응으로 기존 단일 렌더러 파일에서 횡단 카드 렌더링만 분리했다. 공통 상수·유틸은 * `_UI_Section_Common`, 설계 지정 컨트롤/설계선 오버레이는 `_UI_Cross_Design`을 참조한다. * 횡단은 종/횡 공통 Y스케일을 쓰지 않고 카드마다 자체 1:1 ppm으로 스케일한다. * ========================================================================== */ import type { CrossSection } from "./B06_Section_Api_Fetch"; import { appendCrossAreaBands, type AreaHighlightSetter, buildAreaReadout, type CrossAreaKey, } from "./B06_Section_UI_Cross_Areas"; import { appendFordPavementOverlay, appendFordSurfaceDropPlan, } from "./B06_Section_UI_Cross_Ford_Pavement"; 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 { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; import { computeStructureAreas, type DesignTrim } from "@util/common_util_cross_structure_areas"; 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 { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel"; import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Dock"; import { culvertCardState, structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure"; import { attachZoomPan, buildZoomControls, cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom"; import type { CrossWidthActions } from "./B06_Section_UI_Cross_View_Zoom"; import { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; import { createCrossAxes, IDENTITY_VIEW } from "./B06_Section_UI_Cross_Axes"; import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert"; import { REVET_FORM_DEFAULT } from "./B06_Section_UI_Cross_Culvert_Const"; import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import { CROSS_PAD, CROSS_WIDTH, type DesignChangeHandler, emptyView, L, svgElement, svgText, validElevation, } from "./B06_Section_UI_Section_Common"; import { crossPlotMetrics, effectiveCardHalfWidth } from "./B06_Section_UI_Cross_View_Metrics"; export { crossCardNaturalHeight } 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: RevetKey | 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, ): 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: RevetKey | null = revetOffset?.selectedFor(section) ?? null; /** 이 카드에서 **강조**할 벽. 연동으로 이어진 옆 측점이 고른 벽도 켠다 — * 조정창(`activeRevet`)은 고른 카드 하나에만 뜬다(2026-08-30 사용자). */ let highlightRevet: RevetKey | null = revetOffset?.highlightFor(section) ?? activeRevet; let setRevetActive: RevetHighlightSetter = () => undefined; let showRevetControl: (visible: boolean) => void = () => undefined; let culvertPipeLengthM: number | null = null; // 관 길이(m) — 조정창 표기용 let culvertInletIsBasin = false; // 유입이 집수정인가 — 조정창 표시·제목 분기 // 이 카드의 기준벽이 **독립 기슭막이**인가(관 없는 숨김 세트). 조정창 걸음이 // 배관(1m)과 다르다(0.1m — 2026-08-29 사용자). let culvertHiddenPipe = false; /** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */ let culvertInletOptions = { revetAllowed: true, basinLUAllowed: true }; /** 추가 기슭막이 상태(2026-08-22) — 끝 성토부 5m 이상(canAdd)·세워진 개수. */ let culvertExtraState = { canAdd: false, count: 0 }; /** 집수정 계류측 다단 상태(2026-08-22) — 조정창의 집수정 단 수 행이 쓴다. */ let culvertBasinExtraState = { canAdd: false, count: 0 }; /** 마지막 계산의 벽 제원(높이·재질) — 배관 벽은 순수 높이(2026-08-23 사용자). */ let culvertWallSpecs = new Map(); /** 마지막 계산의 실제 적용 d(상하) — d 미지정(자동) 벽의 ▲▼ 시작값. */ let culvertAppliedD = new Map(); /** * 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를 * 고르면 다른 하나는 풀린다(2026-08-21 사용자). 또 구조물을 고르면 그 **측점 카드도 * 같이 선택**된다 — 이미 선택된 카드 안에서 다른 구조물을 고를 때는 선택이 옮겨만 * 가고 카드 선택은 건드리지 않는다. */ // 구체 구조물(세월교·BOX암거) 선택·조정창 배선 — 한 모듈로 묶었다(2026-08-25). const bodies = createBodyWiring({ section, ford, box, isCardSelected: () => isSelected, clearArea: () => { if (activeArea === null) return; activeArea = null; setBandActive(null); setChipActive(null); onAreaSelect?.(section.station_id, null); }, selectCard: () => onSelect(section.station_id), }); const toggleRevet = (key: RevetKey): 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) { // 미선택 카드는 **카드 선택만** 부른다. 여기서 면적 비우기(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(); }; card.applyRevetHighlight = (key) => { highlightRevet = key; setRevetActive(key); }; 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); } setBandActive(activeArea); setChipActive(activeArea); onAreaSelect?.(section.station_id, activeArea); }; appendCardHeader(card, section, stationInterval, onDesignChange); const metrics = 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}`, 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 segments: string[] = []; let current: string[] = []; for (const sample of drawSamples) { if (!validElevation(sample)) { if (current.length > 1) segments.push(current.join(" ")); current = []; continue; } const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration; current.push(`${x(sample.offset_m ?? 0)},${y(elevated)}`); } if (current.length > 1) segments.push(current.join(" ")); segments.forEach((points) => plotLayer.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })), ); if (section.design) { const toDisplayY = (elevation: number): number => y(elevationMid + (elevation - elevationMid) * exaggeration); // 면적 밴드는 지면선 위·설계선 아래에 깔아 선이 밴드에 가리지 않게 한다. // 선택 여부와 무관하게 항상 깐다 — 평소에는 투명이고, 선택이 바뀔 때 카드를 새로 만들지 // 않고 클래스만 켜면 되므로 줌·팬이 살아남는다. 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). const boxLayout = computeBoxLayout(section, section.samples, bodies.boxAdjust()); const fordLayout = computeFordLayout(section, section.samples, bodies.fordAdjust()); // 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다. // 물넘이포장은 노면 자체가 파인 자리라 포장층도 그 바닥을 따른다 — 그렸으면 일반 // 포장 박스는 건너뛴다(두 겹으로 깔리면 색 구분이 사라진다). const fordPaved = appendFordPavementOverlay( plotLayer, section.ford_pavement, section.design, x, toDisplayY, ); if (!fordPaved) appendPavementOverlay(plotLayer, section.design, x, toDisplayY); // 세월교 측점 — 계획고는 이미 월류 높이만큼 내려와 있다. "월류가 없었다면" // 노면을 점선으로 남겨 얼마나 내려앉았는지 읽히게 한다(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-08-28 이관 이중그리기 방지). if (!section.culvert && !culvertLink) { 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); // 재질은 구조물 옵션(형태)이 정본이라 조정창에서 숨긴다 — 자리만 채운다. culvertWallSpecs.set("own", { height: ownLayout.heightM, form: section.revetment?.form ?? REVET_FORM_DEFAULT, }); culvertAppliedD.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); appendCrossDesignOverlay(plotLayer, section.design, x, toDisplayY, drawSamples, designTrim); // 암 경계선 = 지면선 복사 + 오프셋(계획선 기준 아님). if (rockBoundary && section.design.geometry_preset === "rock") { appendRockBoundaryOverlay( plotLayer, drawSamples, rockBoundary.offsetFor(section), x, toDisplayY, ); } // 배수관 세트 — 조정창이 쓸 카드 상태를 받아 둔다(2026-08-19). 관은 소유 측점 // 전용이라 링크 카드에서는 길이를 비우고, 나머지는 링크 카드도 채운다(2026-08-24). // 숨김 기슭막이·링크 카드는 관이 없거나 옆에서 이어져 온 것 — 관 길이 표기 숨김. culvertPipeLengthM = isLinkedCulvert || culvertLayout?.culvert.hidden_pipe ? null : (culvertLayout?.pipe.lengthM ?? null); culvertInletIsBasin = !!culvertLayout?.basin; culvertHiddenPipe = culvertLayout?.culvert.hidden_pipe === true; if (culvertLayout) { const state = culvertCardState(culvertLayout); culvertInletOptions = state.inletOptions; culvertExtraState = state.extraState; culvertBasinExtraState = state.basinExtraState; culvertWallSpecs = state.wallSpecs; culvertAppliedD = state.appliedD; } 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, ); if (highlightRevet) setRevetActive(highlightRevet); } } const centerSample = valid.reduce<(typeof valid)[number] | null>((nearest, sample) => { if (!nearest || Math.abs(sample.offset_m ?? 0) < Math.abs(nearest.offset_m ?? 0)) return sample; return nearest; }, null); const centerX = x(0); // 십자선 = 계획 노선 자리(구 데이터는 지반고 폴백). const centerElevation = hasDesign ? (designElevation as number) : (centerSample?.elevation_m ?? null); const centerY = centerElevation !== null ? y(elevationMid + (centerElevation - elevationMid) * exaggeration) : heightPx / 2; const centerMarkerClass = `b06-chart__center-marker${hasDesign ? " b06-chart__center-marker--design" : ""}`; plotLayer.append( svgElement("line", { x1: centerX, y1: centerY - 18, x2: centerX, y2: centerY + 18, class: centerMarkerClass, }), svgElement("line", { x1: centerX - 18, y1: centerY, x2: centerX + 18, y2: centerY, class: centerMarkerClass, }), ); svg.append( svgElement("line", { x1: CROSS_PAD.left, y1: heightPx - CROSS_PAD.bottom, x2: widthPx - CROSS_PAD.right, y2: heightPx - CROSS_PAD.bottom, class: "b06-chart__axis", }), svgElement("line", { x1: CROSS_PAD.left, y1: CROSS_PAD.top, x2: CROSS_PAD.left, y2: heightPx - CROSS_PAD.bottom, class: "b06-chart__axis", }), svgText(L("B06_Profile_View_ElevationAxis"), { x: 13, y: heightPx / 2, "text-anchor": "middle", transform: `rotate(-90 13 ${heightPx / 2})`, class: "b06-chart__axis-label", }), ); // 확대·축소 버튼, 드래그 팬, 더블클릭 원복(E-5). 도형 레이어 transform + non-scaling-stroke. const chartWrap = document.createElement("div"); chartWrap.className = "b06-cross-card__chart-wrap"; // 팬 한계로 쓸 **그려진 범위** — 캐시 전 구간의 좌우 끝과 표고 상·하단. const drawnOffsets = drawSamples.map((sample) => sample.offset_m ?? 0); const drawnDisplayY = drawSamples .filter(validElevation) .map((sample) => y(elevationMid + (sample.elevation_m - elevationMid) * exaggeration)); const contentBounds = drawnOffsets.length && drawnDisplayY.length ? { x: x(Math.max(...drawnOffsets)), width: x(Math.min(...drawnOffsets)) - x(Math.max(...drawnOffsets)), y: Math.min(...drawnDisplayY), height: Math.max(...drawnDisplayY) - Math.min(...drawnDisplayY), } : undefined; const zoomPan = attachZoomPan( svg, plotLayer, widthPx, heightPx, cardZoomStates.get(section.station_id), (state) => { cardZoomStates.set(section.station_id, state); renderAxes(state); }, contentBounds, ); // 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 값 칸은 항상 강조 토글이다. const readout = buildAreaReadout(section.design, toggleArea); setChipActive = readout.setActive; // 구조물 조정은 도면 안 오버레이 창(2026-08-21). 화면 좌(◀) = offset 증가 — // 벽 기준 이동량(outward 부호)으로 환산해 넘긴다. // 유입측 벽 — 기준벽과 유입측 다단(bextra). 빼먹으면 그 벽만 좌우가 뒤집힌다. const inletSideRole = (role: RevetKey): boolean => role === "inlet" || role.startsWith("bextra"); const outwardOf = (role: RevetKey): number => // 독립 기슭막이는 배관 유입/유출과 무관 — 정본 설치 측(좌 = +offset)으로 부호를 잡는다. role === "own" ? section.revetment?.side === "우" ? -1 : 1 : ((section.uphill_side ?? "left") === "left") === inletSideRole(role) ? 1 : -1; const heightOfWall = (key: RevetKey): number => culvertWallSpecs.get(key)?.height ?? 0; const formOfWall = (key: RevetKey): string => culvertWallSpecs.get(key)?.form ?? REVET_FORM_DEFAULT; // 연동 중인 링크 카드의 조작은 **소유 측점**에 실린다 — 본 기슭막이를 조절하면 // 링크된 측점이 같이 움직인다는 뜻이다(2026-08-24 사용자). 연동을 풀면 이 카드가 // 자기 값을 갖는다. const adjustTarget = (): CrossSection => isLinkedCulvert && culvertLink && (revetLink?.linkedFor(section) ?? true) ? culvertLink.source : section; // 연동을 푼 측점에서 **아직 손대지 않은** 벽은 소유 측점 값을 보여 준다 — 창이 // 0(자동)을 보여 주면 첫 ◀ 한 번에 벽이 원위치로 튄다(2026-08-30). const adjustOf = (key: RevetKey): WallAdjust => { const stored = revetOffset?.storedAdjustFor(adjustTarget(), key); if (stored) return stored; const source = isLinkedCulvert && culvertLink ? culvertLink.source : section; return revetOffset?.adjustFor(source, key) ?? { x: 0, d: null, h: null, m: null }; }; const panelContext = { section, scrollKey: section.station_id, adjustChainage: () => adjustTarget().chainage_m, isLinked: isLinkedCulvert, revetOffset, inletStructure, extraWalls, structureSpan, revetLink, adjustOf, outwardOf, heightOfWall, drawnWallKeys: () => [...culvertWallSpecs.keys()], formOfWall, appliedD: () => culvertAppliedD, pipeLengthM: () => culvertPipeLengthM, inletIsBasin: () => culvertInletIsBasin, hiddenPipe: () => culvertHiddenPipe, inletOptions: () => culvertInletOptions, extraState: () => culvertExtraState, basinExtraState: () => culvertBasinExtraState, activeRevet: () => activeRevet, setActiveRevet: (key: RevetKey | null) => { activeRevet = key; }, toggleRevet, }; const panel = buildStructurePanel(structurePanelDeps(panelContext)); // 좌측 [횡단 조정] 사본 — 같은 deps라 어느 쪽 조작이든 카드 재렌더에서 두 창이 // 함께 새 값으로 그려진다(2026-08-29 일원화). 스크롤 기억 키만 분리한다. const dockPanel = buildStructurePanel( structurePanelDeps({ ...panelContext, scrollKey: `dock:${section.station_id}` }), { dock: true }, ); showRevetControl = (visible) => { const key = visible ? activeRevet : null; panel.show(key); dockPanel.show(key); if (key) adoptAdjustPanel(dockPanel.root); else releaseAdjustPanel(dockPanel.root); }; 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), ), // ⤢ = 초기화. 기본은 **전역 횡단 반폭 보기값**으로 되돌리고, 그 폭 안에서는 // 절·성토선과 원지반의 교차점이 안 보이는 측점만 교차점이 보이는 폭으로 되돌린다 // (2026-08-23 사용자 지시 — 그 측점의 기본값이 그 폭이라는 뜻). fit: () => { const base = crossHalfWidth ?? maxOffset; const needed = toeFitHalfWidth(section); stationWidth.request(section.chainage_m, needed !== null && needed > base ? needed : base); }, }; chartWrap.append(svg, readout.root, buildZoomControls(zoomPan, widthActions), 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); return card; } /** * 구조물이 만든 폐회로 면적을 측점 설계에 반영한다(2026-09-06 사용자 확정). * * 표준 설계선만 보는 기본 계산은 구조물이 있는지 모른다 — 기슭막이·세월교·BOX가 서면 * 성토 사면이 벽에서 끊기고 그 바깥은 벽·성토부선이 대신 그리므로 폐회로가 달라진다. * 트림이 없으면(구조물 없는 측점) 아무것도 하지 않는다. * * 조작 중 즉시 반영용이다. 정본은 [저장]·[확정] 때 서버가 같은 코드 * (`B06_Section_Structure_Areas_Node.ts`)로 다시 계산해 얹는다(2026-09-06). */ function applyStructureAreas(section: CrossSection, trim: DesignTrim | undefined | null): void { const design = section.design; if (!trim || !design || !Array.isArray(design.design_line) || design.design_line.length < 2) { return; } const ground = section.samples .filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number") .map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number })) .sort((a, b) => a.offset - b.offset); const areas = computeStructureAreas({ designLine: design.design_line as Array<{ offset_m: number; elevation_m: number }>, ground, trim, rockBoundaryOffsetM: typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null, }); if (!areas) return; design.cut_area_m2 = Number(areas.cutAreaM2.toFixed(4)); design.fill_area_m2 = Number(areas.fillAreaM2.toFixed(4)); if (typeof design.cut_soil_area_m2 === "number") { design.cut_soil_area_m2 = Number(areas.cutSoilAreaM2.toFixed(4)); } }