Files
Aislo/B05_Profile/B05_Profile_UI_Structures_Marks.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

211 lines
7.9 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Structures_Marks.ts
* 종단 그래프 위 구조물 서클마크·벌룬 오버레이.
*
* 표기 규칙 (2026-08-16 사용자 확정):
* - 배치형태와 무관하게 **서클마크 하나**로 표시한다. 구간형은 기점(시점)에 찍는다.
* - 마크를 고르면 벌룬으로 종류·위치·제원을 띄우고, 구간형은 그때만 시~종점 구간을
* 띠로 펼쳐 보여 준다(평상시에는 띠를 그리지 않는다).
*
* 그래프는 매 그리기마다 새로 만들어지므로 이 오버레이도 그때마다 다시 붙인다.
* ========================================================================== */
import {
structureAnchorM,
type StructureInstance,
type StructureType,
} from "./B05_Profile_Api_Structures";
/** 마크를 잡았다고 볼 여유(px). 원 반지름보다 조금 넉넉히 준다. */
const GRAB_SLACK_PX = 10;
/** 마크가 겹칠 때 위로 띄우는 간격(px). 같은 자리 구조물이 서로를 가리지 않게 한다. */
const STACK_STEP_PX = 22;
const STACK_BASE_PX = 10;
export interface StructureMarksOptions {
structures: ReadonlyArray<StructureInstance>;
types: ReadonlyArray<StructureType>;
/** chainage → x(px). 그래프·테이블과 같은 매핑. */
x: (chainageM: number) => number;
/** x(px) → chainage. 마크를 끌 때 역변환. */
chainageAt: (px: number) => number;
maxChainageM: number;
selectedId: string | null;
onSelect: (structureId: string | null) => void;
onMove: (structureId: string, toChainageM: number) => void;
}
function optionSummary(structure: StructureInstance, type: StructureType | undefined): string {
if (!type) return "";
return type.options
.map((option) => {
const value = structure.options?.[option.key];
if (value === undefined || value === "") return null;
return `${option.label} ${value}${option.unit ?? ""}`;
})
.filter(Boolean)
.join(" · ");
}
function positionText(structure: StructureInstance): string {
return structure.placement === "interval"
? `${(structure.start_m ?? 0).toFixed(1)} ~ ${(structure.end_m ?? 0).toFixed(1)} m`
: `${(structure.chainage_m ?? 0).toFixed(1)} m`;
}
const SIDE_TEXT: Record<string, string> = {
left: "좌측",
right: "우측",
center: "중심",
cross: "횡단",
};
/** 같은 자리에 겹친 마크를 위로 쌓아 올릴 높이를 정한다. */
function stackOffsets(
structures: ReadonlyArray<StructureInstance>,
x: (chainageM: number) => number,
): Map<string, number> {
const offsets = new Map<string, number>();
const used: Array<{ px: number; level: number }> = [];
[...structures]
.sort((left, right) => structureAnchorM(left) - structureAnchorM(right))
.forEach((structure) => {
const px = x(structureAnchorM(structure));
const conflicts = used.filter((entry) => Math.abs(entry.px - px) < STACK_STEP_PX);
const level = conflicts.length ? Math.max(...conflicts.map((e) => e.level)) + 1 : 0;
used.push({ px, level });
offsets.set(structure.structure_id ?? "", STACK_BASE_PX + level * STACK_STEP_PX);
});
return offsets;
}
/**
* 그래프 칸에 구조물 마크 레이어를 붙인다.
*/
export function mountStructureMarks(host: HTMLElement, options: StructureMarksOptions): void {
const layer = document.createElement("div");
layer.className = "b05-structure__layer";
const typeMap = new Map(options.types.map((type) => [type.type_id, type]));
const offsets = stackOffsets(options.structures, options.x);
options.structures.forEach((structure) => {
const id = structure.structure_id;
if (!id) return;
const type = typeMap.get(structure.type_id);
const anchorPx = options.x(structureAnchorM(structure));
const bottom = offsets.get(id) ?? STACK_BASE_PX;
const selected = options.selectedId === id;
// 구간형은 고른 동안만 시~종점을 띠로 펼친다(2026-08-16 사용자 확정).
if (selected && structure.placement === "interval") {
const band = document.createElement("div");
band.className = "b05-structure__band";
const startPx = options.x(structure.start_m ?? 0);
const endPx = options.x(structure.end_m ?? 0);
band.style.left = `${Math.min(startPx, endPx)}px`;
band.style.width = `${Math.max(Math.abs(endPx - startPx), 2)}px`;
band.style.bottom = `${bottom}px`;
band.style.background = type?.style?.color ?? "#888";
layer.append(band);
}
const mark = document.createElement("button");
mark.type = "button";
mark.className = "b05-structure__mark";
mark.classList.toggle("is-selected", selected);
mark.style.left = `${anchorPx}px`;
mark.style.bottom = `${bottom}px`;
mark.style.borderColor = type?.style?.color ?? "#888";
mark.style.color = type?.style?.color ?? "#888";
mark.textContent = type?.style?.abbr ?? "?";
mark.title = `${type?.name ?? structure.type_id} ${positionText(structure)}`;
mark.dataset.structureId = id;
mark.addEventListener("click", (event) => {
event.stopPropagation();
options.onSelect(selected ? null : id);
});
attachMarkDrag(mark, structure, options);
layer.append(mark);
if (selected) layer.append(buildBalloon(structure, type, anchorPx, bottom));
});
host.append(layer);
}
/** 고른 구조물의 값 벌룬. 유토곡선 벌룬과 같은 결(도형+텍스트)로 맞춘다. */
function buildBalloon(
structure: StructureInstance,
type: StructureType | undefined,
anchorPx: number,
bottomPx: number,
): HTMLElement {
const balloon = document.createElement("div");
balloon.className = "b05-structure__balloon";
balloon.style.left = `${anchorPx}px`;
balloon.style.bottom = `${bottomPx + STACK_STEP_PX}px`;
balloon.style.borderColor = type?.style?.color ?? "#888";
const title = document.createElement("strong");
title.textContent = type?.name ?? structure.type_id;
const position = document.createElement("span");
position.textContent = `${positionText(structure)} · ${SIDE_TEXT[structure.side] ?? ""}`;
balloon.append(title, position);
const summary = optionSummary(structure, type);
if (summary) {
const detail = document.createElement("span");
detail.textContent = summary;
balloon.append(detail);
}
if (structure.memo) {
const memo = document.createElement("span");
memo.className = "b05-structure__balloon-memo";
memo.textContent = structure.memo;
balloon.append(memo);
}
return balloon;
}
/** 마크를 좌우로 끌어 위치를 옮긴다. 구간형은 길이를 유지한 채 통째로 움직인다. */
function attachMarkDrag(
mark: HTMLElement,
structure: StructureInstance,
options: StructureMarksOptions,
): void {
let dragging = false;
let movedPx = 0;
mark.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
dragging = true;
movedPx = 0;
mark.setPointerCapture(event.pointerId);
event.stopPropagation();
});
mark.addEventListener("pointermove", (event) => {
if (!dragging) return;
movedPx += Math.abs(event.movementX);
const host = mark.parentElement?.parentElement;
if (!host) return;
const localX = event.clientX - host.getBoundingClientRect().left;
mark.style.left = `${localX}px`;
});
mark.addEventListener("pointerup", (event) => {
if (!dragging) return;
dragging = false;
mark.releasePointerCapture(event.pointerId);
// 손이 떨린 정도(수 px)는 이동이 아니라 클릭으로 본다 — 고르려다 옮겨지면 곤란하다.
if (movedPx <= GRAB_SLACK_PX) return;
const host = mark.parentElement?.parentElement;
if (!host) return;
const localX = event.clientX - host.getBoundingClientRect().left;
const chainage = Math.min(Math.max(options.chainageAt(localX), 0), options.maxChainageM);
options.onMove(structure.structure_id ?? "", Number(chainage.toFixed(2)));
});
}