Files
Aislo/B05_Profile/B05_Profile_UI_ExpectedDraw.ts
T
eomsangdonandClaude Opus 5.5 ae6eace1ad refactor(635-1): 예정노선 키트 · 기능 표 · 지도 입력 한 함수 — oneLine · 가짜 값 걷음(요구 546)
- 새 D03_PlanAnalysis_UI_Features.ts — 노선 그리기 기능 표(요구서 546 4장 · pages d03 · b05 = use · view · hide · 까닭) · featureUse · featureOn · featureShown
- 새 D03_PlanAnalysis_UI_Kit.ts — createPlanKit(부품 엮기 · 왼쪽 칸 위치 · 지점 · 그리기 · 구역·연도 · 겹 차례 · 키 · sync) · bindPlanInput(누름 · 맴돎 · 놓음 · 우클릭 · 연도 마커 끌어 놓기 · capture = B05 켠 동안 먼저 받음)
- 예정노선 UI_Page 680 → 620 줄 · 키트로 엮음(화면 · 저장 · 되돌리기 그대로) · UI_Map 지도 입력 줄 → map.input
- UI_Draw · UI_Menu · UI_Props · UI_DrawPanel — oneLine 플래그 → 기능 표 page 읽기 · 지점 · 경위도(lonLat) · 연도 시작 = 없으면 숨김(빈 지점 · epsg 0 가짜 값 걷음) · oneLineBlock → lineLimitBlock
- B05 ExpectedDraw — page: "b05" 넘김 · NO_POINTS · 가짜 값 걷음 · 지도 입력 = bindPlanInput capture(동작 그대로) · 속성 판 경위도 빈칸 → 칸 숨김
- 시험 고침: test_626_3(oneLine 글 대조 → 기능 표 · 키트 capture 대조) · test_475(위치 칸 서클 = 키트 site.title)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:42:49 +09:00

384 lines
16 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`([다시 긋기] ·
* [노드 지움] · [선 지움] · [구역 지움] · [전체 지움]) — 숨김 = 기능 표 「b05」(`D03_PlanAnalysis_UI_Features` · 갈래 · 둘째 선 · 연도 · 지점).
* 캔버스 누름 · 맴돎 · 두 번 클릭 · 오른쪽 클릭을 먼저 받음(키트 `bindPlanInput` capture) = 계획선 · 구조물 · 등고선 고르기 · 재기 · 노드 넣기 막음 ·
* 가운데 · 오른쪽 끌기(지도 이동) · 휠은 그대로. 끔 = 지금 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 { bindPlanInput } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit";
import type { PlanMapLayer, ScreenMap } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Map";
import { createPlanMenu } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu";
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">;
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({
page: "b05",
network: () => network,
heightAt: () => z,
commit,
onMode,
});
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({
page: "b05",
network: () => network,
edit: editor,
editable: () => !busy,
commit,
heightAt: () => z,
commitPoints: (_points, next) => commit(next),
respan: draw.respan,
});
const menu = createPlanMenu({
page: "b05",
network: () => network,
edit: editor,
draw,
editable: () => open && !busy,
respan: draw.respan,
});
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 claim = (event: Event): void => {
event.stopImmediatePropagation();
event.preventDefault();
};
bindPlanInput({
canvas,
at: (event) => {
const rect = canvas.getBoundingClientRect();
return params.rotation().unrotate(event.clientX - rect.left, event.clientY - rect.top);
},
screen: map,
layers: () => layers,
redraw: params.redraw,
capture: { active: () => open, busy: () => busy },
});
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,
};
}