무슨 일이 있었나 랩탑 줄의 병합 `20ba886c`(Merge origin/main_desktop_1·main_laptop_1·sub_desktop_1 into sub_laptop_1)가 우리 파일 22개를 떨구고 35개 파일의 내용을 옛것으로 되돌림. 손으로 지운 커밋은 없고 **병합 자체가 떨군 것**임. 그것이 `origin/dev`·`main_laptop_1`·`sub_laptop_1`· `CODEX` 까지 퍼졌고(데스크탑 둘만 무사), 이 창의 병합 `d92c1f2b` 로 들어옴. 잃었던 것 - 공용 — `common_util_provenance.py` · `ui_template_provenance.ts` - B08 — 근거 사전 · 좌측 패널 상자 모듈 · 토량환산계수 칸 - B09 — 근거 사전 셋 - B05 — 계획노선 편집 모듈 아홉 · 지형 라우터 · B04 지도 모듈 - 시험 셋과, 35개 파일 안의 최근 작업(환산계수 고르기 · 근거 호버 배선 등) 어떻게 되살렸나 `611a2b40`(병합 직전, 전부 온전)에서 `git show <커밋>:<경로>` 로 내용만 꺼내 되돌림. 이력은 안 건드림. ⚠ HEAD 에만 있던 「추가 816줄」은 랩탑의 새 작업이 아니라 **되살아난 옛 코드**였음(B05 편집은 모듈로 쪼개기 전 덩어리 · B08 라우터는 환산계수 고르기 전 옛 상수판). 되돌릴 시점 이후의 **진짜 새 커밋은 둘뿐**이라 그 둘만 패치로 다시 얹음 — `9f827bf6`(리로드 빌드 고리 끊기, 데스크탑 보조) · `b9bca6b3`(B06 조정창 1px, 랩탑). 위키 여덟은 코덱스 몫이라 손대지 않음. 자체검증 — 양쪽 작업이 다 살아 있음을 짚어 확인: `main.py` 의 「개발 서버는 살려 둔다」 · `B05_Profile_Engine_Grade.py` 의 `plan_curve_length_limit_m` · `B08_..._EarthworkGrid.ts` 의 `attachProvenance`. `tsc --noEmit` 통과 · `pytest -q` **1317 passed, 28 skipped** (되살리기 전에는 시험 둘이 수집 단계에서 깨져 있었음). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
157 lines
6.5 KiB
TypeScript
157 lines
6.5 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_RouteEdit_CurveBar.ts
|
|
* 곡선 조작 패널의 **배선** — 어느 꺾임점을 만질지 정하고, 칸에서 들어온 값을 편집값에
|
|
* 옮겨 적는다. 패널을 그리고 자리를 잡는 일은 `_Label` 몫이다.
|
|
*
|
|
* `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 본문 로직과
|
|
* 수치는 그대로이고, 모달 클로저가 쥐고 있던 값만 `state()` 로 받는다.
|
|
*
|
|
* **R 과 곡선 길이는 한 쌍**(L = R·Δ) — 어느 쪽으로 들어와도 **반지름 한 값**으로 바꿔
|
|
* 들고 간다. 두 벌로 두면 교각이 바뀔 때 서로 어긋난다(`_Edits.ts` 설명 참고).
|
|
* ========================================================================== */
|
|
|
|
import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
|
|
import type { CurveLock } from "./B05_Profile_UI_RouteEdit_Edits";
|
|
import {
|
|
centerDirectionOf,
|
|
createCurveLabel,
|
|
deflectionRad,
|
|
type CurveLabel,
|
|
} from "./B05_Profile_UI_RouteEdit_Label";
|
|
|
|
/** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */
|
|
export interface CurveBarState {
|
|
picked: number;
|
|
planned: Vertex[];
|
|
nodeInfo: EditedNode[];
|
|
curveInfo: EditedCurve[];
|
|
curveOn: boolean[];
|
|
curveRadius: Array<number | null>;
|
|
curveLock: CurveLock[];
|
|
curveArc: Array<number | null>;
|
|
/** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
|
|
limitRadiusM: number;
|
|
limitArcM: number;
|
|
}
|
|
|
|
export interface CurveBarParams {
|
|
canvas: HTMLCanvasElement;
|
|
state: () => CurveBarState;
|
|
/** 그리기 좌표로 옮긴다. 돌린 지도에서는 **돌린 뒤 자리**를 줘야 패널이 노드 옆에 붙는다. */
|
|
toScreen: (vertex: Vertex) => [number, number];
|
|
/** 한 번의 편집을 마무리한다 — 다시 그리고 되돌리기에 쌓는다. */
|
|
applyEdit: (message: string) => void;
|
|
/** 고른 꺾임점을 푼다 — 닫기 단추와 「빈 곳 누르기」가 부른다(계획서 0-9 ㉖). */
|
|
onUnselect: () => void;
|
|
}
|
|
|
|
export interface CurveBar {
|
|
label: CurveLabel;
|
|
/** 고른 자리에 맞춰 패널을 옮겨 그린다. */
|
|
sync: () => void;
|
|
}
|
|
|
|
export function createCurveBar(params: CurveBarParams): CurveBar {
|
|
const label = createCurveLabel({
|
|
onClose: () => params.onUnselect(),
|
|
onRadius: (value) => {
|
|
const { picked, curveRadius } = params.state();
|
|
if (picked < 0) return;
|
|
curveRadius[picked] = value;
|
|
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
|
|
params.applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
|
|
},
|
|
onArcLength: (value) => {
|
|
const { picked, nodeInfo, curveArc, curveRadius } = params.state();
|
|
if (picked < 0) return;
|
|
// 곡선 길이 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;
|
|
params.applyEdit(
|
|
value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.",
|
|
);
|
|
},
|
|
onLock: (lock) => {
|
|
const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state();
|
|
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;
|
|
params.applyEdit(
|
|
lock === "radius"
|
|
? "반지름을 고정했습니다."
|
|
: lock === "arc"
|
|
? "곡선 길이를 고정했습니다."
|
|
: "고정을 풀었습니다.",
|
|
);
|
|
},
|
|
onCurveOn: (on) => {
|
|
const { picked, curveOn } = params.state();
|
|
if (picked < 0) return;
|
|
curveOn[picked] = on;
|
|
params.applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
|
|
},
|
|
});
|
|
|
|
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
|
|
function sync(): void {
|
|
const {
|
|
picked,
|
|
planned,
|
|
nodeInfo,
|
|
curveInfo,
|
|
curveOn,
|
|
curveRadius,
|
|
curveLock,
|
|
limitRadiusM,
|
|
limitArcM,
|
|
} = params.state();
|
|
if (!(picked > 0 && picked < planned.length - 1)) {
|
|
label.hide();
|
|
return;
|
|
}
|
|
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
|
|
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
|
|
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
|
const rect = params.canvas.getBoundingClientRect();
|
|
const [screenX, screenY] = params.toScreen(planned[picked]);
|
|
label.show({
|
|
seat: picked,
|
|
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다.
|
|
at: [screenX + rect.left, screenY + rect.top],
|
|
// 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을
|
|
// 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨).
|
|
bounds: {
|
|
left: rect.left + 8,
|
|
top: rect.top + 8,
|
|
right: rect.right - 8,
|
|
bottom: rect.bottom - 8,
|
|
},
|
|
centerDirection: pickedCurve
|
|
? centerDirectionOf(
|
|
params.toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
|
|
params.toScreen(pickedCurve.start),
|
|
params.toScreen(pickedCurve.end),
|
|
)
|
|
: null,
|
|
curveOn: curveOn[picked] !== false,
|
|
radiusShown: shown,
|
|
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
|
|
lock: curveLock[picked] ?? null,
|
|
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
|
|
limitRadiusM,
|
|
limitArcM,
|
|
});
|
|
}
|
|
|
|
return { label, sync };
|
|
}
|