$intervals Already normalised when constructed via factories. */ private function __construct( private readonly array $intervals, ) {} /** * Empty set factory. */ public static function empty(): self { return new self( [] ); } /** * Build a normalised set from raw intervals. * * @param list $intervals Raw intervals to normalise. */ public static function from( array $intervals ): self { return self::normalise( $intervals ); } /** * All intervals in sorted order. * * @return list */ public function all(): array { return $this->intervals; } /** * Whether the set has no intervals. */ public function is_empty(): bool { return [] === $this->intervals; } /** * Union with another set (merged). * * @param self $other Other set. */ public function union( self $other ): self { return self::normalise( array_merge( $this->intervals, $other->intervals ) ); } /** * Intersection with another set. * * @param self $other Other set. */ public function intersect( self $other ): self { $result = []; foreach ( $this->intervals as $a ) { foreach ( $other->intervals as $b ) { if ( ! $a->overlaps( $b ) ) { continue; } $start = $a->start() > $b->start() ? $a->start() : $b->start(); $end = $a->end() < $b->end() ? $a->end() : $b->end(); if ( $end > $start ) { $result[] = new Interval( $start, $end ); } } } return self::normalise( $result ); } /** * Working intervals minus busy intervals — the availability workhorse. * * @param self $other Intervals to subtract. */ public function subtract( self $other ): self { if ( $this->is_empty() ) { return self::empty(); } if ( $other->is_empty() ) { return $this; } $result = []; foreach ( $this->intervals as $subject ) { $fragments = [ $subject ]; foreach ( $other->intervals as $cutter ) { $next = []; foreach ( $fragments as $frag ) { if ( ! $frag->overlaps( $cutter ) ) { $next[] = $frag; continue; } // Left remainder [frag.start, cutter.start). if ( $frag->start() < $cutter->start() ) { $next[] = new Interval( $frag->start(), $cutter->start() ); } // Right remainder [cutter.end, frag.end). if ( $cutter->end() < $frag->end() ) { $next[] = new Interval( $cutter->end(), $frag->end() ); } } $fragments = $next; } foreach ( $fragments as $frag ) { $result[] = $frag; } } return self::normalise( $result ); } /** * Whether any interval fully contains the needle. * * @param Interval $needle Interval to look for. */ public function contains( Interval $needle ): bool { foreach ( $this->intervals as $interval ) { if ( $interval->contains( $needle ) ) { return true; } } return false; } /** * Whether any interval overlaps the needle. * * @param Interval $needle Interval to test. */ public function overlaps( Interval $needle ): bool { foreach ( $this->intervals as $interval ) { if ( $interval->overlaps( $needle ) ) { return true; } } return false; } /** * Sort and merge overlapping or abutting intervals. * * @param list $intervals Intervals to sort and merge. */ private static function normalise( array $intervals ): self { if ( [] === $intervals ) { return self::empty(); } usort( $intervals, static function ( Interval $a, Interval $b ): int { return $a->start() <=> $b->start(); } ); $merged = []; $current = $intervals[0]; $count = count( $intervals ); for ( $i = 1; $i < $count; $i++ ) { $next = $intervals[ $i ]; if ( $next->start() <= $current->end() ) { $end = $next->end() > $current->end() ? $next->end() : $current->end(); $current = new Interval( $current->start(), $end ); } else { $merged[] = $current; $current = $next; } } $merged[] = $current; return new self( $merged ); } }