사용자 지시(2026-09-06): 구조물 표시가 너무 커서 높이를 많이 차지함. - 알약 높이 24 → 18px, 혼자 있을 때 31 → 22px, 글자 11 → 10px - 레인 전체 54 → 42px — 그만큼 종단 그래프가 세로를 더 씀 검증: 실화면 레인 42px·알약 22px, 이름표(찰쌓기·배수관·옹벽·세월교) 모두 읽힘. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
302 lines
14 KiB
TypeScript
302 lines
14 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) — 레인 높이를 정하는 값이다. */
|
|
/** 알약 높이(px) — 2026-09-06 사용자: 「너무 커서 높이를 많이 차지함」. 24 → 18 로 낮춰
|
|
* 레인이 54 → 42px 가 되고 그만큼 그래프가 넓게 쓴다. 글자는 10px 로 함께 줄인다(CSS). */
|
|
const PILL_HEIGHT_PX = 18;
|
|
/** 혼자 있는(가운데 한 줄) 알약 높이(px) — 겹칠 때보다 조금만 크게(옛 31 → 22). */
|
|
const SOLO_PILL_HEIGHT_PX = 22;
|
|
/** 두 줄 사이 간격(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)));
|
|
});
|
|
}
|