Files
EZPPLauncher/src/lib/theme/themeLayers.ts
T

175 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
ThemeV2ClockElement,
ThemeV2CountdownElement,
ThemeV2ParticlesElement,
} from './themeV2';
/**
* Shared helpers for the dynamic API 2.0 layer types. The DOM renderer and the
* canvas thumbnail renderer both use these so a layer looks the same in the
* launcher, the editor canvas and the exported preview image.
*/
const pad = (value: number) => String(Math.max(0, Math.floor(value))).padStart(2, '0');
export const themeClockTime = (element: ThemeV2ClockElement, now: Date): string => {
const hours = now.getHours();
const minutes = now.getMinutes();
const seconds = now.getSeconds();
const showSeconds = element.showSeconds !== false;
if (element.hour12) {
const hour = hours % 12 || 12;
return `${hour}:${pad(minutes)}${showSeconds ? `:${pad(seconds)}` : ''} ${hours < 12 ? 'AM' : 'PM'}`;
}
return `${pad(hours)}:${pad(minutes)}${showSeconds ? `:${pad(seconds)}` : ''}`;
};
export const themeClockDate = (now: Date): string =>
now.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
export type ThemeCountdownParts = {
days: number;
hours: number;
minutes: number;
seconds: number;
/** True when the target is a valid date that has already passed. */
finished: boolean;
/** False for empty or unparsable targets; the layer then shows zeroes. */
valid: boolean;
};
export const themeCountdownParts = (target: string, now: Date): ThemeCountdownParts => {
const timestamp = target.trim() ? Date.parse(target) : Number.NaN;
if (!Number.isFinite(timestamp)) {
return { days: 0, hours: 0, minutes: 0, seconds: 0, finished: false, valid: false };
}
const remaining = Math.max(0, timestamp - now.getTime());
const totalSeconds = Math.floor(remaining / 1000);
return {
days: Math.floor(totalSeconds / 86_400),
hours: Math.floor((totalSeconds % 86_400) / 3_600),
minutes: Math.floor((totalSeconds % 3_600) / 60),
seconds: totalSeconds % 60,
finished: remaining <= 0,
valid: true,
};
};
export const themeCountdownText = (element: ThemeV2CountdownElement, now: Date): string => {
const parts = themeCountdownParts(element.target, now);
if (parts.finished) return element.finishedText?.trim() || 'The wait is over';
const template = element.text?.trim() ? element.text : '{days}d {hours}h {minutes}m {seconds}s';
return template
.replaceAll('{days}', String(parts.days))
.replaceAll('{hours}', pad(parts.hours))
.replaceAll('{minutes}', pad(parts.minutes))
.replaceAll('{seconds}', pad(parts.seconds));
};
export type ThemeParticleSeed = {
/** Horizontal position in percent of the layer. */
left: number;
/** Vertical offset in percent of the layer. */
top: number;
/** Full animation duration in seconds. */
duration: number;
/** Negative delay so particles start already spread along their path. */
delay: number;
/** Particle size multiplier, 0.45–1.4. */
size: number;
/** Horizontal drift in percent of the layer width. */
drift: number;
/** Total rotation in degrees. */
spin: number;
/** Use the secondary color. */
alternate: boolean;
/** Peak opacity, 0.35–1. */
opacity: number;
};
const seeded = (seed: number) => {
let state = seed || 1;
return () => {
state = (state * 9301 + 49297) % 233_280;
return state / 233_280;
};
};
export const themeParticleSeeds = (element: ThemeV2ParticlesElement): ThemeParticleSeed[] => {
const density = Math.max(4, Math.min(120, Math.round(element.density ?? 28)));
const speed = Math.max(0.1, Math.min(3, element.speed ?? 1));
const wind = Math.max(-1, Math.min(1, element.wind ?? 0));
const random = seeded(density * 7919 + 17);
return Array.from({ length: density }, () => {
const duration = (6 + random() * 10) / speed;
return {
left: random() * 100,
top: random() * 100,
duration,
delay: -duration * random(),
size: 0.45 + random() * 0.95,
drift: (random() * 2 - 1) * 10 + wind * 16,
spin: (random() * 2 - 1) * 720,
alternate: random() > 0.5,
opacity: 0.35 + random() * 0.65,
};
});
};
/** SVG linear-gradient vector for an angle, in 0–1 gradient units. */
export const themeSvgGradientVector = (angle: number) => {
const radians = (angle * Math.PI) / 180;
const x = Math.sin(radians);
const y = -Math.cos(radians);
return {
x1: 0.5 - x / 2,
y1: 0.5 - y / 2,
x2: 0.5 + x / 2,
y2: 0.5 + y / 2,
};
};
/**
* Arc geometry in a 0–100 view box. Angles start at 12 o'clock and run
* clockwise; the stroke is inset so it never clips at the layer edge.
*/
export const themeArcGeometry = (
startAngle: number,
endAngle: number,
thickness: number,
width: number,
height: number
) => {
const minSide = Math.max(1, Math.min(width, height));
const strokeWidth = Math.max(0.5, Math.min(60, (thickness / minSide) * 100));
const radius = Math.max(1, 50 - strokeWidth / 2);
const start = ((startAngle % 360) + 360) % 360;
let end = ((endAngle % 360) + 360) % 360;
if (end <= start) end += 360;
const span = Math.min(360, end - start);
const point = (angle: number) => {
const radians = (angle * Math.PI) / 180;
return {
x: 50 + radius * Math.sin(radians),
y: 50 - radius * Math.cos(radians),
};
};
const from = point(start);
if (span >= 359.99) {
const to = point(start + 180);
return {
d: `M ${from.x} ${from.y} A ${radius} ${radius} 0 1 1 ${to.x} ${to.y} A ${radius} ${radius} 0 1 1 ${from.x} ${from.y}`,
strokeWidth,
};
}
const to = point(start + span);
const largeArc = span > 180 ? 1 : 0;
return {
d: `M ${from.x} ${from.y} A ${radius} ${radius} 0 ${largeArc} 1 ${to.x} ${to.y}`,
strokeWidth,
};
};