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 @@