feat(B05): 계획노선 편집 모달 — 예상노선 위에서 노선 고치기
좌측 「계획노선」 섹션의 [계획노선 편집] 로 큰 모달을 엶(PLAN 0-2). - 등고선 도엽 위에 예상노선(점선)·계획노선(실선)을 함께 그림. 지도 그리기는 배수유역도와 같은 도구(`B04_PreProcess_UI_MapRender`) 재사용. - 노드 끌어 옮기기 · 선 두 번 클릭으로 노드 끼우기 · 오른쪽 클릭으로 지우기, 배경 끌기로 화면 이동, 휠로 확대. - 편집 중에는 계산이 나가지 않음. [확인]에서만 서버가 배수유역부터 재계산하며 그동안 화면을 덮는 안내를 띄움. 끝나면 세션 초안·조회 캐시를 비우고 페이지를 다시 세움. - [예상노선으로] 는 수정본을 지우고 같은 재계산(노선 초기화). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_Api_Replan.ts
|
||||
* 계획노선 두 벌(예상노선·계획노선) 읽기와 노선 갈아 끼우기 요청.
|
||||
*
|
||||
* GET /projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계 m)
|
||||
* POST /projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산
|
||||
* POST /projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산
|
||||
*
|
||||
* 재계산은 배수유역부터 전 단계를 다시 도는 무거운 작업이라(용화 67측점 기준 3분대)
|
||||
* 타임아웃을 길게 잡는다 — 기본값으로 두면 중간에 끊긴다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
|
||||
/** 노선 재계산 대기 상한 — 배수유역 분석(90초대)까지 포함해 넉넉히 잡는다. */
|
||||
const REPLAN_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
|
||||
export interface RoutePlanResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
/** 예상노선(원본) 정점 [[x, y], …] — 사업지 좌표계(m). */
|
||||
expected: Array<[number, number]>;
|
||||
/** 계획노선(수정본). 고친 적이 없으면 예상노선과 같은 값. */
|
||||
planned: Array<[number, number]>;
|
||||
/** 사용자가 고친 계획노선이 저장돼 있으면 true. */
|
||||
edited: boolean;
|
||||
}
|
||||
|
||||
export interface RouteReplanResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
route_id: number | null;
|
||||
total_length_m: number | null;
|
||||
vertex_count: number;
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string, init: RequestInit, timeoutMs: number): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(init.headers ?? {}) },
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = (await response.json()) as T & { message?: string };
|
||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
return payload;
|
||||
} finally {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** 예상노선·계획노선을 함께 읽는다(편집 모달이 점선·실선으로 그린다). */
|
||||
export async function fetchRoutePlan(projectId: string): Promise<RoutePlanResponse> {
|
||||
return requestJson<RoutePlanResponse>(
|
||||
`/projects/${projectId}/route/plan`,
|
||||
{ method: "GET" },
|
||||
60000,
|
||||
);
|
||||
}
|
||||
|
||||
/** 고친 계획노선으로 갈아 끼우고 배수유역부터 다시 계산한다. */
|
||||
export async function replanRoute(
|
||||
projectId: string,
|
||||
vertices: Array<[number, number]>,
|
||||
): Promise<RouteReplanResponse> {
|
||||
return requestJson<RouteReplanResponse>(
|
||||
`/projects/${projectId}/route/replan`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ vertices: vertices.map(([x, y]) => ({ x, y })) }),
|
||||
},
|
||||
REPLAN_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
/** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */
|
||||
export async function resetRoutePlan(projectId: string): Promise<RouteReplanResponse> {
|
||||
return requestJson<RouteReplanResponse>(
|
||||
`/projects/${projectId}/route/replan/reset`,
|
||||
{ method: "POST" },
|
||||
REPLAN_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
@@ -67,7 +67,7 @@ class RouteVertexInput(BaseModel):
|
||||
|
||||
|
||||
class RouteReplanRequest(BaseModel):
|
||||
"""고친 계획노선. 정점은 시점 → 종점 순서."""
|
||||
"""고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m)."""
|
||||
|
||||
vertices: list[RouteVertexInput] = Field(default_factory=list)
|
||||
|
||||
@@ -259,15 +259,14 @@ async def replan_route(
|
||||
|
||||
# 고치기 전에 예상노선(원본)이 서 있는지 본다 — 초기화가 돌아갈 자리다.
|
||||
await asyncio.to_thread(_ensure_expected_route, project_root)
|
||||
vertices = [(vertex.x, vertex.y) for vertex in request.vertices]
|
||||
written = await asyncio.to_thread(
|
||||
_write_working_route,
|
||||
planned_route_working_path(project_root),
|
||||
[(vertex.x, vertex.y) for vertex in request.vertices],
|
||||
_write_working_route, planned_route_working_path(project_root), vertices
|
||||
)
|
||||
logger.info(
|
||||
"계획노선 갈아 끼움: project_id=%s 정점 %d→%d(조밀화)",
|
||||
project_id,
|
||||
len(request.vertices),
|
||||
len(vertices),
|
||||
written,
|
||||
)
|
||||
result = await _recompute(project_id, project_root, stored_path)
|
||||
|
||||
@@ -26,6 +26,7 @@ import { createRoutePanel, type RoutePanelValues } from "./B05_Profile_UI_Panel"
|
||||
import { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
|
||||
import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { openRouteEditModal } from "./B05_Profile_UI_RouteEdit";
|
||||
import { createSelectionSync } from "./B05_Profile_UI_Selection";
|
||||
import {
|
||||
restoreStructurePick,
|
||||
@@ -270,6 +271,12 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
|
||||
const panel = createRoutePanel({
|
||||
onSolve: () => void solveRouteAction(actionContext),
|
||||
// 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하므로, 끝나면
|
||||
// 옛 노선 기준 캐시를 버리고 페이지를 새로 세운다([초기화]와 같은 뒷정리).
|
||||
onEditPlannedRoute: () =>
|
||||
void openRouteEditModal(activeProjectId, () => {
|
||||
navigateTo(ROUTES.B05_PROFILE);
|
||||
}),
|
||||
onTempSave: () => void tempSaveAction(actionContext),
|
||||
onGoCross: () => {
|
||||
// 페이지 이동 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
|
||||
|
||||
@@ -86,6 +86,8 @@ const SPEED_CHOICES: Record<string, Array<RoutePanelValues["designSpeed"]>> = {
|
||||
|
||||
interface PanelCallbacks {
|
||||
onSolve: () => void;
|
||||
/** [계획노선 편집] — 큰 모달을 열어 노선을 고친다(계산은 모달 [확인]에서만 돈다). */
|
||||
onEditPlannedRoute: () => void;
|
||||
/** [임시저장] — 현재 편집(계획선 델타·관로·비정규 측점·상단측)을 확정 전이 없이 저장. */
|
||||
onTempSave: () => void;
|
||||
/** [횡단 이동] — 저장 없이 B06 횡단 페이지로 이동만. */
|
||||
@@ -302,6 +304,17 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
// 포인트 팔레트 + 임도 기준·옵션을 한 컨테이너로 병합하고 [최적 경로 계산]도 이 안에
|
||||
// 둔다 — 경로 재탐색은 B04~B06 재계산을 부르는 무거운 작업이라 가끔만 쓴다
|
||||
// (2026-08-08 사용자 지시).
|
||||
// 계획노선 편집 — 노선을 고치는 유일한 입구(2026-09-06 사용자 확정). 자동탐색
|
||||
// (「경로 계산 설정」)은 사용 중단이라 이 자리가 노선을 바꾸는 길이다.
|
||||
const plannedRoute = section("계획노선");
|
||||
const plannedRouteBtn = document.createElement("button");
|
||||
plannedRouteBtn.type = "button";
|
||||
plannedRouteBtn.className = "b05-route__btn";
|
||||
plannedRouteBtn.textContent = "계획노선 편집";
|
||||
plannedRouteBtn.title = "예상노선 위에서 계획노선을 고칩니다. [확인] 때만 다시 계산합니다.";
|
||||
plannedRouteBtn.addEventListener("click", () => callbacks.onEditPlannedRoute());
|
||||
plannedRoute.body.append(plannedRouteBtn);
|
||||
|
||||
const routeCalc = section("경로 계산 설정");
|
||||
routeCalc.root.classList.add("is-collapsed");
|
||||
const paletteGrid = document.createElement("div");
|
||||
@@ -603,7 +616,14 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
|
||||
// 배치 순서: 구조물 배치 > 페이지 설정 > 경로 계산 설정(비활성) > 선택 포인트
|
||||
// (평소 숨김) > 하단 고정 dock (2026-08-18 사용자 확정).
|
||||
root.append(structures.root, sectionOptions.root, routeCalc.root, selected.root, actionDock);
|
||||
root.append(
|
||||
structures.root,
|
||||
sectionOptions.root,
|
||||
plannedRoute.root,
|
||||
routeCalc.root,
|
||||
selected.root,
|
||||
actionDock,
|
||||
);
|
||||
|
||||
// 컨테이너 제목 행 전체 클릭 시 본문을 접거나 편다(공용 collapsible). 내부 details 등 별도
|
||||
// 접힘 항목은 손대지 않는다.
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit.ts
|
||||
* 계획노선 편집 모달 — 예상노선(점선) 위에 계획노선(실선)을 고쳐 그린다.
|
||||
*
|
||||
* 왜 모달인가(2026-09-06 사용자 확정) — [확인]을 누르면 배수유역부터 종·횡단·유토곡선까지
|
||||
* 전 단계가 다시 도는 무거운 작업이다(용화 67측점 3분대). 신중히 하라는 뜻으로 큰 모달을
|
||||
* 쓰고, **편집 중에는 아무 계산도 나가지 않는다**.
|
||||
*
|
||||
* 노선은 두 벌이다 — 예상노선(원본, 안 바뀜)과 계획노선(수정본, 사용자가 고침).
|
||||
* [예상노선으로]는 수정본을 버리고 원본으로 되돌린다(서버가 파일을 지우고 같은 재계산).
|
||||
*
|
||||
* 그림은 배수유역도와 같은 지도 도구(`B04_PreProcess_UI_MapRender`)를 쓴다 — 등고선 도엽은
|
||||
* 위경도, 노선은 사업지 좌표계(m)지만 두 변환기가 같은 정규화 공간을 본다.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
computeMapRect,
|
||||
computeRouteView,
|
||||
drawPreparedLayer,
|
||||
createNormalizer,
|
||||
metricToScreen,
|
||||
prepareLayer,
|
||||
type PreparedLayer,
|
||||
type ViewState,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
|
||||
import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
|
||||
import "./B05_Profile_UI_Style_RouteEdit.css";
|
||||
|
||||
/** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */
|
||||
const NODE_HIT_PX = 9;
|
||||
/** 노드 반지름(px). */
|
||||
const NODE_R = 4;
|
||||
/** 끌기로 볼 최소 이동(px) — 이보다 작으면 클릭으로 본다. */
|
||||
const DRAG_THRESHOLD_PX = 3;
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
/** 모달을 연다. [확인]·[예상노선으로]가 끝나면 `onApplied`를 부른다(화면 다시 읽기). */
|
||||
export async function openRouteEditModal(
|
||||
projectId: string,
|
||||
onApplied: () => void | Promise<void>,
|
||||
): Promise<void> {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "b05-routeedit";
|
||||
overlay.innerHTML = `
|
||||
<div class="b05-routeedit__box" role="dialog" aria-label="계획노선 편집">
|
||||
<div class="b05-routeedit__head">
|
||||
<strong>계획노선 편집</strong>
|
||||
<span class="b05-routeedit__hint">
|
||||
노드를 끌어 옮기고, 선을 두 번 누르면 노드가 생깁니다. 노드 오른쪽 클릭은 삭제.
|
||||
</span>
|
||||
<button type="button" class="b05-routeedit__close" aria-label="닫기">✕</button>
|
||||
</div>
|
||||
<div class="b05-routeedit__canvas-wrap"><canvas class="b05-routeedit__canvas"></canvas></div>
|
||||
<div class="b05-routeedit__foot">
|
||||
<span class="b05-routeedit__status">노선을 읽는 중…</span>
|
||||
<span class="b05-routeedit__legend">
|
||||
<i class="is-expected"></i> 예상노선(원본)
|
||||
<i class="is-planned"></i> 계획노선
|
||||
</span>
|
||||
<button type="button" class="b05-routeedit__btn" data-act="reset">예상노선으로</button>
|
||||
<button type="button" class="b05-routeedit__btn" data-act="cancel">취소</button>
|
||||
<button type="button" class="b05-routeedit__btn is-primary" data-act="apply">확인</button>
|
||||
</div>
|
||||
<div class="b05-routeedit__busy" hidden><span></span></div>
|
||||
</div>`;
|
||||
document.body.append(overlay);
|
||||
|
||||
const canvas = overlay.querySelector<HTMLCanvasElement>(".b05-routeedit__canvas")!;
|
||||
const status = overlay.querySelector<HTMLElement>(".b05-routeedit__status")!;
|
||||
const busy = overlay.querySelector<HTMLElement>(".b05-routeedit__busy")!;
|
||||
const context = canvas.getContext("2d")!;
|
||||
|
||||
let expected: Vertex[] = [];
|
||||
let planned: Vertex[] = [];
|
||||
let meta: VWorldMeta | null = null;
|
||||
let sheets: PreparedLayer[] = [];
|
||||
let view: ViewState = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
scale: 1,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
mapRect: computeMapRect(null, 0, 0),
|
||||
};
|
||||
let closed = false;
|
||||
|
||||
const close = (): void => {
|
||||
closed = true;
|
||||
window.removeEventListener("resize", resize);
|
||||
overlay.remove();
|
||||
};
|
||||
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close);
|
||||
overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", close);
|
||||
// 배경 클릭으로 닫지 않는다 — 고치던 노선을 실수로 날리지 않게.
|
||||
|
||||
function resize(): void {
|
||||
if (closed) return;
|
||||
const wrap = canvas.parentElement!;
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
const width = wrap.clientWidth;
|
||||
const height = wrap.clientHeight;
|
||||
canvas.width = Math.round(width * ratio);
|
||||
canvas.height = Math.round(height * ratio);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||
view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) };
|
||||
draw();
|
||||
}
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
const toScreen = (vertex: Vertex): [number, number] =>
|
||||
meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0];
|
||||
|
||||
/** 화면 px → 사업지 좌표(m). `metricToScreen`이 선형이므로 두 기준점으로 역산한다. */
|
||||
function toMetric(px: number, py: number): Vertex {
|
||||
if (!meta) return [0, 0];
|
||||
const [x0, y0] = metricToScreen(meta, view, meta.x_min, meta.y_min);
|
||||
const [x1, y1] = metricToScreen(
|
||||
meta,
|
||||
view,
|
||||
meta.x_min + meta.width_meters,
|
||||
meta.y_min + meta.height_meters,
|
||||
);
|
||||
const sx = (x1 - x0) / (meta.width_meters || 1);
|
||||
const sy = (y1 - y0) / (meta.height_meters || 1);
|
||||
return [meta.x_min + (px - x0) / (sx || 1), meta.y_min + (py - y0) / (sy || 1)];
|
||||
}
|
||||
|
||||
function strokePolyline(points: Vertex[], dash: number[], color: string, width: number): void {
|
||||
if (points.length < 2) return;
|
||||
context.save();
|
||||
context.setLineDash(dash);
|
||||
context.strokeStyle = color;
|
||||
context.lineWidth = width;
|
||||
context.beginPath();
|
||||
points.forEach((vertex, index) => {
|
||||
const [x, y] = toScreen(vertex);
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
context.stroke();
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
if (closed) return;
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
context.clearRect(0, 0, view.width, view.height);
|
||||
context.fillStyle = style.getPropertyValue("--color-surface") || "#111";
|
||||
context.fillRect(0, 0, view.width, view.height);
|
||||
|
||||
context.save();
|
||||
context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc";
|
||||
context.lineWidth = 0.8;
|
||||
for (const layer of sheets) drawPreparedLayer(context, layer, view, "dot");
|
||||
context.restore();
|
||||
|
||||
strokePolyline(
|
||||
expected,
|
||||
[6, 5],
|
||||
style.getPropertyValue("--color-text-secondary") || "#9ca3af",
|
||||
1.6,
|
||||
);
|
||||
strokePolyline(planned, [], style.getPropertyValue("--map-route") || "#f97316", 2.4);
|
||||
|
||||
context.save();
|
||||
context.fillStyle = style.getPropertyValue("--map-route") || "#f97316";
|
||||
context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
|
||||
context.lineWidth = 1;
|
||||
for (const vertex of planned) {
|
||||
const [x, y] = toScreen(vertex);
|
||||
context.beginPath();
|
||||
context.arc(x, y, NODE_R, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
}
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -1. */
|
||||
function nodeAt(px: number, py: number): number {
|
||||
let best = -1;
|
||||
let bestDistance = NODE_HIT_PX;
|
||||
planned.forEach((vertex, index) => {
|
||||
const [x, y] = toScreen(vertex);
|
||||
const distance = Math.hypot(x - px, y - py);
|
||||
if (distance <= bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = index;
|
||||
}
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 두 노드 사이 선분 중 클릭에 가장 가까운 것 — 새 노드를 끼울 자리. */
|
||||
function segmentAt(px: number, py: number): number {
|
||||
let best = -1;
|
||||
let bestDistance = 12;
|
||||
for (let index = 0; index < planned.length - 1; index += 1) {
|
||||
const [ax, ay] = toScreen(planned[index]);
|
||||
const [bx, by] = toScreen(planned[index + 1]);
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const lengthSquared = dx * dx + dy * dy || 1;
|
||||
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared));
|
||||
const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = index;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ──
|
||||
let dragNode = -1;
|
||||
let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
|
||||
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
if (event.button !== 0) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const px = event.clientX - rect.left;
|
||||
const py = event.clientY - rect.top;
|
||||
dragNode = nodeAt(px, py);
|
||||
if (dragNode < 0) panFrom = { x: px, y: py, offsetX: view.offsetX, offsetY: view.offsetY };
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
});
|
||||
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const px = event.clientX - rect.left;
|
||||
const py = event.clientY - rect.top;
|
||||
if (dragNode >= 0) {
|
||||
planned[dragNode] = toMetric(px, py);
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
if (panFrom) {
|
||||
if (Math.hypot(px - panFrom.x, py - panFrom.y) < DRAG_THRESHOLD_PX) return;
|
||||
view = {
|
||||
...view,
|
||||
offsetX: panFrom.offsetX + (px - panFrom.x),
|
||||
offsetY: panFrom.offsetY + (py - panFrom.y),
|
||||
};
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
canvas.style.cursor = nodeAt(px, py) >= 0 ? "grab" : "default";
|
||||
});
|
||||
|
||||
const endDrag = (event: PointerEvent): void => {
|
||||
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
|
||||
dragNode = -1;
|
||||
panFrom = null;
|
||||
};
|
||||
canvas.addEventListener("pointerup", endDrag);
|
||||
canvas.addEventListener("pointercancel", endDrag);
|
||||
|
||||
canvas.addEventListener("dblclick", (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const px = event.clientX - rect.left;
|
||||
const py = event.clientY - rect.top;
|
||||
const segment = segmentAt(px, py);
|
||||
if (segment < 0) return;
|
||||
planned.splice(segment + 1, 0, toMetric(px, py));
|
||||
status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다.`;
|
||||
draw();
|
||||
});
|
||||
|
||||
canvas.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const index = nodeAt(event.clientX - rect.left, event.clientY - rect.top);
|
||||
if (index < 0) return;
|
||||
if (planned.length <= 2) {
|
||||
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
|
||||
return;
|
||||
}
|
||||
planned.splice(index, 1);
|
||||
status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다.`;
|
||||
draw();
|
||||
});
|
||||
|
||||
canvas.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const px = event.clientX - rect.left;
|
||||
const py = event.clientY - rect.top;
|
||||
const factor = event.deltaY < 0 ? 1.2 : 1 / 1.2;
|
||||
const nextScale = Math.max(1, Math.min(2000, view.scale * factor));
|
||||
const ratio = nextScale / view.scale;
|
||||
// 커서 아래 지점이 제자리에 남도록 이동량을 함께 고친다.
|
||||
view = {
|
||||
...view,
|
||||
scale: nextScale,
|
||||
offsetX: px - (px - view.offsetX) * ratio,
|
||||
offsetY: py - (py - view.offsetY) * ratio,
|
||||
};
|
||||
draw();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
async function runHeavy(label: string, task: () => Promise<unknown>): Promise<void> {
|
||||
busy.hidden = false;
|
||||
busy.querySelector("span")!.textContent =
|
||||
`${label} — 배수유역부터 다시 계산 중입니다. 몇 분 걸립니다.`;
|
||||
try {
|
||||
await task();
|
||||
// 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5).
|
||||
clearDrafts(projectId);
|
||||
clearResults(projectId);
|
||||
showToast("노선을 다시 계산했습니다.", "success");
|
||||
close();
|
||||
await onApplied();
|
||||
} catch (error) {
|
||||
busy.hidden = true;
|
||||
showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => {
|
||||
if (planned.length < 2) {
|
||||
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
|
||||
return;
|
||||
}
|
||||
void runHeavy("계획노선 반영", () => replanRoute(projectId, planned));
|
||||
});
|
||||
overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => {
|
||||
void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId));
|
||||
});
|
||||
|
||||
// ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ──
|
||||
try {
|
||||
const [plan, drainage] = await Promise.all([
|
||||
fetchRoutePlan(projectId),
|
||||
fetchDrainageLayers(projectId, () => {}),
|
||||
]);
|
||||
if (closed) return;
|
||||
expected = plan.expected as Vertex[];
|
||||
planned = (plan.planned as Vertex[]).map((vertex) => [vertex[0], vertex[1]]);
|
||||
meta = drainage.meta;
|
||||
const normalizer = createNormalizer(drainage.meta);
|
||||
sheets = drainage.layers
|
||||
.map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null))
|
||||
.filter((layer): layer is PreparedLayer => layer !== null);
|
||||
resize();
|
||||
const xs = planned.map((vertex) => vertex[0]);
|
||||
const ys = planned.map((vertex) => vertex[1]);
|
||||
const fitted = computeRouteView(
|
||||
meta,
|
||||
{
|
||||
x_min: Math.min(...xs),
|
||||
x_max: Math.max(...xs),
|
||||
y_min: Math.min(...ys),
|
||||
y_max: Math.max(...ys),
|
||||
},
|
||||
view.width,
|
||||
view.height,
|
||||
);
|
||||
view = { ...view, ...fitted };
|
||||
status.textContent = `노드 ${planned.length}개 · ${plan.edited ? "고친 계획노선" : "예상노선과 같음"}`;
|
||||
draw();
|
||||
} catch (error) {
|
||||
status.textContent = error instanceof Error ? error.message : "노선을 읽지 못했습니다.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/* 계획노선 편집 모달 — 큰 모달 하나. 계산이 오래 걸리는 조작이라 화면을 통째로 덮는다
|
||||
(2026-09-06 사용자 확정). 색은 전부 테마 토큰을 쓴다. */
|
||||
|
||||
.b05-routeedit {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal, 1000);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgb(0 0 0 / 55%);
|
||||
}
|
||||
|
||||
.b05-routeedit__box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(1200px, 94vw);
|
||||
height: min(820px, 92vh);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-16, 12px);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: 0 12px 40px rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.b05-routeedit__head {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: var(--spacing-12);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b05-routeedit__hint {
|
||||
flex: 1 1 auto;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b05-routeedit__close {
|
||||
flex: none;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b05-routeedit__canvas-wrap {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b05-routeedit__canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.b05-routeedit__foot {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b05-routeedit__status {
|
||||
flex: 1 1 auto;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b05-routeedit__legend {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b05-routeedit__legend i {
|
||||
display: inline-block;
|
||||
width: 22px;
|
||||
height: 0;
|
||||
margin-right: 2px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.b05-routeedit__legend i.is-expected {
|
||||
border-top: 2px dashed var(--color-text-secondary, #9ca3af);
|
||||
}
|
||||
|
||||
.b05-routeedit__legend i.is-planned {
|
||||
border-top: 2px solid var(--map-route, #f97316);
|
||||
}
|
||||
|
||||
.b05-routeedit__btn {
|
||||
flex: none;
|
||||
padding: var(--spacing-8) var(--spacing-16);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 6px);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b05-routeedit__btn.is-primary {
|
||||
border-color: transparent;
|
||||
background: var(--color-primary, #7c3aed);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 재계산 중에는 화면 전체를 덮는다 — 결과를 기다릴 수밖에 없는 조작(CLAUDE.md 5장). */
|
||||
.b05-routeedit__busy {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-16);
|
||||
background: rgb(0 0 0 / 55%);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
Reference in New Issue
Block a user