- 평면 오른쪽 횡단 판 = 계산 묶음 그 측점(stationSection — B06 카드 · 새 노선 상세와 같은 함수) · cross 차례마다 다시 그림(노선 · 계획고) · 노선이 바뀌면 전후 - 서버 POST route/cross-preview · fetchCrossPreview · 편집기 안 refresh 두 줄 지움 - 시험 test_365(판 계획고 · 면적 · 표본 = 카드 · 서버 길 없음) · cross_width · 133-1 · 224-2 고침 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
263 lines
12 KiB
TypeScript
263 lines
12 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_RouteEdit_Cross.ts
|
|
* 계획노선 편집 중 **측점 횡단** — 메인 창 오른쪽 세로 칸에 **붙박이 두 판**(계획서 0-9 ⑱⑲).
|
|
*
|
|
* · 위 판 = **지금 횡단**. 측점 눈금을 누르면 그 측점 횡단을 여기에 낸다.
|
|
* · 아래 판 = **이전 횡단**. 평소에는 빈 화면이고, 노선을 고쳐 **노드를 놓는 순간**
|
|
* 위 판의 것이 이리로 내려오고 새 횡단이 위로 올라간다 — 전후를 나란히 본다.
|
|
*
|
|
* 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 계획 횡단선 ·
|
|
* 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다.
|
|
*
|
|
* 값 = 계산 묶음 그 측점(365-3) — 측점 · 지반 표본 = ground 단계 · 계획고 · 설계선 = 종단 → cross 단계
|
|
* (B06 카드 · 새 노선 상세와 같은 함수 `stationSection`) · 노선 · 계획고가 바뀔 때마다(cross 차례) 다시 그림 ·
|
|
* 서버 왕복 없음. 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
|
|
* ========================================================================== */
|
|
|
|
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
|
|
import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan";
|
|
import { pagePipeline } from "../A00_Common/design/design_pipeline_page";
|
|
import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline";
|
|
import { useDesignStore } from "../A00_Common/design/design_store";
|
|
import { stationSection } from "./logic/B05_Profile_Pipeline_Detail";
|
|
import {
|
|
buildCrossSvg,
|
|
CROSS_PLOT_PAD,
|
|
summarizeCross,
|
|
} from "./B05_Profile_UI_RouteEdit_Cross_Draw";
|
|
import { readByKey, stateKey } from "../A00_Common/b_page_state";
|
|
import { stationLabel as formatStation } from "../common_util/common_util_station";
|
|
|
|
export interface CrossPreviewParams {
|
|
projectId: string;
|
|
/** 두 판이 들어앉을 오른쪽 세로 칸. */
|
|
side: HTMLElement;
|
|
/** 지금 편집값 — 측점 표기 간격만 씀(횡단은 계산 묶음 몫 · 365-3). */
|
|
request: () => {
|
|
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
|
|
min_radius_m: number;
|
|
station_interval_m: number;
|
|
station_snap?: boolean;
|
|
};
|
|
/** 위 판에 횡단이 떴다 · 비었다 — 평면도의 반폭 선을 다시 그리라는 신호. */
|
|
onShown?: () => void;
|
|
}
|
|
|
|
/** 위 판에 떠 있는 측점과 그 좌·우 횡단 반폭(m) — 평면도가 반폭 선을 그리는 재료. */
|
|
export interface CrossWatch {
|
|
chainageM: number;
|
|
leftM: number;
|
|
rightM: number;
|
|
}
|
|
|
|
export interface CrossPreviewWindow {
|
|
/** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */
|
|
open: (chainageM: number) => Promise<void>;
|
|
/** 다시 그림(묶음 지금 값) — 노선이 바뀌었으면 전후로. 보던 것이 없으면 아무 일도 없다. */
|
|
refresh: () => Promise<void>;
|
|
/** 위 판에 떠 있는 측점과 반폭. 떠 있지 않으면 null. */
|
|
watched: () => CrossWatch | null;
|
|
}
|
|
|
|
interface CrossPane {
|
|
root: HTMLElement;
|
|
/** 셈해 온 횡단을 그린다. `null` 이면 빈 화면으로 되돌린다. */
|
|
show: (preview: CrossPreviewResponse | null, intervalM: number) => void;
|
|
/** 기다리는 중임을 알린다. */
|
|
wait: (text: string) => void;
|
|
}
|
|
|
|
/**
|
|
* 우측 패널 「횡단 반폭(m)」에 **적용해 둔 값**(m). 적용한 적이 없으면 undefined — 서버가 기본값을 쓴다.
|
|
*
|
|
* B06 이 [적용]에서 노선 단위 세션 키에 적는다. 이 창은 노선 번호를 몰라 프로젝트 접두어로
|
|
* 훑고, 노선이 여럿 남아 있으면 번호가 가장 큰(최신) 것을 쓴다.
|
|
*/
|
|
function appliedHalfWidth(projectId: string): number | undefined {
|
|
const prefix = stateKey("cross-display", projectId, "");
|
|
if (!prefix) return undefined;
|
|
try {
|
|
const latest = Object.keys(window.sessionStorage)
|
|
.filter((key) => key.startsWith(prefix))
|
|
.sort((a, b) => Number(b.slice(prefix.length)) - Number(a.slice(prefix.length)))[0];
|
|
const value = Number(latest ? readByKey(latest) : NaN);
|
|
return Number.isFinite(value) && value > 0 ? value : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function createPane(title: string, empty: string): CrossPane {
|
|
const root = document.createElement("section");
|
|
root.className = "b05-routeedit__cross";
|
|
root.innerHTML = `
|
|
<div class="b05-routeedit__cross-head">
|
|
<strong class="b05-routeedit__cross-title">${title}</strong>
|
|
<span class="b05-routeedit__cross-station"></span>
|
|
</div>
|
|
<div class="b05-routeedit__cross-box"></div>
|
|
<div class="b05-routeedit__cross-foot">${empty}</div>`;
|
|
const station = root.querySelector<HTMLElement>(".b05-routeedit__cross-station")!;
|
|
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
|
|
const box = root.querySelector<HTMLElement>(".b05-routeedit__cross-box")!;
|
|
/** 그려 둔 횡단 · 그때 판 크기 · 확대·이동 — 판 크기가 바뀌면 이것으로 다시 짓는다. */
|
|
let drawn: CrossPreviewResponse | null = null;
|
|
let size = { width: 0, height: 0 };
|
|
let zoom: ZoomPanState | undefined;
|
|
function draw(preview: CrossPreviewResponse): void {
|
|
// 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라가고
|
|
// 글자가 늘어나 보이지 않는다. 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깔고,
|
|
// 펼쳐지면 아래 ResizeObserver 가 실제 크기로 다시 짓는다.
|
|
const rect = box.getBoundingClientRect();
|
|
size = { width: Math.round(rect.width), height: Math.round(rect.height) };
|
|
const plot = buildCrossSvg(preview, size.width || 240, size.height || 150);
|
|
box.replaceChildren(plot.svg);
|
|
observer.observe(box); // 창에 붙은 뒤에 지켜본다 — 붙기 전에는 isConnected 가 거짓이다.
|
|
// 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③).
|
|
// 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다).
|
|
attachSvgZoomPan({
|
|
svg: plot.svg,
|
|
layer: plot.layer,
|
|
widthPx: plot.width,
|
|
heightPx: plot.height,
|
|
pad: CROSS_PLOT_PAD,
|
|
content: plot.content,
|
|
wheelZoom: true,
|
|
initial: zoom,
|
|
onChange: (state) => (zoom = state),
|
|
});
|
|
}
|
|
const observer = new ResizeObserver(() => {
|
|
if (!box.isConnected) return observer.disconnect(); // 편집 창이 닫혔다.
|
|
const rect = box.getBoundingClientRect();
|
|
const width = Math.round(rect.width);
|
|
const height = Math.round(rect.height);
|
|
if (!drawn || width === 0 || (width === size.width && height === size.height)) return;
|
|
draw(drawn);
|
|
});
|
|
return {
|
|
root,
|
|
show(preview, intervalM) {
|
|
box.replaceChildren();
|
|
drawn = preview;
|
|
zoom = undefined;
|
|
if (!preview) {
|
|
station.textContent = "";
|
|
foot.textContent = empty;
|
|
return;
|
|
}
|
|
// 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물
|
|
// 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다.
|
|
station.textContent = formatStation(preview.chainage_m, intervalM);
|
|
draw(preview);
|
|
foot.textContent = summarizeCross(preview);
|
|
},
|
|
wait(text) {
|
|
box.replaceChildren();
|
|
drawn = null;
|
|
foot.textContent = text;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** 묶음 출력에서 그 누가거리에 가장 가까운 측점 횡단 — 묶음이 아직 안 셌으면 null. */
|
|
export function pipelineCrossAt(
|
|
pipe: DesignPipeline | null,
|
|
chainageM: number,
|
|
intervalM: number,
|
|
halfWidthM: number | undefined,
|
|
): CrossPreviewResponse | null {
|
|
const stations = pipe?.output("ground")?.stations ?? [];
|
|
if (!pipe || !stations.length) return null;
|
|
const station = stations.reduce((best, next) =>
|
|
Math.abs(next.chainageM - chainageM) < Math.abs(best.chainageM - chainageM) ? next : best,
|
|
);
|
|
const section = stationSection(station, pipe.output("cross"), {}, intervalM);
|
|
const reach = Math.max(0, ...section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)));
|
|
return {
|
|
status: "success",
|
|
chainage_m: section.chainage_m,
|
|
label: section.label ?? null,
|
|
uphill_side: section.uphill_side ?? null,
|
|
plan_radius_m: section.plan_radius_m ?? null,
|
|
curve_widening_m: section.curve_widening_m ?? null,
|
|
cross_half_width_m: halfWidthM ?? reach,
|
|
samples: section.samples,
|
|
design: (section.design as CrossPreviewResponse["design"] | undefined) ?? null,
|
|
};
|
|
}
|
|
|
|
export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow {
|
|
const current = createPane("횡단", "측점 눈금을 누르면 그 측점 횡단이 뜹니다.");
|
|
const previous = createPane("이전 횡단", "노선을 고치면 고치기 전 횡단이 여기 남습니다.");
|
|
params.side.append(current.root, previous.root);
|
|
const interval = (): number => params.request().station_interval_m;
|
|
|
|
/** 지금 보고 있는 측점(누가거리). 아직 없으면 null. */
|
|
let watching: number | null = null;
|
|
/** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료 · 그때 노선 초안(바뀌면 전후로). */
|
|
let shown: CrossPreviewResponse | null = null;
|
|
let drawnRoute: unknown = null;
|
|
|
|
function redraw(): void {
|
|
if (watching === null) return;
|
|
const route = pipe?.input("route") ?? null;
|
|
if (route !== drawnRoute && shown) previous.show(shown, interval());
|
|
drawnRoute = route;
|
|
shown = pipelineCrossAt(pipe, watching, interval(), appliedHalfWidth(params.projectId));
|
|
if (shown) current.show(shown, interval());
|
|
else current.wait("계산 중…");
|
|
params.onShown?.();
|
|
}
|
|
|
|
// 묶음 cross 차례마다 다시 그림(노선 · 계획고) — 묶음은 페이지가 세우므로 차례 번호가 날 때 붙음
|
|
let pipe: DesignPipeline | null = null;
|
|
let stopPipe = (): void => undefined;
|
|
let connected = false;
|
|
const attach = (): void => {
|
|
if (current.root.isConnected) connected = true;
|
|
else if (connected) return stop(); // 편집 창이 닫혔다
|
|
const now = pagePipeline(params.projectId);
|
|
if (now === pipe) return;
|
|
stopPipe();
|
|
pipe = now;
|
|
stopPipe =
|
|
now?.subscribe((event) => {
|
|
if (event.kind === "stage" && event.stage === "cross") redraw();
|
|
}, "평면 횡단 판") ?? ((): void => undefined);
|
|
};
|
|
const stopStore = useDesignStore(params.projectId, null).subscribe("generation", attach);
|
|
const stop = (): void => {
|
|
stopStore();
|
|
stopPipe();
|
|
};
|
|
attach();
|
|
|
|
return {
|
|
async open(chainageM) {
|
|
// 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은
|
|
// 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다.
|
|
previous.show(null, interval());
|
|
watching = chainageM;
|
|
attach();
|
|
drawnRoute = pipe?.input("route") ?? null;
|
|
shown = null;
|
|
redraw();
|
|
},
|
|
async refresh() {
|
|
attach();
|
|
redraw();
|
|
},
|
|
// 자리는 보던 측점 그대로 — 노선을 끄는 동안 평면도가 그 누가거리로 다시 짚는다.
|
|
// 반폭 = 적용해 둔 「횡단 반폭」 · 없으면 그린 원지반 선 범위 — 좌·우 같음.
|
|
watched: () =>
|
|
watching === null || !shown
|
|
? null
|
|
: {
|
|
chainageM: watching,
|
|
leftM: shown.cross_half_width_m,
|
|
rightM: shown.cross_half_width_m,
|
|
},
|
|
};
|
|
}
|