Modify the markup for markers on the SVG map

Viewed 297

I'm new to react and d3 and I'm trying to implement a simple SVG map with D3 v6.2 and ReactJS, which will indicate the location of some cities. Each city is being indicated by circles as shown below:

enter image description here

I need to change this marking to put a pin with the name of the city (using a div if possible) as shown in this image:

enter image description here

But the point's symbol shape is determined by D3-path itself and I can't find anything that can change or replace with another markup.

Note: I tried to put a div within the path tags of the points, but I realized that they were not rendered and the only change I was able to make was to change the radius of the mark with pointRadius () but I don't want to do that.

Code:

import React, { useEffect, useRef, useState } from 'react';
import { select, geoPath, geoMercator } from "d3";
import useResizeObserver from 'use-resize-observer';

import drawMap from './mapa.json';
import dataMap from './city.json';

const svgRef = useRef(null);
const wrapperRef = useRef(null);

const { widthRef, heightRef } = useResizeObserver({ wrapperRef });

function map(){

    useEffect(() => {

        const svg = select(svgRef.current);
        const { width, height } = wrapperRef.current.getBoundingClientRect();
       
        const projection = geoMercator().fitSize([width, height], selectedEstados || drawMap);
        
        let pathGenerator = geoPath().projection(projection).pointRadius(4);
        console.log("path: " + pathGenerator)
        
         svg.selectAll(".estado")
            .data(drawMap.features)
            .join("path")
            .attr("class", "estado")
            .attr("d", features => pathGenerator(features));

         svg.selectAll(".city")
            .data(dataMap.features)
            .join("path")
            .attr("class", "city")
            .attr("d", features => pathGenerator(features))
                        
}, [drawMap, wrapperRef, widthRef, heightRef])

return(
    <div className='map' ref={wrapperRef}>
        <svg ref={svgRef}></svg>
    </div>
)
}

city.json

{
    "type": "FeatureCollection",
    "name": "city",
    "crs": {
        "type": "name",
        "properties": {
            "name": "urn:ogc:def:crs:OGC:1.3:CRS84"
        }
    },
    "features": [
        {
            "type": "Feature",
            "properties": {
                "municipio": "sapé"
                
            },
            "geometry": {
                "type": "Point",
                "coordinates": [
                    -41.821476100786512,
                    -8.282815195947387
                ]
            }
        },
        {
            "type": "Feature",
            "properties": {
                "municipio": "muito distante"
                
            },
            "geometry": {
                "type": "Point",
                "coordinates": [
                    -41.065329661162544,
                    -7.183034162673664
                ]
            }
        }
    ]
}

mapa.json

map geojson here

1 Answers

There are a number of options, the simplest is likely just using SVG paths to draw your pin shapes.

The path generator will not help us here. Instead we'll use the projection directly, projection([longitude,latitude]) will return [x,y] in pixels. This projected x,y coordinate will let us anchor our marker to the correct location on the map. The geojson you are using conveniently has a property containing the longitude and latitude. Below I just use a simple non-geojson array for simplicity.

Below I plot each city as a g element with an appropriate translate, which allows me to then place text, path, and circle elements relative to the city:

var svg = d3.select("body").append("svg").attr("width", 400);
var projection = d3.geoMercator();

var cityData = [{name:"Vancouver",longlat:[-123,49]},{name:"Anchorage",longlat:[-150,61]}];

var cities = svg.selectAll(null)
  .data(cityData)
  .enter()
  .append("g")
  .attr("transform", function(d) {
    return "translate("+projection(d.longlat)+")";
  })
  
// Two different paths for comparison:
cities.append("path")
  .attr("d", (d,i) => ["M0,0 Q0 -20 -40 -20 L-40 -40L40 -40L40 -20 Q 0 -20 0 0",
                       "M0,0 L-40 -20L-40 -40L40 -40L40 -20Z"][i] )
  .attr("fill","#ddd")
  .attr("stroke","black")
  .attr("stroke-width",1);

cities.append("text")
  .attr("dy", -25)
  .text( function(d) { return d.name; })
  .style("text-anchor","middle");
  
// optional circle
cities.append("circle")
  .attr("r", (d,i) => [2,6][i])
  .attr("fill", (d,i) => ["black","white"][i])
  .attr("stroke-width",(d,i) => [0,1][i])
  
circle { stroke:black; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>

In this case, the paths are fixed width: it just happens the names fill the pins out relatively well. You could determine the appropriate width for the pin and use that to create the path.

If you really want a div, you'll need to use an SVG foreignObject element. The reason you didn't see any div rendered is SVG doesn't have a div element in its namespace, HTML and SVG elements are not interchangable. A foreignObject let's us place HTML objects within an SVG. Note: IE does not support ForeignObjects.

Here's a quick mockup with a div in a foreignObject:

var svg = d3.select("body").append("svg").attr("width", 400);
var projection = d3.geoMercator();

var cityData = [{name:"Vancouver",longlat:[-123,49]},{name:"Anchorage",longlat:[-150,61]}];

var cities = svg.selectAll(null)
  .data(cityData)
  .enter()
  .append("g")
  .attr("transform", function(d) {
    return "translate("+projection(d.longlat)+")";
  })
  
var div = cities.append("foreignObject")
  .attr("x", -25)
  .attr("y", -35)
  .attr("width", 100)
  .attr("height", 100)
  .append("xhtml:div")
  .attr("class","box arrow-bottom")
  .text(d=>d.name);
  
cities.append("circle")
  .attr("r", 4)
  .attr("fill","orange");

  
  
/* css from https://codeconvey.com/css-message-box-with-arrow/ */

.box {
  height: auto;
  background-color: black;
  color: #fff;
  position: relative;
}

.box.arrow-bottom:after {
  content: "";
  position: absolute;
  left: 10px;
  bottom: -15px;
  border-top: 15px solid black;
  border-right: 15px solid transparent;
  border-left: 15px solid transparent;
  border-bottom: none;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>

The above is just hand positioned, there would be better examples of css styling that provide a more precise point for positioning.

A third option would be to position divs over the map SVG using absolute coordinates, this requires accounting for the position of the SVG on the page. It also makes zooming/panning slightly more complicated.

Related