
每到周日的午后,翻开手账本进行一周的总结与拼贴整理,往往是一场既治愈又考验耐心的美学修行。
在这一周的时间里,我们可能积累了十几张形态各异的素材:两张 1:1 的正方形秋叶拍立得、三段修长的文字便签、两枚扁平的和纸胶带标题条,以及一张横向展开的一周习惯打卡热力图。如果任由用户手动拖拽一张张对齐,不仅极度耗费精力,还很容易把页面搞得杂乱无章;而如果直接套用千篇一律的双列或三列固定瀑布流(Masonry Layout),不同长宽比的卡片又会把页面底部拉得参差不齐,在中间留下大面积令人抓狂的死寂空洞。
如何在没有后端复杂运算服务的前提下,在浏览器客户端纯粹利用轻量级离线算法,在几十毫秒内为十几张任意尺寸的手账卡片,寻找出空间利用率最高、留白匀称且极具画册级杂志感的最优排版布局?
经典的计算运筹学算法——二维断头台装箱算法(2D Guillotine Bin Packing) 给出了极具美感的工程答案。本文将深入解析二维装箱切片原理,结合手账美学中的自适应留白调节机制,带大家打造一个纯前端一键智能拼版排版微引擎。
算法建模:从锯木板到优雅的手账留白
二维装箱问题(2D Bin Packing Problem)在理论上属于 NP-hard 复杂性问题。但在手账单页排版的场景中,待排列的卡片数量通常在 10 到 40 张之间,我们完全可以通过贪心断头台算法(Guillotine Algorithm)配合最佳短边适应策略(Best Short Side Fit, BSSF),在 3 毫秒内求得近乎完美的全局视觉最优解。
其核心几何推演包含三个步骤:
- 可用自由空间树维护(Free Rectangle Tree):
初始状态下,整个手账页面是一个宽为 $W$、高度动态自适应的单一巨大“自由矩形”; - 最佳短边放置策略(BSSF, Best Short Side Fit):
将所有待排版的手账卡片按照面积与长宽比降序排列。遍历当前所有空闲的自由矩形,挑选出放入该卡片后“剩余短边长度最小”的那个空间,以极度紧凑的姿态将卡片贴角放置; - 断头台正交切分(Guillotine Split):
卡片放入后,原本的矩形空间会被切去一角,剩下两块“L”形的未用区域。算法顺着卡片的边缘,像断头台刀片一样做一条贯穿切线,将剩余区域精准分割为两个互不重叠的全新子自由矩形,供后续更小的贴纸和文字便签继续填充; - 手账美学留白补偿(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




