175 lines
5.6 KiB
TypeScript
175 lines
5.6 KiB
TypeScript
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,
|
||
};
|
||
};
|