@kongyo2/cards-css 0.1.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.
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/dist/active-registry.js +10 -0
- package/dist/active-registry.js.map +1 -0
- package/dist/dom.js +66 -0
- package/dist/dom.js.map +1 -0
- package/dist/holo-card.js +450 -0
- package/dist/holo-card.js.map +1 -0
- package/dist/holo-cards.css +601 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/math.js +4 -0
- package/dist/math.js.map +1 -0
- package/dist/orientation.js +64 -0
- package/dist/orientation.js.map +1 -0
- package/dist/spring.js +123 -0
- package/dist/spring.js.map +1 -0
- package/dist/subscribers.js +26 -0
- package/dist/subscribers.js.map +1 -0
- package/dist/textures.js +178 -0
- package/dist/textures.js.map +1 -0
- package/dist/ticker.js +32 -0
- package/dist/ticker.js.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist-types/active-registry.d.ts +6 -0
- package/dist-types/active-registry.d.ts.map +1 -0
- package/dist-types/dom.d.ts +18 -0
- package/dist-types/dom.d.ts.map +1 -0
- package/dist-types/holo-card.d.ts +58 -0
- package/dist-types/holo-card.d.ts.map +1 -0
- package/dist-types/index.d.ts +13 -0
- package/dist-types/index.d.ts.map +1 -0
- package/dist-types/math.d.ts +4 -0
- package/dist-types/math.d.ts.map +1 -0
- package/dist-types/orientation.d.ts +13 -0
- package/dist-types/orientation.d.ts.map +1 -0
- package/dist-types/spring.d.ts +32 -0
- package/dist-types/spring.d.ts.map +1 -0
- package/dist-types/subscribers.d.ts +10 -0
- package/dist-types/subscribers.d.ts.map +1 -0
- package/dist-types/textures.d.ts +23 -0
- package/dist-types/textures.d.ts.map +1 -0
- package/dist-types/ticker.d.ts +7 -0
- package/dist-types/ticker.d.ts.map +1 -0
- package/dist-types/types.d.ts +21 -0
- package/dist-types/types.d.ts.map +1 -0
- package/package.json +75 -0
- package/src/active-registry.ts +15 -0
- package/src/dom.ts +79 -0
- package/src/holo-card.ts +525 -0
- package/src/index.ts +35 -0
- package/src/math.ts +6 -0
- package/src/orientation.ts +83 -0
- package/src/spring.ts +158 -0
- package/src/styles/base.css +262 -0
- package/src/styles/effects/cosmos.css +143 -0
- package/src/styles/effects/glitter.css +103 -0
- package/src/styles/effects/holo.css +127 -0
- package/src/styles/effects/reverse.css +55 -0
- package/src/styles/index.css +5 -0
- package/src/subscribers.ts +30 -0
- package/src/textures.ts +310 -0
- package/src/ticker.ts +46 -0
- package/src/types.ts +22 -0
package/src/holo-card.ts
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import { adjust, clamp, round } from "./math.js";
|
|
2
|
+
import { Spring, type SpringSetOpts } from "./spring.js";
|
|
3
|
+
import { CLASS } from "./dom.js";
|
|
4
|
+
import { getActiveCard, setActiveCard, subscribeActiveCard } from "./active-registry.js";
|
|
5
|
+
import { resetBaseOrientation, subscribeOrientation, type RelativeOrientation } from "./orientation.js";
|
|
6
|
+
import { generateTextures, texturesToCssVariables } from "./textures.js";
|
|
7
|
+
import type { HoloCardOptions } from "./types.js";
|
|
8
|
+
|
|
9
|
+
const requestFrame = (cb: () => void): number =>
|
|
10
|
+
typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame(cb) : setTimeout(cb, 16);
|
|
11
|
+
|
|
12
|
+
const cancelFrame = (id: number): void => {
|
|
13
|
+
if (typeof cancelAnimationFrame !== "undefined") {
|
|
14
|
+
cancelAnimationFrame(id);
|
|
15
|
+
} else {
|
|
16
|
+
clearTimeout(id);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const SPRING_INTERACT = { stiffness: 0.066, damping: 0.25 };
|
|
21
|
+
const SPRING_POPOVER = { stiffness: 0.033, damping: 0.45 };
|
|
22
|
+
const SNAP_STIFFNESS = 0.01;
|
|
23
|
+
const SNAP_DAMPING = 0.06;
|
|
24
|
+
|
|
25
|
+
interface Vec2 {
|
|
26
|
+
x: number;
|
|
27
|
+
y: number;
|
|
28
|
+
[key: string]: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Glare extends Vec2 {
|
|
32
|
+
o: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class HoloCard {
|
|
36
|
+
readonly element: HTMLElement;
|
|
37
|
+
|
|
38
|
+
private readonly rotator: HTMLElement;
|
|
39
|
+
private readonly options: Required<
|
|
40
|
+
Pick<HoloCardOptions, "interactive" | "activateOnClick" | "gyroscope" | "showcase">
|
|
41
|
+
>;
|
|
42
|
+
|
|
43
|
+
private readonly springRotate = new Spring<Vec2>({ x: 0, y: 0 }, SPRING_INTERACT);
|
|
44
|
+
private readonly springGlare = new Spring<Glare>({ x: 50, y: 50, o: 0 }, SPRING_INTERACT);
|
|
45
|
+
private readonly springBackground = new Spring<Vec2>({ x: 50, y: 50 }, SPRING_INTERACT);
|
|
46
|
+
private readonly springRotateDelta = new Spring<Vec2>({ x: 0, y: 0 }, SPRING_POPOVER);
|
|
47
|
+
private readonly springTranslate = new Spring<Vec2>({ x: 0, y: 0 }, SPRING_POPOVER);
|
|
48
|
+
private readonly springScale = new Spring<number>(1, SPRING_POPOVER);
|
|
49
|
+
|
|
50
|
+
private isInteracting = false;
|
|
51
|
+
private firstPop = true;
|
|
52
|
+
private isVisible = typeof document !== "undefined" ? document.visibilityState === "visible" : true;
|
|
53
|
+
private destroyed = false;
|
|
54
|
+
|
|
55
|
+
private renderScheduled = false;
|
|
56
|
+
private interactRaf: number | null = null;
|
|
57
|
+
private pendingUpdate: { background: Vec2; rotate: Vec2; glare: Glare } | null = null;
|
|
58
|
+
|
|
59
|
+
private repositionTimer: ReturnType<typeof setTimeout> | null = null;
|
|
60
|
+
private endTimer: ReturnType<typeof setTimeout> | null = null;
|
|
61
|
+
private showcaseStart: ReturnType<typeof setTimeout> | null = null;
|
|
62
|
+
private showcaseEnd: ReturnType<typeof setTimeout> | null = null;
|
|
63
|
+
private showcaseInterval: ReturnType<typeof setInterval> | null = null;
|
|
64
|
+
private showcaseRunning: boolean;
|
|
65
|
+
|
|
66
|
+
private readonly cleanups: Array<() => void> = [];
|
|
67
|
+
private unsubscribeOrientation: (() => void) | null = null;
|
|
68
|
+
|
|
69
|
+
constructor(element: HTMLElement, options: HoloCardOptions = {}) {
|
|
70
|
+
this.element = element;
|
|
71
|
+
const rotator = element.querySelector<HTMLElement>(`.${CLASS.rotator}`);
|
|
72
|
+
if (!rotator) {
|
|
73
|
+
throw new Error("@kongyo2/cards-css: holo card element is missing its .holo-card__rotator child.");
|
|
74
|
+
}
|
|
75
|
+
this.rotator = rotator;
|
|
76
|
+
|
|
77
|
+
this.options = {
|
|
78
|
+
interactive: options.interactive ?? true,
|
|
79
|
+
activateOnClick: options.activateOnClick ?? false,
|
|
80
|
+
gyroscope: options.gyroscope ?? true,
|
|
81
|
+
showcase: options.showcase ?? false,
|
|
82
|
+
};
|
|
83
|
+
this.showcaseRunning = this.options.showcase;
|
|
84
|
+
|
|
85
|
+
if (options.effect) {
|
|
86
|
+
element.dataset.effect = options.effect;
|
|
87
|
+
} else if (!element.dataset.effect) {
|
|
88
|
+
element.dataset.effect = "none";
|
|
89
|
+
}
|
|
90
|
+
if (options.glow) {
|
|
91
|
+
element.style.setProperty("--card-glow", options.glow);
|
|
92
|
+
}
|
|
93
|
+
if (typeof options.aspectRatio === "number") {
|
|
94
|
+
element.style.setProperty("--card-aspect", String(options.aspectRatio));
|
|
95
|
+
}
|
|
96
|
+
if (options.mask) {
|
|
97
|
+
element.style.setProperty("--mask", `url(${options.mask})`);
|
|
98
|
+
element.classList.add(CLASS.masked);
|
|
99
|
+
}
|
|
100
|
+
if (options.foil) {
|
|
101
|
+
element.style.setProperty("--foil", `url(${options.foil})`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.applyStaticStyles(options.textureSeed);
|
|
105
|
+
|
|
106
|
+
for (const spring of [
|
|
107
|
+
this.springRotate,
|
|
108
|
+
this.springGlare,
|
|
109
|
+
this.springBackground,
|
|
110
|
+
this.springRotateDelta,
|
|
111
|
+
this.springTranslate,
|
|
112
|
+
this.springScale,
|
|
113
|
+
]) {
|
|
114
|
+
this.cleanups.push(spring.subscribe(() => this.scheduleRender()));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this.applyStyles();
|
|
118
|
+
|
|
119
|
+
if (this.options.interactive) {
|
|
120
|
+
this.enableInteractive();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.cleanups.push(subscribeActiveCard(() => this.onActiveChange()));
|
|
124
|
+
|
|
125
|
+
if (typeof document !== "undefined") {
|
|
126
|
+
const onVisibility = (): void => this.onVisibilityChange();
|
|
127
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
128
|
+
this.cleanups.push(() => document.removeEventListener("visibilitychange", onVisibility));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (this.options.showcase) {
|
|
132
|
+
this.startShowcase();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private applyStaticStyles(seed: number | undefined): void {
|
|
137
|
+
const seedX = Math.random();
|
|
138
|
+
const seedY = Math.random();
|
|
139
|
+
const cosmosX = Math.floor(seedX * 734);
|
|
140
|
+
const cosmosY = Math.floor(seedY * 1280);
|
|
141
|
+
this.element.style.setProperty("--seedx", String(seedX));
|
|
142
|
+
this.element.style.setProperty("--seedy", String(seedY));
|
|
143
|
+
this.element.style.setProperty("--cosmosbg", `${cosmosX}px ${cosmosY}px`);
|
|
144
|
+
|
|
145
|
+
if (typeof seed === "number") {
|
|
146
|
+
const vars = texturesToCssVariables(generateTextures({ seed }));
|
|
147
|
+
for (const [name, value] of Object.entries(vars)) {
|
|
148
|
+
this.element.style.setProperty(name, value);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private enableInteractive(): void {
|
|
154
|
+
this.element.classList.add(CLASS.interactive);
|
|
155
|
+
|
|
156
|
+
const onPointerMove = (event: PointerEvent): void => this.interact(event);
|
|
157
|
+
const onPointerLeave = (): void => this.interactEnd();
|
|
158
|
+
this.rotator.addEventListener("pointermove", onPointerMove);
|
|
159
|
+
this.rotator.addEventListener("pointerleave", onPointerLeave);
|
|
160
|
+
this.cleanups.push(() => this.rotator.removeEventListener("pointermove", onPointerMove));
|
|
161
|
+
this.cleanups.push(() => this.rotator.removeEventListener("pointerleave", onPointerLeave));
|
|
162
|
+
|
|
163
|
+
if (this.options.activateOnClick) {
|
|
164
|
+
const onClick = (): void => this.toggleActive();
|
|
165
|
+
const onBlur = (): void => this.deactivate();
|
|
166
|
+
this.rotator.addEventListener("click", onClick);
|
|
167
|
+
this.rotator.addEventListener("blur", onBlur);
|
|
168
|
+
this.rotator.tabIndex = this.rotator.tabIndex >= 0 ? this.rotator.tabIndex : 0;
|
|
169
|
+
this.cleanups.push(() => this.rotator.removeEventListener("click", onClick));
|
|
170
|
+
this.cleanups.push(() => this.rotator.removeEventListener("blur", onBlur));
|
|
171
|
+
|
|
172
|
+
const onScroll = (): void => this.reposition();
|
|
173
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
174
|
+
window.addEventListener("resize", onScroll, { passive: true });
|
|
175
|
+
this.cleanups.push(() => window.removeEventListener("scroll", onScroll));
|
|
176
|
+
this.cleanups.push(() => window.removeEventListener("resize", onScroll));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private interact(event: PointerEvent): void {
|
|
181
|
+
this.endShowcase();
|
|
182
|
+
|
|
183
|
+
if (!this.isVisible) {
|
|
184
|
+
this.setInteracting(false);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const active = getActiveCard();
|
|
189
|
+
if (active && active !== this) {
|
|
190
|
+
this.setInteracting(false);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
this.setInteracting(true);
|
|
195
|
+
if (this.endTimer) {
|
|
196
|
+
clearTimeout(this.endTimer);
|
|
197
|
+
this.endTimer = null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const rect = this.rotator.getBoundingClientRect();
|
|
201
|
+
const absolute = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
|
202
|
+
const percent = {
|
|
203
|
+
x: clamp(round((100 / rect.width) * absolute.x)),
|
|
204
|
+
y: clamp(round((100 / rect.height) * absolute.y)),
|
|
205
|
+
};
|
|
206
|
+
const center = { x: percent.x - 50, y: percent.y - 50 };
|
|
207
|
+
|
|
208
|
+
this.pendingUpdate = {
|
|
209
|
+
background: { x: adjust(percent.x, 0, 100, 37, 63), y: adjust(percent.y, 0, 100, 33, 67) },
|
|
210
|
+
rotate: { x: round(-(center.x / 3.5)), y: round(center.y / 3.5) },
|
|
211
|
+
glare: { x: round(percent.x), y: round(percent.y), o: 1 },
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
if (this.interactRaf === null) {
|
|
215
|
+
this.interactRaf = requestFrame(() => {
|
|
216
|
+
if (this.pendingUpdate) {
|
|
217
|
+
this.updateSprings(this.pendingUpdate.background, this.pendingUpdate.rotate, this.pendingUpdate.glare);
|
|
218
|
+
this.pendingUpdate = null;
|
|
219
|
+
}
|
|
220
|
+
this.interactRaf = null;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private interactEnd(delay = 500): void {
|
|
226
|
+
if (this.interactRaf !== null) {
|
|
227
|
+
cancelFrame(this.interactRaf);
|
|
228
|
+
this.interactRaf = null;
|
|
229
|
+
}
|
|
230
|
+
this.pendingUpdate = null;
|
|
231
|
+
|
|
232
|
+
if (this.endTimer) {
|
|
233
|
+
clearTimeout(this.endTimer);
|
|
234
|
+
}
|
|
235
|
+
this.endTimer = setTimeout(() => {
|
|
236
|
+
this.setInteracting(false);
|
|
237
|
+
this.setSpringDynamics(SNAP_STIFFNESS, SNAP_DAMPING);
|
|
238
|
+
void this.springRotate.set({ x: 0, y: 0 }, { soft: 1 });
|
|
239
|
+
void this.springGlare.set({ x: 50, y: 50, o: 0 }, { soft: 1 });
|
|
240
|
+
void this.springBackground.set({ x: 50, y: 50 }, { soft: 1 });
|
|
241
|
+
}, delay);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private setSpringDynamics(stiffness: number, damping: number): void {
|
|
245
|
+
for (const spring of [this.springRotate, this.springGlare, this.springBackground]) {
|
|
246
|
+
spring.stiffness = stiffness;
|
|
247
|
+
spring.damping = damping;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private settle(opts: SpringSetOpts): void {
|
|
252
|
+
void this.springScale.set(1, opts);
|
|
253
|
+
void this.springTranslate.set({ x: 0, y: 0 }, opts);
|
|
254
|
+
void this.springRotateDelta.set({ x: 0, y: 0 }, opts);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private updateSprings(background: Vec2, rotate: Vec2, glare: Glare): void {
|
|
258
|
+
this.setSpringDynamics(SPRING_INTERACT.stiffness, SPRING_INTERACT.damping);
|
|
259
|
+
void this.springBackground.set(background);
|
|
260
|
+
void this.springRotate.set(rotate);
|
|
261
|
+
void this.springGlare.set(glare);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private setInteracting(value: boolean): void {
|
|
265
|
+
this.isInteracting = value;
|
|
266
|
+
this.element.classList.toggle(CLASS.interacting, value);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
get interacting(): boolean {
|
|
270
|
+
return this.isInteracting;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private scheduleRender(): void {
|
|
274
|
+
if (this.renderScheduled) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
this.renderScheduled = true;
|
|
278
|
+
requestFrame(() => {
|
|
279
|
+
this.renderScheduled = false;
|
|
280
|
+
this.applyStyles();
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private applyStyles(): void {
|
|
285
|
+
const glare = this.springGlare.current;
|
|
286
|
+
const rotate = this.springRotate.current;
|
|
287
|
+
const rotateDelta = this.springRotateDelta.current;
|
|
288
|
+
const background = this.springBackground.current;
|
|
289
|
+
const translate = this.springTranslate.current;
|
|
290
|
+
const scale = this.springScale.current;
|
|
291
|
+
|
|
292
|
+
const fromCenter = clamp(Math.sqrt((glare.y - 50) * (glare.y - 50) + (glare.x - 50) * (glare.x - 50)) / 50, 0, 1);
|
|
293
|
+
|
|
294
|
+
const style = this.element.style;
|
|
295
|
+
style.setProperty("--pointer-x", `${glare.x}%`);
|
|
296
|
+
style.setProperty("--pointer-y", `${glare.y}%`);
|
|
297
|
+
style.setProperty("--pointer-from-center", String(fromCenter));
|
|
298
|
+
style.setProperty("--pointer-from-top", String(glare.y / 100));
|
|
299
|
+
style.setProperty("--pointer-from-left", String(glare.x / 100));
|
|
300
|
+
style.setProperty("--card-opacity", String(glare.o));
|
|
301
|
+
style.setProperty("--rotate-x", `${rotate.x + rotateDelta.x}deg`);
|
|
302
|
+
style.setProperty("--rotate-y", `${rotate.y + rotateDelta.y}deg`);
|
|
303
|
+
style.setProperty("--background-x", `${background.x}%`);
|
|
304
|
+
style.setProperty("--background-y", `${background.y}%`);
|
|
305
|
+
style.setProperty("--card-scale", String(scale));
|
|
306
|
+
style.setProperty("--translate-x", `${translate.x}px`);
|
|
307
|
+
style.setProperty("--translate-y", `${translate.y}px`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private onActiveChange(): void {
|
|
311
|
+
if (getActiveCard() === this) {
|
|
312
|
+
this.popover();
|
|
313
|
+
this.element.classList.add(CLASS.active);
|
|
314
|
+
if (this.options.gyroscope) {
|
|
315
|
+
this.startGyroscope();
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
this.retreat();
|
|
319
|
+
this.element.classList.remove(CLASS.active);
|
|
320
|
+
this.stopGyroscope();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private popover(): void {
|
|
325
|
+
const rect = this.element.getBoundingClientRect();
|
|
326
|
+
let delay = 100;
|
|
327
|
+
const scaleW = (window.innerWidth / rect.width) * 0.9;
|
|
328
|
+
const scaleH = (window.innerHeight / rect.height) * 0.9;
|
|
329
|
+
const scaleF = 1.75;
|
|
330
|
+
this.setCenter();
|
|
331
|
+
if (this.firstPop) {
|
|
332
|
+
delay = 1000;
|
|
333
|
+
void this.springRotateDelta.set({ x: 360, y: 0 });
|
|
334
|
+
}
|
|
335
|
+
this.firstPop = false;
|
|
336
|
+
void this.springScale.set(Math.min(scaleW, scaleH, scaleF));
|
|
337
|
+
this.interactEnd(delay);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private retreat(): void {
|
|
341
|
+
this.settle({ soft: true });
|
|
342
|
+
this.interactEnd(100);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private reset(): void {
|
|
346
|
+
this.interactEnd(0);
|
|
347
|
+
this.settle({ hard: true });
|
|
348
|
+
void this.springRotate.set({ x: 0, y: 0 }, { hard: true });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private setCenter(): void {
|
|
352
|
+
const rect = this.element.getBoundingClientRect();
|
|
353
|
+
const view = document.documentElement;
|
|
354
|
+
void this.springTranslate.set({
|
|
355
|
+
x: round(view.clientWidth / 2 - rect.x - rect.width / 2),
|
|
356
|
+
y: round(view.clientHeight / 2 - rect.y - rect.height / 2),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private reposition(): void {
|
|
361
|
+
if (this.repositionTimer) {
|
|
362
|
+
clearTimeout(this.repositionTimer);
|
|
363
|
+
}
|
|
364
|
+
this.repositionTimer = setTimeout(() => {
|
|
365
|
+
if (getActiveCard() === this) {
|
|
366
|
+
this.setCenter();
|
|
367
|
+
}
|
|
368
|
+
}, 300);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private startGyroscope(): void {
|
|
372
|
+
if (this.unsubscribeOrientation) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
this.unsubscribeOrientation = subscribeOrientation((orientation) => this.orientate(orientation));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private stopGyroscope(): void {
|
|
379
|
+
if (this.unsubscribeOrientation) {
|
|
380
|
+
this.unsubscribeOrientation();
|
|
381
|
+
this.unsubscribeOrientation = null;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private orientate(orientation: RelativeOrientation): void {
|
|
386
|
+
if (getActiveCard() !== this) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const limit = { x: 16, y: 18 };
|
|
390
|
+
const degrees = {
|
|
391
|
+
x: clamp(orientation.relative.gamma, -limit.x, limit.x),
|
|
392
|
+
y: clamp(orientation.relative.beta, -limit.y, limit.y),
|
|
393
|
+
};
|
|
394
|
+
this.setInteracting(true);
|
|
395
|
+
this.updateSprings(
|
|
396
|
+
{ x: adjust(degrees.x, -limit.x, limit.x, 37, 63), y: adjust(degrees.y, -limit.y, limit.y, 33, 67) },
|
|
397
|
+
{ x: round(degrees.x * -1), y: round(degrees.y) },
|
|
398
|
+
{ x: adjust(degrees.x, -limit.x, limit.x, 0, 100), y: adjust(degrees.y, -limit.y, limit.y, 0, 100), o: 1 },
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private onVisibilityChange(): void {
|
|
403
|
+
this.isVisible = document.visibilityState === "visible";
|
|
404
|
+
this.endShowcase();
|
|
405
|
+
this.reset();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private startShowcase(): void {
|
|
409
|
+
if (!this.isVisible) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const s = 0.02;
|
|
413
|
+
const d = 0.5;
|
|
414
|
+
let r = 0;
|
|
415
|
+
this.showcaseStart = setTimeout(() => {
|
|
416
|
+
this.setInteracting(true);
|
|
417
|
+
this.setSpringDynamics(s, d);
|
|
418
|
+
if (!this.isVisible) {
|
|
419
|
+
this.setInteracting(false);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
this.showcaseInterval = setInterval(() => {
|
|
423
|
+
r += 0.05;
|
|
424
|
+
void this.springRotate.set({ x: Math.sin(r) * 25, y: Math.cos(r) * 25 });
|
|
425
|
+
void this.springGlare.set({ x: 55 + Math.sin(r) * 55, y: 55 + Math.cos(r) * 55, o: 0.8 });
|
|
426
|
+
void this.springBackground.set({ x: 20 + Math.sin(r) * 20, y: 20 + Math.cos(r) * 20 });
|
|
427
|
+
}, 20);
|
|
428
|
+
this.showcaseEnd = setTimeout(() => {
|
|
429
|
+
if (this.showcaseInterval) {
|
|
430
|
+
clearInterval(this.showcaseInterval);
|
|
431
|
+
this.showcaseInterval = null;
|
|
432
|
+
}
|
|
433
|
+
this.interactEnd(0);
|
|
434
|
+
}, 4000);
|
|
435
|
+
}, 2000);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private endShowcase(): void {
|
|
439
|
+
if (!this.showcaseRunning) {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (this.showcaseEnd) {
|
|
443
|
+
clearTimeout(this.showcaseEnd);
|
|
444
|
+
this.showcaseEnd = null;
|
|
445
|
+
}
|
|
446
|
+
if (this.showcaseStart) {
|
|
447
|
+
clearTimeout(this.showcaseStart);
|
|
448
|
+
this.showcaseStart = null;
|
|
449
|
+
}
|
|
450
|
+
if (this.showcaseInterval) {
|
|
451
|
+
clearInterval(this.showcaseInterval);
|
|
452
|
+
this.showcaseInterval = null;
|
|
453
|
+
}
|
|
454
|
+
this.showcaseRunning = false;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private toggleActive(): void {
|
|
458
|
+
if (getActiveCard() === this) {
|
|
459
|
+
setActiveCard(null);
|
|
460
|
+
} else {
|
|
461
|
+
this.endShowcase();
|
|
462
|
+
resetBaseOrientation();
|
|
463
|
+
setActiveCard(this);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
activate(): void {
|
|
468
|
+
if (getActiveCard() !== this) {
|
|
469
|
+
this.endShowcase();
|
|
470
|
+
resetBaseOrientation();
|
|
471
|
+
setActiveCard(this);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
deactivate(): void {
|
|
476
|
+
this.interactEnd();
|
|
477
|
+
if (getActiveCard() === this) {
|
|
478
|
+
setActiveCard(null);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
setEffect(effect: HoloCardOptions["effect"]): void {
|
|
483
|
+
this.element.dataset.effect = effect ?? "none";
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
get active(): boolean {
|
|
487
|
+
return getActiveCard() === this;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
destroy(): void {
|
|
491
|
+
if (this.destroyed) {
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
this.destroyed = true;
|
|
495
|
+
this.endShowcase();
|
|
496
|
+
this.stopGyroscope();
|
|
497
|
+
if (getActiveCard() === this) {
|
|
498
|
+
setActiveCard(null);
|
|
499
|
+
}
|
|
500
|
+
for (const timer of [this.repositionTimer, this.endTimer]) {
|
|
501
|
+
if (timer) {
|
|
502
|
+
clearTimeout(timer);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (this.interactRaf !== null) {
|
|
506
|
+
cancelFrame(this.interactRaf);
|
|
507
|
+
this.interactRaf = null;
|
|
508
|
+
}
|
|
509
|
+
for (const cleanup of this.cleanups) {
|
|
510
|
+
cleanup();
|
|
511
|
+
}
|
|
512
|
+
this.cleanups.length = 0;
|
|
513
|
+
for (const spring of [
|
|
514
|
+
this.springRotate,
|
|
515
|
+
this.springGlare,
|
|
516
|
+
this.springBackground,
|
|
517
|
+
this.springRotateDelta,
|
|
518
|
+
this.springTranslate,
|
|
519
|
+
this.springScale,
|
|
520
|
+
]) {
|
|
521
|
+
spring.destroy();
|
|
522
|
+
}
|
|
523
|
+
this.element.classList.remove(CLASS.interactive, CLASS.interacting, CLASS.active);
|
|
524
|
+
}
|
|
525
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { HoloCard } from "./holo-card.js";
|
|
2
|
+
import { buildHoloCardElement } from "./dom.js";
|
|
3
|
+
import type { CreateHoloCardOptions, HoloCardOptions } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export { HoloCard } from "./holo-card.js";
|
|
6
|
+
export { buildHoloCardElement, CLASS } from "./dom.js";
|
|
7
|
+
export {
|
|
8
|
+
generateTextures,
|
|
9
|
+
texturesToCssVariables,
|
|
10
|
+
grainTexture,
|
|
11
|
+
glitterTexture,
|
|
12
|
+
TEXTURE_VARIABLES,
|
|
13
|
+
DEFAULT_TEXTURE_SEED,
|
|
14
|
+
type Textures,
|
|
15
|
+
type TextureOptions,
|
|
16
|
+
} from "./textures.js";
|
|
17
|
+
export {
|
|
18
|
+
subscribeOrientation,
|
|
19
|
+
requestOrientationPermission,
|
|
20
|
+
resetBaseOrientation,
|
|
21
|
+
type Orientation,
|
|
22
|
+
type RelativeOrientation,
|
|
23
|
+
} from "./orientation.js";
|
|
24
|
+
export { getActiveCard, setActiveCard, subscribeActiveCard } from "./active-registry.js";
|
|
25
|
+
export { Spring, type SpringValue, type SpringOpts, type SpringSetOpts } from "./spring.js";
|
|
26
|
+
export { round, clamp, adjust } from "./math.js";
|
|
27
|
+
export type { HoloEffect, HoloCardOptions, CreateHoloCardOptions } from "./types.js";
|
|
28
|
+
|
|
29
|
+
export const createHoloCard = (options: CreateHoloCardOptions): HoloCard => {
|
|
30
|
+
const element = buildHoloCardElement(options);
|
|
31
|
+
return new HoloCard(element, options);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const attachHoloCard = (element: HTMLElement, options: HoloCardOptions = {}): HoloCard =>
|
|
35
|
+
new HoloCard(element, options);
|
package/src/math.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const round = (value: number, precision = 3): number => parseFloat(value.toFixed(precision));
|
|
2
|
+
|
|
3
|
+
export const clamp = (value: number, min = 0, max = 100): number => Math.min(Math.max(value, min), max);
|
|
4
|
+
|
|
5
|
+
export const adjust = (value: number, fromMin: number, fromMax: number, toMin: number, toMax: number): number =>
|
|
6
|
+
round(toMin + ((toMax - toMin) * (value - fromMin)) / (fromMax - fromMin));
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Subscribers } from "./subscribers.js";
|
|
2
|
+
|
|
3
|
+
export interface Orientation {
|
|
4
|
+
alpha: number;
|
|
5
|
+
beta: number;
|
|
6
|
+
gamma: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface RelativeOrientation {
|
|
10
|
+
absolute: Orientation;
|
|
11
|
+
relative: Orientation;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const rawOrientation = (event?: DeviceOrientationEvent): Orientation => {
|
|
15
|
+
if (!event) {
|
|
16
|
+
return { alpha: 0, beta: 0, gamma: 0 };
|
|
17
|
+
}
|
|
18
|
+
return { alpha: event.alpha ?? 0, beta: event.beta ?? 0, gamma: event.gamma ?? 0 };
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
let firstReading = true;
|
|
22
|
+
let baseOrientation = rawOrientation();
|
|
23
|
+
|
|
24
|
+
export const resetBaseOrientation = (): void => {
|
|
25
|
+
firstReading = true;
|
|
26
|
+
baseOrientation = rawOrientation();
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const toRelative = (event?: DeviceOrientationEvent): RelativeOrientation => {
|
|
30
|
+
const o = rawOrientation(event);
|
|
31
|
+
return {
|
|
32
|
+
absolute: o,
|
|
33
|
+
relative: {
|
|
34
|
+
alpha: o.alpha - baseOrientation.alpha,
|
|
35
|
+
beta: o.beta - baseOrientation.beta,
|
|
36
|
+
gamma: o.gamma - baseOrientation.gamma,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const subscribers = new Subscribers<RelativeOrientation>(() => toRelative());
|
|
42
|
+
let listening = false;
|
|
43
|
+
|
|
44
|
+
const handleOrientation = (event: DeviceOrientationEvent): void => {
|
|
45
|
+
if (firstReading) {
|
|
46
|
+
firstReading = false;
|
|
47
|
+
baseOrientation = rawOrientation(event);
|
|
48
|
+
}
|
|
49
|
+
subscribers.emit(toRelative(event));
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const subscribeOrientation = (fn: (orientation: RelativeOrientation) => void): (() => void) => {
|
|
53
|
+
const unsubscribe = subscribers.subscribe(fn);
|
|
54
|
+
if (!listening && typeof window !== "undefined") {
|
|
55
|
+
listening = true;
|
|
56
|
+
window.addEventListener("deviceorientation", handleOrientation, true);
|
|
57
|
+
}
|
|
58
|
+
return () => {
|
|
59
|
+
unsubscribe();
|
|
60
|
+
if (subscribers.size === 0 && listening && typeof window !== "undefined") {
|
|
61
|
+
listening = false;
|
|
62
|
+
window.removeEventListener("deviceorientation", handleOrientation, true);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type PermissionRequester = { requestPermission?: () => Promise<"granted" | "denied"> };
|
|
68
|
+
|
|
69
|
+
export const requestOrientationPermission = async (): Promise<boolean> => {
|
|
70
|
+
if (typeof DeviceOrientationEvent === "undefined") {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
const requester = DeviceOrientationEvent as unknown as PermissionRequester;
|
|
74
|
+
if (typeof requester.requestPermission !== "function") {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const result = await requester.requestPermission();
|
|
79
|
+
return result === "granted";
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
};
|