From 5a6d920ffe4f22b492c4787f2779c00f928bdeca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Miko=C5=82ajek?= Date: Thu, 26 Nov 2020 14:49:30 +0100 Subject: [PATCH] Histogram visualization (https://github.com/enso-org/ide/pull/811) Original commit: https://github.com/enso-org/ide/commit/51d9d5a749a46b2b5f2a4257fead51d81c7183ab --- gui/.github/workflows/check.yml | 12 +- gui/.prettierignore | 2 + gui/.prettierrc.toml | 4 + gui/docs/CONTRIBUTING.md | 7 +- gui/src/js/package.js | 30 +- .../src/builtin/visualization/java_script.rs | 17 +- .../java_script/bubbleVisualization.js | 52 +- .../visualization/java_script/geoMap.js | 481 +++++----- .../visualization/java_script/histogram.js | 714 +++++++++++++++ .../visualization/java_script/loading.js | 24 + .../visualization/java_script/mapView.js | 0 .../visualization/java_script/scatterPlot.js | 822 +++++++++++------- .../visualization/java_script/tableView.js | 226 ++--- .../foreign/java_script/visualization.js | 4 +- .../src/component/visualization/registry.rs | 1 + 15 files changed, 1697 insertions(+), 699 deletions(-) create mode 100644 gui/.prettierignore create mode 100644 gui/.prettierrc.toml create mode 100644 gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/histogram.js create mode 100644 gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/loading.js create mode 100644 gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/mapView.js diff --git a/gui/.github/workflows/check.yml b/gui/.github/workflows/check.yml index 281329b17f1..31a6894caa1 100644 --- a/gui/.github/workflows/check.yml +++ b/gui/.github/workflows/check.yml @@ -57,14 +57,20 @@ jobs: toolchain: nightly-2019-11-04 override: true - - name: Install Clippy - run: rustup component add clippy - - name: Install Node uses: actions/setup-node@v1 with: node-version: '14.15.0' + - name: Install prettier + run: npm install --save-dev --save-exact prettier + + - name: Lint JS + run: npx prettier --check "src/**/*.js" + + - name: Install Clippy + run: rustup component add clippy + - name: Building Rust Sources run: node ./run lint --skip-version-validation diff --git a/gui/.prettierignore b/gui/.prettierignore new file mode 100644 index 00000000000..df3083e8bcd --- /dev/null +++ b/gui/.prettierignore @@ -0,0 +1,2 @@ +**/lib/* +**/target/* diff --git a/gui/.prettierrc.toml b/gui/.prettierrc.toml new file mode 100644 index 00000000000..327d72be66e --- /dev/null +++ b/gui/.prettierrc.toml @@ -0,0 +1,4 @@ +trailingComma = "es5" +tabWidth = 4 +semi = false +singleQuote = true diff --git a/gui/docs/CONTRIBUTING.md b/gui/docs/CONTRIBUTING.md index 487bb32bb85..10409af145a 100644 --- a/gui/docs/CONTRIBUTING.md +++ b/gui/docs/CONTRIBUTING.md @@ -97,13 +97,18 @@ need the following setup:
## Working with sources -Please note that you should not use a code auto-formatter in this codebase. +Please note that you should not use `cargo fmt` on this code base. Please read the following documents to learn more about reasons behind this decision and the recommended code style guide. Be sure to carefully read the [Rust style guide 1](./contributing/style-guide.md) and the [Rust style guide 2](https://dev.enso.org/docs/style-guide/rust.html) before contributing to the codebase. +We do, however, use [`prettier`](https://prettier.io/) for the JavaScript files in our code base. +If you have not installed it already you can do so via `npm install prettier`. To use it manually +via command line run `prettier --write` to all JavaScript files in the project. Alternatively, +there are plugins for many IDEs available to do this for you. + ### Setting up Engine Services IDE requires a service named Project Manager to be running in the background on the local machine. The service, being part of the Enso Engine, can be diff --git a/gui/src/js/package.js b/gui/src/js/package.js index cd3220b4758..b0481efb124 100644 --- a/gui/src/js/package.js +++ b/gui/src/js/package.js @@ -1,24 +1,24 @@ let config = { - name: "root", - version: "2.0.0-alpha.0", + name: 'root', + version: '2.0.0-alpha.0', private: true, devDependencies: { - "lerna": "^3.20.2", - "webpack": "^4.42.1", - "webpack-cli": "^3.3.11", - "webpack-dev-server": "^3.1.5", + lerna: '^3.20.2', + webpack: '^4.42.1', + 'webpack-cli': '^3.3.11', + 'webpack-dev-server': '^3.1.5', }, scripts: { // IMPORTANT! Please remember to run "bootstrap" before "build" or "watch" to install // or update the required dependencies. - "bootstrap": "npx lerna bootstrap", - "build": "npx lerna run build --stream", - "clean": "npx lerna clean -y && rm -Rf node_modules", - "dist": "npx lerna run build --stream && npx lerna run dist --stream", - "install": "npm install lerna && npx lerna bootstrap", - "start": "npm run build && npx lerna run start --stream -- -- ", - "watch": "npx lerna run watch --stream" - } + bootstrap: 'npx lerna bootstrap', + build: 'npx lerna run build --stream', + clean: 'npx lerna clean -y && rm -Rf node_modules', + dist: 'npx lerna run build --stream && npx lerna run dist --stream', + install: 'npm install lerna && npx lerna bootstrap', + start: 'npm run build && npx lerna run start --stream -- -- ', + watch: 'npx lerna run watch --stream', + }, } -module.exports = {config} +module.exports = { config } diff --git a/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script.rs b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script.rs index 345c8db1ae0..dbe771c9826 100644 --- a/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script.rs +++ b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script.rs @@ -20,14 +20,27 @@ pub fn table_view_visualization() -> visualization::java_script::FallibleDefinit /// Return a `JavaScript` Scatter plot visualization. pub fn scatter_plot_visualization() -> visualization::java_script::FallibleDefinition { - let source = include_str!("java_script/scatterPlot.js"); + let loading_scripts = include_str!("java_script/loading.js"); + let source = include_str!("java_script/scatterPlot.js"); + let source = format!("{}{}",loading_scripts,source); + + visualization::java_script::Definition::new(data::builtin_library(),source) +} + +/// Return a `JavaScript` Histogram visualization. +pub fn histogram_visualization() -> visualization::java_script::FallibleDefinition { + let loading_scripts = include_str!("java_script/loading.js"); + let source = include_str!("java_script/histogram.js"); + let source = format!("{}{}",loading_scripts,source); visualization::java_script::Definition::new(data::builtin_library(),source) } /// Return a `JavaScript` Map visualization. pub fn map_view_visualization() -> visualization::java_script::FallibleDefinition { - let source = include_str!("java_script/geoMap.js"); + let loading_scripts = include_str!("java_script/loading.js"); + let source = include_str!("java_script/geoMap.js"); + let source = format!("{}{}",loading_scripts,source); visualization::java_script::Definition::new(data::builtin_library(),source) } diff --git a/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/bubbleVisualization.js b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/bubbleVisualization.js index 4d3dda3b4cd..8f69309db83 100644 --- a/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/bubbleVisualization.js +++ b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/bubbleVisualization.js @@ -1,38 +1,42 @@ class BubbleVisualization extends Visualization { - static inputType = "Any" + static inputType = 'Any' onDataReceived(data) { - const xmlns = "http://www.w3.org/2000/svg"; + const xmlns = 'http://www.w3.org/2000/svg' while (this.dom.firstChild) { - this.dom.removeChild(this.dom.lastChild); + this.dom.removeChild(this.dom.lastChild) } - const width = this.dom.getAttributeNS(null, "width"); - const height = this.dom.getAttributeNS(null, "height"); + const width = this.dom.getAttributeNS(null, 'width') + const height = this.dom.getAttributeNS(null, 'height') - const svgElem = document.createElementNS(xmlns, "svg"); - svgElem.setAttributeNS(null, "class" , "vis-svg"); - svgElem.setAttributeNS(null, "viewBox" , 0 + " " + 0 + " " + width + " " + height); - svgElem.setAttributeNS(null, "width" , "100%"); - svgElem.setAttributeNS(null, "height" , "100%"); - svgElem.setAttributeNS(null, "transform", "matrix(1 0 0 -1 0 0)"); + const svgElem = document.createElementNS(xmlns, 'svg') + svgElem.setAttributeNS(null, 'class', 'vis-svg') + svgElem.setAttributeNS( + null, + 'viewBox', + 0 + ' ' + 0 + ' ' + width + ' ' + height + ) + svgElem.setAttributeNS(null, 'width', '100%') + svgElem.setAttributeNS(null, 'height', '100%') + svgElem.setAttributeNS(null, 'transform', 'matrix(1 0 0 -1 0 0)') - this.dom.appendChild(svgElem); + this.dom.appendChild(svgElem) - data.forEach(data => { - const bubble = document.createElementNS(xmlns,"circle"); - bubble.setAttributeNS(null,"stroke", "black"); - bubble.setAttributeNS(null,"fill" , "red"); - bubble.setAttributeNS(null,"r" , data[2] || 0); - bubble.setAttributeNS(null,"cx" , data[0] || 0); - bubble.setAttributeNS(null,"cy" , data[1] || 0); - svgElem.appendChild(bubble); - }); + data.forEach((data) => { + const bubble = document.createElementNS(xmlns, 'circle') + bubble.setAttributeNS(null, 'stroke', 'black') + bubble.setAttributeNS(null, 'fill', 'red') + bubble.setAttributeNS(null, 'r', data[2] || 0) + bubble.setAttributeNS(null, 'cx', data[0] || 0) + bubble.setAttributeNS(null, 'cy', data[1] || 0) + svgElem.appendChild(bubble) + }) } setSize(size) { - this.dom.setAttributeNS(null, "width", size[0]); - this.dom.setAttributeNS(null, "height", size[1]); + this.dom.setAttributeNS(null, 'width', size[0]) + this.dom.setAttributeNS(null, 'height', size[1]) } } -return BubbleVisualization; +return BubbleVisualization diff --git a/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/geoMap.js b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/geoMap.js index 8e04e5bf5ee..29953ffff0f 100644 --- a/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/geoMap.js +++ b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/geoMap.js @@ -9,45 +9,45 @@ * All the limits of API are listed here: https://docs.mapbox.com/api/#rate-limits */ 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; + 'pk.eyJ1IjoiZW5zby1vcmciLCJhIjoiY2tmNnh5MXh2MGlyOTJ5cWdubnFxbXo4ZSJ9.3KdAcCiiXJcSM18nwk09-Q' +const GEO_POINT = 'Geo_Point' +const GEO_MAP = 'Geo_Map' +const SCATTERPLOT_LAYER = 'Scatterplot_Layer' +const DEFAULT_POINT_RADIUS = 150 -const DEFAULT_MAP_ZOOM = 11; -const DARK_ACCENT_COLOR = [222, 162, 47]; -const LIGHT_ACCENT_COLOR = [1, 234, 146]; +const DEFAULT_MAP_ZOOM = 11 +const DARK_ACCENT_COLOR = [222, 162, 47] +const LIGHT_ACCENT_COLOR = [1, 234, 146] // ===================================== // === Script & Style Initialisation === // ===================================== function loadScript(url) { - var script = document.createElement("script"); - script.src = url; + var script = document.createElement('script') + script.src = url - document.head.appendChild(script); + document.head.appendChild(script) } function loadStyle(url) { - var link = document.createElement("link"); - link.href = url; - link.rel = "stylesheet"; + var link = document.createElement('link') + link.href = url + link.rel = 'stylesheet' - document.head.appendChild(link); + document.head.appendChild(link) } -loadScript("https://unpkg.com/deck.gl@latest/dist.min.js"); -loadScript("https://api.tiles.mapbox.com/mapbox-gl-js/v1.6.1/mapbox-gl.js"); -loadStyle("https://api.tiles.mapbox.com/mapbox-gl-js/v1.6.1/mapbox-gl.css"); +loadScript('https://unpkg.com/deck.gl@latest/dist.min.js') +loadScript('https://api.tiles.mapbox.com/mapbox-gl-js/v1.6.1/mapbox-gl.js') +loadStyle('https://api.tiles.mapbox.com/mapbox-gl-js/v1.6.1/mapbox-gl.css') -const styleHead = document.createElement("style"); +const styleHead = document.createElement('style') styleHead.innerText = ` .mapboxgl-map { border-radius: 14px; -}`; -document.head.appendChild(styleHead); +}` +document.head.appendChild(styleHead) // ==================== // === Id Generator === @@ -60,15 +60,15 @@ document.head.appendChild(styleHead); * @returns {function(): string} */ function makeGenerator() { - let _id = 0; - return () => { - const id = "map_" + _id.toString(); - _id += 1; - return id; - }; + let _id = 0 + return () => { + const id = 'map_' + _id.toString() + _id += 1 + return id + } } -const makeId = makeGenerator(); +const makeId = makeGenerator() // ============================ // === MapViewVisualization === @@ -97,237 +97,246 @@ const makeId = makeGenerator(); * } */ class GeoMapVisualization extends Visualization { - static inputType = "Any"; - static label = "Geo Map"; + static inputType = 'Any' + static label = 'Geo Map' - constructor(api) { - super(api); - this.initMapElement(); - this.initStyle(); - this.dataPoints = []; - } - - initMapElement() { - const width = this.dom.getAttributeNS(null, "width"); - const height = this.dom.getAttributeNS(null, "height"); - const mapElem = document.createElement("div"); - this.mapId = makeId(); - mapElem.setAttributeNS(null, "id", this.mapId); - mapElem.setAttributeNS( - null, - "style", - "width:" + width + "px;height: " + height + "px;" - ); - this.dom.appendChild(mapElem); - this.mapElem = mapElem; - } - - initStyle() { - let defaultMapStyle = "mapbox://styles/mapbox/light-v9"; - let accentColor = LIGHT_ACCENT_COLOR; - if (document.getElementById("root").classList.contains("dark-theme")) { - defaultMapStyle = "mapbox://styles/mapbox/dark-v9"; - accentColor = DARK_ACCENT_COLOR; + constructor(api) { + super(api) + this.initMapElement() + this.initStyle() + this.dataPoints = [] } - this.defaultMapStyle = defaultMapStyle; - this.accentColor = accentColor; - } - onDataReceived(data) { - let parsedData = data; - if (typeof data === "string") { - parsedData = JSON.parse(data); + initMapElement() { + const width = this.dom.getAttributeNS(null, 'width') + const height = this.dom.getAttributeNS(null, 'height') + const mapElem = document.createElement('div') + this.mapId = makeId() + mapElem.setAttributeNS(null, 'id', this.mapId) + mapElem.setAttributeNS( + null, + 'style', + 'width:' + width + 'px;height: ' + height + 'px;' + ) + this.dom.appendChild(mapElem) + this.mapElem = mapElem } - this.updateState(parsedData); - this.updateMap(); - this.updateLayers(); - } - /** - * 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 - ); - - this.latitude = ok(data.latitude) ? data.latitude : latitude; - this.longitude = ok(data.longitude) ? data.longitude : longitude; - - // TODO : Compute zoom somehow from span of latitudes and longitudes. - this.zoom = ok(data.zoom) ? data.zoom : DEFAULT_MAP_ZOOM; - this.mapStyle = ok(data.mapStyle) ? data.mapStyle : this.defaultMapStyle; - this.pitch = ok(data.pitch) ? data.pitch : 0; - this.controller = ok(data.controller) ? data.controller : true; - } - - viewState() { - return { - longitude: this.longitude, - latitude: this.latitude, - zoom: this.zoom, - pitch: this.pitch, - }; - } - - updateMap() { - if (!ok(this.deckgl)) { - this.initDeckGl(); - } else { - this.updateDeckGl(); + initStyle() { + let defaultMapStyle = 'mapbox://styles/mapbox/light-v9' + let accentColor = LIGHT_ACCENT_COLOR + if (document.getElementById('root').classList.contains('dark-theme')) { + defaultMapStyle = 'mapbox://styles/mapbox/dark-v9' + accentColor = DARK_ACCENT_COLOR + } + this.defaultMapStyle = defaultMapStyle + this.accentColor = accentColor } - } - makeScatterLayer() { - return new deck.ScatterplotLayer({ - data: this.dataPoints, - getFillColor: (d) => d.color, - getRadius: (d) => d.radius, - }); - } + onDataReceived(data) { + let parsedData = data + if (typeof data === 'string') { + parsedData = JSON.parse(data) + } + this.updateState(parsedData) + this.updateMap() + this.updateLayers() + } - initDeckGl() { - this.deckgl = new deck.DeckGL({ - container: this.mapId, - mapboxApiAccessToken: TOKEN, - mapStyle: this.mapStyle, - initialViewState: this.viewState(), - controller: this.controller, - }); - } + /** + * 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 + ) - updateDeckGl() { - this.deckgl.mapStyle = this.mapStyle; - this.deckgl.controller = this.controller; - } + this.latitude = ok(data.latitude) ? data.latitude : latitude + this.longitude = ok(data.longitude) ? data.longitude : longitude - updateLayers() { - this.deckgl.setProps({ - layers: [this.makeScatterLayer()], - }); - } + // TODO : Compute zoom somehow from span of latitudes and longitudes. + this.zoom = ok(data.zoom) ? data.zoom : DEFAULT_MAP_ZOOM + this.mapStyle = ok(data.mapStyle) ? data.mapStyle : this.defaultMapStyle + this.pitch = ok(data.pitch) ? data.pitch : 0 + this.controller = ok(data.controller) ? data.controller : true + } - /** - * 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; + viewState() { + return { + longitude: this.longitude, + latitude: this.latitude, + zoom: this.zoom, + pitch: this.pitch, + } + } - 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, + updateMap() { + if (!ok(this.deckgl)) { + this.initDeckGl() + } else { + this.updateDeckGl() + } + } + + makeScatterLayer() { + return new deck.ScatterplotLayer({ + data: this.dataPoints, + getFillColor: (d) => d.color, + getRadius: (d) => d.radius, + }) + } + + initDeckGl() { + this.deckgl = new deck.DeckGL({ + container: this.mapId, + mapboxApiAccessToken: TOKEN, + mapStyle: this.mapStyle, + initialViewState: this.viewState(), + controller: this.controller, + }) + } + + updateDeckGl() { + this.deckgl.mapStyle = this.mapStyle + this.deckgl.controller = this.controller + } + + updateLayers() { + this.deckgl.setProps({ + layers: [this.makeScatterLayer()], + }) + } + + /** + * 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 } + } + + /** + * Helper for prepareDataPoints, pushes `GEO_POINT`'s to the list, and calculates central point. + * @returns {{latitude: number, longitude: number}} - center. + */ + calculateExtremesAndPushPoints( + dataPoints, 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."); - } - }); - } + ) { + 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) + } + }) + 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 } } - return { latitude, longitude }; - } - /** - * Helper for prepareDataPoints, pushes `GEO_POINT`'s to the list, and calculates central point. - * @returns {{latitude: number, longitude: number}} - center. - */ - 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); - } - }); - 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; + /** + * 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) { + let position = [geoPoint.longitude, geoPoint.latitude] + let radius = isNaN(geoPoint.radius) + ? DEFAULT_POINT_RADIUS + : geoPoint.radius + let color = ok(geoPoint.color) ? geoPoint.color : accentColor + preparedDataPoints.push({ position, color, radius }) } - 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) { - let position = [geoPoint.longitude, geoPoint.latitude]; - let radius = isNaN(geoPoint.radius) - ? DEFAULT_POINT_RADIUS - : geoPoint.radius; - let color = ok(geoPoint.color) ? geoPoint.color : accentColor; - preparedDataPoints.push({ position, color, radius }); - } - - /** - * 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;" - ); - } + /** + * 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;' + ) + } } /** * Checks if `t` has defined type and is not null. */ function ok(t) { - return t !== undefined && t !== null; + return t !== undefined && t !== null } -return GeoMapVisualization; +return GeoMapVisualization diff --git a/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/histogram.js b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/histogram.js new file mode 100644 index 00000000000..7b0766c5c94 --- /dev/null +++ b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/histogram.js @@ -0,0 +1,714 @@ +/** Histogram Visualization. */ +// TODO refactor this to avoid loading on startup. See issue #985 . +loadScript('https://d3js.org/d3.v4.min.js') +loadStyle('https://fontlibrary.org/face/dejavu-sans-mono') + +let shortcuts = { + zoomIn: (e) => (e.ctrlKey || e.metaKey) && e.key === 'z', + showAll: (e) => (e.ctrlKey || e.metaKey) && e.key === 'a', +} + +const LABEL_STYLE = 'font-family: DejaVuSansMonoBook; font-size: 10px;' +const MARGIN = 25 +const X_AXIS_LABEL_WIDTH = 10 +const Y_AXIS_LABEL_WIDTH = 10 +const ANIMATION_DURATION = 1000 +const LINEAR_SCALE = 'linear' +const LIGHT_PLOT_COLOR = '#00E890' +const DARK_PLOT_COLOR = '#E0A63B' +const DEFAULT_NUMBER_OF_BINS = 10 +const BUTTON_HEIGHT = 25 + +/** + * A d3.js histogram visualization. + * + * + * Data format (json): + { + "axis" : { + "x" : { "label" : "x-axis label", "scale" : "linear" }, + "y" : { "label" : "y-axis label", "scale" : "logarithmic" }, + }, + "focus" { "x" : 1.7, "y" : 2.1, "zoom" : 3.0 }, + "color" : "rgb(1.0,0.0,0.0)" }, + "bins" : 10, + "data" : [ + "values" : [0.1, 0.2, 0.1, 0.15, 0.7], + ] + } + */ +class Histogram extends Visualization { + static inputType = 'Any' + static label = 'Histogram' + + onDataReceived(data) { + const parsedData = JSON.parse(data) + const isUpdate = parsedData.update === 'diff' + this.updateState(parsedData, isUpdate) + + if (!this.isInitialised()) { + this.initCanvas() + this.initLabels() + this.initHistogram() + } + this.updateLabels() + this.updateHistogram() + } + + /** + * Indicates whether this visualisation has been initialised. + */ + isInitialised() { + ok(this.svg) + } + + /** + * Update the internal data and plot settings with the ones from the new incoming data. + * If no new settings/data have been provided the old ones will be kept. + */ + updateState(data, isUpdate) { + if (isUpdate) { + this._axisSpec = ok(data.axis) ? data.axis : this._axisSpec + this._focus = ok(data.focus) ? data.focus : this._focus + this._dataValues = ok(data.data.values) + ? data.data.values + : this.data + this._bins = ok(data.bins) ? data.bins : this._bins + } else { + this._axisSpec = data.axis + this._focus = data.focus + this._dataValues = data.data.values + this._bins = data.bins + } + } + + /** + * Return the focus area of the histogram. If none is set, returns undefined. + */ + focus() { + return this._focus + } + + /** + * Return specification for the axis. This includes scales (logarithmic/linear) and labels. + */ + axisSpec() { + return ( + this._axisSpec || { + x: { scale: LINEAR_SCALE }, + y: { scale: LINEAR_SCALE }, + } + ) + } + + /** + * Return vales to plot. + */ + data() { + return this._dataValues || {} + } + + /** + * Return the number of bins to use for the histogram. + */ + binCount() { + if (!ok(this._bins)) { + return DEFAULT_NUMBER_OF_BINS + } else { + return Math.max(1, self._bins) + } + } + + /** + * Return the layout measurements for the plot. This includes the outer dimensions of the + * drawing area as well as the inner dimensions of the plotting area and the margins. + */ + canvasDimensions() { + const width = this.dom.getAttributeNS(null, 'width') + let height = this.dom.getAttributeNS(null, 'height') + height = height - BUTTON_HEIGHT + const margin = this.margins() + return { + inner: { + width: width - margin.left - margin.right, + height: height - margin.top - margin.bottom, + }, + outer: { width, height }, + margin, + } + } + + /** + * Initialise the drawing svg and related properties, e.g., canvas size and margins. + */ + initCanvas() { + while (this.dom.firstChild) { + this.dom.removeChild(this.dom.lastChild) + } + + this.canvas = this.canvasDimensions() + const container = this.createOuterContainerWithStyle( + this.canvas.outer.width, + this.canvas.outer.height + ) + this.dom.appendChild(container) + + this.svg = d3 + .select(container) + .append('svg') + .attr('width', this.canvas.outer.width) + .attr('height', this.canvas.outer.height) + .append('g') + .attr( + 'transform', + 'translate(' + + this.canvas.margin.left + + ',' + + this.canvas.margin.top + + ')' + ) + + this.yAxis = this.svg.append('g').attr('style', LABEL_STYLE) + this.xAxis = this.svg.append('g').attr('style', LABEL_STYLE) + + this.plot = this.svg + .append('g') + .attr('clip-path', 'url(#hist-clip-path)') + + // Create clip path + const defs = this.svg.append('defs') + defs.append('clipPath') + .attr('id', 'hist-clip-path') + .append('rect') + .attr('width', this.canvas.inner.width) + .attr('height', this.canvas.inner.height) + } + + /** + * Initialise the histogram with the current data and settings. + */ + initHistogram() { + this.updateHistogram() + const zoom = this.initPanAndZoom() + // TODO [MM]: In task specification buttons were on top of the visualization, but because + // the visualization selector obfuscated them, they're now on the bottom. + // This should be fixed in (#898). + this.createButtonFitAll(zoom) + const selectedZoomBtn = this.createButtonScaleToPoints() + this.initBrushing(selectedZoomBtn, zoom) + } + + /** + * Initialise panning and zooming functionality on the visualization. + */ + initPanAndZoom() { + const canvas = this.canvas + const zoomClass = 'zoom' + const minScale = 0.5 + const maxScale = 20 + const extent = [minScale, maxScale] + const zoom = d3 + .zoom() + .filter(function () { + let right_button = 2 + let mid_button = 1 + let scroll_wheel = 0 + switch (d3.event.type) { + case 'mousedown': + return ( + d3.event.button === right_button || + d3.event.button === mid_button + ) + case 'wheel': + return d3.event.button === scroll_wheel + default: + return false + } + }) + .scaleExtent(extent) + .extent([ + [0, 0], + [canvas.inner.width, canvas.inner.height], + ]) + .on(zoomClass, zoomed) + + const zoomElem = this.svg + .append('g') + .attr('class', zoomClass) + .attr('width', canvas.inner.width) + .attr('height', canvas.inner.height) + .style('fill', 'none') + .style('pointer-events', 'all') + .call(zoom) + + const self = this + /** + * Helper function called on pan/scroll. + */ + function zoomed() { + self.scale.zoom = d3.event.transform.k + let tmpScale = Object.assign({}, self.scale) + tmpScale.x = d3.event.transform.rescaleX(self.scale.x) + self.rescale(tmpScale, false) + } + + return { zoomElem, zoom } + } + + /** + * Initialise brushing functionality on the visualization. + * + * Brush is a tool which enables user to select points, and zoom into selection via + * keyboard shortcut or button event. + */ + initBrushing(selectedZoomBtn, zoom) { + let extent + const brushClass = 'brush' + + const brush = d3 + .brushX() + .extent([ + [0, 0], + [this.canvas.inner.width, this.canvas.inner.height], + ]) + .on('start ' + brushClass, updateChartOnBrush) + + // The brush element must be child of zoom element - this is only way we found to have both + // zoom and brush events working at the same time. See https://stackoverflow.com/a/59757276 . + const brushElem = zoom.zoomElem + .append('g') + .attr('class', brushClass) + .call(brush) + + const self = this + + /** + * Zooms into selected fragment of plot. + * + * Based on https://www.d3-graph-gallery.com/graph/interactivity_brush.html + * Section "Brushing for zooming". + */ + const zoomIn = () => { + const xMin = self.scale.x.invert(extent[0]) + const xMax = self.scale.x.invert(extent[1]) + + self.scale.x.domain([xMin, xMax]) + const dx = extent[1] - extent[0] + self.scale.zoom = self.scale.zoom * (self.canvas.inner.width / dx) + + self.rescale(self.scale, true) + } + + const zoomInKeyEvent = (event) => { + if (shortcuts.zoomIn(event)) { + zoomIn() + endBrushing() + } + } + + /** + * Updates plot when brushing. + */ + function updateChartOnBrush() { + const selectionEvent = d3.event.selection + selectedZoomBtn.style.display = 'inline-block' + selectedZoomBtn.addEventListener('click', zoomIn, true) + document.addEventListener('keydown', zoomInKeyEvent, true) + extent = selectionEvent + } + + /** + * Removes brush, keyboard event and zoom button when end event is captured. + */ + const endBrushing = () => { + brushElem.call(brush.move, null) + selectedZoomBtn.style.display = 'none' + selectedZoomBtn.removeEventListener('click', zoomIn, true) + document.removeEventListener('keydown', zoomInKeyEvent, true) + } + + let endEvents = ['click', 'auxclick', 'contextmenu', 'scroll'] + endEvents.forEach((e) => + document.addEventListener(e, endBrushing, false) + ) + } + + /** + * Helper function for rescaling the data points with a new scale. + */ + rescale(scale, with_animation) { + const animation_duration = with_animation ? ANIMATION_DURATION : 0.0 + this.xAxis + .transition() + .duration(animation_duration) + .call(d3.axisBottom(scale.x).ticks(this.binCount())) + this.plot + .selectAll('rect') + .transition() + .duration(animation_duration) + .attr( + 'transform', + (d) => + 'translate(' + + scale.x(d.x0) + + ',' + + scale.y(d.length) + + ')scale(' + + scale.zoom + + ',1)' + ) + } + + /** + * Update the d3 histogram with the current data. + * + * Binds the new data to the plot, creating new bars, removing old ones and + * updates the axes accordingly. + */ + updateHistogram() { + const extremesAndDeltas = this.extremesAndDeltas() + const dataPoints = this.data() + const focus = this.focus() + + let domain_x = [ + extremesAndDeltas.xMin - extremesAndDeltas.paddingX, + extremesAndDeltas.xMax + extremesAndDeltas.paddingX, + ] + + if (focus !== undefined) { + if (focus.x !== undefined && focus.zoom !== undefined) { + let padding_x = extremesAndDeltas.dx * (1 / (2 * focus.zoom)) + domain_x = [focus.x - padding_x, focus.x + padding_x] + } + } + + const x = d3 + .scaleLinear() + .domain(domain_x) + .range([0, this.canvas.inner.width]) + + this.xAxis + .attr('transform', 'translate(0,' + this.canvas.inner.height + ')') + .call(d3.axisBottom(x)) + + const histogram = d3 + .histogram() + .value((d) => d) + .domain(x.domain()) + .thresholds(x.ticks(this.binCount())) + + const bins = histogram(dataPoints) + + const y = d3.scaleLinear().range([this.canvas.inner.height, 0]) + y.domain([0, d3.max(bins, (d) => d.length)]) + + const yAxisTicks = y.ticks().filter((tick) => Number.isInteger(tick)) + + const yAxis = d3 + .axisLeft(y) + .tickValues(yAxisTicks) + .tickFormat(d3.format('d')) + + this.yAxis.call(yAxis) + + let accentColor = LIGHT_PLOT_COLOR + + if (document.getElementById('root').classList.contains('dark')) { + accentColor = DARK_PLOT_COLOR + } + + const items = this.plot.selectAll('rect').data(bins) + + this.bars = items + .enter() + .append('rect') + .attr('x', 1) + .attr( + 'transform', + (d) => 'translate(' + x(d.x0) + ',' + y(d.length) + ')' + ) + .attr('width', (d) => x(d.x1) - x(d.x0)) + .attr('height', (d) => this.canvas.inner.height - y(d.length)) + .style('fill', accentColor) + + items.exit().remove() + + this.scale = { x, y, zoom: 1.0 } + } + + /** + * Creates labels on axes if they are defined. + */ + initLabels() { + this.yAxisLabel = this.svg + .append('text') + .attr('text-anchor', 'end') + .attr('style', LABEL_STYLE) + .attr('transform', 'rotate(-90)') + this.xAxisLabel = this.svg + .append('text') + .attr('text-anchor', 'end') + .attr('style', LABEL_STYLE) + } + + /** + * Update labels with current data. + */ + updateLabels() { + const axis = this.axisSpec() + const canvas = this.canvas + + const fontStyle = '10px DejaVuSansMonoBook' + if (axis.x.label !== undefined) { + this.xAxisLabel + .attr( + 'y', + canvas.inner.height + + canvas.margin.bottom - + X_AXIS_LABEL_WIDTH / 2.0 + ) + .attr( + 'x', + canvas.inner.width / 2.0 + + this.textWidth(axis.x.label, fontStyle) / 2 + ) + .text(axis.x.label) + } + // Note: y axis is rotated by 90 degrees, so x/y is switched. + if (axis.y.label !== undefined) { + this.yAxisLabel + .attr('y', -canvas.margin.left + Y_AXIS_LABEL_WIDTH) + .attr( + 'x', + -canvas.inner.height / 2 + + this.textWidth(axis.y.label, fontStyle) / 2 + ) + .text(axis.y.label) + } + } + + /** + * Return the text width. Ensures that labels on x axis wont overlap to keeps them readable. + */ + textWidth(text, font) { + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + context.font = font + const metrics = context.measureText(' ' + text) + return metrics.width + } + + /** + * Return the extrema of the data and and paddings that ensure data will fit into the + * drawing area. + * + * It traverses through data getting minimal and maximal values, and calculates padding based on + * span calculated from above values, multiplied by 10% so that the plot is a little bit smaller + * than the container. + */ + extremesAndDeltas() { + const dataPoints = this.data() + let xMin = dataPoints[0] + let xMax = dataPoints[0] + + dataPoints.forEach((value) => { + if (value < xMin) { + xMin = value + } + if (value > xMax) { + xMax = value + } + }) + + const dx = xMax - xMin + const paddingX = 0.1 * dx + + return { xMin, xMax, paddingX, dx } + } + + /** + * Return margins for plots drawing area. + */ + margins() { + const axis = this.axisSpec() + const noXAxis = axis.x.label === undefined + const noYAxis = axis.y.label === undefined + + const top = MARGIN / 2.0 + const right = MARGIN / 2.0 + if (noXAxis && noYAxis) { + return { top, right, bottom: MARGIN, left: MARGIN } + } else if (noYAxis) { + return { + top, + right, + bottom: MARGIN + X_AXIS_LABEL_WIDTH, + left: MARGIN, + } + } else if (noXAxis) { + return { + top, + right, + bottom: MARGIN, + left: MARGIN + Y_AXIS_LABEL_WIDTH, + } + } + return { + top, + right, + bottom: MARGIN + X_AXIS_LABEL_WIDTH, + left: MARGIN + Y_AXIS_LABEL_WIDTH, + } + } + + /** + * Creates HTML div element as container for plot. + */ + createOuterContainerWithStyle(width, height) { + const divElem = document.createElementNS(null, 'div') + divElem.setAttributeNS(null, 'class', 'vis-histogram') + divElem.setAttributeNS( + null, + 'viewBox', + 0 + ' ' + 0 + ' ' + width + ' ' + height + ) + divElem.setAttributeNS(null, 'width', '100%') + divElem.setAttributeNS(null, 'height', '100%') + + const addStyleToElem = (attr, stl) => { + const style = document.createElement('style') + style.innerText = attr + '{' + stl + '}' + + divElem.appendChild(style) + } + + const darkStrokeColor = `rgba(255,255,255,0.7)` + const buttonLightColor = `#333` + const darkBtnHoverColor = `rgba(255,255,255,0.5)` + const darkSelectionFill = `#efefef` + + addStyleToElem('.selection', 'rx: 4px;stroke: transparent;') + addStyleToElem( + 'button', + ` + margin-left: 5px; + margin-bottom: 5px; + display: inline-block; + padding: 2px 10px; + outline: none; + background-color: transparent; + border: 1px solid ${buttonLightColor}; + color: ${buttonLightColor}; + border-radius: 14px; + font-size: 10px; + font-family: DejaVuSansMonoBook; + vertical-align: top; + transition: all 0.3s ease; + ` + ) + addStyleToElem( + 'button:hover', + ` + background-color: ${buttonLightColor}; + color: ${darkSelectionFill}; + ` + ) + + addStyleToElem( + '.dark button', + ` + border: 0; + background-color: ${darkStrokeColor}; + ` + ) + addStyleToElem( + '.dark button:hover', + ` + background-color: ${darkBtnHoverColor}; + ` + ) + addStyleToElem('.dark .selection', `fill: ${darkSelectionFill}`) + addStyleToElem('.dark line', `stroke: ${darkStrokeColor};`) + addStyleToElem('.dark .domain', `stroke: ${darkStrokeColor};`) + addStyleToElem('.dark text', `fill: ${darkStrokeColor};`) + + return divElem + } + + /** + * Create a button HTML element with default size. + */ + createButtonElement() { + const btn = document.createElement('button') + btn.setAttribute('width', '80px') + btn.setAttribute('height', '20px') + return btn + } + + /** + * Creates a button that when clicked pans and zooms the plot to fit all data points. + */ + createButtonFitAll(zoom) { + const extremesAndDeltas = this.extremesAndDeltas() + const btn = this.createButtonElement() + + let text = document.createTextNode('Fit all') + btn.appendChild(text) + + const self = this + const reset_zoom_and_pan = () => { + zoom.zoomElem + .transition() + .duration(0) + .call(zoom.zoom.transform, d3.zoomIdentity) + + let domain_x = [ + extremesAndDeltas.xMin - extremesAndDeltas.paddingX, + extremesAndDeltas.xMax + extremesAndDeltas.paddingX, + ] + + self.scale.x.domain(domain_x) + self.scale.zoom = 1.0 + self.rescale(self.scale, true) + } + + document.addEventListener('keydown', (e) => { + if (shortcuts.showAll(e)) { + reset_zoom_and_pan() + } + }) + + btn.addEventListener('click', reset_zoom_and_pan) + this.dom.appendChild(btn) + } + + /** + * Creates a button to zoom into brushed fragment of plot. + */ + createButtonScaleToPoints() { + const btn = this.createButtonElement() + const text = document.createTextNode('Zoom to selected') + btn.appendChild(text) + btn.setAttribute('width', '120px') + btn.style.display = 'none' + this.dom.appendChild(btn) + return btn + } + + /** + * Sets size of the main parent DOM object. + */ + setSize(size) { + this.dom.setAttributeNS(null, 'width', size[0]) + this.dom.setAttributeNS(null, 'height', size[1]) + } +} + +/** + * Checks if `t` has defined type and is not null. + */ +function ok(t) { + return t !== undefined && t !== null +} + +return Histogram diff --git a/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/loading.js b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/loading.js new file mode 100644 index 00000000000..1bbfdb44663 --- /dev/null +++ b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/loading.js @@ -0,0 +1,24 @@ +/** + * Add script to the DOM. + */ +function loadScript(url, onload) { + let script = document.createElement('script') + script.onload = onload + script.src = url + + document.head.appendChild(script) +} + +/** + * Add css style file to the DOM. + */ +function loadStyle(url, onload) { + let style = document.createElement('link') + style.onload = onload + style.href = url + style.rel = 'stylesheet' + style.media = 'screen' + style.type = 'text/css' + + document.head.appendChild(style) +} diff --git a/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/mapView.js b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/mapView.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/scatterPlot.js b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/scatterPlot.js index 9353a52c0da..dc7536b3145 100644 --- a/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/scatterPlot.js +++ b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/scatterPlot.js @@ -1,49 +1,18 @@ -/** - * Helper function to load scripts. - * - * It runs only once because of the visualization implementation : file is loaded once, then - * the onDataReceived() is called multiple times, so it won't load the same script on this or any - * other visualization show/hide. - */ -function loadScript(url) { - let script = document.createElement("script"); - script.src = url; - - document.head.appendChild(script); -} - -/** - * Helper function to load styles. - * - * It runs only once because of the visualization implementation : file is loaded once, then - * the onDataReceived() is called multiple times, so it won't load the same style on this or any - * other visualization show/hide. - */ -function loadStyle(url) { - let style = document.createElement("link"); - style.href = url; - style.rel = "stylesheet"; - style.media = "screen"; - style.type = "text/css"; - - document.head.appendChild(style); -} - -loadScript('https://d3js.org/d3.v4.min.js'); +loadScript('https://d3js.org/d3.v4.min.js') loadStyle('https://fontlibrary.org/face/dejavu-sans-mono') let shortcuts = { - zoomIn : (e) => ((e.ctrlKey || e.metaKey) && e.key === 'z'), - showAll : (e) => ((e.ctrlKey || e.metaKey) && event.key === 'a') + zoomIn: (e) => (e.ctrlKey || e.metaKey) && e.key === 'z', + showAll: (e) => (e.ctrlKey || e.metaKey) && event.key === 'a', } -const label_style = "font-family: DejaVuSansMonoBook; font-size: 10px;"; -const x_axis_label_width = 30; -const point_label_padding_x = 7; -const point_label_padding_y = 2; -const animation_duration = 1000; -const linear_scale = "linear"; -const visilbe_points = "visible"; +const label_style = 'font-family: DejaVuSansMonoBook; font-size: 10px;' +const x_axis_label_width = 30 +const point_label_padding_x = 7 +const point_label_padding_y = 2 +const animation_duration = 1000 +const linear_scale = 'linear' +const visilbe_points = 'visible' /** * A d3.js ScatterPlot visualization. @@ -71,105 +40,181 @@ const visilbe_points = "visible"; * } */ class ScatterPlot extends Visualization { - static inputType = "Any" - static label = "Scatter Plot (JS)" + static inputType = 'Any' + static label = 'Scatter Plot (JS)' /** * Presents a scatterplot visualization after receiving `data`. */ onDataReceived(data) { - while (this.dom.firstChild) { - this.dom.removeChild(this.dom.lastChild); + this.dom.removeChild(this.dom.lastChild) } - let width = this.dom.getAttributeNS(null,"width"); - let height = this.dom.getAttributeNS(null,"height"); - const buttonsHeight = 25; - height = height - buttonsHeight; - const divElem = this.createDivElem(width,height); - this.dom.appendChild(divElem); + let width = this.dom.getAttributeNS(null, 'width') + let height = this.dom.getAttributeNS(null, 'height') + const buttonsHeight = 25 + height = height - buttonsHeight + const divElem = this.createDivElem(width, height) + this.dom.appendChild(divElem) - let parsedData = data; - if (typeof data === "string") { - parsedData = JSON.parse(data); + let parsedData = data + if (typeof data === 'string') { + parsedData = JSON.parse(data) } - let axis = parsedData.axis || {x:{scale:linear_scale},y:{scale:linear_scale}}; - let focus = parsedData.focus; - let points = parsedData.points || {labels:"invisible"}; - let dataPoints = parsedData.data || {}; + let axis = parsedData.axis || { + x: { scale: linear_scale }, + y: { scale: linear_scale }, + } + let focus = parsedData.focus + let points = parsedData.points || { labels: 'invisible' } + let dataPoints = parsedData.data || {} - let margin = this.getMargins(axis); - let box_width = width - margin.left - margin.right; - let box_height = height - margin.top - margin.bottom; + let margin = this.getMargins(axis) + let box_width = width - margin.left - margin.right + let box_height = height - margin.top - margin.bottom - let svg = d3.select(divElem) - .append("svg") - .attr("width",width) - .attr("height",height) - .append("g") - .attr("transform","translate(" + margin.left + "," + margin.top + ")"); + let svg = d3 + .select(divElem) + .append('svg') + .attr('width', width) + .attr('height', height) + .append('g') + .attr( + 'transform', + 'translate(' + margin.left + ',' + margin.top + ')' + ) - let extremesAndDeltas = this.getExtremesAndDeltas(dataPoints); - let scaleAndAxis = this.createAxes(axis,extremesAndDeltas,box_width,box_height,svg,focus); - this.createLabels(axis,svg,box_width,margin,box_height); - let scatter = this.createScatter(svg,box_width,box_height,points,dataPoints,scaleAndAxis); - let zoom = this.addPanAndZoom(box_width,box_height,svg,margin,scaleAndAxis,scatter,points); + let extremesAndDeltas = this.getExtremesAndDeltas(dataPoints) + let scaleAndAxis = this.createAxes( + axis, + extremesAndDeltas, + box_width, + box_height, + svg, + focus + ) + this.createLabels(axis, svg, box_width, margin, box_height) + let scatter = this.createScatter( + svg, + box_width, + box_height, + points, + dataPoints, + scaleAndAxis + ) + let zoom = this.addPanAndZoom( + box_width, + box_height, + svg, + margin, + scaleAndAxis, + scatter, + points + ) // TODO [MM]: In task specification buttons were on top of the visualization, but because // the visualization selector obfuscated them, they're now on the bottom. // This should be fixed in (#898). - this.createButtonFitAll(scaleAndAxis,scatter,points,extremesAndDeltas,zoom,box_width); - let selectedZoomBtn = this.createButtonScaleToPoints(); - this.addBrushing(box_width,box_height,scatter,scaleAndAxis,selectedZoomBtn,points,zoom); + this.createButtonFitAll( + scaleAndAxis, + scatter, + points, + extremesAndDeltas, + zoom, + box_width + ) + let selectedZoomBtn = this.createButtonScaleToPoints() + this.addBrushing( + box_width, + box_height, + scatter, + scaleAndAxis, + selectedZoomBtn, + points, + zoom + ) } /** * Adds panning and zooming functionality to the visualization. */ - addPanAndZoom(box_width,box_height,svg,margin,scaleAndAxis,scatter,points) { - let zoomClass = "zoom"; - let minScale = .5; - let maxScale = 20; - const extent = [minScale,maxScale]; - let zoom = d3.zoom().filter(function () { - let right_button = 2 - let mid_button = 1 - let scroll_wheel = 0 - switch (d3.event.type) { - case "mousedown": return d3.event.button === right_button || d3.event.button === mid_button - case "wheel": return d3.event.button === scroll_wheel - default: return false - } - }).scaleExtent(extent) - .extent([[0,0],[box_width,box_height]]) - .on(zoomClass,zoomed) - // .on("wheel.zoom", wheeled) + addPanAndZoom( + box_width, + box_height, + svg, + margin, + scaleAndAxis, + scatter, + points + ) { + let zoomClass = 'zoom' + let minScale = 0.5 + let maxScale = 20 + const extent = [minScale, maxScale] + let zoom = d3 + .zoom() + .filter(function () { + let right_button = 2 + let mid_button = 1 + let scroll_wheel = 0 + switch (d3.event.type) { + case 'mousedown': + return ( + d3.event.button === right_button || + d3.event.button === mid_button + ) + case 'wheel': + return d3.event.button === scroll_wheel + default: + return false + } + }) + .scaleExtent(extent) + .extent([ + [0, 0], + [box_width, box_height], + ]) + .on(zoomClass, zoomed) + // .on("wheel.zoom", wheeled) - let zoomElem = scatter.append("g") - .attr("class",zoomClass) - .attr("width",box_width) - .attr("height",box_height) - .style("fill","none") - .style("pointer-events","all") - .call(zoom); + let zoomElem = scatter + .append('g') + .attr('class', zoomClass) + .attr('width', box_width) + .attr('height', box_height) + .style('fill', 'none') + .style('pointer-events', 'all') + .call(zoom) /** * Helper function called on pan/scroll. */ function zoomed() { - let new_xScale = d3.event.transform.rescaleX(scaleAndAxis.xScale); - let new_yScale = d3.event.transform.rescaleY(scaleAndAxis.yScale); + let new_xScale = d3.event.transform.rescaleX(scaleAndAxis.xScale) + let new_yScale = d3.event.transform.rescaleY(scaleAndAxis.yScale) - scaleAndAxis.xAxis.call(d3.axisBottom(new_xScale).ticks(box_width/x_axis_label_width)); - scaleAndAxis.yAxis.call(d3.axisLeft(new_yScale)); - scatter.selectAll("path") - .attr('transform',d => "translate(" + new_xScale(d.x) + "," + new_yScale(d.y) + ")") + scaleAndAxis.xAxis.call( + d3.axisBottom(new_xScale).ticks(box_width / x_axis_label_width) + ) + scaleAndAxis.yAxis.call(d3.axisLeft(new_yScale)) + scatter + .selectAll('path') + .attr( + 'transform', + (d) => + 'translate(' + + new_xScale(d.x) + + ',' + + new_yScale(d.y) + + ')' + ) if (points.labels === visilbe_points) { - scatter.selectAll("text") - .attr("x",d => new_xScale(d.x) + point_label_padding_x) - .attr("y",d => new_yScale(d.y) + point_label_padding_y) + scatter + .selectAll('text') + .attr('x', (d) => new_xScale(d.x) + point_label_padding_x) + .attr('y', (d) => new_yScale(d.y) + point_label_padding_y) } } @@ -180,15 +225,16 @@ class ScatterPlot extends Visualization { * https://medium.com/@auchenberg/detecting-multi-touch-trackpad-gestures-in-javascript-a2505babb10e */ function wheeled() { - let current_transform = d3.zoomTransform(scatter); - let delta_multiplier = 0.01; + let current_transform = d3.zoomTransform(scatter) + let delta_multiplier = 0.01 if (d3.event.ctrlKey) { - current_transform.k = current_transform.k - d3.event.deltaY * delta_multiplier; + current_transform.k = + current_transform.k - d3.event.deltaY * delta_multiplier } - scatter.attr("transform", current_transform); + scatter.attr('transform', current_transform) } - return {zoomElem,zoom}; + return { zoomElem, zoom } } /** @@ -197,21 +243,34 @@ class ScatterPlot extends Visualization { * Brush is a tool which enables user to select points, and zoom into selection via * keyboard shortcut or button event. */ - addBrushing(box_width,box_height,scatter,scaleAndAxis,selectedZoomBtn,points,zoom) { - let extent; - let brushClass = "brush"; + addBrushing( + box_width, + box_height, + scatter, + scaleAndAxis, + selectedZoomBtn, + points, + zoom + ) { + let extent + let brushClass = 'brush' - let brush = d3.brush() - .extent([[0,0],[box_width,box_height]]) - .on("start " + brushClass,updateChart) + let brush = d3 + .brush() + .extent([ + [0, 0], + [box_width, box_height], + ]) + .on('start ' + brushClass, updateChart) // The brush element must be child of zoom element - this is only way we found to have both zoom and brush // events working at the same time. See https://stackoverflow.com/a/59757276 . - let brushElem = zoom.zoomElem.append("g") - .attr("class",brushClass) + let brushElem = zoom.zoomElem + .append('g') + .attr('class', brushClass) .call(brush) - let self = this; + let self = this /** * Zooms into selected fragment of plot. @@ -220,152 +279,226 @@ class ScatterPlot extends Visualization { * Section "Brushing for zooming". */ const zoomIn = () => { - let xMin = scaleAndAxis.xScale.invert(extent[0][0]); - let xMax = scaleAndAxis.xScale.invert(extent[1][0]); - let yMin = scaleAndAxis.yScale.invert(extent[1][1]); - let yMax = scaleAndAxis.yScale.invert(extent[0][1]); + let xMin = scaleAndAxis.xScale.invert(extent[0][0]) + let xMax = scaleAndAxis.xScale.invert(extent[1][0]) + let yMin = scaleAndAxis.yScale.invert(extent[1][1]) + let yMax = scaleAndAxis.yScale.invert(extent[0][1]) - scaleAndAxis.xScale.domain([xMin,xMax]); - scaleAndAxis.yScale.domain([yMin,yMax]); + scaleAndAxis.xScale.domain([xMin, xMax]) + scaleAndAxis.yScale.domain([yMin, yMax]) - self.zoomingHelper(scaleAndAxis,box_width,scatter,points); + self.zoomingHelper(scaleAndAxis, box_width, scatter, points) } const zoomInKeyEvent = (event) => { if (shortcuts.zoomIn(event)) { - zoomIn(); - endBrushing(); + zoomIn() + endBrushing() } - }; + } /** * Updates plot when brushing. */ function updateChart() { - let selectionEvent = d3.event.selection; - selectedZoomBtn.style.display = "inline-block"; - selectedZoomBtn.addEventListener("click",zoomIn,true) - document.addEventListener('keydown',zoomInKeyEvent,true); - extent = selectionEvent; + let selectionEvent = d3.event.selection + selectedZoomBtn.style.display = 'inline-block' + selectedZoomBtn.addEventListener('click', zoomIn, true) + document.addEventListener('keydown', zoomInKeyEvent, true) + extent = selectionEvent } /** * Removes brush, keyboard event and zoom button when end event is captured. */ const endBrushing = () => { - brushElem.call(brush.move,null); - selectedZoomBtn.style.display = "none"; - selectedZoomBtn.removeEventListener("click",zoomIn,true) - document.removeEventListener('keydown',zoomInKeyEvent,true); - }; + brushElem.call(brush.move, null) + selectedZoomBtn.style.display = 'none' + selectedZoomBtn.removeEventListener('click', zoomIn, true) + document.removeEventListener('keydown', zoomInKeyEvent, true) + } - let endEvents = ['click','auxclick','contextmenu','scroll'] - endEvents.forEach(e => document.addEventListener(e,endBrushing,false)); + let endEvents = ['click', 'auxclick', 'contextmenu', 'scroll'] + endEvents.forEach((e) => + document.addEventListener(e, endBrushing, false) + ) } /** * Helper function for zooming in after the scale has been updated. */ - zoomingHelper(scaleAndAxis,box_width,scatter,points) { - scaleAndAxis.xAxis.transition().duration(animation_duration) - .call(d3.axisBottom(scaleAndAxis.xScale).ticks(box_width / x_axis_label_width)); - scaleAndAxis.yAxis.transition().duration(animation_duration) - .call(d3.axisLeft(scaleAndAxis.yScale)); + zoomingHelper(scaleAndAxis, box_width, scatter, points) { + scaleAndAxis.xAxis + .transition() + .duration(animation_duration) + .call( + d3 + .axisBottom(scaleAndAxis.xScale) + .ticks(box_width / x_axis_label_width) + ) + scaleAndAxis.yAxis + .transition() + .duration(animation_duration) + .call(d3.axisLeft(scaleAndAxis.yScale)) - scatter.selectAll("path") - .transition().duration(animation_duration) - .attr('transform',d => "translate(" + scaleAndAxis.xScale(d.x) + "," + scaleAndAxis.yScale(d.y) + ")") + scatter + .selectAll('path') + .transition() + .duration(animation_duration) + .attr( + 'transform', + (d) => + 'translate(' + + scaleAndAxis.xScale(d.x) + + ',' + + scaleAndAxis.yScale(d.y) + + ')' + ) if (points.labels === visilbe_points) { - scatter.selectAll("text") - .transition().duration(animation_duration) - .attr("x",d => scaleAndAxis.xScale(d.x) + point_label_padding_x) - .attr("y",d => scaleAndAxis.yScale(d.y) + point_label_padding_y) + scatter + .selectAll('text') + .transition() + .duration(animation_duration) + .attr( + 'x', + (d) => scaleAndAxis.xScale(d.x) + point_label_padding_x + ) + .attr( + 'y', + (d) => scaleAndAxis.yScale(d.y) + point_label_padding_y + ) } } /** * Creates a plot object and populates it with given data. */ - createScatter(svg,box_width,box_height,points,dataPoints,scaleAndAxis) { - let clip = svg.append("defs").append("svg:clipPath") - .attr("id","clip") - .append("svg:rect") - .attr("width",box_width) - .attr("height",box_height) - .attr("x",0) - .attr("y",0); + createScatter( + svg, + box_width, + box_height, + points, + dataPoints, + scaleAndAxis + ) { + let clip = svg + .append('defs') + .append('svg:clipPath') + .attr('id', 'clip') + .append('svg:rect') + .attr('width', box_width) + .attr('height', box_height) + .attr('x', 0) + .attr('y', 0) - let symbol = d3.symbol(); + let symbol = d3.symbol() - let scatter = svg.append('g') - .attr("clip-path","url(#clip)") + let scatter = svg.append('g').attr('clip-path', 'url(#clip)') let sizeScaleMultiplier = 100 scatter - .selectAll("dataPoint") + .selectAll('dataPoint') .data(dataPoints) .enter() - .append("path") - .attr("d",symbol.type(this.matchShape()).size(d => (d.size || 1.0) * sizeScaleMultiplier)) - .attr('transform',d => "translate(" + scaleAndAxis.xScale(d.x) + "," + scaleAndAxis.yScale(d.y) + ")") - .style("fill",d => "#" + (d.color || "000000")) - .style("opacity",0.5) + .append('path') + .attr( + 'd', + symbol + .type(this.matchShape()) + .size((d) => (d.size || 1.0) * sizeScaleMultiplier) + ) + .attr( + 'transform', + (d) => + 'translate(' + + scaleAndAxis.xScale(d.x) + + ',' + + scaleAndAxis.yScale(d.y) + + ')' + ) + .style('fill', (d) => '#' + (d.color || '000000')) + .style('opacity', 0.5) if (points.labels === visilbe_points) { - scatter.selectAll("dataPoint") + scatter + .selectAll('dataPoint') .data(dataPoints) .enter() - .append("text") - .text(d => d.label) - .attr("x",d => scaleAndAxis.xScale(d.x) + point_label_padding_x) - .attr("y",d => scaleAndAxis.yScale(d.y) + point_label_padding_y) - .attr("style",label_style) - .attr("fill","black"); + .append('text') + .text((d) => d.label) + .attr( + 'x', + (d) => scaleAndAxis.xScale(d.x) + point_label_padding_x + ) + .attr( + 'y', + (d) => scaleAndAxis.yScale(d.y) + point_label_padding_y + ) + .attr('style', label_style) + .attr('fill', 'black') } - return scatter; + return scatter } /** * Helper function to match d3 shape from string. */ matchShape() { - return d => { - if (d.shape === "cross") { return d3.symbolCross } - if (d.shape === "diamond") { return d3.symbolDiamond } - if (d.shape === "square") { return d3.symbolSquare } - if (d.shape === "star") { return d3.symbolStar } - if (d.shape === "triangle") { return d3.symbolTriangle } + return (d) => { + if (d.shape === 'cross') { + return d3.symbolCross + } + if (d.shape === 'diamond') { + return d3.symbolDiamond + } + if (d.shape === 'square') { + return d3.symbolSquare + } + if (d.shape === 'star') { + return d3.symbolStar + } + if (d.shape === 'triangle') { + return d3.symbolTriangle + } return d3.symbolCircle - }; + } } /** * Creates labels on axes if they're defined. */ - createLabels(axis,svg,box_width,margin,box_height) { - let fontStyle = "10px DejaVuSansMonoBook"; + createLabels(axis, svg, box_width, margin, box_height) { + let fontStyle = '10px DejaVuSansMonoBook' if (axis.x.label !== undefined) { - let padding_y = 20; - svg.append("text") - .attr("text-anchor","end") - .attr("style",label_style) - .attr("x",margin.left + (this.getTextWidth(axis.x.label,fontStyle) / 2)) - .attr("y",box_height + margin.top + padding_y) - .text(axis.x.label); + let padding_y = 20 + svg.append('text') + .attr('text-anchor', 'end') + .attr('style', label_style) + .attr( + 'x', + margin.left + this.getTextWidth(axis.x.label, fontStyle) / 2 + ) + .attr('y', box_height + margin.top + padding_y) + .text(axis.x.label) } if (axis.y.label !== undefined) { - let padding_y = 15; - svg.append("text") - .attr("text-anchor","end") - .attr("style",label_style) - .attr("transform","rotate(-90)") - .attr("y",-margin.left + padding_y) - .attr("x",-margin.top - (box_height/2) + (this.getTextWidth(axis.y.label,fontStyle) / 2)) - .text(axis.y.label); + let padding_y = 15 + svg.append('text') + .attr('text-anchor', 'end') + .attr('style', label_style) + .attr('transform', 'rotate(-90)') + .attr('y', -margin.left + padding_y) + .attr( + 'x', + -margin.top - + box_height / 2 + + this.getTextWidth(axis.y.label, fontStyle) / 2 + ) + .text(axis.y.label) } } @@ -373,37 +506,43 @@ class ScatterPlot extends Visualization { * Helper function to get text width to make sure that labels on x axis wont overlap, * and keeps it readable. */ - getTextWidth(text,font) { - const canvas = document.createElement("canvas"); - const context = canvas.getContext("2d"); - context.font = font; - const metrics = context.measureText(" " + text); - return metrics.width; + getTextWidth(text, font) { + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + context.font = font + const metrics = context.measureText(' ' + text) + return metrics.width } /** * Creates plot's axes. */ - createAxes(axis,extremesAndDeltas,box_width,box_height,svg,focus) { - let {domain_x,domain_y} = this.getDomains(extremesAndDeltas,focus); + createAxes(axis, extremesAndDeltas, box_width, box_height, svg, focus) { + let { domain_x, domain_y } = this.getDomains(extremesAndDeltas, focus) - let xScale = d3.scaleLinear(); - if (axis.x.scale !== linear_scale) { xScale = d3.scaleLog(); } + let xScale = d3.scaleLinear() + if (axis.x.scale !== linear_scale) { + xScale = d3.scaleLog() + } - xScale.domain(domain_x).range([0,box_width]); - let xAxis = svg.append("g") - .attr("transform","translate(0," + box_height + ")") - .attr("style",label_style) - .call(d3.axisBottom(xScale).ticks(box_width/x_axis_label_width)) + xScale.domain(domain_x).range([0, box_width]) + let xAxis = svg + .append('g') + .attr('transform', 'translate(0,' + box_height + ')') + .attr('style', label_style) + .call(d3.axisBottom(xScale).ticks(box_width / x_axis_label_width)) let yScale = d3.scaleLinear() - if (axis.y.scale !== linear_scale) { yScale = d3.scaleLog(); } + if (axis.y.scale !== linear_scale) { + yScale = d3.scaleLog() + } - yScale.domain(domain_y).range([box_height,0]); - let yAxis = svg.append("g") - .attr("style",label_style) - .call(d3.axisLeft(yScale)); - return {xScale:xScale,yScale:yScale,xAxis:xAxis,yAxis:yAxis}; + yScale.domain(domain_y).range([box_height, 0]) + let yAxis = svg + .append('g') + .attr('style', label_style) + .call(d3.axisLeft(yScale)) + return { xScale: xScale, yScale: yScale, xAxis: xAxis, yAxis: yAxis } } /** @@ -414,21 +553,29 @@ class ScatterPlot extends Visualization { * from which we can calculate the span of points on X and Y axis, hence domain, with added * padding to make sure points will fit nicely on the chart. */ - getDomains(extremesAndDeltas,focus) { - let domain_x = [extremesAndDeltas.xMin - extremesAndDeltas.paddingX, - extremesAndDeltas.xMax + extremesAndDeltas.paddingX]; - let domain_y = [extremesAndDeltas.yMin - extremesAndDeltas.paddingY, - extremesAndDeltas.yMax + extremesAndDeltas.paddingY]; + getDomains(extremesAndDeltas, focus) { + let domain_x = [ + extremesAndDeltas.xMin - extremesAndDeltas.paddingX, + extremesAndDeltas.xMax + extremesAndDeltas.paddingX, + ] + let domain_y = [ + extremesAndDeltas.yMin - extremesAndDeltas.paddingY, + extremesAndDeltas.yMax + extremesAndDeltas.paddingY, + ] if (focus !== undefined) { - if (focus.x !== undefined && focus.y !== undefined && focus.zoom !== undefined) { - let padding_x = extremesAndDeltas.dx * (1 / (2 * (focus.zoom))); - let padding_y = extremesAndDeltas.dy * (1 / (2 * (focus.zoom))); - domain_x = [focus.x - padding_x,focus.x + padding_x]; - domain_y = [focus.y - padding_y,focus.y + padding_y]; + if ( + focus.x !== undefined && + focus.y !== undefined && + focus.zoom !== undefined + ) { + let padding_x = extremesAndDeltas.dx * (1 / (2 * focus.zoom)) + let padding_y = extremesAndDeltas.dy * (1 / (2 * focus.zoom)) + domain_x = [focus.x - padding_x, focus.x + padding_x] + domain_y = [focus.y - padding_y, focus.y + padding_y] } } - return {domain_x,domain_y}; + return { domain_x, domain_y } } /** @@ -439,25 +586,42 @@ class ScatterPlot extends Visualization { * than the container. */ getExtremesAndDeltas(dataPoints) { - let xMin = dataPoints[0].x; - let xMax = dataPoints[0].x; - let yMin = dataPoints[0].y; - let yMax = dataPoints[0].y; + let xMin = dataPoints[0].x + let xMax = dataPoints[0].x + let yMin = dataPoints[0].y + let yMax = dataPoints[0].y - dataPoints.forEach(d => { - if (d.x < xMin) { xMin = d.x } - if (d.x > xMax) { xMax = d.x } - if (d.y < yMin) { yMin = d.y } - if (d.y > yMax) { yMax = d.y } - }); + dataPoints.forEach((d) => { + if (d.x < xMin) { + xMin = d.x + } + if (d.x > xMax) { + xMax = d.x + } + if (d.y < yMin) { + yMin = d.y + } + if (d.y > yMax) { + yMax = d.y + } + }) - let dx = xMax - xMin; - let dy = yMax - yMin; + let dx = xMax - xMin + let dy = yMax - yMin - let padding_x = 0.1 * dx; - let padding_y = 0.1 * dy; + let padding_x = 0.1 * dx + let padding_y = 0.1 * dy - return {xMin:xMin,xMax:xMax,yMin:yMin,yMax:yMax,paddingX:padding_x,paddingY:padding_y,dx:dx,dy:dy}; + return { + xMin: xMin, + xMax: xMax, + yMin: yMin, + yMax: yMax, + paddingX: padding_x, + paddingY: padding_y, + dx: dx, + dy: dy, + } } /** @@ -465,40 +629,46 @@ class ScatterPlot extends Visualization { */ getMargins(axis) { if (axis.x.label === undefined && axis.y.label === undefined) { - return {top:20,right:20,bottom:20,left:45}; + return { top: 20, right: 20, bottom: 20, left: 45 } } else if (axis.y.label === undefined) { - return {top:10,right:20,bottom:35,left:35}; + return { top: 10, right: 20, bottom: 35, left: 35 } } else if (axis.x.label === undefined) { - return {top:20,right:10,bottom:20,left:55}; + return { top: 20, right: 10, bottom: 20, left: 55 } } - return {top:10,right:10,bottom:35,left:55}; + return { top: 10, right: 10, bottom: 35, left: 55 } } /** * Creates HTML div element as container for plot. */ - createDivElem(width,height) { - const divElem = document.createElementNS(null,"div"); - divElem.setAttributeNS(null,"class","vis-scatterplot"); - divElem.setAttributeNS(null,"viewBox",0 + " " + 0 + " " + width + " " + height); - divElem.setAttributeNS(null,"width","100%"); - divElem.setAttributeNS(null,"height","100%"); - divElem.setAttributeNS(null,"transform","matrix(1 0 0 -1 0 0)"); + createDivElem(width, height) { + const divElem = document.createElementNS(null, 'div') + divElem.setAttributeNS(null, 'class', 'vis-scatterplot') + divElem.setAttributeNS( + null, + 'viewBox', + 0 + ' ' + 0 + ' ' + width + ' ' + height + ) + divElem.setAttributeNS(null, 'width', '100%') + divElem.setAttributeNS(null, 'height', '100%') + divElem.setAttributeNS(null, 'transform', 'matrix(1 0 0 -1 0 0)') - const addStyleToElem = (attr,stl) => { - let style = document.createElement("style"); - style.innerText = attr + "{" + stl + "}" + const addStyleToElem = (attr, stl) => { + let style = document.createElement('style') + style.innerText = attr + '{' + stl + '}' - divElem.appendChild(style); + divElem.appendChild(style) } - let darkStrokeColor = `rgba(255,255,255,0.7)`; - let buttonLightColor = `#333`; - let darkBtnHoverColor = `rgba(255,255,255,0.5)`; - let darkSelectionFill = `#efefef`; + let darkStrokeColor = `rgba(255,255,255,0.7)` + let buttonLightColor = `#333` + let darkBtnHoverColor = `rgba(255,255,255,0.5)` + let darkSelectionFill = `#efefef` - addStyleToElem('.selection','rx: 4px;stroke: transparent;') - addStyleToElem('button',` + addStyleToElem('.selection', 'rx: 4px;stroke: transparent;') + addStyleToElem( + 'button', + ` margin-left: 5px; margin-bottom: 5px; display: inline-block; @@ -512,69 +682,93 @@ class ScatterPlot extends Visualization { font-family: DejaVuSansMonoBook; vertical-align: top; transition: all 0.3s ease; - `) - addStyleToElem('button:hover',` + ` + ) + addStyleToElem( + 'button:hover', + ` background-color: ${buttonLightColor}; color: ${darkSelectionFill}; - `) + ` + ) - addStyleToElem('.dark-theme button',` + addStyleToElem( + '.dark-theme button', + ` border: 0; background-color: ${darkStrokeColor}; - `) - addStyleToElem('.dark-theme button:hover',` + ` + ) + addStyleToElem( + '.dark-theme button:hover', + ` background-color: ${darkBtnHoverColor}; - `) - addStyleToElem('.dark-theme .selection',`fill: ${darkSelectionFill}`) - addStyleToElem('.dark-theme line',`stroke: ${darkStrokeColor};`) - addStyleToElem('.dark-theme .domain',`stroke: ${darkStrokeColor};`) - addStyleToElem('.dark-theme text',`fill: ${darkStrokeColor};`) + ` + ) + addStyleToElem('.dark-theme .selection', `fill: ${darkSelectionFill}`) + addStyleToElem('.dark-theme line', `stroke: ${darkStrokeColor};`) + addStyleToElem('.dark-theme .domain', `stroke: ${darkStrokeColor};`) + addStyleToElem('.dark-theme text', `fill: ${darkStrokeColor};`) - return divElem; + return divElem } /** * Helper function for button creation. */ createBtnHelper() { - const btn = document.createElement("button"); - btn.setAttribute("width","80px"); - btn.setAttribute("height","20px"); + const btn = document.createElement('button') + btn.setAttribute('width', '80px') + btn.setAttribute('height', '20px') return btn } /** * Creates a button to fit all points on plot. */ - createButtonFitAll(scaleAndAxis,scatter,points,extremesAndDeltas,zoom,box_width) { + createButtonFitAll( + scaleAndAxis, + scatter, + points, + extremesAndDeltas, + zoom, + box_width + ) { const btn = this.createBtnHelper() - let text = document.createTextNode("Fit all"); - btn.appendChild(text); + let text = document.createTextNode('Fit all') + btn.appendChild(text) - let self = this; + let self = this const unzoom = () => { - zoom.zoomElem.transition().duration(0).call(zoom.zoom.transform,d3.zoomIdentity); + zoom.zoomElem + .transition() + .duration(0) + .call(zoom.zoom.transform, d3.zoomIdentity) - let domain_x = [extremesAndDeltas.xMin - extremesAndDeltas.paddingX, - extremesAndDeltas.xMax + extremesAndDeltas.paddingX]; - let domain_y = [extremesAndDeltas.yMin - extremesAndDeltas.paddingY, - extremesAndDeltas.yMax + extremesAndDeltas.paddingY]; + let domain_x = [ + extremesAndDeltas.xMin - extremesAndDeltas.paddingX, + extremesAndDeltas.xMax + extremesAndDeltas.paddingX, + ] + let domain_y = [ + extremesAndDeltas.yMin - extremesAndDeltas.paddingY, + extremesAndDeltas.yMax + extremesAndDeltas.paddingY, + ] - scaleAndAxis.xScale.domain(domain_x); - scaleAndAxis.yScale.domain(domain_y); + scaleAndAxis.xScale.domain(domain_x) + scaleAndAxis.yScale.domain(domain_y) - self.zoomingHelper(scaleAndAxis,box_width,scatter,points); + self.zoomingHelper(scaleAndAxis, box_width, scatter, points) } - document.addEventListener('keydown',e => { + document.addEventListener('keydown', (e) => { if (shortcuts.showAll(e)) { unzoom() } - }); + }) - btn.addEventListener("click",unzoom) - this.dom.appendChild(btn); + btn.addEventListener('click', unzoom) + this.dom.appendChild(btn) } /** @@ -582,21 +776,21 @@ class ScatterPlot extends Visualization { */ createButtonScaleToPoints() { const btn = this.createBtnHelper() - let text = document.createTextNode("Zoom to selected"); - btn.appendChild(text); - btn.setAttribute("width","120px"); - btn.style.display = "none"; - this.dom.appendChild(btn); - return btn; + let text = document.createTextNode('Zoom to selected') + btn.appendChild(text) + btn.setAttribute('width', '120px') + btn.style.display = 'none' + this.dom.appendChild(btn) + return btn } /** * Sets size of this DOM object. */ setSize(size) { - this.dom.setAttributeNS(null,"width",size[0]); - this.dom.setAttributeNS(null,"height",size[1]); + this.dom.setAttributeNS(null, 'width', size[0]) + this.dom.setAttributeNS(null, 'height', size[1]) } } -return ScatterPlot; +return ScatterPlot diff --git a/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/tableView.js b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/tableView.js index 105e44b2ea5..5a86af8cf9b 100644 --- a/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/tableView.js +++ b/gui/src/rust/ide/view/graph-editor/src/builtin/visualization/java_script/tableView.js @@ -1,86 +1,96 @@ class TableViewVisualization extends Visualization { - static inputType = "Any" + static inputType = 'Any' onDataReceived(data) { function tableOf(content, level) { - let open = ''; - return open + content + "
"; + let open = '' + return open + content + '
' } function hasExactlyKeys(keys, obj) { - return Object.keys(obj).length === keys.length && keys.every(k => obj.hasOwnProperty(k)); + return ( + Object.keys(obj).length === keys.length && + keys.every((k) => obj.hasOwnProperty(k)) + ) } function getAtNestedKey(data, key) { - let res = data; - key.forEach(k => res = res[k]); - return res; + let res = data + key.forEach((k) => (res = res[k])) + return res } function repNestedKey(key) { - return key.join("."); + return key.join('.') } function generateNestings(data, key) { - let first = getAtNestedKey(data[0], key); - if (!(first instanceof Object)) return [key]; - let firstKeys = Object.keys(first); - let isNestable = data.every(obj => hasExactlyKeys(firstKeys, getAtNestedKey(obj, key))); + let first = getAtNestedKey(data[0], key) + if (!(first instanceof Object)) return [key] + let firstKeys = Object.keys(first) + let isNestable = data.every((obj) => + hasExactlyKeys(firstKeys, getAtNestedKey(obj, key)) + ) if (isNestable) { - let withNests = firstKeys.map(k => key.concat([k])); - let furtherNestings = withNests.map(k => generateNestings(data, k)); - return [].concat.apply([], furtherNestings); + let withNests = firstKeys.map((k) => key.concat([k])) + let furtherNestings = withNests.map((k) => + generateNestings(data, k) + ) + return [].concat.apply([], furtherNestings) } else { - return [key]; + return [key] } } function isObjectMatrix(data) { - let isList = Array.isArray(data) && data[0]; - if (!isList || !(typeof data[0] === "object")) return false; - let firstKeys = Object.keys(data[0]); - return data.every(obj => hasExactlyKeys(firstKeys, obj)); + let isList = Array.isArray(data) && data[0] + if (!isList || !(typeof data[0] === 'object')) return false + let firstKeys = Object.keys(data[0]) + return data.every((obj) => hasExactlyKeys(firstKeys, obj)) } function genObjectMatrix(data, level) { - let result = ""; - let keys = Object.keys(data[0]); - let nests = [].concat.apply([], keys.map(k => generateNestings(data,[k]))); - nests.forEach(key => { - result += ("" + repNestedKey(key) + ""); - }); - result += ""; + let result = '' + let keys = Object.keys(data[0]) + let nests = [].concat.apply( + [], + keys.map((k) => generateNestings(data, [k])) + ) + nests.forEach((key) => { + result += '' + repNestedKey(key) + '' + }) + result += '' data.forEach((row, ix) => { - result += ("" + ix + ""); - nests.forEach(k => { - result += toTableCell(getAtNestedKey(row, k), level); - }); - result += ("") - }); - return tableOf(result, level); + result += '' + ix + '' + nests.forEach((k) => { + result += toTableCell(getAtNestedKey(row, k), level) + }) + result += '' + }) + return tableOf(result, level) } function isMatrix(data) { - let isList = Array.isArray(data) && data[0]; - if (!isList) return false; - let firstIsArray = Array.isArray(data[0]); - if (!firstIsArray) return false; - let firstLen = data[0].length; - return data.every(d => d.length === firstLen); + let isList = Array.isArray(data) && data[0] + if (!isList) return false + let firstIsArray = Array.isArray(data[0]) + if (!firstIsArray) return false + let firstLen = data[0].length + return data.every((d) => d.length === firstLen) } function genMatrix(data, level, header) { - let result = ""; + let result = '' if (header) { header.forEach((elt, ix) => { - result += ("" + elt + ""); - }); + result += '' + elt + '' + }) } else { - data[0].forEach((elt, ix)=> { - result += ("" + ix + ""); - }); + data[0].forEach((elt, ix) => { + result += '' + ix + '' + }) } - result += ""; + result += '' table = [] data.forEach((d, i) => { @@ -91,61 +101,70 @@ class TableViewVisualization extends Visualization { }) table.forEach((row, ix) => { - result += ("" + ix + ""); + result += '' + ix + '' row.forEach((d) => { - result += toTableCell(d, level); - }); - result += ("") - }); - return tableOf(result, level); + result += toTableCell(d, level) + }) + result += '' + }) + return tableOf(result, level) } function genGenericTable(data, level) { - let result = ""; + let result = '' data.forEach((point, ix) => { - result += ("" + ix + "" + toTableCell(point, level) + ""); - }); - return tableOf(result, level); + result += + '' + + ix + + '' + + toTableCell(point, level) + + '' + }) + return tableOf(result, level) } function genRowObjectTable(data, level) { - let keys = Object.keys(data); - let result = ""; - keys.forEach(key => { - result += ("" + key + ""); - }); - result += ""; - keys.forEach(key => { - result += toTableCell(data[key], level); - }); - result += ""; - return tableOf(result, level); + let keys = Object.keys(data) + let result = '' + keys.forEach((key) => { + result += '' + key + '' + }) + result += '' + keys.forEach((key) => { + result += toTableCell(data[key], level) + }) + result += '' + return tableOf(result, level) } function toTableCell(data, level) { if (Array.isArray(data)) { - return "" + genTable(data, level + 1) + ""; + return '' + genTable(data, level + 1) + '' } else if (data instanceof Object) { - return "" + genRowObjectTable(data, level + 1) + ""; + return '' + genRowObjectTable(data, level + 1) + '' } else { - if (data === undefined || data === null) data = ""; - let res = data.toString(); - return '' + (res === "" ? "N/A" : res) + ''; + if (data === undefined || data === null) data = '' + let res = data.toString() + return ( + '' + + (res === '' ? 'N/A' : res) + + '' + ) } } function genTable(data, level, header) { if (isMatrix(data)) { - return genMatrix(data, level, header); + return genMatrix(data, level, header) } else if (isObjectMatrix(data)) { - return genObjectMatrix(data, level); + return genObjectMatrix(data, level) } else { - return genGenericTable(data, level); + return genGenericTable(data, level) } } while (this.dom.firstChild) { - this.dom.removeChild(this.dom.lastChild); + this.dom.removeChild(this.dom.lastChild) } const style_dark = ` @@ -181,7 +200,7 @@ class TableViewVisualization extends Visualization { background-color: rgba(255, 255, 255, 0.03); } - `; + ` const style_light = ` ` - const width = this.dom.getAttributeNS(null, "width"); - const height = this.dom.getAttributeNS(null, "height"); - const tabElem = document.createElement("div"); - tabElem.setAttributeNS(null,"id" ,"vis-tbl-view"); - tabElem.setAttributeNS(null,"class" ,"scrollable"); - tabElem.setAttributeNS(null,"viewBox","0 0 " + width + " " + height); - tabElem.setAttributeNS(null,"width" ,"100%"); - tabElem.setAttributeNS(null,"height" ,"100%"); - const tblViewStyle = - `width: ${width-10}px; - height: ${height-10}px; + const width = this.dom.getAttributeNS(null, 'width') + const height = this.dom.getAttributeNS(null, 'height') + const tabElem = document.createElement('div') + tabElem.setAttributeNS(null, 'id', 'vis-tbl-view') + tabElem.setAttributeNS(null, 'class', 'scrollable') + tabElem.setAttributeNS(null, 'viewBox', '0 0 ' + width + ' ' + height) + tabElem.setAttributeNS(null, 'width', '100%') + tabElem.setAttributeNS(null, 'height', '100%') + const tblViewStyle = `width: ${width - 10}px; + height: ${height - 10}px; overflow: scroll; - padding:2.5px;`; - tabElem.setAttributeNS(null,"style" ,tblViewStyle); - this.dom.appendChild(tabElem); + padding:2.5px;` + tabElem.setAttributeNS(null, 'style', tblViewStyle) + this.dom.appendChild(tabElem) - let parsedData = data; - if (typeof data === "string") { - parsedData = JSON.parse(data); + let parsedData = data + if (typeof data === 'string') { + parsedData = JSON.parse(data) } let style = style_light - if (document.getElementById("root").classList.contains("dark-theme")){ - style = style_dark; + if (document.getElementById('root').classList.contains('dark-theme')) { + style = style_dark } - let table = genTable(parsedData.data || parsedData, 0, parsedData.header); - tabElem.innerHTML = style + table; + let table = genTable( + parsedData.data || parsedData, + 0, + parsedData.header + ) + tabElem.innerHTML = style + table } setSize(size) { - this.dom.setAttributeNS(null, "width", size[0]); - this.dom.setAttributeNS(null, "height", size[1]); + this.dom.setAttributeNS(null, 'width', size[0]) + this.dom.setAttributeNS(null, 'height', size[1]) } } -return TableViewVisualization; +return TableViewVisualization diff --git a/gui/src/rust/ide/view/graph-editor/src/component/visualization/foreign/java_script/visualization.js b/gui/src/rust/ide/view/graph-editor/src/component/visualization/foreign/java_script/visualization.js index 58a0d8ead56..009408b66ad 100644 --- a/gui/src/rust/ide/view/graph-editor/src/component/visualization/foreign/java_script/visualization.js +++ b/gui/src/rust/ide/view/graph-editor/src/component/visualization/foreign/java_script/visualization.js @@ -1,7 +1,7 @@ export class Visualization { constructor(api) { - this.dom = api.root(); - this.__api__ = api; + this.dom = api.root() + this.__api__ = api } setPreprocessor(code) { this.__api__.emit_preprocessor_change(code) diff --git a/gui/src/rust/ide/view/graph-editor/src/component/visualization/registry.rs b/gui/src/rust/ide/view/graph-editor/src/component/visualization/registry.rs index 736395009bd..6703746b0c5 100644 --- a/gui/src/rust/ide/view/graph-editor/src/component/visualization/registry.rs +++ b/gui/src/rust/ide/view/graph-editor/src/component/visualization/registry.rs @@ -41,6 +41,7 @@ impl Registry { registry.add(builtin::visualization::native::RawText::definition()); registry.try_add_java_script(builtin::visualization::java_script::bubble_visualization()); registry.try_add_java_script(builtin::visualization::java_script::scatter_plot_visualization()); + registry.try_add_java_script(builtin::visualization::java_script::histogram_visualization()); registry.try_add_java_script(builtin::visualization::java_script::table_view_visualization()); registry.try_add_java_script(builtin::visualization::java_script::map_view_visualization()); registry