Add filter toggle for user-owned data to map UI

This commit is contained in:
Paul Bienkowski 2022-06-26 12:51:15 +02:00
parent 5beb5ac0d3
commit 7716da8844
6 changed files with 293 additions and 179 deletions

View file

@ -1,54 +1,61 @@
import React, {useState, useCallback, useMemo, useEffect} from 'react' import React, { useState, useCallback, useMemo, useEffect } from "react";
import classnames from 'classnames' import classnames from "classnames";
import {connect} from 'react-redux' import { connect } from "react-redux";
import _ from 'lodash' import _ from "lodash";
import ReactMapGl, {WebMercatorViewport, ScaleControl, NavigationControl} from 'react-map-gl' import ReactMapGl, {
import turfBbox from '@turf/bbox' WebMercatorViewport,
import {useHistory, useLocation} from 'react-router-dom' ScaleControl,
NavigationControl,
} from "react-map-gl";
import turfBbox from "@turf/bbox";
import { useHistory, useLocation } from "react-router-dom";
import {useConfig} from 'config' import { useConfig } from "config";
import {baseMapStyles} from '../../mapstyles' import { useCallbackRef } from "../../utils";
import { baseMapStyles } from "../../mapstyles";
import styles from './styles.module.less' import styles from "./styles.module.less";
interface Viewport { interface Viewport {
longitude: number longitude: number;
latitude: number latitude: number;
zoom: number zoom: number;
} }
const EMPTY_VIEWPORT: Viewport = {longitude: 0, latitude: 0, zoom: 0} const EMPTY_VIEWPORT: Viewport = { longitude: 0, latitude: 0, zoom: 0 };
export const withBaseMapStyle = connect((state) => ({baseMapStyle: state.mapConfig?.baseMap?.style ?? 'positron'})) export const withBaseMapStyle = connect((state) => ({
baseMapStyle: state.mapConfig?.baseMap?.style ?? "positron",
}));
function parseHash(v: string): Viewport | null { function parseHash(v: string): Viewport | null {
if (!v) return null if (!v) return null;
const m = v.match(/^#([0-9\.]+)\/([0-9\.]+)\/([0-9\.]+)$/) const m = v.match(/^#([0-9\.]+)\/([0-9\.]+)\/([0-9\.]+)$/);
if (!m) return null if (!m) return null;
return { return {
zoom: Number.parseFloat(m[1]), zoom: Number.parseFloat(m[1]),
latitude: Number.parseFloat(m[2]), latitude: Number.parseFloat(m[2]),
longitude: Number.parseFloat(m[3]), longitude: Number.parseFloat(m[3]),
} };
} }
function buildHash(v: Viewport): string { function buildHash(v: Viewport): string {
return `${v.zoom.toFixed(2)}/${v.latitude}/${v.longitude}` return `${v.zoom.toFixed(2)}/${v.latitude}/${v.longitude}`;
} }
function useViewportFromUrl(): [Viewport | null, (v: Viewport) => void] { function useViewportFromUrl(): [Viewport | null, (v: Viewport) => void] {
const history = useHistory() const history = useHistory();
const location = useLocation() const location = useLocation();
const value = useMemo(() => parseHash(location.hash), [location.hash]) const value = useMemo(() => parseHash(location.hash), [location.hash]);
const setter = useCallback( const setter = useCallback(
(v) => { (v) => {
history.replace({ history.replace({
hash: buildHash(v), hash: buildHash(v),
}) });
}, },
[history] [history]
) );
return [value || EMPTY_VIEWPORT, setter] return [value || EMPTY_VIEWPORT, setter];
} }
function Map({ function Map({
@ -58,29 +65,58 @@ function Map({
baseMapStyle, baseMapStyle,
...props ...props
}: { }: {
viewportFromUrl?: boolean viewportFromUrl?: boolean;
children: React.ReactNode children: React.ReactNode;
boundsFromJson: GeoJSON.Geometry boundsFromJson: GeoJSON.Geometry;
baseMapStyle: string baseMapStyle: string;
}) { }) {
const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT) const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT);
const [viewportUrl, setViewportUrl] = useViewportFromUrl() const [viewportUrl, setViewportUrl] = useViewportFromUrl();
const [viewport, setViewport] = viewportFromUrl ? [viewportUrl, setViewportUrl] : [viewportState, setViewportState] const [viewport, setViewport] = viewportFromUrl
? [viewportUrl, setViewportUrl]
: [viewportState, setViewportState];
const config = useConfig() const config = useConfig();
useEffect(() => { useEffect(() => {
if (config?.mapHome && viewport?.latitude === 0 && viewport?.longitude === 0 && !boundsFromJson) { if (
setViewport(config.mapHome) config?.mapHome &&
viewport?.latitude === 0 &&
viewport?.longitude === 0 &&
!boundsFromJson
) {
setViewport(config.mapHome);
} }
}, [config, boundsFromJson]) }, [config, boundsFromJson]);
const mapSourceHosts = useMemo(
() =>
_.uniq(
config?.obsMapSource?.tiles?.map(
(tileUrl: string) => new URL(tileUrl).host
) ?? []
),
[config?.obsMapSource]
);
const transformRequest = useCallbackRef((url, resourceType) => {
if (resourceType === "Tile" && mapSourceHosts.includes(new URL(url).host)) {
return {
url,
credentials: "include",
};
}
});
useEffect(() => { useEffect(() => {
if (boundsFromJson) { if (boundsFromJson) {
const bbox = turfBbox(boundsFromJson); const bbox = turfBbox(boundsFromJson);
if (bbox.every(v => Math.abs(v) !== Infinity)) { if (bbox.every((v) => Math.abs(v) !== Infinity)) {
const [minX, minY, maxX, maxY] = bbox; const [minX, minY, maxX, maxY] = bbox;
const vp = new WebMercatorViewport({width: 1000, height: 800}).fitBounds( const vp = new WebMercatorViewport({
width: 1000,
height: 800,
}).fitBounds(
[ [
[minX, minY], [minX, minY],
[maxX, maxY], [maxX, maxY],
@ -89,11 +125,11 @@ function Map({
padding: 20, padding: 20,
offset: [0, -100], offset: [0, -100],
} }
) );
setViewport(_.pick(vp, ['zoom', 'latitude', 'longitude'])) setViewport(_.pick(vp, ["zoom", "latitude", "longitude"]));
} }
} }
}, [boundsFromJson]) }, [boundsFromJson]);
return ( return (
<ReactMapGl <ReactMapGl
@ -101,16 +137,21 @@ function Map({
width="100%" width="100%"
height="100%" height="100%"
onViewportChange={setViewport} onViewportChange={setViewport}
{...{ transformRequest }}
{...viewport} {...viewport}
{...props} {...props}
className={classnames(styles.map, props.className)} className={classnames(styles.map, props.className)}
> >
<NavigationControl style={{left: 10, top: 10}} /> <NavigationControl style={{ left: 10, top: 10 }} />
<ScaleControl maxWidth={200} unit="metric" style={{left: 10, bottom: 10}} /> <ScaleControl
maxWidth={200}
unit="metric"
style={{ left: 10, bottom: 10 }}
/>
{children} {children}
</ReactMapGl> </ReactMapGl>
) );
} }
export default withBaseMapStyle(Map) export default withBaseMapStyle(Map);

View file

@ -1,50 +1,45 @@
import React from 'react' import React from "react";
export type MapSoure = export type MapSource = {
| { type: "vector";
type: 'vector' tiles: string[];
url: string minzoom: number;
} maxzoom: number;
| { };
type: 'vector'
tiles: string[]
minzoom: number
maxzoom: number
}
export interface Config { export interface Config {
apiUrl: string apiUrl: string;
mapHome: { mapHome: {
latitude: number latitude: number;
longitude: number longitude: number;
zoom: number zoom: number;
} };
obsMapSource?: MapSoure obsMapSource?: MapSource;
imprintUrl?: string imprintUrl?: string;
privacyPolicyUrl?: string privacyPolicyUrl?: string;
banner?: { banner?: {
text: string text: string;
style?: 'warning' | 'info' style?: "warning" | "info";
} };
} }
async function loadConfig(): Promise<Config> { async function loadConfig(): Promise<Config> {
const response = await fetch(__webpack_public_path__ + 'config.json') const response = await fetch(__webpack_public_path__ + "config.json");
const config = await response.json() const config = await response.json();
return config return config;
} }
let _configPromise: Promise<Config> = loadConfig() let _configPromise: Promise<Config> = loadConfig();
let _configCache: null | Config = null let _configCache: null | Config = null;
export function useConfig() { export function useConfig() {
const [config, setConfig] = React.useState<Config>(_configCache) const [config, setConfig] = React.useState<Config>(_configCache);
React.useEffect(() => { React.useEffect(() => {
if (!_configCache) { if (!_configCache) {
_configPromise.then(setConfig) _configPromise.then(setConfig);
} }
}, []) }, []);
return config return config;
} }
export default _configPromise export default _configPromise;

View file

@ -32,10 +32,14 @@ const ROAD_ATTRIBUTE_OPTIONS = [
"zone", "zone",
]; ];
type User = Object;
function LayerSidebar({ function LayerSidebar({
mapConfig, mapConfig,
login,
setMapConfigFlag, setMapConfigFlag,
}: { }: {
login: User | null;
mapConfig: MapConfig; mapConfig: MapConfig;
setMapConfigFlag: (flag: string, value: unknown) => void; setMapConfigFlag: (flag: string, value: unknown) => void;
}) { }) {
@ -44,6 +48,7 @@ function LayerSidebar({
baseMap: { style }, baseMap: { style },
obsRoads: { show: showRoads, showUntagged, attribute, maxCount }, obsRoads: { show: showRoads, showUntagged, attribute, maxCount },
obsEvents: { show: showEvents }, obsEvents: { show: showEvents },
filters: { currentUser: filtersCurrentUser },
} = mapConfig; } = mapConfig;
return ( return (
@ -134,22 +139,40 @@ function LayerSidebar({
/> />
</List.Item> </List.Item>
</> </>
) : attribute.endsWith('zone') ? ( ) : attribute.endsWith("zone") ? (
<>
<List.Item>
<Label size="small" style={{background: "blue",color:"white"}}>{t("general.zone.urban")} (1.5&nbsp;m)</Label>
<Label size="small" style={{background: "cyan", color:"black"}}>{t("general.zone.rural")}(2&nbsp;m)</Label>
</List.Item></>
) :
(
<> <>
<List.Item> <List.Item>
<List.Header>{_.upperFirst(t("general.zone.urban"))}</List.Header> <Label
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][5].slice(2)} /> size="small"
style={{ background: "blue", color: "white" }}
>
{t("general.zone.urban")} (1.5&nbsp;m)
</Label>
<Label
size="small"
style={{ background: "cyan", color: "black" }}
>
{t("general.zone.rural")}(2&nbsp;m)
</Label>
</List.Item>
</>
) : (
<>
<List.Item>
<List.Header>
{_.upperFirst(t("general.zone.urban"))}
</List.Header>
<DiscreteColorMapLegend
map={colorByDistance("distance_overtaker")[3][5].slice(2)}
/>
</List.Item> </List.Item>
<List.Item> <List.Item>
<List.Header>{_.upperFirst(t("general.zone.rural"))}</List.Header> <List.Header>
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][3].slice(2)} /> {_.upperFirst(t("general.zone.rural"))}
</List.Header>
<DiscreteColorMapLegend
map={colorByDistance("distance_overtaker")[3][3].slice(2)}
/>
</List.Item> </List.Item>
</> </>
)} )}
@ -172,15 +195,38 @@ function LayerSidebar({
{showEvents && ( {showEvents && (
<> <>
<List.Item> <List.Item>
<List.Header>{_.upperFirst(t('general.zone.urban'))}</List.Header> <List.Header>{_.upperFirst(t("general.zone.urban"))}</List.Header>
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][5].slice(2)} /> <DiscreteColorMapLegend
map={colorByDistance("distance_overtaker")[3][5].slice(2)}
/>
</List.Item> </List.Item>
<List.Item> <List.Item>
<List.Header>{_.upperFirst(t('general.zone.rural'))}</List.Header> <List.Header>{_.upperFirst(t("general.zone.rural"))}</List.Header>
<DiscreteColorMapLegend map={colorByDistance('distance_overtaker')[3][3].slice(2)} /> <DiscreteColorMapLegend
map={colorByDistance("distance_overtaker")[3][3].slice(2)}
/>
</List.Item> </List.Item>
</> </>
)} )}
<Divider />
<List.Item>
<Header as="h4" style={{ marginBottom: 8 }}>
Filter
</Header>
{login && (
<Checkbox
toggle
size="small"
id="filters.currentUser"
checked={filtersCurrentUser}
onChange={() =>
setMapConfigFlag("filters.currentUser", !filtersCurrentUser)
}
label="Show only my own data"
/>
)}
{!login && <div>No filters available without login.</div>}
</List.Item>
</List> </List>
</div> </div>
); );
@ -194,6 +240,7 @@ export default connect(
(state as any).mapConfig as MapConfig (state as any).mapConfig as MapConfig
// //
), ),
login: state.login,
}), }),
{ setMapConfigFlag: setMapConfigFlagAction } { setMapConfigFlag: setMapConfigFlagAction }
// //

View file

@ -1,44 +1,45 @@
import React, {useState, useCallback, useMemo} from 'react' import React, { useState, useCallback, useMemo } from "react";
import _ from 'lodash' import _ from "lodash";
import {Button} from 'semantic-ui-react' import { connect } from "react-redux";
import {Layer, Source} from 'react-map-gl' import { Button } from "semantic-ui-react";
import produce from 'immer' import { Layer, Source } from "react-map-gl";
import produce from "immer";
import {Page, Map} from 'components' import {Page, Map} from 'components'
import {useConfig} from 'config' import {useConfig} from 'config'
import {colorByDistance, colorByCount, borderByZone, reds, isValidAttribute} from 'mapstyles' import {colorByDistance, colorByCount, borderByZone, reds, isValidAttribute} from 'mapstyles'
import {useMapConfig} from 'reducers/mapConfig' import {useMapConfig} from 'reducers/mapConfig'
import RoadInfo from './RoadInfo' import RoadInfo from "./RoadInfo";
import LayerSidebar from './LayerSidebar' import LayerSidebar from "./LayerSidebar";
import styles from './styles.module.less' import styles from "./styles.module.less";
const untaggedRoadsLayer = { const untaggedRoadsLayer = {
id: 'obs_roads_untagged', id: "obs_roads_untagged",
type: 'line', type: "line",
source: 'obs', source: "obs",
'source-layer': 'obs_roads', "source-layer": "obs_roads",
filter: ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]], filter: ["!", ["to-boolean", ["get", "distance_overtaker_mean"]]],
layout: { layout: {
'line-cap': 'round', "line-cap": "round",
'line-join': 'round', "line-join": "round",
}, },
paint: { paint: {
'line-width': ['interpolate', ['exponential', 1.5], ['zoom'], 12, 2, 17, 2], "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 12, 2, 17, 2],
'line-color': '#ABC', "line-color": "#ABC",
'line-opacity': ['interpolate', ['linear'], ['zoom'], 14, 0, 15, 1], "line-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1],
'line-offset': [ "line-offset": [
'interpolate', "interpolate",
['exponential', 1.5], ["exponential", 1.5],
['zoom'], ["zoom"],
12, 12,
['get', 'offset_direction'], ["get", "offset_direction"],
19, 19,
['*', ['get', 'offset_direction'], 8], ["*", ["get", "offset_direction"], 8],
], ],
}, },
minzoom: 12, minzoom: 12,
} };
const getUntaggedRoadsLayer = (colorAttribute, maxCount) => const getUntaggedRoadsLayer = (colorAttribute, maxCount) =>
produce(untaggedRoadsLayer, (draft) => { produce(untaggedRoadsLayer, (draft) => {
@ -48,110 +49,122 @@ const getUntaggedRoadsLayer = (colorAttribute, maxCount) =>
const getRoadsLayer = (colorAttribute, maxCount) => const getRoadsLayer = (colorAttribute, maxCount) =>
produce(untaggedRoadsLayer, (draft) => { produce(untaggedRoadsLayer, (draft) => {
draft.id = 'obs_roads_normal' draft.id = "obs_roads_normal";
draft.filter = isValidAttribute(colorAttribute) draft.filter = isValidAttribute(colorAttribute)
draft.paint['line-width'][6] = 6 // scale bigger on zoom draft.paint["line-width"][6] = 6; // scale bigger on zoom
draft.paint['line-color'] = colorAttribute.startsWith('distance_') draft.paint["line-color"] = colorAttribute.startsWith("distance_")
? colorByDistance(colorAttribute) ? colorByDistance(colorAttribute)
: colorAttribute.endsWith('_count') : colorAttribute.endsWith("_count")
? colorByCount(colorAttribute, maxCount) ? colorByCount(colorAttribute, maxCount)
: colorAttribute.endsWith('zone') : colorAttribute.endsWith('zone')
? borderByZone() ? borderByZone()
: '#DDD' : "#DDD";
draft.paint['line-opacity'][3] = 12 draft.paint["line-opacity"][3] = 12;
draft.paint['line-opacity'][5] = 13 draft.paint["line-opacity"][5] = 13;
}) });
const getEventsLayer = () => ({ const getEventsLayer = () => ({
id: 'obs_events', id: "obs_events",
type: 'circle', type: "circle",
source: 'obs', source: "obs",
'source-layer': 'obs_events', "source-layer": "obs_events",
paint: { paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 3, 17, 8], "circle-radius": ["interpolate", ["linear"], ["zoom"], 14, 3, 17, 8],
'circle-color': colorByDistance('distance_overtaker'), "circle-color": colorByDistance("distance_overtaker"),
}, },
minzoom: 11, minzoom: 11,
}) });
const getEventsTextLayer = () => ({ const getEventsTextLayer = () => ({
id: 'obs_events_text', id: "obs_events_text",
type: 'symbol', type: "symbol",
minzoom: 18, minzoom: 18,
source: 'obs', source: "obs",
'source-layer': 'obs_events', "source-layer": "obs_events",
layout: { layout: {
'text-field': [ "text-field": [
'number-format', "number-format",
['get', 'distance_overtaker'], ["get", "distance_overtaker"],
{'min-fraction-digits': 2, 'max-fraction-digits': 2}, { "min-fraction-digits": 2, "max-fraction-digits": 2 },
], ],
'text-allow-overlap': true, "text-allow-overlap": true,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Regular'], "text-font": ["Open Sans Bold", "Arial Unicode MS Regular"],
'text-size': 14, "text-size": 14,
'text-keep-upright': false, "text-keep-upright": false,
'text-anchor': 'left', "text-anchor": "left",
'text-radial-offset': 1, "text-radial-offset": 1,
'text-rotate': ['-', 90, ['*', ['get', 'course'], 180 / Math.PI]], "text-rotate": ["-", 90, ["*", ["get", "course"], 180 / Math.PI]],
'text-rotation-alignment': 'map', "text-rotation-alignment": "map",
}, },
paint: { paint: {
'text-halo-color': 'rgba(255, 255, 255, 1)', "text-halo-color": "rgba(255, 255, 255, 1)",
'text-halo-width': 1, "text-halo-width": 1,
'text-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.3, 1], "text-opacity": ["interpolate", ["linear"], ["zoom"], 15, 0, 15.3, 1],
}, },
}) });
export default function MapPage() { function MapPage({ login }) {
const {obsMapSource} = useConfig() || {} const { obsMapSource } = useConfig() || {};
const [clickLocation, setClickLocation] = useState<{longitude: number; latitude: number} | null>(null) const [clickLocation, setClickLocation] = useState<{
longitude: number;
latitude: number;
} | null>(null);
const mapConfig = useMapConfig() const mapConfig = useMapConfig();
const onClick = useCallback( const onClick = useCallback(
(e) => { (e) => {
let node = e.target let node = e.target;
while (node) { while (node) {
if (node?.classList?.contains(styles.mapInfoBox)) { if (node?.classList?.contains(styles.mapInfoBox)) {
return return;
} }
node = node.parentNode node = node.parentNode;
} }
setClickLocation({longitude: e.lngLat[0], latitude: e.lngLat[1]}) setClickLocation({ longitude: e.lngLat[0], latitude: e.lngLat[1] });
}, },
[setClickLocation] [setClickLocation]
) );
const [layerSidebar, setLayerSidebar] = useState(true) const [layerSidebar, setLayerSidebar] = useState(true);
const { const {
obsRoads: {attribute, maxCount}, obsRoads: { attribute, maxCount },
} = mapConfig } = mapConfig;
const layers = [] const layers = [];
const untaggedRoadsLayerCustom = useMemo(() => getUntaggedRoadsLayer(attribute), [attribute]) const untaggedRoadsLayerCustom = useMemo(() => getUntaggedRoadsLayer(attribute), [attribute])
if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) { if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) {
layers.push(untaggedRoadsLayerCustom) layers.push(untaggedRoadsLayerCustom)
} }
const roadsLayer = useMemo(() => getRoadsLayer(attribute, maxCount), [attribute, maxCount]) const roadsLayer = useMemo(
() => getRoadsLayer(attribute, maxCount),
[attribute, maxCount]
);
if (mapConfig.obsRoads.show) { if (mapConfig.obsRoads.show) {
layers.push(roadsLayer) layers.push(roadsLayer);
} }
const eventsLayer = useMemo(() => getEventsLayer(), []) const eventsLayer = useMemo(() => getEventsLayer(), []);
const eventsTextLayer = useMemo(() => getEventsTextLayer(), []) const eventsTextLayer = useMemo(() => getEventsTextLayer(), []);
if (mapConfig.obsEvents.show) { if (mapConfig.obsEvents.show) {
layers.push(eventsLayer) layers.push(eventsLayer);
layers.push(eventsTextLayer) layers.push(eventsTextLayer);
} }
if (!obsMapSource) { if (!obsMapSource) {
return null return null;
} }
const tiles = obsMapSource?.tiles?.map(
(tileUrl: string) =>
tileUrl +
(login && mapConfig.filters.currentUser ? `?user=${login.username}` : "")
);
return ( return (
<Page fullScreen title="Map"> <Page fullScreen title="Map">
<div className={styles.mapContainer}> <div className={styles.mapContainer}>
@ -164,7 +177,7 @@ export default function MapPage() {
<Map viewportFromUrl onClick={onClick}> <Map viewportFromUrl onClick={onClick}>
<Button <Button
style={{ style={{
position: 'absolute', position: "absolute",
left: 44, left: 44,
top: 9, top: 9,
}} }}
@ -173,16 +186,20 @@ export default function MapPage() {
active={layerSidebar} active={layerSidebar}
onClick={() => setLayerSidebar(layerSidebar ? false : true)} onClick={() => setLayerSidebar(layerSidebar ? false : true)}
/> />
<Source id="obs" {...obsMapSource}> <Source id="obs" {...obsMapSource} tiles={tiles}>
{layers.map((layer) => ( {layers.map((layer) => (
<Layer key={layer.id} {...layer} /> <Layer key={layer.id} {...layer} />
))} ))}
</Source> </Source>
<RoadInfo {...{clickLocation}} /> <RoadInfo {...{ clickLocation }} />
</Map> </Map>
</div> </div>
</div> </div>
</Page> </Page>
) );
} }
export default connect(
(state) => ({login: state.login}),
)(MapPage);

View file

@ -27,6 +27,9 @@ export type MapConfig = {
obsEvents: { obsEvents: {
show: boolean; show: boolean;
}; };
filters: {
currentUser: boolean;
};
}; };
export const initialState: MapConfig = { export const initialState: MapConfig = {
@ -42,6 +45,9 @@ export const initialState: MapConfig = {
obsEvents: { obsEvents: {
show: false, show: false,
}, },
filters: {
currentUser: false,
},
}; };
type MapConfigAction = { type MapConfigAction = {

View file

@ -1,3 +1,5 @@
import {useRef, useCallback} from 'react'
// Wraps the register callback from useForm into a new ref function, such that // Wraps the register callback from useForm into a new ref function, such that
// any child of the provided element that is an input component will be // any child of the provided element that is an input component will be
// registered. // registered.
@ -22,3 +24,9 @@ export function* pairwise(it) {
lastValue = i lastValue = i
} }
} }
export function useCallbackRef(fn) {
const fnRef = useRef()
fnRef.current = fn
return useCallback(((...args) => fnRef.current(...args)), [])
}