- B05 「예정노선 변경」 켬끔 = D03 createPlanDraw · createPlanEdit · createPlanMenu · createPlanProps 를 한 선 network 로 얹음(같은 함수 · 단추 · 단축키 · 노드 끌기 · 고른 토막 끌어 꺾임점 · Delete · [다시 긋기]) — 갈래 · 둘째 선 · 연도 · 지점은 막음/숨김 - 한 번 고침(긋기 끝 · 놓음 · 지움) → 켠 뒤 첫 고침만 확인창 → `/route/follow` → 편집기 한 걸음 · 다 지우면 새로 그을 때까지 평면 그대로 - 도구줄 「예정노선(원본)」 켬끔 — 파일입력 예정노선(안 덮음)을 옅은 점선으로(고친 것과 다를 때만) - D03 덧 인자 `oneLine`(UI_Draw · UI_DrawPanel 연도 마커 · UI_Menu · UI_Props) — 기본값 = 지금 그대로 · 긋기 칸 · 속성 판 꼴 CSS 를 `D03_PlanAnalysis_UI_DrawParts.css` 로 옮겨 그 TS 가 불러옴(D03 꼴 같음) - 옛 자체 긋기 셈 `logic/B05_Profile_ExpectedDraw.ts` 지움(D03 셈으로 갈음) · 시험 test_626_3 다시 씀(5) - 낡은 글 고침 — 설계 지형 바꿈 = 지형 칩(`/route/design-terrain`) · 「추천 [적용] 때만」 글 걷음(Router_Terrain · Router_Replan · Api_Replan · RouteEdit_Apply · 동작 그대로) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
449 lines
18 KiB
TypeScript
449 lines
18 KiB
TypeScript
/* =============================================================================
|
||
* B05_Profile_UI_ExpectedDraw.ts
|
||
* B05 평면 예정노선 변경(PLAN 626-3 · 요구 530-11 · 12 · 535-1 · 536 · 결정 D3 · D4 · D9) — 도구줄 「예정노선 변경」 켬끔.
|
||
* 켬 = 예정노선이 맨 위 · 예정노선 페이지(D03) 긋기 · 고르기 · 고치기를 **그대로**(같은 함수 · 단추 · 단축키):
|
||
* 긋기 칸 `createPlanDraw`(방식 칸 · 부채꼴 · +/− 색 · Shift · Tab · Enter · Esc · Backspace · 선 끝 이어 긋기) ·
|
||
* 고르기 겹 `createPlanEdit`(노드 → 선 → 구역 → 전체 · 끌어 고르기 · 노드 끌기 · 고른 토막 끌어 꺾임점 · Delete) ·
|
||
* 우클릭 메뉴 `createPlanMenu`(여기서 긋기 시작 · 이어 긋기 · 노드 지움 · 다시 긋기) · 속성 판 `createPlanProps`([다시 긋기] ·
|
||
* [노드 지움] · [선 지움] · [구역 지움] · [전체 지움]) — 한 선(`oneLine`) = 갈래 · 둘째 선 · 연도 · 지점 막음/숨김.
|
||
* 캔버스 누름 · 맴돎 · 두 번 클릭 · 오른쪽 클릭을 먼저 받음 = 계획선 · 구조물 · 등고선 고르기 · 재기 · 노드 넣기 막음 ·
|
||
* 가운데 · 오른쪽 끌기(지도 이동) · 휠은 그대로. 끔 = 지금 B05 평면 편집 그대로(예정노선은 보기만).
|
||
* 한 번 고침(긋기 끝 · 놓음 · 지움) → (켠 뒤 처음만) 확인창(직접 고친 평면 버림 · 예정노선 따라 통째 다시 추적) →
|
||
* `POST /route/follow`(저장 0) → `onFollow(평면, 예정노선)` = 편집기 한 걸음(되돌리기 됨) → 페이지 [저장] = 손 편집과
|
||
* 같은 길 + 초안 `expected`(`expected_route_edit.csv`). 선이 없으면(다 지움) 평면 그대로 · 새로 그을 때까지 기다림.
|
||
* 예정노선 세 벌 = 파일입력(`expected_route.csv` · 안 덮음 · 도구줄 「예정노선(원본)」 켬끔 = 옅은 점선) · 저장본
|
||
* (`expected_edited`) · 이번 고침(`edit` · 걸음 사진에 실림).
|
||
* ========================================================================== */
|
||
|
||
import { keyIs } from "../A00_Common/key_registry";
|
||
import { listenWhileAlive } from "@ui/ui_template_window_watch";
|
||
import { showToast } from "@ui/ui_template_elements";
|
||
import { createFloatPanel } from "@ui/ui_template_float_panel";
|
||
import { askConfirm } from "@ui/ui_template_modal";
|
||
import { createText } from "@ui/ui_template_text";
|
||
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 { createPlanDraw } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw";
|
||
import { createPlanEdit } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit";
|
||
import type { PlanMapLayer, ScreenMap } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Map";
|
||
import { createPlanMenu } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu";
|
||
import type { PlanPoints } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Points";
|
||
import { createPlanProps } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Props";
|
||
import { requestJson, type RoutePlanResponse } from "./B05_Profile_Api_Replan";
|
||
import { loadDesignTerrainGrid, type DesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid";
|
||
import { markShown } from "./B05_Profile_UI_RouteEdit_Marks";
|
||
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 HINT =
|
||
"[긋기] · 우클릭 「여기서 긋기 시작」 · 선 끝에서 이어 긋기 · 고르고 Delete · 노드 끌기 — 한 번 고칠 때마다 평면선을 다시 추적합니다.";
|
||
const EMPTY_HINT = "예정노선이 없습니다 — 빈 땅 우클릭 「여기서 긋기 시작」으로 새로 긋습니다.";
|
||
|
||
/** `POST /route/follow` 응답 — GET `/route/plan` 칸 이름(626-2). */
|
||
type FollowResponse = Pick<RoutePlanResponse, "planned" | "nodes" | "curves" | "node_edits">;
|
||
|
||
/** 지점 없음(B05 예정노선 = 한 선 · 메뉴의 지점 항목 숨김). */
|
||
const NO_POINTS: PlanPoints = {
|
||
panel: document.createElement("div"),
|
||
input: { draw: () => undefined },
|
||
pins: { draw: () => undefined },
|
||
render: () => undefined,
|
||
pinAt: () => null,
|
||
place: () => undefined,
|
||
remove: () => undefined,
|
||
name: () => "",
|
||
full: () => true,
|
||
};
|
||
|
||
export interface ExpectedDrawParams {
|
||
projectId: string;
|
||
canvas: HTMLCanvasElement;
|
||
/** 평면 뼈대 — 도구줄 칸(변경 켬끔 · 원본 켬끔) · 긋기 판 자리(도구 줄) · 속성 판 · 메뉴 자리(지도 칸). */
|
||
chrome: Pick<RouteEditChrome, "overlay" | "expectedDrawButton" | "expectedOriginalButton">;
|
||
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;
|
||
/** 다시 추적한 평면 · 고친 예정노선 — 편집기 한 걸음. */
|
||
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;
|
||
}
|
||
|
||
const sameLine = (a: ReadonlyArray<Vertex>, b: ReadonlyArray<Vertex>): boolean =>
|
||
JSON.stringify(a) === JSON.stringify(b);
|
||
|
||
export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
|
||
const { canvas } = params;
|
||
const { expectedDrawButton: button, expectedOriginalButton: originalButton } = params.chrome;
|
||
const viewport = params.chrome.overlay.querySelector<HTMLElement>(".b05-routeedit__canvas-wrap")!;
|
||
let original: 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;
|
||
/** 고치는 판(D03 꼴 · 한 선) · 긋기 시작 때 판(끝나면 바뀜을 봄). */
|
||
let network: PlanNetwork = { lines: [], pieces: [] };
|
||
let strokeBase: PlanNetwork | null = null;
|
||
|
||
const line = (): Vertex[] =>
|
||
edit === null ? (saved.length > 1 ? saved : original) : edit.length > 1 ? edit : original;
|
||
const z = (x: number, y: number): number => (grid ? gridHeightAt(grid.grid, x, y) : NaN);
|
||
const fromLine = (): PlanNetwork => {
|
||
const shown = line();
|
||
const vertices = shown.map(([x, y]): Vertex => [x, y]);
|
||
return { lines: shown.length > 1 ? [{ id: "main", vertices, branch: null }] : [], pieces: [] };
|
||
};
|
||
|
||
// ── D03 긋기 · 고르기 · 메뉴 · 속성 판(같은 함수) ─────────
|
||
const draw = createPlanDraw({
|
||
base: () => [],
|
||
network: () => network,
|
||
heightAt: () => z,
|
||
commit,
|
||
onMode,
|
||
oneLine: true,
|
||
});
|
||
const show = (next: PlanNetwork): void => {
|
||
network = next;
|
||
params.redraw();
|
||
};
|
||
// `heightAt` = 625-3(놓인 노드 부호 색) 덧 칸 — 합치기 앞뒤 둘 다 맞게 칸 묶음을 따로 둠
|
||
const editParams = {
|
||
network: () => network,
|
||
base: (): Vertex[] => [],
|
||
commit,
|
||
show,
|
||
commitPoints: (_points: Vertex[], next: PlanNetwork) => commit(next),
|
||
showPoints: (_points: Vertex[], next: PlanNetwork) => show(next),
|
||
enabled: () => open && draw.mode() === "idle",
|
||
editable: () => open && !busy,
|
||
heightAt: () => z,
|
||
onSelect: (): void => (props.render(), params.redraw()),
|
||
};
|
||
const editor = createPlanEdit(editParams);
|
||
const props = createPlanProps({
|
||
network: () => network,
|
||
edit: editor,
|
||
editable: () => !busy,
|
||
yearStart: () => new Date().getFullYear(),
|
||
commit,
|
||
heightAt: () => z,
|
||
epsg: () => 0,
|
||
points: () => [],
|
||
addPoint: () => undefined,
|
||
commitPoints: (_points, next) => commit(next),
|
||
respan: draw.respan,
|
||
oneLine: true,
|
||
});
|
||
const menu = createPlanMenu({
|
||
network: () => network,
|
||
points: () => [],
|
||
marks: NO_POINTS,
|
||
edit: editor,
|
||
draw,
|
||
editable: () => open && !busy,
|
||
respan: draw.respan,
|
||
oneLine: true,
|
||
});
|
||
const layers: PlanMapLayer[] = [draw.layer, editor.layer, menu.layer];
|
||
props.panel.classList.add("b05-routeedit__expected-props");
|
||
viewport.append(props.panel);
|
||
menu.mount(viewport);
|
||
|
||
// ── 켬끔 판 ─────────────────────────────────────────────
|
||
const panel = createFloatPanel({ title: "예정노선 변경", onClose: () => setOpen(false) });
|
||
panel.root.classList.add("b05-routeedit__expected-draw");
|
||
panel.root.hidden = true;
|
||
const hint = createText(HINT, "muted");
|
||
panel.body.append(draw.panel, hint);
|
||
params.chrome.overlay.querySelector(".b05-routeedit__tools")!.append(panel.root);
|
||
button.addEventListener("click", () => setOpen(!open));
|
||
const paintOriginalButton = (): void => {
|
||
originalButton.setAttribute("aria-pressed", String(showOriginal));
|
||
originalButton.classList.toggle("is-active", showOriginal);
|
||
};
|
||
paintOriginalButton();
|
||
originalButton.addEventListener("click", () => {
|
||
showOriginal = !showOriginal;
|
||
paintOriginalButton();
|
||
params.redraw();
|
||
});
|
||
|
||
function setOpen(next: boolean): void {
|
||
if (draw.mode() !== "idle") draw.setMode("idle");
|
||
open = next;
|
||
asked = false;
|
||
network = fromLine();
|
||
editor.select([]);
|
||
draw.setEditable(open);
|
||
button.setAttribute("aria-pressed", String(open));
|
||
button.classList.toggle("is-active", open);
|
||
panel.root.hidden = !open;
|
||
hint.textContent = network.lines.length ? HINT : EMPTY_HINT;
|
||
if (open) {
|
||
params.onOpen();
|
||
void loadDesignTerrainGrid(params.projectId).then(
|
||
(loaded) => ((grid = loaded), params.redraw()),
|
||
);
|
||
}
|
||
props.render();
|
||
params.redraw();
|
||
}
|
||
|
||
/** 꼴 한 걸음(D03 `commit`) — 긋는 중이면 판만 · 쉴 때(놓음 · 지움) = 다시 추적. */
|
||
function commit(next: PlanNetwork): void {
|
||
network = next;
|
||
props.render();
|
||
params.redraw();
|
||
if (draw.mode() === "idle") void apply();
|
||
}
|
||
|
||
/** 긋기 들어감 = 판 기억 · 긋기 끝 = 바뀌었으면 다시 추적. */
|
||
function onMode(): void {
|
||
if (draw.mode() === "draw") {
|
||
strokeBase ??= network;
|
||
return params.redraw();
|
||
}
|
||
const base = strokeBase;
|
||
strokeBase = null;
|
||
if (base && JSON.stringify(base.lines) !== JSON.stringify(network.lines)) void apply();
|
||
params.redraw();
|
||
}
|
||
|
||
function revert(): void {
|
||
network = fromLine();
|
||
editor.sync();
|
||
draw.sync();
|
||
props.render();
|
||
params.redraw();
|
||
}
|
||
|
||
/** 다시 추적 — 한 선 · 점 2 이상일 때만 · 켠 뒤 처음만 확인창 → `/route/follow`(저장 0) → 편집기 한 걸음. */
|
||
async function apply(): Promise<void> {
|
||
if (busy || !open) return;
|
||
const drawn = network.lines.filter((entry) => entry.vertices.length > 1);
|
||
hint.textContent = drawn.length ? HINT : EMPTY_HINT;
|
||
if (!drawn.length) return; // 다 지움 — 새로 그을 때까지 평면 그대로
|
||
if (drawn.length > 1) {
|
||
showToast("예정노선은 한 선입니다.", "info");
|
||
return revert();
|
||
}
|
||
const points = drawn[0].vertices.map(([x, y]): Vertex => [x, y]);
|
||
if (sameLine(points, line())) return;
|
||
if (!asked) {
|
||
const ok = await askConfirm({
|
||
title: "평면선 다시 추적",
|
||
message:
|
||
"고친 예정노선을 따라 평면선을 통째로 다시 추적합니다 — 직접 고친 평면(노드 · 곡선)은 버립니다. " +
|
||
"예정노선(원본)은 그대로 · [저장] 전까지는 되돌리기로 물릴 수 있습니다.",
|
||
okLabel: "다시 추적",
|
||
});
|
||
if (!ok) return revert();
|
||
asked = true;
|
||
}
|
||
busy = true;
|
||
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),
|
||
points,
|
||
);
|
||
} catch (error) {
|
||
showToast(
|
||
`평면선을 다시 추적하지 못했습니다.${error instanceof Error ? ` ${error.message}` : ""}`,
|
||
"error",
|
||
);
|
||
revert();
|
||
} finally {
|
||
busy = false;
|
||
params.redraw();
|
||
}
|
||
}
|
||
|
||
// ── 누름 · 키 — 켠 동안 먼저 받아 D03 겹에 넘김(계획선 · 구조물 · 재기로 안 샘) ──
|
||
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,
|
||
};
|
||
};
|
||
const at = (event: MouseEvent): [number, number] => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
return params.rotation().unrotate(event.clientX - rect.left, event.clientY - rect.top);
|
||
};
|
||
const claim = (event: Event): void => {
|
||
event.stopImmediatePropagation();
|
||
event.preventDefault();
|
||
};
|
||
canvas.addEventListener(
|
||
"pointerdown",
|
||
(event) => {
|
||
if (!open || event.button !== 0) return;
|
||
claim(event);
|
||
const [px, py] = at(event);
|
||
if (!busy && layers.some((layer) => layer.press?.(px, py, map(), 0, event.shiftKey)))
|
||
canvas.setPointerCapture(event.pointerId); // 끌기가 밖으로 나가도 놓음을 받음
|
||
params.redraw();
|
||
},
|
||
true,
|
||
);
|
||
canvas.addEventListener(
|
||
"pointermove",
|
||
(event) => {
|
||
if (!open || event.buttons & 6) return; // 오른쪽 · 가운데 끌기 = 지도 이동 그대로
|
||
event.stopImmediatePropagation();
|
||
const [px, py] = at(event);
|
||
const screen = map();
|
||
for (const layer of layers) layer.hover?.(px, py, screen, event.shiftKey);
|
||
params.redraw();
|
||
},
|
||
true,
|
||
);
|
||
canvas.addEventListener(
|
||
"pointerup",
|
||
(event) => {
|
||
if (!open || event.button !== 0) return;
|
||
claim(event);
|
||
const screen = map();
|
||
for (const layer of layers) layer.release?.(screen);
|
||
params.redraw();
|
||
},
|
||
true,
|
||
);
|
||
canvas.addEventListener(
|
||
"contextmenu",
|
||
(event) => {
|
||
if (!open) return;
|
||
claim(event);
|
||
const [px, py] = at(event);
|
||
if (!busy) layers.some((layer) => layer.press?.(px, py, map(), 2, event.shiftKey));
|
||
params.redraw();
|
||
},
|
||
true,
|
||
);
|
||
for (const type of ["click", "dblclick"])
|
||
canvas.addEventListener(type, (event) => open && claim(event), true);
|
||
canvas.addEventListener("pointerleave", () => {
|
||
for (const layer of layers) layer.leave?.();
|
||
if (open) params.redraw();
|
||
});
|
||
|
||
const onKey = (event: KeyboardEvent): void => {
|
||
if (!open || (event.target as HTMLElement | null)?.closest?.("input, select, textarea")) return;
|
||
if (keyIs(event, "plan.undo") || keyIs(event, "plan.redo")) return; // 되돌리기 = 페이지 사진첩
|
||
if (!(draw.key(event) || editor.key(event))) return;
|
||
claim(event);
|
||
params.redraw();
|
||
};
|
||
listenWhileAlive(window, "keydown", canvas, onKey, true);
|
||
|
||
// ── 그림 ────────────────────────────────────────────────
|
||
function paint(context: CanvasRenderingContext2D): void {
|
||
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.globalAlpha = 0.7;
|
||
context.setLineDash([2, 5]);
|
||
context.lineWidth = 2;
|
||
context.strokeStyle = themeColor("--draw-text-secondary", "#9ca3af"); // 원본 = 옅은 점선
|
||
context.stroke();
|
||
context.restore();
|
||
}
|
||
if (!open) return;
|
||
const screen = map();
|
||
for (const layer of layers) {
|
||
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,
|
||
}),
|
||
line,
|
||
original: () => original,
|
||
edit: () => edit,
|
||
screen: (vertex: Vertex) => params.rotation().rerotate(...params.toScreen(vertex)),
|
||
},
|
||
});
|
||
|
||
return {
|
||
load(nextOriginal, nextSaved) {
|
||
original = nextOriginal;
|
||
saved = nextSaved;
|
||
network = fromLine();
|
||
},
|
||
edit: () => edit,
|
||
setEdit(next) {
|
||
edit = next;
|
||
if (draw.mode() !== "idle") return; // 긋는 중 되돌림 — 긋기가 끝나면 그 판으로 다시 추적
|
||
network = fromLine();
|
||
editor.sync();
|
||
props.render();
|
||
},
|
||
line,
|
||
paint,
|
||
};
|
||
}
|