Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts
T
eomsangdonandClaude Sonnet 5.5 22eaed6509 fix(B05): 계획노선 편집 창 파란 반폭 선 길이를 「횡단 반폭」 적용값 양쪽으로 (PLAN 22-1)
- 횡단 미리보기 요청에 적용 반폭을 실어 보내고 서버가 덮은 반폭을 되돌려 줌
- 파란 선 좌·우 길이 = 그 반폭 (원지반 선 범위와 같은 값) · 적용한 적 없으면 서버 기본값
- 시험 test_b05_routeedit_cross_width.py 추가

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
2026-09-29 19:35:02 +09:00

199 lines
8.7 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_RouteEdit_Cross.ts
* 계획노선 편집 중 **측점 횡단** — 메인 창 오른쪽 세로 칸에 **붙박이 두 판**(계획서 0-9 ⑱⑲).
*
* · 위 판 = **지금 횡단**. 측점 눈금을 누르면 그 측점을 셈해 여기에 낸다.
* · 아래 판 = **이전 횡단**. 평소에는 빈 화면이고, 노선을 고쳐 **노드를 놓는 순간**
* 위 판의 것이 이리로 내려오고 새로 셈한 것이 위로 올라간다 — 전후를 나란히 본다.
*
* 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 기본 계획 횡단선 ·
* 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다.
*
* ⚠ **계획고는 편집 중에 없다** — [확인] 뒤 전 체인이 낳는 값이다. 그래서 서버가 그 측점의
* 지반고를 그대로 계획고로 놓고(지반 추종) 사면만 세운 「기본 계획 횡단」을 낸다.
*
* 셈은 **B05·B06 정본을 그대로 재사용**한다 — 측점·지반 샘플은 `generate_sections`, 설계선은
* `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
* ========================================================================== */
import { attachSvgZoomPan } from "../A00_Common/b_svg_zoom_pan";
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
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;
/** 지금 편집값 — 셈을 부르는 순간에 읽는다. */
request: () => {
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
min_radius_m: number;
station_interval_m: number;
};
/** 위 판에 횡단이 떴다 · 비었다 — 평면도의 반폭 선을 다시 그리라는 신호. */
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")!;
return {
root,
show(preview, intervalM) {
box.replaceChildren();
if (!preview) {
station.textContent = "";
foot.textContent = empty;
return;
}
// 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물
// 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다.
station.textContent = formatStation(preview.chainage_m, intervalM);
// 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라간다.
// 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깐다.
const rect = box.getBoundingClientRect();
const plot = buildCrossSvg(
preview,
Math.max(Math.round(rect.width), 240),
Math.max(Math.round(rect.height), 150),
);
box.append(plot.svg);
// 확대·이동은 **공용 조각**이 맡는다 — 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,
});
foot.textContent = summarizeCross(preview);
},
wait(text) {
box.replaceChildren();
foot.textContent = text;
},
};
}
export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow {
const current = createPane("횡단", "측점 눈금을 누르면 그 측점 횡단이 뜹니다.");
const previous = createPane("이전 횡단", "노선을 고치면 고치기 전 횡단이 여기 남습니다.");
params.side.append(current.root, previous.root);
/** 지금 보고 있는 측점(누가거리). 아직 없으면 null. */
let watching: number | null = null;
/** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료. */
let shown: CrossPreviewResponse | null = null;
/** 지금 부른 셈 — 늦게 온 응답을 새 자리에 적지 않으려고 든다. */
let ticket = 0;
async function load(chainageM: number, keepPrevious: boolean): Promise<void> {
const mine = ++ticket;
const request = params.request();
if (keepPrevious && shown) previous.show(shown, request.station_interval_m);
current.wait("읽는 중…");
try {
const preview = await fetchCrossPreview(params.projectId, {
...request,
chainage_m: chainageM,
cross_half_width_m: appliedHalfWidth(params.projectId),
});
if (mine !== ticket) return; // 그 사이 다른 측점을 눌렀다.
shown = preview;
current.show(preview, request.station_interval_m);
} catch (error) {
if (mine !== ticket) return;
shown = null;
current.wait(error instanceof Error ? error.message : "횡단을 읽지 못했습니다.");
}
params.onShown?.();
}
return {
async open(chainageM) {
// 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은
// 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다.
previous.show(null, params.request().station_interval_m);
watching = chainageM;
await load(chainageM, false);
},
async refresh() {
if (watching === null) return;
await load(watching, true);
},
// 자리는 보던 측점 그대로 — 노선을 끄는 동안 평면도가 그 누가거리로 다시 짚는다.
// 반폭은 마지막으로 셈한 횡단이 덮은 폭 — 좌·우 같고, 원지반 선 범위와 한 값이다.
watched: () =>
watching === null || !shown
? null
: {
chainageM: watching,
leftM: shown.cross_half_width_m,
rightM: shown.cross_half_width_m,
},
};
}