@ihabdevteam/core 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -55,6 +55,10 @@
|
|
|
55
55
|
--scb-wrong-bg: #ef4444;
|
|
56
56
|
--scb-wrong-border: #ef4444;
|
|
57
57
|
--scb-wrong-fg: #ffffff;
|
|
58
|
+
/* 호버·포커스도 상태 색과 같은 방식으로 연다 — 소비자가 바꿔 끼울 수 있게. */
|
|
59
|
+
--scb-idle-hover-bg: var(--color-whiteLine);
|
|
60
|
+
--scb-idle-hover-border: var(--color-lightGray);
|
|
61
|
+
--scb-focus-ring: var(--color-main);
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
.sound-compass__speaker-btn--idle {
|
|
@@ -132,6 +136,26 @@
|
|
|
132
136
|
}
|
|
133
137
|
|
|
134
138
|
/* 스피커 버튼 wrap의 정적 속성만 분리(나머지는 상태·좌표 기반 동적 값이라 인라인 유지) */
|
|
139
|
+
/* 호버·포커스 — `all: unset` 이 브라우저 기본 상태까지 지워, 누를 수 있는 자리인지
|
|
140
|
+
눈으로 알 길이 없었다(실측: 이 파일에 :hover·:focus 규칙이 0개였다). 커서만
|
|
141
|
+
pointer 로 바뀔 뿐이었다.
|
|
142
|
+
|
|
143
|
+
고를 수 없을 때는 감싼 자리가 `pointer-events: none` 이라 :hover 가 애초에 안
|
|
144
|
+
걸리고, tabIndex 도 -1 이라 :focus-visible 도 안 걸린다. 그래서 이 규칙들은
|
|
145
|
+
clickable 일 때만 보인다. */
|
|
146
|
+
.sound-compass__speaker-btn:hover {
|
|
147
|
+
box-shadow: 0 0 0 4px color-mix(in srgb, var(--scb-focus-ring) 14%, transparent);
|
|
148
|
+
}
|
|
149
|
+
.sound-compass__speaker-btn--idle:hover {
|
|
150
|
+
background: var(--scb-idle-hover-bg);
|
|
151
|
+
border-color: var(--scb-idle-hover-border);
|
|
152
|
+
}
|
|
153
|
+
/* :focus-visible 이라 마우스로 누를 때는 링이 안 뜨고 키보드로 옮길 때만 뜬다. */
|
|
154
|
+
.sound-compass__speaker-btn:focus-visible {
|
|
155
|
+
outline: 2px solid var(--scb-focus-ring);
|
|
156
|
+
outline-offset: 2px;
|
|
157
|
+
}
|
|
158
|
+
|
|
135
159
|
.sound-compass__speaker-pos {
|
|
136
160
|
position: absolute;
|
|
137
161
|
}
|
|
@@ -79,11 +79,42 @@ export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSp
|
|
|
79
79
|
const avgStep = span / (degs.length - 1);
|
|
80
80
|
const wrapGap = 360 - span;
|
|
81
81
|
const isLoop = sorted.length >= 3 && avgStep > 0 && wrapGap <= avgStep * 1.5;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
// 스피커는 탑뷰에서 한 원 위에 놓이므로 이웃을 **직선이 아니라 호**로 잇는다.
|
|
83
|
+
// 작은 나침반에서는 티가 안 났지만 크게 키우면 꺾인 다각형이 그대로 보인다
|
|
84
|
+
// (실측: HORIZON_5 는 45° 간격이라 현이 호 안쪽으로 57.6px 들어간다).
|
|
85
|
+
//
|
|
86
|
+
// 다만 **정면뷰에서는 호가 틀린다** — 그쪽은 격자라 같은 row 가 같은 y 에
|
|
87
|
+
// 서고, 즉 곧은 가로줄이다. 그래서 t 로 부풀림을 줄여 t=1 에서 정확히
|
|
88
|
+
// 직선이 되게 한다. 탑뷰(t=0)에서는 A 로 그려 근사 없이 스피커 중심을 지난다.
|
|
89
|
+
// 부동소수 찌꺼기를 path 에 싣지 않는다 — `300 * (110/300)` 이 109.99999999999999 이라
|
|
90
|
+
// 그대로 쓰면 반지름이 호마다 그 긴 값으로 적힌다(SVG 만 커지고 얻는 것이 없다).
|
|
91
|
+
const R = Math.round(getRadius(row) * 1000) / 1000;
|
|
92
|
+
const seg = (prev, cur, p0, p1) => {
|
|
93
|
+
// 방향을 짐작하지 않고 각도 차로 정한다 — 배열이 거꾸로 온 소비자도 맞게.
|
|
94
|
+
const delta = ((cur.angleDeg - prev.angleDeg + 540) % 360) - 180;
|
|
95
|
+
if (delta === 0)
|
|
96
|
+
return `L ${p1.x} ${p1.y}`;
|
|
97
|
+
if (t === 0) {
|
|
98
|
+
// sweep-flag 는 SVG 의 양의 각 방향(=화면상 시계방향)이 1 이다.
|
|
99
|
+
return `A ${R} ${R} 0 0 ${delta > 0 ? 1 : 0} ${p1.x} ${p1.y}`;
|
|
100
|
+
}
|
|
101
|
+
// 호의 중간점을 현의 중간점 쪽으로 t 만큼 당긴다. t=1 이면 제어점이 현의
|
|
102
|
+
// 중간점과 같아져 2차 곡선이 곧은 선으로 무너진다.
|
|
103
|
+
const midRad = ((prev.angleDeg + delta / 2) * Math.PI) / 180;
|
|
104
|
+
const arcMid = { x: CENTER + R * Math.cos(midRad), y: CENTER + R * Math.sin(midRad) };
|
|
105
|
+
const chordMid = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 };
|
|
106
|
+
const m = { x: lerp(arcMid.x, chordMid.x, t), y: lerp(arcMid.y, chordMid.y, t) };
|
|
107
|
+
// Q(0.5) = (P0 + 2C + P1)/4 = m → C = 2m − (P0+P1)/2
|
|
108
|
+
const c = { x: 2 * m.x - chordMid.x, y: 2 * m.y - chordMid.y };
|
|
109
|
+
return `Q ${c.x} ${c.y} ${p1.x} ${p1.y}`;
|
|
110
|
+
};
|
|
111
|
+
const ring = isLoop ? [...sorted, sorted[0]] : sorted;
|
|
112
|
+
const ringPts = isLoop ? [...pts, pts[0]] : pts;
|
|
113
|
+
let d = `M ${ringPts[0].x} ${ringPts[0].y}`;
|
|
114
|
+
for (let i = 1; i < ringPts.length; i++) {
|
|
115
|
+
d += ' ' + seg(ring[i - 1], ring[i], ringPts[i - 1], ringPts[i]);
|
|
116
|
+
}
|
|
117
|
+
rowArcPaths.push({ key: `arc-${row}`, d });
|
|
87
118
|
}
|
|
88
119
|
// ── 스피커 시각 상태를 props에서 파생 ──────────────────────────────────
|
|
89
120
|
const played = useMemo(() => new Set(playedIds ?? []), [playedIds]);
|
|
@@ -131,6 +162,6 @@ export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSp
|
|
|
131
162
|
const iconSize = Math.round(Math.max(10, Math.min(56, BTN * 0.38)));
|
|
132
163
|
const numberSize = Math.round(Math.max(7, Math.min(18, BTN * 0.2)));
|
|
133
164
|
const numberOnlySize = Math.round(Math.max(10, Math.min(56, BTN * 0.4)));
|
|
134
|
-
return (_jsxs("div", { className: "sound-compass__speaker-pos", style: { ...baseStyle, left: x - BTN / 2, top: y - BTN / 2 },
|
|
165
|
+
return (_jsxs("div", { className: "sound-compass__speaker-pos", style: { ...baseStyle, left: x - BTN / 2, top: y - BTN / 2 }, children: [pulse && _jsx("div", { className: "sound-compass__pulse", style: { width: BTN, height: BTN } }), _jsxs("button", { type: "button", tabIndex: clickable ? 0 : -1, "aria-disabled": clickable ? undefined : true, onClick: clickable ? () => onSelectSpeaker?.(sp.id) : undefined, className: `sound-compass__speaker-btn sound-compass__speaker-btn--${state}`, style: { width: BTN, height: BTN }, "aria-label": sp.label, children: [showIcon && getStateIcon(state, iconSize), _jsx("span", { className: "sound-compass__speaker-number", style: { fontSize: showIcon ? numberSize : numberOnlySize }, children: idx + 1 })] })] }, sp.id));
|
|
135
166
|
})] }) }));
|
|
136
167
|
}
|