| export interface LayoutOptions { |
| aspect: number; |
| count: number; |
| logoScale: number; |
| sizeVariation: number; |
| spacing: number; |
| rotation: number; |
| seed: number; |
| } |
|
|
| export const PLACEMENT_STRIDE = 4; |
|
|
| const SIZE_BANDS = [ |
| [0.12, 0.18, 0.259], |
| [0.42, 0.13, 0.19], |
| [0.84, 0.084, 0.141], |
| [1, 0.054, 0.09], |
| ] as const; |
|
|
| class Random { |
| private state: number; |
|
|
| constructor(seed: number) { |
| this.state = seed >>> 0 || 0x6d2b79f5; |
| } |
|
|
| next(): number { |
| let value = (this.state += 0x6d2b79f5); |
| value = Math.imul(value ^ (value >>> 15), value | 1); |
| value ^= value + Math.imul(value ^ (value >>> 7), value | 61); |
| return ((value ^ (value >>> 14)) >>> 0) / 4294967296; |
| } |
| } |
|
|
| function pickWidth(random: Random, scale: number, variation: number): number { |
| const roll = random.next(); |
| for (const [threshold, low, high] of SIZE_BANDS) { |
| if (roll <= threshold) { |
| const sampledWidth = low + random.next() * (high - low); |
| const uniformWidth = 0.115; |
| return Math.max(0.025, uniformWidth + (sampledWidth - uniformWidth) * variation) * scale; |
| } |
| } |
| return 0.07 * scale; |
| } |
|
|
| function cellIndex(value: number, cellSize: number, limit: number): number { |
| return Math.max(0, Math.min(limit - 1, Math.floor(value / cellSize))); |
| } |
|
|
| export function generateLayout(options: LayoutOptions): Float32Array { |
| const random = new Random(options.seed); |
| const count = Math.max(1, Math.floor(options.count)); |
| const width = Math.max(0.5, options.aspect); |
| const height = 1; |
| const widths = new Float32Array(count); |
| for (let index = 0; index < count; index += 1) { |
| widths[index] = pickWidth(random, options.logoScale, Math.max(0, options.sizeVariation)); |
| } |
| widths.sort(); |
| widths.reverse(); |
|
|
| const maxRadius = (widths[0] ?? 0.1) * 0.49; |
| const minimumGap = 0.0037; |
| const cellSize = Math.max(0.025, maxRadius * 1.7 + minimumGap); |
| const gridWidth = Math.max(1, Math.ceil(width / cellSize)); |
| const gridHeight = Math.max(1, Math.ceil(height / cellSize)); |
| const grid: number[][] = Array.from({ length: gridWidth * gridHeight }, () => []); |
| const output = new Float32Array(count * PLACEMENT_STRIDE); |
|
|
| const neighborScore = (x: number, y: number, radius: number): number => { |
| const reach = Math.ceil((radius + maxRadius + minimumGap) / cellSize); |
| const centerX = cellIndex(x, cellSize, gridWidth); |
| const centerY = cellIndex(y, cellSize, gridHeight); |
| let closest = Number.POSITIVE_INFINITY; |
| for (let gridY = Math.max(0, centerY - reach); gridY <= Math.min(gridHeight - 1, centerY + reach); gridY += 1) { |
| for (let gridX = Math.max(0, centerX - reach); gridX <= Math.min(gridWidth - 1, centerX + reach); gridX += 1) { |
| const bucket = grid[gridY * gridWidth + gridX]; |
| if (!bucket) continue; |
| for (const other of bucket) { |
| const offset = other * PLACEMENT_STRIDE; |
| const dx = x - (output[offset] ?? 0); |
| const dy = y - (output[offset + 1] ?? 0); |
| const otherRadius = (output[offset + 2] ?? 0) * 0.49; |
| closest = Math.min(closest, Math.hypot(dx, dy) - radius - otherRadius); |
| } |
| } |
| } |
| return closest; |
| }; |
|
|
| for (let index = 0; index < count; index += 1) { |
| const logoWidth = widths[index] ?? 0.08; |
| const radius = logoWidth * 0.49; |
| const bleed = radius * 0.34; |
| let bestX = width * 0.5; |
| let bestY = height * 0.5; |
| let bestScore = Number.NEGATIVE_INFINITY; |
| const attempts = index < 24 ? 42 : index < 200 ? 30 : 18; |
|
|
| for (let attempt = 0; attempt < attempts; attempt += 1) { |
| const x = -bleed + random.next() * (width + bleed * 2); |
| const y = -bleed + random.next() * (height + bleed * 2); |
| const gap = neighborScore(x, y, radius); |
| const edgeDistance = Math.min(x + radius, width - x + radius, y + radius, height - y + radius); |
| const score = gap + Math.min(0, edgeDistance) * 0.65; |
| if (score > bestScore) { |
| bestScore = score; |
| bestX = x; |
| bestY = y; |
| } |
| } |
|
|
| const triangular = random.next() + random.next() - 1; |
| const rawAngle = triangular * options.rotation; |
| const angle = rawAngle > 0 ? rawAngle * 1.35 : rawAngle * 0.72; |
| const offset = index * PLACEMENT_STRIDE; |
| output[offset] = bestX; |
| output[offset + 1] = bestY; |
| output[offset + 2] = logoWidth; |
| output[offset + 3] = angle; |
|
|
| const gridX = cellIndex(bestX, cellSize, gridWidth); |
| const gridY = cellIndex(bestY, cellSize, gridHeight); |
| grid[gridY * gridWidth + gridX]?.push(index); |
| } |
|
|
| |
| |
| const spacingOffset = options.spacing - 1; |
| const spread = 1 + spacingOffset * 0.055; |
| const logoSpacingScale = 1 / (1 + spacingOffset * 0.28); |
| for (let offset = 0; offset < output.length; offset += PLACEMENT_STRIDE) { |
| output[offset] = width * 0.5 + ((output[offset] ?? width * 0.5) - width * 0.5) * spread; |
| output[offset + 1] = 0.5 + ((output[offset + 1] ?? 0.5) - 0.5) * spread; |
| output[offset + 2] = (output[offset + 2] ?? 0.08) * logoSpacingScale; |
| } |
|
|
| return output; |
| } |
|
|