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:
2026-08-24 19:26:35 +09:00
co-authored by Claude Opus 5
parent 83eb58a251
commit cd8d14f105
21 changed files with 991 additions and 174 deletions
@@ -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": "유입 기슭막이 형태",
+1 -1
View File
@@ -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;
},
};
}
+13 -1
View File
@@ -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 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */
+9 -1
View File
@@ -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(
+2
View File
@@ -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
+4
View File
@@ -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):
+7 -17
View File
@@ -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);
}
+127 -43
View File
@@ -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;
},
};
}
+56 -90
View File
@@ -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);
},
};
}
+14
View File
@@ -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,