- 도구줄 「예정노선 그리기」 칸(공용 표 b05 · 연필 아이콘) → 떠 있는 판(단추 셋 + 공용 방식 칸) · 고치는 예정노선 맨 위 · 판 켠 동안 캔버스 누름을 먼저 받음(계획선 · 구조물 · 등고선 고르기 · Shift 재기 · 노드 넣기 · 지움 막음 · 가운데 끌기 · 휠 그대로) - 긋기 = D03 과 같은 방식 칸 · 부채꼴 · +/− 색 · Shift 사이 기울기 · Tab · Enter · Esc · Backspace(셈 `logic/B05_Profile_ExpectedDraw` · 그림 D03 `paintPreview` 그대로) - 끝 → 확인창(직접 고친 평면 버림) → `POST /route/follow`(저장 0) → `restoreSnapshot(…, true)` 한 걸음(되돌리기 됨) · 지움 = 파일입력 예정노선으로 다시 추적 - 걸음 사진 · 노선 초안에 `expected`(같은 선 판정 · 세션 되살림 · 페이지 되돌리기) · [저장] = 손 편집과 같은 길 + 초안 `expected`(null 안 실음 · [] 지움) · 다시 열면 `expected_edited` - `/route/replan` · `as_initial` · `expected_route.csv` · 초기값 · 추천 저장본 안 건드림 · 시험 test_626_3_expected_edit(7) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
443 lines
17 KiB
TypeScript
443 lines
17 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_ExpectedDraw.ts
|
|
* B05 평면 예정노선 그리기(PLAN 626-3 · 요구 530-11 · 12 · 결정 D3 · D4 · D9 · D10) — 도구줄 「예정노선 그리기」 칸.
|
|
* 켬 = 떠 있는 판(새로 긋기 · 구간 다시 긋기 · 지움 + 공용 방식 칸 `map_draw_kind`) · 고치는 예정노선이 맨 위 ·
|
|
* 캔버스 왼쪽 누름 · 맴돎 · 두 번 클릭 · 오른쪽 클릭을 먼저 받음 = 계획선 · 구조물 · 등고선 고르기 · 재기(Shift+누름) ·
|
|
* 노드 넣기 · 지움 막음 · 가운데 끌기 · 휠은 그대로.
|
|
* 긋기 = D03 과 같은 방식 칸 · 부채꼴 · +/− 색 · Shift 사이 기울기 · Tab(자유지정 ↔ 길이·기울기) · Enter 끝 · Esc 취소 ·
|
|
* Backspace 마지막 점 빼기. 구간 다시 긋기 = 예정노선 위를 눌러 시작 · 다시 예정노선 위를 누르면 끝(그 사이 갈음 ·
|
|
* Enter = 취소). 지움 = 고친 예정노선 버리고 파일입력 예정노선으로.
|
|
* 끝 → 확인창(직접 고친 평면 버림 · 예정노선 따라 통째 다시 추적) → `POST /route/follow`(저장 0) →
|
|
* `onFollow(평면, 예정노선)` = 편집기 한 걸음(되돌리기 됨) → 페이지 [저장] 이 손 편집과 같은 길 + 초안 `expected`.
|
|
* 예정노선 세 벌 = 파일입력(`expected` · 안 바뀜) · 저장본(`expected_edited`) · 이번 고침(`edit` · 걸음 사진에 실림).
|
|
* 셈 = `logic/B05_Profile_ExpectedDraw` · 그림 = D03 `paintPreview` · `strokeCased` · `slopeColor` 그대로.
|
|
* ========================================================================== */
|
|
|
|
import { keyIs } from "../A00_Common/key_registry";
|
|
import { listenWhileAlive } from "@ui/ui_template_window_watch";
|
|
import { createButton, 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 { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler";
|
|
import {
|
|
createDrawKindFields,
|
|
DRAW_DEFAULT_LENGTH_M,
|
|
DRAW_DEFAULT_SLOPE_PCT,
|
|
type DrawConstraint,
|
|
} from "../A00_Common/map/map_draw_kind";
|
|
import { groundSlopePct, segmentLength } from "../D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw";
|
|
import type { Fan } from "../D03_PlanAnalysis/logic/D03_PlanAnalysis_Fan";
|
|
import {
|
|
paintPreview,
|
|
slopeColor,
|
|
strokeCased,
|
|
} from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint";
|
|
import type { DrawPreview } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw";
|
|
import { planColors } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Map";
|
|
import { requestJson, type RoutePlanResponse } from "./B05_Profile_Api_Replan";
|
|
import { loadDesignTerrainGrid, type DesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid";
|
|
import { flattenServerPlan, type FlattenedPlan } from "./logic/B05_Profile_UI_RouteEdit_Edits";
|
|
import {
|
|
leadBefore,
|
|
nearestOnLine,
|
|
pickNext,
|
|
respliceLine,
|
|
} from "./logic/B05_Profile_ExpectedDraw";
|
|
|
|
type Vertex = [number, number];
|
|
|
|
/** 예정노선 위로 볼 반경(px) — D03 붙음 반경과 같음. */
|
|
const SNAP_PX = 12;
|
|
const IDLE_HINT = "새로 긋기 · 구간 다시 긋기 · 지움 중 하나를 고릅니다.";
|
|
const KEYS_HINT = "Enter 끝 · Esc 취소 · Backspace 점 빼기 · Tab 방식 · Shift 사이 기울기";
|
|
|
|
/** `POST /route/follow` 응답 — GET `/route/plan` 칸 이름(626-2). */
|
|
type FollowResponse = Pick<RoutePlanResponse, "planned" | "nodes" | "curves" | "node_edits">;
|
|
|
|
export interface ExpectedDrawParams {
|
|
projectId: string;
|
|
canvas: HTMLCanvasElement;
|
|
/** 도구줄 칸 · 판을 얹을 도구 줄 칸. */
|
|
button: HTMLButtonElement;
|
|
host: HTMLElement;
|
|
toScreen: (vertex: Vertex) => [number, number];
|
|
toMetric: (px: number, py: number) => Vertex;
|
|
/** 보이는 화면 px → 그리기 px(돌린 지도). */
|
|
unrotate: (px: number, py: number) => [number, number];
|
|
pxPerMeter: () => number;
|
|
uprightRad: () => number;
|
|
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;
|
|
/** 검증 창구 — 판 켬 · 긋는 중 · 미리보기. */
|
|
state: () => {
|
|
open: boolean;
|
|
stroke: string | null;
|
|
points: number;
|
|
preview: DrawPreview | null;
|
|
};
|
|
}
|
|
|
|
type Stroke =
|
|
| { mode: "new"; points: Vertex[] }
|
|
| { mode: "respan"; start: { atM: number; point: Vertex } | null; through: Vertex[] };
|
|
|
|
export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
|
|
const { canvas } = params;
|
|
let original: Vertex[] = [];
|
|
let saved: Vertex[] = [];
|
|
let edit: Vertex[] | null = null;
|
|
let open = false;
|
|
let busy = false;
|
|
let stroke: Stroke | null = null;
|
|
let preview: DrawPreview | null = null;
|
|
let fan: Fan | null = null;
|
|
let hoverAt: { px: number; py: number; shift: boolean } | null = null;
|
|
let grid: DesignTerrainGrid | null = null;
|
|
let kind: DrawConstraint = "both"; // 기억 안 함(B9)
|
|
let lengthM = DRAW_DEFAULT_LENGTH_M;
|
|
let slopePct = DRAW_DEFAULT_SLOPE_PCT;
|
|
|
|
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 panel = createFloatPanel({ title: "예정노선 그리기", onClose: () => setOpen(false) });
|
|
panel.root.classList.add("b05-routeedit__expected-draw");
|
|
panel.root.hidden = true;
|
|
const hint = createText(IDLE_HINT, "muted");
|
|
const kindFields = createDrawKindFields({
|
|
value: { kind, lengthM, slopePct },
|
|
onChange: (value) => {
|
|
({ kind, lengthM, slopePct } = value);
|
|
rehover();
|
|
},
|
|
});
|
|
const row = document.createElement("div");
|
|
row.className = "b05-routeedit__expected-row";
|
|
row.append(
|
|
createButton({ label: "새로 긋기", variant: "filled", size: 28, onClick: () => begin("new") }),
|
|
createButton({
|
|
label: "구간 다시 긋기",
|
|
variant: "ghost",
|
|
size: 28,
|
|
onClick: () => begin("respan"),
|
|
}),
|
|
createButton({ label: "지움", variant: "ghost", size: 28, onClick: () => void clear() }),
|
|
);
|
|
panel.body.append(row, kindFields.root, hint);
|
|
params.host.append(panel.root);
|
|
params.button.addEventListener("click", () => setOpen(!open));
|
|
|
|
function setOpen(next: boolean): void {
|
|
open = next;
|
|
stroke = null;
|
|
preview = fan = null;
|
|
params.button.setAttribute("aria-pressed", String(open));
|
|
params.button.classList.toggle("is-active", open);
|
|
panel.root.hidden = !open;
|
|
hint.textContent = IDLE_HINT;
|
|
canvas.style.removeProperty("cursor");
|
|
if (open) {
|
|
params.onOpen();
|
|
void loadDesignTerrainGrid(params.projectId).then((loaded) => (grid = loaded));
|
|
}
|
|
params.redraw();
|
|
}
|
|
|
|
function begin(mode: Stroke["mode"]): void {
|
|
if (busy) return;
|
|
if (mode === "respan" && line().length < 2)
|
|
return showToast("다시 그을 예정노선이 없습니다 — 새로 긋기를 쓰세요.", "info");
|
|
stroke = mode === "new" ? { mode, points: [] } : { mode, start: null, through: [] };
|
|
preview = fan = null;
|
|
hint.textContent =
|
|
(mode === "new"
|
|
? "첫 점을 누릅니다 · "
|
|
: "예정노선 위를 눌러 시작 · 다시 예정노선 위를 누르면 끝 · ") + KEYS_HINT;
|
|
canvas.style.cursor = "crosshair";
|
|
params.redraw();
|
|
}
|
|
|
|
function stop(message = IDLE_HINT): void {
|
|
stroke = null;
|
|
preview = fan = null;
|
|
hint.textContent = message;
|
|
canvas.style.removeProperty("cursor");
|
|
params.redraw();
|
|
}
|
|
|
|
// ── 셈 ──────────────────────────────────────────────────
|
|
/** 화면 px 둘레 예정노선 위 자리 — 멀면 null. */
|
|
function onLine(px: number, py: number): { atM: number; point: Vertex } | null {
|
|
const shown = line();
|
|
if (shown.length < 2) return null;
|
|
const hit = nearestOnLine(shown, params.toMetric(px, py));
|
|
const [sx, sy] = params.toScreen(hit.point);
|
|
return Math.hypot(sx - px, sy - py) <= SNAP_PX ? hit : null;
|
|
}
|
|
|
|
/** 그은 점(구간 다시 긋기 = 시작 자리 + 속 점). */
|
|
function strokePoints(): Vertex[] {
|
|
if (!stroke) return [];
|
|
if (stroke.mode === "new") return stroke.points;
|
|
return stroke.start ? [stroke.start.point, ...stroke.through] : [];
|
|
}
|
|
|
|
function previewAt(px: number, py: number, shift: boolean): DrawPreview | null {
|
|
const points = strokePoints();
|
|
const from = points[points.length - 1];
|
|
if (!stroke || !from) return null;
|
|
const cursor = params.toMetric(px, py);
|
|
const prev =
|
|
points.length > 1
|
|
? points[points.length - 2]
|
|
: stroke.mode === "respan" && stroke.start
|
|
? leadBefore(line(), stroke.start.atM)
|
|
: null;
|
|
const end = stroke.mode === "respan" ? onLine(px, py) : null;
|
|
const pick = pickNext(kind, from, cursor, lengthM, slopePct, z, prev, shift);
|
|
fan = pick.fan;
|
|
const point = end ? end.point : pick.point;
|
|
const at = point ?? cursor;
|
|
const length = segmentLength(from, at);
|
|
return {
|
|
point: at,
|
|
lengthM: length,
|
|
slopePct: groundSlopePct(from, at, z),
|
|
note: end ? "예정노선에 붙음 — 끝" : pick.note,
|
|
blocked: !point || length <= 0,
|
|
snapped: !!end,
|
|
lengthOnly: pick.lengthOnly && !end,
|
|
onLine: null,
|
|
};
|
|
}
|
|
|
|
function rehover(): void {
|
|
if (hoverAt) preview = previewAt(hoverAt.px, hoverAt.py, hoverAt.shift);
|
|
params.redraw();
|
|
}
|
|
|
|
function press(px: number, py: number, shift: boolean): void {
|
|
if (!stroke || busy) return void (open && !stroke && showToast(IDLE_HINT, "info"));
|
|
if (stroke.mode === "new" && !stroke.points.length) {
|
|
stroke.points.push(params.toMetric(px, py));
|
|
return rehover();
|
|
}
|
|
if (stroke.mode === "respan" && !stroke.start) {
|
|
stroke.start = onLine(px, py);
|
|
if (!stroke.start) showToast("예정노선 위를 눌러 시작합니다.", "info");
|
|
return rehover();
|
|
}
|
|
preview = previewAt(px, py, shift);
|
|
if (!preview || preview.blocked)
|
|
return showToast(preview?.note ?? "여기에는 점을 찍을 수 없습니다.", "info");
|
|
if (stroke.mode === "respan" && preview.snapped) {
|
|
const end = onLine(px, py)!;
|
|
const next = respliceLine(line(), stroke.start!.atM, end.atM, stroke.through);
|
|
return void follow(next);
|
|
}
|
|
if (stroke.mode === "new") stroke.points.push(preview.point);
|
|
else stroke.through.push(preview.point);
|
|
rehover();
|
|
}
|
|
|
|
function finish(): void {
|
|
if (!stroke) return;
|
|
if (stroke.mode === "respan")
|
|
return stop("구간 다시 긋기는 예정노선 위를 눌러 끝 — 취소했습니다.");
|
|
if (stroke.points.length < 2) return void showToast("점이 2개 이상이어야 합니다.", "info");
|
|
void follow([...stroke.points]);
|
|
}
|
|
|
|
/** 다시 추적 — 확인창 → `/route/follow`(저장 0) → 편집기 한 걸음. `next` = [] 면 파일입력 예정노선. */
|
|
async function follow(next: Vertex[]): Promise<void> {
|
|
stop();
|
|
const ok = await askConfirm({
|
|
title: "평면선 다시 추적",
|
|
message:
|
|
(next.length ? "고친 예정노선" : "파일입력 예정노선") +
|
|
"을 따라 평면선을 통째로 다시 추적합니다 — 직접 고친 평면(노드 · 곡선)은 버립니다. " +
|
|
"[저장] 전까지는 되돌리기로 물릴 수 있습니다.",
|
|
okLabel: "다시 추적",
|
|
});
|
|
if (!ok) return;
|
|
busy = true;
|
|
try {
|
|
const points = next.length ? next : original;
|
|
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),
|
|
next,
|
|
);
|
|
} catch (error) {
|
|
showToast(
|
|
`평면선을 다시 추적하지 못했습니다.${error instanceof Error ? ` ${error.message}` : ""}`,
|
|
"error",
|
|
);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
async function clear(): Promise<void> {
|
|
if (busy) return;
|
|
if (line() === original) return showToast("고친 예정노선이 없습니다.", "info");
|
|
await follow([]);
|
|
}
|
|
|
|
// ── 누름 · 키 — 판이 켜진 동안 먼저 받음(계획선 · 구조물 · 재기로 안 샘) ──
|
|
const at = (event: MouseEvent): [number, number] => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
return params.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);
|
|
press(...at(event), event.shiftKey);
|
|
},
|
|
true,
|
|
);
|
|
canvas.addEventListener(
|
|
"pointermove",
|
|
(event) => {
|
|
if (!open || event.buttons) return; // 가운데 끌기 = 지도 이동 그대로
|
|
event.stopImmediatePropagation();
|
|
const [px, py] = at(event);
|
|
hoverAt = { px, py, shift: event.shiftKey };
|
|
if (stroke) rehover();
|
|
},
|
|
true,
|
|
);
|
|
for (const type of ["pointerup", "click", "dblclick", "contextmenu"])
|
|
canvas.addEventListener(
|
|
type,
|
|
(event) => {
|
|
if (open && (event as MouseEvent).button !== 1) claim(event);
|
|
},
|
|
true,
|
|
);
|
|
canvas.addEventListener("pointerleave", () => {
|
|
hoverAt = null;
|
|
if (preview) ((preview = null), params.redraw());
|
|
});
|
|
|
|
const onKey = (event: KeyboardEvent): void => {
|
|
if (!open || (event.target as HTMLElement | null)?.closest?.("input, select, textarea")) return;
|
|
if (!stroke) return;
|
|
if (event.key === "Shift") {
|
|
if (hoverAt) ((hoverAt.shift = event.type === "keydown"), rehover());
|
|
return;
|
|
}
|
|
if (event.type !== "keydown") return;
|
|
if (keyIs(event, "plan.drawKind")) {
|
|
kind = kind === "both" ? "free" : "both"; // 단독 고정이면 길이·기울기로(625-1)
|
|
kindFields.set({ kind });
|
|
rehover();
|
|
} else if (keyIs(event, "plan.drawFinish")) finish();
|
|
else if (event.key === "Escape") stop();
|
|
else if (keyIs(event, "plan.drawBack")) {
|
|
const points = stroke.mode === "new" ? stroke.points : stroke.through;
|
|
if (!points.pop() && stroke.mode === "respan") stroke.start = null;
|
|
rehover();
|
|
} else return;
|
|
claim(event);
|
|
};
|
|
listenWhileAlive(window, "keydown", canvas, onKey, true);
|
|
listenWhileAlive(window, "keyup", canvas, onKey, true);
|
|
|
|
// ── 그림 ────────────────────────────────────────────────
|
|
const map = {
|
|
toScreen: params.toScreen,
|
|
pxPerMeter: 1,
|
|
upright(context: CanvasRenderingContext2D, x: number, y: number, paint: () => void): void {
|
|
const radians = params.uprightRad();
|
|
if (!radians) return paint();
|
|
context.save();
|
|
context.translate(x, y);
|
|
context.rotate(radians);
|
|
context.translate(-x, -y);
|
|
paint();
|
|
context.restore();
|
|
},
|
|
};
|
|
const path = (context: CanvasRenderingContext2D, points: ReadonlyArray<Vertex>): void => {
|
|
context.beginPath();
|
|
points.forEach((point, i) => {
|
|
const [sx, sy] = params.toScreen(point);
|
|
if (i) context.lineTo(sx, sy);
|
|
else context.moveTo(sx, sy);
|
|
});
|
|
};
|
|
|
|
function paint(context: CanvasRenderingContext2D): void {
|
|
if (!open) return;
|
|
const colors = planColors();
|
|
map.pxPerMeter = params.pxPerMeter();
|
|
context.save();
|
|
context.lineCap = "round";
|
|
context.lineJoin = "round";
|
|
path(context, line()); // 고치는 예정노선 = 맨 위(D9)
|
|
strokeCased(context, colors.measure, 2.5, [8, 5]);
|
|
const points = strokePoints();
|
|
if (points.length > 1) {
|
|
path(context, points);
|
|
strokeCased(context, colors.active, 3);
|
|
}
|
|
points.forEach((point, i) => {
|
|
const [sx, sy] = params.toScreen(point);
|
|
context.beginPath();
|
|
context.arc(sx, sy, 3.5, 0, Math.PI * 2);
|
|
context.fillStyle = i ? slopeColor(groundSlopePct(points[i - 1], point, z)) : colors.active;
|
|
context.fill();
|
|
});
|
|
const from = points[points.length - 1];
|
|
if (from && preview) paintPreview(context, map, from, preview, fan ? { lengthM, fan } : null);
|
|
context.restore();
|
|
}
|
|
|
|
return {
|
|
load(nextOriginal, nextSaved) {
|
|
original = nextOriginal;
|
|
saved = nextSaved;
|
|
},
|
|
edit: () => edit,
|
|
setEdit(next) {
|
|
edit = next;
|
|
},
|
|
line,
|
|
paint,
|
|
state: () => ({
|
|
open,
|
|
stroke: stroke?.mode ?? null,
|
|
points: strokePoints().length,
|
|
preview,
|
|
}),
|
|
};
|
|
}
|