import { DeckGL } from '@deck.gl/react';
import { MapController } from '@deck.gl/core';
import * as React from 'react';
import { ReactNode, Reducer, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { alea } from 'seedrandom';
import { _MapContext as MapContext, StaticMap } from 'react-map-gl';
import FlowMapLayer, {
FlowLayerPickingInfo,
FlowPickingInfo,
LocationPickingInfo,
PickingType,
} from '@flowmap.gl/core';
import { Button, ButtonGroup, Classes, Colors, HTMLSelect, Intent } from '@blueprintjs/core';
import { getViewStateForLocations, LocationTotalsLegend } from '@flowmap.gl/react';
import WebMercatorViewport from '@math.gl/web-mercator';
import { Absolute, Box, BoxStyle, Column, Description, LegendTitle, Title, TitleBox, ToastContent, } from './Boxes';
import { FlowTooltipContent, formatCount, LocationTooltipContent } from './TooltipContent';
import Tooltip, { TargetBounds } from './Tooltip';
import { Link, useHistory } from 'react-router-dom';
import Collapsible, { Direction } from './Collapsible';
import {
AsyncState,
Config,
ConfigPropName,
Flow,
getFlowDestId,
getFlowMagnitude,
getFlowOriginId,
getLocationCentroid,
getLocationId,
Location,
ViewportProps,
} from './types';
import Message from './Message';
import LoadingSpinner from './LoadingSpinner';
import NoScrollContainer from './NoScrollContainer';
import styled from '@emotion/styled';
import { IconNames } from '@blueprintjs/icons';
import LocationsSearchBox from './LocationSearchBox';
import Away from './Away';
import {
Action,
ActionType,
getInitialState,
Highlight,
HighlightType,
LocationFilterMode,
mapTransition,
MAX_PITCH,
MAX_ZOOM_LEVEL,
MIN_PITCH,
MIN_ZOOM_LEVEL,
reducer,
State,
stateToQueryString,
} from './FlowMap.state';
import {
getAvailableClusterZoomLevels,
getClusterIndex,
getClusterZoom,
getDarkMode,
getDiffMode,
getFetchedFlows,
getFlowMagnitudeExtent,
getFlowMapColors,
getFlowsForFlowMapLayer,
getFlowsSheets,
getInvalidLocationIds,
getLocations,
getLocationsForFlowMapLayer,
getLocationsForSearchBox,
getLocationsHavingFlows,
getLocationsInBbox,
getLocationsTree,
getLocationTotals,
getLocationTotalsExtent,
getMapboxMapStyle,
getMaxLocationCircleSize,
getSortedFlowsForKnownLocations,
getTimeExtent,
getTimeGranularity,
getTotalCountsByTime,
getTotalFilteredCount,
getTotalUnfilteredCount,
getUnknownLocations,
NUMBER_OF_FLOWS_TO_DISPLAY,
} from './FlowMap.selectors';
import { AppToaster } from './AppToaster';
import useDebounced from './hooks';
import SharePopover from './SharePopover';
import SettingsPopover from './SettingsPopover';
import MapDrawingEditor, { MapDrawingFeature, MapDrawingMode } from './MapDrawingEditor';
import getBbox from '@turf/bbox';
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
import Timeline from './Timeline';
import { TimeGranularity } from './time';
import { findAppropriateZoomLevel } from '@flowmap.gl/cluster/dist-esm';
const CONTROLLER_OPTIONS = {
type: MapController,
doubleClickZoom: false,
dragRotate: true,
touchRotate: true,
minZoom: MIN_ZOOM_LEVEL,
maxZoom: MAX_ZOOM_LEVEL,
};
export type Props = {
inBrowser: boolean;
embed?: boolean;
config: Config;
locationsFetch: AsyncState;
flowsFetch: AsyncState;
spreadSheetKey: string | undefined;
flowsSheet: string | undefined;
onSetFlowsSheet?: (sheet: string) => void;
};
/* This is temporary until mixBlendMode style prop works in as before v8 */
const DeckGLOuter = styled.div<{
darkMode: boolean;
baseMapOpacity: number;
cursor: 'crosshair' | 'pointer' | undefined;
}>(
({ cursor, baseMapOpacity, darkMode }) => `
& #deckgl-overlay {
mix-blend-mode: ${darkMode ? 'screen' : 'multiply'};
}
& .mapboxgl-map {
opacity: ${baseMapOpacity}
}
${cursor != null ? `& #view-default-view { cursor: ${cursor}; }` : ''},
`
);
export const ErrorsLocationsBlock = styled.div`
font-size: 8px;
padding: 10px;
max-height: 100px;
overflow: auto;
`;
const SelectedTimeRangeBox = styled(BoxStyle)<{ darkMode: boolean }>((props) => ({
display: 'flex',
alignSelf: 'center',
fontSize: 12,
padding: '5px 10px',
borderRadius: 5,
backgroundColor: props.darkMode ? Colors.DARK_GRAY4 : Colors.LIGHT_GRAY4,
textAlign: 'center',
}));
const TimelineBox = styled(BoxStyle)({
minWidth: 400,
display: 'block',
boxShadow: '0 0 5px #aaa',
borderTop: '1px solid #999',
});
const TotalCount = styled.div<{ darkMode: boolean }>((props) => ({
padding: 5,
borderRadius: 5,
backgroundColor: props.darkMode ? Colors.DARK_GRAY4 : Colors.LIGHT_GRAY4,
textAlign: 'center',
}));
export const MAX_NUM_OF_IDS_IN_ERROR = 100;
const FlowMap: React.FC = (props) => {
const { inBrowser, embed, config, spreadSheetKey, locationsFetch, flowsFetch } = props;
const deckRef = useRef();
const history = useHistory();
const initialState = useMemo(() => getInitialState(config, history.location.search), [
config,
// history.location.search, // this leads to initial state being recomputed on every change
]);
const outerRef = useRef(null);
const [state, dispatch] = useReducer>(reducer, initialState);
const [mapDrawingEnabled, setMapDrawingEnabled] = useState(false);
const { selectedTimeRange } = state;
const timeGranularity = getTimeGranularity(state, props);
const timeExtent = getTimeExtent(state, props);
const totalCountsByTime = getTotalCountsByTime(state, props);
const totalFilteredCount = getTotalFilteredCount(state, props);
const totalUnfilteredCount = getTotalUnfilteredCount(state, props);
useEffect(() => {
if (timeExtent) {
if (!selectedTimeRange ||
// reset selectedTimeRange if not within the timeExtent
!(timeExtent[0] <= selectedTimeRange[0] && selectedTimeRange[1] <= timeExtent[1])
) {
dispatch({
type: ActionType.SET_TIME_RANGE,
range: timeExtent,
});
}
}
}, [timeExtent, selectedTimeRange]);
const [updateQuerySearch] = useDebounced(
() => {
if (inBrowser) return;
const locationSearch = `?${stateToQueryString(state)}`;
if (locationSearch !== history.location.search) {
history.replace({
...history.location, // keep location state for in-browser flowmap
search: locationSearch,
});
}
},
250,
[state, history.location.search]
);
useEffect(updateQuerySearch, [history, state]);
const { viewport, tooltip, animationEnabled, baseMapEnabled } = state;
const allFlows = getFetchedFlows(state, props);
const allLocations = getLocations(state, props);
const locationsHavingFlows = getLocationsHavingFlows(state, props);
const locations = getLocationsForFlowMapLayer(state, props);
const flows = getFlowsForFlowMapLayer(state, props);
const flowsSheets = getFlowsSheets(config);
const handleKeyDown = (evt: Event) => {
if (evt instanceof KeyboardEvent && evt.key === 'Escape') {
if (mapDrawingEnabled) {
setMapDrawingEnabled(false);
} else {
if (tooltip) {
hideTooltip();
}
if (state.highlight) {
highlight(undefined);
}
// dispatch({ type: ActionType.CLEAR_SELECTION });
}
}
};
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
});
const [time, setTime] = useState(0);
// Use useRef for mutable variables that we want to persist
// without triggering a re-render on their change
const requestAnimationFrameRef = useRef();
const animate = useCallback(
(time: number) => {
const loopLength = 1800;
const animationSpeed = 20;
const loopTime = loopLength / animationSpeed;
const timestamp = time / 1000;
setTime(((timestamp % loopTime) / loopTime) * loopLength);
requestAnimationFrameRef.current = requestAnimationFrame(animate);
},
[requestAnimationFrameRef, setTime]
);
useEffect(() => {
if (animationEnabled) {
requestAnimationFrameRef.current = requestAnimationFrame(animate);
} else {
const animationFrame = requestAnimationFrameRef.current;
if (animationFrame != null && animationFrame > 0) {
window.cancelAnimationFrame(animationFrame);
requestAnimationFrameRef.current = undefined;
}
}
return () => {
if (requestAnimationFrameRef.current != null) {
cancelAnimationFrame(requestAnimationFrameRef.current);
}
};
}, [animationEnabled, animate]);
const showErrorToast = useCallback(
(errorText: ReactNode) => {
if (config[ConfigPropName.IGNORE_ERRORS] !== 'yes') {
AppToaster.show({
intent: Intent.WARNING,
icon: IconNames.WARNING_SIGN,
timeout: 0,
message: {errorText},
});
}
},
[config]
);
const invalidLocations = getInvalidLocationIds(state, props);
useEffect(() => {
if (invalidLocations) {
showErrorToast(
<>
Locations with the following IDs have invalid coordinates:
{(invalidLocations.length > MAX_NUM_OF_IDS_IN_ERROR
? invalidLocations.slice(0, MAX_NUM_OF_IDS_IN_ERROR)
: invalidLocations
)
.map((id) => `${id}`)
.join(', ')}
{invalidLocations.length > MAX_NUM_OF_IDS_IN_ERROR &&
`… and ${invalidLocations.length - MAX_NUM_OF_IDS_IN_ERROR} others`}
Make sure you named the columns "lat" and "lon" and didn't confuse latitudes and
longitudes. The coordinates must be in decimal form. If your coordinates are in
degrees-minutes-seconds (DSM format) you can convert them with{' '}
this tool
{' '}
for example.
>
);
}
}, [invalidLocations, showErrorToast]);
const unknownLocations = getUnknownLocations(state, props);
const flowsForKnownLocations = getSortedFlowsForKnownLocations(state, props);
useEffect(() => {
if (unknownLocations) {
if (flowsForKnownLocations && allFlows) {
const ids = Array.from(unknownLocations).sort();
showErrorToast(
<>
Locations with the following IDs couldn't be found in the locations sheet:
{(ids.length > MAX_NUM_OF_IDS_IN_ERROR ? ids.slice(0, MAX_NUM_OF_IDS_IN_ERROR) : ids)
.map((id) => `${id}`)
.join(', ')}
{ids.length > MAX_NUM_OF_IDS_IN_ERROR &&
`… and ${ids.length - MAX_NUM_OF_IDS_IN_ERROR} others`}
{formatCount(allFlows.length - flowsForKnownLocations.length)} flows were omitted.
{flowsForKnownLocations.length === 0 && (
Make sure the columns are named header row in the flows sheet is correct. There must
be origin, dest, and count.
)}
>
);
}
}
}, [unknownLocations, showErrorToast, allFlows, flowsForKnownLocations]);
const { adjustViewportToLocations } = state;
useEffect(() => {
if (!adjustViewportToLocations) {
return;
}
const width = window.innerWidth;
const height = window.innerHeight;
if (allLocations != null) {
let draft = getViewStateForLocations(
locationsHavingFlows ?? allLocations,
getLocationCentroid,
[width, height],
{ pad: 0.1 }
);
if (!draft.zoom) {
draft = {
zoom: 1,
latitude: 0,
longitude: 0,
};
}
dispatch({
type: ActionType.SET_VIEWPORT,
viewport: {
width,
height,
...draft,
minZoom: MIN_ZOOM_LEVEL,
maxZoom: MAX_ZOOM_LEVEL,
minPitch: MIN_PITCH,
maxPitch: MAX_PITCH,
bearing: 0,
pitch: 0,
altitude: 1.5,
...mapTransition(1000),
},
adjustViewportToLocations: false,
});
}
}, [allLocations, locationsHavingFlows, adjustViewportToLocations]);
const clusterIndex = getClusterIndex(state, props);
const handleChangeClusteringAuto = (value: boolean) => {
if (!value) {
if (clusterIndex) {
const { availableZoomLevels } = clusterIndex;
if (availableZoomLevels != null) {
dispatch({
type: ActionType.SET_MANUAL_CLUSTER_ZOOM,
manualClusterZoom:
findAppropriateZoomLevel(clusterIndex.availableZoomLevels, viewport.zoom),
});
}
}
}
dispatch({
type: ActionType.SET_CLUSTERING_AUTO,
clusteringAuto: value,
});
}
const [showFullscreenButton, setShowFullscreenButton] = useState(
embed && document.fullscreenEnabled
);
useEffect(() => {
function handleFullScreenChange() {
setShowFullscreenButton(
embed && document.fullscreenEnabled && !document.fullscreenElement
);
}
document.addEventListener('fullscreenchange', handleFullScreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullScreenChange);
}, [setShowFullscreenButton]);
const getContainerClientRect = useCallback(() => {
const container = outerRef.current;
if (!container) return undefined;
return container.getBoundingClientRect();
}, [outerRef]);
const getMercator = useCallback(() => {
const containerBounds = getContainerClientRect();
if (!containerBounds) return undefined;
const { width, height } = containerBounds;
return new WebMercatorViewport({
...viewport,
width,
height,
});
}, [viewport, getContainerClientRect]);
const showTooltip = (bounds: TargetBounds, content: React.ReactNode) => {
const containerBounds = getContainerClientRect();
if (!containerBounds) return;
const { top, left } = containerBounds;
dispatch({
type: ActionType.SET_TOOLTIP,
tooltip: {
target: {
...bounds,
left: left + bounds.left,
top: top + bounds.top,
},
placement: 'top',
content,
},
});
};
const highlight = (highlight: Highlight | undefined) => {
dispatch({ type: ActionType.SET_HIGHLIGHT, highlight });
};
const [showTooltipDebounced, cancelShowTooltipDebounced] = useDebounced(showTooltip, 500);
const [highlightDebounced, cancelHighlightDebounced] = useDebounced(highlight, 500);
const hideTooltip = () => {
dispatch({
type: ActionType.SET_TOOLTIP,
tooltip: undefined,
});
cancelShowTooltipDebounced();
};
const showFlowTooltip = (pos: [number, number], info: FlowPickingInfo) => {
const [x, y] = pos;
const r = 5;
const bounds = {
left: x - r,
top: y - r,
width: r * 2,
height: r * 2,
};
const content = (
);
if (state.tooltip) {
showTooltip(bounds, content);
cancelShowTooltipDebounced();
} else {
showTooltipDebounced(bounds, content);
}
};
const showLocationTooltip = (info: LocationPickingInfo) => {
const { object: location, circleRadius } = info;
const mercator = getMercator();
if (!mercator) return;
const [x, y] = mercator.project(getLocationCentroid(location));
const r = circleRadius + 5;
const { selectedLocations } = state;
const bounds = {
left: x - r,
top: y - r,
width: r * 2,
height: r * 2,
};
const content = (
id === location.id) ? true : false
}
config={config}
/>
);
if (state.tooltip) {
showTooltip(bounds, content);
cancelShowTooltipDebounced();
} else {
showTooltipDebounced(bounds, content);
}
};
const handleHover = (info: FlowLayerPickingInfo) => {
const { type, object, x, y } = info;
switch (type) {
case PickingType.FLOW: {
if (object) {
highlight({
type: HighlightType.FLOW,
flow: object,
});
cancelHighlightDebounced();
showFlowTooltip([x, y], info as FlowPickingInfo);
} else {
highlight(undefined);
cancelHighlightDebounced();
hideTooltip();
}
break;
}
case PickingType.LOCATION: {
if (object) {
highlightDebounced({
type: HighlightType.LOCATION,
locationId: getLocationId!(object),
});
showLocationTooltip(info as LocationPickingInfo);
} else {
highlight(undefined);
cancelHighlightDebounced();
hideTooltip();
}
break;
}
default: {
highlight(undefined);
cancelHighlightDebounced();
hideTooltip();
}
}
};
if (locationsFetch.loading) {
return ;
}
if (locationsFetch.error || flowsFetch.error) {
return (
{spreadSheetKey
? <>
Oops… Couldn't fetch data from{` `}
this spreadsheet.
{` `}
If you are the owner of this spreadsheet, make sure you have shared it by doing the
following:
- Click the “Share” button in the spreadsheet
-
Change the selection from “Restricted” to “Anyone with the link” in the drop-down
under “Get link”
>
: Oops… Couldn't fetch data
}
);
}
const searchBoxLocations = getLocationsForSearchBox(state, props);
const title = config[ConfigPropName.TITLE];
const description = config[ConfigPropName.DESCRIPTION];
const sourceUrl = config[ConfigPropName.SOURCE_URL];
const sourceName = config[ConfigPropName.SOURCE_NAME];
const authorUrl = config[ConfigPropName.AUTHOR_URL];
const authorName = config[ConfigPropName.AUTHOR_NAME];
const mapboxAccessToken = config[ConfigPropName.MAPBOX_ACCESS_TOKEN];
const diffMode = getDiffMode(state, props);
const darkMode = getDarkMode(state, props);
const mapboxMapStyle = getMapboxMapStyle(state, props);
const getHighlightForZoom = () => {
const { highlight, clusteringEnabled } = state;
if (!highlight || !clusteringEnabled) {
return highlight;
}
const clusterTree = getClusterIndex(state, props);
const clusterZoom = getClusterZoom(state, props);
if (!clusterTree || clusterZoom === undefined) {
return undefined;
}
const isValidForClusterZoom = (itemId: string) => {
const cluster = clusterTree.getClusterById(itemId);
if (cluster) {
return cluster.zoom === clusterZoom;
} else {
const minZoom = clusterTree.getMinZoomForLocation(itemId);
if (minZoom === undefined || clusterZoom >= minZoom) {
return true;
}
}
return false;
};
switch (highlight.type) {
case HighlightType.LOCATION:
const { locationId } = highlight;
return isValidForClusterZoom(locationId) ? highlight : undefined;
case HighlightType.FLOW:
const {
flow: { origin, dest },
} = highlight;
if (isValidForClusterZoom(origin) && isValidForClusterZoom(dest)) {
return highlight;
}
return undefined;
}
return undefined;
};
const handleClick = (info: FlowLayerPickingInfo, event: { srcEvent: MouseEvent }) => {
switch (info.type) {
case PickingType.LOCATION:
// fall through
case PickingType.LOCATION_AREA: {
const { object } = info;
if (object) {
dispatch({
type: ActionType.SELECT_LOCATION,
locationId: getLocationId(object),
incremental: event.srcEvent.shiftKey,
});
}
break;
}
}
};
const handleChangeSelectLocations = (selectedLocations: string[] | undefined) => {
dispatch({
type: ActionType.SET_SELECTED_LOCATIONS,
selectedLocations,
});
};
const handleChangeLocationFilterMode = (mode: LocationFilterMode) => {
dispatch({
type: ActionType.SET_LOCATION_FILTER_MODE,
mode,
});
};
const handleViewStateChange = ({ viewState }: { viewState: ViewportProps }) => {
dispatch({
type: ActionType.SET_VIEWPORT,
viewport: viewState,
});
};
const locationsTree = getLocationsTree(state, props);
const handleTimeRangeChanged = (range: [Date, Date]) => {
dispatch({
type: ActionType.SET_TIME_RANGE,
range,
});
};
const handleMapFeatureDrawn = (feature: MapDrawingFeature | undefined) => {
if (feature != null) {
const bbox = getBbox(feature) as [number, number, number, number];
const candidates = getLocationsInBbox(locationsTree, bbox);
if (candidates) {
const inPolygon = candidates.filter((loc) =>
booleanPointInPolygon(getLocationCentroid(loc), feature)
);
if (inPolygon.length > 0) {
handleChangeSelectLocations(inPolygon.map(getLocationId));
// TODO: support incremental
} else {
handleChangeSelectLocations(undefined);
}
}
}
setMapDrawingEnabled(false);
if (deckRef.current && deckRef.current.deck) {
// This is a workaround for a deck.gl issue.
// Without it the following happens:
// 1. When finishing a map drawing and releasing the mouse over a deck.gl layer
// an onClick event is generated.
// 2. Deck.gl sets the info of this event to _lastPointerDownInfo
// which holds the last object that was clicked any time before
// starting the map drawing.
// 3. The object info is passed to the onClick handler of the corresponding
// layer which leads to selecting the object and altering the map drawing selection.
deckRef.current.deck._lastPointerDownInfo = null;
}
};
const handleToggleMapDrawing = () => {
setMapDrawingEnabled(!mapDrawingEnabled);
};
const handleZoomIn = () => {
dispatch({ type: ActionType.ZOOM_IN });
};
const handleZoomOut = () => {
dispatch({ type: ActionType.ZOOM_OUT });
};
const handleResetBearingPitch = () => {
dispatch({ type: ActionType.RESET_BEARING_PITCH });
};
const handleSelectFlowsSheet: React.ChangeEventHandler = (event) => {
const sheet = event.currentTarget.value;
const { onSetFlowsSheet } = props;
if (onSetFlowsSheet) {
onSetFlowsSheet(sheet);
handleChangeSelectLocations(undefined);
}
};
const handleFullScreen = () => {
const outer = outerRef.current;
if (outer) {
if (outer.requestFullscreen) {
outer.requestFullscreen();
} else if ((outer as any).webkitRequestFullscreen) {
(outer as any).webkitRequestFullscreen();
}
}
};
const getLayers = () => {
const {
animationEnabled,
adaptiveScalesEnabled,
locationTotalsEnabled,
darkMode,
colorSchemeKey,
fadeAmount,
} = state;
const layers = [];
if (locations && flows) {
const id = [
'flow-map',
animationEnabled ? 'animated' : 'arrows',
locationTotalsEnabled ? 'withTotals' : '',
colorSchemeKey,
darkMode ? 'dark' : 'light',
fadeAmount,
].join('-');
const locationTotals = getLocationTotals(state, props);
const highlight = getHighlightForZoom();
layers.push(
new FlowMapLayer({
id,
animate: animationEnabled,
animationCurrentTime: time,
diffMode: getDiffMode(state, props),
colors: getFlowMapColors(state, props),
locations,
flows,
showOnlyTopFlows: NUMBER_OF_FLOWS_TO_DISPLAY,
getLocationCentroid,
getFlowMagnitude,
getFlowOriginId,
getFlowDestId,
getLocationId,
getLocationTotalIn: loc => locationTotals?.get(loc.id)?.incoming || 0,
getLocationTotalOut: loc => locationTotals?.get(loc.id)?.outgoing || 0,
getLocationTotalWithin: loc => locationTotals?.get(loc.id)?.within || 0,
getAnimatedFlowLineStaggering: (d: Flow) =>
// @ts-ignore
new alea(`${d.origin}-${d.dest}`)(),
showTotals: true,
maxLocationCircleSize: getMaxLocationCircleSize(state, props),
maxFlowThickness: animationEnabled ? 18 : 12,
...(!adaptiveScalesEnabled) && {
flowMagnitudeExtent: getFlowMagnitudeExtent(state, props),
},
// locationTotalsExtent needs to be always calculated, because locations
// are not filtered by the viewport (e.g. the connected ones need to be included).
// Also, the totals cannot be correctly calculated from the flows passed to the layer.
locationTotalsExtent: getLocationTotalsExtent(state, props),
// selectedLocationIds: getExpandedSelection(state, props),
highlightedLocationId:
highlight && highlight.type === HighlightType.LOCATION
? highlight.locationId
: undefined,
highlightedFlow:
highlight && highlight.type === HighlightType.FLOW ? highlight.flow : undefined,
pickable: true,
...(!mapDrawingEnabled && {
onHover: handleHover,
onClick: handleClick as any,
}),
visible: true,
updateTriggers: {
onHover: handleHover, // to avoid stale closure in the handler
onClick: handleClick,
} as any,
})
);
}
return layers;
};
return (
{mapboxAccessToken && baseMapEnabled && (
)}
{mapDrawingEnabled && (
)}
{timeExtent && timeGranularity && totalCountsByTime && selectedTimeRange && (
{selectedTimeRangeToString(selectedTimeRange, timeGranularity)}
)}
{flows && (
<>
{searchBoxLocations && (
)}
{!inBrowser && !embed && (
)}
{state.locationTotalsEnabled && !embed && (
Location totals
)}
>
)}
{!embed && (
)}
{showFullscreenButton && (
)}
{spreadSheetKey && !embed && (
{title && (
{title}
{description}
)}
{flowsSheets && flowsSheets.length > 1 && (
({
label: sheet,
value: sheet,
}))}
/>
)}
{authorUrl ? (
{`Created by: `}
{authorName || 'Author'}
) : authorName ? (
Created by: {authorName}
) : null}
{sourceName && sourceUrl && (
{'Original data source: '}
<>
{sourceName}
>
)}
{'Data behind this map is in '}
this spreadsheet
. You can
publish your own too.
{totalFilteredCount != null && totalUnfilteredCount != null && (
{Math.round(totalFilteredCount) === Math.round(totalUnfilteredCount)
? config['msg.totalCount.allTrips']?.replace(
'{0}',
formatCount(totalUnfilteredCount)
)
: config['msg.totalCount.countOfTrips']
?.replace('{0}', formatCount(totalFilteredCount))
.replace('{1}', formatCount(totalUnfilteredCount))}
)}
)}
{tooltip && }
{flowsFetch.loading && }
);
};
function selectedTimeRangeToString(
selectedTimeRange: [Date, Date],
timeGranularity: TimeGranularity
) {
const { interval, formatFull, order } = timeGranularity;
const start = selectedTimeRange[0];
let end = selectedTimeRange[1];
if (order >= 3) {
end = interval.offset(end, -1);
}
if (end <= start) end = start;
const startStr = formatFull(start);
const endStr = formatFull(end);
if (startStr === endStr) return startStr;
// TODO: split by words, only remove common words
// let i = 0;
// while (i < Math.min(startStr.length, endStr.length)) {
// if (startStr.charAt(startStr.length - i - 1) !== endStr.charAt(endStr.length - i - 1)) {
// break;
// }
// i++;
// }
// if (i > 0) {
// return `${startStr.substring(0, startStr.length - i - 1)} - ${endStr}`;
// }
return `${startStr} – ${endStr}`;
}
export default FlowMap;