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:
@@ -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<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(
|
||||
projectId: string,
|
||||
vertices: Array<[number, number]>,
|
||||
vertices: Array<[number, number]> | RouteReplanVertex[],
|
||||
): 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>(
|
||||
`/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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<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__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">
|
||||
<span class="b05-routeedit__status">노선을 읽는 중…</span>
|
||||
<span class="b05-routeedit__legend">
|
||||
@@ -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<number | null> = [];
|
||||
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -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<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 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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user