add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), null) * ->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), null) * ->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), null) * ->add(new \DateTime('2009-01-01'), new \DateTime('2010-09-15'), null) * ->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), null) * ->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), null) * ->compute() * ; * $cover->getIntersections(); * ``` */ class DateRangeCovering { private bool $computed = false; private array $intersections = []; private array $intervals = []; private int $minCover; private int $uniqueKeyCounter = 0; private array $metadatas = []; private array $sequence = []; private \DateTimeZone $tz; /** * @param int $minCover the minimum of covering required */ public function __construct(int $minCover, \DateTimeZone $tz) { if ($minCover < 0) { throw new \LogicException("argument minCover cannot be lower than 0"); } $this->minCover = $minCover; $this->tz = $tz; } public function add(\DateTimeInterface $start, \DateTimeInterface $end = null, $metadata = null): self { if ($this->computed) { throw new \LogicException("You cannot add intervals to a computed instance"); } $k = $this->uniqueKeyCounter++; $this->intervals[$k] = [$start, $end]; $this->metadatas[$k] = $metadata; $this->addToSequence($start->getTimestamp(), $k, null); $this->addToSequence( NULL === $end ? PHP_INT_MAX : $end->getTimestamp(), null, $k ); return $this; } private function addToSequence($timestamp, int $start = null, int $end = null) { if (!\array_key_exists($timestamp, $this->sequence)) { $this->sequence[$timestamp] = [ 's' => [], 'e' => [] ]; } if (NULL !== $start) { $this->sequence[$timestamp]['s'][] = $start; } if (NULL !== $end) { $this->sequence[$timestamp]['e'][] = $end; } } public function compute(): self { \ksort($this->sequence); $currentPeriod = []; $currents = []; $isOpen = false; $overs = []; foreach ($this->sequence as $ts => $moves) { $currents = \array_merge($currents, $moves['s']); $currents = \array_diff($currents, $moves['e']); if (count($currents) > $this->minCover && !$isOpen) { $currentPeriod[0] = $ts; $currentPeriod[2] = $currents; $isOpen = true; } elseif ($isOpen && count($currents) <= $this->minCover) { $currentPeriod[1] = $ts; $overs[] = $currentPeriod; $currentPeriod = []; $isOpen = false; } elseif ($isOpen) { $currentPeriod[2] = \array_merge($currentPeriod[2], $currents); } } // process metadata foreach ($overs as list($start, $end, $metadata)) { $this->intersections[] = [ (new \DateTimeImmutable('@'.$start)) ->setTimezone($this->tz), $end === PHP_INT_MAX ? null : (new \DateTimeImmutable('@'.$end)) ->setTimezone($this->tz), \array_values( \array_intersect_key( $this->metadatas, \array_flip(\array_unique($metadata)) ) ) ]; } $this->computed = true; return $this; } private function process(array $intersections): array { $result = []; $starts = []; $ends = []; $metadatas = []; while (null !== ($current = \array_pop($intersections))) { list($cStart, $cEnd, $cMetadata) = $current; $n = count($cMetadata); foreach ($intersections as list($iStart, $iEnd, $iMetadata)) { $start = max($cStart, $iStart); $end = min($cEnd, $iEnd); if ($start <= $end) { if (FALSE !== ($key = \array_search($start, $starts))) { if ($ends[$key] === $end) { $metadatas[$key] = \array_unique(\array_merge($metadatas[$key], $iMetadata)); continue; } } $starts[] = $start; $ends[] = $end; $metadatas[] = \array_unique(\array_merge($iMetadata, $cMetadata)); } } } // recompose results foreach ($starts as $k => $start) { $result[] = [$start, $ends[$k], \array_unique($metadatas[$k])]; } return $result; } private function addToIntersections(array $intersections, array $intersection) { $foundExisting = false; list($nStart, $nEnd, $nMetadata) = $intersection; \array_walk($intersections, function(&$i, $key) use ($nStart, $nEnd, $nMetadata, $foundExisting) { if ($foundExisting) { return; }; if ($i[0] === $nStart && $i[1] === $nEnd) { $foundExisting = true; $i[2] = \array_merge($i[2], $nMetadata); } } ); if (!$foundExisting) { $intersections[] = $intersection; } return $intersections; } public function hasIntersections(): bool { if (!$this->computed) { throw new \LogicException(sprintf("You cannot call the method %s before ". "'process'", __METHOD)); } return count($this->intersections) > 0; } public function getIntersections(): array { if (!$this->computed) { throw new \LogicException(sprintf("You cannot call the method %s before ". "'process'", __METHOD)); } return $this->intersections; } }