tilekit / src /layout.ts
osolmaz's picture
osolmaz HF Staff
feat: add size variation control
3a2fcd3 verified
Raw
History Blame Contribute Delete
5.17 kB
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);
}
// Morph one stable layout continuously instead of reseeding or switching
// between discrete placement modes as the spacing slider moves.
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;
}