001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.data;
003
004import java.util.ArrayList;
005import java.util.Collection;
006import java.util.Collections;
007import java.util.List;
008import java.util.stream.Collectors;
009
010import org.openstreetmap.josm.data.coor.LatLon;
011import org.openstreetmap.josm.data.gpx.GpxImageEntry;
012import org.openstreetmap.josm.data.osm.QuadBuckets;
013import org.openstreetmap.josm.gui.layer.geoimage.ImageEntry;
014import org.openstreetmap.josm.tools.ListenerList;
015
016/**
017 * Class to hold {@link ImageEntry} and the current selection
018 * @since 14590
019 */
020public class ImageData implements Data {
021    /**
022     * A listener that is informed when the current selection change
023     */
024    public interface ImageDataUpdateListener {
025        /**
026         * Called when the data change
027         * @param data the image data
028         */
029        void imageDataUpdated(ImageData data);
030
031        /**
032         * Called when the selection change
033         * @param data the image data
034         */
035        void selectedImageChanged(ImageData data);
036    }
037
038    private final List<ImageEntry> data;
039
040    private final List<Integer> selectedImagesIndex = new ArrayList<>();
041
042    private final ListenerList<ImageDataUpdateListener> listeners = ListenerList.create();
043    private final QuadBuckets<ImageEntry> geoImages = new QuadBuckets<>();
044
045    /**
046     * Construct a new image container without images
047     */
048    public ImageData() {
049        this(null);
050    }
051
052    /**
053     * Construct a new image container with a list of images
054     * @param data the list of {@link ImageEntry}
055     */
056    public ImageData(List<ImageEntry> data) {
057        if (data != null) {
058            Collections.sort(data);
059            this.data = data;
060            this.data.forEach(image -> image.setDataSet(this));
061        } else {
062            this.data = new ArrayList<>();
063        }
064        this.geoImages.addAll(this.data);
065        selectedImagesIndex.add(-1);
066    }
067
068    /**
069     * Returns the images
070     * @return the images
071     */
072    public List<ImageEntry> getImages() {
073        return data;
074    }
075
076    /**
077     * Determines if one image has modified GPS data.
078     * @return {@code true} if data has been modified; {@code false}, otherwise
079     */
080    public boolean isModified() {
081        return data.stream().anyMatch(GpxImageEntry::hasNewGpsData);
082    }
083
084    /**
085     * Merge 2 ImageData
086     * @param otherData {@link ImageData} to merge
087     */
088    public void mergeFrom(ImageData otherData) {
089        data.addAll(otherData.getImages());
090        this.geoImages.addAll(otherData.getImages());
091        Collections.sort(data);
092
093        final ImageEntry selected = otherData.getSelectedImage();
094
095        // Suppress the double photos.
096        if (data.size() > 1) {
097            ImageEntry prev = data.get(data.size() - 1);
098            for (int i = data.size() - 2; i >= 0; i--) {
099                ImageEntry cur = data.get(i);
100                if (cur.getFile().equals(prev.getFile())) {
101                    data.remove(i);
102                } else {
103                    prev = cur;
104                }
105            }
106        }
107        if (selected != null) {
108            setSelectedImageIndex(data.indexOf(selected));
109        }
110    }
111
112    /**
113     * Return the first currently selected image
114     * @return the first selected image as {@link ImageEntry} or null
115     * @see #getSelectedImages
116     */
117    public ImageEntry getSelectedImage() {
118        int selectedImageIndex = selectedImagesIndex.isEmpty() ? -1 : selectedImagesIndex.get(0);
119        if (selectedImageIndex > -1) {
120            return data.get(selectedImageIndex);
121        }
122        return null;
123    }
124
125    /**
126     * Return the current selected images
127     * @return the selected images as list {@link ImageEntry}
128     * @since 15333
129     */
130    public List<ImageEntry> getSelectedImages() {
131        return selectedImagesIndex.stream().filter(i -> i > -1 && i < data.size()).map(data::get).collect(Collectors.toList());
132    }
133
134    /**
135     * Get the first image on the layer
136     * @return The first image
137     * @since 18246
138     */
139    public ImageEntry getFirstImage() {
140        if (!this.data.isEmpty()) {
141            return this.data.get(0);
142        }
143        return null;
144    }
145
146    /**
147     * Select the first image of the sequence
148     * @deprecated Use {@link #getFirstImage()} in conjunction with {@link #setSelectedImage}
149     */
150    @Deprecated
151    public void selectFirstImage() {
152        if (!data.isEmpty()) {
153            setSelectedImageIndex(0);
154        }
155    }
156
157    /**
158     * Get the last image in the layer
159     * @return The last image
160     * @since 18246
161     */
162    public ImageEntry getLastImage() {
163        if (!this.data.isEmpty()) {
164            return this.data.get(this.data.size() - 1);
165        }
166        return null;
167    }
168
169    /**
170     * Select the last image of the sequence
171     * @deprecated Use {@link #getLastImage()} with {@link #setSelectedImage}
172     */
173    @Deprecated
174    public void selectLastImage() {
175        setSelectedImageIndex(data.size() - 1);
176    }
177
178    /**
179     * Check if there is a next image in the sequence
180     * @return {@code true} is there is a next image, {@code false} otherwise
181     */
182    public boolean hasNextImage() {
183        return (selectedImagesIndex.size() == 1 && selectedImagesIndex.get(0) != data.size() - 1);
184    }
185
186    /**
187     * Search for images in a bounds
188     * @param bounds The bounds to search
189     * @return images in the bounds
190     * @since 17459
191     */
192    public Collection<ImageEntry> searchImages(Bounds bounds) {
193        return this.geoImages.search(bounds.toBBox());
194    }
195
196    /**
197     * Get the image next to the current image
198     * @return The next image
199     * @since 18246
200     */
201    public ImageEntry getNextImage() {
202        if (this.hasNextImage()) {
203            return this.data.get(this.selectedImagesIndex.get(0) + 1);
204        }
205        return null;
206    }
207
208    /**
209     * Select the next image of the sequence
210     * @deprecated Use {@link #getNextImage()} in conjunction with {@link #setSelectedImage}
211     */
212    @Deprecated
213    public void selectNextImage() {
214        if (hasNextImage()) {
215            setSelectedImageIndex(selectedImagesIndex.get(0) + 1);
216        }
217    }
218
219    /**
220     * Get the image previous to the current image
221     * @return The previous image
222     * @since 18246
223     */
224    public ImageEntry getPreviousImage() {
225        if (this.hasPreviousImage()) {
226            return this.data.get(Integer.max(0, selectedImagesIndex.get(0) - 1));
227        }
228        return null;
229    }
230
231    /**
232     *  Check if there is a previous image in the sequence
233     * @return {@code true} is there is a previous image, {@code false} otherwise
234     */
235    public boolean hasPreviousImage() {
236        return (selectedImagesIndex.size() == 1 && selectedImagesIndex.get(0) - 1 > -1);
237    }
238
239    /**
240     * Select the previous image of the sequence
241     * @deprecated Use {@link #getPreviousImage()} with {@link #setSelectedImage}
242     */
243    @Deprecated
244    public void selectPreviousImage() {
245        if (data.isEmpty()) {
246            return;
247        }
248        setSelectedImageIndex(Integer.max(0, selectedImagesIndex.get(0) - 1));
249    }
250
251    /**
252     * Select as the selected the given image
253     * @param image the selected image
254     */
255    public void setSelectedImage(ImageEntry image) {
256        setSelectedImageIndex(data.indexOf(image));
257    }
258
259    /**
260     * Add image to the list of selected images
261     * @param image {@link ImageEntry} the image to add
262     * @since 15333
263     */
264    public void addImageToSelection(ImageEntry image) {
265        int index = data.indexOf(image);
266        if (selectedImagesIndex.get(0) == -1) {
267            setSelectedImage(image);
268        } else if (!selectedImagesIndex.contains(index)) {
269            selectedImagesIndex.add(index);
270            listeners.fireEvent(l -> l.selectedImageChanged(this));
271        }
272    }
273
274    /**
275     * Indicate that an entry has changed
276     * @param gpxImageEntry The entry to update
277     * @since 17574
278     */
279    public void fireNodeMoved(ImageEntry gpxImageEntry) {
280        this.geoImages.remove(gpxImageEntry);
281        this.geoImages.add(gpxImageEntry);
282    }
283
284    /**
285     * Remove the image from the list of selected images
286     * @param image {@link ImageEntry} the image to remove
287     * @since 15333
288     */
289    public void removeImageToSelection(ImageEntry image) {
290        int index = data.indexOf(image);
291        selectedImagesIndex.remove(selectedImagesIndex.indexOf(index));
292        if (selectedImagesIndex.isEmpty()) {
293            selectedImagesIndex.add(-1);
294        }
295        listeners.fireEvent(l -> l.selectedImageChanged(this));
296    }
297
298    /**
299     * Clear the selected image(s)
300     */
301    public void clearSelectedImage() {
302        setSelectedImageIndex(-1);
303    }
304
305    private void setSelectedImageIndex(int index) {
306        setSelectedImageIndex(index, false);
307    }
308
309    private void setSelectedImageIndex(int index, boolean forceTrigger) {
310        if (selectedImagesIndex.size() > 1) {
311            selectedImagesIndex.clear();
312            selectedImagesIndex.add(-1);
313        }
314        if (index == selectedImagesIndex.get(0) && !forceTrigger) {
315            return;
316        }
317        selectedImagesIndex.set(0, index);
318        listeners.fireEvent(l -> l.selectedImageChanged(this));
319    }
320
321    /**
322     * Remove the current selected image from the list
323     * @since 15348
324     */
325    public void removeSelectedImages() {
326        removeImages(getSelectedImages());
327    }
328
329    private void removeImages(List<ImageEntry> selectedImages) {
330        if (selectedImages.isEmpty()) {
331            return;
332        }
333        for (ImageEntry img: getSelectedImages()) {
334            removeImage(img, false);
335        }
336        updateSelectedImage();
337    }
338
339    /**
340     * Update the selected image after removal of one or more images.
341     * @since 18049
342     */
343    public void updateSelectedImage() {
344        int size = data.size();
345        Integer firstSelectedImageIndex = selectedImagesIndex.get(0);
346        if (firstSelectedImageIndex >= size) {
347            setSelectedImageIndex(size - 1);
348        } else {
349            setSelectedImageIndex(firstSelectedImageIndex, true);
350        }
351    }
352
353    /**
354     * Determines if the image is selected
355     * @param image the {@link ImageEntry} image
356     * @return {@code true} is the image is selected, {@code false} otherwise
357     * @since 15333
358     */
359    public boolean isImageSelected(ImageEntry image) {
360        return selectedImagesIndex.contains(data.indexOf(image));
361    }
362
363    /**
364     * Remove the image from the list and trigger update listener
365     * @param img the {@link ImageEntry} to remove
366     */
367    public void removeImage(ImageEntry img) {
368        removeImage(img, true);
369    }
370
371    /**
372     * Remove the image from the list and optionnally trigger update listener
373     * @param img the {@link ImageEntry} to remove
374     * @param fireUpdateEvent if {@code true}, notifies listeners of image update
375     * @since 18049
376     */
377    public void removeImage(ImageEntry img, boolean fireUpdateEvent) {
378        data.remove(img);
379        this.geoImages.remove(img);
380        if (fireUpdateEvent) {
381            notifyImageUpdate();
382        }
383    }
384
385    /**
386     * Update the position of the image and trigger update
387     * @param img the image to update
388     * @param newPos the new position
389     */
390    public void updateImagePosition(ImageEntry img, LatLon newPos) {
391        img.setPos(newPos);
392        this.geoImages.remove(img);
393        this.geoImages.add(img);
394        afterImageUpdated(img);
395    }
396
397    /**
398     * Update the image direction of the image and trigger update
399     * @param img the image to update
400     * @param direction the new direction
401     */
402    public void updateImageDirection(ImageEntry img, double direction) {
403        img.setExifImgDir(direction);
404        afterImageUpdated(img);
405    }
406
407    /**
408     * Manually trigger the {@link ImageDataUpdateListener#imageDataUpdated(ImageData)}
409     */
410    public void notifyImageUpdate() {
411        listeners.fireEvent(l -> l.imageDataUpdated(this));
412    }
413
414    private void afterImageUpdated(ImageEntry img) {
415        img.flagNewGpsData();
416        notifyImageUpdate();
417    }
418
419    /**
420     * Add a listener that listens to image data changes
421     * @param listener the {@link ImageDataUpdateListener}
422     */
423    public void addImageDataUpdateListener(ImageDataUpdateListener listener) {
424        listeners.addListener(listener);
425    }
426
427    /**
428     * Removes a listener that listens to image data changes
429     * @param listener The listener
430     */
431    public void removeImageDataUpdateListener(ImageDataUpdateListener listener) {
432        listeners.removeListener(listener);
433    }
434
435    @Override
436    public Collection<DataSource> getDataSources() {
437        return Collections.emptyList();
438    }
439}