// Copyright 2015-2025, University of Colorado Boulder
/**
 * A carousel UI component.
 *
 * A set of N items is divided into M 'pages', based on how many items are visible in the carousel.
 * Pressing the next and previous buttons moves through the pages.
 * Movement through the pages is animated, so that items appear to scroll by.
 *
 * Note that Carousel wraps each item (Node) in an alignBox to ensure all items have an equal "footprint" dimension.
 *
 * The Carousel handles dynamic layout as far as carousel items changing sizes - it will resize to fit all of the items.
 * If you want to align or stretch item content, that can be done with CarouselItem's alignBoxOptions
 * (align:stretch will cause the item to be expanded to its section in the Carousel).
 *
 * Carousel currently is not sizable on its own (you can't set a preferred width/height that causes it to expand).
 *
 * @author Chris Malley (PixelZoom, Inc.)
 * @author Sam Reid (PhET Interactive Simulations)
 */
import '../../globals.js';
import _ from 'lodash';
import '../../chipper/js/browser/initialize-globals.js';
import BooleanProperty from '../../axon/js/BooleanProperty.js';
import DerivedProperty from '../../axon/js/DerivedProperty.js';
import Multilink from '../../axon/js/Multilink.js';
import NumberProperty from '../../axon/js/NumberProperty.js';
import stepTimer from '../../axon/js/stepTimer.js';
import Bounds2 from '../../dot/js/Bounds2.js';
import Dimension2 from '../../dot/js/Dimension2.js';
import Range from '../../dot/js/Range.js';
import Shape from '../../kite/js/Shape.js';
import optionize, { combineOptions } from '../../phet-core/js/optionize.js';
import Orientation from '../../phet-core/js/Orientation.js';
import AlignGroup from '../../scenery/js/layout/constraints/AlignGroup.js';
import LayoutConstraint from '../../scenery/js/layout/constraints/LayoutConstraint.js';
import FlowBox from '../../scenery/js/layout/nodes/FlowBox.js';
import Separator from '../../scenery/js/layout/nodes/Separator.js';
import IndexedNodeIO from '../../scenery/js/nodes/IndexedNodeIO.js';
import Node from '../../scenery/js/nodes/Node.js';
import Rectangle from '../../scenery/js/nodes/Rectangle.js';
import sharedSoundPlayers from '../../tambo/js/sharedSoundPlayers.js';
import isSettingPhetioStateProperty from '../../tandem/js/isSettingPhetioStateProperty.js';
import Tandem from '../../tandem/js/Tandem.js';
import Animation from '../../twixt/js/Animation.js';
import Easing from '../../twixt/js/Easing.js';
import CarouselButton from './buttons/CarouselButton.js';
import ColorConstants from './ColorConstants.js';
import { getGroupItemNodes } from './GroupItemOptions.js';
const DEFAULT_ARROW_SIZE = new Dimension2(20, 7);
export default class Carousel extends Node {
    /**
     * NOTE: This will dispose the item Nodes when the carousel is disposed
     */
    constructor(items, providedOptions) {
        // Don't animate during initialization
        let isInitialized = false;
        // Override defaults with specified options
        const options = optionize()({
            // container
            orientation: 'horizontal',
            fill: 'white',
            stroke: 'black',
            lineWidth: 1,
            cornerRadius: 4,
            defaultPageNumber: 0,
            // items
            itemsPerPage: 4,
            spacing: 12,
            margin: 6,
            alignBoxOptions: {
                phetioType: IndexedNodeIO,
                phetioState: true,
                visiblePropertyOptions: {
                    phetioFeatured: true
                }
            },
            // next/previous buttons
            buttonOptions: {
                xMargin: 5,
                yMargin: 5,
                // for dilating pointer areas of next/previous buttons such that they do not overlap with Carousel content
                touchAreaXDilation: 0,
                touchAreaYDilation: 0,
                mouseAreaXDilation: 0,
                mouseAreaYDilation: 0,
                baseColor: 'rgba( 200, 200, 200, 0.5 )',
                disabledColor: ColorConstants.LIGHT_GRAY,
                lineWidth: 1,
                arrowPathOptions: {
                    stroke: 'black',
                    lineWidth: 3
                },
                arrowSize: DEFAULT_ARROW_SIZE,
                enabledPropertyOptions: {
                    phetioReadOnly: true,
                    phetioFeatured: false
                },
                soundPlayer: sharedSoundPlayers.get('pushButton')
            },
            // item separators
            separatorsVisible: false,
            separatorOptions: {
                stroke: 'rgb( 180, 180, 180 )',
                lineWidth: 0.5,
                pickable: false
            },
            // animation, scrolling between pages
            animationEnabled: true,
            animationOptions: {
                duration: 0.4,
                stepEmitter: stepTimer,
                easing: Easing.CUBIC_IN_OUT
            },
            // phet-io
            tandem: Tandem.OPTIONAL,
            visiblePropertyOptions: {
                phetioFeatured: true
            }
        }, providedOptions);
        super();
        this.isAnimatingProperty = new BooleanProperty(false);
        this.animationEnabled = options.animationEnabled;
        this.items = items;
        this.itemsPerPage = options.itemsPerPage;
        this.defaultPageNumber = options.defaultPageNumber;
        const orientation = Orientation.fromLayoutOrientation(options.orientation);
        const alignGroup = new AlignGroup();
        const itemsTandem = options.tandem.createTandem('items');
        this.carouselItemNodes = getGroupItemNodes(items, itemsTandem);
        // All items are wrapped in AlignBoxes to ensure consistent sizing
        this.alignBoxes = items.map((item, index) => {
            return alignGroup.createBox(this.carouselItemNodes[index], combineOptions({
                tandem: item.tandemName ? itemsTandem.createTandem(item.tandemName) : Tandem.OPTIONAL
            }, options.alignBoxOptions, 
            // Item-specific options take precedence
            item.alignBoxOptions));
        });
        // scrollingNode will contain all items, arranged in the proper orientation, with margins and spacing.
        // NOTE: We'll need to handle updates to the order (for phet-io IndexedNodeIO).
        // Horizontal carousel arrange items left-to-right, vertical is top-to-bottom.
        // Translation of this node will be animated to give the effect of scrolling through the items.
        this.scrollingNode = new ScrollingFlowBox(this, {
            children: this.alignBoxes,
            orientation: options.orientation,
            spacing: options.spacing,
            [`${orientation.opposite.coordinate}Margin`]: options.margin
        });
        // Visible AlignBoxes (these are the ones we lay out and base everything on)
        this.visibleAlignBoxesProperty = DerivedProperty.deriveAny(this.alignBoxes.map(alignBox => alignBox.visibleProperty), () => {
            return this.getVisibleAlignBoxes();
        });
        // When the AlignBoxes are reordered, we need to recompute the visibleAlignBoxesProperty
        this.scrollingNode.childrenReorderedEmitter.addListener(() => this.visibleAlignBoxesProperty.recomputeDerivation());
        // Options common to both buttons
        const buttonOptions = combineOptions({
            cornerRadius: options.cornerRadius
        }, options.buttonOptions);
        ;
        // In order to make it easy for phet-io to re-order items, the separators should not participate
        // in the layout and have indices that get moved around.  Therefore, we add a separate layer to
        // show the separators.
        const separatorLayer = options.separatorsVisible ? new Node({
            pickable: false
        }) : null;
        // Contains the scrolling node and the associated separators, if any
        const scrollingNodeContainer = new Node({
            children: options.separatorsVisible ? [separatorLayer, this.scrollingNode] : [this.scrollingNode]
        });
        // Have to have at least one page, even if it is blank
        const countPages = (items) => Math.max(Math.ceil(items.length / options.itemsPerPage), 1);
        // Number of pages is derived from the total number of items and the number of items per page
        this.numberOfPagesProperty = new DerivedProperty([this.visibleAlignBoxesProperty], visibleAlignBoxes => {
            return countPages(visibleAlignBoxes);
        }, {
            isValidValue: v => v > 0
        });
        const maxPages = countPages(this.alignBoxes);
        ;
        // Number of the page that is visible in the carousel.
        this.pageNumberProperty = new NumberProperty(options.defaultPageNumber, {
            tandem: options.tandem.createTandem('pageNumberProperty'),
            numberType: 'Integer',
            isValidValue: (value) => value < this.numberOfPagesProperty.value && value >= 0,
            // Based on the total number of possible alignBoxes, not just the ones visible on startup
            range: new Range(0, maxPages - 1),
            phetioFeatured: true
        });
        const buttonsVisibleProperty = new DerivedProperty([this.numberOfPagesProperty], numberOfPages => {
            // always show the buttons if there is more than one page, and always hide the buttons if there is only one page
            return numberOfPages > 1;
        });
        // Next button
        const nextButton = new CarouselButton(combineOptions({
            arrowDirection: orientation === Orientation.HORIZONTAL ? 'right' : 'down',
            tandem: options.tandem.createTandem('nextButton'),
            listener: () => this.pageNumberProperty.set(this.pageNumberProperty.get() + 1),
            enabledProperty: new DerivedProperty([this.pageNumberProperty, this.numberOfPagesProperty], (pageNumber, numberofPages) => {
                return pageNumber < numberofPages - 1;
            }),
            visibleProperty: buttonsVisibleProperty
        }, buttonOptions));
        // Previous button
        const previousButton = new CarouselButton(combineOptions({
            arrowDirection: orientation === Orientation.HORIZONTAL ? 'left' : 'up',
            tandem: options.tandem.createTandem('previousButton'),
            listener: () => this.pageNumberProperty.set(this.pageNumberProperty.get() - 1),
            enabledProperty: new DerivedProperty([this.pageNumberProperty], pageNumber => {
                return pageNumber > 0;
            }),
            visibleProperty: buttonsVisibleProperty
        }, buttonOptions));
        // Window with clipping area, so that the scrollingNodeContainer can be scrolled
        const windowNode = new Node({ children: [scrollingNodeContainer] });
        // Background - displays the carousel's fill color
        const backgroundNode = new Rectangle({
            cornerRadius: options.cornerRadius,
            fill: options.fill
        });
        // Foreground - displays the carousel's outline, created as a separate node so that it can be placed on top of
        // everything, for a clean look.
        const foregroundNode = new Rectangle({
            cornerRadius: options.cornerRadius,
            stroke: options.stroke,
            pickable: false
        });
        // Top-level layout (based on background changes).
        this.carouselConstraint = new CarouselConstraint(this, backgroundNode, foregroundNode, windowNode, previousButton, nextButton, scrollingNodeContainer, this.alignBoxes, orientation, this.scrollingNode, this.itemsPerPage, options.margin, alignGroup, separatorLayer, options.separatorOptions);
        // Handle changing pages (or if the content changes)
        let scrollAnimation = null;
        const lastScrollBounds = new Bounds2(0, 0, 0, 0); // used mutably
        Multilink.multilink([this.pageNumberProperty, scrollingNodeContainer.localBoundsProperty], (pageNumber, scrollBounds) => {
            // We might temporarily hit this value. Bail out now instead of an assertion (it will get fired again)
            if (pageNumber >= this.numberOfPagesProperty.value) {
                return;
            }
            // stop any animation that's in progress
            scrollAnimation && scrollAnimation.stop();
            // Find the item at the top of pageNumber page
            const firstItemOnPage = this.visibleAlignBoxesProperty.value[pageNumber * options.itemsPerPage];
            // Place we want to scroll to
            const targetValue = firstItemOnPage ? ((-firstItemOnPage[orientation.minSide]) + options.margin) : 0;
            const scrollBoundsChanged = lastScrollBounds === null || !lastScrollBounds.equals(scrollBounds);
            lastScrollBounds.set(scrollBounds); // scrollBounds is mutable, we get the same reference, don't store it
            // Only animate if animation is enabled and PhET-iO state is not being set.  When PhET-iO state is being set (as
            // in loading a customized state), the carousel should immediately reflect the desired page
            // Do not animate during initialization.
            // Do not animate when our scrollBounds have changed (our content probably resized)
            if (this.animationEnabled && !isSettingPhetioStateProperty?.value && isInitialized && !scrollBoundsChanged) {
                // create and start the scroll animation
                scrollAnimation = new Animation(combineOptions({}, options.animationOptions, {
                    to: targetValue,
                    // options that are specific to orientation
                    getValue: () => scrollingNodeContainer[orientation.coordinate],
                    setValue: (value) => { scrollingNodeContainer[orientation.coordinate] = value; }
                }));
                scrollAnimation.endedEmitter.addListener(() => this.isAnimatingProperty.set(false));
                scrollAnimation.start();
                this.isAnimatingProperty.value = true;
            }
            else {
                // animation disabled, move immediate to new page
                scrollingNodeContainer[orientation.coordinate] = targetValue;
            }
        });
        // Don't stay on a page that doesn't exist
        this.visibleAlignBoxesProperty.link(() => {
            // if the only element in the last page is removed, remove the page and autoscroll to the new final page
            this.pageNumberProperty.value = Math.min(this.pageNumberProperty.value, this.numberOfPagesProperty.value - 1);
        });
        options.children = [backgroundNode, windowNode, nextButton, previousButton, foregroundNode];
        this.disposeCarousel = () => {
            this.visibleAlignBoxesProperty.dispose();
            this.pageNumberProperty.dispose();
            this.alignBoxes.forEach(alignBox => {
                ;
                ;
                alignBox.dispose();
            });
            this.scrollingNode.dispose();
            this.carouselConstraint.dispose();
            this.carouselItemNodes.forEach(node => node.dispose());
        };
        this.mutate(options);
        // Decorating with additional content is an anti-pattern, see https://github.com/phetsims/sun/issues/860
        ;
        // Will allow potential animation after this
        isInitialized = true;
        // support for binder documentation, stripped out in builds and only runs when ?binder is specified
        ;
    }
    /**
     * NOTE: This will dispose the item Nodes
     */
    dispose() {
        this.disposeCarousel();
        super.dispose();
    }
    /**
     * Resets the carousel to its initial state.
     * @param animationEnabled - whether to enable animation during reset
     */
    reset(animationEnabled = false) {
        const saveAnimationEnabled = this.animationEnabled;
        this.animationEnabled = animationEnabled;
        // Reset the page number to the default page number if possible (if things are hidden, it might not be possible)
        this.pageNumberProperty.value = Math.min(this.defaultPageNumber, this.numberOfPagesProperty.value - 1);
        this.animationEnabled = saveAnimationEnabled;
    }
    /**
     * Given an item's visible index, scrolls the carousel to the page that contains that item.
     */
    scrollToItemVisibleIndex(itemVisibleIndex) {
        this.pageNumberProperty.set(this.itemVisibleIndexToPageNumber(itemVisibleIndex));
    }
    /**
     * Given an item, scrolls the carousel to the page that contains that item. This will only scroll if item is in the
     * Carousel and visible.
     */
    scrollToItem(item) {
        this.scrollToAlignBox(this.getAlignBoxForItem(item));
    }
    /**
     * Public for ScrollingFlowBox only
     */
    scrollToAlignBox(alignBox) {
        // If the layout is dynamic, then only account for the visible items
        const alignBoxIndex = this.visibleAlignBoxesProperty.value.indexOf(alignBox);
        ;
        if (alignBoxIndex >= 0) {
            this.scrollToItemVisibleIndex(alignBoxIndex);
        }
    }
    /**
     * Set the visibility of an item in the Carousel. This toggles visibility and will reflow the layout, such that hidden
     * items do not leave a gap in the layout.
     */
    setItemVisible(item, visible) {
        this.getAlignBoxForItem(item).visible = visible;
    }
    /**
     * Gets the AlignBox that wraps an item's Node.
     */
    getAlignBoxForItem(item) {
        const alignBox = this.alignBoxes[this.items.indexOf(item)];
        ;
        return alignBox;
    }
    /**
     * Returns the Node that was created for a given item.
     */
    getNodeForItem(item) {
        const node = this.carouselItemNodes[this.items.indexOf(item)];
        ;
        return node;
    }
    itemVisibleIndexToPageNumber(itemIndex) {
        ;
        return Math.floor(itemIndex / this.itemsPerPage);
    }
    // The order of alignBoxes might be tweaked in scrollingNode's children. We need to respect this order
    getVisibleAlignBoxes() {
        return _.sortBy(this.alignBoxes.filter(alignBox => alignBox.visible), alignBox => this.scrollingNode.children.indexOf(alignBox));
    }
}
/**
 * When moveChildToIndex is called, scrolls the Carousel to that item. For use in PhET-iO when the order of items is
 * changed.
 */
class ScrollingFlowBox extends FlowBox {
    constructor(carousel, options) {
        super(options);
        this.carousel = carousel;
    }
    onIndexedNodeIOChildMoved(child) {
        this.carousel.scrollToAlignBox(child);
    }
}
class CarouselConstraint extends LayoutConstraint {
    constructor(carousel, backgroundNode, foregroundNode, windowNode, previousButton, nextButton, scrollingNodeContainer, alignBoxes, orientation, scrollingNode, itemsPerPage, margin, alignGroup, separatorLayer, separatorOptions) {
        super(carousel);
        this.carousel = carousel;
        this.backgroundNode = backgroundNode;
        this.foregroundNode = foregroundNode;
        this.windowNode = windowNode;
        this.previousButton = previousButton;
        this.nextButton = nextButton;
        this.scrollingNodeContainer = scrollingNodeContainer;
        this.alignBoxes = alignBoxes;
        this.orientation = orientation;
        this.scrollingNode = scrollingNode;
        this.itemsPerPage = itemsPerPage;
        this.margin = margin;
        this.alignGroup = alignGroup;
        this.separatorLayer = separatorLayer;
        this.separatorOptions = separatorOptions;
        // Hook up to listen to these nodes (will be handled by LayoutConstraint disposal)
        [this.backgroundNode,
            this.foregroundNode,
            this.windowNode,
            this.previousButton,
            this.nextButton,
            this.scrollingNodeContainer,
            ...this.alignBoxes].forEach(node => this.addNode(node, false));
        // Whenever layout happens in the scrolling node, it's the perfect time to update the separators
        if (this.separatorLayer) {
            // We do not need to remove this listener because it is internal to Carousel and will get garbage collected
            // when Carousel is disposed.
            this.scrollingNode.constraint.finishedLayoutEmitter.addListener(() => {
                this.updateSeparators();
            });
        }
        this.updateLayout();
    }
    updateSeparators() {
        const visibleChildren = this.carousel.getVisibleAlignBoxes();
        // Add separators between the visible children
        const range = visibleChildren.length >= 2 ? _.range(1, visibleChildren.length) : [];
        this.separatorLayer.children = range.map(index => {
            // Find the location between adjacent nodes
            const inbetween = (visibleChildren[index - 1][this.orientation.maxSide] +
                visibleChildren[index][this.orientation.minSide]) / 2;
            return new Separator(combineOptions({
                [`${this.orientation.coordinate}1`]: inbetween,
                [`${this.orientation.coordinate}2`]: inbetween,
                [`${this.orientation.opposite.coordinate}2`]: this.scrollingNode[this.orientation.opposite.size]
            }, this.separatorOptions));
        });
    }
    // Returns the clip area dimension for our Carousel based off of how many items we want to see per Carousel page.
    computeClipArea() {
        const orientation = this.orientation;
        const visibleAlignBoxes = this.carousel.getVisibleAlignBoxes();
        if (visibleAlignBoxes.length === 0) {
            return new Dimension2(0, 0);
        }
        else {
            // This doesn't fill one page in number play preferences dialog when you forget locales=*,
            // so take the last item, even if it is not a full page
            const lastBox = visibleAlignBoxes[this.itemsPerPage - 1] || visibleAlignBoxes[visibleAlignBoxes.length - 1];
            const horizontalSize = new Dimension2(
            // Measure from the beginning of the first item to the end of the last item on the 1st page
            lastBox[orientation.maxSide] - visibleAlignBoxes[0][orientation.minSide] + (2 * this.margin), this.scrollingNodeContainer.boundsProperty.value[orientation.opposite.size]);
            return this.orientation === Orientation.HORIZONTAL ? horizontalSize : horizontalSize.swapped();
        }
    }
    getBackgroundDimension() {
        let backgroundWidth;
        let backgroundHeight;
        if (this.orientation === Orientation.HORIZONTAL) {
            // For horizontal orientation, buttons contribute to width, if they are visible.
            const nextButtonWidth = this.nextButton.visible ? this.nextButton.width : 0;
            const previousButtonWidth = this.previousButton.visible ? this.previousButton.width : 0;
            backgroundWidth = this.windowNode.width + nextButtonWidth + previousButtonWidth;
            backgroundHeight = this.windowNode.height;
        }
        else {
            // For vertical orientation, buttons contribute to height, if they are visible.
            const nextButtonHeight = this.nextButton.visible ? this.nextButton.height : 0;
            const previousButtonHeight = this.previousButton.visible ? this.previousButton.height : 0;
            backgroundWidth = this.windowNode.width;
            backgroundHeight = this.windowNode.height + nextButtonHeight + previousButtonHeight;
        }
        return new Dimension2(backgroundWidth, backgroundHeight);
    }
    layout() {
        super.layout();
        const orientation = this.orientation;
        // Resize next/previous buttons dynamically
        const maxOppositeSize = this.alignGroup.getMaxSizeProperty(orientation.opposite).value;
        const buttonOppositeSize = maxOppositeSize + (2 * this.margin);
        this.nextButton[orientation.opposite.preferredSize] = buttonOppositeSize;
        this.previousButton[orientation.opposite.preferredSize] = buttonOppositeSize;
        this.nextButton[orientation.opposite.centerCoordinate] = this.backgroundNode[orientation.opposite.centerCoordinate];
        this.previousButton[orientation.opposite.centerCoordinate] = this.backgroundNode[orientation.opposite.centerCoordinate];
        this.windowNode[orientation.opposite.centerCoordinate] = this.backgroundNode[orientation.opposite.centerCoordinate];
        this.previousButton[orientation.minSide] = this.backgroundNode[orientation.minSide];
        this.nextButton[orientation.maxSide] = this.backgroundNode[orientation.maxSide];
        this.windowNode[orientation.centerCoordinate] = this.backgroundNode[orientation.centerCoordinate];
        const clipBounds = this.computeClipArea().toBounds();
        this.windowNode.clipArea = Shape.bounds(clipBounds);
        // Specify the local bounds in order to ensure centering. For full pages, this is not necessary since the scrollingNodeContainer
        // already spans the full area. But for a partial page, this is necessary so the self will be centered.
        this.windowNode.localBounds = clipBounds;
        const backgroundDimension = this.getBackgroundDimension();
        this.carousel.backgroundWidth = backgroundDimension.width;
        this.carousel.backgroundHeight = backgroundDimension.height;
        const backgroundBounds = backgroundDimension.toBounds();
        this.backgroundNode.rectBounds = backgroundBounds;
        this.foregroundNode.rectBounds = backgroundBounds;
        // Only update separators if they are visible
        this.separatorLayer && this.updateSeparators();
    }
}
//# sourceMappingURL=Carousel.js.map