001    package edu.nrao.sss.model.source.experiment;
002    
003    import java.awt.Color;
004    import java.awt.Dimension;
005    import java.awt.FontMetrics;
006    import java.awt.Graphics2D;
007    import java.awt.Rectangle;
008    import java.awt.geom.AffineTransform;
009    import java.awt.geom.Ellipse2D;
010    import java.awt.geom.Line2D;
011    import java.awt.geom.NoninvertibleTransformException;
012    import java.awt.geom.Point2D;
013    import java.awt.geom.Rectangle2D;
014    import java.awt.image.BufferedImage;
015    import java.awt.image.RenderedImage;
016    import java.math.BigDecimal;
017    import java.util.ArrayList;
018    import java.util.Collection;
019    import java.util.HashMap;
020    import java.util.List;
021    import java.util.Map;
022    import java.util.Random;
023    
024    import edu.nrao.sss.astronomy.SimpleSkyPosition;
025    import edu.nrao.sss.astronomy.SkyPosition;
026    import edu.nrao.sss.astronomy.SkyPositionFilter;
027    import edu.nrao.sss.geom.AzimuthalEquidistantProjector;
028    import edu.nrao.sss.geom.Circle;
029    import edu.nrao.sss.geom.SphericalPosition;
030    import edu.nrao.sss.measure.Angle;
031    import edu.nrao.sss.measure.ArcUnits;
032    import edu.nrao.sss.measure.Latitude;
033    import edu.nrao.sss.measure.Longitude;
034    import edu.nrao.sss.model.source.FileBasedBrightness;
035    import edu.nrao.sss.model.source.Source;
036    import edu.nrao.sss.model.source.SourceBrightness;
037    import edu.nrao.sss.model.source.SourceFilter;
038    import edu.nrao.sss.model.source.SourceImageLink;
039    import edu.nrao.sss.model.source.SourceVelocity;
040    import edu.nrao.sss.model.source.Subsource;
041    import edu.nrao.sss.util.StringUtil;
042    
043    /**
044     * A map of the celestial sphere.
045     * <p>
046     * <b>Version Info:</b>
047     * <table style="margin-left:2em">
048     *   <tr><td>$Revision: 1711 $</td></tr>
049     *   <tr><td>$Date: 2008-11-14 13:00:45 -0700 (Fri, 14 Nov 2008) $</td></tr>
050     *   <tr><td>$Author: dharland $ (last person to modify)</td></tr>
051     * </table></p>
052     * 
053     * @author David M. Harland
054     * @since 2007-07-30
055     */
056    public class SourceMap
057    {
058      /** Creates a new instance. */
059      public SourceMap()
060      {
061        initializeColors();
062    
063        height = DEFAULT_HEIGHT;
064        width  = DEFAULT_WIDTH;
065        
066        allSources        = new HashMap<String, Collection<SourceWrapper>>();
067        visibleSources    = new ArrayList<SourceWrapper>();
068        sourceCircles     = new HashMap<SourceWrapper, Circle>();
069        sourceFilter      = new SourceFilter();
070        positionFilter    = new SkyPositionFilter();
071        sourceListChanged = false;
072        
073        sourceFilter.setPositionFilter(positionFilter);
074        
075        initRaDecBins();
076    
077        centerPosition = new SimpleSkyPosition();
078        centerMoved    = true;
079        skyRadius      = new Angle("5.0", ArcUnits.DEGREE);  //TODO: Replace this magic #
080        radiusGrew     = true;
081    
082        projector               = new AzimuthalEquidistantProjector();
083        skyToPixel              = new AffineTransform();
084        circleSpacingInSkyUnits = calcSkyCircleSpacing(skyRadius.getValue().doubleValue());
085    
086        updatePixelSizeAndLocation();
087        updateCenterRadiusVariables();
088      }
089    
090      //============================================================================
091      // SECTION: COLORS & FONTS
092      //============================================================================
093    
094      private static final Color FILL_COLOR          = new Color(40, 40, 80);
095      private static final Color SPACE_DISK_COLOR    = Color.BLACK;
096      private static final Color OUTER_CIRCLE_COLOR  = Color.GREEN;
097      private static final Color INNER_CIRCLES_COLOR = new Color(255, 255, 0, 128);
098      private static final Color LAT_LON_COLOR       = Color.RED;
099    //private static final Color SOURCE_COLOR        = Color.WHITE;
100    
101      private static final int DEFAULT_HEIGHT = 500;  //pixels
102      private static final int DEFAULT_WIDTH  = 500;  //pixels
103      
104      private Color colorFill;
105      private Color colorSpaceDisk;
106      private Color colorOuterCircle;
107      private Color colorInnerCircles;
108      private Color colorLatLon;
109      
110      private Map<String, Color> sourceColors;
111      
112      float fontSize;
113      
114      /** Sets colors to default values. */
115      private void initializeColors()
116      {
117        colorFill         = FILL_COLOR;
118        colorSpaceDisk    = SPACE_DISK_COLOR;
119        colorOuterCircle  = OUTER_CIRCLE_COLOR;
120        colorInnerCircles = INNER_CIRCLES_COLOR;
121        colorLatLon       = LAT_LON_COLOR;
122        
123        sourceColors = new HashMap<String, Color>();
124        
125        int alpha = 255;
126        sourceColors.put("GBT",  new Color(180,255,180,alpha));
127        sourceColors.put("VLA",  new Color(255,150,255,alpha));
128        sourceColors.put("VLBA", new Color(255,255,150,alpha));
129      }
130    
131      //TODO clients should be able to manipulate all the colors
132    
133      /** Should be called whenever map size changes. */
134      private void updateFontSize()
135      {
136        //TODO: Replace magic #s.
137        
138        fontSize = Math.max(8.0f, Math.min(16.0f, (float)pixelDimMin / (500.0f/12.0f)));
139        
140        if (pixelDimMin < 150)
141          fontSize = 0.0f;
142      }
143      
144      /**
145       * @param collectionName
146       * @param color
147       */
148      public void setColorSourceCollection(String collectionName, Color color)
149      {
150        String name = collectionName.toUpperCase();
151        
152        sourceColors.put(name, color);
153      }
154      
155      //============================================================================
156      // SECTION: SIZE
157      //============================================================================
158    
159      private int height;
160      private int width;
161    
162      public int getWidth()
163      {
164        return width;
165      }
166      
167      public int getHeight()
168      {
169        return height;
170      }
171    
172      public void setSize(Dimension size)
173      {
174        setSize(size.width, size.height);
175      }
176      
177      public void setSize(int width, int height)
178      {
179        if (this.width != width || this.height != height)
180        {
181          this.width  = width;
182          this.height = height;
183    
184          updatePixelSizeAndLocation();
185          calculateVisibleSourceLocationAndSize();
186        }
187      }
188    
189      /** Updates the variables that deal with pixel dimensions and center. */
190      private void updatePixelSizeAndLocation()
191      {
192        //TODO: Replace magic #s.
193        
194        pixelDimMin      = Math.min(width, height);
195        pixelBorder      = pixelDimMin / 30;
196        pixelRadiusMax   = pixelDimMin / 2.0 - pixelBorder;
197        pixelCenter      = pixelRadiusMax + pixelBorder;
198        
199        updateFontSize();
200    
201        updatePixelToSkyUnits();
202      }
203    
204      //============================================================================
205      // SECTION: SOLAR SYSTEM
206      //============================================================================
207    
208      //============================================================================
209      // SECTION: SOURCES
210      //============================================================================
211    
212      private Map<String, Collection<SourceWrapper>> allSources;
213      private Collection<SourceWrapper>              visibleSources;
214      
215      private Map<SourceWrapper, Circle> sourceCircles;
216      private Map<SourceWrapper, Color>  visibleSourceColor;
217      
218      private boolean sourceListChanged;
219      
220      //The purpose of this variable is to speed up updates of the map
221      //when moving its center or increasing its radius.  By keeping the
222      //sources in RA & Dec bins, we can present fewer of them to the
223      //source filter.
224      private List<List<List<SourceWrapper>>> raDecBins;
225    
226      private SourceFilter      sourceFilter;
227      private SkyPositionFilter positionFilter;
228    
229      public void addSource(Source newSource)
230      {
231        addSource(newSource, "Anonymous");
232      }
233      
234      /**
235       * @param newSources
236       */
237      public void addSources(Collection<? extends Source> newSources)
238      {
239        addSources(newSources, "Anonymous");
240      }
241    
242      /**
243       * @param newSource
244       * @param collectionName
245       */
246      public void addSource(Source newSource, String collectionName)
247      {
248        String name = collectionName.toUpperCase();
249        
250        //Get the collection of sources stored under the given name
251        Collection<SourceWrapper> namedCollection = getSourceCollection(name);
252        
253        //Add the new source to our collection, and to the RA/Dec bins
254        SourceWrapper wrapper = new SourceWrapper(newSource);
255        namedCollection.add(wrapper);
256        addToBins(wrapper);
257        
258        sourceListChanged = true;
259      }
260    
261      /**
262       * @param newSources
263       * @param collectionName
264       */
265      public void addSources(Collection<? extends Source> newSources,
266                             String                       collectionName)
267      {
268        String name = collectionName.toUpperCase();
269        
270        //TODO: Think about using a separate thread for this method --
271        //      or using one only if size > N.
272    
273        //Get the collection of sources stored under the given name
274        Collection<SourceWrapper> namedCollection = getSourceCollection(name);
275        
276        //Add the new sources to our collection, and to the RA/Dec bins
277        for (Source newSource : newSources)
278        {
279          SourceWrapper wrapper = new SourceWrapper(newSource);
280          namedCollection.add(wrapper);
281          addToBins(wrapper);
282        }
283        
284        sourceListChanged = true;
285      }
286      
287      //public void mapSources(String sourceCollectionName, boolean show)
288      
289      //public void removeSources(String collectionName)
290      
291      /**
292       * Returns a collection of sources for the given name.
293       * If this map has no such collection, one is created, stored, and returned.
294       */
295      private Collection<SourceWrapper> getSourceCollection(String name)
296      {
297        name = name.toUpperCase();
298        
299        Collection<SourceWrapper> namedCollection = allSources.get(name);
300        
301        if (namedCollection == null)
302        {
303          namedCollection = new ArrayList<SourceWrapper>();
304          allSources.put(name, namedCollection);
305        }
306        
307        return namedCollection;
308      }
309    
310      /** Adds a new source to the RA/Dec cache of this map. */
311      private void addToBins(SourceWrapper newSource)
312      {
313        //TODO: coord conversion
314        
315        SkyPosition srcPos = newSource.source.getPosition();
316        
317        double raDeg  = srcPos.getLongitude().toUnits(ArcUnits.DEGREE).doubleValue();
318        double decDeg = srcPos.getLatitude().toUnits(ArcUnits.DEGREE).doubleValue();
319        
320        int raIndex  = getSourceBinIndexRa(raDeg);
321        int decIndex = getSourceBinIndexDec(decDeg);
322        
323        raDecBins.get(decIndex).get(raIndex).add(newSource);
324      }
325      
326      /** Clears the RA/Dec cache of this map.
327      
328      not yet used
329      
330      private void clearBins()
331      {
332        for (List<List<SourceWrapper>> decList : raDecBins)
333          for (List<SourceWrapper> raList : decList)
334            raList.clear();
335      }*/
336    
337      /**
338       * Updates this map's source filter and applies it to its sources in order
339       * to decide which sources are visibile on the map.  This method is called
340       * whenever sources are added or removed, when the center of the map
341       * is moved, or when the radial size of the map is changed.
342       * 
343       * The logic is a little more complex than it need be for merely having
344       * it work; the extra complexity exists to speed up the screen updates
345       * when dealing with thousands of sources.
346       */
347      private void updateAndApplySourceFilter()
348      {
349        //System.out.print("Updating viewable sources from list of ");
350        //long ms = System.currentTimeMillis();  //TODO TEMP
351        
352        //Update position filter
353        //(We're intentionally NOT setting the filter's cone.)
354        
355        ArcUnits units = skyRadius.getUnits();
356        
357        //Latitude range
358        Latitude latFrom = new Latitude(new BigDecimal(skyLatMin), units);
359        Latitude latTo   = new Latitude(new BigDecimal(skyLatMax), units);
360        
361        positionFilter.setLatitudeRange(latFrom, latTo);
362    
363        //Longitude range
364        Longitude lonFrom = new Longitude(new BigDecimal(skyLonMin), units);
365        Longitude lonTo   = new Longitude(new BigDecimal(skyLonMax), units);
366        
367        if (poleInView)
368          lonTo.setToFullCircle();
369        
370        positionFilter.setLongitudeRange(lonFrom, lonTo);
371        
372        //Quickly trim list of candidates on which to filter
373        ArrayList<SourceWrapper> candidates = new ArrayList<SourceWrapper>();
374    
375        //Take a subset of allSources
376        if (centerMoved || radiusGrew || sourceListChanged)
377        {
378          int latIndexMin = getSourceBinIndexDec(latFrom.toUnits(ArcUnits.DEGREE).doubleValue());
379          int latIndexMax = getSourceBinIndexDec(latTo.toUnits(ArcUnits.DEGREE).doubleValue());
380          
381          int lonIndexMin1 = getSourceBinIndexRa(lonFrom.toUnits(ArcUnits.DEGREE).doubleValue());
382          int lonIndexMax1 = getSourceBinIndexRa(lonTo.toUnits(ArcUnits.DEGREE).doubleValue());
383          
384          final int MAX_LON_INDEX = 35; //360/10 - 1
385          lonIndexMax1 = Math.min(lonIndexMax1, MAX_LON_INDEX);
386    
387          int lonIndexMin2 = -1;
388          int lonIndexMax2 = -2;
389          
390          //See if lon range goes through zero point
391          if (lonIndexMax1 < lonIndexMin1)
392          {
393            lonIndexMin2 = lonIndexMin1;
394            lonIndexMax2 = MAX_LON_INDEX;
395            
396            lonIndexMin1 = 0;
397            //lonIndexMax1 = lonIndexMax1;
398          }
399          
400          //Trim list of candidates to those that are in the min/max lon/lat box
401          for (int latIndex=latIndexMin; latIndex <= latIndexMax; latIndex++)
402          {
403            List<List<SourceWrapper>> sourceLists = raDecBins.get(latIndex);
404            
405            for (int lonIndex=lonIndexMin1; lonIndex <= lonIndexMax1; lonIndex++)
406              candidates.addAll(sourceLists.get(lonIndex));
407            
408            for (int lonIndex=lonIndexMin2; lonIndex <= lonIndexMax2; lonIndex++)
409              candidates.addAll(sourceLists.get(lonIndex));
410          }
411        }
412        //Look only at currently displayed sources.
413        //(Use case: zooming-in on center.)
414        else
415        {
416          candidates.addAll(visibleSources);
417        }
418    
419        //Apply to filter to candidate sources
420        visibleSources.clear();
421        for (SourceWrapper candidate : candidates)
422        {
423          if (sourceFilter.allows(candidate.source))
424            visibleSources.add(candidate);
425        }
426        
427        calculateVisibleSourceLocationAndSize();
428        updateVisibleSourceColors();
429        
430        centerMoved       = false;
431        radiusGrew        = false;
432        sourceListChanged = false;
433        
434        //ms = System.currentTimeMillis() - ms;  //TODO TEMP
435        //System.out.println(candidates.size() + " sources took " + ms/1000.0 + " seconds.");
436      }
437        
438      /**
439       * 
440       */
441      private void calculateVisibleSourceLocationAndSize()
442      {
443        //TODO Calculate radius based on brightness
444        final double RADIUS = 6;
445        
446        Map<SourceWrapper, Circle> oldMap = sourceCircles;
447        sourceCircles = new HashMap<SourceWrapper, Circle>();
448        
449        for (SourceWrapper vs : visibleSources)
450        {
451          if (false) //(oldMap.containsKey(vs))
452          {
453            sourceCircles.put(vs, oldMap.get(vs));
454          }
455          else
456          {
457            Point2D xySkyPoint   = projector.getXyFor(vs.source.getPosition());
458            Point2D xyPixelPoint = skyToPixel.transform(xySkyPoint, null);
459    
460            sourceCircles.put(vs, new Circle(xyPixelPoint, RADIUS));
461          }
462        }
463      }
464      
465      /**
466       * 
467       */
468      private void updateVisibleSourceColors()
469      {
470        Map<SourceWrapper, Color> oldMap = visibleSourceColor;
471        visibleSourceColor = new HashMap<SourceWrapper, Color>();
472        
473        for (SourceWrapper vs : visibleSources)
474        {
475          if (oldMap.containsKey(vs))
476          {
477            visibleSourceColor.put(vs, oldMap.get(vs));
478          }
479          else
480          {
481            for (String collectionName : allSources.keySet())
482            {
483              if (allSources.get(collectionName).contains(vs))
484              {
485                Color color = sourceColors.get(collectionName);
486                
487                if (color == null)
488                  color = assignColorTo(collectionName);
489                
490                visibleSourceColor.put(vs, color);
491                
492                break; //inner collectionName loop
493              }
494            }
495          }
496        }
497      }
498      
499      /**
500       * @param sourceCollectionName
501       * @return
502       */
503      private Color assignColorTo(String sourceCollectionName)
504      {
505        String name = sourceCollectionName.toUpperCase();
506        
507        Random rand = new Random();
508        
509        int red   = rand.nextInt(56) + 200;
510        int green = rand.nextInt(56) + 200;
511        int blue  = 655 - red - green;
512        
513        Color c = new Color(red, green, blue, 255);  //TODO allow client to set alpha
514        
515        sourceColors.put(name, c);
516        
517        return c;
518      }
519      
520      /**
521       * Prepares raDecBins variable for presorting of sources into
522       * RA/Dec blocks.  This presorting helps the speed with which
523       * this map is updated as the user zooms out or moves the center.
524       */
525      private void initRaDecBins()
526      {
527        raDecBins = new ArrayList<List<List<SourceWrapper>>>();
528        
529        for (int dec=-90; dec <= +90; dec+=10)
530        {
531          List<List<SourceWrapper>> decList = new ArrayList<List<SourceWrapper>>();
532    
533          for (int ra=0; ra < 360; ra+=10)
534            decList.add(new ArrayList<SourceWrapper>());
535          
536          raDecBins.add(decList);
537        }
538      }
539    
540      /** Calculates an index into sourceBins for the given RA. */
541      private int getSourceBinIndexRa(double raDegrees)
542      {
543        return (int)Math.floor(raDegrees / 10.0);
544      }
545      
546      /** Calculates an index into sourceBins for the given Declination. */
547      private int getSourceBinIndexDec(double decDegrees)
548      {
549        return (int)Math.floor((decDegrees + 90.0) / 10.0);
550      }
551    
552      /**
553       * Allows our collections of sources to use reference, not value,
554       * equality.  This makes it possible to put multiple instances
555       * of the "same" source into the map, as would be the case for
556       * catalogs that have overlap of their sources.  It also makes
557       * it easy to remove one copy of the source when an entire
558       * catalog is removed.
559       */
560      private class SourceWrapper
561      {
562        Source source;
563        
564        SourceWrapper(Source s)  { source = s; }
565        
566        public boolean equals(Object other)  { return other == this; }
567      }
568      
569      //============================================================================
570      // SECTION: MAP CENTER & RADIUS
571      //============================================================================
572      //----------------------------------------------------------------------------
573      // Direct Client Input
574      //----------------------------------------------------------------------------
575    
576      SimpleSkyPosition centerPosition;
577      Angle             skyRadius;
578    
579      //----------------------------------------------------------------------------
580      // Variables Derived at the Time of Client Input
581      //----------------------------------------------------------------------------
582    
583      //TODO change from double to BigDecimal?
584      
585      private double circleSpacingInSkyUnits;
586    
587      //Maps points so that distances from center on screen show true relative dists
588      AzimuthalEquidistantProjector projector;
589    
590      //The variables below are calculated once each time the center or radius
591      //changes for the convenience of several methods.
592    
593      //A quarter circle in the units of skyRadius
594      double skyQuarterCircle;
595    
596      //All in units of skyRadius
597      private double skyLatMin, skyLatCtr, skyLatMax;
598      private double skyLonMin, skyLonCtr, skyLonMax;
599    
600      //True if the outermost circle includes a pole
601      private boolean poleInView;
602    
603      private boolean centerMoved;
604      private boolean radiusGrew;
605    
606      //----------------------------------------------------------------------------
607      // Variables Updated Just-in-Time
608      //----------------------------------------------------------------------------
609    
610      //Transforms sky units to pixels
611      AffineTransform skyToPixel;
612    
613      //Lesser of height and width
614      private int pixelDimMin;
615    
616      //The distance between the circle representing the map's radius
617      //and the edge of the black outerspace circle.
618      private int pixelBorder;
619    
620      //The radius, in pixels, of the outermost of the concentric circles.
621      //This circle represents the map's radius in sky units.
622      private double pixelRadiusMax;
623    
624      //The center of the circle, in pixels, for x & y
625      double pixelCenter;
626    
627      //The ratio of pixels to sky-units (degrees, radians, etc.)
628      private double pixelsPerSkyUnit;
629    
630      //----------------------------------------------------------------------------
631      // Methods
632      //----------------------------------------------------------------------------
633      
634      /**
635       * Sets {@code newPosition} as the center of this map.
636       * 
637       * @param newPosition
638       *          the new center of this map.
639       */
640      public void setCenterPosition(SphericalPosition newPosition)
641      {
642        //Sometimes this class updates centerPosition and sends it here.
643        //No need copying from centerPostion into centerPosition.
644        if (newPosition != centerPosition)  //intentional reference comparison
645        {
646          centerPosition.setLongitude(newPosition.getLongitude().clone());
647          centerPosition.setLatitude(newPosition.getLatitude().clone());
648        }
649    
650        centerMoved = true;
651        
652        updateCenterRadiusVariables();
653      }
654      
655    
656      //TODO public void setCenterSource(Source centerSource)
657    
658      /**
659       * Sets the radius, in angular sky units, of this map.
660       * 
661       * @param newRadius
662       *          the radius, in angular sky units, of this map.
663       */
664      public void setSkyRadius(Angle newRadius)
665      {
666        radiusGrew = (newRadius.compareTo(skyRadius) > 0);
667        
668        skyRadius = newRadius.clone();
669    
670        circleSpacingInSkyUnits = calcSkyCircleSpacing(skyRadius.getValue().doubleValue());
671    
672        updateCenterRadiusVariables();
673      }
674      
675    
676      /**
677       * Updates internal variables that depend on centerPosition and skyRadius.
678       * 
679       * Must be called when either the SKY RADIUS or the SKY CENTER is changed.
680       */
681      private void updateCenterRadiusVariables()
682      {
683        updatePixelToSkyUnits();
684        updateLatLonVars();
685        updateXyTransformer();
686        updateAndApplySourceFilter();
687      }
688      
689    
690      /**
691       * Configures the affine transform of the mapping projector to return values
692       * that are scaled to the current sky radius based on this map's center
693       * position.
694       */
695      private void updateXyTransformer()
696      {
697        //Reset the transformer for a new calc
698        projector.setCenter(centerPosition);
699        projector.getXyTransformer().setToIdentity();
700    
701        double skyRadiusValue = skyRadius.getValue().doubleValue();
702    
703        //Set scaling in sky units, ignoring pixels for now
704        Latitude refLat = centerPosition.getLatitude().clone();
705        Latitude latLen = new Latitude(new BigDecimal(skyRadiusValue), skyRadius.getUnits());
706        if (refLat.isNorthOfEquator())
707          refLat.subtract(latLen);
708        else
709          refLat.add(latLen);
710    
711        //We pick a point at the same longitude as the center, but at a latitude
712        //that is one radius, in sky units, north or south. We then send that
713        //reference position to the mapping projector and get its y coordinate,
714        //which is a distance from the center. By taking the ratio of the
715        //sky radius to the x-y radius, we get a multiplier that we can use
716        //for transforming the rest of the points.
717        SimpleSkyPosition refPos = new SimpleSkyPosition();
718        
719        refPos.setLongitude(centerPosition.getLongitude());
720        refPos.setLatitude(refLat);
721        
722        double skyScale =
723          skyRadiusValue / Math.abs(projector.getXyFor(refPos).getY());
724    
725        projector.getXyTransformer().setToScale(skyScale, skyScale);
726        
727        //TODO: firePropertyChange("projection", null, null); ?
728      }
729      
730      /** Updates convenience variables based on centerPosition & skyRadius. */
731      private void updateLatLonVars()
732      {
733        ArcUnits units  = skyRadius.getUnits();
734        double   radius = skyRadius.getValue().doubleValue();
735    
736        skyLatCtr = centerPosition.getLatitude().toUnits(units).doubleValue();
737        skyLonCtr = centerPosition.getLongitude().toUnits(units).doubleValue();
738    
739        skyQuarterCircle = units.toQuarterCircle().doubleValue();
740    
741        skyLatMin = Math.max(-skyQuarterCircle, skyLatCtr - radius);
742        skyLatMax = Math.min(+skyQuarterCircle, skyLatCtr + radius);
743    
744        poleInView = (skyLatMin <= -skyQuarterCircle || skyLatMax >= +skyQuarterCircle);
745    
746        //If our outermost circle touches a pole, special logic
747        if (poleInView)
748        {
749          skyLonMin = 0.0;
750          skyLonMax = units.toFullCircle().doubleValue();
751        }
752        else //not touching either pole
753        {
754          skyLatMin = skyLatCtr - radius;
755          skyLatMax = skyLatCtr + radius;
756    
757          radius = radius /
758                   Math.cos(centerPosition.getLatitude().toUnits(ArcUnits.RADIAN).doubleValue());
759    
760          skyLonMin = skyLonCtr - radius;
761          skyLonMax = skyLonCtr + radius;
762        }
763      }
764      
765      /** Updates variables that deal with ratio of pixels to sky units. */
766      private void updatePixelToSkyUnits()
767      {
768        pixelsPerSkyUnit = pixelRadiusMax / skyRadius.getValue().doubleValue();
769    
770        skyToPixel.setToTranslation(pixelCenter, pixelCenter);
771        skyToPixel.scale(pixelsPerSkyUnit, -pixelsPerSkyUnit);
772      }
773    
774      //============================================================================
775      // SECTION: PAINTING
776      //============================================================================
777    
778      /**
779       * Paints this map on the given graphics context.
780       * @param g
781       */
782      public void paint(Graphics2D g)
783      {
784        //The list of visible sources is updated elsewhere when the center
785        //or radius of the map is changed. However, it is NOT updated when
786        //a new source is added, so we must update it here.
787        if (sourceListChanged)
788          updateAndApplySourceFilter();
789        
790        g.setFont(g.getFont().deriveFont(fontSize));
791        g.setColor(colorFill);
792        g.fill(new Rectangle(width, height));
793    
794        paintConcentricCircles(g);
795    
796        paintLatLonLines(g);
797    
798        paintSources(g);
799    
800        //Draw central "+"
801        g.setColor(colorOuterCircle);
802    
803        int center    = (int)(pixelRadiusMax + pixelBorder);
804        int halfWidth = 3;
805        
806        g.drawLine(center - halfWidth, center, center + halfWidth, center);
807        g.drawLine(center, center - halfWidth, center, center + halfWidth);
808      }
809    
810      /**
811       * Takes a snapshot of this map in its current state and returns it as an
812       * image.
813       * @return an image of the current state of this map.
814       */
815      public RenderedImage takeSnapshot()
816      {
817        BufferedImage image = 
818          new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
819        
820        paint((Graphics2D)image.getGraphics());
821        
822        return image;
823      }
824    
825      //----------------------------------------------------------------------------
826      // Sources
827      //----------------------------------------------------------------------------
828    
829      /** Represents each visible source as a small circle. */
830      private void paintSources(Graphics2D g)
831      {
832        for (SourceWrapper s : sourceCircles.keySet())
833        {
834          g.setColor(visibleSourceColor.get(s));
835          
836          Circle c = sourceCircles.get(s);
837          
838          g.fill(c);
839          
840          Rectangle2D bounds = c.getBounds2D();
841          
842          //if (false)  //TODO see if & when we want to use labels
843            g.drawString(s.source.getName(), Math.round(bounds.getMaxX()),
844                                             Math.round(bounds.getMinY()));
845        }
846      }
847    
848      //----------------------------------------------------------------------------
849      // Bullseye Circles
850      //----------------------------------------------------------------------------
851    
852      /**
853       * Creates a black "outerspace" circle and a series of concentric unfilled
854       * circles, the outermost of which represents the radius in sky-units
855       * requested by the client.
856       */
857      private void paintConcentricCircles(Graphics2D g2)
858      {
859        //Draw & fill the black outerspace circle
860        //Ellipse2D circle = new Ellipse2D.Double(0, 0, pixelDimMin, pixelDimMin);
861        Ellipse2D circle = new Ellipse2D.Double(pixelBorder, pixelBorder, pixelDimMin-2*pixelBorder, pixelDimMin-2*pixelBorder);
862        g2.setColor(colorSpaceDisk);
863        g2.fill(circle);
864    
865        //g2.setClip(circle.getBounds());
866        g2.setClip(0, 0, pixelDimMin, pixelDimMin);
867    
868        //Prepare to draw concentric circles
869        g2.setColor(colorInnerCircles);
870    
871        BigDecimal circleSpacingInSkyUnits_BD =
872          BigDecimal.valueOf(circleSpacingInSkyUnits);
873        
874        Angle mapAngle = new Angle(circleSpacingInSkyUnits_BD, skyRadius.getUnits());
875    
876        //Determines how many concentric circles are drawn
877        double pixelSpacing = circleSpacingInSkyUnits * pixelsPerSkyUnit;
878    
879        double radiusPixels = pixelSpacing;
880    
881        //Draw the inner concentric circles
882        while (radiusPixels < pixelRadiusMax)
883        {
884          paintCircle(g2, radiusPixels,
885                      pixelRadiusMax + pixelBorder - radiusPixels, pixelCenter,
886                      mapAngle);
887    
888          radiusPixels += pixelSpacing;
889          mapAngle.add(circleSpacingInSkyUnits_BD);
890        }
891    
892        //Draw the outermost circle
893        g2.setColor(colorOuterCircle);
894        paintCircle(g2, pixelRadiusMax, pixelBorder, pixelCenter, skyRadius);
895      }
896    
897      /**
898       * Paints a circle. Double values are in pixels. Angle in sky units.
899       */
900      private void paintCircle(Graphics2D g2, double radius, double border,
901                               double center, Angle keyAngle)
902      {
903        double diameter = 2.0 * radius;
904    
905        g2.draw(new Ellipse2D.Double(border, border, diameter, diameter));
906    
907        FontMetrics fm = g2.getFontMetrics();
908    
909        //Key text in map units
910        String keyText = keyAngle.toString(1,4);
911        double textWidth = fm.getStringBounds(keyText, g2).getWidth();
912        int textX = (int) (center - textWidth / 2.0);
913        int textY = (int) (center - radius);
914    
915        g2.drawString(keyText, textX, textY);
916      }
917    
918      /** Tries to derive a "good" spacing for the concentric circles. */
919      private double calcSkyCircleSpacing(double searchRadiusMax)
920      {
921        //Get a number between 10 & 100
922        double expMinusOne = Math.floor(Math.log10(searchRadiusMax)) - 1;
923        double tenToX = Math.pow(10.0, expMinusOne);
924        int x = (int) Math.round(searchRadiusMax / tenToX);
925    
926        //Bring it down to between 2 & 20
927        int y = Math.round((float) x / 5.0f);
928    
929        //Values: 1,2,3,4,5,10,15,20
930        int s = y;
931    
932        if      (s >= 17)  s = 20;
933        else if (s >= 13)  s = 15;
934        else if (s >=  8)  s = 10;
935        else if (s >   6)  s =  5;
936        //else leave as is
937    
938        //Put back into original scale
939        return s * tenToX;
940      }
941    
942      //----------------------------------------------------------------------------
943      // Longitude & Latitude Lines
944      //----------------------------------------------------------------------------
945    
946      /** Creates dashed lines for latitude and longitude. */
947      private void paintLatLonLines(Graphics2D g2)
948      {
949        g2.setColor(colorLatLon);
950    
951        paintLatitudeLines(g2, 6);
952        paintLongitudeLines(g2, 6);
953      }
954    
955      /** Creates dashed lines for latitude. */
956      private void paintLatitudeLines(Graphics2D g2, int numLines)
957      {
958        ArcUnits units = skyRadius.getUnits();
959    
960        //Set up positions with latitudes from min to max
961        SimpleSkyPosition[] pos = new SimpleSkyPosition[numLines];
962        double angle     = skyLatMin;
963        double angleIncr = (skyLatMax - skyLatMin) / (double) (numLines - 1);
964        for (int p = 0; p < numLines; p++)
965        {
966          pos[p] = new SimpleSkyPosition();
967          pos[p].getLatitude().set(BigDecimal.valueOf(angle), units);
968          angle += angleIncr;
969        }
970    
971        //Draw the latitude lines by varying longitude
972        boolean writeLabels = false; // true;
973        double incr = (skyLonMax - skyLonMin) / 100.0;
974        for (double lon = skyLonMin; lon <= skyLonMax; lon += incr)
975        {
976          for (int p = 0; p < numLines; p++)
977          {
978            pos[p].getLongitude().set(BigDecimal.valueOf(lon), units);
979            Point2D xySkyPoint   = projector.getXyFor(pos[p]);
980            Point2D xyPixelPoint = skyToPixel.transform(xySkyPoint, null);
981            g2.draw(new Line2D.Double(xyPixelPoint, xyPixelPoint));
982    
983            if (writeLabels)
984              g2.drawString(pos[p].getLatitude().toStringDms(),
985                            (int)xyPixelPoint.getX(),
986                            (int)xyPixelPoint.getY());
987          }
988          writeLabels = false;
989        }
990      }
991    
992      /** Creates dashed lines for longitude. */
993      private void paintLongitudeLines(Graphics2D g2, int numLines)
994      {
995        ArcUnits units = skyRadius.getUnits();
996    
997        //Set up positions with longitudes from min to max
998        SimpleSkyPosition[] pos = new SimpleSkyPosition[numLines];
999        double angle     = skyLonMin;
1000        double angleIncr = (skyLonMax - skyLonMin) / (double) (numLines - 1);
1001        for (int p = 0; p < numLines; p++)
1002        {
1003          pos[p] = new SimpleSkyPosition();
1004          pos[p].getLongitude().set(BigDecimal.valueOf(angle), units);
1005          angle += angleIncr;
1006        }
1007    
1008        //Draw the longitude lines by varying latitude
1009        double incr = (skyLatMax - skyLatMin) / 100.0;
1010        for (double lat = skyLatMin; lat <= skyLatMax; lat += incr)
1011        {
1012          for (int p = 0; p < numLines; p++)
1013          {
1014            pos[p].getLatitude().set(BigDecimal.valueOf(lat), units);
1015            Point2D xySkyPoint   = projector.getXyFor(pos[p]);
1016            Point2D xyPixelPoint = skyToPixel.transform(xySkyPoint, null);
1017            g2.draw(new Line2D.Double(xyPixelPoint, xyPixelPoint));
1018          }
1019        }
1020      }
1021    
1022      //============================================================================
1023      // 
1024      //============================================================================
1025      
1026      /**
1027       * Calculates and returns a position on the celestial sphere given an
1028       * x,y point on the display.
1029       * 
1030       * @param x the x-coordinate of a pixel on the display.
1031       * @param y the y-coordinate of a pixel on the display.
1032       * @return a position on the celestial sphere.
1033       * @throws NoninvertibleTransformException if (x,y) cannot be converted to
1034       *           a position on the celestial sphere.
1035       */
1036      SphericalPosition calculatePositionFor(int x, int y)
1037        throws NoninvertibleTransformException
1038      {
1039        Point2D skyPos = skyToPixel.inverseTransform(new Point2D.Double(x,y), null);
1040    
1041        return projector.getLatLonFor(skyPos);
1042      }
1043    
1044      //============================================================================
1045      // 
1046      //============================================================================
1047      
1048      /**
1049       * This method probably does not belong here; just experimenting for now.
1050       */
1051      public StringBuilder toHtmlImageMap(String mapName)
1052      {
1053        //The list of visible sources is updated elsewhere when the center
1054        //or radius of the map is changed. However, it is NOT updated when
1055        //a new source is added, so we must update it here.
1056        if (sourceListChanged)
1057          updateAndApplySourceFilter();
1058        
1059        final String jsMethod = "writeText";
1060        final String EOL      = StringUtil.EOL;
1061        
1062        StringBuilder buff = new StringBuilder();
1063        
1064        buff.append("<map id=\"").append(mapName)
1065            .append("\" name=\"").append(mapName).append("\">")
1066            .append(EOL);
1067        
1068        for (SourceWrapper s : sourceCircles.keySet())
1069        {
1070          Circle c = sourceCircles.get(s);
1071          Point2D center = c.getCenter();
1072          
1073          buff.append("  <area shape=\"circle\" coords=\"");
1074          buff.append(Math.round(center.getX())).append(',');
1075          buff.append(Math.round(center.getY())).append(',');
1076          buff.append(Math.round(c.getRadius())).append('\"');
1077          
1078          buff.append(" nohref=\"true\"").append(EOL);
1079    
1080          buff.append("        onMouseOver=\"").append(jsMethod).append("('");
1081          fillSourceInfo(s, buff);
1082          buff.append("')\"");
1083    
1084          //buff.append(EOL);
1085          //buff.append("        onMouseOut=\"").append(jsMethod).append("('')\"");
1086          
1087          buff.append("/>").append(EOL);
1088        }
1089        
1090        buff.append("</map>").append(EOL);
1091        
1092        return buff;
1093      }
1094      
1095      //Another method that doesn't belong; just an experiment
1096      private void fillSourceInfo(SourceWrapper sw, StringBuilder buff)
1097      {
1098        Source s = sw.source;
1099        
1100        buff.append("<h2>").append(s.getName()).append("</h2>");
1101        
1102        if (s.getAliases().size() > 0)
1103        {
1104          buff.append("<i>Aliases:</i>");
1105          
1106          for (String alias : s.getAliases())
1107            buff.append(' ').append(alias);
1108          
1109          buff.append("<br/>");
1110        }
1111    
1112        SkyPosition pos = s.getPosition();
1113        
1114        String lonAbbr = pos.getCoordinateSystem().getAbbreviationForLongitude(); 
1115        buff.append("<i>").append(lonAbbr).append(":</i> ");
1116        buff.append(pos.getLongitude().toStringHms(3,3));
1117        buff.append("<br/>");
1118        
1119        String latAbbr = pos.getCoordinateSystem().getAbbreviationForLatitude();
1120        buff.append("<i>").append(latAbbr).append(":</i> ");
1121        buff.append(pos.getLatitude().toStringDmsHtml(3,3)).append("<br/>");
1122        buff.insert(buff.lastIndexOf("&#x0027"), '\\');
1123        
1124        buff.append("<i>Uncertainties (mas):</i>").append(" ");
1125        buff.append(lonAbbr).append(" ");
1126        buff.append(pos.getLongitudeUncertainty().toUnits(ArcUnits.MILLI_ARC_SECOND));
1127        buff.append(", ");
1128        buff.append(latAbbr).append(" ");
1129        buff.append(pos.getLatitudeUncertainty().toUnits(ArcUnits.MILLI_ARC_SECOND));
1130        buff.append("<br/>");
1131        
1132        buff.append("<i>Flux Densities:</i>").append("<br/>");
1133        Subsource ss = s.getCentralSubsource();
1134        List<SourceBrightness> sbs = ss.getBrightnesses();
1135        boolean foundSb = false;
1136        for (SourceBrightness sb : sbs)
1137        {
1138          if (sb instanceof FileBasedBrightness)
1139            continue;
1140          
1141          buff.append("&nbsp;&nbsp;");
1142          buff.append(sb.getTotalFluxDensity());
1143          buff.append(" (").append(sb.getValidFrequency()).append(")<br/>");
1144          
1145          foundSb = true;
1146        }
1147        if (!foundSb)
1148          buff.append("&nbsp;&nbsp;No Information<br/>");
1149        
1150        buff.append("<i>Velocities:</i>").append("<br/>");
1151        List<SourceVelocity> svs = ss.getVelocities();
1152        if (svs.size() == 0)
1153        {
1154          buff.append("&nbsp;&nbsp;No Information<br/>");
1155        }
1156        else
1157        {
1158          for (SourceVelocity sv : svs)
1159          {
1160            buff.append("&nbsp;&nbsp;");
1161            buff.append(sv.getRadialVelocity());
1162            buff.append(" (").append(sv.getValidFrequency()).append(")<br/>");
1163          }
1164        }
1165        
1166        buff.append("<i>Images:</i>").append("<br/>");
1167        List<SourceImageLink> images = s.getImageLinks();
1168        if (images.size() == 0)
1169        {
1170          buff.append("&nbsp;&nbsp;None<br/>");
1171        }
1172        else
1173        {
1174          for (SourceImageLink image : images)
1175          {
1176            String anchor = image.toHtmlAnchor().replaceAll("\"", "&quot;");
1177            buff.append("&nbsp;&nbsp;").append(anchor).append("<br/>");
1178          }
1179        }
1180      }
1181    }