Files
Aislo/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts
T
eomsangdonandClaude Opus 5.5 c8be383428 feat(B05 종단): 원지반 점 임시 횡단 작은 창 · [측점 추가] · 점선 · 우클릭 측점 삭제 (368-3)
- 도구 꺼진 때 원지반선 근처 누름(정수 m) · 추가 측점 선 누름 = 종단 칸 안 작은 창
- 내용 = 서버 미리 뜬 횡단(저장 계획선 기준) · 평면 우측 횡단과 같은 그림 · 확대 · 이동
- [측점 추가] · [삭제] · [닫기] · 같은 m 측점 있으면 막음 · 우클릭 「측점 삭제」
- 추가 측점 세로선 = 일반측점 색 점선(--plus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-17 12:09:07 +09:00

215 lines
8.2 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Profile_CrossModal.ts
* 종단 원지반선 점 → 그 자리 임시 횡단 작은 창(368-3 · 요구 240-21).
*
* 열기 = 도구가 꺼진 때 원지반선 근처 누름(정수 m) · 추가 측점 세로선 누름. 창은 종단 칸 안에 겹침
* (유토 배지처럼 · [창 분할]에 따라감 · 다시 그려도 남음). 내용 = 서버 미리 뜬 횡단(`preview-stations` ·
* 저장 계획선 기준 · 파일에 안 씀) + 평면 우측 횡단과 같은 그림(`buildCrossSvg`) · 확대 · 이동.
* [측점 추가] = 추가 측점 초안(일반측점 · [저장] 한 번) · 같은 m 에 측점이 있으면 막음 · [삭제] = 추가
* 측점일 때만 · [닫기].
* ========================================================================== */
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 { 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";
/** 원지반선을 눌렀다고 볼 위아래 여유(px). */
const GROUND_SLACK_PX = 8;
/** 같은 측점으로 볼 거리(m) — 정수 m 측점끼리. */
const SAME_M = 0.5;
export interface CrossModalOpen {
chainageM: number;
/** 지금 종단 측점 누가거리(일반 · 구조물 · 추가) — 같은 m 막음. */
stations: readonly number[];
intervalM: number;
}
/** 그래프 누름 → 원지반선 근처면 그 누가거리(정수 m) · 아니면 null. */
export function groundPickAt(
chart: HTMLElement,
event: MouseEvent,
chainageAtPx: (px: number) => number,
samples: ReadonlyArray<{ chainage_m?: number; elevation_m?: number | null }>,
): number | null {
const svg = chart.querySelector<SVGSVGElement>(`svg[${Y_WINDOW_ATTRS.auto}="1"]`);
if (!svg) return null;
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,
),
};
if (![view.center, view.pxPerM, view.zero].every(Number.isFinite) || view.pxPerM <= 0)
return null;
const rect = chart.getBoundingClientRect();
const chainage = chainageAtPx(event.clientX - rect.left);
const valid = samples.filter(
(s): s is { chainage_m: number; elevation_m: number } =>
Number.isFinite(s.chainage_m ?? NaN) && Number.isFinite(s.elevation_m ?? NaN),
);
const after = valid.findIndex((s) => s.chainage_m >= chainage);
if (!Number.isFinite(chainage) || after <= 0) return null;
const [a, b] = [valid[after - 1], valid[after]];
const t = (chainage - a.chainage_m) / (b.chainage_m - a.chainage_m || 1);
const ground = a.elevation_m + (b.elevation_m - a.elevation_m) * t;
const top = svg.getBoundingClientRect().top - rect.top;
const groundY = chartYAtElevation(view, ground) + top;
return Math.abs(event.clientY - rect.top - groundY) <= GROUND_SLACK_PX
? Math.round(chainage)
: null;
}
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>
<div class="b05-crossmodal__box"></div>
<div class="b05-crossmodal__foot"></div>
<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>`;
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);
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 [cross] = 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,
});
foot.textContent = `${summarizeCross(preview)} · 저장된 계획선 기준`;
} catch (error) {
if (mine === turn)
foot.textContent = error instanceof Error ? error.message : "횡단을 뜨지 못했습니다.";
}
}
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;
syncButtons();
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)),
);
}