- C군 기본값 확정(2026-08-19 사용자): 높이 2.5·길이 10·전길이 5 — 기본값이 생겨 required 해제. 기준 측점만 넣으면 범위가 바로 계산된다. - 드래그 민감도 완화: 종단 알약(누적→변위 14px, 임계 전 시각 이동 금지)·배수유역도 관 마커(이동 시작 8px 임계) — 고르려다 옮겨지는 문제. - 3D 노선 우클릭 → 구조물 배치 메뉴(신규 B05_Profile_UI_Viewer_Menu) — 지형 픽을 노선 폴리라인에 투영(15m 이내), 그래프·배수유역도와 같은 2단 메뉴·addAt 경로. - 우클릭 메뉴 전역 단일화(ui_template_context_menu): 새 메뉴가 열리면 다른 영역 메뉴는 닫히고, 좌클릭·중간버튼은 어디서든 닫는다(캡처 단계). - 3D 비정규 측점(배관 등) 좌표를 노선 폴리라인 chainage 투영으로 정확화 — 규칙 측점 직선보간의 곡선 모서리 잘림(노선 이탈) 해소. - 3D 측점선(노란 띠) 도로 반폭 1.5배 연장 — 측구 방향 램프는 끝단에 따라붙음. - 검증: pytest 112 통과(정책 테스트 기본값 반영), tsc, 헤드 브라우저 저장 왕복 (기준 3+0 → 55~65·anchor 60·before 5, 목록 '배수관 1/2' 번호 표기). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
305 lines
14 KiB
TypeScript
305 lines
14 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_Page_Structures.ts
|
|
* 구조물·계곡 통과 시설을 화면 세 곳(사이드 목록·종단 그래프·3D)에 맞추는 다리.
|
|
*
|
|
* 화면 본체(B05_Profile_UI_Page)가 700줄 한계에 닿아 분리했다. 두 정본을 섞어
|
|
* 쓰는 자리라 상태를 여기로 옮겨 왔다 — 구조물 정본(structures.json)의 목록·판번호와,
|
|
* 관 지점 정본(pipe_points.json)에서 투영한 측점·알약이 그것이다. 본체는 이 객체의
|
|
* 메서드만 부르고 목록은 `irregularStations()`로 읽는다.
|
|
*
|
|
* 저장 규칙은 종전 그대로다 — 사이드 목록이 바뀌면 곧바로 서버 정본에 저장하고,
|
|
* 판번호가 밀리면(다른 창이 먼저 저장) 최신본을 받아 화면을 맞추고 사용자에게 알린다.
|
|
* ========================================================================== */
|
|
|
|
import { PIPE_DEFAULT_TYPE, pickPipeDiameter } from "@config/config_frontend";
|
|
import { showToast } from "@ui/ui_template_elements";
|
|
import { mountViewerStructureMenu } from "./B05_Profile_UI_Viewer_Menu";
|
|
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
import {
|
|
fetchStructures,
|
|
fetchStructureTypes,
|
|
saveStructures,
|
|
StructureConflictError,
|
|
type StructureInstance,
|
|
} from "./B05_Profile_Api_Structures";
|
|
import {
|
|
isPipeStation,
|
|
structureLabel,
|
|
type IrregularStation,
|
|
} from "./B05_Profile_UI_IrregularStations";
|
|
import type { createRoutePanel } from "./B05_Profile_UI_Panel";
|
|
import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
|
|
import { FACILITY_NAMES } from "./B05_Profile_UI_Page_Helpers";
|
|
|
|
/** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */
|
|
export interface PipeProjection {
|
|
chainage_m: number;
|
|
effective_diameter_mm: number | null;
|
|
facility: PipeFacility;
|
|
options?: Record<string, string | number>;
|
|
}
|
|
|
|
export interface StructuresBridgeDeps {
|
|
projectId: string;
|
|
/** 사이드 패널 — 생성 순서상 나중에 만들어지므로 게터로 받는다. */
|
|
panel: () => ReturnType<typeof createRoutePanel>;
|
|
profilePanel: () => ReturnType<typeof createRouteProfilePanel>;
|
|
/** 3D 측점 세로선 다시 그리기 — 상세가 아직 없으면 본체가 알아서 건너뛴다. */
|
|
renderStationLines: () => void;
|
|
/** 복원 중에는 서버로 저장하지 않는다(되살린 값을 그대로 되쓰지 않기 위함). */
|
|
isRestoring: () => boolean;
|
|
}
|
|
|
|
export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
|
/** 비정규 측점(그래프 세로선·3D 라벨) — 관 투영분 + A군 수동 구조물. */
|
|
let irregularStations: IrregularStation[] = [];
|
|
|
|
function pipesToStations(
|
|
pipes: Array<{
|
|
chainage_m: number;
|
|
effective_diameter_mm: number | null;
|
|
facility: PipeFacility;
|
|
}>,
|
|
): IrregularStation[] {
|
|
const interval = deps.panel().values().stationInterval || 20;
|
|
return pipes.map((pipe) => {
|
|
const station = Math.floor(pipe.chainage_m / interval);
|
|
const remainder = pipe.chainage_m - station * interval;
|
|
const label =
|
|
pipe.facility === "pipe"
|
|
? structureLabel({
|
|
structureType: "배관",
|
|
pipeType: PIPE_DEFAULT_TYPE,
|
|
diameterMm: pickPipeDiameter(PIPE_DEFAULT_TYPE, pipe.effective_diameter_mm),
|
|
})
|
|
: FACILITY_NAMES[pipe.facility];
|
|
return {
|
|
id: `pipe-${pipe.chainage_m.toFixed(2)}`,
|
|
station,
|
|
remainder,
|
|
chainage_m: pipe.chainage_m,
|
|
structure: label,
|
|
structureType: "배관",
|
|
origin: "pipe",
|
|
};
|
|
});
|
|
}
|
|
|
|
/** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */
|
|
function clearProjectedStations(): void {
|
|
pipeStations = [];
|
|
irregularStations = [];
|
|
deps.renderStationLines();
|
|
deps.profilePanel().setIrregularStations([]);
|
|
}
|
|
|
|
/** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */
|
|
function applyIrregularStations(stations: IrregularStation[]): void {
|
|
// 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 지운다.
|
|
const nextKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3)));
|
|
irregularStations
|
|
.filter((station) => !nextKeys.has(station.chainage_m.toFixed(3)))
|
|
.forEach((station) => deps.profilePanel().resetStationEdit(station.chainage_m));
|
|
irregularStations = stations;
|
|
deps.renderStationLines();
|
|
deps.profilePanel().setIrregularStations(stations);
|
|
// 좌측 구조물 폼에서 "배관" 항목을 고쳤거나 지웠으면 배수유역도까지 따라가야 한다.
|
|
// 관 목록이 그대로면 배수유역도가 아무 일도 하지 않으므로 되먹임 고리는 여기서 끊긴다.
|
|
deps
|
|
.profilePanel()
|
|
.drainage.setPipeChainages(stations.filter(isPipeStation).map((entry) => entry.chainage_m));
|
|
}
|
|
|
|
/* ── 구조물 정본(structures.json) ────────────────────────────────────
|
|
* 사이드 목록이 바뀌면 곧바로 서버 정본에 저장한다 — 화면에만 남겨 두면 새로고침에
|
|
* 사라지고, 다른 창과도 어긋난다. 판번호가 밀리면(다른 창이 먼저 저장) 최신본을
|
|
* 받아 화면을 맞추고 사용자에게 알린다. */
|
|
let structureRevision = 0;
|
|
let structureSaving: Promise<void> = Promise.resolve();
|
|
|
|
/* ── 횡단배수(A군) 측점 세로선 ──────────────────────────────────────────
|
|
* 횡단배수 시설은 계획선을 그 지점에 물리므로 그래프에 세로 점선과 계획고 틸팅
|
|
* 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거·
|
|
* 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */
|
|
let pipeStations: IrregularStation[] = [];
|
|
let structureTypeMap = new Map<string, { group: string; name: string }>();
|
|
|
|
/** A군 수동 구조물을 그래프 측점 목록으로 투영한다(관 정본 항목은 pipeStations 몫). */
|
|
function crossDrainStationsOf(structures: StructureInstance[]): IrregularStation[] {
|
|
const interval = deps.panel().values().stationInterval || 20;
|
|
return structures
|
|
.filter((structure) => structureTypeMap.get(structure.type_id)?.group === "A")
|
|
.map((structure) => {
|
|
const chainage = structure.chainage_m ?? structure.start_m ?? 0;
|
|
const station = Math.floor(chainage / interval);
|
|
return {
|
|
id: structure.structure_id ?? `structure-${chainage.toFixed(2)}`,
|
|
station,
|
|
remainder: chainage - station * interval,
|
|
chainage_m: chainage,
|
|
structure: structureTypeMap.get(structure.type_id)?.name ?? structure.type_id,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** 관 정본 + A군 수동 구조물을 합쳐 그래프·3D 측점 목록을 맞춘다. */
|
|
function syncCrossDrainStations(): void {
|
|
applyIrregularStations([...pipeStations, ...crossDrainStationsOf(ownStructures)]);
|
|
}
|
|
|
|
/** 알약 id가 계곡 통과 시설(관 정본)이면 그 누가거리, 아니면 null. */
|
|
function pipeMarkChainage(structureId: string | null): number | null {
|
|
if (!structureId?.startsWith("pipe-")) return null;
|
|
const value = Number(structureId.slice("pipe-".length));
|
|
return Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
/** 그래프 알약 레인에 올릴 목록 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합친다.
|
|
* 자동 배수관도 세로 점선이 아니라 같은 알약으로 나온다(2026-08-17 사용자 지시 1). */
|
|
let ownStructures: StructureInstance[] = [];
|
|
let pipeMarks: StructureInstance[] = [];
|
|
|
|
function syncGraphStructures(): void {
|
|
deps.profilePanel().setStructures([...ownStructures, ...pipeMarks]);
|
|
}
|
|
|
|
/** 관 지점을 알약 레인용 가상 구조물로 만든다(정본은 pipe_points, 저장하지 않는다). */
|
|
function pipesToMarks(
|
|
pipes: Array<{
|
|
chainage_m: number;
|
|
facility: PipeFacility;
|
|
options?: Record<string, string | number>;
|
|
}>,
|
|
): StructureInstance[] {
|
|
return pipes.map((pipe) => ({
|
|
structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`,
|
|
type_id: pipe.facility,
|
|
placement: "point",
|
|
chainage_m: pipe.chainage_m,
|
|
start_m: null,
|
|
end_m: null,
|
|
options: pipe.options ?? {},
|
|
memo: "",
|
|
placement_source: "automatic",
|
|
status: "draft",
|
|
revision: 0,
|
|
geometry: null,
|
|
}));
|
|
}
|
|
|
|
function applyStructures(next: StructureInstance[]): void {
|
|
ownStructures = next;
|
|
syncGraphStructures();
|
|
syncCrossDrainStations();
|
|
// 저장 요청이 겹치면 판번호가 어긋나므로 앞의 저장이 끝난 뒤에 보낸다.
|
|
structureSaving = structureSaving.then(() => persistStructures(next));
|
|
}
|
|
|
|
/** 서버 정본을 다시 받아 화면(사이드 목록·그래프 마크)을 그 상태로 맞춘다. */
|
|
async function refreshStructuresFromServer(): Promise<boolean> {
|
|
const stored = await fetchStructures(deps.projectId).catch(() => null);
|
|
if (!stored) return false;
|
|
structureRevision = stored.revision;
|
|
deps.panel().structures.setStructures(stored.structures);
|
|
ownStructures = stored.structures;
|
|
syncGraphStructures();
|
|
syncCrossDrainStations();
|
|
return true;
|
|
}
|
|
|
|
async function persistStructures(next: StructureInstance[]): Promise<void> {
|
|
if (deps.isRestoring()) return;
|
|
try {
|
|
const saved = await saveStructures(deps.projectId, structureRevision, next);
|
|
structureRevision = saved.revision;
|
|
// 서버가 새 항목에 식별자를 붙이므로 그 결과로 화면 목록을 맞춘다.
|
|
await refreshStructuresFromServer();
|
|
// 구조물이 바뀌면 B06 이후를 다시 돌려야 한다. 그 표시를 서버가 못 남겼다면
|
|
// 화면상 "완료"인 뒤 단계가 옛 구조물로 만든 결과라는 뜻이라 사용자가 알아야 한다.
|
|
if (saved.needs_downstream_invalidation && !saved.invalidated_downstream) {
|
|
showToast(
|
|
"구조물은 저장되었지만 이후 단계(횡단·수량) 재작업 표시에 실패했습니다. " +
|
|
"B06을 다시 실행해 주세요.",
|
|
"error",
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof StructureConflictError) {
|
|
await refreshStructuresFromServer();
|
|
showToast("다른 창에서 구조물이 먼저 저장되어 최신 내용으로 되돌렸습니다.", "error");
|
|
return;
|
|
}
|
|
// 저장이 거절되면 화면에만 남은 항목은 식별자가 없어 고치지도 지우지도 못한다.
|
|
// 서버 정본으로 되돌려 화면과 정본을 다시 일치시킨다(2026-08-16 크로스체크 지적 1).
|
|
await refreshStructuresFromServer();
|
|
showToast(error instanceof Error ? error.message : "구조물 저장에 실패했습니다.", "error");
|
|
}
|
|
}
|
|
|
|
/** 진입·새로고침 때 타입 레지스트리와 구조물 정본을 받아 화면에 채운다. */
|
|
async function loadStructures(): Promise<void> {
|
|
try {
|
|
const [types, stored] = await Promise.all([
|
|
fetchStructureTypes(),
|
|
fetchStructures(deps.projectId),
|
|
]);
|
|
deps.panel().structures.setTypes(types);
|
|
deps.profilePanel().setStructureTypes(types);
|
|
// A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다.
|
|
structureTypeMap = new Map(
|
|
types.map((type) => [type.type_id, { group: type.group, name: type.name }]),
|
|
);
|
|
structureRevision = stored.revision;
|
|
deps.panel().structures.setStructures(stored.structures);
|
|
ownStructures = stored.structures;
|
|
syncGraphStructures();
|
|
syncCrossDrainStations();
|
|
} catch (error) {
|
|
showToast(
|
|
error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.",
|
|
"error",
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
/** 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19).
|
|
* 타입 목록은 레지스트리 로드 후 채워지는 structureTypeMap을 그대로 쓴다. */
|
|
mountViewerMenu(
|
|
viewer: {
|
|
root: HTMLElement;
|
|
modelPointAt: (clientX: number, clientY: number) => { x: number; y: number } | null;
|
|
},
|
|
routePoints: () => ReadonlyArray<{ x: number; y: number }>,
|
|
): void {
|
|
mountViewerStructureMenu({
|
|
root: viewer.root,
|
|
modelPointAt: viewer.modelPointAt,
|
|
routePoints,
|
|
structureTypes: () =>
|
|
[...structureTypeMap].map(([type_id, info]) => ({ type_id, ...info })),
|
|
onPick: (chainage, typeId) => deps.panel().structures.addAt(chainage, typeId),
|
|
});
|
|
},
|
|
/** 그래프·3D가 쓰는 현재 비정규 측점 목록. */
|
|
irregularStations: () => irregularStations,
|
|
/** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */
|
|
setPipes(pipes: PipeProjection[]): void {
|
|
pipeStations = pipesToStations(pipes);
|
|
syncCrossDrainStations();
|
|
pipeMarks = pipesToMarks(pipes);
|
|
syncGraphStructures();
|
|
},
|
|
/** [초기선 복원] — 그래프의 배관 투영만 지운다(관 정본은 배수유역이 맡는다). */
|
|
clearProjectedStations,
|
|
/** 알약 식별자에서 관 누가거리를 되읽는다(관이 아니면 null). */
|
|
pipeMarkChainage,
|
|
/** 사이드 목록이 바뀜 — 화면을 맞추고 서버 정본에 저장한다. */
|
|
applyStructures,
|
|
/** 서버 정본을 다시 받아 화면을 그 상태로 맞춘다. */
|
|
refreshFromServer: refreshStructuresFromServer,
|
|
/** 진입·새로고침 — 타입 레지스트리와 구조물 정본을 받아 화면을 채운다. */
|
|
load: loadStructures,
|
|
};
|
|
}
|