~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
221 lines · 6.7 KB Raw History
// ─── Collage Layout (ported from grain-next og-server/lib/collage.js) ────────

function hashCode(str: string): number {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    const char = str.charCodeAt(i);
    hash = ((hash << 5) - hash + char) | 0;
  }
  return Math.abs(hash);
}

function createSeededRandom(seed: number): () => number {
  let currentSeed = seed;
  return () => {
    currentSeed = (currentSeed * 9301 + 49297) % 233280;
    return currentSeed / 233280;
  };
}

export interface ImageItem {
  url: string;
  aspectRatio: number;
}

export interface Placement {
  item: ImageItem;
  x: number;
  y: number;
  width: number;
  height: number;
}

interface Rect {
  x: number;
  y: number;
  width: number;
  height: number;
}

export function calculateCollageLayout(
  imageData: ImageItem[],
  containerWidth: number,
  containerHeight: number,
  gap = 12,
): Placement[] {
  // A lone photo is not a collage. Scale it to fit the frame whole, centred, so
  // a landscape stays landscape and a portrait stays portrait.
  if (imageData.length === 1) {
    const item = imageData[0];
    const scale = Math.min(containerWidth / item.aspectRatio, containerHeight);
    const width = scale * item.aspectRatio;
    const height = scale;
    return [
      {
        item,
        x: Math.round((containerWidth - width) / 2),
        y: Math.round((containerHeight - height) / 2),
        width: Math.round(width),
        height: Math.round(height),
      },
    ];
  }

  const seedString = imageData
    .map((item) => `${item.url}_${item.aspectRatio.toFixed(3)}`)
    .join("|");
  const seed = hashCode(seedString);
  const random = createSeededRandom(seed);

  const placements: Placement[] = [];
  const occupiedSpace: Rect[] = [];

  const sortedImages = [...imageData].sort((a, b) => {
    const aScore = a.aspectRatio + (random() - 0.5) * 0.1;
    const bScore = b.aspectRatio + (random() - 0.5) * 0.1;
    return bScore - aScore;
  });

  const containerArea = containerWidth * containerHeight;
  const numImages = sortedImages.length;
  const targetCoverage = 0.75 + random() * 0.1;
  const targetTotalArea = containerArea * targetCoverage;
  const averageImageArea = targetTotalArea / numImages;

  const sizeCategories = [
    { min: 1.2, max: 1.8 },
    { min: 0.8, max: 1.2 },
    { min: 0.5, max: 0.8 },
  ];

  for (const item of sortedImages) {
    const categoryIndex = Math.floor(random() * sizeCategories.length);
    const category = sizeCategories[categoryIndex];
    const sizeMultiplier = category.min + random() * (category.max - category.min);

    const targetArea = averageImageArea * sizeMultiplier;
    const targetHeight = Math.sqrt(targetArea / item.aspectRatio);
    const targetWidth = targetHeight * item.aspectRatio;

    const minSize = Math.min(containerWidth, containerHeight) * 0.12;
    const maxSize = Math.min(containerWidth, containerHeight) * 0.75;

    let finalWidth = Math.max(minSize, Math.min(maxSize, targetWidth));
    let finalHeight = finalWidth / item.aspectRatio;

    if (finalHeight > maxSize) {
      finalHeight = maxSize;
      finalWidth = finalHeight * item.aspectRatio;
    }

    finalWidth = Math.min(finalWidth, containerWidth - gap);
    finalHeight = Math.min(finalHeight, containerHeight - gap);

    let placed = false;
    let attempts = 0;
    const maxAttempts = 200;

    while (!placed && attempts < maxAttempts) {
      let x: number, y: number;

      if (attempts < 50) {
        x = random() * (containerWidth - finalWidth - gap);
        y = random() * (containerHeight - finalHeight - gap);
      } else {
        const gridSize = Math.min(containerWidth, containerHeight) / 8;
        const gridX = Math.floor(
          random() * Math.ceil((containerWidth - finalWidth - gap) / gridSize),
        );
        const gridY = Math.floor(
          random() * Math.ceil((containerHeight - finalHeight - gap) / gridSize),
        );
        x = Math.min(gridX * gridSize, containerWidth - finalWidth - gap);
        y = Math.min(gridY * gridSize, containerHeight - finalHeight - gap);
      }

      const reservedRect: Rect = {
        x,
        y,
        width: finalWidth + gap,
        height: finalHeight + gap,
      };

      const overlaps = occupiedSpace.some((rect) => {
        return !(
          reservedRect.x >= rect.x + rect.width ||
          reservedRect.x + reservedRect.width <= rect.x ||
          reservedRect.y >= rect.y + rect.height ||
          reservedRect.y + reservedRect.height <= rect.y
        );
      });

      if (!overlaps) {
        placements.push({
          item,
          x: Math.round(x),
          y: Math.round(y),
          width: Math.round(finalWidth),
          height: Math.round(finalHeight),
        });
        occupiedSpace.push({
          x: Math.round(x),
          y: Math.round(y),
          width: Math.round(finalWidth) + gap,
          height: Math.round(finalHeight) + gap,
        });
        placed = true;
      }

      attempts++;
    }

    // Fallback: try smaller sizes
    if (!placed) {
      for (let sizeReduction = 0; sizeReduction <= 0.5; sizeReduction += 0.1) {
        if (placed) break;
        const reducedWidth = finalWidth * (1 - sizeReduction);
        const reducedHeight = finalHeight * (1 - sizeReduction);
        const minAcceptable = Math.min(containerWidth, containerHeight) * 0.08;
        if (reducedWidth < minAcceptable || reducedHeight < minAcceptable) continue;

        const gridSize = Math.max(10, Math.min(reducedWidth, reducedHeight) / 4);
        for (let gx = 0; gx <= containerWidth - reducedWidth - gap && !placed; gx += gridSize) {
          for (let gy = 0; gy <= containerHeight - reducedHeight - gap && !placed; gy += gridSize) {
            const testRect: Rect = {
              x: gx,
              y: gy,
              width: reducedWidth + gap,
              height: reducedHeight + gap,
            };
            const hasOverlap = occupiedSpace.some((rect) => {
              return !(
                testRect.x >= rect.x + rect.width ||
                testRect.x + testRect.width <= rect.x ||
                testRect.y >= rect.y + rect.height ||
                testRect.y + testRect.height <= rect.y
              );
            });
            if (!hasOverlap) {
              placements.push({
                item,
                x: Math.round(gx),
                y: Math.round(gy),
                width: Math.round(reducedWidth),
                height: Math.round(reducedHeight),
              });
              occupiedSpace.push({
                x: Math.round(gx),
                y: Math.round(gy),
                width: Math.round(reducedWidth) + gap,
                height: Math.round(reducedHeight) + gap,
              });
              placed = true;
            }
          }
        }
      }
    }
  }

  return placements;
}