Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Retain Markers and Layers Whenever Leaving and Returning to Map Page #37

Merged
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion cypress/e2e/map.cy.ts
Original file line number Diff line number Diff line change
@@ -74,12 +74,41 @@ describe("Map", () => {
cy.url().should("not.contain", "lng=-82.401078");
});

it("Map contents are redisplayed whenever returning to the page from another", () => {
loadMap("/?maps=adult-day-care");

cy.get(".leaflet-marker-icon").its("length").as("initialNumberOfMarkers");

// Go to the About page...
cy.contains("About").click();
// ... and then back to the map page.
cy.get("nav > a:nth-child(1)").click();

// Check that the markers are all still there
cy.get("@initialNumberOfMarkers").then((initialCount) => {
cy.get(".leaflet-marker-icon")
.its("length")
.then((newCount) => {
expect(initialCount).to.eq(newCount);
});
});

cy.url().should("contain", "maps=adult-day-care");

// Check that the layer checkbox is still checked
cy.get("[title='Layers']").trigger("mouseover");
cy.get(".leaflet-control-layers-overlays label input[checked]").should(
"have.length",
1,
);
});

describe("Attribution Control", () => {
it("Attribution control displays the proper message", () => {
loadMap("/");

cy.get(".leaflet-control-attribution").contains(
"Brought to you by HackGreenville Labs.",
"Brought to you by HackGreenville Labs",
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've removed this period since it looked kind of odd in the little attribution bar. It's not directly related to these changes, so please let me know if you'd like for me to extract it.

);
});
});
143 changes: 76 additions & 67 deletions src/components/MainMap.vue
Original file line number Diff line number Diff line change
@@ -2,10 +2,11 @@
import "leaflet/dist/leaflet.css";
import L, { LatLng } from "leaflet";
import type { Map, GeoJSON, LayersControlEvent } from "leaflet";
import { createMarker, setTooltips } from "@/utils/map";
</script>

<script setup lang="ts">
import { ref } from "vue";
import { ref, onBeforeUnmount } from "vue";
import {
LMap,
LTileLayer,
@@ -48,13 +49,26 @@ async function initializeMap(map: Map) {

for (const mapTitle in await mapStore.fetchAvailableMaps()) {
const mapData = mapStore.availableMaps[mapTitle];
const layerData = mapStore.loadedMaps[mapData.mapSlug];

// If the layerData is already present, then it is leftover from a previous
// visit to this page.
if (layerData) {
addLayerToOverlay(
layersControl,
layerData.layer,
mapData.mapTitle,
mapData.color,
);
}

await addMapLayer(
map,
layersControl,
mapData,
// Enable a map layer right away if it is listed in the query params
mapsToEnable.has(mapData.mapSlug),
true,
);
}

@@ -69,17 +83,59 @@ async function initializeMap(map: Map) {

map.on("overlayadd", async function (e: LayersControlEvent) {
const mapName = e.name.toString().replace(/ \(.+\)$/, "");
addMapLayer(map, layersControl, mapStore.availableMaps[mapName], true);
addMapLayer(
map,
layersControl,
mapStore.availableMaps[mapName],
true,
false,
);
});

map.on("overlayremove", async function (e: LayersControlEvent) {
const mapName = e.name.toString().replace(/ \(.+\)$/, "");
addMapLayer(map, layersControl, mapStore.availableMaps[mapName], false);
addMapLayer(
map,
layersControl,
mapStore.availableMaps[mapName],
false,
false,
);
});

mapInitialized.value = true;
}

async function addLayerToOverlay(
layersControl: L.Control.Layers,
layer: L.GeoJSON,
mapTitle: string,
color: string,
) {
layersControl.addOverlay(layer, `${mapTitle} (${color})`);
}

/*
* Ensures layers are populated with GeoJSON data and added to the map
*/
function initializeLayerData(map: Map, mapData: MapData, layerData: LayerData) {
const featureCollection = mapStore.availableMaps[mapData.mapTitle]?.geoJson;

// If the data isn't already available then perform an external request to
// obtain it.
if (featureCollection) {
layerData.layer.addData(featureCollection);
} else {
mapStore.fetchGeoJson(mapData).then((response) => {
if (response) {
layerData.layer.addData(response);
}
});
}

layerData.layer.addTo(map);
}

/*
* This method will either add a temporary empty layer that will be lazy-loaded
* later, or go ahead with loading depending on the `visible` argument
@@ -89,6 +145,7 @@ async function addMapLayer(
control: L.Control.Layers,
mapData: MapData,
visible: boolean,
initializingMap: boolean,
) {
// if layer is already fully loaded, just update the visibility in the store
const layerData = mapStore.loadedMaps[mapData.mapSlug];
@@ -102,6 +159,15 @@ async function addMapLayer(
layerData.visible = visible;
mapStore.addMapLayer(mapData.mapSlug, layerData, maintainerData);
setUrl();

// If the map is being reloaded, but the store hasn't been cleared
// (ex: user went to the About page and then came back)
// then we need to reinitialize the layer by adding back in the
// GeoJSON feature collection to it and then adding the layer to the map.
if (initializingMap) {
initializeLayerData(map, mapData, layerData);
}

return;
}

@@ -117,58 +183,10 @@ async function addMapLayer(
color: mapData.color,
};
},
pointToLayer: function (_feature: Feature, latlng: LatLng) {
const markerHtmlStyles = `
background-color: ${mapData.color};
width: 2rem;
height: 2rem;
display: block;
left: -1rem;
top: -1rem;
position: relative;
border-radius: 2rem 2rem 0;
transform: rotate(45deg);
border: 1px solid #FFFFFFAA`;

const markerIcon = L.divIcon({
className: "",
iconAnchor: [0, 24],
popupAnchor: [0, -36],
html: `<span style="${markerHtmlStyles}" />`,
});

return L.marker(latlng, { icon: markerIcon });
},
onEachFeature: function (feature: Feature, layer: GeoJSON) {
if (feature && feature.properties) {
const properties = feature.properties;

const tooltipString =
`<div>Map: ${mapData.mapTitle}</div>` +
Object.keys(properties)
.filter((key) => key != "OBJECTID" && properties[key])
.map((key) => {
const propertyName = toTitleCase(key.toString()).replace(
/_/g,
" ",
);
let propertyValue;
if (
properties[key]?.toString().startsWith("http") ||
properties[key]?.toString().startsWith("tel")
) {
propertyValue = `<a href="${properties[key]}" target="_blank" rel="noreferrer">${properties[key]}</a>`;
} else {
propertyValue = properties[key];
}

return `<div>${propertyName}: ${propertyValue}</div>`;
})
.join("");

layer.bindPopup(tooltipString, {});
}
},
pointToLayer: (_feature: Feature, latlng: LatLng) =>
createMarker(mapData.color, latlng),
onEachFeature: (feature: Feature, layer: GeoJSON) =>
setTooltips(mapData.mapTitle, feature, layer),
};

layer = L.geoJSON([], options);
@@ -179,12 +197,7 @@ async function addMapLayer(
mapStore.addMapLayer(mapData.mapSlug, newLayerData, maintainerData);

if (visible) {
mapStore.fetchGeoJson(mapData).then((response) => {
if (response) {
layer.addData(response);
}
});
layer.addTo(map);
initializeLayerData(map, mapData, newLayerData);
}

setUrl();
@@ -207,11 +220,7 @@ function setUrl() {
});
}

function toTitleCase(string: string) {
return string.replace(/\w\S*/g, function (txt) {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
});
}
onBeforeUnmount(() => mapStore.clearLayerData());
</script>

<template>
@@ -229,7 +238,7 @@ function toTitleCase(string: string) {
>
<l-control-attribution
position="bottomright"
prefix="Brought to you by <a href='https://hackgreenville.com/' target='_blank'>HackGreenville Labs</a>."
prefix="Brought to you by <a href='https://hackgreenville.com/' target='_blank'>HackGreenville Labs</a>"
/>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
14 changes: 13 additions & 1 deletion src/stores/map.ts
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@ import type {
LayerData,
MaintainerData,
} from "../types";
import type { LatLng } from "leaflet";
import L, { type LatLng } from "leaflet";

export const useMapStore = defineStore("map", {
state: () => ({
@@ -101,6 +101,18 @@ export const useMapStore = defineStore("map", {

return this.availableMaps;
},
/*
* Resets the layer feature collection, while retaining options
* (for things like the styling of the marker and the pointToLayer
* and onEachFeature callbacks) to prevent memory leaks from occurring.
*/
async clearLayerData() {
for (const key in this.loadedMaps) {
this.loadedMaps[key].layer = new L.GeoJSON(undefined, {
...this.loadedMaps[key].layer.options,
});
}
},
},
});

55 changes: 55 additions & 0 deletions src/tests/stores/map.spec.ts
Original file line number Diff line number Diff line change
@@ -146,4 +146,59 @@ describe("mapStore", () => {
mapStore.removeMapLayer(fakeMapSlug);
expect(mapStore.loadedMaps).toStrictEqual({});
});

describe("clearLayerData", () => {
it("clears feature collection and retains options from previous copy of layer", async () => {
const featureCollection: GeoJSON.FeatureCollection<any> = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: {
type: "Point",
coordinates: [0, 0],
},
properties: {},
},
],
};

const mapStore = useMapStore();
const layerData: LayerData = {
layer: L.geoJSON(featureCollection, {
style: () => ({
fillColor: "test",
color: "test",
}),
}),
loaded: false,
visible: false,
};

mapStore.addMapLayer(fakeMapSlug, layerData, sampleMaintainerData);

const beforeKeyCount = Object.keys(
mapStore.loadedMaps[fakeMapSlug].layer,
).length;

await mapStore.clearLayerData();

const afterKeyCount = Object.keys(
mapStore.loadedMaps[fakeMapSlug].layer,
).length;

expect(beforeKeyCount).toBeGreaterThan(afterKeyCount);

expect(typeof layerData.layer.options.style).toEqual("function");

if (typeof layerData.layer.options.style == "function") {
expect(JSON.stringify(layerData.layer.options.style())).toEqual(
JSON.stringify({
fillColor: "test",
color: "test",
}),
);
}
});
});
});
Loading