Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts
T
eomsangdonandClaude Opus 5 abe956affc feat(b05): 계획노선 편집 손질 여섯 — 교각점 고정·횡단 벡터화·라벨 맨 위
- 교각점 고정 단추: 켜면 그 꺾임점이 노드·손잡이 어느 쪽으로도 안 끌림(`apexLock`)
- 계획 횡단선을 절·성토 교차점에서 끊음 — 그 밖은 원지반이라 지반선과 겹쳐 그려지던 것.
  교차점은 B06 이 쓰는 `toeOffsets` 를 열어 함께 씀
- 횡단 판을 캔버스에서 SVG 로 바꾸고 휠 줌·가운데 끌기 팬·더블클릭 원복을 붙임
- 줌·팬 본문을 `A00_Common/b_svg_zoom_pan.ts` 공용 조각으로 빼고 B06 은 감싸개만 남김
- 「이전 횡단」 판은 계획 평면선이 바뀔 때만 채움(같은 측점 두 번 누르기로는 안 채움)
- 「거리 재기」 단추의 눌린 꼴 추가 · 등고선 높이 라벨을 맨 위로 올리고 더 촘촘히

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJffo4VwgTumVUM3kdbJzd
2026-09-12 20:37:13 +09:00

228 lines
10 KiB
TypeScript

/* =============================================================================
* 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;
}
}
/**
* **반지름 하한을 지키도록 지정값을 끌어올린다**(계획서 0-9 ④, 2026-09-12 사용자 확정).
*
* **비워 둔(자동) 자리는 건드리지 않는다** — 자동은 이미 기본 반지름을 쓰고 있고, 여기서
* 값을 적어 넣으면 아무것도 안 고쳤는데 「R 지정」이 늘어난다.
*
* ⚠ **곡선 길이(L) 하한은 여기서 안 건다.** L = R·Δ 라, 내각이 179° 처럼 거의 곧은 자리는
* L 5m 를 채우려면 R 이 286m 로 부풀어 **아무것도 안 고쳤는데 노선이 바뀐다**(2026-09-12
* 실화면: 길이 1017.5m → 1017.2m). 별표2 도 내각 155° 이상은 곡선을 안 둘 수 있다고 한다.
* L 하한은 **사용자가 칸에 적을 때만** 막고(`_Label`), 패널에 「L ≥ 5m」으로 보이기만 한다.
*/
export function applyCurveLimits(
planned: Vertex[],
curveOn: ReadonlyArray<boolean>,
curveRadius: Array<number | null>,
limitRadiusM: number,
): void {
if (limitRadiusM <= 0) return;
for (let seat = 1; seat < planned.length - 1; seat += 1) {
if (curveOn[seat] === false) continue;
const current = curveRadius[seat];
if (current === null || current === undefined) continue;
if (current < limitRadiusM) curveRadius[seat] = limitRadiusM;
}
}
/**
* 노드마다 **반지름 하한**을 얼마나 밑돌고 있나(m). 다 지키고 있으면 0.
*
* 곡선 길이(L) 하한은 여기서도 안 본다 — 까닭은 `applyCurveLimits` 설명에 있다. 거의 곧은
* 자리에서 L 을 채우라고 막으면 **노드를 한 뼘도 못 옮긴다**.
*
* 노드를 옮기면 접선 자리가 모자라 그리기 단계에서 R 이 눌릴 수 있다. 그 눌림까지 막으려면
* 옮기기 자체를 되돌려야 하므로, **옮기기 전보다 나빠진 자리가 있는지**만 견준다 — 이미
* 하한을 밑돌던 옛 노선도 그대로 고칠 수 있어야 하기 때문이다(2026-09-12).
*
* ⚠ 가장 큰 값 하나로 견주면 안 된다. 크게 밑도는 자리가 이미 있으면 **다른 자리가 새로
* 무너져도 최댓값이 안 움직여** 그냥 통과한다(실화면에서 「기준 미달 1곳 → 2곳」이 그대로
* 지나갔다). 자리마다 따로 견준다.
*/
export function curveShortfalls(nodes: ReadonlyArray<EditedNode>, limitRadiusM: number): number[] {
return nodes.map((node) => {
if (node.radius_m === null || limitRadiusM <= 0) return 0;
return Math.max(0, limitRadiusM - node.radius_m);
});
}
/**
* 하한을 지키던 자리가 **이번 걸음에 처음으로 무너졌나**. 자리 수가 달라지면(넣기·지우기)
* 안 따진다.
*
* ⚠ 「조금이라도 나빠졌으면 막기」로 두면 **이미 하한을 밑돌던 옛 노선을 아예 못 고친다** —
* 그 옆 노드를 1px 만 건드려도 밑돌던 값이 미세하게 더 내려가 첫 걸음부터 막혔다(2026-09-12
* 실화면). 이미 무너진 자리는 그대로 두고(붉은 표시는 남는다), **지키고 있던 자리가 넘어가는
* 것만** 막는다.
*/
export function shortfallCrossed(before: readonly number[], after: readonly number[]): boolean {
if (before.length !== after.length) return false;
return after.some((value, index) => value > 1e-6 && before[index] <= 1e-6);
}
export interface CurveSummaryInput {
nodeCount: number;
curveOn: boolean[];
curveRadius: Array<number | null>;
curveLock: CurveLock[];
/** 교각점을 못 박은 자리 — 「고정 N곳」에 함께 센다(2026-09-12 사용자 지시 ①). */
apexLock: boolean[];
/** 그려 낸 곡선 수 — 아직 안 그렸으면 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 + input.apexLock.filter(Boolean).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>;
apexLock: boolean[];
}
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: [],
apexLock: [],
};
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);
out.apexLock.push(false);
if (curve) out.curves.push({ ...curve, node_first: seat, node_last: seat });
});
return out;
}