feat(B05·B06): 기슭막이 구간값 조정창 · 연동 기슭막이 옵션 · 가림 구간 선 정리
기슭막이 범위(길이·기준측점 전/후)를 B06 횡단도 조정창에서 지정하게 하고, 그 범위가 덮는 옆 측점의 기슭막이를 "연동" 파생물로 다루도록 체계를 세웠다. 값의 원천은 배수관 옵션(pipe_points) 하나다 — 같은 숫자를 B06 정본에 따로 담으면 B05 배수관 카드와 갈려 어느 쪽이 참인지 알 수 없다. ① 집수정 전/후 필드 신설 — inlet_basin_before_m·after_m(기본 각 1m), 길이 2m 유지. 종방향 구간 계산을 revetSpanOfSpec·basinSpanOfSpec 한 곳으로 모아 2D 링크 판정과 3D 스윕이 같은 값을 쓰게 했다. ② B06 조정창에 길이·전·후 행 — 기슭막이(유입·유출)·집수정 모두. 캐시를 고쳐 즉시 보여주고 저장은 조작이 멎으면 한 번만 나간다(세부유역 재계산이 매번 돌지 않게). 길이를 바꾸면 늘어난 몫만 지금 비율대로 나눠 담는다 — 총길이에서 비율로 다시 계산하면 0.1m 반올림이 쌓여 5.0/5.0이 5.6/5.4로 어긋난다. ③ 링크 카드 선택 허용 — "소유 측점 통째 복사"에서 "위치 4축만 물려받고 원지반 접합은 이 측점 지형으로 재계산"으로 바꿨다. 구조물 형식은 언제나 소유 측점 값이다. 손대기 전에도 어긋나지 않게 소유 측점이 실제로 적용한 4축(revetShift)을 명시값으로 물려준다. ④ 연동(측점별)·경사(전체 공통) 토글 — 연동 조작은 소유 측점 키에 실리고 그 연장이 덮는 측점을 함께 다시 그린다. 경사는 3D 전용 스위치다(끄면 스윕 프레임 dz=0). 횡단도까지 -dz로 밀면 노견에서 출발하는 성토선까지 내려가 노견과 벌어진다. ⑤ 가림 구간 선 정리 — 유입부 접속선과 유출부 접지 후 절토선을 2D·3D 모두에서 빼되 기하는 남긴다. 면적·수량 계산은 건드리지 않았다(전면 개편 대상). ⑥ 집수정은 소유 측점 횡단도 하나에만 — 링크 카드에서 집수정·계류측 다단을 뺀다. ⑦ 3D 구간 겹침 정리 — 측점 간격이 구조물 연장보다 좁으면 두 구간 제어점이 번갈아 서서 짧은 쪽(집수정 2m)이 잘려 보였다. 두 소유 측점의 중간에서 끊는다. ⑧ 조정창 정리 — 십자·9키를 맨 아래로, 창을 위쪽 기준 오버레이로(낮은 횡단도에서 제목이 잘리던 문제), 연동·경사를 1행 토글 버튼으로, 스크롤 자리 유지. 파일 분리: B06_Section_UI_Cross_View_Structure.ts(조정창 배선), B06_Section_Api_Culvert_Options.ts(구간값 저장) — 700줄 제한. BUILD_VERSION 14 → 17(저장 코리도 만료). tsc/ruff/prettier 통과, pytest 196 passed(기존 실패 1건 유지). 작업이력·자체검증 기록: docs/raw/PLAN.md §4-9. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -66,6 +66,24 @@
|
||||
"required": false,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "inlet_basin_before_m",
|
||||
"label": "집수정 기준측점 전",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 1,
|
||||
"required": false,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "inlet_basin_after_m",
|
||||
"label": "집수정 기준측점 후",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 1,
|
||||
"required": false,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "inlet_revet_form",
|
||||
"label": "유입 기슭막이 형태",
|
||||
|
||||
@@ -76,7 +76,7 @@ function fnv1a(text: string): string {
|
||||
* (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에
|
||||
* 섞어 두면 배포와 동시에 저장본이 만료된다.
|
||||
*/
|
||||
const BUILD_VERSION = 14; // 절·성토 비탈도 제어점 보간 — 확폭 끝 단차 제거(2026-08-24).
|
||||
const BUILD_VERSION = 17; // 연동 자리 고정 · 3D 절토선 트림 제거(2026-08-24).
|
||||
|
||||
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
|
||||
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
|
||||
|
||||
@@ -45,6 +45,19 @@ export function buildPieceControls(
|
||||
})
|
||||
.filter((entry): entry is NonNullable<typeof entry> => entry !== null)
|
||||
.sort((a, b) => a.fromM - b.fromM);
|
||||
// 겹친 구간 정리(2026-08-24 사용자) — 측점 간격이 구조물 연장보다 좁으면 두 구간이
|
||||
// 포개진다. 그대로 두면 겹친 자리에 제어점이 번갈아 서서 단면이 두 구조물 사이를
|
||||
// 오가고, 짧은 쪽(집수정 2m 같은)이 **잘려 보인다**. 경계를 두 소유 측점의 중간에서
|
||||
// 끊어 한 자리에는 한 구조물만 남게 한다.
|
||||
for (let index = 1; index < spans.length; index += 1) {
|
||||
const previous = spans[index - 1];
|
||||
const current = spans[index];
|
||||
if (previous.toM <= current.fromM + 1e-9) continue;
|
||||
const mid = (previous.station.chainage_m + current.station.chainage_m) / 2;
|
||||
// 각 구간은 자기 측점을 반드시 품는다 — 중간점이 측점을 넘어가면 측점에서 끊는다.
|
||||
previous.toM = Math.max(previous.station.chainage_m, Math.min(previous.toM, mid));
|
||||
current.fromM = Math.min(current.station.chainage_m, Math.max(current.fromM, mid));
|
||||
}
|
||||
const inSpan = (chainageM: number): boolean =>
|
||||
spans.some((span) => chainageM >= span.fromM - 1e-9 && chainageM <= span.toM + 1e-9);
|
||||
|
||||
|
||||
@@ -315,11 +315,16 @@ export function classifyStation(section: CrossSection): StationPieces | null {
|
||||
spans[side] = wallSpanOf(section, wall.role === "inlet" ? "inlet" : "outlet");
|
||||
}
|
||||
// 벽 하단 성토부선·다단 기슭막이(2026-08-23 사용자) — 유출측과 집수정 계류측.
|
||||
layout.outletFill.segments.forEach((segment) => push(outletSide, segment.points));
|
||||
// 유출부가 지면에 닿은 뒤의 **절토선**(kind="cut")과 유입 기슭막이 이후의 **접속선**
|
||||
// (inletFill)은 넣지 않는다 — 2D 횡단도에서 지운 선이라 3D 지반 트림에도 반영해야
|
||||
// 두 화면이 같은 형상이 된다(2026-08-24 사용자: "3D에 반영이 안 된 것 같다").
|
||||
layout.outletFill.segments.forEach((segment) => {
|
||||
if (segment.kind === "cut") return;
|
||||
push(outletSide, segment.points);
|
||||
});
|
||||
layout.extraWalls.forEach((wall) => push(outletSide, [wall.topJoint, wall.bottomFront]));
|
||||
layout.basinFill.segments.forEach((segment) => push(inletSide, segment.points));
|
||||
layout.basinExtras.forEach((wall) => push(inletSide, [wall.topJoint, wall.bottomFront]));
|
||||
if (layout.inletFill) push(inletSide, layout.inletFill.points);
|
||||
|
||||
let basinCut = false;
|
||||
if (layout.basin) {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* 2026-08-23 사용자: 직선 압출 금지). 연장 = B05 사이드 패널의 길이·기준측점
|
||||
* 전/후(revet_length/before/after_m, 기본 10m·5/5).
|
||||
* · 집수정: 부재 폴리곤 대신 **전체 직육면체**(부재 외곽 박스 — 2026-08-23
|
||||
* 사용자). 연장 = basin_length_m(기본 2m), 전후 동일.
|
||||
* 사용자). 연장 = basin_length/before/after_m(기본 2m·1/1 — 2026-08-24).
|
||||
* · 배관: 관 하단 시·끝점(횡단면 안 대각선)을 축으로 하는 원통, 지름 = 관경.
|
||||
* 조정창 조작값(기슭막이 4축 x·d·h·m, 다단 단 수)은 확정 시 정본(design.revet_adjust·
|
||||
* extra_wall_counts)에 실린다 — 3D는 **정본만** 읽는다(2026-08-24 사용자 확정: 3D는
|
||||
@@ -124,10 +124,16 @@ export function wallSpanOf(section: CrossSection, role: "inlet" | "outlet"): Str
|
||||
return { beforeM, afterM };
|
||||
}
|
||||
|
||||
/** 집수정의 종방향 점유 구간 — 전후 동일(길이/2), 기본 2m(2026-08-23 사용자). */
|
||||
/**
|
||||
* 집수정의 종방향 점유 구간 — 길이 기본 2m, 전/후는 따로 지정하고 기본 각 1m
|
||||
* (2026-08-24 사용자). 저장분이 없으면 기슭막이와 같이 길이 절반씩으로 본다.
|
||||
*/
|
||||
export function basinSpanOf(section: CrossSection): StructureSpan {
|
||||
const half = Math.max(section.culvert?.inlet.basin_length_m ?? 2, 0.1) / 2;
|
||||
return { beforeM: half, afterM: half };
|
||||
const spec = section.culvert?.inlet;
|
||||
const length = Math.max(spec?.basin_length_m ?? 2, 0.1);
|
||||
const beforeM = Math.max(spec?.basin_before_m ?? length / 2, 0);
|
||||
const afterM = Math.max(spec?.basin_after_m ?? Math.max(length - beforeM, 0), 0);
|
||||
return { beforeM, afterM };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,7 +158,10 @@ export function buildCorridorStructures(
|
||||
dz: 0,
|
||||
};
|
||||
// 측점 자리 계획고 — 스윕 각 지점의 계획고 차이가 곧 종단 경사다(2026-08-23).
|
||||
const baseZ = frameAt(chainage)?.designZ ?? null;
|
||||
// 종단경사 반영을 끄면(2026-08-24 사용자) 그 차이를 0으로 눕혀 **Z를 동일하게**
|
||||
// 본다. 이 스위치는 3D 전용이다 — 횡단도는 언제나 그 측점 계획고로 그린다.
|
||||
const followGrade = section.design?.revet_follow_grade !== false;
|
||||
const baseZ = followGrade ? (frameAt(chainage)?.designZ ?? null) : null;
|
||||
const ringsOf = (beforeM: number, afterM: number): StructureFrame[] => {
|
||||
const from = chainage - beforeM;
|
||||
const to = chainage + afterM;
|
||||
@@ -178,7 +187,7 @@ export function buildCorridorStructures(
|
||||
|
||||
const inletSpan = wallSpanOf(section, "inlet");
|
||||
const outletSpan = wallSpanOf(section, "outlet");
|
||||
const basinLength = Math.max(section.culvert?.inlet.basin_length_m ?? 2, 0.1);
|
||||
const basinSpan = basinSpanOf(section);
|
||||
|
||||
const pushSwept = (
|
||||
kind: "revet" | "basin",
|
||||
@@ -204,7 +213,7 @@ export function buildCorridorStructures(
|
||||
pushSwept("revet", wall.points, outletSpan.beforeM, outletSpan.afterM);
|
||||
}
|
||||
for (const wall of layout.basinExtras) {
|
||||
pushSwept("revet", wall.points, basinLength / 2, basinLength / 2);
|
||||
pushSwept("revet", wall.points, basinSpan.beforeM, basinSpan.afterM);
|
||||
}
|
||||
|
||||
if (layout.basin) {
|
||||
@@ -230,8 +239,8 @@ export function buildCorridorStructures(
|
||||
{ offset: maxOffset, elevation: maxElevation },
|
||||
{ offset: maxOffset, elevation: minElevation },
|
||||
],
|
||||
basinLength / 2,
|
||||
basinLength / 2,
|
||||
basinSpan.beforeM,
|
||||
basinSpan.afterM,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -263,6 +272,8 @@ export function structureHashParts(section: CrossSection): Array<string | number
|
||||
spec.revet_before_m ?? "",
|
||||
spec.revet_after_m ?? "",
|
||||
spec.basin_length_m ?? "",
|
||||
spec.basin_before_m ?? "",
|
||||
spec.basin_after_m ?? "",
|
||||
].join(",");
|
||||
const basin = section.design?.basin_adjust;
|
||||
const revet = section.design?.revet_adjust;
|
||||
@@ -286,5 +297,6 @@ export function structureHashParts(section: CrossSection): Array<string | number
|
||||
.join("|")
|
||||
: "",
|
||||
counts ? `${counts.outlet},${counts.basin}` : "",
|
||||
section.design?.revet_follow_grade === false ? "flatZ" : "",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_Api_Culvert_Options.ts
|
||||
* B06 조정창에서 만진 **배수관 세트 구간값**(기슭막이·집수정 길이·전/후)을
|
||||
* 원천인 `pipe_points.json`에 되돌려 쓴다(2026-08-24 사용자).
|
||||
*
|
||||
* 값을 B06 정본(design)에 따로 담지 않는 이유: 같은 숫자가 B05 배수관 카드에도
|
||||
* 있어서 두 곳이 갈리면 어느 쪽이 참인지 알 수 없다. 원천은 하나여야 한다.
|
||||
*
|
||||
* 저장은 **묶어서 늦게** 한다 — +/- 한 번마다 PUT하면 세부유역 재계산이 매번
|
||||
* 돌아간다. 화면은 캐시를 고쳐 즉시 반영하고, 저장은 마지막 조작 뒤 한 번만 간다.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
fetchDetailPipePoints,
|
||||
saveDetailPipePoints,
|
||||
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
|
||||
/** 마지막 조작 뒤 이만큼 조용하면 저장한다(ms). */
|
||||
const SAVE_DELAY_MS = 800;
|
||||
|
||||
/** 측점 하나에 얹을 옵션 조각 — 키는 `pipe_points` 옵션 키 그대로. */
|
||||
export type CulvertOptionPatch = Record<string, number>;
|
||||
|
||||
export interface CulvertOptionWriter {
|
||||
/** 측점 옵션을 예약한다. 같은 측점의 앞선 예약과는 합쳐진다. */
|
||||
queue: (chainageM: number, patch: CulvertOptionPatch) => void;
|
||||
/** 예약분을 즉시 내보낸다(확정·임시저장 직전에 쓴다). */
|
||||
flush: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장기 하나를 만든다. `projectId`가 없으면 아무것도 하지 않는다(조회 전용 상태).
|
||||
* `onError`는 저장 실패를 화면에 알리는 쪽이 구현한다.
|
||||
*/
|
||||
export function createCulvertOptionWriter(
|
||||
projectId: () => string | null,
|
||||
onError?: (message: string) => void,
|
||||
): CulvertOptionWriter {
|
||||
/** 측점(누가거리 문자열) → 아직 안 보낸 옵션 조각. */
|
||||
const pending = new Map<string, CulvertOptionPatch>();
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let inFlight: Promise<void> = Promise.resolve();
|
||||
|
||||
const keyOf = (chainageM: number): string => chainageM.toFixed(2);
|
||||
|
||||
async function send(): Promise<void> {
|
||||
const id = projectId();
|
||||
if (!id || pending.size === 0) {
|
||||
pending.clear();
|
||||
return;
|
||||
}
|
||||
const batch = new Map(pending);
|
||||
pending.clear();
|
||||
try {
|
||||
const current = await fetchDetailPipePoints(id);
|
||||
let touched = false;
|
||||
const points: DetailPipeInput[] = current.pipe_points.map((point) => {
|
||||
// 좌표는 서버가 다시 계산하므로 싣지 않는다(DetailPipeInput 규약).
|
||||
const { lonlat: _lonlat, ...input } = point;
|
||||
const patch = batch.get(keyOf(point.chainage_m));
|
||||
if (!patch) return input;
|
||||
touched = true;
|
||||
return { ...input, options: { ...(input.options ?? {}), ...patch } };
|
||||
});
|
||||
if (!touched) return;
|
||||
await saveDetailPipePoints(id, points);
|
||||
} catch (error) {
|
||||
// 실패한 조각은 되살려 다음 기회에 다시 시도한다 — 조용히 잃으면 화면과
|
||||
// 저장분이 갈린다.
|
||||
batch.forEach((patch, key) => {
|
||||
pending.set(key, { ...patch, ...(pending.get(key) ?? {}) });
|
||||
});
|
||||
onError?.(error instanceof Error ? error.message : "배수관 구간값 저장 실패");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
queue(chainageM, patch) {
|
||||
const key = keyOf(chainageM);
|
||||
pending.set(key, { ...(pending.get(key) ?? {}), ...patch });
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
inFlight = inFlight.then(send);
|
||||
}, SAVE_DELAY_MS);
|
||||
},
|
||||
async flush() {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
inFlight = inFlight.then(send);
|
||||
await inFlight;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -198,8 +198,11 @@ export interface CulvertSideSpec {
|
||||
/** 기준측점 전/후 종방향 몫(m) — 3D 예상형상 배치(2026-08-23). 없으면 길이 절반씩. */
|
||||
revet_before_m?: number | null;
|
||||
revet_after_m?: number | null;
|
||||
/** 집수정 종방향 길이(m) — structure="집수정"일 때만. 전후 동일, 기본 2m. */
|
||||
/** 집수정 종방향 길이(m) — structure="집수정"일 때만. 기본 2m. */
|
||||
basin_length_m?: number | null;
|
||||
/** 집수정 기준측점 전/후 몫(m) — 기슭막이와 같은 체계(2026-08-24). 기본 각 1m. */
|
||||
basin_before_m?: number | null;
|
||||
basin_after_m?: number | null;
|
||||
/** 기슭막이 전면 기울기(1:n). 돌쌓기 전면 1:0.3(교본 7-3). */
|
||||
face_slope?: number;
|
||||
/** 보호공(물받이) 길이 = 낙차고 × 2 (사방교본 교차 참조, 2026-08-19 사용자 확정). */
|
||||
@@ -276,6 +279,12 @@ export interface CrossDesign {
|
||||
revet_adjust?: Record<string, StoredWallAdjust>;
|
||||
/** 다단 기슭막이 단 수 — 유출 성토부·집수정 계류측. */
|
||||
extra_wall_counts?: StoredExtraWallCounts;
|
||||
/** 연동 해제(측점별 — 2026-08-24 사용자). 옆 측점에서 연장돼 온 기슭막이의 위치
|
||||
* 4축을 이 측점에서 따로 잡는다. 구조물 추가가 아니라 3D 위치의 개별 지정이다. */
|
||||
revet_link_detached?: boolean;
|
||||
/** 종단경사 반영(소유 측점 1개 = 기슭막이 한 벌 전체 공통, 기본 켬).
|
||||
* 끄면 연장 구간의 표고를 소유 측점과 같게 본다. */
|
||||
revet_follow_grade?: boolean;
|
||||
ground_type: GroundType;
|
||||
geometry_preset: "soil" | "rock";
|
||||
section_mode: SectionMode;
|
||||
@@ -374,6 +383,9 @@ export interface CrossSectionPatch {
|
||||
};
|
||||
revet_adjust?: Record<string, StoredWallAdjust>;
|
||||
extra_wall_counts?: StoredExtraWallCounts;
|
||||
/** 연동 해제(측점별)·종단경사 반영(전체 공통) — 2026-08-24 사용자. */
|
||||
revet_link_detached?: boolean;
|
||||
revet_follow_grade?: boolean;
|
||||
}
|
||||
|
||||
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */
|
||||
|
||||
@@ -105,11 +105,19 @@ def _side_spec(options: dict[str, Any], defaults: dict[str, Any], side: str) ->
|
||||
spec: dict[str, Any] = {"role": side, "structure": str(structure)}
|
||||
if spec["structure"] == INLET_STRUCTURE_BASIN:
|
||||
# 집수정 종방향 길이(3D 예상형상용, 기본 2m — 2026-08-23 사용자).
|
||||
# 전후 길이는 동일(길이/2씩)로 본다.
|
||||
# 전/후는 기슭막이와 같은 체계로 따로 지정한다(기본 각 1m — 2026-08-24 사용자).
|
||||
spec["basin_length_m"] = _number(
|
||||
options.get("inlet_basin_length_m"),
|
||||
_number(defaults.get("inlet_basin_length_m"), 2.0),
|
||||
)
|
||||
spec["basin_before_m"] = _number(
|
||||
options.get("inlet_basin_before_m"),
|
||||
_number(defaults.get("inlet_basin_before_m"), None),
|
||||
)
|
||||
spec["basin_after_m"] = _number(
|
||||
options.get("inlet_basin_after_m"),
|
||||
_number(defaults.get("inlet_basin_after_m"), None),
|
||||
)
|
||||
return spec
|
||||
|
||||
height = _number(
|
||||
|
||||
@@ -665,6 +665,8 @@ async def compute_cross_section_design(
|
||||
"basin_adjust",
|
||||
"revet_adjust",
|
||||
"extra_wall_counts",
|
||||
"revet_link_detached",
|
||||
"revet_follow_grade",
|
||||
):
|
||||
if stored_design.get(key) is not None:
|
||||
design[key] = stored_design[key]
|
||||
|
||||
@@ -128,6 +128,10 @@ async def _apply_section_edits(
|
||||
}
|
||||
if patch_item.extra_wall_counts is not None:
|
||||
patch["extra_wall_counts"] = patch_item.extra_wall_counts.model_dump()
|
||||
if patch_item.revet_link_detached is not None:
|
||||
patch["revet_link_detached"] = patch_item.revet_link_detached
|
||||
if patch_item.revet_follow_grade is not None:
|
||||
patch["revet_follow_grade"] = patch_item.revet_follow_grade
|
||||
if patch:
|
||||
await merge_cross_section_design_patch(
|
||||
connection, route_id=route_id, chainage_m=patch_item.chainage_m, patch=patch
|
||||
|
||||
@@ -105,6 +105,10 @@ class CrossSectionPatch(BaseModel):
|
||||
# 세션 전용이던 값을 정본으로 올린다(2026-08-24 사용자: 3D는 확정 결과물).
|
||||
revet_adjust: dict[str, WallAdjustPatch] | None = None
|
||||
extra_wall_counts: ExtraWallCountsPatch | None = None
|
||||
# 연동 기슭막이 옵션(2026-08-24 사용자). 연동 해제는 측점별, 종단경사 반영은
|
||||
# 기슭막이 한 벌 전체 공통이라 소유 측점에만 실린다.
|
||||
revet_link_detached: bool | None = None
|
||||
revet_follow_grade: bool | None = None
|
||||
|
||||
|
||||
class SectionConfirmRequest(BaseModel):
|
||||
|
||||
@@ -98,14 +98,10 @@ export function appendCulvertOverlay(
|
||||
};
|
||||
const drawOutletFill = (): void => {
|
||||
for (const segment of layout.outletFill.segments) {
|
||||
if (segment.kind === "cut") {
|
||||
// 매몰 구간 — 0도 절토선을 원지반 교차까지(2026-08-22 사용자 ③).
|
||||
planLine(
|
||||
segment.points,
|
||||
`유출부 3도 절토선(계류 쪽 내림 — 물이 밖으로) — 벽이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// 유출부가 지면에 닿은 뒤의 절토선은 그리지 않는다(2026-08-24 사용자) — 벽이
|
||||
// 이미 원지반에 묻힌 구간이라 도면에 더 보탤 정보가 없다. 면적·수량 계산은
|
||||
// 건드리지 않는다(그림만 — 면적은 전면 개편 대상).
|
||||
if (segment.kind === "cut") continue;
|
||||
planLine(
|
||||
segment.points,
|
||||
`유출부 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
|
||||
@@ -134,15 +130,9 @@ export function appendCulvertOverlay(
|
||||
` · 성토 물매 1:${(segment.ratio ?? 1.2).toFixed(2)}`,
|
||||
);
|
||||
}
|
||||
// 유입 기슭막이의 관 시작 접속선(2026-08-22 사용자 ②).
|
||||
if (layout.inletFill) {
|
||||
planLine(
|
||||
layout.inletFill.points,
|
||||
layout.inletFill.kind === "cut"
|
||||
? `유입부 접속선 — 관 하단에서 3도(계류 쪽 오름) 1m 공간 확보에서 끝 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)`
|
||||
: `유입부 3도 성토선(계류 쪽 오름 — 물이 관으로) — 원지반 교차에서 정지 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)`,
|
||||
);
|
||||
}
|
||||
// 유입 기슭막이 **이후**의 절·성토선(옛 관 시작 접속선 — 2026-08-22 ②)은 그리지
|
||||
// 않는다(2026-08-24 사용자). 기하(`layout.inletFill`)는 남겨 둔다 — 3D 트림과
|
||||
// 면적 개편이 그 값을 쓴다. 여기서는 도면에 얹지 않을 뿐이다.
|
||||
};
|
||||
|
||||
// ①·② 구조물 — 유출측(추가 벽 포함)이 먼저 서고, 그 다음 유입측.
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* 분리했다(700줄 제한). 값의 근거는 각 상수 주석에 지식DB 출처로 남긴다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CulvertSideSpec } from "./B06_Section_Api_Fetch";
|
||||
|
||||
/** 관 위 최소 토피(m) — 프론트 정의처(백엔드는 `B06_Section_Engine_Culvert.MIN_PIPE_COVER_M`).
|
||||
* ⚠ 임도 배수관 토피 규정이 지식DB에 없어 별표2 교량·암거 "복토 50㎝ 이상"을 교차
|
||||
* 참조로 채택(2026-08-19 사용자 확정). B05 배수관 측점 계획고 하향 차단 기준이자,
|
||||
@@ -192,3 +194,39 @@ export const BASIN_MAX_FILL_SLOPE_M = 3.0;
|
||||
|
||||
/** 집수정 부재(벽·바닥판) 두께(m) — 2026-08-22 사용자 지정 0.2. */
|
||||
export const BASIN_MEMBER_THICKNESS_M = 0.2;
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* 구조물의 종방향 점유 구간(기준측점 전/후 m)
|
||||
*
|
||||
* 기슭막이 연장(길이·전/후)과 집수정 연장을 **한 곳에서** 정한다 — B06 링크 판정
|
||||
* (`_Cross_Culvert_Wire.ts`)과 3D 스윕(`B05_Profile_UI_Corridor_Structures.ts`)이
|
||||
* 서로 다른 값을 쓰면 2D와 3D가 어긋난다(2026-08-24).
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** 기준측점 전/후 몫(m). */
|
||||
export interface StructureSpan {
|
||||
beforeM: number;
|
||||
afterM: number;
|
||||
}
|
||||
|
||||
/** 길이 + 전/후 저장분에서 구간을 확정 — 전/후가 없으면 길이 절반씩. */
|
||||
function spanFrom(
|
||||
lengthM: number,
|
||||
beforeM: number | null | undefined,
|
||||
afterM: number | null | undefined,
|
||||
): StructureSpan {
|
||||
const before = Math.max(beforeM ?? lengthM / 2, 0);
|
||||
const after = Math.max(afterM ?? Math.max(lengthM - before, 0), 0);
|
||||
return { beforeM: before, afterM: after };
|
||||
}
|
||||
|
||||
/** 기슭막이 한 벽의 종방향 구간 — 기본 10m(전 5·후 5). */
|
||||
export function revetSpanOfSpec(spec: CulvertSideSpec | undefined): StructureSpan {
|
||||
return spanFrom(spec?.revet_length_m ?? 10, spec?.revet_before_m, spec?.revet_after_m);
|
||||
}
|
||||
|
||||
/** 집수정의 종방향 구간 — 기본 길이 2m, 전/후 각 1m(2026-08-24 사용자). */
|
||||
export function basinSpanOfSpec(spec: CulvertSideSpec | undefined): StructureSpan {
|
||||
const length = Math.max(spec?.basin_length_m ?? 2, 0.1);
|
||||
return spanFrom(length, spec?.basin_before_m, spec?.basin_after_m);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,13 @@ import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
|
||||
import { computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert";
|
||||
import type { CulvertLayout, InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert";
|
||||
import type { BasinAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { materialLabel, materialLimit } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import {
|
||||
basinSpanOfSpec,
|
||||
materialLabel,
|
||||
materialLimit,
|
||||
revetSpanOfSpec,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { StructureSpan } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import {
|
||||
designInterpolator,
|
||||
@@ -31,6 +37,41 @@ export interface RevetOffsetControl {
|
||||
reset: (chainageM: number, role: RevetKey) => void;
|
||||
}
|
||||
|
||||
/** 구간값을 갖는 요소 — 유입·유출 기슭막이, 유입 집수정(2026-08-24). */
|
||||
export type SpanRole = "inlet" | "outlet" | "basin";
|
||||
|
||||
/** 길이와 기준측점 전/후. 세 값이 곧 종방향 범위(폭)다 — 별도 폭 필드는 없다. */
|
||||
export interface SpanValues {
|
||||
lengthM: number;
|
||||
beforeM: number;
|
||||
afterM: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기슭막이·집수정 **구간값 제어**(2026-08-24 사용자) — 조정창에서 길이·전·후를
|
||||
* 만진다. 원천은 배수관 옵션(`pipe_points`) 하나이고, 편집은 캐시를 고쳐 즉시
|
||||
* 보여준 뒤 늦게 한 번 저장한다. 링크 측점에서는 **소유 측점 값**을 만진다.
|
||||
*/
|
||||
export interface StructureSpanControl {
|
||||
/** 이 카드가 구간값을 만질 대상 측점 — 링크면 소유 측점, 아니면 자기 자신. */
|
||||
ownerOf: (section: CrossSection) => CrossSection | null;
|
||||
valuesFor: (section: CrossSection, role: SpanRole) => SpanValues | null;
|
||||
update: (section: CrossSection, role: SpanRole, patch: Partial<SpanValues>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 연동 기슭막이 옵션 제어(2026-08-24 사용자) — 연동은 **측점별**, 종단경사 반영은
|
||||
* 기슭막이 한 벌 **전체 공통**(소유 측점 1개)이다.
|
||||
*/
|
||||
export interface RevetLinkControl {
|
||||
/** 연동 여부 — 기본 켬. 끄면 이 측점에서 4축을 따로 잡는다. */
|
||||
linkedFor: (section: CrossSection) => boolean;
|
||||
setLinked: (chainageM: number, linked: boolean) => void;
|
||||
/** 종단경사 반영 여부 — 소유 측점에 하나. 기본 켬. */
|
||||
followGradeFor: (section: CrossSection) => boolean;
|
||||
setFollowGrade: (section: CrossSection, follow: boolean) => void;
|
||||
}
|
||||
|
||||
/** 유입측 구조물 형식 선택(드롭다운 — 2026-08-22 사용자). 세션 보관은 Page가 한다. */
|
||||
export interface InletStructureControl {
|
||||
valueFor: (section: CrossSection) => InletStructureChoice;
|
||||
@@ -86,22 +127,24 @@ export interface CulvertLink {
|
||||
dz: number;
|
||||
/** 소유 측점과의 거리(m) — 요소별 연장 안에 드는지 판정에 쓴다. */
|
||||
distanceM: number;
|
||||
/** 소유 측점 기준 부호 있는 위치(m). 음수 = 전(前), 양수 = 후(後). 전/후 몫이
|
||||
* 다른 요소(집수정 1/1, 기슭막이 5/5)를 어느 쪽 몫으로 잴지 가른다(2026-08-24). */
|
||||
deltaM: number;
|
||||
}
|
||||
|
||||
/** 구조물이 종방향으로 덮는 범위(기준측점 전/후 m) — 기슭막이 연장·집수정 길이 중 큰 값. */
|
||||
function culvertReach(section: CrossSection): { beforeM: number; afterM: number } | null {
|
||||
/** 구조물이 종방향으로 덮는 범위(기준측점 전/후 m) — 기슭막이 연장·집수정 연장 중 큰 값. */
|
||||
function culvertReach(section: CrossSection): StructureSpan | null {
|
||||
const culvert = section.culvert;
|
||||
if (!culvert) return null;
|
||||
let beforeM = 0;
|
||||
let afterM = 0;
|
||||
for (const spec of [culvert.inlet, culvert.outlet]) {
|
||||
if (!spec) continue;
|
||||
const length = spec.revet_length_m ?? 10;
|
||||
const before = spec.revet_before_m ?? length / 2;
|
||||
const after = spec.revet_after_m ?? Math.max(length - before, 0);
|
||||
const basinHalf = (spec.basin_length_m ?? 0) / 2;
|
||||
beforeM = Math.max(beforeM, before, basinHalf);
|
||||
afterM = Math.max(afterM, after, basinHalf);
|
||||
// 집수정이 선 쪽에는 기슭막이가 없다 — 기슭막이 기본 10m를 얹으면 있지도 않은
|
||||
// 벽의 연장으로 옆 측점이 링크된다.
|
||||
const span = spec.structure === "집수정" ? basinSpanOfSpec(spec) : revetSpanOfSpec(spec);
|
||||
beforeM = Math.max(beforeM, span.beforeM);
|
||||
afterM = Math.max(afterM, span.afterM);
|
||||
}
|
||||
return beforeM > 0 || afterM > 0 ? { beforeM, afterM } : null;
|
||||
}
|
||||
@@ -110,13 +153,12 @@ function culvertReach(section: CrossSection): { beforeM: number; afterM: number
|
||||
* 이 측점을 덮는 구조물 측점을 찾는다. 자기 구조물이 있으면 링크 아님(소유 측점).
|
||||
* 여러 구조물이 겹치면 가장 가까운 측점 — 3D `structureAt`과 같은 규칙.
|
||||
*/
|
||||
export function culvertLinkFor(
|
||||
export function culvertOwnerFor(
|
||||
section: CrossSection,
|
||||
sections: readonly CrossSection[],
|
||||
designZAt: (chainageM: number) => number | null,
|
||||
): CulvertLink | undefined {
|
||||
): CrossSection | undefined {
|
||||
if (section.culvert) return undefined;
|
||||
let best: CrossSection | null = null;
|
||||
let best: CrossSection | undefined;
|
||||
let bestDistance = Number.POSITIVE_INFINITY;
|
||||
for (const candidate of sections) {
|
||||
const reach = culvertReach(candidate);
|
||||
@@ -133,34 +175,54 @@ export function culvertLinkFor(
|
||||
bestDistance = distance;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function culvertLinkFor(
|
||||
section: CrossSection,
|
||||
sections: readonly CrossSection[],
|
||||
designZAt: (chainageM: number) => number | null,
|
||||
): CulvertLink | undefined {
|
||||
const best = culvertOwnerFor(section, sections);
|
||||
if (!best) return undefined;
|
||||
const bestDistance = Math.abs(section.chainage_m - best.chainage_m);
|
||||
const here = designZAt(section.chainage_m);
|
||||
const there = designZAt(best.chainage_m);
|
||||
return {
|
||||
source: best,
|
||||
dz: here != null && there != null ? here - there : 0,
|
||||
distanceM: bestDistance,
|
||||
deltaM: section.chainage_m - best.chainage_m,
|
||||
};
|
||||
}
|
||||
|
||||
/** 링크 측점이 그 구간(전/후 몫) 안에 드는지 — 부호로 어느 쪽 몫인지 가른다. */
|
||||
function spanCovers(span: StructureSpan, deltaM: number): boolean {
|
||||
return deltaM < 0 ? -deltaM <= span.beforeM + 1e-9 : deltaM <= span.afterM + 1e-9;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기하 전체를 종단 차이만큼 올려/내린다 — offset(횡방향)은 그대로다. 표고 필드는
|
||||
* 이름이 `elevation`·`minElevation`·`maxElevation`처럼 끝나므로 키로 찾아 더한다.
|
||||
* 링크 카드에서 **연장이 여기까지 오지 않는 요소**를 지운다(요소별 판정).
|
||||
* 유입·유출 기슭막이는 각자 길이·전/후를 갖고, 집수정은 자기 연장(기본 2m·1/1)
|
||||
* 만큼만 이어진다. 관은 한 측점 전용이라 그리기 단계에서 숨긴다(`hidePipe`).
|
||||
*/
|
||||
function shiftLayoutElevation<T>(value: T, dz: number): T {
|
||||
if (Array.isArray(value))
|
||||
return value.map((item) => shiftLayoutElevation(item, dz)) as unknown as T;
|
||||
if (value && typeof value === "object") {
|
||||
const clone: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
clone[key] =
|
||||
typeof entry === "number" && /elevation$/i.test(key)
|
||||
? entry + dz
|
||||
: shiftLayoutElevation(entry, dz);
|
||||
}
|
||||
return clone as T;
|
||||
}
|
||||
return value;
|
||||
function trimLinkedLayout(layout: CulvertLayout, link: CulvertLink): CulvertLayout {
|
||||
const spec = layout.culvert;
|
||||
const spanOf = (side: "inlet" | "outlet"): StructureSpan =>
|
||||
spec[side]?.structure === "집수정" ? basinSpanOfSpec(spec[side]) : revetSpanOfSpec(spec[side]);
|
||||
const inletIn = spanCovers(spanOf("inlet"), link.deltaM);
|
||||
const outletIn = spanCovers(spanOf("outlet"), link.deltaM);
|
||||
return {
|
||||
...layout,
|
||||
walls: layout.walls.filter((wall) => (wall.role === "inlet" ? inletIn : outletIn)),
|
||||
// 다단(성토부)은 유출 벽에 딸린다.
|
||||
extraWalls: outletIn ? layout.extraWalls : [],
|
||||
// 집수정은 **소유 측점 횡단도 하나에만** 선다(2026-08-24 사용자 확정) — 기슭막이와
|
||||
// 달리 옆 측점으로 이어지는 구조물이 아니다. 계류측 다단도 집수정에 딸린 것이라
|
||||
// 함께 뺀다. 3D에서는 소유 측점 하나가 자기 길이(기본 2m)만큼 스윕한다.
|
||||
basinExtras: [],
|
||||
basin: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeCardCulvert(
|
||||
@@ -171,23 +233,45 @@ export function computeCardCulvert(
|
||||
inletStructure?: InletStructureControl,
|
||||
extraWalls?: ExtraWallControl,
|
||||
link?: CulvertLink,
|
||||
revetLink?: RevetLinkControl,
|
||||
): CulvertLayout | null {
|
||||
// 링크 측점 — 소유 측점 단면을 그대로 계산해 종단 차이만 얹는다. 한계 토스트·
|
||||
// 조작값 되받기는 하지 않는다(소유 측점 카드가 이미 한다).
|
||||
// 링크 측점 — 소유 측점 **스펙**을 이 측점 지형 위에 세운다. 연동으로 물려받는
|
||||
// 것은 횡단도 **위치 4축(x·d·h·m)** 뿐이고, 원지반과 만나는 정보(접합·성토부선·
|
||||
// 교차점)는 이 측점 것으로 정상 계산한다(2026-08-24 사용자 확정).
|
||||
if (!section.culvert && link) {
|
||||
const owner = computeCulvertLayout(
|
||||
link.source,
|
||||
link.source.samples,
|
||||
adjustsInput(link.source, revetOffset, extraWalls),
|
||||
inletStructure?.valueFor(link.source),
|
||||
inletStructure?.adjustFor(link.source),
|
||||
const detached = !(
|
||||
revetLink?.linkedFor(section) ?? section.design?.revet_link_detached !== true
|
||||
);
|
||||
if (!owner) return null;
|
||||
const shifted = shiftLayoutElevation(owner, link.dz);
|
||||
// 집수정은 자기 길이(기본 2m)만큼만 이어진다 — 기슭막이 연장(10m)에 얹혀
|
||||
// 따라오면 있지도 않은 자리에 집수정이 생긴다. 관도 마찬가지(한 측점 전용).
|
||||
const basinHalf = (link.source.culvert?.inlet.basin_length_m ?? 0) / 2;
|
||||
return link.distanceM > basinHalf + 1e-9 ? { ...shifted, basin: null } : shifted;
|
||||
// 조작값 원천 — 연동이면 소유 측점, 해제면 이 측점(4축 전부 개별).
|
||||
// 단 **구조물 형식**(기슭막이/집수정 I·ㄴ·ㄷ)은 언제나 소유 측점 값이다. 해제는
|
||||
// "위치를 따로 잡는다"는 뜻이지 다른 구조물을 세운다는 뜻이 아니다 — 이 측점에서
|
||||
// 자동 판정을 다시 돌리면 벽 2개가 집수정 1개로 바뀐다(2026-08-24 화면 실측).
|
||||
const hosted: CrossSection = { ...section, culvert: link.source.culvert };
|
||||
// 연동이면 소유 측점이 **실제로 적용한 4축**(자동 자리 포함)을 명시값으로 물려준다.
|
||||
// 조작값만 넘기면 손대기 전(전부 자동)에는 이 측점이 자기 지형으로 자동 자리를 다시
|
||||
// 잡아 소유 측점과 어긋난다 — "한 번 움직이면 맞는다"던 증상의 원인(2026-08-24 사용자).
|
||||
const ownerAdjusts = detached
|
||||
? adjustsInput(section, revetOffset, extraWalls)
|
||||
: (computeCulvertLayout(
|
||||
link.source,
|
||||
link.source.samples,
|
||||
adjustsInput(link.source, revetOffset, extraWalls),
|
||||
inletStructure?.valueFor(link.source),
|
||||
inletStructure?.adjustFor(link.source),
|
||||
)?.revetShift ?? adjustsInput(link.source, revetOffset, extraWalls));
|
||||
const layout = computeCulvertLayout(
|
||||
hosted,
|
||||
sourceSamples,
|
||||
ownerAdjusts,
|
||||
inletStructure?.valueFor(link.source),
|
||||
inletStructure?.adjustFor(detached ? section : link.source),
|
||||
);
|
||||
if (!layout) return null;
|
||||
// 종단경사 반영은 **3D 전용** 스위치다(2026-08-24 사용자 확인). 횡단도는 언제나
|
||||
// 그 측점 계획고로 그린다 — 레이아웃 전체를 −dz로 밀면 벽뿐 아니라 노견에서
|
||||
// 출발하는 성토선까지 같이 내려가 **성토선이 노견에서 떨어진다**(사용자 지적).
|
||||
// 3D 스윕에서만 프레임 dz를 0으로 눕힌다(B05_Profile_UI_Corridor_Structures).
|
||||
return trimLinkedLayout(layout, link);
|
||||
}
|
||||
const equalizeExtras = extraWalls?.consumeEqualize(section) ?? false;
|
||||
const layout = computeCulvertLayout(
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culv
|
||||
import { materialLimit, REVET_MATERIALS } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { BasinAdjust, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type { SpanValues } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { L } from "./B06_Section_UI_Section_Common";
|
||||
|
||||
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
|
||||
@@ -58,6 +59,19 @@ export interface StructurePanelDeps {
|
||||
setExtraCount: (count: number) => void;
|
||||
/** 다단 등간격 배치(2026-08-22 ①) — 사면 구간이 같아지게 단들을 재배치. */
|
||||
equalizeExtras: () => void;
|
||||
/** 이 구조물의 종방향 구간값(길이·전·후). 구간값이 없는 요소면 null(2026-08-24). */
|
||||
spanFor: (key: RevetKey) => SpanValues | null;
|
||||
/** 구간값 변경 — 링크 카드에서 만져도 **소유 측점** 값이 바뀐다. */
|
||||
setSpan: (key: RevetKey, patch: Partial<SpanValues>) => void;
|
||||
/** 이 카드가 연동으로 생긴 기슭막이인지 + 지금 연동 상태. 소유 측점이면 null. */
|
||||
linkState: () => { linked: boolean } | null;
|
||||
setLinked: (linked: boolean) => void;
|
||||
/** 종단경사 반영 — 기슭막이 한 벌 전체 공통(소유 측점에 하나). */
|
||||
followGrade: () => boolean;
|
||||
setFollowGrade: (follow: boolean) => void;
|
||||
/** 창 스크롤 자리를 카드별로 기억할 키(측점 id). 버튼을 누르면 카드가 다시 그려지고
|
||||
* 창도 새로 만들어지므로, 이 키로 스크롤 자리를 되살린다(2026-08-24 사용자). */
|
||||
scrollKey: string;
|
||||
}
|
||||
|
||||
export interface StructurePanelHandle {
|
||||
@@ -73,6 +87,11 @@ export interface StructurePanelHandle {
|
||||
const STEP_M = 1.0;
|
||||
const HEIGHT_STEP_M = 0.1;
|
||||
const BASIN_STEP_M = 0.1;
|
||||
/** 전/후 한 걸음(m) — 집수정 기본이 2m·1/1이라 1m 눈금은 너무 거칠다. */
|
||||
const SPAN_STEP_M = 0.5;
|
||||
/** 길이 한 걸음(m) — **전/후 걸음의 2배**여야 한 번에 양쪽이 정확히 반씩 늘어난다.
|
||||
* 0.5m로 두면 반쪽이 0.25m라 0.1m 눈금에 안 떨어지고 전/후가 어긋난다. */
|
||||
const LENGTH_STEP_M = SPAN_STEP_M * 2;
|
||||
|
||||
function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
@@ -101,11 +120,20 @@ function makeRow(labelText: string): { row: HTMLElement; controls: HTMLElement }
|
||||
return { row, controls };
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드별 창 스크롤 자리. 조작 버튼을 누르면 카드가 통째로 다시 그려져 창이 새로
|
||||
* 만들어진다 — 기억해 두지 않으면 매번 맨 위(제목)로 튄다(2026-08-24 사용자).
|
||||
*/
|
||||
const panelScrollTops = new Map<string, number>();
|
||||
|
||||
/** 구조물 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */
|
||||
export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHandle {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b06-structure-panel";
|
||||
root.classList.add("is-hidden");
|
||||
root.addEventListener("scroll", () => {
|
||||
if (!root.classList.contains("is-hidden")) panelScrollTops.set(deps.scrollKey, root.scrollTop);
|
||||
});
|
||||
// 창 안에서의 클릭이 카드 선택으로 번지지 않게 한 번에 막는다.
|
||||
root.addEventListener("click", (event) => event.stopPropagation());
|
||||
|
||||
@@ -337,6 +365,69 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
);
|
||||
extraParts.controls.append(countMinus, countValue, countPlus);
|
||||
|
||||
// 종방향 구간값(2026-08-24 사용자) — 길이·기준측점 전·후. 세 값이 곧 이 구조물이
|
||||
// 덮는 범위이고, 그 범위에 걸린 옆 측점에 기슭막이가 자동으로 선다. 길이를 바꾸면
|
||||
// 전/후가 지금 비율대로 따라가고, 전·후를 바꾸면 길이는 둘의 합이 된다.
|
||||
let currentSpan: SpanValues | null = null;
|
||||
const spanRows: Array<{ row: HTMLElement; value: HTMLElement; field: keyof SpanValues }> = [];
|
||||
const makeSpanRow = (label: string, field: keyof SpanValues): void => {
|
||||
const parts = makeRow(label);
|
||||
const readout = document.createElement("span");
|
||||
readout.className = "b06-structure-panel__hval";
|
||||
const stepM = field === "lengthM" ? LENGTH_STEP_M : SPAN_STEP_M;
|
||||
const step = (delta: number): void => {
|
||||
if (!current || !currentSpan) return;
|
||||
const next = Math.max(0, Math.round((currentSpan[field] + delta) * 10) / 10);
|
||||
deps.setSpan(current, { [field]: next } as Partial<SpanValues>);
|
||||
};
|
||||
parts.controls.append(
|
||||
makeButton("-", `${label} ${stepM}m 줄이기`, () => step(-stepM)),
|
||||
readout,
|
||||
makeButton("+", `${label} ${stepM}m 늘리기`, () => step(stepM)),
|
||||
);
|
||||
spanRows.push({ row: parts.row, value: readout, field });
|
||||
};
|
||||
makeSpanRow("길이", "lengthM");
|
||||
makeSpanRow("기준측점 전", "beforeM");
|
||||
makeSpanRow("기준측점 후", "afterM");
|
||||
|
||||
// 연동 기슭막이 옵션 2가지(2026-08-24 사용자) — **버튼 두 개를 한 행**에 둔다.
|
||||
// 연동은 측점별, 경사(종단경사 반영)는 기슭막이 한 벌 전체 공통이다.
|
||||
const optionParts = makeRow("옵션");
|
||||
optionParts.controls.classList.add("b06-structure-panel__toggles");
|
||||
const makeToggle = (label: string, title: string, onToggle: (on: boolean) => void) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b06-structure-panel__btn b06-structure-panel__toggle";
|
||||
button.textContent = label;
|
||||
button.title = title;
|
||||
let on = true;
|
||||
button.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
on = !on;
|
||||
button.classList.toggle("is-on", on);
|
||||
button.setAttribute("aria-pressed", String(on));
|
||||
onToggle(on);
|
||||
});
|
||||
const set = (value: boolean): void => {
|
||||
on = value;
|
||||
button.classList.toggle("is-on", value);
|
||||
button.setAttribute("aria-pressed", String(value));
|
||||
};
|
||||
optionParts.controls.append(button);
|
||||
return { button, set };
|
||||
};
|
||||
const linkToggle = makeToggle(
|
||||
"연동",
|
||||
"옆 측점 기슭막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다.",
|
||||
(on) => deps.setLinked(on),
|
||||
);
|
||||
const gradeToggle = makeToggle(
|
||||
"경사",
|
||||
"종단경사 반영(3D 전용) — 켜면 3D 기슭막이가 종단경사를 따라 서고, 끄면 연장 구간 내내 Z를 동일하게 본다. 횡단도는 언제나 그 측점 계획고로 그린다.",
|
||||
(on) => deps.setFollowGrade(on),
|
||||
);
|
||||
|
||||
// 키보드 방향키 = 십자 버튼(2026-08-23 사용자). 9키(집수정)도 **십자 축만** —
|
||||
// 모서리(크기) 키는 대상 아님. 셀렉트 포커스 중엔 드롭다운 탐색에 양보한다.
|
||||
// 창은 카드마다 새로 만들어지므로 문서 리스너는 show/hide에 맞춰 붙였다 떼고,
|
||||
@@ -381,11 +472,15 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
head,
|
||||
value,
|
||||
structureRow,
|
||||
basinRow.row,
|
||||
basinExtraRow,
|
||||
materialRow,
|
||||
heightRow,
|
||||
extraRow,
|
||||
...spanRows.map((entry) => entry.row),
|
||||
optionParts.row,
|
||||
// 십자(기슭막이)·9키(집수정) 조작 세트는 **패널 맨 아래**에 모은다(2026-08-24
|
||||
// 사용자). 둘은 동시에 뜨지 않으므로 어느 쪽이 보이든 같은 자리에 온다.
|
||||
basinRow.row,
|
||||
moveRow.row,
|
||||
);
|
||||
|
||||
@@ -453,6 +548,20 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
extraRow.classList.toggle("is-hidden", key !== "outlet");
|
||||
currentCount = extra.count;
|
||||
countValue.textContent = `${extra.count}단`;
|
||||
// 구간값 — 있는 요소(유입·유출 기슭막이, 유입 집수정)에서만 세 행이 뜬다.
|
||||
// 다단(extra·bextra)은 소유 벽 연장을 그대로 따르는 파생물이라 대상이 아니다.
|
||||
currentSpan = deps.spanFor(key);
|
||||
for (const entry of spanRows) {
|
||||
entry.row.classList.toggle("is-hidden", currentSpan === null);
|
||||
if (currentSpan) entry.value.textContent = `${currentSpan[entry.field].toFixed(1)}m`;
|
||||
}
|
||||
// 연동은 링크 카드에서만, 경사는 구간값이 있는 구조물에서만. 둘 다 없으면 행 자체를 숨긴다.
|
||||
const linkState = deps.linkState();
|
||||
linkToggle.button.classList.toggle("is-hidden", linkState === null);
|
||||
linkToggle.set(linkState?.linked ?? true);
|
||||
gradeToggle.button.classList.toggle("is-hidden", currentSpan === null);
|
||||
gradeToggle.set(deps.followGrade());
|
||||
optionParts.row.classList.toggle("is-hidden", linkState === null && currentSpan === null);
|
||||
const adjust = deps.adjustFor(key);
|
||||
// 정보는 항목당 한 행(2026-08-22 사용자) — 0이면 "자동", 좌우는 안/바깥,
|
||||
// 상하는 위/아래로 적는다.
|
||||
@@ -474,6 +583,10 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
: Math.abs(adjust.d) > 1e-9
|
||||
? `${L(adjust.d > 0 ? "B06_Cross_Revet_DirDown" : "B06_Cross_Revet_DirUp")} ${Math.abs(adjust.d).toFixed(1)}m`
|
||||
: "0m");
|
||||
// 행 표시가 다 끝난 뒤에 되살린다 — 숨김 토글로 창 높이가 바뀌기 전에 넣으면
|
||||
// 브라우저가 잘라 버린다.
|
||||
const saved = panelScrollTops.get(deps.scrollKey);
|
||||
if (saved) root.scrollTop = saved;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,16 +23,18 @@ import {
|
||||
sectionModeLabel,
|
||||
type RockBoundaryControl,
|
||||
} from "./B06_Section_UI_Cross_Design";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert";
|
||||
import { computeCardCulvert, culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import type {
|
||||
ExtraWallControl,
|
||||
InletStructureControl,
|
||||
RevetLinkControl,
|
||||
RevetOffsetControl,
|
||||
StructureSpanControl,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel";
|
||||
import { structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure";
|
||||
import { attachZoomPan, buildZoomControls } 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";
|
||||
@@ -90,7 +92,11 @@ export interface StationWidthControl {
|
||||
export type {
|
||||
ExtraWallControl,
|
||||
InletStructureControl,
|
||||
RevetLinkControl,
|
||||
RevetOffsetControl,
|
||||
SpanRole,
|
||||
SpanValues,
|
||||
StructureSpanControl,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
|
||||
/** 측점별 줌·팬 상태 — 카드 재생성에도 배율 유지(2026-08-22 사용자 ③). */
|
||||
@@ -120,11 +126,16 @@ export function createCrossSectionCard(
|
||||
inletStructure?: InletStructureControl,
|
||||
/** 유출측 추가 기슭막이 개수 제어(2026-08-22). */
|
||||
extraWalls?: ExtraWallControl,
|
||||
/** 옆 측점 구조물이 이 측점까지 넘어온 경우의 링크(2026-08-24). 표시 전용. */
|
||||
/** 옆 측점 구조물이 이 측점까지 넘어온 경우의 링크(2026-08-24). */
|
||||
culvertLink?: CulvertLink,
|
||||
/** 기슭막이·집수정 구간값(길이·전·후) 제어(2026-08-24). */
|
||||
structureSpan?: StructureSpanControl,
|
||||
/** 연동·종단경사 반영 제어(2026-08-24). */
|
||||
revetLink?: RevetLinkControl,
|
||||
): CrossCardElement {
|
||||
// 링크 카드 = 구조물이 옆 측점에 서 있고 그 연장이 여기까지 온 경우. 그림만 얹고
|
||||
// 선택·조정은 막는다 — 조작값은 소유 측점 하나에서만 관리해야 한다.
|
||||
// 링크 카드 = 구조물이 옆 측점에 서 있고 그 연장이 여기까지 온 경우. 관만 숨기고
|
||||
// 선택·조정은 연다 — 연동을 풀어 이 측점 위치를 따로 잡을 수 있어야 한다
|
||||
// (2026-08-24 사용자). 길이·전/후 같은 구간 값은 소유 측점 한 곳에서 관리한다.
|
||||
const isLinkedCulvert = !section.culvert && !!culvertLink;
|
||||
// 실효 표시 반폭 — 개별값 > 전역값(2026-08-06). 절·성토선이 원지반과 만나는 지점
|
||||
// (교차점)이 반폭 밖이면 **이 카드만** 자동 줌아웃한다(2026-08-22, 판정 기준 개편
|
||||
@@ -392,6 +403,7 @@ export function createCrossSectionCard(
|
||||
inletStructure,
|
||||
extraWalls,
|
||||
culvertLink,
|
||||
revetLink,
|
||||
);
|
||||
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
|
||||
appendPavementOverlay(plotLayer, section.design, x, toDisplayY);
|
||||
@@ -414,9 +426,11 @@ export function createCrossSectionCard(
|
||||
);
|
||||
}
|
||||
// 배수관 세트 — 조정창이 쓸 카드 상태를 여기서 받아 둔다(2026-08-19).
|
||||
// 관은 소유 측점 전용이라 링크 카드에서는 길이를 비운다. 나머지 상태는 링크
|
||||
// 카드도 채운다 — 조정창을 열어 위치·연동을 만져야 한다(2026-08-24 사용자).
|
||||
culvertPipeLengthM = isLinkedCulvert ? null : (culvertLayout?.pipe.lengthM ?? null);
|
||||
culvertInletIsBasin = !isLinkedCulvert && !!culvertLayout?.basin;
|
||||
if (culvertLayout && !isLinkedCulvert) {
|
||||
culvertInletIsBasin = !!culvertLayout?.basin;
|
||||
if (culvertLayout) {
|
||||
culvertInletOptions = culvertLayout.inletOptions;
|
||||
culvertExtraState = {
|
||||
canAdd: culvertLayout.outletFill.addable,
|
||||
@@ -452,7 +466,7 @@ export function createCrossSectionCard(
|
||||
culvertLayout,
|
||||
x,
|
||||
toDisplayY,
|
||||
revetOffset && !isLinkedCulvert ? toggleRevet : undefined,
|
||||
revetOffset ? toggleRevet : undefined,
|
||||
isLinkedCulvert,
|
||||
);
|
||||
if (activeRevet) setRevetActive(activeRevet);
|
||||
@@ -558,91 +572,43 @@ export function createCrossSectionCard(
|
||||
const heightOfWall = (key: RevetKey): number => culvertWallSpecs.get(key)?.height ?? 0;
|
||||
const materialOfWall = (key: RevetKey): RevetMaterial =>
|
||||
culvertWallSpecs.get(key)?.material ?? "dry";
|
||||
// 연동 중인 링크 카드의 조작은 **소유 측점**에 실린다 — 본 기슭막이를 조절하면
|
||||
// 링크된 측점이 같이 움직인다는 뜻이다(2026-08-24 사용자). 연동을 풀면 이 카드가
|
||||
// 자기 값을 갖는다.
|
||||
const adjustTarget = (): CrossSection =>
|
||||
isLinkedCulvert && culvertLink && (revetLink?.linkedFor(section) ?? true)
|
||||
? culvertLink.source
|
||||
: section;
|
||||
const adjustOf = (key: RevetKey): WallAdjust =>
|
||||
revetOffset?.adjustFor(section, key) ?? { x: 0, d: null, h: null, m: null };
|
||||
const panel = buildStructurePanel({
|
||||
adjustFor: adjustOf,
|
||||
nudge: (key, screenDeltaM) =>
|
||||
revetOffset?.update(section.chainage_m, key, {
|
||||
x: adjustOf(key).x + screenDeltaM * outwardOf(key),
|
||||
}),
|
||||
// d 미지정(자동)이면 실제 적용 자리에서부터 움직인다(2026-08-23 — d는 0점
|
||||
// 기준 절대값이라 시작값이 필요하다).
|
||||
nudgeSlope: (key, deltaM) =>
|
||||
revetOffset?.update(section.chainage_m, key, {
|
||||
d: (adjustOf(key).d ?? culvertAppliedD.get(key) ?? 0) + deltaM,
|
||||
}),
|
||||
nudgeHeight: (key, deltaM) => {
|
||||
const current = adjustOf(key);
|
||||
const base = current.h ?? heightOfWall(key);
|
||||
revetOffset?.update(section.chainage_m, key, {
|
||||
h: Math.round((base + deltaM) * 10) / 10,
|
||||
});
|
||||
},
|
||||
materialFor: (key) => materialOfWall(key),
|
||||
setMaterial: (key, material) => revetOffset?.update(section.chainage_m, key, { m: material }),
|
||||
heightFor: (key) => heightOfWall(key),
|
||||
reset: (key) => revetOffset?.reset(section.chainage_m, key),
|
||||
pipeLengthM: () => culvertPipeLengthM,
|
||||
close: () => {
|
||||
if (activeRevet) toggleRevet(activeRevet);
|
||||
},
|
||||
structureFor: () => inletStructure?.valueFor(section) ?? "auto",
|
||||
setStructure: (value) => inletStructure?.set(section.chainage_m, value),
|
||||
basinAdjustFor: () =>
|
||||
inletStructure?.adjustFor(section) ?? {
|
||||
innerWidthM: 1,
|
||||
innerHeightM: 1.2,
|
||||
lateralM: 0,
|
||||
slopeM: 0,
|
||||
revetOffset?.adjustFor(adjustTarget(), key) ?? { x: 0, d: null, h: null, m: null };
|
||||
const panel = buildStructurePanel(
|
||||
structurePanelDeps({
|
||||
section,
|
||||
scrollKey: section.station_id,
|
||||
adjustChainage: () => adjustTarget().chainage_m,
|
||||
isLinked: isLinkedCulvert,
|
||||
revetOffset,
|
||||
inletStructure,
|
||||
extraWalls,
|
||||
structureSpan,
|
||||
revetLink,
|
||||
adjustOf,
|
||||
outwardOf,
|
||||
heightOfWall,
|
||||
materialOfWall,
|
||||
appliedD: () => culvertAppliedD,
|
||||
pipeLengthM: () => culvertPipeLengthM,
|
||||
inletIsBasin: () => culvertInletIsBasin,
|
||||
inletOptions: () => culvertInletOptions,
|
||||
extraState: () => culvertExtraState,
|
||||
basinExtraState: () => culvertBasinExtraState,
|
||||
activeRevet: () => activeRevet,
|
||||
setActiveRevet: (key) => {
|
||||
activeRevet = key;
|
||||
},
|
||||
updateBasin: (patch) =>
|
||||
inletStructure?.updateAdjust(section.chainage_m, {
|
||||
...inletStructure.adjustFor(section),
|
||||
...patch,
|
||||
}),
|
||||
// 집수정 좌우는 **화면 기준**으로 받는다(2026-08-22 사용자 확정) — 유입이 우측인
|
||||
// 측점에서 ◀가 화면 오른쪽으로 가던 반전을 없앤다. 노견 안쪽(음수)은 금지라
|
||||
// 0에서 멈추고, 멈춘 사실은 토스트로 알린다(기슭막이와 같은 규칙).
|
||||
nudgeBasinLateral: (screenDeltaM) => {
|
||||
const current = inletStructure?.adjustFor(section);
|
||||
if (!current) return;
|
||||
const next = current.lateralM + screenDeltaM * outwardOf("inlet");
|
||||
if (next < -1e-9) {
|
||||
showToast(L("B06_Cross_Revet_Limit_Inward"), "info");
|
||||
}
|
||||
inletStructure?.updateAdjust(section.chainage_m, {
|
||||
...current,
|
||||
lateralM: Math.max(0, Math.round(next * 10) / 10),
|
||||
});
|
||||
},
|
||||
resetBasin: () => inletStructure?.resetAdjust(section.chainage_m),
|
||||
// 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다(추가 벽은 항상 이동).
|
||||
canNudge: (key) => key !== "inlet" || !culvertInletIsBasin,
|
||||
optionsFor: () => culvertInletOptions,
|
||||
extraState: () => culvertExtraState,
|
||||
basinExtraState: () => culvertBasinExtraState,
|
||||
setExtraCount: (count) => {
|
||||
// 줄어들며 사라지는 벽이 선택 중이면 선택을 유출 벽으로 옮긴다(빈 대상 방지).
|
||||
if (
|
||||
activeRevet?.startsWith("extra") &&
|
||||
!activeRevet.startsWith("bextra") &&
|
||||
Number(activeRevet.slice(5)) >= count
|
||||
) {
|
||||
activeRevet = "outlet";
|
||||
revetOffset?.select(section.chainage_m, "outlet");
|
||||
}
|
||||
extraWalls?.setCount(section.chainage_m, count, "outlet");
|
||||
},
|
||||
setBasinExtraCount: (count) => {
|
||||
if (activeRevet?.startsWith("bextra") && Number(activeRevet.slice(6)) >= count) {
|
||||
activeRevet = "inlet";
|
||||
revetOffset?.select(section.chainage_m, "inlet");
|
||||
}
|
||||
extraWalls?.setCount(section.chainage_m, count, "basin");
|
||||
},
|
||||
equalizeExtras: () => extraWalls?.equalize(section.chainage_m),
|
||||
});
|
||||
toggleRevet,
|
||||
}),
|
||||
);
|
||||
showRevetControl = (visible) => panel.show(visible ? activeRevet : null);
|
||||
showRevetControl(activeRevet !== null);
|
||||
// 우측 상단 줌 버튼이 원배율에서 표시 반폭까지 다룬다(하단 ◀/▶/↺ 폐지, 2026-08-23).
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_View_Structure.ts
|
||||
* 횡단 카드 ↔ **구조물 조정창** 배선 — 카드 렌더러(`_UI_Cross_View.ts`)에서 700줄
|
||||
* 제한으로 분리했다(2026-08-24). 조정창이 부르는 동작을 제어기(기슭막이 4축·유입
|
||||
* 구조물·다단·구간값·연동)로 넘기는 얇은 층이고, 기하나 그리기는 하지 않는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { 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";
|
||||
import type { StructurePanelDeps } from "./B06_Section_UI_Cross_Structure_Panel";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { L } from "./B06_Section_UI_Section_Common";
|
||||
import type {
|
||||
ExtraWallControl,
|
||||
InletStructureControl,
|
||||
RevetLinkControl,
|
||||
RevetOffsetControl,
|
||||
SpanRole,
|
||||
StructureSpanControl,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
|
||||
/**
|
||||
* 선택된 벽이 구간값을 갖는 요소인지 — 유입·유출 기슭막이와 유입 집수정만 자기
|
||||
* 길이·전/후를 갖는다. 다단(extra·bextra)은 소유 벽 연장을 그대로 따른다.
|
||||
*/
|
||||
export function spanRoleOf(key: RevetKey, inletIsBasin = false): SpanRole | null {
|
||||
if (key === "inlet") return inletIsBasin ? "basin" : "inlet";
|
||||
if (key === "outlet") return "outlet";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조정창 배선에 필요한 카드 상태·제어기 묶음. 마지막 계산 결과(높이·재질·단 수 등)는
|
||||
* 카드가 그릴 때마다 바뀌므로 **값이 아니라 읽는 함수**로 받는다.
|
||||
*/
|
||||
export interface StructurePanelContext {
|
||||
section: CrossSection;
|
||||
/**
|
||||
* 4축·다단 조작이 실제로 실릴 측점의 누가거리. 연동 중인 링크 카드에서는 **소유
|
||||
* 측점**이다 — 연동은 "본 기슭막이를 조절하면 같이 움직인다"는 뜻이므로 조작값이
|
||||
* 한 곳에만 있어야 한다(2026-08-24 사용자). 연동을 풀면 이 카드 자신이 된다.
|
||||
*/
|
||||
adjustChainage: () => number;
|
||||
/** 이 카드가 옆 측점에서 연장돼 온 링크 카드인가. */
|
||||
isLinked: boolean;
|
||||
revetOffset?: RevetOffsetControl;
|
||||
inletStructure?: InletStructureControl;
|
||||
extraWalls?: ExtraWallControl;
|
||||
structureSpan?: StructureSpanControl;
|
||||
revetLink?: RevetLinkControl;
|
||||
adjustOf: (key: RevetKey) => WallAdjust;
|
||||
/** 화면 좌(◀) 방향을 벽 기준 부호로 환산한다. */
|
||||
outwardOf: (key: RevetKey) => number;
|
||||
heightOfWall: (key: RevetKey) => number;
|
||||
materialOfWall: (key: RevetKey) => RevetMaterial;
|
||||
/** 마지막 계산의 실제 적용 d(상하) — d 미지정 벽의 ▲▼ 시작값. */
|
||||
appliedD: () => Map<RevetKey, number>;
|
||||
pipeLengthM: () => number | null;
|
||||
inletIsBasin: () => boolean;
|
||||
inletOptions: () => { revetAllowed: boolean; basinLUAllowed: boolean };
|
||||
extraState: () => { canAdd: boolean; count: number };
|
||||
basinExtraState: () => { canAdd: boolean; count: number };
|
||||
activeRevet: () => RevetKey | null;
|
||||
setActiveRevet: (key: RevetKey | null) => void;
|
||||
toggleRevet: (key: RevetKey) => void;
|
||||
/** 창 스크롤 자리를 기억할 카드 키(측점 id). */
|
||||
scrollKey: string;
|
||||
}
|
||||
|
||||
export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDeps {
|
||||
return {
|
||||
scrollKey: ctx.scrollKey,
|
||||
adjustFor: ctx.adjustOf,
|
||||
nudge: (key, screenDeltaM) =>
|
||||
ctx.revetOffset?.update(ctx.adjustChainage(), key, {
|
||||
x: ctx.adjustOf(key).x + screenDeltaM * ctx.outwardOf(key),
|
||||
}),
|
||||
// d 미지정(자동)이면 실제 적용 자리에서부터 움직인다(2026-08-23 — d는 0점
|
||||
// 기준 절대값이라 시작값이 필요하다).
|
||||
nudgeSlope: (key, deltaM) =>
|
||||
ctx.revetOffset?.update(ctx.adjustChainage(), key, {
|
||||
d: (ctx.adjustOf(key).d ?? ctx.appliedD().get(key) ?? 0) + deltaM,
|
||||
}),
|
||||
nudgeHeight: (key, deltaM) => {
|
||||
const current = ctx.adjustOf(key);
|
||||
const base = current.h ?? ctx.heightOfWall(key);
|
||||
ctx.revetOffset?.update(ctx.adjustChainage(), key, {
|
||||
h: Math.round((base + deltaM) * 10) / 10,
|
||||
});
|
||||
},
|
||||
materialFor: (key) => ctx.materialOfWall(key),
|
||||
setMaterial: (key, material) =>
|
||||
ctx.revetOffset?.update(ctx.adjustChainage(), key, { m: material }),
|
||||
heightFor: (key) => ctx.heightOfWall(key),
|
||||
reset: (key) => ctx.revetOffset?.reset(ctx.adjustChainage(), key),
|
||||
pipeLengthM: () => ctx.pipeLengthM(),
|
||||
close: () => {
|
||||
const active = ctx.activeRevet();
|
||||
if (active) ctx.toggleRevet(active);
|
||||
},
|
||||
structureFor: () => ctx.inletStructure?.valueFor(ctx.section) ?? "auto",
|
||||
setStructure: (value) => ctx.inletStructure?.set(ctx.adjustChainage(), value),
|
||||
basinAdjustFor: () =>
|
||||
ctx.inletStructure?.adjustFor(ctx.section) ?? {
|
||||
innerWidthM: 1,
|
||||
innerHeightM: 1.2,
|
||||
lateralM: 0,
|
||||
slopeM: 0,
|
||||
},
|
||||
updateBasin: (patch) =>
|
||||
ctx.inletStructure?.updateAdjust(ctx.adjustChainage(), {
|
||||
...ctx.inletStructure.adjustFor(ctx.section),
|
||||
...patch,
|
||||
}),
|
||||
// 집수정 좌우는 **화면 기준**으로 받는다(2026-08-22 사용자 확정) — 유입이 우측인
|
||||
// 측점에서 ◀가 화면 오른쪽으로 가던 반전을 없앤다. 노견 안쪽(음수)은 금지라
|
||||
// 0에서 멈추고, 멈춘 사실은 토스트로 알린다(기슭막이와 같은 규칙).
|
||||
nudgeBasinLateral: (screenDeltaM) => {
|
||||
const current = ctx.inletStructure?.adjustFor(ctx.section);
|
||||
if (!current) return;
|
||||
const next = current.lateralM + screenDeltaM * ctx.outwardOf("inlet");
|
||||
if (next < -1e-9) {
|
||||
showToast(L("B06_Cross_Revet_Limit_Inward"), "info");
|
||||
}
|
||||
ctx.inletStructure?.updateAdjust(ctx.adjustChainage(), {
|
||||
...current,
|
||||
lateralM: Math.max(0, Math.round(next * 10) / 10),
|
||||
});
|
||||
},
|
||||
resetBasin: () => ctx.inletStructure?.resetAdjust(ctx.adjustChainage()),
|
||||
// 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다(추가 벽은 항상 이동).
|
||||
canNudge: (key) => key !== "inlet" || !ctx.inletIsBasin(),
|
||||
optionsFor: () => ctx.inletOptions(),
|
||||
extraState: () => ctx.extraState(),
|
||||
basinExtraState: () => ctx.basinExtraState(),
|
||||
setExtraCount: (count) => {
|
||||
// 줄어들며 사라지는 벽이 선택 중이면 선택을 유출 벽으로 옮긴다(빈 대상 방지).
|
||||
const active = ctx.activeRevet() ?? "";
|
||||
if (
|
||||
active.startsWith("extra") &&
|
||||
!active.startsWith("bextra") &&
|
||||
Number(active.slice(5)) >= count
|
||||
) {
|
||||
ctx.setActiveRevet("outlet");
|
||||
ctx.revetOffset?.select(ctx.section.chainage_m, "outlet");
|
||||
}
|
||||
ctx.extraWalls?.setCount(ctx.adjustChainage(), count, "outlet");
|
||||
},
|
||||
setBasinExtraCount: (count) => {
|
||||
const activeBasin = ctx.activeRevet() ?? "";
|
||||
if (activeBasin.startsWith("bextra") && Number(activeBasin.slice(6)) >= count) {
|
||||
ctx.setActiveRevet("inlet");
|
||||
ctx.revetOffset?.select(ctx.section.chainage_m, "inlet");
|
||||
}
|
||||
ctx.extraWalls?.setCount(ctx.adjustChainage(), count, "basin");
|
||||
},
|
||||
equalizeExtras: () => ctx.extraWalls?.equalize(ctx.adjustChainage()),
|
||||
// 구간값(길이·전·후) — 다단은 소유 벽 연장을 따르는 파생물이라 대상이 아니다.
|
||||
// 링크 카드에서 만져도 제어기가 소유 측점 값을 고친다(원천은 하나).
|
||||
spanFor: (key) => {
|
||||
const role = spanRoleOf(key, ctx.inletIsBasin());
|
||||
return role && ctx.structureSpan ? ctx.structureSpan.valuesFor(ctx.section, role) : null;
|
||||
},
|
||||
setSpan: (key, patch) => {
|
||||
const role = spanRoleOf(key, ctx.inletIsBasin());
|
||||
if (role) ctx.structureSpan?.update(ctx.section, role, patch);
|
||||
},
|
||||
linkState: () =>
|
||||
ctx.isLinked ? { linked: ctx.revetLink?.linkedFor(ctx.section) ?? true } : null,
|
||||
setLinked: (linked) => ctx.revetLink?.setLinked(ctx.section.chainage_m, linked),
|
||||
followGrade: () =>
|
||||
ctx.revetLink?.followGradeFor(ctx.structureSpan?.ownerOf(ctx.section) ?? ctx.section) ?? true,
|
||||
setFollowGrade: (follow) => {
|
||||
const owner = ctx.structureSpan?.ownerOf(ctx.section) ?? ctx.section;
|
||||
ctx.revetLink?.setFollowGrade(owner, follow);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -169,6 +169,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
basin_adjust: target.design?.basin_adjust,
|
||||
revet_adjust: target.design?.revet_adjust,
|
||||
extra_wall_counts: target.design?.extra_wall_counts,
|
||||
revet_link_detached: target.design?.revet_link_detached,
|
||||
revet_follow_grade: target.design?.revet_follow_grade,
|
||||
};
|
||||
sectionView.refreshCard(chainageM);
|
||||
} catch (error) {
|
||||
@@ -261,6 +263,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
basin_adjust: section.design?.basin_adjust,
|
||||
revet_adjust: section.design?.revet_adjust,
|
||||
extra_wall_counts: section.design?.extra_wall_counts,
|
||||
revet_link_detached: section.design?.revet_link_detached,
|
||||
revet_follow_grade: section.design?.revet_follow_grade,
|
||||
};
|
||||
sectionView.refreshCard(section.chainage_m);
|
||||
}
|
||||
@@ -407,6 +411,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
crossHalfWidth,
|
||||
sampledHalfWidth,
|
||||
ensureSampledWidth,
|
||||
projectId: () => projectId,
|
||||
onSaveError: (message) => showToast(`배수관 구간값 저장 실패 — ${message}`, "error"),
|
||||
});
|
||||
const stationWidthControl = stationControls.stationWidth;
|
||||
const revetOffsetControl = stationControls.revetOffset;
|
||||
@@ -423,6 +429,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
revetOffsetControl,
|
||||
stationControls.inletStructure,
|
||||
stationControls.extraWalls,
|
||||
stationControls.structureSpan,
|
||||
stationControls.revetLink,
|
||||
);
|
||||
|
||||
// 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다.
|
||||
@@ -530,6 +538,12 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
stationControls.extraCountsByChainage().forEach((counts, chainage) => {
|
||||
patchFor(chainage).extra_wall_counts = counts;
|
||||
});
|
||||
// 연동 해제(측점별)·종단경사 반영(전체 공통) — 같은 체계로 정본에 싣는다.
|
||||
stationControls.linkFlagsByChainage().forEach((flags, chainage) => {
|
||||
if (flags.detached !== undefined) patchFor(chainage).revet_link_detached = flags.detached;
|
||||
if (flags.followGrade !== undefined)
|
||||
patchFor(chainage).revet_follow_grade = flags.followGrade;
|
||||
});
|
||||
const crossPatches: CrossSectionPatch[] = [...patchByChainage.values()];
|
||||
// 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다.
|
||||
const result =
|
||||
|
||||
@@ -5,21 +5,28 @@
|
||||
* (`_UI_Page.ts`)에서 700줄 제한으로 분리했다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert";
|
||||
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type { BasinAdjust, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type {
|
||||
ExtraWallControl,
|
||||
InletStructureControl,
|
||||
RevetLinkControl,
|
||||
RevetOffsetControl,
|
||||
SpanRole,
|
||||
SpanValues,
|
||||
StationWidthControl,
|
||||
StructureSpanControl,
|
||||
} from "./B06_Section_UI_Cross_View";
|
||||
import { basinSpanOfSpec, revetSpanOfSpec } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { culvertOwnerFor } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options";
|
||||
|
||||
/** 제어가 페이지에서 가져다 쓰는 값들 — 클로저 대신 함수로 받아 결합을 끊는다. */
|
||||
export interface StationControlDeps {
|
||||
sessionKey: (
|
||||
kind: "crossw" | "revetx" | "inletstruct" | "basinadjust" | "extrawall",
|
||||
kind: "crossw" | "revetx" | "inletstruct" | "basinadjust" | "extrawall" | "revetlink",
|
||||
) => string | null;
|
||||
refreshCard: (chainageM: number) => void;
|
||||
detail: () => SectionDetailResponse | null;
|
||||
@@ -30,6 +37,10 @@ export interface StationControlDeps {
|
||||
* 재생성에 실패하면 false. 페이지가 구현한다(2026-08-23 사용자 지시).
|
||||
*/
|
||||
ensureSampledWidth: (widthM: number) => Promise<boolean>;
|
||||
/** 구간값(길이·전/후)을 되돌려 쓸 대상 프로젝트. 없으면 저장하지 않는다. */
|
||||
projectId: () => string | null;
|
||||
/** 구간값 저장 실패 알림 — 화면 토스트는 페이지가 띄운다. */
|
||||
onSaveError?: (message: string) => void;
|
||||
}
|
||||
|
||||
/** 반폭·기슭막이·유입 구조물 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */
|
||||
@@ -38,6 +49,8 @@ export interface StationControls {
|
||||
revetOffset: RevetOffsetControl;
|
||||
inletStructure: InletStructureControl;
|
||||
extraWalls: ExtraWallControl;
|
||||
structureSpan: StructureSpanControl;
|
||||
revetLink: RevetLinkControl;
|
||||
widths: Map<string, number>;
|
||||
inletStructures: Map<string, InletStructureChoice>;
|
||||
basinAdjustments: Map<string, BasinAdjust>;
|
||||
@@ -45,6 +58,10 @@ export interface StationControls {
|
||||
revetAdjustsByChainage: () => Map<number, Record<string, WallAdjust>>;
|
||||
/** 확정 payload용 — 측점별 다단 단 수. */
|
||||
extraCountsByChainage: () => Map<number, { outlet: number; basin: number }>;
|
||||
/** 확정 payload용 — 측점별 연동 해제·종단경사 반영(2026-08-24). */
|
||||
linkFlagsByChainage: () => Map<number, { detached?: boolean; followGrade?: boolean }>;
|
||||
/** 예약된 구간값 저장을 즉시 내보낸다 — 확정·임시저장 직전에 부른다. */
|
||||
flushCulvertOptions: () => Promise<void>;
|
||||
load: () => void;
|
||||
/** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */
|
||||
applyGlobalWidth: (requested: number | undefined, chainages: number[]) => void;
|
||||
@@ -177,6 +194,27 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
(a.h ?? null) === (b.h ?? null) &&
|
||||
(a.m ?? null) === (b.m ?? null);
|
||||
|
||||
/** 그 측점과 **그 구조물이 덮는 측점**을 함께 다시 그린다 — 연동된 옆 측점이
|
||||
* 같이 움직여야 한다(2026-08-24 사용자). 구조물이 없는 측점이면 자기 카드만. */
|
||||
const refreshWithLinked = (chainageM: number): void => {
|
||||
deps.refreshCard(chainageM);
|
||||
const sections = deps.detail()?.cross_sections ?? [];
|
||||
const owner = sections.find((s) => Math.abs(s.chainage_m - chainageM) < 0.01);
|
||||
const spec = owner?.culvert;
|
||||
if (!spec) return;
|
||||
const reach = Math.max(
|
||||
...[spec.inlet, spec.outlet].flatMap((side) => {
|
||||
const span = side?.structure === "집수정" ? basinSpanOfSpec(side) : revetSpanOfSpec(side);
|
||||
return [span.beforeM, span.afterM];
|
||||
}),
|
||||
);
|
||||
for (const other of sections) {
|
||||
if (other === owner) continue;
|
||||
if (Math.abs(other.chainage_m - chainageM) <= reach + 1e-9)
|
||||
deps.refreshCard(other.chainage_m);
|
||||
}
|
||||
};
|
||||
|
||||
const revetOffsetControl: RevetOffsetControl = {
|
||||
// 세션 → 정본(design.revet_adjust) → 자동. 확정된 조작값이 재접근·3D에서 살아난다
|
||||
// (2026-08-24 사용자: 3D는 종단·횡단 확정 뒤의 최종 산출물).
|
||||
@@ -209,12 +247,12 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
m: patch.m === undefined ? current.m : patch.m,
|
||||
};
|
||||
storeAdjust(key, next);
|
||||
deps.refreshCard(chainageM);
|
||||
refreshWithLinked(chainageM);
|
||||
},
|
||||
reset: (chainageM, role) => {
|
||||
revetShifts.delete(revetKey(chainageM, role));
|
||||
persistRevetShifts();
|
||||
deps.refreshCard(chainageM);
|
||||
refreshWithLinked(chainageM);
|
||||
},
|
||||
};
|
||||
/* ── 유입측 구조물 형식(2026-08-22 사용자 — 드롭다운) ────────────────
|
||||
@@ -336,7 +374,7 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
}
|
||||
persistRevetShifts();
|
||||
persistExtraCounts();
|
||||
deps.refreshCard(chainageM);
|
||||
refreshWithLinked(chainageM);
|
||||
},
|
||||
syncCount: (chainageM, built, side = "outlet") => {
|
||||
const key = extraKey(chainageM, side);
|
||||
@@ -366,7 +404,7 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
set: (chainageM, value) => {
|
||||
inletStructures.set(chainageM.toFixed(2), value);
|
||||
persistInletStructures();
|
||||
deps.refreshCard(chainageM);
|
||||
refreshWithLinked(chainageM);
|
||||
},
|
||||
updateAdjust: (chainageM, patch) => {
|
||||
const key = chainageM.toFixed(2);
|
||||
@@ -379,13 +417,185 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
slopeM: Math.max(0, clampMove(next.slopeM)),
|
||||
});
|
||||
persistBasinAdjustments();
|
||||
deps.refreshCard(chainageM);
|
||||
refreshWithLinked(chainageM);
|
||||
},
|
||||
resetAdjust: (chainageM) => {
|
||||
basinAdjustments.set(chainageM.toFixed(2), { ...DEFAULT_BASIN_ADJUST });
|
||||
persistBasinAdjustments();
|
||||
refreshWithLinked(chainageM);
|
||||
},
|
||||
};
|
||||
|
||||
/* ── 구조물 구간값(길이·전/후)과 연동 옵션 ─────────────────────────────
|
||||
* 2026-08-24 사용자. 구간값의 원천은 배수관 옵션(`pipe_points`) 하나다 — 여기서는
|
||||
* 캐시(detail의 culvert 스펙)를 고쳐 즉시 보여주고, 저장은 페이지가 예약해 늦게
|
||||
* 한 번 내보낸다. 링크 측점에서 만져도 바뀌는 것은 **소유 측점** 값이다.
|
||||
* 연동은 측점별(세션 → 정본 → 기본 켬), 종단경사 반영은 소유 측점에 하나. */
|
||||
const culvertOptions = createCulvertOptionWriter(deps.projectId, deps.onSaveError);
|
||||
const linkDetached = new Map<string, boolean>();
|
||||
const followGrades = new Map<string, boolean>();
|
||||
const chainageKey = (chainageM: number): string => chainageM.toFixed(2);
|
||||
|
||||
/** 두 체크박스는 한 세션 항목에 같이 담는다 — 확정 전 리로드에도 살아남는다. */
|
||||
function loadLinkFlags(): void {
|
||||
linkDetached.clear();
|
||||
followGrades.clear();
|
||||
const key = deps.sessionKey("revetlink");
|
||||
if (!key) return;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(key);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw) as {
|
||||
detached?: Record<string, boolean>;
|
||||
grade?: Record<string, boolean>;
|
||||
};
|
||||
Object.entries(parsed.detached ?? {}).forEach(([at, value]) => linkDetached.set(at, !!value));
|
||||
Object.entries(parsed.grade ?? {}).forEach(([at, value]) => followGrades.set(at, !!value));
|
||||
} catch {
|
||||
/* 손상된 세션 값은 무시 — 정본·기본값으로 재시작. */
|
||||
}
|
||||
}
|
||||
|
||||
function persistLinkFlags(): void {
|
||||
const key = deps.sessionKey("revetlink");
|
||||
if (!key) return;
|
||||
window.sessionStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
detached: Object.fromEntries(linkDetached),
|
||||
grade: Object.fromEntries(followGrades),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 이 카드가 구간값을 만질 대상 — 링크면 소유 측점, 아니면 자기 자신. */
|
||||
const ownerOf = (section: CrossSection): CrossSection | null => {
|
||||
if (section.culvert) return section;
|
||||
const sections = deps.detail()?.cross_sections ?? [];
|
||||
return culvertOwnerFor(section, sections) ?? null;
|
||||
};
|
||||
|
||||
/** 역할별 옵션 키 — 배수관 레지스트리 옵션 이름 그대로다. */
|
||||
const OPTION_KEYS: Record<SpanRole, { length: string; before: string; after: string }> = {
|
||||
inlet: {
|
||||
length: "inlet_revet_length_m",
|
||||
before: "inlet_revet_before_m",
|
||||
after: "inlet_revet_after_m",
|
||||
},
|
||||
outlet: {
|
||||
length: "outlet_revet_length_m",
|
||||
before: "outlet_revet_before_m",
|
||||
after: "outlet_revet_after_m",
|
||||
},
|
||||
basin: {
|
||||
length: "inlet_basin_length_m",
|
||||
before: "inlet_basin_before_m",
|
||||
after: "inlet_basin_after_m",
|
||||
},
|
||||
};
|
||||
|
||||
const spanValuesOf = (owner: CrossSection, role: SpanRole): SpanValues | null => {
|
||||
const spec = role === "outlet" ? owner.culvert?.outlet : owner.culvert?.inlet;
|
||||
if (!spec) return null;
|
||||
const span = role === "basin" ? basinSpanOfSpec(spec) : revetSpanOfSpec(spec);
|
||||
return {
|
||||
lengthM: Math.round((span.beforeM + span.afterM) * 10) / 10,
|
||||
beforeM: Math.round(span.beforeM * 10) / 10,
|
||||
afterM: Math.round(span.afterM * 10) / 10,
|
||||
};
|
||||
};
|
||||
|
||||
const structureSpanControl: StructureSpanControl = {
|
||||
ownerOf,
|
||||
valuesFor: (section, role) => {
|
||||
const owner = ownerOf(section);
|
||||
return owner ? spanValuesOf(owner, role) : null;
|
||||
},
|
||||
update: (section, role, patch) => {
|
||||
const owner = ownerOf(section);
|
||||
if (!owner?.culvert) return;
|
||||
const current = spanValuesOf(owner, role);
|
||||
if (!current) return;
|
||||
// 길이를 바꾸면 **늘어난 몫만** 지금 비율대로 나눠 담는다 — 매번 총길이에서
|
||||
// 비율로 다시 계산하면 0.1m 반올림이 쌓여 5.0/5.0이 5.6/5.4로 어긋난다
|
||||
// (2026-08-24 화면 실측). 전·후를 바꾸면 길이는 둘의 합이다.
|
||||
let { beforeM, afterM } = current;
|
||||
if (patch.lengthM !== undefined) {
|
||||
const total = Math.max(patch.lengthM, 0);
|
||||
const delta = total - current.lengthM;
|
||||
const ratio = current.lengthM > 1e-9 ? current.beforeM / current.lengthM : 0.5;
|
||||
beforeM = Math.max(Math.round((current.beforeM + delta * ratio) * 10) / 10, 0);
|
||||
afterM = Math.max(Math.round((total - beforeM) * 10) / 10, 0);
|
||||
}
|
||||
if (patch.beforeM !== undefined) beforeM = Math.max(patch.beforeM, 0);
|
||||
if (patch.afterM !== undefined) afterM = Math.max(patch.afterM, 0);
|
||||
const lengthM = Math.round((beforeM + afterM) * 10) / 10;
|
||||
const spec = role === "outlet" ? owner.culvert.outlet : owner.culvert.inlet;
|
||||
const keys = OPTION_KEYS[role];
|
||||
if (role === "basin") {
|
||||
spec.basin_length_m = lengthM;
|
||||
spec.basin_before_m = beforeM;
|
||||
spec.basin_after_m = afterM;
|
||||
} else {
|
||||
spec.revet_length_m = lengthM;
|
||||
spec.revet_before_m = beforeM;
|
||||
spec.revet_after_m = afterM;
|
||||
}
|
||||
culvertOptions.queue(owner.chainage_m, {
|
||||
[keys.length]: lengthM,
|
||||
[keys.before]: beforeM,
|
||||
[keys.after]: afterM,
|
||||
});
|
||||
// 연장이 바뀌면 링크되는 옆 측점 목록이 달라진다. 다시 그릴 대상은 **옛 연장과
|
||||
// 새 연장을 합친 구간**뿐이다 — 측점 23개를 통째로 다시 그리면 조정창이 매번
|
||||
// 새로 만들어져 연타한 +/-가 중간에 삼켜진다(2026-08-24 화면 실측: 3번 눌러
|
||||
// 2번만 반영).
|
||||
const reach = Math.max(current.beforeM, current.afterM, beforeM, afterM);
|
||||
for (const other of deps.detail()?.cross_sections ?? []) {
|
||||
if (Math.abs(other.chainage_m - owner.chainage_m) <= reach + 1e-9) {
|
||||
deps.refreshCard(other.chainage_m);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const revetLinkControl: RevetLinkControl = {
|
||||
linkedFor: (section) => {
|
||||
const session = linkDetached.get(chainageKey(section.chainage_m));
|
||||
if (session !== undefined) return !session;
|
||||
return section.design?.revet_link_detached !== true;
|
||||
},
|
||||
setLinked: (chainageM, linked) => {
|
||||
linkDetached.set(chainageKey(chainageM), !linked);
|
||||
persistLinkFlags();
|
||||
deps.refreshCard(chainageM);
|
||||
},
|
||||
followGradeFor: (section) => {
|
||||
const session = followGrades.get(chainageKey(section.chainage_m));
|
||||
if (session !== undefined) return session;
|
||||
return section.design?.revet_follow_grade !== false;
|
||||
},
|
||||
setFollowGrade: (section, follow) => {
|
||||
followGrades.set(chainageKey(section.chainage_m), follow);
|
||||
persistLinkFlags();
|
||||
// 종단경사 반영은 그 기슭막이가 덮는 측점들의 표고를 바꾼다 — 소유 측점과 그
|
||||
// 연장 안의 측점만 다시 그린다.
|
||||
const spec = section.culvert;
|
||||
const reach = spec
|
||||
? Math.max(
|
||||
...[spec.inlet, spec.outlet].flatMap((side) => {
|
||||
const span =
|
||||
side?.structure === "집수정" ? basinSpanOfSpec(side) : revetSpanOfSpec(side);
|
||||
return [span.beforeM, span.afterM];
|
||||
}),
|
||||
)
|
||||
: 0;
|
||||
for (const other of deps.detail()?.cross_sections ?? []) {
|
||||
if (Math.abs(other.chainage_m - section.chainage_m) <= reach + 1e-9) {
|
||||
deps.refreshCard(other.chainage_m);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -393,6 +603,8 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
revetOffset: revetOffsetControl,
|
||||
inletStructure: inletStructureControl,
|
||||
extraWalls: extraWallControl,
|
||||
structureSpan: structureSpanControl,
|
||||
revetLink: revetLinkControl,
|
||||
widths: stationWidths,
|
||||
inletStructures,
|
||||
basinAdjustments,
|
||||
@@ -409,6 +621,18 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
});
|
||||
return result;
|
||||
},
|
||||
// 확정 payload용 — 측점별 연동 해제·종단경사 반영 플래그.
|
||||
linkFlagsByChainage: () => {
|
||||
const result = new Map<number, { detached?: boolean; followGrade?: boolean }>();
|
||||
const put = (key: string, patch: { detached?: boolean; followGrade?: boolean }): void => {
|
||||
const value = Number(key);
|
||||
if (!Number.isFinite(value)) return;
|
||||
result.set(value, { ...(result.get(value) ?? {}), ...patch });
|
||||
};
|
||||
linkDetached.forEach((detached, key) => put(key, { detached }));
|
||||
followGrades.forEach((followGrade, key) => put(key, { followGrade }));
|
||||
return result;
|
||||
},
|
||||
extraCountsByChainage: () => {
|
||||
const result = new Map<number, { outlet: number; basin: number }>();
|
||||
extraCounts.forEach((count, key) => {
|
||||
@@ -421,12 +645,14 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
});
|
||||
return result;
|
||||
},
|
||||
flushCulvertOptions: () => culvertOptions.flush(),
|
||||
load: () => {
|
||||
loadStationWidths();
|
||||
loadRevetShifts();
|
||||
loadInletStructures();
|
||||
loadBasinAdjustments();
|
||||
loadExtraCounts();
|
||||
loadLinkFlags();
|
||||
},
|
||||
applyGlobalWidth: (requested, chainages) => {
|
||||
stationWidths.clear();
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
type ExtraWallControl,
|
||||
type InletStructureControl,
|
||||
type RevetOffsetControl,
|
||||
type RevetLinkControl,
|
||||
type StructureSpanControl,
|
||||
crossCardNaturalHeight,
|
||||
type CrossCardElement,
|
||||
type StationWidthControl,
|
||||
@@ -123,6 +125,10 @@ export function createSectionView(
|
||||
inletStructure?: InletStructureControl,
|
||||
/** 유출측 추가 기슭막이 개수 제어(2026-08-22). */
|
||||
extraWalls?: ExtraWallControl,
|
||||
/** 기슭막이·집수정 구간값(길이·전·후) 제어(2026-08-24). */
|
||||
structureSpan?: StructureSpanControl,
|
||||
/** 연동·종단경사 반영 제어(2026-08-24). */
|
||||
revetLink?: RevetLinkControl,
|
||||
): SectionViewController {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b06-section";
|
||||
@@ -397,6 +403,8 @@ export function createSectionView(
|
||||
inletStructure,
|
||||
extraWalls,
|
||||
culvertLinkFor(section),
|
||||
structureSpan,
|
||||
revetLink,
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,16 +1,39 @@
|
||||
/* 구조물 위치 조정 오버레이 창. */
|
||||
/* 구조물 조정 오버레이 — **위쪽 기준**으로 붙인다(2026-08-24 사용자). 아래를 기준으로
|
||||
* 두면 높이가 낮은 횡단도에서 창이 카드 위로 넘쳐 **제목·값 줄이 잘린다**. 카드 안에
|
||||
* 넘칠 만큼 길어지면 창 안에서 스크롤한다. z-index는 카드 안 오버레이 중 최상단. */
|
||||
.b06-structure-panel {
|
||||
position: absolute;
|
||||
bottom: var(--spacing-8);
|
||||
top: var(--spacing-8);
|
||||
left: var(--spacing-8);
|
||||
z-index: 3;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
max-height: calc(100% - 2 * var(--spacing-8));
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-danger);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: color-mix(in srgb, var(--color-surface-raised) 94%, transparent);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* 연동·경사 — 한 행에 버튼 두 개(2026-08-24 사용자). 켜짐은 채운 색으로 구분한다. */
|
||||
.b06-structure-panel__controls.b06-structure-panel__toggles {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.b06-structure-panel__toggle {
|
||||
width: auto;
|
||||
padding: 0 var(--spacing-8);
|
||||
}
|
||||
|
||||
.b06-structure-panel__toggle.is-on {
|
||||
border-color: var(--color-danger);
|
||||
background: var(--color-danger);
|
||||
color: var(--color-text-inverse, #fff);
|
||||
}
|
||||
|
||||
.b06-structure-panel.is-hidden,
|
||||
|
||||
Reference in New Issue
Block a user