Skip to content

Commit 561ff08

Browse files
committed
feat: Add layout and positioning escape hatches
1 parent b22b5eb commit 561ff08

File tree

4 files changed

+120
-14
lines changed

4 files changed

+120
-14
lines changed

README.md

+19-1
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,15 @@ pnpm add @adamscybot/react-leaflet-component-marker
3232

3333
## Usage
3434

35+
### Using a React Component as a marker
36+
3537
Instead of importing `Marker` from `react-leaflet`, instead import `Marker` from `react-leaflet-component-marker`.
3638

3739
The `icon` prop is extended to allow for a JSX element of your choosing. All other props are identical to the `react-leaflet` [Marker](https://react-leaflet.js.org/docs/api-components/#marker) API.
3840

3941
The `icon` prop can also accept all of the original types of icons that the underlying `react-leaflet` Marker accepts. Though there is no gain in using this library for this case, it may help if you want to just use this library in place of Marker universally.
4042

41-
### Basic Example
43+
#### Basic Example
4244

4345
```javascript
4446
import React from 'react'
@@ -69,3 +71,19 @@ const App = () => {
6971
)
7072
}
7173
```
74+
75+
### Advanced Sizing and Positioning
76+
77+
Note, that it is often possible to achieve the desired effect by use of margins/padding on the React icon component itself. However, in some cases, adjustments may be needed to get pixel perfect like popup positioning
78+
79+
`iconComponentOpts` can be passed which provides a subset of the [options](https://leafletjs.com/reference.html#icon) that can be passed to an underlying leaflet icon, which is used by this library as an intermediary wrapper. It should be considered an escape hatch.
80+
81+
`iconComponentLayout` can be passed to control the alignment and size of the React component. It defaults to `fit-content`, meaning the React Component decides its own size and is not constrained by `iconSize` (which defaults to `[0,0]`). The library automatically handles the alignment of the component such that it is centred horizontally with the marker coordinates, regardless of the component's size (which can even change dynamically). Note the anchor options that can be passed to `iconComponentOpts` remain functional with `fit-content`.
82+
83+
If more granular control is needed, `iconComponentLayout` can be set `fit-parent` which defers all sizing and positioning to leaflets configuration options, that can be provided via the aforementioned `iconComponentOpts`. This means you will likely need to pass an `iconSize` to `iconComponentOpts`. In this mode, the React icon component should also have a root element that has a width and height of 100%, and it should prevent overflowing. The downside to this approach is the component size is inherently static. The upside is Leaflet knows about the icon size, and so the default anchor coordinates for other elements like popups, will be likely closer to the default expectations.
84+
85+
### Gotchas
86+
87+
Currently, if any options in `iconComponentOpts` have a material change (new `iconSize` or changed anchors), the React Component will completely remount and lose any state it had. This will be fixed in a future release.
88+
89+
Hot reloading causes markers to disappear. This will be fixed in a future release.

src/Marker.tsx

+88-11
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import React, {
66
useCallback,
77
isValidElement,
88
type ComponentType,
9+
useLayoutEffect,
910
} from 'react'
1011
import { isValidElementType } from 'react-is'
1112
import { createPortal } from 'react-dom'
@@ -17,28 +18,103 @@ import {
1718
type LeafletEventHandlerFn,
1819
type LeafletEventHandlerFnMap,
1920
divIcon,
21+
type PointExpression,
22+
type DivIconOptions,
2023
} from 'leaflet'
24+
import { getCoordsFromPointExpression } from './utils'
2125

22-
export type MarkerProps = Omit<ReactLeafletMarkerProps, 'icon'> & {
26+
type BaseMarkerProps<AdditionalIconTypes = never> = Omit<
27+
ReactLeafletMarkerProps,
28+
'icon'
29+
> & {
2330
/** A {@link ReactElement} representing the Markers icon, or any type from [react-leaflet Marker](https://react-leaflet.js.org/docs/api-components/#marker) component. */
24-
icon: ReactElement | ComponentType | ReactLeafletMarkerProps['icon']
31+
icon: ReactElement | AdditionalIconTypes
32+
33+
/**
34+
* The {@link DivIconOptions} (except for the `html` property and properties that are not relevant in the context of a React driven marker) that are to be supplied to the `div` wrapper for the leaflet-managed wrapper of the React icon component.
35+
*
36+
* By default, `iconSize` is set to `[0,0]`, which is useful when combined with an "auto" `iconComponentSize` in order to allow for dynamically sized React icon markers.
37+
*
38+
* Typically, it is not necessary to override these options, and doing so may lead to unexpected results for some properties.
39+
*
40+
* These options are only effective when a React element/component is being used for the `icon` prop.
41+
**/
42+
iconComponentOpts?: Omit<
43+
DivIconOptions,
44+
'html' | 'bgPos' | 'shadowAnchor' | 'shadowRetinaUrl'
45+
>
46+
47+
/**
48+
* `"fit-content"` disregards the `iconSize` passed to leaflet (defaults to `[0,0]`) and allows the React icon marker to be determined by the size of the provided component itself (which could be dynamic). Automatic alignment compensation is
49+
* added to ensure the icon component stays centred on the X axis with the marker.
50+
*
51+
* `'fit-parent'` will set the container of the component to be the same size as the `iconSize`. Typically, this is used alongside a static `iconSize` that is passed via `iconComponentOpts`. This setup may allow for more granular control over positioning and anchor configuration. The user supplied Icon component itself should have a root element that has 100% width and height.
52+
*
53+
* This option is not effective if `icon` is not a React element/component.
54+
*
55+
* @defaultValue `"fit-content"`
56+
*/
57+
iconComponentLayout: 'fit-content' | 'fit-parent'
2558
}
59+
export type MarkerProps = BaseMarkerProps<
60+
ReactLeafletMarkerProps['icon'] | ComponentType
61+
>
2662

63+
const DEFAULT_ICON_SIZE: PointExpression = [0, 0]
2764
const ComponentMarker = ({
2865
eventHandlers: providedEventHandlers,
2966
icon: providedIcon,
67+
iconComponentOpts = {},
68+
iconComponentLayout = 'fit-content',
3069
...otherProps
31-
}: Omit<ReactLeafletMarkerProps, 'icon'> & { icon: ReactElement }) => {
70+
}: BaseMarkerProps) => {
3271
const [markerRendered, setMarkerRendered] = useState(false)
72+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
73+
const [changeCount, setChangeCount] = useState(0)
3374
const id = 'marker-' + useId()
3475

35-
const icon = useMemo(
36-
() =>
37-
divIcon({
38-
html: `<div id="${id}"></div>`,
39-
}),
40-
[id],
41-
)
76+
const {
77+
attribution,
78+
className,
79+
iconAnchor,
80+
iconSize = DEFAULT_ICON_SIZE,
81+
pane,
82+
popupAnchor,
83+
tooltipAnchor,
84+
} = iconComponentOpts
85+
86+
const iconDeps = [
87+
id,
88+
iconComponentLayout,
89+
attribution,
90+
className,
91+
pane,
92+
...getCoordsFromPointExpression(iconSize),
93+
...getCoordsFromPointExpression(iconAnchor),
94+
...getCoordsFromPointExpression(popupAnchor),
95+
...getCoordsFromPointExpression(tooltipAnchor),
96+
]
97+
98+
const icon = useMemo(() => {
99+
const parentStyles =
100+
iconComponentLayout === 'fit-content'
101+
? 'width: min-content; transform: translateX(-50%)'
102+
: 'width: 100%; height: 100%'
103+
return divIcon({
104+
html: `<div style="${parentStyles}" id="${id}"></div>`,
105+
...(iconSize ? { iconSize } : []),
106+
...(iconAnchor ? { iconAnchor } : []),
107+
...(popupAnchor ? { popupAnchor } : []),
108+
...(tooltipAnchor ? { tooltipAnchor } : []),
109+
pane,
110+
attribution,
111+
className,
112+
})
113+
}, iconDeps)
114+
115+
useLayoutEffect(() => {
116+
setChangeCount((prev) => prev + 1)
117+
}, [icon])
42118

43119
const handleAddEvent = useCallback<LeafletEventHandlerFn>(
44120
(...args) => {
@@ -82,9 +158,10 @@ const ComponentMarker = ({
82158
eventHandlers={eventHandlers}
83159
icon={icon}
84160
/>
161+
85162
{markerRendered &&
86163
portalTarget !== null &&
87-
createPortal(providedIcon, portalTarget)}
164+
createPortal(providedIcon, portalTarget, JSON.stringify(iconDeps))}
88165
</>
89166
)
90167
}

src/utils.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { type PointTuple, type PointExpression } from 'leaflet'
2+
3+
export const getCoordsFromPointExpression = (expression?: PointExpression) => {
4+
if (!expression) return []
5+
if (Array.isArray(expression)) {
6+
return expression
7+
} else {
8+
return [expression.x, expression.y] as PointTuple
9+
}
10+
}

tsconfig.build.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
"jsx": "react",
66
"moduleResolution": "Node10",
77
"target": "ES2020",
8-
"lib": ["dom", "es2020"],
8+
"lib": ["dom", "dom.iterable", "es2020"],
99
"outDir": "dist",
1010
"allowSyntheticDefaultImports": true,
11+
"downlevelIteration": true
1112
},
12-
"include": ["./src/**/*", "cypress.config.js"],
13+
"include": ["./src/**/*", "cypress.config.js"]
1314
}

0 commit comments

Comments
 (0)