Files
Aislo/B05_Profile/B05_Profile_UI_Profile_Structures.ts
T
eomsangdonandClaude Fable 5 61108f540d feat(B05): 구조물 수동 추가 프론트 — 패널·종단도 서클마크·벌룬·정본 연동
구조물군 B~G 전체를 화면에서 수동 배치할 수 있게 한다. 타입 목록·옵션 폼은
서버 레지스트리(GET /structure-types)에서 받아 그리므로 구조물이 늘어도
프론트 코드는 그대로다.

- B05_Profile_Api_Structures.ts: 레지스트리·정본 CRUD 클라이언트.
  409(판번호 충돌)를 전용 오류로 구분, 구간형 기점 앵커 헬퍼.
- B05_Profile_UI_Structures_Panel.ts: 사이드바 「구조물 추가」 섹션.
  구조물군→종류→위치(점형/구간형)·설치측·이격·옵션 동적 폼·메모.
- B05_Profile_UI_Structures_Marks.ts: 종단 그래프 서클마크 오버레이.
  전 배치형태 = 마크 하나(구간형은 기점), 선택 시 벌룬 + 구간 띠 확장
  (2026-08-16 사용자 확정 표기 규칙). 드래그 이동·겹침 스택.
- B05_Profile_UI_Style_Structures.css: 마크·띠·벌룬 스타일.
- Profile_Panel: 마크 레이어 장착·타입 주입·선택 동기화 API 추가.
  우클릭 메뉴(Profile_Structures)에 레지스트리 타입 추가 항목.
- Page: 진입 시 타입·정본 로드, 변경 즉시 정본 저장(직렬화 큐),
  409 시 최신본 재적재 안내, 그래프↔사이드 선택 양방향 동기화.
  정본 주입은 onChange를 울리지 않아 저장 루프를 차단.

tsc --noEmit 통과. 기존 배관·배수유역·종단 편집 흐름은 손대지 않았다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 22:30:36 +09:00

103 lines
5.0 KiB
TypeScript

/* =============================================================================
* 종단 테이블 구조물 배치 라인 (B05)
*
* 종단 **그래프** 위 우클릭 메뉴. 구조물 조작은 전부 그래프에서 한다 — 측점선을 끌어 옮기고,
* 우클릭으로 배관을 넣거나 지운다(2026-08-01 사용자 지시). 테이블은 값만 보여 준다.
*
* 가까운 구조물이 있으면 삭제, 없으면 그 자리에 배관 추가를 띄운다. 배관은 배수유역도의 관 지점이
* 투영된 것이라(`origin: "pipe"`), 지우면 관 지점 정본(`pipe_points.json`)이 바뀌고 세부유역도
* 함께 다시 나뉜다 — 값을 두 곳에 따로 쌓지 않기 위해서다.
* ========================================================================== */
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { isPipeStation, type IrregularStation } from "./B05_Profile_UI_IrregularStations";
/** 선을 잡았다고 볼 좌우 여유(px). 선 자체는 얇아 그대로는 집기 어렵다. */
const GRAB_SLACK_PX = 6;
export interface StructureLineOptions {
/** 테이블에 얹을 구조물 측점 목록. */
stations: ReadonlyArray<IrregularStation>;
/** chainage → x(px). 테이블·그래프와 같은 매핑을 쓴다. */
x: (chainageM: number) => number;
/** x(px) → chainage. 선을 끌 때 역변환에 쓴다. */
chainageAt: (px: number) => number;
/** 노선 총 연장(m). 이 범위를 벗어난 자리로는 옮기지 않는다. */
maxChainageM: number;
/** 선을 지울 때. */
onRemove: (station: IrregularStation) => void;
/** 빈 자리에서 우클릭해 배관을 넣을 때. */
onAddPipe: (chainageM: number) => void;
/** 빈 자리에서 우클릭해 배관 외 구조물(기성막이/대피로/기타)을 넣을 때. */
onAddStructure?: (chainageM: number, structureType: "기성막이" | "대피로" | "기타") => void;
/** 레지스트리 타입 목록(구조물군·이름). 우클릭 메뉴의 "구조물 추가" 항목이 된다. */
structureTypes?: ReadonlyArray<{ type_id: string; group: string; name: string }>;
/** 레지스트리 타입을 골라 넣을 때. */
onAddStructureType?: (chainageM: number, typeId: string) => void;
}
/** 우클릭 메뉴에 한 번에 늘어놓을 타입 수. 넘으면 메뉴가 화면을 넘긴다 — 나머지는 사이드에서. */
const MENU_TYPE_LIMIT = 12;
/**
* 그래프 칸에 우클릭 메뉴를 붙인다. 그래프는 매 그리기마다 새로 만들어지므로 이 함수도
* 그때마다 다시 부른다 — 상태를 밖에 남기지 않는다.
*/
export function mountStructureMenu(host: HTMLElement, options: StructureLineOptions): void {
const menu = createMapContextMenu("b05-profile-chart");
function clamp(chainageM: number): number {
return Math.min(Math.max(chainageM, 0), options.maxChainageM);
}
// 우클릭 — 가까운 구조물이 있으면 삭제, 없으면 그 자리에 배관을 넣는다.
host.addEventListener("contextmenu", (event) => {
if (menu.contains(event.target)) {
event.preventDefault();
return;
}
const rect = host.getBoundingClientRect();
const localX = event.clientX - rect.left;
const chainage = clamp(options.chainageAt(localX));
// 선을 그리지 않으므로 화면 거리로 가장 가까운 구조물을 찾는다.
const near = options.stations
.map((station) => ({ station, distance: Math.abs(options.x(station.chainage_m) - localX) }))
.filter((entry) => entry.distance <= GRAB_SLACK_PX)
.sort((left, right) => left.distance - right.distance)[0];
event.preventDefault();
const at = Number(chainage.toFixed(2));
// 빈 자리 — 구조물 종류별 추가 항목(2026-08-05 사용자 지시. 배관은 관 지점 정본 경유).
// 레지스트리 타입이 오면 그 목록을 쓰고(구조물군 B~G), 아직 못 받았으면 구 항목을 쓴다.
const registryItems = (options.structureTypes ?? [])
.slice(0, MENU_TYPE_LIMIT)
.map((type): [string, () => void] => [
`${type.name} 추가`,
() => options.onAddStructureType?.(at, type.type_id),
]);
const addItems: Array<[string, () => void]> = [
["배관 추가", () => options.onAddPipe(at)],
...(registryItems.length
? registryItems
: (["기성막이", "대피로", "기타"] as const).map((type): [string, () => void] => [
`${type === "기타" ? "기타 구조물" : type} 추가`,
() => options.onAddStructure?.(at, type),
])),
];
menu.open(localX, event.clientY - rect.top, [
...(near
? [
[
isPipeStation(near.station) ? "배관 삭제" : "구조물 삭제",
() => options.onRemove(near.station),
] as [string, () => void],
]
: addItems),
]);
});
host.addEventListener("pointerdown", (event) => {
if (!menu.contains(event.target)) menu.close();
});
host.append(menu.element);
}