- 새 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>
384 lines
16 KiB
TypeScript
384 lines
16 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`([다시 긋기] ·
|
||
* [노드 지움] · [선 지움] · [구역 지움] · [전체 지움]) — 숨김 = 기능 표 「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,
|
||
};
|
||
}
|