Skip to content

Commit

Permalink
feat(components): add Clusterer component. (#313)
Browse files Browse the repository at this point in the history
This PR adds the `Clusterer` component, which allows users to cluster
their `Marker` components when they are close together at a zoom level
given the cluster radius.

Implements #279.

---------

Co-authored-by: Matt Kilpatrick <mkilpatrick@yext.com>
  • Loading branch information
Jared-Hood and mkilpatrick committed Jun 12, 2023
1 parent a2d1b1c commit c7d9741
Show file tree
Hide file tree
Showing 7 changed files with 313 additions and 5 deletions.
4 changes: 4 additions & 0 deletions packages/pages/src/components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,7 @@ Render a marker in a Map component at a given coorindate.
## LocationMap

Render a Map component with a single marker that links to an `href`.

## Clusterer

Cluster the Marker components rendered within a Map component.
237 changes: 237 additions & 0 deletions packages/pages/src/components/map/clusterer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import React, {
createContext,
useContext,
useEffect,
useState,
Fragment,
} from "react";
import { MapContext } from "./map.js";
import { Marker } from "./marker.js";
import type {
MapContextType,
ClustererProps,
PinStoreType,
ClustererContextType,
ClusterTemplateProps,
} from "./types";
import type { Map } from "@yext/components-tsx-maps";
import {
Unit,
Projection,
Coordinate,
GeoBounds,
} from "@yext/components-tsx-geo";

const defaultClusterTemplate = ({ count }: ClusterTemplateProps) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="22"
height="22"
viewBox="0 0 22 22"
>
<g fill="none" fillRule="evenodd">
<circle
fill="red"
fillRule="nonzero"
stroke="white"
cx="11"
cy="11"
r="11"
/>
<text
fill="white"
fontFamily="Arial-BoldMT,Arial"
fontSize="12"
fontWeight="bold"
>
<tspan x="50%" y="15" textAnchor="middle">
{count}
</tspan>
</text>
</g>
</svg>
);
};

export const ClustererContext = createContext<ClustererContextType | null>(
null
);

export function useClusterContext() {
const ctx = useContext(ClustererContext);

if (!ctx) {
throw new Error(
"Attempted to call useClustererContext() outside of <Clusterer>."
);
}

return ctx;
}

export const Clusterer = ({
clusterRadius = 50,
children,
ClusterTemplate = defaultClusterTemplate,
}: ClustererProps) => {
const { map } = useContext(MapContext) as MapContextType;
const [pinStore, setPinStore] = useState<PinStoreType[]>([]);
const [clusters, setClusters] = useState<PinStoreType[][]>();
const [clusterIds, setClusterIds] = useState<string[]>([]);
const [clustersToRender, setClustersToRender] = useState<JSX.Element[]>([]);

// Recalculate the clusters when either the pin store is updated or the map zoom level changes.
useEffect(() => {
setClusters(_generateClusters(pinStore, map, clusterRadius));
}, [pinStore, map.getZoom()]);

// When the clusters are updated, remove any pins in a cluster of more than 1 pin from the map.
// Then calculate the geo bounds of all the pins in the cluster and render a single marker
// at their center.
useEffect(() => {
setClustersToRender(() => []);
setClusterIds(() => []);

if (clusters?.length === 0) {
return;
}

clusters?.forEach((cluster) => {
// Add pins back to map if they are in a cluster of 1.
if (cluster.length === 1) {
cluster[0].pin.setMap(map);
return;
}
if (cluster.length > 1) {
// Calculate center of all markers in the cluster.
// Used to set the coordinate of the marker as well as generate a unique id.
const clusterCenter: Coordinate = GeoBounds.fit(
cluster.map((p) => p.pin.getCoordinate())
).getCenter(Projection.MERCATOR);
const id = `cluster-{${clusterCenter._lat},${clusterCenter._lon}}`;

// Remove all markers in cluster from the map and instead
// render one cluster marker at their geo center.
cluster.forEach((p) => p.pin.setMap(null));

// Add cluster id to clusterIds in order to track what markers are actually clusters.
setClusterIds((clusterIds) => [...clusterIds, id]);

// Add cluster marker to array to be rendered.
setClustersToRender((clustersToRender) => [
...clustersToRender,
<Marker
coordinate={clusterCenter}
id={id}
key={id}
onClick={() =>
map.fitCoordinates(
cluster.map((p) => p.pin.getCoordinate()),
true,
Infinity
)
}
>
<ClusterTemplate count={cluster.length} />
</Marker>,
]);
}
});
}, [clusters]);

return (
<ClustererContext.Provider
value={{
clusters: clusters ?? [],
clusterIds,
setPinStore,
}}
>
<>
{clustersToRender.map((cluster, idx) => (
<Fragment key={idx}>{cluster}</Fragment>
))}
{children}
</>
</ClustererContext.Provider>
);
};

/**
* Generate groups of pins such that each pin is in exactly one cluster, each pin is at most
* @param clusterRadius pixels from the center of the cluster, and each cluster
* has at least one pin.
*/
const _generateClusters = (
pins: PinStoreType[],
map: Map,
clusterRadius: number
) => {
const clusterRadiusRadians =
(clusterRadius * Math.PI) / 2 ** (map.getZoom() + 7);
const pinsInRadius = pins.map((_, index) => [index]);
const pinClusters = [];

// Calculate the distances of each pin to each other pin
pins.forEach((pin, index) => {
for (let otherIndex = index; otherIndex < pins.length; otherIndex++) {
if (otherIndex != index) {
const distance = new Coordinate(pin.pin.getCoordinate()).distanceTo(
new Coordinate(pins[otherIndex].pin.getCoordinate()),
Unit.RADIAN,
Projection.MERCATOR
);

if (distance <= clusterRadiusRadians) {
pinsInRadius[index].push(otherIndex);
pinsInRadius[otherIndex].push(index);
}
}
}
});

// Loop until there are no pins left to cluster
let maxCount = 1;
while (maxCount) {
maxCount = 0;
let chosenIndex;

// Find the pin with the most other pins within radius
pinsInRadius.forEach((pinGroup, index) => {
if (pinGroup.length > maxCount) {
maxCount = pinGroup.length;
chosenIndex = index;
}
});

// If there are no more pins within clustering radius of another pin, break
if (!maxCount) {
break;
}

// Add pins to a new cluster, and remove them from pinsInRadius
const chosenPins = pinsInRadius[chosenIndex ?? 0];
const cluster = [];

pinsInRadius[chosenIndex ?? 0] = [];

for (const index of chosenPins) {
const pinGroup = pinsInRadius[index];

// Add the pin to this cluster and remove it from consideration for other clusters
cluster.push(pins[index]);
pinsInRadius[index] = [];
pinGroup.forEach((otherIndex) =>
pinsInRadius[otherIndex].splice(
pinsInRadius[otherIndex].indexOf(index),
1
)
);
}

pinClusters.push(cluster);
}

return pinClusters;
};
1 change: 1 addition & 0 deletions packages/pages/src/components/map/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { Map, useMapContext } from "./map.js";
export { Marker } from "./marker.js";
export { Clusterer, useClusterContext } from "./clusterer.js";
export * from "./types.js";
19 changes: 18 additions & 1 deletion packages/pages/src/components/map/map.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import * as React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import { MapboxMaps } from "@yext/components-tsx-maps";
import { Map, Marker, useMapContext } from ".";
import { Clusterer, Map, Marker, useMapContext } from ".";

describe("Map", () => {
it("renders with Google Maps", async () => {
Expand Down Expand Up @@ -57,4 +57,21 @@ describe("Map", () => {

expect(() => render(<MapSiblingComponent />)).toThrow();
});

it("renders with Clusterer", async () => {
render(
<Map clientKey="gme-yextinc">
<Clusterer>
<Marker
id="1"
coordinate={{ latitude: 38.8974, longitude: -97.0638 }}
/>
<Marker
id="1"
coordinate={{ latitude: 38.9974, longitude: -97.1638 }}
/>
</Clusterer>
</Map>
);
});
});
1 change: 1 addition & 0 deletions packages/pages/src/components/map/map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,5 +142,6 @@ Map.defaultProps = {
panHandler: () => null,
panStartHandler: () => null,
provider: GoogleMaps,
providerOptions: {},
singleZoom: 14,
};
27 changes: 24 additions & 3 deletions packages/pages/src/components/map/marker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
import { MapPin, MapPinOptions } from "@yext/components-tsx-maps";
import { MapContext } from "./map.js";
import { MapContextType, MarkerProps } from "./types.js";
import { ClustererContext } from "./clusterer.js";

const defaultMarkerIcon = (
<svg
Expand Down Expand Up @@ -31,6 +32,7 @@ export const Marker = ({
zIndex,
}: MarkerProps): JSX.Element | null => {
const { map, provider } = useContext(MapContext) as MapContextType;
const cluster = useContext(ClustererContext);

const marker: MapPin = useMemo(() => {
return new MapPinOptions()
Expand All @@ -45,8 +47,10 @@ export const Marker = ({
if (zIndex !== 0 && !zIndex) {
return;
}
const wrapper = marker.getProviderPin().getWrapperElement();
wrapper.style.zIndex = zIndex;
const wrapper: HTMLDivElement = marker.getProviderPin().getWrapperElement();
if (wrapper) {
wrapper.style.zIndex = zIndex.toString();
}
}, [zIndex]);

// Sync events
Expand All @@ -56,8 +60,26 @@ export const Marker = ({
marker.setFocusHandler((focused: boolean) => onFocus(focused, id));
marker.setHoverHandler((hovered: boolean) => onHover(hovered, id));

// Add the pin to the pinStore if it is not a cluster marker.
const isClusterMarker = cluster?.clusterIds.includes(id);
if (cluster && !isClusterMarker) {
cluster.setPinStore((pinStore) => [
...pinStore,
{
pin: marker,
id,
},
]);
}

return () => {
marker.setMap(null);

if (cluster) {
cluster.setPinStore((pinStore) =>
pinStore.filter((pin) => pin.id !== id)
);
}
};
}, []);

Expand All @@ -68,7 +90,6 @@ export const Marker = ({
Object.assign(pinEl.style, {
height: "auto",
width: "auto",
fontSize: 0,
});
return createPortal(elementToRender, pinEl);
}
Expand Down
29 changes: 28 additions & 1 deletion packages/pages/src/components/map/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { Map as MapType, MapProvider } from "@yext/components-tsx-maps";
import type {
Map as MapType,
MapProvider,
MapPin,
} from "@yext/components-tsx-maps";
import type { GeoBounds } from "@yext/components-tsx-geo";
import React from "react";

Expand Down Expand Up @@ -51,3 +55,26 @@ export interface MarkerProps {
statusOptions?: { [key: string]: boolean };
zIndex?: number;
}

// Clusterer

export type ClusterTemplateProps = {
count?: number;
};

export type ClustererProps = {
clusterRadius?: number;
children: JSX.Element[] | JSX.Element;
ClusterTemplate?: (props: ClusterTemplateProps) => JSX.Element;
};

export type PinStoreType = {
id: string;
pin: MapPin;
};

export interface ClustererContextType {
clusters: PinStoreType[][];
clusterIds: string[];
setPinStore: React.Dispatch<React.SetStateAction<PinStoreType[]>>;
}

0 comments on commit c7d9741

Please sign in to comment.