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;
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user