Skip to content

Commit 8ca3a3b

Browse files
feat: gbfs visualizations + auto discovery (#1179)
* gbfs visualizations + auto discovery * gbfs map details
1 parent 9430ff0 commit 8ca3a3b

File tree

10 files changed

+439
-242
lines changed

10 files changed

+439
-242
lines changed

web-app/public/locales/en/common.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
},
2121
"loading": "Loading...",
2222
"download": "Download",
23+
"updated": "Updated",
2324
"errors": {
2425
"generic": "We are unable to complete your request at the moment."
2526
},

web-app/public/locales/en/feeds.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"detailedCoveredAreaView": "Detailed",
6868
"detailedCoveredAreaViewTooltip": "View the detailed covered area of the feed",
6969
"unableToGenerateBoundingBox": "Unable to generate bounding box from the feed's stops.txt file.",
70+
"unableToGetGbfsMap": "Error fetching GBFS Map",
7071
"areYouOfficialProducer": "Are you the official producer or transit agency responsible for this data ?",
7172
"isOfficialSource": "Is this an official data source?",
7273
"isOfficialSourceDetails": "Select \"Yes\" if the inputted feed is the official source of information by the transit provider and should be used to display to riders.",

web-app/src/app/components/ContentBox.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const ContentBox = (
3131
...props.sx,
3232
}}
3333
>
34-
{props.title.trim() !== '' && (
34+
{(props.title.trim() !== '' || props.action != null) && (
3535
<Typography
3636
variant='h5'
3737
sx={{
@@ -42,7 +42,7 @@ export const ContentBox = (
4242
mb: 1,
4343
}}
4444
>
45-
{props.title}
45+
<span>{props.title}</span>
4646
{props.action != null && props.action}
4747
</Typography>
4848
)}

web-app/src/app/components/CoveredAreaMap.tsx

Lines changed: 131 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,28 @@ import {
66
Tooltip,
77
Skeleton,
88
Button,
9+
Typography,
910
} from '@mui/material';
1011
import MapIcon from '@mui/icons-material/Map';
1112
import TravelExploreIcon from '@mui/icons-material/TravelExplore';
1213
import { ContentBox } from './ContentBox';
1314
import { WarningContentBox } from './WarningContentBox';
1415
import { mapBoxPositionStyle } from '../screens/Feed/Feed.styles';
15-
import { type GeoJSONData, MapGeoJSON } from './MapGeoJSON';
16+
import {
17+
type GeoJSONData,
18+
type GeoJSONDataGBFS,
19+
MapGeoJSON,
20+
} from './MapGeoJSON';
1621
import { Map } from './Map';
1722
import { useTranslation } from 'react-i18next';
1823
import type { LatLngExpression } from 'leaflet';
1924
import { useTheme } from '@mui/material/styles';
2025
import { type GBFSFeedType, type AllFeedType } from '../services/feeds/utils';
2126
import { OpenInNew } from '@mui/icons-material';
27+
import { computeBoundingBox } from '../screens/Feed/Feed.functions';
28+
import { displayFormattedDate } from '../utils/date';
29+
import { useSelector } from 'react-redux';
30+
import { selectLatestGbfsVersion } from '../store/feed-selectors';
2231

2332
interface CoveredAreaMapProps {
2433
boundingBox?: LatLngExpression[];
@@ -28,7 +37,7 @@ interface CoveredAreaMapProps {
2837

2938
export const fetchGeoJson = async (
3039
latestDatasetUrl: string,
31-
): Promise<GeoJSONData> => {
40+
): Promise<GeoJSONData | GeoJSONDataGBFS> => {
3241
const geoJsonUrl = latestDatasetUrl
3342
.split('/')
3443
.slice(0, -2)
@@ -49,36 +58,57 @@ const CoveredAreaMap: React.FC<CoveredAreaMapProps> = ({
4958
const { t } = useTranslation('feeds');
5059
const theme = useTheme();
5160

52-
const [geoJsonData, setGeoJsonData] = useState<GeoJSONData | null>(null);
61+
const [geoJsonData, setGeoJsonData] = useState<
62+
GeoJSONData | GeoJSONDataGBFS | null
63+
>(null);
5364
const [geoJsonError, setGeoJsonError] = useState(false);
5465
const [geoJsonLoading, setGeoJsonLoading] = useState(false);
5566
const [view, setView] = useState<
5667
'boundingBoxView' | 'detailedCoveredAreaView'
5768
>('detailedCoveredAreaView');
69+
const latestGbfsVersion = useSelector(selectLatestGbfsVersion);
70+
71+
const getAndSetGeoJsonData = (urlToExtract: string): void => {
72+
setGeoJsonLoading(true);
73+
fetchGeoJson(urlToExtract)
74+
.then((data) => {
75+
setGeoJsonData(data);
76+
setGeoJsonError(false);
77+
setView('detailedCoveredAreaView');
78+
})
79+
.catch(() => {
80+
setGeoJsonError(true);
81+
setView('boundingBoxView');
82+
})
83+
.finally(() => {
84+
setGeoJsonLoading(false);
85+
});
86+
};
5887

5988
useEffect(() => {
60-
if (latestDataset?.hosted_url !== undefined && boundingBox != undefined) {
61-
setGeoJsonLoading(true);
62-
fetchGeoJson(latestDataset.hosted_url)
63-
.then((data) => {
64-
setGeoJsonData(data);
65-
setGeoJsonError(false);
66-
setView('detailedCoveredAreaView');
67-
})
68-
.catch(() => {
69-
setGeoJsonError(true);
70-
setView('boundingBoxView');
71-
})
72-
.finally(() => {
73-
setGeoJsonLoading(false);
74-
});
75-
} else {
76-
// No dataset, fallback to bounding box
77-
setGeoJsonData(null);
78-
setGeoJsonError(true);
79-
setView('boundingBoxView');
89+
if (feed?.data_type === 'gbfs') {
90+
const latestGbfsVersionReportUrl =
91+
latestGbfsVersion?.latest_validation_report?.report_summary_url;
92+
if (latestGbfsVersionReportUrl === undefined) {
93+
setGeoJsonData(null);
94+
setGeoJsonError(true);
95+
return;
96+
}
97+
getAndSetGeoJsonData(latestGbfsVersionReportUrl);
98+
return;
8099
}
81-
}, [latestDataset]);
100+
if (
101+
feed?.data_type === 'gtfs' &&
102+
latestDataset?.hosted_url != undefined &&
103+
boundingBox != undefined
104+
) {
105+
getAndSetGeoJsonData(latestDataset.hosted_url);
106+
return;
107+
}
108+
setGeoJsonData(null);
109+
setGeoJsonError(true);
110+
setView('boundingBoxView');
111+
}, [latestDataset, feed]);
82112

83113
const handleViewChange = (
84114
_: React.MouseEvent<HTMLElement>,
@@ -87,11 +117,47 @@ const CoveredAreaMap: React.FC<CoveredAreaMapProps> = ({
87117
if (newView !== null) setView(newView);
88118
};
89119

90-
const getGbfsLatestVersionVisualizationUrl = (feed: GBFSFeedType): string => {
91-
// TODO: Redo logic when versions all have the auto discovery
92-
return `https://gbfs-validator.mobilitydata.org/visualization?url=${feed?.source_info?.producer_url}`;
120+
const getGbfsLatestVersionVisualizationUrl = (
121+
feed: GBFSFeedType,
122+
): string | undefined => {
123+
const latestAutodiscoveryUrl = latestGbfsVersion?.endpoints?.find(
124+
(endpoint) => endpoint.name === 'gbfs',
125+
)?.url;
126+
if (latestAutodiscoveryUrl != undefined) {
127+
return `https://gbfs-validator.mobilitydata.org/visualization?url=${latestAutodiscoveryUrl}`;
128+
}
129+
return undefined;
93130
};
94131

132+
const renderMap = (): JSX.Element => {
133+
const displayBoundingBoxMap =
134+
view === 'boundingBoxView' && feed?.data_type === 'gtfs';
135+
let gbfsBoundingBox: LatLngExpression[] = [];
136+
if (feed?.data_type === 'gbfs') {
137+
gbfsBoundingBox = computeBoundingBox(geoJsonData) ?? [];
138+
if (gbfsBoundingBox.length === 0) {
139+
setGeoJsonError(true);
140+
}
141+
}
142+
return (
143+
<>
144+
{displayBoundingBoxMap ? (
145+
<Map polygon={boundingBox ?? []} />
146+
) : (
147+
<MapGeoJSON
148+
geoJSONData={geoJsonData}
149+
polygon={boundingBox ?? gbfsBoundingBox}
150+
displayMapDetails={feed?.data_type === 'gtfs'}
151+
/>
152+
)}
153+
</>
154+
);
155+
};
156+
157+
const mapDisplayError = boundingBox == undefined && geoJsonError;
158+
const latestAutodiscoveryUrl = getGbfsLatestVersionVisualizationUrl(
159+
feed as GBFSFeedType,
160+
);
95161
return (
96162
<ContentBox
97163
sx={{
@@ -102,22 +168,39 @@ const CoveredAreaMap: React.FC<CoveredAreaMapProps> = ({
102168
xs: '100%',
103169
md: '70vh',
104170
},
171+
minHeight: '50vh',
105172
}}
106-
title={t('coveredAreaTitle') + ' - ' + t(view)}
173+
title={mapDisplayError ? '' : t('coveredAreaTitle') + ' - ' + t(view)}
107174
width={{ xs: '100%' }}
108175
outlineColor={theme.palette.primary.dark}
109176
padding={2}
110177
action={
111178
<>
112179
{feed?.data_type === 'gbfs' ? (
113-
<Button
114-
href={getGbfsLatestVersionVisualizationUrl(feed as GBFSFeedType)}
115-
target='_blank'
116-
rel='noreferrer'
117-
endIcon={<OpenInNew />}
118-
>
119-
{t('viewRealtimeVisualization')}
120-
</Button>
180+
<Box sx={{ textAlign: 'right' }}>
181+
{latestAutodiscoveryUrl != undefined && (
182+
<Button
183+
href={latestAutodiscoveryUrl}
184+
target='_blank'
185+
rel='noreferrer'
186+
endIcon={<OpenInNew />}
187+
>
188+
{t('viewRealtimeVisualization')}
189+
</Button>
190+
)}
191+
{(geoJsonData as GeoJSONDataGBFS)?.extracted_at != undefined && (
192+
<Typography
193+
variant='caption'
194+
color='text.secondary'
195+
sx={{ display: 'block', px: 1 }}
196+
>
197+
{t('common:updated')}:{' '}
198+
{displayFormattedDate(
199+
(geoJsonData as GeoJSONDataGBFS).extracted_at,
200+
)}
201+
</Typography>
202+
)}
203+
</Box>
121204
) : (
122205
<ToggleButtonGroup
123206
value={view}
@@ -150,13 +233,19 @@ const CoveredAreaMap: React.FC<CoveredAreaMapProps> = ({
150233
</>
151234
}
152235
>
153-
{boundingBox === undefined && view === 'boundingBoxView' && (
154-
<WarningContentBox>
155-
{t('unableToGenerateBoundingBox')}
156-
</WarningContentBox>
236+
{feed?.data_type === 'gtfs' &&
237+
boundingBox === undefined &&
238+
view === 'boundingBoxView' && (
239+
<WarningContentBox>
240+
{t('unableToGenerateBoundingBox')}
241+
</WarningContentBox>
242+
)}
243+
244+
{feed?.data_type === 'gbfs' && geoJsonError && (
245+
<WarningContentBox>{t('unableToGetGbfsMap')}</WarningContentBox>
157246
)}
158247

159-
{boundingBox !== undefined && (
248+
{(boundingBox != undefined || !geoJsonError) && (
160249
<Box sx={mapBoxPositionStyle}>
161250
{geoJsonLoading ? (
162251
<Skeleton
@@ -165,10 +254,8 @@ const CoveredAreaMap: React.FC<CoveredAreaMapProps> = ({
165254
height='100%'
166255
animation='wave'
167256
/>
168-
) : view === 'boundingBoxView' ? (
169-
<Map polygon={boundingBox} />
170257
) : (
171-
<MapGeoJSON geoJSONData={geoJsonData} polygon={boundingBox} />
258+
<>{geoJsonData !== null && <>{renderMap()}</>}</>
172259
)}
173260
</Box>
174261
)}

web-app/src/app/components/MapGeoJSON.tsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,23 @@ export interface GeoJSONData {
2626
}>;
2727
}
2828

29+
export interface GeoJSONDataGBFS extends GeoJSONData {
30+
extracted_at: string;
31+
extraction_url: string;
32+
}
33+
2934
export interface MapProps {
3035
geoJSONData: GeoJSONData | null;
3136
polygon: LatLngExpression[];
37+
displayMapDetails?: boolean;
3238
}
3339

3440
export const MapGeoJSON = (
3541
props: React.PropsWithChildren<MapProps>,
3642
): JSX.Element => {
3743
const theme = useTheme();
3844
const { t } = useTranslation('feeds');
39-
const { geoJSONData } = props;
45+
const { geoJSONData, displayMapDetails = true } = props;
4046
const bounds = React.useMemo(() => {
4147
return props.polygon.length > 0
4248
? (props.polygon as LatLngBoundsExpression)
@@ -46,6 +52,14 @@ export const MapGeoJSON = (
4652
] as LatLngBoundsExpression);
4753
}, [props.polygon]);
4854

55+
if (!displayMapDetails) {
56+
geoJSONData?.features?.forEach((feature) => {
57+
feature.properties.color = 'rgba(127, 0, 0, 1.0)';
58+
delete feature.properties.stops_in_area;
59+
delete feature.properties.stops_in_area_coverage;
60+
});
61+
}
62+
4963
const handleFeatureClick = (
5064
e: LeafletMouseEvent,
5165
previousColor: string,
@@ -85,7 +99,7 @@ export const MapGeoJSON = (
8599
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
86100
url={mapTiles}
87101
/>
88-
{props.geoJSONData !== null && (
102+
{props.geoJSONData !== null && displayMapDetails && (
89103
<Box
90104
sx={{
91105
position: 'absolute',
@@ -160,18 +174,15 @@ export const MapGeoJSON = (
160174
const container = document.createElement('div');
161175
container.style.background = theme.palette.background.default;
162176
const root = createRoot(container);
177+
const featureProperties = feature?.properties ?? {};
163178
root.render(
164-
<PopupTable properties={feature.properties} theme={theme} />,
179+
<PopupTable properties={featureProperties} theme={theme} />,
165180
);
166181
layer.bindPopup(container);
167-
168182
// Handle feature clicks
169183
layer.on({
170184
click: (e) => {
171-
handleFeatureClick(
172-
e,
173-
feature?.properties?.color ?? '#3388ff',
174-
);
185+
handleFeatureClick(e, featureProperties?.color ?? '#3388ff');
175186
},
176187
});
177188
}}

0 commit comments

Comments
 (0)