I am creating a region wise map, I want to show a circle to all the countries where the country have a corporation setup, I have also enabled zoom feature which seems to be working fine, I just want that when I zoom a particular country the circle should move with it, currently it does not, it is sticked to that part what I tried to do was to delete all the circles on zoom and render them back when I have zoomed in. When I zoom out on a particular country I want the circle for that country to show up and that too in the centroid.
Here is the code I have written by now:
import {
Loading,
Separator,
Spinner,
useMeasure,
useOnClickOutside,
} from 'ideal';
import React, { useEffect, useRef, useState } from 'react';
import * as topojson from 'topojson-client';
import * as d3 from 'd3';
import { Country } from 'country-state-city';
import useCompanyData, { TCorporation } from 'hooks/api-hooks/useCompanyData';
import { useApiDataContext } from 'components/admin/context/api-call-data-context';
import { TGeoJsonFeature, TRegion } from './types';
import {
MAP_GEO_JSON_FILE_BY_REGION_NAME,
REGION_NAME_VS_NAME,
doesHaveEntitiesInCountry,
getEntitiesInCountry,
} from './utils';
const GenericMapComponent = ({ region }: { region: TRegion }) => {
const { token, loggedInUserCompanyId } = useApiDataContext();
const { corporations, error } = useCompanyData(loggedInUserCompanyId, token);
const svgRef = useRef(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const [containerRef, bounds] = useMeasure();
const [isMapLoading, setIsMapLoading] = useState<boolean>(false);
const [geoData, setGeoData] = useState(null);
const [tooltipCountries, setTooltipCountries] = useState<string>('');
const [tooltipCorporations, setTooltipCorporations] = useState<
TCorporation[]
>([]);
const [isZoomed, setZoomed] = useState<boolean>(false);
const zoom = d3
.zoom()
.scaleExtent([1, 8])
.on('zoom', (e) => {
d3.select(svgRef.current)
.selectAll('path')
.attr('transform', e.transform);
});
const renderCircles = (path: d3.GeoPath<any, d3.GeoPermissibleObjects>) => {
const tooltip = d3.select(tooltipRef.current);
const svg = d3.select(svgRef.current);
svg
.selectAll('.country')
.filter((d: TGeoJsonFeature) =>
doesHaveEntitiesInCountry(d, corporations)
)
.each((d: TGeoJsonFeature) => {
const centroid = path.centroid(d as d3.GeoPermissibleObjects);
const shadow = svg
.append('circle')
.attr('cx', centroid[0])
.attr('cy', centroid[1])
.attr('r', 10)
.attr('fill', '#1F1F1F20')
.attr('visibility', 'hidden');
svg
.append('circle')
.attr('cx', centroid[0])
.attr('cy', centroid[1])
.attr('r', 6)
.attr('fill', '#1F1F1F')
.attr('cursor', 'pointer')
.on('mouseover', () => shadow.attr('visibility', 'visible'))
.on('click', (event) => {
event.stopPropagation();
const entitiesInCountry = getEntitiesInCountry(d, corporations);
if (entitiesInCountry.length) {
setTooltipCorporations(entitiesInCountry);
setTooltipCountries(d.properties.ISO_CODE);
tooltip.transition().duration(200).style('opacity', 1);
tooltip
.style('left', `${event.pageX + 20}px`)
.style('top', `${event.pageY - 28}px`);
}
})
.on('mouseout', () => {
shadow.attr('visibility', 'hidden');
});
});
};
const zoomToPath = (d: TGeoJsonFeature, svg: any, path: any) => {
setZoomed(true);
d3.selectAll('circle').remove();
const updatedBounds = path.bounds(d as d3.GeoPermissibleObjects);
const dx = updatedBounds[1][0] - updatedBounds[0][0];
const dy = updatedBounds[1][1] - updatedBounds[0][1];
const x = (updatedBounds[0][0] + updatedBounds[1][0]) / 2;
const y = (updatedBounds[0][1] + updatedBounds[1][1]) / 2;
const scale =
0.9 / Math.max(dx / svg.attr('width'), dy / svg.attr('height'));
const translate = [
svg.attr('width') / 2 - scale * x,
svg.attr('height') / 2 - scale * y,
];
svg
.transition()
.duration(750)
.call(
zoom.transform,
d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale)
);
};
const resetZoom = () => {
setZoomed(false);
d3.select(svgRef.current)
.transition()
.duration(750)
.call(zoom.transform, d3.zoomIdentity);
};
useOnClickOutside(svgRef, resetZoom);
useEffect(() => {
setIsMapLoading(true);
d3.json(MAP_GEO_JSON_FILE_BY_REGION_NAME[region])
.then((data) => {
setGeoData(data);
setIsMapLoading(false);
})
.catch((err) => {
console.error(err);
});
}, []);
useEffect(() => {
if (geoData) {
const { width, height } = bounds;
d3.selectAll('circle').remove();
const svg = d3
.select(svgRef.current)
.attr('width', width)
.attr('height', height)
.attr('viewBox', [0, 0, width, height])
.attr('style', 'max-width: 100%; height: auto;');
const land = topojson.feature(geoData, geoData.objects.countries);
const projectionFn =
region === 'NORTH_AMERICA' ? d3.geoAlbers() : d3.geoNaturalEarth1();
const path = d3.geoPath().projection(
projectionFn.fitExtent(
[
[40, 40],
[width - 80, height - 80],
],
land
)
);
svg
.selectAll('path')
.data(land.features)
.enter()
.append('path')
.attr('d', path)
.attr('class', 'country')
.attr('fill', '#F6F6F6')
.attr('stroke', '#DDDDDD')
.attr('stroke-width', 0.3)
.attr(
'id',
(d: { properties: { NAME: string } }) =>
`country_${d.properties.NAME}`
)
.on('click', (e, d: TGeoJsonFeature) => {
e.stopPropagation();
zoomToPath(d, svg, path);
})
.append('title')
.text((d: { properties: { NAME: string } }) => d.properties.NAME);
if (!isZoomed && geoData) renderCircles(path);
}
}, [geoData, bounds, isZoomed]);
if (error) {
console.error(error);
return <Loading />;
}
return (
<div className="flex flex-col gap-6 py-6">
<div className="border rounded-2xl mx-6 border-stroke-2 h-[calc(100vh-196px)] bg-neutral-7 flex flex-col overflow-hidden">
<div className="flex items-center w-full border-b border-b-stroke-2">
<span className="p-4">{REGION_NAME_VS_NAME[region]}</span>
</div>
<div className="min-h-full flex-1 flex">
<div
className="flex-1 flex justify-center items-center"
ref={containerRef}
>
{isMapLoading ? (
<Spinner size="lg" />
) : (
<div>
<svg
ref={svgRef}
width={bounds.width}
height={bounds.height}
viewBox={`0 0 ${bounds.width} ${bounds.height}`}
onClick={resetZoom}
/>
<div
ref={tooltipRef}
style={{
opacity: '0',
}}
className="absolute bg-neutral-7 rounded-b-lg pointer-events-none flex flex-col shadow min-w-[200px] shadow-[0px_4px_32px_0px_rgba(31,_31,_31,_0.15)]"
>
<div className="px-3 py-2 bg-violet-1 text-neutral-7 rounded-t-lg">
<h5 className="inline-block">
{Country.getCountryByCode(tooltipCountries)?.name}
</h5>
<h5 className="inline-block font-normal">
[{tooltipCountries}]
</h5>
</div>
<div>
{tooltipCorporations.map((corp) => {
return (
<div className="flex w-full items-start px-3 py-2 last:rounded-b-lg">
<p className="text-stone-900 text-xs font-normal leading-none">
{corp.companyLegalName}
</p>
</div>
);
})}
</div>
</div>
</div>
)}
</div>
<div className="h-full w-[240px] border-l border-l-stroke-2 flex flex-col overflow-y-auto" />
</div>
</div>
<Separator className="h-1" />
</div>
);
};
export default GenericMapComponent;
Here is a loom of the current behavior: