diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 13d08cb2..28d71984 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -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": "유입 기슭막이 형태", diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index a37d3d83..9d30bc84 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -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 { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Frames.ts b/B05_Profile/B05_Profile_UI_Corridor_Frames.ts index ff547d56..562d141e 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Frames.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Frames.ts @@ -45,6 +45,19 @@ export function buildPieceControls( }) .filter((entry): entry is NonNullable => 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); diff --git a/B05_Profile/B05_Profile_UI_Corridor_Station.ts b/B05_Profile/B05_Profile_UI_Corridor_Station.ts index 0e39e2b6..c8562acc 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Station.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Station.ts @@ -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) { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 820aab4c..99975f49 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -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; + +export interface CulvertOptionWriter { + /** 측점 옵션을 예약한다. 같은 측점의 앞선 예약과는 합쳐진다. */ + queue: (chainageM: number, patch: CulvertOptionPatch) => void; + /** 예약분을 즉시 내보낸다(확정·임시저장 직전에 쓴다). */ + flush: () => Promise; +} + +/** + * 저장기 하나를 만든다. `projectId`가 없으면 아무것도 하지 않는다(조회 전용 상태). + * `onError`는 저장 실패를 화면에 알리는 쪽이 구현한다. + */ +export function createCulvertOptionWriter( + projectId: () => string | null, + onError?: (message: string) => void, +): CulvertOptionWriter { + /** 측점(누가거리 문자열) → 아직 안 보낸 옵션 조각. */ + const pending = new Map(); + let timer: ReturnType | null = null; + let inFlight: Promise = Promise.resolve(); + + const keyOf = (chainageM: number): string => chainageM.toFixed(2); + + async function send(): Promise { + 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; + }, + }; +} diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 7a06807c..0b2c244e 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -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; /** 다단 기슭막이 단 수 — 유출 성토부·집수정 계류측. */ 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; extra_wall_counts?: StoredExtraWallCounts; + /** 연동 해제(측점별)·종단경사 반영(전체 공통) — 2026-08-24 사용자. */ + revet_link_detached?: boolean; + revet_follow_grade?: boolean; } /** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */ diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index fcec3dc9..2e192463 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -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( diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index f696203d..e5dc4e8a 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -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] diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index a9976e5c..f618bbf1 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -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 diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 4a210cff..03e13bd9 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -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): diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index ef9fbe51..c90ac42f 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -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 트림과 + // 면적 개편이 그 값을 쓴다. 여기서는 도면에 얹지 않을 뿐이다. }; // ①·② 구조물 — 유출측(추가 벽 포함)이 먼저 서고, 그 다음 유입측. diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index 1eb1b50b..7884ec2e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -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); +} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index f72d353f..7f54b88b 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -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) => 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(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 = {}; - for (const [key, entry] of Object.entries(value as Record)) { - 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( diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index aa5c8d46..ed433ed2 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -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) => 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(); + /** 구조물 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */ 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); + }; + 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; }, }; } diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 1faa69e5..06feff46 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -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). diff --git a/B06_Section/B06_Section_UI_Cross_View_Structure.ts b/B06_Section/B06_Section_UI_Cross_View_Structure.ts new file mode 100644 index 00000000..b106fd92 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_View_Structure.ts @@ -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; + 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); + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 191ece67..e9fc8f5c 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -169,6 +169,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { 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 { 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 { 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 { revetOffsetControl, stationControls.inletStructure, stationControls.extraWalls, + stationControls.structureSpan, + stationControls.revetLink, ); // 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다. @@ -530,6 +538,12 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { 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 = diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index e53fdfb2..40e049bc 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -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; + /** 구간값(길이·전/후)을 되돌려 쓸 대상 프로젝트. 없으면 저장하지 않는다. */ + 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; inletStructures: Map; basinAdjustments: Map; @@ -45,6 +58,10 @@ export interface StationControls { revetAdjustsByChainage: () => Map>; /** 확정 payload용 — 측점별 다단 단 수. */ extraCountsByChainage: () => Map; + /** 확정 payload용 — 측점별 연동 해제·종단경사 반영(2026-08-24). */ + linkFlagsByChainage: () => Map; + /** 예약된 구간값 저장을 즉시 내보낸다 — 확정·임시저장 직전에 부른다. */ + flushCulvertOptions: () => Promise; 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(); + const followGrades = new Map(); + 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; + grade?: Record; + }; + 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 = { + 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(); + 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(); 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(); diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index edf4c842..83f8fa18 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -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, ); /** diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css index 8a0ad7a5..9a66455a 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Controls.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css @@ -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,