feat(B06): 횡단 줌 조작 일원화 + 교차점 맞춤 자동 확대
- 카드 하단 ◀/▶/↺(개별 표시 반폭) 삭제. 조작구는 우측 상단 줌 버튼 하나로. - 줌 버튼 역할 확장(원배율에서만 반폭 조작): − 배율>1이면 축소, 원배율이면 표시 반폭 +1m + 배율>1이면 확대, 원배율이면 표시 반폭 −1m(하한 2m) ⤢ 배율 원복 + 절·성토 교차점이 다 보이는 폭으로 맞춤 - 교차점 맞춤(_UI_Cross_Fit): 보유 샘플(계산 반폭 20m) 안에서 교차점을 찾고, 샘플 끝까지 안 만나면 끝단 사면·지반 기울기 차로 만나는 자리를 추세 외삽해 올림 +1m을 요청 반폭으로 쓴다(상한 60m). - 요청 반폭 > 보유 샘플이면 그 폭으로 재생성(regenerate) 후 해당 측점만 세션 반폭으로 넓게 표시. 계산 기본 20m·캐시는 그대로, 확정 시 기존 경로대로 display_half_width_m에 영구 저장. - 재생성 로직을 _UI_Page_Common.createSampleWidener로 분리(700줄 제한), [전체 측점 반영]도 같은 경로 사용. 연타 중복 요청 차단. 자체검증: node tmp/tests/test_toe_extrapolate.mjs (외삽 좌/우·발산 지형·+1m 올림), tsc --noEmit 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Fit.ts
|
||||
* 횡단 카드 「교차점 맞춤」 — 절토·성토 사면이 원지반과 만나는 지점(교차점)이 모두
|
||||
* 보이는 표시 반폭(m)을 구한다.
|
||||
*
|
||||
* 보유 샘플(계산 반폭, 기본 20m) 안에서 교차점을 찾으면 그 자리 + 여유로 끝난다.
|
||||
* 샘플 끝까지 만나지 않으면 **추세로 외삽**한다 — 끝단의 계획 사면 기울기와 지반
|
||||
* 기울기 차이로 만나는 자리를 추정하고 +1m 해서 넘긴다(2026-08-23 사용자 지시).
|
||||
* 그 값이 계산 반폭을 넘으면 페이지가 백엔드 재생성으로 샘플을 넓힌다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import {
|
||||
designInterpolator,
|
||||
groundInterpolator,
|
||||
slopeToeOffset,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Solve";
|
||||
|
||||
/** 교차점이 샘플 끝에 걸렸다고 볼 여유(m) — 끝 격자에서 잡히면 실제로는 더 밖이다. */
|
||||
const EDGE_TOLERANCE_M = 0.05;
|
||||
/** 추세 기울기를 재는 구간 길이(m). */
|
||||
const TREND_SPAN_M = 2;
|
||||
/** 외삽 결과의 상한(m) — 발산하는 지형에서 폭이 무한정 커지는 것을 막는다. */
|
||||
const MAX_FIT_HALF_WIDTH_M = 60;
|
||||
|
||||
/**
|
||||
* 절·성토 교차점이 모두 들어오는 표시 반폭(m). 설계선·지반선이 없으면 null.
|
||||
*
|
||||
* 반환값은 1m 단위 올림 — 교차점이 축선에 딱 붙지 않게 한다.
|
||||
*/
|
||||
export function toeFitHalfWidth(section: CrossSection): number | null {
|
||||
const design = section.design;
|
||||
if (!design) return null;
|
||||
const groundAt = groundInterpolator(section.samples);
|
||||
const designAt = designInterpolator(design.design_line);
|
||||
const edges = design.road_edges;
|
||||
if (!groundAt || !designAt || !edges) return null;
|
||||
const offsets = section.samples.map((sample) => sample.offset_m ?? 0);
|
||||
const limits = { left: Math.max(...offsets), right: Math.min(...offsets) };
|
||||
let extent = 0;
|
||||
for (const side of ["left", "right"] as const) {
|
||||
const edge = side === "left" ? edges.left : edges.right;
|
||||
const limit = limits[side];
|
||||
const toe = slopeToeOffset(designAt, groundAt, edge.offset_m, limit);
|
||||
// 샘플 끝에서 멈췄고 아직 지반과 만나지 않았으면 추세로 더 밖을 추정한다.
|
||||
const stopped = Math.abs(toe - limit) <= EDGE_TOLERANCE_M;
|
||||
const reach = stopped ? extrapolateMeet(designAt, groundAt, limit, edge.offset_m) : toe;
|
||||
extent = Math.max(extent, Math.abs(reach));
|
||||
}
|
||||
if (!(extent > 0)) return null;
|
||||
return Math.min(Math.ceil(extent + 1), MAX_FIT_HALF_WIDTH_M);
|
||||
}
|
||||
|
||||
/**
|
||||
* 샘플 끝(limit)에서 계획 사면과 지반의 기울기 차로 만나는 자리를 외삽한다.
|
||||
* 벌어지기만 하면(차이가 줄지 않으면) 끝 자리를 그대로 돌려준다 — 무한정 넓히지 않는다.
|
||||
*/
|
||||
function extrapolateMeet(
|
||||
designAt: (offset: number) => number,
|
||||
groundAt: (offset: number) => number,
|
||||
limit: number,
|
||||
startOffset: number,
|
||||
): number {
|
||||
const outward = Math.sign(limit - startOffset) || 1;
|
||||
const back = limit - outward * TREND_SPAN_M;
|
||||
const diffAtLimit = designAt(limit) - groundAt(limit);
|
||||
const diffAtBack = designAt(back) - groundAt(back);
|
||||
// 바깥으로 갈수록 차이가 줄어드는 속도(m/m). 0 이하이면 만나지 않는다.
|
||||
const closingRate = (Math.abs(diffAtBack) - Math.abs(diffAtLimit)) / TREND_SPAN_M;
|
||||
if (!(closingRate > 1e-6)) return limit;
|
||||
const extra = Math.abs(diffAtLimit) / closingRate;
|
||||
if (!Number.isFinite(extra)) return limit;
|
||||
return limit + outward * Math.min(extra, MAX_FIT_HALF_WIDTH_M);
|
||||
}
|
||||
@@ -33,7 +33,8 @@ import type {
|
||||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel";
|
||||
import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
|
||||
import type { ZoomPanState } from "./B06_Section_UI_Cross_View_Zoom";
|
||||
import type { CrossWidthActions, ZoomPanState } from "./B06_Section_UI_Cross_View_Zoom";
|
||||
import { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
|
||||
import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert";
|
||||
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
@@ -76,8 +77,11 @@ export interface CrossCardElement extends HTMLElement {
|
||||
export interface StationWidthControl {
|
||||
/** 개별값(세션→저장값) 우선. 없으면 undefined — 카드가 전역 반폭으로 그린다. */
|
||||
widthFor: (section: CrossSection) => number | undefined;
|
||||
adjust: (chainageM: number, deltaM: number) => void;
|
||||
reset: (chainageM: number) => void;
|
||||
/**
|
||||
* 이 측점 표시 반폭을 지정한다(2026-08-23 개편). 계산 반폭(기본 20m)을 넘는 값이면
|
||||
* 페이지가 백엔드 재생성으로 샘플을 넓힌 뒤 이 카드만 그 폭으로 다시 그린다.
|
||||
*/
|
||||
request: (chainageM: number, widthM: number) => void;
|
||||
}
|
||||
|
||||
// 기슭막이·유입 구조물·다단 제어 인터페이스는 Wire에 있다(700줄 제한) — 재수출.
|
||||
@@ -106,7 +110,7 @@ export function createCrossSectionCard(
|
||||
initialAreaKey?: CrossAreaKey | null,
|
||||
/** 면적 값을 눌렀을 때. 선택되지 않은 카드에서도 눌릴 수 있어 측점 id를 함께 넘긴다. */
|
||||
onAreaSelect?: (stationId: string, key: CrossAreaKey | null) => void,
|
||||
/** 개별 표시 반폭 제어 — 있으면 카드 하단에 ◀/▶/↺ 버튼 그룹을 우측 맞춤으로 단다. */
|
||||
/** 개별 표시 반폭 제어 — 있으면 우측 상단 줌 버튼이 원배율에서 반폭을 조절한다. */
|
||||
stationWidth?: StationWidthControl,
|
||||
/** 기슭막이 X 자리 제어 — 있으면 벽이 선택 가능해지고 선택 시 ◀/▶/↺가 뜬다. */
|
||||
revetOffset?: RevetOffsetControl,
|
||||
@@ -628,7 +632,19 @@ export function createCrossSectionCard(
|
||||
});
|
||||
showRevetControl = (visible) => panel.show(visible ? activeRevet : null);
|
||||
showRevetControl(activeRevet !== null);
|
||||
chartWrap.append(svg, readout.root, buildZoomControls(zoomPan), panel.root);
|
||||
// 우측 상단 줌 버튼이 원배율에서 표시 반폭까지 다룬다(하단 ◀/▶/↺ 폐지, 2026-08-23).
|
||||
const widthActions: CrossWidthActions | undefined = stationWidth && {
|
||||
step: (deltaM) =>
|
||||
stationWidth.request(
|
||||
section.chainage_m,
|
||||
Math.round((effectiveHalfWidth ?? maxOffset) + deltaM),
|
||||
),
|
||||
fit: () => {
|
||||
const fitted = toeFitHalfWidth(section);
|
||||
if (fitted !== null) stationWidth.request(section.chainage_m, fitted);
|
||||
},
|
||||
};
|
||||
chartWrap.append(svg, readout.root, buildZoomControls(zoomPan, widthActions), panel.root);
|
||||
if (activeArea) {
|
||||
setBandActive(activeArea);
|
||||
setChipActive(activeArea);
|
||||
@@ -646,30 +662,6 @@ export function createCrossSectionCard(
|
||||
rockControl.classList.add("b06-cross-card__rockb");
|
||||
footer.append(rockControl);
|
||||
}
|
||||
// 개별 표시 반폭 ◀/▶/↺ — 우측 맞춤, 초기화는 전역 반폭 복귀(2026-08-06).
|
||||
if (stationWidth) {
|
||||
const widthControl = document.createElement("div");
|
||||
widthControl.className = "b06-cross-card__widthctl";
|
||||
const makeButton = (label: string, title: string, onClick: () => void): HTMLButtonElement => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b06-design__rockb-btn";
|
||||
button.textContent = label;
|
||||
button.title = title;
|
||||
button.addEventListener("click", (event) => {
|
||||
// 카드 선택 클릭으로 번지면 재렌더로 줌·팬이 초기화된다.
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
});
|
||||
return button;
|
||||
};
|
||||
widthControl.append(
|
||||
makeButton("◀", L("B06_Cross_Width_Dec"), () => stationWidth.adjust(section.chainage_m, -1)),
|
||||
makeButton("▶", L("B06_Cross_Width_Inc"), () => stationWidth.adjust(section.chainage_m, 1)),
|
||||
makeButton("↺", L("B06_Cross_Width_Reset"), () => stationWidth.reset(section.chainage_m)),
|
||||
);
|
||||
footer.append(widthControl);
|
||||
}
|
||||
card.append(footer);
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,19 @@ export interface ZoomPanHandle {
|
||||
/** 1보다 크면 확대, 작으면 축소. 플롯 영역의 중앙을 붙잡는다. */
|
||||
zoom: (factor: number) => void;
|
||||
reset: () => void;
|
||||
/** 현재 배율 — 원배율(1)일 때는 버튼이 배율 대신 **표시 반폭**을 조절한다. */
|
||||
scale: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 표시 반폭 조작(2026-08-23 사용자 지시). 카드 하단 ◀/▶/↺을 없애고 우측 상단
|
||||
* 줌 버튼에 합쳤다 — 원배율에서 −는 폭 넓히기, +는 폭 좁히기, ⤢는 교차점 맞춤이다.
|
||||
*/
|
||||
export interface CrossWidthActions {
|
||||
/** 표시 반폭을 deltaM(m)만큼 조절한다. 계산 반폭을 넘으면 페이지가 재생성까지 처리. */
|
||||
step: (deltaM: number) => void;
|
||||
/** 절·성토 교차점이 모두 보이는 폭으로 맞춘다. */
|
||||
fit: () => void;
|
||||
}
|
||||
|
||||
/** 줌·팬 상태 — 카드가 다시 그려질 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */
|
||||
@@ -123,6 +136,7 @@ export function attachZoomPan(
|
||||
svg.addEventListener("click", (event) => {
|
||||
if (moved) event.stopPropagation();
|
||||
});
|
||||
const currentScale = (): number => scale;
|
||||
const reset = (): void => {
|
||||
scale = 1;
|
||||
tx = 0;
|
||||
@@ -134,11 +148,18 @@ export function attachZoomPan(
|
||||
event.stopPropagation();
|
||||
reset();
|
||||
});
|
||||
return { zoom, reset };
|
||||
return { zoom, reset, scale: currentScale };
|
||||
}
|
||||
|
||||
/** 그래프 우측 상단 줌 버튼(확대/축소/핏). 휠을 대신하는 조작구다. */
|
||||
export function buildZoomControls(handle: ZoomPanHandle): HTMLElement {
|
||||
/**
|
||||
* 그래프 우측 상단 줌 버튼(확대/축소/핏). 휠을 대신하는 조작구다.
|
||||
*
|
||||
* `width`를 넘기면 **원배율에서만** 버튼이 표시 반폭을 조절한다(2026-08-23 사용자 지시):
|
||||
* − : 배율>1이면 축소, 원배율이면 반폭 +1m(계산 반폭 20m를 넘으면 백엔드 재계산)
|
||||
* + : 배율>1이면 확대, 원배율이면 반폭 −1m
|
||||
* ⤢ : 배율 원복 + 절·성토 교차점이 다 보이는 폭으로 맞춤
|
||||
*/
|
||||
export function buildZoomControls(handle: ZoomPanHandle, width?: CrossWidthActions): HTMLElement {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "b06-cross-card__zoom";
|
||||
const add = (label: string, title: string, action: () => void): void => {
|
||||
@@ -154,8 +175,17 @@ export function buildZoomControls(handle: ZoomPanHandle): HTMLElement {
|
||||
});
|
||||
bar.append(button);
|
||||
};
|
||||
add("+", L("B06_Profile_View_ZoomIn"), () => handle.zoom(1 / 0.85));
|
||||
add("−", L("B06_Profile_View_ZoomOut"), () => handle.zoom(0.85));
|
||||
add("⤢", L("B06_Profile_View_ZoomFit"), () => handle.reset());
|
||||
add("+", L("B06_Profile_View_ZoomIn"), () => {
|
||||
if (!width || handle.scale() > 1 + 1e-6) handle.zoom(1 / 0.85);
|
||||
else width.step(-1);
|
||||
});
|
||||
add("−", L("B06_Profile_View_ZoomOut"), () => {
|
||||
if (!width || handle.scale() > 1 + 1e-6) handle.zoom(0.85);
|
||||
else width.step(1);
|
||||
});
|
||||
add("⤢", L("B06_Profile_View_ZoomFit"), () => {
|
||||
handle.reset();
|
||||
width?.fit();
|
||||
});
|
||||
return bar;
|
||||
}
|
||||
|
||||
@@ -23,10 +23,8 @@ import {
|
||||
saveSections,
|
||||
type CrossSectionPatch,
|
||||
fetchSectionContext,
|
||||
fetchSectionDetail,
|
||||
getSections,
|
||||
previewCrossDesigns,
|
||||
regenerateSections,
|
||||
type SectionContextResponse,
|
||||
type SectionDetailResponse,
|
||||
type StandardCrossSection,
|
||||
@@ -47,8 +45,8 @@ import "./B06_Section_UI_Style.css";
|
||||
import "./B06_Section_UI_Style_Cross.css";
|
||||
import "./B06_Section_UI_Style_Cross_Controls.css";
|
||||
import "./B06_Section_UI_Style_Cross_Areas.css";
|
||||
import { loadSectionDetail, replaceSectionDetail } from "./B06_Section_Section_Store";
|
||||
import { buildGroup, L } from "./B06_Section_UI_Page_Common";
|
||||
import { loadSectionDetail } from "./B06_Section_Section_Store";
|
||||
import { buildGroup, createSampleWidener, L } from "./B06_Section_UI_Page_Common";
|
||||
import "@util/common_util_mass_haul.css";
|
||||
|
||||
export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
@@ -257,35 +255,23 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const ensureSampledWidth = createSampleWidener({
|
||||
target: () =>
|
||||
projectId && currentRouteId !== null ? { projectId, routeId: currentRouteId } : null,
|
||||
sampledHalfWidth,
|
||||
applyDetail: (fresh) => {
|
||||
sectionDetail = fresh;
|
||||
renderSectionDetail();
|
||||
},
|
||||
});
|
||||
|
||||
/** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다.
|
||||
* handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만
|
||||
* 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다.
|
||||
*
|
||||
* 횡단 반폭 적용도 여기서 한다(2026-08-06 사용자 지시 — 재계산 버튼 폐지):
|
||||
* 요청 반폭 ≤ 보유 샘플 폭 → 표시 범위만 바뀌므로 재렌더로 끝(재계산 없음).
|
||||
* 요청 반폭 > 보유 샘플 폭 → B05부터 재생성해 영구 저장 후 상세를 다시 로드한다
|
||||
* (기본 설계는 상세 조회가 자동으로 얹는다. 느려도 허용 — 2026-08-06 사용자 확정). */
|
||||
* 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */
|
||||
async function applyPanelToAll(): Promise<void> {
|
||||
if (!sectionDetail || !projectId || currentRouteId === null) return;
|
||||
const requested = crossHalfWidth();
|
||||
if (requested !== undefined && requested > sampledHalfWidth() + 1e-6) {
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await regenerateSections(projectId, currentRouteId, requested);
|
||||
// 재생성 응답에는 설계가 없다 — 상세를 다시 받아 기본 설계 프리뷰까지 얹는다.
|
||||
const fresh = await fetchSectionDetail(projectId, currentRouteId);
|
||||
sectionDetail = fresh;
|
||||
// 공유 캐시도 새 상세로 바꿔 B05가 옛 값을 못 보게 한다.
|
||||
replaceSectionDetail(projectId, currentRouteId, fresh);
|
||||
showToast(L("B06_Profile_Regenerate_Success"), "success");
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`${L("B06_Profile_Regenerate_Failed")}${detail}`, "error");
|
||||
hideLoadingOverlay();
|
||||
return;
|
||||
}
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
if (requested !== undefined && !(await ensureSampledWidth(requested))) return;
|
||||
// 전체 반영 = 개별 반폭도 전역값으로 초기화(2026-08-06). 저장된 개별값
|
||||
// (design.display_half_width_m)이 남아 있으면 전역 반폭이 무시되므로, 세션에
|
||||
// 전역값을 명시해 저장값보다 우선하게 한다(확정 시 저장값도 전역으로 덮인다).
|
||||
@@ -392,6 +378,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
detail: () => sectionDetail,
|
||||
crossHalfWidth,
|
||||
sampledHalfWidth,
|
||||
ensureSampledWidth,
|
||||
});
|
||||
const stationWidthControl = stationControls.stationWidth;
|
||||
const revetOffsetControl = stationControls.revetOffset;
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchSectionDetail,
|
||||
regenerateSections,
|
||||
type SectionDetailResponse,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import { replaceSectionDetail } from "./B06_Section_Section_Store";
|
||||
|
||||
export function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -13,3 +20,48 @@ export function buildGroup(legend: string, collapsed = false): HTMLElement {
|
||||
group.append(title);
|
||||
return group;
|
||||
}
|
||||
|
||||
/** 반폭 확대(백엔드 재생성)에 필요한 페이지 상태 — 클로저 대신 함수로 받아 결합을 끊는다. */
|
||||
export interface SampleWidenerDeps {
|
||||
/** 현재 대상 경로. 없으면 확대 불가. */
|
||||
target: () => { projectId: string; routeId: number } | null;
|
||||
/** 보유 샘플의 최대 반폭(m). */
|
||||
sampledHalfWidth: () => number;
|
||||
/** 재생성으로 받은 새 상세를 페이지 상태에 반영하고 다시 그린다. */
|
||||
applyDetail: (detail: SectionDetailResponse) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 반폭까지 지반 샘플을 넓히는 함수를 만든다 — 보유 샘플로 충분하면 즉시 true
|
||||
* (재계산 없음). 모자라면 그 폭으로 B05부터 재생성(영구 저장)하고 상세를 다시 받는다.
|
||||
* 느려도 허용(2026-08-06 사용자 확정). 전역 [전체 측점 반영]과 카드 줌 버튼의 반폭
|
||||
* 확대가 같은 경로를 쓴다(2026-08-23 사용자 지시).
|
||||
*/
|
||||
export function createSampleWidener(deps: SampleWidenerDeps): (widthM: number) => Promise<boolean> {
|
||||
let running = false;
|
||||
return async (widthM) => {
|
||||
const target = deps.target();
|
||||
if (!target) return false;
|
||||
if (widthM <= deps.sampledHalfWidth() + 1e-6) return true;
|
||||
if (running) return false; // 재생성 중 중복 요청은 무시(버튼 연타).
|
||||
running = true;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await regenerateSections(target.projectId, target.routeId, widthM);
|
||||
// 재생성 응답에는 설계가 없다 — 상세를 다시 받아 기본 설계 프리뷰까지 얹는다.
|
||||
const fresh = await fetchSectionDetail(target.projectId, target.routeId);
|
||||
// 공유 캐시도 새 상세로 바꿔 B05가 옛 값을 못 보게 한다.
|
||||
replaceSectionDetail(target.projectId, target.routeId, fresh);
|
||||
deps.applyDetail(fresh);
|
||||
showToast(L("B06_Profile_Regenerate_Success"), "success");
|
||||
return true;
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`${L("B06_Profile_Regenerate_Failed")}${detail}`, "error");
|
||||
return false;
|
||||
} finally {
|
||||
running = false;
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@ export interface StationControlDeps {
|
||||
detail: () => SectionDetailResponse | null;
|
||||
crossHalfWidth: () => number | undefined;
|
||||
sampledHalfWidth: () => number;
|
||||
/**
|
||||
* 요청 반폭까지 지반 샘플을 넓힌다(백엔드 재생성). 이미 충분하면 즉시 true,
|
||||
* 재생성에 실패하면 false. 페이지가 구현한다(2026-08-23 사용자 지시).
|
||||
*/
|
||||
ensureSampledWidth: (widthM: number) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** 반폭·기슭막이·유입 구조물 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */
|
||||
@@ -76,9 +81,11 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
}
|
||||
}
|
||||
|
||||
/** 개별 반폭 하한 2m·상한은 보유 샘플 폭 — 표시용이라 샘플 밖은 의미가 없다. */
|
||||
const clampStationWidth = (value: number): number =>
|
||||
Math.min(Math.max(value, 2), Math.max(deps.sampledHalfWidth(), 2));
|
||||
/**
|
||||
* 개별 반폭 하한 2m. **상한은 두지 않는다**(2026-08-23 개편) — 계산 반폭(20m)을
|
||||
* 넘는 값은 `deps.ensureSampledWidth`가 백엔드 재생성으로 샘플을 넓힌 뒤 적용된다.
|
||||
*/
|
||||
const clampStationWidth = (value: number): number => Math.max(Math.round(value), 2);
|
||||
|
||||
const stationWidthControl: StationWidthControl = {
|
||||
widthFor: (section) => {
|
||||
@@ -87,28 +94,22 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
const stored = section.design?.display_half_width_m;
|
||||
return typeof stored === "number" && stored > 0 ? stored : undefined;
|
||||
},
|
||||
adjust: (chainageM, deltaM) => {
|
||||
const key = widthKey(chainageM);
|
||||
const section = deps
|
||||
.detail()
|
||||
?.cross_sections.find((entry) => Math.abs(entry.chainage_m - chainageM) < 0.01);
|
||||
const stored = section?.design?.display_half_width_m;
|
||||
const current =
|
||||
stationWidths.get(key) ??
|
||||
(typeof stored === "number" && stored > 0 ? stored : undefined) ??
|
||||
deps.crossHalfWidth() ??
|
||||
deps.sampledHalfWidth();
|
||||
stationWidths.set(key, clampStationWidth(Math.round(current + deltaM)));
|
||||
persistStationWidths();
|
||||
deps.refreshCard(chainageM);
|
||||
},
|
||||
reset: (chainageM) => {
|
||||
// 초기화 = 전역 반폭 복귀. 저장값(design)도 무시해야 하므로 세션에 전역값을 명시한다.
|
||||
const globalWidth = deps.crossHalfWidth();
|
||||
if (globalWidth === undefined) stationWidths.delete(widthKey(chainageM));
|
||||
else stationWidths.set(widthKey(chainageM), clampStationWidth(globalWidth));
|
||||
persistStationWidths();
|
||||
deps.refreshCard(chainageM);
|
||||
request: (chainageM, widthM) => {
|
||||
const width = clampStationWidth(widthM);
|
||||
const apply = (): void => {
|
||||
stationWidths.set(widthKey(chainageM), width);
|
||||
persistStationWidths();
|
||||
deps.refreshCard(chainageM);
|
||||
};
|
||||
// 보유 샘플 안이면 표시만 바꾸면 된다 — 캐시(계산 20m)를 그대로 쓴다.
|
||||
if (width <= deps.sampledHalfWidth() + 1e-6) {
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
// 샘플 밖 = 지반이 없는 폭. 백엔드에 그 폭으로 재계산을 맡기고, 끝나면 적용한다.
|
||||
void deps.ensureSampledWidth(width).then((ready) => {
|
||||
if (ready) apply();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -554,11 +554,3 @@
|
||||
border-radius: var(--radius-inputs);
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
/* 개별 표시 반폭 ◀/▶/↺ — 암 경계 그룹 우측, 행 끝에 우측 맞춤(2026-08-06 사용자 지시). */
|
||||
.b06-cross-card__widthctl {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user