Merge remote-tracking branches 'origin/sub_desktop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_4' and 'origin/sub_laptop_7' into main_laptop_1
This commit is contained in:
@@ -262,6 +262,9 @@ export interface FordWingSpec {
|
||||
angle_deg: number | null;
|
||||
/** 바닥판 편측 연장(m) = 길이 × cos(각도). 각도는 관축 기준 벌어짐각. */
|
||||
slab_extend_m: number;
|
||||
/** 세월교 유입 날개벽 끝 수평 여유(m) — 흙막이 수평공간과 같은 칸(`inlet_revet_berm_m` · 107-3) ·
|
||||
* null = 화면 기본값(1 m). */
|
||||
berm_m?: number | null;
|
||||
}
|
||||
|
||||
/** 세월교 측점의 세트 제원 — 양측 ㄴ형 측벽 + 바닥판 + 관, 관 위는 성토 채움. */
|
||||
@@ -289,6 +292,8 @@ export interface FordSet {
|
||||
/** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */
|
||||
export interface BoxSet {
|
||||
type: "box";
|
||||
/** 이 시설이 **놓인** 누가거리(m) — 걸친 옆 측점과 주인 측점을 가리는 열쇠(110). */
|
||||
chainage_m?: number;
|
||||
/** 사용자 입력 내공(유로) 폭·높이(m). */
|
||||
inner_width_m: number;
|
||||
inner_height_m: number;
|
||||
@@ -561,6 +566,9 @@ export interface CrossSectionPatch {
|
||||
cut_slope_ratio_user?: number;
|
||||
/** 배수관 연장(m) — 구조물 면적과 같은 길로 정본에 실린다(2026-09-08). */
|
||||
pipe_length_m?: number;
|
||||
/** B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 관 길이와 같은 길. */
|
||||
pipe_slope_pct?: number;
|
||||
box_ground_slope_pct?: number;
|
||||
inlet_structure?: "auto" | "revet" | "I" | "L" | "U";
|
||||
basin_adjust?: {
|
||||
innerWidthM: number;
|
||||
|
||||
@@ -444,6 +444,9 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
pipe_count = max(int(count), 1) if count else 1
|
||||
wing_in = _wing_spec(values, defaults, "in")
|
||||
wing_out = _wing_spec(values, defaults, "out")
|
||||
if wing_in["installed"]:
|
||||
# 유입 날개벽 수평 여유 — 흙막이 수평공간과 같은 칸(107-3) · 빈 값 = 화면 기본값
|
||||
wing_in["berm_m"] = _number(values.get("inlet_revet_berm_m"), None)
|
||||
defaulted = _defaulted(
|
||||
values,
|
||||
[
|
||||
|
||||
@@ -184,6 +184,10 @@ class CrossSectionPatch(BaseModel):
|
||||
# 공종을 세려면 정본에 있어야 한다(2026-09-08). 계산은 서버가 같은 코드를 Node 로
|
||||
# 돌려 내므로 한 벌이다(`B06_Section_Server_Calc_Node`).
|
||||
pipe_length_m: float | None = Field(default=None, ge=0)
|
||||
# B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 기하가 낸 값
|
||||
# (관 길이와 같은 길). 0 · 음수도 그대로 — 빈칸 판정은 B08 몫.
|
||||
pipe_slope_pct: float | None = None
|
||||
box_ground_slope_pct: float | None = None
|
||||
|
||||
|
||||
class SectionConfirmRequest(BaseModel):
|
||||
|
||||
@@ -66,6 +66,8 @@ _AREA_KEYS = (
|
||||
"cut_soil_area_m2",
|
||||
"cut_rock_area_m2",
|
||||
"pipe_length_m",
|
||||
"pipe_slope_pct",
|
||||
"box_ground_slope_pct",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ import { computeStructureAreas, wallBodyInFillM2 } from "@util/common_util_cross
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import { withoutGuardedFill } from "./B06_Section_UI_Cross_Body_Guard";
|
||||
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
|
||||
import type { BoxLayout } from "./B06_Section_UI_Cross_Box_Geom";
|
||||
import { REVET_EMBED_DEPTH_M } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
|
||||
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type { BasinShape, WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import {
|
||||
@@ -68,16 +70,15 @@ const inletStructure: InletStructureControl = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 그 관의 **주인 측점** 누가거리 — 관 자리에서 가장 가까운 측점 하나. 관 자리를 모르면 `null`.
|
||||
* 그 시설의 **주인 측점** 누가거리 — 시설 자리(`target`)에서 가장 가까운 측점 하나. 자리를 모르면 `null`.
|
||||
*
|
||||
* ⚠ 관 자리와 측점 자리는 스냅 때문에 어긋날 수 있다(위 설명 참조). 거리 한계를 두지 않고
|
||||
* **가장 가까운 하나**만 고르는 것이 요점 — 두 측점이 같이 「주인」이 되면 같은 관을 두 번 센다.
|
||||
*/
|
||||
function pipeOwnerChainage(
|
||||
section: CrossSection,
|
||||
function ownerChainage(
|
||||
target: number | undefined,
|
||||
sections: readonly CrossSection[],
|
||||
): number | null {
|
||||
const target = section.culvert?.chainage_m;
|
||||
if (typeof target !== "number") return null;
|
||||
let best: number | null = null;
|
||||
for (const item of sections) {
|
||||
@@ -155,8 +156,21 @@ export const STRUCTURE_ROW_KEYS = [
|
||||
"cut_soil_area_m2",
|
||||
"cut_rock_area_m2",
|
||||
"pipe_length_m",
|
||||
// B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 같은 길로 실음.
|
||||
"pipe_slope_pct",
|
||||
"box_ground_slope_pct",
|
||||
] as const;
|
||||
|
||||
/** 박스 두 끝(구체 끝) 원지반 경사(%) — 유입 − 유출 ÷ 수평거리. 본체는 늘 수평이라 원지반으로(110). */
|
||||
function boxGroundSlopePct(section: CrossSection, box: BoxLayout): number | null {
|
||||
const groundAt = groundInterpolator(section.samples);
|
||||
const inlet = box.sides.find((side) => side.wingRole === "inlet");
|
||||
const outlet = box.sides.find((side) => side.wingRole === "outlet");
|
||||
const run = inlet && outlet ? Math.abs(inlet.offset - outlet.offset) : 0;
|
||||
if (!groundAt || !inlet || !outlet || !(run > 0)) return null;
|
||||
return ((groundAt(inlet.offset) - groundAt(outlet.offset)) / run) * 100;
|
||||
}
|
||||
|
||||
/** 폐회로 면적 입력(설계선·지반선·트림) — 트림이나 설계선이 모자라면 null. */
|
||||
function areaInputOf(section: CrossSection, layouts: StoredLayouts) {
|
||||
const trim = trimOfLayouts(layouts);
|
||||
@@ -195,18 +209,26 @@ function areaRowOf(
|
||||
// B08 이 「연장 없음」으로 막아 **금액이 통째로 빠진다**(실측: 배수관 넷).
|
||||
// ⇒ **가장 가까운 측점 하나**를 주인으로 본다. 거리로 자르지 않으므로 스냅 폭이
|
||||
// 바뀌어도 따라가고, 하나만 고르므로 두 번 세지도 않는다.
|
||||
const ownerChainage = pipeOwnerChainage(section, sections);
|
||||
const isOwner = (target: number | undefined): boolean => {
|
||||
const owner = ownerChainage(target, sections);
|
||||
return owner === null || Math.abs(owner - section.chainage_m) <= CHAINAGE_TOLERANCE_M;
|
||||
};
|
||||
// 세월교 · 박스 흙막이 세트(73-4)는 관이 아님 — 관 연장을 안 실음.
|
||||
const pipeOwner =
|
||||
!!section.culvert &&
|
||||
!section.culvert.body &&
|
||||
(ownerChainage === null ||
|
||||
Math.abs(ownerChainage - section.chainage_m) <= CHAINAGE_TOLERANCE_M);
|
||||
const pipeLengthM = pipeOwner ? layouts.culvert?.pipe?.lengthM : undefined;
|
||||
const pipeRow: Record<string, number> | null =
|
||||
typeof pipeLengthM === "number" && pipeLengthM > 0
|
||||
? { chainage_m: section.chainage_m, pipe_length_m: Number(pipeLengthM.toFixed(4)) }
|
||||
!!section.culvert && !section.culvert.body && isOwner(section.culvert.chainage_m);
|
||||
const pipe = pipeOwner ? layouts.culvert?.pipe : undefined;
|
||||
const ownRow: Record<string, number> = { chainage_m: section.chainage_m };
|
||||
if (pipe && pipe.lengthM > 0) {
|
||||
ownRow.pipe_length_m = Number(pipe.lengthM.toFixed(4));
|
||||
ownRow.pipe_slope_pct = Number(pipe.slopePct.toFixed(4));
|
||||
}
|
||||
// 박스도 걸친 옆 측점이 아니라 주인 측점에만(관과 같은 규칙).
|
||||
const boxSlope =
|
||||
layouts.box && isOwner(section.box?.chainage_m)
|
||||
? boxGroundSlopePct(section, layouts.box)
|
||||
: null;
|
||||
if (boxSlope !== null) ownRow.box_ground_slope_pct = Number(boxSlope.toFixed(4));
|
||||
const pipeRow = Object.keys(ownRow).length > 1 ? ownRow : null;
|
||||
const areaInput = areaInputOf(section, layouts);
|
||||
if (!areaInput) return pipeRow;
|
||||
const design = layouts.design;
|
||||
@@ -280,7 +302,7 @@ export function extraWallRows(sections: readonly CrossSection[]): ExtraWallRow[]
|
||||
const rows: ExtraWallRow[] = [];
|
||||
for (const section of sections) {
|
||||
if (!section.culvert) continue;
|
||||
const owner = pipeOwnerChainage(section, sections);
|
||||
const owner = ownerChainage(section.culvert.chainage_m, sections);
|
||||
if (owner !== null && Math.abs(owner - section.chainage_m) > CHAINAGE_TOLERANCE_M) continue;
|
||||
const layouts = computeStoredLayouts(section, sections);
|
||||
const culvert = layouts?.culvert;
|
||||
|
||||
@@ -47,14 +47,13 @@ const EXTRA_ROWS = [
|
||||
".b06-structure-panel__struct--span",
|
||||
].join(", ");
|
||||
|
||||
/** 유입구·유출구·날개벽 칸은 폼에 없는 줄만 받는다 — 읽기(좌우 · 상하 · 구체 길이 · 표고) ·
|
||||
* 단 수 · 옵션 · 집수정 내공 · 세월교 측벽 높이(24-4a). */
|
||||
/** 유입구·유출구 칸은 폼에 없는 줄만 받는다 — 읽기(좌우 · 상하 · 구체 길이 · 표고) ·
|
||||
* 단 수 · 옵션 · 집수정 내공(24-4a). */
|
||||
const SIDE_ROWS = [
|
||||
".b06-structure-panel__struct--readout",
|
||||
".b06-structure-panel__struct--tier",
|
||||
".b06-structure-panel__struct--options",
|
||||
".b06-structure-panel__struct--basin-size",
|
||||
".b06-structure-panel__struct--wall-height",
|
||||
].join(", ");
|
||||
|
||||
/** 배관 칸 = 폼에 자리 없는 집수정 내공 · 유입 단 수만 — 목록에서 골라도 같은 글(54-29).
|
||||
@@ -64,12 +63,11 @@ const PIPE_INLET_ROWS = [
|
||||
".b06-structure-panel__struct--basin-size",
|
||||
].join(", ");
|
||||
const PIPE_OUTLET_ROWS = ".b06-structure-panel__struct--basin-size";
|
||||
const WALL_HEIGHT_ROW = ".b06-structure-panel__struct--wall-height";
|
||||
|
||||
/** 받을 칸에 맞는 줄 — 배관 유입 · 유출 칸이 아니면 옛 줄 그대로 · 세월교 · 박스 유입 · 유출 칸은
|
||||
* 세월교 측벽 높이(바닥 손 조정 · 91-2)만 받음(좌우 · 상하 · 바닥판 읽기는 칸에 안 둠 · 65-1). */
|
||||
* 받는 줄 없음(측벽 높이 줄 지움 · 107-3 · 읽기는 칸에 안 둠 · 65-1). */
|
||||
function rowsFor(slot: HTMLElement): string | null {
|
||||
if (slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return WALL_HEIGHT_ROW;
|
||||
if (slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null;
|
||||
if (slot === sideSlots?.outlet) return PIPE_OUTLET_ROWS;
|
||||
return slot === sideSlots?.inlet ? PIPE_INLET_ROWS : SIDE_ROWS;
|
||||
}
|
||||
|
||||
@@ -43,9 +43,11 @@ export interface BodyGuardBase {
|
||||
trim: CulvertDesignTrim;
|
||||
}
|
||||
|
||||
/** `defaults` = 세월교 측벽 조작을 안 본 기본 자리(관 1 m 맞춤 기준 · 107-1). */
|
||||
export function bodyGuardBase(
|
||||
section: CrossSection,
|
||||
samples: SectionSample[],
|
||||
defaults = false,
|
||||
): BodyGuardBase | null {
|
||||
const body = section.culvert?.body;
|
||||
const adjust = section.design;
|
||||
@@ -74,9 +76,10 @@ export function bodyGuardBase(
|
||||
return { ends: { inlet: endOf("inlet"), outlet: endOf("outlet") }, trim: layout.designTrim };
|
||||
}
|
||||
if (body === "ford" && section.ford) {
|
||||
const stored = defaults ? null : adjust?.ford_adjust;
|
||||
const layout = computeFordLayout(section, samples, {
|
||||
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(adjust?.ford_adjust?.inlet ?? {}) },
|
||||
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(adjust?.ford_adjust?.outlet ?? {}) },
|
||||
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.inlet ?? {}) },
|
||||
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.outlet ?? {}) },
|
||||
});
|
||||
if (!layout) return null;
|
||||
// 세월교 끝 = 관 끝(측벽 바깥면) · 윗면 = 측벽 상단(상판 높이) · 바닥 = 바닥판 밑
|
||||
@@ -143,16 +146,21 @@ export function bodyGuardAxes(
|
||||
export type BodyAxes = { x: number; d: number; through: number; outward: number };
|
||||
|
||||
/** 세월교 관은 1 m 단위(80) — 흙막이 전면까지 든 관 길이를 올림하고 모자란 만큼 흙막이를 바깥으로
|
||||
* 미는 기본 x(유출 먼저 · 배관 정수 맞춤과 같은 쪽). 손으로 민 x 는 그 위에 1 m 걸음으로 얹힘. */
|
||||
* 미는 기본 x(유출 먼저 · 배관 정수 맞춤과 같은 쪽). 손으로 민 x 는 그 위에 1 m 걸음으로 얹힘.
|
||||
* 맞춤 길이는 **기본 자리**(측벽 조작 0)로만 잼 — 한쪽 이동이 반대쪽 흙막이를 안 밈(107-1 ·
|
||||
* 수량 L 은 그린 관 길이 올림 그대로). */
|
||||
export function fordPipeFit(
|
||||
base: BodyGuardBase | null,
|
||||
section: CrossSection,
|
||||
samples: SectionSample[],
|
||||
culvert: { body?: string | null; inlet: { structure?: string }; outlet: { structure?: string } },
|
||||
axesOf: (role: Role) => BodyAxes | null,
|
||||
axesOf: (role: Role, base?: BodyGuardBase | null) => BodyAxes | null,
|
||||
): [BodyAxes | null, BodyAxes | null] {
|
||||
const axes: [BodyAxes | null, BodyAxes | null] = [axesOf("inlet"), axesOf("outlet")];
|
||||
if (!base || culvert.body !== "ford") return axes;
|
||||
if (culvert.body !== "ford") return axes;
|
||||
const base = bodyGuardBase(section, samples, true);
|
||||
if (!base) return axes;
|
||||
const roles = ["inlet", "outlet"] as const;
|
||||
const guarded = roles.map((role, i) => (culvert[role].structure ? axes[i] : null));
|
||||
const guarded = roles.map((role) => (culvert[role].structure ? axesOf(role, base) : null));
|
||||
const tips = roles.map((role, i) => {
|
||||
const end = base.ends[role];
|
||||
const reach = guarded[i] ? guarded[i]!.outward * guarded[i]!.through : 0;
|
||||
@@ -163,8 +171,8 @@ export function fordPipeFit(
|
||||
const length = Math.hypot(tips[0].offset - tips[1].offset, tips[0].z - tips[1].z);
|
||||
const gap = Math.ceil(length - 1e-6) - length;
|
||||
const at = guarded[1] ? 1 : 0;
|
||||
const fit = guarded[at];
|
||||
if (!fit || gap < 1e-6) return axes;
|
||||
const fit = axes[at];
|
||||
if (!guarded[at] || !fit || gap < 1e-6) return axes;
|
||||
axes[at] = { ...fit, x: fit.x + gap };
|
||||
return axes;
|
||||
}
|
||||
|
||||
@@ -28,12 +28,40 @@ export type {
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/** 고르는 부재 — 좌 · 우 끝 또는 본체(구체 가운데 1/3 · 좌측 「본체」 상자 · 66-1). */
|
||||
/** 고르는 부재 — 좌 · 우 끝 또는 본체(두 구체 끝 사이 · 좌측 「본체」 상자 · 66-1 · 108-3). */
|
||||
export type BoxPick = BoxSideRole | "body";
|
||||
|
||||
/** 선택 강조를 카드 재생성 없이 갈아 끼우는 setter. */
|
||||
export type BoxHighlightSetter = (role: BoxPick | null) => void;
|
||||
|
||||
/** 날개벽(에이프런) 없는 끝의 판정 띠 폭(m) — 구체 끝 바깥으로. */
|
||||
const BARE_END_HIT_M = 0.3;
|
||||
|
||||
/**
|
||||
* 고르기 판정면(108-3) — 본체 = 두 구체 끝 사이 · 끝 = 구체 끝(`side.offset`) ~ 에이프런 바깥
|
||||
* (`apron[1]`) · 에이프런이 없으면 구체 끝 바깥 얇은 띠. 높이 = 저판 밑 ~ 상판 위. 서로 겹치지 않는다.
|
||||
*/
|
||||
export function boxHitBands(layout: BoxLayout): Array<[BoxPick, OffsetPoint[]]> {
|
||||
const top = Math.max(...layout.topSlab.map((point) => point.elevation));
|
||||
const bottom = Math.min(...layout.bottomSlab.map((point) => point.elevation));
|
||||
const band = (from: number, to: number): OffsetPoint[] => [
|
||||
{ offset: from, elevation: top },
|
||||
{ offset: to, elevation: top },
|
||||
{ offset: to, elevation: bottom },
|
||||
{ offset: from, elevation: bottom },
|
||||
];
|
||||
return [
|
||||
["body", band(layout.sides[0].offset, layout.sides[1].offset)],
|
||||
...layout.sides.map((side): [BoxPick, OffsetPoint[]] => {
|
||||
const outward = side.role === "left" ? 1 : -1;
|
||||
const outer = side.apron.length
|
||||
? side.apron[1].offset
|
||||
: side.offset + outward * BARE_END_HIT_M;
|
||||
return [side.role, band(side.offset, outer)];
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* BOX암거 구체 오버레이 — computeBoxLayout 결과를 그린다.
|
||||
* `onSelectSide`가 오면 좌·우 절반이 선택 대상이 된다(조정창이 그 측을 잡는다).
|
||||
@@ -172,27 +200,11 @@ export function appendBoxOverlay(
|
||||
label.textContent = layout.label.text;
|
||||
layer.append(label);
|
||||
|
||||
// 투명 겹면 — 구체 가운데 1/3 = 본체, 그 바깥 양쪽 = 그 끝(66-1). 어느 것을 조정할지 클릭으로 고른다.
|
||||
// 투명 겹면(108-3) — 본체 = 두 구체 끝 사이 · 끝 = 구체 끝 ~ 에이프런 바깥(날개벽 없으면 바깥
|
||||
// 얇은 띠) · 높이 저판 밑 ~ 상판 위. 어느 것을 조정할지 클릭으로 고른다.
|
||||
const hits = new Map<BoxPick, SVGPolygonElement>();
|
||||
if (onSelectSide) {
|
||||
const midOffset = (layout.sides[0].offset + layout.sides[1].offset) / 2;
|
||||
const third = (layout.sides[0].offset - layout.sides[1].offset) / 6;
|
||||
const top = Math.max(...layout.topSlab.map((point) => point.elevation));
|
||||
const bottom = Math.min(...layout.bottomSlab.map((point) => point.elevation));
|
||||
const band = (from: number, to: number): OffsetPoint[] => [
|
||||
{ offset: from, elevation: top },
|
||||
{ offset: to, elevation: top },
|
||||
{ offset: to, elevation: bottom },
|
||||
{ offset: from, elevation: bottom },
|
||||
];
|
||||
const parts: Array<[BoxPick, OffsetPoint[]]> = [
|
||||
["body", band(midOffset - third, midOffset + third)],
|
||||
...layout.sides.map((side): [BoxPick, OffsetPoint[]] => {
|
||||
const outer = side.apron.length ? side.apron[1].offset : side.offset;
|
||||
const inner = midOffset + Math.sign(side.offset - midOffset) * Math.abs(third);
|
||||
return [side.role, band(inner, outer)];
|
||||
}),
|
||||
];
|
||||
const parts: Array<[BoxPick, OffsetPoint[]]> = boxHitBands(layout);
|
||||
for (const [role, points] of parts) {
|
||||
const hit = polygon(
|
||||
points,
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
bindArrowKeys,
|
||||
bindDragHandle,
|
||||
buildPanelShell,
|
||||
clampAxis,
|
||||
dpadButton,
|
||||
dragReadout,
|
||||
makeRow,
|
||||
snapAxisDrag,
|
||||
} from "./B06_Section_UI_Cross_Panel_Base";
|
||||
|
||||
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
|
||||
@@ -27,6 +29,8 @@ export interface BoxPanelDeps {
|
||||
nudgeLength: (role: BoxSideRole, screenDeltaM: number) => void;
|
||||
/** 그 끝의 구체 표고 ±0.1m. */
|
||||
nudgeRise: (role: BoxSideRole, deltaM: number) => void;
|
||||
/** 그림 끌기(108-2) — 길이(화면 좌 +) · 표고를 한 번에 반영. */
|
||||
move: (role: BoxSideRole, screenDeltaM: number, riseDeltaM: number) => void;
|
||||
/** 그 측을 자동 자리로 되돌린다. */
|
||||
reset: (role: BoxSideRole) => void;
|
||||
/** 이 측이 좌측 「구조물 배치」의 어느 칸인가 — 좌·우는 측점 지형이 정하고, 폼은
|
||||
@@ -48,9 +52,10 @@ export interface BoxPanelHandle {
|
||||
|
||||
/** 한 걸음(m) — 사용자 지정 0.1m. */
|
||||
const STEP_M = 0.1;
|
||||
const RISE_LIMIT_M = 10;
|
||||
|
||||
/** BOX암거 조정 오버레이 창을 만든다. 처음에는 숨어 있다. 값 칸은 없다(계획서 24-4a) —
|
||||
* 오버레이 = 읽기 + 십자 + 그림 끌기 손잡이 · `opts.dock` = 좌측 폼 날개벽 칸으로 옮겨 붙는 읽기 줄. */
|
||||
* 오버레이 = 제목 + 십자 + 그림 끌기 손잡이(108-1) · `opts.dock` = 좌측 폼 날개벽 칸으로 옮겨 붙는 읽기 줄. */
|
||||
export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): BoxPanelHandle {
|
||||
const shell = buildPanelShell(() => deps.close());
|
||||
const { root, title } = shell;
|
||||
@@ -63,7 +68,8 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo
|
||||
const riseLine = document.createElement("span");
|
||||
const slopeLine = document.createElement("span");
|
||||
const dragLine = dragReadout();
|
||||
value.append(lengthLine, riseLine, slopeLine, dragLine);
|
||||
// 횡단도 안 창 = 제목 · 단추 · 끄는 중 미리 보기만(108-1) — 읽기 줄은 좌측 사본에만.
|
||||
value.append(...(opts?.dock ? [lengthLine, riseLine, slopeLine] : []), dragLine);
|
||||
|
||||
let current: BoxSideRole | null = null;
|
||||
const act = (run: (role: BoxSideRole) => void) => () => {
|
||||
@@ -106,17 +112,27 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo
|
||||
|
||||
const off = { attach: (): void => undefined, detach: (): void => undefined };
|
||||
const arrows = opts?.dock ? off : bindArrowKeys(root, () => [moveRow.controls]);
|
||||
// 그림 위 끌기 손잡이 — ◀▶ 구체 길이 · ▲▼ 표고(십자와 같은 걸음 · 24-4a).
|
||||
// 그림 위 끌기 손잡이 — ◀▶ 구체 길이 · ▲▼ 표고 두 축 함께(십자와 같은 걸음 · 108-2).
|
||||
// 한계 = 길이 ≥ 0(바깥으로만) · 표고 ±10 m(제어기 `_Page_Ford_Controls` 와 같음).
|
||||
const drag = opts?.dock
|
||||
? off
|
||||
: bindDragHandle(root, {
|
||||
stepM: () => (current ? STEP_M : null),
|
||||
preview: (text) => (dragLine.textContent = text),
|
||||
commit: (left, down) => {
|
||||
if (!current) return;
|
||||
if (left) deps.nudgeLength(current, left * STEP_M);
|
||||
if (down) deps.nudgeRise(current, -down * STEP_M);
|
||||
snap: (leftM, downM) => {
|
||||
const role = current;
|
||||
if (!role) return null;
|
||||
const from = deps.adjustFor(role);
|
||||
const lengthSign = role === "left" ? 1 : -1;
|
||||
return snapAxisDrag(leftM, downM, {
|
||||
stepM: STEP_M,
|
||||
slopeOutward: null,
|
||||
clamp: (left, down) => [
|
||||
clampAxis(from.lengthM, left * lengthSign, 0, Infinity) * lengthSign,
|
||||
-clampAxis(from.riseM, -down, -RISE_LIMIT_M, RISE_LIMIT_M),
|
||||
],
|
||||
apply: (left, down) => deps.move(role, left, -down),
|
||||
});
|
||||
},
|
||||
preview: (text) => (dragLine.textContent = text),
|
||||
});
|
||||
|
||||
function render(): void {
|
||||
|
||||
@@ -124,14 +124,14 @@ export function appendCulvertOverlay(
|
||||
segment.points,
|
||||
`유출부 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
|
||||
(segment.overLimit
|
||||
? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)"
|
||||
? " (법정 5m 이상 — 흙막이 의무 구간, 조정창에서 추가)"
|
||||
: " (법정 5m 이내)") +
|
||||
` · 성토 물매 1:${(segment.ratio ?? layout.fillSlope.ratio).toFixed(2)}` +
|
||||
(layout.fillSlope.roadWideningM > 0.01
|
||||
? ` · 노폭 연장 ${layout.fillSlope.roadWideningM.toFixed(2)}m`
|
||||
: "") +
|
||||
(layout.fillSlope.structureRequired
|
||||
? " · ⚠ 기슭막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)"
|
||||
? " · ⚠ 흙막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)"
|
||||
: ""),
|
||||
);
|
||||
}
|
||||
@@ -148,7 +148,7 @@ export function appendCulvertOverlay(
|
||||
? `${basinFillName} 3도 절토선 — 구조물이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m`
|
||||
: `${basinFillName} 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
|
||||
(segment.overLimit
|
||||
? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)"
|
||||
? " (법정 5m 이상 — 흙막이 의무 구간, 조정창에서 추가)"
|
||||
: " (법정 5m 이내)") +
|
||||
` · 성토 물매 1:${(segment.ratio ?? 1.2).toFixed(2)}`,
|
||||
);
|
||||
@@ -192,7 +192,7 @@ export function appendCulvertOverlay(
|
||||
"b06-chart__culvert-revet",
|
||||
// 높이 표기 = 순수 높이(바닥~상단, 근입 0.5 포함). 추가 기슭막이도 같은 기준으로
|
||||
// 통일했다(2026-08-23 배관 벽 → 2026-08-29 추가 벽).
|
||||
`${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${(
|
||||
`${roleLabel(wall.role)} 흙막이 ${wall.form ?? ""} H=${(
|
||||
wall.height + REVET_EMBED_DEPTH_M
|
||||
).toFixed(1)}m` +
|
||||
`(상단 = 사면선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사))` +
|
||||
@@ -221,8 +221,8 @@ export function appendCulvertOverlay(
|
||||
basin.reason === "cut"
|
||||
? "절토측 유입 — 집수정 기본(성토사면 없음)"
|
||||
: basin.reason === "manual"
|
||||
? "사용자 선택 집수정(규칙상 기슭막이 자리)"
|
||||
: "유입측 성토사면 3m 이하 — 기슭막이 대신 집수정 기본";
|
||||
? "사용자 선택 집수정(규칙상 흙막이 자리)"
|
||||
: "유입측 성토사면 3m 이하 — 흙막이 대신 집수정 기본";
|
||||
for (const part of basin.parts) {
|
||||
const shape = polygon(
|
||||
part.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
|
||||
@@ -344,7 +344,7 @@ export function appendCulvertOverlay(
|
||||
const hit = polygon(
|
||||
wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
|
||||
"b06-chart__culvert-revet-hit",
|
||||
`${roleLabel(wall.role)} 기슭막이 선택 — 카드 하단 ◀/▶로 자리 조절`,
|
||||
`${roleLabel(wall.role)} 흙막이 선택 — 카드 하단 ◀/▶로 자리 조절`,
|
||||
);
|
||||
hit.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -56,7 +56,7 @@ export interface OutletExtrasInput {
|
||||
}
|
||||
|
||||
/** 수평공간(berm) 뒤 절토(복합경사 · 91-4)로 원지반까지 — 공간 안에서 지반을 만나면 거기서 끝(79-7). */
|
||||
function bermCut(
|
||||
export function bermCut(
|
||||
src: OffsetPoint,
|
||||
outward: number,
|
||||
berm: { lengthM: number; cut: StructureCutSpec },
|
||||
|
||||
@@ -172,8 +172,8 @@ export function computeCulvertLayout(
|
||||
const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet);
|
||||
const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet);
|
||||
// 본체 끝 자리(x 0점 · d 기본값). 본체 없으면(연동 옆 측점) 배관 규칙.
|
||||
const bodyAxesOf = (role: "inlet" | "outlet") => {
|
||||
const end = body?.ends[role];
|
||||
const bodyAxesOf = (role: "inlet" | "outlet", base = body) => {
|
||||
const end = base?.ends[role];
|
||||
const [spec, info] =
|
||||
role === "inlet" ? [inletWallSpec, inletInfo] : [outletWallSpec, outletInfo];
|
||||
if (!end) return null;
|
||||
@@ -186,7 +186,7 @@ export function computeCulvertLayout(
|
||||
thickness: REVET_THICKNESS_M,
|
||||
});
|
||||
};
|
||||
const [inletBody, outletBody] = fordPipeFit(body, culvert, bodyAxesOf);
|
||||
const [inletBody, outletBody] = fordPipeFit(section, groundSamples, culvert, bodyAxesOf);
|
||||
const onBody = (adjust: WallAdjust, axes: { x: number; d: number } | null): WallAdjust =>
|
||||
axes ? { ...adjust, x: axes.x + adjust.x, d: adjust.d ?? axes.d } : adjust;
|
||||
const minX = culvert.body ? Number.NEGATIVE_INFINITY : undefined;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection, FordSet, SectionSample } from "./B06_Section_Api_Fetch";
|
||||
import { pipeWallThicknessM } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { GUARD_CUT_BERM_M, pipeWallThicknessM } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type {
|
||||
BasinLayout,
|
||||
CulvertDesignTrim,
|
||||
@@ -25,12 +25,13 @@ import type {
|
||||
OutletFillSegment,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { basinApproachPoints, buildBasin } from "./B06_Section_UI_Cross_Culvert_Basin";
|
||||
import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
import { bermCut, buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
|
||||
import { structureCutSpec } from "./B06_Section_UI_Cross_Structure_Cut";
|
||||
|
||||
/** 측벽 한 매의 조작값 — 집수정 9키와 같은 축(높이·좌우·상하 대각). */
|
||||
export interface FordWallAdjust {
|
||||
/** 벽 높이(m, 노견 상단 ~ 바닥판 상면). null = 자동(바닥판이 계류 하상에 놓임). */
|
||||
/** 벽 높이(m, 노견 상단 ~ 바닥판 상면). null = 기본 = 관경 + 최소 토피(원지반 안 봄 · 107-2). */
|
||||
heightM: number | null;
|
||||
/** 좌우(m, + = 노견 바깥). 안쪽으로는 못 간다. */
|
||||
lateralM: number;
|
||||
@@ -103,7 +104,8 @@ export interface FordLayout {
|
||||
adjust: FordAdjust;
|
||||
}
|
||||
|
||||
/** 측벽 높이 하한 = 관경 + 최소 토피. 관이 노면 위로 솟지 않게 막는다. */
|
||||
/** 측벽 높이 하한 · 기본 = 관경 + 최소 토피 — 바닥 = 노면 − (관경 + 토피)(박스 「노면 − 복토」 꼴 ·
|
||||
* 원지반 안 봄 · 깊은 계류면 ▲▼ 로 내림 · 107-2). 관이 노면 위로 솟지 않게 막는다. */
|
||||
function minWallHeight(ford: FordSet): number {
|
||||
return ford.diameter_m + ford.min_cover_m;
|
||||
}
|
||||
@@ -149,15 +151,6 @@ export function computeFordLayout(
|
||||
const inletOnLeft = uphill === "left";
|
||||
const roadTopAt = roadTopFactory(section, edges.left, edges.right);
|
||||
|
||||
// 바닥판이 놓일 계류 하상 — 두 노견 사이 원지반 최저점.
|
||||
const bedElevation = (() => {
|
||||
let low = Math.min(groundAt(edges.left.offset_m), groundAt(edges.right.offset_m));
|
||||
for (let offset = edges.right.offset_m; offset <= edges.left.offset_m; offset += 0.25) {
|
||||
low = Math.min(low, groundAt(offset));
|
||||
}
|
||||
return low;
|
||||
})();
|
||||
|
||||
const buildSide = (role: FordWallRole): FordSideLayout | null => {
|
||||
const onLeft = role === "inlet" ? inletOnLeft : !inletOnLeft;
|
||||
const edge = onLeft ? edges.left : edges.right;
|
||||
@@ -166,8 +159,7 @@ export function computeFordLayout(
|
||||
const wing = role === "inlet" ? ford.wing_in : ford.wing_out;
|
||||
const wanted = role === "inlet" ? adjust.inlet : adjust.outlet;
|
||||
const lateralM = Math.max(wanted.lateralM, 0);
|
||||
const autoHeight = roadTopAt(edge.offset_m) - (bedElevation + ford.slab_thickness_m);
|
||||
const heightM = Math.max(wanted.heightM ?? autoHeight, minWallHeight(ford));
|
||||
const heightM = Math.max(wanted.heightM ?? minWallHeight(ford), minWallHeight(ford));
|
||||
// 벽 상단이 노견에 붙도록 바닥판 상면을 역산한다. 상하 이동은 buildBasin이 얹는다.
|
||||
const anchor: OffsetPoint = {
|
||||
offset: edge.offset_m,
|
||||
@@ -235,7 +227,20 @@ export function computeFordLayout(
|
||||
);
|
||||
})(),
|
||||
fillSegments: extras.segments,
|
||||
cutLine: built.basin.cutLine,
|
||||
// 유입 날개벽 끝이 묻히면 수평 여유 뒤 절토 — 흙막이 수평공간과 같은 칸 · 기본 1 m(107-3)
|
||||
cutLine:
|
||||
role === "inlet" && wing.installed && built.basin.cutLine
|
||||
? bermCut(
|
||||
floor.points[1],
|
||||
outward,
|
||||
{
|
||||
lengthM: Math.max(wing.berm_m ?? GUARD_CUT_BERM_M, 0),
|
||||
cut: structureCutSpec(section.design),
|
||||
},
|
||||
groundAt,
|
||||
limitOffset,
|
||||
).points
|
||||
: built.basin.cutLine,
|
||||
adjust: {
|
||||
heightM: wanted.heightM === null ? null : heightM,
|
||||
lateralM,
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* `_Cross_Panel_Base.ts`)을 쓴다 — 머리줄(제목·닫기), 2행 항목 구조,
|
||||
* **십자 조작 세트는 창 맨 아래**, 키보드 방향키 제어 포함(2026-08-25 일원화).
|
||||
*
|
||||
* 조작: 측벽 좌우(노견 확장) · 측벽 상하(1:1.2 대각) — 십자 · 그림 끌기. 측벽 높이는 폼 날개벽
|
||||
* 칸으로 옮겨 붙는 줄 · 관경 · 수량 · 날개벽 제원은 폼 칸 하나(계획서 24-4a).
|
||||
* 조작: 측벽 좌우(노견 확장) · 측벽 상하(1:1.2 대각) — 십자 · 그림 끌기. 측벽 높이 줄 없음(107-3) ·
|
||||
* 관경 · 수량 · 날개벽 제원은 폼 칸 하나(계획서 24-4a).
|
||||
* 바닥판 연장은 날개벽 길이×cos(각도)가 정하므로 읽기 전용으로 보여 준다.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
bindArrowKeys,
|
||||
bindDragHandle,
|
||||
buildPanelShell,
|
||||
clampAxis,
|
||||
dpadButton,
|
||||
dragReadout,
|
||||
makeButton,
|
||||
makeRow,
|
||||
snapAxisDrag,
|
||||
} from "./B06_Section_UI_Cross_Panel_Base";
|
||||
|
||||
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
|
||||
@@ -29,12 +30,14 @@ export interface FordPanelDeps {
|
||||
adjustFor: (role: FordWallRole) => FordWallAdjust;
|
||||
/** 실제 그려진 벽 높이(m) — 자동일 때도 숫자를 보여 준다. */
|
||||
heightFor: (role: FordWallRole) => number;
|
||||
/** 높이 ±0.1m. */
|
||||
nudgeHeight: (role: FordWallRole, deltaM: number) => void;
|
||||
/** 좌우: 화면 좌(+)/우(−)로 미는 양(m). 부호 환산은 카드가 한다. */
|
||||
nudge: (role: FordWallRole, screenDeltaM: number) => void;
|
||||
/** 상하: 1:1.2 사면을 타는 대각 이동(수평 성분 m, + = 사면 아래). */
|
||||
nudgeSlope: (role: FordWallRole, deltaM: number) => void;
|
||||
/** 사면 바깥이 화면 왼쪽이면 1 · 오른쪽이면 −1(끌기 축 나누기 · 108-2). */
|
||||
outwardOf: (role: FordWallRole) => number;
|
||||
/** 그림 끌기 — 좌우(화면 좌 +) · 상하(사면) 를 한 번에 반영. */
|
||||
move: (role: FordWallRole, screenDeltaM: number, slopeDeltaM: number) => void;
|
||||
/** 자동 자리로 되돌린다(세 축 모두). */
|
||||
reset: (role: FordWallRole) => void;
|
||||
/** 바닥판 길이(m) — 날개벽 투영이 정한 값을 읽기 전용으로 보여 준다. */
|
||||
@@ -51,11 +54,13 @@ export interface FordPanelHandle {
|
||||
|
||||
/** 한 걸음 — 집수정 9키와 같은 0.1m 눈금. */
|
||||
const STEP_M = 0.1;
|
||||
/** 좌우 · 상하 한계(m) — 제어기 `clampMove`(`_Page_Ford_Controls`)와 같다. */
|
||||
const MOVE_LIMIT_M = 10;
|
||||
|
||||
/** 세월교 측벽 조정 오버레이 창을 만든다. 처음에는 숨어 있다.
|
||||
* 관경 · 수량 · 날개벽은 좌측 폼 칸이 정본이라 창에 두지 않는다(계획서 24-4a).
|
||||
* `opts.dock` — 좌측 폼에 옮겨 붙을 줄(읽기 · 측벽 높이)만 만든다 — 폼에 없던 칸이다.
|
||||
* 오버레이는 읽기 + 십자 + 그림 끌기 손잡이. */
|
||||
* `opts.dock` — 읽기 줄만 만든다(측벽 높이 줄 없음 · 107-3).
|
||||
* 오버레이는 제목 + 십자 + 그림 끌기 손잡이(108-1). */
|
||||
export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): FordPanelHandle {
|
||||
const shell = buildPanelShell(() => deps.close());
|
||||
const { root, title } = shell;
|
||||
@@ -68,33 +73,14 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
|
||||
const moveLine = document.createElement("span");
|
||||
const slabLine = document.createElement("span");
|
||||
const dragLine = dragReadout();
|
||||
value.append(heightLine, moveLine, slabLine, dragLine);
|
||||
// 횡단도 안 창 = 제목 · 단추 · 끄는 중 미리 보기만(108-1) — 읽기 줄은 좌측 사본에만.
|
||||
value.append(...(opts?.dock ? [heightLine, moveLine, slabLine] : []), dragLine);
|
||||
|
||||
let current: FordWallRole | null = null;
|
||||
const act = (run: (role: FordWallRole) => void) => () => {
|
||||
if (current) run(current);
|
||||
};
|
||||
|
||||
// 측벽 높이 — 폼에 칸이 없던 값이라 폼 날개벽 칸으로 옮겨 붙는 줄(24-4a).
|
||||
const heightRow = makeRow("측벽 높이");
|
||||
heightRow.row.classList.add("b06-structure-panel__struct--wall-height");
|
||||
const heightLabel = heightRow.row.querySelector<HTMLElement>(".b06-structure-panel__label")!;
|
||||
const heightValue = document.createElement("span");
|
||||
heightValue.className = "b06-structure-panel__hval";
|
||||
heightRow.controls.append(
|
||||
makeButton(
|
||||
"−",
|
||||
"측벽 높이 0.1m 낮추기",
|
||||
act((role) => deps.nudgeHeight(role, -STEP_M)),
|
||||
),
|
||||
heightValue,
|
||||
makeButton(
|
||||
"+",
|
||||
"측벽 높이 0.1m 높이기",
|
||||
act((role) => deps.nudgeHeight(role, STEP_M)),
|
||||
),
|
||||
);
|
||||
|
||||
// 십자 조작 세트 — **창 맨 아래**(템플릿 규약).
|
||||
const moveRow = makeRow("이동");
|
||||
moveRow.controls.classList.add("b06-structure-panel__buttons");
|
||||
@@ -126,22 +112,34 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
|
||||
),
|
||||
);
|
||||
|
||||
root.append(value, opts?.dock ? heightRow.row : moveRow.row);
|
||||
// 측벽 높이 줄 없음 — 세월교 바닥은 노면 − (관경 + 토피) 기본(107-3)
|
||||
root.append(value);
|
||||
if (!opts?.dock) root.append(moveRow.row);
|
||||
|
||||
const arrows = opts?.dock
|
||||
? { attach: (): void => undefined, detach: (): void => undefined }
|
||||
: bindArrowKeys(root, () => [moveRow.controls]);
|
||||
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 같은 동작(24-4a).
|
||||
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 좌우 + 1:1.2 사면 두 축 함께(108-2).
|
||||
// 한계 = 좌우 0 ~ 10 m(노견 안쪽 금지) · 상하 ±10 m.
|
||||
const drag = opts?.dock
|
||||
? { attach: (): void => undefined, detach: (): void => undefined }
|
||||
: bindDragHandle(root, {
|
||||
stepM: () => (current ? STEP_M : null),
|
||||
preview: (text) => (dragLine.textContent = text),
|
||||
commit: (left, down) => {
|
||||
if (!current) return;
|
||||
if (left) deps.nudge(current, left * STEP_M);
|
||||
if (down) deps.nudgeSlope(current, down * STEP_M);
|
||||
snap: (leftM, downM) => {
|
||||
const role = current;
|
||||
if (!role) return null;
|
||||
const from = deps.adjustFor(role);
|
||||
const outward = deps.outwardOf(role);
|
||||
return snapAxisDrag(leftM, downM, {
|
||||
stepM: STEP_M,
|
||||
slopeOutward: outward,
|
||||
clamp: (left, down) => [
|
||||
clampAxis(from.lateralM, left * outward, 0, MOVE_LIMIT_M) * outward,
|
||||
clampAxis(from.slopeM, down, -MOVE_LIMIT_M, MOVE_LIMIT_M),
|
||||
],
|
||||
apply: (left, down) => deps.move(role, left, down),
|
||||
});
|
||||
},
|
||||
preview: (text) => (dragLine.textContent = text),
|
||||
});
|
||||
|
||||
function render(): void {
|
||||
@@ -153,9 +151,6 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
|
||||
`높이 ${deps.heightFor(current).toFixed(2)}m` + (adjust.heightM === null ? " (자동)" : "");
|
||||
moveLine.textContent = `좌우 ${adjust.lateralM.toFixed(1)}m · 상하 ${adjust.slopeM.toFixed(1)}m`;
|
||||
slabLine.textContent = `바닥판 ${deps.slabLengthM().toFixed(2)}m`;
|
||||
heightValue.textContent = `${deps.heightFor(current).toFixed(1)}m`;
|
||||
// 자동 = 바닥판이 계류 하상을 따라감 · 손 값 = 원지반이 바뀌어도 그대로(91-2) — 값 칸이 좁아 이름표에
|
||||
heightLabel.textContent = adjust.heightM === null ? "측벽 높이 (자동)" : "측벽 높이";
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* · 키보드 방향키 = 십자 버튼(2026-08-23) — 셀렉트·입력 포커스 중엔 양보.
|
||||
* ========================================================================== */
|
||||
|
||||
import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
|
||||
/** 조작 버튼 하나 — 클릭이 카드 선택·팬으로 번지지 않게 막는다. */
|
||||
export function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
@@ -89,19 +91,77 @@ export function arrowsBelongToProfile(): boolean {
|
||||
/** 고른 구조물의 도형 — 벽 · 집수정 · 측벽은 `culvert` 계열, BOX 끝은 판정면이 켜진다. */
|
||||
const ACTIVE_SHAPES = '[class*="culvert"].is-active, .b06-chart__box-hit.is-active';
|
||||
|
||||
/** 끌기 손잡이가 조정 창에 묻는 것 — 걸음(m)이 null 이면 지금 고른 것은 끌 수 없다. */
|
||||
/** 끈 자리를 패널 걸음 · 한계로 맞춘 결과 — 화면 이동(m · 왼쪽 + · 아래 +) · 미리 보기 글 · 반영. */
|
||||
export interface DragSnap {
|
||||
leftM: number;
|
||||
downM: number;
|
||||
text: string;
|
||||
apply: () => void;
|
||||
}
|
||||
|
||||
/** 끌기 손잡이가 조정 창에 묻는 것. */
|
||||
export interface DragHandleDeps {
|
||||
stepM: () => number | null;
|
||||
/** 끈 거리(m · 화면 왼쪽 + · 실제 아래 +) → 맞춘 자리. null = 지금 고른 것은 끌 수 없다. */
|
||||
snap: (leftM: number, downM: number) => DragSnap | null;
|
||||
/** 끄는 동안 창에 적을 미리 보기(빈 글이면 지운다). */
|
||||
preview: (text: string) => void;
|
||||
/** 놓을 때 한 번 — 화면 왼쪽(◀) 걸음 수 · 아래(▼) 걸음 수. 둘 중 하나만 0 이 아니다. */
|
||||
commit: (stepsLeft: number, stepsDown: number) => void;
|
||||
}
|
||||
|
||||
const round1 = (value: number): number => Math.round(value * 10) / 10;
|
||||
const clampTo = (value: number, low: number, high: number): number =>
|
||||
Math.min(Math.max(value, low), high);
|
||||
|
||||
/** 십자 두 축 끌기를 맞추는 데 필요한 것 — 걸음 · ▲▼ 축 뜻 · 한계 · 반영. */
|
||||
export interface AxisDragSpec {
|
||||
stepM: number;
|
||||
/** ▲▼ 한 칸이 1:1.2 사면(바깥 d · 아래 d/1.2)이면 사면 바깥의 화면 방향(왼쪽 = 1) · 순수 수직이면 null. */
|
||||
slopeOutward: number | null;
|
||||
/** 걸음 맞춘 축 이동(◀ + · ▼ +) → 한계 안 가장 가까운 축 이동. */
|
||||
clamp: (left: number, down: number) => [number, number];
|
||||
apply: (left: number, down: number) => void;
|
||||
}
|
||||
|
||||
/** 1:1.2 사면 — 사면 축 한 칸 = 바깥 d · 아래 d/1.2(`_Culvert_Solve.placePipeWall` · 집수정과 같음). */
|
||||
const SLOPE_RUN = FILL_SLOPE_RATIO_MIN;
|
||||
|
||||
/**
|
||||
* 끈 거리를 십자 두 축으로 나눠 걸음에 맞춘다(108-2) — ◀▶ 축 + ▲▼ 축을 함께.
|
||||
* 사면 축이면 아래 성분이 사면 칸을 정하고, 남은 가로가 ◀▶ 칸이 된다. 한계에 걸리면
|
||||
* `clamp` 가 가장 가까운 허용 자리로 자르고, 미리 보기도 그 자리(놓은 뒤 결과와 같음)다.
|
||||
*/
|
||||
export function snapAxisDrag(leftM: number, downM: number, spec: AxisDragSpec): DragSnap {
|
||||
const { stepM, slopeOutward: outward } = spec;
|
||||
const snapStep = (value: number): number => round1(Math.round(value / stepM) * stepM);
|
||||
const wantDown = snapStep(outward === null ? downM : downM * SLOPE_RUN);
|
||||
const wantLeft = snapStep(leftM - (outward ?? 0) * wantDown);
|
||||
const [left, down] = spec.clamp(wantLeft, wantDown).map(round1);
|
||||
return {
|
||||
leftM: left + (outward ?? 0) * down,
|
||||
downM: outward === null ? down : down / SLOPE_RUN,
|
||||
text: moveText(left, down),
|
||||
apply: () => spec.apply(left, down),
|
||||
};
|
||||
}
|
||||
|
||||
/** 미리 보기 글 — 십자 단추 뜻 그대로(◀▶ m · ▲▼ m). */
|
||||
function moveText(left: number, down: number): string {
|
||||
return [
|
||||
left ? `${left > 0 ? "◀" : "▶"} ${Math.abs(left).toFixed(1)}m` : "",
|
||||
down ? `${down > 0 ? "▼" : "▲"} ${Math.abs(down).toFixed(1)}m` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
/** 축 한계 — 지금 값 `from` 에서 `move` 만큼 가되 [low, high] 안(조정값 공간). */
|
||||
export function clampAxis(from: number, move: number, low: number, high: number): number {
|
||||
return round1(clampTo(from + move, low, high) - from);
|
||||
}
|
||||
|
||||
/**
|
||||
* 그림 위 손잡이(계획서 24-4a) — 고른 구조물 도형을 누른 채 끌면 십자와 같은 걸음으로 옮긴다.
|
||||
* 끄는 동안은 도형만 옮겨 보이고(미리 보기) 놓을 때 한 번 반영한다 — 반영하면 카드가 다시 그려진다.
|
||||
* 좌우 · 상하 중 많이 끈 쪽 하나만 쓴다(십자와 같음). 축척은 카드 도면이 적어 둔 `data-px-per-m`.
|
||||
* 끄는 동안은 도형만 맞춘 자리로 옮겨 보이고(미리 보기) 놓을 때 한 번 반영한다 — 반영하면 카드가
|
||||
* 다시 그려진다. 좌우 · 상하 두 축을 함께 쓴다(108-2). 축척은 카드 도면이 적어 둔 `data-px-per-m`.
|
||||
*/
|
||||
export function bindDragHandle(
|
||||
root: HTMLElement,
|
||||
@@ -126,33 +186,22 @@ export function bindDragHandle(
|
||||
event.clientY <= box.bottom + 3
|
||||
);
|
||||
});
|
||||
const stepM = deps.stepM();
|
||||
const pxPerM = Number(svg.dataset.pxPerM);
|
||||
const exaggeration = Number(svg.dataset.exaggeration) || 1;
|
||||
const ctm = layer.getScreenCTM();
|
||||
if (!onShape || !stepM || !(pxPerM > 0) || !ctm) return;
|
||||
if (!onShape || !deps.snap(0, 0) || !(pxPerM > 0) || !ctm) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const [startX, startY] = [event.clientX, event.clientY];
|
||||
let left = 0;
|
||||
let down = 0;
|
||||
let snapped: DragSnap | null = null;
|
||||
const move = (next: PointerEvent): void => {
|
||||
const dx = next.clientX - startX;
|
||||
const dy = next.clientY - startY;
|
||||
const horizontal = Math.abs(dx) >= Math.abs(dy);
|
||||
left = horizontal ? Math.round(-dx / (pxPerM * ctm.a) / stepM) : 0;
|
||||
down = horizontal ? 0 : Math.round(dy / (pxPerM * exaggeration * ctm.d) / stepM);
|
||||
const tx = -left * stepM * pxPerM;
|
||||
const ty = down * stepM * pxPerM * exaggeration;
|
||||
const leftM = -(next.clientX - startX) / (pxPerM * ctm.a);
|
||||
const downM = (next.clientY - startY) / (pxPerM * exaggeration * ctm.d);
|
||||
snapped = deps.snap(leftM, downM);
|
||||
const tx = -(snapped?.leftM ?? 0) * pxPerM;
|
||||
const ty = (snapped?.downM ?? 0) * pxPerM * exaggeration;
|
||||
shapes.forEach((shape) => shape.setAttribute("transform", `translate(${tx} ${ty})`));
|
||||
const meters = (Math.abs(left || down) * stepM).toFixed(1);
|
||||
deps.preview(
|
||||
left
|
||||
? `${left > 0 ? "◀" : "▶"} ${meters}m`
|
||||
: down
|
||||
? `${down > 0 ? "▼" : "▲"} ${meters}m`
|
||||
: "",
|
||||
);
|
||||
deps.preview(snapped?.text ?? "");
|
||||
};
|
||||
const up = (): void => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
@@ -160,12 +209,12 @@ export function bindDragHandle(
|
||||
window.removeEventListener("pointercancel", up);
|
||||
shapes.forEach((shape) => shape.removeAttribute("transform"));
|
||||
deps.preview("");
|
||||
if (!left && !down) return;
|
||||
if (!snapped?.text) return;
|
||||
// 놓은 자리의 click 이 「같은 구조물 다시 누름 = 선택 해제」로 번지지 않게 한 번 막는다.
|
||||
const swallow = (click: Event): void => click.stopPropagation();
|
||||
window.addEventListener("click", swallow, { capture: true, once: true });
|
||||
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
|
||||
deps.commit(left, down);
|
||||
snapped.apply();
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
dragReadout,
|
||||
makeButton,
|
||||
makeRow,
|
||||
snapAxisDrag,
|
||||
type AxisDragSpec,
|
||||
} from "./B06_Section_UI_Cross_Panel_Base";
|
||||
|
||||
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
|
||||
@@ -66,6 +68,8 @@ export interface StructurePanelDeps {
|
||||
canNudge: (key: RevetKey) => boolean;
|
||||
/** 좌우·상하 걸음이 0.1m인가(독립·추가 기슭막이). 배관 기준벽은 false = 1m. */
|
||||
fineMove: (key: RevetKey) => boolean;
|
||||
/** 그림 끌기 두 축(108-2) — ▲▼ 축 뜻 · 한계 · 한 번 반영. 없으면 끌 수 없다. */
|
||||
dragAxes?: (key: RevetKey, basin: boolean) => Omit<AxisDragSpec, "stepM"> | null;
|
||||
/** 다단 기슭막이 상태 — 유출 벽 선택 시 단 수 행 표시 판단에 쓴다. */
|
||||
extraState: () => { canAdd: boolean; count: number };
|
||||
/** 이 카드가 연동으로 넘어온 링크 카드인가 — 단 수는 **기준(소유) 측점**에서만
|
||||
@@ -407,14 +411,13 @@ export function buildStructurePanel(
|
||||
return { button, set };
|
||||
};
|
||||
const LINK_TITLE =
|
||||
"옆 측점 기슭막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다.";
|
||||
"옆 측점 흙막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다.";
|
||||
/** 경사 해제(수평) 중에는 연동을 못 푼다 — 기슭막이 Z를 하나로 묶기 때문(2026-08-30). */
|
||||
const LINK_LOCKED_TITLE =
|
||||
"경사 해제(수평) 중에는 연동을 풀 수 없다 — 기슭막이 Z를 하나로 묶는다.";
|
||||
const LINK_LOCKED_TITLE = "경사 해제(수평) 중에는 연동을 풀 수 없다 — 흙막이 Z를 하나로 묶는다.";
|
||||
const linkToggle = makeToggle("연동", LINK_TITLE, (on) => deps.setLinked(on));
|
||||
const gradeToggle = makeToggle(
|
||||
"경사",
|
||||
"종단경사 반영(3D 전용) — 켜면 3D 기슭막이가 종단경사를 따라 서고, 끄면 연장 구간 내내 Z를 동일하게 본다. 횡단도는 언제나 그 측점 계획고로 그린다.",
|
||||
"종단경사 반영(3D 전용) — 켜면 3D 흙막이가 종단경사를 따라 서고, 끄면 연장 구간 내내 Z를 동일하게 본다. 횡단도는 언제나 그 측점 계획고로 그린다.",
|
||||
(on) => deps.setFollowGrade(on),
|
||||
);
|
||||
|
||||
@@ -457,9 +460,8 @@ export function buildStructurePanel(
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "b06-structure-panel__head";
|
||||
const basinReset = makeButton("↺", "집수정 기본값", () => deps.resetBasin());
|
||||
basinReset.classList.add("b06-structure-panel__basin-reset");
|
||||
head.append(title, basinReset, closeButton);
|
||||
// 집수정 기본값은 위치 십자 가운데 ↺ 하나(108-1 — 머리줄은 제목 · 닫기만).
|
||||
head.append(title, closeButton);
|
||||
// 오버레이 = 읽기 + 십자(기슭막이) · 위치 십자(집수정)만 — 값 칸이 없다(24-4a). 두 조작
|
||||
// 세트는 **창 맨 아래**(2026-08-24 사용자) · 동시에 뜨지 않아 같은 자리에 온다.
|
||||
// 사본 = 폼 칸으로 옮겨 붙을 줄만(`_Adjust_Dock`) — 십자는 오버레이 전용(2026-08-29 지시 7).
|
||||
@@ -478,22 +480,16 @@ export function buildStructurePanel(
|
||||
: [basinRow.row, moveRow.row]),
|
||||
);
|
||||
|
||||
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 같은 동작(24-4a). 오버레이만.
|
||||
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 두 축 함께 · 놓을 때 한 번(108-2). 오버레이만.
|
||||
let dragKind: "wall" | "basin" | null = null;
|
||||
const drag = bindDragHandle(root, {
|
||||
stepM: () =>
|
||||
!current || !dragKind ? null : dragKind === "basin" ? BASIN_STEP_M : moveStepOf(current),
|
||||
preview: (text) => (dragLine.textContent = text),
|
||||
commit: (left, down) => {
|
||||
if (!current) return;
|
||||
if (dragKind === "basin") {
|
||||
if (left) deps.nudgeBasinLateral(left * BASIN_STEP_M);
|
||||
if (down) moveBasinSlope(down);
|
||||
return;
|
||||
}
|
||||
if (left) deps.nudge(current, left * moveStepOf(current));
|
||||
if (down) deps.nudgeSlope(current, down * moveStepOf(current));
|
||||
snap: (leftM, downM) => {
|
||||
const axes = current && dragKind ? deps.dragAxes?.(current, dragKind === "basin") : null;
|
||||
if (!current || !axes) return null;
|
||||
const stepM = dragKind === "basin" ? BASIN_STEP_M : moveStepOf(current);
|
||||
return snapAxisDrag(leftM, downM, { stepM, ...axes });
|
||||
},
|
||||
preview: (text) => (dragLine.textContent = text),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -541,7 +537,6 @@ export function buildStructurePanel(
|
||||
.querySelector(".b06-structure-panel__btn--equal")
|
||||
?.classList.toggle("is-hidden", isOwn || key !== "outlet");
|
||||
basinRow.row.classList.toggle("is-hidden", !isBasin);
|
||||
basinReset.classList.toggle("is-hidden", !isBasin);
|
||||
// I형은 내공(높이·폭) 개념이 없다 — 내공 줄을 숨긴다.
|
||||
for (const entry of basinSizeRows) {
|
||||
// 화면에서 숨김(54-40 · 값 · 그림 계산은 그대로)
|
||||
|
||||
@@ -213,6 +213,7 @@ export function createCrossSectionCard(
|
||||
box,
|
||||
isCardSelected: () => isSelected,
|
||||
clearArea: () => {
|
||||
if (activeRevet !== null) toggleRevet(activeRevet); // 벽 · 본체 동시 고름 금지(107-1)
|
||||
if (activeArea === null) return;
|
||||
activeArea = null;
|
||||
setBandActive(null);
|
||||
@@ -235,10 +236,10 @@ export function createCrossSectionCard(
|
||||
setChipActive(null);
|
||||
}
|
||||
if (activeRevet !== null) {
|
||||
// 미선택 카드는 **카드 선택만** 부른다. 여기서 면적 비우기(onAreaSelect)를 먼저
|
||||
// 부르면 그쪽이 카드를 선택해 버려, 뒤이은 onSelect가 "같은 측점 재클릭 = 해제"로
|
||||
// 먹혀 아무 일도 일어나지 않는다(2026-08-21 화면 확인). 카드 선택 경로가 부모의
|
||||
// 면적 강조를 이미 비운다.
|
||||
bodies.clearSelection(); // 세월교 · 박스 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1)
|
||||
// 미선택 카드는 **카드 선택만** 부른다. 면적 비우기(onAreaSelect)를 먼저 부르면 그쪽이
|
||||
// 카드를 선택해 버려 뒤이은 onSelect가 "같은 측점 재클릭 = 해제"로 먹힌다(2026-08-21
|
||||
// 화면 확인). 카드 선택 경로가 부모의 면적 강조를 이미 비운다.
|
||||
if (wasSelected) onAreaSelect?.(section.station_id, null);
|
||||
else onSelect(section.station_id);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,13 @@ export function boxPanelDeps(context: BoxPanelContext): BoxPanelDeps {
|
||||
}),
|
||||
nudgeRise: (role, deltaM) =>
|
||||
box.update(chainageM, role, { riseM: box.adjustFor(chainageM)[role].riseM + deltaM }),
|
||||
move: (role, screenDeltaM, riseDeltaM) => {
|
||||
const from = box.adjustFor(chainageM)[role];
|
||||
box.update(chainageM, role, {
|
||||
lengthM: Math.round((from.lengthM + screenDeltaM * (role === "left" ? 1 : -1)) * 10) / 10,
|
||||
riseM: Math.round((from.riseM + riseDeltaM) * 10) / 10,
|
||||
});
|
||||
},
|
||||
reset: (role) => box.reset(chainageM, role),
|
||||
wingRoleFor: (role) => ((role === "left") === context.inletOnLeft ? "inlet" : "outlet"),
|
||||
bodyLengthM: () => context.layout()?.bodyLengthM ?? 0,
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford";
|
||||
export interface FordPanelContext {
|
||||
section: CrossSection;
|
||||
ford: FordControl;
|
||||
/** 마지막 계산의 측벽 높이(m) — 높이 조작의 시작값. */
|
||||
/** 마지막 계산의 측벽 높이(m) — 읽기 줄. */
|
||||
heightFor: (role: FordWallRole) => number;
|
||||
/** 마지막 계산의 바닥판 길이(m). */
|
||||
slabLengthM: () => number;
|
||||
@@ -31,8 +31,6 @@ export function fordPanelDeps(context: FordPanelContext): FordPanelDeps {
|
||||
return {
|
||||
adjustFor: (role) => ford.adjustFor(chainage)[role],
|
||||
heightFor: context.heightFor,
|
||||
nudgeHeight: (role, deltaM) =>
|
||||
ford.update(chainage, role, { heightM: context.heightFor(role) + deltaM }),
|
||||
nudge: (role, screenDeltaM) =>
|
||||
ford.update(chainage, role, {
|
||||
lateralM: ford.adjustFor(chainage)[role].lateralM + screenDeltaM * outwardOf(section, role),
|
||||
@@ -41,6 +39,14 @@ export function fordPanelDeps(context: FordPanelContext): FordPanelDeps {
|
||||
ford.update(chainage, role, {
|
||||
slopeM: ford.adjustFor(chainage)[role].slopeM + deltaM,
|
||||
}),
|
||||
outwardOf: (role) => outwardOf(section, role),
|
||||
move: (role, screenDeltaM, slopeDeltaM) => {
|
||||
const from = ford.adjustFor(chainage)[role];
|
||||
ford.update(chainage, role, {
|
||||
lateralM: from.lateralM + screenDeltaM * outwardOf(section, role),
|
||||
slopeM: from.slopeM + slopeDeltaM,
|
||||
});
|
||||
},
|
||||
reset: (role) => ford.reset(chainage, role),
|
||||
slabLengthM: context.slabLengthM,
|
||||
close: context.close,
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { RevetKey } from "./B06_Section_UI_Cross_Culvert";
|
||||
import {
|
||||
FILL_SLOPE_RATIO_MIN,
|
||||
PIPE_WALL_DEFAULT_RUN_M,
|
||||
REVET_EMBED_DEPTH_M,
|
||||
REVET_FORM_DEFAULT,
|
||||
WALL_MIN_PURE_HEIGHT_M,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { clampAxis, type AxisDragSpec } from "./B06_Section_UI_Cross_Panel_Base";
|
||||
import type { CulvertLayout } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type { StructurePanelDeps } from "./B06_Section_UI_Cross_Structure_Panel";
|
||||
@@ -149,6 +152,7 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
|
||||
// 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m. 세월교 흙막이는 안이 관이라 1m(80).
|
||||
fineMove: (key) =>
|
||||
(key !== "inlet" && key !== "outlet") || (ctx.hiddenPipe() && !ctx.fordGuard?.()),
|
||||
dragAxes: (key, basin) => wallDragAxes(ctx, key, basin),
|
||||
extraState: () => ctx.extraState(),
|
||||
isLinkedCard: () => ctx.isLinked,
|
||||
basinExtraState: () => ctx.basinExtraState(),
|
||||
@@ -222,6 +226,70 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
|
||||
};
|
||||
}
|
||||
|
||||
/** 끌기 한계 ±10 m — 제어기 `clampMove`(`_Page_Station_Controls`)와 같다. */
|
||||
const DRAG_LIMIT_M = 10;
|
||||
const round1 = (value: number): number => Math.round(value * 10) / 10;
|
||||
|
||||
/**
|
||||
* 벽 · 집수정 그림 끌기 두 축(108-2) — 축 뜻 · 한계 · 한 번 반영. 한계는 저장 · 기하가 거는 것 중
|
||||
* 미리 알 수 있는 것(±10 m · 안쪽 금지 · 노면 위 금지 · 다단 선반 1.2·d) — 넘으면 가장 가까운 허용 자리.
|
||||
* · 배관 기준벽 · 집수정 = ▲▼ 1:1.2 사면(`placePipeWall` · `_Culvert_Basin` 사면 이동)
|
||||
* · 다단 · 독립 벽 = ▲▼ 순수 수직(`_Culvert_Extra` · `_Revetment` 축)
|
||||
*/
|
||||
function wallDragAxes(
|
||||
ctx: StructurePanelContext,
|
||||
key: RevetKey,
|
||||
basin: boolean,
|
||||
): Omit<AxisDragSpec, "stepM"> | null {
|
||||
if (!ctx.revetOffset) return null;
|
||||
const outward = ctx.outwardOf(basin ? "inlet" : key);
|
||||
if (basin) {
|
||||
const from = ctx.inletStructure?.adjustFor(ctx.section);
|
||||
if (!from) return null;
|
||||
const lateralMove = (left: number): number =>
|
||||
clampAxis(from.lateralM, left * outward, 0, DRAG_LIMIT_M);
|
||||
return {
|
||||
slopeOutward: outward,
|
||||
clamp: (left, down) => [
|
||||
lateralMove(left) * outward,
|
||||
clampAxis(from.slopeM, down, 0, DRAG_LIMIT_M),
|
||||
],
|
||||
apply: (left, down) =>
|
||||
ctx.inletStructure?.updateAdjust(ctx.adjustChainage(), {
|
||||
...from,
|
||||
lateralM: round1(from.lateralM + left * outward),
|
||||
slopeM: round1(from.slopeM + down),
|
||||
}),
|
||||
};
|
||||
}
|
||||
const adjust = ctx.adjustOf(key);
|
||||
const d0 = adjust.d ?? ctx.appliedD().get(key) ?? 0;
|
||||
const diagonal = key === "inlet" || key === "outlet";
|
||||
// x 하한 — 배관 기준벽 = 안쪽 금지(흙막이는 본체 끝이 0점이라 안 막음 · 73-4) · 다단 = 선반 ≥ 0 ·
|
||||
// 독립 = 성토선 각도(1.2·d − 기본 run)
|
||||
const xLow = (d: number): number =>
|
||||
isTierKey(key)
|
||||
? Math.max(0, FILL_SLOPE_RATIO_MIN * d)
|
||||
: key === "own"
|
||||
? FILL_SLOPE_RATIO_MIN * d - PIPE_WALL_DEFAULT_RUN_M
|
||||
: ctx.bodyGuard?.()
|
||||
? -DRAG_LIMIT_M
|
||||
: 0;
|
||||
return {
|
||||
slopeOutward: diagonal ? outward : null,
|
||||
clamp: (left, down) => {
|
||||
const dMove = clampAxis(d0, down, 0, DRAG_LIMIT_M);
|
||||
const low = Math.max(xLow(d0 + dMove), -DRAG_LIMIT_M);
|
||||
return [clampAxis(adjust.x, left * outward, low, DRAG_LIMIT_M) * outward, dMove];
|
||||
},
|
||||
apply: (left, down) =>
|
||||
ctx.revetOffset?.update(ctx.adjustChainage(), key, {
|
||||
x: round1(adjust.x + left * outward),
|
||||
d: round1(d0 + down),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 조정창이 쓸 **카드 상태**를 마지막 기하 결과에서 뽑는다(2026-08-25 분리 — 카드
|
||||
* 렌더러 700줄). 값 계산은 하지 않고 레이아웃 결과를 조정창 키 체계로 옮기기만 한다.
|
||||
|
||||
@@ -109,6 +109,8 @@ export interface FordControlDeps {
|
||||
queuePipeOptions: (chainageM: number, patch: Record<string, number | string>) => void;
|
||||
round1: (value: number) => number;
|
||||
clampMove: (value: number) => number;
|
||||
/** 같은 측점 벽 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1). */
|
||||
releaseWall: (chainageM: number) => void;
|
||||
}
|
||||
|
||||
export interface FordControls {
|
||||
@@ -225,6 +227,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
|
||||
},
|
||||
selectedFor: (chainageM) => fordSelections.get(chainageM.toFixed(2)) ?? null,
|
||||
select: (chainageM, role) => {
|
||||
if (role) deps.releaseWall(chainageM);
|
||||
fordSelections.set(chainageM.toFixed(2), role);
|
||||
},
|
||||
};
|
||||
@@ -425,6 +428,7 @@ export function createBoxControls(deps: FordControlDeps): {
|
||||
},
|
||||
selectedFor: (chainageM) => selections.get(chainageM.toFixed(2)) ?? null,
|
||||
select: (chainageM, role) => {
|
||||
if (role) deps.releaseWall(chainageM);
|
||||
selections.set(chainageM.toFixed(2), role);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -266,6 +266,10 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
},
|
||||
select: (chainageM, key) => {
|
||||
revetSelection = key ? { at: chainageM.toFixed(2), key } : null;
|
||||
// 벽을 고르면 같은 측점 세월교 · 박스 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1)
|
||||
const { ford, box } = bodyControls;
|
||||
if (key && ford.control.selectedFor(chainageM)) ford.control.select(chainageM, null);
|
||||
if (key && box.control.selectedFor(chainageM)) box.control.select(chainageM, null);
|
||||
revetOffsetControl.onSelectionChange?.();
|
||||
},
|
||||
syncApplied: (chainageM, role, applied) => {
|
||||
@@ -561,6 +565,11 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
queuePipeOptions: culvertOptions.queue,
|
||||
round1,
|
||||
clampMove,
|
||||
releaseWall: (chainageM) => {
|
||||
if (revetSelection?.at !== chainageM.toFixed(2)) return;
|
||||
revetSelection = null;
|
||||
revetOffsetControl.onSelectionChange?.();
|
||||
},
|
||||
},
|
||||
deps.sessionKey,
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
HaulEquipmentLimit,
|
||||
SectionDetailResponse,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
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";
|
||||
@@ -197,6 +198,26 @@ export function createSectionView(
|
||||
};
|
||||
if (revetOffset) revetOffset.onSelectionChange = syncRevetHighlights;
|
||||
|
||||
/**
|
||||
* 고른 구간형 구조물(노면공 등)의 시작~종료 안 횡단 카드 전부에 테두리 강조(106-1).
|
||||
* 가장 가까운 한 장의 `--selected` 는 그대로 — 클래스만 갈아 끼워 줌·팬을 지킨다.
|
||||
*/
|
||||
let spanMarks: ReadonlyArray<StructureInstance> = [];
|
||||
let spanMarkId: string | null = null;
|
||||
const inSpan = (section: CrossSection): boolean => {
|
||||
const mark = spanMarkId ? spanMarks.find((entry) => entry.structure_id === spanMarkId) : null;
|
||||
const { start_m: start, end_m: end } = mark ?? {};
|
||||
if (start == null || end == null || start === end) return false;
|
||||
const at = section.chainage_m;
|
||||
return at >= Math.min(start, end) - 0.01 && at <= Math.max(start, end) + 0.01;
|
||||
};
|
||||
const syncSpanHighlights = (): void => {
|
||||
for (const section of currentDetail?.cross_sections ?? [])
|
||||
document
|
||||
.getElementById(`cross-${section.station_id}`)
|
||||
?.classList.toggle("b06-cross-card--span", inSpan(section));
|
||||
};
|
||||
|
||||
/** 카드 선택 변경 수신자(좌측 구조물 폼). 페이지가 등록한다. */
|
||||
let stationSelectListener: ((stationId: string | null) => void) | null = null;
|
||||
|
||||
@@ -269,8 +290,8 @@ export function createSectionView(
|
||||
section: CrossSection,
|
||||
forcedHeightPx?: number,
|
||||
plotBase?: CrossPlotBase | null,
|
||||
): HTMLElement =>
|
||||
createCrossSectionCard(
|
||||
): HTMLElement => {
|
||||
const card = createCrossSectionCard(
|
||||
section,
|
||||
section.station_id === selectedStationId,
|
||||
currentExaggeration,
|
||||
@@ -296,6 +317,9 @@ export function createSectionView(
|
||||
plotBase,
|
||||
cutSlope,
|
||||
);
|
||||
card.classList.toggle("b06-cross-card--span", inSpan(section));
|
||||
return card;
|
||||
};
|
||||
|
||||
/**
|
||||
* 옆 측점 구조물이 이 측점까지 넘어왔는지 — 기슭막이 기본 연장 10m(전 5·후 5)면
|
||||
@@ -483,7 +507,11 @@ export function createSectionView(
|
||||
);
|
||||
}
|
||||
},
|
||||
selectMark: (structureId) => profile.selectMark(structureId),
|
||||
selectMark: (structureId) => {
|
||||
spanMarkId = structureId;
|
||||
syncSpanHighlights();
|
||||
profile.selectMark(structureId);
|
||||
},
|
||||
setStationSelectListener(listener) {
|
||||
stationSelectListener = listener;
|
||||
},
|
||||
@@ -491,6 +519,8 @@ export function createSectionView(
|
||||
structureEdit = edit;
|
||||
},
|
||||
setStructureMarks(structures, types) {
|
||||
spanMarks = structures;
|
||||
syncSpanHighlights();
|
||||
profile.setMarks(structures, types);
|
||||
// 검증용 훅 — 알약 레인이 무엇을 받았는지 화면 밖에서 수치로 본다
|
||||
// (`__corridorBuild` 등과 같은 용도).
|
||||
|
||||
@@ -175,6 +175,12 @@
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-danger) 45%, transparent);
|
||||
}
|
||||
|
||||
/* 구간형 구조물(노면공 등)을 고르면 시작~종료 안 모든 카드(106-1) — 가까운 한 장의 선택 강조보다 옅게. */
|
||||
.b06-cross-card--span:not(.b06-cross-card--selected) {
|
||||
border-color: var(--color-danger);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-danger) 45%, transparent);
|
||||
}
|
||||
|
||||
/* 제목행 1행 구조(E-2/E-3): 측점 라벨 → 단면유형 pill → 지반유형 → 구조물·kind. */
|
||||
.b06-cross-card > header {
|
||||
display: flex;
|
||||
|
||||
@@ -174,8 +174,9 @@
|
||||
grid-area: se;
|
||||
}
|
||||
|
||||
.b06-structure-panel__basin-reset {
|
||||
margin-left: auto;
|
||||
/* 횡단도 안 조정창 = 제목 · 단추만(108-1) — 줄 이름은 좌측 사본에만. */
|
||||
.b06-structure-panel:not(.b06-structure-panel--dock) .b06-structure-panel__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b06-structure-panel__select {
|
||||
@@ -297,11 +298,6 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 집수정 기본값(↺)은 9키와 한 세트라 오버레이에만 둔다. */
|
||||
.b06-structure-panel--dock .b06-structure-panel__basin-reset {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 좌측 폼과 중복된 행은 사본에서 숨긴다(2026-08-29 사용자 지시 3·5):
|
||||
* 형태 = 유입구·유출구 그룹의 "형태", 구조물 형식 = 유입구 "구조"로 합쳤다. */
|
||||
.b06-structure-panel--dock .b06-structure-panel__struct--form,
|
||||
|
||||
Reference in New Issue
Block a user