Home > Software engineering >  Clamp geodesic to min/max latitude with d3
Clamp geodesic to min/max latitude with d3

Time:12-05

I am using d3 to plot a route on a map:

const width = 400;
const height = width / 2;

const projection = d3.geoEquirectangular()
  .translate([width / 2, height / 2])
  .scale((width - 1) / 2 / Math.pi);

const route_projection = d3.geoEquirectangular()
  .translate([width / 2, height / 2])
  .scale((width - 1) / 2 / Math.pi);
//.preclip(SOME CLIPPING FUNCTION)

const route_path = d3.geoPath()
  .projection(route_projection);

const path = d3.geoPath()
  .projection(projection);

const zoom = d3.zoom()
  .extent([
    [0, 0],
    [width, height]
  ])
  .translateExtent([
    [0, 0],
    [width, height]
  ])
  .scaleExtent([1, Infinity])
  .on("zoom", zoomed);

let svg = d3.select('#map')
  .append("svg")
  .attr("width", width)
  .attr("height", height);

svg.call(zoom);

svg.append("rect")
  .attr("class", "background")
  .attr("fill", "#b4dcfc")
  .attr("width", width)
  .attr("height", height);

let g = svg.append('g');

let geojson = {}

let curve = {
  "type": "FeatureCollection",
  "features": [{
      "type": "Feature",
      "properties": {
        "population": 200
      },
      "geometry": {
        "type": "LineString",
        "coordinates": [
          [-56.12, -35.50],
          [47.81, -25.37],
          [78.71, 7.73]
        ]
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [-56.12, -35.50]
      },
      "properties": {
        "name": "Dinagat Islands"
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [47.81, -25.37]
      },
      "properties": {
        "name": "Dinagat Islands"
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [78.71, 7.73]
      },
      "properties": {
        "name": "Dinagat Islands"
      }
    }
  ]
}

function createMap() {
  projection.fitSize([width, height], geojson);
  route_projection.fitSize([width, height], geojson);
  g
    .attr("id", "dmas")
    .selectAll("path")
    .data(geojson.features)
    .enter()
    .append("path")
    .attr("fill", "white")
    .attr("stroke", "#222")
    .attr("stroke-width", 1)
    .attr("vector-effect", "non-scaling-stroke")
    .attr("d", path);

  g.append("path")
    .datum(curve)
    .attr("id", "route")
    .attr("d", route_path)
    .attr("fill", "none")
    .attr("stroke", 'red')
    .attr("stroke-width", 2)
    .attr("vector-effect", "non-scaling-stroke")
    .attr("stroke-opacity", 1);

}

function zoomed(transform) {
  g
    .selectAll('path')
    .attr('transform', transform.transform)
  // .attr('stroke-width', 2/transform.transform.k)
}

d3.json("https://cdn.jsdelivr.net/npm/world-atlas@2/countries-50m.json")
  .then(function(data) {
    geojson = topojson.feature(data, data.objects.countries);
    createMap();
  })
<script src="https://unpkg.com/[email protected]/dist/d3-geo-polygon.min.js"></script>
<script src="https://unpkg.com/[email protected]/dist/topojson.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.2.0/d3.min.js"></script>

<div id="map"></div>
<iframe name="sif1" sandbox="allow-forms allow-modals allow-scripts" frameborder="0"></iframe>

On route_projection, I want to use a pre-clip to clamp the maximum latitude of the red route to above -40deg latitude, so it would end up as a straight line from South America to South Africa.

like this: enter image description here

How can I do this?

CodePudding user response:

A clipping function won't achieve the correct effect as it will cut your features not coerce them to follow that line.

Instead, we can combine your projection with a geotransform to force the line to adhere to rules within the projected space:

const limit = d3.geoTransform({
  point: function(x,y) {
    this.stream.point(x, Math.min(y, projection([0,-36])[1]));
  }
});

const route_projection = {
  stream: function(s) {
      return projection.stream(limit.stream(s));
  }
};

const route_path = d3.geoPath()
  .projection(route_projection);

This means we only need one equirectangular projection rather than two, we just combine it with the geoTransform stream and pass that combined stream to the 2nd path generator:

Show code snippet

const width = 400;
const height = width / 2;

const projection = d3.geoEquirectangular()
  .translate([width / 2, height / 2])
  .scale((width - 1) / 2 / Math.pi);


const limit = d3.geoTransform({
  point: function(x,y) {
    this.stream.point(x, Math.min(y, projection([0,-36])[1]));
  }
});

const route_projection = {
  stream: function(s) {
      return projection.stream(limit.stream(s));
  }
};

const route_path = d3.geoPath()
  .projection(route_projection);

const path = d3.geoPath()
  .projection(projection);

const zoom = d3.zoom()
  .extent([
    [0, 0],
    [width, height]
  ])
  .translateExtent([
    [0, 0],
    [width, height]
  ])
  .scaleExtent([1, Infinity])
  .on("zoom", zoomed);

let svg = d3.select('#map')
  .append("svg")
  .attr("width", width)
  .attr("height", height);

svg.call(zoom);

svg.append("rect")
  .attr("class", "background")
  .attr("fill", "#b4dcfc")
  .attr("width", width)
  .attr("height", height);

let g = svg.append('g');

let geojson = {}

let curve = {
  "type": "FeatureCollection",
  "features": [{
      "type": "Feature",
      "properties": {
        "population": 200
      },
      "geometry": {
        "type": "LineString",
        "coordinates": [
          [-56.12, -35.50],
          [47.81, -25.37],
          [78.71, 7.73]
        ]
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [-56.12, -35.50]
      },
      "properties": {
        "name": "Dinagat Islands"
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [47.81, -25.37]
      },
      "properties": {
        "name": "Dinagat Islands"
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [78.71, 7.73]
      },
      "properties": {
        "name": "Dinagat Islands"
      }
    }
  ]
}

function createMap() {
  projection.fitSize([width, height], geojson);

  g
    .attr("id", "dmas")
    .selectAll("path")
    .data(geojson.features)
    .enter()
    .append("path")
    .attr("fill", "white")
    .attr("stroke", "#222")
    .attr("stroke-width", 1)
    .attr("vector-effect", "non-scaling-stroke")
    .attr("d", path);

  g.append("path")
    .datum(curve)
    .attr("id", "route")
    .attr("d", route_path)
    .attr("fill", "none")
    .attr("stroke", 'red')
    .attr("stroke-width", 2)
    .attr("vector-effect", "non-scaling-stroke")
    .attr("stroke-opacity", 1);

}

function zoomed(transform) {
  g
    .selectAll('path')
    .attr('transform', transform.transform)
}

d3.json("https://cdn.jsdelivr.net/npm/world-atlas@2/countries-50m.json")
  .then(function(data) {
    geojson = topojson.feature(data, data.objects.countries);
    createMap();
  })
  
  
<script src="https://unpkg.com/[email protected]/dist/d3-geo-polygon.min.js"></script>
<script src="https://unpkg.com/[email protected]/dist/topojson.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.2.0/d3.min.js"></script>

<div id="map"></div>
<iframe name="sif2" sandbox="allow-forms allow-modals allow-scripts" frameborder="0"></iframe>

Which should give:

enter image description here

  • Related