좌측 폼의 세월교 상세 항목(관종·관경·수량·월류 폭·날개벽 유입/유출)이 도면에 전혀 반영되지 않았다. `applyPipeOptionsToCache`가 `section.culvert`만 보고 세월교 측점(`section.ford`)에서는 즉시 return했기 때문이다. 프론트는 구조물 배치의 상세 UI를 쓰고 로직은 조정창 제어기 것을 쓴다 (2026-08-30 사용자 확정). - `applyFordFormOptions()` 신설 — 폼 옵션 키를 `FordControl.setPipe`/`setWing` 인자로 옮긴다. 캐시 반영·바닥판 연장 재계산·정본 예약·카드 갱신은 제어기에 이미 있어 새로 만들지 않았다. - `FordControl.setPipe` 확장 — `pipe_kind`(관종)·`ford_width_m`(월류 폭 = `span_m`) 추가. 두 값은 조정창에 칸이 없고 폼만 보낸다. - `withFordSpec()` — 폼 기본값을 `section.ford` 스펙에서 읽는다. 두 창이 같은 스펙을 보므로 어느 쪽에서 만졌든 값이 갈리지 않는다(수량(련) 빈 칸 해소). 월류 높이(`ford_height_m`)는 백엔드 `_ford_set`이 읽지 않는다 — 세월교는 구체 위 노면이라 파임이 없고, 물넘이포장 전용 값이다. 폼에는 남되 도면 반영처는 없다. 자체검증(공용 브라우저 5174, 측점 7+9.7 세월교): - tsc --noEmit 통과. - 폼 날개 길이(유입) 2→4 → 조정창 「날개 길이 4.0m」, 바닥판 7.63m→9.04m (= +2×cos45°). 수정 전에는 둘 다 그대로였다. - 폼 수량 3 → 조정창 「3련」, 폼 관경 Ø1200 → 조정창 select 1200. - 재선택 왕복: 월류폭 12·수량 3·관경 1200·날개길이 4 그대로 되읽힘(스펙 정본). - 진입 시 수량(련) 칸 = 1(수정 전 빈 칸), 관종 파형강관. - 조작값은 원래 값(10/1/1000/2)으로 되돌려 놓았다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
317 lines
13 KiB
TypeScript
317 lines
13 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_UI_Page_Ford_Controls.ts
|
|
* 세월교 측벽·BOX암거 구체 조작값 제어 — 측점 제어기
|
|
* (`_UI_Page_Station_Controls.ts`)에서 700줄 제한으로 분리했다(2026-08-25).
|
|
*
|
|
* 데이터 흐름은 집수정과 같다: 세션 사본 + 캐시 `design.ford_adjust`에 함께 실어
|
|
* 3D(코리도)가 같은 값으로 다시 그리게 한다. 관경·수량은 B05 정본(`pipe_points`)이
|
|
* 원천이라 배수관 구간값과 같은 저장기로 되돌려 쓴다.
|
|
* ========================================================================== */
|
|
|
|
import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch";
|
|
import { DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford";
|
|
import type { FordAdjust, FordWallAdjust, FordWallRole } from "./B06_Section_UI_Cross_Ford";
|
|
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
|
|
import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box";
|
|
import type { BoxAdjust, BoxSideAdjust, BoxSideRole } from "./B06_Section_UI_Cross_Box";
|
|
import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel";
|
|
|
|
export interface FordControlDeps {
|
|
/** 세션 보관 키(프로젝트·노선별). 없으면 세션에 담지 않는다. */
|
|
sessionKey: () => string | null;
|
|
sectionAt: (chainageM: number) => CrossSection | undefined;
|
|
patchCachedDesign: (chainageM: number, patch: Partial<CrossDesign>) => void;
|
|
refreshCard: (chainageM: number) => void;
|
|
/** 관경·수량·날개벽 제원을 B05 정본으로 되돌려 쓴다(묶어서 늦게 저장). */
|
|
queuePipeOptions: (chainageM: number, patch: Record<string, number | string>) => void;
|
|
round1: (value: number) => number;
|
|
clampMove: (value: number) => number;
|
|
}
|
|
|
|
export interface FordControls {
|
|
control: FordControl;
|
|
/** 확정 payload용 — 측점별 조작값. */
|
|
byChainage: () => Map<number, FordAdjust>;
|
|
/** 노선이 바뀔 때 세션 값을 다시 읽는다. */
|
|
load: () => void;
|
|
}
|
|
|
|
export function createFordControls(deps: FordControlDeps): FordControls {
|
|
const { round1, clampMove, sectionAt, patchCachedDesign } = deps;
|
|
/* ── 세월교 측벽 조작(2026-08-25 사용자) ───────────────────────────────
|
|
* 축은 집수정과 같다(높이·좌우·상하 대각). 값은 세션 사본 + 캐시 design에 함께
|
|
* 실어 3D가 같은 값으로 다시 그리게 한다. 관경·수량은 B05 정본이 원천이라
|
|
* 배수관 구간값과 같은 저장기(`pipe_points` 되쓰기)로 보낸다. */
|
|
const fordAdjustments = new Map<string, FordAdjust>();
|
|
const fordSelections = new Map<string, FordWallRole | null>();
|
|
const fordSessionKey = (): string | null => deps.sessionKey();
|
|
|
|
function loadFordAdjustments(): void {
|
|
fordAdjustments.clear();
|
|
const key = fordSessionKey();
|
|
if (!key) return;
|
|
try {
|
|
const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record<
|
|
string,
|
|
FordAdjust
|
|
>;
|
|
Object.entries(parsed).forEach(([chainage, value]) =>
|
|
fordAdjustments.set(chainage, {
|
|
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...value.inlet },
|
|
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...value.outlet },
|
|
}),
|
|
);
|
|
} catch {
|
|
/* 손상된 세션 값은 기본값으로 대체. */
|
|
}
|
|
}
|
|
|
|
function persistFordAdjustments(): void {
|
|
const key = fordSessionKey();
|
|
if (key)
|
|
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(fordAdjustments)));
|
|
}
|
|
|
|
const fordAdjustAt = (chainageM: number): FordAdjust => {
|
|
const stored = sectionAt(chainageM)?.design?.ford_adjust;
|
|
return (
|
|
fordAdjustments.get(chainageM.toFixed(2)) ?? {
|
|
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.inlet ?? {}) },
|
|
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.outlet ?? {}) },
|
|
}
|
|
);
|
|
};
|
|
|
|
const writeFord = (chainageM: number, next: FordAdjust): void => {
|
|
fordAdjustments.set(chainageM.toFixed(2), next);
|
|
persistFordAdjustments();
|
|
patchCachedDesign(chainageM, { ford_adjust: next });
|
|
deps.refreshCard(chainageM);
|
|
};
|
|
|
|
const fordControl: FordControl = {
|
|
adjustFor: fordAdjustAt,
|
|
update: (chainageM, role, patch) => {
|
|
const current = fordAdjustAt(chainageM);
|
|
const wall: FordWallAdjust = { ...current[role], ...patch };
|
|
writeFord(chainageM, {
|
|
...current,
|
|
[role]: {
|
|
// 높이는 0.1m 눈금, 하한(관경+토피)은 기하가 다시 잡는다.
|
|
heightM: wall.heightM === null ? null : Math.max(round1(wall.heightM), 0),
|
|
lateralM: Math.max(0, clampMove(wall.lateralM)),
|
|
slopeM: clampMove(wall.slopeM),
|
|
},
|
|
});
|
|
},
|
|
reset: (chainageM, role) => {
|
|
const current = fordAdjustAt(chainageM);
|
|
writeFord(chainageM, { ...current, [role]: { ...DEFAULT_FORD_WALL_ADJUST } });
|
|
},
|
|
setPipe: (chainageM, patch) => {
|
|
const spec = sectionAt(chainageM)?.ford;
|
|
if (spec) {
|
|
// 캐시를 먼저 고쳐 즉시 반영한다 — 저장은 늦게 묶어서 간다.
|
|
if (patch.pipe_diameter_mm) spec.diameter_m = patch.pipe_diameter_mm / 1000;
|
|
if (patch.pipe_count) spec.pipe_count = patch.pipe_count;
|
|
if (patch.pipe_kind) spec.pipe_kind = patch.pipe_kind;
|
|
// 월류 폭 = 구체의 도로 진행 방향 길이(`span_m`) — 백엔드 `_ford_set`과 같은 자리.
|
|
if (patch.ford_width_m) spec.span_m = patch.ford_width_m;
|
|
}
|
|
deps.queuePipeOptions(chainageM, patch);
|
|
deps.refreshCard(chainageM);
|
|
},
|
|
setWing: (chainageM, role, patch) => {
|
|
const spec = sectionAt(chainageM)?.ford;
|
|
const wing = role === "inlet" ? spec?.wing_in : spec?.wing_out;
|
|
if (wing) {
|
|
// 캐시 먼저 — 바닥판 연장(길이 × cos각)도 백엔드 산식 그대로 다시 계산한다.
|
|
if (patch.installed !== undefined) wing.installed = patch.installed;
|
|
if (patch.height_m !== undefined) wing.height_m = patch.height_m;
|
|
if (patch.length_m !== undefined) wing.length_m = patch.length_m;
|
|
if (patch.angle_deg !== undefined) wing.angle_deg = patch.angle_deg;
|
|
wing.slab_extend_m = wing.installed
|
|
? Math.max((wing.length_m ?? 0) * Math.cos(((wing.angle_deg ?? 45) * Math.PI) / 180), 0)
|
|
: 0;
|
|
}
|
|
const prefix = role === "inlet" ? "wing_in" : "wing_out";
|
|
const options: Record<string, number | string> = {};
|
|
if (patch.installed !== undefined) options[prefix] = patch.installed ? "있음" : "없음";
|
|
if (patch.height_m !== undefined) options[`${prefix}_height_m`] = patch.height_m;
|
|
if (patch.length_m !== undefined) options[`${prefix}_length_m`] = patch.length_m;
|
|
if (patch.angle_deg !== undefined) options[`${prefix}_angle_deg`] = patch.angle_deg;
|
|
deps.queuePipeOptions(chainageM, options);
|
|
deps.refreshCard(chainageM);
|
|
},
|
|
selectedFor: (chainageM) => fordSelections.get(chainageM.toFixed(2)) ?? null,
|
|
select: (chainageM, role) => {
|
|
fordSelections.set(chainageM.toFixed(2), role);
|
|
},
|
|
};
|
|
|
|
return {
|
|
control: fordControl,
|
|
byChainage: () => {
|
|
const result = new Map<number, FordAdjust>();
|
|
fordAdjustments.forEach((adjust, key) => {
|
|
const value = Number(key);
|
|
if (Number.isFinite(value)) result.set(value, adjust);
|
|
});
|
|
return result;
|
|
},
|
|
load: loadFordAdjustments,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 좌측 「구조물 배치」 폼이 낸 세월교 옵션을 **조정창 제어기**로 흘려보낸다
|
|
* (2026-08-30 사용자 확정: 프론트는 구조물 배치의 상세 UI, 로직은 조정창 것을 쓴다).
|
|
*
|
|
* 폼 옵션 키(`pipe_*`·`ford_width_m`·`wing_in*`·`wing_out*`)를 제어기 인자로 옮기기만
|
|
* 한다 — 캐시 반영·바닥판 연장 재계산·정본 예약·카드 갱신은 제어기 안에 이미 있다.
|
|
* 값이 온 항목만 보낸다(빈 patch는 부르지 않는다).
|
|
*/
|
|
export function applyFordFormOptions(
|
|
control: FordControl,
|
|
chainageM: number,
|
|
patch: Record<string, number | string>,
|
|
): void {
|
|
const num = (key: string): number | undefined => {
|
|
if (patch[key] === undefined) return undefined;
|
|
const value = Number(patch[key]);
|
|
return Number.isFinite(value) ? value : undefined;
|
|
};
|
|
const pipe: Parameters<FordControl["setPipe"]>[1] = {};
|
|
const diameterMm = num("pipe_diameter_mm");
|
|
if (diameterMm !== undefined) pipe.pipe_diameter_mm = diameterMm;
|
|
const count = num("pipe_count");
|
|
if (count !== undefined) pipe.pipe_count = count;
|
|
if (typeof patch.pipe_kind === "string") pipe.pipe_kind = patch.pipe_kind;
|
|
const widthM = num("ford_width_m");
|
|
if (widthM !== undefined) pipe.ford_width_m = widthM;
|
|
if (Object.keys(pipe).length) control.setPipe(chainageM, pipe);
|
|
|
|
for (const [role, prefix] of [
|
|
["inlet", "wing_in"],
|
|
["outlet", "wing_out"],
|
|
] as const) {
|
|
const wing: Parameters<FordControl["setWing"]>[2] = {};
|
|
// 설치 값은 폼이 "있음"/"없음" 문자열로 낸다(백엔드 `_wing_spec`과 같은 규약).
|
|
if (patch[prefix] !== undefined) wing.installed = patch[prefix] !== "없음";
|
|
const heightM = num(`${prefix}_height_m`);
|
|
if (heightM !== undefined) wing.height_m = heightM;
|
|
const lengthM = num(`${prefix}_length_m`);
|
|
if (lengthM !== undefined) wing.length_m = lengthM;
|
|
const angleDeg = num(`${prefix}_angle_deg`);
|
|
if (angleDeg !== undefined) wing.angle_deg = angleDeg;
|
|
if (Object.keys(wing).length) control.setWing(chainageM, role, wing);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* BOX암거 구체 조작값 제어 — 세월교와 같은 흐름(세션 사본 + 캐시 `design.box_adjust`).
|
|
* 좌·우 끝을 따로 잡으며 길이는 바깥으로만, 표고는 양방향으로 움직인다.
|
|
*/
|
|
export function createBoxControls(deps: FordControlDeps): {
|
|
control: BoxControl;
|
|
byChainage: () => Map<number, BoxAdjust>;
|
|
load: () => void;
|
|
} {
|
|
const { round1, clampMove, sectionAt, patchCachedDesign } = deps;
|
|
const adjustments = new Map<string, BoxAdjust>();
|
|
const selections = new Map<string, BoxSideRole | null>();
|
|
|
|
function load(): void {
|
|
adjustments.clear();
|
|
const key = deps.sessionKey();
|
|
if (!key) return;
|
|
try {
|
|
const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record<
|
|
string,
|
|
BoxAdjust
|
|
>;
|
|
Object.entries(parsed).forEach(([chainage, value]) =>
|
|
adjustments.set(chainage, {
|
|
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...value.left },
|
|
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...value.right },
|
|
}),
|
|
);
|
|
} catch {
|
|
/* 손상된 세션 값은 기본값으로 대체. */
|
|
}
|
|
}
|
|
|
|
function persist(): void {
|
|
const key = deps.sessionKey();
|
|
if (key) window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(adjustments)));
|
|
}
|
|
|
|
const adjustAt = (chainageM: number): BoxAdjust => {
|
|
const stored = sectionAt(chainageM)?.design?.box_adjust;
|
|
return (
|
|
adjustments.get(chainageM.toFixed(2)) ?? {
|
|
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(stored?.left ?? {}) },
|
|
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(stored?.right ?? {}) },
|
|
}
|
|
);
|
|
};
|
|
|
|
const write = (chainageM: number, next: BoxAdjust): void => {
|
|
adjustments.set(chainageM.toFixed(2), next);
|
|
persist();
|
|
patchCachedDesign(chainageM, { box_adjust: next });
|
|
deps.refreshCard(chainageM);
|
|
};
|
|
|
|
const control: BoxControl = {
|
|
adjustFor: adjustAt,
|
|
update: (chainageM, role, patch) => {
|
|
const current = adjustAt(chainageM);
|
|
const side: BoxSideAdjust = { ...current[role], ...patch };
|
|
write(chainageM, {
|
|
...current,
|
|
[role]: {
|
|
// 길이는 바깥으로만 — 안쪽으로 줄이면 성토선 물매가 깨진다.
|
|
lengthM: Math.max(0, round1(side.lengthM)),
|
|
riseM: clampMove(side.riseM),
|
|
},
|
|
});
|
|
},
|
|
reset: (chainageM, role) => {
|
|
const current = adjustAt(chainageM);
|
|
write(chainageM, { ...current, [role]: { ...DEFAULT_BOX_SIDE_ADJUST } });
|
|
},
|
|
selectedFor: (chainageM) => selections.get(chainageM.toFixed(2)) ?? null,
|
|
select: (chainageM, role) => {
|
|
selections.set(chainageM.toFixed(2), role);
|
|
},
|
|
};
|
|
|
|
return {
|
|
control,
|
|
byChainage: () => {
|
|
const result = new Map<number, BoxAdjust>();
|
|
adjustments.forEach((adjust, key) => {
|
|
const value = Number(key);
|
|
if (Number.isFinite(value)) result.set(value, adjust);
|
|
});
|
|
return result;
|
|
},
|
|
load,
|
|
};
|
|
}
|
|
|
|
/** 세월교·BOX암거 제어를 한 번에 만든다 — 측점 제어기 쪽 배선을 줄인다(700줄 제한). */
|
|
export function createBodyControls(
|
|
common: Omit<FordControlDeps, "sessionKey">,
|
|
sessionKey: (kind: "fordadjust" | "boxadjust") => string | null,
|
|
): {
|
|
ford: ReturnType<typeof createFordControls>;
|
|
box: ReturnType<typeof createBoxControls>;
|
|
} {
|
|
return {
|
|
ford: createFordControls({ ...common, sessionKey: () => sessionKey("fordadjust") }),
|
|
box: createBoxControls({ ...common, sessionKey: () => sessionKey("boxadjust") }),
|
|
};
|
|
}
|