I need to group waypoints based on their proximity to each other. We have resources that are assigned appointments (patients). For each patient, we see them 2x per week. I need to calculate the schedule for each day and then the route. The route part is easy with a call to the Bing Maps API. But, I'm struggling with how to generate a schedule.
For instance, if Resource1 sees 8 patients per week and each of them 2x - that's 16 appointments. Let's also assume I will see them on Mon & Wed or Tues & Thur. How would I assign which of the 8 patients should be seen on Mon/Wed & which ones are Tues/Thur. It should be based on their proximity to each other. So, give me a calculation which calculates which 4 should be seen on Mon/Wed & which 4 should be seen on Tues/Thur (assuming it's split 4/4 and not 5/3, etc)
There are a lot of ways to do this.
If you are using Power automate, there is a chance you are using Dynamics. Dynamics has a scheduler capability built in for this specific scenario. https://learn.microsoft.com/en-us/dynamics365/field-service/universal-resource-scheduling-for-field-service
You can cluster that data points initially. There are different methods to do this; distance based, travel time based, k-means clustering, DBscan clustering. distance, k-means, and DBscan are fairly common, but don't take into consideration the physical layout which can have a big impact (person might have to go out of way to get to a location on other side of a river). That said, travel time-based clustering has a much higher cost.
I want to say that the Route itinerary optimizations service would help here, but it would need you to already know which customers you are visiting for the day. https://learn.microsoft.com/en-us/bingmaps/rest-services/routes/optimized-itinerary You might be able to trick it and double the time of a shift, and then split those out afterwards. This assumes you don't care which resource visits which patients.
There are many existing services out there that provide this capability, so also worth comparing the dev/maintenance cost with a license for something already working at scale.
Looking at your scenario it looks like it would be fair to say that you would want half the patients to be visited on Mon/Wed, and the other half on Tues/Thurs. Furthermore, you would want a single resource works 4 days, and would be assigned half their patients per combination of days. With the above in mind, if we cluster the data into groups of no more than 4 patients per cluster, a single cluster would be the schedule for a single resource for a single day combo (e.g. Mon/Wed). Once you have these clusters, you can then slip them in half and assign to your resources. For a fairly accurate approach, I would want to use travel time for the clustering and would tackle this like so:
Take first patient, calculate a travel time matrix to all other patients.
Loop through all other patients and find the 3 closest and consider that an assigned cluster. Optionally assign the day combo/resource.
Take the next unclustered/unassigned patient and calculate a travel time matrix to all other patients.
Loop through all other unclustered/unassigned patients and find the 3 closest. Assign them. When half the patients are assigned, use the second day combo and allow all resources to be assigned a second time for the remaining calculated clusters.
Repeat steps 3 and 4 until all patients are assigned.
A cheaper, but less spatially accurate version of the above (less time efficient), would be to calculate straight line distances rather than travel time. Straight line distances are a simple calculation (Haversine formula).
If you want to go a step further, calculate the distance from the cluster or one of the patients in the cluster to each resource, and assign the resource who is closest (assuming the resources are not all located at the same location).
Here is proof of concept:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title></title>
<style>
table, td, th {
border: 1px solid;
}
table {
width: 100%;
border-collapse: collapse;
}
</style>
</head>
<body>
<input type="button" onclick="schedulePatients()" value="Schedule patients"/>
<br/><br />
<div id="output"></div>
<script>
var resources = {
"type": "FeatureCollection",
"features": [
{ "type": "Feature", "id": 248, "properties": { "assignedMonWed": false, "assignedTuesThurs": false, "MonWebSchedule": [], "TuesThursSchedule": [] }, "geometry": { "type": "Point", "coordinates": [-122.050234696603, 47.6808627484536] } },
{ "type": "Feature", "id": 467, "properties": { "assignedMonWed": false, "assignedTuesThurs": false, "MonWebSchedule": [], "TuesThursSchedule": [] }, "geometry": { "type": "Point", "coordinates": [-122.003917771292, 47.697428787792] } },
{ "type": "Feature", "id": 415, "properties": { "assignedMonWed": false, "assignedTuesThurs": false, "MonWebSchedule": [], "TuesThursSchedule": [] }, "geometry": { "type": "Point", "coordinates": [-122.023083481977, 47.6673715172954] } },
{ "type": "Feature", "id": 564, "properties": { "assignedMonWed": false, "assignedTuesThurs": false, "MonWebSchedule": [], "TuesThursSchedule": [] }, "geometry": { "type": "Point", "coordinates": [-121.985338674036, 47.7022520412864] } },
{ "type": "Feature", "id": 533, "properties": { "assignedMonWed": false, "assignedTuesThurs": false, "MonWebSchedule": [], "TuesThursSchedule": [] }, "geometry": { "type": "Point", "coordinates": [-122.006075987628, 47.7046529915799] } }
]
};
var patients = {
"type": "FeatureCollection",
"features": [
{ "type": "Feature", "id": 1, "properties": {}, "geometry": { "type": "Point", "coordinates": [-121.997913065484, 47.7195839450159] } },
{ "type": "Feature", "id": 2, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.040368485372, 47.6869829619951] } },
{ "type": "Feature", "id": 3, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.048824870578, 47.7013191572943] } },
{ "type": "Feature", "id": 4, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.071031688833, 47.7449665072182] } },
{ "type": "Feature", "id": 5, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.015643772116, 47.7570578745646] } },
{ "type": "Feature", "id": 6, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.034052201107, 47.7451844538795] } },
{ "type": "Feature", "id": 7, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.032132503429, 47.714347009898] } },
{ "type": "Feature", "id": 8, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.04547396506, 47.6893615399414] } },
{ "type": "Feature", "id": 9, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.068392436109, 47.6832920975517] } },
{ "type": "Feature", "id": 10, "properties": {}, "geometry": { "type": "Point", "coordinates": [-121.984992007383, 47.6735499724146] } },
{ "type": "Feature", "id": 11, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.036957923548, 47.7530782996456] } },
{ "type": "Feature", "id": 12, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.066388808463, 47.7044949126107] } },
{ "type": "Feature", "id": 13, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.065430673396, 47.6936482443828] } },
{ "type": "Feature", "id": 14, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.005818300822, 47.6841991471929] } },
{ "type": "Feature", "id": 15, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.013423871487, 47.7267318710591] } },
{ "type": "Feature", "id": 16, "properties": {}, "geometry": { "type": "Point", "coordinates": [-121.991867919251, 47.6730825754312] } },
{ "type": "Feature", "id": 17, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.075035596741, 47.6825165944555] } },
{ "type": "Feature", "id": 18, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.02704005476, 47.7359799244539] } },
{ "type": "Feature", "id": 19, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.069711259786, 47.7582015474685] } },
{ "type": "Feature", "id": 20, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.00363876449, 47.696504598779] } },
{ "type": "Feature", "id": 21, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.027230559197, 47.7463213935986] } },
{ "type": "Feature", "id": 22, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.020278138182, 47.7565298732157] } },
{ "type": "Feature", "id": 23, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.041675234009, 47.7605001277431] } },
{ "type": "Feature", "id": 24, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.059226260036, 47.6842719244522] } },
{ "type": "Feature", "id": 25, "properties": {}, "geometry": { "type": "Point", "coordinates": [-121.99079261183, 47.6745521677151] } },
{ "type": "Feature", "id": 26, "properties": {}, "geometry": { "type": "Point", "coordinates": [-121.998635617356, 47.741838009627] } },
{ "type": "Feature", "id": 27, "properties": {}, "geometry": { "type": "Point", "coordinates": [-121.983315341491, 47.7529779792852] } },
{ "type": "Feature", "id": 28, "properties": {}, "geometry": { "type": "Point", "coordinates": [-121.996663951226, 47.6982171986915] } },
{ "type": "Feature", "id": 29, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.069967005873, 47.7499047491259] } },
{ "type": "Feature", "id": 30, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.032190749586, 47.757600296338] } },
{ "type": "Feature", "id": 31, "properties": {}, "geometry": { "type": "Point", "coordinates": [-121.992400954596, 47.6827035465803] } },
{ "type": "Feature", "id": 32, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.063594482899, 47.7247082989927] } },
{ "type": "Feature", "id": 33, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.075535378044, 47.6772273648534] } },
{ "type": "Feature", "id": 34, "properties": {}, "geometry": { "type": "Point", "coordinates": [-121.994743747063, 47.7387867524387] } },
{ "type": "Feature", "id": 35, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.041914349392, 47.6784018054591] } },
{ "type": "Feature", "id": 36, "properties": {}, "geometry": { "type": "Point", "coordinates": [-121.985731516363, 47.7173223782956] } },
{ "type": "Feature", "id": 37, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.066638385437, 47.7276733135571] } },
{ "type": "Feature", "id": 38, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.004119609955, 47.6871862520471] } },
{ "type": "Feature", "id": 39, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.042819720217, 47.699794194686] } },
{ "type": "Feature", "id": 40, "properties": {}, "geometry": { "type": "Point", "coordinates": [-122.029705426136, 47.72521276633] } }
]
};
//Check to see if there is enough resources for the number of patients (Half the patients assigned per day combo, with a resource having no more than 4 patients per day.
if (Math.ceil(patients.features.length) / 2 / 4 > resources.features.length) {
console.log('Not enough resources to see all patients');
}
function schedulePatients() {
var currentDayCombo = 'MonWeb';
var midPoint = Math.ceil(patients.features.length / 2);
var assigned = 0;
for (var i = 0; i < patients.features.length; i++) {
var p = patients.features[i];
var cluster = getThreeClosest(i);
//Add patient to the cluster.
cluster.push(p);
if (assigned >= midPoint) {
currentDayCombo = 'TuesThurs';
}
//Loop through resources and assign clusters.
var resource = getClosestUnassignedResource(cluster, currentDayCombo);
if (resource === null) {
//No available resource left. Handle this some how. Possibly throw an alert or log to console.
cluster.forEach(c => {
console.log(`No resources left, patient ${c.id} unassigned.`)
});
} else {
assigned += cluster.length;
}
}
//Scheduling complete. Recommend using route optimizate for the patients of each resource to get the most efficient order to visit those patients.
//Creating a table of the schedule.
var html = ['<table><tr><td>Resource ID</td><td>Mon/Wed Patient IDs</td><td>Tues/Thurs Patient IDs</td></tr>'];
resources.features.forEach(r => {
html.push(`<tr><td>${r.id}</td><td>${r.properties.MonWebSchedule.join(',')}</td><td>${r.properties.TuesThursSchedule.join(',') }</td></tr>`);
});
html.push('</table>');
document.getElementById('output').innerHTML = html.join('');
}
function getThreeClosest(patientIdx) {
//Create an array of distances and indices for each patient.
var distances = [];
var p = patients.features[patientIdx];
//Look at remaining unassigned patients and calculate distances.
for (var k = patientIdx + 1; k < patients.features.length; k++) {
var p2 = patients.features[k];
if (!p2.properties.assigned) {
distances.push({
distance: haversine(p.geometry.coordinates, p2.geometry.coordinates),
idx: k //Capture the index of the patient to do a quick lookup later.
});
}
}
//Sort the distances in ascending order.
distances.sort((a, b) => { return a.distance - b.distance });
//Possible there are less than 3 patients remaining.
var grab = Math.min(3, distances.length);
var closest = [];
for (var i = 0; i < grab; i++) {
closest.push(patients.features[distances[i].idx]);
}
return closest;
}
function getClosestUnassignedResource(cluster, dayCombo) {
//Calculate the average position of the cluster.
var sumLon = cluster.reduce((acc, curr) => acc + curr.geometry.coordinates[0], 0);
var sumLat = cluster.reduce((acc, curr) => acc + curr.geometry.coordinates[1], 0);
var origin = [sumLon / cluster.length, sumLat / cluster.length];
var currentDayProp = (dayCombo === 'MonWeb') ? 'assignedMonWed' : 'assignedTuesThurs';
//Create an array of distances and indices for each resource.
var closest = null;
var minDist = Infinity;
//Loop through resources and assign clusters.
for (var j = 0; j < resources.features.length; j++) {
var r = resources.features[j];
//Check that resource is not assigned.
if (!r.properties[currentDayProp]) {
var d = haversine(origin, r.geometry.coordinates);
if (d < minDist) {
minDist = d;
closest = r;
}
}
}
//Assign resource.
if (closest) {
closest.properties[currentDayProp] = true;
cluster.forEach(c => {
c.properties.dayCombo = dayCombo;
c.properties.resource = closest.id;
closest.properties[dayCombo + 'Schedule'].push(c.id);
});
}
return closest;
}
function haversine(p1, p2) {
var lat1 = p1[1] / 180.0 * Math.PI;
var lon1 = p1[0] / 180.0 * Math.PI;
var lat2 = p2[1] / 180.0 * Math.PI;
var lon2 = p2[0] / 180.0 * Math.PI;
var R = 6372.8; // km
var dLat = lat2 - lat1;
var dLon = lon2 - lon1;
var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2);
var c = 2 * Math.asin(Math.sqrt(a));
return R * c;
}
</script>
</body>
</html>
I plot lines correctly on a Mapbox iOS map using a geojson files I have created.
I read the geojson file and associate it to a shape like so:
shapeFromGeoJSON = try? MGLShape(data: jsonData, encoding: String.Encoding.utf8.rawValue)
Then I create a linesSource and a linesLayer and then I show the linesLayer on the map correctly:
linesSource = MGLShapeSource(identifier: "polyline", shape: shapeFromGeoJSON, options: nil)
linesLayer = MGLLineStyleLayer(identifier: "polyline", source: linesSource)
mapView.style?.addSource(linesSource)
mapView.style?.addLayer(linesLayer)
Inside the geojson file, each line is represented by a "geometry" with multiple "coordinates" of type "LineString".
I need to do some special processing that involves knowing all the coordinates of the points making the lines.
Question: how can I get all the multiple point coordinates of each LineString from linesSource?
Here's my geojson file for testing:
{
"features":
{
"type": "Feature",
"properties": {},
"geometry": {
"coordinates": [
-9.316333,
38.680709
],
"type": "Point"
},
"id": "1234"
},
{
"type": "Feature",
"properties": {},
"geometry": {
"coordinates": [
[
-9.31687,
38.680772
],
[
-9.317531,
38.679794
],
[
-9.318001,
38.679196
],
[
-9.318436,
38.678612
],
[
-9.318592,
38.678354
]
],
"type": "LineString"
},
"id": "567"
},
{
"type": "Feature",
"properties": {},
"geometry": {
"coordinates": [
-9.316057,
38.680838
],
"type": "Point"
},
"id": "89"
},
{
"type": "Feature",
"properties": {},
"geometry": {
"coordinates": [
-9.316719,
38.680715
],
"type": "Point"
},
"id": "1011"
},
{
"type": "Feature",
"properties": {},
"geometry": {
"coordinates": [
-9.315931,
38.681066
],
"type": "Point"
},
"id": "1213"
}
],
"type": "FeatureCollection"
}
From my experiences with MapBox, not everything is easily user accessible. It might just be easiest to make a simple struct, decode the data into that, and access the coordinates from there.
I have a bus network with 2 GeoJSONs : one for stations (points) and the other for lines between them.
I have 3 selects :first one to select the bus line and filter the stations on the other two that are for origin and destination stations.
What I want to do is to dynamically filter the bus lines on the first select and the bus stations on the last two selects.
Something like this :
User selects L1 on first select > Map only shows Line 1 (both geojsons, points and lines) and hide the other Lines that were showing.
User selects Station x on select 2 and Station y on select 3 > Map only shows these stations and the ones in between (both geojsons, points and lines).
Stations GeoJSON (not complete) :
var station ={
"type": "FeatureCollection",
"name": "test",
"crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } },
"features": [
{ "type": "Feature", "properties": { "id": 0, "nom": "JAMAA EL FNA", "ligne": "L1", "ville": "MARRAKECH", "direction": "A" }, "geometry": { "type": "Point", "coordinates": [ -7.991506070410076, 31.624380871588261 ] } },
{ "type": "Feature", "properties": { "id": 1, "nom": "KOUTOUBIA", "ligne": "L1", "ville": "MARRAKECH", "direction": "A" }, "geometry": { "type": "Point", "coordinates": [ -7.993921192850516, 31.62551706188404 ] } },
{ "type": "Feature", "properties": { "id": 2, "nom": "HOTE DE VILLE", "ligne": "L1", "ville": "MARRAKECH", "direction": "A" }, "geometry": { "type": "Point", "coordinates": [ -7.997800602958748, 31.627492814514493 ] } },
{ "type": "Feature", "properties": { "id": 3, "nom": "R.P BERDII", "ligne": "L1", "ville": "MARRAKECH", "direction": "A" }, "geometry": { "type": "Point", "coordinates": [ -8.003733858117105, 31.630010280990067 ] } },
{ "type": "Feature", "properties": { "id": 4, "nom": "GRAND POSTE", "ligne": "L1", "ville": "MARRAKECH", "direction": "A" }, "geometry": { "type": "Point", "coordinates": [ -8.009040991276375, 31.633004916540266 ] } },
{ "type": "Feature", "properties": { "id": 5, "nom": "CAREE EDEN", "ligne": "L1", "ville": "MARRAKECH", "direction": "A" }, "geometry": { "type": "Point", "coordinates": [ -8.011327830139466, 31.634311225216251 ] } },....
Lines GeoJSON (not complete) :
var lignemarrakech = {
"type": "FeatureCollection",
"name": "ligne",
"crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } },
"features": [
{ "type": "Feature", "properties": { "id": 0, "ligne_bus": "L1", "direction": "A", "from_st": null, "to_st": null, "ville": "MARRAKECH" }, "geometry": { "type": "LineString", "coordinates": [ [ -7.991506070410076, 31.624380871588261 ], [ -7.99177772113954, 31.62455510452893 ], [ -7.992132146977649, 31.624680377597475 ], [ -7.992359514496435, 31.624737319845583 ], [ -7.992653754814866, 31.624862592668791 ], [ -7.993295733691442, 31.625147303003317 ], [ -7.993583286729908, 31.625306740410121 ], [ -7.993921192850516, 31.62551706188404 ] ] } },
{ "type": "Feature", "properties": { "id": 1, "ligne_bus": "L1", "direction": "A", "from_st": null, "to_st": null, "ville": "MARRAKECH" }, "geometry": { "type": "LineString", "coordinates": [ [ -7.993929771507931, 31.625520494421043 ], [ -7.993921192850516, 31.62551706188404 ], [ -7.994251765813826, 31.625734485234361 ], [ -7.994613027718003, 31.625968537122866 ], [ -7.994699416434219, 31.626022034614714 ], [ -7.995311990967388, 31.626396516196305 ], [ -7.995618278233972, 31.626550320694935 ], [ -7.995971686618494, 31.626730873477591 ], [ -7.99655284707304, 31.627018419778416 ], [ -7.997188982165176, 31.627279216817595 ], [ -7.997597365187289, 31.627433019856959 ], [ -7.997800602958748, 31.627492814514493 ] ] } },
{ "type": "Feature", "properties": { "id": 2, "ligne_bus": "L1", "direction": "A", "from_st": null, "to_st": null, "ville": "MARRAKECH" }, "geometry": { "type": "LineString", "coordinates": [ [ -7.997800602958748, 31.627492814514493 ], [ -7.998131404523898, 31.627586822642048 ], [ -7.998704711458787, 31.62774731223368 ], [ -7.999246604315053, 31.627847618087841 ], [ -7.999898446446502, 31.627981359058502 ], [ -8.000487460420704, 31.628188657183067 ], [ -8.001194277189745, 31.628529695028082 ], [ -8.001987482675004, 31.628984410209988 ], [ -8.00258435016886, 31.629345505977 ], [ -8.002796395199573, 31.629499305600273 ], [ -8.002835662797853, 31.629606296492579 ], [ -8.002859223356822, 31.629659791892589 ], [ -8.002906344474757, 31.629686539581062 ], [ -8.002984879671317, 31.629726661099347 ], [ -8.00307912190719, 31.629733348017368 ], [ -8.003181217662718, 31.629726661099347 ], [ -8.003401116213087, 31.629820277908031 ], [ -8.003733858117105, 31.630010280990067 ] ] } },...
This is what I could do for the first select :
$('#selectLine').on('change', function() {
console.log("chosen_line= " + this.value);
chosen_line = this.value;
filterLines();
if (chosen_line= 'L1'){
L.geoJSON(lignemarrakech, {
filter: function(feature, layer) {
return feature.properties.ligne='L1';
}
}).addTo(mymap);
}
else{
}
});
Beside the fact that it's not generalised, it doesn't work as intended (show only L1 when L1 is selected), it just display all the lines again on top.
if (chosen_line= 'L1')
should be
if (chosen_line == 'L1')