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]();
@@ -34,7 +34,12 @@ export interface PlanFeatureRow {
}
export const PLAN_FEATURES: readonly PlanFeatureRow[] = [
{ key: "site", name: "주소 · 장소 찾기(B05 = 지도 이동만)", pages: { d03: "use", b05: "use" } },
{
key: "site",
name: "주소 · 장소 찾기",
pages: { d03: "use", b05: "hide" },
why: "B 사업지 = 전처리 확정",
},
{
key: "draw",
name: "긋기 네 방식 · 길이 · 기울기 · Tab · Shift · 부채꼴 · ± 색",
@@ -92,9 +97,8 @@ export const PLAN_FEATURES: readonly PlanFeatureRow[] = [
},
{
key: "join",
name: "[잇기] 두 선 끝 이음",
pages: { d03: "use", b05: "hide" },
why: "B 계획선 한 줄",
name: "[잇기] 두 선 끝 이음(B05 = 켠 동안 끊긴 곳 이음)",
pages: { d03: "use", b05: "use" },
},
{
key: "others",
+3 -5
View File
@@ -76,9 +76,7 @@ def test_D03_긋기_고르기_메뉴_속성판_그대로_한_선():
for fake in ("NO_POINTS", "epsg: () => 0", "addPoint: () => undefined", "oneLine"):
assert fake not in ui, fake
assert "if (!kit.key(event)) return;" in ui # 같은 단축키(키트)
assert (
'keyIs(event, "plan.undo") || keyIs(event, "plan.redo")' in ui
) # 되돌리기 = 페이지 사진첩
assert '? (step("undo"), true)' in ui # 켠 동안 Ctrl+Z = 이 판 사진첩(755-1)
# 지도 입력 = 키트 한 함수 · 잡기 단계(capture) — 재기 · 계획선 고르기보다 먼저
assert "capture: { active: () => open, busy: () => busy }," in ui
assert "canvas.addEventListener(type, run, !!capture)" in kit
@@ -99,7 +97,7 @@ def test_D03_긋기_고르기_메뉴_속성판_그대로_한_선():
for src in (draw, menu, props):
assert "oneLine" not in src
table = _src(D03 / "D03_PlanAnalysis_UI_Features.ts")
# 638-3 — 세트 칸 · 세트 줄 = B05 보기만 · [잇기] = B05 숨김 · 세트 고침 = assignSet 한 길
# 638-3 — 세트 칸 · 세트 줄 = B05 보기만 · 세트 고침 = assignSet 한 길 · 755-1 [잇기] = B05 사용(끊긴 곳 이음)
assert 'disabled: !params.editable() || !featureOn(page, "set")' in props
assert 'featureOn(page, "join") && edit.isEnd(' in props + menu
assert "setPiecesYear" not in props and "setLineYear" not in _src(
@@ -109,7 +107,7 @@ def test_D03_긋기_고르기_메뉴_속성판_그대로_한_선():
("branch", "hide"),
("years", "view"),
("set", "view"),
("join", "hide"),
("join", "use"),
("points", "hide"),
("draw", "use"),
):
+15 -15
View File
@@ -1,10 +1,10 @@
"""635-3 B05 왼쪽 「위치」 · 「그리기」 칸 · 「예정노선 수정」 · 받은 노선 비교 · [받은 노선으로] · 어둡게(요구 546).
"""635-3 B05 왼쪽 「그리기」 칸(755-2 위치 칸 걷음) · 「예정노선 수정」 · 받은 노선 비교 · [받은 노선으로] · 어둡게(요구 546).
- 칸 = 예정노선 키트(`createPlanKit` 「b05」) 칸 · 자리 = `registerPanelSection` order 1 · 2(종단 보기) · 편집기가 채움.
- 위치 = 찾기 → 지도 이동만(지형 받기 · 기준점 없음) · 고칠 선 = 받은 원본 꼭짓점(`GET /route/plan/source` · 635-2).
- 칸 = 예정노선 키트(`createPlanKit` 「b05」) 칸 · 자리 = `registerPanelSection` order 1(종단 보기) · 편집기가 채움.
- 위치 칸 = 기능 표 「site」 b05 숨김(589-8) · 고칠 선 = 받은 원본 꼭짓점(`GET /route/plan/source` · 635-2).
- [받은 노선으로] = 받은 꼭짓점 다시 추적 + 초안 `expected` [](고친 예정노선 지움) — 한 걸음 · 페이지 [초기화] 그대로.
- 켬 = 아래 계획노선 그림 불투명 `--draw-map-dim`(0.35) · 등고 높이값 · 예정노선 그림은 그대로.
- 큰 파일 안 늘림(RouteEdit 683 · Render 648 + 10 안).
- 큰 파일 안 늘림(RouteEdit 690 · Render 648 + 10 안).
./venv/Scripts/python.exe -m pytest resources/tester/test_635_3_b05_plan_panel.py -q
"""
@@ -22,10 +22,10 @@ def _src(path: Path) -> str:
return path.read_text(encoding="utf-8")
def test_왼쪽_칸_둘_종단_보기_위():
def test_왼쪽_칸_하나_종단_보기_위():
panel = _src(B05 / "B05_Profile_UI_Panel_Plan.ts")
assert '["site", 1, "위치"]' in panel and '["draw", 2, "그리기"]' in panel
assert 'views: ["profile"]' in panel and "id: `plan-${slot}`" in panel
assert 'id: "plan-draw",' in panel and 'title: "그리기",' in panel and "order: 1," in panel
assert 'views: ["profile"]' in panel and '"site"' not in panel and '위치"' not in panel
assert "export function fillPlanPanel(" in panel
assert "holder.hidden = !card;" in panel # 비면 자리 숨김
# 다른 칸(구조물 배치 10 · 페이지 설정 20)보다 위
@@ -33,18 +33,18 @@ def test_왼쪽_칸_둘_종단_보기_위():
assert "order: 20" in _src(B05 / "B05_Profile_UI_Panel_Sections.ts")
def test_키트_칸_위치_찾기_이동만_원본_꼭짓점():
def test_키트_칸_위치_걷음_원본_꼭짓점():
ui = _src(B05 / "B05_Profile_UI_ExpectedDraw.ts")
body = ui.split("*/", 1)[1]
assert 'page: "b05",' in body and "cards: { site: { parts: [find.root] } }," in body
assert "fillPlanPanel({" in body and "createPlaceFind(" in body
assert "coords.toPlan(hit.lon, hit.lat)" in body # 지도 이동만(서버 변환 · 635-5)
assert 'page: "b05",' in body and "cards: {}," in body
assert "fillPlanPanel(drawCard);" in body and "createPlaceFind" not in body
assert "toPlan" not in body and "frame" not in body # 찾기 이동 걷음(589-8)
for banned in ("createPlanTerrain", "ensureCovered", "createPlanSite", "createFloatPanel"):
assert banned not in body, banned
assert "/route/plan/source" in body and "expected_source" in body
assert "shown === original ? received() : shown" in body # 안 고친 선 = 원본 꼭짓점
features = _src(ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_Features.ts")
assert re.search(r'key: "site",[^}]*b05: "use"', features)
assert re.search(r'key: "site",[^}]*b05: "hide"', features, re.S)
assert re.search(r'key: "terrain",[^}]*b05: "hide"', features, re.S)
@@ -56,10 +56,10 @@ def test_수정_켬끔_받은_노선_비교_되돌림():
assert "받은 노선 ${meters(received())} m → 고친 노선 ${meters(line())} m" in body
assert 'label: "받은 노선으로"' in body and "follow(received(), [])" in body
assert "resetButton.disabled = !edited || busy;" in body
assert "if (!sameLine(points, base)) await follow(points, points);" in body
assert "return sameLine(points, base) || (await follow(points, points));" in body # 끌 때 한 번
main = _src(B05 / "B05_Profile_UI_RouteEdit.ts")
assert "frame: () => ({ meta, setView: (next: ViewState) => (view = next) })," in main
assert len(main.splitlines()) <= 683
assert "frame:" not in main
assert len(main.splitlines()) <= 690
def test_켬_때_아래_노선_어둡게():
+4 -4
View File
@@ -1,7 +1,7 @@
"""635-5 B05 경위도 ↔ 사업지 좌표 서버 변환(요구 546) — 찾기 이동 · 속성 판 경위도.
"""635-5 B05 경위도 ↔ 사업지 좌표 서버 변환(요구 546) — 속성 판 경위도(찾기 이동 = 755-2 걷음).
- 서버 `POST /route/coords` = 작업 좌표계(`resolve_project_crs` · WKT 그대로) pyproj 묶음 변환.
- 화면 = 찾기 이동 · 노드 고름 때만 부름 · 같은 점 캐시 · 바탕 `tm_grid` 늘려 쓰기 걷음.
- 화면 = 노드 고름 때만 부름 · 같은 점 캐시 · 바탕 `tm_grid` 늘려 쓰기 걷음.
./venv/Scripts/python.exe -m pytest resources/tester/test_635_5_b05_coords.py -q
"""
@@ -50,12 +50,12 @@ def test_라우터_등록():
assert "resolve_project_crs(root, route_crs_input=" in router
def test_화면_서버_변환_찾기_고름만():
def test_화면_서버_변환_고름만():
panel = (B05 / "B05_Profile_UI_Panel_Plan.ts").read_text(encoding="utf-8")
assert "`/projects/${projectId}/route/coords`" in panel # requestJson 이 /api 를 붙임
assert "known.has(key)" in panel # 같은 점 캐시
ui = (B05 / "B05_Profile_UI_ExpectedDraw.ts").read_text(encoding="utf-8")
assert "coords.toPlan(hit.lon, hit.lat)" in ui and "lonLat: coords.lonLat," in ui
assert "lonLat: coords.lonLat," in ui and "toPlan" not in ui + panel
assert "normOfLonLat" not in ui and "tm_grid` 늘려" not in ui
# 끌기 중엔 판을 안 그림 — 속성 판은 고름 · 놓음(commit) 때만
kit = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_Kit.ts").read_text(encoding="utf-8")
@@ -0,0 +1,84 @@
"""755 B05 평면 예정노선 수정 흐름(요구 589-1 · 3 · 5 · 6 · 7 · 8).
- 755-1 켠 동안 = 판만 고침 · 끊김 허용(가운데 묶음 지움) · 이 판 사진첩(Ctrl+Z · 도구줄 단추) ·
끌 때 한 번 = 끊겼으면 알림하고 안 끔 · 바뀌었으면 `/route/follow` 한 걸음 · [잇기] = B05 사용.
- 755-2 켠 동안 재기 = D03 재기 겹 · 받은 노선 = 분홍 점선 + 검은 테 · 켬 설명 걷음 · 위치 칸 걷음.
./venv/Scripts/python.exe -m pytest resources/tester/test_755_expected_flow.py -q
"""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
D03 = ROOT / "D03_PlanAnalysis"
def _body() -> str:
return (B05 / "B05_Profile_UI_ExpectedDraw.ts").read_text("utf-8").split("*/", 1)[1]
def _fn(body: str, head: str) -> str:
start = body.index(head)
end = body.index("\n }\n", start)
return body[start:end]
def test_켠_동안_판만_한_걸음_사진첩():
body = _body().replace("\r\n", "\n")
commit = _fn(body, "function commit(")
assert "undoStack.push(network);" in commit and "redoStack = [];" in commit
assert "follow(" not in commit and "apply(" not in commit # 켠 동안 평면선 안 바꿈
assert "function apply(" not in body and "strokeBase" not in body
assert "onMode: () => params.redraw()," in body # 긋기 끝 = 다시 추적 아님
assert 'keyIs(event, "plan.undo")\n ? (step("undo"), true)' in body
assert "history: () =>\n open" in body # 끔 = 페이지 사진첩
history = (B05 / "B05_Profile_UI_RouteEdit_History.ts").read_text("utf-8")
assert "const local = params.local?.();" in history
assert "params.local?.() ?? getHistory()" in history
main = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text("utf-8")
assert "local: () => expectedDraw.history()," in main
assert "onHistory: () => historyControls.sync()," in main
def test_끌_때_끊김_알림_안_끔_한_번_추적():
body = _body().replace("\r\n", "\n")
open_ = _fn(body, "async function setOpen(")
assert "if (!next && open && !(await finish())) return toggle.set(true);" in open_
finish = _fn(body, "async function finish(")
assert "drawn.length > 1" in finish and "return false;" in finish and "showToast(" in finish
assert "return sameLine(points, base) || (await follow(points, points));" in finish
follow = _fn(body, "async function follow(")
assert (
"/route/follow" in follow
and "return true;" in follow
and "if (!ok) return false;" in follow
)
assert "revert" not in body # 실패 · 취소 = 판 그대로(켠 채)
table = (D03 / "D03_PlanAnalysis_UI_Features.ts").read_text("utf-8")
assert re.search(r'key: "join",[^}]*b05: "use"', table, re.S)
assert re.search(r'key: "branch",[^}]*b05: "hide"', table, re.S) # 갈래 · 둘째 선 그대로 막음
def test_재기_D03_겹_켠_동안():
body = _body()
assert "createPlanMeasure(" in body and "params.measuring()" in body
assert "kit.layers([], [measure.layer])" in body and "layers: liveLayers," in body
assert "idle: () => open && !measuring()," in body # 재는 동안 고르기 · 끌기 쉼
main = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text("utf-8")
assert "measuring: () => measureControls.mode()," in main
def test_받은_노선_색_설명_위치_걷음():
body = _body()
assert 'strokeCased(context, themeColor("--draw-map-route-3", "#ff8cbc"), 2.5, [8, 5]);' in body
assert "--draw-text-secondary" not in body
assert "HINT =" not in body.replace("OFF_HINT =", "") and "hint.hidden = open;" in body
table = (D03 / "D03_PlanAnalysis_UI_Features.ts").read_text("utf-8")
assert re.search(r'key: "site",[^}]*b05: "hide"', table, re.S)
assert re.search(r'key: "site",[^}]*d03: "use"', table, re.S) # D03 그대로
theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8")
assert "--draw-map-route-3:" in theme