Skip to content

Commit

Permalink
feat: Add layout and positioning escape hatches
Browse files Browse the repository at this point in the history
  • Loading branch information
adamscybot committed Feb 3, 2024
1 parent b22b5eb commit 561ff08
Show file tree
Hide file tree
Showing 4 changed files with 120 additions and 14 deletions.
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,15 @@ pnpm add @adamscybot/react-leaflet-component-marker

## Usage

### Using a React Component as a marker

Instead of importing `Marker` from `react-leaflet`, instead import `Marker` from `react-leaflet-component-marker`.

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.

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.

### Basic Example
#### Basic Example

```javascript
import React from 'react'
Expand Down Expand Up @@ -69,3 +71,19 @@ const App = () => {
)
}
```

### Advanced Sizing and Positioning

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

`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.

`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`.

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.

### Gotchas

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.

Hot reloading causes markers to disappear. This will be fixed in a future release.
99 changes: 88 additions & 11 deletions src/Marker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import React, {
useCallback,
isValidElement,
type ComponentType,
useLayoutEffect,
} from 'react'
import { isValidElementType } from 'react-is'
import { createPortal } from 'react-dom'
Expand All @@ -17,28 +18,103 @@ import {
type LeafletEventHandlerFn,
type LeafletEventHandlerFnMap,
divIcon,
type PointExpression,
type DivIconOptions,
} from 'leaflet'
import { getCoordsFromPointExpression } from './utils'

export type MarkerProps = Omit<ReactLeafletMarkerProps, 'icon'> & {
type BaseMarkerProps<AdditionalIconTypes = never> = Omit<
ReactLeafletMarkerProps,
'icon'
> & {
/** A {@link ReactElement} representing the Markers icon, or any type from [react-leaflet Marker](https://react-leaflet.js.org/docs/api-components/#marker) component. */
icon: ReactElement | ComponentType | ReactLeafletMarkerProps['icon']
icon: ReactElement | AdditionalIconTypes

/**
* 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.
*
* 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.
*
* Typically, it is not necessary to override these options, and doing so may lead to unexpected results for some properties.
*
* These options are only effective when a React element/component is being used for the `icon` prop.
**/
iconComponentOpts?: Omit<
DivIconOptions,
'html' | 'bgPos' | 'shadowAnchor' | 'shadowRetinaUrl'
>

/**
* `"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
* added to ensure the icon component stays centred on the X axis with the marker.
*
* `'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.
*
* This option is not effective if `icon` is not a React element/component.
*
* @defaultValue `"fit-content"`
*/
iconComponentLayout: 'fit-content' | 'fit-parent'
}
export type MarkerProps = BaseMarkerProps<
ReactLeafletMarkerProps['icon'] | ComponentType
>

const DEFAULT_ICON_SIZE: PointExpression = [0, 0]
const ComponentMarker = ({
eventHandlers: providedEventHandlers,
icon: providedIcon,
iconComponentOpts = {},
iconComponentLayout = 'fit-content',
...otherProps
}: Omit<ReactLeafletMarkerProps, 'icon'> & { icon: ReactElement }) => {
}: BaseMarkerProps) => {
const [markerRendered, setMarkerRendered] = useState(false)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [changeCount, setChangeCount] = useState(0)
const id = 'marker-' + useId()

const icon = useMemo(
() =>
divIcon({
html: `<div id="${id}"></div>`,
}),
[id],
)
const {
attribution,
className,
iconAnchor,
iconSize = DEFAULT_ICON_SIZE,
pane,
popupAnchor,
tooltipAnchor,
} = iconComponentOpts

const iconDeps = [
id,
iconComponentLayout,
attribution,
className,
pane,
...getCoordsFromPointExpression(iconSize),
...getCoordsFromPointExpression(iconAnchor),
...getCoordsFromPointExpression(popupAnchor),
...getCoordsFromPointExpression(tooltipAnchor),
]

const icon = useMemo(() => {
const parentStyles =
iconComponentLayout === 'fit-content'
? 'width: min-content; transform: translateX(-50%)'
: 'width: 100%; height: 100%'
return divIcon({
html: `<div style="${parentStyles}" id="${id}"></div>`,
...(iconSize ? { iconSize } : []),
...(iconAnchor ? { iconAnchor } : []),
...(popupAnchor ? { popupAnchor } : []),
...(tooltipAnchor ? { tooltipAnchor } : []),
pane,
attribution,
className,
})
}, iconDeps)

useLayoutEffect(() => {
setChangeCount((prev) => prev + 1)
}, [icon])

const handleAddEvent = useCallback<LeafletEventHandlerFn>(
(...args) => {
Expand Down Expand Up @@ -82,9 +158,10 @@ const ComponentMarker = ({
eventHandlers={eventHandlers}
icon={icon}
/>

{markerRendered &&
portalTarget !== null &&
createPortal(providedIcon, portalTarget)}
createPortal(providedIcon, portalTarget, JSON.stringify(iconDeps))}
</>
)
}
Expand Down
10 changes: 10 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { type PointTuple, type PointExpression } from 'leaflet'

export const getCoordsFromPointExpression = (expression?: PointExpression) => {
if (!expression) return []
if (Array.isArray(expression)) {
return expression
} else {
return [expression.x, expression.y] as PointTuple
}
}
5 changes: 3 additions & 2 deletions tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
"jsx": "react",
"moduleResolution": "Node10",
"target": "ES2020",
"lib": ["dom", "es2020"],
"lib": ["dom", "dom.iterable", "es2020"],
"outDir": "dist",
"allowSyntheticDefaultImports": true,
"downlevelIteration": true
},
"include": ["./src/**/*", "cypress.config.js"],
"include": ["./src/**/*", "cypress.config.js"]
}

0 comments on commit 561ff08

Please sign in to comment.