001    /*
002    Copyright (c) 1996-2011, Damon Hart-Davis
003    All rights reserved.
004    
005    Redistribution and use in source and binary forms, with or without
006    modification, are permitted provided that the following conditions are
007    met:
008    
009      * Redistributions of source code must retain the above copyright
010        notice, this list of conditions and the following disclaimer.
011    
012      * Redistributions in binary form must reproduce the above copyright
013        notice, this list of conditions and the following disclaimer in the
014        documentation and/or other materials provided with the
015        distribution.
016    
017    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
018    IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
019    TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
020    PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
021    OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
022    SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
023    LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
024    DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
025    THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
026    (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
027    OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
028    */
029    
030    package org.hd.d.pg2k.webSvr.virtualHosts.AlohaEarth;
031    
032    import java.awt.Point;
033    import java.awt.Rectangle;
034    import java.awt.geom.AffineTransform;
035    import java.awt.image.AffineTransformOp;
036    import java.awt.image.BufferedImage;
037    import java.io.IOException;
038    import java.util.ArrayList;
039    import java.util.Collections;
040    import java.util.Comparator;
041    import java.util.HashMap;
042    import java.util.List;
043    import java.util.Map;
044    
045    import javax.servlet.ServletContext;
046    
047    import org.hd.d.pg2k.svrCore.CoreConsts;
048    import org.hd.d.pg2k.svrCore.ExhibitAttrUtils;
049    import org.hd.d.pg2k.svrCore.ExhibitName;
050    import org.hd.d.pg2k.svrCore.ExhibitPropsComputable;
051    import org.hd.d.pg2k.svrCore.ExhibitPropsComputableMutable;
052    import org.hd.d.pg2k.svrCore.ExhibitThumbnails;
053    import org.hd.d.pg2k.svrCore.GenUtils;
054    import org.hd.d.pg2k.svrCore.ImageUtils;
055    import org.hd.d.pg2k.svrCore.Name;
056    import org.hd.d.pg2k.svrCore.TextUtils;
057    import org.hd.d.pg2k.svrCore.MIME.ExhibitMIME;
058    import org.hd.d.pg2k.svrCore.location.Location;
059    import org.hd.d.pg2k.svrCore.location.LocationMap;
060    import org.hd.d.pg2k.svrCore.props.GenProps;
061    import org.hd.d.pg2k.webSvr.exhibit.DataSourceBean;
062    import org.hd.d.pg2k.webSvr.location.LocationUtils;
063    import org.hd.d.pg2k.webSvr.util.WebConsts;
064    import org.hd.d.pg2k.webSvr.util.WebUtils;
065    
066    import ORG.hd.d.IsDebug;
067    
068    /**
069     * Created by IntelliJ IDEA.
070     * User: Damon Hart-Davis
071     * Date: 13-Sep-2003
072     * Time: 21:46:23
073     */
074    
075    /**Support for Aloha Earth virtual site.
076     */
077    public final class AEUtils
078        {
079    //    /**Site-relative URL to "page" at which Aloha Earth map HTML is shown.
080    //     * This is used to allow the Aloha Earth page to easily refer
081    //     * to itself by name, for example as the target of a form submission.
082    //     */
083    //    public static final String AE_SELF_LRURL = "coords/";
084    
085        /**Root-relative URL to "page" at which Aloha Earth is shown.
086         * This is used to allow the Aloha Earth page to easily refer
087         * to itself by name, for example as the target of a form submission.
088         */
089        public static final String AE_SELF_RRURL =
090            WebConsts.VIRTUAL_SITE_CUST_ALOHAEARTH_ROOT /* + AE_SELF_LRURL */;
091    
092        /**Root-relative URL or source/base 2D Earth image for clickable map.
093         * This is assumed to be a smallish low-colour image
094         * on which we can write red and white markings that will be visible
095         * and which we convert back to a clear and small palette-based image
096         * in a popular and widely-supported format such as GIF or PNG.
097         */
098        public static final String BASE_2D_EARTH_MAP_RRURL =
099            WebConsts.VIRTUAL_SITE_CUST_ALOHAEARTH_ROOT + "Earth-whole-planet.gif";
100    
101        /**Width of base image (pixels). */
102        public static final int BASE_2D_EARTH_MAP_WIDTH = 4320;
103    
104        /**Height of base image (pixels). */
105        public static final int BASE_2D_EARTH_MAP_HEIGHT = 2160;
106    
107    
108        /**Root-relative URL to base directory on which map image servlet is mounted.
109         * This must match entry in web.xml.
110         * <p>
111         * Note that this contains a leading and trailing '/'.
112         * <p>
113         * Any URI under this point will be directed to the servlet,
114         * so we can pick the "expected" suffix to avoid surprising broswers
115         * with the MIME type of the downloaded image.
116         * <p>
117         * Because these generated images may have to change the exhibit set
118         * or meta data may change it it not appropriate for this to be under
119         * /_static.
120         */
121        public static final String MAPIMG_SERVLET_MOUNT_DIR =
122            WebConsts.VIRTUAL_SITE_CUST_ALOHAEARTH_ROOT + "mapimg/";
123    
124       /**Type of clickable-map image that we dynamically generate.
125         * Need not be the same as the raw base image,
126         * as long as it can (efficiently) encode the modified image
127         * in a byte-indexed format.
128         * <p>
129         * Good types to use are PNG and GIF (when the LZW patent expires).
130         */
131        static final int DYN_MAP_IMG_TYPE = ExhibitMIME.ET_PNG;
132    
133        /**ExhibitMIME entry for the chosen outout type. */
134        static final ExhibitMIME.ExhibitTypeParameters ETP =
135            ExhibitMIME.getParamsByType(DYN_MAP_IMG_TYPE);
136    
137        /**Full root-relative URL given for map image servlet.
138         * The extension is chosen to match the MIME type.
139         * <p>
140         * We will append GET-style parameters to allow the appropriate
141         * zoom and shift to be applied.
142         */
143        public static final String MAP_SERVLET_RRURL =
144            MAPIMG_SERVLET_MOUNT_DIR + "Earth" + ETP.dotSuffixForInputFile;
145    
146    
147        /**Zoom ratio: what factor we zoom in/out each time; at least 2.
148         * Also determines size of lateral (up/down/left/right) step
149         * as fraction of width of whole image as shown to the user.
150         * <p>
151         * A value of 2 or 3 is probably good; up to 8 is probably OK.
152         */
153        public static final int ZOOM_RATIO = 2;
154    
155        /**Minimum zoom ratio from initial base image.
156         * A negative value means the image can be zoomed out (shrunk)
157         * relative to the base image.
158         * <p>
159         * This is in terms of zooms in or out
160         * (changes in each dimension) by ZOOM_RATIO.
161         */
162        public static int MIN_ZOOM = -3;
163    
164        /**Maximum zoom ratio from initial base image.
165         * A positive value means the image can be zoomed in (expanded)
166         * relative to the base image.  This will determine how many
167         * pixels on a side a single source pixel can be expanded to.
168         * <p>
169         * This is in terms of zooms in or out
170         * (changes in each dimension) by ZOOM_RATIO.
171         * <p>
172         * This must be no less than MIN_ZOOM,
173         * and is usually positive.
174         * <p>
175         * Since this effectively limits the precision with which a user can
176         * select an area to view on the one hand, and yet increases the
177         * 'chunkiness' of the maximally zoomed image, is is difficult to chose
178         * an optimal value, but one that expand source pixels to no more
179         * than about 8--16 pixels on a side is probably reasonable.
180         */
181        public static int MAX_ZOOM = +5;
182    
183        /**The initial zoom ratio as seen by a visitor.
184         * The raw image is zoomed by this amount and this determines
185         * the size of the derived clickable map to be shown to the visitor
186         * at all levels of zoom.
187         * <p>
188         * This is usually but not always the same as the MIN_ZOOM,
189         * but must be in the inclusive range
190         * [MIN_ZOOM, MAX_ZOOM].
191         */
192        public static int INITIAL_ZOOM = MIN_ZOOM;
193    
194        /**Apply zoom to value in pixels.
195         * A negative value reduces the value (brings it towards zero)
196         * and a positive one increases it;
197         * zero returns the value unchanged.
198         * <p>
199         * This computes input * (ZOOM_RATIO ^ zoomFactor).
200         */
201        public static int applyZoom(int pixels, int zoomFactor)
202            {
203            // A zoom factor of zero is the identity function.
204            if(zoomFactor == 0) { return(pixels); }
205    
206            if(zoomFactor > 0)
207                {
208                // Special case for power of two.
209                if(ZOOM_RATIO == 2)
210                    { return(pixels << zoomFactor); }
211    
212                // General case.
213                while(--zoomFactor >= 0) { pixels *= ZOOM_RATIO; }
214                return(pixels);
215                }
216    
217    
218            // Else zoomFactor < 0.
219    
220            // Special case for power of two.
221            if(ZOOM_RATIO == 2)
222                { return(pixels >> -zoomFactor); }
223    
224            // Avoid repeated division for speed
225            // and to try to avoid unnecessary loss of information/accuracy.
226            int divisor = 1;
227            while(++zoomFactor <= 0) { divisor *= ZOOM_RATIO; }
228            pixels /= divisor;
229            return(pixels);
230            }
231    
232        /**Display width of image to show visitor.
233         * Determined by original image width and INITIAL_ZOOM.
234         */
235        public static final int DISPLAY_2D_EARTH_MAP_WIDTH =
236            applyZoom(BASE_2D_EARTH_MAP_WIDTH, INITIAL_ZOOM);
237    
238        /**Display height of image to show visitor.
239         * Determined by original image height and INITIAL_ZOOM.
240         */
241        public static final int DISPLAY_2D_EARTH_MAP_HEIGHT =
242            applyZoom(BASE_2D_EARTH_MAP_HEIGHT, INITIAL_ZOOM);
243    
244    
245        /**Make scaled base image fragment given Rectangle bounding source pixels.
246         * Returns writable ARGB true colour image fragment suitable to draw on.
247         * <p>
248         * May consume significant resources (CPU and memory),
249         * so access and concurrency should be controlled.
250         *
251         * @param srcRect  as returned by AEParams.getSourceRectangleToDisplay
252         * @param context  servlet context to retrieve base image from
253         */
254        static BufferedImage makeScaledBaseMapFragment(final Rectangle srcRect,
255                                                       final ServletContext context)
256            {
257            final BufferedImage imageIn =
258                WebUtils.getAndCacheStaticImage(false, // We'll copy below.
259                    BASE_2D_EARTH_MAP_RRURL,
260                    false, // Don't force to memory-hogging ARGB true-colour here!
261                    context,
262                    false); // Cache source image indefinitely.
263    
264    //System.out.println("makeScaledBaseMapFragment("+srcRect+", ctxt)");
265    
266            // Extract the map image fragment that we will be copying from.
267            // Avoid copying any data...
268            final BufferedImage srcFragment = imageIn.getSubimage(
269                srcRect.x, srcRect.y, srcRect.width, srcRect.height);
270    
271            // Make a scaled copy of the original image.
272            // We use nearest-neighbour scaling because:
273            //    * It is fast.
274            //    * It should work for indexed-colour (and other) images
275            //      (eg this does not create pixels with new colours).
276            final AffineTransformOp op = new AffineTransformOp(
277                AffineTransform.getScaleInstance(
278                    DISPLAY_2D_EARTH_MAP_WIDTH / (double) srcRect.width,
279                    DISPLAY_2D_EARTH_MAP_HEIGHT / (double) srcRect.height),
280                AffineTransformOp.TYPE_NEAREST_NEIGHBOR);
281            final BufferedImage scaledImage = op.filter(srcFragment, null);
282    
283            // Convert to ARGB true-colour to be easily drawn on, if necessary.
284            final BufferedImage result = ImageUtils.convertToTrueColourARGB(scaledImage, false);
285    
286            return(result);
287            }
288    
289    
290        /**Colour of marker to show location of exhibit on map, in RGB format.
291         * Since the map is usually rendered in green (land) and blue (sea)
292         * this should probably be red or some other high-contrast colour.
293         * This should probably also be fully opaque to minimise encoded image size
294         * (eg 0xffff0000 for opaque red).
295         */
296        static final int EXHIBIT_MARKER_RGB_COLOUR =
297            LocationUtils.DYN_LOC_TN_CROSSHAIR_RGB_COLOUR;
298    
299        /**Colour of marker to show area indicated by title, in RGB format.
300         * Since the map is usually rendered in green (land) and blue (sea)
301         * and individual exhibits in red
302         * this should probably be orange or some other nearish-red colour.
303         * This should probably also be fully opaque to minimise encoded image size
304         * (eg 0xffff0000 for opaque red).
305         */
306        static final int TITLE_AREA_MARKER_RGB_COLOUR = 0xffE47833;
307    
308    
309    
310        /**Category whose LocationMap entries are used by getViewLocationMapVirtualPrefix(); not null. */
311        private static final Name _GVT_CATEGORY = Name.create("places-and-sights");
312        /**Lookup prefix; not null. */
313        private static final Name _GVT_CATEGORY_LU_PREFIX = Name.create(_GVT_CATEGORY.toString() + '/', _GVT_CATEGORY);
314    
315        /**This gets a virtual prefix from the LocationMap for the current view or empty String if none suitable; never null.
316         * This returns the prefix the best possible match for the current view,
317         * or returns ""/EMPTY if it cannot find anything suitable.
318         * The prefix returned does contain the category,
319         * and is of the form dir/word-{word-}*,
320         * eg "places-and-sights/England-London-".
321         * <p>
322         * This should always return "" at the outermost level.
323         * <p>
324         * This looks up places-and-sights prefixes in the given LocationMap,
325         * assuming that each such prefix is a reasonable region.
326         * This tries a number of methods to find the "best" match,
327         * in this order (excluding any "whole Earth" match:
328         * <ol>
329         * <li>The smallest location prefix wholly contained in the current view
330         *     <em>that contains the user's selected target centroid</a>.
331         * <li>The largest location prefix wholly contained in the current view.
332         * <li>The smallest such Estd entry that wholly contains the current view.
333         * <li>The smallest such Estd entry whose centre is in the current view.
334         * <ol>
335         *
336         * @param aeps  parameters with which map to display is chosen; not null
337         * @param lm  map to do lookup in; not null
338         *
339         * @return virtual prefix lookup into
340         *     LocationMap.getMapFromNamePrefixToLocation() or "" if none
341         */
342        public static Name getViewLocationMapVirtualPrefix(final AEParams aeps,
343                                                           final LocationMap lm)
344            {
345            // Optimisation: at max zoom just return empty title immediately.
346            if(aeps.getZoomFactor() == MIN_ZOOM)
347                { return(Name.EMPTY); }
348    
349            // Get prefix map.
350            final Map<Name,Location.Base> prefixMap = lm.getMapFromNamePrefixToLocation();
351            // Get current view area as seen on the screen and as requested...
352            final Location.Estd viewArea = aeps.getLocation(true);
353            final Location.Estd viewAreaUnconstrained = aeps.getLocation(false);
354    
355            // Current best result.
356            Name result = null;
357            // Area of current best result.
358            Location.Estd resultArea = null;
359    
360    
361            // Look for smallest contained prefix/area, if any,
362            // CONTAINING THE CURRENT (UNCONSTRAINED) TARGET CENTROID.
363            for(final Name prefix : prefixMap.keySet())
364                {
365                final Location.Estd area = (Location.Estd) prefixMap.get(prefix);
366                // Screen for usability.
367                if(!_screenForGetViewTitle(prefix, area)) { continue; }
368    
369                // If area not completely contained by view, skip it.
370                if(!viewArea.containsArea(area)) { continue; }
371    
372                // If this area does not contain the target centroid, skip it.
373                if(!area.containsCentre(viewAreaUnconstrained)) { continue; }
374    
375                // If this is smaller than the previous best,
376                // or there is no previous best,
377                // then take it.
378                if((resultArea == null) || resultArea.isLargerThan(area))
379                    {
380                    result = prefix;
381                    resultArea = area;
382                    }
383                }
384            // If we found an enclosed area, return prefix.
385            if(result != null) { return(result); }
386    
387    
388            // Look for largest contained prefix/area, if any.
389            for(final Name prefix : prefixMap.keySet())
390                {
391                final Location.Estd area = (Location.Estd) prefixMap.get(prefix);
392                // Screen for usability.
393                if(!_screenForGetViewTitle(prefix, area)) { continue; }
394    
395                // If area not completely contained by view, skip it.
396                if(!viewArea.containsArea(area)) { continue; }
397    
398                // If this is larger than the previous best,
399                // or there is no previous best,
400                // then take it.
401                if((resultArea == null) || area.isLargerThan(resultArea))
402                    {
403                    result = prefix;
404                    resultArea = area;
405                    }
406                }
407            // If we found an enclosed area, return prefix.
408            if(result != null) { return(result); }
409    
410    
411            // Look for smallest enclosing prefix/area, if any.
412            for(final Name prefix : prefixMap.keySet())
413                {
414                final Location.Estd area = (Location.Estd) prefixMap.get(prefix);
415                // Screen for usability.
416                if(!_screenForGetViewTitle(prefix, area)) { continue; }
417    
418                // If area does not completely contain view, skip it.
419                if(!area.containsArea(viewArea)) { continue; }
420    
421                // If this is smaller than the previous best,
422                // or there is no previous best,
423                // then take it.
424                if((resultArea == null) || resultArea.isLargerThan(area))
425                    {
426                    result = prefix;
427                    resultArea = area;
428                    }
429                }
430            // If we found an enclosing area, return prefix.
431            if(result != null) { return(result); }
432    
433    
434            // Look for smallest prefix/area whose centre is in view, if any.
435            for(final Name prefix : prefixMap.keySet())
436                {
437                final Location.Estd area = (Location.Estd) prefixMap.get(prefix);
438                // Screen for usability.
439                if(!_screenForGetViewTitle(prefix, area)) { continue; }
440    
441                // If view does not contain centre of area, skip it.
442                if(!viewArea.containsCentre(area)) { continue; }
443    
444                // If this is smaller than the previous best,
445                // or there is no previous best,
446                // then take it.
447                if((resultArea == null) || resultArea.isLargerThan(area))
448                    {
449                    result = prefix;
450                    resultArea = area;
451                    }
452                }
453            // If we found an enclosing area, return prefix.
454            if(result != null) { return(result); }
455    
456            return(Name.EMPTY); // Nothing found.
457            }
458    
459        /**Screens entry from Location map; returns true if usable.
460         * Returns true if:
461         * <ul>
462         * <li>Name indicates a places-and-sights entry.
463         * <li>The area is smaller than whole Earth area.
464         * </ul>
465         */
466        private static boolean _screenForGetViewTitle(final Name prefix,
467                                                      final Location.Estd area)
468            {
469            // If not a places-and-sights prefix, ignore it.
470            if(!TextUtils.startsWith(prefix, _GVT_CATEGORY_LU_PREFIX)) { return(false); }
471    
472            // Explicitly reject items that have area of whole Earth,
473            // ie don't carry much information as labels.
474            if((area.getN().error.intValue() >= Location.Estd.MAX_N) &&
475               (area.getE().error.intValue() >= Location.Estd.MAX_E))
476                { return(false); }
477    
478            return(true); // Seems OK.
479            }
480    
481    
482        /**True if labels run horizontally, false if vertical. */
483        public static final boolean LABEL_HORIZ = true;
484    
485        /**Label clearance (ie approximately the typical text height or 1em) in pixels; strictly positive. */
486        public static final int LABEL_CLEARANCE_PX = 20;
487    
488        /**Overlay thumbnail clearance is longest (small) thumbnail dimension. */
489        public static final int LABEL_TN_CLEARANCE_PX = ExhibitThumbnails.SML_STATIC_IMAGE_TN_LDIM_PX;
490    
491        /**Maximum number of labels to show; strictly positive.
492         * This could be partially determined by the map image size,
493         * ie how many labels can be comfortably shown.
494         */
495        public static final int LABEL_COUNT_MAX = Math.max(5, Math.min(20,
496            (LABEL_HORIZ ? DISPLAY_2D_EARTH_MAP_HEIGHT : DISPLAY_2D_EARTH_MAP_WIDTH) /
497                                                       (2 * LABEL_CLEARANCE_PX)));
498    
499        /**Gets locationMap keys for labels to add to the map; result may be empty but is never null.
500         * More important labels are earlier in the list.
501         * <p>
502         * We always try to include the title label, if available.
503         * <p>
504         * We then count how many uses of each prefix there are in this view,
505         * and essentially return the top LABEL_COUNT_MAX of them,
506         * but exclude any that are too close to any already selected
507         * (and maybe those that are too near an edge to be read properly).
508         * <p>
509         * We are only interested in items wholly contained in the current view.
510         *
511         * @return best-first list of prefixes thumbnails; never null
512         */
513        public static List<Name> getViewLocationMapLabelPrefixes(
514                                    final AEParams aeps,
515                                    final LocationMap lm,
516                                    final AlohaEarthMapCache cache)
517            {
518            if((aeps == null) || (lm == null) || (cache == null))
519                { throw new IllegalArgumentException(); }
520    
521            final long startTime = System.currentTimeMillis();
522    
523            final List<Name> result = new ArrayList<Name>(LABEL_COUNT_MAX);
524    
525            // Return the heading prefix/label first if available
526            // and regardless of its position on the map.
527            final Name headingPrefix = getViewLocationMapVirtualPrefix(aeps, lm);
528            if(0 != headingPrefix.length()) { result.add(headingPrefix); }
529    
530            final Location.Estd currentView = aeps.getLocation(true);
531    
532            // Count the number of exhibits in this view with each location.
533            // The keys are Location.Estd instances which, for this to work,
534            // we assume to have useful equality and/or have been intern()ed.
535            // The values are instances of int[1] as counters.
536            // When done we try to map back from Location to prefix.
537            final Map<Location.Estd, int[]> counts = new HashMap<Location.Estd, int[]>();
538            final List<Name.ExhibitFull> exhibits = cache.selectContainedExhibits(aeps);
539            for(final Name.ExhibitFull ex : exhibits)
540                {
541                final Location.Base location = cache.aep.getLocation(ex);
542                if(!(location instanceof Location.Estd)) { continue; }
543                final Location.Estd locE = (Location.Estd) location;
544    
545                // Ignore items not enclosed in the current viewport.
546                if(!currentView.containsArea(locE)) { continue; }
547    
548                int[] count = counts.get(locE);
549                if(count == null)
550                    {
551                    count = new int[1];
552                    counts.put(locE, count);
553                    }
554                ++count[0];
555                }
556    
557    if(IsDebug.isDebug) { System.out.println("[AEUtils.getViewLocationMapLabelPrefixes(): counts.size(): "+(counts.size())+".]"); }
558    
559            // Get list of all the Locations found.
560            final ArrayList<Location.Estd> locations = new ArrayList<Location.Estd>(counts.keySet());
561            // Sort into order by count, highest count first.
562            Collections.sort(locations, new Comparator<Location.Estd>()
563                {
564                public final int compare(final Location.Estd estd1, final Location.Estd estd2)
565                    {
566                    final int count1 = counts.get(estd1)[0];
567                    final int count2 = counts.get(estd2)[0];
568                    if(count1 > count2) { return(-1); } // Correct order.
569                    if(count1 < count2) { return(+1); } // Wrong order.
570                    return(0);
571                    }
572                });
573    
574            // We will use the location lookup maps both ways...
575            final Map<Name,Location.Base> mapFromNamePrefixToLocation = lm.getMapFromNamePrefixToLocation();
576            final Map<Location.Base,Name> mapFromLocationToNamePrefix = lm.getMapFromLocationToNamePrefix();
577    
578            // Go through locations in descending-count order,
579            // picking those that do not clash with existing items,
580            // until we hit our label-count limit.
581            vetLocations: for(final Location.Estd l : locations)
582                {
583                if(result.size() >= LABEL_COUNT_MAX) { break; }
584    
585                final Name prefix = mapFromLocationToNamePrefix.get(l);
586                if(prefix == null)
587                    {
588    if(IsDebug.isDebug) { System.out.println("[AEUtils.getViewLocationMapLabelPrefixes(): no prefix for location "+l+", COUNT="+(counts.get(l)[0])+".]"); }
589                    continue;
590                    }
591    
592                // Avoid duplicates, eg of the title prefix.
593                if(result.contains(prefix))
594                    {
595    //if(IsDebug.isDebug) { System.out.println("[AEUtils.getViewLocationMapLabelPrefixes(): already have prefix "+prefix+".]"); }
596                    continue;
597                    }
598    
599                final Point displayPixel = aeps.getDisplayPixelForEstdLocationCentre(l);
600    
601    //            // Avoid anything too close to any edge...
602    //            if(displayPixel == null)
603    //                {
604    //if(IsDebug.isDebug) { System.out.println("[AEUtils.getViewLocationMapLabelPrefixes(): cannot compute display pixel for location "+l+".]"); }
605    //                continue;
606    //                }
607    //            if((displayPixel.x < LABEL_CLEARANCE_PX) ||
608    //               (displayPixel.y < LABEL_CLEARANCE_PX) ||
609    //               (displayPixel.x >= DISPLAY_2D_EARTH_MAP_WIDTH - LABEL_CLEARANCE_PX) ||
610    //               (displayPixel.y >= DISPLAY_2D_EARTH_MAP_HEIGHT - LABEL_CLEARANCE_PX))
611    //                {
612    ////if(IsDebug.isDebug) { System.out.println("[AEUtils.getViewLocationMapLabelPrefixes(): too close to edge: "+prefix+".]"); }
613    //                continue;
614    //                }
615    
616                // Avoid too close vertically or horizontally to any extant result.
617                // This avoids the labels colliding with whatever length/alignment,
618                // whether written vertically or horizontally.
619                for(final Name r : result)
620                    {
621                    final Location.Base loc = mapFromNamePrefixToLocation.get(r);
622                    assert(loc != null);
623                    assert(loc instanceof Location.Estd);
624                    final Location.Estd locE = (Location.Estd) loc;
625                    final Point dp = aeps.getDisplayPixelForEstdLocationCentre(locE);
626                    assert(dp != null);
627                    if((Math.abs(dp.x - displayPixel.x) < LABEL_CLEARANCE_PX) ||
628                       (Math.abs(dp.y - displayPixel.y) < LABEL_CLEARANCE_PX))
629                        {
630    //if(IsDebug.isDebug) { System.out.println("[AEUtils.getViewLocationMapLabelPrefixes(): too close to exiting result: "+prefix+".]"); }
631                        continue vetLocations;
632                        }
633                    }
634    
635                // Looks OK to add this prefix to the results...
636                result.add(prefix);
637                }
638    
639    if(IsDebug.isDebug) { System.out.println("[AEUtils.getViewLocationMapLabelPrefixes(): result.size(): "+(result.size())+", time="+(System.currentTimeMillis() - startTime)+"ms.]"); }
640    
641            return(result);
642            }
643    
644        /**Preamble for label overlay HTML. */
645        private static final String LABEL_HTML_PREAMBLE = "<ul id=\"labels\" style=\"list-style-type:none; position:absolute; left:0; top:0; width:0; height:0; margin:0; padding:0;\">";
646    
647        /**Postamble for label overlay HTML. */
648        private static final String LABEL_HTML_POSTAMBLE = "</ul>";
649    
650        /**If true then do a "poor-man's drop-shadow" to help improve contrast. */
651        private static final boolean LABEL_PMDS = true;
652    
653        /**Create HTML label text to overlay the map; returns "" if none, never null.
654         * This creates an unnumbered list with one or more absolutely-positioned
655         * (within the encompassing relatively positioned parent element).
656         * If there are no labels to show then this does not create the UL element
657         * and the result is "".
658         * <p>
659         * The first view labels may be rendered more strongly.
660         */
661        public static String getViewLocationLabels(final AEParams aeps,
662                                                   final DataSourceBean dsb,
663                                                   final AlohaEarthMapCache aemfb,
664                                                   final boolean doThumbnails)
665            {
666            final long startTime = System.currentTimeMillis();
667    
668            final LocationMap lm = aemfb.aep.epgi.getLocationMap();
669    
670            final List<Name> prefixes = getViewLocationMapLabelPrefixes(aeps, lm, aemfb);
671            if(prefixes.isEmpty()) { return(""); }
672    
673            final Map<Name,Location.Base> mapFromNamePrefixToLocation = lm.getMapFromNamePrefixToLocation();
674            final int capacity = LABEL_HTML_PREAMBLE.length() + LABEL_HTML_POSTAMBLE.length() +
675                        (LABEL_PMDS?512:256)*(doThumbnails?2:1)*prefixes.size();
676            final StringBuilder sb = new StringBuilder(capacity);
677    
678            final List<Name.ExhibitFull> exhibitsForTNs = doThumbnails ? aemfb.selectContainedExhibits(aeps) : null;
679    
680            // Allow a little local computation time,
681            // but not so much as to drive users mad, we hope...
682            final long localStart = System.currentTimeMillis();
683            final long stopBy = localStart + CoreConsts.MAX_INTERACTIVE_DELAY_MS +
684                                (localStart - startTime); // Spend at least as long as our subroutines did...
685    
686            // True until we've gathered at least one requested thumbnail...
687            boolean stillToGetThumbnail = doThumbnails;
688    
689            // Generate HTML for each label...
690            int ordinal = 0;
691            for(final Name prefix : prefixes)
692                {
693                final Location.Base loc = mapFromNamePrefixToLocation.get(prefix);
694                if(!(loc instanceof Location.Estd)) { continue; }
695                final Location.Estd eloc = ((Location.Estd) loc);
696                final Point p = aeps.getDisplayPixelForEstdLocationCentre(eloc);
697                if(p == null) { continue; }
698    
699                // Emphasise first few labels...
700                final boolean strong = (++ordinal <= 3);
701    
702                // Compute label link target.
703                final String labelHref = AEUtils.AE_SELF_RRURL + (new AEParams()).setLocation(eloc).getAsGetPathInfo(false);
704    
705                // Offset label very slightly so as not to obscure the target.
706                final int leftPX = p.x + 1;
707                final int topPX = p.y + 1;
708                // Use part after the '/' as the label,
709                // and strip off the trailing '-' too.
710                final String labelText = prefix.subSequence(1 + TextUtils.indexOf(prefix, '/'),
711                                                          prefix.length()-1).toString();
712    
713                String imgHTML = null;
714                if(doThumbnails &&
715                   (stillToGetThumbnail || (System.currentTimeMillis() <= stopBy)))
716                    {
717                    GenProps gp = new GenProps();
718                    try { gp = dsb.getGenProps(-1); } catch(final IOException e) { }
719    
720                    // Best thumbnail candidate found so far; null if none.
721                    Name.ExhibitFull bestSoFar = null;
722                    // Score of best so far; initially neutral.
723                    int bestScore = 0;
724    
725                    // Find (first) good (small) thumbnail image for each label.
726                    for(final Name.ExhibitFull exhibitName : exhibitsForTNs)
727                        {
728                        // If this exhibit can't have a thumbnail then skip it.
729                        if(!(ExhibitMIME.getInputFileType(aemfb.aep.aeid.getStaticAttr(exhibitName).getCharSequence())).canPossiblyCreateThumbnailOfSameMIMEType())
730                            { continue; }
731    //System.out.println("CAN HAVE TN: " + exhibitName);
732                        // Skip if this exhibit does not have the right location.
733                        if(!eloc.equals(aemfb.aep.getLocation(exhibitName)))
734                            { continue; }
735    //System.out.println("CORRECT LOC: " + exhibitName);
736                        // If this exhibit is "sensitive" then skip it.
737                        if(GenUtils.isSensitive(exhibitName, gp))
738                            { continue; }
739                        // Skip if this exhibit is not notably good,
740                        // or at least the best non-bad one so far!
741                        // Don't necessarily force goodness value to be computed.
742                        final boolean runningLate = System.currentTimeMillis() >= stopBy;
743                        final ExhibitPropsComputableMutable epcm = aemfb.aep.getExhibitPropsComputableMutable(exhibitName, runningLate, gp, dsb, dsb.getScorerCache());
744                        final int goodness = epcm.getGoodness();
745                        // Skip anything not at least as that we already have...
746                        if(goodness <= bestScore)
747                            { continue; }
748    if(IsDebug.isDebug) { System.out.println("BEST SO FAR: " + exhibitName); }
749                        final boolean isGood = Boolean.TRUE.equals(epcm.isGood());
750                        // Only accept less-than-good if we have nothing at all yet.
751                        if((bestSoFar != null) && !isGood)
752                            { continue; }
753    if(IsDebug.isDebug) { System.out.println("IS GOOD: " + exhibitName); }
754    if(IsDebug.isDebug) { System.out.println("  RUNNING LATE: " + runningLate); }
755                        // Skip if this exhibit does not have a small thumbnail.
756                        // If we get here then it may well be worth trying hard,
757                        // at least once, to create/fetch a missing thumbnail...
758                        // Keep going until we have at least one.
759                        final boolean createTN = stillToGetThumbnail || !runningLate;
760                        final ExhibitThumbnails thumbnails = dsb.getThumbnails(exhibitName, createTN);
761                        if((thumbnails == null) || (thumbnails.getSmall() == null))
762                            { continue; }
763    if(IsDebug.isDebug) { System.out.println("HAS SMALL TN: " + exhibitName); }
764    
765                        // OK, this is the best so far!
766                        bestSoFar = exhibitName;
767                        bestScore = goodness;
768    
769                        // We seem to have a candidate...
770                        // Create img tag for thumbnail...
771                        final ExhibitPropsComputable epc = aemfb.aep.getExhibitPropsComputable(exhibitName);
772                        final java.awt.Dimension xyDim = (epc == null) ? null : epc.getXyDimensions();
773                        final java.awt.Dimension thumbnailXyDim = ExhibitThumbnails.computeThumbnailDimensions(xyDim, false);
774                        final String rrURL = WebUtils.makeThumbnailRRURL(exhibitName, false);
775                        final CharSequence mainWordsComponent = ExhibitName.getMainWordsComponent(exhibitName, ExhibitAttrUtils.getAttrWords().getAttrWordsSortedSet());
776                        imgHTML = "<img src=\"" + rrURL + "\" border=\"0\" " +
777                                    ((thumbnailXyDim == null) ? "" :
778                                        "width=\""+thumbnailXyDim.width+"\" height=\""+thumbnailXyDim.height+"\" " +
779                                        "style=\"position:relative; left:-"+(thumbnailXyDim.width/2)+"px; top:-"+(thumbnailXyDim.height/2)+"px;\" ") +
780                                  "alt=\"" + mainWordsComponent + "\" " +
781                                  "title=\"" + mainWordsComponent + "\">";
782    
783    if(IsDebug.isDebug) { System.out.println("FIRST TN FOR OVERLAY: " + stillToGetThumbnail); }
784                        // We've done at least one thumbnail as requested.
785                        stillToGetThumbnail = false;
786    
787                        // We can stop immediately if this is positively good,
788                        // and if we're short of time,
789                        // else we'll keep hunting for the "best" image.
790                        if(isGood && runningLate)
791                            { break; }
792                        }
793                    }
794    
795                // If there's an image then put it in first, partly transparent,
796                // so that we can overlay the label text on top of it.
797                if(imgHTML != null)
798                    {
799                    sb.append("<li>");
800                    if(labelHref != null) { sb.append("<a href=\"").append(labelHref).append("\">"); }
801                    sb.append("<span style=\"opacity:").append(strong ? "0.9" : "0.7").append("; ").
802                              append("position:absolute; ").
803                              append("left:").append(leftPX).append("px; ").
804                              append("top:").append(topPX).append("px; ");
805                        sb.append("\">");
806                    sb.append(imgHTML);
807                    sb.append("</span>");
808                    if(labelHref != null) { sb.append("</a>"); }
809                    sb.append("</li>");
810                    }
811    
812                if(LABEL_PMDS)
813                    {
814                    // Create crude drop-shadow to help improve contrast.
815                    appendLabelHTML(sb, leftPX+1, topPX+1, strong, labelText, "black", labelHref);
816                    }
817    
818                // Main label.
819                // Allow label text to use extant page colour scheme.
820                appendLabelHTML(sb, leftPX, topPX, strong, labelText, null, labelHref);
821                }
822    
823            if(sb.length() == 0)
824                { return(""); }
825            sb.insert(0, LABEL_HTML_PREAMBLE);
826            sb.append(LABEL_HTML_POSTAMBLE);
827    
828    if(IsDebug.isDebug && (sb.length() > capacity)) { System.err.println("WARNING: getViewLocationLabels(): length="+sb.length()+", capacity="+capacity); }
829    
830    if(IsDebug.isDebug) { System.out.println("[getViewLocationLabels(): "+(System.currentTimeMillis()-startTime)+"ms.]"); }
831    
832            return(sb.toString());
833            }
834    
835        /**Generate HTML for one overlay label and append to given StringBuilder.
836         * The text label's content, colour, position and strength is chose.
837         *
838         * @param labelColour  colour for label text; null if default
839         * @param labelHref  href for label text; null if none
840         */
841        private static void appendLabelHTML(final StringBuilder sb,
842                                            final int leftPX,
843                                            final int topPX,
844                                            final boolean strong,
845                                            final String labelText,
846                                            final String labelColour,
847                                            final String labelHref)
848            {
849            sb.append("<li>");
850            if(labelHref != null) { sb.append("<a href=\"").append(labelHref).append("\">"); }
851            if(strong) { sb.append("<big>"); }
852            sb.append("<span style=\"white-space:nowrap; position:absolute; ").
853                      append("left:").append(leftPX).append("px; ").
854                      append("top:").append(topPX).append("px; ");
855                if(labelColour != null) { sb.append("color:").append(labelColour).append(";"); }
856                sb.append("\">");
857            sb.append(labelText);
858            sb.append("</span>");
859            if(strong) { sb.append("</big>"); }
860            if(labelHref != null) { sb.append("</a>"); }
861            sb.append("</li>");
862            }
863    
864    
865        /**Key for AE-linked data in DataSourceBean.
866         * Package-only to keep scope as narrow as possible!
867         */
868        static final DataSourceBean.AEPLinkedKey AEKey = new DataSourceBean.AEPLinkedKey("AlohaEarth");
869    
870        /**Get the filter bean associated with the current DataSourceBean; never null.
871         * A new empty value is created on first use and when the AEP changes.
872         */
873        public static AlohaEarthMapCache getAemfb(final DataSourceBean dsb)
874            throws IOException
875            {
876            if(dsb == null) { throw new IllegalArgumentException(); }
877    
878            AlohaEarthMapCache aemfb;
879            while((aemfb = (AlohaEarthMapCache) dsb.getAEPLinkedValue(AEKey)) == null)
880                { dsb.putIfAbsentAEPLinkedValue(AEKey, new AlohaEarthMapCache(dsb)); }
881    
882            return(aemfb);
883            }
884        }