Files
Aislo/B05_Profile/B05_Profile_UI_Structures_Marks.ts
T
eomsangdonandClaude Opus 5 2028af6152 feat(B05): 구조물 컨테이너 병합 3단계 — 섹션 통합·측점 두 칸 입력·구 UI 폐기
사용자 화면 피드백 3건(2026-08-17) 반영 + PLAN 3단계(이관·폐기).

- 「구조물 배치」 단일 섹션: 구 비정규 측점 섹션을 흡수·삭제하고 「구조물
  추가」를 개칭. 위치 입력은 측점번호+잔여거리 두 칸(구 비정규 UI 방식),
  순서 = 시작 측점 → 기준 측점 → 종료 측점(점형은 기준만, 비우면 시작).
- A그룹 제어 통합: A군 종류 목록에 계곡 통과 시설(배관/BOX암거/물넘이/세월교)
  표시 — 추가는 관 지점 정본 경유(onPipeAdd, 시설 종류 = type_id), 목록에
  "배수유역 연동"으로 병합 표시·선택 동기화·삭제. 노출형 횡단수로·개거는
  수동 구조물.
- 그래프 측점 표현: 서클마크 툴팁·벌룬 위치를 누가거리에서 측점번호+잔여
  거리 표기로 변경(mountStructureMarks에 측점간격 주입).
- 구 비정규 측점 이관: POST /route/structures/migrate 신설 — 기존 Migration
  매핑(배관 제외·기성막이→기슭막이·대피로→대피소) 사용, 정본 기존 (타입,
  기준점) 점유 검사로 멱등. Page 진입 시 구 확정분을 자동 이관하고 건수를
  토스트로 알린다. TDD 라우터 테스트 4건.
- 구 UI 폐기: onIrregularChange/Select 콜백 제거, 배관 투영은 Page의
  pipesToStations()가 직접 생성(시설 종류별 라벨). 그래프 측점선 드래그·
  삭제는 배관 전용으로 단일화, 구 우클릭 항목은 레지스트리 타입으로 매핑.
  drainage addPipe(chainage, facility?) 확장 — 통합 목록에서 시설 종류를
  지정해 추가하면 재계산 요청에 실려 정본까지 간다.

tmp/tests 92건·ruff·typecheck·build 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 09:28:07 +09:00

217 lines
8.3 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Structures_Marks.ts
* 종단 그래프 위 구조물 서클마크·벌룬 오버레이.
*
* 표기 규칙 (2026-08-16 사용자 확정):
* - 배치형태와 무관하게 **서클마크 하나**로 표시한다. 구간형은 기점(시점)에 찍는다.
* - 마크를 고르면 벌룬으로 종류·위치·제원을 띄우고, 구간형은 그때만 시~종점 구간을
* 띠로 펼쳐 보여 준다(평상시에는 띠를 그리지 않는다).
*
* 그래프는 매 그리기마다 새로 만들어지므로 이 오버레이도 그때마다 다시 붙인다.
* ========================================================================== */
import {
structureAnchorM,
type StructureInstance,
type StructureType,
} from "./B05_Profile_Api_Structures";
import { formatStation } from "./B05_Profile_Util_Station";
/** 마크를 잡았다고 볼 여유(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;
/** 측점간격(m) — 위치 표기를 측점번호+잔여거리로 한다(2026-08-17 사용자 확정). */
stationIntervalM: 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(" · ");
}
/** 위치 표기 — 측점번호+잔여거리(예: 3+18.0). 누가거리 표기는 쓰지 않는다(2026-08-17). */
function positionText(structure: StructureInstance, intervalM: number): string {
return structure.placement === "interval"
? `${formatStation(structure.start_m ?? 0, intervalM)} ~ ${formatStation(structure.end_m ?? 0, intervalM)}`
: formatStation(structure.chainage_m ?? 0, intervalM);
}
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, options.stationIntervalM)}`;
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, options.stationIntervalM));
});
host.append(layer);
}
/** 고른 구조물의 값 벌룬. 유토곡선 벌룬과 같은 결(도형+텍스트)로 맞춘다. */
function buildBalloon(
structure: StructureInstance,
type: StructureType | undefined,
anchorPx: number,
bottomPx: number,
stationIntervalM: 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, stationIntervalM)} · ${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)));
});
}