사용자 지적 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>
181 lines
8.3 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|