Source code for figrecipe._composition._auto_tile

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Auto-tiler: bin-pack panel ASPECT RATIOS into a tight, whitespace-free grid.

Where :func:`figrecipe._composition._tile.build_tiled_sources` places panels
whose row assignment (``layout``) is already known, :func:`auto_tile_layout`
answers the prior question -- GIVEN only each panel's true aspect ratio (no
source, no render), how many rows should there be and which labels go in
which row so the resulting canvas is as tight as possible (a target height)
or wastes as little area as possible (no target height)? Its output is a
``layout`` row-list in the EXACT format :func:`build_tiled_sources` consumes,
so the two functions compose directly:

    layout, sizes_mm, canvas_mm = auto_tile_layout(aspects, width_mm=180)
    # ... re-author/re-plot each panel at sizes_mm[label] ...
    sources_mm, canvas_mm = build_tiled_sources(layout, sources, width_mm=180)

This module never renders or resizes a panel itself -- it only proposes
geometry. Panels keep their true aspect ratio by construction (a row's
common height ``h_r`` derived once, each panel's width ``aspect_i * h_r`` --
see :func:`figrecipe._composition._tile._row_height_mm`), so "auto-tiling"
never means "stretch to fit": that is a hard invariant, not a best-effort one.
"""

from typing import Dict, List, Tuple

from ._tile import _row_height_mm, _row_panel_widths_mm

__all__ = ["auto_tile_layout"]


def _greedy_partition(
    labels_by_aspect_desc: List[str],
    aspects: Dict[str, float],
    n_rows: int,
) -> List[List[str]]:
    """Multi-way partition of labels into ``n_rows`` rows (LPT-greedy).

    Visits labels largest-aspect-first (Longest-Processing-Time-first, the
    standard greedy for multiway partition / multiprocessor scheduling) and
    drops each one into whichever row currently has the SMALLEST running
    aspect-sum. Balancing the aspect-sum (not the panel COUNT) keeps row
    heights close to parity -- see :func:`_row_height_mm`, where a row's
    height is inversely proportional to its aspect-sum -- so this avoids one
    very tall/skinny row sitting next to a very short/wide one.

    Ties (equal running sums) break by lowest row index, which also
    guarantees every one of the ``n_rows <= len(labels)`` rows receives at
    least one label (the first ``n_rows`` labels each land in a still-empty
    row before any row's sum grows past zero).
    """
    rows: List[List[str]] = [[] for _ in range(n_rows)]
    row_sums = [0.0] * n_rows
    for label in labels_by_aspect_desc:
        idx = min(range(n_rows), key=lambda i: (row_sums[i], i))
        rows[idx].append(label)
        row_sums[idx] += aspects[label]
    return rows


def _row_geometry(
    rows: List[List[str]],
    aspects: Dict[str, float],
    width_mm: float,
    gap_mm: float,
) -> Tuple[Dict[str, Tuple[float, float]], float]:
    """Apply the shared row-height formula to every row of a candidate layout.

    Returns ``(sizes_mm, total_height_mm)`` using the IDENTICAL
    ``_row_height_mm`` / ``_row_panel_widths_mm`` helpers that
    :func:`figrecipe._composition._tile.build_tiled_sources` uses, so a
    proposed layout's sizes never drift from what actually placing it would
    produce.
    """
    sizes_mm: Dict[str, Tuple[float, float]] = {}
    row_heights: List[float] = []
    for row in rows:
        row_aspects = [aspects[lab] for lab in row]
        h_r = _row_height_mm(row_aspects, width_mm, gap_mm)
        row_widths = _row_panel_widths_mm(row_aspects, h_r)
        for lab, w_i in zip(row, row_widths):
            sizes_mm[lab] = (w_i, h_r)
        row_heights.append(h_r)
    total_height = sum(row_heights) + (len(rows) - 1) * gap_mm
    return sizes_mm, total_height


[docs] def auto_tile_layout( aspects: Dict[str, float], width_mm: float, height_mm: float = None, gap_mm: float = 1.0, ) -> Tuple[List[List[str]], Dict[str, Tuple[float, float]], Tuple[float, float]]: """Bin-pack panel aspect ratios into a tight ``layout`` for ``build_tiled_sources``. Parameters ---------- aspects : dict ``{label: w/h}`` -- each panel's TRUE aspect ratio. Never a size to target; only a ratio the packer must preserve exactly. width_mm : float Overall content width ``W`` of the figure in mm (same role as ``build_tiled_sources``'s ``width_mm``). height_mm : float, optional Target overall canvas height in mm. When given, the packer searches row-counts and picks the one whose resulting total height is closest to ``height_mm`` without exceeding it (falling back to the closest overall if every candidate exceeds it). When omitted, the packer instead MINIMISES wasted canvas area (the ``width_mm x total_height`` bounding box minus the summed true panel areas) -- the canonical packing-efficiency objective. gap_mm : float Gutter between adjacent panels (both within a row and between rows), same convention as ``build_tiled_sources``. Returns ------- layout : list of list of str Row-list of labels, ready to hand to ``build_tiled_sources`` (or ``fr.compose``) verbatim. sizes_mm : dict ``{label: (width_mm, height_mm)}`` -- the target size each panel should be RE-AUTHORED/re-plotted at. Every panel's ``w/h`` here equals ``aspects[label]`` exactly (to floating-point precision): this function only ever proposes geometry, it never stretches or upscales a panel. canvas_size_mm : tuple ``(width_mm, total_height_mm)`` of the resulting canvas. Notes ----- For each candidate row-count ``n_rows`` (searched over ``1..len(aspects)``) labels are greedily partitioned by :func:`_greedy_partition` (largest aspect first, into whichever row has the smallest running aspect-sum -- this keeps row heights close to parity). Each row is then sized by the SAME formula ``build_tiled_sources`` uses (see ``figrecipe._composition._tile._row_height_mm``): row height ``h_r = (width_mm - (k-1) * gap_mm) / sum(row aspects)``, panel width ``w_i = aspect_i * h_r``. Row order (top-to-bottom) and each row's internal label order follow assignment order and are never reshuffled afterwards. """ if not aspects: raise ValueError("auto_tile_layout requires at least one panel in `aspects`.") labels = list(aspects.keys()) n = len(labels) labels_by_aspect_desc = sorted(labels, key=lambda lab: aspects[lab], reverse=True) candidates: List[Tuple[List[List[str]], Dict[str, Tuple[float, float]], float]] = [] for n_rows in range(1, n + 1): rows = _greedy_partition(labels_by_aspect_desc, aspects, n_rows) sizes_mm, total_height = _row_geometry(rows, aspects, width_mm, gap_mm) candidates.append((rows, sizes_mm, total_height)) if height_mm is not None: # Closest-without-exceeding when possible, else closest overall. fitting = [c for c in candidates if c[2] <= height_mm + 1e-9] best = ( max(fitting, key=lambda c: c[2]) if fitting else min(candidates, key=lambda c: abs(c[2] - height_mm)) ) else: def _wasted_area( candidate: Tuple[List[List[str]], Dict[str, Tuple[float, float]], float], ) -> float: _rows, sizes_mm, total_height = candidate occupied = sum(w * h for w, h in sizes_mm.values()) return width_mm * total_height - occupied best = min(candidates, key=_wasted_area) rows, sizes_mm, total_height = best return rows, sizes_mm, (float(width_mm), float(total_height))
# EOF