Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1

# Conflicts:
#	B05_Profile/B05_Profile_UI_RouteEdit.ts
This commit is contained in:
2026-11-02 23:14:27 +09:00
9 changed files with 277 additions and 147 deletions
+126 -80
View File
@@ -1,13 +1,15 @@
/* =============================================================================
* 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`).
* B05 평면 예정노선 수정(PLAN 626-3 · 635-3 · 755 · 요구 530 · 535-1 · 536 · 546 · 589) — 예정노선 키트(`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`).
* 켠 동안(755) = 판만 고침 · 끊김 허용(가운데 묶음 지움) · 되돌리기 = 이 판 사진첩(Ctrl+Z · 도구줄 단추) ·
* 재기 켬 = D03 재기 겹(고치는 판 따라 · 두 점 곧은 거리 · `createPlanMeasure`).
* 끌 때 한 번 = 끊겨 있으면 알림하고 안 끔 · 바뀌었으면 확인창 → `POST /route/follow`(저장 0 · 폴리라인 두 단계) →
* `onFollow(평면, 예정노선)` = 편집기 한 걸음 → 페이지 [저장] = 손 편집과 같은 길 + 초안 `expected`(`expected_route_edit.csv`).
* [받은 노선으로] = 받은 꼭짓점으로 다시 추적 · 초안 `expected` = [](고친 예정노선 지움) — 한 걸음 · 페이지 [초기화] 는 그대로.
* 예정노선 세 벌 = 받은 노선(`expected_route.csv` · 안 덮음 · 고쳤으면 옅은 점선) · 저장본(`expected_edited`) · 이번 고침(`edit`).
* 같은 예정노선 다른 세트(635-4) = 흐린 겹 보기만(`B05_Profile_UI_PlanContext`) · 그 끝 = 긋기 · 끌기 붙을 자리 · 벗어나면 안내 한 줄.
@@ -21,12 +23,12 @@ 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 { strokeCased } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint";
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 type { PlanMapLayer, ScreenMap } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Map";
import { createPlanMeasure } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure";
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";
@@ -39,9 +41,6 @@ import { flattenServerPlan, type FlattenedPlan } from "./logic/B05_Profile_UI_Ro
type Vertex = [number, number];
const HINT =
"[긋기] · 우클릭 「여기서 긋기 시작」 · 선 끝에서 이어 긋기 · 고르고 Delete · 노드 끌기 — 한 번 고칠 때마다 평면선을 다시 추적합니다.";
const EMPTY_HINT = "예정노선이 없습니다 — 빈 땅 우클릭 「여기서 긋기 시작」으로 새로 긋습니다.";
const OFF_HINT = "「예정노선 수정」을 켜면 지도에서 예정노선을 고칩니다.";
/** `POST /route/follow` 응답 — GET `/route/plan` 칸 이름(626-2). */
@@ -58,11 +57,13 @@ export interface ExpectedDrawParams {
rotation: () => Pick<MapRotation, "unrotate" | "rerotate" | "uprightRad">;
pxPerMeter: () => number;
view: () => ViewState;
/** 바탕 틀(경위도 ↔ 화면) · 보기 바꿈 — 위치 찾기 → 지도 이동. */
frame: () => { meta: VWorldMeta | null; setView: (next: ViewState) => void };
redraw: () => void;
/** 켬 — 편집기가 고른 노드 · 곡선 판을 풂. */
onOpen: () => void;
/** 도구줄 재기 단추 켬 — 켠 동안 D03 재기 겹이 누름을 받음(755-2). */
measuring: () => boolean;
/** 켠 동안 사진첩이 바뀜 — 도구줄 되돌리기 단추 맞춤. */
onHistory: () => void;
/** 다시 추적한 평면 · 고친 예정노선(빈 = 받은 노선으로) — 편집기 한 걸음. */
onFollow: (plan: FlattenedPlan, expected: Vertex[]) => void;
}
@@ -78,6 +79,15 @@ export interface ExpectedDraw {
paint: (context: CanvasRenderingContext2D) => void;
/** 아래 그림 불투명(켠 동안 `--draw-map-dim`). */
dim: () => number;
/** 켠 동안 되돌리기 — 이 판 사진첩(끔 = null · 페이지 사진첩). */
history: () => ExpectedHistory | null;
}
export interface ExpectedHistory {
undo: () => void;
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
}
const sameLine = (a: ReadonlyArray<Vertex>, b: ReadonlyArray<Vertex>): boolean =>
@@ -99,10 +109,14 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
/** 켠 뒤 확인창을 물었나 — 다시 추적 뒤 평면은 손 고침이 없음(켠 동안 계획선 안 집힘). */
let asked = false;
let grid: DesignTerrainGrid | null = null;
/** 고치는 판(예정노선 꼴 · 한 선) · 판을 지은 꼭짓점 · 긋기 시작 때 판(끝나면 바뀜을 봄). */
/** 고치는 판(예정노선 꼴 · 켠 동안 끊김 허용) · 판을 지은 꼭짓점. */
let network: PlanNetwork = { lines: [], pieces: [] };
let base: Vertex[] = [];
let strokeBase: PlanNetwork | null = null;
/** 켠 동안 사진첩(755-1) — `commit` 한 번 = 한 걸음 · 판을 다시 지으면 비움. */
let undoStack: PlanNetwork[] = [];
let redoStack: PlanNetwork[] = [];
/** 재기 겹이 받던 때(켬끔 바뀜 = 잰 것 지움). */
let measured = false;
const line = (): Vertex[] =>
edit === null ? (saved.length > 1 ? saved : original) : edit.length > 1 ? edit : original;
@@ -113,26 +127,20 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
const shown = line();
base = (shown === original ? received() : shown).map(([x, y]): Vertex => [x, y]);
const vertices = base.map(([x, y]): Vertex => [x, y]);
undoStack = [];
redoStack = [];
return { lines: base.length > 1 ? [{ id: "main", vertices, branch: null }] : [], pieces: [] };
};
const measuring = (): boolean => open && params.measuring();
// ── 위치 칸 — 찾기 → 지도 가운데로(지형 받기 · 기준점 없음 · 기능 표 「terrain」 숨김) · 좌표 = 서버 변환(635-5) ──
// 속성 판 경위도 = 서버 변환(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 measure = createPlanMeasure(
() => z,
() => network,
);
// ── 예정노선 키트(긋기 · 고르기 · 메뉴 · 속성 판 · 칸 = 예정노선 페이지와 같은 한 벌) ──
const kit = createPlanKit({
@@ -146,21 +154,44 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
heightAt: () => z,
lonLat: coords.lonLat,
editable: () => open && !busy,
idle: () => open,
onMode,
idle: () => open && !measuring(),
onMode: () => params.redraw(),
onSelect: () => (kit.props.render(), params.redraw()),
snaps: others.joints,
cards: { site: { parts: [find.root] } },
onKey: (event) =>
keyIs(event, "plan.undo")
? (step("undo"), true)
: keyIs(event, "plan.redo")
? (step("redo"), true)
: measuring() && event.key === "Escape"
? (measure.clear(), true)
: false,
cards: {},
});
const { draw, edit: editor, props, menu } = kit;
const layers = kit.layers([], []);
const measureLayers = kit.layers([], [measure.layer]);
/** 지금 겹 — 재기 켬이면 재기 겹(고르기 · 노드 끌기 쉼) · 켬끔이 바뀌면 잰 것 지움 · 긋기 끝. */
const liveLayers = (): PlanMapLayer[] => {
const on = measuring();
if (on !== measured) {
measured = on;
measure.clear();
if (on && draw.mode() !== "idle") draw.setMode("idle");
}
return on ? measureLayers : 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) });
const toggle = createToggle({
label: "예정노선 수정",
size: 20,
onChange: (on) => void setOpen(on),
});
toggle.root.addEventListener("click", (event) => event.stopPropagation()); // 칸 접기로 안 올라감
drawCard.firstElementChild!.append(toggle.root);
const originalToggle = createToggle({
@@ -182,10 +213,7 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
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,
});
fillPlanPanel(drawCard);
function syncCard(): void {
const edited = changed();
@@ -196,15 +224,17 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
compare.hidden = !edited;
jointHint.textContent = others.note(line()) ?? "";
jointHint.hidden = !jointHint.textContent;
hint.textContent = !open ? OFF_HINT : network.lines.length ? HINT : EMPTY_HINT;
hint.hidden = open; // 켠 동안 설명 걷음(589-7)
}
function setOpen(next: boolean): void {
async function setOpen(next: boolean): Promise<void> {
if (draw.mode() !== "idle") draw.setMode("idle");
if (!next && open && !(await finish())) return toggle.set(true);
open = next;
asked = false;
network = fromLine();
editor.select([]);
measure.clear();
draw.setEditable(open);
toggle.set(open);
if (open) {
@@ -215,53 +245,56 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
}
syncCard();
props.render();
params.onHistory();
params.redraw();
}
/** 꼴 한 걸음(예정노선 `commit`) — 긋는 중이면 판만 · 쉴 때(놓음 · 지움) = 다시 추적. */
/** 꼴 한 걸음(예정노선 `commit`) — 켠 동안 판만 · 이 판 사진첩에 쌓음(평면선 = 끌 때 한 번). */
function commit(next: PlanNetwork): void {
undoStack.push(network);
redoStack = [];
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();
kit.sync(); // 지운 고름 · 판 맞춤(예정노선 페이지 `refresh` 와 같음)
params.onHistory();
params.redraw();
}
function revert(): void {
network = fromLine();
function step(which: "undo" | "redo"): void {
const [from, to] = which === "undo" ? [undoStack, redoStack] : [redoStack, undoStack];
const last = from.pop();
if (!last) return;
if (draw.mode() !== "idle") draw.setMode("idle");
to.push(network);
network = last;
editor.select([]);
kit.sync();
syncCard();
params.onHistory();
params.redraw();
}
/** 고친 판 → 다시 추적(한 선 · 점 2 이상 · 판을 지은 꼭짓점과 다를 때만). */
async function apply(): Promise<void> {
if (busy || !open) return;
/** 끌 때 한 번 — 끊겼으면 알림 · 안 끔 · 바뀌었으면 다시 추적(실패 · 물음 취소 = 안 끔). 참 = 꺼도 됨. */
async function finish(): Promise<boolean> {
if (busy) return false;
const drawn = network.lines.filter((entry) => entry.vertices.length > 1);
syncCard();
if (!drawn.length) return; // 다 지움 — 새로 그을 때까지 평면 그대로
if (drawn.length > 1) {
showToast("예정노선은 한 선입니다.", "info");
return revert();
showToast(
`예정노선이 ${drawn.length} 토막으로 끊겨 있습니다 — 선 끝에서 이어 긋거나 [잇기]로 이은 뒤 끕니다.`,
"info",
);
return false;
}
if (!drawn.length) {
if (base.length > 1)
showToast("예정노선이 비어 고친 것을 버렸습니다 — 평면선 그대로.", "info");
return true;
}
const points = drawn[0].vertices.map(([x, y]): Vertex => [x, y]);
if (!sameLine(points, base)) await follow(points, points);
return 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;
/** 확인창(켠 뒤 처음만) → `/route/follow`(저장 0) → 편집기 한 걸음(초안 `expected` = `expected`). 참 = 됨. */
async function follow(points: Vertex[], expected: Vertex[]): Promise<boolean> {
if (busy || points.length < 2) return false;
if (!asked) {
const ok = await askConfirm({
title: "평면선 다시 추적",
@@ -270,7 +303,7 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
"받은 노선(원본)은 그대로 · [저장] 전까지는 되돌리기로 물릴 수 있습니다.",
okLabel: "다시 추적",
});
if (!ok) return revert();
if (!ok) return false;
asked = true;
}
busy = true;
@@ -286,12 +319,13 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
flattenServerPlan(plan.nodes ?? [], plan.curves ?? [], plan.node_edits),
expected,
);
return true;
} catch (error) {
showToast(
`평면선을 다시 추적하지 못했습니다.${error instanceof Error ? ` ${error.message}` : ""}`,
"error",
);
revert();
return false;
} finally {
busy = false;
syncCard();
@@ -331,14 +365,13 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
return params.rotation().unrotate(event.clientX - rect.left, event.clientY - rect.top);
},
screen: map,
layers: () => layers,
layers: liveLayers,
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();
@@ -357,16 +390,15 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
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.lineCap = "round";
context.lineJoin = "round";
// 받은 노선 = 분홍 점선 + 검은 테(589-6 · 계획 주황 · 예정 민트 · 추천 노랑 · 등고선과 가름 · 테마 안 따름)
strokeCased(context, themeColor("--draw-map-route-3", "#ff8cbc"), 2.5, [8, 5]);
context.restore();
}
if (!open) return;
const screen = map();
for (const layer of layers) {
for (const layer of liveLayers()) {
context.save();
layer.draw(context, screen); // 고치는 예정노선 = 맨 위(D9) · 고름 · 미리보기
context.restore();
@@ -385,9 +417,14 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
busy,
compare: compare.textContent,
joint: jointHint.textContent,
undo: undoStack.length,
redo: redoStack.length,
measuring: measuring(),
}),
measure: () => measure.result(),
others: others.probe,
line,
network: () => network,
original: () => original,
source: () => source,
edit: () => edit,
@@ -418,7 +455,7 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
setEdit(next) {
edit = next;
syncCard();
if (draw.mode() !== "idle") return; // 긋는 중 되돌림 — 긋기가 끝나면 그 판으로 다시 추적
if (draw.mode() !== "idle") return; // 긋는 중 = 긋기 판 그대로
network = fromLine();
editor.sync();
props.render();
@@ -427,5 +464,14 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
line,
paint,
dim: () => (open ? parseFloat(themeColor("--draw-map-dim", "0.35")) || 0.35 : 1),
history: () =>
open
? {
undo: () => step("undo"),
redo: () => step("redo"),
canUndo: () => undoStack.length > 0,
canRedo: () => redoStack.length > 0,
}
: null,
};
}
+24 -37
View File
@@ -1,9 +1,9 @@
/* =============================================================================
* B05_Profile_UI_Panel_Plan.ts
* 종단 보기 왼쪽 「위치」 · 「그리기」 칸 자리(PLAN 635-3 · 요구 546) — 예정노선 키트(`createPlanKit` 「b05」) 칸을 앉힘.
* 칸은 페이지가 먼저 세우고 편집기는 노선을 읽은 뒤 섬 → 칸 = 빈 자리(order 1 · 2) · 편집기가 `fillPlanPanel` 로 채움.
* 종단 보기 왼쪽 「그리기」 칸 자리(PLAN 635-3 · 755-2 · 요구 546 · 589-8 위치 칸 걷음) — 예정노선 키트(`createPlanKit` 「b05」) 칸을 앉힘.
* 칸은 페이지가 먼저 세우고 편집기는 노선을 읽은 뒤 섬 → 칸 = 빈 자리(order 1) · 편집기가 `fillPlanPanel` 로 채움.
* 늦게 온 쪽이 붙임 · 새 칸 자리(페이지 다시 세움) = 떠난 편집기 칸(문서 밖) 버림 · 비면 자리 숨김.
* 좌표 변환(635-5) = 서버(`POST /route/coords` · 사업지 좌표계 pyproj) — 찾기 이동 · 속성 판 경위도 때만 부름.
* 좌표 변환(635-5) = 서버(`POST /route/coords` · 사업지 좌표계 pyproj) — 속성 판 경위도 때만 부름.
* ========================================================================== */
import { registerPanelSection } from "../A00_Common/design/design_panel";
@@ -11,55 +11,43 @@ import { requestJson } from "./B05_Profile_Api_Replan";
type Vertex = [number, number];
type Slot = "site" | "draw";
const holders: Partial<Record<Slot, HTMLElement>> = {};
const cards: Partial<Record<Slot, HTMLElement | null>> = {};
let holder: HTMLElement | null = null;
let card: HTMLElement | null = null;
const attach = (slot: Slot): void => {
const holder = holders[slot];
const attach = (): void => {
if (!holder) return;
const card = cards[slot];
holder.replaceChildren(...(card ? [card] : []));
holder.hidden = !card;
};
for (const [slot, order, title] of [
["site", 1, "위치"],
["draw", 2, "그리기"],
] as const)
registerPanelSection({
id: `plan-${slot}`,
title,
views: ["profile"],
order,
build: () => {
const holder = document.createElement("div");
holder.className = "b05-route__plan-slot";
holders[slot] = holder;
if (!cards[slot]?.isConnected) cards[slot] = null; // 떠난 편집기 칸
attach(slot);
return holder;
},
});
registerPanelSection({
id: "plan-draw",
title: "그리기",
views: ["profile"],
order: 1,
build: () => {
holder = document.createElement("div");
holder.className = "b05-route__plan-slot";
if (!card?.isConnected) card = null; // 떠난 편집기 칸
attach();
return holder;
},
});
/** 키트 칸을 앉힘(새 편집기가 갈음). */
export function fillPlanPanel(next: Record<Slot, HTMLElement | null>): void {
for (const slot of ["site", "draw"] as const) {
cards[slot] = next[slot];
attach(slot);
}
export function fillPlanPanel(next: HTMLElement | null): void {
card = next;
attach();
}
export interface PlanCoords {
/** 경위도 → 사업지 [x, y] m(못 받으면 null). */
toPlan: (lon: number, lat: number) => Promise<Vertex | null>;
/** 사업지 → 경위도 — 받은 점이면 바로 · 처음이면 null 주고 한 번 물음(오면 `onLonLat`). */
lonLat: (x: number, y: number) => Vertex | null;
}
/** 경위도 ↔ 사업지 좌표 서버 변환 · 같은 점은 캐시(끌기 중엔 안 부름 — 판은 고름 · 놓음 때만 그림). */
/** 사업지 → 경위도 서버 변환 · 같은 점은 캐시(끌기 중엔 안 부름 — 판은 고름 · 놓음 때만 그림). */
export function createPlanCoords(projectId: string, onLonLat: () => void): PlanCoords {
const ask = (to: "plan" | "lonlat", point: Vertex): Promise<Vertex | null> =>
const ask = (to: "lonlat", point: Vertex): Promise<Vertex | null> =>
requestJson<{ points: Vertex[] }>(
`/projects/${projectId}/route/coords`,
{ method: "POST", body: JSON.stringify({ to, points: [point] }) },
@@ -70,7 +58,6 @@ export function createPlanCoords(projectId: string, onLonLat: () => void): PlanC
);
const known = new Map<string, Vertex | null>();
return {
toPlan: (lon, lat) => ask("plan", [lon, lat]),
lonLat(x, y) {
const key = `${x},${y}`;
if (known.has(key)) return known.get(key) ?? null;
+3 -1
View File
@@ -129,9 +129,10 @@ export async function mountRouteEditor(
rotation: () => rotation,
pxPerMeter: () => pxPerMeter(),
view: () => view,
frame: () => ({ meta, setView: (next: ViewState) => (view = next) }),
redraw: () => (draw(), sheetFollow?.poke()), // 켠 동안 예정노선이 도엽 밖으로 가면 다시 받음(589-10)
onOpen: () => ((picked = -1), syncCurveBar()),
measuring: () => measureControls.mode(), // 켠 동안 재기 = D03 재기 겹(755-2)
onHistory: () => historyControls.sync(), // 켠 동안 되돌리기 = 예정노선 판(755-1)
onFollow: (flat, line) =>
restoreSnapshot(
{ ...flat, apexLock: [], picked: -1, rec: initial.tag(), expected: line },
@@ -500,6 +501,7 @@ export async function mountRouteEditor(
const historyControls = bindHistoryControls({
overlay,
getHistory: () => history,
local: () => expectedDraw.history(),
restore: (snapshot, message) => {
initial.restoreTag(snapshot.rec); // 「현재 추천」도 그 걸음 것으로 — 노선과 짝.
restoreSnapshot(snapshot, message, false);
@@ -143,6 +143,13 @@ export interface HistoryControlsParams {
overlay: HTMLElement;
/** 아직 노선을 못 읽었으면 null 이다(단추는 꺼진 채로 둔다). */
getHistory: () => RouteEditHistory | null;
/** 예정노선 수정 켠 동안 사진첩(755-1) — 있으면 단추가 이쪽을 씀. */
local?: () => {
undo: () => void;
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
} | null;
/** 사진 한 벌을 화면에 되살린다. */
restore: (snapshot: RouteEditSnapshot, message: string) => void;
}
@@ -162,12 +169,14 @@ export function bindHistoryControls(params: HistoryControlsParams): {
const redoBtn = overlay.querySelector<HTMLButtonElement>('[data-act="redo"]')!;
const sync = (): void => {
const history = getHistory();
const history = params.local?.() ?? getHistory();
undoBtn.disabled = !history?.canUndo();
redoBtn.disabled = !history?.canRedo();
};
const step = (which: "undo" | "redo"): void => {
const local = params.local?.();
if (local) return (local[which](), sync());
const history = getHistory();
if (!history) return;
const snapshot = history[which]();