Files
Aislo/B05_Profile/B05_Profile_UI_Structures_Marks.ts
T
eomsangdonandClaude Fable 5 0b1a66b8d1 feat(B05/B06): 입력 흐름·알약 번호·초기화 모달·전후 분할·표시 반폭 정비
- 위치 마지막 칸 Tab → 상세 옵션 첫 칸(잠금 해제보다 탭 계산이 빨라 메모로
  건너뛰던 문제). 화면 순서(위→아래) 그대로 이동.
- [초기선 복원] → "저장선 복원"으로 문구 교정 — 복원 목표는 마지막 저장선이다
  (임시저장 시 편집이 정본에 반영). 최초 자동 계산 복귀는 [초기화] 안내.
- 종단 알약에 좌측 목록과 같은 중복 번호("옹벽 1/2") — 라벨·툴팁·벌룬 동일 표기.
- [초기화] 무동작 수정: 원인은 playwright 연결 브라우저의 네이티브 confirm 자동
  취소. 공용 showConfirmDialog 모달(ui_template_elements) 신설로 교체.
- C군 옵션: 전길이 → "기준측점 전" + "기준측점 후" 신설(기본 10/5/5). 나중에
  입력한 쪽이 살아남고 반대쪽 = 길이 − 그 값. 저장은 before_m 기준(후는 파생).
- B06 횡단 반폭 표시 기본 20 → 12 (계산·보유 샘플 폭은 백엔드 20m 유지 — 표시
  범위만 좁힘, 저장·세션값 있으면 그 값 우선).
- 측점 범위 표기 폰트 = 기준 측점 입력 칸과 동일(13.33px), 볼드 유지.
- 검증: pytest 112 통과, tsc, 헤드 브라우저 — Tab 포커스 '높이', 전7→후3·후2→전8
  연동, 범위 2+12.0~3+2.0, 알약 '옹벽 1/2', 초기화 모달 표시, 저장 왕복·원복.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 18:56:06 +09:00

301 lines
13 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Structures_Marks.ts
* 종단 그래프 아래 구조물 알약(pill) 레인 + 벌룬.
*
* 표기 규칙 (2026-08-17 사용자 확정):
* - 구조물은 종류와 무관하게 **알약 하나**로 표시한다. 구간형은 기준점에 찍는다.
* - 자동 배치 배수관도 세로 점선이 아니라 같은 알약으로 나온다 — 자동이든 수동이든
* 같은 구조물이기 때문이다.
* - 레인은 그래프 편집 버튼(구간 ⬆⬇) 아래에 붙고 높이는 2행분뿐이다. 알약이 겹치지
* 않으면 세로 가운데 한 줄로, 겹칠 때만 두 줄로 쌓는다.
* - 알약을 고르면 벌룬으로 종류·위치·제원을 띄우고, 구간형은 그때만 시~종점 띠를
* 그래프 폭에 맞춰 보여 준다.
*
* 그래프는 매 그리기마다 새로 만들어지므로 이 레인도 그때마다 다시 붙인다.
* ========================================================================== */
import {
structureAnchorM,
type StructureInstance,
type StructureType,
} from "./B05_Profile_Api_Structures";
import { numberDuplicateNames } from "./B05_Profile_UI_Structures_List";
import { formatStation } from "./B05_Profile_Util_Station";
/** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로
* 보지 않고 시각적으로도 움직이지 않는다. 예전 10px 누적 방식은 손떨림이 쌓여
* 고르려다 옮겨지는 일이 잦았다(2026-08-19 사용자 보고 — 민감도 완화). */
const GRAB_SLACK_PX = 14;
/** 겹쳐 두 줄로 갈릴 때의 알약 높이(px) — 레인 높이를 정하는 값이다. */
const PILL_HEIGHT_PX = 24;
/** 혼자 있는(가운데 한 줄) 알약 높이(px) — 자리가 남으니 30%만 키운다
* (2026-08-17 사용자 지시 1: 32px는 과했다). */
const SOLO_PILL_HEIGHT_PX = 31;
/** 두 줄 사이 간격(px). */
const ROW_GAP_PX = 2;
/** 레인 위아래 여백(px) — 그래프를 밀어내되 여유는 최소로. */
const LANE_PADDING_PX = 2;
/** 두 알약이 겹친다고 볼 가로 거리(px). 3자 라벨 폭 기준. */
const OVERLAP_PX = 52;
/** 레인 전체 높이(px) — 2행 구조를 항상 확보한다(항목이 하나여도 높이는 같다).
* 큰(혼자) 알약도 이 안에 들어간다. 아래 서브패널 손잡이와의 사이는 CSS 여백을
* 절반으로 줄여 붙여 놓는다(2026-08-17 사용자 지시 2). */
export const STRUCTURE_LANE_HEIGHT_PX = PILL_HEIGHT_PX * 2 + ROW_GAP_PX + LANE_PADDING_PX * 2;
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;
/** 레인 가로 폭(px) — 그래프와 같은 폭이어야 스크롤이 함께 움직인다. */
widthPx: number;
/** 그래프 Y축 띠 폭(px) — 레인도 같은 폭으로 축을 이어 붙여, 스크롤된 알약이
* 축 값 위로 새 나가지 않게 가린다(2026-08-17 사용자 지시 2). */
axisWidthPx: 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);
}
/** 알약 세로 자리 배정 — 이웃과 겹치는 것만 두 줄로 나누고, 나머지는 가운데에 둔다
* (2026-08-17 사용자 지시 2). 값 0 = 윗줄, 1 = 아랫줄, null = 가운데. */
function rowAssignments(
structures: ReadonlyArray<StructureInstance>,
x: (chainageM: number) => number,
): Map<string, number | null> {
const rows = new Map<string, number | null>();
const sorted = [...structures].sort(
(left, right) => structureAnchorM(left) - structureAnchorM(right),
);
const centers = sorted.map((structure) => x(structureAnchorM(structure)));
let row = 0;
sorted.forEach((structure, index) => {
const id = structure.structure_id ?? "";
const near =
(index > 0 && centers[index] - centers[index - 1] < OVERLAP_PX) ||
(index < sorted.length - 1 && centers[index + 1] - centers[index] < OVERLAP_PX);
if (!near) {
// 앞뒤 어디와도 안 겹친다 — 혼자이므로 레인 세로 가운데.
rows.set(id, null);
row = 0;
return;
}
// 겹치는 무리 안에서만 위·아래를 번갈아 쓴다.
rows.set(id, row);
row = row === 0 ? 1 : 0;
});
return rows;
}
/**
* 그래프 아래에 구조물 알약 레인을 만들어 돌려준다(호출부가 원하는 위치에 붙인다).
*/
export function buildStructureLane(options: StructureMarksOptions): HTMLElement {
const lane = document.createElement("div");
lane.className = "b05-structure__lane";
lane.style.width = `${options.widthPx}px`;
lane.style.height = `${STRUCTURE_LANE_HEIGHT_PX}px`;
const typeMap = new Map(options.types.map((type) => [type.type_id, type]));
const rows = rowAssignments(options.structures, options.x);
// 좌측 목록과 같은 중복 이름 번호(2026-08-19 사용자 지시) — 시점에 가까운 순
// "이름 1/2…". 알약 라벨(약호)·툴팁·벌룬 제목이 모두 이 번호를 따른다.
const anchorSorted = [...options.structures].sort(
(left, right) => structureAnchorM(left) - structureAnchorM(right),
);
const numberedNames = numberDuplicateNames(
anchorSorted.map((entry) => typeMap.get(entry.type_id)?.name ?? entry.type_id),
);
const displayNames = new Map(
anchorSorted.map((entry, index) => [entry.structure_id ?? "", numberedNames[index]]),
);
options.structures.forEach((structure) => {
const id = structure.structure_id;
if (!id) return;
const type = typeMap.get(structure.type_id);
const rawName = type?.name ?? structure.type_id;
const displayName = displayNames.get(id) ?? rawName;
// "옹벽 1"에서 붙은 번호만 떼어 약호 뒤에 잇는다(알약은 약호 표기 유지).
const numberSuffix = displayName.startsWith(rawName) ? displayName.slice(rawName.length) : "";
const anchorPx = options.x(structureAnchorM(structure));
const selected = options.selectedId === id;
// 이웃과 겹치는 알약만 위·아래 두 줄로 가르고, 혼자면 레인 세로 가운데에 크게 둔다.
const row = rows.get(id) ?? null;
const height = row === null ? SOLO_PILL_HEIGHT_PX : PILL_HEIGHT_PX;
const top =
row === null
? (STRUCTURE_LANE_HEIGHT_PX - SOLO_PILL_HEIGHT_PX) / 2
: LANE_PADDING_PX + row * (PILL_HEIGHT_PX + ROW_GAP_PX);
// 구간형은 고른 동안만 시~종점을 띠로 펼친다.
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.top = `${top + height / 2 - 1}px`;
band.style.background = type?.style?.color ?? "#888";
lane.append(band);
}
const mark = document.createElement("button");
mark.type = "button";
mark.className = "b05-structure__mark";
mark.classList.toggle("is-selected", selected);
mark.classList.toggle("is-solo", row === null);
mark.style.left = `${anchorPx}px`;
mark.style.top = `${top}px`;
mark.style.borderColor = type?.style?.color ?? "#888";
mark.style.color = type?.style?.color ?? "#888";
// 라벨은 3자 이하 짧은 이름(레지스트리 style.abbr, 2026-08-17 지시 4) + 중복 번호.
mark.textContent = `${type?.style?.abbr ?? "?"}${numberSuffix}`;
mark.title = `${displayName} ${positionText(structure, options.stationIntervalM)}`;
mark.dataset.structureId = id;
mark.addEventListener("click", (event) => {
event.stopPropagation();
options.onSelect(selected ? null : id);
});
attachMarkDrag(mark, structure, options);
lane.append(mark);
if (selected)
lane.append(
buildBalloon(structure, type, anchorPx, row, options.stationIntervalM, displayName),
);
});
// Y축 띠 — 그래프 축을 레인까지 이어 붙인다. sticky라 가로 스크롤에도 왼쪽에
// 머물며, 지나가는 알약을 덮어 축 값 영역으로 새 나가지 않게 한다.
const axis = document.createElement("div");
axis.className = "b05-structure__axis";
const axisInner = document.createElement("div");
axisInner.className = "b05-structure__axis-inner";
axisInner.style.width = `${options.axisWidthPx}px`;
axisInner.style.height = `${STRUCTURE_LANE_HEIGHT_PX}px`;
// 레인이 무엇을 담는 줄인지 축 자리에 적어 준다(2026-08-17 사용자 지시 2).
axisInner.textContent = "구조물";
axis.append(axisInner);
lane.prepend(axis);
return lane;
}
/** 고른 구조물의 값 벌룬. 유토곡선 벌룬과 같은 결(도형+텍스트)로 맞춘다.
* 알약 **위**로 띄운다 — 아래로 펴면 도면 테이블에 가려 안 보인다(2026-08-18
* 사용자 지시). 2행 구조에서 1행(윗줄)은 좌측 위, 2행(아랫줄)은 우측 위로
* 전개해 서로 겹치지 않게 하고, 솔로 알약은 위 중앙이다. */
function buildBalloon(
structure: StructureInstance,
type: StructureType | undefined,
anchorPx: number,
row: number | null,
stationIntervalM: number,
/** 중복 번호가 붙은 표시 이름("옹벽 1") — 좌측 목록과 같은 표기(2026-08-19). */
displayName?: string,
): HTMLElement {
const balloon = document.createElement("div");
balloon.className = "b05-structure__balloon";
balloon.classList.add(row === 0 ? "is-left" : row === 1 ? "is-right" : "is-center");
balloon.style.left = `${anchorPx}px`;
balloon.style.bottom = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX}px`;
balloon.style.borderColor = type?.style?.color ?? "#888";
const title = document.createElement("strong");
title.textContent = displayName ?? type?.name ?? structure.type_id;
const position = document.createElement("span");
position.textContent = positionText(structure, stationIntervalM);
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 startX = 0;
/** 시작점에서 가장 멀리 벗어난 거리(px) — 누적이 아니라 변위라 손떨림이 안 쌓인다. */
let maxOffsetPx = 0;
mark.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
dragging = true;
startX = event.clientX;
maxOffsetPx = 0;
mark.setPointerCapture(event.pointerId);
event.stopPropagation();
});
mark.addEventListener("pointermove", (event) => {
if (!dragging) return;
maxOffsetPx = Math.max(maxOffsetPx, Math.abs(event.clientX - startX));
// 여유를 벗어나기 전에는 시각적으로도 움직이지 않는다 — 클릭 중 마크가 흔들리면
// 이동으로 오해하기 쉽다(2026-08-19 민감도 완화).
if (maxOffsetPx <= GRAB_SLACK_PX) return;
const lane = mark.parentElement;
if (!lane) return;
const localX = event.clientX - lane.getBoundingClientRect().left;
mark.style.left = `${localX}px`;
});
mark.addEventListener("pointerup", (event) => {
if (!dragging) return;
dragging = false;
mark.releasePointerCapture(event.pointerId);
// 여유 안에서 뗐으면 이동이 아니라 클릭이다 — 고르려다 옮겨지면 곤란하다.
if (maxOffsetPx <= GRAB_SLACK_PX) return;
const lane = mark.parentElement;
if (!lane) return;
const localX = event.clientX - lane.getBoundingClientRect().left;
const chainage = Math.min(Math.max(options.chainageAt(localX), 0), options.maxChainageM);
options.onMove(structure.structure_id ?? "", Number(chainage.toFixed(2)));
});
}