Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: advanced marker anchoring #577

Merged
merged 6 commits into from
Oct 24, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions examples/advanced-marker-interaction/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
InfoWindow,
Map,
Pin,
useAdvancedMarkerRef
useAdvancedMarkerRef,
CollisionBehavior
} from '@vis.gl/react-google-maps';

import {getData} from './data';
Expand Down Expand Up @@ -108,7 +109,8 @@ const App = () => {
zIndex={zIndex}
className="custom-marker"
style={{
transform: `scale(${[hoverId, selectedId].includes(id) ? 1.4 : 1})`
transform: `scale(${[hoverId, selectedId].includes(id) ? 1.3 : 1})`,
transformOrigin: AdvancedMarkerAnchorPoint['BOTTOM'].join(' ')
}}
position={position}>
<Pin
Expand All @@ -129,12 +131,17 @@ const App = () => {
anchorPoint={AdvancedMarkerAnchorPoint[anchorPoint]}
className="custom-marker"
style={{
transform: `scale(${[hoverId, selectedId].includes(id) ? 1.4 : 1})`
transform: `scale(${[hoverId, selectedId].includes(id) ? 1.3 : 1})`,
transformOrigin:
AdvancedMarkerAnchorPoint[anchorPoint].join(' ')
}}
onMarkerClick={(
marker: google.maps.marker.AdvancedMarkerElement
) => onMarkerClick(id, marker)}
onMouseEnter={() => onMouseEnter(id)}
collisionBehavior={
CollisionBehavior.OPTIONAL_AND_HIDES_LOWER_PRIORITY
}
onMouseLeave={onMouseLeave}>
<div
className={`custom-html-content ${selectedId === id ? 'selected' : ''}`}></div>
Expand All @@ -145,7 +152,7 @@ const App = () => {
onMarkerClick={(
marker: google.maps.marker.AdvancedMarkerElement
) => onMarkerClick(id, marker)}
zIndex={zIndex}
zIndex={zIndex + 1}
onMouseEnter={() => onMouseEnter(id)}
onMouseLeave={onMouseLeave}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
Expand All @@ -160,6 +167,7 @@ const App = () => {
{infoWindowShown && selectedMarker && (
<InfoWindow
anchor={selectedMarker}
pixelOffset={[0, -2]}
onCloseClick={handleInfowindowCloseClick}>
<h2>Marker {selectedId}</h2>
<p>Some arbitrary html to be rendered into the InfoWindow.</p>
Expand Down
10 changes: 10 additions & 0 deletions examples/advanced-marker-interaction/src/control-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ function ControlPanel(props: Props) {
})}
</select>
</p>
<p>
The blue markers also have the{' '}
<a
href="https://developers.google.com/maps/documentation/javascript/reference/advanced-markers#AdvancedMarkerElement.collisionBehavior"
target="_blank">
collision detection
</a>{' '}
feature turned on for demonstration purposes.
</p>

<div className="links">
<a
href="https://codesandbox.io/s/github/visgl/react-google-maps/tree/main/examples/advanced-marker-interaction"
Expand Down
5 changes: 3 additions & 2 deletions examples/custom-marker-clustering/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const App = () => {
features: Feature<Point>[];
} | null>(null);

const hamdleInfoWindowClose = useCallback(
const handleInfoWindowClose = useCallback(
() => setInfowindowData(null),
[setInfowindowData]
);
Expand All @@ -40,6 +40,7 @@ const App = () => {
defaultZoom={3}
gestureHandling={'greedy'}
disableDefaultUI
onClick={() => setInfowindowData(null)}
className={'custom-marker-clustering-map'}>
{geojson && (
<ClusteredMarkers
Expand All @@ -51,7 +52,7 @@ const App = () => {

{infowindowData && (
<InfoWindow
onClose={hamdleInfoWindowClose}
onCloseClick={handleInfoWindowClose}
anchor={infowindowData.anchor}>
<InfoWindowContent features={infowindowData.features} />
</InfoWindow>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import React, {useCallback} from 'react';
import {AdvancedMarker, useAdvancedMarkerRef} from '@vis.gl/react-google-maps';
import {
AdvancedMarker,
AdvancedMarkerAnchorPoint,
useAdvancedMarkerRef
} from '@vis.gl/react-google-maps';
import {CastleSvg} from './castle-svg';

type TreeMarkerProps = {
Expand Down Expand Up @@ -27,6 +31,7 @@ export const FeatureMarker = ({
ref={markerRef}
position={position}
onClick={handleClick}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}
className={'marker feature'}>
<CastleSvg />
</AdvancedMarker>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import React, {useCallback} from 'react';
import {AdvancedMarker, useAdvancedMarkerRef} from '@vis.gl/react-google-maps';
import {
AdvancedMarker,
AdvancedMarkerAnchorPoint,
useAdvancedMarkerRef
} from '@vis.gl/react-google-maps';
import {CastleSvg} from './castle-svg';

type TreeClusterMarkerProps = {
Expand Down Expand Up @@ -33,7 +37,8 @@ export const FeaturesClusterMarker = ({
zIndex={size}
onClick={handleClick}
className={'marker cluster'}
style={{width: markerSize, height: markerSize}}>
style={{width: markerSize, height: markerSize}}
anchorPoint={AdvancedMarkerAnchorPoint.CENTER}>
<CastleSvg />
<span>{sizeAsText}</span>
</AdvancedMarker>
Expand Down
1 change: 0 additions & 1 deletion examples/custom-marker-clustering/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
box-sizing: border-box;
border-radius: 50%;
padding: 8px;
translate: 0 50%;
border: 1px solid white;
color: white;

Expand Down
6 changes: 4 additions & 2 deletions src/components/__tests__/advanced-marker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,10 @@ describe('map and marker-library loaded', () => {
.get(google.maps.marker.AdvancedMarkerElement)
.at(0) as google.maps.marker.AdvancedMarkerElement;

expect(marker.content?.firstChild).toHaveClass('classname-test');
expect(marker.content?.firstChild).toHaveStyle('width: 200px');
expect(marker.content?.firstChild?.firstChild).toHaveClass(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wondering if we should replace this with something like "expect that there is an element with classname-test somewhere in marker.content" – feels like specifying the exact hierarchy here isn't actually what we want to test.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that sounds like a better approach.

'classname-test'
);
expect(marker.content?.firstChild?.firstChild).toHaveStyle('width: 200px');
expect(
queryByTestId(marker.content as HTMLElement, 'marker-content')
).toBeTruthy();
Expand Down
90 changes: 55 additions & 35 deletions src/components/advanced-marker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export function isAdvancedMarker(
);
}

function isElementNode(node: Node): node is HTMLElement {
return (node as Node).nodeType === Node.ELEMENT_NODE;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't as Node redundant here, since that's the type given above?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes, you're right.

}

/**
* Copy of the `google.maps.CollisionBehavior` constants.
* They have to be duplicated here since we can't wait for the maps API to load to be able to use them.
Expand All @@ -48,19 +52,19 @@ export const AdvancedMarkerContext =

// [xPosition, yPosition] when the top left corner is [0, 0]
export const AdvancedMarkerAnchorPoint = {
TOP_LEFT: ['0', '0'],
TOP_CENTER: ['50%', '0'],
TOP: ['50%', '0'],
TOP_RIGHT: ['100%', '0'],
LEFT_CENTER: ['0', '50%'],
LEFT_TOP: ['0', '0'],
LEFT: ['0', '50%'],
LEFT_BOTTOM: ['0', '100%'],
RIGHT_TOP: ['100%', '0'],
TOP_LEFT: ['0%', '0%'],
TOP_CENTER: ['50%', '0%'],
TOP: ['50%', '0%'],
TOP_RIGHT: ['100%', '0%'],
LEFT_CENTER: ['0%', '50%'],
LEFT_TOP: ['0%', '0%'],
LEFT: ['0%', '50%'],
LEFT_BOTTOM: ['0%', '100%'],
RIGHT_TOP: ['100%', '0%'],
RIGHT: ['100%', '50%'],
RIGHT_CENTER: ['100%', '50%'],
RIGHT_BOTTOM: ['100%', '100%'],
BOTTOM_LEFT: ['0', '100%'],
BOTTOM_LEFT: ['0%', '100%'],
BOTTOM_CENTER: ['50%', '100%'],
BOTTOM: ['50%', '100%'],
BOTTOM_RIGHT: ['100%', '100%'],
Expand Down Expand Up @@ -124,28 +128,25 @@ const MarkerContent = ({
const [xTranslation, yTranslation] =
anchorPoint ?? AdvancedMarkerAnchorPoint['BOTTOM'];

const {transform: userTransform, ...restStyles} = styles ?? {};

let transformStyle = `translate(-${xTranslation}, -${yTranslation})`;
// The "translate(50%, 100%)" is here to counter and reset the default anchoring of the advanced marker element
// that comes from the api
const transformStyle = `translate(50%, 100%) translate(-${xTranslation}, -${yTranslation})`;

// preserve extra transform styles that were set by the user
if (userTransform) {
transformStyle += ` ${userTransform}`;
}
return (
<div
className={className}
style={{
width: 'fit-content',
transformOrigin: `${xTranslation} ${yTranslation}`,
transform: transformStyle,
...restStyles
}}>
{children}
// anchoring container
<div style={{transform: transformStyle}}>
{/* AdvancedMarker div that user can give styles and classes */}
<div className={className} style={styles}>
{children}
</div>
</div>
);
};

export type CustomMarkerContent =
| (HTMLDivElement & {isCustomMarker?: boolean})
| null;

export type AdvancedMarkerRef = google.maps.marker.AdvancedMarkerElement | null;
function useAdvancedMarker(props: AdvancedMarkerProps) {
const [marker, setMarker] =
Expand Down Expand Up @@ -185,11 +186,14 @@ function useAdvancedMarker(props: AdvancedMarkerProps) {
setMarker(newMarker);

// create the container for marker content if there are children
let contentElement: HTMLDivElement | null = null;
let contentElement: CustomMarkerContent = null;
if (numChildren > 0) {
contentElement = document.createElement('div');
contentElement.style.width = '0';
contentElement.style.height = '0';

// We need some kind of flag to identify the custom marker content
// in the infowindow component. Choosing a custom property instead of a className
// to not encourage users to style the marker content directly.
contentElement.isCustomMarker = true;

newMarker.content = contentElement;
setContentContainer(contentElement);
Expand Down Expand Up @@ -233,15 +237,31 @@ function useAdvancedMarker(props: AdvancedMarkerProps) {
else marker.gmpDraggable = false;
}, [marker, draggable, onDrag, onDragEnd, onDragStart]);

// set gmpClickable from props (when unspecified, it's true if the onClick event
// callback is specified)
// set gmpClickable from props (when unspecified, it's true if the onClick or one of
// the hover events callbacks are specified)
useEffect(() => {
if (!marker) return;

if (clickable !== undefined) marker.gmpClickable = clickable;
else if (onClick) marker.gmpClickable = true;
else marker.gmpClickable = false;
}, [marker, clickable, onClick]);
const gmpClickable =
clickable !== undefined ||
Boolean(onClick) ||
Boolean(onMouseEnter) ||
Boolean(onMouseLeave);

// gmpClickable is only available in beta version of the
// maps api (as of 2024-10-10)
marker.gmpClickable = gmpClickable;

// enable pointer events for the markers with custom content
if (gmpClickable && marker?.content && isElementNode(marker.content)) {
marker.content.style.pointerEvents = 'none';

if (marker.content.firstElementChild) {
(marker.content.firstElementChild as HTMLElement).style.pointerEvents =
'all';
}
}
}, [marker, clickable, onClick, onMouseEnter, onMouseLeave]);

useMapsEventListener(marker, 'click', onClick);
useMapsEventListener(marker, 'drag', onDrag);
Expand Down
15 changes: 9 additions & 6 deletions src/components/info-window.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {useMapsEventListener} from '../hooks/use-maps-event-listener';
import {setValueForStyles} from '../libraries/set-value-for-styles';
import {useMapsLibrary} from '../hooks/use-maps-library';
import {useDeepCompareEffect} from '../libraries/use-deep-compare-effect';
import {isAdvancedMarker} from './advanced-marker';
import {CustomMarkerContent, isAdvancedMarker} from './advanced-marker';

export type InfoWindowProps = Omit<
google.maps.InfoWindowOptions,
Expand Down Expand Up @@ -180,23 +180,26 @@ export const InfoWindow = (props: PropsWithChildren<InfoWindowProps>) => {

// Only do the infowindow adjusting when dealing with an AdvancedMarker
if (isAdvancedMarker(anchor) && anchor.content instanceof Element) {
const wrapperBcr = anchor.content.getBoundingClientRect() ?? {};
const {width: anchorWidth, height: anchorHeight} = wrapperBcr;
const wrapper = anchor.content as CustomMarkerContent;
const wrapperBcr = wrapper?.getBoundingClientRect();

// This checks whether or not the anchor has custom content with our own
// div wrapper. If not, that means we have a regular AdvancedMarker without any children.
// In that case we do not want to adjust the infowindow since it is all handled correctly
// by the Google Maps API.
if (anchorWidth === 0 && anchorHeight === 0) {
if (wrapperBcr && wrapper?.isCustomMarker) {
// We can safely typecast here since we control that element and we know that
// it is a div
const anchorDomContent = anchor.content.firstElementChild as Element;
const anchorDomContent = anchor.content.firstElementChild
?.firstElementChild as Element;

const contentBcr = anchorDomContent?.getBoundingClientRect();

// center infowindow above marker
const anchorOffsetX =
contentBcr.x - wrapperBcr.x + contentBcr.width / 2;
contentBcr.x -
wrapperBcr.x +
(contentBcr.width - wrapperBcr.width) / 2;
const anchorOffsetY = contentBcr.y - wrapperBcr.y;

const opts: google.maps.InfoWindowOptions = infoWindowOptions;
Expand Down
5 changes: 4 additions & 1 deletion src/components/pin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ export const Pin = (props: PropsWithChildren<PinProps>) => {
}

// Set content of Advanced Marker View to the Pin View element
const markerContent = advancedMarker.content?.firstChild;
// Here we are selecting the anchor container.
// The hierarchy is as follows:
// "advancedMarker.content" (from google) -> "pointer events reset div" -> "anchor container"
const markerContent = advancedMarker.content?.firstChild?.firstChild;

while (markerContent?.firstChild) {
markerContent.removeChild(markerContent.firstChild);
Expand Down