Sunday, March 3, 2019

How to Put Point Source Data on the Celestial Map

I have added another example how to add your own data to my interactive celestial map. Specifically, how to display point data at their proper position with self-defined symbols. This builds on the previous example How To Put Your Own Data on the Celestial Map It'll look like this:

First we need to add the proper libraries and stylesheet:

<script type="text/javascript" src="https://d3js.org/d3.v3.min.js"></script>
<script type="text/javascript" src="https://d3js.org/d3.geo.projection.v0.min.js"></script>
<script type="text/javascript" src="https://ofrohn.github.io/celestial.min.js"></script>
<link rel="stylesheet" href="https://ofrohn.github.io/celestial.css">

First we have to define the objects as valid geoJSON data again, as described in the readme of the data folder of the project on GitHub. Since we're dealing with point sources, the definition is quite simple, the geometry only needs single points. If distinct point sizes are desired, a size criterion in the properties section is required, like the magnitude or extension of each object, and also a name if you want to label the objects on the map. This example uses supernova remnants filtered from the main deep space objects data file that comes with d3-celestial, but you can define your own data as below:

var jsonSN = {
  "type":"FeatureCollection",
  // this is an array, add as many objects as you want
  "features":[
    {"type":"Feature",
     "id":"SomeDesignator",
     "properties": {
       // Name
       "name":"Some Name",
       // magnitude, dimension in arcseconds or any other size criterion
       "mag": 10,
       "dim": 30
     }, "geometry":{
       // the location of the object as a [ra, dec] array in degrees [-180..180, -90..90]
       "type":"Point",
       "coordinates": [-80.7653, 38.7837]
     }
    }  
  ]};

Next we define the appearance of the objects and labels as they will appear on the map. The values are equivalent to CSS-formats. Fill and stroke colors are only necessary if the objects should appear solid (fill) or as an outline (stroke), or an outline with a semitransparent filling as below. Width gives the line width for outlines.

var pointStyle = { 
      stroke: "#f0f", 
      width: 3,
      fill: "rgba(255, 204, 255, 0.4)"
    };
var textStyle = { 
      fill:"#f0f", 
      font: "bold 15px Helvetica, Arial, sans-serif", 
      align: "left", 
      baseline: "top" 
    };

Now we are ready to add the functions that do the real work of putting the data on the map.

Celestial.add({file:string, type:json|raw, callback:function, redraw:function)

The file argument is optional for providing an external geoJSON file; since we already defined our data, we don't need it. Type is 'line', that leaves two function definitions: the first one is called at loading, this is where we add our data to the d3-celestial data container, while the second function 'redraw* is called at every redraw event for the map. So here you need to define how to display the added object(s). Here are two different possibilities to add data to the D3 data container. Either add the data defined as a JSON-Object in-page, as below with the jsonSN object we defined before.

callback: function(error, json) {
  if (error) return console.warn(error);
  // Load the geoJSON file and transform to correct coordinate system, if necessary
  var dsn = Celestial.getData(jsonSN, config.transform);

  // Add to celestial objects container in d3
  Celestial.container.selectAll(".snrs")
    .data(asterism.features)
    .enter().append("path")
    .attr("class", "snr"); 
  // Trigger redraw to display changes
  Celestial.redraw();
}

Or add data from an external file with optional filtering, as shown below. In this case the file parameter of the Celsestial.add() function needs to give a valid url to the data file, while the filter function returns true for every object that meets the intended criteria.

callback: function(error, json) {
  if (error) return console.warn(error);
  // Load the geoJSON file and transform to correct coordinate system, if necessary
  var dsos = Celestial.getData(json, config.transform);

  // Filter SNRs and add to celestial objects container in d3
  Celestial.container.selectAll(".snrs")
    .data(dsos.features.filter(function(d) {
      return d.properties.type === 'snr'
    }))
    .enter().append("path")
    .attr("class", "snr"); 
  // Trigger redraw to display changes
  Celestial.redraw();
}

However you add the data, as long as they receive the same class name - 'snr' in the examples above - the display method is the same, as shown below. With point data we can't rely on the map function to do all the work, we need to paint on the canvas step by step. First, check if the point is currently displayed (clip), then get the location (mapProjection), size (whatever scaling formula you like) and styling.

Now we are ready to throw pixels at the canvas: set the styles (fill color, stroke color & width), followed by whatever canvas commands are required to draw the object shape, here a filled circle outline. And then the same for the adjacent object name offset by the previously calculated radius.

redraw: function() {
  // Select the added objects by class name as given previously
  Celestial.container.selectAll(".snr").each(function(d) {
    // If point is visible (this doesn't work automatically for points)
    if (Celestial.clip(d.geometry.coordinates)) {
      // get point coordinates
      var pt = Celestial.mapProjection(d.geometry.coordinates);
      // object radius in pixel, could be varable depending on e.g. dimension or magnitude 
      var r = Math.pow(20 - prop.mag, 0.7); // replace 20 with dimmest magnitude in the data
   
      // draw on canvas
      //  Set object styles fill color, line color & width etc.
      Celestial.setStyle(pointStyle);
      // Start the drawing path
      Celestial.context.beginPath();
      // Thats a circle in html5 canvas
      Celestial.context.arc(pt[0], pt[1], r, 0, 2 * Math.PI);
      // Finish the drawing path
      Celestial.context.closePath();
      // Draw a line along the path with the prevoiusly set stroke color and line width      
      Celestial.context.stroke();
      // Fill the object path with the prevoiusly set fill color
      Celestial.context.fill();     

      // Set text styles       
      Celestial.setTextStyle(textStyle);
      // and draw text on canvas
      Celestial.context.fillText(d.properties.name, pt[0] + r - 1, pt[1]- r + 1);
    }      
  }); 
}});

Finally, the whole map can be displayed.

Celestial.display();

Bonus: Avoid overlapping labels

You will note that there is a lot of overlap between distinct labels. Fortunately, d3 already has a solution for this: d3.geom.quadtree, which builds a hierarchical data structure ordered by proximity. First we set the closest allowed distance between two labels in pixels, get the map dimensions from Celestial.metrics, and create a quadtree object with the extent of those dimensions.

var PROXIMITY_LIMIT = 20,    // Closest distance between labels
    m = Celestial.metrics(), // Get the current map size in pixels
    // empty quadtree, will be used for proximity check
    quadtree = d3.geom.quadtree().extent([[-1, -1], [m.width + 1, m. height + 1]])([]);

After proceeding as above - get the projected map position in pixelspace (pt) and draw the snr symbol - we use the quadtree.find() function to find the nearest neighbor relative to this position, and if it is more distant than our limit above, add it to quadtree and draw the label, otherwise don't.

// Find nearest neighbor
var nearest = quadtree.find(pt);

// If neigbor exists, check distance limit
if (!nearest || distance(nearest, pt) > PROXIMITY_LIMIT) { 
  // Nothing too close, add it and go on
  quadtree.add(pt)
  // draw the label
}

Now we need just one more thing, the distance function used above, which is the standard Pythagorean square root of the sum of the differences squared function.

// Simple point distance function
function distance(p1, p2) {
  var d1 = p2[0] - p1[0],
      d2 = p2[1] - p1[1];
  return Math.sqrt(d1 * d1 + d2 * d2);
}

This will only draw non-overlapping labels and scales with zoom-level, since it checks in pixel-space and not in coordinate-space.

Check out the documentation or download/fork the D3-Celestial source in the GitHub repository. The complete sample code is in the file snr.html in the demo folder.

1 comment:

  1. Is there a way to force the new data to stay visible no matter what the zoom level is?

    ReplyDelete