Project: 3D visualization of health care facilities in South Africa
This documentation aims to explain the process I took to develop the project. Please go through the documentation keeping in mind that it is meant to explain my process and my approach to developing with Cesium.js and not necessarily a guide based on the best practises for working with Cesium.js.
If you spot any errors in the documentation, please email me : lelonompumelelo@gmail.com to correct them.
Thank you to the Cesium team, for accepting my application and thank you for the taking the time to read this.
Back in 2020, we developed a mapping web application using Google maps with the goal of locating health care facilities across South Africa.
I had fun working on this project and that is the reason I chose to revisit it and work from it to apply my newly acquired Cesium.js skills and knowledge gained so far.
Below I list the inputs or the requirements I gathered before getting started to do the work:
The district map data, the health facilities data along with the population data are from sources that are outdated and might not reflect accurately the current realities in South Africa.
I preferred all the data to be in a json format because I am more comfortable working with this format, so in the next section - I explain the process of converting data that was in json format (including geojson) to become what I needed it to be.
To get the district map shape files, I visited igismap where I easily downloaded the shape file and it worked. See below:
For the data to show on the map, I used Humdata.org to get the population of South Africa by district with a breakdown between female and male. Initially it was in an excel spreadsheet - I used the Mapshaper to popualate the data into the shape file.
Below is a rough wireframe/ sketch - handdrawn to get the idea out of my mind into the real world.
I faced a couple of challenges while working on this project, see them below:
Snippets of the custom .js script file are shown below:
For feature #1 - where the district map overlays on the Cesium globe.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 | < script > // FEATURE #1 : DISTRICT MAP const geoJsonUrl = "./data/topo.json"; let dataSource; async function getCoordinatesFromJSON() { const response = await fetch("./data/data.json"); const data = await response.json(); return data.features .map((feature) => { const coordinates = feature.geometry?.coordinates; if ( coordinates && Array.isArray(coordinates) && coordinates.length === 2 ) { return { lat: coordinates[1], lon: coordinates[0] }; } return null; }) .filter((position) => position !== null); } function addGeoJsonDataSourceWithPopulationColor(url) { return Cesium.GeoJsonDataSource.load(url).then(function (loadedDataSource) { dataSource = loadedDataSource; // Access the entities in the data source const entities = dataSource.entities.values; let minPopulation = Number.MAX_VALUE; let maxPopulation = Number.MIN_VALUE; entities.forEach(function (entity) { const population = entity.properties["TPopulation"].getValue(); if (population < minPopulation ) { minPopulation = population ; } if (population > maxPopulation) { maxPopulation = population; } return dataSource; }); function getPinkColorWithOpacity(opacity) { return new Cesium.Color(1.0, 19 / 255, 20 / 255, opacity); } entities.forEach(function (entity) { const population = entity.properties["TPopulation"].getValue(); const populationFraction = (population - minPopulation) / (maxPopulation - minPopulation); const opacity = populationFraction; const color = getPinkColorWithOpacity(opacity); entity.polygon.material = new Cesium.ColorMaterialProperty(color); }); return dataSource; }); } function removeGeoJsonDataSource() { if (dataSource) { viewer.dataSources.remove(dataSource); } } function showGeoJsonDataSource() { if (dataSource) { viewer.dataSources.add(dataSource); } } addGeoJsonDataSourceWithPopulationColor(geoJsonUrl) .then(function (dataSource) { viewer.dataSources.add(dataSource); dataSource.entities.values.forEach(function (entity) { entity.polygon.minimumPixelSize = 10; }); }) .catch(function (error) { console.error("An error occurred: ", error); }); </ script > |
For adding health facilities into the Cesium globe
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 | < script > // FEATURE # 2: DATA.JSON async function getCoordinatesFromJSON() { const response = await fetch('./data/data.json'); const jsonData = await response.json(); return jsonData.features; } async function createModelAtLocation(position) { const category = position.properties.Category; let iconImage = ''; if (category === "Other Hospital") { iconImage = 'images/other.png'; } else if (category === "District Hospital") { iconImage = 'images/district.png'; } else{ iconImage = 'images/private.png'; } const coordinates = position.geometry.coordinates; if (Array.isArray(coordinates) && coordinates.length === 2 && !isNaN(coordinates[0]) && !isNaN(coordinates[1])) { const entity = viewer.entities.add({ category: "icon", position: Cesium.Cartesian3.fromDegrees(coordinates[0], coordinates[1]), billboard: { image: iconImage, width: 32, height: 32, }, properties: { ...position.properties, }, }); if (entity.category === "icon") { entity.description = ` < h2 >${position.properties.Name}</ h2 > < p >< strong >Category:</ strong > ${position.properties.Category}</ p > < p >< strong >Province:</ strong > ${position.properties.Province}</ p > < p >< strong >District:</ strong > ${position.properties.District}</ p > < p >< strong >Subdistrict:</ strong > ${position.properties.Subdistrict}</ p > < p >< strong >BedsUsable:</ strong > ${position.properties.BedsUsable}</ p > `; const handler = new Cesium.ScreenSpaceEventHandler(viewer.canvas); handler.setInputAction(() => { entity.description.show = !entity.description.show; }, Cesium.ScreenSpaceEventType.LEFT_CLICK); } const dropdown = document.getElementById("dropdown"); dropdown.addEventListener("change", (event) => { const selectedOption = event.target.value; viewer.entities.values.forEach((entity) => { if (entity.category === "icon") { const category = entity.properties.Category; if (selectedOption === "none") { entity.billboard.show = false; if (entity.label) entity.label.show = false; if (entity.description) entity.description.show = false; } else if (selectedOption === "all") { entity.billboard.show = true; if (entity.label) entity.label.show = true; if (entity.description) entity.description.show = true; } else { if (category === selectedOption) { entity.billboard.show = false; if (entity.label) entity.label.show = false; if (entity.description) entity.description.show = false; } else { entity.billboard.show = false; if (entity.label) entity.label.show = false; if (entity.description) entity.description.show = false; } } } }); }); } } getCoordinatesFromJSON().then((positions) => { positions.forEach(createModelAtLocation); }); </ script > |
For using Google's 3D Realistic tiles and flyover effect:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 | < script > // FEATURE #3 : 3D TILES IN CESIUM viewer.scene.globe.show = true; function flyOver() { Cesium.createGooglePhotorealistic3DTileset() .then(function (tileset) { viewer.scene.primitives.add(tileset); }) .catch(function (error) { console.log(`Error loading Photorealistic 3D Tiles tileset. ${error}`); }); var cities = [ { name: "Johannesburg", latitude: -26.26 , longitude: 27.93799}, { name: "Pretoria", latitude: -25.7295, longitude: 28.202561 }, { name: "Durban", latitude:-28.6245, longitude: 31.086834}, { name: "Port Elizabeth", latitude: -32.9959 , longitude: 27.89156 }, { name: "Cape Town", latitude: -33.9408 , longitude: 18.465164 } ]; var flyoverParameters = { currentIndex: 0, duration: 5, }; function flyOverCities() { if (flyoverParameters.currentIndex < cities.length ) { var city = cities [flyoverParameters.currentIndex]; var destination = Cesium .Cartesian3.fromDegrees(city.longitude, city.latitude, 1700); viewer.camera.flyTo({ destination: destination, duration: flyoverParameters.duration, complete: function () { var startTime = new Date().getTime(); var initialHeading = viewer .camera.heading; var rotateInterval = setInterval (function () { var elapsedTime = (new Date().getTime() - startTime) / 1000; if (elapsedTime < flyoverParameters.rotationDuration) { var rotationProgress = elapsedTime / flyoverParameters.rotationDuration; var newHeading = initialHeading + (Cesium.Math.TWO_PI * rotationProgress); viewer.camera.setView({ orientation: { heading: newHeading, pitch: Cesium.Math.toRadians(-30), roll: 0, } }); } else { clearInterval(rotateInterval); flyoverParameters.currentIndex++; flyOverCities(); } }, flyoverParameters.rotateInterval); // Use the specified interval }, }); } } flyOverCities(); } </script> |
I have re-used the basic css that came with the Cesium github repo, a few inline stylings and an animated icon to apply a bit of styling into this web application. See below how:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | < style > @import url(../Build/CesiumUnminified/Widgets/widgets.css); @import url(./Sandcastle/templates/bucket.css); #toolbar { background: rgba(42, 42, 42, 0.8); padding: 4px; border-radius: 4px; } #toolbar input { vertical-align: middle; padding-top: 2px; padding-bottom: 2px; } #toolbar .header { font-weight: bold; } html, body, #cesiumContainer { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; } </ style > |
To version control, I started with github at first but instead of creating a new repository, I nested it under some private personal projects. For submission purposes, I moved it into Google drive but I am planning to create a new public repo once the review process has been completed and this work has been accepted.
The completed project's source code can be found here.
This project used animated icons from Lordicon and static icons from iconfinder, district map was edited using Map shaper, the first draft of this documentation was done using Readme.so editor, the district population data shown on the district map is from Humdata.org , the shapefile of the district map is from igismap
This documentation is from a template by Themeforest under an open source license and they describe more about licenses here: choosealicense.com.