// SPDX-License-Identifier: AGPL-3.0-or-later import fs from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; import {TYPING_BRIDGE_RIGHT_SHIFT_RATIO, TYPING_WIDTH_MULTIPLIER} from '../src/features/ui/constants/TypingConstants'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); type AvatarSize = 16 | 20 | 24 | 32 | 36 | 40 | 44 | 48 | 56 | 80 | 120; interface StatusConfig { statusSize: number; cutoutRadius: number; cutoutCenter: number; } const LARGE_STATUS_CUTOUT_GUTTER_RATIO = 0.2; const STATUS_CONFIG: Record = { 16: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 13}, 20: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 17}, 24: {statusSize: 10, cutoutRadius: 7, cutoutCenter: 20}, 32: {statusSize: 10, cutoutRadius: 8, cutoutCenter: 27}, 36: {statusSize: 10, cutoutRadius: 8, cutoutCenter: 30}, 40: {statusSize: 12, cutoutRadius: 9, cutoutCenter: 34}, 44: {statusSize: 14, cutoutRadius: 10, cutoutCenter: 38}, 48: {statusSize: 14, cutoutRadius: 10, cutoutCenter: 42}, 56: {statusSize: 16, cutoutRadius: 11, cutoutCenter: 49}, 80: {statusSize: 16, cutoutRadius: 16 / 2 + 16 * LARGE_STATUS_CUTOUT_GUTTER_RATIO, cutoutCenter: 68}, 120: {statusSize: 24, cutoutRadius: 24 / 2 + 24 * LARGE_STATUS_CUTOUT_GUTTER_RATIO, cutoutCenter: 100}, }; const DESIGN_RULES = { mobileAspectRatio: 0.75, mobileCornerRadius: 0.12, mobileScreenWidth: 0.72, mobileScreenHeight: 0.7, mobileScreenY: 0.06, mobileWheelRadius: 0.13, mobileWheelY: 0.83, mobilePhoneExtraHeight: 2, mobileDisplayExtraHeight: 2, mobileDisplayExtraWidthPerSide: 2, idle: { cutoutRadiusRatio: 0.7, cutoutOffsetRatio: 0.35, }, dnd: { barWidthRatio: 1.3, barHeightRatio: 0.4, minBarHeight: 2, }, offline: { innerRingRatio: 0.6, }, } as const; const MOBILE_SCREEN_WIDTH_TRIM_PX = 4; const MOBILE_SCREEN_HEIGHT_TRIM_PX = 2; const MOBILE_SCREEN_X_OFFSET_PX = 0; const MOBILE_SCREEN_Y_OFFSET_PX = 3; function getStatusConfig(avatarSize: number): StatusConfig { if (STATUS_CONFIG[avatarSize]) { return STATUS_CONFIG[avatarSize]; } const sizes = Object.keys(STATUS_CONFIG) .map(Number) .sort((a, b) => a - b); const firstSize = sizes[0]; const lastSize = sizes[sizes.length - 1]; if (avatarSize <= firstSize) { return scaleStatusConfig(STATUS_CONFIG[firstSize], avatarSize / firstSize); } if (avatarSize >= lastSize) { return scaleStatusConfig(STATUS_CONFIG[lastSize], avatarSize / lastSize); } const upperSize = sizes.find((size) => size > avatarSize) ?? lastSize; const lowerSize = sizes[sizes.indexOf(upperSize) - 1] ?? firstSize; const progress = (avatarSize - lowerSize) / (upperSize - lowerSize); return interpolateStatusConfig(STATUS_CONFIG[lowerSize], STATUS_CONFIG[upperSize], progress); } function interpolateStatusConfig(from: StatusConfig, to: StatusConfig, progress: number): StatusConfig { return { statusSize: interpolate(from.statusSize, to.statusSize, progress), cutoutRadius: interpolate(from.cutoutRadius, to.cutoutRadius, progress), cutoutCenter: interpolate(from.cutoutCenter, to.cutoutCenter, progress), }; } function scaleStatusConfig(config: StatusConfig, scale: number): StatusConfig { return { statusSize: config.statusSize * scale, cutoutRadius: config.cutoutRadius * scale, cutoutCenter: config.cutoutCenter * scale, }; } function interpolate(from: number, to: number, progress: number): number { return from + (to - from) * progress; } function formatNumber(value: number): string { return Number(value.toFixed(6)).toString(); } interface StatusGeometry { size: number; cx: number; cy: number; innerRadius: number; outerRadius: number; borderWidth: number; } interface MobileStatusGeometry extends StatusGeometry { phoneWidth: number; phoneHeight: number; phoneX: number; phoneY: number; phoneRx: number; bezelHeight: number; } function calculateStatusGeometry(avatarSize: number, isMobile: boolean = false): StatusGeometry | MobileStatusGeometry { const config = getStatusConfig(avatarSize); const statusSize = config.statusSize; const cutoutCenter = config.cutoutCenter; const cutoutRadius = config.cutoutRadius; const innerRadius = statusSize / 2; const outerRadius = cutoutRadius; const borderWidth = cutoutRadius - innerRadius; const baseGeometry = { size: statusSize, cx: cutoutCenter, cy: cutoutCenter, innerRadius, outerRadius, borderWidth, }; if (!isMobile) { return baseGeometry; } const phoneWidth = statusSize; const phoneHeight = Math.round(phoneWidth / DESIGN_RULES.mobileAspectRatio) + DESIGN_RULES.mobilePhoneExtraHeight; const phoneRx = Math.round(phoneWidth * DESIGN_RULES.mobileCornerRadius); const bezelHeight = Math.max(1, Math.round(phoneHeight * 0.05)); const phoneX = cutoutCenter - phoneWidth / 2; const phoneY = cutoutCenter - phoneHeight / 2; return { ...baseGeometry, phoneWidth, phoneHeight, phoneX, phoneY, phoneRx, bezelHeight, }; } function generateAvatarMaskDefault(size: number): string { const r = size / 2; return ``; } function generateAvatarMaskStatusRound(size: number): string { const r = size / 2; const status = calculateStatusGeometry(size); return `( <> )`; } function generateAvatarMaskStatusTyping(size: number): string { const r = size / 2; const status = calculateStatusGeometry(size); const typingWidth = Math.round(status.size * TYPING_WIDTH_MULTIPLIER); const typingHeight = status.size; const typingRx = status.outerRadius; const typingExtension = Math.max(0, typingWidth - status.size); const typingBridgeShift = typingExtension * TYPING_BRIDGE_RIGHT_SHIFT_RATIO; const x = status.cx - typingWidth / 2 + typingBridgeShift; const y = status.cy - typingHeight / 2; return `( <> )`; } function generateMobilePhoneMask(mobileStatus: MobileStatusGeometry): string { const displayExtraHeight = DESIGN_RULES.mobileDisplayExtraHeight; const displayExtraWidthPerSide = DESIGN_RULES.mobileDisplayExtraWidthPerSide; const screenWidth = mobileStatus.phoneWidth * DESIGN_RULES.mobileScreenWidth + displayExtraWidthPerSide * 2 - MOBILE_SCREEN_WIDTH_TRIM_PX; const screenHeight = mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenHeight + displayExtraHeight - MOBILE_SCREEN_HEIGHT_TRIM_PX; const screenX = mobileStatus.phoneX + (mobileStatus.phoneWidth - screenWidth) / 2 + MOBILE_SCREEN_X_OFFSET_PX; const screenY = mobileStatus.phoneY + mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenY - displayExtraHeight / 2 + MOBILE_SCREEN_Y_OFFSET_PX; const screenRx = Math.min(screenWidth, screenHeight) * 0.1; const wheelRadius = mobileStatus.phoneWidth * DESIGN_RULES.mobileWheelRadius; const wheelCx = mobileStatus.phoneX + mobileStatus.phoneWidth / 2; const wheelCy = mobileStatus.phoneY + mobileStatus.phoneHeight * DESIGN_RULES.mobileWheelY; return `( <> )`; } function generateStatusOnline(size: number, isMobile: boolean = false): string { const status = calculateStatusGeometry(size, isMobile); if (!isMobile) { return ``; } return generateMobilePhoneMask(status as MobileStatusGeometry); } function generateStatusIdle(size: number, isMobile: boolean = false): string { const status = calculateStatusGeometry(size, isMobile); if (!isMobile) { const cutoutRadius = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutRadiusRatio); const cutoutOffsetDistance = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutOffsetRatio); const cutoutCx = status.cx - cutoutOffsetDistance; const cutoutCy = status.cy - cutoutOffsetDistance; return `( <> )`; } return generateMobilePhoneMask(status as MobileStatusGeometry); } function generateStatusDnd(size: number, isMobile: boolean = false): string { const status = calculateStatusGeometry(size, isMobile); if (!isMobile) { const barWidth = Math.round(status.outerRadius * DESIGN_RULES.dnd.barWidthRatio); const rawBarHeight = status.outerRadius * DESIGN_RULES.dnd.barHeightRatio; const barHeight = Math.max(DESIGN_RULES.dnd.minBarHeight, Math.round(rawBarHeight)); const barX = status.cx - barWidth / 2; const barY = status.cy - barHeight / 2; const barRx = barHeight / 2; return `( <> )`; } return generateMobilePhoneMask(status as MobileStatusGeometry); } function generateStatusOffline(size: number): string { const status = calculateStatusGeometry(size); const innerRadius = Math.round(status.innerRadius * DESIGN_RULES.offline.innerRingRatio); return `( <> )`; } function generateStatusTyping(size: number): string { const status = calculateStatusGeometry(size); const typingWidth = Math.round(status.size * TYPING_WIDTH_MULTIPLIER); const typingHeight = status.size; const rx = status.outerRadius; const typingExtension = Math.max(0, typingWidth - status.size); const typingBridgeShift = typingExtension * TYPING_BRIDGE_RIGHT_SHIFT_RATIO; const x = status.cx - typingWidth / 2 + typingBridgeShift; const y = status.cy - typingHeight / 2; return ``; } const SIZES: Array = [16, 20, 24, 32, 36, 40, 44, 48, 56, 80, 120]; const GENERATED_HEADER = '// SPDX-License-Identifier: AGPL-3.0-or-later\n\n'; let output = `${GENERATED_HEADER}type AvatarSize = ${SIZES.join(' | ')}; interface MaskDefinition { viewBox: string; content: React.ReactElement; } interface MaskSet { avatarDefault: MaskDefinition; avatarStatusRound: MaskDefinition; avatarStatusTyping: MaskDefinition; statusOnline: MaskDefinition; statusOnlineMobile: MaskDefinition; statusIdle: MaskDefinition; statusIdleMobile: MaskDefinition; statusDnd: MaskDefinition; statusDndMobile: MaskDefinition; statusOffline: MaskDefinition; statusTyping: MaskDefinition; } export const AVATAR_MASKS: Record = { `; for (const size of SIZES) { output += ` ${size}: { avatarDefault: { viewBox: '0 0 ${size} ${size}', content: ${generateAvatarMaskDefault(size)}, }, avatarStatusRound: { viewBox: '0 0 ${size} ${size}', content: ${generateAvatarMaskStatusRound(size)}, }, avatarStatusTyping: { viewBox: '0 0 ${size} ${size}', content: ${generateAvatarMaskStatusTyping(size)}, }, statusOnline: { viewBox: '0 0 ${size} ${size}', content: ${generateStatusOnline(size, false)}, }, statusOnlineMobile: { viewBox: '0 0 ${size} ${size}', content: ${generateStatusOnline(size, true)}, }, statusIdle: { viewBox: '0 0 ${size} ${size}', content: ${generateStatusIdle(size, false)}, }, statusIdleMobile: { viewBox: '0 0 ${size} ${size}', content: ${generateStatusIdle(size, true)}, }, statusDnd: { viewBox: '0 0 ${size} ${size}', content: ${generateStatusDnd(size, false)}, }, statusDndMobile: { viewBox: '0 0 ${size} ${size}', content: ${generateStatusDnd(size, true)}, }, statusOffline: { viewBox: '0 0 ${size} ${size}', content: ${generateStatusOffline(size)}, }, statusTyping: { viewBox: '0 0 ${size} ${size}', content: ${generateStatusTyping(size)}, }, }, `; } output += `} as const; export const SVGMasks = () => ( `; for (const size of SIZES) { const status = calculateStatusGeometry(size, false); const mobileStatus = calculateStatusGeometry(size, true) as MobileStatusGeometry; const cx = status.cx / size; const cy = status.cy / size; const r = status.outerRadius / size; const idleCutoutR = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutRadiusRatio) / size; const idleCutoutOffset = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutOffsetRatio) / size; const idleCutoutCx = cx - idleCutoutOffset; const idleCutoutCy = cy - idleCutoutOffset; const dndBarWidth = Math.round(status.outerRadius * DESIGN_RULES.dnd.barWidthRatio) / size; const dndBarHeight = Math.max(DESIGN_RULES.dnd.minBarHeight, Math.round(status.outerRadius * DESIGN_RULES.dnd.barHeightRatio)) / size; const dndBarX = cx - dndBarWidth / 2; const dndBarY = cy - dndBarHeight / 2; const dndBarRx = dndBarHeight / 2; const offlineInnerR = Math.round(status.innerRadius * DESIGN_RULES.offline.innerRingRatio) / size; const typingWidthPx = Math.round(status.size * TYPING_WIDTH_MULTIPLIER); const typingExtensionPx = Math.max(0, typingWidthPx - status.size); const typingBridgeShift = (typingExtensionPx * TYPING_BRIDGE_RIGHT_SHIFT_RATIO) / size; const typingWidth = typingWidthPx / size; const typingHeight = status.size / size; const typingX = cx - typingWidth / 2 + typingBridgeShift; const typingY = cy - typingHeight / 2; const typingRx = status.outerRadius / size; const cutoutPhoneWidth = (mobileStatus.phoneWidth + mobileStatus.borderWidth * 2) / size; const cutoutPhoneHeight = (mobileStatus.phoneHeight + mobileStatus.borderWidth * 2) / size; const cutoutPhoneX = (mobileStatus.phoneX - mobileStatus.borderWidth) / size; const cutoutPhoneY = (mobileStatus.phoneY - mobileStatus.borderWidth) / size; const cutoutPhoneRx = (mobileStatus.phoneRx + mobileStatus.borderWidth) / size; const displayExtraHeight = DESIGN_RULES.mobileDisplayExtraHeight; const displayExtraWidthPerSide = DESIGN_RULES.mobileDisplayExtraWidthPerSide; const screenWidthPx = mobileStatus.phoneWidth * DESIGN_RULES.mobileScreenWidth + displayExtraWidthPerSide * 2 - MOBILE_SCREEN_WIDTH_TRIM_PX; const screenHeightPx = mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenHeight + displayExtraHeight - MOBILE_SCREEN_HEIGHT_TRIM_PX; const screenXpx = mobileStatus.phoneX + (mobileStatus.phoneWidth - screenWidthPx) / 2 + MOBILE_SCREEN_X_OFFSET_PX; const screenYpx = mobileStatus.phoneY + mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenY - displayExtraHeight / 2 + MOBILE_SCREEN_Y_OFFSET_PX; const screenRxPx = Math.min(screenWidthPx, screenHeightPx) * 0.1; const mobileScreenX = ((screenXpx - mobileStatus.phoneX) / mobileStatus.phoneWidth).toFixed(4); const mobileScreenY = ((screenYpx - mobileStatus.phoneY) / mobileStatus.phoneHeight).toFixed(4); const mobileScreenWidth = (screenWidthPx / mobileStatus.phoneWidth).toFixed(4); const mobileScreenHeight = ((screenHeightPx / mobileStatus.phoneHeight) * DESIGN_RULES.mobileAspectRatio).toFixed(4); const mobileScreenRx = (screenRxPx / mobileStatus.phoneWidth).toFixed(4); const mobileScreenRy = ((screenRxPx / mobileStatus.phoneWidth) * DESIGN_RULES.mobileAspectRatio).toFixed(4); output += ` `; } output += ` ); `; const outputPath = path.join(__dirname, '../src/features/ui/components/SVGMasks.tsx'); fs.writeFileSync(outputPath, output); console.log(`Generated ${outputPath}`); const layoutOutput = `${GENERATED_HEADER}export interface StatusGeometry { size: number; cx: number; cy: number; radius: number; borderWidth: number; isMobile?: boolean; phoneWidth?: number; phoneHeight?: number; } const STATUS_GEOMETRY: Record = { ${SIZES.map((size) => { const geom = calculateStatusGeometry(size, false); return ` ${size}: {size: ${formatNumber(geom.size)}, cx: ${formatNumber(geom.cx)}, cy: ${formatNumber(geom.cy)}, radius: ${formatNumber(geom.outerRadius)}, borderWidth: ${formatNumber(geom.borderWidth)}, isMobile: false}`; }).join(',\n')}, }; const STATUS_GEOMETRY_MOBILE: Record = { ${SIZES.map((size) => { const geom = calculateStatusGeometry(size, true) as MobileStatusGeometry; return ` ${size}: {size: ${formatNumber(geom.size)}, cx: ${formatNumber(geom.cx)}, cy: ${formatNumber(geom.cy)}, radius: ${formatNumber(geom.outerRadius)}, borderWidth: ${formatNumber(geom.borderWidth)}, isMobile: true, phoneWidth: ${formatNumber(geom.phoneWidth)}, phoneHeight: ${formatNumber(geom.phoneHeight)}}`; }).join(',\n')}, }; export function getStatusGeometry(avatarSize: number, isMobile: boolean = false): StatusGeometry { const map = isMobile ? STATUS_GEOMETRY_MOBILE : STATUS_GEOMETRY; if (map[avatarSize]) { return map[avatarSize]; } const sizes = Object.keys(map) .map(Number) .sort((a, b) => a - b); const firstSize = sizes[0]; const lastSize = sizes[sizes.length - 1]; if (avatarSize <= firstSize) { return scaleGeometry(map[firstSize], avatarSize / firstSize, isMobile); } if (avatarSize >= lastSize) { return scaleGeometry(map[lastSize], avatarSize / lastSize, isMobile); } const upperSize = sizes.find((size) => size > avatarSize) ?? lastSize; const lowerSize = sizes[sizes.indexOf(upperSize) - 1] ?? firstSize; const progress = (avatarSize - lowerSize) / (upperSize - lowerSize); return interpolateGeometry(map[lowerSize], map[upperSize], progress, isMobile); } function interpolateGeometry(from: StatusGeometry, to: StatusGeometry, progress: number, isMobile: boolean): StatusGeometry { return { size: interpolate(from.size, to.size, progress), cx: interpolate(from.cx, to.cx, progress), cy: interpolate(from.cy, to.cy, progress), radius: interpolate(from.radius, to.radius, progress), borderWidth: interpolate(from.borderWidth, to.borderWidth, progress), isMobile, phoneWidth: from.phoneWidth != null && to.phoneWidth != null ? interpolate(from.phoneWidth, to.phoneWidth, progress) : undefined, phoneHeight: from.phoneHeight != null && to.phoneHeight != null ? interpolate(from.phoneHeight, to.phoneHeight, progress) : undefined, }; } function scaleGeometry(geometry: StatusGeometry, scale: number, isMobile: boolean): StatusGeometry { return { size: geometry.size * scale, cx: geometry.cx * scale, cy: geometry.cy * scale, radius: geometry.radius * scale, borderWidth: geometry.borderWidth * scale, isMobile, phoneWidth: geometry.phoneWidth == null ? undefined : geometry.phoneWidth * scale, phoneHeight: geometry.phoneHeight == null ? undefined : geometry.phoneHeight * scale, }; } function interpolate(from: number, to: number, progress: number): number { return from + (to - from) * progress; } `; const layoutPath = path.join(__dirname, '../src/features/ui/constants/AvatarStatusGeometry.ts'); fs.writeFileSync(layoutPath, layoutOutput);