wip
This commit is contained in:
parent
f429ed32f3
commit
7ad3896cdd
|
@ -56,17 +56,23 @@ function Map({
|
|||
children,
|
||||
boundsFromJson,
|
||||
baseMapStyle,
|
||||
onViewportChange,
|
||||
...props
|
||||
}: {
|
||||
viewportFromUrl?: boolean
|
||||
children: React.ReactNode
|
||||
boundsFromJson: GeoJSON.Geometry
|
||||
baseMapStyle: string
|
||||
onViewportChange: (viewport: Viewport) => void,
|
||||
}) {
|
||||
const [viewportState, setViewportState] = useState(EMPTY_VIEWPORT)
|
||||
const [viewportUrl, setViewportUrl] = useViewportFromUrl()
|
||||
|
||||
const [viewport, setViewport] = viewportFromUrl ? [viewportUrl, setViewportUrl] : [viewportState, setViewportState]
|
||||
const [viewport, setViewport_] = viewportFromUrl ? [viewportUrl, setViewportUrl] : [viewportState, setViewportState]
|
||||
const setViewport = useCallback((viewport: Viewport) => {
|
||||
setViewport_(viewport);
|
||||
onViewportChange?.(viewport);
|
||||
}, [setViewport_, onViewportChange])
|
||||
|
||||
const config = useConfig()
|
||||
useEffect(() => {
|
||||
|
|
33
frontend/src/pages/MapPage/RegionInfo.tsx
Normal file
33
frontend/src/pages/MapPage/RegionInfo.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import React, { useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import _ from "lodash";
|
||||
import { List, Header, Icon, Button } from "semantic-ui-react";
|
||||
|
||||
import styles from "./styles.module.less";
|
||||
|
||||
export default function RegionInfo({ region, mapInfoPortal, onClose }) {
|
||||
const content = (
|
||||
<>
|
||||
<div className={styles.closeHeader}>
|
||||
<Header as="h3">{region.properties.name || "Unnamed region"}</Header>
|
||||
<Button primary icon onClick={onClose}>
|
||||
<Icon name="close" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<List>
|
||||
<List.Item>
|
||||
<List.Header>Number of events</List.Header>
|
||||
<List.Content>{region.properties.overtaking_event_count ?? 0}</List.Content>
|
||||
</List.Item>
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
|
||||
return content && mapInfoPortal
|
||||
? createPortal(
|
||||
<div className={styles.mapInfoBox}>{content}</div>,
|
||||
mapInfoPortal
|
||||
)
|
||||
: null;
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import React, {useState, useCallback} from 'react'
|
||||
import {createPortal} from 'react-dom'
|
||||
import _ from 'lodash'
|
||||
import {Segment, Menu, Header, Label, Icon, Table} from 'semantic-ui-react'
|
||||
import {Menu, Header, Label, Icon, Table, Button} from 'semantic-ui-react'
|
||||
import {Layer, Source} from 'react-map-gl'
|
||||
import {of, from, concat} from 'rxjs'
|
||||
import {useObservable} from 'rxjs-hooks'
|
||||
|
@ -110,7 +111,7 @@ function HistogramChart({bins, counts}) {
|
|||
)
|
||||
}
|
||||
|
||||
export default function RoadInfo({clickLocation}) {
|
||||
export default function RoadInfo({roadInfo: info, mapInfoPortal, onClose}) {
|
||||
const [direction, setDirection] = useState('forwards')
|
||||
|
||||
const onClickDirection = useCallback(
|
||||
|
@ -122,72 +123,42 @@ export default function RoadInfo({clickLocation}) {
|
|||
[setDirection]
|
||||
)
|
||||
|
||||
const info = useObservable(
|
||||
(_$, inputs$) =>
|
||||
inputs$.pipe(
|
||||
distinctUntilChanged(_.isEqual),
|
||||
switchMap(([location]) =>
|
||||
location
|
||||
? concat(
|
||||
of(null),
|
||||
from(
|
||||
api.get('/mapdetails/road', {
|
||||
query: {
|
||||
...location,
|
||||
radius: 100,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
: of(null)
|
||||
)
|
||||
),
|
||||
null,
|
||||
[clickLocation]
|
||||
)
|
||||
const offsetDirection = info.road.oneway ? 0 : direction === 'forwards' ? 1 : -1 // TODO: change based on left-hand/right-hand traffic
|
||||
|
||||
if (!clickLocation) {
|
||||
return null
|
||||
}
|
||||
|
||||
const loading = info == null
|
||||
|
||||
const offsetDirection = info?.road?.oneway ? 0 : direction === 'forwards' ? 1 : -1 // TODO: change based on left-hand/right-hand traffic
|
||||
|
||||
const content =
|
||||
!loading && !info.road ? (
|
||||
'No road found.'
|
||||
) : (
|
||||
const content = (
|
||||
<>
|
||||
<Header as="h3">{loading ? '...' : info?.road.name || 'Unnamed way'}</Header>
|
||||
<div className={styles.closeHeader}>
|
||||
<Header as="h3">{info.road.name || 'Unnamed way'}</Header>
|
||||
<Button primary icon onClick={onClose}><Icon name='close' /></Button>
|
||||
</div>
|
||||
|
||||
{info?.road.zone && (
|
||||
<Label size="small" color={ZONE_COLORS[info?.road.zone]}>
|
||||
{info?.road.zone}
|
||||
{info.road.zone && (
|
||||
<Label size="small" color={ZONE_COLORS[info.road.zone]}>
|
||||
{info.road.zone}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
{info?.road.oneway && (
|
||||
{info.road.oneway && (
|
||||
<Label size="small" color="blue">
|
||||
<Icon name="long arrow alternate right" fitted /> oneway
|
||||
</Label>
|
||||
)}
|
||||
|
||||
{info?.road.oneway ? null : (
|
||||
<Menu size="tiny" fluid secondary>
|
||||
{info.road.oneway ? null : (
|
||||
<Menu size="tiny" pointing>
|
||||
<Menu.Item header>Direction</Menu.Item>
|
||||
<Menu.Item name="forwards" active={direction === 'forwards'} onClick={onClickDirection}>
|
||||
{getCardinalDirection(info?.forwards?.bearing)}
|
||||
{getCardinalDirection(info.forwards?.bearing)}
|
||||
</Menu.Item>
|
||||
<Menu.Item name="backwards" active={direction === 'backwards'} onClick={onClickDirection}>
|
||||
{getCardinalDirection(info?.backwards?.bearing)}
|
||||
{getCardinalDirection(info.backwards?.bearing)}
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
)}
|
||||
|
||||
{info?.[direction] && <RoadStatsTable data={info[direction]} />}
|
||||
{info[direction] && <RoadStatsTable data={info[direction]} />}
|
||||
|
||||
{info?.[direction]?.distanceOvertaker?.histogram && (
|
||||
{info[direction]?.distanceOvertaker?.histogram && (
|
||||
<>
|
||||
<Header as="h5">Overtaker distance distribution</Header>
|
||||
<HistogramChart {...info[direction]?.distanceOvertaker?.histogram} />
|
||||
|
@ -198,7 +169,7 @@ export default function RoadInfo({clickLocation}) {
|
|||
|
||||
return (
|
||||
<>
|
||||
{info?.road && (
|
||||
{info.road && (
|
||||
<Source id="highlight" type="geojson" data={info.road.geometry}>
|
||||
<Layer
|
||||
id="route"
|
||||
|
@ -223,10 +194,11 @@ export default function RoadInfo({clickLocation}) {
|
|||
</Source>
|
||||
)}
|
||||
|
||||
{content && (
|
||||
{content && mapInfoPortal && (
|
||||
createPortal(
|
||||
<div className={styles.mapInfoBox}>
|
||||
<Segment loading={loading}>{content}</Segment>
|
||||
</div>
|
||||
{content}
|
||||
</div>, mapInfoPortal))}
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -1,171 +1,255 @@
|
|||
import React, {useState, useCallback, useMemo} from 'react'
|
||||
import _ from 'lodash'
|
||||
import {Button} from 'semantic-ui-react'
|
||||
import {Layer, Source} from 'react-map-gl'
|
||||
import produce from 'immer'
|
||||
import React, { useState, useCallback, useMemo, useRef } from "react";
|
||||
import _ from "lodash";
|
||||
import { Button } from "semantic-ui-react";
|
||||
import { Layer, Source } from "react-map-gl";
|
||||
import produce from "immer";
|
||||
|
||||
import {Page, Map} from 'components'
|
||||
import {useConfig} from 'config'
|
||||
import {colorByDistance, colorByCount, getRegionLayers} from 'mapstyles'
|
||||
import {useMapConfig} from 'reducers/mapConfig'
|
||||
import api from "api";
|
||||
import { Page, Map } from "components";
|
||||
import { useConfig } from "config";
|
||||
import { colorByDistance, colorByCount, getRegionLayers } from "mapstyles";
|
||||
import { useMapConfig } from "reducers/mapConfig";
|
||||
|
||||
import RoadInfo from './RoadInfo'
|
||||
import LayerSidebar from './LayerSidebar'
|
||||
import styles from './styles.module.less'
|
||||
import RoadInfo from "./RoadInfo";
|
||||
import RegionInfo from "./RegionInfo";
|
||||
import LayerSidebar from "./LayerSidebar";
|
||||
import styles from "./styles.module.less";
|
||||
|
||||
const untaggedRoadsLayer = {
|
||||
id: 'obs_roads_untagged',
|
||||
type: 'line',
|
||||
source: 'obs',
|
||||
'source-layer': 'obs_roads',
|
||||
id: "obs_roads_untagged",
|
||||
type: "line",
|
||||
source: "obs",
|
||||
"source-layer": "obs_roads",
|
||||
minzoom: 12,
|
||||
filter: ['!', ['to-boolean', ['get', 'distance_overtaker_mean']]],
|
||||
filter: ["!", ["to-boolean", ["get", "distance_overtaker_mean"]]],
|
||||
layout: {
|
||||
'line-cap': 'round',
|
||||
'line-join': 'round',
|
||||
"line-cap": "round",
|
||||
"line-join": "round",
|
||||
},
|
||||
paint: {
|
||||
'line-width': ['interpolate', ['exponential', 1.5], ['zoom'], 12, 2, 17, 2],
|
||||
'line-color': '#ABC',
|
||||
"line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 12, 2, 17, 2],
|
||||
"line-color": "#ABC",
|
||||
// 'line-opacity': ['interpolate', ['linear'], ['zoom'], 14, 0, 15, 1],
|
||||
'line-offset': [
|
||||
'interpolate',
|
||||
['exponential', 1.5],
|
||||
['zoom'],
|
||||
"line-offset": [
|
||||
"interpolate",
|
||||
["exponential", 1.5],
|
||||
["zoom"],
|
||||
12,
|
||||
['get', 'offset_direction'],
|
||||
["get", "offset_direction"],
|
||||
19,
|
||||
['*', ['get', 'offset_direction'], 8],
|
||||
["*", ["get", "offset_direction"], 8],
|
||||
],
|
||||
},
|
||||
minzoom: 12,
|
||||
}
|
||||
};
|
||||
|
||||
const getRoadsLayer = (colorAttribute, maxCount) =>
|
||||
produce(untaggedRoadsLayer, (draft) => {
|
||||
draft.id = 'obs_roads_normal'
|
||||
if (colorAttribute.endsWith('_count')) {
|
||||
draft.id = "obs_roads_normal";
|
||||
if (colorAttribute.endsWith("_count")) {
|
||||
// delete draft.filter
|
||||
draft.filter = ['to-boolean', ['get', colorAttribute]]
|
||||
draft.filter = ["to-boolean", ["get", colorAttribute]];
|
||||
} else {
|
||||
draft.filter = draft.filter[1] // remove '!'
|
||||
draft.filter = draft.filter[1]; // remove '!'
|
||||
}
|
||||
draft.minzoom = 10
|
||||
draft.paint['line-width'][6] = 6 // scale bigger on zoom
|
||||
draft.paint['line-color'] = colorAttribute.startsWith('distance_')
|
||||
draft.minzoom = 10;
|
||||
draft.paint["line-width"][6] = 6; // scale bigger on zoom
|
||||
draft.paint["line-color"] = colorAttribute.startsWith("distance_")
|
||||
? colorByDistance(colorAttribute)
|
||||
: colorAttribute.endsWith('_count')
|
||||
: colorAttribute.endsWith("_count")
|
||||
? colorByCount(colorAttribute, maxCount)
|
||||
: '#DDD'
|
||||
: "#DDD";
|
||||
// draft.paint['line-opacity'][3] = 12
|
||||
// draft.paint['line-opacity'][5] = 13
|
||||
})
|
||||
});
|
||||
|
||||
const getEventsLayer = () => ({
|
||||
id: 'obs_events',
|
||||
type: 'circle',
|
||||
source: 'obs',
|
||||
'source-layer': 'obs_events',
|
||||
id: "obs_events",
|
||||
type: "circle",
|
||||
source: "obs",
|
||||
"source-layer": "obs_events",
|
||||
paint: {
|
||||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 14, 3, 17, 8],
|
||||
'circle-color': colorByDistance('distance_overtaker'),
|
||||
"circle-radius": ["interpolate", ["linear"], ["zoom"], 14, 3, 17, 8],
|
||||
"circle-color": colorByDistance("distance_overtaker"),
|
||||
},
|
||||
minzoom: 11,
|
||||
})
|
||||
});
|
||||
|
||||
const getEventsTextLayer = () => ({
|
||||
id: 'obs_events_text',
|
||||
type: 'symbol',
|
||||
id: "obs_events_text",
|
||||
type: "symbol",
|
||||
minzoom: 18,
|
||||
source: 'obs',
|
||||
'source-layer': 'obs_events',
|
||||
source: "obs",
|
||||
"source-layer": "obs_events",
|
||||
layout: {
|
||||
'text-field': [
|
||||
'number-format',
|
||||
['get', 'distance_overtaker'],
|
||||
{'min-fraction-digits': 2, 'max-fraction-digits': 2},
|
||||
"text-field": [
|
||||
"number-format",
|
||||
["get", "distance_overtaker"],
|
||||
{ "min-fraction-digits": 2, "max-fraction-digits": 2 },
|
||||
],
|
||||
'text-allow-overlap': true,
|
||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Regular'],
|
||||
'text-size': 14,
|
||||
'text-keep-upright': false,
|
||||
'text-anchor': 'left',
|
||||
'text-radial-offset': 1,
|
||||
'text-rotate': ['-', 90, ['*', ['get', 'course'], 180 / Math.PI]],
|
||||
'text-rotation-alignment': 'map',
|
||||
"text-allow-overlap": true,
|
||||
"text-font": ["Open Sans Bold", "Arial Unicode MS Regular"],
|
||||
"text-size": 14,
|
||||
"text-keep-upright": false,
|
||||
"text-anchor": "left",
|
||||
"text-radial-offset": 1,
|
||||
"text-rotate": ["-", 90, ["*", ["get", "course"], 180 / Math.PI]],
|
||||
"text-rotation-alignment": "map",
|
||||
},
|
||||
paint: {
|
||||
'text-halo-color': 'rgba(255, 255, 255, 1)',
|
||||
'text-halo-width': 1,
|
||||
'text-opacity': ['interpolate', ['linear'], ['zoom'], 15, 0, 15.3, 1],
|
||||
"text-halo-color": "rgba(255, 255, 255, 1)",
|
||||
"text-halo-width": 1,
|
||||
"text-opacity": ["interpolate", ["linear"], ["zoom"], 15, 0, 15.3, 1],
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
interface RegionInfo {
|
||||
properties: {
|
||||
admin_level: number;
|
||||
name: string;
|
||||
overtaking_event_count: number;
|
||||
};
|
||||
}
|
||||
interface ArrayStats {
|
||||
statistics: {
|
||||
count: number;
|
||||
mean: number;
|
||||
min: number;
|
||||
max: number;
|
||||
median: number;
|
||||
};
|
||||
histogram: {
|
||||
bins: number[];
|
||||
counts: number[];
|
||||
};
|
||||
values: number[];
|
||||
}
|
||||
interface RoadDirectionInfo {
|
||||
bearing: number;
|
||||
distanceOvertaker: ArrayStats;
|
||||
distanceStationary: ArrayStats;
|
||||
speed: ArrayStats;
|
||||
}
|
||||
|
||||
interface RoadInfo {
|
||||
road: {
|
||||
way_id: number;
|
||||
zone: "urban" | "rural" | null;
|
||||
name: string;
|
||||
directionality: -1 | 0 | 1;
|
||||
oneway: boolean;
|
||||
geometry: Object;
|
||||
};
|
||||
forwards: RoadDirectionInfo;
|
||||
backwards: RoadDirectionInfo;
|
||||
}
|
||||
type Details =
|
||||
| { type: "road"; road: Object }
|
||||
| { type: "region"; region: RegionInfo };
|
||||
|
||||
export default function MapPage() {
|
||||
const {obsMapSource} = useConfig() || {}
|
||||
const [clickLocation, setClickLocation] = useState<{longitude: number; latitude: number} | null>(null)
|
||||
const { obsMapSource } = useConfig() || {};
|
||||
const [details, setDetails] = useState<null | Details>(null);
|
||||
|
||||
const mapConfig = useMapConfig()
|
||||
const onCloseDetails = useCallback(() => setDetails(null), [setDetails]);
|
||||
|
||||
const mapConfig = useMapConfig();
|
||||
|
||||
const viewportRef = useRef();
|
||||
const mapInfoPortal = useRef();
|
||||
|
||||
const onViewportChange = useCallback(
|
||||
(viewport) => {
|
||||
viewportRef.current = viewport;
|
||||
},
|
||||
[viewportRef]
|
||||
);
|
||||
|
||||
const onClick = useCallback(
|
||||
(e) => {
|
||||
let node = e.target
|
||||
async (e) => {
|
||||
// check if we clicked inside the mapInfoBox, if so, early exit
|
||||
let node = e.target;
|
||||
while (node) {
|
||||
if (node?.classList?.contains(styles.mapInfoBox)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
node = node.parentNode
|
||||
node = node.parentNode;
|
||||
}
|
||||
|
||||
setClickLocation({longitude: e.lngLat[0], latitude: e.lngLat[1]})
|
||||
const { zoom } = viewportRef.current;
|
||||
|
||||
if (zoom < 10) {
|
||||
const clickedRegion = e.features?.find(
|
||||
(f) => f.source === "obs" && f.sourceLayer === "obs_regions"
|
||||
);
|
||||
setDetails(
|
||||
clickedRegion ? { type: "region", region: clickedRegion } : null
|
||||
);
|
||||
} else {
|
||||
const road = await api.get("/mapdetails/road", {
|
||||
query: {
|
||||
longitude: e.lngLat[0],
|
||||
latitude: e.lngLat[1],
|
||||
radius: 100,
|
||||
},
|
||||
[setClickLocation]
|
||||
)
|
||||
});
|
||||
setDetails(road?.road ? { type: "road", road } : null);
|
||||
}
|
||||
},
|
||||
[setDetails]
|
||||
);
|
||||
|
||||
const [layerSidebar, setLayerSidebar] = useState(true)
|
||||
const [layerSidebar, setLayerSidebar] = useState(true);
|
||||
|
||||
const {
|
||||
obsRoads: {attribute, maxCount},
|
||||
} = mapConfig
|
||||
obsRoads: { attribute, maxCount },
|
||||
} = mapConfig;
|
||||
|
||||
const layers = []
|
||||
const layers = [];
|
||||
|
||||
if (mapConfig.obsRoads.show && mapConfig.obsRoads.showUntagged) {
|
||||
layers.push(untaggedRoadsLayer)
|
||||
layers.push(untaggedRoadsLayer);
|
||||
}
|
||||
|
||||
const roadsLayer = useMemo(() => getRoadsLayer(attribute, maxCount), [attribute, maxCount])
|
||||
const roadsLayer = useMemo(
|
||||
() => getRoadsLayer(attribute, maxCount),
|
||||
[attribute, maxCount]
|
||||
);
|
||||
if (mapConfig.obsRoads.show) {
|
||||
layers.push(roadsLayer)
|
||||
layers.push(roadsLayer);
|
||||
}
|
||||
|
||||
const regionLayers = useMemo(() => getRegionLayers(), [])
|
||||
layers.push(...regionLayers)
|
||||
const regionLayers = useMemo(() => getRegionLayers(), []);
|
||||
layers.push(...regionLayers);
|
||||
|
||||
const eventsLayer = useMemo(() => getEventsLayer(), [])
|
||||
const eventsTextLayer = useMemo(() => getEventsTextLayer(), [])
|
||||
const eventsLayer = useMemo(() => getEventsLayer(), []);
|
||||
const eventsTextLayer = useMemo(() => getEventsTextLayer(), []);
|
||||
if (mapConfig.obsEvents.show) {
|
||||
layers.push(eventsLayer)
|
||||
layers.push(eventsTextLayer)
|
||||
layers.push(eventsLayer);
|
||||
layers.push(eventsTextLayer);
|
||||
}
|
||||
|
||||
if (!obsMapSource) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page fullScreen title="Map">
|
||||
<div className={styles.mapContainer}>
|
||||
<div className={styles.mapContainer} ref={mapInfoPortal}>
|
||||
{layerSidebar && (
|
||||
<div className={styles.mapSidebar}>
|
||||
<LayerSidebar />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.map}>
|
||||
<Map viewportFromUrl onClick={onClick}>
|
||||
<Map
|
||||
viewportFromUrl
|
||||
onClick={onClick}
|
||||
onViewportChange={onViewportChange}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
left: 44,
|
||||
top: 9,
|
||||
}}
|
||||
|
@ -180,10 +264,24 @@ export default function MapPage() {
|
|||
))}
|
||||
</Source>
|
||||
|
||||
<RoadInfo {...{clickLocation}} />
|
||||
{details?.type === "road" && details?.road?.road && (
|
||||
<RoadInfo
|
||||
roadInfo={details.road}
|
||||
mapInfoPortal={mapInfoPortal.current}
|
||||
onClose={onCloseDetails}
|
||||
/>
|
||||
)}
|
||||
|
||||
{details?.type === "region" && details?.region && (
|
||||
<RegionInfo
|
||||
region={details.region}
|
||||
mapInfoPortal={mapInfoPortal.current}
|
||||
onClose={onCloseDetails}
|
||||
/>
|
||||
)}
|
||||
</Map>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -20,11 +20,38 @@
|
|||
}
|
||||
|
||||
.mapInfoBox {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
max-height: 100%;
|
||||
width: 36rem;
|
||||
overflow: auto;
|
||||
margin: 20px;
|
||||
border-left: 1px solid @borderColor;
|
||||
background: white;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.closeHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
|
||||
@media @mobile {
|
||||
.mapContainer {
|
||||
height: auto;
|
||||
min-height: calc(100vh - @menuHeight);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.map {
|
||||
height: 60vh;
|
||||
}
|
||||
|
||||
.mapSidebar {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.mapInfoBox {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue