Files
Aislo/B05_Profile/B05_Profile_UI_Profile_Structures.ts
T
eomsangdonandClaude Opus 5.5 5b351e8c68 fix(structure): B05 · B06 좌측 구조물 목록 · 하이라이트 점검 흠 고침(53-2)
- B05 목록 · 구조물 알약 누르기에 3D 강조가 안 따라옴 → 목록 고름 · 관 고름이 3D 도 그 측점으로
- 물넘이포장(측점선 없음) 목록 · 알약 · 카드 고름에 알약이 안 켜짐 → 관 알약 id 로 바로 켬
- B06 정규 측점 · 측점 사이 구조물을 고르면 카드가 켜지며 알약이 꺼짐 → 알약은 카드 뒤에 · 측점선 짝 판정이 규칙 측점 기준점도 봄
- B06 목록에서 다시 눌러 풀어도 알약이 남음 → 풂 = 알약도 끔
- B06 목록에서 푼 뒤 같은 카드 관 알약을 누르면 아무것도 안 골라짐 → 카드 다시 고름
- 알약 번호 · 툴팁 · 벌룬이 기타 손 이름 대신 「기타 N」 → 목록과 같은 이름
- 구조물집계표 기타 열 머리: 빈 이름이 「기타 구조물」 · 빈칸 글 → 「기타」(집계 건 이름과 같게)
- [저장] 뒤 고른 구조물 강조가 풀림(다시 열면 되살아나 어긋남) → 같은 id 면 그대로 고름
- 목록 줄 마우스 올림 테두리
- 시험: ui_checks/53-2_목록_강조.js · test_53_2_etc_table_name.py · test_design_page_single 판정 자리 옮김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
2026-10-06 09:16:55 +09:00

194 lines
8.9 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";
import { HIDDEN_PICK_GROUPS } from "./B05_Profile_UI_Structures_Panel_Types";
/** 우클릭 메뉴용 최소 타입 정보 — 사이드 「구조물 배치」 종류 목록과 같은 원천. */
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))].filter(
(group) => !HIDDEN_PICK_GROUPS.has(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 near = structures.filter(
(item) => Math.abs(structureAnchorM(item) - station.chainage_m) < SAME_CHAINAGE_M,
);
// 관 측점선의 짝은 관 알약(`pipe-*`)이다 — 같은 측점 다른 구조물(포장 등)을 켜면 그 알약을
// 누를 때 해제로 읽혀 안 골라졌다(52-15).
const hit = near.find((item) => String(item.structure_id ?? "").startsWith("pipe-")) ?? near[0];
return hit?.structure_id ?? null;
}
/** 측점선이 골라질 때 켤 알약 — 지금 고른 알약이 그 측점 자리면 그대로 둔다(같은 측점
* 여럿 · 24-6b). 규칙 측점(B06 카드)도 기준점 누가거리로 맞춰 본다 — 안 보면 카드가 켜지며
* 고른 구조물 알약이 꺼졌다(53-2). */
export function structureIdKeeping(
stationId: string | null,
selectedId: string | null,
stations: IrregularStation[],
structures: StructureInstance[],
detail: {
longitudinal: { stations: ReadonlyArray<{ station_id: string; chainage_m: number }> };
} | null,
): string | null {
const selected = structures.find((item) => item.structure_id === selectedId);
const at = detail?.longitudinal.stations.find(
(entry) => entry.station_id === stationId,
)?.chainage_m;
const keeps =
stationId !== null &&
!!selected &&
(stationIdAtStructure(selectedId, stations, structures) === stationId ||
(at !== undefined && Math.abs(structureAnchorM(selected) - at) < SAME_CHAINAGE_M));
return keeps ? selectedId : structureIdAtStation(stationId, stations, structures);
}
/** 알약(구조물) 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;
}