Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts
T
eomsangdonandClaude Opus 5 4300be8385 fix(B05): 노선 편집 접선점을 늘 보이게, 라벨을 R+곡선 길이로, 중심 반대쪽에 배치
사용자 지적 3건 반영.

- **접선점 표기 복구** — 손잡이를 「고른 곡선만」으로 줄이면서 직선↔R 만나는 자리의
  **표기까지 없앴음**. 접선점은 손잡이이기 이전에 읽을 정보라 **늘 그림**(고른 곡선은
  속을 채워 도드라지게). 노드를 못 집던 문제는 집기 우선순위로 이미 풀려 겹치지 않음.
- **라벨을 반지름 + 곡선 길이 두 칸으로, [자동] 단추 삭제** — 교각 Δ 는 앞뒤 직선이
  정하므로 L = R·Δ 로 묶임. 길이를 받으면 R 로 바꿔 한 값만 보관. 칸을 비우면 자동.
- **라벨 자리를 곡선 중심의 반대쪽으로**(상하좌우) — 중심 쪽에 두면 곡선을 가림.
  중심 방향은 접선점 두 방향 단위벡터의 합(각 이등분선)으로 구함.
- ⚠ 라벨 글자가 안 읽히던 것 — `--color-surface-2` 가 이 테마에 없어 밝은 기본값으로
  떨어졌음. 모달 본체와 같은 토큰으로 교체(실측 배경 rgb(37,31,56)·글자 rgb(228,224,240)).
- 상자 높이가 늦게 자라 자리가 11px 어긋나던 것도 다음 프레임에 다시 맞추게 함.
- 700줄 유지 — 교각·중심방향은 `_Label`, 등고선 띠는 `_Input` 으로 옮겨 본체 687줄.

실화면 검증 — 접선점이 고르기 전에도 보임 / 곡선 길이 40 → 반지름 203.7m 로 따라옴 /
라벨이 중심 반대쪽(위)에 붙음 / [자동] 없음. 554 passed · 18 skipped, tsc 통과.

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

181 lines
8.3 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_RouteEdit_Label.ts
* 고른 꺾임점 **옆에 뜨는 곡선 라벨** — 반지름과 곡선 길이로 곡선을 만진다.
*
* 왜 옮겼나(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이었다. 지금 고른 것이
* 지도 어디인지 눈으로 이어지지 않아, 값을 바꾸면서도 어느 곡선을 만지는지 몰랐다.
*
* **R 과 곡선 길이 두 칸이 한 쌍이다**(2026-09-07 사용자 지시) — 교각(Δ)은 앞뒤 직선이
* 정하므로 둘은 L = R·Δ 로 묶여 있다. 한쪽을 고치면 다른 쪽이 따라온다. 「반지름 자동」
* 단추는 없앴다 — 칸을 비우는 것이 곧 자동이다.
*
* **자리는 곡선 중심의 반대쪽**(2026-09-07 사용자 지시) — 중심 쪽에 두면 라벨이 곡선을
* 가린다. 상하좌우 네 방향 중 중심에서 먼 쪽에 붙이고, 캔버스 밖으로 나가면 안으로 접는다.
* ========================================================================== */
/** 그 꺾임점의 **교각 Δ**(라디안) — 내각의 나머지. 곡선 길이 L = R·Δ 에 쓴다. */
export function deflectionRad(innerAngleDeg: number | null | undefined): number {
if (innerAngleDeg === null || innerAngleDeg === undefined) return 0;
return ((180 - innerAngleDeg) * Math.PI) / 180;
}
/** 곡선 중심이 있는 **화면 방향**(단위벡터) — 라벨을 그 반대쪽에 붙이는 데 쓴다.
*
* 중심은 접선점 둘이 이루는 각의 이등분선 위에 있다. 접선점은 교각점에서 앞뒤 직선을 따라
* 뻗은 자리이므로, 두 방향의 단위벡터를 더하면 그대로 중심 쪽이다. 셋이 한 점이면 null. */
export function centerDirectionOf(
apex: [number, number],
start: [number, number],
end: [number, number],
): [number, number] | null {
const arm = (point: [number, number]): [number, number] => {
const dx = point[0] - apex[0];
const dy = point[1] - apex[1];
const length = Math.hypot(dx, dy);
return length <= 1e-9 ? [0, 0] : [dx / length, dy / length];
};
const [ux, uy] = arm(start);
const [vx, vy] = arm(end);
const sx = ux + vx;
const sy = uy + vy;
const length = Math.hypot(sx, sy);
return length <= 1e-9 ? null : [sx / length, sy / length];
}
/** 라벨과 노드 사이 여백(px). */
const GAP_PX = 14;
/** 캔버스 가장자리에서 이만큼은 띄운다(px). */
const EDGE_PX = 8;
export interface CurveLabelState {
/** 몇 번째 꺾임점인지 — 0부터 센 자리. 표시는 +1 해서 낸다. */
seat: number;
/** 그 꺾임점의 화면 좌표(캔버스 기준 px). */
at: [number, number];
/** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 라벨은 이 반대쪽에 붙는다.
* 곡선이 없으면 null — 그때는 오른쪽에 둔다. */
centerDirection: [number, number] | null;
/** 캔버스 크기(px) — 라벨을 안쪽으로 접어 넣는 데 쓴다. */
canvas: { width: number; height: number };
curveOn: boolean;
/** 지금 보일 반지름(m). 곡선이 없으면 null. */
radiusShown: number | null;
/** 지금 보일 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */
arcLengthShown: number | null;
/** 사용자가 못박은 값인지(아니면 자동). */
forced: boolean;
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;
}
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">반지름
<input type="number" class="b05-routeedit__curve-radius" min="1" step="0.5" />
<span>m</span>
</label>
<label class="b05-routeedit__curve-field">곡선 길이
<input type="number" class="b05-routeedit__curve-arc" min="1" step="0.5" />
<span>m</span>
</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 arc = root.querySelector<HTMLInputElement>(".b05-routeedit__curve-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());
let curveOn = true;
const numberOf = (input: HTMLInputElement): number | null => {
const value = Number(input.value);
return input.value.trim() !== "" && Number.isFinite(value) && value > 0 ? value : null;
};
radius.addEventListener("change", () => handlers.onRadius(numberOf(radius)));
arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc)));
toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn));
/** 라벨을 **곡선 중심의 반대쪽**에 붙인다 — 상하좌우 넷 중 하나. */
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));
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;
arc.disabled = !state.curveOn;
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;
info.textContent = state.curveOn
? `${state.forced ? "값 지정" : "자동"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` +
` · 법정 하한 ${state.minRadiusM}m · 칸을 비우면 자동`
: "곡선 없음 — 직선이 그대로 꺾입니다";
place(state);
// 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다
// (2026-09-07 실측: 처음 잰 높이 120px, 실제 131px 라 11px 어긋났음).
requestAnimationFrame(() => {
if (!root.hidden) place(state);
});
},
hide() {
root.hidden = true;
},
};
}