Post

Map Visualization with React and D3.js

Map Visualization with React and D3.js

This post was migrated from Tistory. You can find the original here.

Approaches to Map Visualization

What if you use a map API like Naver, Kakao, or Mapbox?

Kakao Map API

  • Pros - No need to draw the map yourself, and you can implement visualization features with very little code.
  • Cons - Hard to customize the default design, and the visualization feature you want might not be available. You may also be charged based on usage.

What if you draw the map yourself on a canvas, like SVG or <canvas>?

d3 example

  • Pros - You have full control over every element being drawn, and using a visualization library like d3 lets you build dynamic graphics.
  • Cons - More code and more state to manage.

Preparing Map Data

If you’ve decided to draw the map yourself, let’s take a look at map data formats.

GeoJSON, TopoJSON

   
CharacteristicGeoJSONTopoJSON
Data structureSimple geographic structures (points, lines, polygons)Topology-based structure (nodes, links, arcs)
Shared boundary handlingBoundaries are stored redundantlyBoundaries are stored once and reused across multiple regions
File sizeLargerCompressed and smaller
Relationships between geographic objectsEach geographic object is independentBoundaries are shared, and relationships between objects are defined
Data conversionGenerally more intuitive and easier to useRequires a conversion tool (e.g. GeoJSON ↔ TopoJSON)
SupportDirectly supported by most map librariesDirectly supported only by some libraries, such as D3.js

https://github.com/vuski/admdongkor

A repo that provides GeoJSON data for South Korea.

https://github.com/raqoon886/Local_HangJeongDong?tab=readme-ov-file

There’s also a repo that splits the South Korea GeoJSON data into administrative dong (neighborhood) units by the 17 metropolitan cities/provinces. (Not recently updated.)

https://mapshaper.org/

The site above lets you convert the GeoJSON you just downloaded from GitHub into TopoJSON.

mapshaper.org

You can upload your GeoJSON to the site, reduce data size by simplifying boundaries in the Simplify menu, and then download it as TopoJSON from the Export menu.

Drawing the Map (React, D3)

https://observablehq.com/@d3/zoom-to-bounding-box

Let’s draw the map based on the example above.

The example uses TopoJSON. I converted the Gangwon-do GeoJSON to TopoJSON and used that.

Note that D3.js manipulates the DOM directly, while React manipulates a virtual DOM.

So in React, we write the code so that D3 manipulates the DOM only after the component has mounted.

▼ Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
import React, { useEffect, useRef, useState } from 'react';
import topoData from './강원도_topojson.json';
import * as topojson from 'topojson-client';
import * as d3 from 'd3';

export const MapVisualize = () => {
    const svgRef = useRef(null);
    const [name, setName] = useState('');

    useEffect(() => {
        if (svgRef.current) {
            const geoData = topojson.feature(topoData, topoData.objects.hangjeongdong_강원도).features;

            const zoom = d3.zoom().scaleExtent([0.3, 10]).on('zoom', zoomed);
            const width = svgRef.current.clientWidth;
            const height = svgRef.current.clientHeight;

            // Prepare the canvas
            const svg = d3
                .select(svgRef.current)
                .attr('viewBox', [0, 0, width, height])
                .attr('width', width)
                .attr('height', height)
                .attr('style', 'max-width: 100%; height: auto;')
                .on('click', reset);

            // Set up the projection
            const projection = d3
                .geoMercator()
                .center([128.35, 37.68]) // Set the center to the target coordinates
                .scale(28000) // Set the initial scale
                .translate([width / 2, height / 2]); // Move to the center of the SVG

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

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

            // Draw the land
            const states = g
                .append('g')
                .attr('fill', '#444')
                .attr('cursor', 'pointer')
                .selectAll('path')
                .data(geoData)
                .join('path')
                .on('click', clicked)
                .attr('d', path);

            svg.call(zoom);
            states.append('title').text((d) => d.properties.adm_nm);

            // Draw the boundaries
            g.append('path')
                .attr('fill', 'none')
                .attr('stroke', 'white')
                .attr('stroke-linejoin', 'round')
                .attr('d', path(topojson.mesh(topoData, topoData.objects.hangjeongdong_강원도, (a, b) => a !== b)));

            function reset() {
                states.transition().style('fill', null);
                setName("");
                svg.transition()
                    .duration(750)
                    .call(zoom.transform, d3.zoomIdentity, d3.zoomTransform(svg.node()).invert([width / 2, height / 2]));
            }

            function clicked(event, d) {
                console.log(d.properties.adm_nm);
                setName(d.properties.adm_nm);
                const [[x0, y0], [x1, y1]] = path.bounds(d);
                event.stopPropagation();
                states.transition().style('fill', null);
                d3.select(this).transition().style('fill', '#181818');
                svg.transition()
                    .duration(750)
                    .call(
                        zoom.transform,
                        d3.zoomIdentity
                            .translate(width / 2, height / 2)
                            .scale(Math.min(8, 0.5 / Math.max((x1 - x0) / width, (y1 - y0) / height))) // AAA
                            .translate(-(x0 + x1) / 2, -(y0 + y1) / 2),
                        d3.pointer(event, svg.node())
                    );
            }

            function zoomed(event) {
                const { transform } = event;
                g.attr('transform', transform);
                g.attr('stroke-width', 1 / transform.k);
            }
        }
    }, []);
    return (
        <div
            style={{
                width: '100vw',
                height: '100vh',
                backgroundColor: '#e2e2e2',
                display: 'flex',
                justifyContent: 'center',
                alignItems: 'center',
                flexDirection: "column"
            }}
        >
            <h1>{name}</h1>
            <div
                style={{
                    width: '80%',
                    height: '90%',
                    backgroundColor: '#acacac',
                }}
            >
                <svg
                    ref={svgRef}
                    style={{
                        width: '100%',
                        height: '100%',
                        border: '1px solid white',
                    }}
                ></svg>
            </div>
        </div>
    );
};

Regarding the formula next to the AAA comment inside clicked():

The 0.5 means that for a state larger than the SVG size, we scale it down to fit, and for a smaller state, we scale it up.

The 8 in front is the upper bound placed on the scale when zooming out to fit a large state.

This post is licensed under CC BY-NC 4.0 by the author.