diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 782cc902..4f6c4539 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -21,13 +21,30 @@ export interface RoutePlanNode { y: number; /** 직전·직후 구간이 이루는 내각(도). 끝점은 null. */ inner_angle_deg: number | null; - /** 이 자리에 끼운 원호 반지름(m). 곡선을 안 둔 자리(내각 155° 이상)는 null. */ + /** 이 자리에 끼운 원호 반지름(m). 곡선을 지운 자리는 null. */ radius_m: number | null; tangent_m: number | null; /** 법정 기준 위반 표시 — 값은 내되 막지 않는다. */ violations: string[]; } +/** 직선 사이에 놓인 **곡선 성분 하나** — 화면이 손잡이와 R 칸을 그리는 재료. */ +export interface RoutePlanCurve { + /** 앞뒤 직선을 늘려 만나는 자리(교각점). **반지름을 바꿔도 여기는 안 움직인다.** */ + apex: [number, number]; + radius_m: number; + tangent_m: number; + inner_angle_deg: number; + /** 곡선 시작점 — 직선이 곡선에 닿는 자리. 사용자가 잡는 손잡이다. */ + start: [number, number]; + /** 곡선 끝점. */ + end: [number, number]; + /** 이 곡선이 대신하는 꺾임점 구간(첫·끝) — 편집이 어느 노드를 건드리는지 알려 준다. */ + node_first: number; + node_last: number; + violations: string[]; +} + export interface RoutePlanResponse { status: string; project_id: string; @@ -37,6 +54,8 @@ export interface RoutePlanResponse { planned: Array<[number, number]>; /** 잡아 옮기는 노드(꺾임점). 편집은 이것으로 한다(2026-09-06 사용자 지시). */ nodes: RoutePlanNode[]; + /** 직선·곡선 성분 — 곡선 시작·끝점과 반지름. 화면이 이것으로 손잡이를 그린다. */ + curves: RoutePlanCurve[]; /** 이 프로젝트에 적용한 법정 최소곡선반지름(m). */ min_radius_m: number; curve_count: number; @@ -80,17 +99,31 @@ export async function fetchRoutePlan(projectId: string): Promise, + vertices: Array<[number, number]> | RouteReplanVertex[], ): Promise { + const payload = (vertices as Array<[number, number] | RouteReplanVertex>).map((vertex) => + Array.isArray(vertex) ? { x: vertex[0], y: vertex[1] } : vertex, + ); return requestJson( `/projects/${projectId}/route/replan`, - { - method: "POST", - body: JSON.stringify({ vertices: vertices.map(([x, y]) => ({ x, y })) }), - }, + { method: "POST", body: JSON.stringify({ vertices: payload }) }, REPLAN_TIMEOUT_MS, ); } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 60d15825..dae496b8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -28,6 +28,7 @@ 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 type { RoutePlanCurve } from "./B05_Profile_Api_Replan"; import "./B05_Profile_UI_Style_RouteEdit.css"; /** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */ @@ -36,6 +37,8 @@ const NODE_HIT_PX = 9; const NODE_R = 4; /** 끌기로 볼 최소 이동(px) — 이보다 작으면 클릭으로 본다. */ const DRAG_THRESHOLD_PX = 3; +/** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 네모로 그린다. */ +const CURVE_HANDLE_PX = 3.5; type Vertex = [number, number]; @@ -56,6 +59,17 @@ export async function openRouteEditModal(
+
노선을 읽는 중… @@ -87,6 +101,13 @@ export async function openRouteEditModal( violations: string[]; }> = []; let minRadiusM = 0; + /** 서버가 준 곡선 성분 — 손잡이(곡선 시작·끝점)를 그리는 재료. 편집하면 비운다. */ + let curveInfo: RoutePlanCurve[] = []; + /** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */ + let curveOn: boolean[] = []; + let curveRadius: Array = []; + /** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */ + let picked = -1; let meta: VWorldMeta | null = null; let sheets: PreparedLayer[] = []; let view: ViewState = { @@ -198,9 +219,35 @@ export async function openRouteEditModal( ? style.getPropertyValue("--color-danger") || "#dc2626" : style.getPropertyValue("--map-route") || "#f97316"; context.beginPath(); - context.arc(x, y, NODE_R, 0, Math.PI * 2); + context.arc(x, y, index === picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2); context.fill(); context.stroke(); + // 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다. + if (curveOn.length && !curveOn[index] && index > 0 && index < planned.length - 1) { + context.save(); + context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; + context.beginPath(); + context.arc(x, y, NODE_R - 2, 0, Math.PI * 2); + context.fill(); + context.restore(); + } + }); + + // 곡선 시작·끝점 — 사용자가 눈으로 곡선 범위를 알아보는 자리(2026-09-07 사용자 지시). + context.fillStyle = style.getPropertyValue("--map-route") || "#f97316"; + curveInfo.forEach((curve) => { + [curve.start, curve.end].forEach((point) => { + const [x, y] = toScreen([point[0], point[1]]); + context.beginPath(); + context.rect( + x - CURVE_HANDLE_PX, + y - CURVE_HANDLE_PX, + CURVE_HANDLE_PX * 2, + CURVE_HANDLE_PX * 2, + ); + context.fill(); + context.stroke(); + }); }); context.restore(); } @@ -245,16 +292,100 @@ export async function openRouteEditModal( function markEdited(): void { plannedLine = []; nodeInfo = []; + curveInfo = []; // 손잡이 자리도 낡았다 — [확인] 때 서버가 다시 낸다. } /** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */ function curveHint(): string { - if (!nodeInfo.length) return minRadiusM ? `곡선 기준 R ${minRadiusM}m — [확인] 때 반영` : ""; + const off = curveOn.filter( + (on, index) => !on && index > 0 && index < planned.length - 1, + ).length; + const forced = curveRadius.filter((value) => value !== null).length; + const edits = [off ? `곡선 지움 ${off}곳` : "", forced ? `R 지정 ${forced}곳` : ""] + .filter(Boolean) + .join(" · "); + if (!nodeInfo.length) { + const base = minRadiusM ? `곡선 기준 R ${minRadiusM}m — [확인] 때 반영` : ""; + return edits ? `${base}${base ? " · " : ""}${edits}` : base; + } const bad = nodeInfo.filter((node) => node.violations.length).length; - const curves = nodeInfo.filter((node) => node.radius_m !== null).length; - return `곡선 ${curves}곳(R ${minRadiusM}m)${bad ? ` · 기준 미달 ${bad}곳` : ""}`; + const curves = curveInfo.length || nodeInfo.filter((node) => node.radius_m !== null).length; + return ( + `곡선 ${curves}곳(하한 R ${minRadiusM}m)` + + `${bad ? ` · 기준 미달 ${bad}곳` : ""}${edits ? ` · ${edits}` : ""}` + ); } + // ── 곡선 편집줄 — 고른 자리의 R 을 바꾸고, 곡선을 지우고 넣는다(2026-09-07 사용자 지시) ── + const curveBar = overlay.querySelector(".b05-routeedit__curve")!; + const curveLabel = curveBar.querySelector(".b05-routeedit__curve-label")!; + const curveRadiusInput = curveBar.querySelector( + ".b05-routeedit__curve-radius", + )!; + const curveInfoText = curveBar.querySelector(".b05-routeedit__curve-info")!; + const curveOffBtn = curveBar.querySelector('[data-act="curve-off"]')!; + const curveOnBtn = curveBar.querySelector('[data-act="curve-on"]')!; + const curveAutoBtn = curveBar.querySelector('[data-act="curve-auto"]')!; + + /** 고른 자리에 맞춰 편집줄을 다시 그린다. 끝점은 곡선이 없으므로 줄을 숨긴다. */ + function syncCurveBar(): void { + const editable = picked > 0 && picked < planned.length - 1; + curveBar.hidden = !editable; + if (!editable) return; + const on = curveOn[picked] !== false; + curveLabel.textContent = `${picked + 1}번째 꺾임점`; + curveOffBtn.hidden = !on; + curveOnBtn.hidden = on; + curveRadiusInput.disabled = !on; + curveAutoBtn.disabled = !on || curveRadius[picked] === null; + const forced = curveRadius[picked]; + const shown = forced ?? curveInfo.find((c) => c.node_first === picked)?.radius_m ?? null; + curveRadiusInput.value = shown === null ? "" : String(Math.round(shown * 10) / 10); + const inner = nodeInfo[picked]?.inner_angle_deg; + curveInfoText.textContent = on + ? `${forced === null ? "자동" : "값 지정"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` + + ` · 법정 하한 ${minRadiusM}m` + : "곡선 없음 — 직선이 그대로 꺾입니다"; + } + + curveRadiusInput.addEventListener("change", () => { + if (picked < 0) return; + const value = Number(curveRadiusInput.value); + curveRadius[picked] = Number.isFinite(value) && value > 0 ? value : null; + // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. + markEdited(); + syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 반지름을 바꿨습니다. ${curveHint()}`; + draw(); + }); + + curveOffBtn.addEventListener("click", () => { + if (picked < 0) return; + curveOn[picked] = false; + markEdited(); + syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 곡선을 지웠습니다. ${curveHint()}`; + draw(); + }); + + curveOnBtn.addEventListener("click", () => { + if (picked < 0) return; + curveOn[picked] = true; + markEdited(); + syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 곡선을 넣었습니다. ${curveHint()}`; + draw(); + }); + + curveAutoBtn.addEventListener("click", () => { + if (picked < 0) return; + curveRadius[picked] = null; // 서버가 예정노선에 맞춰 다시 고른다. + markEdited(); + syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 반지름을 자동으로 되돌렸습니다. ${curveHint()}`; + draw(); + }); + // ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── let dragNode = -1; let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null; @@ -265,7 +396,13 @@ export async function openRouteEditModal( 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 }; + if (dragNode >= 0) { + picked = dragNode; // 누른 자리를 고른다 — 편집줄이 그 곡선을 만진다. + syncCurveBar(); + draw(); + } else { + panFrom = { x: px, y: py, offsetX: view.offsetX, offsetY: view.offsetY }; + } canvas.setPointerCapture(event.pointerId); }); @@ -307,8 +444,13 @@ export async function openRouteEditModal( const segment = segmentAt(px, py); if (segment < 0) return; planned.splice(segment + 1, 0, toMetric(px, py)); + // 편집값도 같은 자리에 끼워 넣는다 — 안 그러면 뒤 노드의 R·켬끔이 한 칸씩 밀린다. + curveOn.splice(segment + 1, 0, true); + curveRadius.splice(segment + 1, 0, null); + picked = segment + 1; markEdited(); - status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다. ${curveHint()}`; + syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다(직선 추가). ${curveHint()}`; draw(); }); @@ -322,8 +464,12 @@ export async function openRouteEditModal( return; } planned.splice(index, 1); + curveOn.splice(index, 1); + curveRadius.splice(index, 1); + picked = -1; markEdited(); - status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다. ${curveHint()}`; + syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다(직선 삭제). ${curveHint()}`; draw(); }); @@ -372,7 +518,17 @@ export async function openRouteEditModal( showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); return; } - void runHeavy("계획노선 반영", () => replanRoute(projectId, planned)); + void runHeavy("계획노선 반영", () => + replanRoute( + projectId, + planned.map(([x, y], index) => ({ + x, + y, + curve: curveOn[index] !== false, + radius_m: curveRadius[index] ?? null, + })), + ), + ); }); overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => { void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId)); @@ -397,6 +553,12 @@ export async function openRouteEditModal( violations: node.violations ?? [], })); minRadiusM = plan.min_radius_m ?? 0; + curveInfo = plan.curves ?? []; + // 편집값은 「서버가 준 그대로」에서 시작한다 — 곡선이 있는 자리는 켬, 반지름은 자동. + curveOn = planned.map(() => true); + curveRadius = planned.map(() => null); + picked = -1; + syncCurveBar(); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); meta = drainage.meta; const normalizer = createNormalizer(drainage.meta); diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index c67c77b5..fc664288 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -130,3 +130,49 @@ color: #fff; text-align: center; } + +/* 곡선 편집줄 — 고른 꺾임점의 R 을 바꾸고, 곡선을 지우고 넣는다(2026-09-07 사용자 지시). + 바닥 단추줄과 같은 결로 두되, 고른 것이 없으면 통째로 숨는다. */ +.b05-routeedit__curve { + display: flex; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-12); + border-top: 1px solid var(--color-border, #e5e7eb); + background: var(--color-surface-2, #f9fafb); + font-size: var(--font-size-13, 13px); +} + +.b05-routeedit__curve-label { + font-weight: 600; + white-space: nowrap; +} + +.b05-routeedit__curve-field { + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +.b05-routeedit__curve-radius { + width: 5.5rem; + padding: 2px 6px; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: var(--radius-4, 4px); + font: inherit; + text-align: right; +} + +.b05-routeedit__curve-radius:disabled { + background: var(--color-surface-3, #f3f4f6); + color: var(--color-text-muted, #9ca3af); +} + +.b05-routeedit__curve-info { + flex: 1 1 auto; + color: var(--color-text-muted, #6b7280); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +}