diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts new file mode 100644 index 00000000..2761b114 --- /dev/null +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -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(path: string, init: RequestInit, timeoutMs: number): Promise { + 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 { + return requestJson( + `/projects/${projectId}/route/plan`, + { method: "GET" }, + 60000, + ); +} + +/** 고친 계획노선으로 갈아 끼우고 배수유역부터 다시 계산한다. */ +export async function replanRoute( + projectId: string, + vertices: Array<[number, number]>, +): Promise { + return requestJson( + `/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 { + return requestJson( + `/projects/${projectId}/route/replan/reset`, + { method: "POST" }, + REPLAN_TIMEOUT_MS, + ); +} diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 12d7d330..6071d1e7 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -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) diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index e21df61d..57f200e4 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -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 { const panel = createRoutePanel({ onSolve: () => void solveRouteAction(actionContext), + // 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하므로, 끝나면 + // 옛 노선 기준 캐시를 버리고 페이지를 새로 세운다([초기화]와 같은 뒷정리). + onEditPlannedRoute: () => + void openRouteEditModal(activeProjectId, () => { + navigateTo(ROUTES.B05_PROFILE); + }), onTempSave: () => void tempSaveAction(actionContext), onGoCross: () => { // 페이지 이동 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다. diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index e9ec05cf..2480ac40 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -86,6 +86,8 @@ const SPEED_CHOICES: Record> = { 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 등 별도 // 접힘 항목은 손대지 않는다. diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts new file mode 100644 index 00000000..6fbbae78 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -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, +): Promise { + const overlay = document.createElement("div"); + overlay.className = "b05-routeedit"; + overlay.innerHTML = ` + `; + document.body.append(overlay); + + const canvas = overlay.querySelector(".b05-routeedit__canvas")!; + const status = overlay.querySelector(".b05-routeedit__status")!; + const busy = overlay.querySelector(".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): Promise { + 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 : "노선을 읽지 못했습니다."; + } +} diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css new file mode 100644 index 00000000..c67c77b5 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -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; +}