Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts
T
eomsangdonandClaude Opus 5 f84b3d9451 feat(B05): 노선 편집 — R 라벨을 고른 자리 옆으로, 되돌리기·다시하기·초기화, 줌·팬 통일
사용자 지적 ③④⑤⑥ 과 추가 지시(되돌리기)를 한 묶음으로 처리.

- **③ R 라벨** — 모달 맨 아랫줄이던 곡선 편집칸을 걷고, 고른 꺾임점 **옆에 뜨는 라벨**로
  바꿈(`_Label.ts` 신설). 캔버스 밖으로 나가면 반대쪽으로 접어 넣음. 확대·이동·창 크기가
  바뀌어도 고른 노드를 따라감.
- **④ 노드가 손잡이보다 먼저 잡히게** — 반대였던 탓에 헤어핀처럼 곡선이 몰린 데서는
  노드를 아예 못 집었음. 손잡이는 **고른 곡선에만** 그림. 머리말 안내에 조작 여섯을 적음.
- **⑤ 줌·팬을 배수유역도와 동일하게**(`_Input.ts` 신설) — 휠은 **당기면 확대**(반대였음),
  계수 1.15/0.87, 상한은 화면 폭 16m 기준, 하한 0.5, **팬은 가운데 버튼 전용**.
  ⚠ 커서 고정 계산이 좌상단 기준이라 확대할수록 지점이 밀리던 것도 중심 기준으로 고침.
- **⑥ 등고선을 노선 ±300m 띠로** — 창 크기와 무관한 고정 띠라 창을 늘려도 안 깨짐.
  화면 밖 걸러내기는 종전대로 `drawPreparedLayer` 가 함.
- **되돌리기·다시하기·초기화**(`_History.ts` 신설) — [확인]이 무거워 되돌릴 길이 없으므로
  창 안에서 물릴 수 있게 함. **끌기 한 번이 한 걸음**이고 클릭만으로는 걸음이 안 생김.
  [초기화]는 **이 창을 연 상태**로 (≠ [예상노선으로]). Ctrl+Z / Ctrl+Y · Ctrl+Shift+Z.
- 700줄 제한 — 집기(hit test)를 `_Input.ts` 로 옮겨 본체 695줄.

실화면 검증(5174) — 라벨이 노드 오른쪽 [+18,−38]px 에 뜸 / 휠 당기니 중심에서 373.1 →
428.1px 로 확대 / 왼쪽 끌기로는 지도 안 움직임 / 가운데 버튼 끌기는 [60,40] 그대로 따라옴 /
단추 켜짐이 여섯 단계 모두 맞음. 전체 554 passed · 18 skipped, tsc 통과. 정본 안 건드림.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 19:10:07 +09:00

121 lines
5.4 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_RouteEdit_Label.ts
* 고른 꺾임점 **옆에 뜨는 R 라벨** — 반지름을 바꾸고, 곡선을 지우고 넣는다.
*
* 왜 옮겼나(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이었다. 지금 고른 것이
* 지도 어디인지 눈으로 이어지지 않아, 값을 바꾸면서도 어느 곡선을 만지는지 몰랐다.
* 「해당 요소를 선택하면 우측에 라벨 같은 입력이 보이게」가 사용자의 말 그대로다.
*
* 캔버스 밖으로 나가지 않게 가장자리에서 **반대쪽으로 접어 넣는다** — 노선 끝의 꺾임점을
* 골라도 입력칸이 잘리지 않아야 한다.
* ========================================================================== */
/** 라벨과 노드 사이 여백(px). */
const GAP_PX = 14;
/** 캔버스 가장자리에서 이만큼은 띄운다(px). */
const EDGE_PX = 8;
export interface CurveLabelState {
/** 몇 번째 꺾임점인지 — 0부터 센 자리. 표시는 +1 해서 낸다. */
seat: number;
/** 그 꺾임점의 화면 좌표(캔버스 기준 px). */
at: [number, number];
/** 캔버스 크기(px) — 라벨을 안쪽으로 접어 넣는 데 쓴다. */
canvas: { width: number; height: number };
curveOn: boolean;
/** 지금 보일 반지름(m). 곡선이 없으면 null. */
radiusShown: number | null;
/** 사용자가 못박은 값인지(아니면 자동). */
forced: boolean;
innerAngleDeg: number | null;
minRadiusM: number;
}
export interface CurveLabelHandlers {
/** R 칸을 고쳤다 — null 이면 「자동」. */
onRadius: (value: number | null) => void;
/** 곡선을 지우거나 넣었다. */
onCurveOn: (on: boolean) => void;
/** 반지름을 자동으로 되돌렸다. */
onAuto: () => void;
}
export interface CurveLabel {
show: (state: CurveLabelState) => void;
hide: () => void;
}
/** 라벨을 만들어 `host`(캔버스를 감싼 칸, `position: relative`)에 붙인다. */
export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers): CurveLabel {
const root = document.createElement("div");
root.className = "b05-routeedit__label";
root.hidden = true;
root.innerHTML = `
<div class="b05-routeedit__label-head">
<span class="b05-routeedit__curve-label"></span>
<button type="button" class="b05-routeedit__label-toggle" data-act="curve-toggle"></button>
</div>
<label class="b05-routeedit__curve-field">R
<input type="number" class="b05-routeedit__curve-radius" min="1" step="0.5" />
<span>m</span>
<button type="button" class="b05-routeedit__label-auto" data-act="curve-auto">자동</button>
</label>
<span class="b05-routeedit__curve-info"></span>`;
host.append(root);
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 auto = root.querySelector<HTMLButtonElement>('[data-act="curve-auto"]')!;
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());
let curveOn = true;
radius.addEventListener("change", () => {
const value = Number(radius.value);
handlers.onRadius(Number.isFinite(value) && value > 0 ? value : null);
});
toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn));
auto.addEventListener("click", () => handlers.onAuto());
function place(at: [number, number], canvas: { width: number; height: number }): void {
// 먼저 보여야 크기를 잴 수 있다 — 그 다음 자리를 잡는다.
const width = root.offsetWidth;
const height = root.offsetHeight;
// 기본은 오른쪽. 오른쪽이 좁으면 왼쪽으로 접는다(사용자 말대로 「우측에」가 기본).
let left = at[0] + GAP_PX;
if (left + width > canvas.width - EDGE_PX) left = at[0] - GAP_PX - width;
left = Math.max(EDGE_PX, Math.min(left, canvas.width - width - EDGE_PX));
let top = at[1] - height / 2;
top = Math.max(EDGE_PX, Math.min(top, canvas.height - height - EDGE_PX));
root.style.left = `${Math.round(left)}px`;
root.style.top = `${Math.round(top)}px`;
}
return {
show(state) {
curveOn = state.curveOn;
root.hidden = false;
seatText.textContent = `${state.seat + 1}번째 꺾임점`;
toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기";
radius.disabled = !state.curveOn;
auto.disabled = !state.curveOn || !state.forced;
radius.value =
state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10);
const inner = state.innerAngleDeg;
info.textContent = state.curveOn
? `${state.forced ? "값 지정" : "자동"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` +
` · 법정 하한 ${state.minRadiusM}m`
: "곡선 없음 — 직선이 그대로 꺾입니다";
place(state.at, state.canvas);
},
hide() {
root.hidden = true;
},
};
}