1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
|
/*!
* Copyright 2019 SmugMug, Inc.
* Licensed under the terms of the MIT license. Please see LICENSE file in the project root for terms.
* @license
*/
var merge = require('merge');
/**
* Row
* Wrapper for each row in a justified layout.
* Stores relevant values and provides methods for calculating layout of individual rows.
*
* @param {Object} layoutConfig - The same as that passed
* @param {Object} Initialization parameters. The following are all required:
* @param params.top {Number} Top of row, relative to container
* @param params.left {Number} Left side of row relative to container (equal to container left padding)
* @param params.width {Number} Width of row, not including container padding
* @param params.spacing {Number} Horizontal spacing between items
* @param params.targetRowHeight {Number} Layout algorithm will aim for this row height
* @param params.targetRowHeightTolerance {Number} Row heights may vary +/- (`targetRowHeight` x `targetRowHeightTolerance`)
* @param params.edgeCaseMinRowHeight {Number} Absolute minimum row height for edge cases that cannot be resolved within tolerance.
* @param params.edgeCaseMaxRowHeight {Number} Absolute maximum row height for edge cases that cannot be resolved within tolerance.
* @param params.isBreakoutRow {Boolean} Is this row in particular one of those breakout rows? Always false if it's not that kind of photo list
* @param params.widowLayoutStyle {String} If widows are visible, how should they be laid out?
* @constructor
*/
var Row = module.exports = function (params) {
// Top of row, relative to container
this.top = params.top;
// Left side of row relative to container (equal to container left padding)
this.left = params.left;
// Width of row, not including container padding
this.width = params.width;
// Horizontal spacing between items
this.spacing = params.spacing;
// Row height calculation values
this.targetRowHeight = params.targetRowHeight;
this.targetRowHeightTolerance = params.targetRowHeightTolerance;
this.minAspectRatio = this.width / params.targetRowHeight * (1 - params.targetRowHeightTolerance);
this.maxAspectRatio = this.width / params.targetRowHeight * (1 + params.targetRowHeightTolerance);
// Edge case row height minimum/maximum
this.edgeCaseMinRowHeight = params.edgeCaseMinRowHeight;
this.edgeCaseMaxRowHeight = params.edgeCaseMaxRowHeight;
// Widow layout direction
this.widowLayoutStyle = params.widowLayoutStyle;
// Full width breakout rows
this.isBreakoutRow = params.isBreakoutRow;
// Store layout data for each item in row
this.items = [];
// Height remains at 0 until it's been calculated
this.height = 0;
};
Row.prototype = {
/**
* Attempt to add a single item to the row.
* This is the heart of the justified algorithm.
* This method is direction-agnostic; it deals only with sizes, not positions.
*
* If the item fits in the row, without pushing row height beyond min/max tolerance,
* the item is added and the method returns true.
*
* If the item leaves row height too high, there may be room to scale it down and add another item.
* In this case, the item is added and the method returns true, but the row is incomplete.
*
* If the item leaves row height too short, there are too many items to fit within tolerance.
* The method will either accept or reject the new item, favoring the resulting row height closest to within tolerance.
* If the item is rejected, left/right padding will be required to fit the row height within tolerance;
* if the item is accepted, top/bottom cropping will be required to fit the row height within tolerance.
*
* @method addItem
* @param itemData {Object} Item layout data, containing item aspect ratio.
* @return {Boolean} True if successfully added; false if rejected.
*/
addItem: function (itemData) {
var newItems = this.items.concat(itemData),
// Calculate aspect ratios for items only; exclude spacing
rowWidthWithoutSpacing = this.width - (newItems.length - 1) * this.spacing,
newAspectRatio = newItems.reduce(function (sum, item) {
return sum + item.aspectRatio;
}, 0),
targetAspectRatio = rowWidthWithoutSpacing / this.targetRowHeight,
previousRowWidthWithoutSpacing,
previousAspectRatio,
previousTargetAspectRatio;
// Handle big full-width breakout photos if we're doing them
if (this.isBreakoutRow) {
// Only do it if there's no other items in this row
if (this.items.length === 0) {
// Only go full width if this photo is a square or landscape
if (itemData.aspectRatio >= 1) {
// Close out the row with a full width photo
this.items.push(itemData);
this.completeLayout(rowWidthWithoutSpacing / itemData.aspectRatio, 'justify');
return true;
}
}
}
if (newAspectRatio < this.minAspectRatio) {
// New aspect ratio is too narrow / scaled row height is too tall.
// Accept this item and leave row open for more items.
this.items.push(merge(itemData));
return true;
} else if (newAspectRatio > this.maxAspectRatio) {
// New aspect ratio is too wide / scaled row height will be too short.
// Accept item if the resulting aspect ratio is closer to target than it would be without the item.
// NOTE: Any row that falls into this block will require cropping/padding on individual items.
if (this.items.length === 0) {
// When there are no existing items, force acceptance of the new item and complete the layout.
// This is the pano special case.
this.items.push(merge(itemData));
this.completeLayout(rowWidthWithoutSpacing / newAspectRatio, 'justify');
return true;
}
// Calculate width/aspect ratio for row before adding new item
previousRowWidthWithoutSpacing = this.width - (this.items.length - 1) * this.spacing;
previousAspectRatio = this.items.reduce(function (sum, item) {
return sum + item.aspectRatio;
}, 0);
previousTargetAspectRatio = previousRowWidthWithoutSpacing / this.targetRowHeight;
if (Math.abs(newAspectRatio - targetAspectRatio) > Math.abs(previousAspectRatio - previousTargetAspectRatio)) {
// Row with new item is us farther away from target than row without; complete layout and reject item.
this.completeLayout(previousRowWidthWithoutSpacing / previousAspectRatio, 'justify');
return false;
} else {
// Row with new item is us closer to target than row without;
// accept the new item and complete the row layout.
this.items.push(merge(itemData));
this.completeLayout(rowWidthWithoutSpacing / newAspectRatio, 'justify');
return true;
}
} else {
// New aspect ratio / scaled row height is within tolerance;
// accept the new item and complete the row layout.
this.items.push(merge(itemData));
this.completeLayout(rowWidthWithoutSpacing / newAspectRatio, 'justify');
return true;
}
},
/**
* Check if a row has completed its layout.
*
* @method isLayoutComplete
* @return {Boolean} True if complete; false if not.
*/
isLayoutComplete: function () {
return this.height > 0;
},
/**
* Set row height and compute item geometry from that height.
* Will justify items within the row unless instructed not to.
*
* @method completeLayout
* @param newHeight {Number} Set row height to this value.
* @param widowLayoutStyle {String} How should widows display? Supported: left | justify | center
*/
completeLayout: function (newHeight, widowLayoutStyle) {
var itemWidthSum = this.left,
rowWidthWithoutSpacing = this.width - (this.items.length - 1) * this.spacing,
clampedToNativeRatio,
clampedHeight,
errorWidthPerItem,
roundedCumulativeErrors,
singleItemGeometry,
centerOffset;
// Justify unless explicitly specified otherwise.
if (typeof widowLayoutStyle === 'undefined' || ['justify', 'center', 'left'].indexOf(widowLayoutStyle) < 0) {
widowLayoutStyle = 'left';
}
// Clamp row height to edge case minimum/maximum.
clampedHeight = Math.max(this.edgeCaseMinRowHeight, Math.min(newHeight, this.edgeCaseMaxRowHeight));
if (newHeight !== clampedHeight) {
// If row height was clamped, the resulting row/item aspect ratio will be off,
// so force it to fit the width (recalculate aspectRatio to match clamped height).
// NOTE: this will result in cropping/padding commensurate to the amount of clamping.
this.height = clampedHeight;
clampedToNativeRatio = (rowWidthWithoutSpacing / clampedHeight) / (rowWidthWithoutSpacing / newHeight);
} else {
// If not clamped, leave ratio at 1.0.
this.height = newHeight;
clampedToNativeRatio = 1.0;
}
// Compute item geometry based on newHeight.
this.items.forEach(function (item) {
item.top = this.top;
item.width = item.aspectRatio * this.height * clampedToNativeRatio;
item.height = this.height;
// Left-to-right.
// TODO right to left
// item.left = this.width - itemWidthSum - item.width;
item.left = itemWidthSum;
// Increment width.
itemWidthSum += item.width + this.spacing;
}, this);
// If specified, ensure items fill row and distribute error
// caused by rounding width and height across all items.
if (widowLayoutStyle === 'justify') {
itemWidthSum -= (this.spacing + this.left);
errorWidthPerItem = (itemWidthSum - this.width) / this.items.length;
roundedCumulativeErrors = this.items.map(function (item, i) {
return Math.round((i + 1) * errorWidthPerItem);
});
if (this.items.length === 1) {
// For rows with only one item, adjust item width to fill row.
singleItemGeometry = this.items[0];
singleItemGeometry.width -= Math.round(errorWidthPerItem);
} else {
// For rows with multiple items, adjust item width and shift items to fill the row,
// while maintaining equal spacing between items in the row.
this.items.forEach(function (item, i) {
if (i > 0) {
item.left -= roundedCumulativeErrors[i - 1];
item.width -= (roundedCumulativeErrors[i] - roundedCumulativeErrors[i - 1]);
} else {
item.width -= roundedCumulativeErrors[i];
}
});
}
} else if (widowLayoutStyle === 'center') {
// Center widows
centerOffset = (this.width - itemWidthSum) / 2;
this.items.forEach(function (item) {
item.left += centerOffset + this.spacing;
}, this);
}
},
/**
* Force completion of row layout with current items.
*
* @method forceComplete
* @param fitToWidth {Boolean} Stretch current items to fill the row width.
* This will likely result in padding.
* @param fitToWidth {Number}
*/
forceComplete: function (fitToWidth, rowHeight) {
// TODO Handle fitting to width
// var rowWidthWithoutSpacing = this.width - (this.items.length - 1) * this.spacing,
// currentAspectRatio = this.items.reduce(function (sum, item) {
// return sum + item.aspectRatio;
// }, 0);
if (typeof rowHeight === 'number') {
this.completeLayout(rowHeight, this.widowLayoutStyle);
} else {
// Complete using target row height.
this.completeLayout(this.targetRowHeight, this.widowLayoutStyle);
}
},
/**
* Return layout data for items within row.
* Note: returns actual list, not a copy.
*
* @method getItems
* @return Layout data for items within row.
*/
getItems: function () {
return this.items;
}
};
|