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} backgroundColors={backdropColors}
patternColor={patternColor} patternColor={patternColor}
patternIcon={pattern.sticker} patternIcon={pattern.sticker}
patternSize={14}
ringsCount={1}
ovalFactor={1}
/> />
); );
}, [backdrop, pattern]); }, [backdrop, pattern]);

View File

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

View File

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

View File

@ -1,4 +1,6 @@
.root { .root {
--_y-shift: 50%;
overflow: hidden; overflow: hidden;
border-radius: inherit; border-radius: inherit;
@ -9,9 +11,15 @@
position: absolute; position: absolute;
inset: 0; inset: 0;
background-image: background-image: radial-gradient(closest-side at 50% var(--_y-shift), var(--_bg-radial-1) 25%, var(--_bg-radial-2) 125%);
radial-gradient(circle closest-side, #ffffff32 3rem, #ffffff00 7rem), }
radial-gradient(closest-side, var(--_bg-1), var(--_bg-2));
&.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 { import { memo, useEffect, useLayoutEffect, useMemo, useRef, useSignal, useState } from '../../../lib/teact/teact';
memo, useEffect, useMemo, useRef, useSignal, useState,
} from '../../../lib/teact/teact';
import type { ApiSticker } from '../../../api/types'; import type { ApiSticker } from '../../../api/types';
@ -9,7 +7,8 @@ import { getStickerMediaHash } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import buildStyle from '../../../util/buildStyle'; import buildStyle from '../../../util/buildStyle';
import { preloadImage } from '../../../util/files'; 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 useLastCallback from '../../../hooks/useLastCallback';
import useMedia from '../../../hooks/useMedia'; import useMedia from '../../../hooks/useMedia';
@ -20,31 +19,39 @@ import styles from './RadialPatternBackground.module.scss';
type OwnProps = { type OwnProps = {
backgroundColors: string[]; backgroundColors: string[];
patternColor?: string;
patternIcon?: ApiSticker; patternIcon?: ApiSticker;
patternColor?: string;
patternSize?: number;
ringsCount?: number;
ovalFactor?: number;
withLinearGradient?: boolean;
className?: string; className?: string;
clearBottomSector?: boolean; clearBottomSector?: boolean;
patternSize?: number; yPosition?: number;
patternOpacity?: number;
}; };
const RINGS = 3;
const BASE_RING_ITEM_COUNT = 8; const BASE_RING_ITEM_COUNT = 8;
const RING_INCREMENT = 0.5; const RING_INCREMENT = 0.5;
const CENTER_EMPTINESS = 0.05; const CENTER_EMPTINESS = 0.1;
const MAX_RADIUS = 0.4; const MAX_RADIUS = 0.43;
const BASE_ICON_SIZE = 20; 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 = ({ const RadialPatternBackground = ({
backgroundColors, backgroundColors,
patternColor,
patternIcon, patternIcon,
patternOpacity, patternColor,
patternSize = DEFAULT_PATTERN_SIZE,
ringsCount = DEFAULT_RINGS_COUNT,
ovalFactor = DEFAULT_OVAL_FACTOR,
withLinearGradient,
clearBottomSector, clearBottomSector,
className, className,
patternSize = 1, yPosition,
}: OwnProps) => { }: OwnProps) => {
const containerRef = useRef<HTMLDivElement>(); const containerRef = useRef<HTMLDivElement>();
const canvasRef = useRef<HTMLCanvasElement>(); const canvasRef = useRef<HTMLCanvasElement>();
@ -65,11 +72,10 @@ const RadialPatternBackground = ({
const patternPositions = useMemo(() => { const patternPositions = useMemo(() => {
const coordinates: { x: number; y: number; sizeFactor: number }[] = []; 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 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 ringRadius = CENTER_EMPTINESS + (MAX_RADIUS - CENTER_EMPTINESS) * ringProgress;
const angleShift = ring % 2 === 0 ? Math.PI / ringItemCount : 0; const angleShift = ring % 2 === 0 ? Math.PI / ringItemCount : 0;
for (let i = 0; i < ringItemCount; i++) { for (let i = 0; i < ringItemCount; i++) {
@ -79,11 +85,9 @@ const RadialPatternBackground = ({
continue; continue;
} }
// Slightly oval const xOffset = ringRadius * Math.cos(angle) * ovalFactor;
const xOffset = ringRadius * 1.71 * Math.cos(angle);
const yOffset = ringRadius * Math.sin(angle); const yOffset = ringRadius * Math.sin(angle);
const sizeFactor = 1.65 - ringProgress + Math.random() / ringsCount;
const sizeFactor = 1.4 - ringProgress * Math.random();
coordinates.push({ coordinates.push({
x: xOffset, x: xOffset,
@ -93,7 +97,7 @@ const RadialPatternBackground = ({
} }
} }
return coordinates; return coordinates;
}, [clearBottomSector]); }, [clearBottomSector, ovalFactor, ringsCount]);
useResizeObserver(containerRef, (entry) => { useResizeObserver(containerRef, (entry) => {
setContainerSize({ setContainerSize({
@ -119,59 +123,67 @@ const RadialPatternBackground = ({
const { width, height } = canvas; const { width, height } = canvas;
if (!width || !height) return; if (!width || !height) return;
const centerX = width / 2;
const centerY = yPosition !== undefined ? yPosition * dpr : height / 2;
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
ctx.save(); const patternImage = drawPattern(
patternPositions.forEach(({ width, height, centerX, centerY, backgroundColors[1] ?? backgroundColors[0],
x, y, sizeFactor, );
}) => { ctx.drawImage(patternImage, 0, 0, width, height);
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 size = BASE_ICON_SIZE * dpr * patternSize * sizeFactor; const radialGradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, width / 2);
radialGradient.addColorStop(0.75, 'rgb(255 255 255 / 0)');
ctx.drawImage(emojiImage, renderX - size / 2, renderY - size / 2, size, size); radialGradient.addColorStop(1, 'rgb(255 255 255 / 0.75)');
});
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)`);
// Alpha mask // Alpha mask
ctx.save();
ctx.globalCompositeOperation = 'destination-out'; ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = radialGradient; ctx.fillStyle = radialGradient;
ctx.fillRect(0, 0, width, height); 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(() => { useEffect(() => {
draw(); draw();
}, [emojiImage, patternOpacity, patternSize, patternColor, patternPositions]); }, [emojiImage, patternColor, patternPositions, yPosition]);
useEffect(() => { useLayoutEffect(() => {
const { width, height } = getContainerSize(); const { width, height } = getContainerSize();
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!width || !height || !canvas) { if (!width || !height || !canvas) {
return; return;
} }
const maxSide = Math.max(width, height);
requestMutation(() => { requestMutation(() => {
canvas.width = maxSide * dpr; canvas.width = width * dpr;
canvas.height = maxSide * dpr; canvas.height = height * dpr;
draw(); draw();
}); });
@ -180,10 +192,10 @@ const RadialPatternBackground = ({
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className={buildClassName(styles.root, className)} className={buildClassName(styles.root, withLinearGradient && styles.withLinearGradient, className)}
style={buildStyle( style={buildStyle(
`--_bg-1: ${backgroundColors[0]}`, ...buildGradients(backgroundColors, withLinearGradient),
`--_bg-2: ${backgroundColors[1] || backgroundColors[0]}`, yPosition !== undefined && `--_y-shift: ${yPosition}px`,
)} )}
> >
<canvas <canvas
@ -196,3 +208,36 @@ const RadialPatternBackground = ({
}; };
export default memo(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 buildClassName from '../../../../util/buildClassName';
import buildStyle from '../../../../util/buildStyle'; import buildStyle from '../../../../util/buildStyle';
import { getGiftAttributes, getStickerFromGift } from '../../../common/helpers/gifts'; import { getGiftAttributes, getStickerFromGift } from '../../../common/helpers/gifts';
import { REM } from '../../../common/helpers/mediaDimensions.ts';
import { renderPeerLink } from '../helpers/messageActions'; import { renderPeerLink } from '../helpers/messageActions';
import useFlag from '../../../../hooks/useFlag.ts'; import useFlag from '../../../../hooks/useFlag.ts';
@ -98,6 +99,8 @@ const StarGiftAction = ({
backgroundColors={backgroundColors} backgroundColors={backgroundColors}
patternColor={backdrop.patternColor} patternColor={backdrop.patternColor}
patternIcon={pattern.sticker} patternIcon={pattern.sticker}
patternSize={14}
yPosition={9.5 * REM}
clearBottomSector clearBottomSector
/> />
</div> </div>