물매를 1:1.2~2.0 범위에서 역산하던 방식을 걷어내고 **수평 1.2 : 수직 1 고정**으로 바꿨다(2026-08-21 사용자 확정). 벽이 1:1.2 사면이 닿는 자리보다 밖에 있으면 물매를 완만하게 바꾸는 대신 **노폭을 그만큼 늘린다** — 기슭막이를 바깥으로 1m 밀면 노폭이 약 1m 늘어난다. - fillSlopeOf가 [원 노견 → 연장된 노견 → 벽 이음선 상단점] 폴리라인을 돌려주고, 설계선 오버레이가 그 폴리라인을 그린다(노면 연장 구간이 수평으로 붙는다). - 반대로 벽이 안쪽이라 물매가 1:1.2보다 급해지는 자리는 노폭으로 못 맞춘다(도로를 좁힐 수는 없다) — 자동 자리를 1:1.2가 될 때까지 바깥으로 미는 처리를 유지한다. - fillSlope.roadWideningM 신설, 툴팁에 "노폭 연장 x.xxm" 표시. 검증(계획고 스윕 24케이스): 물매 23건이 정확히 1:1.200(나머지 1건은 성토고 0.2m로 사면이랄 게 없는 측점). 벽 이동 시험 — 밖으로 1m씩 밀 때 노폭 연장 1.52→3.27→ 4.22→5.21m, 물매는 계속 1:1.200. tsc 통과, pytest 148 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
691 lines
30 KiB
TypeScript
691 lines
30 KiB
TypeScript
/* =============================================================================
|
|
* 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";
|
|
|
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
|
|
function L(key: keyof typeof ui_locales): string {
|
|
return ui_locales[key][currentLanguageIndex];
|
|
}
|
|
|
|
const GROUND_OPTIONS: Array<[GroundType, keyof typeof ui_locales]> = [
|
|
["soil", "B06_Design_Ground_Soil"],
|
|
["ripping_rock", "B06_Design_Ground_Ripping"],
|
|
["blasting_rock", "B06_Design_Ground_Blasting"],
|
|
];
|
|
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=자동 판정, true/false=수동 override. */
|
|
ditch_enabled: 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<T extends string>(
|
|
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;
|
|
} = {
|
|
ground: design?.ground_type ?? "soil",
|
|
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,
|
|
ditchEnabled: design?.ditch_enabled ?? 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_enabled: state.ditchEnabled,
|
|
});
|
|
};
|
|
|
|
// 지반유형: 제목행 배치용으로 분리 반환(D-6). 라벨 삭제(3번) — 버튼만.
|
|
const groundSegment = segment("", GROUND_OPTIONS, state.ground, (value) => {
|
|
state.ground = value;
|
|
emit();
|
|
});
|
|
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);
|
|
|
|
const reflow = (): void => {
|
|
// 후보 전부 인라인 복귀 → more 숨김 → 넘치면 뒤에서부터 패널로 이동.
|
|
for (const element of moveable) bar.insertBefore(element, more);
|
|
morePanel.replaceChildren();
|
|
more.hidden = true;
|
|
if (bar.clientWidth <= 0) return;
|
|
for (
|
|
let index = moveable.length - 1;
|
|
index >= 0 && bar.scrollWidth > bar.clientWidth + 1;
|
|
index -= 1
|
|
) {
|
|
more.hidden = false;
|
|
morePanel.insertBefore(moveable[index], morePanel.firstChild);
|
|
}
|
|
};
|
|
const overflowObserver = new ResizeObserver(() => reflow());
|
|
overflowObserver.observe(bar);
|
|
requestAnimationFrame(reflow);
|
|
|
|
// 암 경계선 제어는 그래프 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 => {
|
|
// 배수관 세트 트림 + 사면 끝 접점 트림을 합친 유효 범위.
|
|
const minOffset = Math.max(trim?.minOffset ?? -Infinity, meetLeft ?? -Infinity);
|
|
const maxOffset = Math.min(trim?.maxOffset ?? Infinity, meetRight ?? 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 암 경계선(지면선 복사 + 상하 오프셋, 점선)을 겹쳐 그린다.
|
|
* 계획선(설계선)이 아니라 **지반선(지면선)**을 복사해 이동하는 것이 규칙이다.
|
|
* 리핑암·발파암 지반에서만 호출한다. offsetM 음수 = 하향.
|
|
* 무효 샘플 구간은 지반선과 동일하게 선을 끊어 그린다.
|
|
*/
|
|
export function appendRockBoundaryOverlay(
|
|
svg: SVGElement,
|
|
groundSamples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>,
|
|
offsetM: number,
|
|
x: (offset: number) => number,
|
|
toDisplayY: (elevation: number) => number,
|
|
): void {
|
|
const segments: string[][] = [];
|
|
let current: string[] = [];
|
|
for (const sample of groundSamples) {
|
|
const elevation = sample.elevation_m;
|
|
if (sample.valid === false || elevation === null || !Number.isFinite(elevation ?? NaN)) {
|
|
if (current.length > 1) segments.push(current);
|
|
current = [];
|
|
continue;
|
|
}
|
|
current.push(`${x(sample.offset_m ?? 0)},${toDisplayY((elevation as number) + offsetM)}`);
|
|
}
|
|
if (current.length > 1) segments.push(current);
|
|
for (const points of segments) {
|
|
const polyline = document.createElementNS(SVG_NS, "polyline");
|
|
polyline.setAttribute("points", points.join(" "));
|
|
polyline.setAttribute("class", "b06-chart__rock-boundary");
|
|
svg.append(polyline);
|
|
}
|
|
}
|
|
|
|
/** 포장 측점의 노면 포장층 박스를 겹쳐 그린다 (노면 양 끝점 기준, 두께만큼 하향). */
|
|
export function appendPavementOverlay(
|
|
svg: SVGElement,
|
|
design: CrossDesign,
|
|
x: (offset: number) => number,
|
|
toDisplayY: (elevation: number) => number,
|
|
): void {
|
|
// 포장은 차도(노견 제외)만 덮는다(D-5). 구 데이터 폴백으로 road_edges를 쓴다.
|
|
const edges = design.carriageway_edges ?? design.road_edges;
|
|
if (!design.paved || !edges) return;
|
|
const thickness = design.pavement_thickness_m ?? 0.2;
|
|
const { left, right } = edges;
|
|
const points = [
|
|
`${x(left.offset_m)},${toDisplayY(left.elevation_m)}`,
|
|
`${x(right.offset_m)},${toDisplayY(right.elevation_m)}`,
|
|
`${x(right.offset_m)},${toDisplayY(right.elevation_m - thickness)}`,
|
|
`${x(left.offset_m)},${toDisplayY(left.elevation_m - thickness)}`,
|
|
];
|
|
const polygon = document.createElementNS(SVG_NS, "polygon");
|
|
polygon.setAttribute("points", points.join(" "));
|
|
polygon.setAttribute("class", "b06-chart__pavement");
|
|
svg.append(polygon);
|
|
}
|