1/*
2 * Copyright (C) 2010 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.gallery3d.data;
18
19import android.content.Context;
20import android.text.format.DateFormat;
21import android.text.format.DateUtils;
22
23import com.android.gallery3d.common.Utils;
24import com.android.gallery3d.util.GalleryUtils;
25
26import java.util.ArrayList;
27import java.util.Collections;
28import java.util.Comparator;
29
30public class TimeClustering extends Clustering {
31    @SuppressWarnings("unused")
32    private static final String TAG = "TimeClustering";
33
34    // If 2 items are greater than 25 miles apart, they will be in different
35    // clusters.
36    private static final int GEOGRAPHIC_DISTANCE_CUTOFF_IN_MILES = 20;
37
38    // Do not want to split based on anything under 1 min.
39    private static final long MIN_CLUSTER_SPLIT_TIME_IN_MS = 60000L;
40
41    // Disregard a cluster split time of anything over 2 hours.
42    private static final long MAX_CLUSTER_SPLIT_TIME_IN_MS = 7200000L;
43
44    // Try and get around 9 clusters (best-effort for the common case).
45    private static final int NUM_CLUSTERS_TARGETED = 9;
46
47    // Try and merge 2 clusters if they are both smaller than min cluster size.
48    // The min cluster size can range from 8 to 15.
49    private static final int MIN_MIN_CLUSTER_SIZE = 8;
50    private static final int MAX_MIN_CLUSTER_SIZE = 15;
51
52    // Try and split a cluster if it is bigger than max cluster size.
53    // The max cluster size can range from 20 to 50.
54    private static final int MIN_MAX_CLUSTER_SIZE = 20;
55    private static final int MAX_MAX_CLUSTER_SIZE = 50;
56
57    // Initially put 2 items in the same cluster as long as they are within
58    // 3 cluster frequencies of each other.
59    private static int CLUSTER_SPLIT_MULTIPLIER = 3;
60
61    // The minimum change factor in the time between items to consider a
62    // partition.
63    // Example: (Item 3 - Item 2) / (Item 2 - Item 1).
64    private static final int MIN_PARTITION_CHANGE_FACTOR = 2;
65
66    // Make the cluster split time of a large cluster half that of a regular
67    // cluster.
68    private static final int PARTITION_CLUSTER_SPLIT_TIME_FACTOR = 2;
69
70    private Context mContext;
71    private ArrayList<Cluster> mClusters;
72    private String[] mNames;
73    private Cluster mCurrCluster;
74
75    private long mClusterSplitTime =
76            (MIN_CLUSTER_SPLIT_TIME_IN_MS + MAX_CLUSTER_SPLIT_TIME_IN_MS) / 2;
77    private long mLargeClusterSplitTime =
78            mClusterSplitTime / PARTITION_CLUSTER_SPLIT_TIME_FACTOR;
79    private int mMinClusterSize = (MIN_MIN_CLUSTER_SIZE + MAX_MIN_CLUSTER_SIZE) / 2;
80    private int mMaxClusterSize = (MIN_MAX_CLUSTER_SIZE + MAX_MAX_CLUSTER_SIZE) / 2;
81
82
83    private static final Comparator<SmallItem> sDateComparator =
84            new DateComparator();
85
86    private static class DateComparator implements Comparator<SmallItem> {
87        @Override
88        public int compare(SmallItem item1, SmallItem item2) {
89            return -Utils.compare(item1.dateInMs, item2.dateInMs);
90        }
91    }
92
93    public TimeClustering(Context context) {
94        mContext = context;
95        mClusters = new ArrayList<Cluster>();
96        mCurrCluster = new Cluster();
97    }
98
99    @Override
100    public void run(MediaSet baseSet) {
101        final int total = baseSet.getTotalMediaItemCount();
102        final SmallItem[] buf = new SmallItem[total];
103        final double[] latLng = new double[2];
104
105        baseSet.enumerateTotalMediaItems(new MediaSet.ItemConsumer() {
106            @Override
107            public void consume(int index, MediaItem item) {
108                if (index < 0 || index >= total) return;
109                SmallItem s = new SmallItem();
110                s.path = item.getPath();
111                s.dateInMs = item.getDateInMs();
112                item.getLatLong(latLng);
113                s.lat = latLng[0];
114                s.lng = latLng[1];
115                buf[index] = s;
116            }
117        });
118
119        ArrayList<SmallItem> items = new ArrayList<SmallItem>(total);
120        for (int i = 0; i < total; i++) {
121            if (buf[i] != null) {
122                items.add(buf[i]);
123            }
124        }
125
126        Collections.sort(items, sDateComparator);
127
128        int n = items.size();
129        long minTime = 0;
130        long maxTime = 0;
131        for (int i = 0; i < n; i++) {
132            long t = items.get(i).dateInMs;
133            if (t == 0) continue;
134            if (minTime == 0) {
135                minTime = maxTime = t;
136            } else {
137                minTime = Math.min(minTime, t);
138                maxTime = Math.max(maxTime, t);
139            }
140        }
141
142        setTimeRange(maxTime - minTime, n);
143
144        for (int i = 0; i < n; i++) {
145            compute(items.get(i));
146        }
147
148        compute(null);
149
150        int m = mClusters.size();
151        mNames = new String[m];
152        for (int i = 0; i < m; i++) {
153            mNames[i] = mClusters.get(i).generateCaption(mContext);
154        }
155    }
156
157    @Override
158    public int getNumberOfClusters() {
159        return mClusters.size();
160    }
161
162    @Override
163    public ArrayList<Path> getCluster(int index) {
164        ArrayList<SmallItem> items = mClusters.get(index).getItems();
165        ArrayList<Path> result = new ArrayList<Path>(items.size());
166        for (int i = 0, n = items.size(); i < n; i++) {
167            result.add(items.get(i).path);
168        }
169        return result;
170    }
171
172    @Override
173    public String getClusterName(int index) {
174        return mNames[index];
175    }
176
177    private void setTimeRange(long timeRange, int numItems) {
178        if (numItems != 0) {
179            int meanItemsPerCluster = numItems / NUM_CLUSTERS_TARGETED;
180            // Heuristic to get min and max cluster size - half and double the
181            // desired items per cluster.
182            mMinClusterSize = meanItemsPerCluster / 2;
183            mMaxClusterSize = meanItemsPerCluster * 2;
184            mClusterSplitTime = timeRange / numItems * CLUSTER_SPLIT_MULTIPLIER;
185        }
186        mClusterSplitTime = Utils.clamp(mClusterSplitTime, MIN_CLUSTER_SPLIT_TIME_IN_MS, MAX_CLUSTER_SPLIT_TIME_IN_MS);
187        mLargeClusterSplitTime = mClusterSplitTime / PARTITION_CLUSTER_SPLIT_TIME_FACTOR;
188        mMinClusterSize = Utils.clamp(mMinClusterSize, MIN_MIN_CLUSTER_SIZE, MAX_MIN_CLUSTER_SIZE);
189        mMaxClusterSize = Utils.clamp(mMaxClusterSize, MIN_MAX_CLUSTER_SIZE, MAX_MAX_CLUSTER_SIZE);
190    }
191
192    private void compute(SmallItem currentItem) {
193        if (currentItem != null) {
194            int numClusters = mClusters.size();
195            int numCurrClusterItems = mCurrCluster.size();
196            boolean geographicallySeparateItem = false;
197            boolean itemAddedToCurrentCluster = false;
198
199            // Determine if this item should go in the current cluster or be the
200            // start of a new cluster.
201            if (numCurrClusterItems == 0) {
202                mCurrCluster.addItem(currentItem);
203            } else {
204                SmallItem prevItem = mCurrCluster.getLastItem();
205                if (isGeographicallySeparated(prevItem, currentItem)) {
206                    mClusters.add(mCurrCluster);
207                    geographicallySeparateItem = true;
208                } else if (numCurrClusterItems > mMaxClusterSize) {
209                    splitAndAddCurrentCluster();
210                } else if (timeDistance(prevItem, currentItem) < mClusterSplitTime) {
211                    mCurrCluster.addItem(currentItem);
212                    itemAddedToCurrentCluster = true;
213                } else if (numClusters > 0 && numCurrClusterItems < mMinClusterSize
214                        && !mCurrCluster.mGeographicallySeparatedFromPrevCluster) {
215                    mergeAndAddCurrentCluster();
216                } else {
217                    mClusters.add(mCurrCluster);
218                }
219
220                // Creating a new cluster and adding the current item to it.
221                if (!itemAddedToCurrentCluster) {
222                    mCurrCluster = new Cluster();
223                    if (geographicallySeparateItem) {
224                        mCurrCluster.mGeographicallySeparatedFromPrevCluster = true;
225                    }
226                    mCurrCluster.addItem(currentItem);
227                }
228            }
229        } else {
230            if (mCurrCluster.size() > 0) {
231                int numClusters = mClusters.size();
232                int numCurrClusterItems = mCurrCluster.size();
233
234                // The last cluster may potentially be too big or too small.
235                if (numCurrClusterItems > mMaxClusterSize) {
236                    splitAndAddCurrentCluster();
237                } else if (numClusters > 0 && numCurrClusterItems < mMinClusterSize
238                        && !mCurrCluster.mGeographicallySeparatedFromPrevCluster) {
239                    mergeAndAddCurrentCluster();
240                } else {
241                    mClusters.add(mCurrCluster);
242                }
243                mCurrCluster = new Cluster();
244            }
245        }
246    }
247
248    private void splitAndAddCurrentCluster() {
249        ArrayList<SmallItem> currClusterItems = mCurrCluster.getItems();
250        int numCurrClusterItems = mCurrCluster.size();
251        int secondPartitionStartIndex = getPartitionIndexForCurrentCluster();
252        if (secondPartitionStartIndex != -1) {
253            Cluster partitionedCluster = new Cluster();
254            for (int j = 0; j < secondPartitionStartIndex; j++) {
255                partitionedCluster.addItem(currClusterItems.get(j));
256            }
257            mClusters.add(partitionedCluster);
258            partitionedCluster = new Cluster();
259            for (int j = secondPartitionStartIndex; j < numCurrClusterItems; j++) {
260                partitionedCluster.addItem(currClusterItems.get(j));
261            }
262            mClusters.add(partitionedCluster);
263        } else {
264            mClusters.add(mCurrCluster);
265        }
266    }
267
268    private int getPartitionIndexForCurrentCluster() {
269        int partitionIndex = -1;
270        float largestChange = MIN_PARTITION_CHANGE_FACTOR;
271        ArrayList<SmallItem> currClusterItems = mCurrCluster.getItems();
272        int numCurrClusterItems = mCurrCluster.size();
273        int minClusterSize = mMinClusterSize;
274
275        // Could be slightly more efficient here but this code seems cleaner.
276        if (numCurrClusterItems > minClusterSize + 1) {
277            for (int i = minClusterSize; i < numCurrClusterItems - minClusterSize; i++) {
278                SmallItem prevItem = currClusterItems.get(i - 1);
279                SmallItem currItem = currClusterItems.get(i);
280                SmallItem nextItem = currClusterItems.get(i + 1);
281
282                long timeNext = nextItem.dateInMs;
283                long timeCurr = currItem.dateInMs;
284                long timePrev = prevItem.dateInMs;
285
286                if (timeNext == 0 || timeCurr == 0 || timePrev == 0) continue;
287
288                long diff1 = Math.abs(timeNext - timeCurr);
289                long diff2 = Math.abs(timeCurr - timePrev);
290
291                float change = Math.max(diff1 / (diff2 + 0.01f), diff2 / (diff1 + 0.01f));
292                if (change > largestChange) {
293                    if (timeDistance(currItem, prevItem) > mLargeClusterSplitTime) {
294                        partitionIndex = i;
295                        largestChange = change;
296                    } else if (timeDistance(nextItem, currItem) > mLargeClusterSplitTime) {
297                        partitionIndex = i + 1;
298                        largestChange = change;
299                    }
300                }
301            }
302        }
303        return partitionIndex;
304    }
305
306    private void mergeAndAddCurrentCluster() {
307        int numClusters = mClusters.size();
308        Cluster prevCluster = mClusters.get(numClusters - 1);
309        ArrayList<SmallItem> currClusterItems = mCurrCluster.getItems();
310        int numCurrClusterItems = mCurrCluster.size();
311        if (prevCluster.size() < mMinClusterSize) {
312            for (int i = 0; i < numCurrClusterItems; i++) {
313                prevCluster.addItem(currClusterItems.get(i));
314            }
315            mClusters.set(numClusters - 1, prevCluster);
316        } else {
317            mClusters.add(mCurrCluster);
318        }
319    }
320
321    // Returns true if a, b are sufficiently geographically separated.
322    private static boolean isGeographicallySeparated(SmallItem itemA, SmallItem itemB) {
323        if (!GalleryUtils.isValidLocation(itemA.lat, itemA.lng)
324                || !GalleryUtils.isValidLocation(itemB.lat, itemB.lng)) {
325            return false;
326        }
327
328        double distance = GalleryUtils.fastDistanceMeters(
329            Math.toRadians(itemA.lat),
330            Math.toRadians(itemA.lng),
331            Math.toRadians(itemB.lat),
332            Math.toRadians(itemB.lng));
333        return (GalleryUtils.toMile(distance) > GEOGRAPHIC_DISTANCE_CUTOFF_IN_MILES);
334    }
335
336    // Returns the time interval between the two items in milliseconds.
337    private static long timeDistance(SmallItem a, SmallItem b) {
338        return Math.abs(a.dateInMs - b.dateInMs);
339    }
340}
341
342class SmallItem {
343    Path path;
344    long dateInMs;
345    double lat, lng;
346}
347
348class Cluster {
349    @SuppressWarnings("unused")
350    private static final String TAG = "Cluster";
351    private static final String MMDDYY_FORMAT = "MMddyy";
352
353    // This is for TimeClustering only.
354    public boolean mGeographicallySeparatedFromPrevCluster = false;
355
356    private ArrayList<SmallItem> mItems = new ArrayList<SmallItem>();
357
358    public Cluster() {
359    }
360
361    public void addItem(SmallItem item) {
362        mItems.add(item);
363    }
364
365    public int size() {
366        return mItems.size();
367    }
368
369    public SmallItem getLastItem() {
370        int n = mItems.size();
371        return (n == 0) ? null : mItems.get(n - 1);
372    }
373
374    public ArrayList<SmallItem> getItems() {
375        return mItems;
376    }
377
378    public String generateCaption(Context context) {
379        int n = mItems.size();
380        long minTimestamp = 0;
381        long maxTimestamp = 0;
382
383        for (int i = 0; i < n; i++) {
384            long t = mItems.get(i).dateInMs;
385            if (t == 0) continue;
386            if (minTimestamp == 0) {
387                minTimestamp = maxTimestamp = t;
388            } else {
389                minTimestamp = Math.min(minTimestamp, t);
390                maxTimestamp = Math.max(maxTimestamp, t);
391            }
392        }
393        if (minTimestamp == 0) return "";
394
395        String caption;
396        String minDay = DateFormat.format(MMDDYY_FORMAT, minTimestamp)
397                .toString();
398        String maxDay = DateFormat.format(MMDDYY_FORMAT, maxTimestamp)
399                .toString();
400
401        if (minDay.substring(4).equals(maxDay.substring(4))) {
402            // The items are from the same year - show at least as
403            // much granularity as abbrev_all allows.
404            caption = DateUtils.formatDateRange(context, minTimestamp,
405                    maxTimestamp, DateUtils.FORMAT_ABBREV_ALL);
406
407            // Get a more granular date range string if the min and
408            // max timestamp are on the same day and from the
409            // current year.
410            if (minDay.equals(maxDay)) {
411                int flags = DateUtils.FORMAT_ABBREV_MONTH | DateUtils.FORMAT_SHOW_DATE;
412                // Contains the year only if the date does not
413                // correspond to the current year.
414                String dateRangeWithOptionalYear = DateUtils.formatDateTime(
415                        context, minTimestamp, flags);
416                String dateRangeWithYear = DateUtils.formatDateTime(
417                        context, minTimestamp, flags | DateUtils.FORMAT_SHOW_YEAR);
418                if (!dateRangeWithOptionalYear.equals(dateRangeWithYear)) {
419                    // This means both dates are from the same year
420                    // - show the time.
421                    // Not enough room to display the time range.
422                    // Pick the mid-point.
423                    long midTimestamp = (minTimestamp + maxTimestamp) / 2;
424                    caption = DateUtils.formatDateRange(context, midTimestamp,
425                            midTimestamp, DateUtils.FORMAT_SHOW_TIME | flags);
426                }
427            }
428        } else {
429            // The items are not from the same year - only show
430            // month and year.
431            int flags = DateUtils.FORMAT_NO_MONTH_DAY
432                    | DateUtils.FORMAT_ABBREV_MONTH | DateUtils.FORMAT_SHOW_DATE;
433            caption = DateUtils.formatDateRange(context, minTimestamp,
434                    maxTimestamp, flags);
435        }
436
437        return caption;
438    }
439}
440