Radial Pattern Background: Various improvements

This commit is contained in:
Alexander Zinchuk 2025-10-11 19:07:24 +02:00
parent 5e679218e2
commit 595d2f4b4c
6 changed files with 128 additions and 74 deletions

View File

@ -140,6 +140,9 @@ const SavedGift = ({
backgroundColors={backdropColors}
patternColor={patternColor}
patternIcon={pattern.sticker}
patternSize={14}
ringsCount={1}
ovalFactor={1}
/>
);
}, [backdrop, pattern]);

View File

@ -386,14 +386,9 @@
.radialPatternBackground {
pointer-events: none;
// Hacky way to keep background stable during resizes
position: absolute;
top: 8rem;
inset: 0;
aspect-ratio: 1 / 1;
width: 140%;
margin-top: -70%;
}
.standaloneAvatar {

View File

@ -45,6 +45,7 @@ import buildStyle from '../../../util/buildStyle';
import { captureEvents, SwipeDirection } from '../../../util/captureEvents';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
import { resolveTransitionName } from '../../../util/resolveTransitionName';
import { REM } from '../helpers/mediaDimensions.ts';
import renderText from '../helpers/renderText.tsx';
import { useVtn } from '../../../hooks/animations/useVtn';
@ -104,9 +105,7 @@ const EMOJI_TOPIC_SIZE = 120;
const LOAD_MORE_THRESHOLD = 3;
const MAX_PHOTO_DASH_COUNT = 30;
const STATUS_UPDATE_INTERVAL = 1000 * 60; // 1 min
const PATTERN_COLOR = '#000000';
const PATTERN_SIZE_FACTOR = 0.75;
const PATTERN_OPACITY = 0.75;
const PATTERN_Y_SHIFT = 8 * REM;
const ProfileInfo = ({
isExpanded,
@ -493,10 +492,11 @@ const ProfileInfo = ({
<RadialPatternBackground
backgroundColors={profileColorSet.bgColors}
patternIcon={backgroundEmoji}
patternColor={collectibleEmojiStatus?.patternColor || PATTERN_COLOR}
patternColor={collectibleEmojiStatus?.patternColor}
patternSize={16}
withLinearGradient={Boolean(!collectibleEmojiStatus && profileColorSet.bgColors[1])}
className={styles.radialPatternBackground}
patternSize={PATTERN_SIZE_FACTOR}
patternOpacity={collectibleEmojiStatus ? 1 : PATTERN_OPACITY}
yPosition={PATTERN_Y_SHIFT}
/>
)}
{pinnedGifts && (

View File

@ -1,4 +1,6 @@
.root {
--_y-shift: 50%;
overflow: hidden;
border-radius: inherit;
@ -9,9 +11,15 @@
position: absolute;
inset: 0;
background-image:
radial-gradient(circle closest-side, #ffffff32 3rem, #ffffff00 7rem),
radial-gradient(closest-side, var(--_bg-1), var(--_bg-2));
background-image: radial-gradient(closest-side at 50% var(--_y-shift), var(--_bg-radial-1) 25%, var(--_bg-radial-2) 125%);
}
&.withLinearGradient {
&::before {
background-image:
radial-gradient(closest-side at 50% var(--_y-shift), var(--_bg-radial-1) 25%, var(--_bg-radial-2) 125%),
linear-gradient(var(--_bg-linear-1), var(--_bg-linear-2));
}
}
}

View File

@ -1,6 +1,4 @@
import {
memo, useEffect, useMemo, useRef, useSignal, useState,
} from '../../../lib/teact/teact';
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useSignal, useState } from '../../../lib/teact/teact';
import type { ApiSticker } from '../../../api/types';
@ -9,7 +7,8 @@ import { getStickerMediaHash } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import buildStyle from '../../../util/buildStyle';
import { preloadImage } from '../../../util/files';
import { clamp } from '../../../util/math';
import { hexToRgb } from '../../../util/switchTheme.ts';
import { REM } from '../helpers/mediaDimensions';
import useLastCallback from '../../../hooks/useLastCallback';
import useMedia from '../../../hooks/useMedia';
@ -20,31 +19,39 @@ import styles from './RadialPatternBackground.module.scss';
type OwnProps = {
backgroundColors: string[];
patternColor?: string;
patternIcon?: ApiSticker;
patternColor?: string;
patternSize?: number;
ringsCount?: number;
ovalFactor?: number;
withLinearGradient?: boolean;
className?: string;
clearBottomSector?: boolean;
patternSize?: number;
patternOpacity?: number;
yPosition?: number;
};
const RINGS = 3;
const BASE_RING_ITEM_COUNT = 8;
const RING_INCREMENT = 0.5;
const CENTER_EMPTINESS = 0.05;
const MAX_RADIUS = 0.4;
const BASE_ICON_SIZE = 20;
const CENTER_EMPTINESS = 0.1;
const MAX_RADIUS = 0.43;
const MIN_SIZE = 4 * REM;
const PATTERN_OPACITY = 0.9;
const MIN_SIZE = 250;
const DEFAULT_PATTERN_SIZE = 20;
const DEFAULT_RINGS_COUNT = 3;
const DEFAULT_OVAL_FACTOR = 1.4;
const RadialPatternBackground = ({
backgroundColors,
patternColor,
patternIcon,
patternOpacity,
patternColor,
patternSize = DEFAULT_PATTERN_SIZE,
ringsCount = DEFAULT_RINGS_COUNT,
ovalFactor = DEFAULT_OVAL_FACTOR,
withLinearGradient,
clearBottomSector,
className,
patternSize = 1,
yPosition,
}: OwnProps) => {
const containerRef = useRef<HTMLDivElement>();
const canvasRef = useRef<HTMLCanvasElement>();
@ -65,11 +72,10 @@ const RadialPatternBackground = ({
const patternPositions = useMemo(() => {
const coordinates: { x: number; y: number; sizeFactor: number }[] = [];
for (let ring = 1; ring <= RINGS; ring++) {
for (let ring = 1; ring <= ringsCount; ring++) {
const ringItemCount = Math.floor(BASE_RING_ITEM_COUNT * (1 + (ring - 1) * RING_INCREMENT));
const ringProgress = ring / RINGS;
const ringProgress = ring / ringsCount;
const ringRadius = CENTER_EMPTINESS + (MAX_RADIUS - CENTER_EMPTINESS) * ringProgress;
const angleShift = ring % 2 === 0 ? Math.PI / ringItemCount : 0;
for (let i = 0; i < ringItemCount; i++) {
@ -79,11 +85,9 @@ const RadialPatternBackground = ({
continue;
}
// Slightly oval
const xOffset = ringRadius * 1.71 * Math.cos(angle);
const xOffset = ringRadius * Math.cos(angle) * ovalFactor;
const yOffset = ringRadius * Math.sin(angle);
const sizeFactor = 1.4 - ringProgress * Math.random();
const sizeFactor = 1.65 - ringProgress + Math.random() / ringsCount;
coordinates.push({
x: xOffset,
@ -93,7 +97,7 @@ const RadialPatternBackground = ({
}
}
return coordinates;
}, [clearBottomSector]);
}, [clearBottomSector, ovalFactor, ringsCount]);
useResizeObserver(containerRef, (entry) => {
setContainerSize({
@ -119,59 +123,67 @@ const RadialPatternBackground = ({
const { width, height } = canvas;
if (!width || !height) return;
const centerX = width / 2;
const centerY = yPosition !== undefined ? yPosition * dpr : height / 2;
ctx.clearRect(0, 0, width, height);
ctx.save();
patternPositions.forEach(({
x, y, sizeFactor,
}) => {
const renderX = x * patternSize * Math.max(width, MIN_SIZE * dpr) + width / 2;
const renderY = y * patternSize * Math.max(height, MIN_SIZE * dpr) + height / 2;
const patternImage = drawPattern(
width, height, centerX, centerY, backgroundColors[1] ?? backgroundColors[0],
);
ctx.drawImage(patternImage, 0, 0, width, height);
const size = BASE_ICON_SIZE * dpr * patternSize * sizeFactor;
ctx.drawImage(emojiImage, renderX - size / 2, renderY - size / 2, size, size);
});
ctx.restore();
if (patternColor) {
ctx.save();
ctx.fillStyle = patternColor;
ctx.globalCompositeOperation = 'source-atop';
ctx.fillRect(0, 0, width, height);
ctx.restore();
}
const radialGradient = ctx.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, width / 2);
const alpha = clamp(0.6 * (patternOpacity ?? 1), 0, 1);
radialGradient.addColorStop(0, `rgb(255 255 255 / ${1 - alpha})`);
radialGradient.addColorStop(1, `rgb(255 255 255 / 1)`);
const radialGradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, width / 2);
radialGradient.addColorStop(0.75, 'rgb(255 255 255 / 0)');
radialGradient.addColorStop(1, 'rgb(255 255 255 / 0.75)');
// Alpha mask
ctx.save();
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = radialGradient;
ctx.fillRect(0, 0, width, height);
ctx.restore();
});
const drawPattern = useLastCallback((
width: number, height: number, centerX: number, centerY: number, color: string,
) => {
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext('2d')!;
ctx.globalAlpha = PATTERN_OPACITY;
patternPositions.forEach(({
x, y, sizeFactor,
}) => {
const renderX = x * Math.max(width, MIN_SIZE * dpr) + centerX;
const renderY = yPosition !== undefined ? y * Math.max(width, MIN_SIZE * dpr) + centerY
: y * Math.max(height, MIN_SIZE * dpr) + centerY;
const size = patternSize * dpr * sizeFactor;
ctx.drawImage(emojiImage!, renderX - size / 2, renderY - size / 2, size, size);
});
ctx.fillStyle = adjustBrightness(color, -0.075);
ctx.globalCompositeOperation = 'source-in';
ctx.fillRect(0, 0, width, height);
return canvas;
});
useEffect(() => {
draw();
}, [emojiImage, patternOpacity, patternSize, patternColor, patternPositions]);
}, [emojiImage, patternColor, patternPositions, yPosition]);
useEffect(() => {
useLayoutEffect(() => {
const { width, height } = getContainerSize();
const canvas = canvasRef.current;
if (!width || !height || !canvas) {
return;
}
const maxSide = Math.max(width, height);
requestMutation(() => {
canvas.width = maxSide * dpr;
canvas.height = maxSide * dpr;
canvas.width = width * dpr;
canvas.height = height * dpr;
draw();
});
@ -180,10 +192,10 @@ const RadialPatternBackground = ({
return (
<div
ref={containerRef}
className={buildClassName(styles.root, className)}
className={buildClassName(styles.root, withLinearGradient && styles.withLinearGradient, className)}
style={buildStyle(
`--_bg-1: ${backgroundColors[0]}`,
`--_bg-2: ${backgroundColors[1] || backgroundColors[0]}`,
...buildGradients(backgroundColors, withLinearGradient),
yPosition !== undefined && `--_y-shift: ${yPosition}px`,
)}
>
<canvas
@ -196,3 +208,36 @@ const RadialPatternBackground = ({
};
export default memo(RadialPatternBackground);
function buildGradients(colors: string[], withLinearGradient = false) {
if (withLinearGradient) {
return [
`--_bg-linear-1: ${colors[0]}`,
`--_bg-linear-2: ${colors[1]}`,
`--_bg-radial-1: #ffffff33`,
`--_bg-radial-2: #ffffff00`,
];
}
return [
`--_bg-radial-1: ${colors[1] ? colors[0] : adjustBrightness(colors[0], 0.2)}`,
`--_bg-radial-2: ${colors[1] ?? colors[0]}`,
];
}
function adjustBrightness(hex: string, delta: number) {
const factor = 1 + delta;
const rgba = hexToRgb(hex);
const darkenedRgba = [
Math.min(255, Math.round(rgba.r * factor)),
Math.min(Math.round(rgba.g * factor)),
Math.min(Math.round(rgba.b * factor)),
rgba.a ?? 1,
] as const;
return rgbaToHex(...darkenedRgba);
}
function rgbaToHex(r: number, g: number, b: number, a: number) {
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}${Math.round(a * 255).toString(16)}`;
}

View File

@ -15,6 +15,7 @@ import { IS_TOUCH_ENV } from '../../../../util/browser/windowEnvironment.ts';
import buildClassName from '../../../../util/buildClassName';
import buildStyle from '../../../../util/buildStyle';
import { getGiftAttributes, getStickerFromGift } from '../../../common/helpers/gifts';
import { REM } from '../../../common/helpers/mediaDimensions.ts';
import { renderPeerLink } from '../helpers/messageActions';
import useFlag from '../../../../hooks/useFlag.ts';
@ -98,6 +99,8 @@ const StarGiftAction = ({
backgroundColors={backgroundColors}
patternColor={backdrop.patternColor}
patternIcon={pattern.sticker}
patternSize={14}
yPosition={9.5 * REM}
clearBottomSector
/>
</div>