2026-09-02 사용자 지시 9건 반영. 화면 실조작 검증은 다음 세션 몫 (사용자 지시로 코딩까지만 진행). 1. 초기 계획선 = 전체 측점 폴리라인 — `design_ground_following_profile()` 신설. 모든 측점을 변화점으로 잡고 계획고 = 원지반고, 라운드는 R을 지정한 자리에만 (`build_curves(only_explicit=)`·`build_alignment(only_explicit_curves=)`). basis `ground_polyline`. 관 정착 선형은 폴백으로 내림. 2. 구간 쉬프트(⬆⬇) 삭제. 3. [직선화] 신설 — `B05_Profile_UI_Profile_Straighten.ts`. 두 측점의 라운드에 탄젠트한 직선으로 대체하고 사이 라운드 삭제. 직선 틸팅 시 가운데 라운드 + 양측 탄젠트 재구성. 4. [쉬프트] 신설 — 직선 구간을 기하에서 되읽어(`detectStraightRun`) 복수 선택, 최외곽 라운드 중심 기준 상·하 평행이동. 5. 방향키 조작 — 상하 0.1m 계획고, 좌우 0.1m 누가거리(구조물·비정규 측점 한정). 6. undo/redo 신설 — B05·B06 조작 세션 키 묶음 스냅샷(`_Profile_History.ts`). 버튼은 요약줄 맨 앞(최대 기울기 좌측), 21x17px. Ctrl+Z / Ctrl+Shift+Z. 7. 종단 요약줄의 횡단배수 최소고 표시 삭제(산식·편집 차단은 유지). 8. [편집 되돌리기] 버튼 삭제 — undo/redo로 대체. 9. 지형 구분 기본값 특수지형 — 패널 셀렉트와 백엔드 기본값(스키마·체인 폴백) 일치. 700줄 제한 — 패널을 `_Profile_Panel_Tools` · `_Profile_Preview` 로 분리하고 측점↔구조물 짝짓기를 `_Profile_Structures` 로 이관(842줄 → 696줄). 검증: tsc --noEmit 오류 0, ruff format/check 통과, pytest tmp/tests/ -q → 366 passed / 14 skipped / 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
164 lines
7.4 KiB
TypeScript
164 lines
7.4 KiB
TypeScript
/* =============================================================================
|
|
* 종단 테이블 구조물 배치 라인 (B05)
|
|
*
|
|
* 종단 **그래프** 위 우클릭 메뉴. 구조물 조작은 전부 그래프에서 한다 — 측점선을 끌어 옮기고,
|
|
* 우클릭으로 배관을 넣거나 지운다(2026-08-01 사용자 지시). 테이블은 값만 보여 준다.
|
|
*
|
|
* 가까운 구조물이 있으면 삭제, 없으면 그 자리에 배관 추가를 띄운다. 배관은 배수유역도의 관 지점이
|
|
* 투영된 것이라(`origin: "pipe"`), 지우면 관 지점 정본(`pipe_points.json`)이 바뀌고 세부유역도
|
|
* 함께 다시 나뉜다 — 값을 두 곳에 따로 쌓지 않기 위해서다.
|
|
* ========================================================================== */
|
|
|
|
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
|
|
import {
|
|
irregularStationId,
|
|
isPipeStation,
|
|
type IrregularStation,
|
|
} from "./B05_Profile_UI_IrregularStations";
|
|
import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures";
|
|
import { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel";
|
|
|
|
/** 우클릭 메뉴용 최소 타입 정보 — 사이드 「구조물 배치」 종류 목록과 같은 원천. */
|
|
export interface StructureMenuType {
|
|
type_id: string;
|
|
group: string;
|
|
name: string;
|
|
}
|
|
|
|
/** 사이드 「구조물 배치」와 같은 구조물군 → 종류 2단 메뉴 항목을 만든다
|
|
* (2026-08-18 우클릭 메뉴 일원화 — 종단그래프·배수유역도 공용). 항목 선택 =
|
|
* 즉시 추가가 아니라 폼 자동 지정(addAt 경로). */
|
|
export function structureGroupMenuItems(
|
|
types: ReadonlyArray<StructureMenuType>,
|
|
onPick: (typeId: string) => void,
|
|
): MapContextMenuItem[] {
|
|
const groups = [...new Set(types.map((type) => type.group))];
|
|
return groups.map((group) => ({
|
|
label: GROUP_LABELS[group] ?? group,
|
|
children: types
|
|
.filter((type) => type.group === group)
|
|
.map((type): [string, () => void] => [type.name, () => onPick(type.type_id)]),
|
|
}));
|
|
}
|
|
|
|
/** 선을 잡았다고 볼 좌우 여유(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;
|
|
}
|
|
|
|
/**
|
|
* 그래프 칸에 우클릭 메뉴를 붙인다. 그래프는 매 그리기마다 새로 만들어지므로 이 함수도
|
|
* 그때마다 다시 부른다 — 상태를 밖에 남기지 않는다.
|
|
*/
|
|
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));
|
|
// 빈 자리 — 사이드 「구조물 배치」와 같은 구조물군 → 종류 2단 메뉴(2026-08-18
|
|
// 일원화). 선택 = 폼 자동 지정(addAt 경로, A군은 임시 배치까지). 레지스트리를
|
|
// 아직 못 받았으면 구 항목(배관·기성막이 등)으로 대신한다.
|
|
const types = options.structureTypes ?? [];
|
|
const addItems: MapContextMenuItem[] = types.length
|
|
? structureGroupMenuItems(types, (typeId) => options.onAddStructureType?.(at, typeId))
|
|
: [
|
|
["배관 추가", () => options.onAddPipe(at)],
|
|
...(["기성막이", "대피로", "기타"] 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);
|
|
}
|
|
|
|
/** 같은 자리로 볼 여유(m) — 측점선과 알약은 같은 누가거리를 쓰지만 소수점이 갈린다. */
|
|
export const SAME_CHAINAGE_M = 0.51;
|
|
|
|
/**
|
|
* 측점선 id ↔ 알약(구조물) id 짝짓기.
|
|
*
|
|
* 둘은 한 구조물의 두 표시라 선택이 함께 움직여야 한다(2026-08-17 사용자 지시).
|
|
* 패널 본체가 700줄 한계라 이 짝짓기만 여기로 옮겼다(2026-09-02).
|
|
*/
|
|
export function structureIdAtStation(
|
|
stationId: string | null,
|
|
stations: IrregularStation[],
|
|
structures: StructureInstance[],
|
|
): string | null {
|
|
if (stationId === null) return null;
|
|
const prefix = irregularStationId("");
|
|
if (!stationId.startsWith(prefix)) return null;
|
|
const station = stations.find((entry) => irregularStationId(entry.id) === stationId);
|
|
if (!station) return null;
|
|
const hit = structures.find(
|
|
(item) => Math.abs(structureAnchorM(item) - station.chainage_m) < SAME_CHAINAGE_M,
|
|
);
|
|
return hit?.structure_id ?? null;
|
|
}
|
|
|
|
/** 알약(구조물) id → 측점선 id. 세로선이 없는 구조물(A군 외)이면 null. */
|
|
export function stationIdAtStructure(
|
|
structureId: string | null,
|
|
stations: IrregularStation[],
|
|
structures: StructureInstance[],
|
|
): string | null {
|
|
if (structureId === null) return null;
|
|
const structure = structures.find((item) => item.structure_id === structureId);
|
|
if (!structure) return null;
|
|
const anchor = structureAnchorM(structure);
|
|
const station = stations.find((entry) => Math.abs(entry.chainage_m - anchor) < SAME_CHAINAGE_M);
|
|
return station ? irregularStationId(station.id) : null;
|
|
}
|