Files
Aislo/B05_Profile/B05_Profile_UI_ExpectedDraw.ts
T
eomsangdonandClaude Opus 5.5 9d5778bf57 feat(755-3): 예정노선 수정 켠 채 판 고침 — [저장] · [확정] 막고 알림 · 떠남 물음 · 탭 닫기 브라우저 물음
- design_save 저장 막음 자리(holdDesignSave · 화면에서 빠지면 풂) · 막음 = 저장 줄 · 가림막 앞
- ExpectedDraw = 켠 채 판 고침(사진첩 한 걸음 이상)이면 막음 글 · 공용 떠남 물음(guardProjectLeave) · 되돌려 고침 없으면 안 물음
- 시험 test_755 지킴 한 건 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:28:47 +09:00

491 lines
20 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_ExpectedDraw.ts
* B05 평면 예정노선 수정(PLAN 626-3 · 635-3 · 755 · 요구 530 · 535-1 · 536 · 546 · 589) — 예정노선 키트(`createPlanKit` 「b05」)를 엮음.
* 왼쪽 칸 = 키트 「그리기」 하나(머리 「예정노선 수정」 켬끔 · 받은 노선 켬끔 · 길이 비교 · [받은 노선으로]) —
* 자리 = `B05_Profile_UI_Panel_Plan`. 숨김 = 기능 표 「b05」(`D03_PlanAnalysis_UI_Features` · 위치 칸 숨김).
* 켬 = 지도 누름 · 키를 먼저 받음(키트 `bindPlanInput` capture) · 다른 계획노선 그림 어둡게(`--draw-map-dim` · 장면 `overAll.dim`) ·
* 가운데 · 오른쪽 끌기(지도 이동) · 휠은 그대로. 끔 = 지금 B05 평면 편집 그대로(예정노선은 보기만).
* 고칠 선 = 받은 원본 꼭짓점(`GET /route/plan/source` · 635-2) — 고친 뒤엔 고친 꼭짓점.
* 켠 동안(755) = 판만 고침 · 끊김 허용(가운데 묶음 지움) · 되돌리기 = 이 판 사진첩(Ctrl+Z · 도구줄 단추) ·
* 재기 켬 = D03 재기 겹(고치는 판 따라 · 두 점 곧은 거리 · `createPlanMeasure`).
* 끌 때 한 번 = 끊겨 있으면 알림하고 안 끔 · 바뀌었으면 확인창 → `POST /route/follow`(저장 0 · 폴리라인 두 단계) →
* `onFollow(평면, 예정노선)` = 편집기 한 걸음 → 페이지 [저장] = 손 편집과 같은 길 + 초안 `expected`(`expected_route_edit.csv`).
* [받은 노선으로] = 받은 꼭짓점으로 다시 추적 · 초안 `expected` = [](고친 예정노선 지움) — 한 걸음 · 페이지 [초기화] 는 그대로.
* 켠 채 판을 고쳤으면(755-3) = [저장] · [확정] 막고 알림(`holdDesignSave`) · 떠남 = 공용 떠남 물음 · 탭 닫기 = 브라우저 물음.
* 예정노선 세 벌 = 받은 노선(`expected_route.csv` · 안 덮음 · 고쳤으면 옅은 점선) · 저장본(`expected_edited`) · 이번 고침(`edit`).
* 같은 예정노선 다른 세트(635-4) = 흐린 겹 보기만(`B05_Profile_UI_PlanContext`) · 그 끝 = 긋기 · 끌기 붙을 자리 · 벗어나면 안내 한 줄.
* ========================================================================== */
import { keyIs } from "../A00_Common/key_registry";
import { guardProjectLeave } from "../A00_Common/b_project_switch";
import { holdDesignSave } from "../A00_Common/design/design_save";
import { listenWhileAlive } from "@ui/ui_template_window_watch";
import { createButton, showToast } from "@ui/ui_template_elements";
import { askConfirm } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { createToggle } from "@ui/ui_template_toggle";
import { themeColor } from "@ui/ui_template_palette";
import type { ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler";
import type { PlanNetwork } from "../D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan";
import { strokeCased } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint";
import { bindPlanInput, createPlanKit } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit";
import type { PlanMapLayer, ScreenMap } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Map";
import { createPlanMeasure } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure";
import { requestJson, type RoutePlanResponse } from "./B05_Profile_Api_Replan";
import { loadDesignTerrainGrid, type DesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid";
import { createPlanCoords, fillPlanPanel } from "./B05_Profile_UI_Panel_Plan";
import { createPlanContextLayer } from "./B05_Profile_UI_PlanContext";
import { markShown } from "./B05_Profile_UI_RouteEdit_Marks";
import { polylineLengthM } from "./B05_Profile_UI_RouteEdit_Render";
import type { RouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome";
import type { MapRotation } from "./B05_Profile_UI_RouteEdit_Rotate";
import { flattenServerPlan, type FlattenedPlan } from "./logic/B05_Profile_UI_RouteEdit_Edits";
type Vertex = [number, number];
const OFF_HINT = "「예정노선 수정」을 켜면 지도에서 예정노선을 고칩니다.";
const HELD =
"예정노선 수정 중 — 「예정노선 수정」을 끈 뒤 [저장]합니다(켠 동안 고친 판은 끌 때 평면선에 들어감).";
/** `POST /route/follow` 응답 — GET `/route/plan` 칸 이름(626-2). */
type FollowResponse = Pick<RoutePlanResponse, "planned" | "nodes" | "curves" | "node_edits">;
export interface ExpectedDrawParams {
projectId: string;
canvas: HTMLCanvasElement;
/** 평면 뼈대 — 속성 판 · 메뉴 자리(지도 칸). */
chrome: Pick<RouteEditChrome, "overlay">;
toScreen: (vertex: Vertex) => [number, number];
toMetric: (px: number, py: number) => Vertex;
/** 지도 돌림 — 보이는 px ↔ 그리기 px · 글자 세울 각(만든 뒤에 서므로 함수로). */
rotation: () => Pick<MapRotation, "unrotate" | "rerotate" | "uprightRad">;
pxPerMeter: () => number;
view: () => ViewState;
redraw: () => void;
/** 켬 — 편집기가 고른 노드 · 곡선 판을 풂. */
onOpen: () => void;
/** 도구줄 재기 단추 켬 — 켠 동안 D03 재기 겹이 누름을 받음(755-2). */
measuring: () => boolean;
/** 켠 동안 사진첩이 바뀜 — 도구줄 되돌리기 단추 맞춤. */
onHistory: () => void;
/** 다시 추적한 평면 · 고친 예정노선(빈 = 받은 노선으로) — 편집기 한 걸음. */
onFollow: (plan: FlattenedPlan, expected: Vertex[]) => void;
}
export interface ExpectedDraw {
/** 노선을 읽음 — 받은 예정노선 · 저장본(없으면 빈). */
load: (original: Vertex[], saved: Vertex[]) => void;
/** 이번 고침(걸음 사진 칸) — null = 연 때 그대로 · [] = 받은 노선으로. */
edit: () => Vertex[] | null;
setEdit: (edit: Vertex[] | null) => void;
/** 지금 보일 예정노선(같은 배열을 돌려줌 — 재기 높이 표가 배열로 낡음을 봄). */
line: () => Vertex[];
paint: (context: CanvasRenderingContext2D) => void;
/** 아래 그림 불투명(켠 동안 `--draw-map-dim`). */
dim: () => number;
/** 켠 동안 되돌리기 — 이 판 사진첩(끔 = null · 페이지 사진첩). */
history: () => ExpectedHistory | null;
}
export interface ExpectedHistory {
undo: () => void;
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
}
const sameLine = (a: ReadonlyArray<Vertex>, b: ReadonlyArray<Vertex>): boolean =>
JSON.stringify(a) === JSON.stringify(b);
const meters = (line: ReadonlyArray<Vertex>): string =>
Math.round(polylineLengthM(line)).toLocaleString();
export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
const { canvas } = params;
const viewport = params.chrome.overlay.querySelector<HTMLElement>(".b05-routeedit__canvas-wrap")!;
let original: Vertex[] = [];
/** 받은 원본 꼭짓점(635-2 · 조밀화 전) — 못 받으면 빈(= 조밀 점 `original`). */
let source: Vertex[] = [];
let saved: Vertex[] = [];
let edit: Vertex[] | null = null;
let open = false;
let showOriginal = true;
let busy = false;
/** 켠 뒤 확인창을 물었나 — 다시 추적 뒤 평면은 손 고침이 없음(켠 동안 계획선 안 집힘). */
let asked = false;
let grid: DesignTerrainGrid | null = null;
/** 고치는 판(예정노선 꼴 · 켠 동안 끊김 허용) · 판을 지은 꼭짓점. */
let network: PlanNetwork = { lines: [], pieces: [] };
let base: Vertex[] = [];
/** 켠 동안 사진첩(755-1) — `commit` 한 번 = 한 걸음 · 판을 다시 지으면 비움. */
let undoStack: PlanNetwork[] = [];
let redoStack: PlanNetwork[] = [];
/** 재기 겹이 받던 때(켬끔 바뀜 = 잰 것 지움). */
let measured = false;
const line = (): Vertex[] =>
edit === null ? (saved.length > 1 ? saved : original) : edit.length > 1 ? edit : original;
const received = (): Vertex[] => (source.length > 1 ? source : original);
const changed = (): boolean => line() !== original && original.length > 1;
const z = (x: number, y: number): number => (grid ? gridHeightAt(grid.grid, x, y) : NaN);
const fromLine = (): PlanNetwork => {
const shown = line();
base = (shown === original ? received() : shown).map(([x, y]): Vertex => [x, y]);
const vertices = base.map(([x, y]): Vertex => [x, y]);
undoStack = [];
redoStack = [];
return { lines: base.length > 1 ? [{ id: "main", vertices, branch: null }] : [], pieces: [] };
};
const measuring = (): boolean => open && params.measuring();
/** 켠 채 판을 고침 — [저장] · 떠남이면 잃음(755-3). */
const pending = (): boolean => open && undoStack.length > 0;
holdDesignSave(canvas, () => (pending() ? HELD : null));
guardProjectLeave(canvas, {
dirty: pending,
save: async () => (showToast(HELD, "warning"), false),
message: HELD,
});
// 속성 판 경위도 = 서버 변환(635-5)
const coords = createPlanCoords(params.projectId, () => kit.props.render());
const others = createPlanContextLayer(params.projectId, params.redraw);
// 재기 = 예정노선 페이지 재기 한 벌(첫 누름 선 위 = 선 따라 · 밖 = 두 점 곧은 거리 · 높이 = 설계 지형 격자)
const measure = createPlanMeasure(
() => z,
() => network,
);
// ── 예정노선 키트(긋기 · 고르기 · 메뉴 · 속성 판 · 칸 = 예정노선 페이지와 같은 한 벌) ──
const kit = createPlanKit({
page: "b05",
network: () => network,
commit,
show: (next) => {
network = next;
params.redraw();
},
heightAt: () => z,
lonLat: coords.lonLat,
editable: () => open && !busy,
idle: () => open && !measuring(),
onMode: () => params.redraw(),
onSelect: () => (kit.props.render(), params.redraw()),
snaps: others.joints,
onKey: (event) =>
keyIs(event, "plan.undo")
? (step("undo"), true)
: keyIs(event, "plan.redo")
? (step("redo"), true)
: measuring() && event.key === "Escape"
? (measure.clear(), true)
: false,
cards: {},
});
const { draw, edit: editor, props, menu } = kit;
const layers = kit.layers([], []);
const measureLayers = kit.layers([], [measure.layer]);
/** 지금 겹 — 재기 켬이면 재기 겹(고르기 · 노드 끌기 쉼) · 켬끔이 바뀌면 잰 것 지움 · 긋기 끝. */
const liveLayers = (): PlanMapLayer[] => {
const on = measuring();
if (on !== measured) {
measured = on;
measure.clear();
if (on && draw.mode() !== "idle") draw.setMode("idle");
}
return on ? measureLayers : layers;
};
props.panel.classList.add("b05-routeedit__expected-props");
viewport.append(props.panel);
menu.mount(viewport);
// ── 그리기 칸 — 머리 「예정노선 수정」 · 받은 노선 · 길이 비교 · [받은 노선으로] ──
const drawCard = kit.cards.find((card) => card.contains(draw.panel))!;
const toggle = createToggle({
label: "예정노선 수정",
size: 20,
onChange: (on) => void setOpen(on),
});
toggle.root.addEventListener("click", (event) => event.stopPropagation()); // 칸 접기로 안 올라감
drawCard.firstElementChild!.append(toggle.root);
const originalToggle = createToggle({
label: "받은 노선",
checked: true,
size: 20,
onChange: (on) => ((showOriginal = on), params.redraw()),
});
const resetButton = createButton({
label: "받은 노선으로",
variant: "ghost",
size: 24,
onClick: () => void follow(received(), []),
});
const compare = createText("", "muted");
const hint = createText(OFF_HINT, "muted");
const jointHint = createText("", "muted");
const row = document.createElement("div");
row.className = "b05-routeedit__expected-row";
row.append(originalToggle.root, resetButton);
drawCard.append(row, compare, jointHint, hint);
fillPlanPanel(drawCard);
function syncCard(): void {
const edited = changed();
resetButton.disabled = !edited || busy;
compare.textContent = edited
? `받은 노선 ${meters(received())} m → 고친 노선 ${meters(line())} m`
: "";
compare.hidden = !edited;
jointHint.textContent = others.note(line()) ?? "";
jointHint.hidden = !jointHint.textContent;
hint.hidden = open; // 켠 동안 설명 걷음(589-7)
}
async function setOpen(next: boolean): Promise<void> {
if (draw.mode() !== "idle") draw.setMode("idle");
if (!next && open && !(await finish())) return toggle.set(true);
open = next;
asked = false;
network = fromLine();
editor.select([]);
measure.clear();
draw.setEditable(open);
toggle.set(open);
if (open) {
params.onOpen();
void loadDesignTerrainGrid(params.projectId).then(
(loaded) => ((grid = loaded), params.redraw()),
);
}
syncCard();
props.render();
params.onHistory();
params.redraw();
}
/** 꼴 한 걸음(예정노선 `commit`) — 켠 동안 판만 · 이 판 사진첩에 쌓음(평면선 = 끌 때 한 번). */
function commit(next: PlanNetwork): void {
undoStack.push(network);
redoStack = [];
network = next;
kit.sync(); // 지운 고름 · 판 맞춤(예정노선 페이지 `refresh` 와 같음)
params.onHistory();
params.redraw();
}
function step(which: "undo" | "redo"): void {
const [from, to] = which === "undo" ? [undoStack, redoStack] : [redoStack, undoStack];
const last = from.pop();
if (!last) return;
if (draw.mode() !== "idle") draw.setMode("idle");
to.push(network);
network = last;
editor.select([]);
kit.sync();
params.onHistory();
params.redraw();
}
/** 끌 때 한 번 — 끊겼으면 알림 · 안 끔 · 바뀌었으면 다시 추적(실패 · 물음 취소 = 안 끔). 참 = 꺼도 됨. */
async function finish(): Promise<boolean> {
if (busy) return false;
const drawn = network.lines.filter((entry) => entry.vertices.length > 1);
if (drawn.length > 1) {
showToast(
`예정노선이 ${drawn.length} 토막으로 끊겨 있습니다 — 선 끝에서 이어 긋거나 [잇기]로 이은 뒤 끕니다.`,
"info",
);
return false;
}
if (!drawn.length) {
if (base.length > 1)
showToast("예정노선이 비어 고친 것을 버렸습니다 — 평면선 그대로.", "info");
return true;
}
const points = drawn[0].vertices.map(([x, y]): Vertex => [x, y]);
return sameLine(points, base) || (await follow(points, points));
}
/** 확인창(켠 뒤 처음만) → `/route/follow`(저장 0) → 편집기 한 걸음(초안 `expected` = `expected`). 참 = 됨. */
async function follow(points: Vertex[], expected: Vertex[]): Promise<boolean> {
if (busy || points.length < 2) return false;
if (!asked) {
const ok = await askConfirm({
title: "평면선 다시 추적",
message:
"고친 예정노선을 따라 평면선을 통째로 다시 추적합니다 — 직접 고친 평면(노드 · 곡선)은 버립니다. " +
"받은 노선(원본)은 그대로 · [저장] 전까지는 되돌리기로 물릴 수 있습니다.",
okLabel: "다시 추적",
});
if (!ok) return false;
asked = true;
}
busy = true;
syncCard();
params.redraw();
try {
const plan = await requestJson<FollowResponse>(
`/projects/${params.projectId}/route/follow`,
{ method: "POST", body: JSON.stringify({ points, crs: "tm" }) },
60000,
);
params.onFollow(
flattenServerPlan(plan.nodes ?? [], plan.curves ?? [], plan.node_edits),
expected,
);
return true;
} catch (error) {
showToast(
`평면선을 다시 추적하지 못했습니다.${error instanceof Error ? ` ${error.message}` : ""}`,
"error",
);
return false;
} finally {
busy = false;
syncCard();
params.redraw();
}
}
// ── 누름 · 키 — 켠 동안 먼저 받아 예정노선 겹에 넘김(계획선 · 구조물 · 재기로 안 샘) ──
const map = (): ScreenMap => {
const rotation = params.rotation();
return {
toScreen: params.toScreen,
toMetric: params.toMetric,
pxPerMeter: params.pxPerMeter(),
view: params.view(),
stations: false, // 측점 눈금 = 계획선 몫
slopes: markShown("slopes"),
uprightRad: rotation.uprightRad(),
upright(context, x, y, paint) {
const radians = rotation.uprightRad();
if (!radians) return paint();
context.save();
context.translate(x, y);
context.rotate(radians);
context.translate(-x, -y);
paint();
context.restore();
},
rerotate: rotation.rerotate,
unrotate: rotation.unrotate,
};
};
bindPlanInput({
canvas,
at: (event) => {
const rect = canvas.getBoundingClientRect();
return params.rotation().unrotate(event.clientX - rect.left, event.clientY - rect.top);
},
screen: map,
layers: liveLayers,
redraw: params.redraw,
capture: { active: () => open, busy: () => busy },
});
const onKey = (event: KeyboardEvent): void => {
if (!open) return;
if (!kit.key(event)) return;
event.stopImmediatePropagation();
event.preventDefault();
params.redraw();
};
listenWhileAlive(window, "keydown", canvas, onKey, true);
// ── 그림 ────────────────────────────────────────────────
function paint(context: CanvasRenderingContext2D): void {
others.paint(context, map()); // 다른 세트 = 흐린 겹(켬끔 상관없이 보기만)
if (showOriginal && original.length > 1 && line() !== original) {
context.save();
context.beginPath();
original.forEach((point, i) => {
const [sx, sy] = params.toScreen(point);
if (i) context.lineTo(sx, sy);
else context.moveTo(sx, sy);
});
context.lineCap = "round";
context.lineJoin = "round";
// 받은 노선 = 분홍 점선 + 검은 테(589-6 · 계획 주황 · 예정 민트 · 추천 노랑 · 등고선과 가름 · 테마 안 따름)
strokeCased(context, themeColor("--draw-map-route-3", "#ff8cbc"), 2.5, [8, 5]);
context.restore();
}
if (!open) return;
const screen = map();
for (const layer of liveLayers()) {
context.save();
layer.draw(context, screen); // 고치는 예정노선 = 맨 위(D9) · 고름 · 미리보기
context.restore();
}
}
// 검증 창구 — 켬 · 긋기 모드 · 고친 판 · 보이는 예정노선 · 받은 꼭짓점 · 사업지 좌표 → 보이는 화면 px
Object.assign(canvas, {
expectedProbe: {
state: () => ({
open,
mode: draw.mode(),
lines: network.lines.map((entry) => entry.vertices.length),
preview: draw.preview(),
selected: editor.selected()?.kind ?? null,
busy,
compare: compare.textContent,
joint: jointHint.textContent,
undo: undoStack.length,
redo: redoStack.length,
measuring: measuring(),
}),
measure: () => measure.result(),
others: others.probe,
line,
network: () => network,
original: () => original,
source: () => source,
edit: () => edit,
screen: (vertex: Vertex) => params.rotation().rerotate(...params.toScreen(vertex)),
},
});
return {
load(nextOriginal, nextSaved) {
original = nextOriginal;
saved = nextSaved;
network = fromLine();
syncCard();
void others.load().then(syncCard);
void requestJson<{ expected_source?: Vertex[] }>(
`/projects/${params.projectId}/route/plan/source`,
{},
30000,
)
.then((body) => {
source = body.expected_source ?? [];
if (!open) network = fromLine();
syncCard();
})
.catch(() => undefined); // 못 받음 = 조밀 점 그대로
},
edit: () => edit,
setEdit(next) {
edit = next;
syncCard();
if (draw.mode() !== "idle") return; // 긋는 중 = 긋기 판 그대로
network = fromLine();
editor.sync();
props.render();
syncCard();
},
line,
paint,
dim: () => (open ? parseFloat(themeColor("--draw-map-dim", "0.35")) || 0.35 : 1),
history: () =>
open
? {
undo: () => step("undo"),
redo: () => step("redo"),
canUndo: () => undoStack.length > 0,
canRedo: () => redoStack.length > 0,
}
: null,
};
}