- 알약 글 = 좌측 「종류」 칸 · 목록 · 정보 창 1행과 같은 이름(약호 · 기타 머리 자르기 뺌) · 같은 이름 여럿이면 번호 - 알약 겹침 판정 = 이름 글 폭 어림(고정 52px 뺌) - 공용 우클릭 메뉴 안 click 은 메뉴에서 멈춤 — 배관 고른 채 군 머리를 누르면 그래프 빈 곳 클릭으로 새어 폼 리셋 · 메뉴 사라짐 - 시험: test_54_9_etc_pill 새 규칙 · 헬퍼 json 불러오기 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
340 lines
16 KiB
TypeScript
340 lines
16 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) — 글자당 · 좌우 여백+테(54-49 이름 글이라 폭이 제각각). */
|
|
const PILL_CHAR_PX = 11;
|
|
const PILL_PAD_PX = 20;
|
|
/** 라벨을 위로 비키는 높이(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,
|
|
labelOf: (structure: StructureInstance) => string,
|
|
): 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)));
|
|
const halves = sorted.map(
|
|
(structure) => ([...labelOf(structure)].length * PILL_CHAR_PX + PILL_PAD_PX) / 2,
|
|
);
|
|
|
|
let row = 0;
|
|
sorted.forEach((structure, index) => {
|
|
const id = structure.structure_id ?? "";
|
|
const near =
|
|
(index > 0 && centers[index] - centers[index - 1] < halves[index] + halves[index - 1]) ||
|
|
(index < sorted.length - 1 &&
|
|
centers[index + 1] - centers[index] < halves[index] + halves[index + 1]);
|
|
if (!near) {
|
|
// 앞뒤 어디와도 안 겹친다 — 혼자이므로 레인 세로 가운데.
|
|
rows.set(id, null);
|
|
row = 0;
|
|
return;
|
|
}
|
|
// 겹치는 무리 안에서만 위·아래를 번갈아 쓴다.
|
|
rows.set(id, row);
|
|
row = row === 0 ? 1 : 0;
|
|
});
|
|
|
|
return rows;
|
|
}
|
|
|
|
/** 표시 이름 · 알약 글(구조물 id 별) — 둘 다 좌측 「종류」 칸 · 목록과 같은 이름(54-49).
|
|
* 같은 이름이 여럿이면 시점에 가까운 순 「이름 1/2…」 · 기타 = 손 이름(빈 이름 = 「기타」). */
|
|
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 numbered = numberDuplicateNames(
|
|
sorted.map((entry) => etcName(entry) ?? typeMap.get(entry.type_id)?.name ?? entry.type_id),
|
|
);
|
|
return new Map(
|
|
sorted.map((entry, index) => [
|
|
entry.structure_id ?? "",
|
|
{ name: numbered[index], pill: numbered[index] },
|
|
]),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 그래프 아래에 구조물 알약 레인을 만들어 돌려준다(호출부가 원하는 위치에 붙인다).
|
|
*/
|
|
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]));
|
|
// 좌측 목록과 같은 중복 이름 번호(2026-08-19 사용자 지시) — 알약 글·툴팁·벌룬 제목이
|
|
// 모두 이 번호를 따른다.
|
|
const texts = structurePillTexts(options.structures, typeMap);
|
|
const rows = rowAssignments(
|
|
options.structures,
|
|
options.x,
|
|
(structure) => texts.get(structure.structure_id ?? "")?.pill ?? "",
|
|
);
|
|
|
|
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";
|
|
// 라벨 = 「종류」 칸과 같은 이름 + 중복 번호(54-49).
|
|
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)));
|
|
});
|
|
}
|