diff --git a/corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js b/corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js
index 40c04a466e24..d8ed8c9e708a 100644
--- a/corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js
+++ b/corehq/apps/geospatial/static/geospatial/js/case_grouping_map.js
@@ -4,54 +4,32 @@ hqDefine("geospatial/js/case_grouping_map",[
'underscore',
'hqwebapp/js/initial_page_data',
'hqwebapp/js/bootstrap3/alert_user',
+ 'geospatial/js/models',
+ 'geospatial/js/utils'
], function (
$,
ko,
_,
initialPageData,
- alertUser
+ alertUser,
+ models,
+ utils
) {
const MAPBOX_LAYER_VISIBILITY = {
None: 'none',
Visible: 'visible',
};
-
const DEFAULT_MARKER_OPACITY = 1.0;
const MAP_CONTAINER_ID = 'case-grouping-map';
- let map; // TODO: Map and related functions should be moved to a model
- let mapDrawControls;
const clusterStatsInstance = new clusterStatsModel();
let exportModelInstance;
let groupLockModelInstance = new groupLockModel()
let caseGroupsInstance = new caseGroupSelectModel()
let mapMarkers = [];
+ let mapModel;
let polygonFilterInstance;
- const FEATURE_QUERY_PARAM = 'features';
-
- function caseModel(caseId, coordinates, caseLink) {
- 'use strict';
- var self = {};
- self.caseId = caseId;
- self.coordinates = coordinates;
- self.caseLink = caseLink;
- self.groupId = null;
- self.groupCoordinates = null;
-
- self.toJson = function () {
- const coordinates = (self.coordinates) ? `${self.coordinates.lng} ${self.coordinates.lat}` : "";
- const groupCoordinates = (self.groupCoordinates) ? `${self.groupCoordinates.lng} ${self.groupCoordinates.lat}` : "";
- return {
- 'groupId': self.groupId,
- 'groupCenterCoordinates': groupCoordinates,
- 'caseId': self.caseId,
- 'coordinates': coordinates,
- };
- }
-
- return self;
- }
function clusterStatsModel() {
'use strict';
@@ -101,7 +79,7 @@ hqDefine("geospatial/js/case_grouping_map",[
const hiddenElement = document.createElement('a');
hiddenElement.href = 'data:text/csv;charset=utf-8,' + encodeURI(csvStr);
hiddenElement.target = '_blank';
- hiddenElement.download = `Grouped Cases (${getTodayDate()}).csv`;
+ hiddenElement.download = `Grouped Cases (${utils.getTodayDate()}).csv`;
hiddenElement.click();
hiddenElement.remove();
};
@@ -109,7 +87,7 @@ hqDefine("geospatial/js/case_grouping_map",[
self.addGroupsToCases = function(caseGroups) {
clearCaseGroups();
self.casesToExport().forEach(caseItem => {
- const groupData = caseGroups[caseItem.caseId];
+ const groupData = caseGroups[caseItem.itemId];
if (groupData !== undefined) {
caseItem.groupId = groupData.groupId;
caseItem.groupCoordinates = groupData.groupCoordinates;
@@ -128,232 +106,8 @@ hqDefine("geospatial/js/case_grouping_map",[
return self;
}
- function polygonModel(polygon) {
- let self = {};
- self.text = polygon.name;
- self.id = polygon.id;
- self.geoJson = polygon.geo_json;
- return self;
- }
-
- function polygonFilterModel() {
- let self = {};
- self.polygons = {};
- self.shouldRefreshPage = ko.observable(false);
-
- self.savedPolygons = ko.observableArray();
- self.selectedSavedPolygonId = ko.observable('');
- self.activeSavedPolygon;
-
- self.addPolygonsToFilterList = function (featureList) {
- for (const feature of featureList) {
- self.polygons[feature.id] = feature;
- }
- updatePolygonQueryParam();
- };
-
- self.removePolygonsFromFilterList = function (featureList) {
- for (const feature of featureList) {
- if (self.polygons[feature.id]) {
- delete self.polygons[feature.id];
- }
- }
- updatePolygonQueryParam();
- };
-
- function updatePolygonQueryParam() {
- const url = new URL(window.location.href);
- if (Object.keys(self.polygons).length) {
- url.searchParams.set(FEATURE_QUERY_PARAM, JSON.stringify(self.polygons));
- } else {
- url.searchParams.delete(FEATURE_QUERY_PARAM);
- }
- window.history.replaceState({ path: url.href }, '', url.href);
- self.shouldRefreshPage(true);
- }
-
- self.loadPolygonFromQueryParam = function () {
- const url = new URL(window.location.href);
- const featureParam = url.searchParams.get(FEATURE_QUERY_PARAM);
- if (featureParam) {
- const features = JSON.parse(featureParam);
- for (const featureId in features) {
- const feature = features[featureId];
- mapDrawControls.add(feature);
- self.polygons[featureId] = feature;
- }
- }
- };
-
- function removeActivePolygonLayer() {
- if (self.activeSavedPolygon) {
- map.removeLayer(self.activeSavedPolygon.id);
- map.removeSource(self.activeSavedPolygon.id);
- }
- }
-
- function createActivePolygonLayer(polygonObj) {
- map.addSource(
- String(polygonObj.id),
- {'type': 'geojson', 'data': polygonObj.geoJson}
- );
- map.addLayer({
- 'id': String(polygonObj.id),
- 'type': 'fill',
- 'source': String(polygonObj.id),
- 'layout': {},
- 'paint': {
- 'fill-color': '#0080ff',
- 'fill-opacity': 0.5,
- },
- });
- }
-
- self.clearActivePolygon = function () {
- self.selectedSavedPolygonId('');
- self.removePolygonsFromFilterList(self.activeSavedPolygon.geoJson.features);
- removeActivePolygonLayer();
- self.activeSavedPolygon = null;
- };
-
- self.selectedSavedPolygonId.subscribe(() => {
- const selectedId = parseInt(self.selectedSavedPolygonId());
- const polygonObj = self.savedPolygons().find(
- function (o) { return o.id === selectedId; }
- );
- if (!polygonObj) {
- return;
- }
-
- if (self.activeSavedPolygon) {
- self.clearActivePolygon();
- }
-
- removeActivePolygonLayer();
- createActivePolygonLayer(polygonObj);
-
- self.activeSavedPolygon = polygonObj;
- self.addPolygonsToFilterList(polygonObj.geoJson.features);
- });
-
- self.loadPolygons = function (polygonArr) {
- self.loadPolygonFromQueryParam();
- self.savedPolygons([]);
- _.each(polygonArr, (polygon) => {
- // Saved features don't have IDs, so we need to give them to uniquely identify them for polygon filtering
- for (const feature of polygon.geo_json.features) {
- feature.id = uuidv4();
- }
- self.savedPolygons.push(polygonModel(polygon));
- });
- };
-
- return self;
- }
-
- function getTodayDate() {
- const todayDate = new Date();
- return todayDate.toLocaleDateString();
- }
-
- function initMap() {
- 'use strict';
-
- mapboxgl.accessToken = initialPageData.get('mapbox_access_token'); // eslint-disable-line no-undef
- const centerCoordinates = [2.43333330, 9.750];
-
- const mapboxInstance = new mapboxgl.Map({ // eslint-disable-line no-undef
- container: MAP_CONTAINER_ID, // container ID
- style: 'mapbox://styles/mapbox/streets-v12', // style URL
- center: centerCoordinates, // starting position [lng, lat]
- zoom: 6,
- attribution: '© Mapbox ©' +
- ' OpenStreetMap',
- });
-
- mapboxInstance.on('load', () => {
- map.addSource('caseWithGPS', {
- type: 'geojson',
- data: {
- "type": "FeatureCollection",
- "features": [],
- },
- cluster: true,
- clusterMaxZoom: 14, // Max zoom to cluster points on
- clusterRadius: 50, // Radius of each cluster when clustering points (defaults to 50)
- });
- map.addLayer({
- id: 'clusters',
- type: 'circle',
- source: 'caseWithGPS',
- filter: ['has', 'point_count'],
- paint: {
- 'circle-color': [
- 'step',
- ['get', 'point_count'],
- '#51bbd6',
- 100,
- '#f1f075',
- 750,
- '#f28cb1',
- ],
- 'circle-radius': [
- 'step',
- ['get', 'point_count'],
- 20,
- 100,
- 30,
- 750,
- 40,
- ],
- },
- });
- map.addLayer({
- id: 'cluster-count',
- type: 'symbol',
- source: 'caseWithGPS',
- filter: ['has', 'point_count'],
- layout: {
- 'text-field': ['get', 'point_count_abbreviated'],
- 'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
- 'text-size': 12,
- },
- });
- map.addLayer({
- id: 'unclustered-point',
- type: 'circle',
- source: 'caseWithGPS',
- filter: ['!', ['has', 'point_count']],
- paint: {
- 'circle-color': 'red',
- 'circle-radius': 10,
- 'circle-stroke-width': 1,
- 'circle-stroke-color': '#fff',
- },
- });
- });
-
- mapDrawControls = new MapboxDraw({ // eslint-disable-line no-undef
- // API: https://github.com/mapbox/mapbox-gl-draw/blob/main/docs/API.md
- displayControlsDefault: false,
- boxSelect: true,
- controls: {
- polygon: true,
- trash: true,
- },
- });
- mapboxInstance.addControl(mapDrawControls);
-
- mapboxInstance.on('moveend', updateClusterStats);
- mapboxInstance.on('draw.update', (e) => polygonFilterInstance.addPolygonsToFilterList(e.features));
- mapboxInstance.on('draw.create', (e) => polygonFilterInstance.addPolygonsToFilterList(e.features));
- mapboxInstance.on('draw.delete', (e) => polygonFilterInstance.removePolygonsFromFilterList(e.features));
-
- return mapboxInstance;
- }
-
function updateClusterStats() {
- const sourceFeatures = map.querySourceFeatures('caseWithGPS', {
+ const sourceFeatures = mapModel.mapInstance.querySourceFeatures('caseWithGPS', {
sourceLayer: 'clusters',
filter: ['==', 'cluster', true],
});
@@ -396,13 +150,13 @@ hqDefine("geospatial/js/case_grouping_map",[
};
_.each(caseList, function (caseWithGPS) {
- const coordinates = caseWithGPS.coordinates;
+ const coordinates = caseWithGPS.itemData.coordinates;
if (coordinates && coordinates.lat && coordinates.lng) {
caseLocationsGeoJson["features"].push(
{
"type": "feature",
"properties": {
- "id": caseWithGPS.caseId,
+ "id": caseWithGPS.itemId,
},
"geometry": {
"type": "Point",
@@ -413,11 +167,11 @@ hqDefine("geospatial/js/case_grouping_map",[
}
});
- if (map.getSource('caseWithGPS')) {
- map.getSource('caseWithGPS').setData(caseLocationsGeoJson);
+ if (mapModel.mapInstance.getSource('caseWithGPS')) {
+ mapModel.mapInstance.getSource('caseWithGPS').setData(caseLocationsGeoJson);
} else {
- map.on('load', () => {
- map.getSource('caseWithGPS').setData(caseLocationsGeoJson);
+ mapModel.mapInstance.on('load', () => {
+ mapModel.mapInstance.getSource('caseWithGPS').setData(caseLocationsGeoJson);
});
}
}
@@ -435,17 +189,9 @@ hqDefine("geospatial/js/case_grouping_map",[
}
function setMapLayersVisibility(visibility) {
- map.setLayoutProperty('clusters', 'visibility', visibility);
- map.setLayoutProperty('cluster-count', 'visibility', visibility);
- map.setLayoutProperty('unclustered-point', 'visibility', visibility);
- }
-
- function getRandomRGBColor() { // TODO: Ensure generated colors looks different!
- var r = Math.floor(Math.random() * 256); // Random value between 0 and 255 for red
- var g = Math.floor(Math.random() * 256); // Random value between 0 and 255 for green
- var b = Math.floor(Math.random() * 256); // Random value between 0 and 255 for blue
-
- return `rgba(${r},${g},${b},${DEFAULT_MARKER_OPACITY})`;
+ mapModel.mapInstance.setLayoutProperty('clusters', 'visibility', visibility);
+ mapModel.mapInstance.setLayoutProperty('cluster-count', 'visibility', visibility);
+ mapModel.mapInstance.setLayoutProperty('unclustered-point', 'visibility', visibility);
}
function collapseGroupsOnMap() {
@@ -454,7 +200,8 @@ hqDefine("geospatial/js/case_grouping_map",[
mapMarkers = [];
exportModelInstance.casesToExport().forEach(function (caseItem) {
- if (!caseItem.coordinates) {
+ const coordinates = caseItem.itemData.coordinates;
+ if (!coordinates) {
return;
}
const caseGroupID = caseItem.groupId;
@@ -462,22 +209,15 @@ hqDefine("geospatial/js/case_grouping_map",[
let caseGroup = caseGroupsInstance.getGroupByID(caseGroupID);
color = caseGroup.color;
const marker = new mapboxgl.Marker({ color: color, draggable: false }); // eslint-disable-line no-undef
- marker.setLngLat([caseItem.coordinates.lng, caseItem.coordinates.lat]);
+ marker.setLngLat([coordinates.lng, coordinates.lat]);
// Add the marker to the map
- marker.addTo(map);
+ marker.addTo(mapModel.mapInstance);
mapMarkers.push(marker);
}
});
}
- function uuidv4() {
- // https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid/2117523#2117523
- return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
- (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
- );
- }
-
function caseGroupSelectModel() {
'use strict';
var self = {};
@@ -506,7 +246,7 @@ hqDefine("geospatial/js/case_grouping_map",[
}
new Set(groupIds).forEach(id => self.allGroups.push(
- {groupID: id, color: getRandomRGBColor()}
+ {groupID: id, color: utils.getRandomRGBColor()}
));
let visibleIDs = _.map(self.allGroups(), function(group) {return group.groupID});
@@ -534,7 +274,7 @@ hqDefine("geospatial/js/case_grouping_map",[
}
let marker = mapMarkers.find((marker) => {
let markerCoordinates = marker.getLngLat();
- let caseCoordinates = caseItem.coordinates;
+ let caseCoordinates = caseItem.itemData.coordinates;
let latEqual = markerCoordinates.lat === caseCoordinates.lat;
let lonEqual = markerCoordinates.lng === caseCoordinates.lng;
return latEqual && lonEqual;
@@ -578,11 +318,11 @@ hqDefine("geospatial/js/case_grouping_map",[
}
async function setCaseGroups() {
- const sourceFeatures = map.querySourceFeatures('caseWithGPS', {
+ const sourceFeatures = mapModel.mapInstance.querySourceFeatures('caseWithGPS', {
sourceLayer: 'clusters',
filter: ['==', 'cluster', true],
});
- const clusterSource = map.getSource('caseWithGPS');
+ const clusterSource = mapModel.mapInstance.getSource('caseWithGPS');
let caseGroups = {};
let failedClustersCount = 0;
processedCluster = {}
@@ -597,7 +337,7 @@ hqDefine("geospatial/js/case_grouping_map",[
try {
const casePoints = await getClusterLeavesAsync(clusterSource, clusterId, pointCount);
- const groupUUID = uuidv4();
+ const groupUUID = utils.uuidv4();
for (const casePoint of casePoints) {
const caseId = casePoint.properties.id;
caseGroups[caseId] = {
@@ -627,6 +367,7 @@ hqDefine("geospatial/js/case_grouping_map",[
mapMarkers.forEach((marker) => marker.remove());
mapMarkers = [];
exportModelInstance.clearCaseGroups();
+ caseGroupsInstance.allCaseGroups = undefined;
}
function groupLockModel() {
@@ -639,10 +380,10 @@ hqDefine("geospatial/js/case_grouping_map",[
// reset the warning banner
self.groupsLocked(!self.groupsLocked());
if (self.groupsLocked()) {
- map.scrollZoom.disable();
+ mapModel.mapInstance.scrollZoom.disable();
setCaseGroups();
} else {
- map.scrollZoom.enable();
+ mapModel.mapInstance.scrollZoom.enable();
clearCaseGroups();
caseGroupsInstance.clear();
}
@@ -669,10 +410,29 @@ hqDefine("geospatial/js/case_grouping_map",[
const caseRowOrder = initialPageData.get('case_row_order');
for (const caseItem of rawCaseData) {
const caseObj = parseCaseItem(caseItem, caseRowOrder);
- const caseModelInstance = new caseModel(caseObj.case_id, caseObj.gps_point, caseObj.link);
+ const caseModelInstance = new models.GroupedCaseMapItem(caseObj.case_id, {coordinates: caseObj.gps_point}, caseObj.link);
caseModels.push(caseModelInstance);
}
+ mapModel.caseMapItems(caseModels);
exportModelInstance.casesToExport(caseModels);
+
+ mapModel.fitMapBounds(caseModels);
+ }
+
+ function initMap() {
+ mapModel = new models.Map(true);
+ mapModel.initMap(MAP_CONTAINER_ID);
+
+ mapModel.mapInstance.on('moveend', updateClusterStats);
+ mapModel.mapInstance.on("draw.update", (e) => {
+ polygonFilterInstance.addPolygonsToFilterList(e.features);
+ });
+ mapModel.mapInstance.on('draw.delete', function (e) {
+ polygonFilterInstance.removePolygonsFromFilterList(e.features);
+ });
+ mapModel.mapInstance.on('draw.create', function (e) {
+ polygonFilterInstance.addPolygonsToFilterList(e.features);
+ });
}
$(document).ajaxComplete(function (event, xhr, settings) {
@@ -680,9 +440,9 @@ hqDefine("geospatial/js/case_grouping_map",[
if (isAfterReportLoad) {
$("#export-controls").koApplyBindings(exportModelInstance);
$("#lock-groups-controls").koApplyBindings(groupLockModelInstance);
- map = initMap();
+ initMap();
$("#clusterStats").koApplyBindings(clusterStatsInstance);
- polygonFilterInstance = new polygonFilterModel();
+ polygonFilterInstance = new models.PolygonFilter(mapModel, true, false);
polygonFilterInstance.loadPolygons(initialPageData.get('saved_polygons'));
$("#polygon-filters").koApplyBindings(polygonFilterInstance);
diff --git a/corehq/apps/geospatial/static/geospatial/js/geospatial_map.js b/corehq/apps/geospatial/static/geospatial/js/geospatial_map.js
index 8ddcfd200687..ecc3e29f8021 100644
--- a/corehq/apps/geospatial/static/geospatial/js/geospatial_map.js
+++ b/corehq/apps/geospatial/static/geospatial/js/geospatial_map.js
@@ -2,11 +2,13 @@ hqDefine("geospatial/js/geospatial_map", [
"jquery",
"hqwebapp/js/initial_page_data",
"knockout",
+ 'geospatial/js/models',
'select2/dist/js/select2.full.min',
], function (
$,
initialPageData,
- ko
+ ko,
+ models
) {
const caseMarkerColors = {
'default': "#808080", // Gray
@@ -16,57 +18,17 @@ hqDefine("geospatial/js/geospatial_map", [
'default': "#0e00ff", // Blue
'selected': "#0b940d", // Dark Green
};
- const DOWNPLAY_OPACITY = 0.2;
- const HOVER_DELAY = 400;
const DEFAULT_POLL_TIME_MS = 1500;
- const DEFAULT_CENTER_COORD = [-20.0, -0.0];
+ const MAP_CONTAINER_ID = 'geospatial-map';
var saveGeoJSONUrl = initialPageData.reverse('geo_polygon');
var runDisbursementUrl = initialPageData.reverse('case_disbursement');
var disbursementRunner;
- var caseGroupsIndex = {};
- function getLineFeatureId(itemId) {
- return "route-" + itemId;
- }
-
- function mapItemModel(itemId, itemData, marker, markerColors) {
- 'use strict';
- var self = {};
- self.itemId = itemId;
- self.itemData = itemData;
- self.marker = marker;
- self.selectCssId = "select" + itemId;
- self.isSelected = ko.observable(false);
- self.markerColors = markerColors;
-
- self.setMarkerOpacity = function (opacity) {
- let element = self.marker.getElement();
- element.style.opacity = opacity;
- };
-
- function changeMarkerColor(selectedCase, newColor) {
- let marker = selectedCase.marker;
- let element = marker.getElement();
- let svg = element.getElementsByTagName("svg")[0];
- let path = svg.getElementsByTagName("path")[0];
- path.setAttribute("fill", newColor);
- }
-
- self.getItemType = function () {
- if (self.itemData.type === "user") {
- return gettext("Mobile Worker");
- }
- return gettext("Case");
- };
-
- self.isSelected.subscribe(function () {
- var color = self.isSelected() ? self.markerColors.selected : self.markerColors.default;
- changeMarkerColor(self, color);
- });
- return self;
- }
+ var mapModel;
+ var polygonFilterModel;
+ var missingGPSModelInstance;
function showMapControls(state) {
$("#geospatial-map").toggle(state);
@@ -75,287 +37,144 @@ hqDefine("geospatial/js/geospatial_map", [
$("#user-filters-panel").toggle(state);
}
- $(function () {
- // Global var
- var map;
-
- var caseModels = ko.observableArray([]);
- var userModels = ko.observableArray([]);
- var selectedCases = ko.computed(function () {
- return caseModels().filter(function (currCase) {
- return currCase.isSelected();
- });
- });
- var selectedUsers = ko.computed(function () {
- return userModels().filter(function (currUser) {
- return currUser.isSelected();
- });
- });
-
- function filterMapItemsInPolygon(polygonFeature) {
- _.values(caseModels()).filter(function (currCase) {
- if (currCase.itemData.coordinates) {
- currCase.isSelected(isMapItemInPolygon(polygonFeature, currCase.itemData.coordinates));
- }
- });
- _.values(userModels()).filter(function (currUser) {
- if (currUser.itemData.coordinates) {
- currUser.isSelected(isMapItemInPolygon(polygonFeature, currUser.itemData.coordinates));
- }
+ var saveGeoJson = function () {
+ const data = mapModel.drawControls.getAll();
+ if (data.features.length) {
+ let name = window.prompt(gettext("Name of the Area"));
+ data['name'] = name;
+
+ $.ajax({
+ type: 'post',
+ url: saveGeoJSONUrl,
+ dataType: 'json',
+ data: JSON.stringify({'geo_json': data}),
+ contentType: "application/json; charset=utf-8",
+ success: function (ret) {
+ delete data.name;
+ // delete drawn area
+ mapModel.drawControls.deleteAll();
+ console.log('newPoly', name);
+ polygonFilterModel.savedPolygons.push(
+ new models.SavedPolygon({
+ name: name,
+ id: ret.id,
+ geo_json: data,
+ })
+ );
+ // redraw using mapControlsModelInstance
+ polygonFilterModel.selectedSavedPolygonId(ret.id);
+ },
});
}
+ };
- function isMapItemInPolygon(polygonFeature, coordinates) {
- // Will be 0 if a user deletes a point from a three-point polygon,
- // since mapbox will delete the entire polygon. turf.booleanPointInPolygon()
- // does not expect this, and will raise a 'TypeError' exception.
- if (!polygonFeature.geometry.coordinates.length) {
- return false;
- }
- const coordinatesArr = [coordinates.lng, coordinates.lat];
- const point = turf.point(coordinatesArr); // eslint-disable-line no-undef
- return turf.booleanPointInPolygon(point, polygonFeature.geometry); // eslint-disable-line no-undef
- }
-
- var loadMapBox = function (centerCoordinates) {
- 'use strict';
+ var disbursementRunnerModel = function () {
+ var self = {};
- var self = {};
- let clickedMarker;
- mapboxgl.accessToken = initialPageData.get('mapbox_access_token'); // eslint-disable-line no-undef
+ self.pollUrl = ko.observable('');
+ self.isBusy = ko.observable(false);
- if (!centerCoordinates) {
- centerCoordinates = DEFAULT_CENTER_COORD; // should be domain specific
+ self.setBusy = function (isBusy) {
+ self.isBusy(isBusy);
+ $("#hq-content *").prop("disabled", isBusy);
+ if (isBusy) {
+ $("#btnRunDisbursement").addClass('disabled');
+ } else {
+ $("#btnRunDisbursement").removeClass('disabled');
}
+ };
- const map = new mapboxgl.Map({ // eslint-disable-line no-undef
- container: 'geospatial-map', // container ID
- style: 'mapbox://styles/mapbox/streets-v12', // style URL
- center: centerCoordinates, // starting position [lng, lat]
- attribution: '© Mapbox ©' +
- ' OpenStreetMap',
- });
-
- const draw = new MapboxDraw({ // eslint-disable-line no-undef
- // API: https://github.com/mapbox/mapbox-gl-draw/blob/main/docs/API.md
- displayControlsDefault: false,
- boxSelect: true, // enables box selection
- controls: {
- polygon: true,
- trash: true,
- },
- });
-
- map.addControl(draw);
-
- map.on("draw.update", function (e) {
- var selectedFeatures = e.features;
-
- // Check if any features are selected
- if (!selectedFeatures.length) {
- return;
- }
- var selectedFeature = selectedFeatures[0];
-
- if (selectedFeature.geometry.type === 'Polygon') {
- filterMapItemsInPolygon(selectedFeature);
- }
- });
-
- map.on('draw.selectionchange', function (e) {
- // See https://github.com/mapbox/mapbox-gl-draw/blob/main/docs/API.md#drawselectionchange
- var selectedFeatures = e.features;
- if (!selectedFeatures.length) {
- return;
- }
-
- // Check if any features are selected
- var selectedFeature = selectedFeatures[0];
- // Update this logic if we need to support case filtering by selecting multiple polygons
-
- if (selectedFeature.geometry.type === 'Polygon') {
- // Now that we know we selected a polygon, we need to check which markers are inside
- filterMapItemsInPolygon(selectedFeature);
- }
+ self.handleDisbursementResults = function (result) {
+ var groupId = 0;
+ Object.keys(result).forEach((userId) => {
+ let user = mapModel.userMapItems().find((userModel) => {return userModel.itemId === userId;});
+ const userCoordString = user.itemData.coordinates['lng'] + " " + user.itemData.coordinates['lat'];
+ mapModel.caseGroupsIndex[userCoordString] = {groupId: groupId, item: user};
+
+ let cases = [];
+ mapModel.caseMapItems().forEach((caseModel) => {
+ if (result[userId].includes(caseModel.itemId)) {
+ cases.push(caseModel);
+ const coordString = caseModel.itemData.coordinates['lng'] + " " + caseModel.itemData.coordinates['lat'];
+ mapModel.caseGroupsIndex[coordString] = {groupId: groupId, item: caseModel};
+ }
+ });
+ connectUserWithCasesOnMap(user, cases);
+ groupId += 1;
});
+ self.setBusy(false);
+ };
- function getCoordinates(event) {
- return event.lngLat;
- }
+ self.runCaseDisbursementAlgorithm = function (cases, users) {
+ self.setBusy(true);
+ let mapInstance = mapModel.mapInstance;
- // We should consider refactoring and splitting the below out to a new JS file
- function moveMarkerToClickedCoordinate(coordinates) { // eslint-disable-line no-unused-vars
- if (clickedMarker !== null) {
- clickedMarker.remove();
+ let caseData = [];
+ cases.forEach(function (c) {
+ const layerId = mapModel.getLineFeatureId(c.itemId);
+ if (mapInstance.getLayer(layerId)) {
+ mapInstance.removeLayer(layerId);
}
- if (draw.getMode() === 'draw_polygon') {
- // It's weird moving the marker around with the ploygon
- return;
+ if (mapInstance.getSource(layerId)) {
+ mapInstance.removeSource(layerId);
}
- clickedMarker = new mapboxgl.Marker({color: "FF0000", draggable: true}); // eslint-disable-line no-undef
- clickedMarker.setLngLat(coordinates);
- clickedMarker.addTo(map);
- }
-
- self.getMapboxDrawInstance = function () {
- return draw;
- };
- self.getMapboxInstance = function () {
- return map;
- };
-
- self.removeMarkersFromMap = function (itemArr) {
- _.each(itemArr, function (currItem) {
- currItem.marker.remove();
+ caseData.push({
+ id: c.itemId,
+ lon: c.itemData.coordinates.lng,
+ lat: c.itemData.coordinates.lat,
});
- };
-
- self.addMarkersToMap = function (itemArr, markerColours) {
- let outArr = [];
- _.forEach(itemArr, function (item, itemId) {
- const coordinates = item.coordinates;
- if (coordinates && coordinates.lat && coordinates.lng) {
- const mapItem = self.addMarker(itemId, item, markerColours);
- outArr.push(mapItem);
- }
- });
- return outArr;
- };
+ });
- self.addMarker = function (itemId, itemData, colors) {
- const coordinates = itemData.coordinates;
- // Create the marker
- const marker = new mapboxgl.Marker({ color: colors.default, draggable: false }); // eslint-disable-line no-undef
- marker.setLngLat(coordinates);
-
- // Add the marker to the map
- marker.addTo(map);
-
- let popupDiv = document.createElement("div");
- popupDiv.setAttribute("data-bind", "template: 'select-case'");
-
- let popup = new mapboxgl.Popup({ offset: 25, anchor: "bottom" }) // eslint-disable-line no-undef
- .setLngLat(coordinates)
- .setDOMContent(popupDiv);
-
- marker.setPopup(popup);
-
- const markerDiv = marker.getElement();
- // Show popup on hover
- markerDiv.addEventListener('mouseenter', () => marker.togglePopup());
- markerDiv.addEventListener('mouseenter', () => highlightMarkerGroup(marker));
- markerDiv.addEventListener('mouseleave', () => resetMarkersOpacity());
-
- // Hide popup if mouse leaves marker and popup
- var addLeaveEvent = function (fromDiv, toDiv) {
- fromDiv.addEventListener('mouseleave', function () {
- setTimeout(function () {
- if (!$(toDiv).is(':hover')) {
- // mouse left toDiv as well
- marker.togglePopup();
- }
- }, 100);
- });
+ let userData = users.map(function (c) {
+ return {
+ id: c.itemId,
+ lon: c.itemData.coordinates.lng,
+ lat: c.itemData.coordinates.lat,
};
- addLeaveEvent(markerDiv, popupDiv);
- addLeaveEvent(popupDiv, markerDiv);
-
- const mapItemInstance = new mapItemModel(itemId, itemData, marker, colors);
- $(popupDiv).koApplyBindings(mapItemInstance);
-
- return mapItemInstance;
- };
+ });
- ko.applyBindings({'userModels': userModels, 'selectedUsers': selectedUsers}, $("#user-modals")[0]);
- ko.applyBindings({'caseModels': caseModels, 'selectedCases': selectedCases}, $("#case-modals")[0]);
- // Handle click events here
- map.on('click', (event) => {
- let coordinates = getCoordinates(event); // eslint-disable-line no-unused-vars
+ $.ajax({
+ type: 'post',
+ url: runDisbursementUrl,
+ dataType: 'json',
+ data: JSON.stringify({'users': userData, "cases": caseData}),
+ contentType: "application/json; charset=utf-8",
+ success: function (ret) {
+ if (ret['poll_url'] !== undefined) {
+ self.startPoll(ret['poll_url']);
+ } else {
+ self.handleDisbursementResults(ret['result']);
+ }
+ },
});
- return self;
};
- var saveGeoJson = function (drawInstance, mapControlsModelInstance) {
- var data = drawInstance.getAll();
-
- if (data.features.length) {
- let name = window.prompt(gettext("Name of the Area"));
- data['name'] = name;
-
- $.ajax({
- type: 'post',
- url: saveGeoJSONUrl,
- dataType: 'json',
- data: JSON.stringify({'geo_json': data}),
- contentType: "application/json; charset=utf-8",
- success: function (ret) {
- delete data.name;
- // delete drawn area
- drawInstance.deleteAll();
- mapControlsModelInstance.savedPolygons.push(
- savedPolygon({
- name: name,
- id: ret.id,
- geo_json: data,
- })
- );
- // redraw using mapControlsModelInstance
- mapControlsModelInstance.selectedPolygon(ret.id);
- },
- });
+ self.startPoll = function (pollUrl) {
+ if (!self.isBusy()) {
+ self.setBusy(true);
}
+ self.pollUrl(pollUrl);
+ self.doPoll();
};
- function resetMarkersOpacity() {
- let mapInstance = map.getMapboxInstance();
- let markers = [];
- Object.keys(caseGroupsIndex).forEach(itemCoordinates => {
- const mapMarkerItem = caseGroupsIndex[itemCoordinates];
- markers.push(mapMarkerItem.item);
-
- const lineId = getLineFeatureId(mapMarkerItem.item.itemId);
- if (mapInstance.getLayer(lineId)) {
- mapInstance.setPaintProperty(lineId, 'line-opacity', 1);
- }
- });
- changeMarkersOpacity(markers, 1);
- }
-
- function highlightMarkerGroup(marker) {
- const markerCoords = marker.getLngLat();
- const currentMarkerPosition = markerCoords.lng + " " + markerCoords.lat;
- const markerItem = caseGroupsIndex[currentMarkerPosition];
- let mapInstance = map.getMapboxInstance();
-
- if (markerItem) {
- const groupId = markerItem.groupId;
-
- let markersToHide = [];
- Object.keys(caseGroupsIndex).forEach(itemCoordinates => {
- const mapMarkerItem = caseGroupsIndex[itemCoordinates];
-
- if (mapMarkerItem.groupId !== groupId) {
- markersToHide.push(mapMarkerItem.item);
- const lineId = getLineFeatureId(mapMarkerItem.item.itemId);
- if (mapInstance.getLayer(lineId)) {
- mapInstance.setPaintProperty(lineId, 'line-opacity', DOWNPLAY_OPACITY);
+ self.doPoll = function () {
+ var tick = function () {
+ $.ajax({
+ method: 'GET',
+ url: self.pollUrl(),
+ success: function (data) {
+ const result = data.result;
+ if (!data) {
+ setTimeout(tick, DEFAULT_POLL_TIME_MS);
+ } else {
+ self.handleDisbursementResults(result);
}
- }
- });
- changeMarkersOpacity(markersToHide, DOWNPLAY_OPACITY);
- }
- }
-
- function changeMarkersOpacity(markers, opacity) {
- // It's necessary to delay obscuring the markers since mapbox does not play nice
- // if we try to do it all at once.
- setTimeout(function () {
- markers.forEach(marker => {
- marker.setMarkerOpacity(opacity);
+ },
});
- }, HOVER_DELAY);
- }
+ };
+ tick();
+ };
function connectUserWithCasesOnMap(user, cases) {
cases.forEach((caseModel) => {
@@ -363,9 +182,9 @@ hqDefine("geospatial/js/geospatial_map", [
[user.itemData.coordinates.lng, user.itemData.coordinates.lat],
[caseModel.itemData.coordinates.lng, caseModel.itemData.coordinates.lat],
];
- let mapInstance = map.getMapboxInstance();
+ let mapInstance = mapModel.mapInstance;
mapInstance.addLayer({
- id: getLineFeatureId(caseModel.itemId),
+ id: mapModel.getLineFeatureId(caseModel.itemId),
type: 'line',
source: {
type: 'geojson',
@@ -390,450 +209,242 @@ hqDefine("geospatial/js/geospatial_map", [
});
}
- function savedPolygon(polygon) {
- var self = {};
- self.text = polygon.name;
- self.id = polygon.id;
- self.geoJson = polygon.geo_json;
- return self;
- }
-
- var disbursementRunnerModel = function () {
- var self = {};
-
- self.pollUrl = ko.observable('');
- self.isBusy = ko.observable(false);
-
- self.setBusy = function (isBusy) {
- self.isBusy(isBusy);
- $("#hq-content *").prop("disabled", isBusy);
- if (isBusy) {
- $("#btnRunDisbursement").addClass('disabled');
- } else {
- $("#btnRunDisbursement").removeClass('disabled');
- }
- };
-
- self.handleDisbursementResults = function (result) {
- var groupId = 0;
- Object.keys(result).forEach((userId) => {
- let user = userModels().find((userModel) => {return userModel.itemId === userId;});
- const userCoordString = user.itemData.coordinates['lng'] + " " + user.itemData.coordinates['lat'];
- caseGroupsIndex[userCoordString] = {groupId: groupId, item: user};
-
- let cases = [];
- caseModels().forEach((caseModel) => {
- if (result[userId].includes(caseModel.itemId)) {
- cases.push(caseModel);
- const coordString = caseModel.itemData.coordinates['lng'] + " " + caseModel.itemData.coordinates['lat'];
- caseGroupsIndex[coordString] = {groupId: groupId, item: caseModel};
- }
- });
- connectUserWithCasesOnMap(user, cases);
- groupId += 1;
- });
- self.setBusy(false);
- };
-
- self.runCaseDisbursementAlgorithm = function (cases, users) {
- self.setBusy(true);
- let mapInstance = map.getMapboxInstance();
-
- let caseData = [];
- cases.forEach(function (c) {
- const layerId = getLineFeatureId(c.itemId);
- if (mapInstance.getLayer(layerId)) {
- mapInstance.removeLayer(layerId);
- }
- if (mapInstance.getSource(layerId)) {
- mapInstance.removeSource(layerId);
- }
-
- caseData.push({
- id: c.itemId,
- lon: c.itemData.coordinates.lng,
- lat: c.itemData.coordinates.lat,
- });
- });
-
- let userData = users.map(function (c) {
- return {
- id: c.itemId,
- lon: c.itemData.coordinates.lng,
- lat: c.itemData.coordinates.lat,
- };
- });
-
- $.ajax({
- type: 'post',
- url: runDisbursementUrl,
- dataType: 'json',
- data: JSON.stringify({'users': userData, "cases": caseData}),
- contentType: "application/json; charset=utf-8",
- success: function (ret) {
- if (ret['poll_url'] !== undefined) {
- self.startPoll(ret['poll_url']);
- } else {
- self.handleDisbursementResults(ret['result']);
- }
- },
- });
- };
-
- self.startPoll = function (pollUrl) {
- if (!self.isBusy()) {
- self.setBusy(true);
- }
- self.pollUrl(pollUrl);
- self.doPoll();
- };
-
- self.doPoll = function () {
- var tick = function () {
- $.ajax({
- method: 'GET',
- url: self.pollUrl(),
- success: function (data) {
- const result = data.result;
- if (!data) {
- setTimeout(tick, DEFAULT_POLL_TIME_MS);
- } else {
- self.handleDisbursementResults(result);
- }
- },
- });
- };
- tick();
- };
+ return self;
+ };
- return self;
- };
+ function initMap() {
+ mapModel = new models.Map();
+ mapModel.initMap(MAP_CONTAINER_ID);
- var mapControlsModel = function () {
- 'use strict';
- var self = {};
- var mapboxinstance = map.getMapboxInstance();
- self.btnRunDisbursementDisabled = ko.computed(function () {
- return !caseModels().length || !userModels().length;
- });
- self.btnSaveDisabled = ko.observable(true);
- self.btnExportDisabled = ko.observable(true);
-
- // initial saved polygons
- self.savedPolygons = ko.observableArray();
- _.each(initialPageData.get('saved_polygons'), function (polygon) {
- self.savedPolygons.push(savedPolygon(polygon));
+ let selectedCases = ko.computed(function () {
+ return mapModel.caseMapItems().filter(function (currCase) {
+ return currCase.isSelected();
});
- // Keep track of the Polygon selected by the user
- self.selectedPolygon = ko.observable();
- // Keep track of the Polygon displayed
- self.activePolygon = ko.observable();
-
- // On selection, add the polygon to the map
- self.selectedPolygon.subscribe(function (value) {
- const polygonId = parseInt(self.selectedPolygon());
- var polygonObj = self.savedPolygons().find(
- function (o) { return o.id === polygonId; }
- );
- if (!polygonObj) {
- return;
- }
-
- // Clear existing polygon
- if (self.activePolygon()) {
- mapboxinstance.removeLayer(self.activePolygon());
- mapboxinstance.removeSource(self.activePolygon());
- }
- if (value !== undefined) {
- // Add selected polygon
- mapboxinstance.addSource(
- String(polygonObj.id),
- {'type': 'geojson', 'data': polygonObj.geoJson}
- );
- mapboxinstance.addLayer({
- 'id': String(polygonObj.id),
- 'type': 'fill',
- 'source': String(polygonObj.id),
- 'layout': {},
- 'paint': {
- 'fill-color': '#0080ff',
- 'fill-opacity': 0.5,
- },
- });
- polygonObj.geoJson.features.forEach(
- filterMapItemsInPolygon
- );
- self.btnExportDisabled(false);
- self.btnSaveDisabled(true);
- }
- // Mark as active polygon
- self.activePolygon(self.selectedPolygon());
+ });
+ let selectedUsers = ko.computed(function () {
+ return mapModel.userMapItems().filter(function (currUser) {
+ return currUser.isSelected();
});
+ });
- var mapHasPolygons = function () {
- var drawnFeatures = map.getMapboxDrawInstance().getAll().features;
- if (!drawnFeatures.length) {
- return false;
- }
- return drawnFeatures.some(function (feature) {
- return feature.geometry.type === "Polygon";
- });
- };
-
- mapboxinstance.on('draw.delete', function () {
- self.btnSaveDisabled(!mapHasPolygons());
- });
+ ko.applyBindings({'userModels': mapModel.userMapItems, 'selectedUsers': selectedUsers}, $("#user-modals")[0]);
+ ko.applyBindings({'caseModels': mapModel.caseMapItems, 'selectedCases': selectedCases}, $("#case-modals")[0]);
- mapboxinstance.on('draw.create', function () {
- self.btnSaveDisabled(!mapHasPolygons());
- });
+ mapModel.mapInstance.on("draw.update", (e) => {
+ mapModel.selectAllMapItems(e.features);
+ });
+ mapModel.mapInstance.on('draw.selectionchange', (e) => {
+ mapModel.selectAllMapItems(e.features);
+ });
+ mapModel.mapInstance.on('draw.delete', function () {
+ // TODO: Need to fix this
+ polygonFilterModel.btnSaveDisabled(!mapModel.mapHasPolygons());
+ });
+ mapModel.mapInstance.on('draw.create', function () {
+ // TODO: Need to fix this
+ polygonFilterModel.btnSaveDisabled(!mapModel.mapHasPolygons());
+ });
+ }
- self.exportGeoJson = function () {
- var exportButton = $("#btnExportDrawnArea");
- var selectedPolygon = self.savedPolygons().find(
- function (o) { return o.id === self.selectedPolygon(); }
- );
- if (selectedPolygon) {
- var convertedData = 'text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(selectedPolygon.geoJson));
- exportButton.attr('href', 'data:' + convertedData);
- exportButton.attr('download','data.geojson');
- }
- };
+ function initPolygonFilters() {
+ // Assumes `map` var is initialized
+ const $mapControlDiv = $("#mapControls");
+ polygonFilterModel = new models.PolygonFilter(mapModel, false, true);
+ polygonFilterModel.loadPolygons(initialPageData.get('saved_polygons'));
+ if ($mapControlDiv.length) {
+ ko.cleanNode($mapControlDiv[0]);
+ $mapControlDiv.koApplyBindings(polygonFilterModel);
+ }
- return self;
- };
+ const $saveDrawnArea = $("#btnSaveDrawnArea");
+ $saveDrawnArea.click(function () {
+ if (mapModel && mapModel.mapInstance) {
+ saveGeoJson();
+ }
+ });
- function initMapControls() {
- // Assumes `map` var is initialized
- var $mapControlDiv = $("#mapControls");
- var mapControlsModelInstance = mapControlsModel();
- if ($mapControlDiv.length) {
- ko.cleanNode($mapControlDiv[0]);
- $mapControlDiv.koApplyBindings(mapControlsModelInstance);
+ var $exportDrawnArea = $("#btnExportDrawnArea");
+ $exportDrawnArea.click(function () {
+ if (mapModel && mapModel.mapInstance) {
+ polygonFilterModel.exportGeoJson("btnExportDrawnArea");
}
+ });
- var $saveDrawnArea = $("#btnSaveDrawnArea");
- $saveDrawnArea.click(function () {
- if (map) {
- saveGeoJson(map.getMapboxDrawInstance(), mapControlsModelInstance);
- }
- });
+ var $runDisbursement = $("#btnRunDisbursement");
+ $runDisbursement.click(function () {
+ if (mapModel && mapModel.mapInstance) {
+ disbursementRunner.runCaseDisbursementAlgorithm(mapModel.caseMapItems(), mapModel.userMapItems());
+ }
+ });
+ }
- var $exportDrawnArea = $("#btnExportDrawnArea");
- $exportDrawnArea.click(function () {
- if (map) {
- mapControlsModelInstance.exportGeoJson();
- }
- });
+ var userFiltersModel = function () {
+ var self = {};
- var $runDisbursement = $("#btnRunDisbursement");
- $runDisbursement.click(function () {
- if (map) {
- disbursementRunner.runCaseDisbursementAlgorithm(caseModels(), userModels());
- }
- });
- }
+ self.shouldShowUsers = ko.observable(false);
+ self.hasFiltersChanged = ko.observable(false); // Used to disable "Apply" button
+ self.showFilterMenu = ko.observable(true);
+ self.hasErrors = ko.observable(false);
+ self.selectedLocation = null;
+
+ self.loadUsers = function () {
+ mapModel.removeMarkersFromMap(mapModel.userMapItems());
+ mapModel.userMapItems([]);
+ self.hasErrors(false);
+ if (!self.shouldShowUsers()) {
+ self.hasFiltersChanged(false);
+ missingGPSModelInstance.usersWithoutGPS([]);
+ return;
+ }
- var missingGPSModel = function () {
- this.casesWithoutGPS = ko.observable([]);
- this.usersWithoutGPS = ko.observable([]);
- };
- var missingGPSModelInstance = new missingGPSModel();
-
- var userFiltersModel = function () {
- var self = {};
-
- self.shouldShowUsers = ko.observable(false);
- self.hasFiltersChanged = ko.observable(false); // Used to disable "Apply" button
- self.showFilterMenu = ko.observable(true);
- self.hasErrors = ko.observable(false);
- self.selectedLocation = null;
-
- self.loadUsers = function () {
- map.removeMarkersFromMap(userModels());
- userModels([]);
- self.hasErrors(false);
- if (!self.shouldShowUsers()) {
+ $.ajax({
+ method: 'GET',
+ data: {'location_id': self.selectedLocation},
+ url: initialPageData.reverse('get_users_with_gps'),
+ success: function (data) {
self.hasFiltersChanged(false);
- missingGPSModelInstance.usersWithoutGPS([]);
- return;
- }
-
- $.ajax({
- method: 'GET',
- data: {'location_id': self.selectedLocation},
- url: initialPageData.reverse('get_users_with_gps'),
- success: function (data) {
- self.hasFiltersChanged(false);
-
- // TODO: There is a lot of indexing happening here. This should be replaced with a mapping to make reading it more explicit
- const usersWithoutGPS = data.user_data.filter(function (item) {
- return item.gps_point === null || !item.gps_point.length;
- });
- missingGPSModelInstance.usersWithoutGPS(usersWithoutGPS);
- const usersWithGPS = data.user_data.filter(function (item) {
- return item.gps_point !== null && item.gps_point.length;
- });
+ // TODO: There is a lot of indexing happening here. This should be replaced with a mapping to make reading it more explicit
+ const usersWithoutGPS = data.user_data.filter(function (item) {
+ return item.gps_point === null || !item.gps_point.length;
+ });
+ missingGPSModelInstance.usersWithoutGPS(usersWithoutGPS);
- const userData = _.object(_.map(usersWithGPS, function (userData) {
- const gpsData = (userData.gps_point) ? userData.gps_point.split(' ') : [];
- const lat = parseFloat(gpsData[0]);
- const lng = parseFloat(gpsData[1]);
+ const usersWithGPS = data.user_data.filter(function (item) {
+ return item.gps_point !== null && item.gps_point.length;
+ });
- const editUrl = initialPageData.reverse('edit_commcare_user', userData.id);
- const link = `${userData.username}`;
+ const userData = _.object(_.map(usersWithGPS, function (userData) {
+ const gpsData = (userData.gps_point) ? userData.gps_point.split(' ') : [];
+ const lat = parseFloat(gpsData[0]);
+ const lng = parseFloat(gpsData[1]);
- return [userData.id, {'coordinates': {'lat': lat, 'lng': lng}, 'link': link, 'type': 'user'}];
- }));
+ const editUrl = initialPageData.reverse('edit_commcare_user', userData.id);
+ const link = `${userData.username}`;
- const userMapItems = map.addMarkersToMap(userData, userMarkerColors);
- userModels(userMapItems);
- },
- error: function () {
- self.hasErrors(true);
- },
- });
- };
+ return [userData.id, {'coordinates': {'lat': lat, 'lng': lng}, 'link': link, 'type': 'user'}];
+ }));
- self.onLocationFilterChange = function (_, e) {
- self.selectedLocation = $(e.currentTarget).select2('val');
- self.onFiltersChange();
- };
+ const userMapItems = mapModel.addMarkersToMap(userData, userMarkerColors);
+ mapModel.userMapItems(userMapItems);
+ },
+ error: function () {
+ self.hasErrors(true);
+ },
+ });
+ };
- self.onFiltersChange = function () {
- self.hasFiltersChanged(true);
- };
+ self.onLocationFilterChange = function (_, e) {
+ self.selectedLocation = $(e.currentTarget).select2('val');
+ self.onFiltersChange();
+ };
- self.toggleFilterMenu = function () {
- self.showFilterMenu(!self.showFilterMenu());
- const shouldShow = self.showFilterMenu() ? 'show' : 'hide';
- $("#user-filters-panel .panel-body").collapse(shouldShow);
- };
+ self.onFiltersChange = function () {
+ self.hasFiltersChanged(true);
+ };
- return self;
+ self.toggleFilterMenu = function () {
+ self.showFilterMenu(!self.showFilterMenu());
+ const shouldShow = self.showFilterMenu() ? 'show' : 'hide';
+ $("#user-filters-panel .panel-body").collapse(shouldShow);
};
- function initUserFilters() {
- const $userFiltersDiv = $("#user-filters-panel");
- if ($userFiltersDiv.length) {
- const userFiltersInstance = userFiltersModel();
- $userFiltersDiv.koApplyBindings(userFiltersInstance);
- $("#location-filter-select").select2({
- placeholder: gettext('All locations'),
- allowClear: true,
- cache: true,
- ajax: {
- url: initialPageData.reverse('location_search'),
- dataType: 'json',
- processResults: function (data) {
- return {
- results: $.map(data.results, function (item) {
- return {
- text: item.text,
- id: item.id,
- };
- }),
- };
- },
- },
- });
- }
- }
+ return self;
+ };
- function loadCases(caseData) {
- map.removeMarkersFromMap(caseModels());
- caseModels([]);
- var casesWithGPS = caseData.filter(function (item) {
- return item[1] !== null;
+ function initUserFilters() {
+ const $userFiltersDiv = $("#user-filters-panel");
+ if ($userFiltersDiv.length) {
+ const userFiltersInstance = userFiltersModel();
+ $userFiltersDiv.koApplyBindings(userFiltersInstance);
+ $("#location-filter-select").select2({
+ placeholder: gettext('All locations'),
+ allowClear: true,
+ cache: true,
+ ajax: {
+ url: initialPageData.reverse('location_search'),
+ dataType: 'json',
+ processResults: function (data) {
+ return {
+ results: $.map(data.results, function (item) {
+ return {
+ text: item.text,
+ id: item.id,
+ };
+ }),
+ };
+ },
+ },
});
- // Index by case_id
- var casesById = _.object(_.map(casesWithGPS, function (item) {
- if (item[1]) {
- return [item[0], {'coordinates': item[1], 'link': item[2], 'type': 'case'}];
- }
- }));
- const caseMapItems = map.addMarkersToMap(casesById, caseMarkerColors);
- caseModels(caseMapItems);
+ }
+ }
- var $missingCasesDiv = $("#missing-gps-cases");
- var casesWithoutGPS = caseData.filter(function (item) {
- return item[1] === null;
- });
- casesWithoutGPS = _.map(casesWithoutGPS, function (item) {return {"link": item[2]};});
- // Don't re-apply if this is the next page of the pagination
- if (ko.dataFor($missingCasesDiv[0]) === undefined) {
- $missingCasesDiv.koApplyBindings(missingGPSModelInstance);
- missingGPSModelInstance.casesWithoutGPS(casesWithoutGPS);
+ function loadCases(caseData) {
+ mapModel.removeMarkersFromMap(mapModel.caseMapItems());
+ mapModel.caseMapItems([]);
+ var casesWithGPS = caseData.filter(function (item) {
+ return item[1] !== null;
+ });
+ // Index by case_id
+ var casesById = _.object(_.map(casesWithGPS, function (item) {
+ if (item[1]) {
+ return [item[0], {'coordinates': item[1], 'link': item[2], 'type': 'case'}];
}
- missingGPSModelInstance.casesWithoutGPS(casesWithoutGPS);
+ }));
+ const caseMapItems = mapModel.addMarkersToMap(casesById, caseMarkerColors);
+ mapModel.caseMapItems(caseMapItems);
- fitMapBounds(caseMapItems);
+ var $missingCasesDiv = $("#missing-gps-cases");
+ var casesWithoutGPS = caseData.filter(function (item) {
+ return item[1] === null;
+ });
+ casesWithoutGPS = _.map(casesWithoutGPS, function (item) {return {"link": item[2]};});
+ // Don't re-apply if this is the next page of the pagination
+ if (ko.dataFor($missingCasesDiv[0]) === undefined) {
+ $missingCasesDiv.koApplyBindings(missingGPSModelInstance);
+ missingGPSModelInstance.casesWithoutGPS(casesWithoutGPS);
}
+ missingGPSModelInstance.casesWithoutGPS(casesWithoutGPS);
- // @param mapItems - Should be an array of mapItemModel type objects
- function fitMapBounds(mapItems) {
- const mapInstance = map.getMapboxInstance();
- if (!mapItems.length) {
- mapInstance.flyTo({
- zoom: 0,
- center: DEFAULT_CENTER_COORD,
- duration: 500,
- });
- return;
- }
-
- // See https://stackoverflow.com/questions/62939325/scale-mapbox-gl-map-to-fit-set-of-markers
- const firstCoord = mapItems[0].itemData.coordinates;
- const bounds = mapItems.reduce(function (bounds, mapItem) {
- const coord = mapItem.itemData.coordinates;
- if (coord) {
- return bounds.extend(coord);
- }
- }, new mapboxgl.LngLatBounds(firstCoord, firstCoord)); // eslint-disable-line no-undef
+ mapModel.fitMapBounds(caseMapItems);
+ }
- map.getMapboxInstance().fitBounds(bounds, {
- padding: 50, // in pixels
- duration: 500, // in ms
- maxZoom: 10, // 0-23
- });
+ $(document).ajaxComplete(function (event, xhr, settings) {
+ // When mobile workers are loaded from the user filtering menu, ajaxComplete will be called again.
+ // We don't want to reload the map or cases when this happens, so simply return.
+ const isAfterUserLoad = settings.url.includes('geospatial/get_users_with_gps/');
+ if (isAfterUserLoad) {
+ return;
}
- $(document).ajaxComplete(function (event, xhr, settings) {
- // When mobile workers are loaded from the user filtering menu, ajaxComplete will be called again.
- // We don't want to reload the map or cases when this happens, so simply return.
- const isAfterUserLoad = settings.url.includes('geospatial/get_users_with_gps/');
- if (isAfterUserLoad) {
- return;
- }
+ const isAfterReportLoad = settings.url.includes('geospatial/async/case_management_map/');
+ // This indicates clicking Apply button or initial page load
+ if (isAfterReportLoad) {
+ initMap();
+ initPolygonFilters();
+ initUserFilters();
+ // Hide controls until data is displayed
+ showMapControls(false);
+ missingGPSModelInstance = new models.MissingGPSModel();
- const isAfterReportLoad = settings.url.includes('geospatial/async/case_management_map/');
- // This indicates clicking Apply button or initial page load
- if (isAfterReportLoad) {
- map = loadMapBox();
- initMapControls();
- initUserFilters();
- // Hide controls until data is displayed
- showMapControls(false);
- return;
- }
+ disbursementRunner = new disbursementRunnerModel();
+ $("#disbursement-spinner").koApplyBindings(disbursementRunner);
- // This indicates that report data is fetched either after apply or after pagination
- const isAfterDataLoad = settings.url.includes('geospatial/json/case_management_map/');
- if (!isAfterDataLoad) {
- return;
- }
+ return;
+ }
- showMapControls(true);
- // Hide the datatable rows but not the pagination bar
- $('.dataTables_scroll').hide();
+ // This indicates that report data is fetched either after apply or after pagination
+ const isAfterDataLoad = settings.url.includes('geospatial/json/case_management_map/');
+ if (!isAfterDataLoad) {
+ return;
+ }
- if (xhr.responseJSON.aaData.length && map) {
- loadCases(xhr.responseJSON.aaData);
- }
+ showMapControls(true);
+ // Hide the datatable rows but not the pagination bar
+ $('.dataTables_scroll').hide();
- disbursementRunner = new disbursementRunnerModel();
- $("#disbursement-spinner").koApplyBindings(disbursementRunner);
- });
+ if (xhr.responseJSON.aaData.length && mapModel.mapInstance) {
+ loadCases(xhr.responseJSON.aaData);
+ }
});
});
diff --git a/corehq/apps/geospatial/static/geospatial/js/models.js b/corehq/apps/geospatial/static/geospatial/js/models.js
new file mode 100644
index 000000000000..2bbdfe6e7093
--- /dev/null
+++ b/corehq/apps/geospatial/static/geospatial/js/models.js
@@ -0,0 +1,543 @@
+hqDefine('geospatial/js/models', [
+ 'jquery',
+ 'knockout',
+ 'hqwebapp/js/initial_page_data',
+ 'geospatial/js/utils',
+], function (
+ $,
+ ko,
+ initialPageData,
+ utils
+) {
+ const HOVER_DELAY = 400;
+ const DOWNPLAY_OPACITY = 0.2;
+ const FEATURE_QUERY_PARAM = 'features';
+ const DEFAULT_CENTER_COORD = [-20.0, -0.0];
+
+ var MissingGPSModel = function () {
+ this.casesWithoutGPS = ko.observable([]);
+ this.usersWithoutGPS = ko.observable([]);
+ };
+
+ var SavedPolygon = function (polygon) {
+ var self = this;
+ self.text = polygon.name;
+ self.id = polygon.id;
+ self.geoJson = polygon.geo_json;
+ };
+
+ var MapItem = function (itemId, itemData, marker, markerColors) {
+ 'use strict';
+ var self = this;
+ self.itemId = itemId;
+ self.itemData = itemData;
+ self.marker = marker;
+ self.selectCssId = "select" + itemId;
+ self.isSelected = ko.observable(false);
+ self.markerColors = markerColors;
+
+ self.groupId = null;
+ self.groupCoordinates = null;
+
+ self.setMarkerOpacity = function (opacity) {
+ let element = self.marker.getElement();
+ element.style.opacity = opacity;
+ };
+
+ function changeMarkerColor(selectedCase, newColor) {
+ let marker = selectedCase.marker;
+ let element = marker.getElement();
+ let svg = element.getElementsByTagName("svg")[0];
+ let path = svg.getElementsByTagName("path")[0];
+ path.setAttribute("fill", newColor);
+ }
+
+ self.getItemType = function () {
+ if (self.itemData.type === "user") {
+ return gettext("Mobile Worker");
+ }
+ return gettext("Case");
+ };
+
+ self.isSelected.subscribe(function () {
+ var color = self.isSelected() ? self.markerColors.selected : self.markerColors.default;
+ changeMarkerColor(self, color);
+ });
+ };
+
+ var GroupedCaseMapItem = function (itemId, itemData, link) {
+ let self = this;
+ self.itemId = itemId;
+ self.itemData = itemData;
+ self.link = link;
+ self.groupId = null;
+ self.groupCoordinates = null;
+
+ self.toJson = function () {
+ const coordinates = (self.itemData.coordinates) ? `${self.itemData.coordinates.lng} ${self.itemData.coordinates.lat}` : "";
+ const groupCoordinates = (self.groupCoordinates) ? `${self.groupCoordinates.lng} ${self.groupCoordinates.lat}` : "";
+ return {
+ 'groupId': self.groupId,
+ 'groupCenterCoordinates': groupCoordinates,
+ 'caseId': self.caseId,
+ 'coordinates': coordinates,
+ };
+ };
+ };
+
+ var Map = function (usesClusters) {
+ var self = this;
+
+ self.usesClusters = usesClusters;
+
+ self.mapInstance;
+ self.drawControls;
+
+ self.caseMapItems = ko.observableArray([]);
+ self.userMapItems = ko.observableArray([]);
+
+ self.caseGroupsIndex = {};
+
+ self.initMap = function (mapDivId, centerCoordinates) {
+ mapboxgl.accessToken = initialPageData.get('mapbox_access_token'); // eslint-disable-line no-undef
+ if (!centerCoordinates) {
+ centerCoordinates = [-91.874, 42.76]; // should be domain specific
+ }
+
+ self.mapInstance = new mapboxgl.Map({ // eslint-disable-line no-undef
+ container: mapDivId, // container ID
+ style: 'mapbox://styles/mapbox/streets-v12', // style URL
+ center: centerCoordinates, // starting position [lng, lat]
+ zoom: 12,
+ attribution: '© Mapbox ©' +
+ ' OpenStreetMap',
+ });
+
+ self.drawControls = new MapboxDraw({ // eslint-disable-line no-undef
+ // API: https://github.com/mapbox/mapbox-gl-draw/blob/main/docs/API.md
+ displayControlsDefault: false,
+ boxSelect: true, // enables box selection
+ controls: {
+ polygon: true,
+ trash: true,
+ },
+ });
+ self.mapInstance.addControl(self.drawControls);
+ if (self.usesClusters) {
+ createClusterLayers();
+ }
+ };
+
+ function createClusterLayers() {
+ // const mapInstance = self.mapInstance;
+ self.mapInstance.on('load', () => {
+ self.mapInstance.addSource('caseWithGPS', {
+ type: 'geojson',
+ data: {
+ "type": "FeatureCollection",
+ "features": [],
+ },
+ cluster: true,
+ clusterMaxZoom: 14, // Max zoom to cluster points on
+ clusterRadius: 50, // Radius of each cluster when clustering points (defaults to 50)
+ });
+ self.mapInstance.addLayer({
+ id: 'clusters',
+ type: 'circle',
+ source: 'caseWithGPS',
+ filter: ['has', 'point_count'],
+ paint: {
+ 'circle-color': [
+ 'step',
+ ['get', 'point_count'],
+ '#51bbd6',
+ 100,
+ '#f1f075',
+ 750,
+ '#f28cb1',
+ ],
+ 'circle-radius': [
+ 'step',
+ ['get', 'point_count'],
+ 20,
+ 100,
+ 30,
+ 750,
+ 40,
+ ],
+ },
+ });
+ self.mapInstance.addLayer({
+ id: 'cluster-count',
+ type: 'symbol',
+ source: 'caseWithGPS',
+ filter: ['has', 'point_count'],
+ layout: {
+ 'text-field': ['get', 'point_count_abbreviated'],
+ 'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
+ 'text-size': 12,
+ },
+ });
+ self.mapInstance.addLayer({
+ id: 'unclustered-point',
+ type: 'circle',
+ source: 'caseWithGPS',
+ filter: ['!', ['has', 'point_count']],
+ paint: {
+ 'circle-color': 'red',
+ 'circle-radius': 10,
+ 'circle-stroke-width': 1,
+ 'circle-stroke-color': '#fff',
+ },
+ });
+ });
+ }
+
+ self.removeMarkersFromMap = function (itemArr) {
+ _.each(itemArr, function (currItem) {
+ currItem.marker.remove();
+ });
+ };
+
+ self.addMarkersToMap = function (itemArr, markerColours) {
+ let outArr = [];
+ _.forEach(itemArr, function (item, itemId) {
+ const coordinates = item.coordinates;
+ if (coordinates && coordinates.lat && coordinates.lng) {
+ const mapItem = addMarker(itemId, item, markerColours);
+ outArr.push(mapItem);
+ }
+ });
+ return outArr;
+ };
+
+ function addMarker(itemId, itemData, colors) {
+ const coordinates = itemData.coordinates;
+ // Create the marker
+ const marker = new mapboxgl.Marker({ color: colors.default, draggable: false }); // eslint-disable-line no-undef
+ marker.setLngLat(coordinates);
+
+ // Add the marker to the map
+ marker.addTo(self.mapInstance);
+
+ let popupDiv = document.createElement("div");
+ popupDiv.setAttribute("data-bind", "template: 'select-case'");
+
+ let popup = new mapboxgl.Popup({ offset: 25, anchor: "bottom" }) // eslint-disable-line no-undef
+ .setLngLat(coordinates)
+ .setDOMContent(popupDiv);
+
+ marker.setPopup(popup);
+
+ const markerDiv = marker.getElement();
+ // Show popup on hover
+ markerDiv.addEventListener('mouseenter', () => marker.togglePopup());
+ markerDiv.addEventListener('mouseenter', () => highlightMarkerGroup(marker));
+ markerDiv.addEventListener('mouseleave', () => resetMarkersOpacity());
+
+ // Hide popup if mouse leaves marker and popup
+ var addLeaveEvent = function (fromDiv, toDiv) {
+ fromDiv.addEventListener('mouseleave', function () {
+ setTimeout(function () {
+ if (!$(toDiv).is(':hover')) {
+ // mouse left toDiv as well
+ marker.togglePopup();
+ }
+ }, 100);
+ });
+ };
+ addLeaveEvent(markerDiv, popupDiv);
+ addLeaveEvent(popupDiv, markerDiv);
+
+ const mapItemInstance = new MapItem(itemId, itemData, marker, colors);
+ $(popupDiv).koApplyBindings(mapItemInstance);
+
+ return mapItemInstance;
+ }
+
+ function resetMarkersOpacity() {
+ let markers = [];
+ Object.keys(self.caseGroupsIndex).forEach(itemCoordinates => {
+ const mapMarkerItem = self.caseGroupsIndex[itemCoordinates];
+ markers.push(mapMarkerItem.item);
+
+ const lineId = self.getLineFeatureId(mapMarkerItem.item.itemId);
+ if (self.mapInstance.getLayer(lineId)) {
+ self.mapInstance.setPaintProperty(lineId, 'line-opacity', 1);
+ }
+ });
+ changeMarkersOpacity(markers, 1);
+ }
+
+ function highlightMarkerGroup(marker) {
+ const markerCoords = marker.getLngLat();
+ const currentMarkerPosition = markerCoords.lng + " " + markerCoords.lat;
+ const markerItem = self.caseGroupsIndex[currentMarkerPosition];
+
+ if (markerItem) {
+ const groupId = markerItem.groupId;
+
+ let markersToHide = [];
+ Object.keys(self.caseGroupsIndex).forEach(itemCoordinates => {
+ const mapMarkerItem = self.caseGroupsIndex[itemCoordinates];
+
+ if (mapMarkerItem.groupId !== groupId) {
+ markersToHide.push(mapMarkerItem.item);
+ const lineId = self.getLineFeatureId(mapMarkerItem.item.itemId);
+ if (self.mapInstance.getLayer(lineId)) {
+ self.mapInstance.setPaintProperty(lineId, 'line-opacity', DOWNPLAY_OPACITY);
+ }
+ }
+ });
+ changeMarkersOpacity(markersToHide, DOWNPLAY_OPACITY);
+ }
+ }
+
+ function changeMarkersOpacity(markers, opacity) {
+ // It's necessary to delay obscuring the markers since mapbox does not play nice
+ // if we try to do it all at once.
+ setTimeout(function () {
+ markers.forEach(marker => {
+ marker.setMarkerOpacity(opacity);
+ });
+ }, HOVER_DELAY);
+ }
+
+ self.getLineFeatureId = function (itemId) {
+ return "route-" + itemId;
+ };
+
+ self.selectAllMapItems = function (featuresArr) {
+ // See https://github.com/mapbox/mapbox-gl-draw/blob/main/docs/API.md#drawselectionchange
+ if (!featuresArr.length) {
+ return;
+ }
+
+ for (const feature of featuresArr) {
+ if (feature.geometry.type === 'Polygon') {
+ self.selectMapItemsInPolygon(feature, self.caseMapItems());
+ self.selectMapItemsInPolygon(feature, self.userMapItems());
+ }
+ }
+ };
+
+ self.selectMapItemsInPolygon = function (polygonFeature, mapItems) {
+ _.values(mapItems).filter(function (mapItem) {
+ if (mapItem.itemData.coordinates) {
+ mapItem.isSelected(isMapItemInPolygon(polygonFeature, mapItem.itemData.coordinates));
+ }
+ });
+ };
+
+ function isMapItemInPolygon(polygonFeature, coordinates) {
+ // Will be 0 if a user deletes a point from a three-point polygon,
+ // since mapbox will delete the entire polygon. turf.booleanPointInPolygon()
+ // does not expect this, and will raise a 'TypeError' exception.
+ if (!polygonFeature.geometry.coordinates.length) {
+ return false;
+ }
+ const coordinatesArr = [coordinates.lng, coordinates.lat];
+ const point = turf.point(coordinatesArr); // eslint-disable-line no-undef
+ return turf.booleanPointInPolygon(point, polygonFeature.geometry); // eslint-disable-line no-undef
+ }
+
+ self.mapHasPolygons = function () {
+ const drawnFeatures = self.drawControls.getAll().features;
+ if (!drawnFeatures.length) {
+ return false;
+ }
+ return drawnFeatures.some(function (feature) {
+ return feature.geometry.type === "Polygon";
+ });
+ };
+
+ // @param mapItems - Should be an array of mapItemModel type objects
+ self.fitMapBounds = function (mapItems) {
+ if (!mapItems.length) {
+ self.mapInstance.flyTo({
+ zoom: 0,
+ center: DEFAULT_CENTER_COORD,
+ duration: 500,
+ });
+ return;
+ }
+
+ // See https://stackoverflow.com/questions/62939325/scale-mapbox-gl-map-to-fit-set-of-markers
+ const firstCoord = mapItems[0].itemData.coordinates;
+ const bounds = mapItems.reduce(function (bounds, mapItem) {
+ const coord = mapItem.itemData.coordinates;
+ if (coord) {
+ return bounds.extend(coord);
+ }
+ }, new mapboxgl.LngLatBounds(firstCoord, firstCoord)); // eslint-disable-line no-undef
+
+ self.mapInstance.fitBounds(bounds, {
+ padding: 50, // in pixels
+ duration: 500, // in ms
+ maxZoom: 10, // 0-23
+ });
+ };
+ };
+
+ var PolygonFilter = function (mapObj, shouldUpdateQueryParam, shouldSelectAfterFilter) {
+ var self = this;
+
+ self.mapObj = mapObj;
+
+ // TODO: This can be moved to geospatial JS (specific functionality)
+ self.btnRunDisbursementDisabled = ko.computed(function () {
+ return !self.mapObj.caseMapItems().length || !self.mapObj.userMapItems().length;
+ });
+
+ self.shouldUpdateQuryParam = shouldUpdateQueryParam;
+ self.shouldSelectAfterFilter = shouldSelectAfterFilter;
+ self.btnSaveDisabled = ko.observable(true);
+ self.btnExportDisabled = ko.observable(true);
+
+ self.polygons = {};
+ self.shouldRefreshPage = ko.observable(false);
+
+ self.savedPolygons = ko.observableArray([]);
+ self.selectedSavedPolygonId = ko.observable('');
+ self.activeSavedPolygon;
+
+ self.addPolygonsToFilterList = function (featureList) {
+ for (const feature of featureList) {
+ self.polygons[feature.id] = feature;
+ }
+ if (self.shouldUpdateQuryParam) {
+ updatePolygonQueryParam();
+ }
+ };
+
+ self.removePolygonsFromFilterList = function (featureList) {
+ for (const feature of featureList) {
+ if (self.polygons[feature.id]) {
+ delete self.polygons[feature.id];
+ }
+ }
+ if (self.shouldUpdateQuryParam) {
+ updatePolygonQueryParam();
+ }
+ };
+
+ function updatePolygonQueryParam() {
+ const url = new URL(window.location.href);
+ if (Object.keys(self.polygons).length) {
+ url.searchParams.set(FEATURE_QUERY_PARAM, JSON.stringify(self.polygons));
+ } else {
+ url.searchParams.delete(FEATURE_QUERY_PARAM);
+ }
+ window.history.replaceState({ path: url.href }, '', url.href);
+ self.shouldRefreshPage(true);
+ }
+
+ self.loadPolygonFromQueryParam = function () {
+ const url = new URL(window.location.href);
+ const featureParam = url.searchParams.get(FEATURE_QUERY_PARAM);
+ if (featureParam) {
+ const features = JSON.parse(featureParam);
+ for (const featureId in features) {
+ const feature = features[featureId];
+ self.mapObj.drawControls.add(feature);
+ self.polygons[featureId] = feature;
+ }
+ }
+ };
+
+ function removeActivePolygonLayer() {
+ if (self.activeSavedPolygon) {
+ self.mapObj.mapInstance.removeLayer(self.activeSavedPolygon.id);
+ self.mapObj.mapInstance.removeSource(self.activeSavedPolygon.id);
+ }
+ }
+
+ function createActivePolygonLayer(polygonObj) {
+ self.mapObj.mapInstance.addSource(
+ String(polygonObj.id),
+ {'type': 'geojson', 'data': polygonObj.geoJson}
+ );
+ self.mapObj.mapInstance.addLayer({
+ 'id': String(polygonObj.id),
+ 'type': 'fill',
+ 'source': String(polygonObj.id),
+ 'layout': {},
+ 'paint': {
+ 'fill-color': '#0080ff',
+ 'fill-opacity': 0.5,
+ },
+ });
+ }
+
+ self.clearActivePolygon = function () {
+ if (self.activeSavedPolygon) {
+ // self.selectedSavedPolygonId('');
+ self.removePolygonsFromFilterList(self.activeSavedPolygon.geoJson.features);
+ removeActivePolygonLayer();
+ self.activeSavedPolygon = null;
+ self.btnSaveDisabled(false);
+ self.btnExportDisabled(true);
+ }
+ };
+
+ self.selectedSavedPolygonId.subscribe(() => {
+ const selectedId = parseInt(self.selectedSavedPolygonId());
+ const polygonObj = self.savedPolygons().find(
+ function (o) { return o.id === selectedId; }
+ );
+ if (!polygonObj) {
+ return;
+ }
+
+ self.clearActivePolygon();
+
+ removeActivePolygonLayer();
+ createActivePolygonLayer(polygonObj);
+
+ self.activeSavedPolygon = polygonObj;
+ self.addPolygonsToFilterList(polygonObj.geoJson.features);
+ self.btnExportDisabled(false);
+ self.btnSaveDisabled(true);
+ if (self.shouldSelectAfterFilter) {
+ self.mapObj.selectAllMapItems(polygonObj.geoJson.features);
+ }
+ });
+
+ self.loadPolygons = function (polygonArr) {
+ if (self.shouldUpdateQuryParam) {
+ self.loadPolygonFromQueryParam();
+ }
+ self.savedPolygons([]);
+
+ _.each(polygonArr, (polygon) => {
+ // Saved features don't have IDs, so we need to give them to uniquely identify them for polygon filtering
+ for (const feature of polygon.geo_json.features) {
+ feature.id = utils.uuidv4();
+ }
+ self.savedPolygons.push(new SavedPolygon(polygon));
+ });
+ };
+
+ self.exportGeoJson = function (exportButtonId) {
+ const exportButton = $(`#${exportButtonId}`);
+ const selectedId = parseInt(self.selectedSavedPolygonId());
+ const selectedPolygon = self.savedPolygons().find(
+ function (o) { return o.id === selectedId; }
+ );
+ if (selectedPolygon) {
+ const convertedData = 'text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(selectedPolygon.geoJson));
+ exportButton.attr('href', 'data:' + convertedData);
+ exportButton.attr('download','data.geojson');
+ }
+ };
+ };
+
+ return {
+ MissingGPSModel: MissingGPSModel,
+ SavedPolygon: SavedPolygon,
+ MapItem: MapItem,
+ GroupedCaseMapItem: GroupedCaseMapItem,
+ Map: Map,
+ PolygonFilter: PolygonFilter,
+ };
+});
\ No newline at end of file
diff --git a/corehq/apps/geospatial/static/geospatial/js/utils.js b/corehq/apps/geospatial/static/geospatial/js/utils.js
new file mode 100644
index 000000000000..2bd2675ff098
--- /dev/null
+++ b/corehq/apps/geospatial/static/geospatial/js/utils.js
@@ -0,0 +1,30 @@
+hqDefine('geospatial/js/utils', [], function () {
+
+ const DEFAULT_MARKER_OPACITY = 1.0;
+
+ var getRandomRGBColor = function () { // TODO: Ensure generated colors looks different!
+ var r = Math.floor(Math.random() * 256); // Random value between 0 and 255 for red
+ var g = Math.floor(Math.random() * 256); // Random value between 0 and 255 for green
+ var b = Math.floor(Math.random() * 256); // Random value between 0 and 255 for blue
+
+ return `rgba(${r},${g},${b},${DEFAULT_MARKER_OPACITY})`;
+ };
+
+ var uuidv4 = function () {
+ // https://stackoverflow.com/questions/105034/how-do-i-create-a-guid-uuid/2117523#2117523
+ return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
+ (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
+ );
+ };
+
+ var getTodayDate = function () {
+ const todayDate = new Date();
+ return todayDate.toLocaleDateString();
+ };
+
+ return {
+ getRandomRGBColor: getRandomRGBColor,
+ uuidv4: uuidv4,
+ getTodayDate: getTodayDate,
+ };
+});
\ No newline at end of file
diff --git a/corehq/apps/geospatial/templates/base_template.html b/corehq/apps/geospatial/templates/base_template.html
index d1b34a1ab732..2c5e99c10c08 100644
--- a/corehq/apps/geospatial/templates/base_template.html
+++ b/corehq/apps/geospatial/templates/base_template.html
@@ -6,6 +6,8 @@
+
+
{% endblock %}
{% block stylesheets %}
diff --git a/corehq/apps/geospatial/templates/map_visualization.html b/corehq/apps/geospatial/templates/map_visualization.html
index 0997ba5a81d3..46ae2667270a 100644
--- a/corehq/apps/geospatial/templates/map_visualization.html
+++ b/corehq/apps/geospatial/templates/map_visualization.html
@@ -74,7 +74,7 @@