Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_ProfileStrip.ts
T
eomsangdonandClaude Opus 5.5 3c97d7c32d feat(B05): 계획노선 편집 창 종단 띠 — 노드를 놓을 때마다 지금 노선 종단 · [확인] = 띠에 보인 종단(75-1)
- 지도 아래 한 줄 띠: 원지반선 · 계획선 · 측점 눈금 · 켬/끔 단추(추천 창 「종단 반영」과 한 벌)
- 켬 = 서버 새 통로 /route/profile-preview 가 [확인]과 같은 폴리라인(같은 R · CSV 자릿수)으로 균형 종단 셈 · 끔 = /route/elevations 지반선만
- 갱신은 놓을 때 · 되돌리기 · 추천 적용 · 켬/끔 때만 · 최신 응답만 채택 · 기다리는 동안 옛 그림 흐림
- [확인]은 띠 응답을 기다린 뒤 띠에 보인 계획선(균형 종단 · 지반고)을 그대로 적용 — 추천을 손으로 고쳐도 켬이면 균형 종단

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
2026-10-07 19:45:23 +09:00

127 lines
5.6 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_RouteEdit_ProfileStrip.ts
* 계획노선 편집 창 지도 아래 **종단 띠**(PLAN 75-1) — 지금 노선 전체 연장의 원지반선 · 계획선 ·
* 측점 눈금 한 줄.
*
* 「종단 반영」(추천 창과 같은 `profileMemory`) 켬 = 계획선은 지금 노드로 서버가 다시 셈한 균형
* 종단(`/route/profile-preview` — [확인] 체인과 같은 셈). 끔 = 지반선만(계획고 = 지반고 ·
* `/route/elevations`). [확인]도 같은 규칙을 탄다(`_Apply` 의 `balanced_profile`).
*
* 갱신은 노드를 놓을 때마다(끌기 중 아님) · 최신 요청만 채택(seq) · 기다리는 동안 옛 그림은 흐리게.
* 계산 자리 = 서버 단독 — 여기는 받은 값을 그리기만 한다.
* ========================================================================== */
import {
fetchProfilePreview,
fetchRouteElevations,
type RouteReplanVertex,
} from "./B05_Profile_Api_Replan";
import { parseBalance, profileSvg } from "./B05_Profile_UI_RouteEdit_Recommend_Profile";
import { lengthOf, pointsAt, stationChainages } from "./B05_Profile_UI_RouteEdit_Slope";
type Vertex = [number, number];
/** 지반선만 그릴 때 측점 간격(m) — 균형 종단(서버 `STATION_M`)과 같음. */
const STATION_M = 20;
export interface ProfileStripParams {
root: HTMLElement;
projectId: string;
/** 추천 창 「종단 반영」과 한 벌 — 이 띠의 켬/끔 단추도 같은 값을 바꾼다. */
profileMemory: { withProfile: boolean };
/** 지금 편집값 — 노드(서버로) · 그려진 노선(지반선 측점) · 지금 추천 제약. */
now: () => { vertices: RouteReplanVertex[]; line: ReadonlyArray<Vertex>; criteria: unknown };
}
export interface ProfileStrip {
/** 노선이 바뀌었다(놓음 · 되돌리기 · 추천 적용 · 켬/끔) — 다시 묻는다. */
refresh: () => void;
/** [확인] 직전 — 지금 노선으로 띠를 맞추고 응답을 기다려, 띠에 보인 계획선이 균형 종단인지
* 돌려준다(보인 것 = 적용된 것). 띠가 못 그렸으면 켬/끔 값. */
settle: () => Promise<boolean>;
}
export function createProfileStrip(params: ProfileStripParams): ProfileStrip {
const { root, projectId, profileMemory } = params;
const toggle = root.querySelector<HTMLButtonElement>("[data-strip-toggle]")!;
const note = root.querySelector<HTMLElement>("[data-strip-note]")!;
const graph = root.querySelector<HTMLElement>("[data-strip-graph]")!;
let seq = 0;
/** 마지막으로 그렸거나 묻는 중인 값 — 같은 노선 · 같은 켬/끔이면 다시 묻지 않는다. */
let lastKey = "";
/** 마지막 요청(끝날 때까지) · 띠에 그려진 계획선이 균형 종단인가(못 그렸으면 null). */
let pending: Promise<void> = Promise.resolve();
let shownOn: boolean | null = null;
async function groundOnly(line: ReadonlyArray<Vertex>) {
const stationsM = stationChainages(lengthOf(line), STATION_M);
const z = await fetchRouteElevations(projectId, pointsAt(line, stationsM));
const known = stationsM.flatMap((s, i) => (z[i] === null ? [] : [[s, z[i]!] as const]));
if (known.length < 2) throw new Error("노선 위 지반고를 읽지 못했습니다.");
return {
stationsM: known.map(([s]) => s),
groundM: known.map(([, h]) => h),
planM: [] as number[],
};
}
function refresh(): void {
const on = profileMemory.withProfile;
toggle.setAttribute("aria-pressed", String(on));
toggle.textContent = `종단 반영 ${on ? "켬" : "끔"}`;
const { vertices, line, criteria } = params.now();
const key = JSON.stringify([on, vertices, criteria]);
if (vertices.length < 2 || line.length < 2 || key === lastKey) return;
lastKey = key;
pending = draw(on, vertices, line, criteria);
}
async function draw(
on: boolean,
vertices: RouteReplanVertex[],
line: ReadonlyArray<Vertex>,
criteria: unknown,
): Promise<void> {
const ticket = ++seq;
root.classList.add("is-waiting");
note.textContent = on ? "균형 종단을 셈하는 중…" : "지반선을 읽는 중…";
const started = performance.now();
try {
const profile = on
? parseBalance(await fetchProfilePreview(projectId, vertices, criteria))
: await groundOnly(line);
if (ticket !== seq || !root.isConnected) return; // 그 사이 노선이 또 바뀌었다.
if (!profile) throw new Error("균형 종단을 세우지 못했습니다.");
const seconds = ((performance.now() - started) / 1000).toFixed(1);
const total = profile.stationsM[profile.stationsM.length - 1];
graph.innerHTML = profileSvg(profile, true);
shownOn = on;
note.textContent =
`${on ? "계획선 = 균형 종단" : "지반선만(계획고 = 지반고)"} · ` +
`연장 ${total.toFixed(1)}m · 측점 ${profile.stationsM.length}개 · ${seconds}초`;
} catch (error) {
if (ticket !== seq) return;
lastKey = ""; // 실패 — 다음 번에 다시 묻는다.
shownOn = null;
graph.innerHTML = "";
note.textContent = error instanceof Error ? error.message : "종단을 읽지 못했습니다.";
} finally {
if (ticket === seq) root.classList.remove("is-waiting");
}
}
toggle.addEventListener("click", () => {
profileMemory.withProfile = !profileMemory.withProfile;
refresh();
});
return {
refresh,
settle: async () => {
refresh();
await pending;
return shownOn ?? profileMemory.withProfile;
},
};
}