feat(B06·B05): BOX암거 조정창 · 구체 기울기 · 3D 벽 구간 정리

조정창에서 좌·우 끝을 따로 잡는다 — ◀▶는 구체 길이, ▲▼는 그 끝의 구체 표고를
0.1m씩 움직인다. 좌·우 표고가 달라지면 구체가 기울고, 날개벽 구간 바닥(에이프런)은
각 끝 표고에서 수평을 지킨다(2026-08-25 사용자).

- `computeBoxLayout`이 좌·우를 따로 푼다: 끝 표고·길이·성토선·에이프런
- 성토선은 그쪽 표고 기준으로 물매 1:1.2를 지키므로 표고를 내리면 구체가 길어진다
- 조정창 `_Cross_Box_Panel.ts` + 제어 `createBoxControls` + `design.box_adjust` 저장
  (세션 → 캐시 → 확정 payload, 세월교와 같은 체계)
- 3D: 구체 밖(날개벽 구간)에 서 있던 벽을 없앴다 — 천장 없는 통로가 생기던 자리는
  날개벽 몫이다. 이제 상판·구체 저판·측벽 2매·에이프런 2매 + 날개벽 4매로 나뉜다
- 카드 렌더러의 구체 배선(선택 토글·강조·조정창)을 `_Cross_View_Bodies.ts`로 분리,
  연동 플래그 세션은 `_Page_Link_Session.ts`로 분리(700줄 제한)

검증(10+0.9): 구체 5.34m·수평 → ◀ 1회 5.44m → ▼ 2회 표고 −0.2m·물매 1:28.4,
에이프런 두 장 모두 수평 유지, ↺ 원복. 3D 솔리드 10개(측벽 링 0.2m).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-25 15:03:53 +09:00
co-authored by Claude Opus 5
parent 2fe8f617da
commit a87182496e
20 changed files with 982 additions and 310 deletions
@@ -35,6 +35,7 @@ import {
splitByBand,
} from "./B05_Profile_UI_Corridor_Structures_Box";
import type { SectionPolygon } from "./B05_Profile_UI_Corridor_Structures_Box";
import { DEFAULT_BOX_SIDE_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Box";
/** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */
export interface StructureFrame {
@@ -335,7 +336,16 @@ export function buildCorridorStructures(
}
};
if (section.box) solids.push(...boxSolids(section, frameAt, stationFrame));
if (section.box) {
// 3D는 **정본만** 읽는다(2026-08-24 규칙) — 확정 전 세션 값은 반영하지 않는다.
const stored = section.design?.box_adjust;
solids.push(
...boxSolids(section, frameAt, stationFrame, {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(stored?.left ?? {}) },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(stored?.right ?? {}) },
}),
);
}
if (fordLayout) {
// 구체는 월류 폭만큼 도로 방향으로 이어지고 기준 측점 전후로 절반씩 걸친다.
@@ -457,6 +467,11 @@ export function structureHashParts(section: CrossSection): Array<string | number
box.wing_in.height_m ?? "",
box.wing_out.slab_extend_m,
box.wing_out.height_m ?? "",
// 조작값이 정본에 실리므로 해시에도 넣는다 — 안 넣으면 저장 코리도가 안 만료된다.
...(["left", "right"] as const).map((side) => {
const value = section.design?.box_adjust?.[side];
return value ? `${side}:${value.lengthM},${value.riseM}` : "";
}),
];
}
const ford = section.ford;
@@ -13,6 +13,7 @@
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import { computeBoxLayout } from "../B06_Section/B06_Section_UI_Cross_Box";
import type { BoxAdjust } from "../B06_Section/B06_Section_UI_Cross_Box";
import type {
CorridorStructure,
RouteFrame,
@@ -108,78 +109,110 @@ export function boreRingChainages(
return [...values].sort((a, b) => a - b);
}
/** BOX암거 3D 솔리드(구체 셸 + 날개벽 4매). 기하가 부족하면 빈 배열. */
/** 링 누가거리 목록 → 프레임(실패하면 측점 프레임으로 대체). */
function ringsAt(
chainages: number[],
frameAt: (chainageM: number) => RouteFrame | null,
fallback: StructureFrame,
): StructureFrame[] {
return chainages.map((at) => {
const frame = frameAt(at);
return frame
? { cx: frame.cx, cy: frame.cy, leftX: frame.leftX, leftY: frame.leftY, dz: 0 }
: fallback;
});
}
/** 단면 폴리곤을 링 수만큼 복제한다(구체는 링마다 단면이 같다). */
function repeat(
points: Array<{ offset: number; elevation: number }>,
count: number,
): SectionPolygon[] {
const polygon: SectionPolygon = points.map((point) => [point.offset, point.elevation]);
return Array.from({ length: count }, () => polygon);
}
/**
* BOX암거 3D 솔리드 — 상판·구체 저판·측벽 2매·에이프런 2매 + 날개벽 4매.
*
* 구체 밖(날개벽 구간)에는 **벽을 세우지 않는다**(2026-08-25 사용자) — 천장 없는
* 통로가 생기던 자리는 날개벽이 맡는다. 그래서 측벽은 구체 폭 구간에만 선다.
*/
export function boxSolids(
section: CrossSection,
frameAt: (chainageM: number) => RouteFrame | null,
stationFrame: StructureFrame,
adjust?: BoxAdjust,
): CorridorStructure[] {
const box = section.box;
const layout = computeBoxLayout(section, section.samples);
const layout = computeBoxLayout(section, section.samples, adjust);
if (!box || !layout) return [];
const chainage = section.chainage_m;
const halfSpan = box.span_m / 2;
const innerHalf = box.inner_width_m / 2;
const barrelTop = layout.topElevation - box.top_thickness_m;
const barrelBottom = barrelTop - box.inner_height_m;
const leftEdge = layout.topSlab[0].offset;
const rightEdge = layout.topSlab[1].offset;
const slabLeft = layout.bottomSlab[0].offset;
const slabRight = layout.bottomSlab[1].offset;
// 측벽 자리는 틈 0(속 참), 내공 구간만 벌린다. 벽면을 수직으로 세우려고 경계
// 누가거리마다 링을 두 장 겹쳐 둔다(길이 0 구간 = 벽 안쪽 면).
const ringPlan: Array<{ at: number; open: boolean }> = [
{ at: chainage - halfSpan, open: false },
{ at: chainage - innerHalf, open: false },
{ at: chainage - innerHalf, open: true },
{ at: chainage + innerHalf, open: true },
{ at: chainage + innerHalf, open: false },
{ at: chainage + halfSpan, open: false },
const bodyChainages = [
chainage - halfSpan,
chainage - innerHalf,
chainage + innerHalf,
chainage + halfSpan,
];
const rings = ringPlan.map(({ at }) => {
const frame = frameAt(at);
return frame
? { cx: frame.cx, cy: frame.cy, leftX: frame.leftX, leftY: frame.leftY, dz: 0 }
: stationFrame;
});
const upper: SectionPolygon[] = [];
const lower: SectionPolygon[] = [];
for (const { open } of ringPlan) {
const band: VoidBand = open
? { bottom: barrelBottom, top: barrelTop }
: { bottom: barrelTop, top: barrelTop };
upper.push([
[leftEdge, layout.topElevation],
[rightEdge, layout.topElevation],
[rightEdge, band.top],
[leftEdge, band.top],
]);
lower.push([
[slabLeft, band.bottom],
[slabRight, band.bottom],
[slabRight, layout.bottomElevation],
[slabLeft, layout.bottomElevation],
]);
}
const bodyRings = ringsAt(bodyChainages, frameAt, stationFrame);
const solids: CorridorStructure[] = [
{ chainage_m: chainage, kind: "basin", polygons: upper, rings },
{ chainage_m: chainage, kind: "basin", polygons: lower, rings },
// 상판·저판은 구체 폭 전체에 걸친다. 좌·우 표고가 다르면 폴리곤째 기울어 있다.
{
chainage_m: chainage,
kind: "basin",
polygons: repeat(layout.topSlab, bodyRings.length),
rings: bodyRings,
},
{
chainage_m: chainage,
kind: "basin",
polygons: repeat(layout.bottomSlab, bodyRings.length),
rings: bodyRings,
},
];
solids.push(...wingSolids(section, layout, frameAt, stationFrame));
// 측벽 2매 — 내공 단면을 구체 폭의 바깥 구간에만 세운다(천장 아래 벽).
for (const sign of [-1, 1]) {
const wallRings = ringsAt(
[chainage + sign * halfSpan, chainage + sign * innerHalf],
frameAt,
stationFrame,
);
solids.push({
chainage_m: chainage,
kind: "basin",
polygons: repeat(layout.barrel, wallRings.length),
rings: wallRings,
});
}
// 에이프런 2매 — 날개벽 구간 바닥, 각 끝 표고에서 수평.
for (const side of layout.sides) {
if (!side.apron.length) continue;
solids.push({
chainage_m: chainage,
kind: "basin",
polygons: repeat(side.apron, bodyRings.length),
rings: bodyRings,
});
}
solids.push(...wingSolids(section, layout, frameAt));
return solids;
}
/**
* 날개벽 4매 — 박스 네 모서리에서 각도만큼 벌어져 나간다(2026-08-25 사용자: 3D에
* 날개벽과 바닥이 있어야 한다). 노선이 아니라 **날개 축**을 따라 프레임을 만들어
* 같은 로프트에 태우고, 높이는 구체 높이에서 짧은쪽 높이로 체감시킨다.
* 같은 로프트에 태우고, 높이는 그쪽 구체 높이에서 짧은쪽 높이로 체감시킨다.
*/
function wingSolids(
section: CrossSection,
layout: NonNullable<ReturnType<typeof computeBoxLayout>>,
frameAt: (chainageM: number) => RouteFrame | null,
stationFrame: StructureFrame,
): CorridorStructure[] {
const box = section.box;
if (!box) return [];
@@ -187,7 +220,7 @@ function wingSolids(
const here = frameAt(chainage);
const ahead = frameAt(chainage + 1) ?? here;
if (!here || !ahead) return [];
// 노선 진행 방향 — 앞 점 프레임과의 차이. 실패하면 좌향의 법선으로 대체한다.
// 노선 진행 방향 — 앞 점 프레임과의 차이. 실패하면 좌향의 법선으로 대체한다.
let dirX = ahead.cx - here.cx;
let dirY = ahead.cy - here.cy;
const norm = Math.hypot(dirX, dirY);
@@ -203,20 +236,18 @@ function wingSolids(
const thickness = box.wall_thickness_m;
const solids: CorridorStructure[] = [];
for (const side of ["left", "right"] as const) {
const wing = (side === "left") === inletOnLeft ? box.wing_in : box.wing_out;
for (const side of layout.sides) {
const wing = (side.role === "left") === inletOnLeft ? box.wing_in : box.wing_out;
if (!wing.installed) continue;
const length = Math.max(wing.length_m ?? 0, 0);
if (length <= 0.05) continue;
const angle = ((wing.angle_deg ?? 45) * Math.PI) / 180;
// 구체 축(= 좌향) 기준으로 벌어진다. 박스 양 끝에서 도로 앞·뒤로 한 장씩.
const axisSign = side === "left" ? 1 : -1;
const edgeOffset = side === "left" ? layout.topSlab[0].offset : layout.topSlab[1].offset;
const height0 = layout.topElevation - layout.bottomElevation;
const axisSign = side.role === "left" ? 1 : -1;
const height0 = side.topElevation - side.bottomElevation;
const height1 = Math.max(wing.height_m ?? height0, 0.3);
for (const along of [1, -1]) {
const startX = here.cx + here.leftX * edgeOffset + dirX * along * halfSpan;
const startY = here.cy + here.leftY * edgeOffset + dirY * along * halfSpan;
const startX = here.cx + here.leftX * side.offset + dirX * along * halfSpan;
const startY = here.cy + here.leftY * side.offset + dirY * along * halfSpan;
// 날개 방향 = 구체 축을 도로 방향으로 각도만큼 튼 단위벡터.
const wingX = here.leftX * axisSign * Math.cos(angle) + dirX * along * Math.sin(angle);
const wingY = here.leftY * axisSign * Math.cos(angle) + dirY * along * Math.sin(angle);
@@ -228,25 +259,18 @@ function wingSolids(
const steps = 4;
for (let i = 0; i <= steps; i += 1) {
const t = (length * i) / steps;
rings.push({
cx: startX + ux * t,
cy: startY + uy * t,
// 패널 두께 방향 = 날개 축의 법선.
leftX: -uy,
leftY: ux,
dz: 0,
});
const top = layout.bottomElevation + height0 + ((height1 - height0) * i) / steps;
// 패널 두께 방향 = 날개 축의 법선.
rings.push({ cx: startX + ux * t, cy: startY + uy * t, leftX: -uy, leftY: ux, dz: 0 });
const top = side.bottomElevation + height0 + ((height1 - height0) * i) / steps;
polygons.push([
[-thickness / 2, top],
[thickness / 2, top],
[thickness / 2, layout.bottomElevation],
[-thickness / 2, layout.bottomElevation],
[thickness / 2, side.bottomElevation],
[-thickness / 2, side.bottomElevation],
]);
}
solids.push({ chainage_m: chainage, kind: "revet", polygons, rings });
}
}
if (!solids.length && stationFrame) return [];
return solids;
}
+15
View File
@@ -326,6 +326,18 @@ export interface StoredFordAdjust {
outlet: StoredFordWallAdjust;
}
/** BOX암거 한쪽 끝의 저장형 조작값(2026-08-25). */
export interface StoredBoxSideAdjust {
lengthM: number;
riseM: number;
}
/** BOX암거 측점의 저장형 조작값 — 좌·우 끝을 따로 담는다. */
export interface StoredBoxAdjust {
left: StoredBoxSideAdjust;
right: StoredBoxSideAdjust;
}
/** 측점 표준횡단 설계 계산 결과(잠정치). data.design에 저장되는 구조와 동일. */
export interface CrossDesign {
inlet_structure?: "auto" | "revet" | "I" | "L" | "U";
@@ -337,6 +349,8 @@ export interface CrossDesign {
};
/** 세월교 측벽 조작값(유입·유출) — 높이·좌우·상하(2026-08-25). */
ford_adjust?: StoredFordAdjust;
/** BOX암거 구체 조작값(좌·우 끝) — 길이·표고(2026-08-25). */
box_adjust?: StoredBoxAdjust;
/** 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…).
* 세션 전용이던 값을 정본에 남긴다(2026-08-24: 3D는 확정 결과물). */
revet_adjust?: Record<string, StoredWallAdjust>;
@@ -446,6 +460,7 @@ export interface CrossSectionPatch {
};
revet_adjust?: Record<string, StoredWallAdjust>;
ford_adjust?: StoredFordAdjust;
box_adjust?: StoredBoxAdjust;
extra_wall_counts?: StoredExtraWallCounts;
/** 연동 해제(측점별)·종단경사 반영(전체 공통) — 2026-08-24 사용자. */
revet_link_detached?: boolean;
+1
View File
@@ -664,6 +664,7 @@ async def compute_cross_section_design(
"inlet_structure",
"basin_adjust",
"ford_adjust",
"box_adjust",
"revet_adjust",
"extra_wall_counts",
"revet_link_detached",
@@ -128,6 +128,8 @@ async def _apply_section_edits(
}
if patch_item.ford_adjust is not None:
patch["ford_adjust"] = patch_item.ford_adjust.model_dump()
if patch_item.box_adjust is not None:
patch["box_adjust"] = patch_item.box_adjust.model_dump()
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:
+2
View File
@@ -142,4 +142,6 @@ def recompute_designs_for_alignment(
design["basin_adjust"] = stored["basin_adjust"]
if stored.get("ford_adjust") is not None:
design["ford_adjust"] = stored["ford_adjust"]
if stored.get("box_adjust") is not None:
design["box_adjust"] = stored["box_adjust"]
section["design"] = design
+16
View File
@@ -106,6 +106,20 @@ class FordAdjustPatch(BaseModel):
outlet: FordWallAdjustPatch = Field(default_factory=FordWallAdjustPatch)
class BoxSideAdjustPatch(BaseModel):
"""BOX암거 한쪽 끝의 조작값 — 길이 연장·표고(2026-08-25)."""
lengthM: float = Field(default=0, ge=0, le=50)
riseM: float = Field(default=0, ge=-20, le=20)
class BoxAdjustPatch(BaseModel):
"""BOX암거 측점의 좌·우 끝 조작값."""
left: BoxSideAdjustPatch = Field(default_factory=BoxSideAdjustPatch)
right: BoxSideAdjustPatch = Field(default_factory=BoxSideAdjustPatch)
class CrossSectionPatch(BaseModel):
"""확정 시 측점별 data.design에 병합할 프론트 세션 보관값."""
@@ -121,6 +135,8 @@ class CrossSectionPatch(BaseModel):
revet_adjust: dict[str, WallAdjustPatch] | None = None
# 세월교 측벽 조작값(유입·유출) — 2026-08-25 사용자.
ford_adjust: FordAdjustPatch | None = None
# BOX암거 구체 조작값(좌·우 끝 길이·표고) — 2026-08-25 사용자.
box_adjust: BoxAdjustPatch | None = None
extra_wall_counts: ExtraWallCountsPatch | None = None
# 연동 기슭막이 옵션(2026-08-24 사용자). 연동 해제는 측점별, 종단경사 반영은
# 기슭막이 한 벌 전체 공통이라 소유 측점에만 실린다.
+1
View File
@@ -103,6 +103,7 @@ export function crossPatchesFromCache(detail: SectionDetailResponse): CrossSecti
put("basin_adjust", design.basin_adjust);
put("revet_adjust", design.revet_adjust);
put("ford_adjust", design.ford_adjust);
put("box_adjust", design.box_adjust);
put("extra_wall_counts", design.extra_wall_counts);
put("revet_link_detached", design.revet_link_detached);
put("revet_follow_grade", design.revet_follow_grade);
+93 -35
View File
@@ -1,29 +1,46 @@
/* =============================================================================
* B06_Section_UI_Cross_Box.ts
* BOX암거 (··) ** **.
* BOX암거 (····) ** **.
*
* `B06_Section_UI_Cross_Box_Geom.ts` (700 ).
* (`BoxLayout`) SVG .
*
* = : .
* = : .
* .
* ========================================================================== */
import type { BoxLayout } from "./B06_Section_UI_Cross_Box_Geom";
import type { BoxLayout, BoxSideRole } from "./B06_Section_UI_Cross_Box_Geom";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
export { computeBoxLayout } from "./B06_Section_UI_Cross_Box_Geom";
export type { BoxLayout } from "./B06_Section_UI_Cross_Box_Geom";
export {
computeBoxLayout,
DEFAULT_BOX_ADJUST,
DEFAULT_BOX_SIDE_ADJUST,
} from "./B06_Section_UI_Cross_Box_Geom";
export type {
BoxAdjust,
BoxLayout,
BoxSideAdjust,
BoxSideLayout,
BoxSideRole,
} from "./B06_Section_UI_Cross_Box_Geom";
const SVG_NS = "http://www.w3.org/2000/svg";
/** BOX암거 구체 오버레이 — computeBoxLayout 결과를 그린다. */
/** 선택 강조를 카드 재생성 없이 갈아 끼우는 setter. */
export type BoxHighlightSetter = (role: BoxSideRole | null) => void;
/**
* BOX암거 computeBoxLayout .
* `onSelectSide` · ( ).
*/
export function appendBoxOverlay(
layer: SVGElement,
layout: BoxLayout,
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
): void {
onSelectSide?: (role: BoxSideRole) => void,
): BoxHighlightSetter {
const { box } = layout;
const toXY = (point: OffsetPoint): [number, number] => [
x(point.offset),
@@ -42,57 +59,67 @@ export function appendBoxOverlay(
shape.append(title);
return shape;
};
const wingText = (wing: {
installed: boolean;
length_m: number | null;
angle_deg: number | null;
}): string => (wing.installed ? `${wing.length_m ?? 0}${wing.angle_deg ?? 0}°` : "없음");
const slopeText = layout.slopeRatio
? ` · 구체 물매 1:${layout.slopeRatio.toFixed(1)}`
: " · 구체 수평";
// ① 저판 — 날개벽 투영만큼 계류 상·하류로 길어진다.
// ① 에이프런 — 날개벽 구간 바닥. 각 끝 표고에서 수평이다(2026-08-25 사용자).
for (const side of layout.sides) {
if (!side.apron.length) continue;
const mark = side.role === "left" ? "좌" : "우";
layer.append(
polygon(
side.apron,
"b06-chart__ford-slab",
`${mark}측 날개벽 구간 바닥 — 수평 · 두께 ${box.slab_thickness_m.toFixed(2)}m` +
` (날개벽 ${wingText(side.role === "left" ? box.wing_in : box.wing_out)} 투영 연장)`,
),
);
}
// ② 저판 · ③ 상판 — 좌·우 표고가 다르면 함께 기운다.
layer.append(
polygon(
layout.bottomSlab,
"b06-chart__ford-slab",
`BOX암거 저판 — 길이 ${layout.slabLengthM.toFixed(2)}m · 두께 ${box.slab_thickness_m.toFixed(2)}m` +
` (날개벽 유입 ${wingText(box.wing_in)} / 유출 ${wingText(box.wing_out)} 투영 연장 반영)` +
(layout.bedGapM > 0.05
? ` · 계류 하상보다 ${layout.bedGapM.toFixed(2)}m 높음`
: layout.bedGapM < -0.05
? ` · 계류 하상보다 ${Math.abs(layout.bedGapM).toFixed(2)}m 낮음(터파기)`
`BOX암거 저판 — 두께 ${box.slab_thickness_m.toFixed(2)}m${slopeText}` +
(layout.bedGapM < -0.05
? ` · 계류 하상보다 ${Math.abs(layout.bedGapM).toFixed(2)}m 낮음(터파기)`
: layout.bedGapM > 0.05
? ` · 계류 하상보다 ${layout.bedGapM.toFixed(2)}m 높음`
: ""),
),
);
// ② 상판 — 윗면이 노면 아래 복토 두께만큼 내려온다.
layer.append(
polygon(
layout.topSlab,
"b06-chart__ford-slab",
`BOX암거 상판 — 두께 ${box.top_thickness_m.toFixed(2)}m · 윗면이 노면 ${box.cover_m.toFixed(2)}m(복토)`,
`BOX암거 상판 — 두께 ${box.top_thickness_m.toFixed(2)}m · 윗면이 노면 ${box.cover_m.toFixed(2)}m(복토)${slopeText}`,
),
);
// 내공(유로) — 면은 비우고 윤곽만 점선으로 두른다.
const barrel = polygon(
layout.barrel,
"b06-chart__box-barrel",
`BOX암거 내공(유로) ${box.inner_width_m.toFixed(1)}×${box.inner_height_m.toFixed(1)}m` +
` · 측벽 두께 ${box.wall_thickness_m.toFixed(2)}m`,
// 내공(유로) — 면은 비우고 윤곽만 점선으로 두른다.
layer.append(
polygon(
layout.barrel,
"b06-chart__box-barrel",
`BOX암거 내공(유로) ${box.inner_width_m.toFixed(1)}×${box.inner_height_m.toFixed(1)}m` +
` · 측벽 두께 ${box.wall_thickness_m.toFixed(2)}m · 구체 길이 ${layout.bodyLengthM.toFixed(2)}m`,
),
);
layer.append(barrel);
// 성토선 — 노견에서 (연장 후) 박스 최상단 모서리를 지나 원지반까지, 물매 1:1.2.
for (const line of layout.fillLines) {
if (line.length < 2) continue;
// 성토선 — 노견에서 (연장 후) 구체 최상단 모서리까지, 물매 1:1.2.
for (const side of layout.sides) {
if (side.fillLine.length < 2) continue;
const polyline = document.createElementNS(SVG_NS, "polyline");
polyline.setAttribute("points", line.map((point) => toXY(point).join(",")).join(" "));
polyline.setAttribute("points", side.fillLine.map((point) => toXY(point).join(",")).join(" "));
polyline.setAttribute("class", "b06-chart__design-cross");
const title = document.createElementNS(SVG_NS, "title");
const height = line[0].elevation - line[line.length - 1].elevation;
title.textContent =
`BOX암거 성토선 — 물매 1:1.2 · 성토고 ${height.toFixed(2)}m` +
" (박스 최상단 모서리를 지난다)";
const drop = side.fillLine[0].elevation - side.fillLine[side.fillLine.length - 1].elevation;
title.textContent = `BOX암거 성토선 — 물매 1:1.2 · 성토고 ${drop.toFixed(2)}m (구체 최상단에서 끝)`;
polyline.append(title);
layer.append(polyline);
}
@@ -105,4 +132,35 @@ export function appendBoxOverlay(
label.setAttribute("class", "b06-chart__culvert-label");
label.textContent = layout.label.text;
layer.append(label);
// 좌·우 절반을 덮는 투명 겹면 — 어느 끝을 조정할지 클릭으로 고른다.
const hits = new Map<BoxSideRole, SVGPolygonElement>();
if (onSelectSide) {
const midOffset = (layout.sides[0].offset + layout.sides[1].offset) / 2;
const top = Math.max(...layout.topSlab.map((point) => point.elevation));
const bottom = Math.min(...layout.bottomSlab.map((point) => point.elevation));
for (const side of layout.sides) {
const outer = side.apron.length ? side.apron[1].offset : side.offset;
const hit = polygon(
[
{ offset: midOffset, elevation: top },
{ offset: outer, elevation: top },
{ offset: outer, elevation: bottom },
{ offset: midOffset, elevation: bottom },
],
"b06-chart__culvert-revet-hit",
"",
);
hit.addEventListener("click", (event) => {
event.stopPropagation();
onSelectSide(side.role);
});
hits.set(side.role, hit);
layer.append(hit);
}
}
return (role: BoxSideRole | null): void => {
for (const [side, hit] of hits) hit.classList.toggle("is-active", side === role);
};
}
+130 -79
View File
@@ -5,13 +5,13 @@
*
* (2026-08-25 ):
* · = ** **(··) .
* · = ** 0.5m**(2 ), ( 3).
* · ** **(×cos각) · .
* · () .
* · ** **(2026-08-25 ). 1:1.2
*
* "벽이 밖으로 나간 만큼 노폭 연장" .
* .
* · = 0.5m(2 ) , **·
* ** ().
* · ** .** 1:1.2
* ( "벽이 밖으로 나간 만큼 노폭 연장" ).
* · ** () **
* . = (×cos각).
* · () .
* ( 0.2· 0.3· 0.3) .
* ========================================================================== */
@@ -20,29 +20,66 @@ import type { CulvertDesignTrim, OffsetPoint } from "./B06_Section_UI_Cross_Culv
import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const";
import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
/** 구체 한쪽 끝의 조작값 — 좌·우를 따로 잡는다(2026-08-25 사용자). */
export interface BoxSideAdjust {
/** 기본 길이(성토선 규칙)에서 더 내보낸 양(m, + = 바깥). 음수는 물매가 깨져 막는다. */
lengthM: number;
/** 그쪽 끝의 구체 표고 조정(m, + = 위). 좌·우가 다르면 구체가 기운다. */
riseM: number;
}
/** 측점별 BOX암거 조정값. */
export interface BoxAdjust {
left: BoxSideAdjust;
right: BoxSideAdjust;
}
export type BoxSideRole = "left" | "right";
export const DEFAULT_BOX_SIDE_ADJUST: BoxSideAdjust = { lengthM: 0, riseM: 0 };
export const DEFAULT_BOX_ADJUST: BoxAdjust = {
left: DEFAULT_BOX_SIDE_ADJUST,
right: DEFAULT_BOX_SIDE_ADJUST,
};
export interface BoxSideLayout {
role: BoxSideRole;
/** 구체 끝 offset(+ = 좌측). */
offset: number;
/** 그 끝의 상판 윗면 표고. */
topElevation: number;
/** 그 끝의 저판 밑면 표고. */
bottomElevation: number;
/** 날개벽 구간 바닥(에이프런) — 이 끝에서 수평으로 뻗는다. 연장이 0이면 빈 배열. */
apron: OffsetPoint[];
/** 성토선 — 노견 → (수평 연장) → 구체 최상단 모서리에서 끝. */
fillLine: OffsetPoint[];
/** 적용된 조작값(한계에 걸린 뒤). */
adjust: BoxSideAdjust;
}
export interface BoxLayout {
box: BoxSet;
/** 상판 폴리곤(노견~노견). */
sides: BoxSideLayout[];
/** 상판 폴리곤(좌·우 표고가 다르면 기울어진다). */
topSlab: OffsetPoint[];
/** 저판 폴리곤 — 날개벽 투영만큼 각 측 연장. */
/** 구체 저판 폴리곤 — 상판과 같은 기울기. */
bottomSlab: OffsetPoint[];
/** 내공(유로) 사각 — 비워 두고 윤곽선·라벨만 쓴다. */
barrel: OffsetPoint[];
/** 상판 윗면·저판 밑면 표고. */
topElevation: number;
bottomElevation: number;
/** 저판 전체 길이(m) — 노폭 + 유입·유출 날개벽 투영. */
/** 구체 길이(m, 좌우 끝 사이). */
bodyLengthM: number;
/** 바닥 전체 길이(m) — 구체 + 양측 에이프런. */
slabLengthM: number;
/** 구체 물매(1:n). 수평이면 null. */
slopeRatio: number | null;
/** 구체 바닥과 계류 하상(원지반 최저)의 고저차(m). +면 구체가 하상 위로 떠 있다. */
bedGapM: number;
/**
* ( ) .
* 1:1.2 , .
*/
fillLines: OffsetPoint[][];
label: { at: OffsetPoint; text: string };
/** 노견 밖 설계선은 성토선이 대신한다 — 노견에서 끊는다. */
/** 노견 밖 설계선은 성토선이 대신한다 — 노견에서 끊는다. */
designTrim: CulvertDesignTrim;
/** 한계에 걸린 뒤 **실제 적용된** 조작값 — 조정창이 되받는다. */
adjust: BoxAdjust;
}
/** 노면 표고 — 설계선이 있으면 그 곡선, 없으면 노견 직선(세월교와 같은 규칙). */
@@ -61,20 +98,11 @@ function roadTopFactory(
};
}
/** 사각 폴리곤(좌상 → 우상 → 우하 → 좌하). */
function rect(left: number, right: number, top: number, bottom: number): OffsetPoint[] {
return [
{ offset: left, elevation: top },
{ offset: right, elevation: top },
{ offset: right, elevation: bottom },
{ offset: left, elevation: bottom },
];
}
/** BOX암거 구체 기하. 제원·설계선·지반이 부족하면 null. */
export function computeBoxLayout(
section: CrossSection,
groundSamples: SectionSample[],
adjust: BoxAdjust = DEFAULT_BOX_ADJUST,
): BoxLayout | null {
const box = section.box;
if (!box) return null;
@@ -82,36 +110,65 @@ export function computeBoxLayout(
if (!edges) return null;
const groundAt = groundInterpolator(groundSamples);
if (!groundAt) return null;
// 좌표 규약: +offset = 좌측. 유입 = 상단측(미상이면 좌측 폴백).
const inletOnLeft = (section.uphill_side ?? "left") === "left";
const roadTopAt = roadTopFactory(section, edges.left, edges.right);
const leftEdge = edges.left.offset_m;
const rightEdge = edges.right.offset_m;
if (!(leftEdge > rightEdge)) return null;
// 구체는 수평 설치(교본 3장) — 노면이 가장 낮은 자리에서도 복토를 확보한다.
const roadLow = Math.min(roadTopAt(leftEdge), roadTopAt(rightEdge), roadTopAt(0));
const topElevation = roadLow - box.cover_m;
const barrelTop = topElevation - box.top_thickness_m;
const barrelBottom = barrelTop - box.inner_height_m;
const bottomElevation = barrelBottom - box.slab_thickness_m;
// 좌표 규약: +offset = 좌측. 유입 = 상단측(미상이면 좌측 폴백).
const inletOnLeft = (section.uphill_side ?? "left") === "left";
const baseTop = Math.min(roadTopAt(leftEdge), roadTopAt(rightEdge), roadTopAt(0)) - box.cover_m;
const bodyHeight = box.top_thickness_m + box.inner_height_m + box.slab_thickness_m;
// 구체 길이 — 성토선이 박스 최상단을 지나려면 (노면 − 박스 상단) × 물매만큼은 노견
// 밖으로 나가야 한다. 노면이 크라운이라 좌·우 필요량이 다르므로 **큰 쪽에 맞춰**
// 대칭으로 잡고, 남는 쪽은 노견을 수평 연장해 물매 1:1.2를 지킨다(2026-08-25 사용자).
const reach =
Math.max(roadTopAt(leftEdge), roadTopAt(rightEdge)) - topElevation > 0
? (Math.max(roadTopAt(leftEdge), roadTopAt(rightEdge)) - topElevation) * FILL_SLOPE_RATIO_MIN
: box.cover_m * FILL_SLOPE_RATIO_MIN;
const bodyLeft = leftEdge + reach;
const bodyRight = rightEdge - reach;
const sideOf = (role: BoxSideRole): BoxSideLayout => {
const outward = role === "left" ? 1 : -1;
const edgeOffset = role === "left" ? leftEdge : rightEdge;
const wanted = role === "left" ? adjust.left : adjust.right;
const lengthM = Math.max(wanted.lengthM, 0);
const topElevation = baseTop + wanted.riseM;
// 성토선이 구체 최상단을 지나려면 (노면 − 상단) × 물매만큼은 노견 밖으로 나가야 한다.
const reach = Math.max((roadTopAt(edgeOffset) - topElevation) * FILL_SLOPE_RATIO_MIN, 0);
const offset = edgeOffset + outward * (reach + lengthM);
const bottomElevation = topElevation - bodyHeight;
// 날개벽 구간 바닥 — 각 끝 표고에서 **수평**(2026-08-25 사용자).
const wing = (role === "left") === inletOnLeft ? box.wing_in : box.wing_out;
const extend = Math.max(wing.slab_extend_m, 0);
const outer = offset + outward * extend;
const apron: OffsetPoint[] = extend
? [
{ offset, elevation: bottomElevation + box.slab_thickness_m },
{ offset: outer, elevation: bottomElevation + box.slab_thickness_m },
{ offset: outer, elevation: bottomElevation },
{ offset, elevation: bottomElevation },
]
: [];
// 성토선 — 노견 표고에서 수평으로 나간 뒤 구체 최상단 모서리에서 끝난다.
const roadZ = roadTopAt(edgeOffset);
const start = offset - outward * (roadZ - topElevation) * FILL_SLOPE_RATIO_MIN;
const fillLine: OffsetPoint[] = [{ offset: edgeOffset, elevation: roadZ }];
if ((start - edgeOffset) * outward > 0.01) fillLine.push({ offset: start, elevation: roadZ });
fillLine.push({ offset, elevation: topElevation });
return {
role,
offset,
topElevation,
bottomElevation,
apron,
fillLine,
adjust: { lengthM, riseM: wanted.riseM },
};
};
// 저판 연장 — 유입·유출 날개벽 각도가 만든 투영량(백엔드 계산값).
const leftExtend = Math.max((inletOnLeft ? box.wing_in : box.wing_out).slab_extend_m, 0);
const rightExtend = Math.max((inletOnLeft ? box.wing_out : box.wing_in).slab_extend_m, 0);
const slabLeft = bodyLeft + leftExtend;
const slabRight = bodyRight - rightExtend;
const left = sideOf("left");
const right = sideOf("right");
const quad = (topDrop: number, bottomDrop: number): OffsetPoint[] => [
{ offset: left.offset, elevation: left.topElevation - topDrop },
{ offset: right.offset, elevation: right.topElevation - topDrop },
{ offset: right.offset, elevation: right.topElevation - bottomDrop },
{ offset: left.offset, elevation: left.topElevation - bottomDrop },
];
const barrelTopDrop = box.top_thickness_m;
const barrelBottomDrop = barrelTopDrop + box.inner_height_m;
// 계류 하상과의 고저차 — 터파기·성토 판정은 후속이라 값만 넘긴다.
let bed = Math.min(groundAt(leftEdge), groundAt(rightEdge));
@@ -119,40 +176,34 @@ export function computeBoxLayout(
bed = Math.min(bed, groundAt(offset));
}
// 성토선 — 노견 표고에서 수평으로 나간 뒤 박스 최상단 모서리를 지나 지반까지.
const fillLine = (edgeOffset: number, bodyOffset: number, outward: number): OffsetPoint[] => {
const roadZ = roadTopAt(edgeOffset);
// 사면이 박스 모서리를 지나려면 시작점이 모서리에서 (높이차 × 물매)만큼 안쪽이다.
const start = bodyOffset - outward * (roadZ - topElevation) * FILL_SLOPE_RATIO_MIN;
const corner: OffsetPoint = { offset: bodyOffset, elevation: topElevation };
const points: OffsetPoint[] = [{ offset: edgeOffset, elevation: roadZ }];
// 박스가 더 길면 노견을 수평으로 늘린다(각도 불변 — 2026-08-25 사용자).
if ((start - edgeOffset) * outward > 0.01) points.push({ offset: start, elevation: roadZ });
// 모서리에서 끝난다 — 그 밖은 구체 개구부·날개벽이라 성토가 아니다
// (2026-08-25 사용자: 박스와 만난 뒤의 성토선은 필요 없다).
points.push(corner);
return points;
};
const rise = left.topElevation - right.topElevation;
const run = left.offset - right.offset;
const apronLengthOf = (side: BoxSideLayout): number =>
side.apron.length ? Math.abs(side.apron[1].offset - side.apron[0].offset) : 0;
return {
box,
topSlab: rect(bodyLeft, bodyRight, topElevation, barrelTop),
bottomSlab: rect(slabLeft, slabRight, barrelBottom, bottomElevation),
barrel: rect(bodyLeft, bodyRight, barrelTop, barrelBottom),
fillLines: [fillLine(leftEdge, bodyLeft, 1), fillLine(rightEdge, bodyRight, -1)],
sides: [left, right],
topSlab: quad(0, barrelTopDrop),
bottomSlab: quad(barrelBottomDrop, barrelBottomDrop + box.slab_thickness_m),
barrel: quad(barrelTopDrop, barrelBottomDrop),
bodyLengthM: run,
slabLengthM: run + apronLengthOf(left) + apronLengthOf(right),
slopeRatio: Math.abs(rise) > 1e-6 ? Math.abs(run / rise) : null,
bedGapM: Number((Math.min(left.bottomElevation, right.bottomElevation) - bed).toFixed(3)),
label: {
at: {
offset: (left.offset + right.offset) / 2,
elevation:
(left.topElevation + right.topElevation) / 2 - (barrelTopDrop + barrelBottomDrop) / 2,
},
text: `BOX ${box.inner_width_m.toFixed(1)}×${box.inner_height_m.toFixed(1)}`,
},
designTrim: {
minOffset: rightEdge,
maxOffset: leftEdge,
minElevation: roadTopAt(rightEdge),
maxElevation: roadTopAt(leftEdge),
},
topElevation,
bottomElevation,
slabLengthM: Math.abs(slabLeft - slabRight),
bedGapM: Number((bottomElevation - bed).toFixed(3)),
label: {
at: { offset: (bodyLeft + bodyRight) / 2, elevation: (barrelTop + barrelBottom) / 2 },
text: `BOX ${box.inner_width_m.toFixed(1)}×${box.inner_height_m.toFixed(1)}`,
},
adjust: { left: left.adjust, right: right.adjust },
};
}
@@ -0,0 +1,162 @@
/* =============================================================================
* B06_Section_UI_Cross_Box_Panel.ts
* BOX암거 ** **(2026-08-25 ).
*
* · ,
* ** **, ** ** 0.1m씩 . ·
* , () .
* CSS · .
* ========================================================================== */
import type { BoxSideAdjust, BoxSideRole } from "./B06_Section_UI_Cross_Box_Geom";
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
export interface BoxPanelDeps {
/** 지금 조작값(한계에 걸린 뒤 실제 적용값). */
adjustFor: (role: BoxSideRole) => BoxSideAdjust;
/** 구체 길이 ±0.1m — 화면 좌(◀)/우(▶) 부호 환산은 카드가 한다. */
nudgeLength: (role: BoxSideRole, screenDeltaM: number) => void;
/** 그 끝의 구체 표고 ±0.1m. */
nudgeRise: (role: BoxSideRole, deltaM: number) => void;
/** 그 측을 자동 자리로 되돌린다. */
reset: (role: BoxSideRole) => void;
/** 지금 그려진 구체 길이(m)와 물매(1:n, 수평이면 null). */
bodyLengthM: () => number;
slopeRatio: () => number | null;
/** 창을 닫는다 = 선택 해제. */
close: () => void;
}
export interface BoxPanelHandle {
root: HTMLElement;
/** null이면 숨긴다. 값이 오면 그 측 기준으로 다시 그린다. */
show: (role: BoxSideRole | null) => void;
}
/** 한 걸음(m) — 사용자 지정 0.1m. */
const STEP_M = 0.1;
function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "b06-structure-panel__btn";
button.textContent = label;
button.title = title;
button.addEventListener("click", (event) => {
// 카드 선택·팬으로 번지면 도면이 다시 그려져 맞춰 둔 배율이 날아간다.
event.stopPropagation();
onClick();
});
return button;
}
/** 항목 행 — 1행 이름 라벨 + 2행 값 조작(다른 조정창과 같은 2행 구조). */
function makeRow(labelText: string): { row: HTMLElement; controls: HTMLElement } {
const row = document.createElement("div");
row.className = "b06-structure-panel__struct";
const label = document.createElement("span");
label.className = "b06-structure-panel__label";
label.textContent = labelText;
const controls = document.createElement("div");
controls.className = "b06-structure-panel__controls";
row.append(label, controls);
return { row, controls };
}
/** BOX암거 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */
export function buildBoxPanel(deps: BoxPanelDeps): BoxPanelHandle {
const root = document.createElement("div");
root.className = "b06-structure-panel is-hidden";
root.addEventListener("click", (event) => event.stopPropagation());
const title = document.createElement("span");
title.className = "b06-structure-panel__title";
const value = document.createElement("div");
value.className = "b06-structure-panel__value";
const lengthLine = document.createElement("span");
const riseLine = document.createElement("span");
const slopeLine = document.createElement("span");
value.append(lengthLine, riseLine, slopeLine);
let current: BoxSideRole | null = null;
const act = (run: (role: BoxSideRole) => void) => () => {
if (current) run(current);
};
const moveRow = makeRow("길이 ◀▶ · 표고 ▲▼");
moveRow.controls.classList.add("b06-structure-panel__buttons");
const dpad = (
label: string,
slot: "up" | "down" | "left" | "right" | "reset",
tip: string,
onClick: () => void,
): HTMLButtonElement => {
const button = makeButton(label, tip, onClick);
button.classList.add(`b06-structure-panel__btn--${slot}`);
return button;
};
moveRow.controls.append(
dpad(
"▲",
"up",
"이쪽 끝 구체 표고 0.1m 올리기",
act((role) => deps.nudgeRise(role, STEP_M)),
),
dpad(
"◀",
"left",
"화면 왼쪽으로 구체 0.1m 늘리기",
act((role) => deps.nudgeLength(role, STEP_M)),
),
dpad("↺", "reset", "이 측 조정값 초기화", act(deps.reset)),
dpad(
"▶",
"right",
"화면 오른쪽으로 구체 0.1m 늘리기",
act((role) => deps.nudgeLength(role, -STEP_M)),
),
dpad(
"▼",
"down",
"이쪽 끝 구체 표고 0.1m 내리기",
act((role) => deps.nudgeRise(role, -STEP_M)),
),
);
const closeButton = makeButton("✕", "닫기", () => deps.close());
closeButton.classList.add("b06-structure-panel__close");
root.append(title, closeButton, value, moveRow.row);
function render(): void {
if (!current) return;
const side = current === "left" ? "좌" : "우";
title.textContent = `BOX암거 ${side}측 끝`;
const adjust = deps.adjustFor(current);
lengthLine.textContent =
`구체 길이 ${deps.bodyLengthM().toFixed(2)}m` +
(adjust.lengthM > 0 ? ` (이 측 +${adjust.lengthM.toFixed(1)}m)` : " (이 측 자동)");
riseLine.textContent = `표고 ${adjust.riseM >= 0 ? "+" : ""}${adjust.riseM.toFixed(1)}m`;
const ratio = deps.slopeRatio();
slopeLine.textContent = ratio ? `구체 물매 1:${ratio.toFixed(1)}` : "구체 수평";
}
return {
root,
show(role) {
current = role;
root.classList.toggle("is-hidden", role === null);
render();
},
};
}
/** 측점별 BOX암거 조작값 제어 — 페이지(세션·정본)가 구현한다. */
export interface BoxControl {
adjustFor: (chainageM: number) => { left: BoxSideAdjust; right: BoxSideAdjust };
update: (chainageM: number, role: BoxSideRole, patch: Partial<BoxSideAdjust>) => void;
reset: (chainageM: number, role: BoxSideRole) => void;
selectedFor: (chainageM: number) => BoxSideRole | null;
select: (chainageM: number, role: BoxSideRole | null) => void;
}
+34 -63
View File
@@ -25,11 +25,10 @@ import {
} from "./B06_Section_UI_Cross_Design";
import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert";
import { appendBoxOverlay, computeBoxLayout } from "./B06_Section_UI_Cross_Box";
import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel";
import { createBodyWiring } from "./B06_Section_UI_Cross_View_Bodies";
import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford";
import { buildFordPanel } from "./B06_Section_UI_Cross_Ford_Panel";
import { fordCardState, fordPanelDeps } from "./B06_Section_UI_Cross_View_Ford";
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
import type { FordHighlightSetter, FordWallRole } from "./B06_Section_UI_Cross_Ford";
import { computeCardCulvert, culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire";
import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
import type {
@@ -137,8 +136,9 @@ export function createCrossSectionCard(
structureSpan?: StructureSpanControl,
/** 연동·종단경사 반영 제어(2026-08-24). */
revetLink?: RevetLinkControl,
/** 세월교 측벽 조작 제어(2026-08-25). */
/** 세월교 측벽·BOX암거 구체 조작 제어(2026-08-25). */
ford?: FordControl,
box?: BoxControl,
): CrossCardElement {
// 링크 카드 = 구조물이 옆 측점에 서 있고 그 연장이 여기까지 온 경우. 관만 숨기고
// 선택·조정은 연다 — 연동을 풀어 이 측점 위치를 따로 잡을 수 있어야 한다
@@ -178,10 +178,6 @@ export function createCrossSectionCard(
let setBandActive: AreaHighlightSetter = () => undefined;
let setChipActive: AreaHighlightSetter = () => undefined;
let activeRevet: RevetKey | null = revetOffset?.selectedFor(section) ?? null;
// 세월교 측벽 선택 — 배수관 기슭막이와 한 측점에 같이 서지 않는다.
let activeFordWall = section.ford ? (ford?.selectedFor(section.chainage_m) ?? null) : null;
let setFordActive: FordHighlightSetter = () => {};
let showFordPanel = (_visible: boolean): void => {};
let setRevetActive: RevetHighlightSetter = () => undefined;
let showRevetControl: (visible: boolean) => void = () => undefined;
let culvertPipeLengthM: number | null = null; // 관 길이(m) — 조정창 표기용
@@ -202,28 +198,21 @@ export function createCrossSectionCard(
* **
* .
*/
/** 마지막 계산의 세월교 바닥판 길이(m)·측벽 높이(m) — 조정창 표시·조작 시작값. */
let fordSlabLengthM = 0;
let fordWallHeights = new Map<
FordWallRole,
number
>(); /** 세월교 측벽을 고른다 — 기슭막이 선택과 같은 규칙(면적 강조와 배타). */
const toggleFordWall = (role: FordWallRole): void => {
const wasSelected = isSelected;
activeFordWall = activeFordWall === role ? null : role;
setFordActive(activeFordWall);
showFordPanel(activeFordWall !== null);
ford?.select(section.chainage_m, activeFordWall);
if (activeFordWall !== null && activeArea !== null) {
// 구체 구조물(세월교·BOX암거) 선택·조정창 배선 — 한 모듈로 묶었다(2026-08-25).
const bodies = createBodyWiring({
section,
ford,
box,
isCardSelected: () => isSelected,
clearArea: () => {
if (activeArea === null) return;
activeArea = null;
setBandActive(null);
setChipActive(null);
}
if (activeFordWall !== null) {
if (wasSelected) onAreaSelect?.(section.station_id, null);
else onSelect(section.station_id);
}
};
onAreaSelect?.(section.station_id, null);
},
selectCard: () => onSelect(section.station_id),
});
const toggleRevet = (key: RevetKey): void => {
const wasSelected = isSelected;
activeRevet = activeRevet === key ? null : key;
@@ -257,12 +246,7 @@ export function createCrossSectionCard(
showRevetControl(false);
revetOffset?.select(section.chainage_m, null);
}
if ((activeArea !== null || !nextSelected) && activeFordWall !== null) {
activeFordWall = null;
setFordActive(null);
showFordPanel(false);
ford?.select(section.chainage_m, null);
}
if (activeArea !== null || !nextSelected) bodies.clearSelection();
};
const toggleArea = (key: CrossAreaKey): void => {
if (!isSelected) {
@@ -443,9 +427,8 @@ export function createCrossSectionCard(
revetLink,
);
// 세월교·BOX암거 구체 — 배수관과 그림이 달라 계산·그리기를 따로 탄다(2026-08-25).
const boxLayout = computeBoxLayout(section, section.samples);
const adjust = ford?.adjustFor(section.chainage_m);
const fordLayout = computeFordLayout(section, section.samples, adjust);
const boxLayout = computeBoxLayout(section, section.samples, bodies.boxAdjust());
const fordLayout = computeFordLayout(section, section.samples, bodies.fordAdjust());
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
appendPavementOverlay(plotLayer, section.design, x, toDisplayY);
appendCrossDesignOverlay(
@@ -478,17 +461,23 @@ export function createCrossSectionCard(
culvertWallSpecs = state.wallSpecs;
culvertAppliedD = state.appliedD;
}
if (boxLayout) appendBoxOverlay(plotLayer, boxLayout, x, toDisplayY);
if (boxLayout) {
bodies.attachBox(
appendBoxOverlay(plotLayer, boxLayout, x, toDisplayY, box ? bodies.toggleBox : undefined),
boxLayout,
);
}
if (fordLayout) {
setFordActive = appendFordOverlay(
plotLayer,
bodies.attachFord(
appendFordOverlay(
plotLayer,
fordLayout,
x,
toDisplayY,
ford ? bodies.toggleFord : undefined,
),
fordLayout,
x,
toDisplayY,
ford ? toggleFordWall : undefined,
);
if (activeFordWall) setFordActive(activeFordWall);
({ slabLengthM: fordSlabLengthM, heights: fordWallHeights } = fordCardState(fordLayout));
}
if (culvertLayout) {
setRevetActive = appendCulvertOverlay(
@@ -642,24 +631,6 @@ export function createCrossSectionCard(
showRevetControl = (visible) => panel.show(visible ? activeRevet : null);
showRevetControl(activeRevet !== null);
// 세월교 조정창 — 배수관 창과 조작 축이 달라 따로 만든다(2026-08-25 사용자).
const fordPanel =
section.ford && ford
? buildFordPanel(
fordPanelDeps({
section,
ford,
heightFor: (role) => fordWallHeights.get(role) ?? 0,
slabLengthM: () => fordSlabLengthM,
close: () => {
if (activeFordWall) toggleFordWall(activeFordWall);
},
}),
)
: null;
if (fordPanel) {
showFordPanel = (visible) => fordPanel.show(visible ? activeFordWall : null);
showFordPanel(activeFordWall !== null);
}
// 우측 상단 줌 버튼이 원배율에서 표시 반폭까지 다룬다(하단 ◀/▶/↺ 폐지, 2026-08-23).
const widthActions: CrossWidthActions | undefined = stationWidth && {
step: (deltaM) =>
@@ -677,7 +648,7 @@ export function createCrossSectionCard(
},
};
chartWrap.append(svg, readout.root, buildZoomControls(zoomPan, widthActions), panel.root);
if (fordPanel) chartWrap.append(fordPanel.root);
for (const panel of bodies.panels) chartWrap.append(panel.root);
if (activeArea) {
setBandActive(activeArea);
setChipActive(activeArea);
@@ -0,0 +1,143 @@
/* =============================================================================
* B06_Section_UI_Cross_View_Bodies.ts
* ** (·BOX암거)** · setter·
* (`_UI_Cross_View.ts`) 700
* (2026-08-25). .
* ========================================================================== */
import type { CrossSection } from "./B06_Section_Api_Fetch";
import type {
FordAdjust,
FordHighlightSetter,
FordLayout,
FordWallRole,
} from "./B06_Section_UI_Cross_Ford";
import { buildFordPanel } from "./B06_Section_UI_Cross_Ford_Panel";
import type { FordControl, FordPanelHandle } from "./B06_Section_UI_Cross_Ford_Panel";
import { fordCardState, fordPanelDeps } from "./B06_Section_UI_Cross_View_Ford";
import type {
BoxAdjust,
BoxHighlightSetter,
BoxLayout,
BoxSideRole,
} from "./B06_Section_UI_Cross_Box";
import { buildBoxPanel } from "./B06_Section_UI_Cross_Box_Panel";
import type { BoxControl, BoxPanelHandle } from "./B06_Section_UI_Cross_Box_Panel";
import { boxPanelDeps, structureToggle } from "./B06_Section_UI_Cross_View_Box";
export interface BodyWiringDeps {
section: CrossSection;
ford?: FordControl;
box?: BoxControl;
/** 카드가 지금 선택 상태인가 — 구조물을 고르면 카드도 함께 선택된다. */
isCardSelected: () => boolean;
/** 면적 강조 해제(구조물 선택과 배타). */
clearArea: () => void;
selectCard: () => void;
}
export interface BodyWiring {
fordAdjust: () => FordAdjust | undefined;
boxAdjust: () => BoxAdjust | undefined;
toggleFord: (role: FordWallRole) => void;
toggleBox: (role: BoxSideRole) => void;
/** 오버레이가 만들어 준 강조 setter를 등록하고 현재 선택을 다시 칠한다. */
attachFord: (setter: FordHighlightSetter, layout: FordLayout) => void;
attachBox: (setter: BoxHighlightSetter, layout: BoxLayout) => void;
/** 카드 선택이 풀리거나 면적이 켜지면 구조물 선택도 푼다. */
clearSelection: () => void;
panels: Array<FordPanelHandle | BoxPanelHandle>;
}
export function createBodyWiring(deps: BodyWiringDeps): BodyWiring {
const { section, ford, box } = deps;
const chainage = section.chainage_m;
let fordRole = section.ford ? (ford?.selectedFor(chainage) ?? null) : null;
let boxRole = section.box ? (box?.selectedFor(chainage) ?? null) : null;
let setFordActive: FordHighlightSetter = () => {};
let setBoxActive: BoxHighlightSetter = () => {};
let fordSlabLengthM = 0;
let fordHeights = new Map<FordWallRole, number>();
let boxLayout: BoxLayout | null = null;
const fordPanel =
section.ford && ford
? buildFordPanel(
fordPanelDeps({
section,
ford,
heightFor: (role) => fordHeights.get(role) ?? 0,
slabLengthM: () => fordSlabLengthM,
close: () => {
if (fordRole) toggleFord(fordRole);
},
}),
)
: null;
const boxPanel =
section.box && box
? buildBoxPanel(
boxPanelDeps({
chainageM: chainage,
box,
layout: () => boxLayout,
close: () => {
if (boxRole) toggleBox(boxRole);
},
}),
)
: null;
const toggleFord = structureToggle<FordWallRole>({
current: () => fordRole,
setCurrent: (value) => (fordRole = value),
setActive: (value) => setFordActive(value),
showPanel: (visible) => fordPanel?.show(visible ? fordRole : null),
persist: (value) => ford?.select(chainage, value),
isCardSelected: deps.isCardSelected,
clearArea: deps.clearArea,
selectCard: deps.selectCard,
});
const toggleBox = structureToggle<BoxSideRole>({
current: () => boxRole,
setCurrent: (value) => (boxRole = value),
setActive: (value) => setBoxActive(value),
showPanel: (visible) => boxPanel?.show(visible ? boxRole : null),
persist: (value) => box?.select(chainage, value),
isCardSelected: deps.isCardSelected,
clearArea: deps.clearArea,
selectCard: deps.selectCard,
});
fordPanel?.show(fordRole);
boxPanel?.show(boxRole);
return {
fordAdjust: () => ford?.adjustFor(chainage),
boxAdjust: () => box?.adjustFor(chainage),
toggleFord,
toggleBox,
attachFord: (setter, layout) => {
setFordActive = setter;
const state = fordCardState(layout);
fordSlabLengthM = state.slabLengthM;
fordHeights = state.heights;
if (fordRole) setter(fordRole);
// 창은 그리기 전에 만들어져 값이 비어 있다 — 계산 결과가 들어온 뒤 다시 렌더한다.
fordPanel?.show(fordRole);
},
attachBox: (setter, layout) => {
setBoxActive = setter;
boxLayout = layout;
if (boxRole) setter(boxRole);
boxPanel?.show(boxRole);
},
clearSelection: () => {
if (fordRole !== null) toggleFord(fordRole);
if (boxRole !== null) toggleBox(boxRole);
},
panels: [fordPanel, boxPanel].filter(
(panel): panel is FordPanelHandle | BoxPanelHandle => !!panel,
),
};
}
@@ -0,0 +1,63 @@
/* =============================================================================
* B06_Section_UI_Cross_View_Box.ts
* **BOX암거 **
* (`_UI_Cross_View.ts`) 700 (2026-08-25).
* .
* ========================================================================== */
import type { BoxLayout } from "./B06_Section_UI_Cross_Box";
import type { BoxControl, BoxPanelDeps } from "./B06_Section_UI_Cross_Box_Panel";
export interface BoxPanelContext {
chainageM: number;
box: BoxControl;
/** 마지막 계산 결과 — 창이 길이·물매를 읽는다. */
layout: () => BoxLayout | null;
close: () => void;
}
export function boxPanelDeps(context: BoxPanelContext): BoxPanelDeps {
const { chainageM, box } = context;
return {
adjustFor: (role) => box.adjustFor(chainageM)[role],
// 화면 좌(◀) = offset 증가. 좌측 끝은 그대로, 우측 끝은 부호를 뒤집어야 바깥이 된다.
nudgeLength: (role, screenDeltaM) =>
box.update(chainageM, role, {
lengthM: box.adjustFor(chainageM)[role].lengthM + screenDeltaM * (role === "left" ? 1 : -1),
}),
nudgeRise: (role, deltaM) =>
box.update(chainageM, role, { riseM: box.adjustFor(chainageM)[role].riseM + deltaM }),
reset: (role) => box.reset(chainageM, role),
bodyLengthM: () => context.layout()?.bodyLengthM ?? 0,
slopeRatio: () => context.layout()?.slopeRatio ?? null,
close: context.close,
};
}
/**
* ·BOX암거 (2026-08-21
* 규칙: 구조물 , ).
*/
export function structureToggle<T>(deps: {
current: () => T | null;
setCurrent: (value: T | null) => void;
setActive: (value: T | null) => void;
showPanel: (visible: boolean) => void;
persist: (value: T | null) => void;
isCardSelected: () => boolean;
clearArea: () => void;
selectCard: () => void;
}): (value: T) => void {
return (value: T): void => {
const wasSelected = deps.isCardSelected();
const next = deps.current() === value ? null : value;
deps.setCurrent(next);
deps.setActive(next);
deps.showPanel(next !== null);
deps.persist(next);
if (next === null) return;
deps.clearArea();
// 미선택 카드는 카드 선택만 부른다 — 그 경로가 부모의 면적 강조를 이미 비운다.
if (!wasSelected) deps.selectCard();
};
}
+4 -5
View File
@@ -415,11 +415,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
projectId: () => projectId,
onSaveError: (message) => showToast(`배수관 구간값 저장 실패 — ${message}`, "error"),
});
const stationWidthControl = stationControls.stationWidth;
const revetOffsetControl = stationControls.revetOffset;
const stationWidths = stationControls.widths;
const inletStructures = stationControls.inletStructures;
const basinAdjustments = stationControls.basinAdjustments;
const { stationWidth: stationWidthControl, revetOffset: revetOffsetControl } = stationControls;
const { widths: stationWidths, inletStructures, basinAdjustments } = stationControls;
const sectionView = createSectionView(
(chainageM, change) => {
@@ -433,6 +430,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
stationControls.structureSpan,
stationControls.revetLink,
stationControls.ford,
stationControls.box,
);
// 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다.
@@ -519,6 +517,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
revetAdjusts: stationControls.revetAdjustsByChainage(),
extraCounts: stationControls.extraCountsByChainage(),
fordAdjusts: stationControls.fordAdjustsByChainage(),
boxAdjusts: stationControls.boxAdjustsByChainage(),
linkFlags: stationControls.linkFlagsByChainage(),
});
// 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다.
@@ -1,7 +1,7 @@
/* =============================================================================
* B06_Section_UI_Page_Ford_Controls.ts
* (`_UI_Page_Station_Controls.ts`) 700
* (2026-08-25).
* ·BOX암거
* (`_UI_Page_Station_Controls.ts`) 700 (2026-08-25).
*
* 같다: 세션 + `design.ford_adjust`
* 3D() . · B05 (`pipe_points`)
@@ -12,6 +12,9 @@ import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch";
import { DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford";
import type { FordAdjust, FordWallAdjust, FordWallRole } from "./B06_Section_UI_Cross_Ford";
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box";
import type { BoxAdjust, BoxSideAdjust, BoxSideRole } from "./B06_Section_UI_Cross_Box";
import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel";
export interface FordControlDeps {
/** 세션 보관 키(프로젝트·노선별). 없으면 세션에 담지 않는다. */
@@ -134,3 +137,110 @@ export function createFordControls(deps: FordControlDeps): FordControls {
load: loadFordAdjustments,
};
}
/**
* BOX암거 ( + `design.box_adjust`).
* · , .
*/
export function createBoxControls(deps: FordControlDeps): {
control: BoxControl;
byChainage: () => Map<number, BoxAdjust>;
load: () => void;
} {
const { round1, clampMove, sectionAt, patchCachedDesign } = deps;
const adjustments = new Map<string, BoxAdjust>();
const selections = new Map<string, BoxSideRole | null>();
function load(): void {
adjustments.clear();
const key = deps.sessionKey();
if (!key) return;
try {
const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record<
string,
BoxAdjust
>;
Object.entries(parsed).forEach(([chainage, value]) =>
adjustments.set(chainage, {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...value.left },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...value.right },
}),
);
} catch {
/* 손상된 세션 값은 기본값으로 대체. */
}
}
function persist(): void {
const key = deps.sessionKey();
if (key) window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(adjustments)));
}
const adjustAt = (chainageM: number): BoxAdjust => {
const stored = sectionAt(chainageM)?.design?.box_adjust;
return (
adjustments.get(chainageM.toFixed(2)) ?? {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(stored?.left ?? {}) },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(stored?.right ?? {}) },
}
);
};
const write = (chainageM: number, next: BoxAdjust): void => {
adjustments.set(chainageM.toFixed(2), next);
persist();
patchCachedDesign(chainageM, { box_adjust: next });
deps.refreshCard(chainageM);
};
const control: BoxControl = {
adjustFor: adjustAt,
update: (chainageM, role, patch) => {
const current = adjustAt(chainageM);
const side: BoxSideAdjust = { ...current[role], ...patch };
write(chainageM, {
...current,
[role]: {
// 길이는 바깥으로만 — 안쪽으로 줄이면 성토선 물매가 깨진다.
lengthM: Math.max(0, round1(side.lengthM)),
riseM: clampMove(side.riseM),
},
});
},
reset: (chainageM, role) => {
const current = adjustAt(chainageM);
write(chainageM, { ...current, [role]: { ...DEFAULT_BOX_SIDE_ADJUST } });
},
selectedFor: (chainageM) => selections.get(chainageM.toFixed(2)) ?? null,
select: (chainageM, role) => {
selections.set(chainageM.toFixed(2), role);
},
};
return {
control,
byChainage: () => {
const result = new Map<number, BoxAdjust>();
adjustments.forEach((adjust, key) => {
const value = Number(key);
if (Number.isFinite(value)) result.set(value, adjust);
});
return result;
},
load,
};
}
/** 세월교·BOX암거 제어를 한 번에 만든다 — 측점 제어기 쪽 배선을 줄인다(700줄 제한). */
export function createBodyControls(
common: Omit<FordControlDeps, "sessionKey">,
sessionKey: (kind: "fordadjust" | "boxadjust") => string | null,
): {
ford: ReturnType<typeof createFordControls>;
box: ReturnType<typeof createBoxControls>;
} {
return {
ford: createFordControls({ ...common, sessionKey: () => sessionKey("fordadjust") }),
box: createBoxControls({ ...common, sessionKey: () => sessionKey("boxadjust") }),
};
}
@@ -0,0 +1,51 @@
/* =============================================================================
* B06_Section_UI_Page_Link_Session.ts
* ( · ) ** **
* (`_UI_Page_Station_Controls.ts`) 700 (2026-08-25).
* .
* ========================================================================== */
export interface LinkFlagMaps {
detached: Map<string, boolean>;
followGrade: Map<string, boolean>;
load: () => void;
persist: () => void;
}
export function createLinkFlagSession(sessionKey: () => string | null): LinkFlagMaps {
const detached = new Map<string, boolean>();
const followGrade = new Map<string, boolean>();
return {
detached,
followGrade,
load(): void {
detached.clear();
followGrade.clear();
const key = sessionKey();
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]) => detached.set(at, !!value));
Object.entries(parsed.grade ?? {}).forEach(([at, value]) => followGrade.set(at, !!value));
} catch {
/* 손상된 세션 값은 무시 — 정본·기본값으로 재시작. */
}
},
persist(): void {
const key = sessionKey();
if (!key) return;
window.sessionStorage.setItem(
key,
JSON.stringify({
detached: Object.fromEntries(detached),
grade: Object.fromEntries(followGrade),
}),
);
},
};
}
@@ -10,6 +10,7 @@
import type { CrossSectionPatch, StoredWallAdjust } from "./B06_Section_Api_Fetch";
import type { BasinAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import type { FordAdjust } from "./B06_Section_UI_Cross_Ford";
import type { BoxAdjust } from "./B06_Section_UI_Cross_Box";
import type { InletStructureChoice } from "./B06_Section_UI_Cross_Culvert";
export interface CrossPatchSources {
@@ -22,6 +23,7 @@ export interface CrossPatchSources {
revetAdjusts: Map<number, Record<string, StoredWallAdjust>>;
extraCounts: Map<number, { outlet: number; basin: number }>;
fordAdjusts: Map<number, FordAdjust>;
boxAdjusts: Map<number, BoxAdjust>;
linkFlags: Map<number, { detached?: boolean; followGrade?: boolean }>;
}
@@ -59,6 +61,9 @@ export function buildCrossPatches(sources: CrossPatchSources): CrossSectionPatch
sources.fordAdjusts.forEach((adjust, chainage) => {
patchFor(chainage).ford_adjust = adjust;
});
sources.boxAdjusts.forEach((adjust, chainage) => {
patchFor(chainage).box_adjust = adjust;
});
// 연동 해제(측점별)·종단경사 반영(전체 공통) — 같은 체계로 정본에 싣는다.
sources.linkFlags.forEach((flags, chainage) => {
if (flags.detached !== undefined) patchFor(chainage).revet_link_detached = flags.detached;
@@ -20,9 +20,12 @@ import type {
import * as CulvertConst from "./B06_Section_UI_Cross_Culvert_Const";
import { culvertOwnerFor } from "./B06_Section_UI_Cross_Culvert_Wire";
import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options";
import { createFordControls } from "./B06_Section_UI_Page_Ford_Controls";
import { createBodyControls } from "./B06_Section_UI_Page_Ford_Controls";
import { createLinkFlagSession } from "./B06_Section_UI_Page_Link_Session";
import type { FordAdjust } from "./B06_Section_UI_Cross_Ford";
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
import type { BoxAdjust } from "./B06_Section_UI_Cross_Box";
import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel";
/** 제어가 페이지에서 가져다 쓰는 값들 — 클로저 대신 함수로 받아 결합을 끊는다. */
export interface StationControlDeps {
@@ -34,7 +37,8 @@ export interface StationControlDeps {
| "basinadjust"
| "extrawall"
| "revetlink"
| "fordadjust",
| "fordadjust"
| "boxadjust",
) => string | null;
refreshCard: (chainageM: number) => void;
detail: () => SectionDetailResponse | null;
@@ -61,8 +65,11 @@ export interface StationControls {
revetLink: RevetLinkControl;
/** 세월교 측벽 조작(2026-08-25). */
ford: FordControl;
/** 확정 payload용 — 측점별 세월교 조작값. */
/** BOX암거 구체 조작(2026-08-25). */
box: BoxControl;
/** 확정 payload용 — 측점별 세월교·BOX암거 조작값. */
fordAdjustsByChainage: () => Map<number, FordAdjust>;
boxAdjustsByChainage: () => Map<number, BoxAdjust>;
widths: Map<string, number>;
inletStructures: Map<string, InletStructureChoice>;
basinAdjustments: Map<string, BasinAdjust>;
@@ -482,41 +489,12 @@ export function createStationControls(deps: StationControlDeps): StationControls
* . ** ** .
* ( ), . */
const culvertOptions = createCulvertOptionWriter(deps.projectId, deps.onSaveError);
const linkDetached = new Map<string, boolean>();
const followGrades = new Map<string, boolean>();
const linkSession = createLinkFlagSession(() => deps.sessionKey("revetlink"));
const linkDetached = linkSession.detached;
const followGrades = linkSession.followGrade;
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 loadLinkFlags = linkSession.load;
const persistLinkFlags = linkSession.persist;
/** 이 카드가 구간값을 만질 대상 — 링크면 소유 측점, 아니면 자기 자신. */
const ownerOf = (section: CrossSection): CrossSection | null => {
@@ -622,16 +600,18 @@ export function createStationControls(deps: StationControlDeps): StationControls
},
};
// 세월교 측벽 조작은 별도 모듈(700줄 제한) — 축·저장 흐름은 집수정과 같다.
const fordControls = createFordControls({
sessionKey: () => deps.sessionKey("fordadjust"),
sectionAt,
patchCachedDesign,
refreshCard: deps.refreshCard,
queuePipeOptions: culvertOptions.queue,
round1,
clampMove,
});
// 세월교·BOX암거 조작은 별도 모듈(700줄 제한) — 저장 흐름은 집수정과 같다.
const bodyControls = createBodyControls(
{
sectionAt,
patchCachedDesign,
refreshCard: deps.refreshCard,
queuePipeOptions: culvertOptions.queue,
round1,
clampMove,
},
deps.sessionKey,
);
return {
stationWidth: stationWidthControl,
@@ -640,8 +620,10 @@ export function createStationControls(deps: StationControlDeps): StationControls
extraWalls: extraWallControl,
structureSpan: structureSpanControl,
revetLink: revetLinkControl,
ford: fordControls.control,
fordAdjustsByChainage: fordControls.byChainage,
ford: bodyControls.ford.control,
box: bodyControls.box.control,
fordAdjustsByChainage: bodyControls.ford.byChainage,
boxAdjustsByChainage: bodyControls.box.byChainage,
widths: stationWidths,
inletStructures,
basinAdjustments,
@@ -690,7 +672,8 @@ export function createStationControls(deps: StationControlDeps): StationControls
loadBasinAdjustments();
loadExtraCounts();
loadLinkFlags();
fordControls.load();
bodyControls.ford.load();
bodyControls.box.load();
},
applyGlobalWidth: (requested, chainages) => {
stationWidths.clear();
+7 -7
View File
@@ -26,6 +26,7 @@ import type {
import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design";
import type { CrossAreaKey } from "./B06_Section_UI_Cross_Areas";
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel";
import {
createCrossSectionCard,
type ExtraWallControl,
@@ -118,18 +119,16 @@ export interface SectionViewController {
export function createSectionView(
onDesignChange?: DesignChangeHandler,
rockBoundary?: RockBoundaryControl,
/** 측점 개별 표시 반폭 제어(2026-08-06) — 카드 하단 ◀/▶/↺과 행 높이 계산이 쓴다. */
/** 측점 개별 표시 반폭(2026-08-06)·기슭막이 X 자리(2026-08-21) 제어. */
stationWidth?: StationWidthControl,
/** 기슭막이 X 자리 제어(2026-08-21) — 벽을 골라 0.1m씩 민다. */
revetOffset?: RevetOffsetControl,
/** 유입측 구조물 형식 선택(2026-08-22) — 조정창 드롭다운. */
inletStructure?: InletStructureControl,
/** 유출측 추가 기슭막이 개수 제어(2026-08-22). */
extraWalls?: ExtraWallControl,
/** 기슭막이·집수정 구간값(길이·전·후)·연동 제어(2026-08-24), 세월교 측벽(2026-08-25). */
inletStructure?: InletStructureControl, // 유입측 구조물 형식(2026-08-22)
extraWalls?: ExtraWallControl, // 유출측 추가 기슭막이 단 수(2026-08-22)
/** 구간값·연동(2026-08-24), 세월교 측벽·BOX암거 구체(2026-08-25) 제어. */
structureSpan?: StructureSpanControl,
revetLink?: RevetLinkControl,
ford?: FordControl,
box?: BoxControl,
): SectionViewController {
const root = document.createElement("div");
root.className = "b06-section";
@@ -407,6 +406,7 @@ export function createSectionView(
structureSpan,
revetLink,
ford,
box,
);
/**