merge: B05·B06 구조물 패널 일원화 작업을 B07 작업 브랜치로 가져온다
다른 브랜치(feat/B05B06-구조물패널-일원화)의 B05·B06 구조물 배치 폼 일원화 결과를 받는다. 충돌 없음 — 그쪽은 B05/B06만, 이쪽은 B07/B08만 건드렸다. 그쪽 브랜치에 남아 있던 옛 폴더(B08_DesignDetail, B07_Quantity)는 이쪽 삭제가 반영돼 사라진다. main.py·라우트·척도 등 B07 작업물은 이쪽 것이 유지된다.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
/* =============================================================================
|
||||
* b_structures_section.ts
|
||||
* 「구조물 배치」 컨테이너 공용 진입점 — B05·B06 두 페이지가 같은 주소로 쓴다
|
||||
* (2026-08-29 사용자: B05/B06 인터페이스 일원화 — 배치 폼·하단 목록 템플릿 동일).
|
||||
*
|
||||
* 구현은 B05 모듈(`B05_Profile_UI_Structures_Panel` 외)에 있다. 이 파일은 재수출과
|
||||
* 스타일 동봉만 한다 — B06이 이 파일 하나만 import해도 B05와 같은 모양이 실린다.
|
||||
* ========================================================================== */
|
||||
import "../B05_Profile/B05_Profile_UI_Style.css";
|
||||
import "../B05_Profile/B05_Profile_UI_Style_Structures.css";
|
||||
|
||||
export {
|
||||
createStructuresSection,
|
||||
GROUP_LABELS,
|
||||
type PipeFacilityItem,
|
||||
type StructuresSection,
|
||||
} from "../B05_Profile/B05_Profile_UI_Structures_Panel";
|
||||
@@ -11,15 +11,16 @@
|
||||
* · 집수정: 부재 폴리곤 대신 **전체 직육면체**(부재 외곽 박스 — 2026-08-23
|
||||
* 사용자). 연장 = basin_length/before/after_m(기본 2m·1/1 — 2026-08-24).
|
||||
* · 배관: 관 하단 시·끝점(횡단면 안 대각선)을 축으로 하는 원통, 지름 = 관경.
|
||||
* 조정창 조작값(기슭막이 4축 x·d·h·m, 다단 단 수)은 확정 시 정본(design.revet_adjust·
|
||||
* extra_wall_counts)에 실린다 — 3D는 **정본만** 읽는다(2026-08-24 사용자 확정: 3D는
|
||||
* 종단·횡단 확정 뒤의 최종 산출물이며 3D 쪽 편집은 없다). 확정 전 세션 값은 반영하지
|
||||
* 않는다.
|
||||
* 조정창 조작값(기슭막이 4축 x·d·h·m, 다단 단 수)은 **캐시**(`section.design`)에 그 자리에서
|
||||
* 실린다 — 3D는 그 **캐시를 읽는다**(2026-08-30 사용자 확정. 영구저장소 정본이 아니라
|
||||
* 캐시가 지금 화면의 값이다). B06 조정창이 캐시를 고치면 코리도 해시가 바뀌어 다시 빌드된다.
|
||||
*
|
||||
* 연동(2026-08-30 사용자 확정): 기슭막이는 길이·전/후로 **이미 하나**다. 연동을 푼 측점은
|
||||
* 그 측점 자리에 서므로 벽이 노선과 평행이 아니라 **자기 경로선**을 따라간다 — 링마다
|
||||
* 폴리곤을 갈아 끼워(`polygons`) 측점 사이를 Catmull-Rom으로 잇는다. 벽은 여전히 하나다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { WallAdjust } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
|
||||
import { ZERO_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
|
||||
import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
|
||||
import { restrictToSide, tierSpanOf } from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { pipeWallThicknessM } from "../B06_Section/B06_Section_UI_Cross_Culvert_Const";
|
||||
@@ -39,6 +40,13 @@ import type { SectionPolygon } from "./B05_Profile_UI_Corridor_Structures_Box";
|
||||
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Box";
|
||||
import type { BoxAdjust, BoxLayout } from "../B06_Section/B06_Section_UI_Cross_Box";
|
||||
import { buildWingSolids } from "./B05_Profile_UI_Corridor_Structures_Wing";
|
||||
import {
|
||||
buildWallPath,
|
||||
detachedControlsOf,
|
||||
polygonAt,
|
||||
storedAdjusts,
|
||||
type WallPath,
|
||||
} from "./B05_Profile_UI_Corridor_Structures_Path";
|
||||
import { computeRevetmentLayout } from "../B06_Section/B06_Section_UI_Cross_Revetment";
|
||||
|
||||
/** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */
|
||||
@@ -163,27 +171,6 @@ function splitOf(spec: CulvertSideSpec | undefined, fallbackLength: number): [nu
|
||||
*/
|
||||
const layoutCache = new WeakMap<CrossSection, { hash: string; layout: CulvertLayout | null }>();
|
||||
|
||||
/** 정본(design.revet_adjust·extra_wall_counts)을 기하 입력 형태로 되접는다. */
|
||||
function storedAdjusts(
|
||||
section: CrossSection,
|
||||
):
|
||||
| { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[]; basinExtras: WallAdjust[] }
|
||||
| undefined {
|
||||
const stored = section.design?.revet_adjust;
|
||||
const counts = section.design?.extra_wall_counts;
|
||||
if (!stored && !counts) return undefined;
|
||||
const at = (role: string): WallAdjust =>
|
||||
stored?.[role]
|
||||
? { ...ZERO_ADJUST, ...(stored[role] as Partial<WallAdjust>) }
|
||||
: { ...ZERO_ADJUST };
|
||||
return {
|
||||
inlet: at("inlet"),
|
||||
outlet: at("outlet"),
|
||||
extras: Array.from({ length: counts?.outlet ?? 0 }, (_unused, i) => at(`extra${i}`)),
|
||||
basinExtras: Array.from({ length: counts?.basin ?? 0 }, (_unused, i) => at(`bextra${i}`)),
|
||||
};
|
||||
}
|
||||
|
||||
/** BOX암거 정본 조작값 — 확정 전 세션 값은 안 읽는다(2026-08-24 규칙). */
|
||||
export function boxAdjustOf(section: CrossSection): BoxAdjust {
|
||||
const stored = section.design?.box_adjust;
|
||||
@@ -301,41 +288,38 @@ export function buildCorridorStructures(
|
||||
// 본다. 이 스위치는 3D 전용이다 — 횡단도는 언제나 그 측점 계획고로 그린다.
|
||||
const followGrade = section.design?.revet_follow_grade !== false;
|
||||
const baseZ = followGrade ? (frameAt(chainage)?.designZ ?? null) : null;
|
||||
const ringsOf = (beforeM: number, afterM: number): StructureFrame[] => {
|
||||
/** 스윕 링이 설 누가거리 열 — 프레임과 폴리곤 보간이 같은 자리를 쓴다. */
|
||||
const ringChainagesOf = (beforeM: number, afterM: number): number[] => {
|
||||
const from = chainage - beforeM;
|
||||
const to = chainage + afterM;
|
||||
const count = Math.max(2, Math.ceil((to - from) / SWEEP_STEP_M) + 1);
|
||||
const rings: StructureFrame[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const at = from + ((to - from) * i) / (count - 1);
|
||||
const frame = frameAt(at);
|
||||
if (!frame) {
|
||||
rings.push(stationFrame);
|
||||
continue;
|
||||
}
|
||||
rings.push({
|
||||
cx: frame.cx,
|
||||
cy: frame.cy,
|
||||
leftX: frame.leftX,
|
||||
leftY: frame.leftY,
|
||||
dz: baseZ != null && frame.designZ != null ? frame.designZ - baseZ : 0,
|
||||
});
|
||||
}
|
||||
return rings;
|
||||
return Array.from({ length: count }, (_unused, i) => from + ((to - from) * i) / (count - 1));
|
||||
};
|
||||
|
||||
const pushSwept = (
|
||||
kind: "revet" | "basin",
|
||||
points: Array<{ offset: number; elevation: number }>,
|
||||
beforeM: number,
|
||||
afterM: number,
|
||||
/** 벽 경로선 — 연동을 푼 측점이 있으면 링마다 단면을 갈아 끼운다(2026-08-30). */
|
||||
path?: WallPath | null,
|
||||
): void => {
|
||||
if (points.length < 3) return;
|
||||
const chainages = ringChainagesOf(beforeM, afterM);
|
||||
const rings = ringsAt(chainages);
|
||||
if (!path) {
|
||||
solids.push({
|
||||
chainage_m: chainage,
|
||||
kind,
|
||||
polygon: points.map((p) => [p.offset, p.elevation]),
|
||||
rings,
|
||||
});
|
||||
return;
|
||||
}
|
||||
solids.push({
|
||||
chainage_m: chainage,
|
||||
kind,
|
||||
polygon: points.map((p) => [p.offset, p.elevation]),
|
||||
rings: ringsOf(beforeM, afterM),
|
||||
polygons: chainages.map((at) => polygonAt(path, at)),
|
||||
rings,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -378,6 +362,8 @@ export function buildCorridorStructures(
|
||||
},
|
||||
/** 보어(구멍) 중심 누가거리 목록 — 세월교는 련수만큼(2026-08-25 사용자 ⑥). */
|
||||
boreChainages: number[] = [chainage],
|
||||
/** 벽 경로선(2026-08-30) — 링마다 그 자리 단면을 잘라 구멍을 낸다. */
|
||||
path?: WallPath | null,
|
||||
): void => {
|
||||
if (points.length < 3) return;
|
||||
const polygon: SectionPolygon = points.map((point) => [point.offset, point.elevation]);
|
||||
@@ -385,7 +371,7 @@ export function buildCorridorStructures(
|
||||
const right = Math.min(...polygon.map(([offset]) => offset));
|
||||
// 관 진행 구간과 겹치지 않는 벽(다단 등)은 손대지 않는다.
|
||||
if (left < bore.minOffset || right > bore.maxOffset) {
|
||||
pushSwept(kind, points, beforeM, afterM);
|
||||
pushSwept(kind, points, beforeM, afterM, path);
|
||||
return;
|
||||
}
|
||||
// 보어마다 촘촘한 링을 깐 뒤 합친다 — 링이 성기면 원형이 사라진다.
|
||||
@@ -423,7 +409,7 @@ export function buildCorridorStructures(
|
||||
clamped.bottom = center;
|
||||
clamped.top = center;
|
||||
}
|
||||
const [top, bottom] = splitByBand(polygon, clamped);
|
||||
const [top, bottom] = splitByBand(path ? polygonAt(path, at) : polygon, clamped);
|
||||
upper.push(top);
|
||||
lower.push(bottom);
|
||||
}
|
||||
@@ -532,21 +518,37 @@ export function buildCorridorStructures(
|
||||
const basinSpan = basinSpanOf(section);
|
||||
const hiddenPipe = section.culvert?.hidden_pipe === true;
|
||||
const culvertBore = pipeBore(layout.culvert.diameter_m, layout.pipe.inlet, layout.pipe.outlet);
|
||||
// 연동을 푼 측점이 있으면 벽은 노선과 평행이 아니라 **자기 경로선**을 따라간다
|
||||
// (2026-08-30 사용자). 벽 하나에 솔리드 하나인 것은 그대로다 — 링 단면만 바뀐다.
|
||||
const detachedControls = detachedControlsOf(section, crossSections);
|
||||
const pathOf = (
|
||||
key: string,
|
||||
points: Array<{ offset: number; elevation: number }>,
|
||||
): WallPath | null =>
|
||||
detachedControls.length
|
||||
? buildWallPath(
|
||||
{ at: chainage, polygon: points.map((p) => [p.offset, p.elevation]) },
|
||||
detachedControls,
|
||||
key,
|
||||
)
|
||||
: null;
|
||||
for (const wall of layout.walls) {
|
||||
const span = wall.role === "inlet" ? inletSpan : outletSpan;
|
||||
const path = pathOf(wall.role, wall.points);
|
||||
// 독립 기슭막이는 관이 없다 — 벽을 관통 컷 없이 그대로 스윕한다.
|
||||
if (hiddenPipe) pushSwept("revet", wall.points, span.beforeM, span.afterM);
|
||||
else pushPierced("revet", wall.points, span.beforeM, span.afterM, culvertBore);
|
||||
if (hiddenPipe) pushSwept("revet", wall.points, span.beforeM, span.afterM, path);
|
||||
else
|
||||
pushPierced("revet", wall.points, span.beforeM, span.afterM, culvertBore, [chainage], path);
|
||||
}
|
||||
// 다단은 **단별 구간값**을 따른다(2026-08-29 사용자 — 단마다 연장이 다르다).
|
||||
// 값이 없는 단은 고정 기본 10m(5/5)로 선다(`tierSpanOf`).
|
||||
layout.extraWalls.forEach((wall, i) => {
|
||||
const span = tierSpanOf(section, `extra${i}`);
|
||||
pushSwept("revet", wall.points, span.beforeM, span.afterM);
|
||||
pushSwept("revet", wall.points, span.beforeM, span.afterM, pathOf(`extra${i}`, wall.points));
|
||||
});
|
||||
layout.basinExtras.forEach((wall, i) => {
|
||||
const span = tierSpanOf(section, `bextra${i}`);
|
||||
pushSwept("revet", wall.points, span.beforeM, span.afterM);
|
||||
pushSwept("revet", wall.points, span.beforeM, span.afterM, pathOf(`bextra${i}`, wall.points));
|
||||
});
|
||||
|
||||
if (layout.basin) {
|
||||
@@ -701,5 +703,12 @@ export function structureHashParts(section: CrossSection): Array<string | number
|
||||
: "",
|
||||
counts ? `${counts.outlet},${counts.basin}` : "",
|
||||
section.design?.revet_follow_grade === false ? "flatZ" : "",
|
||||
// 다단 단별 구간값·연동 해제도 형상을 바꾼다 — 빠지면 저장 코리도가 안 만료돼
|
||||
// 값만 바뀌고 3D는 옛 모양 그대로다(2026-08-30).
|
||||
Object.entries(section.design?.extra_spans ?? {})
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([wall, span]) => `${wall}:${span.length_m},${span.before_m},${span.after_m}`)
|
||||
.join("|"),
|
||||
section.design?.revet_link_detached === true ? "detached" : "",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Structures_Path.ts
|
||||
* 기슭막이 **벽 경로선** — 연동을 푼 측점이 있을 때 벽이 노선과 평행하지 않고 자기
|
||||
* 경로를 따라가도록, 측점별 단면 폴리곤을 링마다 보간한다(2026-08-30 사용자 확정:
|
||||
* "연동은 분리가 아니다 — 하나의 기슭막이가 스플라인 형태의 경로를 따라간다").
|
||||
*
|
||||
* 순수 기하만 둔다(Three.js·레이아웃 계산 무의존). 측점 폴리곤을 만드는 쪽은
|
||||
* `_Corridor_Structures.ts`이고, 여기서는 **꼭짓점별 Catmull-Rom**으로 잇기만 한다.
|
||||
* 꼭짓점 수가 다른 측점은 로프트 토폴로지가 깨지므로 제어점에서 뺀다 — 그런 벽은
|
||||
* 소유 측점 폴리곤 하나로 고정(종전 동작)된다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { CulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
|
||||
import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
|
||||
import type { WallAdjust } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
|
||||
import { ZERO_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
|
||||
import {
|
||||
culvertOwnerFor,
|
||||
culvertReach,
|
||||
restrictToSide,
|
||||
} from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire";
|
||||
|
||||
/** 단면 폴리곤 [offset, 표고][] — 코리도 솔리드가 쓰는 그 형식. */
|
||||
export type SectionPolygon = Array<[number, number]>;
|
||||
|
||||
/** 경로선 제어점 하나 — 그 측점 누가거리와 거기서 잰 벽 단면. */
|
||||
export interface WallPathControl {
|
||||
at: number;
|
||||
polygon: SectionPolygon;
|
||||
}
|
||||
|
||||
/** 누가거리 오름차순 제어점 열. 2개 이상이라야 경로선이 생긴다. */
|
||||
export type WallPath = WallPathControl[];
|
||||
|
||||
/** 조정창 키(`inlet`·`outlet`·`extra0`·`bextra0`…)로 그 벽 단면을 꺼낸다. */
|
||||
export function wallPolygonOf(layout: CulvertLayout, key: string): SectionPolygon | null {
|
||||
const wall = key.startsWith("bextra")
|
||||
? layout.basinExtras[Number(key.slice(6))]
|
||||
: key.startsWith("extra")
|
||||
? layout.extraWalls[Number(key.slice(5))]
|
||||
: layout.walls.find((candidate) => candidate.role === key);
|
||||
if (!wall || wall.points.length < 3) return null;
|
||||
return wall.points.map((point) => [point.offset, point.elevation]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 소유 측점 단면 + 연동을 푼 옆 측점 단면으로 경로선을 만든다. 제어점이 소유 측점
|
||||
* 하나뿐이거나 꼭짓점 수가 어긋나면 null — 호출부는 종전대로 단일 폴리곤을 스윕한다.
|
||||
*/
|
||||
export function buildWallPath(
|
||||
base: WallPathControl,
|
||||
others: ReadonlyArray<{ at: number; layout: CulvertLayout }>,
|
||||
key: string,
|
||||
): WallPath | null {
|
||||
const path: WallPath = [base];
|
||||
for (const other of others) {
|
||||
const polygon = wallPolygonOf(other.layout, key);
|
||||
// 꼭짓점 수가 다르면 로프트 링 토폴로지가 깨진다 — 그 측점은 제어점에서 뺀다.
|
||||
if (!polygon || polygon.length !== base.polygon.length) continue;
|
||||
path.push({ at: other.at, polygon });
|
||||
}
|
||||
if (path.length < 2) return null;
|
||||
return path.sort((a, b) => a.at - b.at);
|
||||
}
|
||||
|
||||
/** 제어값 열의 Catmull-Rom 보간(끝 밖은 끝값 유지). 제어점 2개면 사실상 선형. */
|
||||
function splineValue(values: number[], ats: number[], at: number): number {
|
||||
const last = values.length - 1;
|
||||
if (last < 1) return values[0];
|
||||
if (at <= ats[0]) return values[0];
|
||||
if (at >= ats[last]) return values[last];
|
||||
let i = 0;
|
||||
while (i < last - 1 && ats[i + 1] < at) i += 1;
|
||||
const span = ats[i + 1] - ats[i];
|
||||
const t = span <= 1e-9 ? 0 : (at - ats[i]) / span;
|
||||
const p0 = i > 0 ? values[i - 1] : values[i];
|
||||
const p1 = values[i];
|
||||
const p2 = values[i + 1];
|
||||
const p3 = i + 2 <= last ? values[i + 2] : values[i + 1];
|
||||
const t2 = t * t;
|
||||
const t3 = t2 * t;
|
||||
return (
|
||||
0.5 *
|
||||
(2 * p1 +
|
||||
(-p0 + p2) * t +
|
||||
(2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 +
|
||||
(-p0 + 3 * p1 - 3 * p2 + p3) * t3)
|
||||
);
|
||||
}
|
||||
|
||||
/** 그 누가거리의 단면 — 꼭짓점마다 offset·표고를 따로 보간한다. */
|
||||
export function polygonAt(path: WallPath, at: number): SectionPolygon {
|
||||
const ats = path.map((control) => control.at);
|
||||
return path[0].polygon.map((_vertex, index) => [
|
||||
splineValue(
|
||||
path.map((control) => control.polygon[index][0]),
|
||||
ats,
|
||||
at,
|
||||
),
|
||||
splineValue(
|
||||
path.map((control) => control.polygon[index][1]),
|
||||
ats,
|
||||
at,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 정본(design.revet_adjust·extra_wall_counts)을 기하 입력 형태로 되접는다. */
|
||||
export function storedAdjusts(
|
||||
section: CrossSection,
|
||||
):
|
||||
| { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[]; basinExtras: WallAdjust[] }
|
||||
| undefined {
|
||||
const stored = section.design?.revet_adjust;
|
||||
const counts = section.design?.extra_wall_counts;
|
||||
if (!stored && !counts) return undefined;
|
||||
const at = (role: string): WallAdjust =>
|
||||
stored?.[role]
|
||||
? { ...ZERO_ADJUST, ...(stored[role] as Partial<WallAdjust>) }
|
||||
: { ...ZERO_ADJUST };
|
||||
return {
|
||||
inlet: at("inlet"),
|
||||
outlet: at("outlet"),
|
||||
extras: Array.from({ length: counts?.outlet ?? 0 }, (_unused, i) => at(`extra${i}`)),
|
||||
basinExtras: Array.from({ length: counts?.basin ?? 0 }, (_unused, i) => at(`bextra${i}`)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 연동을 푼 측점의 4축 — **소유 측점 값에서 출발**해 이 측점이 손댄 벽만 갈아 끼운다.
|
||||
* 단 수는 소유 측점 것이다(2026-08-30 사용자: 연동 해제는 분리가 아니다). 횡단도
|
||||
* `detachedAdjusts`와 같은 규칙 — 3D가 2D와 다른 벽을 세우면 안 된다.
|
||||
*/
|
||||
function detachedAdjusts(
|
||||
owner: CrossSection,
|
||||
section: CrossSection,
|
||||
): ReturnType<typeof storedAdjusts> {
|
||||
const base = storedAdjusts(owner);
|
||||
const mine = section.design?.revet_adjust;
|
||||
const counts = owner.design?.extra_wall_counts;
|
||||
const pick = (role: string, fallback?: WallAdjust): WallAdjust =>
|
||||
mine?.[role]
|
||||
? { ...ZERO_ADJUST, ...(mine[role] as Partial<WallAdjust>) }
|
||||
: (fallback ?? { ...ZERO_ADJUST });
|
||||
return {
|
||||
inlet: pick("inlet", base?.inlet),
|
||||
outlet: pick("outlet", base?.outlet),
|
||||
extras: Array.from({ length: counts?.outlet ?? 0 }, (_unused, i) =>
|
||||
pick(`extra${i}`, base?.extras[i]),
|
||||
),
|
||||
basinExtras: Array.from({ length: counts?.basin ?? 0 }, (_unused, i) =>
|
||||
pick(`bextra${i}`, base?.basinExtras[i]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* **연동을 푼** 옆 측점의 기하 — 소유 측점 **스펙**을 그 측점 지형·조작값 위에 세운다.
|
||||
* 횡단도 링크 카드(`computeCardCulvert`)와 같은 규칙이다: 구조물 형식은 언제나 소유
|
||||
* 측점 것, 4축은 이 측점 것. 벽 경로선의 제어점으로 쓴다(2026-08-30 사용자).
|
||||
*/
|
||||
export function detachedLayoutOf(owner: CrossSection, section: CrossSection): CulvertLayout | null {
|
||||
if (!owner.culvert || !section.design) return null;
|
||||
const hosted: CrossSection = { ...section, culvert: owner.culvert };
|
||||
try {
|
||||
const layout = computeCulvertLayout(
|
||||
hosted,
|
||||
section.samples,
|
||||
detachedAdjusts(owner, section),
|
||||
owner.culvert.hidden_pipe ? "revet" : (owner.design?.inlet_structure ?? "auto"),
|
||||
section.design.basin_adjust,
|
||||
);
|
||||
if (layout && owner.culvert.hidden_pipe) return restrictToSide(layout, owner.culvert.side);
|
||||
return layout;
|
||||
} catch {
|
||||
return null; // 한 측점의 기하 실패가 3D 전체를 막으면 안 된다.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 구조물의 연장 안에서 **연동을 푼** 측점들의 기하. 경사 해제(수평)면 연동을 풀 수
|
||||
* 없으므로 빈 배열이다 — 그때는 소유 측점 단면 하나가 Z 그대로 눕는다(2026-08-30).
|
||||
*/
|
||||
export function detachedControlsOf(
|
||||
owner: CrossSection,
|
||||
sections: ReadonlyArray<CrossSection>,
|
||||
): Array<{ at: number; layout: CulvertLayout }> {
|
||||
if (owner.design?.revet_follow_grade === false) return [];
|
||||
const reach = culvertReach(owner);
|
||||
if (!reach) return [];
|
||||
const controls: Array<{ at: number; layout: CulvertLayout }> = [];
|
||||
for (const section of sections) {
|
||||
if (section === owner || section.design?.revet_link_detached !== true) continue;
|
||||
const deltaM = section.chainage_m - owner.chainage_m;
|
||||
if (deltaM < -reach.beforeM - 1e-9 || deltaM > reach.afterM + 1e-9) continue;
|
||||
// 겹치는 구조물이 있으면 가장 가까운 측점이 주인이다 — 남의 벽을 끌어오지 않는다.
|
||||
if (culvertOwnerFor(section, sections) !== owner) continue;
|
||||
const layout = detachedLayoutOf(owner, section);
|
||||
if (layout) controls.push({ at: section.chainage_m, layout });
|
||||
}
|
||||
return controls;
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
BOX_SIZE_CUSTOM,
|
||||
BOX_SIZE_PRESETS,
|
||||
createRevetmentFields,
|
||||
createRevetSideGroup,
|
||||
createWingFields,
|
||||
fordSection,
|
||||
grid,
|
||||
@@ -51,10 +52,35 @@ import {
|
||||
OUTLET_REVET_KEYS,
|
||||
putNumber,
|
||||
REVET_COMMON_DEFAULTS,
|
||||
stepper,
|
||||
WING_IN_KEYS,
|
||||
WING_OUT_KEYS,
|
||||
} from "./B05_Profile_UI_Drainage_Facility_Fields";
|
||||
|
||||
/** B06 유입측 구조물 형식 — 조정창 드롭다운과 같은 값 집합(2026-08-29 병합).
|
||||
* 문자열 리터럴로 두어 B05가 B06 모듈에 기대지 않게 한다. */
|
||||
export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U";
|
||||
|
||||
/** 유입구 "구조" 목록 — 조정창의 구조물 형식(자동/기슭막이/집수정 I·ㄴ·ㄷ)을 이 한
|
||||
* 드롭다운으로 합쳤다(2026-08-29 사용자 지시 5: 일단 리스트를 합치고 중복은 뒤에 뺀다).
|
||||
* 고른 값 하나가 정본 둘을 정한다 — 관 옵션 `inlet_type`(기슭막이/집수정)과 B06
|
||||
* 유입측 형식(`inlet_structure`). */
|
||||
const INLET_KINDS: ReadonlyArray<{
|
||||
label: string;
|
||||
type: "기슭막이" | "집수정";
|
||||
structure: InletStructureKind;
|
||||
}> = [
|
||||
{ label: "기슭막이", type: "기슭막이", structure: "revet" },
|
||||
{ label: "집수정", type: "집수정", structure: "auto" },
|
||||
{ label: "자동(규칙)", type: "기슭막이", structure: "auto" },
|
||||
{ label: "집수정 I형", type: "집수정", structure: "I" },
|
||||
{ label: "집수정 ㄴ형", type: "집수정", structure: "L" },
|
||||
{ label: "집수정 ㄷ형", type: "집수정", structure: "U" },
|
||||
];
|
||||
|
||||
const inletKindOf = (label: string): (typeof INLET_KINDS)[number] =>
|
||||
INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0];
|
||||
|
||||
/** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. 배관(pipe)도 부속 옵션을
|
||||
* 가지면 저장한다. */
|
||||
export interface FacilityAttributes {
|
||||
@@ -153,6 +179,21 @@ export interface FacilityOptionsForm {
|
||||
/** 설계유량만 갱신한다 — 필드 값은 건드리지 않는다. 임시 배치(2026-08-18)의
|
||||
* 유역 재계산 결과를 입력 중인 폼에 살짝 반영하는 용도. */
|
||||
setDesignFlow: (designFlow: number | null) => void;
|
||||
/** 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — B06만 채운다(2026-08-29 지시 4). */
|
||||
inletSlot: HTMLElement;
|
||||
outletSlot: HTMLElement;
|
||||
/** 다단(추가) 기슭막이 행이 옮겨 붙는 자리 — 비면 그룹째 숨는다(2026-08-30 지시 4). */
|
||||
extraSlot: HTMLElement;
|
||||
/** 독립 기슭막이 좌·우 칸의 이식 자리 — 배관 유입구·유출구 자리와 같은 몫이다. */
|
||||
revetInletSlot: HTMLElement;
|
||||
revetOutletSlot: HTMLElement;
|
||||
/** 유입구 "구조"에 합쳐진 B06 형식 값(2026-08-29 지시 5). B06이 조정창 값과 맞춘다. */
|
||||
inletStructure: () => InletStructureKind;
|
||||
setInletStructure: (value: InletStructureKind) => void;
|
||||
/** 독립 기슭막이 좌·우 칸의 이름표를 그 측점 방향으로 맞춘다(B06 전용). */
|
||||
setRevetSideLabels: (labels: { inlet: "좌" | "우"; outlet: "좌" | "우" }) => void;
|
||||
/** 구조가 바뀔 때 B06 유입측 형식 제어에 알린다(B05는 등록하지 않는다). */
|
||||
onInletStructureChange: (handler: (value: InletStructureKind) => void) => void;
|
||||
}
|
||||
|
||||
/** 구조물 배치 폼의 옵션 자리에 끼워지는 계곡 통과 시설 부속 옵션 서브폼. */
|
||||
@@ -182,8 +223,8 @@ export function createFacilityOptionsForm(
|
||||
// 빈값(미지정)을 두면 하위 칸이 전부 숨어 기본값이 안 보인다 — 기슭막이를 기본으로
|
||||
// 두고 저유량 지점에서 집수정으로 바꾼다(2026-08-17 사용자 지시).
|
||||
const inletType = document.createElement("select");
|
||||
inletType.replaceChildren(...["기슭막이", "집수정"].map((value) => new Option(value, value)));
|
||||
inletType.value = "기슭막이";
|
||||
inletType.replaceChildren(...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)));
|
||||
inletType.value = INLET_KINDS[0].label;
|
||||
const basinForm = optionalSelect("선택", [
|
||||
"동물이동형",
|
||||
"□형(기본형)",
|
||||
@@ -201,17 +242,25 @@ export function createFacilityOptionsForm(
|
||||
// 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자).
|
||||
const basinLength = numberInput("0.1");
|
||||
basinLength.value = "2";
|
||||
const basinRows = [grid(labeled("형식", basinForm), labeled("길이 (m)", basinLength))];
|
||||
const basinRows = [
|
||||
grid(labeled("형식", basinForm), labeled("길이 (m)", stepper(basinLength, 1))),
|
||||
];
|
||||
const inletRevet = createRevetmentFields(INLET_REVET_KEYS, {
|
||||
...REVET_COMMON_DEFAULTS,
|
||||
form: "돌쌓기(찰)",
|
||||
});
|
||||
// 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다
|
||||
// (2026-08-17 사용자 지시 3).
|
||||
// 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리(2026-08-29 사용자 지시 4).
|
||||
// 비어 있으면 아무 자리도 차지하지 않는다.
|
||||
const inletSlot = document.createElement("div");
|
||||
inletSlot.className = "b05-structure__adjust-slot";
|
||||
const outletSlot = inletSlot.cloneNode() as HTMLDivElement;
|
||||
inletGroup.body.append(
|
||||
grid(labeled("구조", inletType), basinMaterialField, inletRevet.formField),
|
||||
...basinRows,
|
||||
...inletRevet.rows,
|
||||
inletSlot,
|
||||
);
|
||||
|
||||
// ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ──
|
||||
@@ -225,45 +274,59 @@ export function createFacilityOptionsForm(
|
||||
outletGroup.body.append(
|
||||
grid(labeled("구조", outletType), outletRevet.formField),
|
||||
...outletRevet.rows,
|
||||
outletSlot,
|
||||
);
|
||||
|
||||
// ── 추가 기슭막이(다단) — 횡단도에서 다단 벽을 고를 때만 뜬다(2026-08-30 사용자
|
||||
// 지시 4). 유입구·유출구와 같은 양식이되, 값은 조정창 사본의 행이 그대로 옮겨 온다
|
||||
// (다단 벽은 폼에 자기 칸이 없다 — 높이·형태·구간값이 전부 조정 채널에 있다).
|
||||
const extraGroup = group("추가 기슭막이");
|
||||
const extraSlot = document.createElement("div");
|
||||
extraSlot.className = "b05-structure__adjust-slot";
|
||||
extraGroup.body.append(extraSlot);
|
||||
extraGroup.root.hidden = true;
|
||||
|
||||
// ── 독립 기슭막이(2026-08-28 이관: 배관처럼 pipe_points 관리) ─────────────
|
||||
// 배관 없는 기슭막이 — 설치 측(양쪽/좌/우) + 형태·높이·길이·전/후(배관 유입·유출과
|
||||
// 같은 조각) + 다단 단 수. B06 횡단도·3D·조정창이 배관 기슭막이 경로를 그대로 탄다.
|
||||
const revetGroup = group("기슭막이");
|
||||
// 설치 측은 **컨테이너 밖**(배관의 관종·관경과 같은 층), 벽 제원과 **단 수**는 좌·우
|
||||
// 두 칸이 각자 갖는다(2026-08-30 사용자). 설치 측이 한쪽이면 그 칸만 뜬다.
|
||||
// 좌·우가 저장 채널(유입/유출) 중 어느 쪽인지는 측점 지형이 정하므로 이름표만
|
||||
// 바꿔 단다(`setRevetSideLabels`) — 값은 언제나 그 채널 키로 오간다.
|
||||
const revetSide = optionalSelect("양쪽", ["양쪽", "좌", "우"]);
|
||||
revetSide.value = "양쪽";
|
||||
const revetTiers = numberInput("1", "1");
|
||||
revetTiers.value = "1";
|
||||
// 옵션 키는 배관 유출 기슭막이와 **같은 한 벌**을 쓴다 — B06 조정창의 구간값도 이 키로
|
||||
// 저장되므로 원천이 하나여야 한다(2026-08-28: 전용 키 `length_m`을 쓰던 동안 조정창
|
||||
// 값이 매번 되돌아갔다). 읽을 때만 옛 전용 키를 폴백으로 본다.
|
||||
const standaloneRevet = createRevetmentFields(OUTLET_REVET_KEYS, {
|
||||
...REVET_COMMON_DEFAULTS,
|
||||
form: "돌쌓기(메)",
|
||||
});
|
||||
/** 옛 전용 키(`form`·`length_m`…)로 저장된 값을 배관 키 자리로 옮겨 읽는다. */
|
||||
const revetTopRow = grid(labeled("설치 측", revetSide));
|
||||
const revetInlet = createRevetSideGroup(INLET_REVET_KEYS, "돌쌓기(메)");
|
||||
const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS, "돌쌓기(메)");
|
||||
/** 좌·우 이름표 — B06이 그 측점의 벽 방향을 알려 주면 바뀐다. 기본은 유입=좌. */
|
||||
let revetSideLabels: { inlet: "좌" | "우"; outlet: "좌" | "우" } = {
|
||||
inlet: "좌",
|
||||
outlet: "우",
|
||||
};
|
||||
/** 옛 전용 키(`form`·`length_m`…)와 한 벌 저장분을 **양쪽 칸**으로 펼쳐 읽는다. */
|
||||
const legacyRevetOptions = (
|
||||
options: Record<string, string | number>,
|
||||
): Record<string, string | number> => {
|
||||
const merged = { ...options };
|
||||
const pairs: Array<[string, string]> = [
|
||||
["outlet_revet_form", "form"],
|
||||
["outlet_revet_length_m", "length_m"],
|
||||
["outlet_revet_height_m", "height_m"],
|
||||
["outlet_revet_before_m", "before_m"],
|
||||
["outlet_revet_after_m", "after_m"],
|
||||
const pairs: Array<[string, string, string]> = [
|
||||
["outlet_revet_form", "inlet_revet_form", "form"],
|
||||
["outlet_revet_length_m", "inlet_revet_length_m", "length_m"],
|
||||
["outlet_revet_height_m", "inlet_revet_height_m", "height_m"],
|
||||
["outlet_revet_before_m", "inlet_revet_before_m", "before_m"],
|
||||
["outlet_revet_after_m", "inlet_revet_after_m", "after_m"],
|
||||
];
|
||||
for (const [key, legacy] of pairs) {
|
||||
if (merged[key] === undefined && options[legacy] !== undefined) merged[key] = options[legacy];
|
||||
for (const [outletKey, inletKey, legacy] of pairs) {
|
||||
if (merged[outletKey] === undefined && options[legacy] !== undefined) {
|
||||
merged[outletKey] = options[legacy];
|
||||
}
|
||||
// 한쪽만 저장된 옛 자료 — 반대쪽 칸도 같은 값으로 세운다.
|
||||
if (merged[inletKey] === undefined && merged[outletKey] !== undefined) {
|
||||
merged[inletKey] = merged[outletKey];
|
||||
}
|
||||
if (merged[outletKey] === undefined && merged[inletKey] !== undefined) {
|
||||
merged[outletKey] = merged[inletKey];
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
revetGroup.body.append(
|
||||
grid(labeled("설치 측", revetSide), standaloneRevet.formField),
|
||||
...standaloneRevet.rows,
|
||||
grid(labeled("단 수(다단)", revetTiers)),
|
||||
);
|
||||
|
||||
// ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ────
|
||||
// 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정).
|
||||
@@ -364,7 +427,10 @@ export function createFacilityOptionsForm(
|
||||
pipeRow,
|
||||
inletGroup.root,
|
||||
outletGroup.root,
|
||||
revetGroup.root,
|
||||
extraGroup.root,
|
||||
revetTopRow,
|
||||
revetInlet.root,
|
||||
revetOutlet.root,
|
||||
boxWrap,
|
||||
wingInFields.root,
|
||||
wingOutFields.root,
|
||||
@@ -396,7 +462,18 @@ export function createFacilityOptionsForm(
|
||||
// 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다.
|
||||
fordSlopeRow.hidden = current !== "ford_pavement";
|
||||
fordSummary.hidden = !isFord;
|
||||
revetGroup.root.hidden = current !== "revetment";
|
||||
const isRevet = current === "revetment";
|
||||
revetTopRow.hidden = !isRevet;
|
||||
// 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다.
|
||||
revetInlet.root.hidden =
|
||||
!isRevet || (revetSide.value !== "양쪽" && revetSide.value !== revetSideLabels.inlet);
|
||||
revetOutlet.root.hidden =
|
||||
!isRevet || (revetSide.value !== "양쪽" && revetSide.value !== revetSideLabels.outlet);
|
||||
revetInlet.legend.textContent = `기슭막이 (${revetSideLabels.inlet})`;
|
||||
revetOutlet.legend.textContent = `기슭막이 (${revetSideLabels.outlet})`;
|
||||
// 추가 기슭막이 칸은 **내용이 있을 때만** 선다 — 고른 다단 벽의 행이 여기 오면 뜨고,
|
||||
// 선택이 풀리면 스스로 사라진다.
|
||||
extraGroup.root.hidden = extraSlot.childElementCount === 0;
|
||||
if (isFord) syncFordSummary();
|
||||
if (isBox) syncBoxSize();
|
||||
if (hasWing) {
|
||||
@@ -405,8 +482,10 @@ export function createFacilityOptionsForm(
|
||||
}
|
||||
if (isPipe) {
|
||||
// 유입구 구조에 따라 집수정 칸과 기슭막이 칸을 갈아 끼운다(사용자 지시 6~8).
|
||||
const basin = inletType.value === "집수정";
|
||||
const revet = inletType.value === "기슭막이";
|
||||
// 병합 목록이라 판정은 라벨이 아니라 매핑된 정본 종류로 한다(2026-08-29).
|
||||
const kind = inletKindOf(inletType.value);
|
||||
const basin = kind.type === "집수정";
|
||||
const revet = kind.type === "기슭막이";
|
||||
basinRows.forEach((row) => (row.hidden = !basin));
|
||||
basinMaterialField.hidden = !basin;
|
||||
inletRevet.formField.hidden = !revet;
|
||||
@@ -430,8 +509,11 @@ export function createFacilityOptionsForm(
|
||||
);
|
||||
inletRevet.onChange(emit);
|
||||
outletRevet.onChange(emit);
|
||||
standaloneRevet.onChange(emit);
|
||||
[revetSide, revetTiers].forEach((input) => input.addEventListener("change", emit));
|
||||
revetInlet.fields.onChange(emit);
|
||||
revetOutlet.fields.onChange(emit);
|
||||
[revetSide, revetInlet.tiers, revetOutlet.tiers].forEach((input) =>
|
||||
input.addEventListener("change", emit),
|
||||
);
|
||||
[
|
||||
pipeMaterial,
|
||||
pipeDiameter,
|
||||
@@ -444,8 +526,36 @@ export function createFacilityOptionsForm(
|
||||
fordWidth,
|
||||
].forEach((input) => input.addEventListener("change", emit));
|
||||
|
||||
/** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */
|
||||
let inletStructureHandler: ((value: InletStructureKind) => void) | null = null;
|
||||
inletType.addEventListener("change", () =>
|
||||
inletStructureHandler?.(inletKindOf(inletType.value).structure),
|
||||
);
|
||||
|
||||
return {
|
||||
root,
|
||||
inletSlot,
|
||||
outletSlot,
|
||||
extraSlot,
|
||||
revetInletSlot: revetInlet.slot,
|
||||
revetOutletSlot: revetOutlet.slot,
|
||||
setRevetSideLabels(labels) {
|
||||
revetSideLabels = labels;
|
||||
syncVisibility();
|
||||
},
|
||||
inletStructure: () => inletKindOf(inletType.value).structure,
|
||||
setInletStructure(value) {
|
||||
// 지금 고른 정본 종류(기슭막이/집수정)는 지키고 형식만 맞춘다.
|
||||
const type = inletKindOf(inletType.value).type;
|
||||
const hit =
|
||||
INLET_KINDS.find((kind) => kind.type === type && kind.structure === value) ??
|
||||
INLET_KINDS.find((kind) => kind.structure === value);
|
||||
if (hit) inletType.value = hit.label;
|
||||
syncVisibility();
|
||||
},
|
||||
onInletStructureChange(handler) {
|
||||
inletStructureHandler = handler;
|
||||
},
|
||||
setFacility(facility, options = {}, designFlow = null) {
|
||||
current = facility;
|
||||
designFlowM3s = designFlow ?? null;
|
||||
@@ -458,7 +568,15 @@ export function createFacilityOptionsForm(
|
||||
const isFord = facility === "ford_bridge";
|
||||
pipeMaterial.value = text("pipe_kind") || "파형강관";
|
||||
pipeDiameter.value = text("pipe_diameter_mm") || "1000";
|
||||
inletType.value = text("inlet_type") || "기슭막이";
|
||||
// 병합 목록: 저장된 정본 둘(inlet_type·inlet_structure)로 라벨을 되찾는다.
|
||||
const savedType = text("inlet_type") || "기슭막이";
|
||||
const savedStructure = text("inlet_structure");
|
||||
inletType.value = (
|
||||
INLET_KINDS.find(
|
||||
(kind) =>
|
||||
kind.type === savedType && (!savedStructure || kind.structure === savedStructure),
|
||||
) ?? inletKindOf(savedType)
|
||||
).label;
|
||||
outletType.value = text("outlet_type") || "기슭막이";
|
||||
basinForm.value = text("inlet_basin_form");
|
||||
basinMaterial.value = text("inlet_basin_material");
|
||||
@@ -491,8 +609,13 @@ export function createFacilityOptionsForm(
|
||||
fordHeight.value = text("ford_height_m");
|
||||
fordSlope.value = text("ford_slope_pct");
|
||||
revetSide.value = text("side") || "양쪽";
|
||||
revetTiers.value = text("tiers") || "1";
|
||||
standaloneRevet.write(legacyRevetOptions(options));
|
||||
// 단 수 — 좌·우 각자 키가 먼저고, 옛 한 벌 값(`tiers`)이 폴백이다.
|
||||
const legacyTiers = text("tiers") || "1";
|
||||
revetInlet.tiers.value = text("inlet_revet_tiers") || legacyTiers;
|
||||
revetOutlet.tiers.value = text("outlet_revet_tiers") || legacyTiers;
|
||||
const spread = legacyRevetOptions(options);
|
||||
revetInlet.fields.write(spread);
|
||||
revetOutlet.fields.write(spread);
|
||||
// 월류 폭 기본값 — 세월교 10m·물넘이 포장 5m(2026-08-18 사용자 확정, config 정의처).
|
||||
if (!fordWidth.value && (facility === "ford_pavement" || facility === "ford_bridge")) {
|
||||
fordWidth.value = String(
|
||||
@@ -510,12 +633,15 @@ export function createFacilityOptionsForm(
|
||||
if (current === "pipe") {
|
||||
options.pipe_diameter_mm = Number(pipeDiameter.value);
|
||||
options.pipe_kind = pipeMaterial.value;
|
||||
options.inlet_type = inletType.value;
|
||||
if (inletType.value === "집수정") {
|
||||
const inletKind = inletKindOf(inletType.value);
|
||||
options.inlet_type = inletKind.type;
|
||||
// B06 유입측 형식(조정창과 같은 값) — 병합 드롭다운이 함께 정한다(2026-08-29).
|
||||
options.inlet_structure = inletKind.structure;
|
||||
if (inletKind.type === "집수정") {
|
||||
if (basinForm.value) options.inlet_basin_form = basinForm.value;
|
||||
if (basinMaterial.value) options.inlet_basin_material = basinMaterial.value;
|
||||
putNumber(options, "inlet_basin_length_m", basinLength.value);
|
||||
} else if (inletType.value === "기슭막이") {
|
||||
} else {
|
||||
inletRevet.read(options);
|
||||
}
|
||||
options.outlet_type = outletType.value;
|
||||
@@ -541,20 +667,19 @@ export function createFacilityOptionsForm(
|
||||
wingOutFields.read(options);
|
||||
} else if (current === "revetment") {
|
||||
options.side = revetSide.value;
|
||||
const tiers = Number.parseInt(revetTiers.value, 10);
|
||||
if (Number.isFinite(tiers) && tiers >= 1) options.tiers = tiers;
|
||||
standaloneRevet.read(options);
|
||||
// 양쪽 벽은 같은 한 벌이 정본 — 유입 키에도 같은 값을 실어 벽 둘이 갈리지 않게 한다.
|
||||
for (const [inletKey, outletKey] of [
|
||||
[INLET_REVET_KEYS.form, OUTLET_REVET_KEYS.form],
|
||||
[INLET_REVET_KEYS.length, OUTLET_REVET_KEYS.length],
|
||||
[INLET_REVET_KEYS.height, OUTLET_REVET_KEYS.height],
|
||||
[INLET_REVET_KEYS.before, OUTLET_REVET_KEYS.before],
|
||||
[INLET_REVET_KEYS.after, OUTLET_REVET_KEYS.after],
|
||||
] as Array<[string | undefined, string | undefined]>) {
|
||||
if (inletKey && outletKey && options[outletKey] !== undefined)
|
||||
options[inletKey] = options[outletKey];
|
||||
}
|
||||
const tierOf = (input: HTMLInputElement): number | null => {
|
||||
const value = Number.parseInt(input.value, 10);
|
||||
return Number.isFinite(value) && value >= 1 ? value : null;
|
||||
};
|
||||
const inletTiers = tierOf(revetInlet.tiers);
|
||||
const outletTiers = tierOf(revetOutlet.tiers);
|
||||
if (inletTiers !== null) options.inlet_revet_tiers = inletTiers;
|
||||
if (outletTiers !== null) options.outlet_revet_tiers = outletTiers;
|
||||
// 옛 한 벌 키도 남긴다 — 서버 `_revet_set`이 아직 이 값을 읽는다.
|
||||
options.tiers = Math.max(inletTiers ?? 1, outletTiers ?? 1);
|
||||
// 좌·우가 각자 값을 갖는다 — 저장 채널은 유입/유출 키 그대로다(2026-08-30).
|
||||
revetInlet.fields.read(options);
|
||||
revetOutlet.fields.read(options);
|
||||
}
|
||||
return options;
|
||||
},
|
||||
|
||||
@@ -60,6 +60,10 @@ export function labeled(text: string, input: HTMLElement): HTMLLabelElement {
|
||||
const caption = document.createElement("span");
|
||||
caption.textContent = text;
|
||||
wrapper.append(caption, input);
|
||||
// 묶음(스텝 등)을 담으면 라벨의 암묵 대상이 첫 버튼이 된다 — 안쪽 입력을 명시로
|
||||
// 가리켜 라벨 hover·클릭이 버튼으로 새지 않게 한다(2026-08-29 사용자 보고).
|
||||
const inner = input.querySelector?.("input, select, textarea") as HTMLElement | null;
|
||||
if (inner?.id) wrapper.htmlFor = inner.id;
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
@@ -72,6 +76,48 @@ export function numberInput(step: string, min = "0", placeholder = ""): HTMLInpu
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자 칸 + [-][+] 한 묶음(2026-08-29 사용자 지시 2) — 조정창처럼 눈금으로도
|
||||
* 고칠 수 있게 한다. 래퍼 여백은 0이라 기존 격자 레이아웃을 흔들지 않는다.
|
||||
* 버튼은 값을 고친 뒤 input·change를 모두 울려 기존 리스너(연동·저장)가 그대로 탄다.
|
||||
*/
|
||||
/** 스텝 묶음 입력에 붙일 일련번호 — 라벨이 가리킬 대상을 명시하는 데 쓴다. */
|
||||
let stepperSeq = 0;
|
||||
|
||||
export function stepper(input: HTMLInputElement, stepM: number): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b05-structure__stepper";
|
||||
// 라벨이 이 입력을 가리키게 id를 붙인다. 없으면 <label>의 암묵 대상이 묶음의
|
||||
// **첫 버튼(-)**이 되어, 라벨·입력 위에 커서만 올려도 - 버튼이 눌린 것처럼
|
||||
// 보이고 라벨을 누르면 값이 줄어든다(2026-08-29 사용자 보고).
|
||||
if (!input.id) input.id = `b05-step-${(stepperSeq += 1)}`;
|
||||
const nudge = (delta: number) => (): void => {
|
||||
const current = Number.parseFloat(input.value);
|
||||
const min = Number.parseFloat(input.min);
|
||||
const floor = Number.isFinite(min) ? min : 0;
|
||||
const next = Math.max(
|
||||
floor,
|
||||
Math.round(((Number.isFinite(current) ? current : 0) + delta) * 10) / 10,
|
||||
);
|
||||
input.value = next.toFixed(1);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
};
|
||||
const button = (label: string, delta: number): HTMLButtonElement => {
|
||||
const element = document.createElement("button");
|
||||
element.type = "button";
|
||||
element.className = "b05-structure__step";
|
||||
element.textContent = label;
|
||||
element.title = `${stepM}m ${delta > 0 ? "늘리기" : "줄이기"}`;
|
||||
element.addEventListener("click", nudge(delta));
|
||||
return element;
|
||||
};
|
||||
// 배치는 [-][숫자][+] — 셋이 테두리 하나를 나눠 쓰는 한 덩어리로 보인다
|
||||
// (2026-08-29 사용자 지시).
|
||||
wrap.append(button("-", -stepM), input, button("+", stepM));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** 양수만 옵션에 싣는다 — 빈칸·0·음수는 값이 없는 것으로 본다. */
|
||||
export function putNumber(target: Record<string, string | number>, key: string, raw: string): void {
|
||||
const value = Number.parseFloat(raw);
|
||||
@@ -159,6 +205,39 @@ export const OUTLET_REVET_KEYS: RevetmentKeys = {
|
||||
after: "outlet_revet_after_m",
|
||||
};
|
||||
|
||||
/** 독립 기슭막이 한쪽 칸 — 배관 유출구와 **같은 양식**([구조][형태] + 길이·높이·전/후).
|
||||
* 구조 선택지는 기슭막이 하나뿐이지만 레이아웃을 맞추려 그대로 둔다(2026-08-30 사용자). */
|
||||
export interface RevetSideGroup {
|
||||
root: HTMLFieldSetElement;
|
||||
legend: HTMLLegendElement;
|
||||
fields: RevetmentFields;
|
||||
/** 단 수(다단) — 좌·우가 각자 갖는다(2026-08-30 사용자). */
|
||||
tiers: HTMLInputElement;
|
||||
/** 조정창 행이 옮겨 붙는 자리 — 배관 유입구·유출구 칸과 같은 규칙. */
|
||||
slot: HTMLElement;
|
||||
}
|
||||
|
||||
export function createRevetSideGroup(keys: RevetmentKeys, formDefault: string): RevetSideGroup {
|
||||
const box = group("기슭막이");
|
||||
const kind = document.createElement("select");
|
||||
kind.replaceChildren(new Option("기슭막이", "기슭막이"));
|
||||
const fields = createRevetmentFields(keys, { ...REVET_COMMON_DEFAULTS, form: formDefault });
|
||||
const tiers = numberInput("1", "1");
|
||||
tiers.value = "1";
|
||||
const slot = document.createElement("div");
|
||||
slot.className = "b05-structure__adjust-slot";
|
||||
box.body.append(
|
||||
grid(labeled("구조", kind), fields.formField),
|
||||
...fields.rows,
|
||||
grid(labeled("단 수(다단)", stepper(tiers, 1))),
|
||||
slot,
|
||||
);
|
||||
const legend = box.root.querySelector("legend")!;
|
||||
// 이름표는 좌·우가 정해 준다 — 고른 구조물 이름을 덧붙이지 않는다(2026-08-30).
|
||||
legend.dataset.fixedLabel = "1";
|
||||
return { root: box.root, legend, fields, tiers, slot };
|
||||
}
|
||||
|
||||
export interface RevetmentFields {
|
||||
/** 형태 칸 — 유입·유출 그룹에서 "구조" 옆 2열째에 놓는다(2026-08-17 지시 3·4). */
|
||||
formField: HTMLLabelElement;
|
||||
@@ -207,11 +286,12 @@ export function createRevetmentFields(
|
||||
(keep === before ? after : before).value = Math.max(total - kept, 0).toFixed(1);
|
||||
};
|
||||
|
||||
// 눈금은 조정창과 같은 값이다 — 길이 1m, 전/후 0.5m, 높이 0.1m(2026-08-29 일원화).
|
||||
const formField = labeled("형태", form);
|
||||
const lengthField = length ? labeled("길이 (m)", length) : null;
|
||||
const heightField = labeled("높이 (m)", height);
|
||||
const beforeField = before ? labeled("기준측점 전 (m)", before) : null;
|
||||
const afterField = after ? labeled("기준측점 후 (m)", after) : null;
|
||||
const lengthField = length ? labeled("길이 (m)", stepper(length, 1)) : null;
|
||||
const heightField = labeled("높이 (m)", stepper(height, 0.1));
|
||||
const beforeField = before ? labeled("기준측점 전 (m)", stepper(before, 0.5)) : null;
|
||||
const afterField = after ? labeled("기준측점 후 (m)", stepper(after, 0.5)) : null;
|
||||
|
||||
// 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다.
|
||||
const rows = [
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_
|
||||
import {
|
||||
createFacilityOptionsForm,
|
||||
type FacilityAttributes,
|
||||
type FacilityOptionsForm,
|
||||
} from "./B05_Profile_UI_Drainage_Facility";
|
||||
import { field, numberInput, select, stationFields } from "./B05_Profile_UI_Structures_Fields";
|
||||
import { renderStructureList } from "./B05_Profile_UI_Structures_List";
|
||||
@@ -63,6 +64,9 @@ export interface PipeFacilityItem {
|
||||
|
||||
export interface StructuresSection {
|
||||
root: HTMLElement;
|
||||
/** 계곡 통과 시설 부속 옵션 서브폼 — B06이 조정창 행을 이 폼의 유입구·유출구
|
||||
* 그룹으로 옮겨 붙이고 유입측 형식을 맞춘다(2026-08-29 일원화). */
|
||||
facility: FacilityOptionsForm;
|
||||
/** 배치된 구조물 목록(<ul>) — 섹션 본문이 아니라 사이드 하단 고정 영역에 붙인다
|
||||
* (2026-08-18 사용자 지시: 폼 길이에 밀려 목록이 화면 밖으로 나가던 문제). */
|
||||
listRoot: HTMLElement;
|
||||
@@ -75,6 +79,9 @@ export interface StructuresSection {
|
||||
setPipeFacilities: (pipes: PipeFacilityItem[]) => void;
|
||||
/** 그래프·3D에서 고른 계곡 통과 시설을 목록에서 강조한다(null = 해제). */
|
||||
selectPipeByChainage: (chainageM: number | null) => void;
|
||||
/** 지금 폼이 **어떤 항목을 고쳐 쓰는 중**인가 — 선택이 없으면 폼 값은 아무 데도
|
||||
* 가지 않는다(B06에서 그 상태를 안내하는 데 쓴다, 2026-08-30). */
|
||||
hasSelection: () => boolean;
|
||||
/** 종단도·3D에서 고른 구조물을 폼에 올린다. null이면 선택 해제. */
|
||||
selectById: (structureId: string | null) => void;
|
||||
/** 종단 그래프 우클릭으로 타입을 지정해 추가한다. */
|
||||
@@ -109,7 +116,10 @@ interface StructuresCallbacks {
|
||||
|
||||
export function createStructuresSection(callbacks: StructuresCallbacks): StructuresSection {
|
||||
const root = document.createElement("section");
|
||||
root.className = "b05-route__panel-section ui-collapsible ui-sidebar-section";
|
||||
// b05-structure-section: 이 섹션 안의 입력·버튼 높이를 한 값(--b05-control-h)으로
|
||||
// 묶는 스코프(2026-08-29 사용자 지시 — 조작 컨트롤 높이 통일).
|
||||
root.className =
|
||||
"b05-route__panel-section b05-structure-section ui-collapsible ui-sidebar-section";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "ui-collapsible__title";
|
||||
heading.textContent = "구조물 배치";
|
||||
@@ -156,7 +166,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
// 자리에서, 같은 흐름(종류 선택 → 옵션 → 추가/수정)으로 편집한다(2026-08-17
|
||||
// 사용자 지시 — 자동·수동 배관은 같은 구조물, 별도 UI 금지). 기슭막이 길이를
|
||||
// 넣으면 시작·종료 측점이 기준 − 앞 ~ 기준 + 뒤로 자동 채워진다.
|
||||
const facilityOptions = createFacilityOptionsForm();
|
||||
const facilityOptions = createFacilityOptionsForm({ onChange: () => liveCommit() });
|
||||
|
||||
/** 이 타입이 서브폼(계곡 통과 시설 부속)을 쓰는지 — 쓰면 레지스트리 옵션 칸 대신
|
||||
* 서브폼이 그린다. 독립 기슭막이(D4)는 서브폼을 떠나 C군과 같은 레지스트리 옵션
|
||||
@@ -429,6 +439,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
}
|
||||
const label = option.unit ? `${option.label} (${option.unit})` : option.label;
|
||||
optionRow.append(field(label, input));
|
||||
input.addEventListener("change", () => liveCommit());
|
||||
optionInputs.push({
|
||||
key: option.key,
|
||||
required: !!option.required,
|
||||
@@ -494,6 +505,24 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
if (hadPipe) callbacks.onPipeSelect(null);
|
||||
}
|
||||
|
||||
/** 정본 주입(setStructures) 되먹임 중인가 — 실시간 반영으로 목록이 돌아올 때
|
||||
* 폼을 리셋하지 않기 위한 표시(2026-08-29 사용자 지시 2). */
|
||||
let applyingLive = false;
|
||||
|
||||
/**
|
||||
* 실시간 반영 — 이미 고른 항목(구조물·계곡 통과 시설)을 고치는 중이면 값이 바뀔
|
||||
* 때마다 곧바로 반영한다(2026-08-29 사용자 지시 2). 새 항목은 종전대로 [추가]로만
|
||||
* 확정한다 — 입력이 끝나기 전에 반쪽짜리 항목이 생기면 안 된다.
|
||||
*/
|
||||
function liveCommit(): void {
|
||||
if (!editingId && selectedPipeChainage === null) return;
|
||||
if (tempPipeChainage !== null) return; // 임시 배치는 [추가]가 확정한다
|
||||
applyingLive = true;
|
||||
void Promise.resolve(commit(true)).finally(() => {
|
||||
applyingLive = false;
|
||||
});
|
||||
}
|
||||
|
||||
function syncButtons(): void {
|
||||
primary.textContent =
|
||||
tempPipeChainage !== null
|
||||
@@ -503,6 +532,13 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
: "추가";
|
||||
removeButton.disabled =
|
||||
editingId === null && selectedPipeChainage === null && tempPipeChainage === null;
|
||||
// 고치는 중에는 [수정] 버튼을 쓰지 않는다(실시간 반영) — 자리는 남겨 삭제·리셋의
|
||||
// 위치·크기가 흔들리지 않게 한다(2026-08-29 사용자 지시 2).
|
||||
const editing =
|
||||
(editingId !== null || selectedPipeChainage !== null) && tempPipeChainage === null;
|
||||
// visibility만 끈다 — 자리는 남고 클릭도 먹지 않는다(disabled는 건드리지 않는다,
|
||||
// 다른 곳에서 종류 선택 여부로 이미 관리한다).
|
||||
primary.style.visibility = editing ? "hidden" : "visible";
|
||||
}
|
||||
|
||||
function renderList(): void {
|
||||
@@ -646,7 +682,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
callbacks.onChange([...structures]);
|
||||
}
|
||||
|
||||
async function commit(): Promise<void> {
|
||||
async function commit(live = false): Promise<void> {
|
||||
const type = currentType();
|
||||
if (!type) return;
|
||||
const step = interval();
|
||||
@@ -673,7 +709,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
} else {
|
||||
callbacks.onPipeAdd(anchor, attributes);
|
||||
}
|
||||
loadForm(null);
|
||||
// 실시간 반영 중에는 폼을 닫지 않는다 — 이어서 다른 칸도 고쳐야 한다.
|
||||
if (!live) loadForm(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -770,7 +807,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
} else {
|
||||
records.forEach((record) => structures.push({ ...record, structure_id: null }));
|
||||
}
|
||||
loadForm(null);
|
||||
if (!live) loadForm(null);
|
||||
emit();
|
||||
}
|
||||
|
||||
@@ -885,13 +922,16 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
}
|
||||
});
|
||||
|
||||
memoField.addEventListener("change", () => liveCommit());
|
||||
body.insertBefore(field("메모", memoField), actions);
|
||||
syncButtons();
|
||||
renderList();
|
||||
|
||||
return {
|
||||
root,
|
||||
facility: facilityOptions,
|
||||
listRoot: list,
|
||||
hasSelection: () => editingId !== null || selectedPipeChainage !== null,
|
||||
setTypes(next) {
|
||||
types = next;
|
||||
const groups = [...new Set(next.map((t) => t.group))];
|
||||
@@ -907,8 +947,12 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
|
||||
// 서버 정본 주입 — onChange를 울리지 않는다. 울리면 Page가 다시 저장을 걸어
|
||||
// 저장→재조회→주입→저장의 무한 고리가 된다(변경 알림은 사용자 조작에서만).
|
||||
structures = next.map((entry) => ({ ...entry }));
|
||||
loadForm(null);
|
||||
renderList();
|
||||
// 실시간 반영으로 되돌아온 목록이면 폼을 닫지 않는다 — 고치던 칸이 사라진다.
|
||||
if (applyingLive) renderList();
|
||||
else {
|
||||
loadForm(null);
|
||||
renderList();
|
||||
}
|
||||
},
|
||||
getStructures: () => [...structures],
|
||||
setPipeFacilities(pipes) {
|
||||
|
||||
@@ -243,3 +243,149 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 숫자 칸 + [-][+] 묶음(2026-08-29 사용자 지시 2) — 유입구·유출구 폼에서도
|
||||
조정창처럼 눈금으로 고친다. 여백 0이라 기존 2열 격자 폭·간격은 그대로다. */
|
||||
/* [-][숫자][+]가 테두리 하나를 나눠 쓰는 한 덩어리(2026-08-29 사용자 지시).
|
||||
바깥 여백은 0이라 2열 격자 폭·간격은 그대로다. */
|
||||
.b05-structure__stepper {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border, #3a3f4a);
|
||||
border-radius: var(--radius-inputs, 4px);
|
||||
background: var(--color-surface);
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.b05-structure__stepper:focus-within {
|
||||
border-color: var(--color-royal-amethyst, rgb(109 40 217));
|
||||
}
|
||||
|
||||
/* 가운데 숫자칸은 자기 테두리·배경을 버리고 덩어리 안에 녹는다. */
|
||||
.b05-structure__stepper > input {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.b05-structure__stepper > input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 좌우 끝 버튼 — 덩어리 안에서는 가르는 선 하나씩만 갖는다. */
|
||||
.b05-structure__step {
|
||||
flex: none;
|
||||
/* 손가락·마우스로 집기 쉽게 넓힌다(2026-08-29 사용자 지시). 숫자칸이 남는 폭을
|
||||
차지하므로 이 값이 곧 버튼 폭이다. */
|
||||
width: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-body);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b05-structure__step:first-child {
|
||||
border-right: 1px solid var(--color-border, #3a3f4a);
|
||||
}
|
||||
|
||||
.b05-structure__step:last-child {
|
||||
border-left: 1px solid var(--color-border, #3a3f4a);
|
||||
}
|
||||
|
||||
.b05-structure__step:hover {
|
||||
background: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 14%, transparent);
|
||||
}
|
||||
|
||||
/* 구조물 배치 폼의 숫자 칸 — 브라우저 기본 증감 화살표(스피너)를 없앤다
|
||||
(2026-08-29 사용자 지시). 증감은 옆의 [-][+] 버튼이 맡고, 화살표는 칸 폭만
|
||||
먹으면서 눈금(0.1)도 우리 규칙과 달라 혼동을 준다. */
|
||||
.b05-route__panel-section input[type="number"]::-webkit-outer-spin-button,
|
||||
.b05-route__panel-section input[type="number"]::-webkit-inner-spin-button,
|
||||
.b05-drainage__facility input[type="number"]::-webkit-outer-spin-button,
|
||||
.b05-drainage__facility input[type="number"]::-webkit-inner-spin-button {
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.b05-route__panel-section input[type="number"],
|
||||
.b05-drainage__facility input[type="number"] {
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
/* ── 조작 컨트롤 높이 템플릿(2026-08-29 사용자 지시) ──────────────────────
|
||||
* 「구조물 배치」 섹션 안의 입력·셀렉트·스텝 묶음·이식 행이 **한 높이**를 쓴다.
|
||||
* 값을 바꾸려면 이 변수 하나만 고치면 된다. 기준은 조정창에서 옮겨 온 행의
|
||||
* 컨트롤 높이(26px)다 — 사용자가 그 크기를 기준으로 정했다. */
|
||||
.b05-structure-section,
|
||||
.b05-drainage__facility {
|
||||
--b05-control-h: 26px;
|
||||
}
|
||||
|
||||
.b05-structure-section input:not([type="checkbox"]),
|
||||
.b05-structure-section select,
|
||||
.b05-drainage__facility input:not([type="checkbox"]),
|
||||
.b05-drainage__facility select {
|
||||
box-sizing: border-box;
|
||||
height: var(--b05-control-h);
|
||||
padding: 0 6px;
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 스텝 묶음과 그 안의 버튼·숫자칸도 같은 높이로 선다. */
|
||||
.b05-structure__stepper {
|
||||
height: var(--b05-control-h);
|
||||
}
|
||||
|
||||
.b05-structure__step {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 조정창에서 옮겨 온 행(단 수·옵션) — 같은 변수로 맞춘다. */
|
||||
.b05-structure__adjust-slot .b06-structure-panel__controls {
|
||||
height: var(--b05-control-h);
|
||||
}
|
||||
|
||||
.b05-structure__adjust-slot .b06-structure-panel__controls .b06-structure-panel__btn {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.b05-structure__adjust-slot .b06-structure-panel__hval {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 제목(라벨)과 항목 사이 간격도 한 변수로 묶는다(2026-08-29 사용자 지시) — 기준은
|
||||
조정창에서 옮겨 온 행(2px·작은 글자). 측점 행은 가로 배치라 열 간격을 그대로 둔다. */
|
||||
.b05-structure-section {
|
||||
--b05-label-gap: 2px;
|
||||
}
|
||||
|
||||
.b05-structure-section .b05-route__field {
|
||||
gap: var(--b05-label-gap);
|
||||
}
|
||||
|
||||
.b05-structure-section .b05-route__field > span {
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.b05-structure__adjust-slot .b06-structure-panel__struct {
|
||||
gap: var(--b05-label-gap);
|
||||
}
|
||||
|
||||
.b05-structure-section .b05-structure__position-row .b05-route__field {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ type PendingMap = Record<string, CulvertOptionPatch>;
|
||||
export interface CulvertOptionWriter {
|
||||
/** 측점 옵션을 세션에 예약한다. 같은 측점의 앞선 예약과는 합쳐진다. */
|
||||
queue: (chainageM: number, patch: CulvertOptionPatch) => void;
|
||||
/** 기준 측점 이동을 예약한다(2026-08-29 사용자: B06에서도 옮길 수 있어야 한다).
|
||||
* 세부 배수유역 재분할은 저장 시 서버가 관 목록을 다시 받아 처리한다. */
|
||||
queueMove: (fromChainageM: number, toChainageM: number) => void;
|
||||
/** 예약분을 즉시 내보낸다([저장]·[확정]에서만 부른다). */
|
||||
flush: () => Promise<void>;
|
||||
}
|
||||
@@ -59,25 +62,47 @@ function writePending(sessionKey: string | null, pending: PendingMap): void {
|
||||
* 하지 않는다. 성공하면 세션을 비우고, 실패하면 **그대로 두어** 다음 [저장]에서
|
||||
* 다시 시도한다 — 조용히 잃으면 화면과 저장분이 갈린다.
|
||||
*/
|
||||
/** 예약된 이동 — 옛 측점 키 → 새 누가거리(m). */
|
||||
type PendingMoves = Record<string, number>;
|
||||
|
||||
function readMoves(sessionKey: string | null): PendingMoves {
|
||||
if (!sessionKey) return {};
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(sessionKey);
|
||||
return raw ? (JSON.parse(raw) as PendingMoves) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function flushCulvertOptions(
|
||||
projectId: string | null,
|
||||
sessionKey: string | null,
|
||||
moveKey: string | null = null,
|
||||
): Promise<void> {
|
||||
const pending = readPending(sessionKey);
|
||||
const entries = Object.entries(pending);
|
||||
if (!projectId || entries.length === 0) return;
|
||||
const moves = readMoves(moveKey);
|
||||
if (!projectId || (Object.keys(pending).length === 0 && Object.keys(moves).length === 0)) return;
|
||||
const current = await fetchDetailPipePoints(projectId);
|
||||
let touched = false;
|
||||
const points: DetailPipeInput[] = current.pipe_points.map((point) => {
|
||||
// 좌표는 서버가 다시 계산하므로 싣지 않는다(DetailPipeInput 규약).
|
||||
const { lonlat: _lonlat, ...input } = point;
|
||||
const patch = pending[keyOf(point.chainage_m)];
|
||||
if (!patch) return input;
|
||||
const key = keyOf(point.chainage_m);
|
||||
const patch = pending[key];
|
||||
const movedTo = moves[key];
|
||||
if (!patch && movedTo === undefined) return input;
|
||||
touched = true;
|
||||
return { ...input, options: { ...(input.options ?? {}), ...patch } };
|
||||
const next: DetailPipeInput = patch
|
||||
? { ...input, options: { ...(input.options ?? {}), ...patch } }
|
||||
: { ...input };
|
||||
// 기준 측점 이동 — 관 위치가 바뀌면 서버가 세부 배수유역을 다시 나눠 응답한다.
|
||||
if (movedTo !== undefined) next.chainage_m = movedTo;
|
||||
return next;
|
||||
});
|
||||
if (touched) await saveDetailPipePoints(projectId, points);
|
||||
writePending(sessionKey, {});
|
||||
writePending(moveKey, {});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +113,8 @@ export function createCulvertOptionWriter(
|
||||
projectId: () => string | null,
|
||||
sessionKey: () => string | null,
|
||||
onError?: (message: string) => void,
|
||||
/** 이동 예약 세션 키 — 없으면 이동은 메모리에도 남지 않는다. */
|
||||
moveKey: () => string | null = () => null,
|
||||
): CulvertOptionWriter {
|
||||
return {
|
||||
queue(chainageM, patch) {
|
||||
@@ -96,9 +123,20 @@ export function createCulvertOptionWriter(
|
||||
pending[keyOf(chainageM)] = { ...(pending[keyOf(chainageM)] ?? {}), ...patch };
|
||||
writePending(key, pending);
|
||||
},
|
||||
queueMove(fromChainageM, toChainageM) {
|
||||
const key = moveKey();
|
||||
if (!key) return;
|
||||
const moves = readMoves(key);
|
||||
// 이미 옮긴 관을 또 옮기면 **원래 자리** 기준으로 최종 위치만 남긴다.
|
||||
const origin =
|
||||
Object.keys(moves).find((entry) => Math.abs(moves[entry] - fromChainageM) < 0.005) ??
|
||||
keyOf(fromChainageM);
|
||||
moves[origin] = toChainageM;
|
||||
window.sessionStorage.setItem(key, JSON.stringify(moves));
|
||||
},
|
||||
async flush() {
|
||||
try {
|
||||
await flushCulvertOptions(projectId(), sessionKey());
|
||||
await flushCulvertOptions(projectId(), sessionKey(), moveKey());
|
||||
} catch (error) {
|
||||
onError?.(error instanceof Error ? error.message : "배수관 구간값 저장 실패");
|
||||
throw error;
|
||||
|
||||
@@ -42,8 +42,15 @@ def load_revetments(project_root: Path) -> list[dict[str, Any]]:
|
||||
"start_m": float(min(start, end)),
|
||||
"end_m": float(max(start, end)),
|
||||
"anchor_m": float(structure.anchor_m()),
|
||||
"form": options.get("form"),
|
||||
"height_m": options.get("height_m"),
|
||||
# 형태·높이는 **배관 유출 키 한 벌**이 정본이다(B05 폼 2026-08-28
|
||||
# 이관). 옛 전용 키(form·height_m)는 읽을 때만 폴백으로 본다 —
|
||||
# 이걸 안 보면 형태가 비어 도형이 늘 메쌓기로 그려졌다(2026-08-30).
|
||||
"form": options.get("outlet_revet_form") or options.get("form"),
|
||||
"height_m": (
|
||||
options.get("outlet_revet_height_m")
|
||||
if options.get("outlet_revet_height_m") is not None
|
||||
else options.get("height_m")
|
||||
),
|
||||
"side": options.get("side"),
|
||||
# 다단 — 1이면 단일 벽. 전개 규칙은 화면·3D가 같은 산식으로 푼다.
|
||||
"tiers": options.get("tiers"),
|
||||
|
||||
@@ -105,6 +105,7 @@ export function crossPatchesFromCache(detail: SectionDetailResponse): CrossSecti
|
||||
put("ford_adjust", design.ford_adjust);
|
||||
put("box_adjust", design.box_adjust);
|
||||
put("extra_wall_counts", design.extra_wall_counts);
|
||||
put("extra_spans", design.extra_spans);
|
||||
put("revet_link_detached", design.revet_link_detached);
|
||||
put("revet_follow_grade", design.revet_follow_grade);
|
||||
if (touched) patches.push(patch);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Adjust_Dock.ts
|
||||
* 좌측 [구조물 배치] 섹션 안 **[횡단 조정] 하위 컨테이너**(2026-08-29 사용자:
|
||||
* B05/B06 일원화 — 오버레이 조정창 항목을 좌측에도 보이되, 기존 폼과 구분되게
|
||||
* 접이식 하위 컨테이너에 담는다. 9키·십자 위치제어는 오버레이 전용).
|
||||
*
|
||||
* 카드가 조정창을 만들 때 같은 deps로 **사본 창**을 하나 더 만들어 이 슬롯에
|
||||
* 세운다(adopt). 값·동작이 같은 함수라 오버레이·좌측 어느 쪽을 만져도 카드
|
||||
* 재렌더에서 두 창이 함께 새 값으로 그려진다 — 별도 동기화 코드가 없다.
|
||||
* 카드가 여럿이어도 슬롯은 하나 — 마지막으로 고른 구조물의 사본만 선다.
|
||||
* ========================================================================== */
|
||||
|
||||
let dockRoot: HTMLDetailsElement | null = null;
|
||||
let slot: HTMLElement | null = null;
|
||||
let placeholder: HTMLElement | null = null;
|
||||
/** 유입구·유출구·추가 기슭막이 그룹의 이식 자리(2026-08-29 지시 4, 2026-08-30 지시 4).
|
||||
* B06 패널이 등록한다. */
|
||||
interface SideSlots {
|
||||
inlet: HTMLElement;
|
||||
outlet: HTMLElement;
|
||||
extra: HTMLElement;
|
||||
/** 독립 기슭막이 좌·우 칸 — 배관 유입구·유출구 칸이 숨어 있을 때 이쪽으로 간다. */
|
||||
revetInlet: HTMLElement;
|
||||
revetOutlet: HTMLElement;
|
||||
}
|
||||
let sideSlots: SideSlots | null = null;
|
||||
|
||||
/** 행을 옮겨 붙일 자리를 등록한다(계곡 통과 시설 폼의 유입구·유출구·추가 기슭막이). */
|
||||
export function setAdjustSideSlots(next: SideSlots): void {
|
||||
sideSlots = next;
|
||||
}
|
||||
|
||||
/** 다단 벽에서 옮겨 오는 행 — 폼에 자기 칸이 없어 형태·높이·구간값까지 전부 온다. */
|
||||
const EXTRA_ROWS = [
|
||||
".b06-structure-panel__struct--form",
|
||||
".b06-structure-panel__struct--height",
|
||||
".b06-structure-panel__struct--span",
|
||||
".b06-structure-panel__struct--tier",
|
||||
".b06-structure-panel__struct--options",
|
||||
].join(", ");
|
||||
|
||||
/** 유입구·유출구는 폼이 이미 가진 항목 말고 단 수·옵션만 받는다. */
|
||||
const SIDE_ROWS = [
|
||||
".b06-structure-panel__struct--tier",
|
||||
".b06-structure-panel__struct--options",
|
||||
].join(", ");
|
||||
|
||||
/** 이식 자리가 지금 화면에 서 있는지 — 숨겨진 그룹(다른 시설)이면 옮기지 않는다. */
|
||||
function slotUsable(target: HTMLElement | undefined): target is HTMLElement {
|
||||
if (!target) return false;
|
||||
const group = target.closest("fieldset");
|
||||
return !!group && !group.hidden;
|
||||
}
|
||||
|
||||
/** 옮겨 온 행과 그 **제자리 표식** — 되돌릴 때 원래 순서로 꽂기 위해 짝으로 둔다.
|
||||
* 표식 없이 지우면 행 DOM이 사라져, 같은 창에서 다른 벽을 고를 때 그 행이 다시는
|
||||
* 나오지 않는다(2026-08-29 사용자 보고: 단 수·옵션이 사라짐). */
|
||||
const movedRows: Array<{ row: HTMLElement; anchor: Comment }> = [];
|
||||
|
||||
/** 그룹 이름표 — 원래 문구를 한 번만 적어 두고, 선택에 따라 괄호 표기를 붙였다 뗀다. */
|
||||
function setGroupLabel(group: HTMLElement, suffix: string): void {
|
||||
const legend = group.querySelector<HTMLElement>("legend");
|
||||
if (!legend || legend.dataset.fixedLabel) return;
|
||||
legend.dataset.baseLabel ??= legend.textContent ?? "";
|
||||
const base = legend.dataset.baseLabel;
|
||||
legend.textContent = suffix ? `${base} (${suffix})` : base;
|
||||
}
|
||||
|
||||
/** 옮겨 온 행을 **원래 자리로 되돌리고** 슬롯을 비운다. 그룹 강조도 끈다. */
|
||||
function clearSideSlots(): void {
|
||||
while (movedRows.length) {
|
||||
const { row, anchor } = movedRows.pop()!;
|
||||
anchor.parentNode?.replaceChild(row, anchor);
|
||||
}
|
||||
for (const target of [
|
||||
sideSlots?.inlet,
|
||||
sideSlots?.outlet,
|
||||
sideSlots?.extra,
|
||||
sideSlots?.revetInlet,
|
||||
sideSlots?.revetOutlet,
|
||||
]) {
|
||||
target?.replaceChildren();
|
||||
const group = target?.closest("fieldset");
|
||||
if (!group) continue;
|
||||
group.classList.remove("is-active");
|
||||
setGroupLabel(group, "");
|
||||
// 추가 기슭막이 칸은 고른 다단 벽이 있을 때만 서 있는다 — 비면 그룹째 내린다.
|
||||
if (target === sideSlots?.extra) group.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조정창 사본의 단 수·옵션 행을 고른 벽 쪽(유입/유출) 그룹으로 옮긴다. 행 DOM을
|
||||
* 그대로 옮기므로 배선·값 동기는 손대지 않는다. 자리가 없으면 사본에 그대로 둔다.
|
||||
*/
|
||||
function relocateSideRows(panelRoot: HTMLElement): void {
|
||||
clearSideSlots();
|
||||
const side = panelRoot.dataset.side;
|
||||
if (!sideSlots || (side !== "inlet" && side !== "outlet" && side !== "extra")) return;
|
||||
// 배관이면 유입구·유출구 칸, 독립 기슭막이면 좌·우 칸 — 지금 서 있는 쪽으로 간다.
|
||||
const target =
|
||||
side === "extra"
|
||||
? sideSlots.extra
|
||||
: slotUsable(sideSlots[side])
|
||||
? sideSlots[side]
|
||||
: sideSlots[side === "inlet" ? "revetInlet" : "revetOutlet"];
|
||||
// 추가 기슭막이 칸은 평소 숨어 있다 — 고른 벽이 다단이면 여기서 세운다.
|
||||
if (side === "extra") {
|
||||
const group = target.closest<HTMLElement>("fieldset");
|
||||
if (!group) return;
|
||||
group.hidden = false;
|
||||
} else if (!slotUsable(target)) return;
|
||||
const rows = panelRoot.querySelectorAll<HTMLElement>(side === "extra" ? EXTRA_ROWS : SIDE_ROWS);
|
||||
rows.forEach((row) => {
|
||||
if (row.classList.contains("is-hidden")) return;
|
||||
// 제자리에 표식을 남기고 옮긴다 — 선택이 바뀌면 이 자리로 되돌린다.
|
||||
const anchor = document.createComment("adjust-row");
|
||||
row.parentNode?.insertBefore(anchor, row);
|
||||
movedRows.push({ row, anchor });
|
||||
target.append(row);
|
||||
});
|
||||
// 고른 구조물이 어느 쪽인지 그룹 테두리로 알린다(2026-08-29 사용자 지시 3).
|
||||
const group = target.closest<HTMLElement>("fieldset");
|
||||
if (!group) return;
|
||||
group.classList.add("is-active");
|
||||
// 이름표에 지금 만지는 구조물을 적는다 — "유입구 (기슭막이)", "유출구 (기슭막이(독립))".
|
||||
// 추가 기슭막이 칸은 이름 자체가 그 단이라 괄호 안에 단 번호가 온다(2026-08-30 지시 1).
|
||||
setGroupLabel(group, panelRoot.dataset.panelTitle ?? "");
|
||||
// 다단은 칸이 새로 서는 것이라 화면 밖일 수 있다 — 그 자리로 스크롤한다(지시 2).
|
||||
if (side === "extra") group.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
|
||||
function ensureDock(): void {
|
||||
if (dockRoot) return;
|
||||
dockRoot = document.createElement("details");
|
||||
dockRoot.className = "b06-adjust-dock";
|
||||
dockRoot.open = true;
|
||||
const summary = document.createElement("summary");
|
||||
summary.textContent = "횡단 조정";
|
||||
placeholder = document.createElement("p");
|
||||
placeholder.className = "b06-adjust-dock__empty";
|
||||
placeholder.textContent = "횡단도에서 구조물(벽·구체)을 고르면 조정 항목이 여기에 뜹니다.";
|
||||
slot = document.createElement("div");
|
||||
slot.className = "b06-adjust-dock__slot";
|
||||
dockRoot.append(summary, placeholder, slot);
|
||||
}
|
||||
|
||||
/** 하위 컨테이너 루트 — 페이지가 [구조물 배치] 섹션 본문에 붙인다. */
|
||||
export function adjustDockRoot(): HTMLElement {
|
||||
ensureDock();
|
||||
return dockRoot!;
|
||||
}
|
||||
|
||||
/** 페이지 진입 시 초기 상태(빈 슬롯 + 안내문)로 되돌린다. */
|
||||
export function resetAdjustDock(): void {
|
||||
ensureDock();
|
||||
slot!.replaceChildren();
|
||||
clearSideSlots();
|
||||
placeholder!.hidden = false;
|
||||
}
|
||||
|
||||
/** 조정창 사본을 슬롯에 세운다 — 이미 다른 사본이 있으면 갈아 끼운다. */
|
||||
export function adoptAdjustPanel(panelRoot: HTMLElement): void {
|
||||
ensureDock();
|
||||
panelRoot.classList.add("b06-structure-panel--dock");
|
||||
if (slot!.firstChild !== panelRoot || slot!.childNodes.length !== 1) {
|
||||
slot!.replaceChildren(panelRoot);
|
||||
}
|
||||
placeholder!.hidden = true;
|
||||
relocateSideRows(panelRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이식을 **다시 한 번** 돌린다 — 폼이 다른 시설로 갈아 끼워진 뒤에 부른다.
|
||||
* 벽 선택은 카드가 먼저 처리하고 폼 교체가 뒤따르므로, 처음 이식할 때는 목적지
|
||||
* 칸(배관 유입구·유출구 / 독립 좌·우)이 아직 이전 시설 것이다(2026-08-30 사용자:
|
||||
* 다른 기슭막이를 고른 뒤 배관 유출을 고르면 강조가 없다).
|
||||
*/
|
||||
export function refreshAdjustSlots(): void {
|
||||
const panel = slot?.firstElementChild as HTMLElement | null;
|
||||
if (panel) relocateSideRows(panel);
|
||||
}
|
||||
|
||||
/** 이 사본이 슬롯에 서 있으면 내린다 — 다른 사본이 이미 대신 서 있으면 손대지 않는다. */
|
||||
export function releaseAdjustPanel(panelRoot: HTMLElement): void {
|
||||
if (!slot || !slot.contains(panelRoot)) return;
|
||||
slot.replaceChildren();
|
||||
clearSideSlots();
|
||||
placeholder!.hidden = false;
|
||||
}
|
||||
@@ -42,10 +42,12 @@ export interface BoxPanelHandle {
|
||||
/** 한 걸음(m) — 사용자 지정 0.1m. */
|
||||
const STEP_M = 0.1;
|
||||
|
||||
/** BOX암거 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */
|
||||
export function buildBoxPanel(deps: BoxPanelDeps): BoxPanelHandle {
|
||||
/** BOX암거 조정 오버레이 창을 만든다. 처음에는 숨어 있다.
|
||||
* `opts.dock` — 좌측 [횡단 조정] 사본(2026-08-29): 흐름 배치 + 방향키 미장착. */
|
||||
export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): BoxPanelHandle {
|
||||
const shell = buildPanelShell(() => deps.close());
|
||||
const { root, title } = shell;
|
||||
if (opts?.dock) root.classList.add("b06-structure-panel--dock");
|
||||
|
||||
const value = document.createElement("div");
|
||||
value.className = "b06-structure-panel__value";
|
||||
@@ -93,7 +95,9 @@ export function buildBoxPanel(deps: BoxPanelDeps): BoxPanelHandle {
|
||||
|
||||
root.append(value, moveRow.row);
|
||||
|
||||
const arrows = bindArrowKeys(root, () => [moveRow.controls]);
|
||||
const arrows = opts?.dock
|
||||
? { attach: (): void => undefined, detach: (): void => undefined }
|
||||
: bindArrowKeys(root, () => [moveRow.controls]);
|
||||
|
||||
function render(): void {
|
||||
if (!current) return;
|
||||
@@ -102,7 +106,7 @@ export function buildBoxPanel(deps: BoxPanelDeps): BoxPanelHandle {
|
||||
const adjust = deps.adjustFor(current);
|
||||
lengthLine.textContent =
|
||||
`구체 길이 ${deps.bodyLengthM().toFixed(2)}m` +
|
||||
(adjust.lengthM > 0 ? ` (이 측 +${adjust.lengthM.toFixed(1)}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)}` : "구체 수평";
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
import {
|
||||
REVET_EMBED_DEPTH_M,
|
||||
REVET_LEAN_RATIO,
|
||||
REVET_THICKNESS_M,
|
||||
pipeWallThicknessM,
|
||||
revetHeightLimit,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Geom";
|
||||
@@ -23,6 +22,7 @@ import type {
|
||||
PipeEnd,
|
||||
WallLayout,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Geom";
|
||||
import { appendWallHatch } from "./B06_Section_UI_Cross_Wall_Hatch";
|
||||
|
||||
// 기하 계산 진입점과 공개 상수·타입은 여기서 재수출한다 — B05(최소 토피)와 횡단 뷰가
|
||||
// 이 모듈 경로로 이미 참조하고 있어 분리 후에도 import 경로를 바꾸지 않는다.
|
||||
@@ -181,73 +181,9 @@ export function appendCulvertOverlay(
|
||||
if (onSelectRevet) revetShape.classList.add("is-selectable");
|
||||
revetShapes.set(wallKey, revetShape);
|
||||
layer.append(revetShape);
|
||||
// 평행사변형 띠와 사다리꼴 사이 대각 이음선(내부 경계 — 사용자 스케치의 가운데 선):
|
||||
// 상단 변 중간점(배면+0.45)에서 전면과 나란히 바닥으로 내려온다.
|
||||
const joint = document.createElementNS(SVG_NS, "line");
|
||||
// 하단선이 원지반을 따라 기울어 있으므로 이음선 바닥도 하단선 위에서 잡는다.
|
||||
const jointBaseOffset = wall.outerOffset - wall.outward * REVET_THICKNESS_M;
|
||||
const bottomSpan = wall.bottomFront.offset - wall.bottomBack.offset;
|
||||
const bottomAtOffset = (offset: number): number =>
|
||||
Math.abs(bottomSpan) > 1e-9
|
||||
? wall.bottomBack.elevation +
|
||||
(wall.bottomFront.elevation - wall.bottomBack.elevation) *
|
||||
((offset - wall.bottomBack.offset) / bottomSpan)
|
||||
: wall.bottomBack.elevation;
|
||||
joint.setAttribute("x1", String(x(wall.topJoint.offset)));
|
||||
joint.setAttribute("y1", String(toDisplayY(wall.topJoint.elevation)));
|
||||
joint.setAttribute("x2", String(x(jointBaseOffset)));
|
||||
joint.setAttribute("y2", String(toDisplayY(bottomAtOffset(jointBaseOffset))));
|
||||
joint.setAttribute("class", "b06-chart__culvert-stone");
|
||||
layer.append(joint);
|
||||
// 돌 해칭 — 큰 돌이 한 줄로 쌓인 **계류측 기운 띠**(전면 평행사변형)를 따라 쌓는다.
|
||||
// 좌·우 벽은 outward 부호로 자동 반전된다. 벽 높이는 지형마다 달라지므로
|
||||
// ① 배치 구간을 근입 바닥~상단 **전체**로 잡고 ② 벽 폴리곤 clip 안에만 그려
|
||||
// 어떤 높이에서도 돌이 벽 밖으로 새지 않게 한다(2026-08-20 미스매치 정정).
|
||||
const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1;
|
||||
const clipId = `b06-revet-clip-${wallKey}-${Math.round(wall.backOffset * 100)}`;
|
||||
const clip = document.createElementNS(SVG_NS, "clipPath");
|
||||
clip.setAttribute("id", clipId);
|
||||
clip.append(
|
||||
polygon(
|
||||
wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
|
||||
"",
|
||||
"",
|
||||
),
|
||||
);
|
||||
const stoneGroup = document.createElementNS(SVG_NS, "g");
|
||||
stoneGroup.setAttribute("clip-path", `url(#${clipId})`);
|
||||
layer.append(clip, stoneGroup);
|
||||
|
||||
const centerBaseOffset = wall.outerOffset - wall.outward * (REVET_THICKNESS_M / 2);
|
||||
const embedBase = bottomAtOffset(centerBaseOffset);
|
||||
const stoneSpan = Math.max(0.3, wall.topBack.elevation - embedBase);
|
||||
const stoneCount = Math.max(2, Math.round(stoneSpan / 0.45));
|
||||
const stoneHeight = stoneSpan / stoneCount;
|
||||
// 돌 중심선 = 전면 기운 띠(이음선~전면)의 중심선. 바닥은 근입 바닥까지 연장한다.
|
||||
const centerBase = centerBaseOffset;
|
||||
const centerTop = wall.topJoint.offset + wall.outward * (REVET_THICKNESS_M / 2);
|
||||
// 화면 좌표 기준 기움 각(rect의 세로축을 경사축에 맞춘다).
|
||||
const axisX = x(centerTop) - x(centerBase);
|
||||
const axisY = toDisplayY(wall.topBack.elevation) - toDisplayY(embedBase);
|
||||
const leanDegrees = (Math.atan2(axisX, -axisY) * 180) / Math.PI;
|
||||
for (let i = 0; i < stoneCount; i += 1) {
|
||||
const fraction = (i + 0.5) / stoneCount;
|
||||
const centerOffset = centerBase + (centerTop - centerBase) * fraction;
|
||||
const centerElevation = embedBase + stoneSpan * fraction;
|
||||
const cx = x(centerOffset);
|
||||
const cy = toDisplayY(centerElevation);
|
||||
const stone = document.createElementNS(SVG_NS, "rect");
|
||||
const widthPx = REVET_THICKNESS_M * pixelsPerMeter * 0.9;
|
||||
const heightPx = stoneHeight * pixelsPerMeter * 0.86;
|
||||
stone.setAttribute("x", String(cx - widthPx / 2));
|
||||
stone.setAttribute("y", String(cy - heightPx / 2));
|
||||
stone.setAttribute("width", String(widthPx));
|
||||
stone.setAttribute("height", String(heightPx));
|
||||
stone.setAttribute("rx", String(Math.min(widthPx, heightPx) * 0.3));
|
||||
stone.setAttribute("transform", `rotate(${leanDegrees.toFixed(1)} ${cx} ${cy})`);
|
||||
stone.setAttribute("class", "b06-chart__culvert-stone");
|
||||
stoneGroup.append(stone);
|
||||
}
|
||||
// 이음선 + 형태별 표현(돌쌓기 메/찰·콘크리트·돌망태·통나무·바자)은 공용 해칭이
|
||||
// 맡는다 — 독립 기슭막이(`_Cross_Wall`)와 같은 함수다(2026-08-30 사용자 지시 3).
|
||||
appendWallHatch(layer, wall, x, toDisplayY, wallKey);
|
||||
}
|
||||
if (layout.basin) {
|
||||
const basin = layout.basin;
|
||||
|
||||
@@ -88,6 +88,12 @@ export interface BasinBuildInput {
|
||||
memberThicknessM?: number;
|
||||
/** 바닥판 두께(m). 기본 = 벽 두께. 세월교는 교본 물받이 최소 0.3m를 쓴다. */
|
||||
floorThicknessM?: number;
|
||||
/**
|
||||
* 벽 기움(1:n의 n). 기본 = 집수정 규칙 0.3. **세월교는 0**(수직) — 기움은 집수정
|
||||
* 부재를 재사용하며 딸려 온 값이고, 교본 그림 3-18 세월교 단면은 수직 벽이다
|
||||
* (2026-08-30 사용자 확정: 집수정은 그대로 두고 세월교만 세운다).
|
||||
*/
|
||||
leanRatio?: number;
|
||||
}
|
||||
|
||||
/** 집수정 구성 결과 — 본체가 트림·관 끝단면·관 시작점에 나눠 꽂는다. */
|
||||
@@ -127,6 +133,8 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
|
||||
cutSlopeRatio,
|
||||
adjust,
|
||||
} = input;
|
||||
/** 이 부재의 기움 — 집수정 0.3, 세월교 0(수직). */
|
||||
const lean = input.leanRatio ?? REVET_LEAN_RATIO;
|
||||
const movedAnchor = {
|
||||
offset: baseAnchor.offset + outward * (adjust.lateralM + adjust.slopeM),
|
||||
elevation: baseAnchor.elevation - adjust.slopeM / 1.2,
|
||||
@@ -147,7 +155,7 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
|
||||
dir: number,
|
||||
bottomElevation = defaultBottom,
|
||||
): OffsetPoint[] => {
|
||||
const topShift = -dir * REVET_LEAN_RATIO * wallHeight;
|
||||
const topShift = -dir * lean * wallHeight;
|
||||
const spanZ = wallHeight + floorThickness;
|
||||
const xAt = (elevation: number): number =>
|
||||
baseOffset + topShift * ((elevation - defaultBottom) / spanZ);
|
||||
@@ -166,7 +174,7 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
|
||||
};
|
||||
// I형 벽 자리 = **상단 도로측 꼭짓점이 노견 끝 지점에 닿는다**(2026-08-22 사용자
|
||||
// 확정). 벽 상단이 도로측으로 1:0.3 물러나므로 하단(base)은 그만큼 계류측 바깥.
|
||||
const wallBase = anchor.offset + outward * (REVET_LEAN_RATIO * wallHeight);
|
||||
const wallBase = anchor.offset + outward * (lean * wallHeight);
|
||||
// I형 벽 하단은 **원지반까지 묻는다**(2026-08-22 사용자 — 지반 아래 부재두께만큼
|
||||
// 근입 후 배관을 둔다). ㄴ·ㄷ형은 바닥판이 원지반을 대체하므로 현행 유지.
|
||||
const iWallBottom =
|
||||
@@ -203,12 +211,12 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
|
||||
const wallOuterAt = (elevation: number): number => {
|
||||
const bottom = anchor.elevation - floorThickness;
|
||||
const t = (elevation - bottom) / (wallHeight + floorThickness);
|
||||
return wallBase + outward * memberT - outward * REVET_LEAN_RATIO * wallHeight * t;
|
||||
return wallBase + outward * memberT - outward * lean * wallHeight * t;
|
||||
};
|
||||
const floorTopInner = wallOuterAt(anchor.elevation);
|
||||
const floorBottomInner = wallOuterAt(anchor.elevation - floorThickness);
|
||||
const floorOuter = floorTopInner + outward * (adjust.innerWidthM + memberT);
|
||||
const floorTaper = REVET_LEAN_RATIO * floorThickness;
|
||||
const floorTaper = lean * floorThickness;
|
||||
if (shape !== "I") {
|
||||
parts.push({
|
||||
kind: "floor",
|
||||
@@ -240,7 +248,7 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
|
||||
const refTop =
|
||||
shape === "U"
|
||||
? {
|
||||
offset: mirroredWallBase + outward * REVET_LEAN_RATIO * wallHeight,
|
||||
offset: mirroredWallBase + outward * lean * wallHeight,
|
||||
elevation: anchor.elevation + wallHeight,
|
||||
}
|
||||
: { offset: floorOuter, elevation: anchor.elevation };
|
||||
@@ -289,7 +297,7 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
|
||||
: { offset: floorTopInner, elevation: anchor.elevation },
|
||||
fillStart,
|
||||
fillBottomElevation: anchor.elevation - floorThickness,
|
||||
trimOffset: wallBase - outward * REVET_LEAN_RATIO * wallHeight,
|
||||
trimOffset: wallBase - outward * lean * wallHeight,
|
||||
trimElevation: anchor.elevation + wallHeight,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { CulvertLayout, InletStructureChoice, RevetKey } from "./B06_Sectio
|
||||
import type { BasinAdjust, CulvertDesignTrim } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import {
|
||||
basinSpanOfSpec,
|
||||
FILL_SLOPE_RATIO_MIN,
|
||||
materialLabel,
|
||||
materialLimit,
|
||||
revetSpanOfSpec,
|
||||
@@ -29,8 +30,16 @@ import {
|
||||
*/
|
||||
export interface RevetOffsetControl {
|
||||
adjustFor: (section: CrossSection, role: RevetKey) => WallAdjust;
|
||||
/** 이 측점이 **실제로 손댄** 값만 — 없으면 null(자동·상속과 구분해야 한다). */
|
||||
storedAdjustFor: (section: CrossSection, role: RevetKey) => WallAdjust | null;
|
||||
selectedFor: (section: CrossSection) => RevetKey | null;
|
||||
/** 이 카드에서 **강조**할 벽 — 연동으로 이어진 옆 측점이 고른 벽도 켠다
|
||||
* (2026-08-30 사용자: 한 기슭막이는 그 연장 안 모든 횡단도에서 같이 강조된다).
|
||||
* 조정창은 고른 카드 하나에만 뜨므로 `selectedFor`와 따로 둔다. */
|
||||
highlightFor: (section: CrossSection) => RevetKey | null;
|
||||
select: (chainageM: number, key: RevetKey | null) => void;
|
||||
/** 선택이 바뀌면 화면이 연동 카드 강조를 다시 칠한다 — 목록 쪽이 꽂는다. */
|
||||
onSelectionChange?: () => void;
|
||||
/** 기하가 실제로 적용한 조작값을 되받아 담는다(한계에 걸린 요청값을 잘라 낸다). */
|
||||
syncApplied: (chainageM: number, role: RevetKey, applied: WallAdjust) => void;
|
||||
/** 일부 축만 바꾼다 — 나머지는 유지. h: null = 기본값 복귀. */
|
||||
@@ -154,7 +163,7 @@ export function tierSpanOf(section: CrossSection, key: string): StructureSpan {
|
||||
}
|
||||
|
||||
/** 구조물이 종방향으로 덮는 범위(기준측점 전/후 m) — 기슭막이·집수정·다단 중 큰 값. */
|
||||
function culvertReach(section: CrossSection): StructureSpan | null {
|
||||
export function culvertReach(section: CrossSection): StructureSpan | null {
|
||||
const culvert = section.culvert;
|
||||
if (!culvert) return null;
|
||||
let beforeM = 0;
|
||||
@@ -205,6 +214,26 @@ export function culvertOwnerFor(
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 벽(기준벽·다단)이 **이 측점 카드에도 서는가** — 연동 하이라이트 짝짓기용
|
||||
* (2026-08-30 사용자). 판정은 그리기(`trimLinkedLayout`)와 같은 구간값을 쓴다:
|
||||
* 기준벽은 자기 길이·전/후, 다단은 단별 구간값(`tierSpanOf`).
|
||||
*/
|
||||
export function wallStandsAt(owner: CrossSection, section: CrossSection, key: string): boolean {
|
||||
const deltaM = section.chainage_m - owner.chainage_m;
|
||||
if (Math.abs(deltaM) < 0.01) return true;
|
||||
const spec = owner.culvert;
|
||||
if (!spec) return false;
|
||||
if (key.startsWith("extra") || key.startsWith("bextra")) {
|
||||
return spanCovers(tierSpanOf(owner, key), deltaM);
|
||||
}
|
||||
const side = key === "inlet" ? spec.inlet : key === "outlet" ? spec.outlet : null;
|
||||
if (!side) return false;
|
||||
// 집수정은 소유 측점 하나에만 선다(그리기와 같은 규칙) — 링크 카드에는 없다.
|
||||
if (side.structure === "집수정" && !spec.hidden_pipe) return false;
|
||||
return spanCovers(revetSpanOfSpec(side), deltaM);
|
||||
}
|
||||
|
||||
export function culvertLinkFor(
|
||||
section: CrossSection,
|
||||
sections: readonly CrossSection[],
|
||||
@@ -352,23 +381,57 @@ export function computeCardCulvert(
|
||||
// 연동이면 소유 측점이 **실제로 적용한 4축**(자동 자리 포함)을 명시값으로 물려준다.
|
||||
// 조작값만 넘기면 손대기 전(전부 자동)에는 이 측점이 자기 지형으로 자동 자리를 다시
|
||||
// 잡아 소유 측점과 어긋난다 — "한 번 움직이면 맞는다"던 증상의 원인(2026-08-24 사용자).
|
||||
const ownerAdjusts = detached
|
||||
? adjustsInput(section, revetOffset, extraWalls)
|
||||
: (computeCulvertLayout(
|
||||
link.source,
|
||||
link.source.samples,
|
||||
adjustsInput(link.source, revetOffset, extraWalls),
|
||||
link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source),
|
||||
inletStructure?.adjustFor(link.source),
|
||||
)?.revetShift ?? adjustsInput(link.source, revetOffset, extraWalls));
|
||||
const ownerInput = adjustsInput(link.source, revetOffset, extraWalls);
|
||||
const ownerApplied =
|
||||
computeCulvertLayout(
|
||||
link.source,
|
||||
link.source.samples,
|
||||
ownerInput,
|
||||
link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source),
|
||||
inletStructure?.adjustFor(link.source),
|
||||
)?.revetShift ?? ownerInput;
|
||||
// 연동을 풀어도 **같은 기슭막이**다 — 단 수는 소유 측점 것이고, 이 측점이 손댄
|
||||
// 벽만 자기 값으로 선다(2026-08-30 사용자).
|
||||
const merged = detached ? detachedAdjusts(section, ownerApplied, revetOffset) : ownerApplied;
|
||||
// 이 측점까지 오지 않는 단은 **계산에서부터** 뺀다. 다 세워 놓고 그림만 지우면
|
||||
// (`trimLinkedLayout`) 성토부선은 원래 단 수로 계산된 채 남아, 마지막 단 뒤 성토면이
|
||||
// 갱신되지 않는다(2026-08-30 사용자 지적: 우측 독립 기슭막이에서 단이 없어져도
|
||||
// 그 위 성토면이 그대로). 단은 위에서부터 쌓이므로 **덮이는 앞부분만** 남긴다.
|
||||
const tierPrefix = (kind: "extra" | "bextra", total: number): number => {
|
||||
let count = 0;
|
||||
while (count < total && spanCovers(tierSpanOf(link.source, `${kind}${count}`), link.deltaM)) {
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
};
|
||||
const ownerAdjusts: WallAdjustSet = merged && {
|
||||
...merged,
|
||||
extras: merged.extras.slice(0, tierPrefix("extra", merged.extras.length)),
|
||||
basinExtras: merged.basinExtras.slice(0, tierPrefix("bextra", merged.basinExtras.length)),
|
||||
};
|
||||
// 경사 해제 = **수평(Z 일원화)**. 3D 링만 눕히는 게 아니라 횡단도에서도 기준벽
|
||||
// 상단 표고를 소유 측점과 같게 맞춘다(2026-08-30 사용자 확정 — 종전 "3D 전용"
|
||||
// 규정을 갈아엎는다). 차이는 노견 확장 구간이 흡수하므로 성토선은 이 측점
|
||||
// 노견에서 그대로 출발한다(2026-08-24 "성토선이 노견에서 떨어진다" 지적 회피).
|
||||
const followGrade =
|
||||
revetLink?.followGradeFor(link.source) ?? link.source.design?.revet_follow_grade !== false;
|
||||
const layout = computeCulvertLayout(
|
||||
hosted,
|
||||
sourceSamples,
|
||||
ownerAdjusts,
|
||||
followGrade ? ownerAdjusts : levelToOwner(ownerAdjusts, link.dz),
|
||||
link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source),
|
||||
inletStructure?.adjustFor(detached ? section : link.source),
|
||||
);
|
||||
if (!layout) return null;
|
||||
// 노견 위로는 못 올라간다(기하가 d를 0에서 자른다) — 조작 중인 벽만 이유를 알린다.
|
||||
if (!followGrade && (activeRevet === "inlet" || activeRevet === "outlet")) {
|
||||
const requested = levelToOwner(ownerAdjusts, link.dz);
|
||||
const applied = activeRevet === "inlet" ? layout.revetShift.inlet : layout.revetShift.outlet;
|
||||
const asked = activeRevet === "inlet" ? requested?.inlet : requested?.outlet;
|
||||
if (asked?.d != null && applied.d != null && Math.abs(asked.d - applied.d) > 0.05) {
|
||||
showToast(L("B06_Cross_Revet_Limit_Up"), "info");
|
||||
}
|
||||
}
|
||||
// 종단경사 반영은 **3D 전용** 스위치다(2026-08-24 사용자 확인). 횡단도는 언제나
|
||||
// 그 측점 계획고로 그린다 — 레이아웃 전체를 −dz로 밀면 벽뿐 아니라 노견에서
|
||||
// 출발하는 성토선까지 같이 내려가 **성토선이 노견에서 떨어진다**(사용자 지적).
|
||||
@@ -492,6 +555,51 @@ export function computeCardCulvert(
|
||||
return section.culvert?.hidden_pipe ? restrictToSide(layout, section.culvert.side) : layout;
|
||||
}
|
||||
|
||||
/**
|
||||
* **수평(Z 일원화) 보정**(2026-08-30 사용자 확정) — 경사를 풀면 기슭막이는 종단을 타지
|
||||
* 않고 눕는다. 배관 벽 자리 규칙이 `상단 = 노견 − d/1.2`(`placePipeWall`)이므로 이 측점
|
||||
* d를 `소유 d + 1.2 × dz`로 밀면 상단 표고가 소유 측점 값 그대로가 된다. 계획고 차 dz는
|
||||
* **노견 확장 구간**이 흡수하고 성토선 각도(1:1.2)는 그대로다.
|
||||
*
|
||||
* 다단은 손대지 않는다 — 시작점이 기준벽 하단이라 기준벽을 따라 통째로 딸려 온다.
|
||||
* d가 음수로 내려가면(벽이 이 측점 노견보다 위) 기하가 0에서 자른다.
|
||||
*/
|
||||
function levelToOwner(adjusts: WallAdjustSet, dz: number): WallAdjustSet {
|
||||
if (!adjusts || Math.abs(dz) < 1e-9) return adjusts;
|
||||
const shift = (adjust: WallAdjust): WallAdjust => ({
|
||||
...adjust,
|
||||
d: (adjust.d ?? 0) + FILL_SLOPE_RATIO_MIN * dz,
|
||||
});
|
||||
return { ...adjusts, inlet: shift(adjusts.inlet), outlet: shift(adjusts.outlet) };
|
||||
}
|
||||
|
||||
/** 한 측점의 4축 조작값 한 벌(기준벽 2 + 다단). */
|
||||
type WallAdjustSet =
|
||||
| { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[]; basinExtras: WallAdjust[] }
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* **연동을 푼 측점**의 4축(2026-08-30 사용자 확정: "연동 해제는 분리가 아니다").
|
||||
* 단 수·형식은 소유 측점 것 그대로이고, 이 측점이 **손댄 벽만** 자기 값으로 바뀐다.
|
||||
* 종전에는 이 측점 값(대개 빈 값)으로 통째로 다시 풀어 다단이 통째로 사라졌다
|
||||
* (2026-08-30 화면 실측: 벽 4 → 2).
|
||||
*/
|
||||
function detachedAdjusts(
|
||||
section: CrossSection,
|
||||
base: WallAdjustSet,
|
||||
revetOffset?: RevetOffsetControl,
|
||||
): WallAdjustSet {
|
||||
if (!base) return base;
|
||||
const pick = (role: string, fallback: WallAdjust): WallAdjust =>
|
||||
revetOffset?.storedAdjustFor(section, role as RevetKey) ?? fallback;
|
||||
return {
|
||||
inlet: pick("inlet", base.inlet),
|
||||
outlet: pick("outlet", base.outlet),
|
||||
extras: base.extras.map((adjust, i) => pick(`extra${i}`, adjust)),
|
||||
basinExtras: base.basinExtras.map((adjust, i) => pick(`bextra${i}`, adjust)),
|
||||
};
|
||||
}
|
||||
|
||||
function adjustsInput(
|
||||
section: CrossSection,
|
||||
revetOffset?: RevetOffsetControl,
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
* 백엔드 `section.ford` 제원을 좌표로 옮긴다(치수 결정 금지).
|
||||
*
|
||||
* 구성(2026-08-25 사용자 확정):
|
||||
* · 양측 **측벽** = ㄴ형 집수정을 그대로 재사용한다(`buildBasin`) — 전면 1:0.3
|
||||
* 기움·좌우·상하(대각) 이동·계류측 성토부선이 집수정과 같은 규칙으로 따라온다.
|
||||
* · 양측 **측벽** = ㄴ형 집수정 부재를 재사용한다(`buildBasin`) — 좌우·상하(대각)
|
||||
* 이동·계류측 성토부선이 집수정과 같은 규칙으로 따라온다. 단 **벽은 수직**이다
|
||||
* (`leanRatio: 0` — 2026-08-30 사용자 확정, 교본 그림 3-18). 집수정의 1:0.3
|
||||
* 기움은 세월교 근거가 아니라 부재를 빌려 오며 딸려 온 값이었다.
|
||||
* · **바닥판** = 두 측벽 사이를 잇는 판 + 각 측 바깥으로 **날개벽 투영**(길이×cos각)
|
||||
* 만큼 뻗은 판. 집수정 내공 폭 자리에 그 연장량을 넣어 같은 기하로 만든다.
|
||||
* · **관**은 바닥판 상면(측벽 바깥면)에 안히고, 관 위 ~ 노면 아래는 **성토(노체)** 다.
|
||||
@@ -187,6 +189,10 @@ export function computeFordLayout(
|
||||
},
|
||||
memberThicknessM: ford.wall_thickness_m,
|
||||
floorThicknessM: ford.slab_thickness_m,
|
||||
// 세월교 측벽은 **수직**이다(2026-08-30 사용자 확정 — 교본 그림 3-18 세월교
|
||||
// 단면). 1:0.3 기움은 ㄴ형 집수정 부재를 재사용하며 딸려 온 값이라 여기서 끈다.
|
||||
// 바닥판 taper(0.3×두께)도 같은 값에서 나오므로 함께 사라져 직사각형이 된다.
|
||||
leanRatio: 0,
|
||||
});
|
||||
const wall = built.basin.parts.find((part) => part.kind === "wall");
|
||||
const floor = built.basin.parts.find((part) => part.kind === "floor");
|
||||
|
||||
@@ -65,10 +65,12 @@ const ANGLE_STEP_DEG = 5;
|
||||
/** 관경 선택지(mm) — B05 레지스트리 `ford_bridge.pipe_diameter_mm` choices와 같다. */
|
||||
const DIAMETER_CHOICES_MM = [800, 1000, 1200, 1500];
|
||||
|
||||
/** 세월교 측벽 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */
|
||||
export function buildFordPanel(deps: FordPanelDeps): FordPanelHandle {
|
||||
/** 세월교 측벽 조정 오버레이 창을 만든다. 처음에는 숨어 있다.
|
||||
* `opts.dock` — 좌측 [횡단 조정] 사본(2026-08-29): 흐름 배치 + 방향키 미장착. */
|
||||
export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): FordPanelHandle {
|
||||
const shell = buildPanelShell(() => deps.close());
|
||||
const { root, title } = shell;
|
||||
if (opts?.dock) root.classList.add("b06-structure-panel--dock");
|
||||
|
||||
const value = document.createElement("div");
|
||||
value.className = "b06-structure-panel__value";
|
||||
@@ -233,7 +235,9 @@ export function buildFordPanel(deps: FordPanelDeps): FordPanelHandle {
|
||||
moveRow.row,
|
||||
);
|
||||
|
||||
const arrows = bindArrowKeys(root, () => [moveRow.controls]);
|
||||
const arrows = opts?.dock
|
||||
? { attach: (): void => undefined, detach: (): void => undefined }
|
||||
: bindArrowKeys(root, () => [moveRow.controls]);
|
||||
|
||||
function render(): void {
|
||||
if (!current) return;
|
||||
@@ -243,7 +247,7 @@ export function buildFordPanel(deps: FordPanelDeps): FordPanelHandle {
|
||||
heightLine.textContent =
|
||||
`높이 ${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 (날개벽 각도 종속)`;
|
||||
slabLine.textContent = `바닥판 ${deps.slabLengthM().toFixed(2)}m`;
|
||||
diameter.value = String(deps.pipeDiameterMm());
|
||||
countValue.textContent = `${deps.pipeCount()}련`;
|
||||
const wing = deps.wingFor(current);
|
||||
|
||||
@@ -241,6 +241,9 @@ export function computeRevetmentLayout(
|
||||
groundAt,
|
||||
limitOffset: limit,
|
||||
adjusts: Array.from({ length: requestedTiers - 1 }, () => ({ ...ZERO_ADJUST })),
|
||||
// 단들도 1단과 같은 형태로 선다 — 안 넘기면 형태가 비어 늘 메쌓기로
|
||||
// 그려졌다(2026-08-30 사용자: 형태별 모양이 누락되는 경우).
|
||||
ownerForm: spec.form,
|
||||
equalize: false,
|
||||
})
|
||||
: { walls: [], segments: [], appliedAdjusts: [], addable: false };
|
||||
|
||||
@@ -62,6 +62,9 @@ export interface StructurePanelDeps {
|
||||
optionsFor: () => { revetAllowed: boolean; basinLUAllowed: boolean };
|
||||
/** 다단 기슭막이 상태 — 유출 벽 선택 시 단 수 행 표시 판단에 쓴다. */
|
||||
extraState: () => { canAdd: boolean; count: number };
|
||||
/** 이 카드가 연동으로 넘어온 링크 카드인가 — 단 수는 **기준(소유) 측점**에서만
|
||||
* 다룬다(2026-08-29 사용자). 링크 카드에서는 단 수 행을 숨긴다. */
|
||||
isLinkedCard: () => boolean;
|
||||
/** 다단 기슭막이 단 수 지정 — 지형 허용보다 크면 기하가 자르고 토스트로 알린다. */
|
||||
setExtraCount: (count: number) => void;
|
||||
/** 다단 등간격 배치(2026-08-22 ①) — 사면 구간이 같아지게 단들을 재배치. */
|
||||
@@ -111,11 +114,17 @@ const LENGTH_STEP_M = SPAN_STEP_M * 2;
|
||||
*/
|
||||
const panelScrollTops = new Map<string, number>();
|
||||
|
||||
/** 구조물 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */
|
||||
export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHandle {
|
||||
/** 구조물 조정 오버레이 창을 만든다. 처음에는 숨어 있다.
|
||||
* `opts.dock` — 좌측 [횡단 조정] 사본(2026-08-29 일원화): 흐름 배치 스타일을 입고,
|
||||
* 방향키 문서 리스너를 달지 않는다(오버레이 창과 이중으로 눌리는 것 방지). */
|
||||
export function buildStructurePanel(
|
||||
deps: StructurePanelDeps,
|
||||
opts?: { dock?: boolean },
|
||||
): StructurePanelHandle {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b06-structure-panel";
|
||||
root.classList.add("is-hidden");
|
||||
if (opts?.dock) root.classList.add("b06-structure-panel--dock");
|
||||
root.addEventListener("scroll", () => {
|
||||
if (!root.classList.contains("is-hidden")) panelScrollTops.set(deps.scrollKey, root.scrollTop);
|
||||
});
|
||||
@@ -209,6 +218,8 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
// B05에서 고른 형태가 3종으로 뭉개졌다.
|
||||
const materialParts = makeRow(L("B06_Cross_Mat_Label"));
|
||||
const materialRow = materialParts.row;
|
||||
// 좌측 유입구·유출구 폼의 "형태"와 중복 — 사본에서는 숨긴다(2026-08-29 지시 3).
|
||||
materialRow.classList.add("b06-structure-panel__struct--form");
|
||||
const materialSelect = document.createElement("select");
|
||||
materialSelect.className = "b06-structure-panel__select";
|
||||
for (const form of REVET_FORMS) {
|
||||
@@ -226,6 +237,8 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
// 높이 행 — ±0.1m, 한계는 재질이 정한다.
|
||||
const heightParts = makeRow(L("B06_Cross_Height_Label"));
|
||||
const heightRow = heightParts.row;
|
||||
// 다단 벽에서는 이 행이 [추가 기슭막이] 그룹으로 옮겨 간다(2026-08-30 지시 4).
|
||||
heightRow.classList.add("b06-structure-panel__struct--height");
|
||||
const heightValue = document.createElement("span");
|
||||
heightValue.className = "b06-structure-panel__hval";
|
||||
const heightMinus = makeButton(
|
||||
@@ -243,6 +256,8 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
// 유입측 구조물 형식 드롭다운(2026-08-22 사용자) — 자동/기슭막이/집수정 I·ㄴ·ㄷ.
|
||||
const structureParts = makeRow(L("B06_Cross_Struct_Label"));
|
||||
const structureRow = structureParts.row;
|
||||
// 유입구 "구조" 드롭다운으로 병합됐다 — 사본에서는 숨긴다(2026-08-29 지시 5).
|
||||
structureRow.classList.add("b06-structure-panel__struct--inlet-kind");
|
||||
const select = document.createElement("select");
|
||||
select.className = "b06-structure-panel__select";
|
||||
const OPTIONS: Array<[InletStructureChoice, string]> = [
|
||||
@@ -340,6 +355,8 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
// 유입 벽·집수정 계류측은 `basinExtras`(bextra 키).
|
||||
const extraParts = makeRow(L("B06_Cross_Extra_Count"));
|
||||
const extraRow = extraParts.row;
|
||||
// 유입구·유출구 그룹으로 옮겨 붙는 행(2026-08-29 지시 4).
|
||||
extraRow.classList.add("b06-structure-panel__struct--tier");
|
||||
const countValue = document.createElement("span");
|
||||
countValue.className = "b06-structure-panel__hval";
|
||||
let currentCount = 0;
|
||||
@@ -376,6 +393,8 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
readout,
|
||||
makeButton("+", `${label} ${stepM}m 늘리기`, () => step(stepM)),
|
||||
);
|
||||
// 다단 벽에서는 세 구간값 행도 [추가 기슭막이] 그룹으로 함께 옮겨 간다.
|
||||
parts.row.classList.add("b06-structure-panel__struct--span");
|
||||
spanRows.push({ row: parts.row, value: readout, field });
|
||||
};
|
||||
makeSpanRow("길이", "lengthM");
|
||||
@@ -385,6 +404,8 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
// 연동 기슭막이 옵션 2가지(2026-08-24 사용자) — **버튼 두 개를 한 행**에 둔다.
|
||||
// 연동은 측점별, 경사(종단경사 반영)는 기슭막이 한 벌 전체 공통이다.
|
||||
const optionParts = makeRow("옵션");
|
||||
// 단 수와 함께 유입구·유출구 그룹으로 옮겨 붙는다(2026-08-29 지시 4).
|
||||
optionParts.row.classList.add("b06-structure-panel__struct--options");
|
||||
optionParts.controls.classList.add("b06-structure-panel__toggles");
|
||||
const makeToggle = (label: string, title: string, onToggle: (on: boolean) => void) => {
|
||||
const button = document.createElement("button");
|
||||
@@ -408,11 +429,12 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
optionParts.controls.append(button);
|
||||
return { button, set };
|
||||
};
|
||||
const linkToggle = makeToggle(
|
||||
"연동",
|
||||
"옆 측점 기슭막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다.",
|
||||
(on) => deps.setLinked(on),
|
||||
);
|
||||
const LINK_TITLE =
|
||||
"옆 측점 기슭막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다.";
|
||||
/** 경사 해제(수평) 중에는 연동을 못 푼다 — 기슭막이 Z를 하나로 묶기 때문(2026-08-30). */
|
||||
const LINK_LOCKED_TITLE =
|
||||
"경사 해제(수평) 중에는 연동을 풀 수 없다 — 기슭막이 Z를 하나로 묶는다.";
|
||||
const linkToggle = makeToggle("연동", LINK_TITLE, (on) => deps.setLinked(on));
|
||||
const gradeToggle = makeToggle(
|
||||
"경사",
|
||||
"종단경사 반영(3D 전용) — 켜면 3D 기슭막이가 종단경사를 따라 서고, 끄면 연장 구간 내내 Z를 동일하게 본다. 횡단도는 언제나 그 측점 계획고로 그린다.",
|
||||
@@ -481,7 +503,7 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
root.classList.toggle("is-hidden", key === null);
|
||||
document.removeEventListener("keydown", onArrowKey);
|
||||
if (!key) return;
|
||||
document.addEventListener("keydown", onArrowKey);
|
||||
if (!opts?.dock) document.addEventListener("keydown", onArrowKey);
|
||||
// 다단 벽 — 유출측(extra0…)과 유입·집수정 계류측(bextra0…)이 같은 이름을 쓴다.
|
||||
const isExtra = key.startsWith("extra") || key.startsWith("bextra");
|
||||
const extraIndex = key.startsWith("bextra") ? Number(key.slice(6)) : Number(key.slice(5));
|
||||
@@ -549,6 +571,21 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
// 상태 따라 나타났다 사라지면 레이아웃이 널뛴다). 집수정 계류측은 둘 자리가
|
||||
// 있을 때만 뜬다(배관 유입 기슭막이는 다단 채널 자체가 없다).
|
||||
tierChannel = key === "outlet" ? "outlet" : key === "inlet" ? "basin" : null;
|
||||
// 좌측 [횡단 조정] 사본의 단 수·옵션 행이 어느 그룹(유입구·유출구)으로 옮겨
|
||||
// 붙을지 알린다(2026-08-29 지시 4). 다단 벽은 자기 채널 쪽으로 간다.
|
||||
// 다단(추가) 벽은 유입·유출 어느 칸에도 자기 자리가 없다 — 유입구·유출구 아래
|
||||
// **전용 칸**으로 보낸다(2026-08-30 사용자 지시 4).
|
||||
root.dataset.side = isExtra
|
||||
? "extra"
|
||||
: tierChannel === "outlet"
|
||||
? "outlet"
|
||||
: tierChannel === "basin"
|
||||
? "inlet"
|
||||
: "";
|
||||
// 옮겨 갈 그룹의 이름표에 쓴다 — 유입구·유출구는 괄호 안에, 추가 기슭막이는
|
||||
// 이름 자체로(2026-08-30 사용자 지시 1). 어느 구조물을 만지는지 폼에서 읽힌다.
|
||||
// 다단은 칸 이름이 이미 "추가 기슭막이"라 단 번호만 붙인다 — "추가 기슭막이 (2단)".
|
||||
root.dataset.panelTitle = isExtra ? `${extraIndex + 1}단` : (title.textContent ?? "");
|
||||
const tiers =
|
||||
tierChannel === "outlet"
|
||||
? deps.extraState()
|
||||
@@ -557,10 +594,12 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
: { canAdd: false, count: 0 };
|
||||
extraRow.classList.toggle(
|
||||
"is-hidden",
|
||||
!(
|
||||
tierChannel === "outlet" ||
|
||||
(tierChannel === "basin" && (ownTiers || (isBasin && (tiers.canAdd || tiers.count > 0))))
|
||||
),
|
||||
deps.isLinkedCard() ||
|
||||
!(
|
||||
tierChannel === "outlet" ||
|
||||
(tierChannel === "basin" &&
|
||||
(ownTiers || (isBasin && (tiers.canAdd || tiers.count > 0))))
|
||||
),
|
||||
);
|
||||
currentCount = tiers.count;
|
||||
countValue.textContent = `${tiers.count}단`;
|
||||
@@ -575,8 +614,12 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
|
||||
const linkState = deps.linkState();
|
||||
linkToggle.button.classList.toggle("is-hidden", linkState === null);
|
||||
linkToggle.set(linkState?.linked ?? true);
|
||||
// 경사 해제(수평) = 연동 잠김(2026-08-30 사용자). 버튼은 눌리지 않고 이유를 단다.
|
||||
const levelled = !deps.followGrade();
|
||||
linkToggle.button.disabled = levelled;
|
||||
linkToggle.button.title = levelled ? LINK_LOCKED_TITLE : LINK_TITLE;
|
||||
gradeToggle.button.classList.toggle("is-hidden", currentSpan === null);
|
||||
gradeToggle.set(deps.followGrade());
|
||||
gradeToggle.set(!levelled);
|
||||
optionParts.row.classList.toggle("is-hidden", linkState === null && currentSpan === null);
|
||||
const adjust = deps.adjustFor(key);
|
||||
// 정보는 항목당 한 행(2026-08-22 사용자) — 0이면 "자동", 좌우는 안/바깥,
|
||||
|
||||
@@ -42,6 +42,7 @@ import type {
|
||||
StructureSpanControl,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel";
|
||||
import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Dock";
|
||||
import { culvertCardState, structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure";
|
||||
import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
|
||||
import type { CrossWidthActions, ZoomPanState } from "./B06_Section_UI_Cross_View_Zoom";
|
||||
@@ -70,6 +71,9 @@ export { crossCardNaturalHeight } from "./B06_Section_UI_Cross_View_Metrics";
|
||||
*/
|
||||
export interface CrossCardElement extends HTMLElement {
|
||||
applySelection?: (selected: boolean, areaKey: CrossAreaKey | null) => void;
|
||||
/** 벽 강조만 갈아 끼운다 — 연동으로 이어진 카드도 같이 켜진다(2026-08-30 사용자).
|
||||
* 카드를 다시 만들지 않으므로 줌·팬이 살아남는다. */
|
||||
applyRevetHighlight?: (key: RevetKey | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,6 +185,9 @@ export function createCrossSectionCard(
|
||||
let setBandActive: AreaHighlightSetter = () => undefined;
|
||||
let setChipActive: AreaHighlightSetter = () => undefined;
|
||||
let activeRevet: RevetKey | null = revetOffset?.selectedFor(section) ?? null;
|
||||
/** 이 카드에서 **강조**할 벽. 연동으로 이어진 옆 측점이 고른 벽도 켠다 —
|
||||
* 조정창(`activeRevet`)은 고른 카드 하나에만 뜬다(2026-08-30 사용자). */
|
||||
let highlightRevet: RevetKey | null = revetOffset?.highlightFor(section) ?? activeRevet;
|
||||
let setRevetActive: RevetHighlightSetter = () => undefined;
|
||||
let showRevetControl: (visible: boolean) => void = () => undefined;
|
||||
let culvertPipeLengthM: number | null = null; // 관 길이(m) — 조정창 표기용
|
||||
@@ -222,9 +229,11 @@ export function createCrossSectionCard(
|
||||
const toggleRevet = (key: RevetKey): void => {
|
||||
const wasSelected = isSelected;
|
||||
activeRevet = activeRevet === key ? null : key;
|
||||
setRevetActive(activeRevet);
|
||||
showRevetControl(activeRevet !== null);
|
||||
// 강조는 **연동된 카드 전부**라 제어기를 거친다(제어기가 목록에 다시 칠하기를
|
||||
// 알린다 — 2026-08-30 사용자). 배선이 없어도 이 카드만은 즉시 맞춘다.
|
||||
revetOffset?.select(section.chainage_m, activeRevet);
|
||||
card.applyRevetHighlight?.(revetOffset?.highlightFor(section) ?? activeRevet);
|
||||
if (activeRevet !== null && activeArea !== null) {
|
||||
activeArea = null;
|
||||
setBandActive(null);
|
||||
@@ -247,13 +256,20 @@ export function createCrossSectionCard(
|
||||
setChipActive(activeArea);
|
||||
// 면적이 켜졌거나 카드가 선택에서 빠지면 구조물 선택은 풀린다(같은 레벨).
|
||||
if ((activeArea !== null || !nextSelected) && activeRevet !== null) {
|
||||
// 전역 선택이 **아직 이 카드 것일 때만** 비운다 — 옆 카드에서 방금 고른 벽까지
|
||||
// 지우면 그리로 옮겨 간 조정창이 곧바로 닫힌다(2026-08-30 링크 강조).
|
||||
const owns = revetOffset?.selectedFor(section) === activeRevet;
|
||||
activeRevet = null;
|
||||
setRevetActive(null);
|
||||
showRevetControl(false);
|
||||
revetOffset?.select(section.chainage_m, null);
|
||||
if (owns) revetOffset?.select(section.chainage_m, null);
|
||||
else card.applyRevetHighlight?.(revetOffset?.highlightFor(section) ?? null);
|
||||
}
|
||||
if (activeArea !== null || !nextSelected) bodies.clearSelection();
|
||||
};
|
||||
card.applyRevetHighlight = (key) => {
|
||||
highlightRevet = key;
|
||||
setRevetActive(key);
|
||||
};
|
||||
const toggleArea = (key: CrossAreaKey): void => {
|
||||
if (!isSelected) {
|
||||
// 아직 선택되지 않은 카드 — 부모가 카드를 고르면서 이 면적까지 한 번에 켠다.
|
||||
@@ -479,7 +495,7 @@ export function createCrossSectionCard(
|
||||
baseSetActive(key);
|
||||
setOwnActive(key === "own");
|
||||
};
|
||||
if (activeRevet === "own") setOwnActive(true);
|
||||
if (highlightRevet === "own") setOwnActive(true);
|
||||
}
|
||||
}
|
||||
appendCrossDesignOverlay(
|
||||
@@ -548,7 +564,7 @@ export function createCrossSectionCard(
|
||||
// 독립 기슭막이(hidden_pipe)는 관을 그리지 않는다 — 연동 링크 카드와 같은 규칙.
|
||||
isLinkedCulvert || culvertLayout.culvert.hidden_pipe === true,
|
||||
);
|
||||
if (activeRevet) setRevetActive(activeRevet);
|
||||
if (highlightRevet) setRevetActive(highlightRevet);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,38 +678,55 @@ export function createCrossSectionCard(
|
||||
isLinkedCulvert && culvertLink && (revetLink?.linkedFor(section) ?? true)
|
||||
? culvertLink.source
|
||||
: section;
|
||||
const adjustOf = (key: RevetKey): WallAdjust =>
|
||||
revetOffset?.adjustFor(adjustTarget(), key) ?? { x: 0, d: null, h: null, m: null };
|
||||
const panel = buildStructurePanel(
|
||||
structurePanelDeps({
|
||||
section,
|
||||
scrollKey: section.station_id,
|
||||
adjustChainage: () => adjustTarget().chainage_m,
|
||||
isLinked: isLinkedCulvert,
|
||||
revetOffset,
|
||||
inletStructure,
|
||||
extraWalls,
|
||||
structureSpan,
|
||||
revetLink,
|
||||
adjustOf,
|
||||
outwardOf,
|
||||
heightOfWall,
|
||||
formOfWall,
|
||||
appliedD: () => culvertAppliedD,
|
||||
pipeLengthM: () => culvertPipeLengthM,
|
||||
inletIsBasin: () => culvertInletIsBasin,
|
||||
hiddenPipe: () => culvertHiddenPipe,
|
||||
inletOptions: () => culvertInletOptions,
|
||||
extraState: () => culvertExtraState,
|
||||
basinExtraState: () => culvertBasinExtraState,
|
||||
activeRevet: () => activeRevet,
|
||||
setActiveRevet: (key) => {
|
||||
activeRevet = key;
|
||||
},
|
||||
toggleRevet,
|
||||
}),
|
||||
// 연동을 푼 측점에서 **아직 손대지 않은** 벽은 소유 측점 값을 보여 준다 — 창이
|
||||
// 0(자동)을 보여 주면 첫 ◀ 한 번에 벽이 원위치로 튄다(2026-08-30).
|
||||
const adjustOf = (key: RevetKey): WallAdjust => {
|
||||
const stored = revetOffset?.storedAdjustFor(adjustTarget(), key);
|
||||
if (stored) return stored;
|
||||
const source = isLinkedCulvert && culvertLink ? culvertLink.source : section;
|
||||
return revetOffset?.adjustFor(source, key) ?? { x: 0, d: null, h: null, m: null };
|
||||
};
|
||||
const panelContext = {
|
||||
section,
|
||||
scrollKey: section.station_id,
|
||||
adjustChainage: () => adjustTarget().chainage_m,
|
||||
isLinked: isLinkedCulvert,
|
||||
revetOffset,
|
||||
inletStructure,
|
||||
extraWalls,
|
||||
structureSpan,
|
||||
revetLink,
|
||||
adjustOf,
|
||||
outwardOf,
|
||||
heightOfWall,
|
||||
formOfWall,
|
||||
appliedD: () => culvertAppliedD,
|
||||
pipeLengthM: () => culvertPipeLengthM,
|
||||
inletIsBasin: () => culvertInletIsBasin,
|
||||
hiddenPipe: () => culvertHiddenPipe,
|
||||
inletOptions: () => culvertInletOptions,
|
||||
extraState: () => culvertExtraState,
|
||||
basinExtraState: () => culvertBasinExtraState,
|
||||
activeRevet: () => activeRevet,
|
||||
setActiveRevet: (key: RevetKey | null) => {
|
||||
activeRevet = key;
|
||||
},
|
||||
toggleRevet,
|
||||
};
|
||||
const panel = buildStructurePanel(structurePanelDeps(panelContext));
|
||||
// 좌측 [횡단 조정] 사본 — 같은 deps라 어느 쪽 조작이든 카드 재렌더에서 두 창이
|
||||
// 함께 새 값으로 그려진다(2026-08-29 일원화). 스크롤 기억 키만 분리한다.
|
||||
const dockPanel = buildStructurePanel(
|
||||
structurePanelDeps({ ...panelContext, scrollKey: `dock:${section.station_id}` }),
|
||||
{ dock: true },
|
||||
);
|
||||
showRevetControl = (visible) => panel.show(visible ? activeRevet : null);
|
||||
showRevetControl = (visible) => {
|
||||
const key = visible ? activeRevet : null;
|
||||
panel.show(key);
|
||||
dockPanel.show(key);
|
||||
if (key) adoptAdjustPanel(dockPanel.root);
|
||||
else releaseAdjustPanel(dockPanel.root);
|
||||
};
|
||||
showRevetControl(activeRevet !== null);
|
||||
// 세월교 조정창 — 배수관 창과 조작 축이 달라 따로 만든다(2026-08-25 사용자).
|
||||
// 우측 상단 줌 버튼이 원배율에서 표시 반폭까지 다룬다(하단 ◀/▶/↺ 폐지, 2026-08-23).
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
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";
|
||||
import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Dock";
|
||||
|
||||
export interface BodyWiringDeps {
|
||||
section: CrossSection;
|
||||
@@ -60,39 +61,53 @@ export function createBodyWiring(deps: BodyWiringDeps): BodyWiring {
|
||||
let fordHeights = new Map<FordWallRole, number>();
|
||||
let boxLayout: BoxLayout | null = null;
|
||||
|
||||
const fordPanel =
|
||||
const fordDeps =
|
||||
section.ford && ford
|
||||
? buildFordPanel(
|
||||
fordPanelDeps({
|
||||
section,
|
||||
ford,
|
||||
heightFor: (role) => fordHeights.get(role) ?? 0,
|
||||
slabLengthM: () => fordSlabLengthM,
|
||||
close: () => {
|
||||
if (fordRole) toggleFord(fordRole);
|
||||
},
|
||||
}),
|
||||
)
|
||||
? fordPanelDeps({
|
||||
section,
|
||||
ford,
|
||||
heightFor: (role) => fordHeights.get(role) ?? 0,
|
||||
slabLengthM: () => fordSlabLengthM,
|
||||
close: () => {
|
||||
if (fordRole) toggleFord(fordRole);
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const boxPanel =
|
||||
const boxDeps =
|
||||
section.box && box
|
||||
? buildBoxPanel(
|
||||
boxPanelDeps({
|
||||
chainageM: chainage,
|
||||
box,
|
||||
layout: () => boxLayout,
|
||||
close: () => {
|
||||
if (boxRole) toggleBox(boxRole);
|
||||
},
|
||||
}),
|
||||
)
|
||||
? boxPanelDeps({
|
||||
chainageM: chainage,
|
||||
box,
|
||||
layout: () => boxLayout,
|
||||
close: () => {
|
||||
if (boxRole) toggleBox(boxRole);
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const fordPanel = fordDeps ? buildFordPanel(fordDeps) : null;
|
||||
const boxPanel = boxDeps ? buildBoxPanel(boxDeps) : null;
|
||||
// 좌측 [횡단 조정] 사본 — 배수관 조정창과 같은 규칙(2026-08-29 일원화): 같은 deps로
|
||||
// 하나 더 만들어 고른 동안만 좌측 슬롯에 세운다.
|
||||
const fordDock = fordDeps ? buildFordPanel(fordDeps, { dock: true }) : null;
|
||||
const boxDock = boxDeps ? buildBoxPanel(boxDeps, { dock: true }) : null;
|
||||
const syncDock = (
|
||||
dock: FordPanelHandle | BoxPanelHandle | null,
|
||||
role: FordWallRole | BoxSideRole | null,
|
||||
): void => {
|
||||
if (!dock) return;
|
||||
(dock as { show: (role: FordWallRole | BoxSideRole | null) => void }).show(role);
|
||||
if (role) adoptAdjustPanel(dock.root);
|
||||
else releaseAdjustPanel(dock.root);
|
||||
};
|
||||
|
||||
const toggleFord = structureToggle<FordWallRole>({
|
||||
current: () => fordRole,
|
||||
setCurrent: (value) => (fordRole = value),
|
||||
setActive: (value) => setFordActive(value),
|
||||
showPanel: (visible) => fordPanel?.show(visible ? fordRole : null),
|
||||
showPanel: (visible) => {
|
||||
fordPanel?.show(visible ? fordRole : null);
|
||||
syncDock(fordDock, visible ? fordRole : null);
|
||||
},
|
||||
persist: (value) => ford?.select(chainage, value),
|
||||
isCardSelected: deps.isCardSelected,
|
||||
clearArea: deps.clearArea,
|
||||
@@ -102,7 +117,10 @@ export function createBodyWiring(deps: BodyWiringDeps): BodyWiring {
|
||||
current: () => boxRole,
|
||||
setCurrent: (value) => (boxRole = value),
|
||||
setActive: (value) => setBoxActive(value),
|
||||
showPanel: (visible) => boxPanel?.show(visible ? boxRole : null),
|
||||
showPanel: (visible) => {
|
||||
boxPanel?.show(visible ? boxRole : null);
|
||||
syncDock(boxDock, visible ? boxRole : null);
|
||||
},
|
||||
persist: (value) => box?.select(chainage, value),
|
||||
isCardSelected: deps.isCardSelected,
|
||||
clearArea: deps.clearArea,
|
||||
@@ -111,6 +129,8 @@ export function createBodyWiring(deps: BodyWiringDeps): BodyWiring {
|
||||
|
||||
fordPanel?.show(fordRole);
|
||||
boxPanel?.show(boxRole);
|
||||
syncDock(fordDock, fordRole);
|
||||
syncDock(boxDock, boxRole);
|
||||
|
||||
return {
|
||||
fordAdjust: () => ford?.adjustFor(chainage),
|
||||
@@ -125,12 +145,14 @@ export function createBodyWiring(deps: BodyWiringDeps): BodyWiring {
|
||||
if (fordRole) setter(fordRole);
|
||||
// 창은 그리기 전에 만들어져 값이 비어 있다 — 계산 결과가 들어온 뒤 다시 렌더한다.
|
||||
fordPanel?.show(fordRole);
|
||||
syncDock(fordDock, fordRole);
|
||||
},
|
||||
attachBox: (setter, layout) => {
|
||||
setBoxActive = setter;
|
||||
boxLayout = layout;
|
||||
if (boxRole) setter(boxRole);
|
||||
boxPanel?.show(boxRole);
|
||||
syncDock(boxDock, boxRole);
|
||||
},
|
||||
clearSelection: () => {
|
||||
if (fordRole !== null) toggleFord(fordRole);
|
||||
|
||||
@@ -143,6 +143,7 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
|
||||
fineMove: (key) => (key !== "inlet" && key !== "outlet") || ctx.hiddenPipe(),
|
||||
optionsFor: () => ctx.inletOptions(),
|
||||
extraState: () => ctx.extraState(),
|
||||
isLinkedCard: () => ctx.isLinked,
|
||||
basinExtraState: () => ctx.basinExtraState(),
|
||||
hiddenPipe: () => ctx.hiddenPipe(),
|
||||
setExtraCount: (count) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { OffsetPoint, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { appendWallHatch } from "./B06_Section_UI_Cross_Wall_Hatch";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
@@ -119,68 +120,9 @@ export function drawRevetWall(
|
||||
);
|
||||
layer.append(revetShape);
|
||||
|
||||
// 평행사변형 띠와 사다리꼴 사이 대각 이음선(내부 경계) — 상단 변 중간점에서 바닥으로.
|
||||
const joint = document.createElementNS(SVG_NS, "line");
|
||||
const jointBaseOffset = wall.outerOffset - wall.outward * REVET_THICKNESS_M;
|
||||
const bottomSpan = wall.bottomFront.offset - wall.bottomBack.offset;
|
||||
const bottomAtOffset = (offset: number): number =>
|
||||
Math.abs(bottomSpan) > 1e-9
|
||||
? wall.bottomBack.elevation +
|
||||
(wall.bottomFront.elevation - wall.bottomBack.elevation) *
|
||||
((offset - wall.bottomBack.offset) / bottomSpan)
|
||||
: wall.bottomBack.elevation;
|
||||
joint.setAttribute("x1", String(x(wall.topJoint.offset)));
|
||||
joint.setAttribute("y1", String(toDisplayY(wall.topJoint.elevation)));
|
||||
joint.setAttribute("x2", String(x(jointBaseOffset)));
|
||||
joint.setAttribute("y2", String(toDisplayY(bottomAtOffset(jointBaseOffset))));
|
||||
joint.setAttribute("class", "b06-chart__culvert-stone");
|
||||
layer.append(joint);
|
||||
|
||||
// 돌 해칭 — 큰 돌이 한 줄로 쌓인 계류측 기운 띠. 벽 폴리곤 clip 안에만 그려 어떤
|
||||
// 높이에서도 돌이 벽 밖으로 새지 않게 한다.
|
||||
const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1;
|
||||
const clipId = `b06-revet-clip-${keyId}-${Math.round(wall.backOffset * 100)}`;
|
||||
const clip = document.createElementNS(SVG_NS, "clipPath");
|
||||
clip.setAttribute("id", clipId);
|
||||
clip.append(
|
||||
polygon(
|
||||
wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
|
||||
"",
|
||||
"",
|
||||
),
|
||||
);
|
||||
const stoneGroup = document.createElementNS(SVG_NS, "g");
|
||||
stoneGroup.setAttribute("clip-path", `url(#${clipId})`);
|
||||
layer.append(clip, stoneGroup);
|
||||
|
||||
const centerBaseOffset = wall.outerOffset - wall.outward * (REVET_THICKNESS_M / 2);
|
||||
const embedBase = bottomAtOffset(centerBaseOffset);
|
||||
const stoneSpan = Math.max(0.3, wall.topBack.elevation - embedBase);
|
||||
const stoneCount = Math.max(2, Math.round(stoneSpan / 0.45));
|
||||
const stoneHeight = stoneSpan / stoneCount;
|
||||
const centerBase = centerBaseOffset;
|
||||
const centerTop = wall.topJoint.offset + wall.outward * (REVET_THICKNESS_M / 2);
|
||||
const axisX = x(centerTop) - x(centerBase);
|
||||
const axisY = toDisplayY(wall.topBack.elevation) - toDisplayY(embedBase);
|
||||
const leanDegrees = (Math.atan2(axisX, -axisY) * 180) / Math.PI;
|
||||
for (let i = 0; i < stoneCount; i += 1) {
|
||||
const fraction = (i + 0.5) / stoneCount;
|
||||
const centerOffset = centerBase + (centerTop - centerBase) * fraction;
|
||||
const centerElevation = embedBase + stoneSpan * fraction;
|
||||
const cx = x(centerOffset);
|
||||
const cy = toDisplayY(centerElevation);
|
||||
const stone = document.createElementNS(SVG_NS, "rect");
|
||||
const widthPx = REVET_THICKNESS_M * pixelsPerMeter * 0.9;
|
||||
const heightPx = stoneHeight * pixelsPerMeter * 0.86;
|
||||
stone.setAttribute("x", String(cx - widthPx / 2));
|
||||
stone.setAttribute("y", String(cy - heightPx / 2));
|
||||
stone.setAttribute("width", String(widthPx));
|
||||
stone.setAttribute("height", String(heightPx));
|
||||
stone.setAttribute("rx", String(Math.min(widthPx, heightPx) * 0.3));
|
||||
stone.setAttribute("transform", `rotate(${leanDegrees.toFixed(1)} ${cx} ${cy})`);
|
||||
stone.setAttribute("class", "b06-chart__culvert-stone");
|
||||
stoneGroup.append(stone);
|
||||
}
|
||||
// 이음선 + **형태별 표현**(돌/콘크리트/돌망태/통나무/바자)은 공용 해칭이 맡는다
|
||||
// (2026-08-30 사용자 지시 3) — 배관 오버레이와 같은 함수를 쓴다.
|
||||
appendWallHatch(layer, wall, x, toDisplayY, keyId);
|
||||
return revetShape;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Wall_Hatch.ts
|
||||
* 기슭막이 벽 **형태별 표현**(2026-08-30 사용자 지시 3) — 배관용·독립용·추가(다단)용
|
||||
* 세 갈래가 모두 이 한 함수로 그린다. 종전에는 형태를 가리지 않고 돌 박스만 깔아
|
||||
* 콘크리트·돌망태·통나무·바자가 전부 돌쌓기로 보였다.
|
||||
*
|
||||
* 표현은 벽 **전면 기운 띠**(이음선~전면, 두께 REVET_THICKNESS_M) 안에서만 그리고,
|
||||
* 벽 폴리곤 clip을 씌워 어떤 높이에서도 밖으로 새지 않게 한다(기존 규칙 유지).
|
||||
*
|
||||
* 형태 → 표현(Claude 제안, 2026-08-30 사용자 승인 대기 없이 적용):
|
||||
* · 돌쌓기(메) : 둥근 큰 돌 한 줄 — 줄눈 없이 사이가 벌어진 메쌓기.
|
||||
* · 돌쌓기(찰) : 같은 돌을 작게 + 켜마다 줄눈선 — 모르타르 채움을 나타낸다.
|
||||
* · 콘크리트 : 돌 없음 + 사선 해칭 — 도면 관례의 콘크리트 표기.
|
||||
* · 돌망태 : 2열 격자 칸(철망 상자) — 칸마다 각진 사각형.
|
||||
* · 통나무·목재틀 : 원(통나무 마구리)을 한 줄로 쌓는다.
|
||||
* · 바자 : 세로 말뚝 3줄 + 가로 엮음 눈금.
|
||||
* ========================================================================== */
|
||||
|
||||
import { REVET_THICKNESS_M } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const HATCH_CLASS = "b06-chart__culvert-stone";
|
||||
|
||||
/** 형태 문자열 → 표현 갈래. 판정 순서는 좁은 말(돌망태·바자)부터. */
|
||||
export type WallHatch = "dry" | "wet" | "concrete" | "gabion" | "log" | "wattle";
|
||||
|
||||
export function hatchOfForm(form: string | null | undefined): WallHatch {
|
||||
if (!form) return "dry";
|
||||
if (form.includes("돌망태")) return "gabion";
|
||||
if (form.includes("통나무") || form.includes("목재")) return "log";
|
||||
if (form.includes("바자")) return "wattle";
|
||||
if (form.includes("콘크리트")) return "concrete";
|
||||
if (form.includes("찰")) return "wet";
|
||||
return "dry";
|
||||
}
|
||||
|
||||
/** 띠 안의 국소 좌표 → 화면 px. u = 바닥(0)~상단(1), v = 배면(-0.5)~전면(+0.5). */
|
||||
interface BandFrame {
|
||||
point: (u: number, v: number) => [number, number];
|
||||
/** 띠 축 기울기(도) — 사각형·원의 세로축을 벽 경사에 맞춘다. */
|
||||
leanDegrees: number;
|
||||
/** 띠 길이(m) — 켜 수 산정 기준. */
|
||||
spanM: number;
|
||||
pixelsPerMeter: number;
|
||||
}
|
||||
|
||||
function line(a: [number, number], b: [number, number]): SVGLineElement {
|
||||
const el = document.createElementNS(SVG_NS, "line");
|
||||
el.setAttribute("x1", String(a[0]));
|
||||
el.setAttribute("y1", String(a[1]));
|
||||
el.setAttribute("x2", String(b[0]));
|
||||
el.setAttribute("y2", String(b[1]));
|
||||
el.setAttribute("class", HATCH_CLASS);
|
||||
return el;
|
||||
}
|
||||
|
||||
/** 띠 중심선 위 한 칸 — 회전 사각형(돌·격자 칸). */
|
||||
function cell(
|
||||
frame: BandFrame,
|
||||
u: number,
|
||||
v: number,
|
||||
widthM: number,
|
||||
heightM: number,
|
||||
radiusRatio: number,
|
||||
): SVGRectElement {
|
||||
const [cx, cy] = frame.point(u, v);
|
||||
const widthPx = widthM * frame.pixelsPerMeter;
|
||||
const heightPx = heightM * frame.pixelsPerMeter;
|
||||
const rect = document.createElementNS(SVG_NS, "rect");
|
||||
rect.setAttribute("x", String(cx - widthPx / 2));
|
||||
rect.setAttribute("y", String(cy - heightPx / 2));
|
||||
rect.setAttribute("width", String(widthPx));
|
||||
rect.setAttribute("height", String(heightPx));
|
||||
rect.setAttribute("rx", String(Math.min(widthPx, heightPx) * radiusRatio));
|
||||
rect.setAttribute("transform", `rotate(${frame.leanDegrees.toFixed(1)} ${cx} ${cy})`);
|
||||
rect.setAttribute("class", HATCH_CLASS);
|
||||
return rect;
|
||||
}
|
||||
|
||||
/** 켜 수 — 켜 높이가 목표치에 가장 가깝게, 최소 2켜. */
|
||||
function courses(spanM: number, targetM: number): number {
|
||||
return Math.max(2, Math.round(spanM / targetM));
|
||||
}
|
||||
|
||||
function drawStones(group: SVGElement, frame: BandFrame, wet: boolean): void {
|
||||
const count = courses(frame.spanM, 0.45);
|
||||
const courseM = frame.spanM / count;
|
||||
const widthM = REVET_THICKNESS_M * (wet ? 0.76 : 0.9);
|
||||
const heightM = courseM * (wet ? 0.7 : 0.86);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
group.append(cell(frame, (i + 0.5) / count, 0, widthM, heightM, 0.3));
|
||||
// 찰쌓기는 켜마다 줄눈선을 그어 모르타르로 채운 켜임을 보인다.
|
||||
if (wet && i > 0) group.append(line(frame.point(i / count, -0.5), frame.point(i / count, 0.5)));
|
||||
}
|
||||
}
|
||||
|
||||
function drawConcrete(group: SVGElement, frame: BandFrame): void {
|
||||
// 사선 해칭 — 0.3m 간격, 띠를 가로질러 한 방향으로만 긋는다.
|
||||
const count = courses(frame.spanM, 0.3);
|
||||
const step = 1 / count;
|
||||
for (let i = 0; i <= count; i += 1) {
|
||||
const u = i * step;
|
||||
group.append(line(frame.point(u, -0.5), frame.point(Math.min(1, u + step * 0.9), 0.5)));
|
||||
}
|
||||
}
|
||||
|
||||
function drawGabion(group: SVGElement, frame: BandFrame): void {
|
||||
// 철망 상자 — 2열 격자 칸. 돌보다 각지게(모서리 반경 거의 0) 그린다.
|
||||
const count = courses(frame.spanM, 0.5);
|
||||
const courseM = frame.spanM / count;
|
||||
const widthM = REVET_THICKNESS_M * 0.42;
|
||||
const heightM = courseM * 0.88;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const u = (i + 0.5) / count;
|
||||
group.append(cell(frame, u, -0.24, widthM, heightM, 0.06));
|
||||
group.append(cell(frame, u, 0.24, widthM, heightM, 0.06));
|
||||
}
|
||||
}
|
||||
|
||||
function drawLogs(group: SVGElement, frame: BandFrame): void {
|
||||
// 통나무 마구리 — 지름 0.3m 원을 한 줄로 쌓는다.
|
||||
const count = courses(frame.spanM, 0.3);
|
||||
const radiusPx =
|
||||
(Math.min(frame.spanM / count, REVET_THICKNESS_M) / 2) * 0.86 * frame.pixelsPerMeter;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const [cx, cy] = frame.point((i + 0.5) / count, 0);
|
||||
const circle = document.createElementNS(SVG_NS, "circle");
|
||||
circle.setAttribute("cx", String(cx));
|
||||
circle.setAttribute("cy", String(cy));
|
||||
circle.setAttribute("r", String(Math.max(1, radiusPx)));
|
||||
circle.setAttribute("class", HATCH_CLASS);
|
||||
group.append(circle);
|
||||
}
|
||||
}
|
||||
|
||||
function drawWattle(group: SVGElement, frame: BandFrame): void {
|
||||
// 바자 — 세로 말뚝 3줄에 가로 엮음 눈금을 얹는다.
|
||||
for (const v of [-0.3, 0, 0.3]) group.append(line(frame.point(0, v), frame.point(1, v)));
|
||||
const count = courses(frame.spanM, 0.25);
|
||||
for (let i = 1; i < count; i += 1) {
|
||||
const u = i / count;
|
||||
group.append(line(frame.point(u, -0.42), frame.point(u, 0.42)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 벽 1매의 내부 표현(이음선 + 형태별 해칭)을 그린다. `keyId`는 clipPath id 중복 방지용.
|
||||
* 배관 오버레이(`_Cross_Culvert`)와 독립 기슭막이(`_Cross_Wall`)가 같이 쓴다.
|
||||
*/
|
||||
export function appendWallHatch(
|
||||
layer: SVGElement,
|
||||
wall: WallLayout,
|
||||
x: (offset: number) => number,
|
||||
toDisplayY: (elevation: number) => number,
|
||||
keyId: string,
|
||||
): void {
|
||||
// 하단선이 원지반을 따라 기울어 있으므로 띠 바닥도 하단선 위에서 잡는다.
|
||||
const bottomSpan = wall.bottomFront.offset - wall.bottomBack.offset;
|
||||
const bottomAtOffset = (offset: number): number =>
|
||||
Math.abs(bottomSpan) > 1e-9
|
||||
? wall.bottomBack.elevation +
|
||||
(wall.bottomFront.elevation - wall.bottomBack.elevation) *
|
||||
((offset - wall.bottomBack.offset) / bottomSpan)
|
||||
: wall.bottomBack.elevation;
|
||||
|
||||
// 평행사변형 띠와 사다리꼴 사이 대각 이음선(사용자 스케치의 가운데 선) —
|
||||
// 상단 변 중간점에서 전면과 나란히 바닥으로 내려온다. 형태와 무관하게 늘 긋는다.
|
||||
const jointBaseOffset = wall.outerOffset - wall.outward * REVET_THICKNESS_M;
|
||||
layer.append(
|
||||
line(
|
||||
[x(wall.topJoint.offset), toDisplayY(wall.topJoint.elevation)],
|
||||
[x(jointBaseOffset), toDisplayY(bottomAtOffset(jointBaseOffset))],
|
||||
),
|
||||
);
|
||||
|
||||
const clipId = `b06-revet-clip-${keyId}-${Math.round(wall.backOffset * 100)}`;
|
||||
const clip = document.createElementNS(SVG_NS, "clipPath");
|
||||
clip.setAttribute("id", clipId);
|
||||
const clipShape = document.createElementNS(SVG_NS, "polygon");
|
||||
clipShape.setAttribute(
|
||||
"points",
|
||||
wall.points.map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "),
|
||||
);
|
||||
clip.append(clipShape);
|
||||
const group = document.createElementNS(SVG_NS, "g");
|
||||
group.setAttribute("clip-path", `url(#${clipId})`);
|
||||
layer.append(clip, group);
|
||||
|
||||
const centerBase = wall.outerOffset - wall.outward * (REVET_THICKNESS_M / 2);
|
||||
const centerTop = wall.topJoint.offset + wall.outward * (REVET_THICKNESS_M / 2);
|
||||
const embedBase = bottomAtOffset(centerBase);
|
||||
const spanM = Math.max(0.3, wall.topBack.elevation - embedBase);
|
||||
const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1;
|
||||
const frame: BandFrame = {
|
||||
spanM,
|
||||
pixelsPerMeter,
|
||||
point: (u, v) => [
|
||||
x(centerBase + (centerTop - centerBase) * u + wall.outward * REVET_THICKNESS_M * v),
|
||||
toDisplayY(embedBase + spanM * u),
|
||||
],
|
||||
leanDegrees:
|
||||
(Math.atan2(
|
||||
x(centerTop) - x(centerBase),
|
||||
-(toDisplayY(wall.topBack.elevation) - toDisplayY(embedBase)),
|
||||
) *
|
||||
180) /
|
||||
Math.PI,
|
||||
};
|
||||
|
||||
switch (hatchOfForm(wall.form)) {
|
||||
case "wet":
|
||||
drawStones(group, frame, true);
|
||||
break;
|
||||
case "concrete":
|
||||
drawConcrete(group, frame);
|
||||
break;
|
||||
case "gabion":
|
||||
drawGabion(group, frame);
|
||||
break;
|
||||
case "log":
|
||||
drawLogs(group, frame);
|
||||
break;
|
||||
case "wattle":
|
||||
drawWattle(group, frame);
|
||||
break;
|
||||
default:
|
||||
drawStones(group, frame, false);
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,13 @@ import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul";
|
||||
import { computeHaulPlan } from "@util/common_util_mass_haul_balance";
|
||||
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
|
||||
import { staleDesignChainages } from "./B06_Section_UI_Section_Common";
|
||||
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { createStandardPanel, type StandardPanelController } from "./B06_Section_UI_Standard_Panel";
|
||||
import {
|
||||
createB06StructuresPanel,
|
||||
isWallSelecting,
|
||||
wireStructureSelection,
|
||||
} from "./B06_Section_UI_Page_Structures_Panel";
|
||||
import "./B06_Section_UI_Style.css";
|
||||
import "./B06_Section_UI_Style_Cross.css";
|
||||
import "./B06_Section_UI_Style_Cross_Controls.css";
|
||||
@@ -115,13 +121,181 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
});
|
||||
confirmButton.disabled = true;
|
||||
const actionRow = document.createElement("div");
|
||||
// 사이드 최하단 고정(공용 ui-sidebar-actions) — 스크롤에서 제외(2026-08-05 사용자 지시).
|
||||
actionRow.className = "b06-profile__actions ui-sidebar-actions";
|
||||
actionRow.className = "b06-profile__actions";
|
||||
actionRow.append(goProfileButton, saveButton, confirmButton);
|
||||
|
||||
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤 — 가장 가까운 카드로 간다. */
|
||||
const focusStationAt = (chainageM: number): void => {
|
||||
const sections = sectionDetail?.cross_sections;
|
||||
if (!sections?.length) return;
|
||||
const nearest = sections.reduce((best, entry) =>
|
||||
Math.abs(entry.chainage_m - chainageM) < Math.abs(best.chainage_m - chainageM) ? entry : best,
|
||||
);
|
||||
sectionView.focusStation(nearest.station_id);
|
||||
};
|
||||
|
||||
// 「구조물 배치」 — B05와 같은 컨테이너·하단 목록 템플릿(2026-08-29 일원화).
|
||||
// 하단 고정 dock 도 B05와 같은 구조: [구조물 목록][구분선][액션 버튼 행].
|
||||
const structuresPanel = createB06StructuresPanel({
|
||||
projectId,
|
||||
detail: () => sectionDetail,
|
||||
// 폼 기본 높이 = 지금 도면에 그려진 순수 높이(조정창이 보여주던 값과 같은 계산).
|
||||
wallHeight: (chainageM, role) => {
|
||||
const owner = sectionDetail?.cross_sections.find(
|
||||
(section) => Math.abs(section.chainage_m - chainageM) < 0.51,
|
||||
);
|
||||
const culvert = owner?.culvert;
|
||||
if (!owner || !culvert) return null;
|
||||
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
|
||||
const adjust = stationControls.revetOffset.adjustFor(owner, role);
|
||||
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m)
|
||||
.pureHeight;
|
||||
},
|
||||
// 폼 형태 = 지금 도면이 쓰는 형태. 조작(조정값 m)이 있으면 그것이 정본이라
|
||||
// 저장된 옵션보다 앞선다 — 안 맞추면 폼은 저장값, 그림은 조작값이 되어
|
||||
// "유출구 형태를 바꿔도 안 바뀐다"로 보인다(2026-08-30 사용자 보고).
|
||||
wallForm: (chainageM, role) => {
|
||||
const owner = sectionDetail?.cross_sections.find(
|
||||
(section) => Math.abs(section.chainage_m - chainageM) < 0.51,
|
||||
);
|
||||
const culvert = owner?.culvert;
|
||||
if (!owner || !culvert) return null;
|
||||
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
|
||||
const adjust = stationControls.revetOffset.adjustFor(owner, role);
|
||||
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m).form;
|
||||
},
|
||||
// 좌·우 이름표 — +offset이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다.
|
||||
wallSideLabels: (chainageM) => {
|
||||
const owner = sectionDetail?.cross_sections.find(
|
||||
(section) => Math.abs(section.chainage_m - chainageM) < 0.51,
|
||||
);
|
||||
if (!owner) return null;
|
||||
const inletLeft = (owner.uphill_side ?? "left") === "left";
|
||||
return inletLeft ? { inlet: "좌", outlet: "우" } : { inlet: "우", outlet: "좌" };
|
||||
},
|
||||
// 폼 단 수 ↔ 다단 채널(유출 벽 = extra, 유입·계류측 = bextra) — 좌·우가 각자 쓴다.
|
||||
setTierCount: (chainageM, role, tiers) => {
|
||||
const extras = Math.max(0, Math.round(tiers) - 1);
|
||||
stationControls.extraWalls.setCount(
|
||||
chainageM,
|
||||
extras,
|
||||
role === "outlet" ? "outlet" : "basin",
|
||||
);
|
||||
},
|
||||
tierCount: (chainageM, role) => {
|
||||
const owner = sectionDetail?.cross_sections.find(
|
||||
(section) => Math.abs(section.chainage_m - chainageM) < 0.51,
|
||||
);
|
||||
if (!owner) return null;
|
||||
return stationControls.extraWalls.countFor(owner, role === "outlet" ? "outlet" : "basin") + 1;
|
||||
},
|
||||
stationInterval: () => stationInterval ?? 20,
|
||||
focusChainage: focusStationAt,
|
||||
queuePipeOptions: (chainageM, patch) => stationControls.queueCulvertOptions(chainageM, patch),
|
||||
applyPipeOptions: (chainageM, patch) => applyPipeOptionsToCache(chainageM, patch),
|
||||
movePipe: (fromChainageM, toChainageM) =>
|
||||
stationControls.queueCulvertMove(fromChainageM, toChainageM),
|
||||
});
|
||||
|
||||
/**
|
||||
* 폼에서 바꾼 관 옵션을 **횡단 캐시**(`section.culvert` 스펙)에 얹고, 그 구조물이
|
||||
* 덮는 측점 카드를 다시 그린다 — 조정창 조작과 같은 흐름이라 도면이 바로 따라온다
|
||||
* (2026-08-29 사용자 보고: 값만 바뀌고 횡단도가 그대로였다).
|
||||
* 여기서는 화면 캐시만 고친다 — 영구저장은 [저장]·[확정] 몫이다.
|
||||
*/
|
||||
function applyPipeOptionsToCache(
|
||||
chainageM: number,
|
||||
patch: Record<string, number | string>,
|
||||
): void {
|
||||
const owner = sectionDetail?.cross_sections.find(
|
||||
(section) => Math.abs(section.chainage_m - chainageM) < 0.51,
|
||||
);
|
||||
const culvert = owner?.culvert;
|
||||
if (!owner || !culvert) return;
|
||||
const num = (key: string): number | undefined => {
|
||||
const value = Number(patch[key]);
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
};
|
||||
const put = <T extends object>(target: T, field: keyof T, value: unknown): void => {
|
||||
if (value !== undefined) (target as Record<string, unknown>)[field as string] = value;
|
||||
};
|
||||
put(culvert, "pipe_kind", patch.pipe_kind);
|
||||
const diameterMm = num("pipe_diameter_mm");
|
||||
if (diameterMm !== undefined) culvert.diameter_m = diameterMm / 1000;
|
||||
// 유입·유출 기슭막이 제원과 집수정 구간값 — 폼 옵션 키를 스펙 자리로 옮긴다.
|
||||
for (const [side, prefix] of [
|
||||
[culvert.inlet, "inlet"],
|
||||
[culvert.outlet, "outlet"],
|
||||
] as const) {
|
||||
put(side, "revet_form", patch[`${prefix}_revet_form`]);
|
||||
put(side, "revet_height_m", num(`${prefix}_revet_height_m`));
|
||||
put(side, "revet_length_m", num(`${prefix}_revet_length_m`));
|
||||
put(side, "revet_before_m", num(`${prefix}_revet_before_m`));
|
||||
put(side, "revet_after_m", num(`${prefix}_revet_after_m`));
|
||||
put(side, "structure", patch[`${prefix}_type`]);
|
||||
}
|
||||
// 배관 벽의 **높이·형태는 스펙이 아니라 조정값**(revet_adjust.h·m)이 정한다
|
||||
// — `revetWallSpec`이 배관에서는 spec.revet_height_m을 쓰지 않기 때문이다.
|
||||
// 폼에서 고친 값을 조정창과 같은 채널로 보내야 도면이 따라온다(2026-08-29 사용자).
|
||||
for (const [role, prefix] of [
|
||||
["inlet", "inlet"],
|
||||
["outlet", "outlet"],
|
||||
] as const) {
|
||||
const height = num(`${prefix}_revet_height_m`);
|
||||
const form = patch[`${prefix}_revet_form`];
|
||||
if (height === undefined && typeof form !== "string") continue;
|
||||
stationControls.revetOffset.update(owner.chainage_m, role, {
|
||||
...(height !== undefined ? { h: height } : {}),
|
||||
...(typeof form === "string" ? { m: form } : {}),
|
||||
});
|
||||
// 형태마다 높이 한계가 있다(돌쌓기(메) 2.0m 등). 기하가 잘라 낸 실제 높이를
|
||||
// 폼에 되돌린다 — 숫자만 커지고 그림은 그대로인 상태를 남기지 않는다.
|
||||
if (height === undefined) continue;
|
||||
const applied = revetWallSpec(
|
||||
role === "outlet" ? culvert.outlet : culvert.inlet,
|
||||
stationControls.revetOffset.adjustFor(owner, role),
|
||||
culvert.hidden_pipe === true,
|
||||
culvert.diameter_m,
|
||||
).pureHeight;
|
||||
if (Math.abs(applied - height) > 0.05) {
|
||||
structuresPanel.overrideOptions(owner.chainage_m, {
|
||||
[`${prefix}_revet_height_m`]: Number(applied.toFixed(1)),
|
||||
});
|
||||
showToast(
|
||||
`${prefix === "outlet" ? "유출" : "유입"} 기슭막이 높이는 형태 한계로 ` +
|
||||
`${applied.toFixed(1)}m까지만 적용됩니다.`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
put(culvert.inlet, "basin_length_m", num("inlet_basin_length_m"));
|
||||
put(culvert.inlet, "basin_before_m", num("inlet_basin_before_m"));
|
||||
put(culvert.inlet, "basin_after_m", num("inlet_basin_after_m"));
|
||||
// 연장이 바뀌면 옆 측점 링크도 달라진다 — 그 구조물이 덮는 범위만 다시 그린다.
|
||||
const reach = Math.max(
|
||||
culvert.inlet.revet_before_m ?? 0,
|
||||
culvert.inlet.revet_after_m ?? 0,
|
||||
culvert.outlet.revet_before_m ?? 0,
|
||||
culvert.outlet.revet_after_m ?? 0,
|
||||
num("inlet_revet_length_m") ?? 0,
|
||||
num("outlet_revet_length_m") ?? 0,
|
||||
);
|
||||
for (const other of sectionDetail?.cross_sections ?? []) {
|
||||
if (Math.abs(other.chainage_m - owner.chainage_m) <= reach + 1e-9) {
|
||||
sectionView.refreshCard(other.chainage_m);
|
||||
}
|
||||
}
|
||||
}
|
||||
const dockDivider = document.createElement("hr");
|
||||
dockDivider.className = "b05-structure__divider";
|
||||
const actionDock = document.createElement("div");
|
||||
actionDock.className = "b05-route__dock ui-sidebar-actions";
|
||||
actionDock.append(structuresPanel.listRoot, dockDivider, actionRow);
|
||||
|
||||
const leftForm = document.createElement("div");
|
||||
leftForm.className = "b06-profile__form";
|
||||
leftForm.append(viewGroup, standardGroup, actionRow);
|
||||
// 순서: 구조물 배치(최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준.
|
||||
leftForm.append(structuresPanel.root, viewGroup, standardGroup, actionDock);
|
||||
// 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님.
|
||||
attachCollapsible(leftForm);
|
||||
|
||||
@@ -437,6 +611,32 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
stationControls.ford,
|
||||
stationControls.box,
|
||||
);
|
||||
// 횡단도 벽·구체 선택 → 좌측 「구조물 배치」 폼에 그 시설 로드(2026-08-29 일원화).
|
||||
const structureSelection = wireStructureSelection(
|
||||
stationControls,
|
||||
() => sectionDetail,
|
||||
structuresPanel,
|
||||
);
|
||||
// 횡단도(카드) 자체를 골라도 그 측점 구조물 정보를 폼에 올린다(2026-08-29 사용자).
|
||||
// 연동으로 옆에서 넘어온 카드는 **소유 측점** 시설을 보여 준다.
|
||||
sectionView.setStationSelectListener((stationId) => {
|
||||
const target = stationId
|
||||
? sectionDetail?.cross_sections.find((section) => section.station_id === stationId)
|
||||
: null;
|
||||
if (!target) {
|
||||
structuresPanel.showAtChainage(null);
|
||||
return;
|
||||
}
|
||||
const owner = stationControls.structureSpan.ownerOf(target);
|
||||
structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m);
|
||||
structureSelection.syncInletStructure();
|
||||
// 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다
|
||||
// (2026-08-29 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다.
|
||||
if (!isWallSelecting() && stationControls.revetOffset.selectedFor(target)) {
|
||||
stationControls.revetOffset.select(target.chainage_m, null);
|
||||
sectionView.refreshCard(target.chainage_m);
|
||||
}
|
||||
});
|
||||
|
||||
// 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다.
|
||||
const mainArea = document.createElement("div");
|
||||
@@ -669,6 +869,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
currentRouteId = context.route_id;
|
||||
loadRockOffsets();
|
||||
stationControls.load();
|
||||
// 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다.
|
||||
void structuresPanel.load();
|
||||
try {
|
||||
const existing = await getSections(projectId, context.route_id);
|
||||
if (!existing.longitudinal) {
|
||||
|
||||
@@ -24,7 +24,12 @@ import type {
|
||||
StructureSpanControl,
|
||||
} from "./B06_Section_UI_Cross_View";
|
||||
import * as CulvertConst from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { culvertOwnerFor, tierSpanOf } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import {
|
||||
culvertOwnerFor,
|
||||
culvertReach,
|
||||
tierSpanOf,
|
||||
wallStandsAt,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options";
|
||||
import { createBodyControls } from "./B06_Section_UI_Page_Ford_Controls";
|
||||
import { createLinkFlagSession } from "./B06_Section_UI_Page_Link_Session";
|
||||
@@ -44,6 +49,7 @@ export interface StationControlDeps {
|
||||
| "extrawall"
|
||||
| "extraspan"
|
||||
| "culvertopt"
|
||||
| "culvertmove"
|
||||
| "revetlink"
|
||||
| "fordadjust"
|
||||
| "boxadjust",
|
||||
@@ -91,6 +97,10 @@ export interface StationControls {
|
||||
extraSpansByChainage: () => Map<number, Record<string, StoredWallSpan>>;
|
||||
/** 예약된 구간값 저장을 즉시 내보낸다 — 확정·임시저장 직전에 부른다. */
|
||||
flushCulvertOptions: () => Promise<void>;
|
||||
/** 관 옵션 조각을 예약한다 — 좌측 폼의 [수정]도 이 경로로 정본에 간다(2026-08-29). */
|
||||
queueCulvertOptions: (chainageM: number, patch: Record<string, number | string>) => void;
|
||||
/** 기준 측점 이동을 예약한다 — B06 구조물 배치 폼의 측점 칸이 쓴다(2026-08-29). */
|
||||
queueCulvertMove: (fromChainageM: number, toChainageM: number) => void;
|
||||
load: () => void;
|
||||
/** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */
|
||||
applyGlobalWidth: (requested: number | undefined, chainages: number[]) => void;
|
||||
@@ -184,13 +194,15 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
* 만진 값은 그 세션의 표시 조정으로 본다. 키는 `누가거리:역할`.
|
||||
* 구 형식(숫자 = x 이동량)도 읽어 준다. `select`는 다시 그리지 않는다(줌·팬 보존). */
|
||||
const revetShifts = new Map<string, WallAdjust>();
|
||||
const revetSelected = new Map<string, RevetKey>();
|
||||
/** 지금 고른 벽 **하나** — 조정창이 뜬 측점(누가거리 키)과 벽 키(2026-08-30 개편:
|
||||
* 강조는 연동으로 이어진 카드 전부, 조정창은 고른 카드 하나). */
|
||||
let revetSelection: { at: string; key: RevetKey } | null = null;
|
||||
const revetKey = (chainageM: number, role: RevetKey): string => `${chainageM.toFixed(2)}:${role}`;
|
||||
const revetSessionKey = (): string | null => deps.sessionKey("revetx");
|
||||
|
||||
function loadRevetShifts(): void {
|
||||
revetShifts.clear();
|
||||
revetSelected.clear();
|
||||
revetSelection = null;
|
||||
const key = revetSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
@@ -255,17 +267,11 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
deps.refreshCard(chainageM);
|
||||
const sections = deps.detail()?.cross_sections ?? [];
|
||||
const owner = sections.find((s) => Math.abs(s.chainage_m - chainageM) < 0.01);
|
||||
const spec = owner?.culvert;
|
||||
if (!spec) return;
|
||||
const reach = Math.max(
|
||||
...[spec.inlet, spec.outlet].flatMap((side) => {
|
||||
const span =
|
||||
side?.structure === "집수정"
|
||||
? CulvertConst.basinSpanOfSpec(side)
|
||||
: CulvertConst.revetSpanOfSpec(side);
|
||||
return [span.beforeM, span.afterM];
|
||||
}),
|
||||
);
|
||||
if (!owner) return;
|
||||
// 연장은 그리기·링크 판정과 **같은 함수**로 잰다 — 다단 단별 구간값까지 본다.
|
||||
const span = owner.culvert ? culvertReach(owner) : null;
|
||||
if (!span) return;
|
||||
const reach = Math.max(span.beforeM, span.afterM);
|
||||
for (const other of sections) {
|
||||
if (other === owner) continue;
|
||||
if (Math.abs(other.chainage_m - chainageM) <= reach + 1e-9)
|
||||
@@ -281,10 +287,29 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
(section.design?.revet_adjust?.[role]
|
||||
? { ...ZERO_ADJUST, ...section.design.revet_adjust[role] }
|
||||
: { ...ZERO_ADJUST }),
|
||||
selectedFor: (section) => revetSelected.get(section.chainage_m.toFixed(2)) ?? null,
|
||||
storedAdjustFor: (section, role) => {
|
||||
const session = revetShifts.get(revetKey(section.chainage_m, role));
|
||||
if (session) return session;
|
||||
const stored = section.design?.revet_adjust?.[role];
|
||||
return stored ? { ...ZERO_ADJUST, ...stored } : null;
|
||||
},
|
||||
selectedFor: (section) =>
|
||||
revetSelection?.at === section.chainage_m.toFixed(2) ? revetSelection.key : null,
|
||||
// 강조는 **같은 구조물**을 그리는 카드 전부. 고른 카드의 소유 측점과 이 카드의
|
||||
// 소유 측점이 같고, 그 벽이 이 측점에도 서면 켠다(2026-08-30 사용자).
|
||||
highlightFor: (section) => {
|
||||
if (!revetSelection) return null;
|
||||
if (revetSelection.at === section.chainage_m.toFixed(2)) return revetSelection.key;
|
||||
const picked = sectionAt(Number(revetSelection.at));
|
||||
if (!picked) return null;
|
||||
const owner = picked.culvert ? picked : ownerOf(picked);
|
||||
const here = section.culvert ? section : ownerOf(section);
|
||||
if (!owner || !here || Math.abs(here.chainage_m - owner.chainage_m) > 0.01) return null;
|
||||
return wallStandsAt(owner, section, revetSelection.key) ? revetSelection.key : null;
|
||||
},
|
||||
select: (chainageM, key) => {
|
||||
if (key) revetSelected.set(chainageM.toFixed(2), key);
|
||||
else revetSelected.delete(chainageM.toFixed(2));
|
||||
revetSelection = key ? { at: chainageM.toFixed(2), key } : null;
|
||||
revetOffsetControl.onSelectionChange?.();
|
||||
},
|
||||
syncApplied: (chainageM, role, applied) => {
|
||||
// 기하가 실제로 적용한 값(한계 절삭 후)을 되받아 담는다 — 숫자만 커지는 것 방지.
|
||||
@@ -506,6 +531,7 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
deps.projectId,
|
||||
() => deps.sessionKey("culvertopt"),
|
||||
deps.onSaveError,
|
||||
() => deps.sessionKey("culvertmove"),
|
||||
);
|
||||
const linkSession = createLinkFlagSession(() => deps.sessionKey("revetlink"));
|
||||
const linkDetached = linkSession.detached;
|
||||
@@ -650,8 +676,36 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
},
|
||||
};
|
||||
|
||||
/** 종단경사 반영 여부 — 세션 → 정본 → 기본 켬. 소유 측점에 하나다. */
|
||||
const followGradeOf = (section: CrossSection): boolean => {
|
||||
const session = followGrades.get(chainageKey(section.chainage_m));
|
||||
if (session !== undefined) return session;
|
||||
return section.design?.revet_follow_grade !== false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 연동 해제로 **그 측점에만** 붙어 있던 4축 값을 지운다 — 경사 해제(수평)로 넘어갈 때
|
||||
* 쓴다(2026-08-30 사용자: 해제해 둔 값은 되살리지 않는다). 옛 D경로 독립 벽(`own`)은
|
||||
* 이 구조물 것이 아니라 남긴다.
|
||||
*/
|
||||
const dropLinkedAdjusts = (chainageM: number): void => {
|
||||
const prefix = `${chainageM.toFixed(2)}:`;
|
||||
for (const key of [...revetShifts.keys()]) {
|
||||
if (!key.startsWith(prefix) || key.slice(prefix.length) === "own") continue;
|
||||
revetShifts.delete(key);
|
||||
}
|
||||
persistRevetShifts();
|
||||
const design = sectionAt(chainageM)?.design;
|
||||
if (!design?.revet_adjust) return;
|
||||
const own = design.revet_adjust.own;
|
||||
design.revet_adjust = own ? { own } : {};
|
||||
};
|
||||
|
||||
const revetLinkControl: RevetLinkControl = {
|
||||
linkedFor: (section) => {
|
||||
// 경사 해제(수평)면 연동은 잠긴다 — 측점마다 자리를 따로 잡으면 기슭막이 Z를
|
||||
// 하나로 묶을 수 없다(2026-08-30 사용자).
|
||||
if (!followGradeOf(ownerOf(section) ?? section)) return true;
|
||||
const session = linkDetached.get(chainageKey(section.chainage_m));
|
||||
if (session !== undefined) return !session;
|
||||
return section.design?.revet_link_detached !== true;
|
||||
@@ -662,34 +716,26 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
patchCachedDesign(chainageM, { revet_link_detached: !linked });
|
||||
deps.refreshCard(chainageM);
|
||||
},
|
||||
followGradeFor: (section) => {
|
||||
const session = followGrades.get(chainageKey(section.chainage_m));
|
||||
if (session !== undefined) return session;
|
||||
return section.design?.revet_follow_grade !== false;
|
||||
},
|
||||
followGradeFor: followGradeOf,
|
||||
setFollowGrade: (section, follow) => {
|
||||
followGrades.set(chainageKey(section.chainage_m), follow);
|
||||
persistLinkFlags();
|
||||
patchCachedDesign(section.chainage_m, { revet_follow_grade: follow });
|
||||
// 종단경사 반영은 그 기슭막이가 덮는 측점들의 표고를 바꾼다 — 소유 측점과 그
|
||||
// 연장 안의 측점만 다시 그린다.
|
||||
const spec = section.culvert;
|
||||
const reach = spec
|
||||
? Math.max(
|
||||
...[spec.inlet, spec.outlet].flatMap((side) => {
|
||||
const span =
|
||||
side?.structure === "집수정"
|
||||
? CulvertConst.basinSpanOfSpec(side)
|
||||
: CulvertConst.revetSpanOfSpec(side);
|
||||
return [span.beforeM, span.afterM];
|
||||
}),
|
||||
)
|
||||
: 0;
|
||||
const span = section.culvert ? culvertReach(section) : null;
|
||||
const reach = span ? Math.max(span.beforeM, span.afterM) : 0;
|
||||
for (const other of deps.detail()?.cross_sections ?? []) {
|
||||
if (Math.abs(other.chainage_m - section.chainage_m) <= reach + 1e-9) {
|
||||
deps.refreshCard(other.chainage_m);
|
||||
if (Math.abs(other.chainage_m - section.chainage_m) > reach + 1e-9) continue;
|
||||
// 수평으로 넘어가면 연동 해제를 되돌리고 그 측점만의 자리도 지운다
|
||||
// (2026-08-30 사용자: 경사를 다시 켜도 되살아나지 않는다).
|
||||
if (!follow && Math.abs(other.chainage_m - section.chainage_m) > 0.01) {
|
||||
linkDetached.set(chainageKey(other.chainage_m), false);
|
||||
patchCachedDesign(other.chainage_m, { revet_link_detached: false });
|
||||
dropLinkedAdjusts(other.chainage_m);
|
||||
}
|
||||
deps.refreshCard(other.chainage_m);
|
||||
}
|
||||
persistLinkFlags();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -770,6 +816,9 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
return result;
|
||||
},
|
||||
flushCulvertOptions: () => culvertOptions.flush(),
|
||||
queueCulvertOptions: (chainageM, patch) => culvertOptions.queue(chainageM, patch),
|
||||
queueCulvertMove: (fromChainageM, toChainageM) =>
|
||||
culvertOptions.queueMove(fromChainageM, toChainageM),
|
||||
load: () => {
|
||||
loadStationWidths();
|
||||
loadRevetShifts();
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Page_Structures_Panel.ts
|
||||
* B06 좌측 「구조물 배치」 — B05와 **같은 컨테이너·목록 템플릿**을 세운다
|
||||
* (2026-08-29 사용자: B05/B06 인터페이스 일원화. 페이지 본체는 700줄 제한으로
|
||||
* 여기서 조립만 받아 간다).
|
||||
*
|
||||
* · 폼·목록 구현 = 공용 진입점(`A00_Common/b_structures_section`) — B05 그대로.
|
||||
* · 구조물(B~G군) 추가·수정·삭제는 세션 미저장분(`writePendingStructures`)에 쌓고
|
||||
* [임시저장]·[확정]의 `flushPendingStructures`가 정본에 쓴다(캐시→저장 원칙).
|
||||
* · 계곡 통과 시설(A군, pipe_points 정본)은 **표시·선택만** — 추가·이동·삭제는
|
||||
* 배수유역 재분할 체인이 있는 B05 몫이라 안내만 한다(2026-08-29 1차 범위).
|
||||
* · 목록·폼 선택 → 해당 측점 카드 선택·스크롤. 횡단도 벽·구체 선택 → 폼에 그 시설
|
||||
* 로드(소유 측점 기준). 하위 [횡단 조정] 컨테이너는 Adjust_Dock이 맡는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createStructuresSection, type PipeFacilityItem } from "../A00_Common/b_structures_section";
|
||||
import {
|
||||
fetchStructures,
|
||||
fetchStructureTypes,
|
||||
readPendingStructures,
|
||||
structureAnchorM,
|
||||
writePendingStructures,
|
||||
type StructureInstance,
|
||||
} from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
adjustDockRoot,
|
||||
refreshAdjustSlots,
|
||||
resetAdjustDock,
|
||||
setAdjustSideSlots,
|
||||
} from "./B06_Section_UI_Adjust_Dock";
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import type { StationControls } from "./B06_Section_UI_Page_Station_Controls";
|
||||
|
||||
const PIPE_ADD_GUIDE = "계곡 통과 시설의 추가·삭제는 B05(종단) 화면에서 합니다.";
|
||||
const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다.";
|
||||
/** 고른 것 없이 폼만 만졌을 때 — 값이 어디로도 가지 않으므로 이유를 알린다. */
|
||||
const PIPE_PICK_GUIDE = "먼저 횡단도나 목록에서 구조물을 고르세요 — 고른 것에만 값이 반영됩니다.";
|
||||
const PIPE_MOVE_NOTICE =
|
||||
"기준 측점을 옮겼습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
|
||||
|
||||
export interface B06StructuresPanelDeps {
|
||||
projectId: string | null;
|
||||
/** 횡단 캐시 — 폼 기본값을 조정창과 **같은 원천**(culvert 스펙)에서 읽는다
|
||||
* (2026-08-29 사용자: 예전 조정창이 보여주던 값이 기본값이다). */
|
||||
detail?: () => SectionDetailResponse | null;
|
||||
/** 지금 그려진 벽 높이(m) — 배관 벽은 스펙이 아니라 기하가 정하므로 폼 기본
|
||||
* 높이도 이 값을 쓴다(조정창 표시값과 같다). */
|
||||
wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null;
|
||||
/** 지금 그려진 벽 형태 — 조정값(m)이 있으면 그쪽이 정본이라 폼도 이 값을 보인다. */
|
||||
wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null;
|
||||
/** 그 측점에서 유입·유출 벽이 각각 좌·우 어느 쪽인가(독립 기슭막이 칸 이름표). */
|
||||
wallSideLabels?: (chainageM: number) => { inlet: "좌" | "우"; outlet: "좌" | "우" } | null;
|
||||
/** 폼의 단 수를 다단 채널에 반영한다 — 조정창에서 세우던 것과 같은 자리. */
|
||||
setTierCount?: (chainageM: number, role: "inlet" | "outlet", tiers: number) => void;
|
||||
/** 지금 선 단 수(기준벽 포함) — 폼이 도면과 같은 값을 보이게 한다. */
|
||||
tierCount?: (chainageM: number, role: "inlet" | "outlet") => number | null;
|
||||
/** 측점 간격(m) — 측점번호+잔여거리 환산용. */
|
||||
stationInterval: () => number;
|
||||
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다. */
|
||||
focusChainage: (chainageM: number) => void;
|
||||
/** 폼 [수정]으로 바뀐 관 옵션을 캐시에 예약한다 — [저장]·[확정]이 정본에 쓴다
|
||||
* (2026-08-29: 조정창 구간값과 같은 경로). 없으면 안내만 한다. */
|
||||
queuePipeOptions?: (chainageM: number, patch: Record<string, number | string>) => void;
|
||||
/** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 —
|
||||
* 조정창과 같은 흐름이라 폼 조작도 도면에 바로 보인다(2026-08-29 사용자 보고). */
|
||||
applyPipeOptions?: (chainageM: number, patch: Record<string, number | string>) => void;
|
||||
/** 기준 측점 이동을 예약한다 — 세부 배수유역 재분할은 [저장]·[확정] 때 서버가
|
||||
* 관 목록을 다시 받아 처리한다(2026-08-29 사용자: B06에서도 옮길 수 있어야 한다). */
|
||||
movePipe?: (fromChainageM: number, toChainageM: number) => void;
|
||||
}
|
||||
|
||||
export interface B06StructuresPanel {
|
||||
/** 「구조물 배치」 섹션(하위 [횡단 조정] 포함) — 좌측 패널에 붙인다. */
|
||||
root: HTMLElement;
|
||||
/** 배치된 구조물 목록 — 하단 고정 dock에 붙인다(B05와 같은 자리). */
|
||||
listRoot: HTMLElement;
|
||||
/** 타입 레지스트리·구조물 정본(미저장분 우선)·관 지점을 받아 채운다. */
|
||||
load: () => Promise<void>;
|
||||
/** 횡단도에서 고른 벽·구체의 시설을 폼에 올린다(null = 해제). */
|
||||
showPipeAt: (chainageM: number | null) => void;
|
||||
/** 기하가 한계로 자른 실제 값을 폼에 되돌린다 — 입력 숫자만 커지고 그림은 그대로인
|
||||
* 상태를 막는다(2026-08-29 사용자: 재질 높이 한계에 걸린 값). */
|
||||
overrideOptions: (chainageM: number, patch: Record<string, string | number>) => void;
|
||||
/** 고른 횡단도(측점)에 있는 구조물을 폼에 올린다 — 계곡 통과 시설이 먼저고,
|
||||
* 없으면 그 측점을 덮는 구조물 정본 항목. 둘 다 없으면 해제(2026-08-29). */
|
||||
showAtChainage: (chainageM: number | null) => void;
|
||||
/** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */
|
||||
currentChainage: () => number | null;
|
||||
/** 유입구 "구조"에 합쳐진 B06 유입측 형식 — 조정창 값과 맞춘다(2026-08-29 지시 5). */
|
||||
facility: {
|
||||
setInletStructure: (value: "auto" | "revet" | "I" | "L" | "U") => void;
|
||||
onInletStructureChange: (handler: (value: "auto" | "revet" | "I" | "L" | "U") => void) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/** 관 매칭 허용 오차(m) — 관은 계획선에 스냅돼 측점과 살짝 어긋난다(B05와 같은 기준). */
|
||||
const PIPE_MATCH_M = 0.51;
|
||||
|
||||
/** 이 구조물이 그 측점을 덮는가 — 점형은 근사 일치, 구간형은 시작~종료 안. */
|
||||
function coversChainage(structure: StructureInstance, chainageM: number): boolean {
|
||||
if (structure.placement === "interval" && structure.start_m != null && structure.end_m != null) {
|
||||
return chainageM >= structure.start_m - 0.01 && chainageM <= structure.end_m + 0.01;
|
||||
}
|
||||
return Math.abs(structureAnchorM(structure) - chainageM) < PIPE_MATCH_M;
|
||||
}
|
||||
|
||||
/**
|
||||
* 관 지점 옵션에 **횡단 스펙 값**을 채워 넣는다 — 조정창이 보여주던 값과 폼이
|
||||
* 어긋나지 않게 한다(2026-08-29 사용자). 옵션에 이미 값이 있으면 그대로 두고,
|
||||
* 비어 있을 때만 스펙(서버 계산·저장분)에서 가져온다.
|
||||
*/
|
||||
function withSpecDefaults(
|
||||
options: Record<string, string | number> | undefined,
|
||||
detail: SectionDetailResponse | null,
|
||||
chainageM: number,
|
||||
wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null,
|
||||
wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null,
|
||||
tierCount?: (chainageM: number, role: "inlet" | "outlet") => number | null,
|
||||
): Record<string, string | number> | undefined {
|
||||
const owner = detail?.cross_sections.find(
|
||||
(section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
const culvert = owner?.culvert;
|
||||
if (!culvert) return options;
|
||||
const merged: Record<string, string | number> = { ...(options ?? {}) };
|
||||
const put = (key: string, value: string | number | null | undefined): void => {
|
||||
if (merged[key] === undefined && value !== null && value !== undefined) merged[key] = value;
|
||||
};
|
||||
put("pipe_kind", culvert.pipe_kind);
|
||||
put("pipe_diameter_mm", Math.round(culvert.diameter_m * 1000));
|
||||
for (const [side, prefix] of [
|
||||
[culvert.inlet, "inlet"],
|
||||
[culvert.outlet, "outlet"],
|
||||
] as const) {
|
||||
put(`${prefix}_type`, side.structure);
|
||||
// 형태·높이는 **지금 그려진 값**이 이긴다 — 조정값(조작)이 저장된 옵션보다
|
||||
// 앞서기 때문이다. 저장값을 그대로 두면 폼과 그림이 갈린다(2026-08-30 사용자:
|
||||
// 배관 유출구 기슭막이 형태가 폼과 다르게 그려졌다).
|
||||
const drawnForm = wallForm?.(chainageM, prefix);
|
||||
if (drawnForm) merged[`${prefix}_revet_form`] = drawnForm;
|
||||
else put(`${prefix}_revet_form`, side.revet_form);
|
||||
const drawnHeight = wallHeight?.(chainageM, prefix);
|
||||
if (drawnHeight != null) merged[`${prefix}_revet_height_m`] = Number(drawnHeight.toFixed(1));
|
||||
else put(`${prefix}_revet_height_m`, side.revet_height_m);
|
||||
// 단 수는 다단 채널이 정본이다 — 폼이 그 값을 그대로 보인다.
|
||||
const tiers = tierCount?.(chainageM, prefix);
|
||||
if (tiers != null) merged[`${prefix}_revet_tiers`] = tiers;
|
||||
put(`${prefix}_revet_length_m`, side.revet_length_m);
|
||||
put(`${prefix}_revet_before_m`, side.revet_before_m);
|
||||
put(`${prefix}_revet_after_m`, side.revet_after_m);
|
||||
}
|
||||
put("inlet_basin_length_m", culvert.inlet.basin_length_m);
|
||||
put("inlet_basin_before_m", culvert.inlet.basin_before_m);
|
||||
put("inlet_basin_after_m", culvert.inlet.basin_after_m);
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06StructuresPanel {
|
||||
let structures: StructureInstance[] = [];
|
||||
let pipeFacilities: PipeFacilityItem[] = [];
|
||||
/** 지금 폼에 올라온 계곡 통과 시설의 누가거리 — 유입측 형식 반영 대상. */
|
||||
let currentChainageM: number | null = null;
|
||||
|
||||
/** 새 항목에 식별자를 미리 붙인다 — 저장 전에도 목록에서 고르고 지울 수 있어야
|
||||
* 하고, 서버는 빈 값일 때만 새로 발급한다(B05 다리와 같은 규칙). */
|
||||
const withLocalIds = (next: StructureInstance[]): StructureInstance[] =>
|
||||
next.map((item) =>
|
||||
item.structure_id ? item : { ...item, structure_id: crypto.randomUUID().replace(/-/g, "") },
|
||||
);
|
||||
|
||||
const section = createStructuresSection({
|
||||
onChange: (next) => {
|
||||
structures = withLocalIds(next);
|
||||
section.setStructures(structures);
|
||||
if (deps.projectId) writePendingStructures(deps.projectId, structures);
|
||||
},
|
||||
onSelect: (structure) => {
|
||||
if (structure) deps.focusChainage(structureAnchorM(structure));
|
||||
},
|
||||
getInterval: deps.stationInterval,
|
||||
onPipeAdd: () => showToast(PIPE_ADD_GUIDE, "error"),
|
||||
// 값 수정은 B06에서도 받는다 — 조정창 구간값과 같은 저장 경로(캐시 예약 →
|
||||
// [저장]·[확정])로 보낸다. 기준점 이동만 배수유역 재분할이 걸려 B05 몫이다.
|
||||
onPipeUpdate: (fromChainageM, toChainageM, attributes) => {
|
||||
// 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는
|
||||
// 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다
|
||||
// (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다).
|
||||
const moved = Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M;
|
||||
if (moved) {
|
||||
if (deps.movePipe) {
|
||||
deps.movePipe(fromChainageM, toChainageM);
|
||||
const hit = pipeFacilities.find(
|
||||
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
if (hit) hit.chainage_m = toChainageM;
|
||||
currentChainageM = toChainageM;
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
showToast(PIPE_MOVE_NOTICE, "success");
|
||||
} else {
|
||||
showToast(PIPE_MOVE_GUIDE, "error");
|
||||
}
|
||||
}
|
||||
const patch = attributes.options;
|
||||
if (!patch || !Object.keys(patch).length) return;
|
||||
if (!deps.queuePipeOptions) {
|
||||
showToast(PIPE_ADD_GUIDE, "error");
|
||||
return;
|
||||
}
|
||||
// 단 수는 옵션이자 **조작 채널**이다 — 조정창에서 세우던 그 자리로 보내야
|
||||
// 횡단도에 단이 선다(2026-08-30 사용자 지시 3: 배수관 측점과 동일하게).
|
||||
for (const role of ["inlet", "outlet"] as const) {
|
||||
const tiers = Number(patch[`${role}_revet_tiers`]);
|
||||
if (Number.isFinite(tiers)) deps.setTierCount?.(fromChainageM, role, tiers);
|
||||
}
|
||||
// 옵션은 **옮기기 전 자리**로 예약한다 — 저장 시점의 관 목록이 그 자리 기준이다.
|
||||
deps.queuePipeOptions(fromChainageM, patch as Record<string, number | string>);
|
||||
deps.applyPipeOptions?.(fromChainageM, patch as Record<string, number | string>);
|
||||
// 화면 캐시(목록·폼 재로드용)도 같이 맞춘다 — 저장 전에도 값이 유지된다.
|
||||
const hit = pipeFacilities.find(
|
||||
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
if (hit) hit.options = { ...(hit.options ?? {}), ...patch };
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
},
|
||||
onPipeRemove: () => showToast(PIPE_ADD_GUIDE, "error"),
|
||||
onPipeSelect: (chainageM) => {
|
||||
if (chainageM !== null) deps.focusChainage(chainageM);
|
||||
},
|
||||
});
|
||||
|
||||
// 고른 것 없이 좌측 폼을 만지면 값이 아무 데도 가지 않는다(패널 실시간 반영이
|
||||
// 선택된 항목에만 걸린다) — 조용히 무시되던 자리라 이유를 알린다(2026-08-30 사용자:
|
||||
// "형태에 따라 모양을 넣어주는 기능이 누락되는 경우가 있어"의 원인).
|
||||
section.facility.root.addEventListener("change", () => {
|
||||
if (!section.hasSelection()) showToast(PIPE_PICK_GUIDE, "error");
|
||||
});
|
||||
|
||||
// 하위 [횡단 조정] 컨테이너 — 폼 본문 맨 아래, 기존 항목과 경계 구분(2026-08-29
|
||||
// 사용자: 병합 항목은 하위 컨테이너로 담아 혼동 방지).
|
||||
resetAdjustDock();
|
||||
section.root.querySelector(".b05-route__panel-body")?.append(adjustDockRoot());
|
||||
// 단 수·옵션 행은 유입구·유출구 그룹으로 옮겨 붙는다(2026-08-29 지시 4).
|
||||
setAdjustSideSlots({
|
||||
inlet: section.facility.inletSlot,
|
||||
outlet: section.facility.outletSlot,
|
||||
extra: section.facility.extraSlot,
|
||||
revetInlet: section.facility.revetInletSlot,
|
||||
revetOutlet: section.facility.revetOutletSlot,
|
||||
});
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (!deps.projectId) return;
|
||||
const projectId = deps.projectId;
|
||||
try {
|
||||
const [types, stored, pipeResponse] = await Promise.all([
|
||||
fetchStructureTypes(),
|
||||
fetchStructures(projectId),
|
||||
fetchDetailPipePoints(projectId),
|
||||
]);
|
||||
section.setTypes(types);
|
||||
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(B05와 같은 규칙).
|
||||
structures = readPendingStructures(projectId) ?? stored.structures;
|
||||
section.setStructures(structures);
|
||||
const detail = deps.detail?.() ?? null;
|
||||
pipeFacilities = pipeResponse.pipe_points.map((pipe) => ({
|
||||
chainage_m: pipe.chainage_m,
|
||||
facility: pipe.facility ?? "pipe",
|
||||
start_m: pipe.start_m,
|
||||
end_m: pipe.end_m,
|
||||
source: pipe.source,
|
||||
// 폼 기본값 = 조정창이 쓰던 값(횡단 스펙). 저장된 옵션이 있으면 그쪽이 이긴다.
|
||||
options: withSpecDefaults(
|
||||
pipe.options,
|
||||
detail,
|
||||
pipe.chainage_m,
|
||||
deps.wallHeight,
|
||||
deps.wallForm,
|
||||
deps.tierCount,
|
||||
),
|
||||
design_flow_m3s: null,
|
||||
}));
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 폼에 올리기 직전에 좌·우 이름표를 그 측점 방향으로 맞춘다(독립 기슭막이 칸). */
|
||||
function syncSideLabels(chainageM: number): void {
|
||||
const labels = deps.wallSideLabels?.(chainageM);
|
||||
if (labels) section.facility.setRevetSideLabels(labels);
|
||||
}
|
||||
|
||||
/** 폼에 올리기 직전에 그 관의 옵션을 최신 스펙으로 보강한다(빈 칸만 채운다). */
|
||||
function refreshSpecDefaults(chainageM: number): void {
|
||||
const hit = pipeFacilities.find(
|
||||
(entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
if (!hit) return;
|
||||
syncSideLabels(hit.chainage_m);
|
||||
hit.options = withSpecDefaults(
|
||||
hit.options,
|
||||
deps.detail?.() ?? null,
|
||||
hit.chainage_m,
|
||||
deps.wallHeight,
|
||||
deps.wallForm,
|
||||
deps.tierCount,
|
||||
);
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
}
|
||||
|
||||
return {
|
||||
root: section.root,
|
||||
listRoot: section.listRoot,
|
||||
load,
|
||||
currentChainage: () => currentChainageM,
|
||||
facility: {
|
||||
setInletStructure: (value) => section.facility.setInletStructure(value),
|
||||
onInletStructureChange: (handler) => section.facility.onInletStructureChange(handler),
|
||||
},
|
||||
overrideOptions: (chainageM, patch) => {
|
||||
const hit = pipeFacilities.find(
|
||||
(entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
if (!hit) return;
|
||||
hit.options = { ...(hit.options ?? {}), ...patch };
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
// 폼을 그 값으로 다시 세운다 — 조정창이 한계를 알려 주던 것과 같은 자리다.
|
||||
section.selectPipeByChainage(hit.chainage_m);
|
||||
},
|
||||
showPipeAt: (chainageM) => {
|
||||
currentChainageM = chainageM;
|
||||
if (chainageM !== null) refreshSpecDefaults(chainageM);
|
||||
section.selectPipeByChainage(chainageM);
|
||||
},
|
||||
showAtChainage: (chainageM) => {
|
||||
currentChainageM = null;
|
||||
if (chainageM === null) {
|
||||
section.selectById(null);
|
||||
return;
|
||||
}
|
||||
// 계곡 통과 시설이 먼저다 — 같은 측점에 구조물 정본 항목이 겹쳐도 횡단도에
|
||||
// 그려지는 것은 이쪽이다.
|
||||
const pipe = pipeFacilities.find(
|
||||
(entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
if (pipe) {
|
||||
currentChainageM = pipe.chainage_m;
|
||||
refreshSpecDefaults(pipe.chainage_m);
|
||||
section.selectPipeByChainage(pipe.chainage_m);
|
||||
return;
|
||||
}
|
||||
const hit = structures.find((entry) => coversChainage(entry, chainageM));
|
||||
section.selectById(hit?.structure_id ?? null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 횡단도 선택 → 좌측 폼 연동 배선. 벽(기슭막이)·세월교·BOX 선택이 일어나면 그
|
||||
* 구조물의 **소유 측점** 시설을 좌측 폼에 올린다(2026-08-29 일원화 3단계).
|
||||
* 제어 객체의 select를 감싸기만 한다 — 조작·저장 경로는 그대로다.
|
||||
*/
|
||||
/** 지금 처리 중인 선택이 **도면에서 벽·구체를 찍은 것**인가 — 카드 선택 리스너가
|
||||
* 이 값으로 "카드만 고른 경우"를 가려낸다(2026-08-29 사용자: 측점 선택 때 구조물이
|
||||
* 자동으로 골라지면 안 된다). 같은 턴에서만 참이다. */
|
||||
let wallSelecting = false;
|
||||
|
||||
export function isWallSelecting(): boolean {
|
||||
return wallSelecting;
|
||||
}
|
||||
|
||||
function markWallSelecting(): void {
|
||||
wallSelecting = true;
|
||||
queueMicrotask(() => {
|
||||
wallSelecting = false;
|
||||
});
|
||||
}
|
||||
|
||||
export function wireStructureSelection(
|
||||
stationControls: StationControls,
|
||||
detail: () => SectionDetailResponse | null,
|
||||
panel: B06StructuresPanel,
|
||||
): { syncInletStructure: () => void } {
|
||||
const ownerChainageOf = (chainageM: number): number => {
|
||||
const sectionAt = detail()?.cross_sections.find(
|
||||
(entry) => Math.abs(entry.chainage_m - chainageM) < 0.01,
|
||||
);
|
||||
return (sectionAt && stationControls.structureSpan.ownerOf(sectionAt)?.chainage_m) ?? chainageM;
|
||||
};
|
||||
|
||||
// 유입구 "구조" ↔ B06 유입측 형식 — 폼에서 고르면 조정값에 반영하고, 시설을 폼에
|
||||
// 올릴 때는 조정값을 폼으로 되읽는다(2026-08-29 지시 5 병합).
|
||||
const sectionAt = (chainageM: number) =>
|
||||
detail()?.cross_sections.find((entry) => Math.abs(entry.chainage_m - chainageM) < 0.01);
|
||||
panel.facility.onInletStructureChange((value) => {
|
||||
const chainageM = panel.currentChainage();
|
||||
if (chainageM === null) return;
|
||||
stationControls.inletStructure.set(chainageM, value);
|
||||
});
|
||||
/** 폼에 올라온 시설의 유입측 형식을 폼 드롭다운에 되싣는다. */
|
||||
const syncInletStructure = (): void => {
|
||||
const chainageM = panel.currentChainage();
|
||||
const target = chainageM === null ? undefined : sectionAt(chainageM);
|
||||
if (target) panel.facility.setInletStructure(stationControls.inletStructure.valueFor(target));
|
||||
};
|
||||
|
||||
const origRevet = stationControls.revetOffset.select;
|
||||
stationControls.revetOffset.select = (chainageM, key) => {
|
||||
markWallSelecting();
|
||||
origRevet(chainageM, key);
|
||||
panel.showPipeAt(key ? ownerChainageOf(chainageM) : null);
|
||||
if (key) syncInletStructure();
|
||||
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 목적지 칸이 그제야 선다.
|
||||
refreshAdjustSlots();
|
||||
};
|
||||
|
||||
const origFord = stationControls.ford.select;
|
||||
stationControls.ford.select = (chainageM, role) => {
|
||||
markWallSelecting();
|
||||
origFord(chainageM, role);
|
||||
panel.showPipeAt(role ? chainageM : null);
|
||||
};
|
||||
const origBox = stationControls.box.select;
|
||||
stationControls.box.select = (chainageM, role) => {
|
||||
markWallSelecting();
|
||||
origBox(chainageM, role);
|
||||
panel.showPipeAt(role ? chainageM : null);
|
||||
};
|
||||
return { syncInletStructure };
|
||||
}
|
||||
@@ -112,6 +112,12 @@ export interface SectionViewController {
|
||||
) => void;
|
||||
/** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */
|
||||
refreshCard: (chainageM: number) => void;
|
||||
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
|
||||
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). */
|
||||
focusStation: (stationId: string) => void;
|
||||
/** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을
|
||||
* 올린다(2026-08-29 일원화). null = 선택 해제. */
|
||||
setStationSelectListener: (listener: (stationId: string | null) => void) => void;
|
||||
clear: () => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
@@ -331,16 +337,36 @@ export function createSectionView(
|
||||
card?.applySelection?.(stationId === selectedStationId, activeAreaKey);
|
||||
};
|
||||
|
||||
/**
|
||||
* 연동으로 이어진 카드들의 **벽 강조**를 다시 칠한다(2026-08-30 사용자: 한 기슭막이는
|
||||
* 그 연장 안 모든 횡단도에서 같이 강조된다). 조정창은 고른 카드 하나에만 남는다.
|
||||
* 카드는 다시 만들지 않고 클래스만 갈아 끼운다 — 줌·팬 보존.
|
||||
*/
|
||||
const syncRevetHighlights = (): void => {
|
||||
if (!currentDetail || !revetOffset) return;
|
||||
for (const section of currentDetail.cross_sections) {
|
||||
const card = document.getElementById(
|
||||
`cross-${section.station_id}`,
|
||||
) as CrossCardElement | null;
|
||||
card?.applyRevetHighlight?.(revetOffset.highlightFor(section));
|
||||
}
|
||||
};
|
||||
if (revetOffset) revetOffset.onSelectionChange = syncRevetHighlights;
|
||||
|
||||
/**
|
||||
* 선택이 바뀐 흔적만 화면에 반영한다 — **재렌더 금지**.
|
||||
* 카드를 새로 만들면(전체든 한 장이든) 사용자가 맞춰 둔 휠 줌·팬(viewBox)이 초기화된다
|
||||
* (2026-08-02 사용자 지적). 선택 표시가 바뀌는 카드는 이전·현재 둘뿐이고, 그 둘도 클래스와
|
||||
* 강조 상태만 갈아 끼운다.
|
||||
*/
|
||||
/** 카드 선택 변경 수신자(좌측 구조물 폼). 페이지가 등록한다. */
|
||||
let stationSelectListener: ((stationId: string | null) => void) | null = null;
|
||||
|
||||
const applySelection = (previousStationId: string | null): void => {
|
||||
markCard(previousStationId);
|
||||
if (selectedStationId !== previousStationId) markCard(selectedStationId);
|
||||
drawPanel();
|
||||
if (selectedStationId !== previousStationId) stationSelectListener?.(selectedStationId);
|
||||
};
|
||||
|
||||
/** 같은 측점을 다시 고르면 선택을 푼다(2026-08-02 사용자 지시) — 카드·측점선 어느 쪽이든. */
|
||||
@@ -685,6 +711,13 @@ export function createSectionView(
|
||||
if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(root));
|
||||
},
|
||||
refreshCard,
|
||||
focusStation(stationId) {
|
||||
if (selectedStationId !== stationId) selectStation(stationId, true);
|
||||
else revealCard(stationId, "smooth");
|
||||
},
|
||||
setStationSelectListener(listener) {
|
||||
stationSelectListener = listener;
|
||||
},
|
||||
clear() {
|
||||
currentDetail = null;
|
||||
selectedStationId = null;
|
||||
|
||||
@@ -31,6 +31,12 @@
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
/* 스크롤 영역 상단 여유 — 첫 컨테이너(구조물 배치)가 패널 상단 경계에 붙지 않게
|
||||
B05(.b05-route__panel .ui-sidebar-scroll)와 같은 값을 쓴다(2026-08-29 일원화). */
|
||||
.b06-profile__form .ui-sidebar-scroll {
|
||||
padding-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 사이드 컨테이너 양식은 B05 경로 패널(.b05-route__panel-section)과 같게 맞춘다
|
||||
— 두 화면의 좌측 패널이 같은 폼으로 보여야 한다(2026-08-23 사용자 지시).
|
||||
여백·글자 크기·색을 B05 값으로 옮겼고, 표준단면 「변수 위치 안내」 모식도만
|
||||
|
||||
@@ -30,10 +30,12 @@
|
||||
padding: 0 var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 켜짐 색은 **주 액션 색**이다(2026-08-30 사용자: 붉은 채움이 경고처럼 보인다).
|
||||
* 경고·삭제 자리를 비워 두고, 옵션이 켜졌다는 사실만 조용히 알린다. */
|
||||
.b06-structure-panel__toggle.is-on {
|
||||
border-color: var(--color-danger);
|
||||
background: var(--color-danger);
|
||||
color: var(--color-text-inverse, #fff);
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
}
|
||||
|
||||
.b06-structure-panel.is-hidden,
|
||||
@@ -227,3 +229,193 @@
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* ── 좌측 [횡단 조정] 사본(2026-08-29 B05/B06 일원화) ─────────────────────
|
||||
* 오버레이 창과 같은 DOM을 좌측 사이드바에 흐름 배치로 세운다. */
|
||||
.b06-structure-panel--dock {
|
||||
position: static;
|
||||
z-index: auto;
|
||||
max-height: none;
|
||||
margin-top: var(--spacing-8);
|
||||
border-color: var(--color-border);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* 9키·십자 위치제어(이동 D-pad·집수정 9키)는 횡단도 오버레이 전용
|
||||
* (2026-08-29 사용자 지시 7) — 사본에서는 그 행을 통째로 숨긴다. */
|
||||
.b06-structure-panel--dock
|
||||
.b06-structure-panel__struct:has(> .b06-structure-panel__controls.b06-structure-panel__buttons),
|
||||
.b06-structure-panel--dock
|
||||
.b06-structure-panel__struct:has(
|
||||
> .b06-structure-panel__controls.b06-structure-panel__basin-buttons
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* [횡단 조정] 하위 컨테이너 — 구조물 배치 폼과 경계 구분(접이식). */
|
||||
.b06-adjust-dock {
|
||||
margin-top: var(--spacing-8);
|
||||
padding-top: var(--spacing-8);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b06-adjust-dock > summary {
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b06-adjust-dock__empty {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 횡단도 오버레이 창은 **위치제어 전용**으로 줄인다(2026-08-29 사용자 지시: 좌측
|
||||
* [횡단 조정]과 내용이 같음을 확인한 뒤 중복 삭제). 십자(이동)·9키(집수정) 버튼
|
||||
* 세트만 남기고, 값·형식·구간값 등 항목 행은 좌측 하나로 모은다. DOM·배선은 그대로라
|
||||
* 두 창의 값은 계속 같이 움직인다. */
|
||||
.b06-structure-panel:not(.b06-structure-panel--dock)
|
||||
.b06-structure-panel__struct:not(
|
||||
:has(> .b06-structure-panel__controls.b06-structure-panel__buttons)
|
||||
):not(:has(> .b06-structure-panel__controls.b06-structure-panel__basin-buttons)) {
|
||||
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,
|
||||
.b06-structure-panel--dock .b06-structure-panel__struct--inlet-kind {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 유입구·유출구 그룹으로 옮겨 붙은 행(단 수·옵션) — 그룹 안에서는 폼 여백을 따른다. */
|
||||
.b05-structure__adjust-slot:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-structure__adjust-slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* 유입구·유출구 그룹으로 옮겨 온 행(단 수·옵션) — 폼의 다른 항목과 같은 표기로
|
||||
* 맞추고 2열로 세운다(2026-08-29 사용자 지시 1). 조정창 안에서 쓰던 어두운 행
|
||||
* 양식은 여기서 벗는다. */
|
||||
.b05-structure__adjust-slot {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.b05-structure__adjust-slot .b06-structure-panel__struct {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.b05-structure__adjust-slot .b06-structure-panel__label {
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-caption);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 단 수 [-][값][+]도 폼의 숫자 조정칸과 같은 한 덩어리로 보이게 한다. */
|
||||
.b05-structure__adjust-slot .b06-structure-panel__controls {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: var(--color-surface);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.b05-structure__adjust-slot .b06-structure-panel__controls .b06-structure-panel__btn {
|
||||
width: 28px;
|
||||
height: auto;
|
||||
min-height: 24px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
/* 옮겨 온 형태 드롭다운(다단 벽) — 폼의 다른 선택칸과 같은 한 덩어리로 앉힌다. */
|
||||
.b05-structure__adjust-slot .b06-structure-panel__select {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b05-structure__adjust-slot .b06-structure-panel__hval {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-right: 1px solid var(--color-border);
|
||||
border-left: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 옵션 토글 둘은 한 덩어리 안에서 반씩 나눠 쓴다. */
|
||||
.b05-structure__adjust-slot
|
||||
.b06-structure-panel__controls.b06-structure-panel__toggles
|
||||
.b06-structure-panel__toggle {
|
||||
width: auto;
|
||||
flex: 1 1 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.b05-structure__adjust-slot
|
||||
.b06-structure-panel__controls.b06-structure-panel__toggles
|
||||
.b06-structure-panel__toggle
|
||||
+ .b06-structure-panel__toggle {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 도면에서 고른 구조물 쪽 그룹 강조(2026-08-29 사용자 지시) — 조정창 테두리와 같은
|
||||
붉은 계열로 맞춰 "지금 만지는 구조물"이 한 색으로 읽히게 한다. */
|
||||
.b05-structure__group.is-active {
|
||||
border-color: var(--color-danger);
|
||||
background: color-mix(in srgb, var(--color-danger) 8%, transparent);
|
||||
}
|
||||
|
||||
.b05-structure__group.is-active > legend {
|
||||
color: var(--color-danger);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 옮겨 온 옵션 토글(연동·경사) — 켜짐/꺼짐이 한눈에 보이게 한다(2026-08-29 사용자:
|
||||
* 활성인지 비활성인지 모르겠음). 슬롯 공통 버튼 규칙이 배경을 지워 두 상태가 같아
|
||||
* 보였다. 켜짐은 조정창과 같은 붉은 채움, 꺼짐은 흐린 글자 + 옅은 바탕. */
|
||||
.b05-structure__adjust-slot .b06-structure-panel__toggle {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b05-structure__adjust-slot .b06-structure-panel__toggle.is-on {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-text);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.b05-structure__adjust-slot .b06-structure-panel__toggle:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
---
|
||||
title: 원가 기초데이터 원천 (상용 내역 프로그램 배포자료 관측)
|
||||
category: 05_원가정보
|
||||
sources:
|
||||
|
||||
@@ -278,8 +278,21 @@ function ensureToastContainer(): HTMLDivElement {
|
||||
return toastContainer;
|
||||
}
|
||||
|
||||
/** 토스트 표시. durationMs 후 자동 소멸. */
|
||||
/** 직전에 띄운 문구와 그 소멸 시각 — 같은 문구가 연달아 오면 무시한다. */
|
||||
let lastToast: { key: string; until: number } | null = null;
|
||||
|
||||
/**
|
||||
* 토스트 표시. durationMs 후 자동 소멸.
|
||||
*
|
||||
* **같은 문구 연속 무시**(2026-08-30 사용자 지시 2) — 한계 절삭 안내처럼 한 조작에서
|
||||
* 여러 번 나오는 경고가 화면을 덮었다. 앞 토스트가 아직 떠 있는 동안 온 같은 문구는
|
||||
* 버린다. 문구가 다르거나 앞 토스트가 사라진 뒤라면 정상적으로 다시 뜬다.
|
||||
*/
|
||||
export function showToast(message: string, kind: ToastKind = "info", durationMs = 3000): void {
|
||||
const key = `${kind}::${message}`;
|
||||
const now = Date.now();
|
||||
if (lastToast && lastToast.key === key && now < lastToast.until) return;
|
||||
lastToast = { key, until: now + durationMs };
|
||||
const toast = el("div", { className: `ui-toast ui-toast--${kind}`, text: message });
|
||||
ensureToastContainer().append(toast);
|
||||
// 진입 애니메이션 트리거
|
||||
|
||||
Reference in New Issue
Block a user