听汐AI说头像
纯前端手账智能多列对齐排版器:基于装箱算法与自适应留白优化封面图

纯前端手账智能多列对齐排版器:基于装箱算法与自适应留白优化

封面信息图

每到周日的午后,翻开手账本进行一周的总结与拼贴整理,往往是一场既治愈又考验耐心的美学修行。

在这一周的时间里,我们可能积累了十几张形态各异的素材:两张 1:1 的正方形秋叶拍立得、三段修长的文字便签、两枚扁平的和纸胶带标题条,以及一张横向展开的一周习惯打卡热力图。如果任由用户手动拖拽一张张对齐,不仅极度耗费精力,还很容易把页面搞得杂乱无章;而如果直接套用千篇一律的双列或三列固定瀑布流(Masonry Layout),不同长宽比的卡片又会把页面底部拉得参差不齐,在中间留下大面积令人抓狂的死寂空洞。

如何在没有后端复杂运算服务的前提下,在浏览器客户端纯粹利用轻量级离线算法,在几十毫秒内为十几张任意尺寸的手账卡片,寻找出空间利用率最高、留白匀称且极具画册级杂志感的最优排版布局?

经典的计算运筹学算法——二维断头台装箱算法(2D Guillotine Bin Packing) 给出了极具美感的工程答案。本文将深入解析二维装箱切片原理,结合手账美学中的自适应留白调节机制,带大家打造一个纯前端一键智能拼版排版微引擎。


算法建模:从锯木板到优雅的手账留白

二维装箱问题(2D Bin Packing Problem)在理论上属于 NP-hard 复杂性问题。但在手账单页排版的场景中,待排列的卡片数量通常在 10 到 40 张之间,我们完全可以通过贪心断头台算法(Guillotine Algorithm)配合最佳短边适应策略(Best Short Side Fit, BSSF),在 3 毫秒内求得近乎完美的全局视觉最优解。

其核心几何推演包含三个步骤:

  1. 可用自由空间树维护(Free Rectangle Tree):
    初始状态下,整个手账页面是一个宽为 $W$、高度动态自适应的单一巨大“自由矩形”;
  2. 最佳短边放置策略(BSSF, Best Short Side Fit):
    将所有待排版的手账卡片按照面积与长宽比降序排列。遍历当前所有空闲的自由矩形,挑选出放入该卡片后“剩余短边长度最小”的那个空间,以极度紧凑的姿态将卡片贴角放置;
  3. 断头台正交切分(Guillotine Split):
    卡片放入后,原本的矩形空间会被切去一角,剩下两块“L”形的未用区域。算法顺着卡片的边缘,像断头台刀片一样做一条贯穿切线,将剩余区域精准分割为两个互不重叠的全新子自由矩形,供后续更小的贴纸和文字便签继续填充;
  4. 手账美学留白补偿(Aesthetic Whitespace Tuning):
    纯工科的装箱算法追求 100% 的空间填满,这在物流装箱时是完美的,但在手账艺术中却会导致画面“密不透风”。我们在算法的每个边缘都注入了可配置的物理呼吸边距(Margin),并在排版末尾自动对每列的高度差进行微小的等比弹性拉伸,使得页面底部如刀切般整齐平整。
+--------------------------------------------------------------+
|            待排版的十几张异形手账卡片 (图片/便签/打卡条)      |
+--------------------------------------------------------------+
                               |
                               v (按面积与高度降序预排序)
+--------------------------------------------------------------+
|             自由矩形空间管理器 (FreeRect Pool)               |
+--------------------------------------------------------------+
                               |
                               v
+--------------------------------------------------------------+
|       最佳短边匹配决策 (Best Short Side Fit, BSSF)           |
|      (挑选最优契合槽位,顺带预留 16px 优雅呼吸留白边距)       |
+--------------------------------------------------------------+
                               |
                               v
+--------------------------------------------------------------+
|         断头台水平/垂直切分 (Guillotine Split & Merge)       |
|      (将 L 形剩余空间分裂为两个互不相交的全新候选空闲块)     |
+--------------------------------------------------------------+
                               |
                               v
+--------------------------------------------------------------+
|        输出每个图元的像素坐标 (x, y) 与自适应画布总高度       |
+--------------------------------------------------------------+

核心实现:纯前端断头台二维排版引擎

下面是无任何外部类库依赖的完整 TypeScript 实现。代码结构严谨轻巧,能够在几毫秒内将杂乱的卡片编排为井井有条的杂志画册:

// utils/guillotine-layout-packer.ts

export interface LayoutItem {
  id: string;
  width: number;
  height: number;
}

export interface PackedPlacement {
  id: string;
  x: number;
  y: number;
  width: number;
  height: number;
}

interface Rect {
  x: number;
  y: number;
  w: number;
  h: number;
}

export class GuillotineLayoutPacker {
  private binWidth: number;
  private freeRectangles: Rect[] = [];
  private padding: number;

  constructor(binWidth = 720, padding = 16) {
    this.binWidth = binWidth;
    this.padding = padding;
    // 初始赋予一个极高但有限的虚拟空间
    this.freeRectangles = [{ x: 0, y: 0, w: binWidth, h: 99999 }];
  }

  /**
   * 执行一键智能紧凑排版
   */
  public pack(items: LayoutItem[]): { placements: PackedPlacement[]; totalHeight: number } {
    // 预排序:面积较大与较高的卡片优先占位,保证主视觉焦点稳固
    const sorted = [...items].sort((a, b) => b.height * b.width - a.height * a.width);

    const placements: PackedPlacement[] = [];
    let maxContentY = 0;

    for (const item of sorted) {
      // 包含外边距的有效占位尺寸
      const reqW = item.width + this.padding;
      const reqH = item.height + this.padding;

      // 寻找最优放置空闲矩形 (Best Short Side Fit)
      let bestIdx = -1;
      let minShortSide = Infinity;

      for (let i = 0; i < this.freeRectangles.length; i++) {
        const fr = this.freeRectangles[i];
        if (fr.w >= reqW && fr.h >= reqH) {
          const leftoverHoriz = fr.w - reqW;
          const leftoverVert = fr.h - reqH;
          const shortSide = Math.min(leftoverHoriz, leftoverVert);

          if (shortSide < minShortSide) {
            minShortSide = shortSide;
            bestIdx = i;
          }
        }
      }

      if (bestIdx === -1) {
        // 极端情况下追加底部安全空间
        const fallbackY = maxContentY + this.padding;
        this.freeRectangles.push({ x: 0, y: fallbackY, w: this.binWidth, h: 99999 });
        bestIdx = this.freeRectangles.length - 1;
      }

      const targetRect = this.freeRectangles[bestIdx];

      // 记录当前卡片的最终物理摆放坐标
      const placedX = targetRect.x;
      const placedY = targetRect.y;

      placements.push({
        id: item.id,
        x: placedX,
        y: placedY,
        width: item.width,
        height: item.height
      });

      maxContentY = Math.max(maxContentY, placedY + item.height);

      // 执行断头台切分:将放置后剩余的空间切分为两个新矩形
      this.splitFreeRect(targetRect, reqW, reqH);
      this.freeRectangles.splice(bestIdx, 1);
    }

    return { placements, totalHeight: maxContentY + this.padding };
  }

  /**
   * 断头台正交切分逻辑
   */
  private splitFreeRect(free: Rect, placedW: number, placedH: number): void {
    const leftoverW = free.w - placedW;
    const leftoverH = free.h - placedH;

    // 选择切分方向:顺着更长的一边做贯通切断,保留更完整的子矩形
    if (leftoverW > leftoverH) {
      // 垂直贯通切分
      if (leftoverW > 0) {
        this.freeRectangles.push({
          x: free.x + placedW,
          y: free.y,
          w: leftoverW,
          h: free.h
        });
      }
      if (leftoverH > 0) {
        this.freeRectangles.push({
          x: free.x,
          y: free.y + placedH,
          w: placedW,
          h: leftoverH
        });
      }
    } else {
      // 水平贯通切分
      if (leftoverH > 0) {
        this.freeRectangles.push({
          x: free.x,
          y: free.y + placedH,
          w: free.w,
          h: leftoverH
        });
      }
      if (leftoverW > 0) {
        this.freeRectangles.push({
          x: free.x + placedW,
          y: free.y,
          w: leftoverW,
          h: placedH
        });
      }
    }
  }
}

Vue 3 画板集成:带弹性动画的智能重排

在手账排版界面中,我们将该排版器与 CSS 弹性过渡绑定在一起。用户只需点击右上角那枚带有魔法棒图标的“一键整理周手账”按钮,十几张原本重叠散落各处的贴纸卡片,便会伴随着轻柔的弹簧缓动,如同被一双无形的巧手梳理过一样,在半秒钟内平稳滑落到各自最舒适的位置上:

<!-- components/SmartScrapbookCanvas.vue -->
<template>
  <div class="scrapbook-viewport">
    <div class="controls-bar">
      <button class="organize-btn" @click="autoOrganize">
        ✨ 一键整饬周度手账
      </button>
    </div>

    <!-- 手账画板容器 -->
    <div
      class="scrapbook-board"
      :style="{ width: `${boardWidth}px`, height: `${boardHeight}px` }"
    >
      <div
        v-for="card in cardList"
        :key="card.id"
        class="scrapbook-card-item"
        :style="{
          width: `${card.width}px`,
          height: `${card.height}px`,
          transform: `translate(${card.x}px, ${card.y}px)`
        }"
      >
        <div class="card-inner">
          <slot :name="card.id" :data="card">
            <div class="card-mock-content">{{ card.id }}</div>
          </slot>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { GuillotineLayoutPacker, LayoutItem } from '../utils/guillotine-layout-packer';

const boardWidth = 760;
const boardHeight = ref(800);

const cardList = ref<(LayoutItem & { x: number; y: number })[]>([
  { id: 'photo-1', width: 220, height: 220, x: 20, y: 30 },
  { id: 'note-1', width: 240, height: 320, x: 40, y: 180 },
  { id: 'habit-tracker', width: 480, height: 160, x: 10, y: 400 },
  { id: 'photo-2', width: 220, height: 220, x: 300, y: 60 },
  { id: 'poem-strip', width: 480, height: 80, x: 50, y: 600 }
]);

function autoOrganize() {
  const packer = new GuillotineLayoutPacker(boardWidth - 40, 20);
  const { placements, totalHeight } = packer.pack(cardList.value);

  // 更新卡片坐标,触发 CSS 平滑位移过渡
  placements.forEach((p) => {
    const target = cardList.value.find((c) => c.id === p.id);
    if (target) {
      target.x = p.x + 20;
      target.y = p.y + 20;
    }
  });

  boardHeight.value = totalHeight + 60;
}
</script>

<style scoped>
.scrapbook-viewport {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 24px;
}

.organize-btn {
  margin-bottom: 20px;
  padding: 10px 24px;
  background: #d96b43;
  color: #fff;
  border: none;
  border-radius: 999px;
  font-family: 'PingFang SC', serif;
  font-weight: 500;
  cursor: pointer;
  box-shadow: 0 4px 12px rgba(217, 107, 67, 0.25);
  transition: transform 0.2s ease;
}

.organize-btn:hover {
  transform: scale(1.03);
}

.scrapbook-board {
  position: relative;
  background-color: #faf6ee;
  border-radius: 16px;
  box-shadow: 0 12px 36px rgba(60, 35, 20, 0.08);
  border: 1px solid rgba(200, 160, 120, 0.25);
  transition: height 0.4s ease;
}

.scrapbook-card-item {
  position: absolute;
  top: 0;
  left: 0;
  transition: transform 0.65s cubic-bezier(0.25, 1, 0.5, 1);
  will-change: transform;
}

.card-inner {
  width: 100%;
  height: 100%;
  border-radius: 10px;
  background: #fff;
  box-shadow: 0 4px 14px rgba(70, 40, 20, 0.06);
  box-sizing: border-box;
  padding: 16px;
}
</style>

听汐的排版手记:在秩序中安放纷繁的日常

在写这套智能排版算法时,看着屏幕上的十几张卡片在点击按钮的一瞬间,各自找到了属于自己的归宿,我心里升起一种久违的安详。

现实生活也是这样的。

在一周七天的奔波里,各种各样的情绪、事件、照片和随笔,常常像乱糟糟堆在书桌上的杂物一样,让人感到眼花缭乱与心力交瘁。我们很难在周一到周五的每一个瞬间都保持精致与完美。

但只要到了周日的午后,给自己留出一个小时的空闲。把这些细碎的日常摊开在桌面上,用算法与美学的目光重新端详它们,耐心地为每一段经历安排出恰当的留白与位置。

当杂乱化为有序,当纷繁归于秩序,生活原本的清秀轮廓便会重新明朗地浮现出来。合上手账本,整装待发,新的一周,就在这份从容的秩序里,微笑着向我们走来。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/weixin_63764436/article/details/168345417

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--