사용자 화면 피드백 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>
217 lines
8.3 KiB
TypeScript
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)));
|
|
});
|
|
}
|