Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
361 lines
17 KiB
TypeScript
361 lines
17 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 { etcName, numberDuplicateNames } from "./B05_Profile_UI_Structures_List";
|
|
import { balloonLines, type ContainerCount } from "./B05_Profile_UI_Structures_Balloon";
|
|
import { stationLabel as formatStation } from "../common_util/common_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;
|
|
/** 기타 알약 머리 글자 수 — 약호(3자 이하)와 같은 폭(54-9). */
|
|
const ETC_PILL_CHARS = 3;
|
|
/** 라벨을 위로 비키는 높이(px) — 그래프 바닥 ▼ 단추(바닥 2 + 높이 17) + 틈 2(54-14). */
|
|
const EDIT_BUTTON_CLEARANCE_PX = 21;
|
|
|
|
/** 레인 전체 높이(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;
|
|
/** 라벨 선택 컨테이너 개수(폼과 같은 값 · 배관 흙막이 = 유출 단 수) — 없으면 저장된 것. */
|
|
containerCount?: (structure: StructureInstance) => ContainerCount | undefined;
|
|
}
|
|
|
|
/** 위치 표기 — 측점번호+잔여거리(예: 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;
|
|
}
|
|
|
|
/** 이름마다 남과 갈리는 가장 짧은 머리 — 빈칸 뺌 · 적어도 3글자 · 이름보다 길지 않음. */
|
|
function distinctHeads(names: ReadonlyArray<string>): Map<string, string> {
|
|
const letters = names.map((name) => [...name.replace(/\s+/g, "")]);
|
|
return new Map(
|
|
names.map((name, index) => {
|
|
const own = letters[index];
|
|
let length = ETC_PILL_CHARS;
|
|
letters.forEach((other, otherIndex) => {
|
|
if (otherIndex === index) return;
|
|
let common = 0;
|
|
while (common < own.length && own[common] === other[common]) common += 1;
|
|
length = Math.max(length, common + 1);
|
|
});
|
|
return [name, own.slice(0, length).join("")];
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** 표시 이름 · 알약 글(구조물 id 별). 표시 이름 = 좌측 목록과 같은 중복 번호(시점에 가까운 순
|
|
* "이름 1/2…", 2026-08-19) — 툴팁 · 벌룬 제목이 씀. 알약 = 약호 + 그 번호. 기타는 약호가 모두
|
|
* 「기타」라 손 이름 머리(distinctHeads) + 번호 · 빈 이름 = 「기타 N」(54-9). */
|
|
export function structurePillTexts(
|
|
structures: ReadonlyArray<StructureInstance>,
|
|
typeMap: ReadonlyMap<string, StructureType>,
|
|
): Map<string, { name: string; pill: string }> {
|
|
const sorted = [...structures].sort(
|
|
(left, right) => structureAnchorM(left) - structureAnchorM(right),
|
|
);
|
|
const rawNames = sorted.map(
|
|
(entry) => etcName(entry) ?? typeMap.get(entry.type_id)?.name ?? entry.type_id,
|
|
);
|
|
const numbered = numberDuplicateNames(rawNames);
|
|
const heads = distinctHeads([
|
|
...new Set(rawNames.filter((_name, index) => sorted[index].type_id === "etc")),
|
|
]);
|
|
return new Map(
|
|
sorted.map((entry, index) => {
|
|
const name = numbered[index];
|
|
const suffix = name.slice(rawNames[index].length); // " 1" 또는 ""
|
|
const head = etcName(entry)
|
|
? heads.get(rawNames[index])
|
|
: typeMap.get(entry.type_id)?.style?.abbr;
|
|
return [entry.structure_id ?? "", { name, pill: `${head ?? "?"}${suffix}` }];
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 그래프 아래에 구조물 알약 레인을 만들어 돌려준다(호출부가 원하는 위치에 붙인다).
|
|
*/
|
|
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 사용자 지시) — 알약 글·툴팁·벌룬 제목이
|
|
// 모두 이 번호를 따른다.
|
|
const texts = structurePillTexts(options.structures, typeMap);
|
|
|
|
options.structures.forEach((structure) => {
|
|
const id = structure.structure_id;
|
|
if (!id) return;
|
|
const type = typeMap.get(structure.type_id);
|
|
const { name: displayName, pill } = texts.get(id) ?? { name: structure.type_id, pill: "?" };
|
|
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);
|
|
|
|
// 구간형은 시~종점을 **늘 띠로** 펼친다(2026-09-07 사용자 지시 — B군을 넣어도 어디에
|
|
// 놓였는지 안 보였다). 예전에는 고른 동안만 폈는데, 측구·맹암거처럼 길게 이어지는
|
|
// 시설은 「어디부터 어디까지인가」가 곧 그 시설의 내용이라 늘 보여야 한다.
|
|
// 고른 것은 진하게, 나머지는 옅게 — 레인 높이(42px)는 그대로다.
|
|
if (structure.placement === "interval") {
|
|
const band = document.createElement("div");
|
|
band.className = `b05-structure__band${selected ? " is-selected" : ""}`;
|
|
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`;
|
|
// 알약 세로 가운데에 맞춘다 — 띠 두께가 골랐을 때만 4px 라 반값이 다르다.
|
|
band.style.top = `${top + height / 2 - (selected ? 2 : 1.5)}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) + 중복 번호 ·
|
|
// 기타는 손 이름 머리(54-9).
|
|
mark.textContent = pill;
|
|
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(
|
|
balloonLines(
|
|
structure,
|
|
type,
|
|
{ name: displayName, position: positionText(structure, options.stationIntervalM) },
|
|
options.containerCount?.(structure),
|
|
),
|
|
type,
|
|
anchorPx,
|
|
row,
|
|
),
|
|
);
|
|
});
|
|
|
|
// 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행(아랫줄)은 우측 위로
|
|
* 전개해 서로 겹치지 않게 하고, 솔로 알약은 위 중앙이다.
|
|
* 줄 = 2행 — 이름 + 기준 측점 · 컨테이너마다 첫 옵션 값 이음(`balloonLines` · 54-14 · 54-46) · 그래프 바닥 ▼ 단추를 가리지
|
|
* 않게 단추 높이만큼 더 위 · 위가 잘리면 알약 아래로. */
|
|
function buildBalloon(
|
|
lines: string[],
|
|
type: StructureType | undefined,
|
|
anchorPx: number,
|
|
row: number | null,
|
|
): 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 + EDIT_BUTTON_CLEARANCE_PX}px`;
|
|
balloon.style.borderColor = type?.style?.color ?? "#888";
|
|
|
|
const [name, ...rest] = lines;
|
|
const title = document.createElement("strong");
|
|
title.textContent = name;
|
|
balloon.append(
|
|
title,
|
|
...rest.map((line) => {
|
|
const span = document.createElement("span");
|
|
span.textContent = line;
|
|
return span;
|
|
}),
|
|
);
|
|
requestAnimationFrame(() => flipIfClipped(balloon));
|
|
return balloon;
|
|
}
|
|
|
|
/** 라벨 위가 스크롤 상자 · 창 밖으로 잘리면 알약 아래로 편다(아래가 덜 잘릴 때만). */
|
|
function flipIfClipped(balloon: HTMLElement): void {
|
|
const lane = balloon.parentElement;
|
|
if (!balloon.isConnected || !lane) return;
|
|
let top = 0;
|
|
let bottom = window.innerHeight;
|
|
for (let at: HTMLElement | null = lane; at; at = at.parentElement)
|
|
if (getComputedStyle(at).overflowY !== "visible") {
|
|
const clip = at.getBoundingClientRect();
|
|
top = Math.max(top, clip.top);
|
|
bottom = Math.min(bottom, clip.bottom);
|
|
}
|
|
const box = balloon.getBoundingClientRect();
|
|
const hiddenAbove = top - box.top;
|
|
const hiddenBelow = lane.getBoundingClientRect().bottom + ROW_GAP_PX + box.height - bottom;
|
|
if (hiddenAbove <= 0 || hiddenBelow >= hiddenAbove) return;
|
|
balloon.style.bottom = "auto";
|
|
balloon.style.top = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX}px`;
|
|
}
|
|
|
|
/** 마크를 좌우로 끌어 위치를 옮긴다. 구간형은 길이를 유지한 채 통째로 움직인다. */
|
|
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)));
|
|
});
|
|
}
|