Skip to content

Commit

Permalink
Merge pull request #972 from hackforla/map-page-performance
Browse files Browse the repository at this point in the history
Map page performance
  • Loading branch information
entrotech authored May 11, 2021
2 parents 24083e1 + 7a0e6dc commit 11ae80c
Show file tree
Hide file tree
Showing 9 changed files with 624 additions and 29 deletions.
16 changes: 14 additions & 2 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"react-router-dom": "^5.2.0",
"react-scripts": "^4.0.2",
"react-spinners": "^0.10.4",
"react-virtualized": "^9.22.3",
"yup": "^0.32.8"
},
"browserslist": {
Expand Down
24 changes: 13 additions & 11 deletions client/src/components/FoodSeeker/Marker.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import PropTypes from "prop-types";
import { Marker } from "react-map-gl";
import { makeStyles } from "@material-ui/core/styles";

import mapMarker from "images/mapMarker";
import MapMarkerIcon from "images/mapMarker";
import {
MEAL_PROGRAM_CATEGORY_ID,
FOOD_PANTRY_CATEGORY_ID,
Expand Down Expand Up @@ -32,17 +32,19 @@ const MapMarker = ({ onClick, stakeholder, selectedStakeholder }) => {
latitude={latitude}
className={selected ? classes.active : ""}
>
{mapMarker(
categories[0]?.id === FOOD_PANTRY_CATEGORY_ID &&
<MapMarkerIcon
category={
categories[0]?.id === FOOD_PANTRY_CATEGORY_ID &&
categories[1]?.id === MEAL_PROGRAM_CATEGORY_ID
? -1
: categories[0]?.id === FOOD_PANTRY_CATEGORY_ID
? 0
: 1,
inactiveTemporary || inactive,
onClick,
selected
)}
? -1
: categories[0]?.id === FOOD_PANTRY_CATEGORY_ID
? 0
: 1
}
inactive={inactiveTemporary || inactive}
onClick={onClick}
selected={selected}
/>
</Marker>
);
};
Expand Down
117 changes: 117 additions & 0 deletions client/src/components/FoodSeeker/MarkerHelpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import React from "react";
import { renderToString } from "react-dom/server";
import MapMarker from "images/mapMarker";
import {
MEAL_PROGRAM_CATEGORY_ID,
FOOD_PANTRY_CATEGORY_ID,
} from "constants/stakeholder";

///////////////////// CONSTANTS ///////////////////

// note that we have 3 marker categories, and 2 selected states, for a
// total of 6 possible marker variants.
// the marker categories come from src/images/mapMarker.js
const MARKER_CATEGORIES = [-1, 0, 1];
const SELECTED_VALUES = [false, true];

// using the pixel ratio to scale the marker helps prevent
// blurry edges when the marker is converted to an image
const MARKER_SCALE = window.devicePixelRatio;

////////////////////// FUNCTIONS //////////////////

// each marker variant has a unique id based on its category and
// whether it is selected
function getIconId(markerCategory, isSelected) {
return `fola-marker::${markerCategory}::${isSelected}`
}

// selects the right marker category based on the stakeholder categories array.
// category ids are from src/images/mapMarker.js
function getMarkerCategory(stakeholder) {
if (
stakeholder.categories[0]?.id === FOOD_PANTRY_CATEGORY_ID &&
stakeholder.categories[1]?.id === MEAL_PROGRAM_CATEGORY_ID
)
return -1;

if (stakeholder.categories[0]?.id === FOOD_PANTRY_CATEGORY_ID) return 0;

return 1;
}

// This converts the given marker to an icon, and then loads that icon into the
// map. Once loaded, the icon can be used in a symbol layer.
// see https://docs.mapbox.com/mapbox-gl-js/style-spec/layers/#symbol
function loadMarkerIcon({ map, marker, iconId }) {
return new Promise((resolve, reject) => {
const icon = new Image();
const svgString = renderToString(marker);
const svg = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(svg);
icon.src = url;
icon.onload = () => {
map.addImage(iconId, icon);
URL.revokeObjectURL(url);
resolve();
};
});
}

// load an icon for each possible combination of marker category and
// selected value.
export function loadMarkerIcons(map) {
const iconLoaders = []

MARKER_CATEGORIES.forEach((category) => {
SELECTED_VALUES.forEach((selected) => {
iconLoaders.push(
loadMarkerIcon({
map,
marker: (
<MapMarker
category={category}
selected={selected}
scale={MARKER_SCALE}
/>
),
iconId: getIconId(category, selected),
})
)
})
})

return Promise.all(iconLoaders)
}

export const markersLayerStyles = {
type: "symbol",
layout: {
"icon-image": ["get", "iconId"],
"icon-allow-overlap": true,
"icon-size": 1 / MARKER_SCALE,
},
}

// This generates the geojson needed to show the stakeholders in a symbol layer.
// Note that the iconId is added as a property.
export function getMarkersGeojson(stakeholders, selectedStakeholder) {
return {
type: "FeatureCollection",
features: (stakeholders || []).map((sh) => {
const category = getMarkerCategory(sh)
const selected = sh.id === selectedStakeholder?.id
return {
type: "Feature",
id: sh.id,
geometry: {
type: "Point",
coordinates: [sh.longitude, sh.latitude],
},
properties: {
iconId: getIconId(category, selected),
},
}
}),
}
}
4 changes: 2 additions & 2 deletions client/src/components/FoodSeeker/ResultsContainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { defaultCoordinates } from "helpers/Configuration";
import { DEFAULT_CATEGORIES } from "constants/stakeholder";

import Filters from "./ResultsFilters";
import List from "./ResultsList";
import Map from "./ResultsMap";
import List from "./ResultsListOptimized";
import Map from "./ResultsMapOptimized";
import * as analytics from "../../services/analytics";

const useStyles = makeStyles((theme) => ({
Expand Down
162 changes: 162 additions & 0 deletions client/src/components/FoodSeeker/ResultsListOptimized.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import React, { useEffect, useCallback } from "react";
import PropTypes from "prop-types";
import { Button, CircularProgress, Grid } from "@material-ui/core";
import { makeStyles } from "@material-ui/core/styles";
import List from "react-virtualized/dist/es/List";
import AutoSizer from "react-virtualized/dist/es/AutoSizer";
import CellMeasurer from "react-virtualized/dist/es/CellMeasurer";
import CellMeasurerCache from "react-virtualized/dist/es/CellMeasurer/CellMeasurerCache";
import StakeholderPreview from "components/FoodSeeker/StakeholderPreview";
import StakeholderDetails from "components/FoodSeeker/StakeholderDetails";
import * as analytics from "services/analytics";

const useStyles = makeStyles((theme) => ({
listContainer: {
textAlign: "center",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
[theme.breakpoints.up("md")]: {
height: "100%",
},
[theme.breakpoints.only("sm")]: {
order: 1,
height: "30em",
},
[theme.breakpoints.down("xs")]: {
height: "100%",
fontSize: "12px",
},
},
list: {
width: "100%",
flex: 1,
},
preview: {
width: "100%",
borderBottom: " .2em solid #f1f1f1",
padding: "0 1em",
},
emptyResult: {
padding: "1em 0",
display: "flex",
flexDirection: "column",
alignContent: "center",
},
}));

const cache = new CellMeasurerCache({
defaultHeight: 140,
fixedWidth: true,
});

const clearCache = () => cache.clearAll();

const ResultsList = ({
doSelectStakeholder,
selectedStakeholder,
stakeholders,
setToast,
status,
handleReset,
}) => {
const classes = useStyles();

useEffect(() => {
analytics.postEvent("showList");
}, []);

useEffect(() => {
window.addEventListener("resize", clearCache);
return () => window.removeEventListener("resize", clearCache);
}, []);

useEffect(() => {
clearCache();
}, [stakeholders]);

const scrollToIndex = selectedStakeholder
? stakeholders.findIndex((s) => s.id === selectedStakeholder.id)
: 0;

const rowRenderer = useCallback(
({ index, style, key, parent }) => (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
columnIndex={0}
rowIndex={index}
>
{({ registerChild }) => (
<div ref={registerChild} style={style} className={classes.preview}>
<StakeholderPreview
stakeholder={stakeholders[index]}
doSelectStakeholder={doSelectStakeholder}
/>
</div>
)}
</CellMeasurer>
),
[stakeholders, doSelectStakeholder, classes.preview]
);

return (
<Grid item xs={12} md={4} className={classes.listContainer}>
{status === "loading" && (
<div className={classes.emptyResult}>
<CircularProgress />
</div>
)}
{status === "loaded" && stakeholders.length === 0 && (
<div className={classes.emptyResult}>
<p>Sorry, we don&apos;t have any results for this area.</p>
<Button
onClick={handleReset}
variant="contained"
color="primary"
disableElevation
>
Click here to reset the search
</Button>
</div>
)}
{stakeholders && selectedStakeholder && !selectedStakeholder.inactive ? (
<StakeholderDetails
doSelectStakeholder={doSelectStakeholder}
selectedStakeholder={selectedStakeholder}
setToast={setToast}
/>
) : (
<div className={classes.list}>
<AutoSizer>
{({ height, width }) => (
<List
width={width}
height={height}
rowCount={stakeholders.length}
rowRenderer={rowRenderer}
deferredMeasurementCache={cache}
rowHeight={cache.rowHeight}
scrollToIndex={scrollToIndex}
/>
)}
</AutoSizer>
</div>
)}
</Grid>
);
};

ResultsList.propTypes = {
selectedStakeholder: PropTypes.object,
stakeholders: PropTypes.arrayOf(PropTypes.object),
doSelectStakeholder: PropTypes.func,
setToast: PropTypes.func,
status: PropTypes.string,
handleReset: PropTypes.func,
};

export default ResultsList;
Loading

0 comments on commit 11ae80c

Please sign in to comment.