public static final class

MediaClustering.Cluster

extends MediaSet
/*
 * Copyright (C) 2009 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.cooliris.media;

import java.util.ArrayList;

import android.text.format.DateFormat;
import android.text.format.DateUtils;
import android.content.Context;

import android.content.res.Resources;

import com.cooliris.app.App;
import com.cooliris.app.Res;

/**
 * Implementation of an agglomerative based clustering where all items within a
 * certain time cutoff are grouped into the same cluster. Small adjacent
 * clusters are merged and large individual clusters are considered for
 * splitting.
 * 
 * TODO: Limitation: Can deal with items not being added incrementally to the
 * end of the current date range but effectively assumes this is the case for
 * efficient performance.
 */

public final class MediaClustering {
    // If 2 items are greater than 25 miles apart, they will be in different
    // clusters.
    private static final int GEOGRAPHIC_DISTANCE_CUTOFF_IN_MILES = 20;

    // Do not want to split based on anything under 1 min.
    private static final long MIN_CLUSTER_SPLIT_TIME_IN_MS = 60000L;

    // Disregard a cluster split time of anything over 2 hours.
    private static final long MAX_CLUSTER_SPLIT_TIME_IN_MS = 7200000L;

    // Try and get around 9 clusters (best-effort for the common case).
    private static final int NUM_CLUSTERS_TARGETED = 9;

    // Try and merge 2 clusters if they are both smaller than min cluster size.
    // The min cluster size can range from 8 to 15.
    private static final int MIN_MIN_CLUSTER_SIZE = 8;
    private static final int MAX_MIN_CLUSTER_SIZE = 15;

    // Try and split a cluster if it is bigger than max cluster size.
    // The max cluster size can range from 20 to 50.
    private static final int MIN_MAX_CLUSTER_SIZE = 20;
    private static final int MAX_MAX_CLUSTER_SIZE = 50;

    // Initially put 2 items in the same cluster as long as they are within
    // 3 cluster frequencies of each other.
    private static int CLUSTER_SPLIT_MULTIPLIER = 3;

    // The minimum change factor in the time between items to consider a
    // partition.
    // Example: (Item 3 - Item 2) / (Item 2 - Item 1).
    private static final int MIN_PARTITION_CHANGE_FACTOR = 2;

    // Make the cluster split time of a large cluster half that of a regular
    // cluster.
    private static final int PARTITION_CLUSTER_SPLIT_TIME_FACTOR = 2;

    private ArrayList<Cluster> mClusters;
    private Cluster mCurrCluster;
    private boolean mIsPicassaAlbum = false;
    private long mClusterSplitTime = (MIN_CLUSTER_SPLIT_TIME_IN_MS + MAX_CLUSTER_SPLIT_TIME_IN_MS) / 2;
    private long mLargeClusterSplitTime = mClusterSplitTime / PARTITION_CLUSTER_SPLIT_TIME_FACTOR;
    private int mMinClusterSize = (MIN_MIN_CLUSTER_SIZE + MAX_MIN_CLUSTER_SIZE) / 2;
    private int mMaxClusterSize = (MIN_MAX_CLUSTER_SIZE + MAX_MAX_CLUSTER_SIZE) / 2;

    MediaClustering(boolean isPicassaAlbum) {
        mClusters = new ArrayList<Cluster>();
        mIsPicassaAlbum = isPicassaAlbum;
        mCurrCluster = new Cluster(mIsPicassaAlbum);
    }

    public void clear() {
        int numClusters = mClusters.size();
        for (int i = 0; i < numClusters; i++) {
            Cluster cluster = mClusters.get(i);
            cluster.clear();
        }
        if (mCurrCluster != null) {
            mCurrCluster.clear();
        }
    }

    public void setTimeRange(long timeRange, int numItems) {
        if (numItems != 0) {
            int meanItemsPerCluster = numItems / NUM_CLUSTERS_TARGETED;
            // Heuristic to get min and max cluster size - half and double the
            // desired items per cluster.
            mMinClusterSize = meanItemsPerCluster / 2;
            mMaxClusterSize = meanItemsPerCluster * 2;
            mClusterSplitTime = timeRange / numItems * CLUSTER_SPLIT_MULTIPLIER;
        }
        mClusterSplitTime = Shared.clamp(mClusterSplitTime, MIN_CLUSTER_SPLIT_TIME_IN_MS, MAX_CLUSTER_SPLIT_TIME_IN_MS);
        mLargeClusterSplitTime = mClusterSplitTime / PARTITION_CLUSTER_SPLIT_TIME_FACTOR;
        mMinClusterSize = Shared.clamp(mMinClusterSize, MIN_MIN_CLUSTER_SIZE, MAX_MIN_CLUSTER_SIZE);
        mMaxClusterSize = Shared.clamp(mMaxClusterSize, MIN_MAX_CLUSTER_SIZE, MAX_MAX_CLUSTER_SIZE);
    }

    public void addItemForClustering(MediaItem mediaItem) {
        compute(mediaItem, false);
    }

    public void removeItemFromClustering(MediaItem mediaItem) {
        // Find the cluster that contains this item.
        if (mCurrCluster.removeItem(mediaItem)) {
            return;
        }
        int numClusters = mClusters.size();
        for (int i = 0; i < numClusters; i++) {
            Cluster cluster = mClusters.get(i);
            if (cluster.removeItem(mediaItem)) {
                if (cluster.mNumItemsLoaded == 0) {
                    mClusters.remove(cluster);
                }
                return;
            }
        }
    }

    public void compute(MediaItem currentItem, boolean processAllItems) {
        if (currentItem != null) {
            int numClusters = mClusters.size();
            int numCurrClusterItems = mCurrCluster.mNumItemsLoaded;
            boolean geographicallySeparateItem = false;
            boolean itemAddedToCurrentCluster = false;

            // Determine if this item should go in the current cluster or be the
            // start of a new cluster.
            if (numCurrClusterItems == 0) {
                mCurrCluster.addItem(currentItem);
            } else {
                MediaItem prevItem = mCurrCluster.getLastItem();
                if (isGeographicallySeparated(prevItem, currentItem)) {
                    mClusters.add(mCurrCluster);
                    geographicallySeparateItem = true;
                } else if (numCurrClusterItems > mMaxClusterSize) {
                    splitAndAddCurrentCluster();
                } else if (timeDistance(prevItem, currentItem) < mClusterSplitTime) {
                    mCurrCluster.addItem(currentItem);
                    itemAddedToCurrentCluster = true;
                } else if (numClusters > 0 && numCurrClusterItems < mMinClusterSize
                        && !mCurrCluster.mGeographicallySeparatedFromPrevCluster) {
                    mergeAndAddCurrentCluster();
                } else {
                    mClusters.add(mCurrCluster);
                }

                // Creating a new cluster and adding the current item to it.
                if (!itemAddedToCurrentCluster) {
                    mCurrCluster = new Cluster(mIsPicassaAlbum);
                    if (geographicallySeparateItem) {
                        mCurrCluster.mGeographicallySeparatedFromPrevCluster = true;
                    }
                    mCurrCluster.addItem(currentItem);
                }
            }
        }

        if (processAllItems && mCurrCluster.mNumItemsLoaded > 0) {
            int numClusters = mClusters.size();
            int numCurrClusterItems = mCurrCluster.mNumItemsLoaded;

            // The last cluster may potentially be too big or too small.
            if (numCurrClusterItems > mMaxClusterSize) {
                splitAndAddCurrentCluster();
            } else if (numClusters > 0 && numCurrClusterItems < mMinClusterSize
                    && !mCurrCluster.mGeographicallySeparatedFromPrevCluster) {
                mergeAndAddCurrentCluster();
            } else {
                mClusters.add(mCurrCluster);
            }
            mCurrCluster = new Cluster(mIsPicassaAlbum);
        }
    }

    private void splitAndAddCurrentCluster() {
        ArrayList<MediaItem> currClusterItems = mCurrCluster.getItems();
        int numCurrClusterItems = mCurrCluster.mNumItemsLoaded;
        int secondPartitionStartIndex = getPartitionIndexForCurrentCluster();
        if (secondPartitionStartIndex != -1) {
            Cluster partitionedCluster = new Cluster(mIsPicassaAlbum);
            for (int j = 0; j < secondPartitionStartIndex; j++) {
                partitionedCluster.addItem(currClusterItems.get(j));
            }
            mClusters.add(partitionedCluster);
            partitionedCluster = new Cluster(mIsPicassaAlbum);
            for (int j = secondPartitionStartIndex; j < numCurrClusterItems; j++) {
                partitionedCluster.addItem(currClusterItems.get(j));
            }
            mClusters.add(partitionedCluster);
        } else {
            mClusters.add(mCurrCluster);
        }
    }

    private int getPartitionIndexForCurrentCluster() {
        int partitionIndex = -1;
        float largestChange = MIN_PARTITION_CHANGE_FACTOR;
        ArrayList<MediaItem> currClusterItems = mCurrCluster.getItems();
        int numCurrClusterItems = mCurrCluster.mNumItemsLoaded;
        int minClusterSize = mMinClusterSize;

        // Could be slightly more efficient here but this code seems cleaner.
        if (numCurrClusterItems > minClusterSize + 1) {
            for (int i = minClusterSize; i < numCurrClusterItems - minClusterSize; i++) {
                MediaItem prevItem = currClusterItems.get(i - 1);
                MediaItem currItem = currClusterItems.get(i);
                MediaItem nextItem = currClusterItems.get(i + 1);

                if (prevItem.isDateTakenValid() && currItem.isDateModifiedValid() && nextItem.isDateModifiedValid()) {
                    long diff1 = Math.abs(nextItem.mDateTakenInMs - currItem.mDateTakenInMs);
                    long diff2 = Math.abs(currItem.mDateTakenInMs - prevItem.mDateTakenInMs);
                    float change = Math.max(diff1 / (diff2 + 0.01f), diff2 / (diff1 + 0.01f));
                    if (change > largestChange) {
                        if (timeDistance(currItem, prevItem) > mLargeClusterSplitTime) {
                            partitionIndex = i;
                            largestChange = change;
                        } else if (timeDistance(nextItem, currItem) > mLargeClusterSplitTime) {
                            partitionIndex = i + 1;
                            largestChange = change;
                        }
                    }
                }
            }
        }
        return partitionIndex;
    }

    private void mergeAndAddCurrentCluster() {
        int numClusters = mClusters.size();
        Cluster prevCluster = mClusters.get(numClusters - 1);
        ArrayList<MediaItem> currClusterItems = mCurrCluster.getItems();
        int numCurrClusterItems = mCurrCluster.mNumItemsLoaded;
        if (prevCluster.mNumItemsLoaded < mMinClusterSize) {
            for (int i = 0; i < numCurrClusterItems; i++) {
                prevCluster.addItem(currClusterItems.get(i));
            }
            mClusters.set(numClusters - 1, prevCluster);
        } else {
            mClusters.add(mCurrCluster);
        }
    }

    public synchronized ArrayList<Cluster> getClusters() {
        int numCurrClusterItems = mCurrCluster.mNumItemsLoaded;
        if (numCurrClusterItems == 0) {
            return mClusters;
        }
        ArrayList<Cluster> mergedClusters = new ArrayList<Cluster>();
        mergedClusters.addAll(mClusters);
        if (numCurrClusterItems > 0) {
            mergedClusters.add(mCurrCluster);
        }
        return mergedClusters;
    }

    public ArrayList<Cluster> getClustersForDisplay() {
        return mClusters;
    }

    public static final class Cluster extends MediaSet {
        private boolean mGeographicallySeparatedFromPrevCluster = false;
        private boolean mClusterChanged = false;
        private boolean mIsPicassaAlbum = false;
        private static final String MMDDYY_FORMAT = "MMddyy";

        public Cluster(boolean isPicassaAlbum) {
            mIsPicassaAlbum = isPicassaAlbum;
        }

        public void generateCaption(Context context) {
            if (mClusterChanged) {
                Resources resources = context.getResources();

                long minTimestamp = -1L;
                long maxTimestamp = -1L;
                if (areTimestampsAvailable()) {
                    minTimestamp = mMinTimestamp;
                    maxTimestamp = mMaxTimestamp;
                } else if (areAddedTimestampsAvailable()) {
                    minTimestamp = mMinAddedTimestamp;
                    maxTimestamp = mMaxAddedTimestamp;
                }

                if (minTimestamp != -1L) {
                    if (mIsPicassaAlbum) {
                        minTimestamp -= App.CURRENT_TIME_ZONE.getOffset(minTimestamp);
                        maxTimestamp -= App.CURRENT_TIME_ZONE.getOffset(maxTimestamp);
                    }
                    String minDay = DateFormat.format(MMDDYY_FORMAT, minTimestamp).toString();
                    String maxDay = DateFormat.format(MMDDYY_FORMAT, maxTimestamp).toString();

                    if (minDay.substring(4).equals(maxDay.substring(4))) {
                        // The items are from the same year - show at least as
                        // much granularity as abbrev_all allows.
                        mName = DateUtils.formatDateRange(context, minTimestamp, maxTimestamp, DateUtils.FORMAT_ABBREV_ALL);

                        // Get a more granular date range string if the min and
                        // max timestamp are on the same day and from the
                        // current year.
                        if (minDay.equals(maxDay)) {
                            int flags = DateUtils.FORMAT_ABBREV_MONTH | DateUtils.FORMAT_SHOW_DATE;
                            // Contains the year only if the date does not
                            // correspond to the current year.
                            String dateRangeWithOptionalYear = DateUtils.formatDateTime(context, minTimestamp, flags);
                            String dateRangeWithYear = DateUtils.formatDateTime(context, minTimestamp, flags
                                    | DateUtils.FORMAT_SHOW_YEAR);
                            if (!dateRangeWithOptionalYear.equals(dateRangeWithYear)) {
                                // This means both dates are from the same year
                                // - show the time.
                                // Not enough room to display the time range.
                                // Pick the mid-point.
                                long midTimestamp = (minTimestamp + maxTimestamp) / 2;
                                mName = DateUtils.formatDateRange(context, midTimestamp, midTimestamp, DateUtils.FORMAT_SHOW_TIME
                                        | flags);
                            }
                        }
                    } else {
                        // The items are not from the same year - only show
                        // month and year.
                        int flags = DateUtils.FORMAT_NO_MONTH_DAY | DateUtils.FORMAT_ABBREV_MONTH | DateUtils.FORMAT_SHOW_DATE;
                        mName = DateUtils.formatDateRange(context, minTimestamp, maxTimestamp, flags);
                    }
                } else {
                    mName = resources.getString(Res.string.date_unknown);
                }
                updateNumExpectedItems();
                generateTitle(false);
                mClusterChanged = false;
            }
        }

        public void addItem(MediaItem item) {
            super.addItem(item);
            mClusterChanged = true;
        }

        public boolean removeItem(MediaItem item) {
            if (super.removeItem(item)) {
                mClusterChanged = true;
                return true;
            }
            return false;
        }

        public MediaItem getLastItem() {
            final ArrayList<MediaItem> items = super.getItems();
            if (items == null || mNumItemsLoaded == 0) {
                return null;
            } else {
                return items.get(mNumItemsLoaded - 1);
            }
        }
    }

    // Returns the time interval between the two items in milliseconds.
    public static long timeDistance(MediaItem a, MediaItem b) {
        if (a == null || b == null) {
            return 0;
        }
        return Math.abs(a.mDateTakenInMs - b.mDateTakenInMs);
    }

    // Returns true if a, b are sufficiently geographically separated.
    private static boolean isGeographicallySeparated(MediaItem a, MediaItem b) {
        // If a or b are null, a or b have the default latitude, longitude
        // values or are close enough, return false.
        if (a != null && b != null && a.isLatLongValid() && b.isLatLongValid()) {
            int distance = (int) (LocationMediaFilter.toMile(LocationMediaFilter.distanceBetween(a.mLatitude, a.mLongitude,
                    b.mLatitude, b.mLongitude)) + 0.5);
            if (distance > GEOGRAPHIC_DISTANCE_CUTOFF_IN_MILES) {
                return true;
            }
        }
        return false;
    }
}