Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_CurveIcon.ts
T
eomsangdonandClaude Sonnet 5.5 6aa0582495 feat(B05): 교각점 정보 = 근처 아이콘 · 아이콘 누름 = 정보 창 (PLAN 587-1 · 요구 445 C1)
- 교각점 고름 = 창 없이 근처(40 px) 아이콘 · 멀면 0.7 s 뒤 숨김 · 누름 = 창 열림(멀어져도 유지)
- 창 열린 채 다른 교각점 = 열린 채 새 정보 · 닫힌 채 = 닫힌 채 · [닫기] = 창 닫고 고름 풂
- 부품 상자 아이콘 단추 「유리」 꼴 더함(견본 · 시험 552-2)
- 새 지킴 시험 test_445_curve_icon · 398-2 · 408 도우미에 가짜 아이콘

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:42:16 +09:00

125 lines
4.2 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_RouteEdit_CurveIcon.ts
* 교각점 정보 아이콘(445-C1) — 교각점을 고른 채 마우스가 근처일 때만 교각점 옆에 뜨는 작은 단추.
* 누르면 정보 창이 열림(멀어져도 유지) · 멀어지면 0.7 s 뒤 숨김 · 터치(마우스 없음)는 고르면 늘 보임.
* 규칙은 `logic/B05_Profile_RouteEdit_CurveIcon` · 창 열림 기억은 `_CurveBar`.
* ========================================================================== */
import { createIconButton } from "@ui/ui_template_elements";
import { iconVisible, isNear } from "./logic/B05_Profile_RouteEdit_CurveIcon";
import "./B05_Profile_UI_Style_RouteEdit_CurveIcon.css";
/** 멀어진 뒤 아이콘이 남는 시간(ms) — 아이콘으로 옮겨 갈 틈(등고선 자물쇠와 같은 값). */
const HIDE_DELAY_MS = 700;
/** 아이콘은 교각점 오른쪽 위로 이만큼(px). */
const OFFSET_PX = 14;
export interface CurveIcon {
/** 고름 · 창 열림 · 교각점 화면 자리(클라이언트 좌표 · 고름 없으면 null)를 알린다. */
update: (anchor: [number, number] | null, open: boolean) => void;
destroy: () => void;
}
export function createCurveIcon(canvas: HTMLCanvasElement, onOpen: () => void): CurveIcon {
const wrap = canvas.parentElement ?? document.body;
const button = createIconButton({
icon: "adjustments",
title: "교각점 정보 열기",
size: 24,
variant: "glass",
});
button.classList.add("b05-curveicon");
button.dataset.act = "curve-icon";
button.hidden = true;
wrap.append(button);
let anchor: [number, number] | null = null;
let open = false;
let touch = false;
let mouse: [number, number] | null = null;
let overIcon = false;
let pressing = false;
let hideTimer = 0;
const keep = (): void => {
window.clearTimeout(hideTimer);
hideTimer = 0;
};
const hideNow = (): void => {
keep();
button.hidden = true;
};
const hideLater = (): void => {
if (hideTimer) return;
hideTimer = window.setTimeout(() => {
hideTimer = 0;
button.hidden = true;
}, HIDE_DELAY_MS);
};
/** 지금 상태로 보임 · 숨김을 다시 정함 — 멀면 바로 숨기지 않고 틈 뒤. */
const refresh = (): void => {
if (!anchor || open || pressing) return hideNow();
const distance = mouse ? Math.hypot(mouse[0] - anchor[0], mouse[1] - anchor[1]) : Infinity;
if (!iconVisible(0, false, isNear(distance, overIcon), touch)) {
if (!button.hidden) hideLater();
return;
}
keep();
const origin = wrap.getBoundingClientRect();
button.style.left = `${Math.round(anchor[0] - origin.left + OFFSET_PX)}px`;
button.style.top = `${Math.round(anchor[1] - origin.top - OFFSET_PX - 24)}px`;
button.hidden = false;
};
canvas.addEventListener("pointermove", (event) => {
touch = event.pointerType === "touch";
mouse = [event.clientX, event.clientY];
if (event.buttons === 0) refresh();
});
canvas.addEventListener("pointerdown", (event) => {
touch = event.pointerType === "touch";
mouse = [event.clientX, event.clientY];
pressing = true;
refresh();
});
// 놓은 뒤 다시 판정 — 고른 자리가 바뀌었으면 `update` 가 곧 이어서 부른다.
const release = (): void => {
pressing = false;
refresh();
};
canvas.addEventListener("pointerup", release);
canvas.addEventListener("pointercancel", release);
canvas.addEventListener("wheel", hideNow, { passive: true });
canvas.addEventListener("pointerleave", () => {
mouse = null;
if (!overIcon && !button.hidden) hideLater();
});
button.addEventListener("pointerenter", () => {
overIcon = true;
keep();
});
button.addEventListener("pointerleave", () => {
overIcon = false;
hideLater();
});
button.addEventListener("pointerdown", (event) => event.stopPropagation());
button.addEventListener("click", (event) => {
event.stopPropagation();
hideNow();
onOpen();
});
return {
update: (nextAnchor, nextOpen) => {
anchor = nextAnchor;
open = nextOpen;
refresh();
},
destroy: () => {
hideNow();
button.remove();
},
};
}