feat(B05): 노선 편집 모달에 곡선 제어 — R 칸·곡선 지우기/넣기·시작끝점 표시

사용자 지시(2026-09-07) — 「r과 직선 삭제나 추가가 있어야 하지 않을까?」
서버 쪽(f59932de)에 이어 화면을 붙임.

  - **곡선 시작·끝점을 네모로 표시** — 어디부터 어디까지가 한 곡선인지 눈으로 봄.
  - 꺾임점을 누르면 **고른 자리**가 되고 아래 편집줄이 그 곡선을 만짐.
  - **R 입력칸** — 값을 넣으면 그 반지름으로 못박고, [반지름 자동]으로 되돌리면
    서버가 예정노선에 맞춰 다시 고름.
  - **[곡선 지우기] / [곡선 넣기]** — 지운 자리는 가운데가 빈 동그라미로 보이고
    직선이 그대로 꺾임.
  - 직선 삭제·추가는 원래 있던 조작 그대로(오른쪽 클릭 삭제 · 두 번 클릭 삽입).
    다만 그때 **편집값 목록도 같은 자리에서 밀어 준다** — 안 그러면 뒤 노드의
    R·켬끔이 한 칸씩 어긋남.
  - [확인]이 정점마다 `curve`·`radius_m` 을 함께 보냄.

상태줄에 「곡선 N곳(하한 R m) · 곡선 지움 N곳 · R 지정 N곳」을 보임.

tsc --noEmit 통과 · pytest 427 passed.

남은 것 — **곡선 시작·끝점을 끌어 직선 각도와 R 을 함께 바꾸는 조작**은 다음 단계.
지금은 표시까지이고, 자리 옮기기는 꺾임점 끌기로 함.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 08:14:58 +09:00
co-authored by Claude Opus 5
parent f59932de66
commit 56a6d18611
3 changed files with 256 additions and 15 deletions
+40 -7
View File
@@ -21,13 +21,30 @@ export interface RoutePlanNode {
y: number; y: number;
/** 직전·직후 구간이 이루는 내각(도). 끝점은 null. */ /** 직전·직후 구간이 이루는 내각(도). 끝점은 null. */
inner_angle_deg: number | null; inner_angle_deg: number | null;
/** 이 자리에 끼운 원호 반지름(m). 곡선을 안 둔 자리(내각 155° 이상)는 null. */ /** 이 자리에 끼운 원호 반지름(m). 곡선을 지운 자리는 null. */
radius_m: number | null; radius_m: number | null;
tangent_m: number | null; tangent_m: number | null;
/** 법정 기준 위반 표시 — 값은 내되 막지 않는다. */ /** 법정 기준 위반 표시 — 값은 내되 막지 않는다. */
violations: string[]; 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 { export interface RoutePlanResponse {
status: string; status: string;
project_id: string; project_id: string;
@@ -37,6 +54,8 @@ export interface RoutePlanResponse {
planned: Array<[number, number]>; planned: Array<[number, number]>;
/** 잡아 옮기는 노드(꺾임점). 편집은 이것으로 한다(2026-09-06 사용자 지시). */ /** 잡아 옮기는 노드(꺾임점). 편집은 이것으로 한다(2026-09-06 사용자 지시). */
nodes: RoutePlanNode[]; nodes: RoutePlanNode[];
/** 직선·곡선 성분 — 곡선 시작·끝점과 반지름. 화면이 이것으로 손잡이를 그린다. */
curves: RoutePlanCurve[];
/** 이 프로젝트에 적용한 법정 최소곡선반지름(m). */ /** 이 프로젝트에 적용한 법정 최소곡선반지름(m). */
min_radius_m: number; min_radius_m: number;
curve_count: number; curve_count: number;
@@ -80,17 +99,31 @@ export async function fetchRoutePlan(projectId: string): Promise<RoutePlanRespon
); );
} }
/** 고친 계획노선으로 갈아 끼우고 배수유역부터 다시 계산한다. */ /** 꺾임점 하나에 실어 보내는 편집값 — 곡선을 둘지, 반지름을 못박을지. */
export interface RouteReplanVertex {
x: number;
y: number;
/** 이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다(곡선 삭제). */
curve?: boolean;
/** 못박을 반지름(m). 없으면 서버가 고른다. */
radius_m?: number | null;
}
/** 고친 계획노선으로 갈아 끼우고 배수유역부터 다시 계산한다.
*
* 편집 세 가지가 모두 이 한 목록으로 나간다(2026-09-07 사용자 지시) —
* **직선 삭제·추가**는 점을 빼거나 더하는 것, **곡선 삭제·추가**는 `curve` 를 끄고 켜는 것,
* **반지름 변경**은 `radius_m` 을 주는 것. */
export async function replanRoute( export async function replanRoute(
projectId: string, projectId: string,
vertices: Array<[number, number]>, vertices: Array<[number, number]> | RouteReplanVertex[],
): Promise<RouteReplanResponse> { ): Promise<RouteReplanResponse> {
const payload = (vertices as Array<[number, number] | RouteReplanVertex>).map((vertex) =>
Array.isArray(vertex) ? { x: vertex[0], y: vertex[1] } : vertex,
);
return requestJson<RouteReplanResponse>( return requestJson<RouteReplanResponse>(
`/projects/${projectId}/route/replan`, `/projects/${projectId}/route/replan`,
{ { method: "POST", body: JSON.stringify({ vertices: payload }) },
method: "POST",
body: JSON.stringify({ vertices: vertices.map(([x, y]) => ({ x, y })) }),
},
REPLAN_TIMEOUT_MS, REPLAN_TIMEOUT_MS,
); );
} }
+170 -8
View File
@@ -28,6 +28,7 @@ import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
import { showToast } from "@ui/ui_template_elements"; import { showToast } from "@ui/ui_template_elements";
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan"; import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
import type { RoutePlanCurve } from "./B05_Profile_Api_Replan";
import "./B05_Profile_UI_Style_RouteEdit.css"; import "./B05_Profile_UI_Style_RouteEdit.css";
/** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */ /** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */
@@ -36,6 +37,8 @@ const NODE_HIT_PX = 9;
const NODE_R = 4; const NODE_R = 4;
/** 끌기로 볼 최소 이동(px) — 이보다 작으면 클릭으로 본다. */ /** 끌기로 볼 최소 이동(px) — 이보다 작으면 클릭으로 본다. */
const DRAG_THRESHOLD_PX = 3; const DRAG_THRESHOLD_PX = 3;
/** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 네모로 그린다. */
const CURVE_HANDLE_PX = 3.5;
type Vertex = [number, number]; type Vertex = [number, number];
@@ -56,6 +59,17 @@ export async function openRouteEditModal(
<button type="button" class="b05-routeedit__close" aria-label="닫기">✕</button> <button type="button" class="b05-routeedit__close" aria-label="닫기">✕</button>
</div> </div>
<div class="b05-routeedit__canvas-wrap"><canvas class="b05-routeedit__canvas"></canvas></div> <div class="b05-routeedit__canvas-wrap"><canvas class="b05-routeedit__canvas"></canvas></div>
<div class="b05-routeedit__curve" hidden>
<span class="b05-routeedit__curve-label">고른 곡선</span>
<label class="b05-routeedit__curve-field">R
<input type="number" class="b05-routeedit__curve-radius" min="1" step="0.5" />
<span>m</span>
</label>
<span class="b05-routeedit__curve-info"></span>
<button type="button" class="b05-routeedit__btn" data-act="curve-off">곡선 지우기</button>
<button type="button" class="b05-routeedit__btn" data-act="curve-on" hidden>곡선 넣기</button>
<button type="button" class="b05-routeedit__btn" data-act="curve-auto">반지름 자동</button>
</div>
<div class="b05-routeedit__foot"> <div class="b05-routeedit__foot">
<span class="b05-routeedit__status">노선을 읽는 중…</span> <span class="b05-routeedit__status">노선을 읽는 중…</span>
<span class="b05-routeedit__legend"> <span class="b05-routeedit__legend">
@@ -87,6 +101,13 @@ export async function openRouteEditModal(
violations: string[]; violations: string[];
}> = []; }> = [];
let minRadiusM = 0; let minRadiusM = 0;
/** 서버가 준 곡선 성분 — 손잡이(곡선 시작·끝점)를 그리는 재료. 편집하면 비운다. */
let curveInfo: RoutePlanCurve[] = [];
/** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */
let curveOn: boolean[] = [];
let curveRadius: Array<number | null> = [];
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
let picked = -1;
let meta: VWorldMeta | null = null; let meta: VWorldMeta | null = null;
let sheets: PreparedLayer[] = []; let sheets: PreparedLayer[] = [];
let view: ViewState = { let view: ViewState = {
@@ -198,9 +219,35 @@ export async function openRouteEditModal(
? style.getPropertyValue("--color-danger") || "#dc2626" ? style.getPropertyValue("--color-danger") || "#dc2626"
: style.getPropertyValue("--map-route") || "#f97316"; : style.getPropertyValue("--map-route") || "#f97316";
context.beginPath(); 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.fill();
context.stroke(); 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(); context.restore();
} }
@@ -245,16 +292,100 @@ export async function openRouteEditModal(
function markEdited(): void { function markEdited(): void {
plannedLine = []; plannedLine = [];
nodeInfo = []; nodeInfo = [];
curveInfo = []; // 손잡이 자리도 낡았다 — [확인] 때 서버가 다시 낸다.
} }
/** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */ /** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */
function curveHint(): string { 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 bad = nodeInfo.filter((node) => node.violations.length).length;
const curves = nodeInfo.filter((node) => node.radius_m !== null).length; const curves = curveInfo.length || nodeInfo.filter((node) => node.radius_m !== null).length;
return `곡선 ${curves}곳(R ${minRadiusM}m)${bad ? ` · 기준 미달 ${bad}` : ""}`; return (
`곡선 ${curves}곳(하한 R ${minRadiusM}m)` +
`${bad ? ` · 기준 미달 ${bad}` : ""}${edits ? ` · ${edits}` : ""}`
);
} }
// ── 곡선 편집줄 — 고른 자리의 R 을 바꾸고, 곡선을 지우고 넣는다(2026-09-07 사용자 지시) ──
const curveBar = overlay.querySelector<HTMLElement>(".b05-routeedit__curve")!;
const curveLabel = curveBar.querySelector<HTMLElement>(".b05-routeedit__curve-label")!;
const curveRadiusInput = curveBar.querySelector<HTMLInputElement>(
".b05-routeedit__curve-radius",
)!;
const curveInfoText = curveBar.querySelector<HTMLElement>(".b05-routeedit__curve-info")!;
const curveOffBtn = curveBar.querySelector<HTMLButtonElement>('[data-act="curve-off"]')!;
const curveOnBtn = curveBar.querySelector<HTMLButtonElement>('[data-act="curve-on"]')!;
const curveAutoBtn = curveBar.querySelector<HTMLButtonElement>('[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 dragNode = -1;
let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null; 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 px = event.clientX - rect.left;
const py = event.clientY - rect.top; const py = event.clientY - rect.top;
dragNode = nodeAt(px, py); 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); canvas.setPointerCapture(event.pointerId);
}); });
@@ -307,8 +444,13 @@ export async function openRouteEditModal(
const segment = segmentAt(px, py); const segment = segmentAt(px, py);
if (segment < 0) return; if (segment < 0) return;
planned.splice(segment + 1, 0, toMetric(px, py)); 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(); markEdited();
status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다. ${curveHint()}`; syncCurveBar();
status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다(직선 추가). ${curveHint()}`;
draw(); draw();
}); });
@@ -322,8 +464,12 @@ export async function openRouteEditModal(
return; return;
} }
planned.splice(index, 1); planned.splice(index, 1);
curveOn.splice(index, 1);
curveRadius.splice(index, 1);
picked = -1;
markEdited(); markEdited();
status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다. ${curveHint()}`; syncCurveBar();
status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다(직선 삭제). ${curveHint()}`;
draw(); draw();
}); });
@@ -372,7 +518,17 @@ export async function openRouteEditModal(
showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
return; 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", () => { overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => {
void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId)); void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId));
@@ -397,6 +553,12 @@ export async function openRouteEditModal(
violations: node.violations ?? [], violations: node.violations ?? [],
})); }));
minRadiusM = plan.min_radius_m ?? 0; 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]]); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
meta = drainage.meta; meta = drainage.meta;
const normalizer = createNormalizer(drainage.meta); const normalizer = createNormalizer(drainage.meta);
@@ -130,3 +130,49 @@
color: #fff; color: #fff;
text-align: center; 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;
}