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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 사용자). 연동 해제는 측점별, 종단경사 반영은
|
||||
# 기슭막이 한 벌 전체 공통이라 소유 측점에만 실린다.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}m·${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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user