Improve the mosaic algorithm further #43
@ -8,15 +8,18 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
class AssetIterator extends Asset
 | 
					class AssetIterator extends Asset
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
 | 
						private Database $db;
 | 
				
			||||||
 | 
						private $direction;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private $return_format;
 | 
						private $return_format;
 | 
				
			||||||
	private $res_assets;
 | 
						private $res_assets;
 | 
				
			||||||
	private $res_meta;
 | 
						private $res_meta;
 | 
				
			||||||
	private $res_thumbs;
 | 
						private $res_thumbs;
 | 
				
			||||||
	private Database $db;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	protected function __construct($res_assets, $res_meta, $res_thumbs, $return_format)
 | 
						protected function __construct($res_assets, $res_meta, $res_thumbs, $return_format, $direction)
 | 
				
			||||||
	{
 | 
						{
 | 
				
			||||||
		$this->db = Registry::get('db');
 | 
							$this->db = Registry::get('db');
 | 
				
			||||||
 | 
							$this->direction = $direction;
 | 
				
			||||||
		$this->res_assets = $res_assets;
 | 
							$this->res_assets = $res_assets;
 | 
				
			||||||
		$this->res_meta = $res_meta;
 | 
							$this->res_meta = $res_meta;
 | 
				
			||||||
		$this->res_thumbs = $res_thumbs;
 | 
							$this->res_thumbs = $res_thumbs;
 | 
				
			||||||
@ -174,7 +177,7 @@ class AssetIterator extends Asset
 | 
				
			|||||||
				'_' => '_',
 | 
									'_' => '_',
 | 
				
			||||||
			]);
 | 
								]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		$iterator = new self($res_assets, $res_meta, $res_thumbs, $return_format);
 | 
							$iterator = new self($res_assets, $res_meta, $res_thumbs, $return_format, $params['direction']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		// Returning total count, too?
 | 
							// Returning total count, too?
 | 
				
			||||||
		if ($return_count)
 | 
							if ($return_count)
 | 
				
			||||||
@ -190,4 +193,14 @@ class AssetIterator extends Asset
 | 
				
			|||||||
		else
 | 
							else
 | 
				
			||||||
			return $iterator;
 | 
								return $iterator;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public function isAscending()
 | 
				
			||||||
 | 
						{
 | 
				
			||||||
 | 
							return $this->direction === 'asc';
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public function isDescending()
 | 
				
			||||||
 | 
						{
 | 
				
			||||||
 | 
							return $this->direction === 'desc';
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -145,6 +145,16 @@ class Image extends Asset
 | 
				
			|||||||
		return $ratio >= 1 && $ratio <= 2;
 | 
							return $ratio >= 1 && $ratio <= 2;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public function getType()
 | 
				
			||||||
 | 
						{
 | 
				
			||||||
 | 
							if ($this->isPortrait())
 | 
				
			||||||
 | 
								return self::TYPE_PORTRAIT;
 | 
				
			||||||
 | 
							elseif ($this->isPanorama())
 | 
				
			||||||
 | 
								return self::TYPE_PANORAMA;
 | 
				
			||||||
 | 
							else
 | 
				
			||||||
 | 
								return self::TYPE_LANDSCAPE;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public function getThumbnails()
 | 
						public function getThumbnails()
 | 
				
			||||||
	{
 | 
						{
 | 
				
			||||||
		return $this->thumbnails;
 | 
							return $this->thumbnails;
 | 
				
			||||||
 | 
				
			|||||||
@ -8,17 +8,21 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
class PhotoMosaic
 | 
					class PhotoMosaic
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
 | 
						private $descending;
 | 
				
			||||||
	private AssetIterator $iterator;
 | 
						private AssetIterator $iterator;
 | 
				
			||||||
	private $layouts;
 | 
						private $layouts;
 | 
				
			||||||
	private $processedImages = 0;
 | 
						private $processedImages = 0;
 | 
				
			||||||
	private $queue = [];
 | 
						private $queue = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						const IMAGE_MASK_ALL = Image::TYPE_PORTRAIT | Image::TYPE_LANDSCAPE | Image::TYPE_PANORAMA;
 | 
				
			||||||
	const NUM_DAYS_CUTOFF = 7;
 | 
						const NUM_DAYS_CUTOFF = 7;
 | 
				
			||||||
 | 
						const NUM_BATCH_PHOTOS = 6;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public function __construct(AssetIterator $iterator)
 | 
						public function __construct(AssetIterator $iterator)
 | 
				
			||||||
	{
 | 
						{
 | 
				
			||||||
		$this->iterator = $iterator;
 | 
							$this->iterator = $iterator;
 | 
				
			||||||
		$this->layouts = $this->availableLayouts();
 | 
							$this->layouts = $this->availableLayouts();
 | 
				
			||||||
 | 
							$this->descending = $iterator->isDescending();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public function __destruct()
 | 
						public function __destruct()
 | 
				
			||||||
@ -48,6 +52,7 @@ class PhotoMosaic
 | 
				
			|||||||
			// Dual layouts
 | 
								// Dual layouts
 | 
				
			||||||
			'dualLandscapes' => [Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE],
 | 
								'dualLandscapes' => [Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE],
 | 
				
			||||||
			'dualPortraits' => [Image::TYPE_PORTRAIT, Image::TYPE_PORTRAIT],
 | 
								'dualPortraits' => [Image::TYPE_PORTRAIT, Image::TYPE_PORTRAIT],
 | 
				
			||||||
 | 
								'dualMixed' => [Image::TYPE_LANDSCAPE, Image::TYPE_PORTRAIT],
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			// Fallback layouts
 | 
								// Fallback layouts
 | 
				
			||||||
			'singleLandscape' => [Image::TYPE_LANDSCAPE],
 | 
								'singleLandscape' => [Image::TYPE_LANDSCAPE],
 | 
				
			||||||
@ -57,18 +62,24 @@ class PhotoMosaic
 | 
				
			|||||||
		return $layouts;
 | 
							return $layouts;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private static function daysApart(Image $a, Image $b)
 | 
						private static function daysApart(DateTime $a, DateTime $b)
 | 
				
			||||||
	{
 | 
						{
 | 
				
			||||||
		return $a->getDateCaptured()->diff($b->getDateCaptured())->days;
 | 
							return $a->diff($b)->days;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private function fetchImage($desired_type = Image::TYPE_PORTRAIT | Image::TYPE_LANDSCAPE | Image::TYPE_PANORAMA, Image $refDateImage = null)
 | 
						private function fetchImage($desired_type = self::IMAGE_MASK_ALL, ?DateTime $refDate = null)
 | 
				
			||||||
	{
 | 
						{
 | 
				
			||||||
		// First, check if we have what we're looking for in the queue.
 | 
							// First, check if we have what we're looking for in the queue.
 | 
				
			||||||
		foreach ($this->queue as $i => $image)
 | 
							foreach ($this->queue as $i => $image)
 | 
				
			||||||
		{
 | 
							{
 | 
				
			||||||
 | 
								// Give up on the queue once the dates are too far apart
 | 
				
			||||||
 | 
								if (isset($refDate) && abs(self::daysApart($image->getDateCaptured(), $refDate)) > self::NUM_DAYS_CUTOFF)
 | 
				
			||||||
 | 
								{
 | 
				
			||||||
 | 
									break;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			// Image has to match the desired type and be taken within a week of the reference image.
 | 
								// Image has to match the desired type and be taken within a week of the reference image.
 | 
				
			||||||
			if (self::matchTypeMask($image, $desired_type) && !(isset($refDateImage) && abs(self::daysApart($image, $refDateImage)) > self::NUM_DAYS_CUTOFF))
 | 
								if (self::matchTypeMask($image, $desired_type))
 | 
				
			||||||
			{
 | 
								{
 | 
				
			||||||
				unset($this->queue[$i]);
 | 
									unset($this->queue[$i]);
 | 
				
			||||||
				return $image;
 | 
									return $image;
 | 
				
			||||||
@ -78,61 +89,153 @@ class PhotoMosaic
 | 
				
			|||||||
		// Check whatever's next up!
 | 
							// Check whatever's next up!
 | 
				
			||||||
		while (($asset = $this->iterator->next()) && ($image = $asset->getImage()))
 | 
							while (($asset = $this->iterator->next()) && ($image = $asset->getImage()))
 | 
				
			||||||
		{
 | 
							{
 | 
				
			||||||
			// Image has to match the desired type and be taken within a week of the reference image.
 | 
								// Give up on the recordset once dates are too far apart
 | 
				
			||||||
			if (self::matchTypeMask($image, $desired_type) && !(isset($refDateImage) && abs(self::daysApart($image, $refDateImage)) > self::NUM_DAYS_CUTOFF))
 | 
								if (isset($refDate) && abs(self::daysApart($image->getDateCaptured(), $refDate)) > self::NUM_DAYS_CUTOFF)
 | 
				
			||||||
				return $image;
 | 
								{
 | 
				
			||||||
			else
 | 
					 | 
				
			||||||
				$this->pushToQueue($image);
 | 
									$this->pushToQueue($image);
 | 
				
			||||||
 | 
									break;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								// Image has to match the desired type and be taken within a week of the reference image.
 | 
				
			||||||
 | 
								if (self::matchTypeMask($image, $desired_type))
 | 
				
			||||||
 | 
								{
 | 
				
			||||||
 | 
									return $image;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								else
 | 
				
			||||||
 | 
								{
 | 
				
			||||||
 | 
									$this->pushToQueue($image);
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		return false;
 | 
							return false;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public function fetchImages($num, $refDate = null, $spec = self::IMAGE_MASK_ALL)
 | 
				
			||||||
 | 
						{
 | 
				
			||||||
 | 
							$refDate = null;
 | 
				
			||||||
 | 
							$prevImage = true;
 | 
				
			||||||
 | 
							$images = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							for ($i = 0; $i < $num || !$prevImage; $i++)
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								$image = $this->fetchImage($spec, $refDate);
 | 
				
			||||||
 | 
								if ($image !== false)
 | 
				
			||||||
 | 
								{
 | 
				
			||||||
 | 
									$images[] = $image;
 | 
				
			||||||
 | 
									$refDate = $image->getDateCaptured();
 | 
				
			||||||
 | 
									$prevImage = $image;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							return $images;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public function getRow()
 | 
						public function getRow()
 | 
				
			||||||
	{
 | 
						{
 | 
				
			||||||
		$currentImages = [];
 | 
							$requiredImages = array_map('count', $this->layouts);
 | 
				
			||||||
 | 
							$currentImages = $this->fetchImages(self::NUM_BATCH_PHOTOS);
 | 
				
			||||||
		$selectedLayout = null;
 | 
							$selectedLayout = null;
 | 
				
			||||||
		foreach ($this->layouts as $layout => $requiredImageTypes)
 | 
					 | 
				
			||||||
		{
 | 
					 | 
				
			||||||
			// Fetch images per the layout requirements
 | 
					 | 
				
			||||||
			// TODO: pass last image back into fetchImage for date cutoff purposes
 | 
					 | 
				
			||||||
			$currentImages = array_filter(array_map([$this, 'fetchImage'], $requiredImageTypes));
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
			// Matching requirements?
 | 
							if (empty($currentImages))
 | 
				
			||||||
			if (count($currentImages) === count($requiredImageTypes))
 | 
					 | 
				
			||||||
			{
 | 
					 | 
				
			||||||
				$selectedLayout = $layout;
 | 
					 | 
				
			||||||
				break;
 | 
					 | 
				
			||||||
			}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
			// Push mismatches back into the queue
 | 
					 | 
				
			||||||
			array_map([$this, 'pushToQueue'], $currentImages);
 | 
					 | 
				
			||||||
			$currentImages = [];
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		if ($selectedLayout)
 | 
					 | 
				
			||||||
		{
 | 
					 | 
				
			||||||
			// Hurray, we've got something that works
 | 
					 | 
				
			||||||
			usort($currentImages, [$this, 'orderPhotos']);
 | 
					 | 
				
			||||||
			$this->processedImages += count($currentImages);
 | 
					 | 
				
			||||||
			return [$currentImages, $selectedLayout];
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
		else
 | 
					 | 
				
			||||||
		{
 | 
							{
 | 
				
			||||||
			// Ensure we have no images left in the iterator before giving up
 | 
								// Ensure we have no images left in the iterator before giving up
 | 
				
			||||||
			assert($this->processedImages === $this->iterator->num());
 | 
								assert($this->processedImages === $this->iterator->num());
 | 
				
			||||||
			return false;
 | 
								return false;
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Assign fitness score for each layout
 | 
				
			||||||
 | 
							$fitnessScores = $this->getScoresByLayout($currentImages);
 | 
				
			||||||
 | 
							$scoresByLayout = array_map(fn($el) => $el[0], $fitnessScores);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Select the best-fitting layout
 | 
				
			||||||
 | 
							$bestLayouts = array_keys($scoresByLayout, max($scoresByLayout));
 | 
				
			||||||
 | 
							$bestLayout = $bestLayouts[0];
 | 
				
			||||||
 | 
							$layoutImages = $fitnessScores[$bestLayout][1];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Push any unused back into the queue
 | 
				
			||||||
 | 
							if (count($layoutImages) < count($currentImages))
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								$diff = array_udiff($currentImages, $layoutImages, function($a, $b) {
 | 
				
			||||||
 | 
									return $a->getId() <=> $b->getId();
 | 
				
			||||||
 | 
								});
 | 
				
			||||||
 | 
								array_map([$this, 'pushToQueue'], $diff);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Finally, allow tweaking image order through display priority
 | 
				
			||||||
 | 
							usort($layoutImages, [$this, 'orderPhotosByPriority']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// Done! Return the result
 | 
				
			||||||
 | 
							$this->processedImages += count($layoutImages);
 | 
				
			||||||
 | 
							return [$layoutImages, $bestLayout];
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public function getScoreForRow(array $images, array $specs)
 | 
				
			||||||
 | 
						{
 | 
				
			||||||
 | 
							assert(count($images) === count($specs));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							$score = 0;
 | 
				
			||||||
 | 
							foreach ($images as $i => $image)
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								if (self::matchTypeMask($image, $specs[$i]))
 | 
				
			||||||
 | 
									$score += 1;
 | 
				
			||||||
 | 
								else
 | 
				
			||||||
 | 
									$score -= 10;
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							return $score;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public function getScoresByLayout(array $candidateImages)
 | 
				
			||||||
 | 
						{
 | 
				
			||||||
 | 
							$fitnessScores = [];
 | 
				
			||||||
 | 
							foreach ($this->layouts as $layout => $requiredImageTypes)
 | 
				
			||||||
 | 
							{
 | 
				
			||||||
 | 
								// If we don't have enough candidate images for this layout, skip it
 | 
				
			||||||
 | 
								if (count($candidateImages) < count($requiredImageTypes))
 | 
				
			||||||
 | 
									continue;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								$imageSelection = [];
 | 
				
			||||||
 | 
								$remainingImages = $candidateImages;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								// Try to satisfy the layout spec using the images available
 | 
				
			||||||
 | 
								foreach ($requiredImageTypes as $spec)
 | 
				
			||||||
 | 
								{
 | 
				
			||||||
 | 
									foreach ($remainingImages as $i => $candidate)
 | 
				
			||||||
 | 
									{
 | 
				
			||||||
 | 
										// Satisfied spec from selection?
 | 
				
			||||||
 | 
										if (self::matchTypeMask($candidate, $spec))
 | 
				
			||||||
 | 
										{
 | 
				
			||||||
 | 
											$imageSelection[] = $candidate;
 | 
				
			||||||
 | 
											unset($remainingImages[$i]);
 | 
				
			||||||
 | 
											continue 2;
 | 
				
			||||||
 | 
										}
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									// Unable to satisfy spec from selection
 | 
				
			||||||
 | 
									break;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								// Have we satisfied the spec? Great, assign a score
 | 
				
			||||||
 | 
								if (count($imageSelection) === count($requiredImageTypes))
 | 
				
			||||||
 | 
								{
 | 
				
			||||||
 | 
									$score = $this->getScoreForRow($imageSelection, $requiredImageTypes);
 | 
				
			||||||
 | 
									$fitnessScores[$layout] = [$score, $imageSelection];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
									// Perfect score? Bail out early
 | 
				
			||||||
 | 
									if ($score === count($requiredImageTypes))
 | 
				
			||||||
 | 
										break;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							return $fitnessScores;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private static function matchTypeMask(Image $image, $type_mask)
 | 
						private static function matchTypeMask(Image $image, $type_mask)
 | 
				
			||||||
	{
 | 
						{
 | 
				
			||||||
		return ($type_mask & Image::TYPE_PANORAMA) && $image->isPanorama() ||
 | 
							return $image->getType() & $type_mask;
 | 
				
			||||||
			  ($type_mask & Image::TYPE_LANDSCAPE) && $image->isLandscape() ||
 | 
					 | 
				
			||||||
			  ($type_mask & Image::TYPE_PORTRAIT)  && $image->isPortrait();
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private static function orderPhotos(Image $a, Image $b)
 | 
						private static function orderPhotosByPriority(Image $a, Image $b)
 | 
				
			||||||
	{
 | 
						{
 | 
				
			||||||
		// Leave images of different types as-is
 | 
							// Leave images of different types as-is
 | 
				
			||||||
		if ($a->isLandscape() !== $b->isLandscape())
 | 
							if ($a->isLandscape() !== $b->isLandscape())
 | 
				
			||||||
@ -143,8 +246,17 @@ class PhotoMosaic
 | 
				
			|||||||
		return -$priority_diff;
 | 
							return -$priority_diff;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private function orderQueueByDate()
 | 
				
			||||||
 | 
						{
 | 
				
			||||||
 | 
							usort($this->queue, function($a, $b) {
 | 
				
			||||||
 | 
								$score = $a->getDateCaptured() <=> $b->getDateCaptured();
 | 
				
			||||||
 | 
								return $score * ($this->descending ? -1 : 1);
 | 
				
			||||||
 | 
							});
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private function pushToQueue(Image $image)
 | 
						private function pushToQueue(Image $image)
 | 
				
			||||||
	{
 | 
						{
 | 
				
			||||||
		$this->queue[] = $image;
 | 
							$this->queue[] = $image;
 | 
				
			||||||
 | 
							$this->orderQueueByDate();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -270,6 +270,29 @@ class PhotosIndex extends Template
 | 
				
			|||||||
				</div>';
 | 
									</div>';
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						protected function dualMixed(array $photos, $altLayout)
 | 
				
			||||||
 | 
						{
 | 
				
			||||||
 | 
							$image = array_shift($photos);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							echo '
 | 
				
			||||||
 | 
									<div class="row g-5 mb-5 tile-feat-landscape',
 | 
				
			||||||
 | 
										$altLayout ? ' flex-row-reverse' : '', '">
 | 
				
			||||||
 | 
										<div class="col-md-8">';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							$this->photo($image, 'landscape', static::LANDSCAPE_WIDTH, static::LANDSCAPE_HEIGHT, 'top');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							echo '
 | 
				
			||||||
 | 
										</div>
 | 
				
			||||||
 | 
										<div class="col-md-4">';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							$this->photo($image, 'portrait', static::PORTRAIT_WIDTH, static::PORTRAIT_HEIGHT, true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							echo '
 | 
				
			||||||
 | 
											</div>
 | 
				
			||||||
 | 
										</div>
 | 
				
			||||||
 | 
									</div>';
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	protected function dualPortraits(array $photos, $altLayout)
 | 
						protected function dualPortraits(array $photos, $altLayout)
 | 
				
			||||||
	{
 | 
						{
 | 
				
			||||||
		// Recycle the row layout so portraits don't appear too large
 | 
							// Recycle the row layout so portraits don't appear too large
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user