/* ============================================================================= * B06_Section_UI_Cross_Design.ts * 측점 표준횡단 설계 지정 컨트롤(지반유형·단면유형·측구위치·측구형식·포장)과 * 설계선·암 경계선·포장층 오버레이. * * 카드 헤더 아래에 세그먼트 버튼을 배치하고, 선택이 바뀌면 onChange로 계산을 * 요청한다. 계산 결과(section.design)는 상위에서 다시 렌더될 때 절·성토 단면적 * 표시와 오버레이로 반영된다. 편절편성은 측구위치가 자동 결정되어 컨트롤을 숨기고, * 양절·양성에서만 배수 방향 선택을 노출한다. * * 암(리핑/발파) 지반에서만: 측구형식(일반/L형) 세그먼트와 암 경계선 상/하/리셋 * 버튼(B05 측점 선 제어 ▲/▼/↺ 패턴 재활용)을 노출한다. 암 경계선 오프셋은 * 서버 재계산 없이 프론트 세션에 보관되고(RockBoundaryControl), 확정 시 DB에 * 병합된다. * ========================================================================== */ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { CrossDesign, CrossSection, DitchSide, DitchType, GroundType, SectionMode, SectionSample, } from "./B06_Section_Api_Fetch"; export { appendPavementOverlay, appendRockBoundaryOverlay, } from "./B06_Section_UI_Cross_Design_Surface"; const SVG_NS = "http://www.w3.org/2000/svg"; /** 넘침 정리 한 장 몫 — 쓰기·읽기·쓰기 세 토막으로 갈라 두어 프레임 단위로 묶는다. */ interface ReflowCard { reset(): void; /** 패널로 옮길 개수. -1 이면 아직 자리를 안 잡아 건드리지 않는다. */ measure(): number; apply(moveCount: number): void; } const reflowQueue = new Set(); let reflowScheduled = false; /** * 넘침 정리를 **한 프레임에 몰아** 돌린다 — 모든 카드의 쓰기를 먼저 끝내고, 그 다음 * 읽기를 몰아서 하고, 마지막에 쓰기를 몰아서 한다. 강제 레이아웃이 카드 수만큼(67회) * 나던 것이 프레임당 한 번으로 줄어든다. */ function scheduleReflow(card: ReflowCard): void { reflowQueue.add(card); if (reflowScheduled) return; reflowScheduled = true; requestAnimationFrame(() => { reflowScheduled = false; const cards = [...reflowQueue]; reflowQueue.clear(); for (const item of cards) item.reset(); const counts = cards.map((item) => item.measure()); cards.forEach((item, index) => item.apply(counts[index])); }); } /** 카드 버튼줄의 flex 간격(px) — 모든 카드가 같은 CSS 를 쓰므로 한 번만 잰다. * `getComputedStyle` 도 강제 레이아웃을 부르므로 카드 67장마다 부르지 않는다. */ let barGapPx: number | null = null; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } /** * 지반유형은 **「토사」 토글 하나**다(2026-09-07 사용자 확정) — 켜면 토사, **끄면 암**(기본). * * 사용자 원문 — 「리핑암과 발파암 버튼을 삭제하면서 구분의 의미가 없어졌어. … 토사버튼만 * 존재하고 이값은 활성화/비활성화로 반영(기본값은 비활성화)」. 까닭은 **암반 지정·범위가 * 실무에서 애매해** 측점마다 암질을 못 박는 것이 오히려 틀리기 때문이고, 연암:경암 · * 발파암:리핑암은 **설계내역에서 설계자가 비율로** 넣기로 했다(계획서 8-1). * * ⚠ 저장값은 종전 그대로 쓴다 — 끔 = `ripping_rock`, 켬 = `soil`. 옛 자료의 `blasting_rock` * 도 「암(끔)」으로 읽힌다. 유토곡선·수량은 암을 한 종류(리핑암)로 잡으며, 갈라 넣는 것은 * 설계내역 몫이다. */ const GROUND_ROCK_DEFAULT: GroundType = "ripping_rock"; const MODE_OPTIONS: Array<[SectionMode, keyof typeof ui_locales]> = [ ["left_cut", "B06_Design_Mode_LeftCut"], ["right_cut", "B06_Design_Mode_RightCut"], ["both_cut", "B06_Design_Mode_BothCut"], ["both_fill", "B06_Design_Mode_BothFill"], ]; /** 자동 판정된 단면 유형의 지역화 라벨(제목행 pill용, D-2/E-3). */ export function sectionModeLabel(mode: SectionMode | undefined): string { const key = MODE_OPTIONS.find(([value]) => value === mode)?.[1]; return key ? ui_locales[key][currentLanguageIndex] : "-"; } // 좌/우 버튼 = 노면 횡단 경사 방향(물을 모으는 쪽 = 측구가 있다면 그 위치). 양성도 측구는 // 없지만 "생략된 측구"를 가정한 경사 방향을 여기서 정한다(N-2-3). const DITCH_OPTIONS: Array<[DitchSide, keyof typeof ui_locales]> = [ ["left", "B06_Design_SlopeDir_Left"], ["right", "B06_Design_SlopeDir_Right"], ]; const DITCH_TYPE_OPTIONS: Array<[DitchType, keyof typeof ui_locales]> = [ ["standard", "B06_Design_DitchType_Standard"], ["l_type", "B06_Design_DitchType_LType"], ]; export interface CrossDesignChange { ground_type: GroundType; section_mode: SectionMode; ditch_side: DitchSide | null; ditch_type: DitchType; paved: boolean; /** 암 지반 2단계 경사(암반 경계 아래=암, 위=토사) 적용 여부. 기본 true, 토글로 해제. */ two_stage_slope: boolean; /** 측구를 둘지 **사용자가 정한 선택**. `null` = 자동 판정. * ⚠ 결과(`design.ditch_enabled` = 실제 섰나)와 **다른 값**이다(2026-09-09 정리). */ ditch_choice: boolean | null; } /** * 암 경계선 세션 제어기. Page가 세션 저장소·카드 갱신과 연결해 구현한다. * 오프셋은 지면선(지반선) 기준 상대값(m, 음수=하향)이다 — 계획선 기준이 아니다. */ export interface RockBoundaryControl { stepM: number; defaultOffsetM: number; /** 세션 → design 저장값 → 기본값 순으로 현재 오프셋을 돌려준다. */ offsetFor: (section: CrossSection) => number; adjust: (chainageM: number, deltaM: number) => void; reset: (chainageM: number) => void; } function isRock(ground: GroundType): boolean { return ground === "ripping_rock" || ground === "blasting_rock"; } function segment( legend: string, options: Array<[T, keyof typeof ui_locales]>, selected: T | null, onPick: (value: T) => void, disabled = false, reason = "", ): HTMLElement { const wrap = document.createElement("div"); wrap.className = `b06-design__seg${disabled ? " b06-design__seg--disabled" : ""}`; // 라벨(legend)이 빈 문자열이면 생략한다(E-7: 일부 옵션 라벨 삭제). if (legend) { const legendEl = document.createElement("span"); legendEl.className = "b06-design__seg-legend"; legendEl.textContent = legend; wrap.append(legendEl); } const group = document.createElement("div"); group.className = "b06-design__seg-buttons"; for (const [value, labelKey] of options) { const button = document.createElement("button"); button.type = "button"; button.className = `b06-design__btn${value === selected ? " b06-design__btn--active" : ""}`; button.textContent = L(labelKey); button.setAttribute("aria-pressed", value === selected ? "true" : "false"); // 선행 조건 미충족 시 비활성(상시 노출하되 클릭 불가·사유 툴팁) — E-6. button.disabled = disabled; if (disabled && reason) button.title = reason; else button.addEventListener("click", () => onPick(value)); group.append(button); } wrap.append(group); return wrap; } /** 온/오프 토글 하나(포장·2단계 경사 공용). 세그먼트와 같은 컨테이너/버튼 스타일을 쓴다. */ function toggle( legend: string, on: boolean, onLabel: string, offLabel: string, onToggle: () => void, disabled = false, reason = "", ): { wrap: HTMLElement; button: HTMLButtonElement } { const wrap = document.createElement("div"); wrap.className = `b06-design__seg${disabled ? " b06-design__seg--disabled" : ""}`; if (legend) { const legendEl = document.createElement("span"); legendEl.className = "b06-design__seg-legend"; legendEl.textContent = legend; wrap.append(legendEl); } const buttons = document.createElement("div"); buttons.className = "b06-design__seg-buttons"; const button = document.createElement("button"); button.type = "button"; button.className = `b06-design__btn${on ? " b06-design__btn--active" : ""}`; button.textContent = on ? onLabel : offLabel; button.setAttribute("aria-pressed", on ? "true" : "false"); button.disabled = disabled; if (disabled && reason) button.title = reason; else button.addEventListener("click", onToggle); buttons.append(button); wrap.append(buttons); return { wrap, button }; } /** * 길게 누르는 동안 같은 동작을 반복한다(B05 종단 선 제어 패턴 재사용). 0.5초 유지하면 * 0.1초 간격(0.1m씩) 반복이 시작된다. 버튼이 재빌드로 사라져도 반복이 끊기지 않도록 * 타이머·종료 감지를 버튼이 아니라 window 이벤트로 들고 있는다. */ function createHoldRepeater(): { start: (action: () => void) => void; stop: () => void } { let delayTimer = 0; let repeatTimer = 0; function stop(): void { window.clearTimeout(delayTimer); window.clearInterval(repeatTimer); delayTimer = 0; repeatTimer = 0; window.removeEventListener("pointerup", stop); window.removeEventListener("pointercancel", stop); window.removeEventListener("blur", stop); } function start(action: () => void): void { stop(); delayTimer = window.setTimeout(() => { repeatTimer = window.setInterval(action, 100); }, 500); window.addEventListener("pointerup", stop); window.addEventListener("pointercancel", stop); window.addEventListener("blur", stop); } return { start, stop }; } /** 암 경계선 상/하/리셋 컨트롤(B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용). E-7: X축 행 배치용 export. */ export function buildRockBoundaryControl( section: CrossSection, control: RockBoundaryControl, ): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b06-design__seg b06-design__rockb"; const legendEl = document.createElement("span"); legendEl.className = "b06-design__seg-legend"; legendEl.textContent = L("B06_Design_RockBoundary_Legend"); wrap.append(legendEl); const group = document.createElement("div"); group.className = "b06-design__seg-buttons"; const readout = document.createElement("span"); readout.className = "b06-design__rockb-readout"; const currentOffset = control.offsetFor(section); readout.textContent = `${currentOffset >= 0 ? "+" : ""}${currentOffset.toFixed(1)}m`; const repeater = createHoldRepeater(); const makeButton = ( label: string, title: string, className: string, onClick: () => void, hold = false, ): HTMLButtonElement => { const button = document.createElement("button"); button.type = "button"; button.className = `b06-design__rockb-btn ${className}`; button.textContent = label; button.title = hold ? `${title}\n(길게 누르면 0.5초 뒤부터 연속 조정)` : title; if (hold) { // 포인터로 누르면 즉시 1회 반응하고 반복을 예약한다. 이어지는 click은 중복이라 삼킨다. let swallowClick = false; button.addEventListener("pointerdown", (event) => { event.stopPropagation(); swallowClick = true; onClick(); repeater.start(onClick); }); button.addEventListener("click", (event) => { event.stopPropagation(); if (swallowClick) { swallowClick = false; return; } onClick(); }); } else { button.addEventListener("click", onClick); } return button; }; group.append( makeButton( "▲", `${L("B06_Design_RockBoundary_Up")} (+${control.stepM}m)`, "is-up", () => control.adjust(section.chainage_m, control.stepM), true, ), makeButton( "▼", `${L("B06_Design_RockBoundary_Down")} (-${control.stepM}m)`, "is-down", () => control.adjust(section.chainage_m, -control.stepM), true, ), makeButton("↺", L("B06_Design_RockBoundary_Reset"), "is-reset", () => control.reset(section.chainage_m), ), readout, ); wrap.append(group); return wrap; } /** * 카드 설계 지정 컨트롤을 만든다. 지반유형 세그먼트는 제목행 배치용으로 분리 반환하고(D-6), * 나머지(단면유형·측구·2단·포장·암경계)는 본문 `bar`에 담는다. */ export function buildDesignControls( section: CrossSection, onChange: (chainageM: number, change: CrossDesignChange) => void, ): { bar: HTMLElement; groundSegment: HTMLElement } { const design = section.design; // 기본값: 토사(soil) + 상단측 절토(uphill_side, 미상이면 좌절토) + 일반측구 + 비포장. const state: { ground: GroundType; mode: SectionMode; ditch: DitchSide | null; ditchType: DitchType; paved: boolean; twoStage: boolean; ditchEnabled: boolean | null; } = { // 설계가 아직 없는 측점의 기본은 **암**이다(2026-09-07 사용자: 「기본값은 비활성화」). ground: design?.ground_type ?? GROUND_ROCK_DEFAULT, mode: design?.section_mode ?? (section.uphill_side === "right" ? "right_cut" : "left_cut"), ditch: design?.ditch_side ?? null, ditchType: design?.ditch_type ?? "standard", paved: design?.paved ?? false, // 암 design일 때만 저장값을 신뢰(토사는 two_stage=false echo가 무의미) — 암 전환 시 기본 복합경사(4번). twoStage: design && isRock(design.ground_type) ? (design.two_stage_slope ?? true) : true, // ⚠ **결과가 아니라 선택을 읽는다.** 결과를 읽으면 한 번 저장된 뒤로 // 자동 판정이 영영 안 돈다(2026-09-09에 갈라낸 자리). ditchEnabled: design?.ditch_choice ?? null, }; const bar = document.createElement("div"); bar.className = "b06-design"; // 컨트롤 상호작용이 카드 선택 클릭으로 전파되지 않게 한다. bar.addEventListener("click", (event) => event.stopPropagation()); // 경사 방향 선택이 필요한 경우 = 양절(both_cut)·양성(both_fill). 편절은 절토측 자동. // 양성은 측구가 없어도 노면 기울기 방향(생략된 측구 방향)을 여기서 지정한다(N-2-3). const needsDitch = (): boolean => state.mode === "both_cut" || state.mode === "both_fill"; const emit = (): void => { if (!state.ground || !state.mode) return; // L형 측구는 암 전용 — 토사로 되돌리면 일반측구로 강등해 서버 거부를 예방한다. if (!isRock(state.ground)) state.ditchType = "standard"; onChange(section.chainage_m, { ground_type: state.ground, section_mode: state.mode, ditch_side: needsDitch() ? (state.ditch ?? "left") : null, ditch_type: state.ditchType, paved: state.paved, two_stage_slope: state.twoStage, ditch_choice: state.ditchEnabled, }); }; // 지반유형: 제목행 배치용으로 분리 반환(D-6). 「토사」 토글 하나 — 끄면 암이다(2026-09-07). // 버튼명은 「토사」로 고정하고 **켜짐/꺼짐(색)**으로 상태를 보인다 — 측구 토글과 같은 꼴 // (사용자 원문: 「토사버튼만 존재하고 이값은 활성화/비활성화로 반영」). const groundToggle = toggle( "", state.ground === "soil", L("B06_Design_Ground_Soil"), L("B06_Design_Ground_Soil"), () => { state.ground = state.ground === "soil" ? GROUND_ROCK_DEFAULT : "soil"; emit(); }, ); groundToggle.button.title = state.ground === "soil" ? "토사 — 암반이 없어 절토는 토사 경사 하나로만 그린다. 누르면 암으로 바뀐다." : "암 — 암 경계선이 서고 그 아래는 암 절토각, 위는 토사 경사로 그린다. 누르면 토사로 바뀐다."; const groundSegment = groundToggle.wrap; groundSegment.classList.add("b06-design__seg--header"); // 단면 유형은 지형에서 자동 판정되며(D-2), 제목행 pill로 표시한다(E-3, Cross_View에서 생성). // 아래 옵션은 상시 노출하되 선행 조건 미충족 시 비활성 처리한다(E-6). const rockCut = isRock(state.ground) && state.mode !== "both_fill"; const hasDitch = state.mode !== "both_fill"; // 측구 생성 여부(자동 판정 or 사용자 override) — 측구형식은 측구가 있을 때만 의미 있다. // 화면 표시는 **결과**를 보인다 — 선택이 없으면 자동으로 선 결과가 답이다. const ditchOn = state.ditchEnabled ?? design?.ditch_enabled ?? true; // 경사 방향(좌/우): 양절·양성에서 활성. 항상 인라인 노출(오버플로 대상 아님). 라벨 삭제(E-7). const slopeDirSeg = segment( "", DITCH_OPTIONS, state.ditch, (value) => { state.ditch = value; emit(); }, !needsDitch(), L("B06_Design_Disabled_BothCutOnly"), ); // 측구 생성 토글(D-1): 측구 있는 단면에서만 활성. 버튼명 "측구" 고정, 컬러로 상태 표시(E-7). const ditchToggle = toggle( "", ditchOn, L("B06_Design_Ditch_On"), L("B06_Design_Ditch_Off"), () => { state.ditchEnabled = !ditchOn; emit(); }, !hasDitch, L("B06_Design_Disabled_NoDitch"), ); // 측구형식(일반/L형): 암 지반 + 절토 단면 + 측구가 실제 있을 때만 활성. const ditchTypeSeg = segment( L("B06_Design_DitchType_Legend"), DITCH_TYPE_OPTIONS, state.ditchType, (value) => { state.ditchType = value; emit(); }, !rockCut || !ditchOn, !ditchOn ? L("B06_Design_Disabled_NoDitchType") : L("B06_Design_Disabled_RockCut"), ); // 2단계 경사 토글: 암 지반 + 절토 단면에서만 활성. 활성="복합경사"/비활성="단경사"(E-7). const twoStage = toggle( "", state.twoStage, L("B06_Design_TwoStage_On"), L("B06_Design_TwoStage_Off"), () => { state.twoStage = !state.twoStage; emit(); }, !rockCut, L("B06_Design_Disabled_RockCut"), ); // 포장 토글: 라벨 삭제, 버튼명 활성="포장"/비활성="비포장"(컬러 유지) — E-7. const paved = toggle("", state.paved, L("B06_Design_Paved_On"), L("B06_Design_Paved_Off"), () => { state.paved = !state.paved; emit(); }); // B05 법정 경사 분석이 포장을 제안한 측점은 근거 문구를 배지·툴팁으로 표기한다. if (design?.pavement_suggested) { paved.button.title = L("B06_Design_Paved_Suggested"); const badge = document.createElement("span"); badge.className = "b06-design__paved-badge"; badge.textContent = "⚠"; badge.title = L("B06_Design_Paved_Suggested"); paved.wrap.append(badge); } // 오버플로 드롭다운(N-2-2): 컨트롤이 카드 폭에서 한 행을 넘치면 뒤쪽부터 우측 "⋯" // 드롭다운(세로 배치)으로 옮겨 다음 행으로 밀리는 것을 막는다. 경사 방향은 항상 인라인, // 이동 우선순위(뒤에서부터): 포장 → 복합경사 → 측구형식 → 측구 토글. const moveable = [ditchToggle.wrap, ditchTypeSeg, twoStage.wrap, paved.wrap]; const more = document.createElement("details"); more.className = "b06-design__more"; const moreSummary = document.createElement("summary"); moreSummary.className = "b06-design__more-summary"; moreSummary.textContent = "⋯"; moreSummary.title = L("B06_Design_More"); const morePanel = document.createElement("div"); morePanel.className = "b06-design__more-panel"; more.append(moreSummary, morePanel); bar.append(slopeDirSeg, ...moveable, more); // 넘침 정리는 **세 토막**으로 나눈다 — 쓰기(reset) → 읽기(measure) → 쓰기(apply). // 카드 안에서 한 장씩 하면 쓰기 뒤 읽기가 카드 수만큼 반복돼 브라우저가 레이아웃을 // 그때마다 강제로 다시 잰다. 게다가 한 번의 강제 레이아웃이 **그때까지 들어간 카드 // 전부**를 다시 재므로 뒤로 갈수록 비싸진다(2026-09-06 CPU 프로파일: 진입에서 자기 // 시간 1위). 그래서 `scheduleReflow` 가 67장을 모아 한 프레임에 묶어 돌린다. const card: ReflowCard = { reset() { for (const element of moveable) bar.insertBefore(element, more); morePanel.replaceChildren(); more.hidden = false; // 폭을 재려면 자리에 있어야 한다. }, measure() { const clientWidth = bar.clientWidth; if (clientWidth <= 0) return -1; // 아직 자리를 안 잡았다 — 그대로 둔다. if (barGapPx === null) barGapPx = Number.parseFloat(getComputedStyle(bar).gap) || 0; const widths = moveable.map((element) => element.offsetWidth); let overflow = bar.scrollWidth - clientWidth; let moveCount = 0; while (overflow > 1 && moveCount < moveable.length) { overflow -= widths[moveable.length - 1 - moveCount] + barGapPx; moveCount += 1; } return moveCount; }, apply(moveCount) { if (moveCount <= 0) { more.hidden = true; return; } for (let index = 0; index < moveCount; index += 1) { morePanel.insertBefore(moveable[moveable.length - 1 - index], morePanel.firstChild); } }, }; // 첫 호출은 아래 `scheduleReflow` 가 맡는다 — 관찰을 걸면 초기 크기로 곧바로 한 번 더 // 불려 카드마다 두 번 돌았다. 창 크기가 바뀔 때만 그 카드 하나를 다시 넣는다. let firstObservation = true; const overflowObserver = new ResizeObserver(() => { if (firstObservation) { firstObservation = false; return; } scheduleReflow(card); }); overflowObserver.observe(bar); scheduleReflow(card); // 암 경계선 제어는 그래프 X축 제목 행으로 이동(E-7, Cross_View에서 배치). // 절·성토 면적 readout은 그래프 중상단 오버레이로 이동(E-4, Cross_View에서 배치). return { bar, groundSegment }; } /** * 횡단 SVG에 표준단면 설계선을 겹쳐 그린다. 지면선과 겹치는 구간(사면이 지반을 추종하는 * 부분)만 점선으로 그려 뒤에 깔린 지표선이 비쳐 보이게 하고, 나머지는 실선으로 둔다. * * `trim`이 오면 그 offset 범위 밖 구간은 그리지 않는다 — 배수관 측점에서 기슭막이가 * 성토를 받치므로 벽과 교차된 이후의 성토 경사는 끊는다(2026-08-20 사용자 확정). * 경계에 걸린 구간은 경계 지점까지 잘라 그린다. */ export function appendCrossDesignOverlay( svg: SVGElement, design: CrossDesign, x: (offset: number) => number, toDisplayY: (elevation: number) => number, groundSamples: SectionSample[], trim?: { minOffset: number; maxOffset: number; minElevation?: number; maxElevation?: number; minSlope?: { points: Array<{ offset: number; elevation: number }> }; maxSlope?: { points: Array<{ offset: number; elevation: number }> }; }, ): void { const line = design.design_line; if (!line || line.length < 2) return; // 지면선 표고 보간기(정렬된 유효 샘플, 범위 밖 끝값 클램프). const ground = groundSamples .filter((s) => s.valid !== false && s.elevation_m !== null && Number.isFinite(s.elevation_m)) .map((s) => ({ offset: s.offset_m ?? 0, elevation: s.elevation_m as number })) .sort((a, b) => a.offset - b.offset); const groundAt = (offset: number): number | null => { if (!ground.length) return null; if (offset <= ground[0].offset) return ground[0].elevation; const last = ground[ground.length - 1]; if (offset >= last.offset) return last.elevation; for (let i = 1; i < ground.length; i += 1) { if (offset > ground[i].offset) continue; const a = ground[i - 1]; const b = ground[i]; const span = b.offset - a.offset; if (span <= 0) return b.elevation; return a.elevation + (b.elevation - a.elevation) * ((offset - a.offset) / span); } return last.elevation; }; // 각 설계선 점이 지면선과 픽셀 단위로 겹치는지 판정한다. const OVERLAP_PX = 2; const overlap = line.map((point) => { const g = groundAt(point.offset_m); if (g === null) return false; return Math.abs(toDisplayY(point.elevation_m) - toDisplayY(g)) <= OVERLAP_PX; }); // 사면 끝(계획선이 원지반과 처음 만나는 지점) **바깥**은 계획선을 그리지 않는다 — // 그 너머는 손대지 않는 원지반이라 지반선이 이미 표현한다(2026-08-20 사용자 지적: // 12+0.0 좌측 성토부에서 접점 이후 계획선이 계속 그려짐). // 탐색은 **노견 바깥**에서 시작한다 — 노면·측구·노견을 지나는 계획선은 지반과 // 우연히 겹쳐도 지워선 안 되고, 노견 이후 사면이 지반과 만나는 2차 접점만 종료점이다 // (2026-08-20 사용자 ①). const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m); const edgesForTrim = design.road_edges; const leftEdge = edgesForTrim ? Math.max(edgesForTrim.left.offset_m, edgesForTrim.right.offset_m) : 0; const rightEdge = edgesForTrim ? Math.min(edgesForTrim.left.offset_m, edgesForTrim.right.offset_m) : 0; // 측구는 노견 **바깥**에 붙는다(road_edges 밖). 측구가 있는 쪽은 측구 바깥 끝까지 // 보호하고 그 다음부터 접점을 찾는다 — 노견에서 바로 찾으면 측구가 통째로 잘린다 // (2026-08-20 사용자: "측구 이후 성토부가 존재하는 경사로의 1차 교차점까지 보호"). const ditchSpec = design.ditch; const ditchWidth = design.ditch_enabled === false || !ditchSpec || ditchSpec.type === "none" ? 0 : ditchSpec.type === "standard" ? ditchSpec.top_width_m : ditchSpec.width_m; // 좌표 규약: +offset = 좌측. ditch_side "left" = 큰 offset 쪽. const protectMax = leftEdge + (design.ditch_side === "left" ? ditchWidth : 0); const protectMin = rightEdge - (design.ditch_side === "right" ? ditchWidth : 0); const meetOffset = (startOffset: number, step: number): number | null => { const indices = step > 0 ? sorted.map((_, i) => i) : sorted.map((_, i) => sorted.length - 1 - i); for (const i of indices) { const point = sorted[i]; // 노견 안쪽(도로 구간)은 건너뛴다. if (step > 0 ? point.offset_m <= startOffset : point.offset_m >= startOffset) continue; const g = groundAt(point.offset_m); if (g === null) continue; if (Math.abs(toDisplayY(point.elevation_m) - toDisplayY(g)) <= OVERLAP_PX) return point.offset_m; } return null; }; const meetLeft = meetOffset(protectMin, -1); // 작은 offset 쪽(측구까지 지난 뒤) const meetRight = meetOffset(protectMax, 1); // 큰 offset 쪽(측구까지 지난 뒤) // 트림 범위로 구간 하나를 자른다. 완전히 밖이면 null, 걸치면 경계에서 보간해 끊는다. const clipSegment = ( a: { offset_m: number; elevation_m: number }, b: { offset_m: number; elevation_m: number }, ): | [{ offset_m: number; elevation_m: number }, { offset_m: number; elevation_m: number }] | null => { // 배수관 세트 트림 + 사면 끝 접점 트림을 합친 유효 범위. // 배수관 레이아웃이 사면 폴리라인(min/maxSlope)을 준 쪽은 **그 시작점(노견)에서** // 설계선을 끊는다 — 그 바깥은 폴리라인이 그리므로 이중선이 되고, 트림 경계에서 // 표고를 벽 상단으로 스냅하면서 수직으로 튀는 선이 남는다(2026-08-22 사용자 지적: // 집수정을 좌우로 옮기면 벽 상단이 노견 표고 그대로라 스냅 폭이 그만큼 커진다). const minSlopeStart = trim?.minSlope?.points[0]?.offset; const maxSlopeStart = trim?.maxSlope?.points[0]?.offset; const minOffset = Math.max( trim?.minOffset ?? -Infinity, meetLeft ?? -Infinity, minSlopeStart ?? -Infinity, ); const maxOffset = Math.min( trim?.maxOffset ?? Infinity, meetRight ?? Infinity, maxSlopeStart ?? Infinity, ); if (!Number.isFinite(minOffset) && !Number.isFinite(maxOffset)) return [a, b]; const effective = { minOffset, maxOffset }; const lo = Math.min(a.offset_m, b.offset_m); const hi = Math.max(a.offset_m, b.offset_m); if (hi <= effective.minOffset || lo >= effective.maxOffset) return null; const at = (offset: number) => { const span = b.offset_m - a.offset_m; const t = Math.abs(span) > 1e-9 ? (offset - a.offset_m) / span : 0; return { offset_m: offset, elevation_m: a.elevation_m + (b.elevation_m - a.elevation_m) * t }; }; // 배수관 트림 경계에서는 끝단 표고를 **벽 이음선 상단점**에 맞춰 내린다 — 벽 높이가 // 관경+여유고로 고정된 뒤로는 설계 사면선 표고와 어긋나 사면선 끝이 벽에서 떨어진다 // (2026-08-21 사용자 ②). 사면 끝 접점 트림이 이긴 경계에는 적용하지 않는다. const snap = ( p: { offset_m: number; elevation_m: number }, elevation: number | undefined, wins: boolean, ) => (elevation != null && wins ? { offset_m: p.offset_m, elevation_m: elevation } : p); const minIsCulvert = trim != null && Math.abs(effective.minOffset - trim.minOffset) < 1e-9; const maxIsCulvert = trim != null && Math.abs(effective.maxOffset - trim.maxOffset) < 1e-9; const clamp = (p: { offset_m: number; elevation_m: number }) => p.offset_m < effective.minOffset ? snap(at(effective.minOffset), trim?.minElevation, minIsCulvert) : p.offset_m > effective.maxOffset ? snap(at(effective.maxOffset), trim?.maxElevation, maxIsCulvert) : p; return [clamp(a), clamp(b)]; }; // 세그먼트별로 그린다. 지면선과 겹치는 구간(= 계획선이 원지반과 차이가 없는 구간)은 // **그리지 않는다** — 지반선이 이미 같은 자리를 표현하므로 정보가 없다 // (2026-08-20 사용자 지시: 보라색 점선 삭제). // 단 **노면·노견·측구부터 1차 접점까지는 언제나 그린다** — 계획고가 지반과 우연히 // 몇 ㎝ 차이면 노면 계획선이 통째로 끊겨 보이고(8·10·10+0.9), 노견 끝 성토고가 // 4㎝ 정도면 접점까지의 짧은 사면 조각도 사라진다(9+0.0 — 2026-08-20 사용자 지적). // 접점을 못 찾은 쪽은 보호 구간을 노견·측구까지로 둔다. const keepMin = meetLeft ?? protectMin; const keepMax = meetRight ?? protectMax; const isProtected = (a: { offset_m: number }, b: { offset_m: number }): boolean => Math.max(a.offset_m, b.offset_m) > keepMin && Math.min(a.offset_m, b.offset_m) < keepMax; for (let i = 1; i < line.length; i += 1) { if (overlap[i - 1] && overlap[i] && !isProtected(line[i - 1], line[i])) continue; const clipped = clipSegment(line[i - 1], line[i]); if (!clipped) continue; const seg = document.createElementNS(SVG_NS, "line"); seg.setAttribute("x1", String(x(clipped[0].offset_m))); seg.setAttribute("y1", String(toDisplayY(clipped[0].elevation_m))); seg.setAttribute("x2", String(x(clipped[1].offset_m))); seg.setAttribute("y2", String(toDisplayY(clipped[1].elevation_m))); seg.setAttribute("class", "b06-chart__design-cross"); svg.append(seg); } // 배수관 측점 성토 사면 — 백엔드 설계선(1:1.2 고정) 대신 **배수관 레이아웃이 준 직선** // 을 그린다. 벽 자리가 관 길이 맞춤으로 옮겨져도 노견 → 벽 이음선 상단점을 잇는 단일 // 각도가 유지된다(2026-08-21 사용자 ②, 성토 사면은 단일 각도). for (const segment of [trim?.minSlope, trim?.maxSlope]) { if (!segment || segment.points.length < 2) continue; // 폴리라인 — [원 노견, (연장된 노견), 벽 이음선 상단점]. 물매가 1:1.2 고정이라 // 벽이 밖으로 나간 만큼 노면이 연장된다(2026-08-21 사용자 확정). for (let i = 1; i < segment.points.length; i += 1) { const a = segment.points[i - 1]; const b = segment.points[i]; const seg = document.createElementNS(SVG_NS, "line"); seg.setAttribute("x1", String(x(a.offset))); seg.setAttribute("y1", String(toDisplayY(a.elevation))); seg.setAttribute("x2", String(x(b.offset))); seg.setAttribute("y2", String(toDisplayY(b.elevation))); seg.setAttribute("class", "b06-chart__design-cross"); svg.append(seg); } } // 차도·노견 경계 짧은 수직 틱(N-4-2): ±3.6px(기존 ±6의 60%). 노면 단일 기울기라 육안 // 구분이 안 되는 경계를 표시한다. 노견 바깥 끝(road_edges)은 측구·사면 꺾임으로 이미 구분됨. const edges = design.carriageway_edges; if (edges) { for (const edge of [edges.left, edges.right]) { const cx = x(edge.offset_m); const cy = toDisplayY(edge.elevation_m); const tick = document.createElementNS(SVG_NS, "line"); tick.setAttribute("x1", String(cx)); tick.setAttribute("y1", String(cy - 3.6)); tick.setAttribute("x2", String(cx)); tick.setAttribute("y2", String(cy + 3.6)); tick.setAttribute("class", "b06-chart__carriageway-tick"); svg.append(tick); } // 노폭 라벨(2026-09-06 사용자 지시) — 확폭이 걸린 측점인지 눈으로 바로 알게 한다. // 확폭이 없으면 규격 폭만, 있으면 「4.5m (규격 3.0 + 확폭 1.5)」로 적는다. const widened = (design.widening_left_m ?? 0) + (design.widening_right_m ?? 0); const standardWidth = design.carriageway_standard_width_m; const label = document.createElementNS(SVG_NS, "text"); label.setAttribute("x", String((x(edges.left.offset_m) + x(edges.right.offset_m)) / 2)); label.setAttribute("y", String(toDisplayY(edges.left.elevation_m) - 6)); label.setAttribute("class", "b06-chart__carriageway-label"); label.textContent = widened > 0.001 && typeof standardWidth === "number" ? `노폭 ${design.carriageway_width_m.toFixed(2)}m (규격 ${standardWidth.toFixed(2)} + 확폭 ${widened.toFixed(2)})` : `노폭 ${design.carriageway_width_m.toFixed(2)}m`; svg.append(label); } } /** * 암 경계선(지면선 복사 + 상하 오프셋, 점선)을 겹쳐 그린다. * 계획선(설계선)이 아니라 **지반선(지면선)**을 복사해 이동하는 것이 규칙이다. * 리핑암·발파암 지반에서만 호출한다. offsetM 음수 = 하향. * 무효 샘플 구간은 지반선과 동일하게 선을 끊어 그린다. */