I have been reading Marcos Vainer Loeff over on PISquare about his google maps symbol in Coresight. This great article inspired me to write nearly the same but then with the open source libraries OpenStreetMap and Openlayers.
Since OSIsoft released the 2016 version of Coresight, I am a big fan. I can see where they are going, and I like it. For a developer like me, it is great to be able to contribute to an existing platform while the heavy lifting has already been done.
So this is about the Coresight Extensibility framework. As always you start out with the basic skeleton (all files go into the ext folder, see the Coresight Extensibility docs for details):
(function (CS) { var definition = { typeName: 'magionopenlayers', datasourceBehavior: CS.DatasourceBehaviors.Multiple, getDefaultConfig: function () { return { DataShape: 'Table', }; }, configOptions: function () { return [{ title: 'Format Symbol', mode: 'format' }]; }, init: init }; CS.symbolCatalog.register(definition); })(window.Coresight);
Big decisions right away: the Table DataShape means we will be dropping AF Elements on the display, and the Multiple behavior indicates that it is ok to drop more than one element on the symbol.
Here’s how the symbol will look once configured and connected to AF elements:
The idea is that the radius of the circle corresponds to the value of the attribute. The centre of the circle is the elements current position. These circles are on the Vector layer of our map. That layer is displayed on top of the OpenStreetMap layer. All this is defined in a function called startOpenlayers:
// create the map, OpenStreetMap uses EPSG:3857 // two layers: 1 is OSM, the other is a vector with AF attributes scope.startOpenlayers = function () { if (scope.map === undefined) { scope.vectorSource = new ol.source.Vector({ features: [] }); scope.vectorLayer = new ol.layer.Vector({ source: scope.vectorSource }); scope.map = new ol.Map({ target:elem.find('#'+scope.id)[0], layers: [ new ol.layer.Tile({source: new ol.source.OSM()}), scope.vectorLayer ] , view: new ol.View({ center: ol.proj.fromLonLat([0, 0]), zoom: scope.config.ZoomLevel }) }); scope.map.on('moveend', function() { // if the user zoomed the map, store the changed config, // the user will have to save the display scope.config.ZoomLevel = scope.map.getView().getZoom(); }); } };
As you can see in the code the ZoomLevel is part of the config, it is stored with the display. Other configurable things are:
getDefaultConfig: function () { return { DataShape: 'Table', Height: 300, Width: 400, MarkerColor: '#ff0000', Transparency: 0.3, StrokeColor: '#ff0000', StrokeWidth: 3, Lat: "Lat", Lon: "Lon", Value: "Value", ZoomLevel: 12, Scaling: 1000, }; },
Lat, Lon and Value are the names of the AF attributes. Scaling is a number with which each actual value is multiplied. Use this to create the right information density on your map. If you need finer control, you have to fix it in the AF element. The other attributes define the appearance of the circle.
In order to have direct feedback when the user uses the config panel, you have to implement the configChange function, which is called updateOpenlayersConfig in this symbol:
// function that applies the configurastion changes directly scope.updateOpenlayersConfig = function (config) { var color = scope.makeTransparent(config.MarkerColor); scope.makeStyle(color); // we have to redraw our features with this style scope.vectorSource.forEachFeature(function(f){ f.setStyle(scope.style); if(config.Scaling!=oldScaling){ var geom = f.getGeometry(); geom.setRadius(geom.getRadius()*config.Scaling/oldScaling); } }); oldScaling = config.Scaling; // apply the zoom level scope.map.getView().setZoom(config.ZoomLevel); };
This function is inside the init function of the symbol. The most important part of that function is the dataUpdate function. This functions handles how the symbol updates itself when it receives new data from the Coresight runtime. This happens every 5 seconds and is not configurable per symbol, only for all of Coresight, which is a pity and I think this should be fixed in the final product.
Remember that the symbol accepts multiple table-ly things to be dropped upon it. This is reflected in the data that the symbol receives: A table with rows for each attribute of each element. Only the first time Coresight will indicate which row belongs to which attribute. That is where the developer should store the index of the attribute, so that on the next update (without the Label property) the symbol still knows what is what. The Label property is an AF path string Element|Attribute. If you need more information there is also the Path property which will give you af:\\Server\Database\Path\To\Element|Attribute. In subsequent updates only Value and Time will be in the row.
scope.dataUpdate = function (data) { if ((data == null) || (data.Rows.length == 0)) { return; } if (scope.map != undefined) { // this is where we draw the features for (var i = 0; i < data.Rows.length; i++) { // do we have a label? if(data.Rows[i].Label!==undefined){ var splits = data.Rows[i].Label.split('|'); // splits[0] will be the element if ((splits[1] == scope.config.Lat) || (splits[1] == scope.config.Lon)) { // we have coordinates, so we can add or update our Features if (scope.Features[splits[0]] == undefined) { scope.Features[splits[0]] = new Object(); scope.Features[splits[0]].MarkerCreated = false; scope.Features[splits[0]].LatIndex = null; scope.Features[splits[0]].LonIndex = null; scope.Features[splits[0]].ValueIndex = null; } } // we have to memorize the indices per element if (splits[1] == scope.config.Lat) { scope.Features[splits[0]].LatIndex = i; } if (splits[1] == scope.config.Lon) { scope.Features[splits[0]].LonIndex = i; } if (splits[1] == scope.config.Value) { scope.Features[splits[0]].ValueIndex = i; } } }
So now all rows have been parsed and the feature placeholders have been created. Now it’s time to insert the values from AF and draw them with Openlayers. We set the element name as an Id on the feature so that the next update can find the feature using that key. The configured style is applied to the feature and it is added to the layer.
// Features are in. Let's draw them for (var key in scope.Features) { var currentElement = scope.Features[key]; // the parseFloat is required to get the coordinates right var lon = parseFloat(data.Rows[currentElement.LonIndex].Value); var lat = parseFloat(data.Rows[currentElement.LatIndex].Value); var val = parseFloat(data.Rows[currentElement.ValueIndex].Value); if ((currentElement.MarkerCreated == false)) { if ((currentElement.LatIndex != null) && (currentElement.LonIndex != null)) { // fromLonLat transforms to EPSG:3857 by default // (Web or Spherical Mercator, as used for example by Bing Maps or OpenStreetMap) var coordinate = ol.proj.fromLonLat([lon, lat]); var iconFeature = new ol.Feature({ geometry: new ol.geom.Circle(coordinate, val * scope.config.Scaling), }); iconFeature.setId(key); // this assumes one feature per element iconFeature.setStyle(scope.style); scope.vectorSource.addFeature(iconFeature); currentElement.MarkerCreated = true; // center the map on the latest feature scope.map.getView().setCenter(coordinate); } }else{
Next update cycles use the key to get the feature and update radius and centre as the values of the underlying AF element change over time.
// update position and value var feature = scope.vectorSource.getFeatureById(key); if(feature !== undefined){ var geom = feature.getGeometry(); geom.setCenter(ol.proj.fromLonLat([lon, lat])); geom.setRadius( val * scope.config.Scaling); } } } }
The complete project can be found on our downloads page.