구조물군 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>
211 lines
7.9 KiB
TypeScript
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)));
|
|
});
|
|
}
|