Files
Aislo/B05_Profile/B05_Profile_UI_ExpectedDraw.ts
T
eomsangdonandClaude Opus 5.5 e250517adf feat(B05 평면): 예정노선 변경 = 예정노선 페이지 긋기 · 고르기 · 메뉴 · 속성 판 그대로(한 선) · 원본 옅은 점선 켬끔 (PLAN 626-3 · 요구 535-1 · 536 · 535-3)
- 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>
2026-10-31 22:42:36 +09:00

449 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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,
};
}