Files
Aislo/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Design.ts
T
2026-07-25 13:56:46 +09:00

469 lines
19 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"],
];
/** 자동 판정된 단면 유형의 지역화 라벨(제목행 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 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용). 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 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;
}
/**
* 카드 설계 지정 컨트롤을 만든다. 지반유형 세그먼트는 제목행 배치용으로 분리 반환하고(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 };
}
/** 절·성토 면적값 오버레이(E-4) — 그래프 중상단에 배경색과 함께 표시한다. */
export function buildAreaReadout(design: CrossDesign | undefined): HTMLElement {
const readout = document.createElement("div");
readout.className = "b06-cross-card__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);
}
return readout;
}
/** 횡단 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 {
// 포장은 차도(노견 제외)만 덮는다(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);
}