432 lines
18 KiB
TypeScript
432 lines
18 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_ExpectedDraw.ts
|
|
* B05 평면 예정노선 수정(PLAN 626-3 · 635-3 · 요구 530 · 535-1 · 536 · 546) — 예정노선 키트(`createPlanKit` 「b05」)를 엮음.
|
|
* 왼쪽 칸 = 키트 「위치」(주소 · 장소 찾기 → 지도 이동만) · 「그리기」(머리 「예정노선 수정」 켬끔 · 받은 노선 켬끔 ·
|
|
* 길이 비교 · [받은 노선으로]) — 자리 = `B05_Profile_UI_Panel_Plan`. 숨김 = 기능 표 「b05」(`D03_PlanAnalysis_UI_Features`).
|
|
* 켬 = 지도 누름 · 키를 먼저 받음(키트 `bindPlanInput` capture) · 다른 계획노선 그림 어둡게(`--draw-map-dim` · 장면 `overAll.dim`) ·
|
|
* 가운데 · 오른쪽 끌기(지도 이동) · 휠은 그대로. 끔 = 지금 B05 평면 편집 그대로(예정노선은 보기만).
|
|
* 고칠 선 = 받은 원본 꼭짓점(`GET /route/plan/source` · 635-2) — 고친 뒤엔 고친 꼭짓점.
|
|
* 한 번 고침(긋기 끝 · 놓음 · 지움) → (켠 뒤 처음만) 확인창 → `POST /route/follow`(저장 0) → `onFollow(평면, 예정노선)` =
|
|
* 편집기 한 걸음(되돌리기 됨) → 페이지 [저장] = 손 편집과 같은 길 + 초안 `expected`(`expected_route_edit.csv`).
|
|
* [받은 노선으로] = 받은 꼭짓점으로 다시 추적 · 초안 `expected` = [](고친 예정노선 지움) — 한 걸음 · 페이지 [초기화] 는 그대로.
|
|
* 예정노선 세 벌 = 받은 노선(`expected_route.csv` · 안 덮음 · 고쳤으면 옅은 점선) · 저장본(`expected_edited`) · 이번 고침(`edit`).
|
|
* 같은 예정노선 다른 세트(635-4) = 흐린 겹 보기만(`B05_Profile_UI_PlanContext`) · 그 끝 = 긋기 · 끌기 붙을 자리 · 벗어나면 안내 한 줄.
|
|
* ========================================================================== */
|
|
|
|
import { keyIs } from "../A00_Common/key_registry";
|
|
import { listenWhileAlive } from "@ui/ui_template_window_watch";
|
|
import { createButton, showToast } from "@ui/ui_template_elements";
|
|
import { askConfirm } from "@ui/ui_template_modal";
|
|
import { createText } from "@ui/ui_template_text";
|
|
import { createToggle } from "@ui/ui_template_toggle";
|
|
import { themeColor } from "@ui/ui_template_palette";
|
|
import type { ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
|
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler";
|
|
import type { PlanNetwork } from "../D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan";
|
|
import { bindPlanInput, createPlanKit } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit";
|
|
import type { ScreenMap } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Map";
|
|
import { createPlaceFind } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Site";
|
|
import { requestJson, type RoutePlanResponse } from "./B05_Profile_Api_Replan";
|
|
import { loadDesignTerrainGrid, type DesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid";
|
|
import { createPlanCoords, fillPlanPanel } from "./B05_Profile_UI_Panel_Plan";
|
|
import { createPlanContextLayer } from "./B05_Profile_UI_PlanContext";
|
|
import { markShown } from "./B05_Profile_UI_RouteEdit_Marks";
|
|
import { polylineLengthM } from "./B05_Profile_UI_RouteEdit_Render";
|
|
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 = "예정노선이 없습니다 — 빈 땅 우클릭 「여기서 긋기 시작」으로 새로 긋습니다.";
|
|
const OFF_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">;
|
|
toScreen: (vertex: Vertex) => [number, number];
|
|
toMetric: (px: number, py: number) => Vertex;
|
|
/** 지도 돌림 — 보이는 px ↔ 그리기 px · 글자 세울 각(만든 뒤에 서므로 함수로). */
|
|
rotation: () => Pick<MapRotation, "unrotate" | "rerotate" | "uprightRad">;
|
|
pxPerMeter: () => number;
|
|
view: () => ViewState;
|
|
/** 바탕 틀(경위도 ↔ 화면) · 보기 바꿈 — 위치 찾기 → 지도 이동. */
|
|
frame: () => { meta: VWorldMeta | null; setView: (next: ViewState) => void };
|
|
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;
|
|
/** 아래 그림 불투명(켠 동안 `--draw-map-dim`). */
|
|
dim: () => number;
|
|
}
|
|
|
|
const sameLine = (a: ReadonlyArray<Vertex>, b: ReadonlyArray<Vertex>): boolean =>
|
|
JSON.stringify(a) === JSON.stringify(b);
|
|
const meters = (line: ReadonlyArray<Vertex>): string =>
|
|
Math.round(polylineLengthM(line)).toLocaleString();
|
|
|
|
export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
|
|
const { canvas } = params;
|
|
const viewport = params.chrome.overlay.querySelector<HTMLElement>(".b05-routeedit__canvas-wrap")!;
|
|
let original: Vertex[] = [];
|
|
/** 받은 원본 꼭짓점(635-2 · 조밀화 전) — 못 받으면 빈(= 조밀 점 `original`). */
|
|
let source: 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;
|
|
/** 고치는 판(예정노선 꼴 · 한 선) · 판을 지은 꼭짓점 · 긋기 시작 때 판(끝나면 바뀜을 봄). */
|
|
let network: PlanNetwork = { lines: [], pieces: [] };
|
|
let base: Vertex[] = [];
|
|
let strokeBase: PlanNetwork | null = null;
|
|
|
|
const line = (): Vertex[] =>
|
|
edit === null ? (saved.length > 1 ? saved : original) : edit.length > 1 ? edit : original;
|
|
const received = (): Vertex[] => (source.length > 1 ? source : original);
|
|
const changed = (): boolean => line() !== original && original.length > 1;
|
|
const z = (x: number, y: number): number => (grid ? gridHeightAt(grid.grid, x, y) : NaN);
|
|
const fromLine = (): PlanNetwork => {
|
|
const shown = line();
|
|
base = (shown === original ? received() : shown).map(([x, y]): Vertex => [x, y]);
|
|
const vertices = base.map(([x, y]): Vertex => [x, y]);
|
|
return { lines: base.length > 1 ? [{ id: "main", vertices, branch: null }] : [], pieces: [] };
|
|
};
|
|
|
|
// ── 위치 칸 — 찾기 → 지도 가운데로(지형 받기 · 기준점 없음 · 기능 표 「terrain」 숨김) · 좌표 = 서버 변환(635-5) ──
|
|
const coords = createPlanCoords(params.projectId, () => kit.props.render());
|
|
const find = createPlaceFind((hit) => {
|
|
void coords.toPlan(hit.lon, hit.lat).then((at) => {
|
|
const view = params.view();
|
|
if (!at || view.width <= 0) return;
|
|
const [sx, sy] = params.toScreen(at);
|
|
params.frame().setView({
|
|
...view,
|
|
offsetX: view.offsetX + view.width / 2 - sx,
|
|
offsetY: view.offsetY + view.height / 2 - sy,
|
|
});
|
|
params.redraw();
|
|
});
|
|
});
|
|
|
|
const others = createPlanContextLayer(params.projectId, params.redraw);
|
|
|
|
// ── 예정노선 키트(긋기 · 고르기 · 메뉴 · 속성 판 · 칸 = 예정노선 페이지와 같은 한 벌) ──
|
|
const kit = createPlanKit({
|
|
page: "b05",
|
|
network: () => network,
|
|
commit,
|
|
show: (next) => {
|
|
network = next;
|
|
params.redraw();
|
|
},
|
|
heightAt: () => z,
|
|
lonLat: coords.lonLat,
|
|
editable: () => open && !busy,
|
|
idle: () => open,
|
|
onMode,
|
|
onSelect: () => (kit.props.render(), params.redraw()),
|
|
snaps: others.joints,
|
|
cards: { site: { parts: [find.root] } },
|
|
});
|
|
const { draw, edit: editor, props, menu } = kit;
|
|
const layers = kit.layers([], []);
|
|
props.panel.classList.add("b05-routeedit__expected-props");
|
|
viewport.append(props.panel);
|
|
menu.mount(viewport);
|
|
|
|
// ── 그리기 칸 — 머리 「예정노선 수정」 · 받은 노선 · 길이 비교 · [받은 노선으로] ──
|
|
const drawCard = kit.cards.find((card) => card.contains(draw.panel))!;
|
|
const toggle = createToggle({ label: "예정노선 수정", size: 20, onChange: (on) => setOpen(on) });
|
|
toggle.root.addEventListener("click", (event) => event.stopPropagation()); // 칸 접기로 안 올라감
|
|
drawCard.firstElementChild!.append(toggle.root);
|
|
const originalToggle = createToggle({
|
|
label: "받은 노선",
|
|
checked: true,
|
|
size: 20,
|
|
onChange: (on) => ((showOriginal = on), params.redraw()),
|
|
});
|
|
const resetButton = createButton({
|
|
label: "받은 노선으로",
|
|
variant: "ghost",
|
|
size: 24,
|
|
onClick: () => void follow(received(), []),
|
|
});
|
|
const compare = createText("", "muted");
|
|
const hint = createText(OFF_HINT, "muted");
|
|
const jointHint = createText("", "muted");
|
|
const row = document.createElement("div");
|
|
row.className = "b05-routeedit__expected-row";
|
|
row.append(originalToggle.root, resetButton);
|
|
drawCard.append(row, compare, jointHint, hint);
|
|
fillPlanPanel({
|
|
site: kit.cards.find((card) => card.contains(find.root)) ?? null,
|
|
draw: drawCard,
|
|
});
|
|
|
|
function syncCard(): void {
|
|
const edited = changed();
|
|
resetButton.disabled = !edited || busy;
|
|
compare.textContent = edited
|
|
? `받은 노선 ${meters(received())} m → 고친 노선 ${meters(line())} m`
|
|
: "";
|
|
compare.hidden = !edited;
|
|
jointHint.textContent = others.note(line()) ?? "";
|
|
jointHint.hidden = !jointHint.textContent;
|
|
hint.textContent = !open ? OFF_HINT : network.lines.length ? HINT : EMPTY_HINT;
|
|
}
|
|
|
|
function setOpen(next: boolean): void {
|
|
if (draw.mode() !== "idle") draw.setMode("idle");
|
|
open = next;
|
|
asked = false;
|
|
network = fromLine();
|
|
editor.select([]);
|
|
draw.setEditable(open);
|
|
toggle.set(open);
|
|
if (open) {
|
|
params.onOpen();
|
|
void loadDesignTerrainGrid(params.projectId).then(
|
|
(loaded) => ((grid = loaded), params.redraw()),
|
|
);
|
|
}
|
|
syncCard();
|
|
props.render();
|
|
params.redraw();
|
|
}
|
|
|
|
/** 꼴 한 걸음(예정노선 `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 before = strokeBase;
|
|
strokeBase = null;
|
|
if (before && JSON.stringify(before.lines) !== JSON.stringify(network.lines)) void apply();
|
|
params.redraw();
|
|
}
|
|
|
|
function revert(): void {
|
|
network = fromLine();
|
|
kit.sync();
|
|
syncCard();
|
|
params.redraw();
|
|
}
|
|
|
|
/** 고친 판 → 다시 추적(한 선 · 점 2 이상 · 판을 지은 꼭짓점과 다를 때만). */
|
|
async function apply(): Promise<void> {
|
|
if (busy || !open) return;
|
|
const drawn = network.lines.filter((entry) => entry.vertices.length > 1);
|
|
syncCard();
|
|
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, base)) await follow(points, points);
|
|
}
|
|
|
|
/** 켠 뒤 처음만 확인창 → `/route/follow`(저장 0) → 편집기 한 걸음(초안 `expected` = `expected`). */
|
|
async function follow(points: Vertex[], expected: Vertex[]): Promise<void> {
|
|
if (busy || points.length < 2) return;
|
|
if (!asked) {
|
|
const ok = await askConfirm({
|
|
title: "평면선 다시 추적",
|
|
message:
|
|
"고친 예정노선을 따라 평면선을 통째로 다시 추적합니다 — 직접 고친 평면(노드 · 곡선)은 버립니다. " +
|
|
"받은 노선(원본)은 그대로 · [저장] 전까지는 되돌리기로 물릴 수 있습니다.",
|
|
okLabel: "다시 추적",
|
|
});
|
|
if (!ok) return revert();
|
|
asked = true;
|
|
}
|
|
busy = true;
|
|
syncCard();
|
|
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),
|
|
expected,
|
|
);
|
|
} catch (error) {
|
|
showToast(
|
|
`평면선을 다시 추적하지 못했습니다.${error instanceof Error ? ` ${error.message}` : ""}`,
|
|
"error",
|
|
);
|
|
revert();
|
|
} finally {
|
|
busy = false;
|
|
syncCard();
|
|
params.redraw();
|
|
}
|
|
}
|
|
|
|
// ── 누름 · 키 — 켠 동안 먼저 받아 예정노선 겹에 넘김(계획선 · 구조물 · 재기로 안 샘) ──
|
|
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,
|
|
};
|
|
};
|
|
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) return;
|
|
if (keyIs(event, "plan.undo") || keyIs(event, "plan.redo")) return; // 되돌리기 = 페이지 사진첩
|
|
if (!kit.key(event)) return;
|
|
event.stopImmediatePropagation();
|
|
event.preventDefault();
|
|
params.redraw();
|
|
};
|
|
listenWhileAlive(window, "keydown", canvas, onKey, true);
|
|
|
|
// ── 그림 ────────────────────────────────────────────────
|
|
function paint(context: CanvasRenderingContext2D): void {
|
|
others.paint(context, map()); // 다른 세트 = 흐린 겹(켬끔 상관없이 보기만)
|
|
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,
|
|
compare: compare.textContent,
|
|
joint: jointHint.textContent,
|
|
}),
|
|
others: others.probe,
|
|
line,
|
|
original: () => original,
|
|
source: () => source,
|
|
edit: () => edit,
|
|
screen: (vertex: Vertex) => params.rotation().rerotate(...params.toScreen(vertex)),
|
|
},
|
|
});
|
|
|
|
return {
|
|
load(nextOriginal, nextSaved) {
|
|
original = nextOriginal;
|
|
saved = nextSaved;
|
|
network = fromLine();
|
|
syncCard();
|
|
void others.load().then(syncCard);
|
|
void requestJson<{ expected_source?: Vertex[] }>(
|
|
`/projects/${params.projectId}/route/plan/source`,
|
|
{},
|
|
30000,
|
|
)
|
|
.then((body) => {
|
|
source = body.expected_source ?? [];
|
|
if (!open) network = fromLine();
|
|
syncCard();
|
|
})
|
|
.catch(() => undefined); // 못 받음 = 조밀 점 그대로
|
|
},
|
|
edit: () => edit,
|
|
setEdit(next) {
|
|
edit = next;
|
|
syncCard();
|
|
if (draw.mode() !== "idle") return; // 긋는 중 되돌림 — 긋기가 끝나면 그 판으로 다시 추적
|
|
network = fromLine();
|
|
editor.sync();
|
|
props.render();
|
|
syncCard();
|
|
},
|
|
line,
|
|
paint,
|
|
dim: () => (open ? parseFloat(themeColor("--draw-map-dim", "0.35")) || 0.35 : 1),
|
|
};
|
|
}
|