379 lines
14 KiB
TypeScript
379 lines
14 KiB
TypeScript
/* =============================================================================
|
|
* B06_wf3_ProfileCross_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,
|
|
} from "./B06_wf3_ProfileCross_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"],
|
|
];
|
|
const DITCH_OPTIONS: Array<[DitchSide, keyof typeof ui_locales]> = [
|
|
["left", "B06_Design_Ditch_Left"],
|
|
["right", "B06_Design_Ditch_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;
|
|
}
|
|
|
|
/**
|
|
* 암 경계선 세션 제어기. 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,
|
|
): HTMLElement {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "b06-design__seg";
|
|
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");
|
|
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,
|
|
): { wrap: HTMLElement; button: HTMLButtonElement } {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "b06-design__seg";
|
|
const legendEl = document.createElement("span");
|
|
legendEl.className = "b06-design__seg-legend";
|
|
legendEl.textContent = legend;
|
|
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.addEventListener("click", onToggle);
|
|
buttons.append(button);
|
|
wrap.append(legendEl, buttons);
|
|
return { wrap, button };
|
|
}
|
|
|
|
/** 암 경계선 상/하/리셋 컨트롤(B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용). */
|
|
function rockBoundaryRow(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 makeButton = (
|
|
label: string,
|
|
title: string,
|
|
className: string,
|
|
onClick: () => void,
|
|
): HTMLButtonElement => {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = `b06-design__rockb-btn ${className}`;
|
|
button.textContent = label;
|
|
button.title = title;
|
|
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),
|
|
),
|
|
makeButton("▼", `${L("B06_Design_RockBoundary_Down")} (-${control.stepM}m)`, "is-down", () =>
|
|
control.adjust(section.chainage_m, -control.stepM),
|
|
),
|
|
makeButton("↺", L("B06_Design_RockBoundary_Reset"), "is-reset", () =>
|
|
control.reset(section.chainage_m),
|
|
),
|
|
readout,
|
|
);
|
|
wrap.append(group);
|
|
return wrap;
|
|
}
|
|
|
|
/** 카드 헤더용 설계 지정 컨트롤 바를 만든다. */
|
|
export function buildDesignControls(
|
|
section: CrossSection,
|
|
onChange: (chainageM: number, change: CrossDesignChange) => void,
|
|
rockBoundary?: RockBoundaryControl,
|
|
): HTMLElement {
|
|
const design = section.design;
|
|
// 기본값: 토사(soil) + 상단측 절토(uphill_side, 미상이면 좌절토) + 일반측구 + 비포장.
|
|
const state: {
|
|
ground: GroundType;
|
|
mode: SectionMode;
|
|
ditch: DitchSide | null;
|
|
ditchType: DitchType;
|
|
paved: boolean;
|
|
twoStage: boolean;
|
|
} = {
|
|
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,
|
|
twoStage: design?.two_stage_slope ?? true,
|
|
};
|
|
const bar = document.createElement("div");
|
|
bar.className = "b06-design";
|
|
// 컨트롤 상호작용이 카드 선택 클릭으로 전파되지 않게 한다.
|
|
bar.addEventListener("click", (event) => event.stopPropagation());
|
|
|
|
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,
|
|
});
|
|
};
|
|
|
|
bar.append(
|
|
segment(L("B06_Design_Ground_Legend"), GROUND_OPTIONS, state.ground, (value) => {
|
|
state.ground = value;
|
|
emit();
|
|
}),
|
|
segment(L("B06_Design_Mode_Legend"), MODE_OPTIONS, state.mode, (value) => {
|
|
state.mode = value;
|
|
emit();
|
|
}),
|
|
);
|
|
if (needsDitch()) {
|
|
bar.append(
|
|
segment(L("B06_Design_Ditch_Legend"), DITCH_OPTIONS, state.ditch, (value) => {
|
|
state.ditch = value;
|
|
emit();
|
|
}),
|
|
);
|
|
}
|
|
// 측구형식(일반/L형): 암 지반 + 측구가 존재하는 단면(양성 제외)에서만 노출.
|
|
if (isRock(state.ground) && state.mode !== "both_fill") {
|
|
bar.append(
|
|
segment(L("B06_Design_DitchType_Legend"), DITCH_TYPE_OPTIONS, state.ditchType, (value) => {
|
|
state.ditchType = value;
|
|
emit();
|
|
}),
|
|
);
|
|
// 2단계 경사 토글: 암 지반 + 절토가 있는 단면(양성 제외)에서만. 기본 활성, 해제 시 단일 암 경사.
|
|
const twoStage = toggle(
|
|
L("B06_Design_TwoStage_Legend"),
|
|
state.twoStage,
|
|
L("B06_Design_TwoStage_On"),
|
|
L("B06_Design_TwoStage_Off"),
|
|
() => {
|
|
state.twoStage = !state.twoStage;
|
|
emit();
|
|
},
|
|
);
|
|
bar.append(twoStage.wrap);
|
|
}
|
|
// 포장 토글: 지반유형과 중첩 적용(횡단경사·포장층만 변경).
|
|
const paved = toggle(
|
|
L("B06_Design_Paved_Legend"),
|
|
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);
|
|
}
|
|
bar.append(paved.wrap);
|
|
|
|
// 암 경계선 제어: 암 지반에서만 노출(서버 재계산 없이 세션 보관, 확정 시 DB 병합).
|
|
if (rockBoundary && isRock(state.ground)) {
|
|
bar.append(rockBoundaryRow(section, rockBoundary));
|
|
}
|
|
|
|
const readout = document.createElement("div");
|
|
readout.className = "b06-design__areas";
|
|
if (design) {
|
|
const cut = document.createElement("span");
|
|
cut.className = "b06-design__area b06-design__area--cut";
|
|
cut.textContent = `${L("B06_Design_Cut_Area")} ${design.cut_area_m2.toFixed(2)}㎡`;
|
|
const fill = document.createElement("span");
|
|
fill.className = "b06-design__area b06-design__area--fill";
|
|
fill.textContent = `${L("B06_Design_Fill_Area")} ${design.fill_area_m2.toFixed(2)}㎡`;
|
|
readout.append(cut, fill);
|
|
} else {
|
|
const unset = document.createElement("span");
|
|
unset.className = "b06-design__area b06-design__area--unset";
|
|
unset.textContent = L("B06_Design_Unset");
|
|
readout.append(unset);
|
|
}
|
|
bar.append(readout);
|
|
return bar;
|
|
}
|
|
|
|
/** 횡단 SVG에 표준단면 설계선을 겹쳐 그린다. */
|
|
export function appendCrossDesignOverlay(
|
|
svg: SVGSVGElement,
|
|
design: CrossDesign,
|
|
x: (offset: number) => number,
|
|
toDisplayY: (elevation: number) => number,
|
|
): void {
|
|
const line = design.design_line;
|
|
if (!line || line.length < 2) return;
|
|
const points = line.map((point) => `${x(point.offset_m)},${toDisplayY(point.elevation_m)}`);
|
|
const polyline = document.createElementNS(SVG_NS, "polyline");
|
|
polyline.setAttribute("points", points.join(" "));
|
|
polyline.setAttribute("class", "b06-chart__design-cross");
|
|
svg.append(polyline);
|
|
}
|
|
|
|
/**
|
|
* 암 경계선(지면선 복사 + 상하 오프셋, 점선)을 겹쳐 그린다.
|
|
* 계획선(설계선)이 아니라 **지반선(지면선)**을 복사해 이동하는 것이 규칙이다.
|
|
* 리핑암·발파암 지반에서만 호출한다. offsetM 음수 = 하향.
|
|
* 무효 샘플 구간은 지반선과 동일하게 선을 끊어 그린다.
|
|
*/
|
|
export function appendRockBoundaryOverlay(
|
|
svg: SVGSVGElement,
|
|
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: SVGSVGElement,
|
|
design: CrossDesign,
|
|
x: (offset: number) => number,
|
|
toDisplayY: (elevation: number) => number,
|
|
): void {
|
|
if (!design.paved || !design.road_edges) return;
|
|
const thickness = design.pavement_thickness_m ?? 0.2;
|
|
const { left, right } = design.road_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);
|
|
}
|