feat(B05): 노선 편집 패널 — 화면으로 띄우기, 16방위 자동 위치, 손으로 옮기기, R·길이 고정
사용자 지시 5건. ⚠ **화면 검증은 아직 못 함** — 다른 창의 분배 조율로 git sync 를 먼저 하게 되어 미커밋으로 두지 않으려고 먼저 커밋함. 타입 검사·시험은 통과 상태임. - **1. 패널을 화면으로** — `document.body` 에 `position: fixed` 로 띄움. 모달이 `overflow: hidden` 이라 안에 두면 가장자리에서 잘렸음. 이제 모달 밖으로도 넘어감. - **2. 너무 가까움 + 위치 제어** — 여백 14 → 40px 로 넓히고 방위별 상자 반지름만큼 더 밀어냄. **머리를 잡아 손으로 옮길 수 있음**. 옮긴 자리는 꺾임점 기준으로 기억해 지도를 옮기거나 확대해도 따라오고, 다른 꺾임점을 고르면 자동 자리로 돌아감. - **3. 자동 위치를 16방위로** — 4방위는 대각 자리에서 곡선을 물었음. 곡선 중심의 반대 방향을 22.5° 단위로 맞춤. - **4. 「법정 하한」 삭제**, 「칸을 비우면 자동」을 **다음 줄**로 뺌. 대신 무엇을 붙들고 있는지(고정 없음 / 반지름 고정 / 곡선 길이 고정)를 그 줄에 냄. - **5. 반지름·곡선 길이 고정 단추** — 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직임. 그래서 **셋 중 하나**임: 자동 / R 고정(길이가 따라감) / 길이 고정(R 을 L/Δ 로 다시 잡음). 붙들어 둔 곡선은 **손잡이로도 안 바뀜**(끌면 R 이 바뀌므로 집기에서 제외). 잠금·길이는 되돌리기 사진에도 담김. - 새 모듈 `_Edits.ts` — 잠금 반영·상태줄 요약·서버 응답 펴기. 본체 692줄로 700줄 유지. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,12 @@ import {
|
||||
createCurveLabel,
|
||||
deflectionRad,
|
||||
} from "./B05_Profile_UI_RouteEdit_Label";
|
||||
import {
|
||||
applyArcLocks,
|
||||
curveSummary,
|
||||
flattenServerPlan,
|
||||
type CurveLock,
|
||||
} from "./B05_Profile_UI_RouteEdit_Edits";
|
||||
import {
|
||||
bindHistoryControls,
|
||||
createRouteEditHistory,
|
||||
@@ -128,6 +134,10 @@ export async function openRouteEditModal(
|
||||
/** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */
|
||||
let curveOn: boolean[] = [];
|
||||
let curveRadius: Array<number | null> = [];
|
||||
/** 붙들어 둔 값 — 무엇을 고정할지와, 길이를 고정했을 때의 그 길이(m).
|
||||
* 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */
|
||||
let curveLock: CurveLock[] = [];
|
||||
let curveArc: Array<number | null> = [];
|
||||
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
|
||||
let picked = -1;
|
||||
/** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */
|
||||
@@ -148,6 +158,7 @@ export async function openRouteEditModal(
|
||||
closed = true;
|
||||
window.removeEventListener("resize", resize);
|
||||
historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다.
|
||||
curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다.
|
||||
overlay.remove();
|
||||
};
|
||||
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close);
|
||||
@@ -299,7 +310,14 @@ export async function openRouteEditModal(
|
||||
|
||||
/** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */
|
||||
const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null =>
|
||||
handleAtScreen(curveInfo, toScreen, px, py, NODE_HIT_PX + 2);
|
||||
handleAtScreen(
|
||||
// 붙들어 둔 곡선은 손잡이로도 안 바뀐다 — 끌면 R 이 바뀌기 때문(사용자 지시 5).
|
||||
curveInfo.filter((entry) => (curveLock[entry.node_first] ?? null) === null),
|
||||
toScreen,
|
||||
px,
|
||||
py,
|
||||
NODE_HIT_PX + 2,
|
||||
);
|
||||
const nodeAt = (px: number, py: number): number =>
|
||||
nodeAtScreen(planned, toScreen, px, py, NODE_HIT_PX);
|
||||
const segmentAt = (px: number, py: number): number =>
|
||||
@@ -326,35 +344,29 @@ export async function openRouteEditModal(
|
||||
* 지금은 서버와 **같은 규칙**(`buildEditedPolyline` 짝)으로 즉시 다시 만든다. [확인] 때
|
||||
* 서버가 정본으로 다시 내는 것은 그대로다. */
|
||||
function markEdited(): void {
|
||||
// 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다.
|
||||
applyArcLocks(planned, curveLock, curveArc, curveRadius);
|
||||
const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM);
|
||||
plannedLine = built.vertices;
|
||||
curveInfo = built.curves;
|
||||
nodeInfo = built.nodes;
|
||||
}
|
||||
|
||||
/** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */
|
||||
function curveHint(): string {
|
||||
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 = curveInfo.length || nodeInfo.filter((node) => node.radius_m !== null).length;
|
||||
return (
|
||||
`곡선 ${curves}곳(하한 R ${minRadiusM}m)` +
|
||||
`${bad ? ` · 기준 미달 ${bad}곳` : ""}${edits ? ` · ${edits}` : ""}`
|
||||
);
|
||||
}
|
||||
/** 상태줄 꼬리 — 셈은 `_Edits` 몫. */
|
||||
const curveHint = (): string =>
|
||||
curveSummary({
|
||||
nodeCount: planned.length,
|
||||
curveOn,
|
||||
curveRadius,
|
||||
curveLock,
|
||||
curveCount: curveInfo.length,
|
||||
violationCount: nodeInfo.filter((node) => node.violations.length).length,
|
||||
minRadiusM,
|
||||
fresh: nodeInfo.length === 0,
|
||||
});
|
||||
|
||||
// ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ──
|
||||
const curveLabelBox = createCurveLabel(canvas.parentElement!, {
|
||||
const curveLabelBox = createCurveLabel({
|
||||
onRadius: (value) => {
|
||||
if (picked < 0) return;
|
||||
curveRadius[picked] = value;
|
||||
@@ -366,9 +378,29 @@ export async function openRouteEditModal(
|
||||
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
|
||||
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
curveArc[picked] = value;
|
||||
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
|
||||
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.");
|
||||
},
|
||||
onLock: (lock) => {
|
||||
if (picked < 0) return;
|
||||
curveLock[picked] = lock;
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
|
||||
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
|
||||
if (lock === "arc") {
|
||||
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
|
||||
}
|
||||
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
|
||||
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
|
||||
applyEdit(
|
||||
lock === "radius"
|
||||
? "반지름을 고정했습니다."
|
||||
: lock === "arc"
|
||||
? "곡선 길이를 고정했습니다."
|
||||
: "고정을 풀었습니다.",
|
||||
);
|
||||
},
|
||||
onCurveOn: (on) => {
|
||||
if (picked < 0) return;
|
||||
curveOn[picked] = on;
|
||||
@@ -382,13 +414,15 @@ export async function openRouteEditModal(
|
||||
curveLabelBox.hide();
|
||||
return;
|
||||
}
|
||||
const forced = curveRadius[picked];
|
||||
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
|
||||
const shown = forced ?? pickedCurve?.radius_m ?? null;
|
||||
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const [screenX, screenY] = toScreen(planned[picked]);
|
||||
curveLabelBox.show({
|
||||
seat: picked,
|
||||
at: toScreen(planned[picked]),
|
||||
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다 — 모달 밖으로 넘어가도 안 잘린다.
|
||||
at: [screenX + rect.left, screenY + rect.top],
|
||||
centerDirection: pickedCurve
|
||||
? centerDirectionOf(
|
||||
toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
|
||||
@@ -396,13 +430,11 @@ export async function openRouteEditModal(
|
||||
toScreen(pickedCurve.end),
|
||||
)
|
||||
: null,
|
||||
canvas: { width: view.width, height: view.height },
|
||||
curveOn: curveOn[picked] !== false,
|
||||
radiusShown: shown,
|
||||
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
|
||||
forced: forced !== null,
|
||||
lock: curveLock[picked] ?? null,
|
||||
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
|
||||
minRadiusM,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -418,7 +450,7 @@ export async function openRouteEditModal(
|
||||
|
||||
/** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */
|
||||
function snapshotNow(): RouteEditSnapshot {
|
||||
return { planned, curveOn, curveRadius, picked };
|
||||
return { planned, curveOn, curveRadius, curveLock, curveArc, picked };
|
||||
}
|
||||
|
||||
const historyControls = bindHistoryControls({
|
||||
@@ -428,6 +460,8 @@ export async function openRouteEditModal(
|
||||
planned = snapshot.planned;
|
||||
curveOn = snapshot.curveOn;
|
||||
curveRadius = snapshot.curveRadius;
|
||||
curveLock = snapshot.curveLock;
|
||||
curveArc = snapshot.curveArc;
|
||||
picked = snapshot.picked;
|
||||
applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다.
|
||||
},
|
||||
@@ -526,6 +560,8 @@ export async function openRouteEditModal(
|
||||
// 편집값도 같은 자리에 끼워 넣는다 — 안 그러면 뒤 노드의 R·켬끔이 한 칸씩 밀린다.
|
||||
curveOn.splice(segment + 1, 0, true);
|
||||
curveRadius.splice(segment + 1, 0, null);
|
||||
curveLock.splice(segment + 1, 0, null);
|
||||
curveArc.splice(segment + 1, 0, null);
|
||||
picked = segment + 1;
|
||||
applyEdit("새 노드를 넣었습니다(직선 추가).");
|
||||
});
|
||||
@@ -542,6 +578,8 @@ export async function openRouteEditModal(
|
||||
planned.splice(index, 1);
|
||||
curveOn.splice(index, 1);
|
||||
curveRadius.splice(index, 1);
|
||||
curveLock.splice(index, 1);
|
||||
curveArc.splice(index, 1);
|
||||
picked = -1;
|
||||
applyEdit("노드를 지웠습니다(직선 삭제).");
|
||||
});
|
||||
@@ -609,48 +647,15 @@ export async function openRouteEditModal(
|
||||
// (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다).
|
||||
const nodes = plan.nodes ?? [];
|
||||
minRadiusM = plan.min_radius_m ?? 0;
|
||||
// 곡선 성분을 **편집할 수 있는 꼴로 펴 둔다**(2026-09-07).
|
||||
//
|
||||
// 서버는 처음 만들 때 이어진 꺾임을 한 곡선으로 묶는다 — 그 곡선의 교각점은 앞뒤 직선을
|
||||
// 늘려 만나는 자리라 **원본 꺾임점 중 어느 것도 아니다**. 그런데 편집은 「꺾임점 하나 =
|
||||
// 곡선 하나」로 표현되므로, 묶인 곡선을 그대로 두면 한 번만 손대도 그 묶음이 낱개로
|
||||
// 흩어지고 **맞춰 둔 반지름이 전부 법정 하한으로 되돌아간다**(실측: R 12~199m → 전부 12m).
|
||||
//
|
||||
// 그래서 **묶인 구간을 그 교각점 하나로 갈아 끼운다** — 안쪽 꺾임점은 그 곡선이 대신하므로
|
||||
// 뺀다. 앞뒤 직선과 반지름이 그대로라 **그려지는 선은 똑같고**, 이제 손대도 안 흩어진다.
|
||||
const curves = plan.curves ?? [];
|
||||
const replaced = new Map<number, (typeof curves)[number]>();
|
||||
const dropped = new Set<number>();
|
||||
curves.forEach((curve) => {
|
||||
replaced.set(curve.node_first, curve);
|
||||
for (let index = curve.node_first + 1; index <= curve.node_last; index += 1) {
|
||||
dropped.add(index);
|
||||
}
|
||||
});
|
||||
planned = [];
|
||||
nodeInfo = [];
|
||||
curveOn = [];
|
||||
curveRadius = [];
|
||||
curveInfo = [];
|
||||
nodes.forEach((node, index) => {
|
||||
if (dropped.has(index)) return;
|
||||
const curve = replaced.get(index);
|
||||
const at: Vertex = curve ? [curve.apex[0], curve.apex[1]] : [node.x, node.y];
|
||||
const seat = planned.length;
|
||||
planned.push(at);
|
||||
nodeInfo.push({
|
||||
radius_m: curve ? curve.radius_m : node.radius_m,
|
||||
inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg,
|
||||
violations: curve ? (curve.violations ?? []) : (node.violations ?? []),
|
||||
});
|
||||
// 서버가 **곡선을 안 둔 자리는 「곡선 없음」으로 연다**(2026-09-07). 전부 켬으로 열면
|
||||
// 아무것도 안 만지고 [확인]만 눌러도 그 자리에 곡선이 새로 생겨 노선이 조용히 바뀐다
|
||||
// (서버의 편집 갈래는 켜진 자리마다 원호를 끼우기 때문). 내각 179° 이상인 자리가 그렇다.
|
||||
curveOn.push(curve !== undefined);
|
||||
// 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다.
|
||||
curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null);
|
||||
if (curve) curveInfo.push({ ...curve, node_first: seat, node_last: seat });
|
||||
});
|
||||
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
|
||||
const flat = flattenServerPlan(nodes, plan.curves ?? []);
|
||||
planned = flat.planned;
|
||||
nodeInfo = flat.nodes;
|
||||
curveInfo = flat.curves;
|
||||
curveOn = flat.curveOn;
|
||||
curveRadius = flat.curveRadius;
|
||||
curveLock = flat.curveLock;
|
||||
curveArc = flat.curveArc;
|
||||
picked = -1;
|
||||
// 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로.
|
||||
history = createRouteEditHistory(snapshotNow());
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Edits.ts
|
||||
* 꺾임점마다의 **편집값**을 다루는 순수 함수 — 잠금과 상태줄 요약.
|
||||
*
|
||||
* **잠금이 왜 필요한가**(2026-09-07 사용자 지시) — 노드를 옮기면 앞뒤 직선의 교각(Δ)이
|
||||
* 바뀐다. 반지름 R 과 곡선 길이 L 은 **L = R·Δ** 로 묶여 있으므로, 한쪽을 붙들면 다른 쪽은
|
||||
* 반드시 따라 움직인다. 둘 다 붙들 수는 없다(그러면 Δ 를 못 바꾼다). 그래서 잠금은 셋 중 하나다.
|
||||
*
|
||||
* · `null` — 자동. R 은 법정 하한을 쓰고 L 은 따라온다.
|
||||
* · `"radius"` — **R 고정**. 노드를 옮겨도 R 이 그대로고 L 이 바뀐다.
|
||||
* · `"arc"` — **곡선 길이 고정**. 노드를 옮기면 R 을 L/Δ 로 다시 잡는다.
|
||||
*
|
||||
* ⚠ 접선 자리가 모자라면 그리기 단계에서 R 을 줄이는 것은 그대로다(`buildEditedPolyline`).
|
||||
* 그것은 **그려지는 값**만 줄이고 잠가 둔 값은 안 건드린다 — 자리를 넓히면 되돌아온다.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
innerAngleDeg,
|
||||
type EditedCurve,
|
||||
type EditedNode,
|
||||
type Vertex,
|
||||
} from "./B05_Profile_UI_RouteEdit_Curve";
|
||||
import { deflectionRad } from "./B05_Profile_UI_RouteEdit_Label";
|
||||
|
||||
/** 무엇을 붙들고 있나. */
|
||||
export type CurveLock = "radius" | "arc" | null;
|
||||
|
||||
/**
|
||||
* **곡선 길이를 잠근 자리**의 반지름을 지금 교각에 맞춰 다시 잡는다(`curveRadius` 를 고침).
|
||||
*
|
||||
* 노드를 옮길 때마다 부른다. 잠그지 않았거나 R 을 잠근 자리는 손대지 않는다.
|
||||
* 교각이 0 에 가까우면(거의 직선) 길이를 지킬 방법이 없으므로 그대로 둔다.
|
||||
*/
|
||||
export function applyArcLocks(
|
||||
planned: Vertex[],
|
||||
curveLock: CurveLock[],
|
||||
curveArc: Array<number | null>,
|
||||
curveRadius: Array<number | null>,
|
||||
): void {
|
||||
for (let seat = 1; seat < planned.length - 1; seat += 1) {
|
||||
if (curveLock[seat] !== "arc") continue;
|
||||
const length = curveArc[seat];
|
||||
if (length === null || length === undefined) continue;
|
||||
const inner = innerAngleDeg(planned[seat - 1], planned[seat], planned[seat + 1]);
|
||||
const deflection = deflectionRad(inner);
|
||||
if (deflection <= 1e-9) continue;
|
||||
curveRadius[seat] = length / deflection;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CurveSummaryInput {
|
||||
nodeCount: number;
|
||||
curveOn: boolean[];
|
||||
curveRadius: Array<number | null>;
|
||||
curveLock: CurveLock[];
|
||||
/** 그려 낸 곡선 수 — 아직 안 그렸으면 0. */
|
||||
curveCount: number;
|
||||
/** 법정 기준을 못 맞춘 자리 수. */
|
||||
violationCount: number;
|
||||
minRadiusM: number;
|
||||
/** 아직 한 번도 안 그렸나(막 열었을 때). */
|
||||
fresh: boolean;
|
||||
}
|
||||
|
||||
/** 상태줄 꼬리 — 곡선 수·기준 미달·사용자가 손댄 자리를 한 줄로. */
|
||||
export function curveSummary(input: CurveSummaryInput): string {
|
||||
const off = input.curveOn.filter(
|
||||
(on, index) => !on && index > 0 && index < input.nodeCount - 1,
|
||||
).length;
|
||||
const forced = input.curveRadius.filter((value) => value !== null).length;
|
||||
const locked = input.curveLock.filter((lock) => lock !== null).length;
|
||||
const edits = [
|
||||
off ? `곡선 지움 ${off}곳` : "",
|
||||
forced ? `R 지정 ${forced}곳` : "",
|
||||
locked ? `고정 ${locked}곳` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
if (input.fresh) {
|
||||
const base = input.minRadiusM ? `곡선 기준 R ${input.minRadiusM}m — [확인] 때 반영` : "";
|
||||
return edits ? `${base}${base ? " · " : ""}${edits}` : base;
|
||||
}
|
||||
return (
|
||||
`곡선 ${input.curveCount}곳(하한 R ${input.minRadiusM}m)` +
|
||||
`${input.violationCount ? ` · 기준 미달 ${input.violationCount}곳` : ""}` +
|
||||
`${edits ? ` · ${edits}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
/** 서버가 준 노드·곡선을 **편집할 수 있는 꼴**로 편 결과. */
|
||||
export interface FlattenedPlan {
|
||||
planned: Vertex[];
|
||||
nodes: EditedNode[];
|
||||
curves: EditedCurve[];
|
||||
curveOn: boolean[];
|
||||
curveRadius: Array<number | null>;
|
||||
curveLock: CurveLock[];
|
||||
curveArc: Array<number | null>;
|
||||
}
|
||||
|
||||
interface ServerNode {
|
||||
x: number;
|
||||
y: number;
|
||||
radius_m: number | null;
|
||||
inner_angle_deg: number | null;
|
||||
violations?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버가 준 노드·곡선을 **꺾임점 하나 = 곡선 하나**로 편다(2026-09-07).
|
||||
*
|
||||
* 서버는 처음 만들 때 이어진 꺾임을 한 곡선으로 묶는다 — 그 곡선의 교각점은 앞뒤 직선을
|
||||
* 늘려 만나는 자리라 **원본 꺾임점 중 어느 것도 아니다**. 묶인 채로 두면 한 번만 손대도
|
||||
* 묶음이 낱개로 흩어지고 **맞춰 둔 반지름이 전부 법정 하한으로 되돌아간다**(실측: R
|
||||
* 12~199m → 전부 12m). 그래서 **묶인 구간을 그 교각점 하나로 갈아 끼우고** 안쪽 꺾임점은
|
||||
* 뺀다. 앞뒤 직선과 반지름이 그대로라 **그려지는 선은 똑같다**.
|
||||
*
|
||||
* ⚠ 서버가 **곡선을 안 둔 자리는 「곡선 없음」으로 연다**. 전부 켬으로 열면 아무것도 안
|
||||
* 만지고 [확인]만 눌러도 그 자리에 곡선이 새로 생겨 노선이 조용히 바뀐다(서버의 편집
|
||||
* 갈래는 켜진 자리마다 원호를 끼운다). 내각 179° 이상인 자리가 그렇다.
|
||||
*/
|
||||
export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): FlattenedPlan {
|
||||
const replaced = new Map<number, EditedCurve>();
|
||||
const dropped = new Set<number>();
|
||||
curves.forEach((curve) => {
|
||||
replaced.set(curve.node_first, curve);
|
||||
for (let index = curve.node_first + 1; index <= curve.node_last; index += 1) {
|
||||
dropped.add(index);
|
||||
}
|
||||
});
|
||||
|
||||
const out: FlattenedPlan = {
|
||||
planned: [],
|
||||
nodes: [],
|
||||
curves: [],
|
||||
curveOn: [],
|
||||
curveRadius: [],
|
||||
curveLock: [],
|
||||
curveArc: [],
|
||||
};
|
||||
nodes.forEach((node, index) => {
|
||||
if (dropped.has(index)) return;
|
||||
const curve = replaced.get(index);
|
||||
const seat = out.planned.length;
|
||||
out.planned.push(curve ? [curve.apex[0], curve.apex[1]] : [node.x, node.y]);
|
||||
out.nodes.push({
|
||||
radius_m: curve ? curve.radius_m : node.radius_m,
|
||||
inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg,
|
||||
tangent_m: null,
|
||||
violations: curve ? (curve.violations ?? []) : (node.violations ?? []),
|
||||
});
|
||||
out.curveOn.push(curve !== undefined);
|
||||
// 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다.
|
||||
out.curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null);
|
||||
out.curveLock.push(null);
|
||||
out.curveArc.push(null);
|
||||
if (curve) out.curves.push({ ...curve, node_first: seat, node_last: seat });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -17,6 +17,10 @@ export interface RouteEditSnapshot {
|
||||
planned: Vertex[];
|
||||
curveOn: boolean[];
|
||||
curveRadius: Array<number | null>;
|
||||
/** 무엇을 붙들고 있나 — 반지름 | 곡선 길이 | 없음 (`_Edits.ts`). */
|
||||
curveLock: Array<"radius" | "arc" | null>;
|
||||
/** 길이를 붙들었을 때의 그 길이(m). */
|
||||
curveArc: Array<number | null>;
|
||||
picked: number;
|
||||
}
|
||||
|
||||
@@ -44,6 +48,8 @@ function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot {
|
||||
planned: snapshot.planned.map(([x, y]): Vertex => [x, y]),
|
||||
curveOn: [...snapshot.curveOn],
|
||||
curveRadius: [...snapshot.curveRadius],
|
||||
curveLock: [...snapshot.curveLock],
|
||||
curveArc: [...snapshot.curveArc],
|
||||
picked: snapshot.picked,
|
||||
};
|
||||
}
|
||||
@@ -56,6 +62,8 @@ function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean {
|
||||
if (a.planned[index][1] !== b.planned[index][1]) return false;
|
||||
if (a.curveOn[index] !== b.curveOn[index]) return false;
|
||||
if (a.curveRadius[index] !== b.curveRadius[index]) return false;
|
||||
if (a.curveLock[index] !== b.curveLock[index]) return false;
|
||||
if (a.curveArc[index] !== b.curveArc[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Label.ts
|
||||
* 고른 꺾임점 **옆에 뜨는 곡선 라벨** — 반지름과 곡선 길이로 곡선을 만진다.
|
||||
* 고른 꺾임점 옆에 뜨는 **곡선 조작 패널** — 반지름·곡선 길이를 보고 고치고 붙든다.
|
||||
*
|
||||
* 왜 옮겼나(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이었다. 지금 고른 것이
|
||||
* 지도 어디인지 눈으로 이어지지 않아, 값을 바꾸면서도 어느 곡선을 만지는지 몰랐다.
|
||||
* 왜 옆인가(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이라 지금 고른 것이
|
||||
* 지도 어디인지 눈으로 안 이어졌다.
|
||||
*
|
||||
* **R 과 곡선 길이 두 칸이 한 쌍이다**(2026-09-07 사용자 지시) — 교각(Δ)은 앞뒤 직선이
|
||||
* 정하므로 둘은 L = R·Δ 로 묶여 있다. 한쪽을 고치면 다른 쪽이 따라온다. 「반지름 자동」
|
||||
* 단추는 없앴다 — 칸을 비우는 것이 곧 자동이다.
|
||||
* **R 과 곡선 길이는 한 쌍**(L = R·Δ, Δ 는 앞뒤 직선이 정하는 교각) — 한쪽을 고치면 다른
|
||||
* 쪽이 따라온다. 「자동」 단추는 없다: 칸을 비우는 것이 곧 자동이다. 어느 쪽을 붙들지는
|
||||
* **고정 단추**로 정한다(둘 다 붙들 수는 없다 — `_Edits.ts` 설명 참고).
|
||||
*
|
||||
* **자리는 곡선 중심의 반대쪽**(2026-09-07 사용자 지시) — 중심 쪽에 두면 라벨이 곡선을
|
||||
* 가린다. 상하좌우 네 방향 중 중심에서 먼 쪽에 붙이고, 캔버스 밖으로 나가면 안으로 접는다.
|
||||
* **자리**(2026-09-07 사용자 지시)
|
||||
* · 몸통은 `document.body` 에 `position: fixed` 로 띄운다 — 모달이 `overflow: hidden` 이라
|
||||
* 안에 두면 가장자리에서 **잘린다**. 화면 밖으로도 넘어갈 수 있어야 한다.
|
||||
* · 자동 자리는 **곡선 중심의 반대쪽**, **16방위**로 잡는다(4방위는 대각 자리에서 곡선을 물었다).
|
||||
* · 머리를 잡아 **손으로 옮길 수 있다**. 옮긴 자리는 그 꺾임점을 보는 동안 유지되고,
|
||||
* 다른 꺾임점을 고르면 자동 자리로 돌아간다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CurveLock } from "./B05_Profile_UI_RouteEdit_Edits";
|
||||
|
||||
/** 그 꺾임점의 **교각 Δ**(라디안) — 내각의 나머지. 곡선 길이 L = R·Δ 에 쓴다. */
|
||||
export function deflectionRad(innerAngleDeg: number | null | undefined): number {
|
||||
if (innerAngleDeg === null || innerAngleDeg === undefined) return 0;
|
||||
return ((180 - innerAngleDeg) * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/** 곡선 중심이 있는 **화면 방향**(단위벡터) — 라벨을 그 반대쪽에 붙이는 데 쓴다.
|
||||
/** 곡선 중심이 있는 **화면 방향**(단위벡터) — 패널을 그 반대쪽에 붙이는 데 쓴다.
|
||||
*
|
||||
* 중심은 접선점 둘이 이루는 각의 이등분선 위에 있다. 접선점은 교각점에서 앞뒤 직선을 따라
|
||||
* 뻗은 자리이므로, 두 방향의 단위벡터를 더하면 그대로 중심 쪽이다. 셋이 한 점이면 null. */
|
||||
@@ -42,48 +48,57 @@ export function centerDirectionOf(
|
||||
return length <= 1e-9 ? null : [sx / length, sy / length];
|
||||
}
|
||||
|
||||
/** 라벨과 노드 사이 여백(px). */
|
||||
const GAP_PX = 14;
|
||||
/** 캔버스 가장자리에서 이만큼은 띄운다(px). */
|
||||
const EDGE_PX = 8;
|
||||
/** 꺾임점과 패널 사이 여백(px) — 손잡이(접선점 네모)를 가리지 않을 만큼 띄운다. */
|
||||
const GAP_PX = 40;
|
||||
/** 자동 자리를 고를 방위 수 — 16방위(22.5°마다). */
|
||||
const COMPASS_STEPS = 16;
|
||||
|
||||
export interface CurveLabelState {
|
||||
/** 몇 번째 꺾임점인지 — 0부터 센 자리. 표시는 +1 해서 낸다. */
|
||||
seat: number;
|
||||
/** 그 꺾임점의 화면 좌표(캔버스 기준 px). */
|
||||
/** 그 꺾임점의 **화면(viewport) 좌표** px — 패널이 `position: fixed` 라서. */
|
||||
at: [number, number];
|
||||
/** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 라벨은 이 반대쪽에 붙는다.
|
||||
* 곡선이 없으면 null — 그때는 오른쪽에 둔다. */
|
||||
/** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 패널은 이 반대쪽에 붙는다. */
|
||||
centerDirection: [number, number] | null;
|
||||
/** 캔버스 크기(px) — 라벨을 안쪽으로 접어 넣는 데 쓴다. */
|
||||
canvas: { width: number; height: number };
|
||||
curveOn: boolean;
|
||||
/** 지금 보일 반지름(m). 곡선이 없으면 null. */
|
||||
radiusShown: number | null;
|
||||
/** 지금 보일 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */
|
||||
/** 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */
|
||||
arcLengthShown: number | null;
|
||||
/** 사용자가 못박은 값인지(아니면 자동). */
|
||||
forced: boolean;
|
||||
lock: CurveLock;
|
||||
innerAngleDeg: number | null;
|
||||
minRadiusM: number;
|
||||
}
|
||||
|
||||
export interface CurveLabelHandlers {
|
||||
/** R 칸을 고쳤다 — null 이면 「자동」(칸을 비운 것). */
|
||||
onRadius: (value: number | null) => void;
|
||||
/** 곡선 길이 칸을 고쳤다 — null 이면 「자동」. */
|
||||
onArcLength: (value: number | null) => void;
|
||||
/** 곡선을 지우거나 넣었다. */
|
||||
onCurveOn: (on: boolean) => void;
|
||||
/** 무엇을 붙들지 바꿨다 — 같은 것을 다시 누르면 null(품). */
|
||||
onLock: (lock: CurveLock) => void;
|
||||
}
|
||||
|
||||
export interface CurveLabel {
|
||||
show: (state: CurveLabelState) => void;
|
||||
hide: () => void;
|
||||
/** 창을 닫을 때 — 몸통이 `document.body` 에 붙어 있어 스스로 안 사라진다. */
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
/** 라벨을 만들어 `host`(캔버스를 감싼 칸, `position: relative`)에 붙인다. */
|
||||
export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers): CurveLabel {
|
||||
/** 방향을 16방위 중 가장 가까운 것으로 맞춘다. */
|
||||
function quantize(dx: number, dy: number): [number, number] {
|
||||
const step = (2 * Math.PI) / COMPASS_STEPS;
|
||||
const angle = Math.round(Math.atan2(dy, dx) / step) * step;
|
||||
return [Math.cos(angle), Math.sin(angle)];
|
||||
}
|
||||
|
||||
/** 가운데에서 그 방향으로 상자 가장자리까지의 거리 — 방위마다 다르다. */
|
||||
function boxReach(dx: number, dy: number, width: number, height: number): number {
|
||||
const byX = Math.abs(dx) < 1e-9 ? Infinity : width / 2 / Math.abs(dx);
|
||||
const byY = Math.abs(dy) < 1e-9 ? Infinity : height / 2 / Math.abs(dy);
|
||||
return Math.min(byX, byY);
|
||||
}
|
||||
|
||||
/** 패널을 만든다. 몸통은 `document.body` 에 붙어 모달 밖으로도 넘어간다. */
|
||||
export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b05-routeedit__label";
|
||||
root.hidden = true;
|
||||
@@ -95,26 +110,40 @@ export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers
|
||||
<label class="b05-routeedit__curve-field">반지름
|
||||
<input type="number" class="b05-routeedit__curve-radius" min="1" step="0.5" />
|
||||
<span>m</span>
|
||||
<button type="button" class="b05-routeedit__lock" data-act="lock-radius"
|
||||
title="반지름 고정 — 노드를 옮겨도 안 바뀝니다">고정</button>
|
||||
</label>
|
||||
<label class="b05-routeedit__curve-field">곡선 길이
|
||||
<input type="number" class="b05-routeedit__curve-arc" min="1" step="0.5" />
|
||||
<span>m</span>
|
||||
<button type="button" class="b05-routeedit__lock" data-act="lock-arc"
|
||||
title="곡선 길이 고정 — 노드를 옮겨도 안 바뀝니다">고정</button>
|
||||
</label>
|
||||
<span class="b05-routeedit__curve-info"></span>`;
|
||||
host.append(root);
|
||||
<span class="b05-routeedit__curve-info"></span>
|
||||
<span class="b05-routeedit__curve-note">칸을 비우면 자동</span>`;
|
||||
document.body.append(root);
|
||||
|
||||
const head = root.querySelector<HTMLElement>(".b05-routeedit__label-head")!;
|
||||
const seatText = root.querySelector<HTMLElement>(".b05-routeedit__curve-label")!;
|
||||
const toggle = root.querySelector<HTMLButtonElement>('[data-act="curve-toggle"]')!;
|
||||
const radius = root.querySelector<HTMLInputElement>(".b05-routeedit__curve-radius")!;
|
||||
const arc = root.querySelector<HTMLInputElement>(".b05-routeedit__curve-arc")!;
|
||||
const lockRadius = root.querySelector<HTMLButtonElement>('[data-act="lock-radius"]')!;
|
||||
const lockArc = root.querySelector<HTMLButtonElement>('[data-act="lock-arc"]')!;
|
||||
const info = root.querySelector<HTMLElement>(".b05-routeedit__curve-info")!;
|
||||
|
||||
// 라벨 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다.
|
||||
root.addEventListener("pointerdown", (event) => event.stopPropagation());
|
||||
root.addEventListener("dblclick", (event) => event.stopPropagation());
|
||||
root.addEventListener("contextmenu", (event) => event.stopPropagation());
|
||||
// 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다.
|
||||
for (const type of ["pointerdown", "dblclick", "contextmenu", "wheel"] as const) {
|
||||
root.addEventListener(type, (event) => event.stopPropagation());
|
||||
}
|
||||
|
||||
let curveOn = true;
|
||||
let lock: CurveLock = null;
|
||||
let seat = -1;
|
||||
/** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */
|
||||
let manual: [number, number] | null = null;
|
||||
let anchor: [number, number] = [0, 0];
|
||||
|
||||
const numberOf = (input: HTMLInputElement): number | null => {
|
||||
const value = Number(input.value);
|
||||
return input.value.trim() !== "" && Number.isFinite(value) && value > 0 ? value : null;
|
||||
@@ -122,53 +151,83 @@ export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers
|
||||
radius.addEventListener("change", () => handlers.onRadius(numberOf(radius)));
|
||||
arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc)));
|
||||
toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn));
|
||||
lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius"));
|
||||
lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc"));
|
||||
|
||||
/** 라벨을 **곡선 중심의 반대쪽**에 붙인다 — 상하좌우 넷 중 하나. */
|
||||
// ── 머리를 잡아 옮기기 — 곡선을 가리면 손으로 치울 수 있어야 한다 ──
|
||||
let dragFrom: { x: number; y: number; left: number; top: number } | null = null;
|
||||
head.addEventListener("pointerdown", (event) => {
|
||||
if ((event.target as HTMLElement).closest("button")) return; // 단추는 단추대로.
|
||||
dragFrom = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
left: root.offsetLeft,
|
||||
top: root.offsetTop,
|
||||
};
|
||||
head.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
});
|
||||
head.addEventListener("pointermove", (event) => {
|
||||
if (!dragFrom) return;
|
||||
const left = dragFrom.left + event.clientX - dragFrom.x;
|
||||
const top = dragFrom.top + event.clientY - dragFrom.y;
|
||||
root.style.left = `${Math.round(left)}px`;
|
||||
root.style.top = `${Math.round(top)}px`;
|
||||
// 꺾임점 기준으로 기억한다 — 지도를 옮기거나 확대해도 같은 자리에 따라온다.
|
||||
manual = [left - anchor[0], top - anchor[1]];
|
||||
});
|
||||
const stopDrag = (event: PointerEvent): void => {
|
||||
if (head.hasPointerCapture(event.pointerId)) head.releasePointerCapture(event.pointerId);
|
||||
dragFrom = null;
|
||||
};
|
||||
head.addEventListener("pointerup", stopDrag);
|
||||
head.addEventListener("pointercancel", stopDrag);
|
||||
|
||||
/** 자동 자리 — 곡선 중심의 반대쪽, 16방위. 손으로 옮겼으면 그 어긋남을 얹는다. */
|
||||
function place(state: CurveLabelState): void {
|
||||
const width = root.offsetWidth;
|
||||
const height = root.offsetHeight;
|
||||
const [nx, ny] = state.at;
|
||||
const direction = state.centerDirection;
|
||||
let left = nx + GAP_PX; // 곡선이 없으면 오른쪽이 기본이다.
|
||||
let top = ny - height / 2;
|
||||
if (direction) {
|
||||
const [dx, dy] = direction;
|
||||
if (Math.abs(dx) >= Math.abs(dy)) {
|
||||
// 중심이 오른쪽이면 라벨은 왼쪽으로.
|
||||
left = dx > 0 ? nx - GAP_PX - width : nx + GAP_PX;
|
||||
top = ny - height / 2;
|
||||
} else {
|
||||
left = nx - width / 2;
|
||||
top = dy > 0 ? ny - GAP_PX - height : ny + GAP_PX;
|
||||
}
|
||||
}
|
||||
// 캔버스 밖으로 나가면 안으로 접는다 — 노선 끝을 골라도 칸이 잘리지 않게.
|
||||
left = Math.max(EDGE_PX, Math.min(left, state.canvas.width - width - EDGE_PX));
|
||||
top = Math.max(EDGE_PX, Math.min(top, state.canvas.height - height - EDGE_PX));
|
||||
const away = state.centerDirection
|
||||
? quantize(-state.centerDirection[0], -state.centerDirection[1])
|
||||
: ([1, 0] as [number, number]);
|
||||
const distance = GAP_PX + boxReach(away[0], away[1], width, height);
|
||||
anchor = [nx + away[0] * distance - width / 2, ny + away[1] * distance - height / 2];
|
||||
const left = anchor[0] + (manual ? manual[0] : 0);
|
||||
const top = anchor[1] + (manual ? manual[1] : 0);
|
||||
root.style.left = `${Math.round(left)}px`;
|
||||
root.style.top = `${Math.round(top)}px`;
|
||||
}
|
||||
|
||||
return {
|
||||
show(state) {
|
||||
if (state.seat !== seat) {
|
||||
seat = state.seat;
|
||||
manual = null; // 다른 꺾임점이면 자동 자리부터 다시.
|
||||
}
|
||||
curveOn = state.curveOn;
|
||||
lock = state.lock;
|
||||
root.hidden = false;
|
||||
seatText.textContent = `${state.seat + 1}번째 꺾임점`;
|
||||
toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기";
|
||||
radius.disabled = !state.curveOn;
|
||||
arc.disabled = !state.curveOn;
|
||||
lockRadius.disabled = !state.curveOn;
|
||||
lockArc.disabled = !state.curveOn;
|
||||
lockRadius.classList.toggle("is-on", lock === "radius");
|
||||
lockArc.classList.toggle("is-on", lock === "arc");
|
||||
radius.value =
|
||||
state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10);
|
||||
arc.value =
|
||||
state.arcLengthShown === null ? "" : String(Math.round(state.arcLengthShown * 10) / 10);
|
||||
const inner = state.innerAngleDeg;
|
||||
const held =
|
||||
lock === "radius" ? "반지름 고정" : lock === "arc" ? "곡선 길이 고정" : "고정 없음";
|
||||
info.textContent = state.curveOn
|
||||
? `${state.forced ? "값 지정" : "자동"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` +
|
||||
` · 법정 하한 ${state.minRadiusM}m · 칸을 비우면 자동`
|
||||
? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}`
|
||||
: "곡선 없음 — 직선이 그대로 꺾입니다";
|
||||
place(state);
|
||||
// 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다
|
||||
// (2026-09-07 실측: 처음 잰 높이 120px, 실제 131px 라 11px 어긋났음).
|
||||
// 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다.
|
||||
requestAnimationFrame(() => {
|
||||
if (!root.hidden) place(state);
|
||||
});
|
||||
@@ -176,5 +235,8 @@ export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers
|
||||
hide() {
|
||||
root.hidden = true;
|
||||
},
|
||||
destroy() {
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -135,8 +135,10 @@
|
||||
예전에는 모달 맨 아랫줄이라 지금 고른 것이 지도 어디인지 눈으로 안 이어졌다.
|
||||
자리는 `_Label.ts` 가 매 프레임 잡아 준다 — 여기서는 모양만 정한다. */
|
||||
.b05-routeedit__label {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
/* `document.body` 에 붙여 **화면 기준**으로 띄운다 — 모달이 `overflow: hidden` 이라
|
||||
안에 두면 가장자리에서 잘린다(2026-09-07 사용자 지시 1). */
|
||||
position: fixed;
|
||||
z-index: calc(var(--z-modal, 1000) + 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
@@ -152,11 +154,41 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 머리는 **잡아 옮기는 자리**다 — 패널이 곡선을 가리면 손으로 치울 수 있어야 한다. */
|
||||
.b05-routeedit__label-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8, 8px);
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* 고정 단추 — 켜지면 색이 찬다. 켠 값은 노드를 옮겨도 안 바뀐다. */
|
||||
.b05-routeedit__lock {
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-4, 4px);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b05-routeedit__lock.is-on {
|
||||
border-color: transparent;
|
||||
background: var(--color-primary, #7c3aed);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.b05-routeedit__lock:disabled {
|
||||
color: var(--color-text-secondary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 「칸을 비우면 자동」 — 상태 설명과 줄을 나눈다(2026-09-07 사용자 지시 4). */
|
||||
.b05-routeedit__curve-note {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b05-routeedit__label-toggle {
|
||||
|
||||
Reference in New Issue
Block a user