- 묶음 출력 읽기만 · ground · cross 단계 함수를 그 자리 측점 하나로 다시 부름(파생 저장 · 캐시 안 씀) - 그 자리 측점 = [측점 추가] 뒤 묶음 측점과 같은 틀 · 반경 · 확폭(logic/design_pipeline_probe_station) - 격자 없음 · 분석 지형 다름 = 서버 미리 뜨기 + 「저장 계획선 기준」 한 줄 · 창 글에 계획고 - 묶음 cross 차례마다 떠 있는 창 다시 그림 - 거울 시험 test_368_4(곡선표 있음 · 없음 · 직선 · 곡선 안) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
248 lines
9.7 KiB
TypeScript
248 lines
9.7 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_Profile_CrossModal.ts
|
|
* 종단 원지반선 점 → 그 자리 임시 횡단 작은 창(368-3 · 요구 240-21).
|
|
*
|
|
* 열기 = 도구가 꺼진 때 원지반선 근처 누름(정수 m) · 추가 측점 세로선 누름. 창은 종단 칸 안에 겹침
|
|
* (유토 배지처럼 · [창 분할]에 따라감 · 다시 그려도 남음). 내용 = 지금 계획선(초안 포함 · 묶음 같은 계산
|
|
* `pipelineCrossAtChainage` · 368-4) — 격자 없음 · 분석 지형 다름이면 서버 미리 뜬 횡단(`preview-stations` ·
|
|
* 저장 계획선 기준 · 글 한 줄) · 평면 우측 횡단과 같은 그림(`buildCrossSvg`) · 확대 · 이동 · 묶음 cross 차례마다 다시.
|
|
* [측점 추가] = 추가 측점 초안(일반측점 · [저장] 한 번) · 같은 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 { 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";
|
|
|
|
/** 원지반선을 눌렀다고 볼 위아래 여유(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 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;
|
|
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)),
|
|
);
|
|
}
|