From 1ec89ee1dc4a113bf756d601dd4eeb2ff476baee Mon Sep 17 00:00:00 2001 From: Michael Mauderer Date: Tue, 16 Feb 2021 10:47:57 +0000 Subject: [PATCH] Update geomaps to allow dataframes as input. (#1187) Allow Tables to feed the Geo Maps visualization. Uses the columns longitude, latitude and label. Extracts the data through an Enso preprocessor. --- CHANGELOG.md | 4 + .../visualization/java_script/geoMap.js | 259 ++++++++++-------- 2 files changed, 142 insertions(+), 121 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 154db2ab47..a95cf5b5df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,9 @@ read the notes of the `Enso 2.0.0-alpha.1` release. - [Added the ability to reposition visualisations.][1096] There is now an icon in the visualization action bar that allows dragging the visualization. Once the visualization has been moved, there appears another icon that will reset the position to the original position. +- [Allow Tables to feed the Geo Map visualisation.][1187] Tables that have `latitude`, `longitude` + and optionally `label` columns can now be shown in a Geo Map visualisation where each row is + mapped to a point of the map with the given label. - [Erroneous nodes are highlighted.][1182] The error description appears above the node. The nodes affected by error originating from another node will be highlighted without any description. - [There is now an API to show VCS status for node][1160]. @@ -51,6 +54,7 @@ read the notes of the `Enso 2.0.0-alpha.1` release. [1182]: https://github.com/enso-org/ide/pull/1182 [1160]: https://github.com/enso-org/ide/pull/1160 [1190]: https://github.com/enso-org/ide/pull/1190 +[1187]: https://github.com/enso-org/ide/pull/1187
diff --git a/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/geoMap.js b/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/geoMap.js index 990f2ab0e0..e6b5ec26ce 100644 --- a/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/geoMap.js +++ b/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/geoMap.js @@ -10,8 +10,6 @@ */ const TOKEN = 'pk.eyJ1IjoiZW5zby1vcmciLCJhIjoiY2tmNnh5MXh2MGlyOTJ5cWdubnFxbXo4ZSJ9.3KdAcCiiXJcSM18nwk09-Q' -const GEO_POINT = 'Geo_Point' -const GEO_MAP = 'Geo_Map' const SCATTERPLOT_LAYER = 'Scatterplot_Layer' const DEFAULT_POINT_RADIUS = 150 @@ -36,14 +34,14 @@ const LIGHT_ACCENT_COLOR = [1, 234, 146] // ===================================== function loadScript(url) { - var script = document.createElement('script') + const script = document.createElement('script') script.src = url document.head.appendChild(script) } function loadStyle(url) { - var link = document.createElement('link') + const link = document.createElement('link') link.href = url link.rel = 'stylesheet' @@ -87,28 +85,28 @@ const makeId = makeGenerator() // ============================ /** - * Provides a mapbox & deck.gl-based map visualization for IDE. + * Provides a mapbox & deck.gl-based map visualization. * * > Example creates a map with described properties with a scatter plot overlay: * { - * "type": "Geo_Map", * "latitude": 37.8, * "longitude": -122.45, * "zoom": 15, * "controller": true, - * "showingLabels": true, // Enables presenting labels when hovering over Geo_Point. + * "showingLabels": true, // Enables presenting labels when hovering over a point. * "layers": [{ * "type": "Scatterplot_Layer", * "data": [{ - * "type": "Geo_Point", - * "latitude": -122.45, - * "longitude": 37.8, + * "latitude": 37.8, + * "longitude": -122.45, * "color": [255, 0, 0], * "radius": 100, * "label": "an example label" * }] * }] * } + * + * Can also consume a dataframe that has the columns `latitude`, `longitude` and optionally `label`. */ class GeoMapVisualization extends Visualization { static inputType = 'Any' @@ -158,6 +156,21 @@ class GeoMapVisualization extends Visualization { } onDataReceived(data) { + if (!this.isInit) { + this.setPreprocessor( + 'df -> case df of\n' + + ' Table.Table _ ->\n' + + " columns = df.select ['label', 'latitude', 'longitude'] . columns\n" + + " serialized = columns.map (c -> ['df_' + c.name, c.to_vector])\n" + + ' Json.from_pairs serialized . to_text\n' + + ' _ -> df . to_json . to_text' + ) + this.isInit = true + // We discard this data the first time. We will get another update with + // the correct data that has been transformed by the preprocessor. + return + } + let parsedData = data if (typeof data === 'string') { parsedData = JSON.parse(data) @@ -171,11 +184,9 @@ class GeoMapVisualization extends Visualization { * Update the internal data with the new incoming data. Does not affect anything rendered. */ updateState(data) { - let { latitude, longitude } = this.prepareDataPoints( - data, - this.dataPoints, - this.accentColor - ) + extractDataPoints(data, this.dataPoints, this.accentColor) + + const { latitude, longitude } = this.centerPoint() this.latitude = ok(data.latitude) ? data.latitude : latitude this.longitude = ok(data.longitude) ? data.longitude : longitude @@ -249,128 +260,134 @@ class GeoMapVisualization extends Visualization { }) } - /** - * Prepares data points to be shown on the map. - * - * It checks the type of input data, whether user wants to display single `GEO_POINT`, array of - * those, `SCATTERPLOT_LAYER` or a fully defined `GEO_MAP`, and prepares data field of deck.gl - * layer for given input. - * - * @param preparedDataPoints - List holding data points to push the GeoPoints into. - * @param parsedData - All the parsed data to create points from. - * @param accentColor - accent color of IDE if element doesn't specify one. - */ - prepareDataPoints(parsedData, preparedDataPoints, accentColor) { - let latitude = 0.0 - let longitude = 0.0 - - if (parsedData.type === GEO_POINT) { - this.pushGeoPoint(preparedDataPoints, parsedData, accentColor) - latitude = parsedData.latitude - longitude = parsedData.longitude - } else if (Array.isArray(parsedData) && parsedData.length) { - const computed = this.calculateExtremesAndPushPoints( - parsedData, - preparedDataPoints, - accentColor - ) - latitude = computed.latitude - longitude = computed.longitude - } else { - if ( - parsedData.type === SCATTERPLOT_LAYER && - parsedData.data.length - ) { - const computed = this.calculateExtremesAndPushPoints( - parsedData.data, - preparedDataPoints, - accentColor - ) - latitude = computed.latitude - longitude = computed.longitude - } else if (parsedData.type === GEO_MAP && ok(parsedData.layers)) { - parsedData.layers.forEach((layer) => { - if (layer.type === SCATTERPLOT_LAYER) { - let dataPoints = layer.data || [] - const computed = this.calculateExtremesAndPushPoints( - dataPoints, - preparedDataPoints, - accentColor - ) - latitude = computed.latitude - longitude = computed.longitude - } else { - console.warn( - 'Geo_Map: Currently unsupported deck.gl layer.' - ) - } - }) - } - } - return { latitude, longitude } + centerPoint() { + const { x, y } = calculateCenterPoint(this.dataPoints) + return { latitude: y, longitude: x } } /** - * Helper for prepareDataPoints, pushes `GEO_POINT`'s to the list, and calculates central point. - * @returns {{latitude: number, longitude: number}} - center. + * Sets size of the visualization. + * @param size - new size, list of two numbers containing width and height respectively. */ - calculateExtremesAndPushPoints( - dataPoints, - preparedDataPoints, - accentColor - ) { - let latitudes = [] - let longitudes = [] - dataPoints.forEach((e) => { - if (e.type === GEO_POINT) { - this.pushGeoPoint(preparedDataPoints, e, accentColor) - latitudes.push(e.latitude) - longitudes.push(e.longitude) + setSize(size) { + this.dom.setAttributeNS(null, 'width', size[0]) + this.dom.setAttributeNS(null, 'height', size[1]) + this.mapElem.setAttributeNS( + null, + 'style', + 'width:' + size[0] + 'px;height: ' + size[1] + 'px;' + ) + } +} + +/** + * Extract the visualisation data from a full configuration object. + */ +function extractVisualizationDataFromFullConfig( + parsedData, + preparedDataPoints, + accentColor +) { + if (parsedData.type === SCATTERPLOT_LAYER && parsedData.data.length) { + pushPoints(parsedData.data, preparedDataPoints, accentColor) + } else if (ok(parsedData.layers)) { + parsedData.layers.forEach((layer) => { + if (layer.type === SCATTERPLOT_LAYER) { + let dataPoints = layer.data || [] + pushPoints(dataPoints, preparedDataPoints, accentColor) + } else { + console.warn('Geo_Map: Currently unsupported deck.gl layer.') } }) - let latitude = 0.0 - let longitude = 0.0 - if (latitudes.length && longitudes.length) { - let minLat = Math.min.apply(null, latitudes) - let maxLat = Math.max.apply(null, latitudes) - latitude = (minLat + maxLat) / 2 - let minLon = Math.min.apply(null, longitudes) - let maxLon = Math.max.apply(null, longitudes) - longitude = (minLon + maxLon) / 2 - } - return { latitude, longitude } } +} - /** - * Pushes a new deck.gl-compatible point made out of `GEO_POINT` - * - * @param preparedDataPoints - List holding geoPoints to push the new element into. - * @param geoPoint - `GEO_POINT` to create new deck.gl point from. - * @param accentColor - accent color of IDE if `GEO_POINT` doesn't specify one. - */ - pushGeoPoint(preparedDataPoints, geoPoint, accentColor) { +/** + * Extract the visualisation data from a dataframe. + */ +function extractVisualizationDataFromDataFrame( + parsedData, + preparedDataPoints, + accentColor +) { + const geoPoints = parsedData.df_latitude.map(function (lat, i) { + const lon = parsedData.df_longitude[i] + let label = ok(parsedData.df_label) ? parsedData.df_label[i] : undefined + return { latitude: lat, longitude: lon, label } + }) + pushPoints(geoPoints, preparedDataPoints, accentColor) +} + +function isDataFrame(data) { + return data.df_latitude !== undefined && data.df_longitude !== undefined +} + +/** + * Extracts the data form the given `parsedData`. Checks the type of input data and prepares our + * internal data (`GeoPoints') for consumption in deck.gl. + * + * @param parsedData - All the parsed data to create points from. + * @param preparedDataPoints - List holding data points to push the GeoPoints into. + * @param accentColor - accent color of IDE if element doesn't specify one. + */ +function extractDataPoints(parsedData, preparedDataPoints, accentColor) { + if (isDataFrame(parsedData)) { + extractVisualizationDataFromDataFrame( + parsedData, + preparedDataPoints, + accentColor + ) + } else { + extractVisualizationDataFromFullConfig( + parsedData, + preparedDataPoints, + accentColor + ) + } +} + +/** + * Transforms the `dataPoints` to the internal data format and appends them to the `targetList`. + * Also adds the `accentColor` for each point. + * + * Expects the `dataPoints` to be a list of objects that have a `longitude` and `latitude` and + * optionally `radius`, `color` and `label`. + */ +function pushPoints(dataPoints, targetList, accentColor) { + dataPoints.forEach((geoPoint) => { let position = [geoPoint.longitude, geoPoint.latitude] let radius = isNaN(geoPoint.radius) ? DEFAULT_POINT_RADIUS : geoPoint.radius let color = ok(geoPoint.color) ? geoPoint.color : accentColor let label = ok(geoPoint.label) ? geoPoint.label : '' - preparedDataPoints.push({ position, color, radius, label }) - } + targetList.push({ position, color, radius, label }) + }) +} - /** - * Sets size of the visualization. - * @param size - new size, list of two numbers containing width and height respectively. - */ - setSize(size) { - this.dom.setAttributeNS(null, 'width', size[0]) - this.dom.setAttributeNS(null, 'height', size[1]) - this.mapElem.setAttributeNS( - null, - 'style', - 'width:' + size[0] + 'px;height: ' + size[1] + 'px;' - ) +/** + * Calculate the center of the bounding box of the given list of objects. The objects need to have + * a `position` attribute with two coordinates. + * @returns {{x: number, y: number}} + */ +function calculateCenterPoint(dataPoints) { + const xs = [] + const ys = [] + dataPoints.forEach((e) => { + xs.push(e.position[0]) + ys.push(e.position[1]) + }) + let x = 0.0 + let y = 0.0 + if (xs.length && ys.length) { + let minX = Math.min(...xs) + let maxX = Math.max(...xs) + x = (minX + maxX) / 2 + let minY = Math.min(...ys) + let maxY = Math.max(...ys) + y = (minY + maxY) / 2 } + return { x, y } } /**