Files
Aislo/B05_Profile/B05_Profile_UI_Page_Structures.ts
T
eomsangdonandClaude Opus 5.5 cb4bab7bf2 feat(구조물표): 323-5 고름 한 칸 — 관 세션 id(옮겨도 그대로 · 저장 안 함) · 고름 강조 한 규칙(pickLights · 카드 형상 · 3D 같은 함수) · 그린 것마다 신원표(data-structure-id + data-part) · 같은 자리 거리 = PIPE_MATCH_M 하나 · 322-0 이 깬 옛 시험 도움 파일 · 줄 수 고침
- `design_pipe_ids.ts` — 장부(세션 pipe-ids) · pipe-s{차례} · 자리 → id(찾는 거리 안 가장 가까운 것) · followMovedPipe 가 id 를 새 자리로(고름 id 그대로 · 순환 import 없게 design_selection 에서 옮김) · 설계 페이지가 프로젝트를 정함(usePipeIds) · 자리 이름 만들기 열 곳 · 되읽기 둘 걷음 · 측점 계획 주인 = 세션 id(withPipeIds)
- `design_pick_match.ts` — pickLights(① id ② 같은 자리 ③ 고른 부분) · paintPicked(카드 통째 형상 · 부분 · 관 계열 · 포장층은 제 손잡이) · tagShape — 관 세트 벽 · 집수정 · 관 · 세월교 측벽 · 몸체 · BOX 끝 · C군 벽 · 물넘이 콘크리트에 신원표 · 형상 판정면은 통째 형상 · 콘크리트만
- 3D — 부재 맞춤 = pickLights(옛 0.01 걷음) · C군 벽 솔리드 = 구조물 id(덮는 측점 어디서 골라도 한 번 스윕한 벽이 켜짐)
- 322-0 이 깬 것 고침 — 노면공 흐름 도움 파일(165 · 231-1) 정의 판 확인 흉내 · design_view_profile 700줄(322-0 이 704)
- 시험: test_323_5_pick(세션 id · 옮김 · 강조 규칙 · 칠하기) · 옛 시험 도움 파일 흉내 · 기대 글 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 18:06:13 +09:00

362 lines
19 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Page_Structures.ts
* 구조물·계곡 통과 시설을 화면 세 곳(사이드 목록·종단 그래프·3D)에 맞추는 다리.
*
* 화면 본체(B05_Profile_UI_Page)가 700줄 한계에 닿아 분리했다. 두 정본을 섞어
* 쓰는 자리라 상태를 여기로 옮겨 왔다 — 구조물 정본(structures.json)의 목록·판번호와,
* 관 지점 정본(pipe_points.json)에서 투영한 측점·알약이 그것이다. 본체는 이 객체의
* 메서드만 부르고 목록은 `irregularStations()`로 읽는다.
*
* 저장 규칙은 종전 그대로다 — 사이드 목록이 바뀌면 곧바로 서버 정본에 저장하고,
* 판번호가 밀리면(다른 창이 먼저 저장) 최신본을 받아 화면을 맞추고 사용자에게 알린다.
* ========================================================================== */
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,
dropStaleStructureTypes,
fetchStructureTypes,
staleDefinitionNotice,
saveStructures,
readPendingStructures,
writePendingStructures,
StructureConflictError,
type StructureInstance,
type StructureType,
} from "./B05_Profile_Api_Structures";
import 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 { followedStation } from "./B05_Profile_UI_Profile_Alignment";
import { followSnapped } from "./B05_Profile_UI_Structures_Snap";
import { plantsStation } from "./B05_Profile_UI_Profile_Structures";
import { listPick } from "../common_util/common_util_structure_catalog";
import { planStations } from "../common_util/common_util_structure_station";
import { plannedIrregular } from "../A00_Common/design/design_structure_status";
import { pipeChainageOf, pipeIdAt, withPipeIds } from "../A00_Common/design/design_pipe_ids";
/** 관 지점 한 건 — 유효직경·시설 종류·부속 옵션까지 그대로 받는다. */
export interface PipeProjection {
chainage_m: number;
effective_diameter_mm: number | null;
facility: PipeFacility;
options?: Record<string, string | number>;
/** 독립 기슭막이 구간 — 시작 · 끝도 측점을 심는다(272 · 서버와 같은 규칙). */
start_m?: number | null;
end_m?: number | null;
}
export interface StructuresBridgeDeps {
projectId: string;
/** 사이드 패널 — 생성 순서상 나중에 만들어지므로 게터로 받는다. */
panel: () => ReturnType<typeof createRoutePanel>;
profilePanel: () => ReturnType<typeof createRouteProfilePanel>;
/** 3D 측점 세로선 다시 그리기 — 상세가 아직 없으면 본체가 알아서 건너뛴다. */
renderStationLines: () => void;
/** 복원 중에는 서버로 저장하지 않는다(되살린 값을 그대로 되쓰지 않기 위함). */
isRestoring: () => boolean;
}
export function createStructuresBridge(deps: StructuresBridgeDeps) {
/** 비정규 측점(그래프 세로선·3D 라벨) — 측점 계획 한 벌(관 · 측점 심는 구조물 · 322-3). */
let irregularStations: IrregularStation[] = [];
/** [초기선 복원] 등에서 그래프의 배관 투영만 지운다 — 관 정본은 건드리지 않는다. */
function clearProjectedStations(): void {
planPipes = [];
irregularStations = [];
deps.renderStationLines();
deps.profilePanel().setIrregularStations([]);
}
/** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */
function applyIrregularStations(stations: IrregularStation[]): void {
// 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 옮기거나 지운다.
// 옮김 = 0.006m 안 새 측점(서버 0.01m 반올림) · 같은 id · 사라진 것 하나와 새것 하나(옮긴 관 · 213-2)
// — 세션 초안도 새 키로 바뀌어 옛 키가 다시 안 나감. 나머지는 지움.
const nextKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3)));
const oldKeys = new Set(irregularStations.map((station) => station.chainage_m.toFixed(3)));
const gone = irregularStations.filter(
(station) => !nextKeys.has(station.chainage_m.toFixed(3)),
);
const born = stations.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3)));
gone.forEach((station) => {
// 없으면 맞춘 자리의 새 측점 — 옛 소수 자동 맞춤이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1 · 272)
const same = followedStation(station, gone, born) ?? followSnapped(station, born);
deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m);
});
irregularStations = stations;
deps.renderStationLines();
deps.profilePanel().setIrregularStations(stations);
// 배수유역도로 되먹이지 않음(322-3) — 관 고침 · 지움은 폼 콜백이 배수 패널로 바로 감(관 정본이 원천)
}
/* ── 구조물 정본(structures.json) ────────────────────────────────────
* 목록이 바뀌면 **세션 초안**에 담고(`writePending`), 정본에는 [저장]·[확정]에서만
* 쓴다(`saveStructuresIfDirty`). 판번호가 밀리면(다른 창이 먼저 저장) 최신본을
* 받아 화면을 맞추고 사용자에게 알린다.
* (옛 주석은 「곧바로 서버에 저장한다」였다 — 2026-08-29 에 초안 방식으로 바뀌었고
* 주석만 남아 있었다. 2026-09-06 정정.) */
let structureRevision = 0;
let structureSaving: Promise<void> = Promise.resolve();
/* ── 횡단배수(A군) 측점 세로선 ──────────────────────────────────────────
* 횡단배수 시설은 계획선을 그 지점에 물리므로 그래프에 세로 점선과 계획고 틸팅
* 버튼(▲▼)이 있어야 한다(2026-08-17 사용자 지시). 대상은 관 정본(배수관·BOX암거·
* 물넘이·세월교)에 더해 수동 A군(노출형 횡단수로·개거)까지다. */
/** 측점 계획 입력 — 관(배수 패널이 알려 줌) · 종류 표(정의 기본값 · 카드 이름). */
let planPipes: PipeProjection[] = [];
let planTypes: StructureType[] = [];
let structureTypeMap = new Map<
string,
{ type_id: string; group: string; name: string; placement: string; managed_by: string | null }
>();
/** 그래프 · 3D 측점 = 측점 계획 한 벌(322-3 · 카드 · B06 종단 · 서버와 같은 목록 · 같은 m 하나 · 관이 먼저).
* 종류 표를 받기 전이면 미룸(받으면 다시 맞춤). */
function syncCrossDrainStations(): void {
if (!planTypes.length) return;
const interval = deps.panel().values().stationInterval || 20;
applyIrregularStations(
plannedIrregular(planStations(withPipeIds(planPipes), ownStructures, planTypes), interval),
);
}
/** 알약 id의 누가거리 — 구조물이면 정본에서, 관이면 id 에서 되읽는다(없으면 null).
* 3D·사이드 목록과 마찬가지로 **종단 알약 선택도 B06 으로 이어져야** 해서 둔다
* (2026-09-06: 알약만 `structure-pick` 을 안 써 B06 이 못 잡았음). */
function markChainage(structureId: string | null): number | null {
if (!structureId) return null;
const pipe = pipeMarkChainage(structureId);
if (pipe !== null) return pipe;
const found = ownStructures.find((item) => item.structure_id === structureId);
if (!found) return null;
const chainage = found.chainage_m ?? found.start_m ?? null;
return typeof chainage === "number" && Number.isFinite(chainage) ? chainage : null;
}
/** 알약 id가 계곡 통과 시설(관 정본)이면 그 누가거리, 아니면 null. */
function pipeMarkChainage(structureId: string | null): number | null {
return structureId ? pipeChainageOf(structureId) : null; // 관 세션 id 장부(323-5)
}
/** 그래프 알약 레인에 올릴 목록 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합친다.
* 자동 배수관도 세로 점선이 아니라 같은 알약으로 나온다(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: pipeIdAt(pipe.chainage_m), // 관 세션 id(323-5)
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,
}));
}
/* ── 미저장 조작분 세션 보관(2026-08-29 — CLAUDE.md 5장) ──────────────
* 조작은 화면과 세션에만 남기고 영구저장은 [저장]·[확정]에서만 한다. 세션에 두는
* 이유는 B06을 다녀오면 B05가 다시 그려져 메모리 값이 사라지기 때문이다. */
const readPending = (): StructureInstance[] | null => readPendingStructures(deps.projectId);
const writePending = (next: StructureInstance[] | null): void =>
writePendingStructures(deps.projectId, next);
/** 새 항목에 식별자를 미리 붙인다. 저장 전에도 목록에서 고르고 지울 수 있어야 하고
* (식별자가 null이면 여러 건이 서로 구분되지 않는다), 서버는 빈 값일 때만 새로
* 발급하므로 이 값이 그대로 정본 식별자가 된다(`_Structures_Repository.py:118`). */
function withLocalIds(next: StructureInstance[]): StructureInstance[] {
return next.map((item) =>
item.structure_id ? item : { ...item, structure_id: crypto.randomUUID().replace(/-/g, "") },
);
}
function applyStructures(next: StructureInstance[]): void {
ownStructures = withLocalIds(next);
syncGraphStructures();
syncCrossDrainStations();
// 복원 중 호출은 사용자 조작이 아니다 — 미저장 표시를 남기지 않는다.
if (!deps.isRestoring()) {
deps.panel().structures.setStructures(ownStructures);
writePending(ownStructures);
}
}
/** [저장]·[확정]에서만 부른다 — 미저장분이 있으면 정본에 한 번에 쓴다. */
async function saveStructuresIfDirty(): Promise<void> {
if (readPending() === null) return;
// 저장 요청이 겹치면 판번호가 어긋나므로 앞의 저장이 끝난 뒤에 보낸다.
// 보낼 것 = 세션 초안 — 횡단 보기에서 고친 조작도 이 초안에 있다(이 보기의 `ownStructures` 는
// 횡단 보기 고침을 모른다 · 안 그러면 횡단 보기에서 한 지정 · 해제가 [저장]에서 사라짐).
structureSaving = structureSaving.then(() => persistStructures(readPending() ?? ownStructures));
await structureSaving;
}
/** 서버 정본을 다시 받아 화면(사이드 목록·그래프 마크)을 그 상태로 맞춘다. */
async function refreshStructuresFromServer(): Promise<boolean> {
const stored = await fetchStructures(deps.projectId).catch(() => null);
if (!stored) return false;
writePending(null);
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 {
// 판번호는 **쓰기 직전에** 서버에서 다시 받는다. 진입 때 받은 값으로 보내면,
// 화면이 떠 있는 동안 정본이 한 번이라도 바뀌었을 때(다른 창 저장·노선 재계산)
// 409 로 거절되고 아래 실패 처리가 초안을 지워 **사용자가 넣은 구조물이 통째로
// 사라졌다**(2026-09-06 실측: B06에서 구조물을 넣고 [저장]해도 정본에 안 남음).
const current = await fetchStructures(deps.projectId).catch(() => null);
const saved = await saveStructures(
deps.projectId,
current ? current.revision : structureRevision,
next,
);
structureRevision = saved.revision;
writePending(null);
// 서버가 새 항목에 식별자를 붙이므로 그 결과로 화면 목록을 맞춘다.
await refreshStructuresFromServer();
// 구조물이 바뀌면 B06 이후를 다시 돌려야 한다. 그 표시를 서버가 못 남겼다면
// 화면상 "완료"인 뒤 단계가 옛 구조물로 만든 결과라는 뜻이라 사용자가 알아야 한다.
if (saved.needs_downstream_invalidation && !saved.invalidated_downstream) {
showToast(
"구조물은 저장되었지만 이후 단계(횡단·수량) 재작업 표시에 실패했습니다. " +
"B06을 다시 실행해 주세요.",
"error",
);
}
} catch (error) {
// 실패해도 **초안은 지우지 않는다** — 지우면 사용자가 넣은 구조물이 사라진다
// (2026-09-06 정정). 화면 목록도 그대로 두고 다시 [저장]하면 된다.
if (error instanceof StructureConflictError) {
showToast(
"다른 창에서 구조물이 먼저 저장돼 이번 저장을 건너뛰었습니다. 다시 [저장]해 주세요.",
"error",
);
return;
}
showToast(error instanceof Error ? error.message : "구조물 저장에 실패했습니다.", "error");
}
}
/** 진입·새로고침 때 타입 레지스트리와 구조물 정본을 받아 화면에 채운다. */
async function loadStructures(): Promise<void> {
try {
// 레지스트리는 **한 번만** 받고 쓰는 자리에 따라 나눈다(2026-09-07).
// · 고르기 목록 — 켜진 종류만(B05 는 종전대로 여섯 군).
// · 목록 이름 · 폼 · 알약 레인 · 군 판정 — **전부**. B06 에서 넣은 B군(측구·맹암거 등)은
// 레지스트리에서 꺼져 있는데, 켜진 것만 주면 이름을 못 찾아 영문 키 · 「?」 알약으로 뜬다.
await dropStaleStructureTypes(); // M02 에서 정의 판이 바뀌었으면 다시 받음(322-0)
const [allTypes, stored] = await Promise.all([
fetchStructureTypes(true),
fetchStructures(deps.projectId),
]);
deps.panel().structures.setTypes(allTypes, listPick("b05")); // 표 목록 both(322-1)
deps.profilePanel().setStructureTypes(allTypes);
planTypes = allTypes;
// A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다.
structureTypeMap = new Map(
allTypes.map((type) => [
type.type_id,
{
type_id: type.type_id,
group: type.group,
name: type.name,
placement: type.placement,
managed_by: type.managed_by,
},
]),
);
structureRevision = stored.revision;
const note = staleDefinitionNotice(deps.projectId, stored.definition_version); // 322-2
if (note) showToast(note);
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29).
const pending = readPending();
ownStructures = pending ?? stored.structures;
deps.panel().structures.setStructures(ownStructures);
syncGraphStructures();
syncCrossDrainStations();
} catch (error) {
showToast(
error instanceof Error ? error.message : "구조물 정보를 불러오지 못했습니다.",
"error",
);
}
}
return {
/** 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19).
* 목록은 사이드 패널 군 · 종류 드롭다운 그대로(129-1). */
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,
structureGroups: () => deps.panel().structures.pickGroups(),
onPick: (chainage, typeId) => deps.panel().structures.addAt(chainage, typeId),
});
},
/** 그래프·3D가 쓰는 현재 비정규 측점 목록(물넘이포장 제외 — 알약 레인 몫). */
irregularStations: () => irregularStations,
/** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */
typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group,
/** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */
setPipes(pipes: PipeProjection[]): void {
planPipes = [...pipes];
syncCrossDrainStations();
pipeMarks = pipesToMarks(pipes);
syncGraphStructures();
},
/** [초기선 복원] — 그래프의 배관 투영만 지운다(관 정본은 배수유역이 맡는다). */
clearProjectedStations,
/** 알약 식별자에서 관 누가거리를 되읽는다(관이 아니면 null). */
pipeMarkChainage,
/** 측점 없는 관 알약(물넘이포장)인가 — 고름 값에 알약 id 를 남길지 가름(요구 178). */
stationlessPipeMark: (structureId: string | null): boolean => {
const mark = pipeMarks.find((entry) => entry.structure_id === structureId);
return !!mark && !plantsStation(mark, []);
},
/** 알약 식별자의 누가거리(구조물·관 공통). B06 으로 넘길 선택값을 만들 때 쓴다. */
markChainage,
/** 사이드 목록이 바뀜 — 화면을 맞추고 서버 정본에 저장한다. */
applyStructures,
saveStructuresIfDirty,
/** 서버 정본을 다시 받아 화면을 그 상태로 맞춘다. */
refreshFromServer: refreshStructuresFromServer,
/** 진입·새로고침 — 타입 레지스트리와 구조물 정본을 받아 화면을 채운다. */
load: loadStructures,
};
}