Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts
T
eomsangdonandClaude Opus 5.5 c77e44ab07 refactor(B05·B06 한 페이지): 노선 id 한 칸 — 출처 다섯을 페이지 자료 줄 하나로 (386-2b)
- design_page_route 새로(칸 · 끄는 모듈 없음) · 채움 = design_page_data pageSectionContext(횡단 context · 세션 캐시) 하나
- 읽는 곳 = 종단 보기 [저장] 몫 · 횡단 보기 · 종단도 render · 평면 횡단 판(노선 번호 훑기 지움) · 3D · 측점 그림
- 페이지 떠남에 칸 비움 · 저장 정본 바뀜엔 그대로
- design_profile 700줄 앞 쪼갬(design_profile_context — 만들 때 넘기는 것 · 미리보기 대기)
- 시험 test_384_2b · 평면 횡단 판 시험 = 페이지 노선 값만

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-17 19:08:37 +09:00

413 lines
18 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_RouteEdit_Cross.ts
* 계획노선 편집 중 **측점 횡단** — 메인 창 오른쪽 세로 칸에 **붙박이 두 판**(계획서 0-9 ⑱⑲).
*
* · 위 판 = **지금 횡단**. 측점 눈금을 누르면 그 측점 횡단을 여기에 낸다.
* · 아래 판 = **이전 횡단**. 평소에는 빈 화면이고, 노선을 고쳐 **노드를 놓는 순간**
* 위 판의 것이 이리로 내려오고 새 횡단이 위로 올라간다 — 전후를 나란히 본다.
*
* 그림 = B06 횡단 카드 읽기 전용(393-2 · `_Cross_Card` — 구조물 · 포장 · 면적 · 축) · 상세가 아직 없으면
* 원지반 · 계획 횡단선 · 성토사면 길이만(`_Cross_Draw`).
*
* 값 = 계산 묶음 그 측점(365-3) — 측점 · 지반 표본 = ground 단계 · 계획고 · 설계선 = 종단 → cross 단계
* (B06 카드 · 새 노선 상세와 같은 함수 `stationSection`) · 노선 · 계획고가 바뀔 때마다(cross 차례) 다시 그림 ·
* 서버 왕복 없음. 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
*
* 고름 연동(382-2) — 공용 고름 한 칸(`design_selection`) 구독: 일반측점 고름 = 그 측점 횡단 · 평면 법선 ·
* 구조물 고름 = 평면 그 자리 마름모만 · 평면 눈금 클릭 = 공용 고름에 적음(`from` b05-plan · 제 알림 거름).
* 보는 측점 = 측점 열쇠(grid:k) — 묶음 차례마다 열쇠로 다시 짚음(노선을 고쳐도 같은 측점).
* 파란 법선 폭 = 그 측점 발자국 기록(가게 `footprint.records`) 비탈 끝 좌 · 우 · 깎기 여부(색).
* ========================================================================== */
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 type {
PipelineStation,
PlanFootprint,
} from "../A00_Common/design/logic/design_pipeline_types";
import {
nearestWithin,
selectDesign,
subscribeSelection,
type DesignSelection,
} from "../A00_Common/design/design_selection";
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 { readStateRaw } from "../A00_Common/b_page_state";
import { pageRouteId } from "../A00_Common/design/design_page_route";
import { stationLabel as formatStation } from "../common_util/common_util_station";
import { sidePanelsOf } from "./B05_Profile_UI_RouteEdit_Chrome";
import type { FootprintRecord } from "./logic/B05_Profile_PlanFootprint";
import {
routeCrossCard,
routeCrossSource,
type RouteCrossSource,
} from "./B05_Profile_UI_RouteEdit_Cross_Card";
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;
/** 비탈 끝 깎기(절토) 여부 — 발자국 기록이 없으면 없음(옛 파랑 · 반폭). */
leftCut?: boolean;
rightCut?: boolean;
/** 구조물 · 관 자리 — 법선 없이 마름모만. */
structure?: true;
}
/** 공용 고름에 적는 이름 — 평면 눈금 클릭(제 알림은 거름). */
export const PLAN_PICK_FROM = "b05-plan";
/** 법선을 긋는 측점 갈래 — 일반측점(시점 · 종점 포함). */
const isGridStation = (station: PipelineStation): boolean =>
station.kind === "grid" || station.kind === "end";
export interface CrossPreviewWindow {
/** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */
open: (chainageM: number) => Promise<void>;
/** 다시 그림(묶음 지금 값) — 노선이 바뀌었으면 전후로. 보던 것이 없으면 아무 일도 없다. */
refresh: () => Promise<void>;
/** 고른 측점과 폭(발자국 비탈 끝 · 없으면 반폭) · 구조물이면 마름모. 고른 것 없으면 null. */
watched: () => CrossWatch | null;
}
interface CrossPane {
root: HTMLElement;
/** 셈해 온 횡단을 그린다(단면 사본이 있으면 B06 카드 · 393-2). `null` 이면 빈 화면으로 되돌린다. */
show: (
preview: CrossPreviewResponse | null,
intervalM: number,
source?: RouteCrossSource | null,
) => void;
/** 기다리는 중임을 알린다. */
wait: (text: string) => void;
}
/**
* 우측 패널 「횡단 반폭(m)」에 **적용해 둔 값**(m). 적용한 적이 없으면 undefined — 서버가 기본값을 쓴다.
* B06 이 [적용]에서 노선 단위 세션 키에 적음 · 노선 = 페이지 노선 id 한 칸(386-2b).
*/
function appliedHalfWidth(projectId: string): number | undefined {
const routeId = pageRouteId(projectId);
const value = Number(routeId === null ? NaN : readStateRaw("cross-display", projectId, routeId));
return Number.isFinite(value) && value > 0 ? value : 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;
/** B06 카드 재료(393-2) · 카드 머리 · 발 높이(그림 높이 = 판 − 이것) · 측점 간격. */
let source: RouteCrossSource | null = null;
let chrome = 0;
let interval = 20;
function drawCard(preview: CrossPreviewResponse, from: RouteCrossSource): void {
const plotHeight = (): number => Math.max(120, (box.clientHeight || 150) - chrome); // 테두리 안쪽
const build = (): HTMLElement => {
const card = routeCrossCard(
from,
interval,
preview.cross_half_width_m,
size.width || 240,
plotHeight(),
);
box.replaceChildren(card);
return card;
};
const card = build();
const extra = card.offsetHeight - plotHeight();
if (size.height && extra > 0 && extra !== chrome) {
chrome = extra;
build();
}
}
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) };
observer.observe(box); // 창에 붙은 뒤에 지켜본다 — 붙기 전에는 isConnected 가 거짓이다.
if (source) return drawCard(preview, source);
const plot = buildCrossSvg(preview, size.width || 240, size.height || 150);
box.replaceChildren(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,
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, from = null) {
box.replaceChildren();
drawn = preview;
source = preview ? from : null;
interval = intervalM;
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;
source = 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: { key: string | null; chainageM: number; structure: boolean } | null = null;
/** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료 · 그때 노선 초안(바뀌면 전후로). */
let shown: CrossPreviewResponse | null = null;
let shownSource: RouteCrossSource | null = null;
let drawnRoute: unknown = null;
const stationsNow = (): PipelineStation[] => pipe?.output("ground")?.stations ?? [];
/** 열쇠로 다시 짚은 지금 측점 — 열쇠가 없으면 들고 있던 누가거리 근처. */
function located(): PipelineStation | null {
if (!watching) return null;
const list = stationsNow();
const key = watching.key;
const at = watching.chainageM;
return (
(key ? list.find((station) => station.key === key) : undefined) ??
nearestWithin(list, at, (station) => station.chainageM) ??
null
);
}
function redraw(): void {
if (watching === null || watching.structure) return;
const route = pipe?.input("route") ?? null;
if (route !== drawnRoute && shown)
previous.show(
shown,
interval(),
shownSource && {
...shownSource,
section: {
...shownSource.section,
station_id: shownSource.section.station_id.replace("b05-cur-", "b05-prev-"),
},
},
);
drawnRoute = route;
const at = located()?.chainageM ?? watching.chainageM;
shown = pipelineCrossAt(pipe, at, interval(), appliedHalfWidth(params.projectId));
shownSource = shown && routeCrossSource(params.projectId, pipe, shown.chainage_m, "cur");
if (shown) current.show(shown, interval(), shownSource);
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();
stopPick();
};
/** 그 측점 횡단을 위 판에 · 아래 판은 비움(전후 비교는 노선을 고쳤을 때만) · 접힌 판은 폄. */
function showStation(key: string | null, chainageM: number): void {
// 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은
// 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다.
previous.show(null, interval());
watching = { key, chainageM, structure: false };
drawnRoute = pipe?.input("route") ?? null;
shown = null;
redraw();
const panels = sidePanelsOf.get(params.side);
if (panels && panels.current() === null) panels.open("cross", false); // 열되 기억 안 함(393-1)
}
/** 다른 보기(종단 · 3D · 목록 · 횡단 카드) 고름 → 일반측점이면 그 측점 횡단 · 구조물이면 마름모. */
function follow(selection: DesignSelection): void {
attach();
const at = selection.chainageM;
if (at === null) {
watching = null;
shown = null;
current.show(null, interval());
previous.show(null, interval());
params.onShown?.();
return;
}
const station = nearestWithin(stationsNow(), at, (entry) => entry.chainageM);
if (!selection.structureId && (!station || isGridStation(station)))
return showStation(station?.key ?? null, station?.chainageM ?? at);
watching = { key: station?.key ?? null, chainageM: station?.chainageM ?? at, structure: true };
params.onShown?.();
}
const stopPick = subscribeSelection(params.projectId, (selection, from) => {
if (from !== PLAN_PICK_FROM) follow(selection);
});
attach();
/** 그 측점 발자국 기록(열쇠 · 없으면 누가거리) — 발자국 단계 전이면 없음. */
const footprintAt = (key: string | null, chainageM: number): FootprintRecord | undefined => {
const footprint = useDesignStore(params.projectId, null).get("footprint") as
PlanFootprint | null | undefined;
const records = (footprint?.records ?? []) as FootprintRecord[];
return (
(key ? records.find((record) => record.key === key) : undefined) ??
nearestWithin(records, chainageM, (record) => record.chainage_m)
);
};
return {
async open(chainageM) {
attach();
const list = stationsNow();
const station = list.length
? list.reduce((best, next) =>
Math.abs(next.chainageM - chainageM) < Math.abs(best.chainageM - chainageM)
? next
: best,
)
: null;
const at = station?.chainageM ?? chainageM;
showStation(station?.key ?? null, at);
selectDesign(
params.projectId,
{ chainageM: at, structureId: null },
{ from: PLAN_PICK_FROM },
);
},
async refresh() {
attach();
redraw();
},
// 자리 = 열쇠로 다시 짚은 측점 — 노선을 끄는 동안 평면도가 그 누가거리로 다시 짚는다.
// 폭 = 그 측점 발자국 비탈 끝 좌 · 우 · 기록이 없으면 「횡단 반폭」(없으면 원지반 선 범위) 양쪽.
watched: () => {
if (!watching) return null;
const station = located();
const chainageM = station?.chainageM ?? watching.chainageM;
if (watching.structure || (station && !isGridStation(station)))
return { chainageM, leftM: 0, rightM: 0, structure: true };
const record = footprintAt(station?.key ?? watching.key, chainageM);
if (record)
return {
chainageM,
leftM: record.left_end_m,
rightM: -record.right_end_m,
leftCut: record.left_cut,
rightCut: record.right_cut,
};
if (!shown) return null;
return { chainageM, leftM: shown.cross_half_width_m, rightM: shown.cross_half_width_m };
},
};
}