Files
Aislo/B05_Profile/B05_Profile_UI_ExpectedDraw.ts
T
eomsangdonandClaude Opus 5.5 84e9151b7c feat(B05 평면): 예정노선 그리기 — 새로 긋기 · 구간 다시 긋기 · 지움 → 평면선 다시 추적 = 편집기 한 걸음 → [저장] (PLAN 626-3 · 요구 530 · 531 · D3 · D4 · D9 · D10)
- 도구줄 「예정노선 그리기」 칸(공용 표 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>
2026-10-31 21:58:15 +09:00

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,
}),
};
}