Files
Aislo/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts
T

349 lines
14 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Profile_CrossModal.ts
* 종단 원지반선 점 → 그 자리 임시 횡단 작은 창(368-3 · 요구 240-21).
*
* 열기 = 도구가 꺼진 때 원지반선 근처 누름(정수 m) · 추가 측점 세로선 누름. 창은 종단 칸 안에 겹침
* (유토 배지처럼 · [창 분할]에 따라감 · 다시 그려도 남음). 내용 = 지금 계획선(초안 포함 · 묶음 같은 계산
* `pipelineCrossAtChainage` · 368-4) — 격자 없음 · 분석 지형 다름이면 서버 미리 뜬 횡단(`preview-stations` ·
* 저장 계획선 기준 · 글 한 줄) · 평면 우측 횡단과 같은 그림(`buildCrossSvg`) · 확대 · 이동 · 묶음 cross 차례마다 다시.
* [측점 추가] = 추가 측점 초안(일반측점 · [저장] 한 번) · 같은 m 에 측점이 있으면 막음 · [삭제] = 추가
* 측점일 때만 · [닫기]. 열리는 자리 가까이 = 원 · 세로 점선 · 측점 글(`mountCrossHint` · 490-1).
* ========================================================================== */
import "./B05_Profile_UI_Profile_CrossModal.css";
import { attachSvgZoomPan } from "../A00_Common/b_svg_zoom_pan";
import { draftProjectId } from "../A00_Common/design/design_store";
import {
chartYAtElevation,
parseWindowTransform,
type ChartWindow,
} from "../A00_Common/design/logic/design_profile_free";
import { Y_WINDOW_ATTRS, Y_WINDOW_CLASS } from "../B06_Section/B06_Section_UI_Longitudinal";
import { previewStructureSections } from "../B06_Section/B06_Section_Api_Fetch";
import { pagePipeline } from "../A00_Common/design/design_pipeline_page";
import { pipelineCrossAtChainage } from "../A00_Common/design/design_pipeline_probe";
import { stationLabel } from "../common_util/common_util_station";
import { plusStationsOf, writePlusDraft } from "./B05_Profile_Api_PlusStations";
import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan";
import {
buildCrossSvg,
CROSS_PLOT_PAD,
summarizeCross,
} from "./B05_Profile_UI_RouteEdit_Cross_Draw";
import {
crossPickAt,
groundElevationAt,
type CrossPickHit,
type GroundSample,
} from "./logic/B05_Profile_Profile_CrossPick";
/** 같은 측점으로 볼 거리(m) — 정수 m 측점끼리. */
const SAME_M = 0.5;
export interface CrossModalOpen {
chainageM: number;
/** 지금 종단 측점 누가거리(일반 · 구조물 · 추가) — 같은 m 막음. */
stations: readonly number[];
intervalM: number;
}
/** 그래프 위 포인터 → 임시 횡단 자리(정수 m · 그 자리 원지반선 y px) · 아니면 null.
* 누름(작은 창) · 가까이 표시가 같이 씀(490-1) — 판정은 `crossPickAt` 한 벌. */
export function crossPickFromEvent(
chart: HTMLElement,
event: MouseEvent,
chainageAtPx: (px: number) => number,
samples: readonly GroundSample[],
): { chainageM: number; groundY: number | null } | null {
const target = event.target as Element;
const marker = target.closest(".b06-chart__station");
const plusRaw = marker?.querySelector(".b06-chart__station-line--plus")
? Number(marker.getAttribute("data-chainage"))
: null;
const hit: CrossPickHit =
plusRaw !== null
? { plusM: plusRaw }
: target.closest("button")
? "other"
: marker
? "station"
: null;
const svg = chart.querySelector<SVGSVGElement>(`svg[${Y_WINDOW_ATTRS.auto}="1"]`);
const read = (name: string): number => Number(svg?.getAttribute(name));
const view: ChartWindow = {
center: read(Y_WINDOW_ATTRS.center),
pxPerM: read(Y_WINDOW_ATTRS.pxPerM),
zero: read(Y_WINDOW_ATTRS.zero),
...parseWindowTransform(
svg?.querySelector(`.${Y_WINDOW_CLASS}`)?.getAttribute("transform") ?? null,
),
};
const rect = chart.getBoundingClientRect();
const top = svg ? svg.getBoundingClientRect().top - rect.top : 0;
const viewOk =
!!svg && [view.center, view.pxPerM, view.zero].every(Number.isFinite) && view.pxPerM > 0;
const groundY = (elevation: number): number =>
viewOk ? chartYAtElevation(view, elevation) + top : NaN;
// 포인터 이동은 소수 px · 누름은 정수 px — 내려 맞춰 같은 자리 = 같은 m(0.5 m 경계에서 1 m 어긋남 막음).
const chainageM = crossPickAt({
hit,
chainageM: chainageAtPx(Math.floor(event.clientX) - rect.left),
pointerY: Math.floor(event.clientY) - rect.top,
samples,
groundY,
});
if (chainageM === null || !Number.isFinite(chainageM)) return null;
const ground = groundElevationAt(samples, chainageM);
const y = ground === null ? NaN : groundY(ground);
return { chainageM, groundY: Number.isFinite(y) ? y : null };
}
/** 임시 횡단이 열리는 자리 가까이 = 작은 원 · 세로 점선 · 측점 글 · 손가락 커서(490-1).
* 멀어짐 · 띠 밖 측점선 · 단추 · 도구 중 · 끌기 · 스크롤 = 안 그림. */
export function mountCrossHint(
chart: HTMLElement,
scroller: HTMLElement,
options: {
pick: (event: MouseEvent) => { chainageM: number; groundY: number | null } | null;
x: (chainageM: number) => number;
intervalM: number;
blocked: () => boolean;
},
): void {
const hint = document.createElement("div");
hint.className = "b05-crosshint";
hint.hidden = true;
hint.innerHTML = `<i class="b05-crosshint__line"></i><i class="b05-crosshint__dot"></i><span class="b05-crosshint__label"></span>`;
const dot = hint.querySelector<HTMLElement>(".b05-crosshint__dot")!;
const label = hint.querySelector<HTMLElement>(".b05-crosshint__label")!;
chart.append(hint);
const hide = (): void => {
hint.hidden = true;
chart.style.cursor = "";
};
chart.addEventListener("pointermove", (event) => {
const at = event.buttons || options.blocked() ? null : options.pick(event);
if (!at) return hide();
hint.hidden = false;
hint.style.left = `${options.x(at.chainageM)}px`;
dot.hidden = at.groundY === null;
// 원 · 글 높이 = 원지반선(없으면 위쪽) — 글이 위 ▲ 단추를 가리지 않게 원 옆에 둠.
hint.style.setProperty("--crosshint-y", `${at.groundY ?? 12}px`);
label.textContent = `${stationLabel(at.chainageM, options.intervalM)} · 눌러 임시 횡단`;
chart.style.cursor = "pointer";
scroller.addEventListener("scroll", hide, { once: true, passive: true });
});
chart.addEventListener("pointerleave", hide);
}
/** 제목 줄 끌어 창 옮김(500-1) — 종단 칸 · 화면 안으로 가둠 · 단추 누름은 단추 몫 · 돌려줌 = 끌어 둔 자리 다시 가둠. */
function bindHeadDrag(root: HTMLElement, head: HTMLElement): () => void {
let start: { id: number; x: number; y: number; left: number; top: number } | null = null;
const put = (left: number, top: number): void => {
const box = root.parentElement!.getBoundingClientRect();
const now = root.getBoundingClientRect();
const [dx, dy] = [left - root.offsetLeft, top - root.offsetTop];
const [minX, maxX] = [
Math.max(box.left, 0) - now.left,
Math.min(box.right, innerWidth) - now.right,
];
const [minY, maxY] = [
Math.max(box.top, 0) - now.top,
Math.min(box.bottom, innerHeight) - now.bottom,
];
root.style.left = `${root.offsetLeft + Math.max(minX, Math.min(dx, maxX))}px`;
root.style.top = `${root.offsetTop + Math.max(minY, Math.min(dy, maxY))}px`;
root.style.right = "auto";
};
head.addEventListener("pointerdown", (event) => {
if (event.button !== 0 || (event.target as Element).closest("button")) return;
start = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
left: root.offsetLeft,
top: root.offsetTop,
};
head.setPointerCapture(event.pointerId);
});
head.addEventListener("pointermove", (event) => {
if (start?.id !== event.pointerId) return;
put(start.left + event.clientX - start.x, start.top + event.clientY - start.y);
});
const end = (event: PointerEvent): void => {
if (start?.id === event.pointerId) start = null;
};
head.addEventListener("pointerup", end);
head.addEventListener("pointercancel", end);
return () => {
if (root.style.left) put(root.offsetLeft, root.offsetTop);
};
}
interface CrossModal {
open(request: CrossModalOpen): void;
}
const modals = new WeakMap<HTMLElement, CrossModal>();
function createCrossModal(host: HTMLElement): CrossModal {
const root = document.createElement("section");
root.className = "b05-crossmodal";
root.setAttribute("role", "dialog");
root.hidden = true;
root.innerHTML = `
<div class="b05-crossmodal__head">
<strong>임시 횡단</strong>
<span class="b05-crossmodal__station"></span>
<div class="b05-crossmodal__actions">
<button type="button" class="ui-button" data-act="add">측점 추가</button>
<button type="button" class="ui-button" data-act="remove">삭제</button>
<button type="button" class="ui-button" data-act="close">닫기</button>
</div>
</div>
<div class="b05-crossmodal__box"></div>
<div class="b05-crossmodal__foot"></div>`;
const $ = <T extends HTMLElement>(selector: string): T => root.querySelector<T>(selector)!;
const station = $(".b05-crossmodal__station");
const box = $(".b05-crossmodal__box");
const foot = $(".b05-crossmodal__foot");
const add = $<HTMLButtonElement>('[data-act="add"]');
const remove = $<HTMLButtonElement>('[data-act="remove"]');
// 종단 그래프 누름 · 우클릭으로 새지 않게(창 안 조작은 창 몫)
["click", "pointerdown", "contextmenu"].forEach((type) =>
root.addEventListener(type, (event) => event.stopPropagation()),
);
host.append(root);
const refit = bindHeadDrag(root, $(".b05-crossmodal__head"));
let current: CrossModalOpen | null = null;
let turn = 0;
const projectId = (): string => draftProjectId();
const isPlus = (m: number): boolean => plusStationsOf(projectId()).includes(m);
function syncButtons(): void {
if (!current) return;
const m = current.chainageM;
const plus = isPlus(m);
const taken = !plus && current.stations.some((at) => Math.abs(at - m) < SAME_M);
add.hidden = plus;
add.disabled = taken;
add.title = taken ? "이미 측점이 있는 자리입니다." : "";
remove.hidden = !plus;
}
async function draw(m: number): Promise<void> {
const mine = (turn += 1);
box.replaceChildren();
foot.textContent = "횡단을 뜨는 중…";
try {
// 지금 계획선(초안 포함 · 묶음 같은 계산) — 격자 없음 · 분석 지형 다름이면 서버(저장 계획선)
const now = await pipelineCrossAtChainage(projectId(), m, current?.intervalM ?? 20).catch(
() => null,
);
const [cross] = now ? [now] : await previewStructureSections(projectId(), [m]);
if (mine !== turn) return;
if (!cross) {
foot.textContent = "이 자리의 횡단을 뜨지 못했습니다.";
return;
}
const preview = cross as unknown as CrossPreviewResponse;
const rect = box.getBoundingClientRect();
const plot = buildCrossSvg(
preview,
Math.round(rect.width) || 320,
Math.round(rect.height) || 180,
);
box.replaceChildren(plot.svg);
attachSvgZoomPan({
svg: plot.svg,
layer: plot.layer,
widthPx: plot.width,
heightPx: plot.height,
pad: CROSS_PLOT_PAD,
content: plot.content,
wheelZoom: true,
});
const planZ = Number(
(preview.design as { design_elevation_m?: unknown } | null)?.design_elevation_m,
);
root.dataset.planZ = Number.isFinite(planZ) ? planZ.toFixed(3) : "";
foot.textContent = [
Number.isFinite(planZ) ? `계획고 ${planZ.toFixed(2)} m` : "",
summarizeCross(preview),
now ? "" : "저장 계획선 기준",
]
.filter(Boolean)
.join(" · ");
} catch (error) {
if (mine === turn)
foot.textContent = error instanceof Error ? error.message : "횡단을 뜨지 못했습니다.";
}
}
// 계획고 · 노선이 바뀌면(묶음 cross 차례) 떠 있는 창을 다시 그림
let watched: unknown = null;
let stopPipe = (): void => undefined;
function watchPipe(): void {
const pipe = pagePipeline(projectId());
if (pipe === watched) return;
stopPipe();
watched = pipe;
stopPipe =
pipe?.subscribe((event) => {
if (event.kind === "stage" && event.stage === "cross" && current)
void draw(current.chainageM);
}, "종단 작은 창") ?? ((): void => undefined);
}
function close(): void {
root.hidden = true;
current = null;
turn += 1;
}
add.addEventListener("click", () => {
if (!current || add.disabled) return;
writePlusDraft(projectId(), [...plusStationsOf(projectId()), current.chainageM]);
syncButtons();
});
remove.addEventListener("click", () => {
if (!current) return;
const m = current.chainageM;
writePlusDraft(
projectId(),
plusStationsOf(projectId()).filter((at) => at !== m),
);
syncButtons();
});
$('[data-act="close"]').addEventListener("click", close);
return {
open(request) {
current = request;
station.textContent = stationLabel(request.chainageM, request.intervalM);
root.hidden = false;
refit();
syncButtons();
watchPipe();
void draw(request.chainageM);
},
};
}
/** 종단 칸(`host` · 다시 그려도 남는 칸)에 작은 창을 열고 그 자리 임시 횡단을 그린다. */
export function openCrossModal(host: HTMLElement, request: CrossModalOpen): void {
let modal = modals.get(host);
if (!modal) modals.set(host, (modal = createCrossModal(host)));
modal.open(request);
}
/** 추가 측점 지움 — 우클릭 「측점 삭제」(368-3). */
export function removePlusStation(chainageM: number): void {
const projectId = draftProjectId();
writePlusDraft(
projectId,
plusStationsOf(projectId).filter((at) => at !== Math.round(chainageM)),
);
}